"""Shared helpers for the nnU-Net iterative pseudo-labeling pipeline (scripts_nnu/). Runs against the installed nnunetv2 2.8.1 build (checkpoint key `network_weights`, probabilities saved as .npz channel-first, folds in splits_final.json). """ import os import sys import time import json import shutil import subprocess ROOT = os.environ.get( "LONGITUDINAL_ROOT", os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) sys.path.insert(0, ROOT) from src.common import DATA, d, load_jsonl, save_jsonl, read_nii_arr, head_mask_from_image, largest_cc # noqa: E402 import numpy as np import SimpleITK as sitk from scipy import ndimage DS_ID = 210 DS_NAME = f"Dataset{DS_ID:03d}_NTUH_T1C_PL" TRAINER = "NTUHLPLTrainer" CONFIG = "3d_fullres" FOLD = 0 PLANS = "nnUNetPlans" SCRIPTS_NNU = os.path.join(ROOT, "scripts_nnu") TRAINERS_DIR = os.path.join(SCRIPTS_NNU, "trainers") def nnu_root(): return d(os.path.join("nnu")) def raw_ds(): return os.path.join(nnu_root(), "raw", DS_NAME) def preproc_ds(): return os.path.join(nnu_root(), "preprocessed", DS_NAME) def results_root(): return os.path.join(nnu_root(), "results") def model_folder(): return os.path.join(results_root(), DS_NAME, f"{TRAINER}__{PLANS}__{CONFIG}") def fold_dir(): return os.path.join(model_folder(), f"fold_{FOLD}") def best_ckpt(): return os.path.join(fold_dir(), "checkpoint_best.pth") def final_ckpt(): return os.path.join(fold_dir(), "checkpoint_final.pth") def nnu_env(epoch=None, lr=None, warmstart=None): env = dict(os.environ) env["nnUNet_raw"] = os.path.join(nnu_root(), "raw") env["nnUNet_preprocessed"] = os.path.join(nnu_root(), "preprocessed") env["nnUNet_results"] = results_root() env["nnUNet_extTrainer"] = TRAINERS_DIR env.pop("NNU_PL_EPOCHS", None) env.pop("NNU_PL_LR", None) env.pop("NNU_PL_WARMSTART", None) env.pop("CUDA_VISIBLE_DEVICES", None) if epoch is not None: env["NNU_PL_EPOCHS"] = str(epoch) if lr is not None: env["NNU_PL_LR"] = repr(float(lr)) if warmstart: env["NNU_PL_WARMSTART"] = str(warmstart) return env def run(cmd, log, env=None, retries=2): os.makedirs(os.path.dirname(os.path.abspath(log)), exist_ok=True) cmd_s = " ".join(str(c) for c in cmd) for attempt in range(retries + 1): try: with open(log, "a") as f: f.write(f"$ (attempt {attempt + 1}) " + cmd_s + "\n") f.flush() print(f"$ (attempt {attempt + 1}) " + cmd_s, flush=True) subprocess.run(cmd, cwd=ROOT, stdout=f, stderr=subprocess.STDOUT, env=env, check=True) return except subprocess.CalledProcessError: if attempt == retries: raise print(f"[nnu] command failed, retrying in 60s: {cmd_s}", flush=True) time.sleep(60) def train_cmd(gpus): return ["nnUNetv2_train", str(DS_ID), CONFIG, str(FOLD), "-tr", TRAINER, "-p", PLANS, "-num_gpus", str(gpus)] def plan_preprocess_cmd(npp=8): return ["nnUNetv2_plan_and_preprocess", "-d", str(DS_ID), "-c", CONFIG, "-np", str(npp), "--clean", "--no_pbar"] def predict_cmd(in_dir, out_dir, gpus, part, chk="checkpoint_best.pth", tta=True, npp=2, nps=2): cmd = ["nnUNetv2_predict", "-i", str(in_dir), "-o", str(out_dir), "-d", str(DS_ID), "-c", CONFIG, "-tr", TRAINER, "-p", PLANS, "-f", str(FOLD), "-chk", chk, "--save_probabilities", "--continue_prediction", "--disable_progress_bar", "-num_parts", str(gpus), "-part_id", str(part), "-npp", str(npp), "-nps", str(nps)] if not tta: cmd.append("--disable_tta") return cmd # ---------------- raw dataset (nnU-Net format) ---------------- # # Pipeline B feeds nnU-Net the NATIVE volumes (no resampling / cropping / # intensity normalization beforehand): nnUNetv2_plan_and_preprocess does all of # that itself (nonzero-bbox crop, resample to the plan's target spacing, # per-channel normalization, .b2nd storage). def same_frame(a, b): return (a.GetSize() == b.GetSize() and a.GetSpacing() == b.GetSpacing() and a.GetOrigin() == b.GetOrigin() and a.GetDirection() == b.GetDirection()) def native_label_target(img_path, lab_path, link_path): """Label target for a raw-dataset row: 0/1 on the image's native grid. Symlinks the label when it already sits on the image grid with values {0, 1}; otherwise writes a converted copy to link_path (nearest-neighbor warp to the image grid + binarization to 0/1). This is label fix-up only — no common-grid resampling, cropping, or normalization. """ img_itk = sitk.ReadImage(img_path) lab_itk = sitk.ReadImage(lab_path) if same_frame(img_itk, lab_itk): vals = set(np.unique(sitk.GetArrayFromImage(lab_itk)).tolist()) if vals <= {0, 1}: os.symlink(os.path.abspath(lab_path), link_path) return warped = sitk.Resample(lab_itk, img_itk, sitk.Transform(), sitk.sitkNearestNeighbor, 0.0) out = sitk.GetImageFromArray((sitk.GetArrayFromImage(warped) > 0).astype(np.uint8)) out.CopyInformation(img_itk) os.makedirs(os.path.dirname(link_path), exist_ok=True) sitk.WriteImage(out, link_path, True) def make_raw_dataset(rows, channel="0000"): """rows: list of {key, img, label} — img/label are native (unprocessed) NIfTI paths. Rebuilds imagesTr/labelsTr + dataset.json. Images are symlinked as-is; labels via native_label_target. See section note.""" img_dir = os.path.join(raw_ds(), "imagesTr") lab_dir = os.path.join(raw_ds(), "labelsTr") shutil.rmtree(img_dir, ignore_errors=True) shutil.rmtree(lab_dir, ignore_errors=True) os.makedirs(img_dir, exist_ok=True) os.makedirs(lab_dir, exist_ok=True) seen = set() for r in rows: key, img, lab = r["key"], r["img"], r["label"] if key in seen: raise ValueError(f"duplicate case id in dataset: {key}") seen.add(key) if not os.path.exists(img): raise FileNotFoundError(f"image missing for {key}: {img}") if not os.path.exists(lab): raise FileNotFoundError(f"label missing for {key}: {lab}") os.symlink(os.path.abspath(img), os.path.join(img_dir, f"{key}_{channel}.nii.gz")) native_label_target(img, lab, os.path.join(lab_dir, f"{key}.nii.gz")) ds_json = {"channel_names": {"0": "t1c"}, "labels": {"background": 0, "tumor": 1}, "numTraining": len(seen), "file_ending": ".nii.gz"} with open(os.path.join(raw_ds(), "dataset.json"), "w") as f: json.dump(ds_json, f, indent=1) return len(seen) def dataset_case_keys(): img_dir = os.path.join(raw_ds(), "imagesTr") out = [] if os.path.isdir(img_dir): for fn in sorted(os.listdir(img_dir)): if fn.endswith("_0000.nii.gz"): out.append(fn[:-len("_0000.nii.gz")]) return out def write_splits(train_keys, val_keys): val_keys = sorted(set(val_keys)) train_keys = sorted(set(k for k in train_keys if k not in val_keys)) folds = [{"train": train_keys, "val": val_keys}] * 5 with open(os.path.join(preproc_ds(), "splits_final.json"), "w") as f: json.dump(folds, f) return len(train_keys), len(val_keys) # ---------------- label writing / selection (same gates as scripts/06) ---------------- # # Prediction outputs are ALREADY on each case's native grid: nnUNetv2_predict # (--save_probabilities) resamples tumor probabilities and the argmax seg back # to the original input shape. Gates below therefore run directly on the native # volume; physical mm is recovered from the native voxel spacing. def write_label(arr_u01, path, ref_itk): img = sitk.GetImageFromArray(arr_u01.astype(np.uint8)) img.CopyInformation(ref_itk) os.makedirs(os.path.dirname(path), exist_ok=True) sitk.WriteImage(img, path, True) def pos_mask(p_tumor, tau_pos, min_cc_frac, vol_lo, vol_hi, voxel_vol_mm3=1.0): """Returns (mask, cc_frac, vol_mm3) if the positive gates pass, else None. voxel_vol_mm3 is the native voxel volume in mm3 (Product of NIfTI spacing); 1.0 for 1mm processed volumes (Pipelines A/C).""" m = p_tumor >= tau_pos if m.sum() == 0: return None m = ndimage.median_filter(m, size=(3, 3, 3)) if m.sum() == 0: return None lab, n = ndimage.label(m) sizes = ndimage.sum(m, lab, range(1, n + 1)) big = (lab == (int(np.argmax(sizes)) + 1)).astype(np.uint8) cc_frac = float(big.sum()) / float(m.sum()) vol_mm3 = int(big.sum()) * voxel_vol_mm3 if cc_frac >= min_cc_frac and vol_lo <= vol_mm3 <= vol_hi: return big, cc_frac, vol_mm3 return None def _interior_mask(vol, min_interior=5000): """Head-interior mask. Processed [0,1] percentile-normalized volumes keep the legacy `vol > 0.02` rule; native raw-intensity volumes use the robust percentile / largest-CC head mask (see src/common.head_mask_from_image).""" if float(vol.max()) <= 1.0 + 1e-6: m = vol > 0.02 else: m = head_mask_from_image(vol) return m if m.sum() >= min_interior else None def neg_frac_bg(p_tumor, vol, tau_neg, min_frac, min_interior=5000): interior = _interior_mask(vol, min_interior) if interior is None: return None frac = float(((1.0 - p_tumor)[interior] >= tau_neg).mean()) return frac if frac >= min_frac else None def tumor_probs_from_npz(npz_path): """.npz probabilities: channel-first (C,H,W,D), C=2 (bg, tumor).""" probs = np.load(npz_path, allow_pickle=False)["probabilities"] if probs.shape[0] != 2: raise ValueError(f"unexpected probability channels {probs.shape} in {npz_path}") return probs[1] # ---------------- longitudinal consistency (head-relative) ---------------- # # The absolute patient-space resampling used by scripts/06 cannot work for this # data: each acquisition has its own scanner/patient coordinate system (table # offset, head orientation), so true labels from two visits of the same subject # resample to ~0 dice. Instead we gate on frame-invariant features, computed in # each case's own grid with physical mm (native spacing for Pipeline B, 1mm for # A/C): # * tumor centroid relative to the head centroid (offset in mm) # * tumor volume ratio (mm3) # Two timepoints are compatible if they agree with at least one accepted # neighbor on both; greedy removal until stable (same scheme as scripts/06). def head_centroid(vol): m = _interior_mask(vol) if m is None: return None m = largest_cc(m) if int(m.sum()) == 0: return None return np.array(ndimage.center_of_mass(m)) def rel_tumor_features(mask, vol, spacing=(1.0, 1.0, 1.0)): """Returns (centroid_rel_to_head [z,y,x] in mm, vol_mm3) or None. spacing is the NIfTI voxel spacing in array order (z, y, x); (1, 1, 1) for 1mm processed volumes (Pipelines A/C).""" if int(mask.sum()) == 0: return None spacing = np.asarray(spacing, dtype=np.float64) tc = np.array(ndimage.center_of_mass(mask)) hc = head_centroid(vol) rel = (tc - hc) * spacing if hc is not None else None return rel, int(mask.sum()) * float(spacing.prod()) def _compatible(fa, fb, max_rel_dist_mm, vol_ratio_max): ra, va = fa rb, vb = fb if ra is not None and rb is not None and np.linalg.norm(ra - rb) > max_rel_dist_mm: return False if va > 0 and vb > 0: ratio = max(va / vb, vb / va) if ratio > vol_ratio_max: return False return True def consistency_filter(rows, out_dir, max_rel_dist_mm=40.0, vol_ratio_max=10.0, image_key="pimg"): """Greedy removal of positive timepoints inconsistent with accepted neighbors. masks from /_label.nii.gz, volumes + native spacing from row[image_key] ("pimg": 1mm processed, Pipelines A/C; "img": native, Pipeline B). Returns n_rejected. """ by_subj = {} for r in rows: if r["role"] == "pos": by_subj.setdefault(r["subject"], []).append(r) rejected = 0 for subj, tps in by_subj.items(): if len(tps) < 2: continue tps.sort(key=lambda x: (x.get("date") or "")) feats = {} for i, r in enumerate(tps): mask = read_nii_arr(os.path.join(out_dir, r["key"] + "_label.nii.gz")).astype(bool) vol = read_nii_arr(r[image_key]).astype("float32") if mask.shape != vol.shape: raise ValueError(f"mask shape {mask.shape} != volume {r[image_key]} shape {vol.shape} for {r['key']}") sp = np.asarray(sitk.ReadImage(r[image_key]).GetSpacing()[::-1], dtype=np.float64) feats[i] = rel_tumor_features(mask, vol, sp) accepted = list(range(len(tps))) while True: changed = False for ai in list(accepted): neigh = [b for b in (ai - 1, ai + 1) if b in accepted] if not neigh or feats[ai] is None: continue if not any(_compatible(feats[ai], feats[b], max_rel_dist_mm, vol_ratio_max) for b in neigh if feats[b] is not None): tps[ai]["role"] = "rejected" accepted.remove(ai) rejected += 1 changed = True break if not changed: break return rejected def load_voxel_stats(): p = os.path.join(DATA, "vols.json") return json.load(open(p)) if os.path.exists(p) else {}