Update README.md to include detailed project overview, environment requirements, data sources, and a comprehensive directory layout. Add nnU-Net pipeline documentation and directory descriptions. Update .gitignore to exclude nnU-Net specific directories and add new scripts directory for nnU-Net pipeline. Add initial nnU-Net pipeline scripts.
9.3 KiB
Longitudinal T1c Brain-Tumor Segmentation
Longitudinal (repeated-measures) analysis of brain T1 post-contrast (T1c) MRI with tumor segmentation, plus an iterative pseudo-labeling study that leverages a large unlabeled pool from the same subjects/series to improve a held-out, patient-level test Dice.
All volumes are preprocessed to a uniform 1 mm isotropic, head-cropped, percentile-normalized form so that tumor counts are directly comparable in mm³ across sources and timepoints.
Environment
Conda env longitudinal (Python 3.14, torch 2.14 +cu126). Activate before any
command:
source /opt/conda/etc/profile.d/conda.sh && conda activate longitudinal
GPU (CUDA 12.6) is available; multi-GPU jobs use torchrun (in-house) or
nnU-Net's own DDP (-num_gpus). Run scripts from the repo root.
Data sources
| Source | What | Notes |
|---|---|---|
ntuh |
Labeled T1c + tumor segmentation | Native MR, deduped per acquisition |
m6 |
Labeled (GTV warped from CT) + large unlabeled pool | GTV registered CT→T1c |
lee |
Longitudinal T1c scanned as JPG + DICOM txt | Volumes reconstructed from slices |
Directory layout
data/
manifests/ jsonl row tables (see Pipeline A)
proc/ <key>.nii.gz (1mm, cropped, normalized)
proc/<key>_label.nii.gz
procmeta/<key>.json geometry (origin/direction/crop_vox) + normalization
vols.json labeled tumor volume stats (mm3 percentiles)
pseudo/roundK/ in-house pseudo-labels (rows.jsonl, masks, summary.json)
src/ U-Net, dataset, losses, training/eval helpers
scripts/ Pipeline A (in-house 3D U-Net)
scripts_nnu/ Pipeline B (nnU-Net)
runs/roundK/ in-house checkpoints (best.pt, final.pt, state.pt)
runs_nnu/roundK/ nnU-Net checkpoint snapshots (best_nnu.pth)
nnu/ nnU-Net raw / preprocessed / results trees
results/ evaluation JSON tables + plots
logs/ run logs
Pipeline A — In-house 3D U-Net (scripts/)
3-class-capable but used as binary (background / tumor) Unet3D, 96³ patches,
DDP, per-sample weighted loss.
| # | Script | Purpose |
|---|---|---|
| 01 | 01_build_ntuh_manifest.py |
Build NTUH2022G4 labeled T1c + seg manifest |
| 02 | 02_build_m6_dataset.py |
Build M6-2025 manifests (GTV CT→T1c registration/warp) |
| 03 | 03_scan_lee_t1c.py |
Scan lee for brain T1c series → raw + selected manifests |
| 04 | 04_reconstruct_lee.py |
Reconstruct lee T1c niftis from JPG slices + txt metadata |
| 05 | 05_build_splits.py |
Patient-level train/val/test splits + unlabeled pool + volume stats |
| 06 | 06_pseudo_label.py |
Pseudo-label the pool with a round model (sliding-window + TTA) |
| 07 | 07_train.py |
DDP training entrypoint (labeled + weighted pseudo rows) |
| 08 | 08_eval.py |
Held-out test evaluation (Dice from probability map) |
| 09 | 09_run_iterative.py |
Orchestrates rounds 0…K and produces the summary table/plot |
| — | preprocess.py |
Crop/normalize source volumes → data/proc/ |
| — | scan_procs.py |
Flag corrupt/non-finite processed volumes |
| — | test_dataloader.py |
Smoke-test the training dataloader |
Pseudo-label gates (per volume): pos when p_tumor ≥ tau_pos, largest-CC
fraction ≥ min_cc_frac, and volume within the labeled p2–p98 range; neg when
≥ neg_frac of interior voxels have p_bg ≥ tau_neg; then a per-subject
longitudinal consistency filter.
Run the full study:
python scripts/09_run_iterative.py --rounds 4 --gpus 3
Individual stages are standalone, e.g.:
torchrun --standalone --nproc_per_node 3 scripts/07_train.py \
--rows data/manifests/split_train.jsonl --val data/manifests/split_val.jsonl \
--epochs 40 --lr 3e-4 --batch 3 --ckpt-dir runs/round0
python scripts/08_eval.py --rows data/manifests/split_test.jsonl --ckpt runs/round0/best.pt
Known issue in Pipeline A (longitudinal consistency)
06_pseudo_label.py's grid_info()/dice_a_on_b() resample timepoints into a
shared physical space using procmeta origin/direction/crop_vox. This is
not reliable across separate acquisitions:
- Each scan has its own scanner/patient coordinate frame (table offset + head pose), so two visits of the same subject do not overlap in absolute space. Benchmarking against labeled multi-timepoint patients gives median cross-visit true-label Dice ≈ 0, meaning the consistency gate tends to over-reject.
crop_voxis stored as[z, y, x]array-axis starts, whiledirectionis ordered(x_dir, y_dir, z_dir); the pairings ingrid_infomix these up.
This is noted for the record; Pipeline B below replaces the gate with a frame-invariant one. Pipeline A was left unchanged.
Pipeline B — nnU-Net iterative pseudo-labeling (scripts_nnu/)
The same study driven by nnU-Net v2 (installed build nnunetv2 2.8.1) as the
segmentation backbone, keeping the identical patient-level splits, unlabeled
pool, and evaluation protocol so results are directly comparable to Pipeline A.
This installed nnU-Net is a modern fork: preprocessed data is
.b2nd(blosc2), checkpoints storenetwork_weights(notmodel), and--save_probabilitieswrites a per-case<case>.npz(channel-first(C, z, y, x)). The code below targets that build, not the upstream nnU-Net docs.
Dataset: Dataset210_NTUH_T1C_PL, single channel t1c, 2 classes
(background=0, tumor=1), 3d_fullres only, nnUNetPlans, fold 0.
| # | Script | Purpose |
|---|---|---|
| 01 | 01_nnu_prepare_dataset.py |
Build/rebuild the raw dataset (symlinked imagesTr/labelsTr + dataset.json) from a rows jsonl |
| 02 | 02_nnu_plan_preprocess.py |
Plan + preprocess (--clean), then write subject-level splits_final.json |
| 03 | 03_nnu_train.py |
Train one round via NTUHLPLTrainer (DDP -num_gpus), optional warm start |
| 04 | 04_nnu_pseudo_label.py |
Multi-GPU nnUNetv2_predict on the pool + selection gates |
| 05 | 05_nnu_eval_test.py |
Held-out test evaluation (probability Dice@0.5 + hard-seg Dice) |
| 06 | 06_nnu_run_iterative.py |
Orchestrates rounds 0…K, table + plot |
| — | trainers/ntuh_pl_trainer.py |
NTUHLPLTrainer: env-driven epochs/LR + full-weight warm start |
| — | nnu_common.py |
Shared paths, env, dataset/split helpers, selection + consistency |
Round semantics
- Round 0: train on the labeled patient-level train split (nnU-Net internal
validation = the held-out val split, patient-disjoint via
splits_final.json). Pseudo-label the remaining pool →data/pseudo_nnu/round1. - Round k (k ≥ 1): dataset grows with all accepted pseudo-labels from rounds 1…k (positives + zero-mask negatives, de-duplicated by key); re-plan/preprocess; warm-start training from round k−1's best checkpoint at a lower LR; predict the remaining pool; evaluate on test.
Because this build has no incremental preprocessing, each round re-plans and re-preprocesses the whole (growing) dataset; the pool shrinks each round since accepted cases are excluded from re-prediction.
Custom trainer
NTUHLPLTrainer (resolved through the nnUNet_extTrainer env var) adds:
NNU_PL_EPOCHS/NNU_PL_LR— epoch count and initial PolyLR (set per round).- Full-weight warm start (
NNU_PL_WARMSTART): loads all weights including the segmentation head inon_train_start. The CLI-pretrained_weightsflag deliberately skips.seg_layers.keys, which would silently re-initialize the head and break round-to-round fine-tuning — hence this hook.
Longitudinal consistency (frame-invariant)
Instead of resampling into absolute patient space (unreliable, see Pipeline A note), two timepoints are compatible if they agree with at least one accepted neighbor on both:
- tumor centroid offset relative to the head centroid ≤
--max-rel-dist(default 40 mm), and - tumor volume ratio ≤
--vol-ratio(default 10×)
same greedy-removal scheme as Pipeline A, but robust to scanner/pose differences across visits.
Running
Full study from the repo root:
python scripts_nnu/06_nnu_run_iterative.py --rounds 4 --gpus 3
Defaults: baseline 250 epochs @ 1e-2; warm-started rounds 75 epochs @ 1e-3.
Pseudo-label gates match Pipeline A (--tau-pos 0.95, --min-cc-frac 0.2,
--neg-frac 0.9, volume p2–p98). Optional flags: --no-tta,
--no-neg-pseudo, and the gate overrides. Outputs to results/round{k}_test_nnu.json,
results/iterative_table_nnu.jsonl, results/iterative_dice_nnu.png; per-round
checkpoints snapshotted to runs_nnu/round{k}/best_nnu.pth.
Individual stages:
python scripts_nnu/01_nnu_prepare_dataset.py --rows <rows.jsonl>
python scripts_nnu/02_nnu_plan_preprocess.py --val data/manifests/split_val.jsonl
python scripts_nnu/03_nnu_train.py --gpus 3 --epochs 250 --lr 1e-2
python scripts_nnu/04_nnu_pseudo_label.py --pool data/manifests/unlabeled_pool.jsonl \
--out data/pseudo_nnu/round1 --gpus 3
python scripts_nnu/05_nnu_eval_test.py --rows data/manifests/split_test.jsonl \
--out results/round0_test_nnu.json --gpus 3
Verified
End-to-end smoke-tested on a scratch 4-case dataset: dataset build → planning →
splits_final.json → training 1 epoch → warm-start → 2-way sharded prediction →
selection gates → test eval. The consistency filter's keep / reject /
single-timepoint paths are unit-tested with synthetic volumes.