longitudinal/scripts/02_build_m6_dataset.py
Furen Xiao 16b9828d15 Enhance registration pipelines with CT clipping and NiftyReg Docker support
- Added `--clip-ct LO HI` argument to clip CT intensities before registration and scoring, improving A/B comparisons.
- Implemented `_clip_image` function to handle intensity clipping of images.
- Updated registration functions to utilize CT clipping where beneficial, specifically in `register_niftyreg` and `register_synthmorph`.
- Enhanced `fixed_moving` function to ensure CT is always treated as the fixed image.
- Introduced Docker support for NiftyReg v2.1.1, including a Dockerfile for building the image.
- Updated documentation and help messages to reflect new features and usage instructions.
- Adjusted evaluation criteria for registration quality to account for intensity clipping.
2026-09-29 17:31:24 +08:00

447 lines
No EOL
20 KiB
Python

"""Build M6-2025 manifests: T1c volumes, labeled when GTV exists.
GTV lives in CT space; T1c is registered to CT (rigid, Mattes MI on the full
volumes -- no mask, no crop / pad; multi-start initial tilts because
inter-session head tilt reaches ~30 deg and tilted MI local minima can
outscore the true alignment).
The inverse transform then warps CT and GTV (nearest) into T1c native space.
Acceptance requires the warped CT skull ring to be concentric with the MR head
silhouette (crop-independent check).
Outputs:
data/manifests/m6_labeled.jsonl {key, subject, date, img, label(warped GTV), source:'m6'}
data/manifests/m6_unlabeled.jsonl {key, subject, date, img, label:None, source:'m6'}
Usage: python scripts/02_build_m6_dataset.py [--workers 4] [--skip-reg]
"""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import re
import json
import argparse
import numpy as np
import SimpleITK as sitk
from concurrent.futures import ProcessPoolExecutor, as_completed
from src.common import ROOT, DATA, d, save_jsonl, read_nii_img, path
BASE = path("m6")
T1C_RE = re.compile(r"T1.*\+C|fl3d.*\+.*c", re.I)
EXCL_RE = re.compile(r"FLAIR|DTI|vibe|dixon|t2|SWI|T2|MPR_Cor", re.I)
def pick_t1c(mrd):
cands = []
for f in sorted(os.listdir(mrd)):
if not f.endswith(".nii.gz"):
continue
b = f[: -len(".nii.gz")]
if T1C_RE.search(b) and not EXCL_RE.search(b):
pref = 0
if "MPR_Tra" in b:
pref = -2
if "_c" in b.lower() or "+C" in b:
pref -= 1
cands.append((pref, f))
cands.sort(key=lambda x: (x[0], x[1]))
return os.path.join(mrd, cands[0][1]) if cands else None
def discover():
labeled, unlabeled = [], []
for pid in sorted(os.listdir(BASE)):
fp = os.path.join(BASE, pid)
if not os.path.isdir(fp):
continue
for date in sorted(os.listdir(fp)):
dp = os.path.join(fp, date)
if not os.path.isdir(dp) or not re.match(r"\d{8}$", date):
continue
mrd = os.path.join(dp, "MR")
if not os.path.isdir(mrd):
continue
t1c = pick_t1c(mrd)
if not t1c:
continue
rt = os.path.join(dp, "RT")
ct = os.path.join(rt, "ct_image.nii.gz")
label = None
tv_files = []
if os.path.isdir(rt):
try:
tv_files = sorted(f for f in os.listdir(rt)
if f.startswith("TV-") and f.endswith(".nii.gz"))
except OSError:
tv_files = []
if tv_files:
label = os.path.join(rt, tv_files[0])
elif os.path.exists(os.path.join(rt, "TV", "Struct_GTV.nii.gz")):
label = os.path.join(rt, "TV", "Struct_GTV.nii.gz")
key = f"m6_{pid}_{date}"
row = {"key": key, "subject": pid, "date": date, "img": t1c, "source": "m6"}
if label and os.path.exists(ct):
labeled.append({**row, "label": label, "ct": ct, "gtv_registered": False})
else:
unlabeled.append({**row, "label": None})
return labeled, unlabeled
def _ct_vertex_slice(ct):
"""Topmost tissue slice with 10mm of continuity below, or None.
Tissue > -150 HU (bone HU is unreliable in these CTs). The continuity
test skips small artifacts floating above the head.
"""
per = (sitk.GetArrayFromImage(ct) > -150).sum((1, 2))
n8 = max(2, int(round(10 / ct.GetSpacing()[2])))
for i in range(len(per) - 1, -1, -1):
if per[i] > 500 and per[max(0, i - n8):i + 1].min() >= 50:
return i
return None
def _skull_ring_gap(warped_ct, ha):
"""Concentricity of the CT skull ring vs the MR head silhouette.
The MR volumes are tight crops, so distance-to-crop-edge is
crop-dependent; instead march rays from the head center and measure the
gap between the MR head boundary and the CT skull ring (p97 of positive
CT) at two levels. Returns {"mid_mm", "low_mm", "std_mm"} or None when no
skull ring is found. Aligned = small gap with low angular variance.
The ring test is ``>=`` (not ``>``) so it also works on a CT clipped to a
brain window (e.g. [0, 80] HU), where the p97 threshold sits exactly on
the clipped-skull atom and a strict comparison would give an empty ring.
"""
posw = warped_ct[warped_ct > 0]
if not posw.size:
return None
bthr = float(np.percentile(posw, 97))
sil = ha > 50
izs = np.where(sil.sum((1, 2)) > 500)[0]
if not izs.size:
return None
def _gap(iz):
sl = sil[iz]
if sl.sum() < 500:
return None, None
sk = warped_ct[iz] >= bthr
if not sk.any():
return None, None
hy, hx = np.nonzero(sl)
cy0, cx0 = float(hy.mean()), float(hx.mean())
gaps = []
for a in np.linspace(0, 2 * np.pi, 72, endpoint=False):
dy, dx = np.sin(a), np.cos(a)
rr = np.arange(0.0, 160.0, 0.5)
yy = np.clip((cy0 + rr * dy).astype(int), 0, ha.shape[0] - 1)
xx = np.clip((cx0 + rr * dx).astype(int), 0, ha.shape[1] - 1)
rh = rr[np.where(sl[yy, xx])[0]]
rs = rr[np.where(sk[yy, xx])[0]]
if len(rh) and len(rs):
gaps.append(float(rh.max() - rs.max()))
if not gaps:
return None, None
return float(np.mean(gaps)), float(np.std(gaps))
gm, sm = _gap(int(izs.min() + (izs.max() - izs.min()) * 0.5))
gl, sl = _gap(int(izs.min() + (izs.max() - izs.min()) * 0.75))
if gm is None:
return None
std = max(s for s in (sm, sl) if s is not None) if (sm is not None or sl is not None) else 0.0
return {"mid_mm": round(gm, 1), "low_mm": round(gl, 1) if gl is not None else None,
"std_mm": round(std, 1)}
def _mi_value(fixed_arr, moving_arr, bins=32):
"""Mattes-style MI (nats) on percentile-binned intensities, subsampled for speed."""
def q(a):
lo, hi = np.percentile(a, (1, 99))
return np.clip(np.round((a - lo) * (bins - 1) / max(hi - lo, 1e-9)).astype(int), 0, bins - 1)
A, B = q(fixed_arr), q(moving_arr)
step = max(1, int(np.ceil((A.size / 200_000) ** (1.0 / 3))))
h = np.bincount(A[::step, ::step, ::step].ravel() * bins
+ B[::step, ::step, ::step].ravel(), minlength=bins * bins)
p = h.reshape(bins, bins) / max(h.sum(), 1)
px, py = p.sum(axis=1, keepdims=True), p.sum(axis=0, keepdims=True)
with np.errstate(divide="ignore", invalid="ignore"):
m = np.where((p > 0) & (px > 0) & (py > 0), np.log(p / (px * py)), 0.0)
return float((p * m).sum())
def _valid_pyramid(size, shrink, smooth, min_dim=6):
sh = [s for s in shrink if min(size) / s >= min_dim] or [1]
sm = smooth[: len(sh)] + [0] * max(0, len(sh) - len(smooth))
return sh, sm
def register_ct_to_t1c(ct, t1c, threads=12):
"""Rigid registration with CT as fixed and T1c as moving, Mattes MI on the
full volumes (no mask, no crop / pad).
Both inputs share physical space (oriented). The transform is computed on
the full CT / T1c volumes, so it applies directly to any resampling.
Returns (transform, info): transform is the registration transform, mapping
T1c (moving) coordinates -> CT (fixed) coordinates; resampling CT/TV onto
the T1c grid with it applies the inverse transform to CT-space points,
placing them in T1c (MR) coordinates. info has per-start MI/timing, the
affine decomposition (matrix/offset/scale), and the skull-ring concentricity
QA statistic.
"""
import time
t_start = time.time()
try: # noqa
sitk.CommonProperties.SetGlobalDefaultNumberOfThreads(threads)
except AttributeError:
pass
ct = sitk.Cast(ct, sitk.sitkFloat32)
t1c = sitk.Cast(t1c, sitk.sitkFloat32)
from src.common import head_mask_from_image
ha = sitk.GetArrayFromImage(t1c)
hm = head_mask_from_image(ha)
# full volumes, no mask / crop / pad: fixed = CT, moving = T1c
fixed, moving = ct, t1c
tsp = np.array(t1c.GetSpacing())
stages = []
def run_stage(name, init_tr, shrink, smooth, lr, min_step, iters):
t0 = time.time()
r = sitk.ImageRegistrationMethod()
r.SetMetricAsMattesMutualInformation(numberOfHistogramBins=32)
r.SetOptimizerAsRegularStepGradientDescent(
learningRate=lr, minStep=min_step, numberOfIterations=iters,
relaxationFactor=0.5, gradientMagnitudeTolerance=1e-6,
estimateLearningRate=sitk.ImageRegistrationMethod.EachIteration)
r.SetOptimizerScalesFromPhysicalShift()
sh, sm = _valid_pyramid(fixed.GetSize(), shrink, smooth)
r.SetShrinkFactorsPerLevel(sh)
r.SetSmoothingSigmasPerLevel(sm)
r.SetInterpolator(sitk.sitkLinear)
r.SetInitialTransform(init_tr, inPlace=False)
fa = sitk.GetArrayFromImage(fixed)
mi_in = _mi_value(fa, sitk.GetArrayFromImage(sitk.Resample(moving, fixed, init_tr, sitk.sitkLinear, 0.0)))
tr = r.Execute(fixed, moving)
mi_out = _mi_value(fa, sitk.GetArrayFromImage(sitk.Resample(moving, fixed, tr, sitk.sitkLinear, 0.0)))
stages.append({"name": name, "mi_in": round(mi_in, 4), "mi_out": round(mi_out, 4),
"seconds": round(time.time() - t0, 1), "shrink": sh, "smooth": sm})
return tr
# The MI surface has tilted local minima that can score HIGHER MI than the
# true alignment (patients are repositioned between the CT and MR sessions,
# so the inter-session head tilt can reach ~30 deg), so MI alone cannot
# rank candidates. Run the full pyramid from the centered start; if its
# full-resolution geometric concentricity (skull-ring gap) is poor, repeat
# from a grid of initial tilts and keep the best by gap.
# Euler rotation center = the CT head centroid (the 150 mm band below the
# vertex, same head definition as before); off maps it onto the MR head
# centroid. Both are init-only quantities; the registration itself runs on
# the full volumes.
ca = sitk.GetArrayFromImage(ct)
csp, co = np.array(ct.GetSpacing()), np.array(ct.GetOrigin())
tissue = ca > -150
vz = _ct_vertex_slice(ct)
if vz is not None:
z0 = max(0, vz - int(round(150 / csp[2])))
idx = np.array([z0, 0, 0]) + np.argwhere(tissue[z0:vz + 1]).mean(0)
else:
idx = np.argwhere(tissue).mean(0)
ct_c = np.array(co) + idx * csp
mr_c = np.array(t1c.GetOrigin()) + np.argwhere(hm).mean(0) * tsp
off = (float(mr_c[0] - ct_c[0]), float(mr_c[1] - ct_c[1]), float(mr_c[2] - ct_c[2]))
rad = np.pi / 180.0
def full_run(sx, sy, sz):
init = sitk.Euler3DTransform()
init.SetCenter(tuple(float(x) for x in ct_c))
init.SetParameters((sx * rad, sy * rad, sz * rad) + off)
tr0 = run_stage(f"start_{sx:+d}x{sy:+d}y{sz:+d}z", init, [8, 4, 2, 1], [4, 2, 1, 0], 10.0, 0.5, 400)
gap = _skull_ring_gap(sitk.GetArrayFromImage(
sitk.Resample(ct, t1c, tr0.GetInverse(), sitk.sitkLinear)), ha)
return tr0, gap
def is_good(g):
# |mid| (not 0 <= mid): full-volume registration can land the CT skull
# ring slightly inside the MR silhouette, which is equally concentric
return bool(g) and g["std_mm"] <= 12.0 and abs(g["mid_mm"]) <= 20.0
tr_reg, gap = full_run(0, 0, 0)
if not is_good(gap):
# inter-session tilt can reach ~30 deg in pitch: search a grid of
# initial rotations, keep the best by full-res concentricity
best = (tr_reg, gap)
starts = [(sx, sy, 0)
for sx in (-30, -20, -10, 0, 10, 20, 30)
for sy in (-20, 0, 20) if (sx, sy) != (0, 0)]
for sx, sy, sz in starts:
tr_i, gap_i = full_run(sx, sy, sz)
gi = (gap_i["std_mm"], gap_i["mid_mm"]) if gap_i else (999.0, 999.0)
gb = (best[1]["std_mm"], best[1]["mid_mm"]) if best[1] else (999.0, 999.0)
if gi < gb:
best = (tr_i, gap_i)
tr_reg, gap = best
if not is_good(gap):
for sx, sy, sz in [(sx, 0, sz) for sz in (15, -15) for sx in (-30, 0, 30)]:
tr_i, gap_i = full_run(sx, sy, sz)
gi = (gap_i["std_mm"], gap_i["mid_mm"]) if gap_i else (999.0, 999.0)
gb = (best[1]["std_mm"], best[1]["mid_mm"]) if best[1] else (999.0, 999.0)
if gi < gb:
best = (tr_i, gap_i)
tr_reg, gap = best
# tr_reg (Execute convention) maps CT (fixed) -> T1c (moving). Its inverse
# maps T1c -> CT, so resampling CT/TV onto the T1c grid with it applies the
# inverse transform to CT-space points, placing them in T1c (MR) coords.
tr = tr_reg.GetInverse()
# QA: CT head-box coverage + skull-ring concentricity vs the MR head silhouette
from scipy import ndimage
def _tp(x, y, z):
return np.array(tr.TransformPoint((float(x), float(y), float(z))))
t0 = _tp(0, 0, 0)
mat = np.column_stack([_tp(1, 0, 0) - t0, _tp(0, 1, 0) - t0, _tp(0, 0, 1) - t0])
scale = [round(float(np.linalg.norm(mat[:, i])), 4) for i in range(3)]
warped_ct = sitk.GetArrayFromImage(sitk.Resample(ct, t1c, tr, sitk.sitkLinear))
posw = warped_ct[warped_ct > 0]
tissue_w = (warped_ct > max(2.0, float(np.percentile(posw, 2)))) if posw.size else np.zeros_like(warped_ct, bool)
tissue_w = ndimage.binary_opening(tissue_w, iterations=2)
coverage = float(tissue_w[hm].sum() / max(hm.sum(), 1))
skull_gap = _skull_ring_gap(warped_ct, ha)
info = {"stages": stages,
"affine_stage": {"matrix": [[round(float(v), 4) for v in row] for row in mat.tolist()],
"offset": [round(float(x), 3) for x in t0], "scale": scale},
"coverage_head_box": round(coverage, 3),
"skull_ring_gap_mm": skull_gap,
"seconds_total": round(time.time() - t_start, 1)}
print("reg done %.0fs (rigid %.1fs) mi %.3f -> %.3f cov %.2f%s" % (
info["seconds_total"], sum(s["seconds"] for s in stages),
stages[-1]["mi_in"], stages[-1]["mi_out"], coverage,
" skull_gap mid %.1fmm std %.1fmm" % (skull_gap["mid_mm"], skull_gap["std_mm"]) if skull_gap else " (no skull ring)"), flush=True)
return tr, info, tissue_w
def reg_worker(row):
reg_dir = d("data/m6reg")
key = row["key"]
t1c = read_nii_img(row["img"])
ct = read_nii_img(row["ct"])
# guard: corrupted CT (upper brain missing) cannot be registered
vz = _ct_vertex_slice(ct)
if vz is not None:
sp = ct.GetSpacing()[2]
per = (sitk.GetArrayFromImage(ct) > -150).sum((1, 2))
iz = vz - int(round(100 / sp))
incomplete = not (0 <= iz < len(per) and per[iz] > 5000)
else:
incomplete = True
if incomplete:
with open(os.path.join(reg_dir, key + "_reg.json"), "w") as f:
json.dump({"key": key, "subject": row["subject"], "date": row["date"],
"tv_nvox": 0, "tv_accepted": False,
"accept_reason": "ct head incomplete or not found",
"inputs": {"ct": row["ct"], "mr": row["img"], "tv": row["label"]}},
f, indent=1)
return key, False, 0
gtv = read_nii_img(row["label"])
tr, info, tissue_w = register_ct_to_t1c(ct, t1c)
# QA files in m6reg, everything in (oriented) MR coordinates:
# _ct: CT warped to the MR grid (linear), for overlaying _tv
# _mr: the oriented MR volume itself
# _tv: original TV warped to the MR grid (nearest) — the study label
# _reg.json: registration metrics + final image information
tv_mr = sitk.Resample(gtv, t1c, tr, sitk.sitkNearestNeighbor, 0.0)
z = sitk.GetArrayFromImage(tv_mr)
nvox = int((z > 0.5).sum())
from scipy import ndimage
tv_in_ct_frac = 1.0
if nvox > 0:
tvd = ndimage.binary_dilation(z > 0.5, iterations=3)
tv_in_ct_frac = float((tvd & tissue_w).sum() / max(tvd.sum(), 1))
tv_in_ct = tv_in_ct_frac >= 0.5 # slab must straddle the lesion
gap = info["skull_ring_gap_mm"]
if gap and abs(gap["mid_mm"]) <= 20.0 and (gap["std_mm"] or 0) <= 12.0:
align = "concentric"
else:
align = "not_concentric"
accepted = nvox > 0 and tv_in_ct and align == "concentric"
info = dict(info)
info.update({
"key": key, "subject": row["subject"], "date": row["date"],
"tv_nvox": nvox, "tv_in_ct_frac": round(tv_in_ct_frac, 3),
"alignment": align,
"tv_accepted": accepted,
"accept_reason": ("ok" if accepted else
"tv outside MR grid" if nvox == 0 else
"tv outside CT coverage" if not tv_in_ct else
f"skull ring not concentric (gap={gap})"),
"inputs": {"ct": row["ct"], "mr": row["img"], "tv": row["label"]},
"images": {
"ct": {"size": list(ct.GetSize()), "spacing": [float(s) for s in ct.GetSpacing()],
"origin": [float(o) for o in ct.GetOrigin()],
"direction": [float(x) for x in ct.GetDirection()],
"pixel_type": ct.GetPixelIDTypeAsString()},
"mr": {"size": list(t1c.GetSize()), "spacing": [float(s) for s in t1c.GetSpacing()],
"origin": [float(o) for o in t1c.GetOrigin()],
"direction": [float(x) for x in t1c.GetDirection()],
"pixel_type": t1c.GetPixelIDTypeAsString()},
"tv_warp": "mr grid (same as images.mr)",
},
})
sitk.WriteImage(sitk.Resample(ct, t1c, tr, sitk.sitkLinear), os.path.join(reg_dir, key + "_ct.nii.gz"), True)
sitk.WriteImage(t1c, os.path.join(reg_dir, key + "_mr.nii.gz"), True)
sitk.WriteImage(tv_mr, os.path.join(reg_dir, key + "_tv.nii.gz"), True)
with open(os.path.join(reg_dir, key + "_reg.json"), "w") as f:
json.dump(info, f, indent=1)
return key, accepted, nvox
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--workers", type=int, default=2)
ap.add_argument("--skip-reg", action="store_true")
ap.add_argument("--limit", type=int, default=0, help="register at most N missing (0=all)")
args = ap.parse_args()
labeled, unlabeled = discover()
print(f"m6 labeled candidates: {len(labeled)}, unlabeled: {len(unlabeled)}")
reg_dir = d("data/m6reg")
if not args.skip_reg:
todo = [r for r in labeled if not os.path.exists(os.path.join(reg_dir, r["key"] + "_tv.nii.gz"))]
if args.limit > 0:
todo = todo[:args.limit]
print(f"registering {len(todo)} GTVs with {args.workers} workers")
with ProcessPoolExecutor(max_workers=args.workers) as ex:
futs = {ex.submit(reg_worker, r): r for r in todo}
for fu in as_completed(futs):
k = futs[fu]["key"]
try:
key, ok, nvox = fu.result()
print(f" {key}: ok={ok} nvox={nvox}", flush=True)
except Exception as e: # noqa
print(f" {k}: ERR {e}", flush=True)
# refresh manifest state
labeled, unlabeled = discover()
for r in labeled:
p = os.path.join(reg_dir, r["key"] + "_tv.nii.gz")
accepted = True
jf = os.path.join(reg_dir, r["key"] + "_reg.json")
if os.path.exists(jf):
try:
accepted = json.load(open(jf)).get("tv_accepted", False)
except (OSError, ValueError):
accepted = False
r["label"] = p if os.path.exists(p) and accepted else None
keep = [r for r in labeled if r.get("label")]
# TV present but warped to zero overlap with the T1c FOV (non-cranial
# target) or registration failed: keep the T1c in the pool as unlabeled
dropped = [{**r, "label": None} for r in labeled if r["key"] not in {k["key"] for k in keep}]
unlabeled = [r for r in unlabeled if r["key"] not in {k["key"] for k in keep}] + dropped
for r in keep + unlabeled:
r.pop("ct", None)
save_jsonl(keep, os.path.join(DATA, "manifests/m6_labeled.jsonl"))
save_jsonl(unlabeled, os.path.join(DATA, "manifests/m6_unlabeled.jsonl"))
print(f"final labeled={len(keep)} unlabeled={len(unlabeled)}")
if __name__ == "__main__":
main()