feat(dataset): add row-level exclusion curation for labeled volumes
Introduce src/exclusions.py (rules: total TV > 1e6 mm3, single target
< 100 mm3, non-brain NTUH keys) and scripts/make_tv_slices.py, a
first-stage curation script that classifies every labeled row right
after the source fetch and writes TV-slice QA figures to
data/tv_slices/{inclusion,exclusion}/.
05_build_splits.py re-applies the same rules after split assignment:
kept rows retain their split, excluded subjects still count in the
subject-level shuffle boundaries, and the audit trail is written to
data/manifests/excluded.jsonl. Label stats run in a
ProcessPoolExecutor (32 workers).
Move proc_row into src/common.py and add labeled_source_rows() so the
curation stage and the split builder share one processed-row view.
Document the 143-row exclusion audit and the interim 10-epoch
benchmark results in BENCHMARKING.md; update the README data layout
and script tables.
This commit is contained in:
parent
41c88b2a67
commit
2453fb48ba
6 changed files with 507 additions and 27 deletions
|
|
@ -22,6 +22,37 @@ onto the T1c grid with SMD+linear. After the repair pass:
|
|||
volumes, 3893 subjects; unlabeled pool 13280
|
||||
- tumor volume stats: median 3116 mm3, p95 24.8k, max 3.2M, zero_frac 0.12%
|
||||
|
||||
## Dataset exclusions (2026-10-04)
|
||||
|
||||
Row-level curation lives in `src/exclusions.py` and is applied at the **first
|
||||
stage** by `scripts/make_tv_slices.py` (right after the rows are fetched from
|
||||
the sources, before any split): it classifies every labeled volume and writes
|
||||
its TV-slice QA figure to `data/tv_slices/inclusion/` (kept) or
|
||||
`data/tv_slices/exclusion/` (excluded, reason in the title). `05_build_splits`
|
||||
re-applies the same rules **after** split assignment (kept rows retain their
|
||||
split; the subject-level shuffle boundaries are unchanged):
|
||||
|
||||
- total TV > 1e6 mm3: **1** row (ntuh_6019528, label outlines the full skull)
|
||||
- single target (1 connected component) with TV < 100 mm3: **109** rows
|
||||
- non-brain images: **33** rows (all ntuh — spine/neck/torso CyberKnife
|
||||
cases whose series names are indistinguishable from brain T1c; identified
|
||||
by visual review of the TV-slice figures, now in `data/tv_slices/exclusion/`)
|
||||
|
||||
Audit trail: `data/manifests/excluded.jsonl` (143 rows, reason + split);
|
||||
pre-exclusion manifests in `data/manifests/backup_20261004/`. New dataset:
|
||||
**4057** labeled rows → train 3447 / val 205 / test 405 (3771 subjects);
|
||||
unlabeled pool unchanged (13280); volume stats now median 3212 mm3, p95
|
||||
23.4k, max 163k (m6 meningioma), zero_frac still 0.12% (5 rows, e.g.
|
||||
ntuh_3436472 ×3).
|
||||
|
||||
After the initial 39-row non-brain list, expert inspection overturned 6
|
||||
(skull-base / posterior-fossa brain targets that look like cervical spine at
|
||||
the TV-max slice: ntuh_5902230, ntuh_5231609, ntuh_5083686, ntuh_5157194,
|
||||
ntuh_5303758, ntuh_6373987). The remaining 33 were re-audited at the
|
||||
mid-cranial slice (widest head-mask axial) — all still non-brain. Review
|
||||
protocol now recorded in `src/exclusions.py`: classify at full resolution
|
||||
on the mid-cranial slice, not just the TV-max slice.
|
||||
|
||||
## Smoke run (2026-10-04)
|
||||
|
||||
Small/fast end-to-end check that all three pipelines run on the rebuilt data
|
||||
|
|
@ -52,6 +83,36 @@ ntuh). Artifacts: `results/benchmark_pipelines_smoke.json`,
|
|||
ModuleNotFoundError) — installed `pip install monai==1.6.1 --no-deps`
|
||||
(torch 2.14+cu126 untouched); `import monai` verified.
|
||||
|
||||
## Full-dataset 10-epoch run (2026-10-04) — interim
|
||||
|
||||
`--train-epochs 10`, default budgets (2856 train rows = 80% of split_train,
|
||||
42 eval rows = 10% of split_test, TTA on), A/C/GPU0·GPU2 finished, B still
|
||||
preprocessing at time of writing:
|
||||
|
||||
```
|
||||
python scripts/benchmark_pipelines.py --pipelines A --gpu 0 --train-epochs 10 \
|
||||
--out results/benchmark_full_A.json # done
|
||||
python scripts/benchmark_pipelines.py --pipelines C --gpu 2 --train-epochs 10 \
|
||||
--out results/benchmark_full_C.json # done
|
||||
python scripts/benchmark_pipelines.py --pipelines B --gpu 1 --train-epochs 10 \
|
||||
--out results/benchmark_full_B.json # in progress (nnU-Net prep)
|
||||
```
|
||||
|
||||
| pipeline | train s/epoch | wall | tr VRAM | s/vol (TTA) | in VRAM | params | weights | dice @10ep |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| A Unet3D(16, depth 4) | 258.8 (952 steps) | 46 min | 2.0 GB | 3.51 ± 0.93 | 0.5 GB | 1.46 M | 17.6 MB | **0.0008** |
|
||||
| C MONAI UNet(16→128) | 284.3 (952 steps) | 48 min | 0.4 GB | 0.38 ± 0.06 | 0.5 GB | 1.19 M | 14.3 MB | **0.2119** |
|
||||
| B nnU-Net 3d_fullres | (pending) | (pending) | — | — | — | 30.8 M | 246 MB | (pending) |
|
||||
|
||||
Dice distribution over 41 timed test vols: A p25/p75 = 0.0000/0.0000 (98% ≈ 0);
|
||||
C p25/p75 = 0.0000/0.4048 (29% ≈ 0). Training loss (epoch 1 → 10): A
|
||||
0.335 → 0.209, C 0.403 → 0.188. 10 epochs is far below convergence for these
|
||||
sparse targets (median tumor 3.1 k mm³); the A/C numbers here are
|
||||
intermediate, and C is already clearly ahead at this budget. Checkpoints at
|
||||
`runs_bench/{A,C}/best.pt` (extend with `--resume --train-epochs <total>`).
|
||||
Note: B's first `plan_and_preprocess` attempt died and the built-in 60 s retry
|
||||
re-ran it successfully.
|
||||
|
||||
## Resuming training (`--resume`)
|
||||
|
||||
All three pipelines can continue from their latest benchmark checkpoint:
|
||||
|
|
@ -83,8 +144,9 @@ python scripts/benchmark_pipelines.py --pipelines "A B C" --resume \
|
|||
## Readiness notes for a full run
|
||||
|
||||
- Budget knobs: `--train-epochs` (default 100), `--max-rows` (default 80% of
|
||||
split_train ≈ 2856 for A/C and for B's `--nnu-cases`), `--max-eval-rows`
|
||||
(default 10% of split_test ≈ 42), `--no-tta`.
|
||||
split_train ≈ 2753 for A/C and for B's `--nnu-cases`, post-exclusion;
|
||||
≈ 2856 pre-exclusion), `--max-eval-rows` (default 10% of split_test ≈ 40
|
||||
post-exclusion), `--no-tta`.
|
||||
- `--skip-train` is only usable for **A** (`runs/round0/best.pt`, trained
|
||||
2026-09-25 on the *previous* split — a few patients moved between
|
||||
train/test in the rebuild, so its test dice carries a small leakage
|
||||
|
|
|
|||
|
|
@ -40,6 +40,8 @@ data/
|
|||
proc/ <key>.nii.gz (1mm, cropped, normalized)
|
||||
proc/<key>_label.nii.gz
|
||||
procmeta/<key>.json geometry (origin/direction/crop_vox) + normalization
|
||||
tv_slices/inclusion/ TV-slice QA figures, kept rows (<vol_mm3>_<key>.png)
|
||||
tv_slices/exclusion/ TV-slice QA figures, excluded rows (reason in title)
|
||||
vols.json labeled tumor volume stats (mm3 percentiles)
|
||||
pseudo/roundK/ in-house pseudo-labels (rows.jsonl, masks, summary.json)
|
||||
pseudo_nnu/roundK/ nnU-Net pseudo-labels (rows.jsonl, masks, summary.json)
|
||||
|
|
@ -69,12 +71,13 @@ DDP, per-sample weighted loss.
|
|||
| 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 |
|
||||
| 05 | `05_build_splits.py` | Patient-level train/val/test splits + unlabeled pool + volume stats (re-applies `src/exclusions` post-split) |
|
||||
| 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/` |
|
||||
| — | `make_tv_slices.py` | First-stage curation (right after source fetch): classifies labeled rows via `src/exclusions.py` + TV-slice QA figures → `data/tv_slices/{inclusion,exclusion}/` |
|
||||
| — | `scan_procs.py` | Flag corrupt/non-finite processed volumes |
|
||||
| — | `test_dataloader.py` | Smoke-test the training dataloader |
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,11 @@ normalized, used by Pipelines A and C) and native fields (img/label, the raw
|
|||
source NIfTIs — used by Pipeline B, which feeds nnU-Net its own
|
||||
plan_and_preprocess). Row membership still requires the processed volume to
|
||||
exist, so the patient-level splits stay identical across pipelines.
|
||||
|
||||
Before splitting, every labeled row is screened with the row-level exclusion
|
||||
rules (src/exclusions.py: total TV > 1e6 mm3, single target < 100 mm3, and
|
||||
the non-brain ntuh rows in NON_BRAIN_KEYS). Excluded rows are written to
|
||||
data/manifests/excluded.jsonl with their reason and never enter a split.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
|
@ -19,20 +24,16 @@ import json
|
|||
import argparse
|
||||
import random
|
||||
import numpy as np
|
||||
from src.common import ROOT, DATA, d, load_jsonl, save_jsonl, read_nii_arr
|
||||
from src.common import ROOT, DATA, d, load_jsonl, save_jsonl, proc_row
|
||||
from src.exclusions import label_stats, exclusion_reason
|
||||
|
||||
|
||||
def proc_row(r, prefix):
|
||||
key = r["key"]
|
||||
p = os.path.join(d("data/proc"), key + ".nii.gz")
|
||||
if not os.path.exists(p):
|
||||
return None
|
||||
row = {"key": key, "subject": f"{prefix}_{r['subject']}", "pimg": p,
|
||||
"img": r["img"], "label": r.get("label"), "date": r.get("date")}
|
||||
lab = os.path.join(d("data/proc"), key + "_label.nii.gz")
|
||||
if r.get("label") and os.path.exists(lab):
|
||||
row["plabel"] = lab
|
||||
return row
|
||||
def _label_stats_row(r):
|
||||
"""(key, label stats) for the ProcessPoolExecutor in main()."""
|
||||
try:
|
||||
return r["key"], label_stats(r["plabel"])
|
||||
except Exception:
|
||||
return r["key"], {"total": 0, "n_cc": 0}
|
||||
|
||||
|
||||
def main():
|
||||
|
|
@ -53,6 +54,31 @@ def main():
|
|||
labeled_rows.append(p)
|
||||
print(f"labeled processed rows: {len(labeled_rows)}")
|
||||
|
||||
# row-level curation (src/exclusions.py): label stats + exclusion reason
|
||||
# per row. Exclusions are applied AFTER the split assignment, so kept rows
|
||||
# retain the split of the 2026-10-04 rebuild and excluded.jsonl is the
|
||||
# audit trail (excluded subjects still count in the shuffle boundaries).
|
||||
from collections import Counter
|
||||
from concurrent.futures import ProcessPoolExecutor
|
||||
|
||||
tv_vols, reason_of = {}, {}
|
||||
with ProcessPoolExecutor(max_workers=32) as ex:
|
||||
for k, stats in ex.map(_label_stats_row, labeled_rows, chunksize=16):
|
||||
tv_vols[k] = stats["total"]
|
||||
reason_of[k] = exclusion_reason(k, stats)
|
||||
n_excl = sum(1 for v in reason_of.values() if v)
|
||||
|
||||
def _cat(reason):
|
||||
return (reason.split(" image", 1)[0] + " image"
|
||||
if "non-brain" in reason else
|
||||
reason.split(" TV ", 1)[0] + " TV < 100 mm3"
|
||||
if "single target" in reason else
|
||||
"total TV > 1e6 mm3")
|
||||
rc = Counter(_cat(v) for v in reason_of.values() if v)
|
||||
print(f"excluded rows: {n_excl}")
|
||||
for reason, n in sorted(rc.items()):
|
||||
print(f" excluded {n:4d} {reason}")
|
||||
|
||||
subjects = sorted({r["subject"] for r in labeled_rows})
|
||||
random.shuffle(subjects)
|
||||
n_test = max(1, int(len(subjects) * args.frac_test))
|
||||
|
|
@ -62,18 +88,24 @@ def main():
|
|||
train_subj = set(subjects[n_test + n_val:])
|
||||
print(f"subjects: {len(subjects)} train={len(train_subj)} val={len(val_subj)} test={len(test_subj)}")
|
||||
|
||||
splits = {"train": [], "val": [], "test": []}
|
||||
splits, excluded = {"train": [], "val": [], "test": []}, []
|
||||
for r in labeled_rows:
|
||||
r["w"] = 1.0
|
||||
if r["subject"] in test_subj:
|
||||
splits["test"].append(r)
|
||||
sk = "test"
|
||||
elif r["subject"] in val_subj:
|
||||
splits["val"].append(r)
|
||||
sk = "val"
|
||||
else:
|
||||
splits["train"].append(r)
|
||||
sk = "train"
|
||||
if reason_of[r["key"]]:
|
||||
excluded.append({**r, "split": sk, "exclude_reason": reason_of[r["key"]]})
|
||||
else:
|
||||
splits[sk].append(r)
|
||||
for k, v in splits.items():
|
||||
save_jsonl(v, os.path.join(DATA, "manifests", f"split_{k}.jsonl"))
|
||||
print(f" split_{k}: {len(v)} volumes / {len(set(r['subject'] for r in v))} subjects")
|
||||
save_jsonl(excluded, os.path.join(DATA, "manifests", "excluded.jsonl"))
|
||||
kept_rows = [r for v in splits.values() for r in v]
|
||||
|
||||
labeled_subjects = train_subj | val_subj | test_subj
|
||||
|
||||
|
|
@ -114,15 +146,12 @@ def main():
|
|||
print(f"unlabeled pool: {len(pool)} volumes ({sum(1 for r in pool if r['source']=='m6')} m6, "
|
||||
f"{sum(1 for r in pool if r['source']=='lee')} lee) from {len(set(r['subject'] for r in pool))} subjects")
|
||||
|
||||
# tumor volume stats (1mm voxels == mm3)
|
||||
# tumor volume stats of the kept dataset rows (1mm voxels == mm3; totals from
|
||||
# the curation pass)
|
||||
vols = []
|
||||
for r in labeled_rows:
|
||||
try:
|
||||
l = read_nii_arr(r["plabel"])
|
||||
n = int((l > 0.5).sum())
|
||||
vols.append({"key": r["key"], "subject": r["subject"], "vol_mm3": n})
|
||||
except Exception:
|
||||
pass
|
||||
for r in kept_rows:
|
||||
vols.append({"key": r["key"], "subject": r["subject"],
|
||||
"vol_mm3": tv_vols[r["key"]]})
|
||||
v = np.array([x["vol_mm3"] for x in vols])
|
||||
stats = {"n": int(v.size),
|
||||
"p2": float(np.percentile(v, 2)), "p5": float(np.percentile(v, 5)),
|
||||
|
|
|
|||
230
scripts/make_tv_slices.py
Normal file
230
scripts/make_tv_slices.py
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
"""First-stage curation + TV-slice QA figures for labeled rows.
|
||||
|
||||
Runs on the labeled source rows (data/manifests/{ntuh,m6_labeled}.jsonl, 1mm
|
||||
processed) — the first place rows exist after fetching from the sources,
|
||||
before any split (05). For each labeled volume:
|
||||
|
||||
1. classify with the exclusion rules in src/exclusions.py (total TV > 1e6
|
||||
mm3, single target < 100 mm3, curated non-brain list),
|
||||
2. render one PNG with the three orthogonal slices (axial | coronal |
|
||||
sagittal) that carry the largest TV (target-volume) area, with the TV
|
||||
contour overlaid; when the label has several connected targets
|
||||
(26-connectivity), only the largest one is shown,
|
||||
3. write the figure to data/tv_slices/inclusion/ (kept) or
|
||||
data/tv_slices/exclusion/ (excluded, reason in the title).
|
||||
|
||||
Slices are picked per body axis (S/P/L) from the procmeta direction, so the
|
||||
panel order follows radiological conventions (same mapping as
|
||||
scripts/make_screenshots.py); volumes that are not body-aligned fall back to
|
||||
the image-axis slices with a note in the title. Filenames are
|
||||
<total_vol_mm3:07d>_<key>.png, so each folder lists small TV -> large TV.
|
||||
|
||||
The split files (05) are the dataset of record; this stage only curates and
|
||||
documents. 05 applies the same src/exclusions rules after split assignment,
|
||||
so the two always agree on kept/excluded.
|
||||
|
||||
Usage:
|
||||
python scripts/make_tv_slices.py # all source rows, 32 workers
|
||||
python scripts/make_tv_slices.py --from-splits # rows/tag from split files
|
||||
python scripts/make_tv_slices.py --keys k1 k2 # specific rows
|
||||
python scripts/make_tv_slices.py --limit 50 # first N rows
|
||||
python scripts/make_tv_slices.py --force # overwrite existing PNGs
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
import glob
|
||||
import json
|
||||
import argparse
|
||||
from collections import Counter
|
||||
|
||||
import numpy as np
|
||||
import SimpleITK as sitk
|
||||
import matplotlib
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
from scipy import ndimage
|
||||
|
||||
from src.common import DATA, d, load_jsonl, proc_row, labeled_source_rows
|
||||
from src.exclusions import label_stats, exclusion_reason
|
||||
from scripts.make_screenshots import vol_to_body
|
||||
|
||||
|
||||
def out_name(vol_total, key):
|
||||
"""Flat-folder filename: zero-padded TOTAL TV volume (mm3, all targets)
|
||||
first so a plain listing sorts small -> large, then the row key."""
|
||||
return f"{int(vol_total):07d}_{key}.png"
|
||||
|
||||
|
||||
def largest_cc(mask, structure=None):
|
||||
"""Largest connected component of a boolean mask (26-conn by default)."""
|
||||
if structure is None:
|
||||
structure = np.ones((3, 3, 3), dtype=int)
|
||||
lab, n = ndimage.label(mask, structure=structure)
|
||||
if n == 0:
|
||||
return mask, 0
|
||||
sizes = ndimage.sum(mask, lab, range(1, n + 1))
|
||||
return lab == (int(np.argmax(sizes)) + 1), int(n)
|
||||
|
||||
|
||||
def max_area_slices(cc):
|
||||
"""Per-axis index of the slice containing the largest CC area."""
|
||||
return tuple(int(np.argmax(cc.sum(axis=tuple(j for j in range(cc.ndim) if j != i))))
|
||||
for i in range(cc.ndim))
|
||||
|
||||
|
||||
def render(key, tag, mr, cc, n_cc, vol_total, direction, reason, out, dpi=110):
|
||||
"""1x3 figure: axial | coronal | sagittal, max-CC-area slice each, with the
|
||||
largest-CC contour in red. Writes to `out`. Returns the path."""
|
||||
body, ok, _ = vol_to_body(mr, direction)
|
||||
if ok:
|
||||
c, _, _ = vol_to_body(cc.astype(np.float32), direction)
|
||||
si, pi, li = max_area_slices(c)
|
||||
panels = [
|
||||
(body[si], c[si], "axial", "S", si),
|
||||
(body[:, pi, :][::-1], c[:, pi, :][::-1], "coronal", "P", pi),
|
||||
(body[:, :, li][::-1], c[:, :, li][::-1], "sagittal", "L", li),
|
||||
]
|
||||
else: # not body-aligned: image-axis fallback
|
||||
zi, yi, xi = max_area_slices(cc)
|
||||
panels = [
|
||||
(mr[zi], cc[zi], "axial*", "z", zi),
|
||||
(mr[:, yi, :][::-1], cc[:, yi, :][::-1], "coronal*", "y", yi),
|
||||
(mr[:, :, xi][::-1], cc[:, :, xi][::-1], "sagittal*", "x", xi),
|
||||
]
|
||||
|
||||
lo, hi = np.percentile(mr[mr > 0], [1, 99.5])
|
||||
panel_specs = []
|
||||
for im, c2, title, axn, idx in panels:
|
||||
nr, nc = im.shape
|
||||
a = 1.0 # processed volumes are 1mm isotropic
|
||||
panel_specs.append((im, c2, f"{title} {axn}={idx}", a, nr, nc))
|
||||
|
||||
max_in, gap_in, m_in, top_in, bot_in = 4.4, 0.25, 0.3, 1.7, 0.2
|
||||
w_in, h_in = [], []
|
||||
for _, _, _, a, nrow, ncol in panel_specs:
|
||||
pw, ph = ncol, nrow * a
|
||||
m = max(pw, ph)
|
||||
w_in.append(max_in * pw / m)
|
||||
h_in.append(max_in * ph / m)
|
||||
plot_h = max_in
|
||||
fig_w = sum(w_in) + 2 * gap_in + 2 * m_in
|
||||
fig_h = plot_h + top_in + bot_in
|
||||
fig = plt.figure(figsize=(fig_w, fig_h))
|
||||
left = m_in / fig_w
|
||||
for (im, c2, title, a, _, _), w, h in zip(panel_specs, w_in, h_in):
|
||||
axi = fig.add_axes([left, (bot_in + (plot_h - h) / 2) / fig_h, w / fig_w, h / fig_h])
|
||||
axi.imshow(im, cmap="gray", vmin=lo, vmax=hi, origin="upper", aspect=a)
|
||||
if c2.any():
|
||||
axi.contour(c2, levels=[0.5], colors="r", linewidths=1.2)
|
||||
axi.set_title(title, color="w", fontsize=11)
|
||||
axi.set_xticks([])
|
||||
axi.set_yticks([])
|
||||
for spine in axi.spines.values():
|
||||
spine.set_edgecolor("0.35")
|
||||
left += (w + gap_in) / fig_w
|
||||
tkey = key.split("_", 1)[1] if "_" in key else key
|
||||
align = "body-aligned" if ok else "NOT body-aligned (image axes)"
|
||||
status = f"EXCLUDED: {reason}" if reason else "kept"
|
||||
fig.suptitle(
|
||||
f"{tkey} [{tag}] TV total={int(vol_total)} mm3, "
|
||||
f"largest={int(cc.sum())} mm3 ({n_cc} targets) {status} {align}",
|
||||
color="w", fontsize=12)
|
||||
fig.patch.set_facecolor("k")
|
||||
fig.savefig(out, facecolor="k", dpi=dpi)
|
||||
plt.close(fig)
|
||||
return out
|
||||
|
||||
|
||||
def worker(args):
|
||||
key, tag, pimg, plabel, out_base, force, dpi = args
|
||||
try:
|
||||
meta = json.load(open(os.path.join(d("data/procmeta"), key + ".json")))
|
||||
stats = label_stats(plabel)
|
||||
except Exception:
|
||||
return key, "error: unreadable label/procmeta"
|
||||
reason = exclusion_reason(key, stats)
|
||||
cat = reason.split(" (", 1)[0] if reason else None
|
||||
# reconcile a stale figure for this key left in the other folder by an
|
||||
# earlier run under a different exclusion status (e.g. a key newly added to
|
||||
# NON_BRAIN_KEYS would otherwise keep its old inclusion/ figure)
|
||||
folder = "exclusion" if reason else "inclusion"
|
||||
other = "inclusion" if reason else "exclusion"
|
||||
for stale in glob.glob(os.path.join(out_base, other, f"*_{key}.png")):
|
||||
os.remove(stale)
|
||||
if stats["total"] == 0:
|
||||
return key, "empty-label", cat
|
||||
status = "excluded" if reason else "included"
|
||||
out = os.path.join(out_base, folder, out_name(stats["total"], key))
|
||||
if os.path.exists(out) and not force:
|
||||
return key, f"skip-{status}", cat
|
||||
try:
|
||||
mr = np.asarray(sitk.GetArrayFromImage(sitk.ReadImage(pimg)), dtype=np.float32)
|
||||
lab = sitk.GetArrayFromImage(sitk.ReadImage(plabel))
|
||||
cc, n_cc = largest_cc(lab > 0.5)
|
||||
try:
|
||||
render(key, tag, mr, cc, n_cc, int(stats["total"]),
|
||||
np.array(meta["direction"]), reason, out, dpi)
|
||||
finally:
|
||||
plt.close("all")
|
||||
return key, status, cat
|
||||
except Exception as e: # noqa
|
||||
return key, f"error: {e!r}", cat
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--from-splits", action="store_true",
|
||||
help="rows/tags from split_{train,val,test}.jsonl instead of "
|
||||
"the source manifests (ntuh + m6_labeled)")
|
||||
ap.add_argument("--keys", nargs="*", default=[])
|
||||
ap.add_argument("--limit", type=int, default=0)
|
||||
ap.add_argument("--workers", type=int, default=32)
|
||||
ap.add_argument("--force", action="store_true")
|
||||
ap.add_argument("--out", default=d("data/tv_slices"))
|
||||
ap.add_argument("--dpi", type=int, default=110)
|
||||
args = ap.parse_args()
|
||||
os.makedirs(os.path.join(args.out, "inclusion"), exist_ok=True)
|
||||
os.makedirs(os.path.join(args.out, "exclusion"), exist_ok=True)
|
||||
|
||||
if args.from_splits:
|
||||
rows = []
|
||||
for s in ("train", "val", "test"):
|
||||
for r in load_jsonl(os.path.join(DATA, "manifests", f"split_{s}.jsonl")):
|
||||
rows.append((r["key"], s, r["pimg"], r.get("plabel")))
|
||||
else:
|
||||
rows = [(r["key"],
|
||||
"ntuh" if r["key"].startswith("ntuh_") else "m6",
|
||||
r["pimg"], r.get("plabel")) for r in labeled_source_rows()]
|
||||
if args.keys:
|
||||
keep = set(args.keys)
|
||||
rows = [r for r in rows if r[0] in keep]
|
||||
if args.limit:
|
||||
rows = rows[: args.limit]
|
||||
print(f"rows: {len(rows)}")
|
||||
|
||||
from concurrent.futures import ProcessPoolExecutor, as_completed
|
||||
tasks = [(k, tag, pimg, plabel, args.out, args.force, args.dpi)
|
||||
for k, tag, pimg, plabel in rows]
|
||||
stats = {}
|
||||
reasons = Counter()
|
||||
done = 0
|
||||
with ProcessPoolExecutor(max_workers=args.workers) as ex:
|
||||
futs = {ex.submit(worker, t): t for t in tasks}
|
||||
for fu in as_completed(futs):
|
||||
k, status, cat = fu.result()
|
||||
stats[status] = stats.get(status, 0) + 1
|
||||
if status == "excluded" and cat:
|
||||
reasons[cat] += 1
|
||||
done += 1
|
||||
if done % 100 == 0 or done == len(tasks):
|
||||
print(f" {done}/{len(tasks)} {stats}", flush=True)
|
||||
print(f"done: {stats}")
|
||||
if reasons:
|
||||
print("excluded by rule:")
|
||||
for r, n in sorted(reasons.items()):
|
||||
print(f" {n:4d} {r}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -223,6 +223,45 @@ def write_arr(arr, path, itk_img=None):
|
|||
sitk.WriteImage(itk_img, path, True)
|
||||
|
||||
|
||||
def proc_row(r, prefix):
|
||||
"""Processed-volume view of a source manifest row, or None when the
|
||||
processed volume does not exist.
|
||||
|
||||
Source rows come from data/manifests/{ntuh,m6_labeled,m6_unlabeled}.jsonl
|
||||
({key, subject, img, label?, date?} — bare subjects, no source prefix).
|
||||
Returns {key, subject (prefixed), pimg, img, label, date[, plabel]} where
|
||||
pimg/plabel point at data/proc/<key>{,_label}.nii.gz (1mm processed).
|
||||
"""
|
||||
key = r["key"]
|
||||
proc_root = d("data/proc")
|
||||
p = os.path.join(proc_root, key + ".nii.gz")
|
||||
if not os.path.exists(p):
|
||||
return None
|
||||
row = {"key": key, "subject": f"{prefix}_{r['subject']}", "pimg": p,
|
||||
"img": r["img"], "label": r.get("label"), "date": r.get("date")}
|
||||
lab = os.path.join(proc_root, key + "_label.nii.gz")
|
||||
if r.get("label") and os.path.exists(lab):
|
||||
row["plabel"] = lab
|
||||
return row
|
||||
|
||||
|
||||
def labeled_source_rows():
|
||||
"""All labeled rows fetched from the sources (ntuh + m6_labeled), as
|
||||
processed rows — the first dataset stage, before any split (05)."""
|
||||
rows = []
|
||||
for name, prefix in (("ntuh", "ntuh"), ("m6_labeled", "m6")):
|
||||
p = os.path.join(DATA, "manifests", name + ".jsonl")
|
||||
if not os.path.exists(p):
|
||||
continue
|
||||
for r in load_jsonl(p):
|
||||
if not r.get("label"):
|
||||
continue
|
||||
pr = proc_row(r, prefix)
|
||||
if pr:
|
||||
rows.append(pr)
|
||||
return rows
|
||||
|
||||
|
||||
def dice(a, b, eps=1e-7):
|
||||
a = a > 0
|
||||
b = b > 0
|
||||
|
|
|
|||
117
src/exclusions.py
Normal file
117
src/exclusions.py
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
"""Row-level exclusion rules for the labeled dataset.
|
||||
|
||||
Used at two places, both via exclusion_reason() (single source of truth):
|
||||
- scripts/make_tv_slices.py — first-stage curation right after the rows are
|
||||
fetched from the sources (ntuh + m6_labeled): classifies each labeled
|
||||
volume and writes its QA figure to data/tv_slices/inclusion/ or
|
||||
data/tv_slices/exclusion/;
|
||||
- scripts/05_build_splits.py — applied after the patient-level split
|
||||
assignment so kept rows retain their split; excluded rows are recorded
|
||||
in data/manifests/excluded.jsonl.
|
||||
|
||||
Rules (2026-10-04 curation):
|
||||
1. total TV volume > MAX_TOTAL_VOL mm3 (implausible targets, e.g. a
|
||||
full-skull outline instead of a lesion)
|
||||
2. a single target (1 connected component, 26-conn) with total volume
|
||||
< MIN_SINGLE_TARGET_VOL mm3 (sub-target fragments)
|
||||
3. non-brain images (spine/neck CyberKnife cases in
|
||||
ntuh; their series names are indistinguishable from brain T1c, so the
|
||||
rows were identified by visual review of the TV-slice figures,
|
||||
scripts/make_tv_slices.py — 43 rows, see NON_BRAIN_KEYS)
|
||||
|
||||
label_stats() / exclusion_reason() are the single source of truth; 05 calls
|
||||
them for every labeled row and records the excluded rows (with reason) in
|
||||
data/manifests/excluded.jsonl.
|
||||
"""
|
||||
import numpy as np
|
||||
from scipy import ndimage
|
||||
|
||||
MAX_TOTAL_VOL = 1_000_000 # mm3 (1mm isotropic voxels == mm3)
|
||||
MIN_SINGLE_TARGET_VOL = 100 # mm3
|
||||
|
||||
# Non-brain rows identified by visual review of the TV-slice figures on
|
||||
# 2026-10-04 (spine / neck / torso T1c with vertebral-slab or neck targets;
|
||||
# series filenames carry no anatomical marker). All ntuh.
|
||||
#
|
||||
# Review protocol: classify at FULL resolution (data/tv_slices/exclusion/),
|
||||
# using the mid-cranial slice (widest head-mask axial), not just the
|
||||
# TV-max slice — skull-base / posterior-fossa brain targets look like
|
||||
# cervical spine at the TV-max level (6 such false positives were found and
|
||||
# removed on 2026-10-04 after expert inspection: ntuh_5902230, ntuh_5231609,
|
||||
# ntuh_5083686, ntuh_5157194, ntuh_5303758, ntuh_6373987). TV-size gating
|
||||
# also misses small vertebral targets (e.g. ntuh_5022161, 16k mm3); the
|
||||
# mid-cranial slice is the only reliable discriminator, so the full ntuh
|
||||
# population was audited slice-by-slice on 2026-10-05.
|
||||
NON_BRAIN_KEYS = frozenset({
|
||||
# cervical / thoracic spine slabs
|
||||
"ntuh_5237879_case2009.12.10.13.25.32_20091210120310",
|
||||
"ntuh_5584533_case2011.03.18.13.24.12_20110318115157",
|
||||
"ntuh_4500992_case2009.08.13.13.51.21_20090813120210",
|
||||
"ntuh_2922239_case2010.08.23.15.43.52_20100823123647",
|
||||
"ntuh_0915393_case2012.12.14.17.58.27_20121214121137",
|
||||
"ntuh_5085147_case2013.05.31.12.51.10_20130531121922",
|
||||
"ntuh_2486132_case2013.04.24.14.32.34_20130424122008",
|
||||
"ntuh_4493086_case2009.08.06.13.37.10_20090806114937",
|
||||
"ntuh_5185487_case2009.07.27.14.18.34_20090727114447",
|
||||
"ntuh_2177312_case2012.09.10.15.19.55_20120910111101",
|
||||
"ntuh_5302085_case2014.01.06.14.04.54_20140106123022",
|
||||
"ntuh_6069590_case2014.08.28.14.06.40_20140828105732",
|
||||
"ntuh_4408551_case2012.06.28.16.21.19_20120316123153",
|
||||
"ntuh_4408551_case2012.06.28.16.21.19_20120628114905",
|
||||
"ntuh_4408551_case2012.03.16.15.05.30_20120316123153",
|
||||
"ntuh_2444262_case2009.07.07.13.36.39_20090707113235",
|
||||
"ntuh_5406976_case2009.02.12.14.05.41_20090212112402",
|
||||
"ntuh_8009538_case2009.11.04.12.46.23_20091104123842",
|
||||
"ntuh_3563834_case2010.04.20.13.15.45_20100420113442",
|
||||
"ntuh_5712878_case2011.12.05.14.11.00_20111205120244",
|
||||
"ntuh_4272908_case2016.06.24.13.33.59_20160624113223",
|
||||
"ntuh_4494910_case2008.06.03.13.40.34_20080603114804",
|
||||
"ntuh_2167997_case2016.05.23.13.44.36_20160523114640",
|
||||
"ntuh_4572226_case2017.11.06.14.11.35_20171106115826",
|
||||
"ntuh_3420930_case2012.07.30.15.01.30_20120730111910",
|
||||
"ntuh_4219462_case2010.05.31.13.50.31_20100601120540",
|
||||
"ntuh_4572226_case2017.11.06.14.12.33_20171106115826",
|
||||
"ntuh_5022161_case2011.03.02.13.24.52_20110302113819",
|
||||
"ntuh_5650997_case2011.06.29.13.21.10_20110629113315",
|
||||
"ntuh_6264497_case2016.09.21.14.48.29_20160921170338",
|
||||
"ntuh_6182961_case2016.08.08.13.07.21_20160808123711",
|
||||
"ntuh_2325341_case2010.12.10.13.47.32_20101210122902",
|
||||
"ntuh_5552572_case2010.04.13.13.27.12_20100413120757",
|
||||
"ntuh_0711619_case2011.05.10.13.47.39_20110509161659",
|
||||
"ntuh_5745927_case2013.09.17.13.27.40_20130917115939",
|
||||
"ntuh_4615006_case2010.07.29.13.43.30_20100722121347",
|
||||
"ntuh_5332969_case2008.07.21.13.40.07_20080721140145",
|
||||
"ntuh_5808597_case2012.07.02.15.22.10_20120702115701",
|
||||
"ntuh_5886224_case2013.12.26.14.59.16_20131226115857",
|
||||
# byte-identical duplicate images of reviewed non-brain rows above (same
|
||||
# T1c file registered under a second case-folder name; matched by MD5 on
|
||||
# 2026-10-05), so they escape the row-level list and the TV-size gates
|
||||
"ntuh_2177312_case2012.09.10.16.05.26_20120910111101",
|
||||
"ntuh_2922239_case2010.08.23.14.46.37_20100823123647",
|
||||
"ntuh_4408551_case2012.06.28.16.14.15_20120628114905",
|
||||
"ntuh_5085147_case2013.05.31.12.56.43_20130531121922",
|
||||
})
|
||||
|
||||
_CC_STRUCTURE = np.ones((3, 3, 3), dtype=int)
|
||||
|
||||
|
||||
def label_stats(label_path):
|
||||
"""{total: voxel count, n_cc: connected-component count (26-conn)} for a
|
||||
processed label (1mm isotropic, so voxels == mm3)."""
|
||||
import SimpleITK as sitk
|
||||
m = sitk.GetArrayFromImage(sitk.ReadImage(label_path)) > 0.5
|
||||
total = int(m.sum())
|
||||
n_cc = int(ndimage.label(m, structure=_CC_STRUCTURE)[1])
|
||||
return {"total": total, "n_cc": n_cc}
|
||||
|
||||
|
||||
def exclusion_reason(key, stats):
|
||||
"""Return the exclusion reason for a labeled row, or None to keep it."""
|
||||
if stats["total"] > MAX_TOTAL_VOL:
|
||||
return f"total TV {stats['total']} mm3 > {MAX_TOTAL_VOL} mm3"
|
||||
if stats["n_cc"] == 1 and stats["total"] < MIN_SINGLE_TARGET_VOL:
|
||||
return (f"single target, TV {stats['total']} mm3 "
|
||||
f"< {MIN_SINGLE_TARGET_VOL} mm3")
|
||||
if key in NON_BRAIN_KEYS:
|
||||
return "non-brain image (spine/neck, reviewed 2026-10-04)"
|
||||
return None
|
||||
Loading…
Reference in a new issue