longitudinal/scripts/04_reconstruct_lee.py
Furen Xiao b6fa62a763 feat: initial project structure
Add .gitignore, AGENTS.md, scripts directory, and src directory to initialize the repository.
2026-09-25 16:00:37 +08:00

190 lines
No EOL
8.2 KiB
Python

"""Reconstruct lee T1c volumes from JPG slices + DICOM txt metadata.
The timepoint dir holds only sampled per-slice txts (typically slices 1, 2, N);
slice positions are reconstructed by fitting a linear IPP(s) model, validated
against all available samples.
Usage: python scripts/04_reconstruct_lee.py [--manifest ...selected.jsonl] [--workers 48]
Output: data/lee_nii/<key>.nii.gz (uint8, native grid)
"""
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 PIL import Image
from src.common import ROOT, d, load_jsonl
LINE_RE = re.compile(
r'^\s*"\((\d{4}),(\d{4})\)","([A-Z]+)","(\d+)",(?:\"([^\"]*)\"|([^,]+)),"')
def parse_txt(p):
ipp = spacing = iop = rows = cols = None
try:
with open(p, errors="ignore") as f:
for raw in f:
m = LINE_RE.match(raw)
if not m:
continue
tag = (int(m.group(1), 16), int(m.group(2), 16))
if tag not in ((32, 50), (40, 48), (32, 55), (40, 16), (40, 17)):
continue
val = (m.group(5) if m.group(5) is not None else m.group(6)).strip()
if not val:
continue
try:
if tag == (32, 50):
ipp = np.array([float(x) for x in val.split("\\")[:3]])
elif tag == (40, 48):
spacing = np.array([float(x) for x in val.split("\\")[:2]])
elif tag == (32, 55):
iop = np.array([float(x) for x in val.split("\\")[:6]])
elif tag == (40, 16):
rows = int(float(val))
elif tag == (40, 17):
cols = int(float(val))
except (ValueError, IndexError):
continue
if ipp is None or spacing is None or iop is None or rows is None or cols is None:
return None
return ipp, spacing, iop, rows, cols
except OSError:
return None
def reconstruct(row):
sid, date, ser, jpg_dir = row["sid"], row["date"], row["ser"], row["jpg_dir"]
out = os.path.join(d("data/lee_nii"), row["key"] + ".nii.gz")
if os.path.exists(out):
return row["key"], True, "cached"
pat = re.compile(rf"^{re.escape(sid)}_{date}_MR_{ser}_(\d+)_(\d+)\.jpg$")
tpat = re.compile(rf"^{re.escape(sid)}_{date}_MR_{ser}_(\d+)\.txt$")
tp_dir = os.path.dirname(jpg_dir)
jpg_map, txt_samples = {}, []
try:
for f in os.listdir(jpg_dir):
m = pat.match(f)
if m:
sl, inst = int(m.group(1)), int(m.group(2))
if sl not in jpg_map or inst < jpg_map[sl][1]:
jpg_map[sl] = (f, inst)
for f in os.listdir(tp_dir):
m = tpat.match(f)
if m:
p = parse_txt(os.path.join(tp_dir, f))
if p is not None:
txt_samples.append((int(m.group(1)), p))
except OSError as e:
return row["key"], False, f"listdir fail {e!r}"
if not jpg_map or not txt_samples:
return row["key"], False, f"no jpg({len(jpg_map)}) or txt({len(txt_samples)})"
txt_samples.sort()
# majority (rows, cols, pixel-spacing) as reference geometry
from collections import Counter
geo = Counter((p[3], p[4], round(p[1][0], 4), round(p[1][1], 4)) for _, p in txt_samples)
(rows, cols, ps0, ps1) = geo.most_common(1)[0][0]
ref = [p for _, p in txt_samples if (p[3], p[4]) == (rows, cols) and np.allclose(p[1], (ps0, ps1), atol=1e-3)]
good_idx = [sl for sl, p in txt_samples if (p[3], p[4]) == (rows, cols)]
if len(good_idx) < 2 or not ref:
return row["key"], False, f"only {len(good_idx)} consistent geometry samples"
ipp0, ps, iop0, _, _ = ref[len(ref) // 2]
# linear IPP model (fit on geometry-consistent samples only)
good = [(sl, p) for sl, p in txt_samples if p[3] == rows and p[4] == cols and np.allclose(p[1], ps, atol=1e-3)]
if len(good) < 2:
return row["key"], False, "fewer than 2 consistent geometry samples"
(s_lo, p_lo), (s_hi, p_hi) = good[0], good[-1]
step = (p_hi[0] - p_lo[0]) / max(s_hi - s_lo, 1)
resid = max(np.linalg.norm(p[0] - p_lo[0] - (s - s_lo) * step) for s, p in good)
if resid > 0.35 * np.linalg.norm(step):
return row["key"], False, f"non-linear slice positions resid={resid:.3f}"
u, v = iop0[:3], iop0[3:]
n = np.cross(u, v)
# the stack direction must follow the nominal slice normal (rejects multiplanar 2D exports)
if abs(float(np.dot(step, n))) < 0.6 * float(np.linalg.norm(step)):
return row["key"], False, "stack not along slice normal"
sl_list = sorted(jpg_map)
def ipp_of(s):
return p_lo[0] + (s - s_lo) * step
ipp_all = {sl: ipp_of(sl) for sl in sl_list}
r0 = min(sl_list, key=lambda s: np.dot(ipp_all[s] - ipp_all[sl_list[0]], n))
p_ref = ipp_all[r0]
zs, pos = [], []
for sl in sl_list:
off = ipp_all[sl] - p_ref
zs.append(float(np.dot(off, n)))
pos.append((int(round(float(np.dot(off, u)) / ps[0])), int(round(float(np.dot(off, v)) / ps[1]))))
zs = np.array(zs)
dmed = np.median(np.abs(np.diff(np.sort(zs))))
if dmed <= 0 or not np.isfinite(dmed):
return row["key"], False, "bad slice spacing"
nz = int(round((zs.max() - zs.min()) / dmed)) + 1
ro_min = min(p[0] for p in pos)
ro_max = max(p[0] for p in pos)
co_min = min(p[1] for p in pos)
co_max = max(p[1] for p in pos)
vol = np.zeros((rows + (ro_max - ro_min), cols + (co_max - co_min), nz), dtype=np.uint8)
nread = 0
for idx, (sl, jf, (ro, co)) in enumerate(zip(sl_list, [jpg_map[s][0] for s in sl_list], pos)):
zi = int(round(zs[idx] / dmed))
try:
im = np.asarray(Image.open(os.path.join(jpg_dir, jf)).convert("L"), dtype=np.uint8)
except Exception:
continue
if im.shape[:2] != (rows, cols):
if im.shape[:2] == (cols, rows):
im = im.T.copy()
else:
continue
vol[ro - ro_min: ro - ro_min + rows, co - co_min: co - co_min + cols, zi] = im
nread += 1
if nread < 0.95 * len(sl_list) or vol.max() == 0:
return row["key"], False, f"only {nread}/{len(sl_list)} slices read"
ext = (vol.shape[0] * ps[0], vol.shape[1] * ps[1], vol.shape[2] * dmed)
if min(ext) < 60 or max(ext) > 350:
return row["key"], False, f"plausible extent failed {tuple(round(e, 1) for e in ext)}mm"
origin = p_ref + (ro_min * ps[0]) * u + (co_min * ps[1]) * v
direction = tuple(float(x) for x in np.concatenate([u, v, n]))
img = sitk.GetImageFromArray(vol)
img.SetOrigin(tuple(float(x) for x in origin))
img.SetSpacing((float(ps[0]), float(ps[1]), float(dmed)))
img.SetDirection(direction)
os.makedirs(os.path.dirname(out), exist_ok=True)
sitk.WriteImage(img, out, True)
return row["key"], True, f"{vol.shape} n={nread}"
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--manifest", default=os.path.join(ROOT, "data/manifests/lee_t1c_selected.jsonl"))
ap.add_argument("--workers", type=int, default=48)
args = ap.parse_args()
rows = load_jsonl(args.manifest)
rows = [r for r in rows if not os.path.exists(os.path.join(d("data/lee_nii"), r["key"] + ".nii.gz"))]
print(f"todo={len(rows)} workers={args.workers}")
ok = err = 0
with ProcessPoolExecutor(max_workers=args.workers) as ex:
futs = {ex.submit(reconstruct, r): r for r in rows}
for i, fu in enumerate(as_completed(futs), 1):
try:
k, good, msg = fu.result()
except Exception as e: # noqa
k, good, msg = futs[fu]["key"], False, repr(e)
if good:
ok += 1
else:
err += 1
if err <= 40:
print(" ERR", k, msg, flush=True)
if i % 100 == 0:
print(f" {i}/{len(futs)} ok={ok} err={err}", flush=True)
print(f"done ok={ok} err={err}")
if __name__ == "__main__":
main()