Update M6 manifest building to register T1c to CT with rigid Mattes MI on a CT-space head box, including multi-start initial tilts for large inter-session head motion. The inverse transform warps CT and GTV into T1c native space, with acceptance based on concentricity of the warped CT skull ring and MR head silhouette. Rerun preprocessing when a row has a label but the warped label file is missing. Normalize volume orientation in read_nii_img. Add registration benchmark documentation, benchmark script, and shared registration pipeline helpers.
460 lines
No EOL
21 KiB
Python
460 lines
No EOL
21 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 a head box
|
|
located in CT space; 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.
|
|
"""
|
|
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
|
|
head-box images.
|
|
|
|
Both inputs share physical space (oriented). The registration grid is a head
|
|
box located in CT space from the top of the head (the CTs are head-neck
|
|
exams, so an MR-head crop would show mostly neck/air on the CT); the box
|
|
excludes most air so MI is computed on tissue. The box is in physical
|
|
space, so the resulting transform applies directly to the full volumes.
|
|
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)
|
|
# Registration grid = a head box located in CT space. The CTs are head-neck
|
|
# (often head-neck-chest) exams, so cropping to the MR head box would leave
|
|
# mostly neck/chest/air on the fixed image and MI locks onto the wrong
|
|
# structures. Vertex = topmost tissue slice with 10mm of continuity below
|
|
# (skips small artifacts floating above the head; bone HU is unreliable
|
|
# here, so tissue > -150 HU is used). x/y from the tissue extent of the
|
|
# 150mm band below the vertex. Box covers the whole head (x/y: extent +
|
|
# 15mm margin, z: +10 to -215 from vertex).
|
|
ca = sitk.GetArrayFromImage(ct)
|
|
tissue = ca > -150
|
|
csp, co = np.array(ct.GetSpacing()), np.array(ct.GetOrigin())
|
|
vz = _ct_vertex_slice(ct)
|
|
if vz is not None:
|
|
ztop = co[2] + vz * csp[2]
|
|
band = tissue[max(0, vz - int(round(150 / csp[2]))):vz + 1]
|
|
cols = np.nonzero(band.any(0))
|
|
box_lo = (co[0] + cols[1].min() * csp[0] - 15,
|
|
co[1] + cols[0].min() * csp[1] - 15, ztop - 215)
|
|
box_hi = (co[0] + cols[1].max() * csp[0] + 15,
|
|
co[1] + cols[0].max() * csp[1] + 15, ztop + 10)
|
|
else: # no head found: fall back to the whole CT volume
|
|
gs = ct.GetSize()
|
|
box_lo = tuple(float(x) for x in co)
|
|
box_hi = (co[0] + gs[0] * csp[0], co[1] + gs[1] * csp[1], co[2] + gs[2] * csp[2])
|
|
tsp = np.array(t1c.GetSpacing())
|
|
shape = tuple(int(np.ceil((box_hi[d] - box_lo[d]) / tsp[d])) + 1 for d in range(3))
|
|
ref = sitk.GetImageFromArray(np.zeros(shape, np.float32))
|
|
ref.SetSpacing(t1c.GetSpacing())
|
|
ref.SetDirection(t1c.GetDirection())
|
|
ref.SetOrigin(tuple(float(x) for x in box_lo))
|
|
fixed = sitk.Resample(ct, ref, sitk.Transform(), sitk.sitkLinear, 0.0)
|
|
moving = sitk.Resample(t1c, ref, sitk.Transform(), sitk.sitkLinear, 0.0)
|
|
|
|
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.
|
|
mr_c = np.array(t1c.GetOrigin()) + np.argwhere(hm).mean(0) * tsp
|
|
ct_c = np.array([(box_lo[d] + box_hi[d]) / 2.0 for d in range(3)])
|
|
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):
|
|
return bool(g) and g["std_mm"] <= 12.0 and 0.0 <= 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,
|
|
"ct_head_box_mm": [[round(float(x), 1) for x in box_lo], [round(float(x), 1) for x in box_hi]],
|
|
"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 0.0 <= 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() |