From 0de7655153c2e384a6b4a2720e34ee10fadaaa0e Mon Sep 17 00:00:00 2001 From: Furen Xiao Date: Sun, 27 Sep 2026 15:29:02 +0800 Subject: [PATCH] fix(lee): exclude SSFP sequences and enhance T1c exclusion notes Fiesta/CISS/SSFP are balanced-SSFP (T2-dominant) sequences whose short TE fools the seq/TE fallback, causing T1c misclassification. Add them to the exclusion regex and guard against empty candidates. Expand write_excluded_notes to enumerate all series in a timepoint (not just T1c candidates) and parse DICOM txt metadata for each series' description and protocol, producing richer no-T1c reports. --- scripts/03_scan_lee_t1c.py | 6 ++- scripts/04_reconstruct_lee.py | 96 +++++++++++++++++++++++++++++++---- 2 files changed, 91 insertions(+), 11 deletions(-) diff --git a/scripts/03_scan_lee_t1c.py b/scripts/03_scan_lee_t1c.py index b7829ba..c1fc6cf 100644 --- a/scripts/03_scan_lee_t1c.py +++ b/scripts/03_scan_lee_t1c.py @@ -19,7 +19,9 @@ from src.common import ROOT, DATA, save_jsonl, load_jsonl, is_head_series, path BASE = path("lee") T1_NAME_RE = re.compile(r"t1|tfl|spgr|mp2rage|tse3d|vfl|mpage", re.I) -EXCL_RE = re.compile(r"\bt2\b|dwi|dti|mra|mrv|angi|swi|bold|\bpp2d|\bpp3d|perf|t2\*|t2star", re.I) +# fiesta/ciss/ssfp: balanced-SSFP (fluid-bright, T2-dominant) — their short TE +# fools the seq/TE fallback below, so they are excluded by name, not T1c +EXCL_RE = re.compile(r"\bt2\b|dwi|dti|mra|mrv|angi|swi|bold|\bpp2d|\bpp3d|perf|t2\*|t2star|fiesta|ciss|ssfp", re.I) def parse_txt(p): @@ -132,6 +134,8 @@ def scan_timepoint(sid, date, tpd): # timepoint has no thinner valid T1c; the others are flagged # thick_dropped: out of the selected manifest, but kept in raw so 04 can # fall back to them when every other T1c of the timepoint is lost + if not cand: + return [] thin = [c for c in cand if c["max_sp"] <= MAX_SPACING] keep = thin if thin else [max(cand, key=lambda c: (c["acq"], c["n_slices"]))] dropped = [c for c in cand if c not in keep] diff --git a/scripts/04_reconstruct_lee.py b/scripts/04_reconstruct_lee.py index ffd175c..2d60006 100644 --- a/scripts/04_reconstruct_lee.py +++ b/scripts/04_reconstruct_lee.py @@ -286,27 +286,103 @@ def zero_tp_rows(uniq): and r["sid"] in sub_ok and (r["sid"], r["date"]) not in tp_ok] +def scan_timepoint_series(sid, date, subj_dir): + """All series present in every _MR_* timepoint dir of a subject. + + Returns {ser: {"jpg": n_jpg_files, "txt": [(slice, path), ...]}} covering + not just the T1c candidates but every series the exam exported (T2 / + FLAIR / angio / ... that the scan never considered).""" + out = {} + if not subj_dir or not os.path.isdir(subj_dir): + return out + try: + tps = [d for d in os.listdir(subj_dir) + if d.startswith(date + "_MR_") and os.path.isdir(os.path.join(subj_dir, d))] + except OSError: + return out + pj = re.compile(rf"^{re.escape(sid)}_{date}_MR_(\d+)_(\d+)_(\d+)\.jpg$") + pt = re.compile(rf"^{re.escape(sid)}_{date}_MR_(\d+)_(\d+)\.txt$") + for tp in tps: + tpd = os.path.join(subj_dir, tp) + try: + entries = os.listdir(tpd) + except OSError: + continue + jpg_dir = next((os.path.join(tpd, e) for e in entries + if os.path.isdir(os.path.join(tpd, e))), None) + jpg_files = [] + if jpg_dir is not None: + try: + jpg_files = os.listdir(jpg_dir) + except OSError: + pass + for f in entries: + m = pt.match(f) + if m: + out.setdefault(m.group(1), {"jpg": 0, "txt": []})["txt"].append( + (int(m.group(2)), os.path.join(tpd, f))) + for jf in jpg_files: + m = pj.match(jf) + if m: + out.setdefault(m.group(1), {"jpg": 0, "txt": []})["jpg"] += 1 + return out + + def write_excluded_notes(zero_rows, all_rows, thick_rows, dyn_excl, reasons): """Write data/qa//_no_t1c.md for every timepoint with no - surviving T1c volume, listing each candidate series and its rejection reason.""" + surviving T1c volume. Lists the study description and, for ALL series in + the timepoint (including ones the scan never considered as T1c), the + series description, protocol, and rejection reason (from the DICOM txt + metadata of each series' earliest slice sample).""" tps = {(r["sid"], r["date"]) for r in zero_rows} written = [] + + def esc(s): + return (s or "").strip().replace("|", "/").replace("\n", " ") + for sid, date in sorted(tps): lst = [r for r in all_rows if (r["sid"], r["date"]) == (sid, date)] lst += [r for r in thick_rows if (r["sid"], r["date"]) == (sid, date)] lst.sort(key=lambda r: int(r["ser"])) - lines = [f"# {sid} {date}: no T1c volume", "", - "This timepoint has no reconstructed T1c volume: every T1c " - "candidate series was rejected.", "", - "| series | slices | description | reason |", - "|---|---|---|---|"] + rows_by_ser = {int(r["ser"]): r for r in lst} + cand = {r["key"] for r in lst} + subj_dir = None for r in lst: - if r["key"] in dyn_excl: + if r.get("jpg_dir"): + subj_dir = os.path.dirname(os.path.dirname(r["jpg_dir"])) + break + union = scan_timepoint_series(sid, date, subj_dir) + for r in lst: # manifest series whose timepoint dir is gone + union.setdefault(str(int(r["ser"])), {"jpg": 0, "txt": []}) + metas = {} + for ser, e in union.items(): + r = rows_by_ser.get(int(ser)) + src = min(e["txt"])[1] if e["txt"] else (r.get("txt_first") if r else "") + p = parse_txt(src) if src else None + metas[ser] = (p[5], p[6], p[7]) if p else ("", "", "") + study = next((m[0] for m in metas.values() if m[0]), "") + lines = [f"# {sid} {date}: no T1c volume", ""] + if study: + lines += [f"Study description: {study}", ""] + lines += [ + "This timepoint has no reconstructed T1c volume: every T1c " + "candidate series was rejected. All series in this timepoint are " + "listed below.", "", + "| series | slices | series description | protocol | reason |", + "|---|---|---|---|---|"] + for ser in sorted(union, key=int): + e = union[ser] + r = rows_by_ser.get(int(ser)) + key = f"lee_{sid}_{date}_s{ser}" + if key in dyn_excl: why = "excluded: dynamic-frame series (other T1c in this timepoint)" + elif key in cand: + why = reasons.get(key, "no output") else: - why = reasons.get(r["key"], "no output") - desc = (r.get("why") or "").strip().replace("|", "/") - lines.append(f"| s{r['ser']} | {r.get('n_slices', '?')} | {desc} | {why} |") + why = "not a T1c candidate" + n = e["jpg"] or (r.get("n_slices") if r else None) or "?" + _, sdesc, sproto = metas[ser] + lines.append(f"| s{ser} | {n} | {esc(sdesc)} | {esc(sproto)} | {esc(why)} |") out = os.path.join(DATA, "qa", sid, f"{date}_no_t1c.md") os.makedirs(os.path.dirname(out), exist_ok=True) with open(out, "w") as f: