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.
This commit is contained in:
parent
b495cf0a80
commit
0de7655153
2 changed files with 91 additions and 11 deletions
|
|
@ -19,7 +19,9 @@ from src.common import ROOT, DATA, save_jsonl, load_jsonl, is_head_series, path
|
||||||
|
|
||||||
BASE = path("lee")
|
BASE = path("lee")
|
||||||
T1_NAME_RE = re.compile(r"t1|tfl|spgr|mp2rage|tse3d|vfl|mpage", re.I)
|
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):
|
def parse_txt(p):
|
||||||
|
|
@ -132,6 +134,8 @@ def scan_timepoint(sid, date, tpd):
|
||||||
# timepoint has no thinner valid T1c; the others are flagged
|
# timepoint has no thinner valid T1c; the others are flagged
|
||||||
# thick_dropped: out of the selected manifest, but kept in raw so 04 can
|
# 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
|
# 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]
|
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"]))]
|
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]
|
dropped = [c for c in cand if c not in keep]
|
||||||
|
|
|
||||||
|
|
@ -286,27 +286,103 @@ def zero_tp_rows(uniq):
|
||||||
and r["sid"] in sub_ok and (r["sid"], r["date"]) not in tp_ok]
|
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 <date>_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):
|
def write_excluded_notes(zero_rows, all_rows, thick_rows, dyn_excl, reasons):
|
||||||
"""Write data/qa/<sid>/<date>_no_t1c.md for every timepoint with no
|
"""Write data/qa/<sid>/<date>_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}
|
tps = {(r["sid"], r["date"]) for r in zero_rows}
|
||||||
written = []
|
written = []
|
||||||
|
|
||||||
|
def esc(s):
|
||||||
|
return (s or "").strip().replace("|", "/").replace("\n", " ")
|
||||||
|
|
||||||
for sid, date in sorted(tps):
|
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 all_rows if (r["sid"], r["date"]) == (sid, date)]
|
||||||
lst += [r for r in thick_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"]))
|
lst.sort(key=lambda r: int(r["ser"]))
|
||||||
lines = [f"# {sid} {date}: no T1c volume", "",
|
rows_by_ser = {int(r["ser"]): r for r in lst}
|
||||||
"This timepoint has no reconstructed T1c volume: every T1c "
|
cand = {r["key"] for r in lst}
|
||||||
"candidate series was rejected.", "",
|
subj_dir = None
|
||||||
"| series | slices | description | reason |",
|
|
||||||
"|---|---|---|---|"]
|
|
||||||
for r in lst:
|
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)"
|
why = "excluded: dynamic-frame series (other T1c in this timepoint)"
|
||||||
|
elif key in cand:
|
||||||
|
why = reasons.get(key, "no output")
|
||||||
else:
|
else:
|
||||||
why = reasons.get(r["key"], "no output")
|
why = "not a T1c candidate"
|
||||||
desc = (r.get("why") or "").strip().replace("|", "/")
|
n = e["jpg"] or (r.get("n_slices") if r else None) or "?"
|
||||||
lines.append(f"| s{r['ser']} | {r.get('n_slices', '?')} | {desc} | {why} |")
|
_, 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")
|
out = os.path.join(DATA, "qa", sid, f"{date}_no_t1c.md")
|
||||||
os.makedirs(os.path.dirname(out), exist_ok=True)
|
os.makedirs(os.path.dirname(out), exist_ok=True)
|
||||||
with open(out, "w") as f:
|
with open(out, "w") as f:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue