From 94bb5d6e0a98e9b67ef798d0bf9a89336e9e28d8 Mon Sep 17 00:00:00 2001 From: Robert Graf Date: Thu, 17 Sep 2026 11:51:19 +0000 Subject: [PATCH 01/29] more auto accepts --- TPTBox/spine/spinestats/_load_nako_wh.py | 203 +++++++++++++++++++---- 1 file changed, 173 insertions(+), 30 deletions(-) diff --git a/TPTBox/spine/spinestats/_load_nako_wh.py b/TPTBox/spine/spinestats/_load_nako_wh.py index c076ffd..2d09d7e 100644 --- a/TPTBox/spine/spinestats/_load_nako_wh.py +++ b/TPTBox/spine/spinestats/_load_nako_wh.py @@ -453,44 +453,69 @@ def get_corrected_mevibe(fam: BIDS_Family, compute_PDFF=True): # TODO return di # PDFF is recomputed out = {key: _check(fam[f"mevibe_part-{key}"]) for key in ["eco0-opp1", "eco1-pip1", "eco2-opp2", "eco3-in1", "eco4-pop1", "eco5-arb1"]} - pdff = _check(fam["mevibe_part-fat-fraction"]) if "mevibe_part-water_desc-reconstructed" in fam: - # if "mevibe_part-fat-fraction_desc-reconstructed" not in fam: fat = _check(fam["mevibe_part-fat_desc-reconstructed"]) water = _check(fam["mevibe_part-water_desc-reconstructed"]) - - else: + elif "mevibe_part-water" in fam: fat = _check(fam["mevibe_part-fat"]) water = _check(fam["mevibe_part-water"]) - out["mevibe_part-fat"] = fat - out["mevibe_part-fat"] = water - pdff = water.get_changed_bids( - "nii.gz", bids_format=water.bids_format, parent=water.parent, info={"part": "fat-fraction", "desc": "reconstructed"} + else: + fat = None + water = None + + # Anchor for BIDS derivations: prefer a raw/reconstructed water image, fall back to + # whatever fraction file the family exposes. + if water is not None: + anchor = water + elif "mevibe_part-water-fraction" in fam: + anchor = _check(fam["mevibe_part-water-fraction"]) + elif "mevibe_part-fat-fraction" in fam: + anchor = _check(fam["mevibe_part-fat-fraction"]) + else: + return out + + pdff = anchor.get_changed_bids( + "nii.gz", bids_format=anchor.bids_format, parent=anchor.parent, info={"part": "fat-fraction", "desc": "reconstructed"} ) - pdwf = water.get_changed_bids( - "nii.gz", bids_format=water.bids_format, parent=water.parent, info={"part": "water-fraction", "desc": "reconstructed"} + pdwf = anchor.get_changed_bids( + "nii.gz", bids_format=anchor.bids_format, parent=anchor.parent, info={"part": "water-fraction", "desc": "reconstructed"} ) if compute_PDFF and (not pdff.exists() or not pdwf.exists()): - water_nii = to_nii(water) - fat_nii = to_nii(fat) - water_nii.set_dtype_() - fat_nii.set_dtype_() - if not pdff.exists(): - nii = fat_nii / (water_nii + fat_nii) - nii[water_nii + fat_nii == 0] = 0 - nii *= 1000 - nii.set_dtype_("smallest_int") - nii.save(pdff) - if not pdwf.exists(): - nii = water_nii / (water_nii + fat_nii) - nii[water_nii + fat_nii == 0] = 0 - nii *= 1000 - nii.set_dtype_("smallest_int") - nii.save(pdwf) + if fat is not None and water is not None: + water_nii = to_nii(water) + fat_nii = to_nii(fat) + water_nii.set_dtype_() + fat_nii.set_dtype_() + if not pdff.exists(): + nii = fat_nii / (water_nii + fat_nii) + nii[water_nii + fat_nii == 0] = 0 + nii *= 1000 + nii.set_dtype_("smallest_int") + nii.save(pdff) + if not pdwf.exists(): + nii = water_nii / (water_nii + fat_nii) + nii[water_nii + fat_nii == 0] = 0 + nii *= 1000 + nii.set_dtype_("smallest_int") + nii.save(pdwf) + else: + # No raw fat/water — derive the missing fraction from the one the scanner shipped. + if not pdff.exists() and "mevibe_part-water-fraction" in fam: + wf_nii = to_nii(_check(fam["mevibe_part-water-fraction"])) + nii = 1000 - wf_nii + nii.set_dtype_("smallest_int") + nii.save(pdff) + if not pdwf.exists() and "mevibe_part-fat-fraction" in fam: + ff_nii = to_nii(_check(fam["mevibe_part-fat-fraction"])) + nii = 1000 - ff_nii + nii.set_dtype_("smallest_int") + nii.save(pdwf) + + # Downstream (see _apply_corrections_to_subj_dict) expects "mevibe_part-fat" to hold PDFF. if pdff.exists(): out["mevibe_part-fat"] = pdff - if pdff.exists(): + if pdwf.exists(): out["mevibe_part-fat"] = pdwf # else: # pdff = _check(fam["mevibe_part-fat-fraction_desc-reconstructed"]) @@ -567,10 +592,11 @@ def loop_over_repaired_nako( test=False, verbose=False, sort=True, - test_key="/100/10", # path matching. if you want on specific us a 6 digits + test_key="/102/", # path matching. if you want on specific us a 6 digits decision_cache: DecisionCache | Path | str | None = None, corrected_index: dict | Path | str | None = None, skip_subject=None, + vibe_mismatch_snap_dir: Path | str | None = None, ): """Iterate over the repaired NAKO dataset yielding per-subject file dicts. @@ -593,6 +619,12 @@ def loop_over_repaired_nako( test_key: Path substring passed to the BIDS scanner's ``filter_file`` when ``test=True``; only paths containing this substring are indexed. Defaults to a hard-coded example subject. baseline_metadata: Path to the NAKO baseline CSV used to look up height metadata. + vibe_mismatch_snap_dir: When set, subjects whose VIBE parts don't share a shape are skipped + (not yielded); a review snapshot is written to this directory as + ``sub-_vibe-shape-mismatch.jpg`` when that file doesn't already exist. + Two sub-folders ``accept/`` and ``reject/`` are also created on demand: if the reviewer + moves the jpg into ``accept/`` the next run auto-answers the grid prompt with "y" + (resample); if moved into ``reject/`` it auto-answers "n" (drop mismatched keys). Yields: Dict mapping short keys to ``BIDS_FILE`` entries for one subject. @@ -617,7 +649,6 @@ def loop_over_repaired_nako( "derivatives-fullbody-poi", # fullbody / fov101 / fov102 POIs + registered segmentations on the stitched-water grid ], filter_file=(lambda x: test_key in str(x)) if test else None, - ) for sub, subj in gbi.enumerate_subjects(sort=sort, shuffle=not sort): @@ -702,6 +733,8 @@ def loop_over_repaired_nako( q.filter_format("mevibe") # q.filter("sequ", "me1") mevibe_fams = list(q.loop_dict(key_addendum=["mod", "part", "desc"])) + # Drop derivative-only families that don't carry the raw echo images. + mevibe_fams = [f for f in mevibe_fams if "mevibe_part-eco0-opp1" in f] if len(mevibe_fams) > 1: labels = [str(f.get("mevibe_part-eco0-opp1", f)) for f in mevibe_fams] cached_pick = _cached_pick(cache.get(sub, "mevibe_fam")) @@ -791,6 +824,7 @@ def loop_over_repaired_nako( else: cache.set(sub, "vibe_fam", {"pick": labels[choice], "reason": reason}) vibe_fams = [vibe_fams[choice]] + _vibe_skip_subject = False for fam in vibe_fams: vibe_by_key: dict = {} for _k in ( @@ -803,6 +837,34 @@ def loop_over_repaired_nako( ): if fam.get(_k): vibe_by_key[_k] = fam[_k][0] + if vibe_mismatch_snap_dir is not None: + sigs = _vibe_grid_sigs(vibe_by_key) + if len(set(sigs.values())) > 1: + snap_dir = Path(vibe_mismatch_snap_dir) + accept_dir = snap_dir / "accept" + reject_dir = snap_dir / "reject" + for d in (snap_dir, accept_dir, reject_dir): + d.mkdir(parents=True, exist_ok=True) + snap_name = f"sub-{sub}_vibe-shape-mismatch.jpg" + snap_path = snap_dir / snap_name + if (accept_dir / snap_name).exists(): + # User verified this mismatch is fine — auto-answer "y" (resample). + cache.set(sub, "grid_mismatch:vibe", {"decision": "resample", "reason": "accepted-via-snap"}) + elif (reject_dir / snap_name).exists(): + # User rejected this subject's VIBE — auto-answer "n" (drop mismatched keys). + cache.set(sub, "grid_mismatch:vibe", {"decision": "remove", "reason": "rejected-via-snap"}) + else: + if not snap_path.exists(): + try: + _save_vibe_shape_mismatch_snapshot(vibe_by_key, snap_path) + except Exception as e: # noqa: BLE001 + log.on_warning(f"sub-{sub}: failed to save vibe mismatch snapshot: {e}") + log.on_warning( + f"sub-{sub}: VIBE grid mismatch {sigs} — awaiting review " + f"(move {snap_name} into accept/ or reject/); skipping subject" + ) + _vibe_skip_subject = True + break check_same_grid(cache, sub, "vibe", vibe_by_key, inphase_key="vibe_part-inphase") # Propagate the check's outcome back to ``fam`` so the downstream unpack # loop below picks up resampled files (or skips removed keys). @@ -834,6 +896,8 @@ def loop_over_repaired_nako( for k, k2 in mapp.items(): if k in fam: subj_dict[k2] = fam[k][0] + if _vibe_skip_subject: + continue vert, spine, poi = get_current_best_T2w_seg(sub) subj_dict["vert"] = vert subj_dict["spine"] = spine @@ -864,6 +928,69 @@ def _is_grid_only_json(path: Path | str) -> bool: _CANONICAL_DONE_ROOT = Path("/DATA/NAS/datasets_processed/NAKO/dataset-nako-canonical/.hardlink_done") +_VIBE_MISMATCH_SNAP_DIR = Path("/DATA/NAS/datasets_processed/NAKO/dataset-nako-canonical/snaps/vibe-missmatch") + + +def _vibe_grid_sigs(vibe_by_key: dict) -> dict[str, str]: + """Return ``{key: grid-signature-string}`` for each present VIBE part. + + Mirrors ``check_same_grid``'s comparison: reads ``bf.get_grid_info()`` and + stringifies it, skipping msk entries and files that have no NIfTI. Two + signatures being unequal is exactly what would cause ``check_same_grid`` + to prompt the user. + """ + sigs: dict[str, str] = {} + for k, bf in vibe_by_key.items(): + if bf is None: + continue + if getattr(bf, "format", None) == "msk" or k.startswith("msk"): + continue + get_nii_file = getattr(bf, "get_nii_file", None) + if callable(get_nii_file) and get_nii_file() is None: + continue + try: + g = bf.get_grid_info() + except Exception: # noqa: BLE001 + continue + if g is None: + continue + sigs[k] = str(g) + return sigs + + +def _save_vibe_shape_mismatch_snapshot(vibe_by_key: dict, out_path: Path) -> None: + """Save a review snapshot of VIBE parts whose grids don't agree. + + Renders one sagittal+coronal frame per present VIBE part (in/out/water/fat), + titled with the part's shape, so a reviewer can eyeball what's off. + """ + from TPTBox.spine.snapshot2D import Snapshot_Frame, create_snapshot + + frames = [] + for k in ("vibe_part-inphase", "vibe_part-outphase", "vibe_part-water", "vibe_part-fat"): + bf = vibe_by_key.get(k) + if bf is None: + continue + try: + shape = tuple(bf.get_grid_info().shape) # type: ignore[union-attr] + except Exception: # noqa: BLE001 + shape = None + frames.append( + Snapshot_Frame( + image=bf, + mode="MRI", + sagittal=True, + coronal=True, + axial=False, + crop_msk=False, + title=f"{k} shape={shape}", + ) + ) + if not frames: + return + out_path.parent.mkdir(parents=True, exist_ok=True) + create_snapshot(snp_path=[out_path], frames=frames) + def _hard_link_done_marker(sub: str) -> Path: return _CANONICAL_DONE_ROOT / f"{sub}.done" @@ -1403,6 +1530,17 @@ def _drain(fs): help="Trace hard_link()'s planned links for one subject; check src exists + same fs as target.", ) parser.add_argument("--workers", type=int, default=None, help="Number of worker processes (default: cpu_count-1).") + parser.add_argument( + "--vibe-mismatch-snaps", + nargs="?", + const=str(_VIBE_MISMATCH_SNAP_DIR), + # default=None, + default=str(_VIBE_MISMATCH_SNAP_DIR), + metavar="DIR", + help="Skip subjects whose VIBE parts have grid mismatches; write a review .jpg to DIR " + f"(defaults to {_VIBE_MISMATCH_SNAP_DIR}) unless one already exists there. " + "Pass '' to disable.", + ) args = parser.parse_args() test = True @@ -1414,5 +1552,10 @@ def _drain(fs): precompute_grid_info_parallel(num_workers=args.workers, test=test) else: corrected = load_corrected_index() - for d in loop_over_repaired_nako(test=test, corrected_index=corrected, skip_subject=is_hard_linked): + for d in loop_over_repaired_nako( + test=test, + corrected_index=corrected, + skip_subject=is_hard_linked, + vibe_mismatch_snap_dir=args.vibe_mismatch_snaps or None, + ): hard_link(d) From afe83299f16d81346b982c60682d738dee3d189a Mon Sep 17 00:00:00 2001 From: Robert Graf Date: Thu, 17 Sep 2026 11:59:56 +0000 Subject: [PATCH 02/29] add versioning --- TPTBox/spine/spinestats/_run_all.py | 93 +++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/TPTBox/spine/spinestats/_run_all.py b/TPTBox/spine/spinestats/_run_all.py index 236d189..bbd642d 100644 --- a/TPTBox/spine/spinestats/_run_all.py +++ b/TPTBox/spine/spinestats/_run_all.py @@ -135,6 +135,9 @@ def _is_cache_valid(json_path: Path, seg_files: list[Path], required_keys: tuple - json fails to parse, - any required main key is missing from the loaded dict. """ + # TODO(hash-invalidation): also compare each entry in data["_provenance"]["inputs"] + # against the current file's sha1 (see _build_provenance) and force recompute on + # mismatch. Deferred while we assume inputs are unchanged. if not json_path.exists(): return False, None json_mtime = json_path.stat().st_mtime @@ -153,6 +156,93 @@ def _is_cache_valid(json_path: Path, seg_files: list[Path], required_keys: tuple return True, data +_PROVENANCE_INPUT_KEYS: tuple[str, ...] = ( + "t2w", + "vibe_part-water", + "vibe_part-fat", + "vibe_part-inphase", + "vibe_part-outphase", + "vibe-water", + "vibe-fat", + "vibe-inphase", + "vibe-outphase", + "vert", + "spine", + "vibeseg100", + "roi", + "fullbody_poi", +) + + +def _resolve_prov_path(v) -> Path | None: + """Best-effort ``file_dict`` value → filesystem Path for provenance recording.""" + if v is None: + return None + if isinstance(v, BIDS_FILE): + nii = v.get_nii_file() + if nii is not None: + return Path(nii) + j = v.file.get("json") if hasattr(v, "file") else None + return Path(j) if j is not None else None + if isinstance(v, (str, Path)): + s = str(v) + return Path(s) if s else None + return None + + +def _file_provenance(path: Path, prior: dict | None = None) -> dict: + """Return provenance dict for ``path``. + + Shape is either ``{"path", "mtime_ns", "sha1"}`` or, when the file is + missing, ``{"path", "missing": True}``. When ``prior`` has the same + ``mtime_ns`` as the current file, its ``sha1`` is reused to avoid + re-hashing — this is what keeps reruns cheap while the "assume inputs + unchanged" mode is in effect. + """ + import hashlib + + p = Path(path) + if not p.exists(): + return {"path": str(p), "missing": True} + st = p.stat() + if isinstance(prior, dict) and prior.get("mtime_ns") == st.st_mtime_ns and isinstance(prior.get("sha1"), str): + return {"path": str(p), "mtime_ns": st.st_mtime_ns, "sha1": prior["sha1"]} + h = hashlib.sha1() + with p.open("rb") as f: + for chunk in iter(lambda: f.read(1 << 20), b""): + h.update(chunk) + return {"path": str(p), "mtime_ns": st.st_mtime_ns, "sha1": h.hexdigest()} + + +def _build_provenance(file_dict: dict, poi_out: Path | str | None, prior: dict | None) -> dict: + """Build the ``_provenance`` block for an aggregated per-subject JSON. + + Records ``path`` / ``mtime_ns`` / ``sha1`` for every known input in + ``file_dict`` plus the POI json at ``poi_out``. Reuses + ``prior["inputs"][k]`` to skip re-hashing files whose mtime is unchanged. + """ + from datetime import datetime, timezone + + prior_inputs = (prior or {}).get("inputs", {}) if isinstance(prior, dict) else {} + inputs: dict[str, dict] = {} + for key in _PROVENANCE_INPUT_KEYS: + if key not in file_dict: + continue + p = _resolve_prov_path(file_dict[key]) + if p is None: + continue + inputs[key] = _file_provenance(p, prior_inputs.get(key)) + if poi_out is not None: + p = Path(poi_out) + if p.exists(): + inputs["poi"] = _file_provenance(p, prior_inputs.get("poi")) + return { + "version": 1, + "written_at": datetime.now(timezone.utc).isoformat(timespec="seconds"), + "inputs": inputs, + } + + def run_all( file_dict, override: bool = False, @@ -318,6 +408,7 @@ def _need(*keys: str, compute: bool) -> bool: if not (need_cobb or need_ivd or need_vert or need_vbq or need_bcs or need_mfi or need_torso or need_curvature or need_pelvic): if _merge_endplate_angles(out, Path(poi_out)) or save: + out["_provenance"] = _build_provenance(file_dict, poi_out, out.get("_provenance")) save_json(final_out, out) return out @@ -451,6 +542,7 @@ def _need(*keys: str, compute: bool) -> bool: logger.print_error() _merge_endplate_angles(out, Path(poi_out)) + out["_provenance"] = _build_provenance(file_dict, poi_out, out.get("_provenance")) logger.on_save("save", final_out.name) save_json(final_out, out) return out @@ -604,6 +696,7 @@ def _rows_from_json(subject_id: str, data: dict) -> tuple[dict[str, Any], list[d "axial_rotation", "endplate_internal_angle", "segmental_endplate_angles", + "_provenance", ) subject_view = {k: v for k, v in data.items() if k not in _PER_SUBJECT_EXCLUDE} _flatten("", subject_view, per_subject) From 946742914d301dda8287329a2c53d9178414d711 Mon Sep 17 00:00:00 2001 From: Robert Graf Date: Thu, 17 Sep 2026 12:56:25 +0000 Subject: [PATCH 03/29] add Verida lordosie version --- TPTBox/spine/spinestats/_load_nako.py | 3 + TPTBox/spine/spinestats/_load_nako_wh.py | 22 ++++ TPTBox/spine/spinestats/_run_all.py | 29 +++++- TPTBox/spine/spinestats/angles.py | 7 ++ TPTBox/spine/spinestats/veridah_angles.py | 118 ++++++++++++++++++++++ 5 files changed, 177 insertions(+), 2 deletions(-) create mode 100644 TPTBox/spine/spinestats/veridah_angles.py diff --git a/TPTBox/spine/spinestats/_load_nako.py b/TPTBox/spine/spinestats/_load_nako.py index b0462ee..e2a98c6 100644 --- a/TPTBox/spine/spinestats/_load_nako.py +++ b/TPTBox/spine/spinestats/_load_nako.py @@ -7,6 +7,7 @@ from TPTBox import Print_Logger from TPTBox.core.bids_files import BIDS_FILE, BIDS_Family, Buffered_BIDS_Global_info from TPTBox.core.nii_wrapper import to_nii +from TPTBox.spine.spinestats._load_nako_wh import get_current_best_VERIDAH as _get_current_best_VERIDAH log = Print_Logger() @@ -320,6 +321,8 @@ def loop_over_repaired_nako( subj_dict["vert"] = vert subj_dict["spine"] = spine subj_dict["poi"] = poi + veridah = _get_current_best_VERIDAH(sub) + subj_dict["veridah"] = str(veridah) if veridah is not None else None yield subj_dict diff --git a/TPTBox/spine/spinestats/_load_nako_wh.py b/TPTBox/spine/spinestats/_load_nako_wh.py index 2d09d7e..6cbb1a5 100644 --- a/TPTBox/spine/spinestats/_load_nako_wh.py +++ b/TPTBox/spine/spinestats/_load_nako_wh.py @@ -523,6 +523,25 @@ def get_corrected_mevibe(fam: BIDS_Family, compute_PDFF=True): # TODO return di return out +def get_current_best_VERIDAH(sub) -> Path | None: + """Return the newest VERIDAH-label JSON for ``sub`` (V2 preferred), or ``None`` if missing. + + The file lives alongside the T2w segmentation under + ``derivatives_spine_inference_162_sacrumfix///T2w/`` and holds the + ``orig_label -> fpath`` remapping used by :func:`compute_veridah_variants`. + """ + sub = str(sub).split("_")[0].replace("sub-", "") + for folder in ("derivatives_spine_inference_162_sacrumfix",): + for suffix in ("VERIDAH-label-V2", "VERIDAH-label"): + p = Path( + f"/DATA/NAS/datasets_processed/NAKO/dataset-nako/{folder}/{sub[:3]}/{sub}/T2w/" + f"sub-{sub}_sequ-stitched_acq-sag_mod-T2w_seg-vert_desc-{suffix}_stat.json" + ) + if p.exists(): + return p + return None + + def get_current_best_T2w_seg(sub, black_list_t2w=None): if black_list_t2w is None: black_list_t2w = [ @@ -902,6 +921,8 @@ def loop_over_repaired_nako( subj_dict["vert"] = vert subj_dict["spine"] = spine subj_dict["poi"] = poi + veridah = get_current_best_VERIDAH(sub) + subj_dict["veridah"] = str(veridah) if veridah is not None else None if corrected_index: _apply_corrections_to_subj_dict(str(sub), subj_dict, corrected_index) verify_missing_images(cache, sub, subj_dict) @@ -1049,6 +1070,7 @@ def hard_link( "vert", # t2w (stiched) "spine", # t2w (stiched) "poi", # t2w (stiched) + "veridah", # VERIDAH enumeration-anomaly relabeling (V2 preferred) ] imgs = [ "pd", diff --git a/TPTBox/spine/spinestats/_run_all.py b/TPTBox/spine/spinestats/_run_all.py index bbd642d..7505902 100644 --- a/TPTBox/spine/spinestats/_run_all.py +++ b/TPTBox/spine/spinestats/_run_all.py @@ -96,6 +96,16 @@ def get_nako_paths(nako_id: str) -> dict[str, Path | None]: fullbody_poi = ( DATASET_ROOT / f"derivatives-fullbody-poi/{pfx}/{sub}/vibe/sub-{sub}_sequ-stitched_acq-ax_part-water_seg-fullbody_poi.json" ) + veridah = None + for suffix in ("VERIDAH-label-V2", "VERIDAH-label"): + p = ( + DATASET_ROOT + / f"derivatives_spine_inference_162_sacrumfix/{pfx}/{sub}/T2w/" + f"sub-{sub}_sequ-stitched_acq-sag_mod-T2w_seg-vert_desc-{suffix}_stat.json" + ) + if p.exists(): + veridah = p + break roi = ( DATASET_ROOT / f"derivatives_Abdominal-Segmentation/{pfx}/{sub}/vibe/sub-{nako_id}_sequ-stitched_acq-ax_mod-vibe_seg-ROI_msk.nii.gz" ) @@ -112,6 +122,7 @@ def get_nako_paths(nako_id: str) -> dict[str, Path | None]: "roi": roi, "vibeseg100": vibeseg100 if vibeseg100.exists() else None, "fullbody_poi": fullbody_poi if fullbody_poi.exists() else None, + "veridah": veridah, "dataset": DATASET_ROOT, } @@ -171,6 +182,7 @@ def _is_cache_valid(json_path: Path, seg_files: list[Path], required_keys: tuple "vibeseg100", "roi", "fullbody_poi", + "veridah", ) @@ -398,7 +410,8 @@ def _need(*keys: str, compute: bool) -> bool: _merge_per_vertebra_metrics(out) save = True #### - need_poi = need_cobb or need_ivd or need_vert or need_curvature + need_veridah = override or "curv_veridah" not in out + need_poi = need_cobb or need_ivd or need_vert or need_curvature or need_veridah need_t2w = need_ivd or need_vert or need_vbq need_vert_nii = need_poi or need_vbq or need_bcs or need_mfi need_spine_nii = need_vert_nii or need_vbq @@ -406,7 +419,9 @@ def _need(*keys: str, compute: bool) -> bool: need_roi = need_mfi or need_torso need_vibe_wf = need_mfi - if not (need_cobb or need_ivd or need_vert or need_vbq or need_bcs or need_mfi or need_torso or need_curvature or need_pelvic): + if not ( + need_cobb or need_ivd or need_vert or need_vbq or need_bcs or need_mfi or need_torso or need_curvature or need_pelvic or need_veridah + ): if _merge_endplate_angles(out, Path(poi_out)) or save: out["_provenance"] = _build_provenance(file_dict, poi_out, out.get("_provenance")) save_json(final_out, out) @@ -495,6 +510,16 @@ def _need(*keys: str, compute: bool) -> bool: logger.on_fail("curvature error caught") logger.print_error() + if need_veridah and poi is not None: + try: + from TPTBox.spine.spinestats.veridah_angles import compute_veridah_variants + + logger.on_debug("veridah variants") + out["curv_veridah"] = compute_veridah_variants(poi, file_dict.get("veridah")) + except Exception: + logger.on_fail("veridah variants error caught") + logger.print_error() + # Merge wedge metrics directly into the per-label vert_geometry / ivd_geometry # entries so they land in per_vertebra.xlsx / per_ivd.xlsx automatically. for geom_key in ("vert_geometry", "ivd_geometry"): diff --git a/TPTBox/spine/spinestats/angles.py b/TPTBox/spine/spinestats/angles.py index 606f56a..122a24e 100644 --- a/TPTBox/spine/spinestats/angles.py +++ b/TPTBox/spine/spinestats/angles.py @@ -686,6 +686,7 @@ def plot_compute_lordosis_and_kyphosis( seg: Image_Reference | None = None, line_len=100, project_2D=True, + curvature_definition=curvature_definition, ) -> tuple[dict[str, float | None], Snapshot_Frame]: """Plots and computes the angles of lordosis and kyphosis on a spinal image. @@ -701,6 +702,12 @@ def plot_compute_lordosis_and_kyphosis( seg (Image_Reference | None): The segmentation image reference. Optional, can be None. line_len (int): The length of the lines representing the vertebrae directions (default is 100). project_2D (bool, optional): If True, the angles are computed in the 2D sagittal projection; otherwise in 3D. Defaults to True. + curvature_definition (dict[str, Def_Curvature], optional): Mapping of output-key name + → :class:`Def_Curvature` describing which vertebra pair defines each angle. + Defaults to the module-level ``curvature_definition`` (cervical_lordosis, + thoracic_kyphosis, lumbar_lordosis). Pass a custom dict to compute a different + set of segmental angles or to override the ``last_thoracic`` / ``last_lumbar`` + resolution — the output dict's keys mirror this mapping's keys. Returns: tuple: A tuple containing: diff --git a/TPTBox/spine/spinestats/veridah_angles.py b/TPTBox/spine/spinestats/veridah_angles.py new file mode 100644 index 0000000..2c40bb5 --- /dev/null +++ b/TPTBox/spine/spinestats/veridah_angles.py @@ -0,0 +1,118 @@ +"""VERIDAH-aware lordosis / kyphosis variants. + +Two extra angle computations on top of the standard ``curv`` block in +:mod:`TPTBox.spine.spinestats.angles`: + +- ``anomaly``: relabel the POI using the VERIDAH ``orig_label -> fpath`` + map (so a supernumerary T13 lands on label 28 instead of shifting the + entire lumbar enumeration down by one) and recompute all three + regional angles (cervical / thoracic / lumbar). +- ``k4 / k5 / k6 / k7``: force the last ``k`` present vertebrae above + the sacrum to be interpreted as L1..Lk, drop everything cranial, and + compute **only** ``lumbar_lordosis``. Independent of VERIDAH. + +Both variants live under the top-level JSON key ``curv_veridah`` (see +``_run_all.py``); the standard ``curv`` output is untouched. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +from TPTBox.core.poi import POI, POI_Descriptor +from TPTBox.core.vert_constants import Vertebra_Instance +from TPTBox.spine.spinestats.angles import compute_lordosis_and_kyphosis + + +def _load_veridah(path: Path) -> dict | None: + """Return the single dict inside a VERIDAH ``_stat.json``, or ``None`` on error.""" + try: + data = json.loads(Path(path).read_text()) + except (OSError, json.JSONDecodeError): + return None + if isinstance(data, list) and data and isinstance(data[0], dict): + return data[0] + if isinstance(data, dict): + return data + return None + + +def _relabel_poi(poi: POI, mapping: dict[int, int]) -> POI: + """Return a copy of ``poi`` whose region ids are re-keyed via ``mapping``. + + Regions absent from ``mapping`` are dropped. Non-vertebra regions + (i.e. those already outside the vertebra label range) fall through + with their original id — but in practice a POI produced by + :func:`calc_poi_from_subreg_vert` only carries vertebra regions. + """ + new_centroids = POI_Descriptor() + for region, subregion, coord in poi.centroids.items(): + if region in mapping: + new_centroids[(mapping[region], subregion)] = coord + return poi.copy(centroids=new_centroids) + + +def _last_k_relabel(poi: POI, k: int) -> POI: + """Keep the ``k`` most caudal non-sacral vertebrae; relabel them L1..Lk. + + Uses :meth:`Vertebra_Instance.order` to walk cranio-caudal, ignores + sacrum members, and keeps the last ``k`` present labels. The bottom + one becomes ``Lk`` (label ``20 + k - 1``), stepping up by one to + ``L1`` (label 20). + """ + order = Vertebra_Instance.order() + sacrum_vals = {v.value for v in Vertebra_Instance.sacrum()} + present = {r for r, _s, _c in poi.centroids.items()} + non_sacral_in_order = [v.value for v in order if v.value in present and v.value not in sacrum_vals] + if len(non_sacral_in_order) < k: + return poi.copy(centroids=POI_Descriptor()) + last_k = non_sacral_in_order[-k:] # cranio → caudal + # last_k[0] should be L1 (20), last_k[-1] should be Lk (20+k-1). + mapping = {orig: 20 + i for i, orig in enumerate(last_k)} + return _relabel_poi(poi, mapping) + + +def _compute_anomaly_variant(poi: POI, veridah_json_path: Path | None) -> dict[str, float | None] | None: + """Recompute the three regional angles after applying the VERIDAH label correction. + + Returns ``None`` when the VERIDAH file is missing or unusable. + """ + if veridah_json_path is None: + return None + veridah = _load_veridah(Path(veridah_json_path)) + if veridah is None: + return None + orig = veridah.get("orig_label") + fpath = veridah.get("fpath") + if not isinstance(orig, list) or not isinstance(fpath, list) or len(orig) != len(fpath): + return None + mapping = {int(o): int(f) for o, f in zip(orig, fpath)} + return compute_lordosis_and_kyphosis(_relabel_poi(poi, mapping)) + + +def _compute_k_variant(poi: POI, k: int) -> dict[str, float | None]: + """Compute lumbar-lordosis only, with the last ``k`` vertebrae treated as L1..Lk.""" + relabeled = _last_k_relabel(poi, k) + if not relabeled.centroids: + return {"lumbar_lordosis": None} + full = compute_lordosis_and_kyphosis(relabeled) + return {"lumbar_lordosis": full.get("lumbar_lordosis")} + + +def compute_veridah_variants(poi: POI, veridah_json_path: Path | str | None) -> dict: + """Return the ``curv_veridah`` block for one subject. + + Shape:: + + { + "anomaly": {"cervical_lordosis", "thoracic_kyphosis", "lumbar_lordosis"} | None, + "k4": {"lumbar_lordosis": …}, + "k5": {...}, "k6": {...}, "k7": {...}, + } + """ + veridah_path = Path(veridah_json_path) if veridah_json_path is not None else None + out: dict = {"anomaly": _compute_anomaly_variant(poi, veridah_path)} + for k in (4, 5, 6, 7): + out[f"k{k}"] = _compute_k_variant(poi, k) + return out From 06a5f1348fadf9d8c056f1b24b88662766a48d8d Mon Sep 17 00:00:00 2001 From: Robert Graf Date: Mon, 21 Sep 2026 12:26:46 +0000 Subject: [PATCH 04/29] add S1 Endplate --- TPTBox/spine/spinestats/poi_fun/endplates.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/TPTBox/spine/spinestats/poi_fun/endplates.py b/TPTBox/spine/spinestats/poi_fun/endplates.py index 48c8730..fde98bb 100644 --- a/TPTBox/spine/spinestats/poi_fun/endplates.py +++ b/TPTBox/spine/spinestats/poi_fun/endplates.py @@ -508,7 +508,7 @@ def endplate_to_super_infer_endplate(vert: NII, spine: NII) -> tuple[NII, NII]: spine = spine.copy() vert_org = vert.copy() vert[vert >= 40] = 0 - vert[spine.extract_label([Location.Vertebra_Corpus, Location.Vertebra_Corpus_border]) != 1] = 0 + vert[spine.extract_label([Location.Vertebra_Corpus, Location.Vertebra_Corpus_border, Vertebra_Instance.S1]) != 1] = 0 vert %= 100 v = vert.infect( spine.extract_label( @@ -522,8 +522,10 @@ def endplate_to_super_infer_endplate(vert: NII, spine: NII) -> tuple[NII, NII]: verbose=False, ) endplate_nii = v * endplate_nii + spine[endplate_nii == Vertebra_Instance.S1.value] = Location.Sacrum_Endplate.value spine[np.logical_and(endplate_nii == vert_org % 100, endplate_nii != 0)] = Location.Vertebral_Body_Endplate_Inferior.value spine[spine == Location.Endplate.value] = Location.Vertebral_Body_Endplate_Superior.value + vert_org[endplate_nii != 0] = v[endplate_nii != 0] + 200 return vert_org, spine From 840e3534d378746b382ee045884d91cfdb726193 Mon Sep 17 00:00:00 2001 From: Robert Graf Date: Mon, 21 Sep 2026 12:27:09 +0000 Subject: [PATCH 05/29] add warning to not use is_ct in "DAExt" --- TPTBox/core/internal/train_nnUnet/prepere_dataset.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/TPTBox/core/internal/train_nnUnet/prepere_dataset.py b/TPTBox/core/internal/train_nnUnet/prepere_dataset.py index bf9a4da..ced21f4 100644 --- a/TPTBox/core/internal/train_nnUnet/prepere_dataset.py +++ b/TPTBox/core/internal/train_nnUnet/prepere_dataset.py @@ -199,6 +199,13 @@ def _validate_config(cfg: DatasetConfig) -> None: "Use 'nnUNetTrainerNoMirroring', a SmaugLab DAExt trainer (mirror is stripped from its " "params JSON), or drop the mirror pairs." ) + # SmaugLab DAExt trainers assume the SmaugLab (non-CT) preprocessing path; combining them with + # is_ct=True mixes CT normalization with augmentations tuned for the SmaugLab regime. + if cfg.is_ct and "DAExt" in cfg.nn_trainer: + logger.on_warning( + f"is_ct=True combined with nn_trainer='{cfg.nn_trainer}': SmaugLab DAExt trainers are " + "not intended to be used with CT normalization. Set is_ct=False or pick a non-DAExt trainer." + ) if errors: logger.on_fail("Config validation failed:") for e in errors: From b8e6f3c525a350c7e1b15987f0d0359735555710 Mon Sep 17 00:00:00 2001 From: Robert Graf Date: Mon, 21 Sep 2026 13:05:25 +0000 Subject: [PATCH 06/29] Auto-transform of direction-vector fields in POI.info Adds a small registration mechanism so producers can attach per-vertebra direction vectors (endplate PCA normals, orientation vectors, ...) or label-keyed scalars (endplate_internal_angle, curvature_*) to poi.info and have them stay aligned with the POI points automatically: - New TPTBox/core/poi_fun/vector_fields.py with the constants POI_INFO_VECTOR_FIELDS_KEY / POI_INFO_LABEL_KEYED_FIELDS_KEY and three helpers (_transform_direction_vectors_inplace, _rotate_direction_vectors_inplace, _remap_vector_field_keys_inplace). - POI.reorient signed-permutes vectors; POI.resample_from_to applies the real R_ref.T @ R_self rotation; POI.rescale is a no-op. - POI_Global.to_cord_system flips vector components on RAS<->LPS. - POI.map_labels remaps top-level keys of both registries. - endplates.py registers angle_superior/inferior_endplate, endplate_internal_angle, and curvature_* when they're produced. - Fix: vertebra_pois_non_centroids.py now (re)runs calc_endplate_points_ when S1 is present but its Sacrum_Endplate- derived superior-endplate landmark is missing, so the sacrum endplate direction actually lands in the buffer. - Angles: _endplate_ap_direction preferred over Vertebra_Direction_ Posterior for MoveTo.TOP/BOTTOM in _get_norm, yielding endplate- aligned Cobb-style lordosis/kyphosis lines. Docs: new section in docs/api/poi_fun.md. Tests: unit_tests/test_poi_vector_fields.py (13 tests). Co-Authored-By: Claude Opus 4.7 --- TPTBox/core/poi.py | 30 ++- TPTBox/core/poi_fun/poi_abstract.py | 11 +- TPTBox/core/poi_fun/poi_global.py | 10 +- TPTBox/core/poi_fun/vector_fields.py | 133 ++++++++++++ .../poi_fun/vertebra_pois_non_centroids.py | 7 +- TPTBox/spine/spinestats/angles.py | 71 ++++++- TPTBox/spine/spinestats/poi_fun/endplates.py | 16 ++ docs/api/poi_fun.md | 63 ++++++ unit_tests/test_poi_vector_fields.py | 200 ++++++++++++++++++ 9 files changed, 527 insertions(+), 14 deletions(-) create mode 100644 TPTBox/core/poi_fun/vector_fields.py create mode 100644 unit_tests/test_poi_vector_fields.py diff --git a/TPTBox/core/poi.py b/TPTBox/core/poi.py index eaf38b1..7365fdf 100755 --- a/TPTBox/core/poi.py +++ b/TPTBox/core/poi.py @@ -45,6 +45,16 @@ ### CURRENT TYPE DEFINITIONS C = TypeVar("C", bound="POI") + +# Direction-vector auto-transform helpers live in poi_fun.vector_fields. +# Re-exported here for backwards compatibility with existing callers. +from TPTBox.core.poi_fun.vector_fields import ( # noqa: E402 + POI_INFO_VECTOR_FIELDS_KEY, + _remap_vector_field_keys_inplace, + _rotate_direction_vectors_inplace, + _transform_direction_vectors_inplace, +) + POI_Reference = Union[ bids_files.BIDS_FILE, Path, @@ -522,8 +532,11 @@ def reorient( self.shape = shape self.origin = origin self.rotation = rotation + _transform_direction_vectors_inplace(self.info, trans) return self - return self.copy(orientation=axcodes_to, centroids=points, zoom=zoom, shape=shape, origin=origin, rotation=rotation) + new_poi = self.copy(orientation=axcodes_to, centroids=points, zoom=zoom, shape=shape, origin=origin, rotation=rotation) + _transform_direction_vectors_inplace(new_poi.info, trans) + return new_poi def reorient_(self, axcodes_to: AX_CODES = ("P", "I", "R"), decimals=3, verbose: logging = False, _shape=None) -> Self: """In-place variant of :meth:`reorient`.""" @@ -532,6 +545,11 @@ def reorient_(self, axcodes_to: AX_CODES = ("P", "I", "R"), decimals=3, verbose: def rescale(self, voxel_spacing: ZOOMS = (1, 1, 1), decimals=ROUNDING_LVL, verbose: logging = True, inplace=False) -> Self: """Rescale the POI coordinates to a new voxel spacing in the current x-y-z-orientation. + Direction-vector fields registered under ``info[POI_INFO_VECTOR_FIELDS_KEY]`` + are left untouched: they are assumed to live in mm-space aligned with the + current voxel axes (see :func:`_transform_direction_vectors_inplace`), and + a spacing change relabels axes without rotating them. + Args: voxel_spacing (tuple[float, float, float], optional): New voxel spacing in millimeters. Defaults to (1, 1, 1). decimals (int, optional): Number of decimal places to round the rescaled coordinates to. Defaults to ROUNDING_LVL. @@ -612,7 +630,11 @@ def to_global(self, itk_coords=False) -> POI_Global: ) def resample_from_to(self, ref: Has_Grid) -> POI: - """Resample this POI to the grid of another image by converting to global and back. + """Resample this POI to the voxel grid of ``ref``. + + Registered direction-vector fields (see :data:`POI_INFO_VECTOR_FIELDS_KEY`) + are rotated by ``R_ref^T @ R_self`` so their components stay aligned with + the target grid's axes. Args: ref (Has_Grid): Target image grid (any object providing affine/orientation info). @@ -620,7 +642,9 @@ def resample_from_to(self, ref: Has_Grid) -> POI: Returns: POI: A new POI in the voxel space of ``ref``. """ - return self.to_global().to_other(ref) + out = self.to_global().to_other(ref) + _rotate_direction_vectors_inplace(out.info, self.rotation, getattr(ref, "rotation", None)) + return out def resample_from_to_(self, ref: Has_Grid) -> Self: """In-place variant of :meth:`resample_from_to`.""" diff --git a/TPTBox/core/poi_fun/poi_abstract.py b/TPTBox/core/poi_fun/poi_abstract.py index d0151a1..9778914 100755 --- a/TPTBox/core/poi_fun/poi_abstract.py +++ b/TPTBox/core/poi_fun/poi_abstract.py @@ -558,12 +558,19 @@ def map_labels( continue poi_new[region:subreg] = value new_values = poi_new + from TPTBox.core.poi_fun.vector_fields import _remap_vector_field_keys_inplace + if new_values is None: - return self if inplace else self.copy() + out = self if inplace else self.copy() + _remap_vector_field_keys_inplace(out.info, label_map_region_) + return out if inplace: self.centroids = new_values + _remap_vector_field_keys_inplace(self.info, label_map_region_) return self - return self.copy(centroids=new_values) + out = self.copy(centroids=new_values) + _remap_vector_field_keys_inplace(out.info, label_map_region_) + return out def map_labels_( self, diff --git a/TPTBox/core/poi_fun/poi_global.py b/TPTBox/core/poi_fun/poi_global.py index 016e1fd..c979751 100755 --- a/TPTBox/core/poi_fun/poi_global.py +++ b/TPTBox/core/poi_fun/poi_global.py @@ -158,7 +158,8 @@ def to_cord_system(self, itk_coords: bool, inplace: bool = False) -> Self: """Convert between ITK (LPS) and NIfTI (RAS) coordinate systems. Flips the first two coordinate axes when switching between the two - systems (LPS ↔ RAS only differs in the sign of x and y). + systems (LPS ↔ RAS only differs in the sign of x and y). Registered + direction-vector fields in ``info`` are flipped along the same axes. Args: itk_coords: ``True`` for ITK/LPS output, ``False`` for NIfTI/RAS. @@ -167,12 +168,19 @@ def to_cord_system(self, itk_coords: bool, inplace: bool = False) -> Self: Returns: ``POI_Global`` in the requested coordinate system. """ + import numpy as np + + from TPTBox.core.poi_fun.vector_fields import _transform_direction_vectors_inplace + out = self if inplace else self.copy() if self.itk_coords == itk_coords: return out out.itk_coords = itk_coords for k1, k2, v in self.items(): out[k1, k2] = (-v[0], -v[1], v[2]) + # x and y are negated, z is kept -- express as a signed-permutation trans. + trans = np.array([[0, -1], [1, -1], [2, 1]], dtype=int) + _transform_direction_vectors_inplace(out.info, trans) return out def to_other(self, msk: Has_Grid, verbose=False) -> poi.POI: diff --git a/TPTBox/core/poi_fun/vector_fields.py b/TPTBox/core/poi_fun/vector_fields.py new file mode 100644 index 0000000..3065704 --- /dev/null +++ b/TPTBox/core/poi_fun/vector_fields.py @@ -0,0 +1,133 @@ +"""Helpers for auto-transforming direction-vector fields stored in ``POI.info``. + +Producers write direction vectors into ``poi.info[""]`` as +``{key: (x, y, z)}`` dicts and register ```` in +``poi.info[POI_INFO_VECTOR_FIELDS_KEY]``. The helpers here are then called by +``POI.reorient`` / ``POI.resample_from_to`` / ``POI_Global.to_cord_system`` / +``POI.map_labels`` to keep the vectors and their keys aligned with the POI +points as the POI is transformed. + +Vectors are assumed to live in mm-space aligned with the POI's current voxel +axes; ``rescale`` is therefore a no-op for them. +""" + +from __future__ import annotations + +import numpy as np + +from TPTBox.core.vert_constants import Vertebra_Instance + +# poi.info key naming the info dicts whose values are direction vectors +# in the POI's current axis frame (i.e. mm-space aligned with poi.orientation). +POI_INFO_VECTOR_FIELDS_KEY = "_vector_fields" + +# poi.info key naming *additional* info dicts (typically scalar-valued, e.g. +# per-vertebra angles or curvature) whose top-level keys are region labels or +# ``Vertebra_Instance`` names. They participate in :func:`map_labels` key +# remapping but not in reorient / resample / to_cord_system vector transforms. +POI_INFO_LABEL_KEYED_FIELDS_KEY = "_label_keyed_fields" + + +def _transform_direction_vectors_inplace(info: dict, trans: np.ndarray) -> None: + """Reorient every registered direction-vector field in ``info`` according to ``trans``. + + ``trans`` is the output of ``nibabel.orientations.ornt_transform`` applied to + the POI's source and target axcodes. Each value stored under a registered + field must be a 3-tuple / 3-list (or ``None`` / non-3-tuple, which is skipped) + interpreted as a unit direction in the POI's current mm-space voxel-axis + frame. Values are updated in place. + """ + field_names = info.get(POI_INFO_VECTOR_FIELDS_KEY) + if not field_names: + return + perm = np.asarray(trans[:, 0], dtype=int) + flip = np.asarray(trans[:, 1], dtype=int) + for name in field_names: + vectors = info.get(name) + if not isinstance(vectors, dict): + continue + for k, v in list(vectors.items()): + if v is None: + continue + try: + v_arr = np.asarray(v, dtype=float) + except (TypeError, ValueError): + continue + if v_arr.shape != (3,): + continue + new_v = np.zeros(3, dtype=float) + new_v[perm] = v_arr * flip + vectors[k] = tuple(float(x) for x in new_v) + + +def _remap_vector_field_keys_inplace(info: dict, region_map: dict) -> None: + """Remap the top-level keys of every registered label-keyed field via ``region_map``. + + Considers fields registered under both :data:`POI_INFO_VECTOR_FIELDS_KEY` + (direction vectors) and :data:`POI_INFO_LABEL_KEYED_FIELDS_KEY` (scalar + per-label fields like ``endplate_internal_angle`` or ``curvature_*``). + Keys may be either integer region labels or ``Vertebra_Instance``-name + strings ("L1", "T12", ...); both are matched against ``region_map`` + (int-keyed). Duplicates after remapping keep the last write. No-op if + no fields are registered or ``region_map`` is empty. + """ + if not region_map: + return + field_names: list[str] = [] + for key in (POI_INFO_VECTOR_FIELDS_KEY, POI_INFO_LABEL_KEYED_FIELDS_KEY): + names = info.get(key) + if names: + field_names.extend(names) + if not field_names: + return + for name in field_names: + vectors = info.get(name) + if not isinstance(vectors, dict): + continue + remapped = {} + for k, v in vectors.items(): + new_k = k + if isinstance(k, int) and k in region_map: + new_k = region_map[k] + elif isinstance(k, str): + try: + label = Vertebra_Instance[k].value + except KeyError: + label = None + if label is not None and label in region_map: + try: + new_k = Vertebra_Instance(region_map[label]).name + except ValueError: + new_k = k + remapped[new_k] = v + vectors.clear() + vectors.update(remapped) + + +def _rotate_direction_vectors_inplace(info: dict, src_rot, tgt_rot) -> None: + """Rotate registered direction-vector fields from ``src_rot`` to ``tgt_rot``. + + Composes ``tgt_rot.T @ src_rot`` and applies it in place. No-op when either + rotation is None or no vector fields are registered. + """ + if src_rot is None or tgt_rot is None: + return + field_names = info.get(POI_INFO_VECTOR_FIELDS_KEY) + if not field_names: + return + R = np.asarray(tgt_rot, dtype=float).T @ np.asarray(src_rot, dtype=float) + for name in field_names: + vectors = info.get(name) + if not isinstance(vectors, dict): + continue + for k, v in list(vectors.items()): + if v is None: + continue + try: + v_arr = np.asarray(v, dtype=float) + except (TypeError, ValueError): + continue + if v_arr.shape != (3,): + continue + new_v = R @ v_arr + vectors[k] = tuple(float(x) for x in new_v) diff --git a/TPTBox/core/poi_fun/vertebra_pois_non_centroids.py b/TPTBox/core/poi_fun/vertebra_pois_non_centroids.py index 8f0583a..beb4d04 100755 --- a/TPTBox/core/poi_fun/vertebra_pois_non_centroids.py +++ b/TPTBox/core/poi_fun/vertebra_pois_non_centroids.py @@ -365,7 +365,12 @@ def compute_non_centroid_pois( # noqa: C901 log.on_text("Compute Vertebra Endplate DIRECTIONS", verbose=verbose) sub_regions = poi.keys_subregion() - if any(a.value not in sub_regions for a in endplate[:2]): # skip if all exists + # Also (re)run when S1 is present in the segmentation but its Sacrum_Endplate-derived + # superior-endplate landmark is not in the POI yet -- otherwise the sacrum block inside + # calc_endplate_points_ never gets a chance to populate poi.info["angle_superior_endplate"]["S1"]. + s1 = Vertebra_Instance.S1.value + sacrum_endplate_missing = s1 in _vert_ids and (s1, Location.Vertebral_Body_Endplate_Superior.value) not in poi + if any(a.value not in sub_regions for a in endplate[:2]) or sacrum_endplate_missing: # skip if all exists poi, *_ = calc_endplate_points_(poi, vert, subreg, _vert_ids=_vert_ids, log=log) ### STEP 1 Vert Direction### if Location.Vertebra_Direction_Inferior in locations: diff --git a/TPTBox/spine/spinestats/angles.py b/TPTBox/spine/spinestats/angles.py index 122a24e..f5e41c6 100644 --- a/TPTBox/spine/spinestats/angles.py +++ b/TPTBox/spine/spinestats/angles.py @@ -66,10 +66,10 @@ def get_location(self, v: Vertebra_Instance | int, poi: POI) -> tuple: subreg = Location.Additional_Vertebral_Body_Middle_Inferior_Median if (v, subreg) in poi: return (v, subreg) - # Test if it has next POINT + # Fall back to averaging v's centroid with the next vertebra's centroid. next_vert = v.get_next_poi(poi) - if next_vert is not None and (next_vert, 50) in poi: - return (v, subreg, next_vert, subreg) + if next_vert is not None and (v, 50) in poi and (next_vert, 50) in poi: + return (v, 50, next_vert, 50) elif self == self.TOP: prev_vert = v.get_previous_poi(poi) # Test IVD @@ -84,9 +84,9 @@ def get_location(self, v: Vertebra_Instance | int, poi: POI) -> tuple: subreg = Location.Additional_Vertebral_Body_Middle_Superior_Median if (v, subreg) in poi: return (v, subreg) - # Test if it has next POINT - if prev_vert is not None and (prev_vert, 50) in poi: - return (v, subreg, prev_vert, subreg) + # Fall back to averaging v's centroid with the previous vertebra's centroid. + if prev_vert is not None and (v, 50) in poi and (prev_vert, 50) in poi: + return (v, 50, prev_vert, 50) return (v, 50) def get_point(self, v: Vertebra_Instance | int, poi: POI) -> np.ndarray: @@ -395,10 +395,67 @@ def compute_lordosis_and_kyphosis(poi: POI, project_2D=True) -> dict[str, float return out +def _endplate_ap_direction(poi: POI, vert: Vertebra_Instance, mv: MoveTo) -> np.ndarray | None: + """Return an endplate-plane A/P direction from the buffered endplate POIs, or None. + + Uses the *relevant* endplate landmark for ``mv`` (``Vertebral_Body_Endplate_Superior`` + for :attr:`MoveTo.TOP`, ``Vertebral_Body_Endplate_Inferior`` for :attr:`MoveTo.BOTTOM`) + together with ``Vertebra_Direction_Right`` and ``Vertebra_Corpus`` to build a P/A + vector that lies in the specific endplate plane, rather than the averaged + vertebral body plane implied by ``Vertebra_Direction_Posterior``. + + The sign convention matches :func:`_get_norm` for ``Location.Vertebra_Direction_Posterior`` + with ``inv=1`` — the returned vector points anteriorly, so callers can multiply + by ``inv`` unchanged. + """ + if mv == MoveTo.TOP: + endplate_loc = Location.Vertebral_Body_Endplate_Superior + elif mv == MoveTo.BOTTOM: + endplate_loc = Location.Vertebral_Body_Endplate_Inferior + else: + return None + if (vert, 50) not in poi or (vert, endplate_loc) not in poi or (vert, Location.Vertebra_Direction_Right) not in poi: + return None + corpus = np.array(poi[vert, 50], dtype=float) + ep = np.array(poi[vert, endplate_loc], dtype=float) + r_pt = np.array(poi[vert, Location.Vertebra_Direction_Right], dtype=float) + n = ep - corpus + if mv == MoveTo.BOTTOM: + n = -n # flip inferior endplate so both cases point superior + n_norm = np.linalg.norm(n) + r_vec = r_pt - corpus + r_norm = np.linalg.norm(r_vec) + if n_norm < 1e-8 or r_norm < 1e-8: + return None + n /= n_norm + r_vec /= r_norm + # cross(right, superior-pointing) lies in the endplate plane and points posterior; + # negate to match _get_norm's default sign (anterior for inv=1). + p = -np.cross(r_vec, n) + p_norm = np.linalg.norm(p) + if p_norm < 1e-8: + return None + return p / p_norm + + def _get_norm(poi: POI, id1: int | Vertebra_Instance, mv: MoveTo, location: Location, inv: int = 1) -> np.ndarray | None: # noqa: ARG001 - """Return the normalised direction vector from a location POI to the vertebra centroid.""" + """Return the normalised direction vector from a location POI to the vertebra centroid. + + When ``location`` is :attr:`Location.Vertebra_Direction_Posterior` and ``mv`` targets + an endplate (:attr:`MoveTo.TOP` / :attr:`MoveTo.BOTTOM`), the buffered per-endplate + landmark (``Vertebral_Body_Endplate_Superior`` / ``_Inferior``) is preferred over + the averaged vertebral-body posterior direction — this yields the classical + endplate-line orientation used in Cobb-style lordosis/kyphosis measurements. + The endplate direction is used only when both the relevant endplate point and + ``Vertebra_Direction_Right`` are present in ``poi`` for that vertebra; otherwise + the code falls back to the WK-based averaged direction below. + """ if isinstance(id1, int): id1 = Vertebra_Instance(id1) + if location == Location.Vertebra_Direction_Posterior and mv in (MoveTo.TOP, MoveTo.BOTTOM): + ep_norm = _endplate_ap_direction(poi, id1, mv) + if ep_norm is not None: + return ep_norm * inv subreg = 50 if location in [Location.Vertebra_Disc_Inferior, Location.Vertebra_Disc_Superior]: subreg = 100 diff --git a/TPTBox/spine/spinestats/poi_fun/endplates.py b/TPTBox/spine/spinestats/poi_fun/endplates.py index fde98bb..b14ec2b 100644 --- a/TPTBox/spine/spinestats/poi_fun/endplates.py +++ b/TPTBox/spine/spinestats/poi_fun/endplates.py @@ -399,10 +399,26 @@ def calc_endplate_points_( poi.info.setdefault("angle_superior_endplate", {}) poi.info.setdefault("angle_inferior_endplate", {}) + # Register direction-vector fields (auto-transformed by reorient / resample / + # to_cord_system) and additional per-label scalar fields (whose keys are + # remapped by map_labels). + from TPTBox.core.poi_fun.vector_fields import POI_INFO_LABEL_KEYED_FIELDS_KEY, POI_INFO_VECTOR_FIELDS_KEY + + _vec_fields = poi.info.setdefault(POI_INFO_VECTOR_FIELDS_KEY, []) + for _f in ("angle_superior_endplate", "angle_inferior_endplate"): + if _f not in _vec_fields: + _vec_fields.append(_f) + _lbl_fields = poi.info.setdefault(POI_INFO_LABEL_KEYED_FIELDS_KEY, []) + for _f in ("endplate_internal_angle",): + if _f not in _lbl_fields: + _lbl_fields.append(_f) if compute_curvature: poi.info.setdefault("curvature_superior_endplate", {}) poi.info.setdefault("curvature_inferior_endplate", {}) + for _f in ("curvature_superior_endplate", "curvature_inferior_endplate"): + if _f not in _lbl_fields: + _lbl_fields.append(_f) # Collect normals per vertebra so we can compute the inter-endplate # angle once both superior and inferior have been processed. diff --git a/docs/api/poi_fun.md b/docs/api/poi_fun.md index d1050ca..b70a788 100644 --- a/docs/api/poi_fun.md +++ b/docs/api/poi_fun.md @@ -44,3 +44,66 @@ from segmentation volumes. options: show_source: true filters: ["!^_"] + +## Direction-Vector Fields in `POI.info` + +`POI.info` can hold *auxiliary* per-vertebra data alongside the main POI points — +direction vectors (e.g. endplate PCA normals) and scalar metadata (e.g. per-vertebra +wedge angles or curvature). Producers register field names under two well-known +keys in `poi.info`, and `POI.reorient` / `POI.resample_from_to` / +`POI_Global.to_cord_system` / `POI.map_labels` then keep those fields aligned +with the POI points automatically. + +**Two registries:** + +- `poi.info["_vector_fields"]` (`POI_INFO_VECTOR_FIELDS_KEY`) — a list of field + names whose values are `{key: (x, y, z)}` dicts holding **unit direction + vectors in mm-space aligned with `poi.orientation`**. These fields are + auto-transformed by `reorient`, `resample_from_to`, and + `POI_Global.to_cord_system`, and their keys are also remapped by `map_labels`. + `rescale` is a no-op for them. +- `poi.info["_label_keyed_fields"]` (`POI_INFO_LABEL_KEYED_FIELDS_KEY`) — a + list of field names whose values are `{key: }` dicts (typically + scalars). Only their **keys** are remapped by `map_labels`; no + orientation-based transform is applied. + +Keys in either type of field may be integer region labels (`20`) or +`Vertebra_Instance`-name strings (`"L1"`); both forms are supported. + +**Producer pattern:** + +```python +from TPTBox.core.poi_fun.vector_fields import ( + POI_INFO_VECTOR_FIELDS_KEY, + POI_INFO_LABEL_KEYED_FIELDS_KEY, +) + +# Direction vectors (auto-rotated on reorient / resample): +vec_fields = poi.info.setdefault(POI_INFO_VECTOR_FIELDS_KEY, []) +if "my_vector_field" not in vec_fields: + vec_fields.append("my_vector_field") +poi.info["my_vector_field"] = {"L1": (0.09, -0.99, -0.02), ...} + +# Scalar per-vertebra metadata (key-remapped by map_labels only): +lbl_fields = poi.info.setdefault(POI_INFO_LABEL_KEYED_FIELDS_KEY, []) +if "my_scalar_field" not in lbl_fields: + lbl_fields.append("my_scalar_field") +poi.info["my_scalar_field"] = {"L1": 3.14, ...} +``` + +**Caveats:** + +- `resample_from_to` uses the real `R_ref.T @ R_self` rotation of the two + affines (safe for skewed grids); axcode-only heuristics are deliberately + avoided. +- `map_labels` handles duplicates with a *last-write-wins* policy after remap. +- `map_labels`' `label_map_full` (mapping `(region, subreg)` tuples) does NOT + trigger the key-remap of these fields — only `label_map_region` does. + +::: TPTBox.core.poi_fun.vector_fields + options: + show_source: true + filters: ["!^_"] + members: + - POI_INFO_VECTOR_FIELDS_KEY + - POI_INFO_LABEL_KEYED_FIELDS_KEY diff --git a/unit_tests/test_poi_vector_fields.py b/unit_tests/test_poi_vector_fields.py new file mode 100644 index 0000000..46cb90c --- /dev/null +++ b/unit_tests/test_poi_vector_fields.py @@ -0,0 +1,200 @@ +"""Unit tests for auto-transform of direction-vector and label-keyed fields in ``POI.info``. + +Covers: +- ``POI.reorient`` — signed permutation of direction vectors. +- ``POI.resample_from_to`` — full rotation (``R_ref.T @ R_self``). +- ``POI.rescale`` — no-op for mm-space vectors. +- ``POI_Global.to_cord_system`` — RAS <-> LPS axis flips. +- ``POI.map_labels`` — key remap for both vector and label-keyed fields. +""" + +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +file = Path(__file__).resolve() +sys.path.append(str(file.parents[2])) + +import numpy as np # noqa: E402 + +from TPTBox.core.poi import POI # noqa: E402 +from TPTBox.core.poi_fun.vector_fields import ( # noqa: E402 + POI_INFO_LABEL_KEYED_FIELDS_KEY, + POI_INFO_VECTOR_FIELDS_KEY, +) + + +def _make_poi(orientation=("P", "I", "R"), zoom=(1.0, 1.0, 1.0)) -> POI: + """Build a small POI with identity rotation and one L1 corpus point.""" + centroids: dict[int, dict[int, tuple[float, float, float]]] = {20: {50: (1.0, 2.0, 3.0)}} + return POI( + centroids, + orientation=orientation, + zoom=zoom, + shape=(100, 100, 100), + origin=(0.0, 0.0, 0.0), + rotation=np.eye(3), + ) + + +def _register(poi: POI, vec_fields=(), lbl_fields=()) -> None: + if vec_fields: + poi.info.setdefault(POI_INFO_VECTOR_FIELDS_KEY, []).extend(vec_fields) + if lbl_fields: + poi.info.setdefault(POI_INFO_LABEL_KEYED_FIELDS_KEY, []).extend(lbl_fields) + + +class Test_Vector_Field_Reorient(unittest.TestCase): + def test_pir_to_las_signed_permutation(self): + # In PIR, +x=P, +y=I, +z=R. In LAS, +x=L=-R, +y=A=-P, +z=S=-I. + # v_PIR = (P=0.020, I=-0.092, R=0.996). Same physical direction in LAS is: + # L=-R=-0.996, A=-P=-0.020, S=-I=0.092 -> (-0.996, -0.020, 0.092). + poi = _make_poi(orientation=("P", "I", "R")) + _register(poi, vec_fields=["v"]) + v_in = np.array([0.02, -0.09, 0.996]) + v_in = v_in / np.linalg.norm(v_in) + poi.info["v"] = {"L1": tuple(v_in)} + out = poi.reorient(("L", "A", "S")) + got = np.asarray(out.info["v"]["L1"]) + expected = np.array([-v_in[2], -v_in[0], -v_in[1]]) + np.testing.assert_allclose(got, expected, atol=1e-9) + # Norm is preserved by a signed permutation. + self.assertAlmostEqual(float(np.linalg.norm(got)), 1.0, places=6) + # Full round-trip: PIR -> LAS -> PIR equals original. + back = out.reorient(("P", "I", "R")) + np.testing.assert_allclose(back.info["v"]["L1"], v_in, atol=1e-9) + + def test_unregistered_field_untouched(self): + poi = _make_poi() + # not registered + poi.info["orphan_vec"] = {"L1": (1.0, 0.0, 0.0)} + out = poi.reorient(("L", "A", "S")) + self.assertEqual(tuple(out.info["orphan_vec"]["L1"]), (1.0, 0.0, 0.0)) + + def test_none_or_malformed_values_skipped(self): + poi = _make_poi() + _register(poi, vec_fields=["v"]) + poi.info["v"] = {"L1": (0.0, 1.0, 0.0), "L2": None, "L3": (1.0, 2.0)} + out = poi.reorient(("L", "A", "S")) + self.assertIsNone(out.info["v"]["L2"]) + # 2-tuple stays unchanged, since shape mismatch is silently skipped + self.assertEqual(tuple(out.info["v"]["L3"]), (1.0, 2.0)) + # 3-tuple got transformed + self.assertEqual(len(out.info["v"]["L1"]), 3) + + +class Test_Vector_Field_Rescale(unittest.TestCase): + def test_rescale_leaves_vectors_untouched(self): + poi = _make_poi(zoom=(0.5, 0.5, 3.0)) + _register(poi, vec_fields=["v"]) + v = (0.1, -0.9, 0.05) + poi.info["v"] = {"L1": v} + out = poi.rescale((1.0, 1.0, 1.0)) + np.testing.assert_allclose(out.info["v"]["L1"], v, atol=1e-12) + + +class Test_Vector_Field_ResampleFromTo(unittest.TestCase): + def test_identity_grid_is_noop(self): + poi = _make_poi() + _register(poi, vec_fields=["v"]) + v = (0.09, -0.99, -0.02) + poi.info["v"] = {"L1": v} + # ref = a copy of the same POI -> same rotation -> R_ref.T @ R_self = I + ref = poi.copy() + out = poi.resample_from_to(ref) + np.testing.assert_allclose(out.info["v"]["L1"], v, atol=1e-6) + + def test_rotated_ref_applies_rotation(self): + poi = _make_poi() + _register(poi, vec_fields=["v"]) + v = (1.0, 0.0, 0.0) + poi.info["v"] = {"L1": v} + # Target POI has a 90-degree z-rotation + R_z90 = np.array([[0.0, -1.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]]) + ref = poi.copy() + ref.rotation = R_z90 + out = poi.resample_from_to(ref) + # v_target = R_ref.T @ R_self @ v = R_z90.T @ I @ [1,0,0] = R_z90.T @ [1,0,0] = [0, -1, 0] + np.testing.assert_allclose(out.info["v"]["L1"], (0.0, -1.0, 0.0), atol=1e-9) + + +class Test_ToCordSystem(unittest.TestCase): + def test_ras_to_lps_flips_xy_only(self): + poi = _make_poi() + _register(poi, vec_fields=["v"]) + poi.info["v"] = {"L1": (0.1, 0.2, 0.3)} + g = poi.to_global() # RAS by default + self.assertFalse(g.itk_coords) + self.assertEqual(tuple(g.info["v"]["L1"]), (0.1, 0.2, 0.3)) + g_itk = g.to_cord_system(itk_coords=True) + np.testing.assert_allclose(g_itk.info["v"]["L1"], (-0.1, -0.2, 0.3), atol=1e-12) + # roundtrip + g_back = g_itk.to_cord_system(itk_coords=False) + np.testing.assert_allclose(g_back.info["v"]["L1"], (0.1, 0.2, 0.3), atol=1e-12) + + +class Test_MapLabels(unittest.TestCase): + def test_vector_field_keys_remapped(self): + poi = _make_poi() + _register(poi, vec_fields=["v"]) + v = (0.1, -0.9, 0.4) + poi.info["v"] = {"L1": v} + out = poi.map_labels(label_map_region={20: 2}) # L1 -> C2 + self.assertNotIn("L1", out.info["v"]) + self.assertIn("C2", out.info["v"]) + np.testing.assert_allclose(out.info["v"]["C2"], v, atol=1e-12) + + def test_label_keyed_scalar_field_remapped(self): + poi = _make_poi() + _register(poi, lbl_fields=["endplate_internal_angle"]) + poi.info["endplate_internal_angle"] = {"L1": 3.14} + out = poi.map_labels(label_map_region={20: 2}) + self.assertNotIn("L1", out.info["endplate_internal_angle"]) + self.assertAlmostEqual(out.info["endplate_internal_angle"]["C2"], 3.14) + + def test_int_keys_also_remapped(self): + poi = _make_poi() + _register(poi, vec_fields=["v"]) + poi.info["v"] = {20: (0.0, 1.0, 0.0), 21: (1.0, 0.0, 0.0)} + out = poi.map_labels(label_map_region={20: 2}) + self.assertNotIn(20, out.info["v"]) + self.assertIn(2, out.info["v"]) + # unaffected key + self.assertIn(21, out.info["v"]) + np.testing.assert_allclose(out.info["v"][2], (0.0, 1.0, 0.0), atol=1e-12) + + def test_unknown_string_key_kept(self): + poi = _make_poi() + _register(poi, lbl_fields=["s"]) + poi.info["s"] = {"NOT_A_VERT": 42.0, "L1": 1.0} + out = poi.map_labels(label_map_region={20: 2}) + self.assertIn("NOT_A_VERT", out.info["s"]) + self.assertNotIn("L1", out.info["s"]) + self.assertIn("C2", out.info["s"]) + + def test_empty_map_is_noop(self): + poi = _make_poi() + _register(poi, vec_fields=["v"]) + v = (0.1, -0.9, 0.4) + poi.info["v"] = {"L1": v} + out = poi.map_labels(label_map_region={}) + self.assertEqual(tuple(out.info["v"]["L1"]), v) + + +class Test_Composition(unittest.TestCase): + def test_reorient_and_map_labels_commute(self): + # For a vector-transform + key-remap, order shouldn't matter. + poi_a = _make_poi() + poi_b = _make_poi() + for poi in (poi_a, poi_b): + _register(poi, vec_fields=["v"]) + poi.info["v"] = {"L1": (0.02, -0.09, 0.996)} + out_a = poi_a.reorient(("L", "A", "S")).map_labels(label_map_region={20: 2}) + out_b = poi_b.map_labels(label_map_region={20: 2}).reorient(("L", "A", "S")) + np.testing.assert_allclose(out_a.info["v"]["C2"], out_b.info["v"]["C2"], atol=1e-9) + + +if __name__ == "__main__": + unittest.main() From 431a3203f7e5328c3830cfd20e2c5f2e53f34c74 Mon Sep 17 00:00:00 2001 From: Robert Graf Date: Mon, 21 Sep 2026 14:57:17 +0000 Subject: [PATCH 07/29] claude_remapping --- TPTBox/core/poi_fun/poi_abstract.py | 20 +-- TPTBox/core/poi_fun/vector_fields.py | 131 +++++++++-------- TPTBox/core/poi_fun/vertebra_direction.py | 27 +++- TPTBox/spine/spinestats/_load_nako_wh.py | 11 +- TPTBox/spine/spinestats/_run_all.py | 13 +- TPTBox/spine/spinestats/veridah_angles.py | 102 +++++++++++-- docs/api/poi_fun.md | 63 ++++++-- unit_tests/test_poi_vector_fields.py | 167 ++++++++++++++++++++++ 8 files changed, 433 insertions(+), 101 deletions(-) diff --git a/TPTBox/core/poi_fun/poi_abstract.py b/TPTBox/core/poi_fun/poi_abstract.py index fbc8cff..6139891 100755 --- a/TPTBox/core/poi_fun/poi_abstract.py +++ b/TPTBox/core/poi_fun/poi_abstract.py @@ -636,23 +636,17 @@ def map_labels( continue poi_new[region:subreg] = value new_values = poi_new - from TPTBox.core.poi_fun.vector_fields import _remap_label_name_inplace, _remap_vector_field_keys_inplace + if new_values is None: + # No mapping happened (all input maps were empty) -> info stays as is. + return self if inplace else self.copy() - def _sync_info(target_info: dict) -> None: - _remap_vector_field_keys_inplace(target_info, label_map_region_) - _remap_label_name_inplace(target_info, label_map_region_, label_map_subregion_) + from TPTBox.core.poi_fun.vector_fields import _remap_vector_field_keys_inplace - if new_values is None: - out = self if inplace else self.copy() - _sync_info(out.info) - return out + target = self if inplace else self.copy(centroids=new_values) if inplace: self.centroids = new_values - _sync_info(self.info) - return self - out = self.copy(centroids=new_values) - _sync_info(out.info) - return out + _remap_vector_field_keys_inplace(target.info, label_map_region_, label_map_subregion_) + return target def map_labels_( self, diff --git a/TPTBox/core/poi_fun/vector_fields.py b/TPTBox/core/poi_fun/vector_fields.py index f21b9d0..078cc69 100644 --- a/TPTBox/core/poi_fun/vector_fields.py +++ b/TPTBox/core/poi_fun/vector_fields.py @@ -60,86 +60,99 @@ def _transform_direction_vectors_inplace(info: dict, trans: np.ndarray) -> None: vectors[k] = tuple(float(x) for x in new_v) -def _remap_vector_field_keys_inplace(info: dict, region_map: dict) -> None: - """Remap the top-level keys of every registered label-keyed field via ``region_map``. +def _map_one_key(k, key_map: dict | None): + """Map a single dict key via ``key_map``. + + ``int`` keys are looked up directly. ``str`` keys resolve through + ``Vertebra_Instance`` (name -> value) and, on hit, the mapped value is + turned back into the corresponding ``Vertebra_Instance`` name (or kept + as ``str(id)`` when the target isn't a known instance). + """ + if not key_map: + return k + if isinstance(k, int) and k in key_map: + return key_map[k] + if isinstance(k, str): + try: + label = Vertebra_Instance[k].value + except KeyError: + return k + if label in key_map: + try: + return Vertebra_Instance(key_map[label]).name + except ValueError: + return str(key_map[label]) + return k + + +def _remap_vector_field_keys_inplace(info: dict, region_map: dict | None, subregion_map: dict | None = None) -> None: + """Remap keys of every registered label-keyed field in ``info``. Considers fields registered under both :data:`POI_INFO_VECTOR_FIELDS_KEY` (direction vectors) and :data:`POI_INFO_LABEL_KEYED_FIELDS_KEY` (scalar - per-label fields like ``endplate_internal_angle`` or ``curvature_*``). - Keys may be either integer region labels or ``Vertebra_Instance``-name - strings ("L1", "T12", ...); both are matched against ``region_map`` - (int-keyed). Duplicates after remapping keep the last write. No-op if - no fields are registered or ``region_map`` is empty. + per-label fields), plus ``label_name`` (always handled; migrated to the + nested form on the fly). + + Behaviour is dispatched by *value type*: + + - **Flat fields** (values are tuples / scalars): only outer keys are + remapped via ``region_map``. + - **Nested fields** (values are dicts, e.g. ``label_name`` / + ``{region: {subregion: name, "name": group}}``): outer keys are + remapped via ``region_map``, inner keys via ``subregion_map``, and the + special ``"name"`` group entry is preserved. When two source regions + collide onto one target, their inner dicts merge (last-write-wins on + overlapping keys). + + Keys may be integer labels or ``Vertebra_Instance``-name strings; both + are matched against the int-keyed maps. No-op if there is nothing to do. """ - if not region_map: + from TPTBox.core.poi_fun.poi_abstract import LABEL_NAME, _GROUP_NAME_KEY, label_name_dict + + if not region_map and not subregion_map: return field_names: list[str] = [] for key in (POI_INFO_VECTOR_FIELDS_KEY, POI_INFO_LABEL_KEYED_FIELDS_KEY): names = info.get(key) if names: field_names.extend(names) + # label_name is always handled -- ensure the nested-form migration runs, then include it. + if LABEL_NAME in info and LABEL_NAME not in field_names: + label_name_dict(info) + field_names.append(LABEL_NAME) if not field_names: return for name in field_names: vectors = info.get(name) if not isinstance(vectors, dict): continue - remapped = {} + remapped: dict = {} for k, v in vectors.items(): - new_k = k - if isinstance(k, int) and k in region_map: - new_k = region_map[k] - elif isinstance(k, str): - try: - label = Vertebra_Instance[k].value - except KeyError: - label = None - if label is not None and label in region_map: - try: - new_k = Vertebra_Instance(region_map[label]).name - except ValueError: - new_k = k - remapped[new_k] = v + new_k = _map_one_key(k, region_map) + if new_k is None: + continue # drop entries whose region is mapped to None + new_v = v + # Nested field: recurse into inner dict. + if isinstance(v, dict): + new_inner: dict = {} + for ik, iv in v.items(): + if ik == _GROUP_NAME_KEY: + new_inner[_GROUP_NAME_KEY] = iv + continue + new_ik = _map_one_key(ik, subregion_map) + if new_ik is None: + continue # drop entries whose subregion is mapped to None + new_inner[new_ik] = iv + new_v = new_inner + # Merge on outer-key collision when both values are dicts (label_name-style). + if new_k in remapped and isinstance(remapped[new_k], dict) and isinstance(new_v, dict): + remapped[new_k].update(new_v) + else: + remapped[new_k] = new_v vectors.clear() vectors.update(remapped) -def _remap_label_name_inplace(info: dict, region_map: dict | None, subregion_map: dict | None) -> None: - """Remap the region + subregion keys of ``info["label_name"]`` in place. - - ``label_name`` uses the nested format - ``{region:int -> {subregion:int -> name:str, "name": group_name:str}}`` - (see :func:`normalize_label_name`). This helper remaps top-level region keys - via ``region_map`` and, for each inner dict, remaps subregion keys via - ``subregion_map``. The special ``"name"`` group-name entry is preserved. - No-op if the field is absent or both maps are empty. - """ - from TPTBox.core.poi_fun.poi_abstract import LABEL_NAME, _GROUP_NAME_KEY, label_name_dict - - if not region_map and not subregion_map: - return - if LABEL_NAME not in info: - return - ln = label_name_dict(info) # ensures nested form - remapped: dict[int, dict] = {} - for region, inner in ln.items(): - new_region = region_map[region] if region_map and region in region_map else region - new_inner: dict = {} - for k, v in inner.items(): - if k == _GROUP_NAME_KEY: - new_inner[_GROUP_NAME_KEY] = v - elif subregion_map and k in subregion_map: - new_inner[subregion_map[k]] = v - else: - new_inner[k] = v - # merge if two source regions collide onto one target (last-write-wins on inner keys). - if new_region in remapped: - remapped[new_region].update(new_inner) - else: - remapped[new_region] = new_inner - info[LABEL_NAME] = remapped - - def _rotate_direction_vectors_inplace(info: dict, src_rot, tgt_rot) -> None: """Rotate registered direction-vector fields from ``src_rot`` to ``tgt_rot``. diff --git a/TPTBox/core/poi_fun/vertebra_direction.py b/TPTBox/core/poi_fun/vertebra_direction.py index 7a51081..f2d3b04 100644 --- a/TPTBox/core/poi_fun/vertebra_direction.py +++ b/TPTBox/core/poi_fun/vertebra_direction.py @@ -147,15 +147,26 @@ def calc_orientation_of_vertebra_PIR( if last_vert == 20: last_vert = None break - max_vert_key = max(vert_keys) + # Only pre-sacral vertebrae influence the "next free label" search — S1 (and higher) + # may be present in poi_iso via `calc_endplate_points_` (Sacrum_Endplate landmark), + # but their label must NOT be counted here, otherwise spline anchors would land at + # labels 27/28 and get promoted to phantom vertebrae by the downstream + # `poi_iso.extract_subregion(source_subreg_point_id)` loop (which would then write + # spurious direction POIs at those labels and fool `_get_last_thoracic` into + # returning T13 / COCC). + presacral_keys = [k for k in vert_keys if k < Vertebra_Instance.S1.value] + max_vert_key = max(presacral_keys) if presacral_keys else max(vert_keys) + anchor_labels: set[int] = set() if (Vertebra_Instance.S1.value, Location.Vertebral_Body_Endplate_Superior.value) in poi_iso: poi_iso[max_vert_key + 1, spline_subreg_point_id] = poi_iso[ (Vertebra_Instance.S1.value, Location.Vertebral_Body_Endplate_Superior.value) ] max_vert_key += 1 + anchor_labels.add(max_vert_key) if last_vert is not None and (last_vert, Location.Vertebral_Body_Endplate_Inferior.value) in poi_iso: poi_iso[max_vert_key + 1, spline_subreg_point_id] = poi_iso[(last_vert, Location.Vertebral_Body_Endplate_Inferior.value)] max_vert_key += 1 + anchor_labels.add(max_vert_key) ##### # spline: body_spline, body_spline_der = poi_iso.fit_spline(location=spline_subreg_point_id, vertebra=True) @@ -184,6 +195,10 @@ def calc_orientation_of_vertebra_PIR( fill_back = out.copy() if do_fill_back else None # Draw a plain with the up_vector an cut it with intersection_target for reg_label, _, cords in poi_iso.extract_subregion(source_subreg_point_id).items(): + # Spline-anchor labels (added above to influence the spline fit) must not be + # processed here — they'd otherwise get written back to `ret` as pseudo-vertebrae. + if reg_label in anchor_labels: + continue # calculate_normal_vector if reg_label in down_vector: normal_vector_down = down_vector[reg_label] @@ -222,7 +237,15 @@ def calc_orientation_of_vertebra_PIR( arr = subreg_sar.set_array(fill_back).reorient(poi.orientation).rescale_(poi.zoom).get_array() fill_back_nii.set_array_(arr) - ret = calc_centroids(subreg_iso.set_array(out), second_stage=subreg_id, extend_to=poi_iso.copy(), inplace=True) + # Strip spline anchors from the working POI before extending: they were only + # needed to influence `fit_spline` above, and must not leak into the returned POI + # as pseudo-vertebrae (they'd otherwise show up as phantom regions labelled 25/26 + # / 27/28, fooling `_get_last_thoracic` etc. into treating them as real). + poi_iso_clean = poi_iso.copy() + for _a in anchor_labels: + if (_a, spline_subreg_point_id.value) in poi_iso_clean: + del poi_iso_clean.centroids[_a, spline_subreg_point_id.value] + ret = calc_centroids(subreg_iso.set_array(out), second_stage=subreg_id, extend_to=poi_iso_clean, inplace=True) poi._vert_orientation_pir = {} if save_normals_in_info: diff --git a/TPTBox/spine/spinestats/_load_nako_wh.py b/TPTBox/spine/spinestats/_load_nako_wh.py index 6cbb1a5..fa18c64 100644 --- a/TPTBox/spine/spinestats/_load_nako_wh.py +++ b/TPTBox/spine/spinestats/_load_nako_wh.py @@ -154,6 +154,14 @@ def resolve_pick( # (``main-conflict:pd``, ``mevibe-conflict:pd``, ``vibe-conflict:pd``, …). idx = max(range(len(labels)), key=labels.__getitem__) return candidates[idx] # transient: do not save + if key == "main:T2haste" or key.endswith(":T2haste"): + # auto-accept: when duplicates differ only in the presence of a ``sequ`` entity + # (one raw file without sequ, one or more with an explicit sequ number for the same + # acquisition), prefer the sequ-numbered candidate. Skips the prompt only when this + # split is unambiguous (exactly one sequ'd candidate); otherwise falls through. + with_sequ = [c for c in candidates if getattr(c, "get", lambda *_: None)("sequ", None) is not None] + if len(with_sequ) == 1 and len(with_sequ) < len(candidates): + return with_sequ[0] # transient: do not save choice, reason = _prompt_choice(sub, key, question, labels, allow_discard=allow_discard) if choice == "__skip__": return candidates[0] if candidates else None # transient: do not save @@ -1096,6 +1104,7 @@ def hard_link( info = {"run": None} if isinstance(bf, str): bf = BIDS_FILE(bf, dataset) + bf.info.pop("run", None) assert len([k for k, v in bf.loop_keys() if k not in allowed_keys]) == 0, ( [k for k, v in bf.loop_keys() if k not in allowed_keys], bf, @@ -1564,7 +1573,7 @@ def _drain(fs): "Pass '' to disable.", ) args = parser.parse_args() - test = True + test = False if args.build_corrected_index: build_corrected_index() diff --git a/TPTBox/spine/spinestats/_run_all.py b/TPTBox/spine/spinestats/_run_all.py index 7505902..8e7127b 100644 --- a/TPTBox/spine/spinestats/_run_all.py +++ b/TPTBox/spine/spinestats/_run_all.py @@ -512,10 +512,21 @@ def _need(*keys: str, compute: bool) -> bool: if need_veridah and poi is not None: try: - from TPTBox.spine.spinestats.veridah_angles import compute_veridah_variants + from TPTBox.spine.spinestats.veridah_angles import compute_veridah_variants, plot_veridah_variants logger.on_debug("veridah variants") out["curv_veridah"] = compute_veridah_variants(poi, file_dict.get("veridah")) + if file_dict.get("veridah") is not None and file_dict.get("vert") is not None: + veridah_jpg_out = t2w_bf.get_changed_path( + "jpg", "snp", "derivatives_spine_inference_162_sacrumfix_subregionmeasures-v2", info={"seg": "cobb-veridah"} + ) + plot_veridah_variants( + veridah_jpg_out, + poi, + file_dict["t2w"], + file_dict["vert"], + file_dict.get("veridah"), + ) except Exception: logger.on_fail("veridah variants error caught") logger.print_error() diff --git a/TPTBox/spine/spinestats/veridah_angles.py b/TPTBox/spine/spinestats/veridah_angles.py index 2c40bb5..72e0fa2 100644 --- a/TPTBox/spine/spinestats/veridah_angles.py +++ b/TPTBox/spine/spinestats/veridah_angles.py @@ -13,6 +13,13 @@ Both variants live under the top-level JSON key ``curv_veridah`` (see ``_run_all.py``); the standard ``curv`` output is untouched. + +:func:`plot_veridah_variants` writes a matching snapshot JPG. It re-uses +the already-computed POI points (no re-segmentation, no re-POI-derivation) +by driving the label re-key through :meth:`POI.map_labels` — which also +carries the registered ``label_name`` and vector-field metadata along — +and applies the same integer mapping to the vertebra NIfTI via +:meth:`NII.map_labels` so the overlay matches the relabeled points. """ from __future__ import annotations @@ -20,9 +27,11 @@ import json from pathlib import Path +from TPTBox.core.nii_wrapper import NII, Image_Reference, to_nii from TPTBox.core.poi import POI, POI_Descriptor from TPTBox.core.vert_constants import Vertebra_Instance -from TPTBox.spine.spinestats.angles import compute_lordosis_and_kyphosis +from TPTBox.spine.snapshot2D.snapshot_modular import create_snapshot +from TPTBox.spine.spinestats.angles import compute_lordosis_and_kyphosis, plot_compute_lordosis_and_kyphosis def _load_veridah(path: Path) -> dict | None: @@ -39,18 +48,7 @@ def _load_veridah(path: Path) -> dict | None: def _relabel_poi(poi: POI, mapping: dict[int, int]) -> POI: - """Return a copy of ``poi`` whose region ids are re-keyed via ``mapping``. - - Regions absent from ``mapping`` are dropped. Non-vertebra regions - (i.e. those already outside the vertebra label range) fall through - with their original id — but in practice a POI produced by - :func:`calc_poi_from_subreg_vert` only carries vertebra regions. - """ - new_centroids = POI_Descriptor() - for region, subregion, coord in poi.centroids.items(): - if region in mapping: - new_centroids[(mapping[region], subregion)] = coord - return poi.copy(centroids=new_centroids) + return poi.map_labels(label_map_region=mapping) def _last_k_relabel(poi: POI, k: int) -> POI: @@ -87,7 +85,7 @@ def _compute_anomaly_variant(poi: POI, veridah_json_path: Path | None) -> dict[s fpath = veridah.get("fpath") if not isinstance(orig, list) or not isinstance(fpath, list) or len(orig) != len(fpath): return None - mapping = {int(o): int(f) for o, f in zip(orig, fpath)} + mapping = {int(o): int(f) for o, f in zip(orig, fpath) if int(o) != int(f)} return compute_lordosis_and_kyphosis(_relabel_poi(poi, mapping)) @@ -100,6 +98,82 @@ def _compute_k_variant(poi: POI, k: int) -> dict[str, float | None]: return {"lumbar_lordosis": full.get("lumbar_lordosis")} +def _veridah_region_map(veridah_json_path: Path) -> dict[int, int] | None: + """Return the ``orig_label -> fpath`` region mapping from a VERIDAH stat json.""" + veridah = _load_veridah(Path(veridah_json_path)) + if veridah is None: + return None + orig = veridah.get("orig_label") + fpath = veridah.get("fpath") + if not isinstance(orig, list) or not isinstance(fpath, list) or len(orig) != len(fpath): + return None + return {int(o): int(f) for o, f in zip(orig, fpath) if int(o) != int(f)} + + +def plot_veridah_variants( + jpg_path: str | Path | None, + poi: POI, + img: Image_Reference, + seg_vert: Image_Reference, + veridah_json_path: Path | str | None, + line_len: int = 100, + project_2D: bool = True, +) -> tuple[dict[str, float | None] | None, str | None]: + """Render the VERIDAH-corrected lordosis / kyphosis snapshot next to the standard one. + + Re-uses the *already computed* POI points -- the angle numbers are cheap + trig on those points, and no re-segmentation / re-``calc_poi_from_subreg_vert`` + happens. The re-key is driven through :meth:`POI.map_labels`, so any + registered ``label_name`` / vector-field metadata is carried along + automatically. The vertebra segmentation is remapped with the same + integer table via :meth:`NII.map_labels` so the overlay matches. + + Args: + jpg_path: Output path for the snapshot JPG. If ``None``, the image is not saved. + poi: The (already computed) POI object. + img: The reference image on which to plot. + seg_vert: Vertebra segmentation NIfTI, remapped alongside the POI. + veridah_json_path: Path to the VERIDAH ``_stat.json`` giving + ``{orig_label, fpath}`` lists. + line_len: Length of the direction lines drawn on the snapshot. + project_2D: Compute the angles as 2D sagittal projections. + + Returns: + ``(anomaly_angles, saved_path)`` -- ``anomaly_angles`` is the same + dict :func:`compute_lordosis_and_kyphosis` returns (or ``None`` + when the VERIDAH file is missing/malformed), ``saved_path`` is + the resolved output path (or ``None`` when nothing was written). + """ + if veridah_json_path is None: + return None, None + mapping = _veridah_region_map(Path(veridah_json_path)) + if mapping is None: + return None, None + + relabeled_poi = _relabel_poi(poi, mapping) + if not relabeled_poi.centroids: + return None, None + # Remap the vertebra segmentation with the same integer table so the overlay + # numbering matches the (already-remapped) POI. NII.map_labels leaves labels + # that are not listed in ``mapping`` unchanged. + seg_nii = seg_vert if isinstance(seg_vert, NII) else to_nii(seg_vert, seg=True) + seg_relabeled = seg_nii.map_labels(mapping, verbose=False) # type: ignore[arg-type] + + angles, _frame = plot_compute_lordosis_and_kyphosis( + None, + relabeled_poi, + img, + seg_relabeled, + line_len=line_len, + project_2D=project_2D, + ) + saved: str | None = None + if jpg_path is not None: + create_snapshot(jpg_path, [_frame]) + saved = str(jpg_path) + return angles, saved + + def compute_veridah_variants(poi: POI, veridah_json_path: Path | str | None) -> dict: """Return the ``curv_veridah`` block for one subject. diff --git a/docs/api/poi_fun.md b/docs/api/poi_fun.md index 124a0e9..b4cd34a 100644 --- a/docs/api/poi_fun.md +++ b/docs/api/poi_fun.md @@ -91,20 +91,61 @@ if "my_scalar_field" not in lbl_fields: poi.info["my_scalar_field"] = {"L1": 3.14, ...} ``` -**Special-cased field — `info["label_name"]`:** +**Assigning names (`info["label_name"]`):** -Human-readable per-point / per-region names live in the nested structure -`{region:int -> {subregion:int -> name:str, "name": group_name:str}}` (see -`poi_abstract.LABEL_NAME` and `normalize_label_name`). `map_labels` remaps this -field automatically via its own helper `_remap_label_name_inplace`, which: +Human-readable per-point and per-region names live in +`poi.info["label_name"]` as `{region: {subregion: name, "name": group_name}}`. +Use the accessors on `Abstract_POI` (available on both `POI` and `POI_Global`) +instead of writing the dict directly: -- remaps the top-level `region` keys via `label_map_region`; -- remaps the *inner* `subregion` keys via `label_map_subregion`; -- preserves the special `"name"` group-name entry; -- merges inner dicts with *last-write-wins* on inner-key collisions when two - source regions map onto the same target. +```python +poi.set_label_name(region=2, subregion=10, name="FLCPC") # per-point label +poi.set_level_one_name(region=2, name="Femur") # region group name + +poi.label_name(2, 10) # -> "FLCPC" +poi.level_one_name(2) # -> "Femur" +``` + +`region` / `subregion` accept `int`, numeric string, or `Enum` members. A +custom name in `label_name` always takes priority over the auto-derived name +from `level_one_info` / `level_two_info`; if none is set, `.label_name(...)` +falls back to the enum name, and finally to the raw id as a string. A warning +is emitted when a custom name conflicts with the `level_two_info` enum name +for the same id. + +`map_labels` remaps this field automatically: the same +`_remap_vector_field_keys_inplace` helper handles both flat fields and the +nested `label_name` structure by dispatching on value type. For `label_name`, +`label_map_region` remaps the top-level region keys, `label_map_subregion` +remaps the inner subregion keys, and the `"name"` group entry is preserved. +Two source regions colliding onto one target merge inner dicts with +*last-write-wins* on overlapping keys. No explicit registration required — +`label_name` is always handled. + +**Names in 3D Slicer:** + +`POI_Global.save_mrk(...)` writes a `.mrk.json` markup file whose control +points and groups inherit these names directly: + +```python +poi_global = poi.to_global() +poi_global.save_mrk("points.mrk.json", pointLabelsVisibility=True) +``` + +Per control point, `save_mkr.get_desc(poi, region, subregion)` looks up: + +- `label` — from `poi.info["label_name"][region][subregion]`; falls back to + the `level_two_info` enum name (or the raw subregion id). +- `name2` (group label shown in the markup tree) — from + `poi.info["label_name"][region]["name"]`; falls back to + `poi.info["label_group_name"][region]` and finally to the + `level_one_info` enum name. -No explicit registration is required for `label_name` — it is always handled. +So `poi.set_label_name(...)` and `poi.set_level_one_name(...)` are all you +need: Slicer displays those strings on hover, in the markup tree, and (when +`pointLabelsVisibility=True`) as 3D annotations. Enable +`split_by_region=True` on `save_mrk` to get one Slicer group per region +(named via `level_one_name`). **Caveats:** diff --git a/unit_tests/test_poi_vector_fields.py b/unit_tests/test_poi_vector_fields.py index e120752..cac5fdb 100644 --- a/unit_tests/test_poi_vector_fields.py +++ b/unit_tests/test_poi_vector_fields.py @@ -17,6 +17,8 @@ file = Path(__file__).resolve() sys.path.append(str(file.parents[2])) +from typing import ClassVar # noqa: E402 + import numpy as np # noqa: E402 from TPTBox.core.poi import POI # noqa: E402 @@ -258,6 +260,171 @@ def test_flat_legacy_format_migrated_and_remapped(self): self.assertEqual(ln[2][50], "L1_corpus") +class Test_LabelName_LegacyMigration(unittest.TestCase): + """The old flat ``{"(region, subreg)": name}`` format must migrate cleanly. + + Real-world fixture: a leg-atlas POI with 38 flat entries across 5 regions, + including multi-digit subregion ids like ``(2, 10)`` — those must survive + ``ast.literal_eval`` parsing. + """ + + _ATLAS_FLAT: ClassVar[dict[str, str]] = { + "(1, 1)": "TGT", "(1, 2)": "FHC", "(1, 3)": "FNC", "(1, 4)": "FAAP", + "(2, 1)": "FLCD", "(2, 2)": "FMCD", "(2, 3)": "FLCP", "(2, 4)": "FMCP", + "(2, 5)": "FNP", "(2, 6)": "FADP", "(2, 7)": "TGPP", "(2, 8)": "TGCP", + "(2, 9)": "FMCPC", "(2, 10)": "FLCPC", "(2, 11)": "TRMP", "(2, 12)": "TRLP", + "(3, 1)": "TLCL", "(3, 2)": "TMCM", "(3, 3)": "TKC", "(3, 4)": "TLCA", + "(3, 5)": "TLCP", "(3, 6)": "TMCA", "(3, 7)": "TMCP", "(3, 8)": "TTP", + "(3, 9)": "TAAP", "(3, 10)": "TMIT", "(3, 11)": "TLIT", + "(4, 1)": "FLM", "(4, 2)": "TMM", "(4, 3)": "TAC", "(4, 4)": "TADP", + "(5, 1)": "PPP", "(5, 2)": "PDP", "(5, 3)": "PMP", "(5, 4)": "PLP", + "(5, 5)": "PRPP", "(5, 6)": "PRDP", "(5, 7)": "PRHP", + } + + def test_normalize_label_name_migrates_flat_atlas(self): + from TPTBox.core.poi_fun.poi_abstract import normalize_label_name + + nested = normalize_label_name(dict(self._ATLAS_FLAT)) + # region keys are ints + self.assertEqual(set(nested.keys()), {1, 2, 3, 4, 5}) + # multi-digit inner keys survive parsing + self.assertEqual(nested[2][10], "FLCPC") + self.assertEqual(nested[2][12], "TRLP") + self.assertEqual(nested[3][11], "TLIT") + # inner keys are ints too, no leftover string keys + for region, inner in nested.items(): + for k in inner: + self.assertIsInstance(k, int, f"inner key {k!r} in region {region} is not int") + # count is preserved + total = sum(len(v) for v in nested.values()) + self.assertEqual(total, len(self._ATLAS_FLAT)) + + def test_normalize_is_idempotent(self): + from TPTBox.core.poi_fun.poi_abstract import normalize_label_name + + once = normalize_label_name(dict(self._ATLAS_FLAT)) + twice = normalize_label_name({**once}) + self.assertEqual(once, twice) + + def test_normalize_empty_and_none(self): + from TPTBox.core.poi_fun.poi_abstract import normalize_label_name + + self.assertEqual(normalize_label_name(None), {}) + self.assertEqual(normalize_label_name({}), {}) + + def test_label_name_dict_caches_migration_in_info(self): + from TPTBox.core.poi_fun.poi_abstract import label_name_dict + + info = {"label_name": dict(self._ATLAS_FLAT)} + out = label_name_dict(info) + # returned dict is the normalized form + self.assertEqual(out[1][1], "TGT") + # and the migrated form is cached back into info + self.assertIs(info["label_name"], out) + # a second call is a no-op (still nested) + out2 = label_name_dict(info) + self.assertIs(out2, info["label_name"]) + + def test_load_poi_migrates_atlas_from_disk(self): + """End-to-end: write the legacy JSON, load it, expect the nested form.""" + import json + import tempfile + + from TPTBox.core.poi import POI + + payload = [ + { + "direction": ["R", "A", "S"], + "zoom": [1.0, 1.0, 1.0], + "origin": [0.0, 0.0, 0.0], + "shape": [10, 10, 10], + "rotation": [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], + "format": "POI", + "label_name": dict(self._ATLAS_FLAT), + }, + # one dummy point so the file loads as a POI: {region: {subregion: (x,y,z)}} + {"1": {"1": [1.0, 2.0, 3.0]}}, + ] + with tempfile.NamedTemporaryFile(mode="w", suffix="_poi.json", delete=False) as f: + json.dump(payload, f) + path = f.name + try: + poi = POI.load(path) + ln = poi.info["label_name"] + self.assertEqual(ln[1][1], "TGT") + self.assertEqual(ln[2][10], "FLCPC") + # no more flat "(...)"-style keys + self.assertFalse(any(isinstance(k, str) and k.startswith("(") for k in ln)) + finally: + Path(path).unlink(missing_ok=True) + + def test_migration_then_map_labels(self): + """A flat-format POI still remaps correctly through map_labels.""" + poi = _make_poi() + poi.info["label_name"] = dict(self._ATLAS_FLAT) # legacy flat + # rename region 2 -> 20 and region 5 -> 50 in one shot + out = poi.map_labels(label_map_region={2: 20, 5: 50}) + ln = out.info["label_name"] + self.assertIn(20, ln) + self.assertIn(50, ln) + self.assertNotIn(2, ln) + self.assertNotIn(5, ln) + self.assertEqual(ln[20][10], "FLCPC") + self.assertEqual(ln[50][7], "PRHP") + + +class Test_LabelName_Accessors(unittest.TestCase): + """`set_label_name` / `set_level_one_name` write into ``info['label_name']`` + and get read back by ``label_name`` / ``level_one_name`` and by the + Slicer/mkr exporter.""" + + def _poi_with_enums(self) -> POI: + from TPTBox.core.vert_constants import Location, Vertebra_Instance + + poi = _make_poi() + poi.level_one_info = Vertebra_Instance + poi.level_two_info = Location + return poi + + def test_set_and_get_label_name(self): + poi = self._poi_with_enums() + poi.set_label_name(region=20, subregion=50, name="L1_corpus") + self.assertEqual(poi.label_name(20, 50), "L1_corpus") + # unset points fall back to the level_two_info enum name (or the raw id when + # no enum entry matches). + fallback = poi.label_name(21, 50) + self.assertIsInstance(fallback, str) + self.assertNotEqual(fallback, "L1_corpus") + + def test_set_and_get_level_one_name(self): + poi = self._poi_with_enums() + poi.set_level_one_name(region=20, name="Vertebra L1 custom") + self.assertEqual(poi.level_one_name(20), "Vertebra L1 custom") + # unset region falls back to the level_one_info enum name (L1 -> "L1") + self.assertEqual(poi.level_one_name(21), "L2") + + def test_set_label_name_persists_in_info(self): + poi = _make_poi() + poi.set_label_name(20, 50, "L1_corpus") + poi.set_level_one_name(20, "Femur") + ln = poi.info["label_name"] + self.assertEqual(ln[20][50], "L1_corpus") + self.assertEqual(ln[20]["name"], "Femur") + + def test_names_flow_into_slicer_export(self): + """`get_desc` (used by save_mrk) reads label/group name from label_name.""" + from TPTBox.core.poi_fun.save_mkr import get_desc + + poi = _make_poi() + poi.set_label_name(20, 50, "L1_corpus") + poi.set_level_one_name(20, "Spine") + g = poi.to_global() + name, name2, label = get_desc(g, region=20, subregion=50) + # `label` is the per-point custom name; `name2` is the region group name. + self.assertEqual(label, "L1_corpus") + self.assertEqual(name2, "Spine") + + class Test_Composition(unittest.TestCase): def test_reorient_and_map_labels_commute(self): # For a vector-transform + key-remap, order shouldn't matter. From 7d5b1a5b0a5350f4342a7f01ee700d70a2e9e5a1 Mon Sep 17 00:00:00 2001 From: Robert Graf Date: Mon, 21 Sep 2026 16:17:17 +0000 Subject: [PATCH 08/29] fix angls. --- TPTBox/core/poi.py | 3 + TPTBox/spine/spinestats/_run_all.py | 108 ++++++++++++++++++---- TPTBox/spine/spinestats/angles.py | 83 +++++++++++++---- TPTBox/spine/spinestats/curvature.py | 2 +- TPTBox/spine/spinestats/veridah_angles.py | 20 ++-- 5 files changed, 170 insertions(+), 46 deletions(-) diff --git a/TPTBox/core/poi.py b/TPTBox/core/poi.py index 74c3bd7..470586a 100755 --- a/TPTBox/core/poi.py +++ b/TPTBox/core/poi.py @@ -1401,6 +1401,9 @@ def calc_centroids( ctd_list[first_stage, int(i)] = out_coord else: ctd_list[int(i), second_stage] = out_coord + if extend_to is not None: + args.setdefault("level_one_info", extend_to.level_one_info) + args.setdefault("level_two_info", extend_to.level_two_info) return POI(ctd_list, **msk_nii._extract_affine(), **args) diff --git a/TPTBox/spine/spinestats/_run_all.py b/TPTBox/spine/spinestats/_run_all.py index 8e7127b..c03a114 100644 --- a/TPTBox/spine/spinestats/_run_all.py +++ b/TPTBox/spine/spinestats/_run_all.py @@ -23,8 +23,7 @@ from tqdm import tqdm -from TPTBox import Print_Logger -from TPTBox.core.bids_files import BIDS_FILE +from TPTBox import BIDS_FILE, POI, Print_Logger from TPTBox.core.dicom.dicom2nii_utils import load_json from TPTBox.core.internal.nii_help import save_json from TPTBox.core.nii_wrapper import to_nii @@ -32,6 +31,16 @@ DATASET_ROOT = Path("/DATA/NAS/datasets_processed/NAKO/dataset-nako") logger = Print_Logger() +# Version stamp written into every aggregate `_stat.json` at +# ``_provenance.version`` (built by :func:`_build_provenance`). Bump this whenever +# the produced numbers change in a way that requires already-cached files to be +# recomputed. Current bumps: +# 1 -- initial release (WK-direction based cobb/lordosis; no S1 sacrum endplate). +# 2 -- endplate-plane based lordosis / kyphosis (average of the two flanking +# endplates at each disc), S1 sacrum-endplate landmarks, and correct +# cranio-caudal ordering for the T13 annotation label. +CURRENT_VERSION = 2 + # Top-level keys we require inside a finished json before we consider a # subject "done" and skip recomputation. cobb/curv are optional and only # added when run_all is called with cobb=True. @@ -99,8 +108,7 @@ def get_nako_paths(nako_id: str) -> dict[str, Path | None]: veridah = None for suffix in ("VERIDAH-label-V2", "VERIDAH-label"): p = ( - DATASET_ROOT - / f"derivatives_spine_inference_162_sacrumfix/{pfx}/{sub}/T2w/" + DATASET_ROOT / f"derivatives_spine_inference_162_sacrumfix/{pfx}/{sub}/T2w/" f"sub-{sub}_sequ-stitched_acq-sag_mod-T2w_seg-vert_desc-{suffix}_stat.json" ) if p.exists(): @@ -137,6 +145,33 @@ def _segmentation_inputs(file_dict: dict) -> list[Path]: ] +def _stat_version(loaded_stat: dict | None) -> int: + """Return the ``_provenance.version`` of a loaded stat dict (defaults to 1).""" + if not isinstance(loaded_stat, dict): + return 1 + prov = loaded_stat.get("_provenance") + if isinstance(prov, dict): + try: + return int(prov.get("version", 1)) + except (TypeError, ValueError): + return 1 + return 1 + + +def _poi_is_stale_wrt_stat(stat_path: Path, loaded_stat: dict) -> bool: + """Return True when the POI buffer sitting next to ``stat_path`` should be rebuilt. + + Staleness is derived from the stat json's ``_provenance.version``: whenever + that reads < :data:`ANGLES_VERSION`, the sibling POI buffer is treated as + outdated (v1 stat + v1 POI travelled together). ``loaded_stat`` is the + already-loaded stat dict — pass ``{}`` if none exists (then nothing is stale + since there's no v1 marker to invalidate against). + """ + if not stat_path.exists() or not loaded_stat: + return False + return _stat_version(loaded_stat) < CURRENT_VERSION + + def _is_cache_valid(json_path: Path, seg_files: list[Path], required_keys: tuple[str, ...]) -> tuple[bool, dict | None]: """Return (valid, loaded_dict). @@ -164,6 +199,8 @@ def _is_cache_valid(json_path: Path, seg_files: list[Path], required_keys: tuple for k in required_keys: if k not in data: return False, None + if _stat_version(data) < CURRENT_VERSION: + return False, None return True, data @@ -249,7 +286,7 @@ def _build_provenance(file_dict: dict, poi_out: Path | str | None, prior: dict | if p.exists(): inputs["poi"] = _file_provenance(p, prior_inputs.get("poi")) return { - "version": 1, + "version": CURRENT_VERSION, "written_at": datetime.now(timezone.utc).isoformat(timespec="seconds"), "inputs": inputs, } @@ -349,6 +386,26 @@ def run_all( out = loaded except Exception: out = {} + # Version stale -> drop the ANGLE-related keys so the corresponding _need() + # checks trigger a recompute of just the affected metrics + JPGs, without + # invalidating the expensive body-composition / VBQ / muscle_fat / torso + # blocks that don't depend on the endplate-based lordosis fix. + _cur_ver = _stat_version(out) + if _cur_ver < 2: + logger.on_warning("version bump", _cur_ver, "->", CURRENT_VERSION, ": recomputing angle keys") + for _k in ( + "cobb", + "curv", + "curv_veridah", + "sva", + "coronal_balance", + "axial_rotation", + "segmental_endplate_angles", + "curvature_profile", + "multi_cobb", + "endplate_internal_angle", + ): + out.pop(_k, None) def _need(*keys: str, compute: bool) -> bool: return override or (any(k not in out for k in keys) and compute) @@ -420,7 +477,16 @@ def _need(*keys: str, compute: bool) -> bool: need_vibe_wf = need_mfi if not ( - need_cobb or need_ivd or need_vert or need_vbq or need_bcs or need_mfi or need_torso or need_curvature or need_pelvic or need_veridah + need_cobb + or need_ivd + or need_vert + or need_vbq + or need_bcs + or need_mfi + or need_torso + or need_curvature + or need_pelvic + or need_veridah ): if _merge_endplate_angles(out, Path(poi_out)) or save: out["_provenance"] = _build_provenance(file_dict, poi_out, out.get("_provenance")) @@ -440,13 +506,18 @@ def _need(*keys: str, compute: bool) -> bool: poi = None if need_poi: logger.on_debug("calc_poi_from_subreg_vert") - poi = calc_poi_from_subreg_vert( - vert, - spine, - subreg_id=[Location.Vertebra_Corpus, Location.Vertebra_Direction_Posterior, Location.Endplate, Location.Vertebra_Disc], - buffer_file=poi_out, - save_buffer_file=True, - ) + if _poi_is_stale_wrt_stat(final_out, out): + Path(poi_out).unlink(missing_ok=True) + if poi_out.exists(): + poi = POI.load(poi_out) + else: + poi = calc_poi_from_subreg_vert( + vert, + spine, + subreg_id=[Location.Vertebra_Corpus, Location.Vertebra_Direction_Posterior, Location.Endplate, Location.Vertebra_Disc], + buffer_file=poi_out, + save_buffer_file=True, + ) if need_cobb: try: project_2D = False @@ -579,6 +650,7 @@ def _need(*keys: str, compute: bool) -> bool: _merge_endplate_angles(out, Path(poi_out)) out["_provenance"] = _build_provenance(file_dict, poi_out, out.get("_provenance")) + out.pop("_version", None) # TODO can beremoved logger.on_save("save", final_out.name) save_json(final_out, out) return out @@ -975,7 +1047,7 @@ def _run_one(args: tuple[dict, bool, bool]) -> tuple[str, str | None, dict]: OUT_FOLDER.mkdir(parents=True, exist_ok=True) N_CPUS = 1 # set >1 to parallelize OVERRIDE = False - aggregate = True + aggregate = False do_not_update = False test = False collector: ExcelCollector | None = None @@ -993,10 +1065,10 @@ def _run_one(args: tuple[dict, bool, bool]) -> tuple[str, str | None, dict]: subjects = loop_over_repaired_nako(test=False, sort=aggregate) else: # subjects = tqdm(loop_over_repaired_nako(test=False, sort=aggregate), total=30645) - l = loop_over_repaired_nako(test=False, sort=aggregate) - # total = 1000 - # subjects = iter([next(l) for _ in range(total)]) - subjects = l + l = loop_over_repaired_nako(test=False, sort=True) # aggregate + total = 15 + subjects = iter([next(l) for _ in range(total)]) + # subjects = l # print(f"Run on {total=} random subset") if N_CPUS <= 1: diff --git a/TPTBox/spine/spinestats/angles.py b/TPTBox/spine/spinestats/angles.py index f5e41c6..4d724ba 100644 --- a/TPTBox/spine/spinestats/angles.py +++ b/TPTBox/spine/spinestats/angles.py @@ -258,7 +258,7 @@ def compute_angel_between_two_points_( if id1 > id2: id1, id2 = id2, id1 # Reorient and rescale the POI data - poi.reorient_().rescale_() + poi.reorient_().rescale_(verbose=False) recompute_use_ivd_direction = False # Determine direction-specific settings location2 = None @@ -395,22 +395,17 @@ def compute_lordosis_and_kyphosis(poi: POI, project_2D=True) -> dict[str, float return out -def _endplate_ap_direction(poi: POI, vert: Vertebra_Instance, mv: MoveTo) -> np.ndarray | None: - """Return an endplate-plane A/P direction from the buffered endplate POIs, or None. - - Uses the *relevant* endplate landmark for ``mv`` (``Vertebral_Body_Endplate_Superior`` - for :attr:`MoveTo.TOP`, ``Vertebral_Body_Endplate_Inferior`` for :attr:`MoveTo.BOTTOM`) - together with ``Vertebra_Direction_Right`` and ``Vertebra_Corpus`` to build a P/A - vector that lies in the specific endplate plane, rather than the averaged - vertebral body plane implied by ``Vertebra_Direction_Posterior``. +def _single_endplate_ap_direction(poi: POI, vert: Vertebra_Instance, side: MoveTo) -> np.ndarray | None: + """A/P direction of *one* endplate (superior for TOP, inferior for BOTTOM). - The sign convention matches :func:`_get_norm` for ``Location.Vertebra_Direction_Posterior`` - with ``inv=1`` — the returned vector points anteriorly, so callers can multiply - by ``inv`` unchanged. + Uses ``Vertebra_Corpus``, ``Vertebral_Body_Endplate_Superior/Inferior`` and + ``Vertebra_Direction_Right`` to build a unit vector that lies in the endplate + plane and points *anteriorly* (matches :func:`_get_norm`'s ``inv=1`` sign + convention). Returns ``None`` if any required POI landmark is missing. """ - if mv == MoveTo.TOP: + if side == MoveTo.TOP: endplate_loc = Location.Vertebral_Body_Endplate_Superior - elif mv == MoveTo.BOTTOM: + elif side == MoveTo.BOTTOM: endplate_loc = Location.Vertebral_Body_Endplate_Inferior else: return None @@ -420,7 +415,7 @@ def _endplate_ap_direction(poi: POI, vert: Vertebra_Instance, mv: MoveTo) -> np. ep = np.array(poi[vert, endplate_loc], dtype=float) r_pt = np.array(poi[vert, Location.Vertebra_Direction_Right], dtype=float) n = ep - corpus - if mv == MoveTo.BOTTOM: + if side == MoveTo.BOTTOM: n = -n # flip inferior endplate so both cases point superior n_norm = np.linalg.norm(n) r_vec = r_pt - corpus @@ -438,6 +433,49 @@ def _endplate_ap_direction(poi: POI, vert: Vertebra_Instance, mv: MoveTo) -> np. return p / p_norm +def _endplate_ap_direction(poi: POI, vert: Vertebra_Instance, mv: MoveTo) -> np.ndarray | None: + """Return the endplate-plane A/P direction at a vertebra-disc *boundary*. + + For a lordosis / kyphosis chain to close at every transition (e.g. so + ``thoracic_kyphosis + lumbar_lordosis`` measures the same T4-top→L5-inferior + end-to-end angle as the total), the "bottom of upper" and "top of lower" at + each disc must use the *same* reference direction. This function averages the + two flanking endplate P/A directions at the disc: + + - :attr:`MoveTo.BOTTOM` at vertebra ``V`` averages ``V``'s inferior endplate + with the superior endplate of the next vertebra in the POI (``V.get_next_poi``). + - :attr:`MoveTo.TOP` at vertebra ``V`` averages ``V``'s superior endplate + with the inferior endplate of the previous vertebra (``V.get_previous_poi``). + + Falls back to the single-endplate direction when the neighbour is missing. + Returns ``None`` when even the local endplate is unavailable. + """ + if isinstance(vert, int): + vert = Vertebra_Instance(vert) + if mv == MoveTo.BOTTOM: + neighbour = vert.get_next_poi(poi) + neighbour_side = MoveTo.TOP + elif mv == MoveTo.TOP: + neighbour = vert.get_previous_poi(poi) + neighbour_side = MoveTo.BOTTOM + else: + return _single_endplate_ap_direction(poi, vert, mv) + own = _single_endplate_ap_direction(poi, vert, mv) + other = _single_endplate_ap_direction(poi, neighbour, neighbour_side) if neighbour is not None else None + # If the local endplate landmark is missing (calc_endplate_points_ ray-cast + # sometimes fails to hit the mask), mirror across the disc: use the neighbour's + # endplate as the direction proxy so both sides of the boundary agree. + if own is None: + return other + if other is None: + return own + avg = own + other + n = np.linalg.norm(avg) + if n < 1e-8: + return own + return avg / n + + def _get_norm(poi: POI, id1: int | Vertebra_Instance, mv: MoveTo, location: Location, inv: int = 1) -> np.ndarray | None: # noqa: ARG001 """Return the normalised direction vector from a location POI to the vertebra centroid. @@ -788,7 +826,7 @@ def plot_compute_lordosis_and_kyphosis( >>> print(angles) {'cervical_lordosis': 34.5, 'thoracic_kyphosis': 42.7, 'lumbar_lordosis': 50.3} """ - poi = poi.reorient().rescale_() + poi = poi.reorient().rescale_(verbose=False) poi = _add_artificial_ivd(poi) out = [] text_out = [] @@ -813,7 +851,16 @@ def plot_compute_lordosis_and_kyphosis( id1 = curvature_definition[name].get_start_vert(poi) id2 = curvature_definition[name].get_stop_vert(poi) - vert = round((id1.value + id2.value) / 2) + # Cranio-caudal midpoint via the anatomical order — arithmetic mean of + # `.value` breaks for T13 (value 28, ordered after T12 but numbered after + # S1/COCC), landing the annotation on L1 instead of somewhere thoracic. + order = Vertebra_Instance.order() + try: + i1, i2 = order.index(id1), order.index(id2) + mid_inst = order[(min(i1, i2) + max(i1, i2)) // 2] + vert = mid_inst.value + except ValueError: + vert = round((id1.value + id2.value) / 2) while (vert, 50) not in poi and vert != 0: vert -= 1 text_out.append((vert, (f"{v:.1f}° - {str(name).split('_')[-1]}", 25))) @@ -870,7 +917,7 @@ def plot_cobb_angle( >>> plot_cobb_angle("output.png", poi, img, seg, line_len=100, threshold_deg=10) """ - poi = poi.reorient().rescale_() + poi = poi.reorient().rescale_(verbose=False) poi = _add_artificial_ivd(poi) out = [] diff --git a/TPTBox/spine/spinestats/curvature.py b/TPTBox/spine/spinestats/curvature.py index 8f8f267..3e63ff9 100644 --- a/TPTBox/spine/spinestats/curvature.py +++ b/TPTBox/spine/spinestats/curvature.py @@ -70,7 +70,7 @@ def _prep(poi: POI) -> POI: """Return the POI in the internal ``(P, I, R)`` orientation at 1 mm scale.""" - return poi.reorient().rescale() + return poi.reorient().rescale(verbose=False) def _get(poi: POI, vert: int | Vertebra_Instance, loc: Location) -> np.ndarray | None: diff --git a/TPTBox/spine/spinestats/veridah_angles.py b/TPTBox/spine/spinestats/veridah_angles.py index 72e0fa2..a607319 100644 --- a/TPTBox/spine/spinestats/veridah_angles.py +++ b/TPTBox/spine/spinestats/veridah_angles.py @@ -71,10 +71,12 @@ def _last_k_relabel(poi: POI, k: int) -> POI: return _relabel_poi(poi, mapping) -def _compute_anomaly_variant(poi: POI, veridah_json_path: Path | None) -> dict[str, float | None] | None: +def _compute_anomaly_variant(poi: POI, veridah_json_path: Path | None, project_2D: bool = False) -> dict[str, float | None] | None: """Recompute the three regional angles after applying the VERIDAH label correction. - Returns ``None`` when the VERIDAH file is missing or unusable. + ``project_2D`` must match the setting used for the standard ``curv`` block + in :mod:`_run_all` (default ``False``, i.e. 3D angles). Returns ``None`` + when the VERIDAH file is missing or unusable. """ if veridah_json_path is None: return None @@ -86,15 +88,15 @@ def _compute_anomaly_variant(poi: POI, veridah_json_path: Path | None) -> dict[s if not isinstance(orig, list) or not isinstance(fpath, list) or len(orig) != len(fpath): return None mapping = {int(o): int(f) for o, f in zip(orig, fpath) if int(o) != int(f)} - return compute_lordosis_and_kyphosis(_relabel_poi(poi, mapping)) + return compute_lordosis_and_kyphosis(_relabel_poi(poi, mapping), project_2D=project_2D) -def _compute_k_variant(poi: POI, k: int) -> dict[str, float | None]: +def _compute_k_variant(poi: POI, k: int, project_2D: bool = False) -> dict[str, float | None]: """Compute lumbar-lordosis only, with the last ``k`` vertebrae treated as L1..Lk.""" relabeled = _last_k_relabel(poi, k) if not relabeled.centroids: return {"lumbar_lordosis": None} - full = compute_lordosis_and_kyphosis(relabeled) + full = compute_lordosis_and_kyphosis(relabeled, project_2D=project_2D) return {"lumbar_lordosis": full.get("lumbar_lordosis")} @@ -117,7 +119,7 @@ def plot_veridah_variants( seg_vert: Image_Reference, veridah_json_path: Path | str | None, line_len: int = 100, - project_2D: bool = True, + project_2D: bool = False, ) -> tuple[dict[str, float | None] | None, str | None]: """Render the VERIDAH-corrected lordosis / kyphosis snapshot next to the standard one. @@ -174,7 +176,7 @@ def plot_veridah_variants( return angles, saved -def compute_veridah_variants(poi: POI, veridah_json_path: Path | str | None) -> dict: +def compute_veridah_variants(poi: POI, veridah_json_path: Path | str | None, project_2D: bool = False) -> dict: """Return the ``curv_veridah`` block for one subject. Shape:: @@ -186,7 +188,7 @@ def compute_veridah_variants(poi: POI, veridah_json_path: Path | str | None) -> } """ veridah_path = Path(veridah_json_path) if veridah_json_path is not None else None - out: dict = {"anomaly": _compute_anomaly_variant(poi, veridah_path)} + out: dict = {"anomaly": _compute_anomaly_variant(poi, veridah_path, project_2D=project_2D)} for k in (4, 5, 6, 7): - out[f"k{k}"] = _compute_k_variant(poi, k) + out[f"k{k}"] = _compute_k_variant(poi, k, project_2D=project_2D) return out From cfa270aae09c73ebde34357df073d3dfaca17ac7 Mon Sep 17 00:00:00 2001 From: Robert Graf Date: Mon, 21 Sep 2026 16:25:15 +0000 Subject: [PATCH 09/29] logging --- TPTBox/spine/spinestats/_run_all.py | 6 +++--- TPTBox/spine/spinestats/angles.py | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/TPTBox/spine/spinestats/_run_all.py b/TPTBox/spine/spinestats/_run_all.py index c03a114..0e2598f 100644 --- a/TPTBox/spine/spinestats/_run_all.py +++ b/TPTBox/spine/spinestats/_run_all.py @@ -1066,9 +1066,9 @@ def _run_one(args: tuple[dict, bool, bool]) -> tuple[str, str | None, dict]: else: # subjects = tqdm(loop_over_repaired_nako(test=False, sort=aggregate), total=30645) l = loop_over_repaired_nako(test=False, sort=True) # aggregate - total = 15 - subjects = iter([next(l) for _ in range(total)]) - # subjects = l + # total = 15 + # subjects = iter([next(l) for _ in range(total)]) + subjects = l # print(f"Run on {total=} random subset") if N_CPUS <= 1: diff --git a/TPTBox/spine/spinestats/angles.py b/TPTBox/spine/spinestats/angles.py index 4d724ba..5d70351 100644 --- a/TPTBox/spine/spinestats/angles.py +++ b/TPTBox/spine/spinestats/angles.py @@ -961,8 +961,8 @@ def plot_cobb_angle( if width < 50: padd = [(0, 0) for _ in range(3)] padd[axis] = (int(50 - width), int(50 - width)) - img = to_nii(img).apply_pad(padd) - seg = to_nii(seg, True).apply_pad(padd) + img = to_nii(img).apply_pad(padd, verbose=False) + seg = to_nii(seg, True).apply_pad(padd, verbose=False) poi = poi.resample_from_to(seg) frame = Snapshot_Frame( img, From 3a9cd6509740387e1d88fd5fb40928d0e92cca69 Mon Sep 17 00:00:00 2001 From: Robert Graf Date: Tue, 22 Sep 2026 10:57:24 +0000 Subject: [PATCH 10/29] =?UTF-8?q?spinestats:=20version=203=20=E2=80=94=20e?= =?UTF-8?q?ndplate-plane=20Cobb=20+=20apex=20+=20tightened=20multi-Cobb?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the endplate-plane direction machinery used for v2 lordosis/kyphosis to the coronal Cobb angle, and bumps ``CURRENT_VERSION`` to 3 so cached stat files recompute the angle keys (POI buffers stay valid — no new landmarks). Angles (``TPTBox/spine/spinestats/angles.py``): - ``_get_norm`` now routes ``Vertebra_Direction_Right`` through a new ``_endplate_r_direction`` helper (analogue of ``_endplate_ap_direction``), so Cobb lines are true endplate lines averaged across the shared disc instead of the two flanking vertebrae disagreeing on the same boundary. - ``plot_cobb_angle`` always uses that endplate right direction — the ``use_ivd_direction`` branch used ``Vertebra_Disc_Inferior`` from each vertebra separately, giving two different lines at one anatomic disc. - ``compute_angel_between_two_points_`` orders ``id1``/``id2`` by ``Vertebra_Instance.order_dict()`` rather than by numeric label, so T13 (label 28, anatomically between T12 and L1) pairs to the right endplates and ``MoveTo.TOP``/``BOTTOM`` are not swapped. - ``compute_max_cobb_angle_multi`` uses an exclusive split (``+ 1`` on the ``below`` slice) so sibling curves cannot share an endpoint vertebra. - ``VERT_START_COBB`` is C3 (C2 excluded from Cobb search). - ``compute_lordosis_and_kyphosis`` emits ``{curve}_apex`` keys, computed by the new ``_find_curve_apex`` helper (same bisector heuristic as ``compute_max_cobb_angle``). ``plot_compute_lordosis_and_kyphosis`` renders a ``*apex`` marker at that vertebra. - ``plot_cobb_angle`` anchors the label at the apex disc and widens the coronal pad from 50 to 80 mm so labels don't get clipped. Runner (``TPTBox/spine/spinestats/_run_all.py``): - Introduces ``POI_INVALIDATING_VERSION = 2`` so the ``_poi_is_stale_wrt_stat`` threshold is a named constant rather than a magic ``2``, and its docstring points at a symbol that actually exists (previously referenced a nonexistent ``ANGLES_VERSION``). - Documents the v3 bump in the ``CURRENT_VERSION`` history comment. Veridah (``TPTBox/spine/spinestats/veridah_angles.py``): - ``_compute_k_variant`` also returns ``lumbar_lordosis_apex`` so the per-k variants carry the new apex key. Co-Authored-By: Claude Opus 4.7 --- TPTBox/spine/spinestats/_run_all.py | 27 ++- TPTBox/spine/spinestats/angles.py | 198 +++++++++++++++++++--- TPTBox/spine/spinestats/veridah_angles.py | 2 +- 3 files changed, 194 insertions(+), 33 deletions(-) diff --git a/TPTBox/spine/spinestats/_run_all.py b/TPTBox/spine/spinestats/_run_all.py index 0e2598f..c1d0e1f 100644 --- a/TPTBox/spine/spinestats/_run_all.py +++ b/TPTBox/spine/spinestats/_run_all.py @@ -39,7 +39,18 @@ # 2 -- endplate-plane based lordosis / kyphosis (average of the two flanking # endplates at each disc), S1 sacrum-endplate landmarks, and correct # cranio-caudal ordering for the T13 annotation label. -CURRENT_VERSION = 2 +# 3 -- endplate-plane based cobb (chain-closed, matches lordosis convention), +# C2 excluded from Cobb search (starts at C3), non-overlapping multi-cobb +# curves, anatomic (not numeric) vertebra ordering in +# ``compute_angel_between_two_points_`` so T13 pairs measure the right +# endplates, and per-curve apex vertebra keys in ``curv`` +# (``{cervical_lordosis,thoracic_kyphosis,lumbar_lordosis}_apex``). +CURRENT_VERSION = 3 +# Highest ``CURRENT_VERSION`` bump that required *new POI landmarks*. Stat files +# older than this were written before those landmarks existed, so the sibling +# POI buffer must be rebuilt (not just the angle keys). Bumps that only change +# how numbers are derived from an unchanged POI set do not update this. +POI_INVALIDATING_VERSION = 2 # Top-level keys we require inside a finished json before we consider a # subject "done" and skip recomputation. cobb/curv are optional and only @@ -162,14 +173,14 @@ def _poi_is_stale_wrt_stat(stat_path: Path, loaded_stat: dict) -> bool: """Return True when the POI buffer sitting next to ``stat_path`` should be rebuilt. Staleness is derived from the stat json's ``_provenance.version``: whenever - that reads < :data:`ANGLES_VERSION`, the sibling POI buffer is treated as - outdated (v1 stat + v1 POI travelled together). ``loaded_stat`` is the - already-loaded stat dict — pass ``{}`` if none exists (then nothing is stale - since there's no v1 marker to invalidate against). + that reads < :data:`POI_INVALIDATING_VERSION`, the sibling POI buffer is + treated as outdated (older stat + older POI travelled together). + ``loaded_stat`` is the already-loaded stat dict — pass ``{}`` if none exists + (then nothing is stale since there's no version marker to invalidate against). """ if not stat_path.exists() or not loaded_stat: return False - return _stat_version(loaded_stat) < CURRENT_VERSION + return _stat_version(loaded_stat) < POI_INVALIDATING_VERSION def _is_cache_valid(json_path: Path, seg_files: list[Path], required_keys: tuple[str, ...]) -> tuple[bool, dict | None]: @@ -391,7 +402,7 @@ def run_all( # invalidating the expensive body-composition / VBQ / muscle_fat / torso # blocks that don't depend on the endplate-based lordosis fix. _cur_ver = _stat_version(out) - if _cur_ver < 2: + if _cur_ver < CURRENT_VERSION: logger.on_warning("version bump", _cur_ver, "->", CURRENT_VERSION, ": recomputing angle keys") for _k in ( "cobb", @@ -1059,7 +1070,7 @@ def _run_one(args: tuple[dict, bool, bool]) -> tuple[str, str | None, dict]: try: if test: subjects = loop_over_repaired_nako(test=True) - total = 15 + total = 10 aggregate = False elif aggregate: subjects = loop_over_repaired_nako(test=False, sort=aggregate) diff --git a/TPTBox/spine/spinestats/angles.py b/TPTBox/spine/spinestats/angles.py index 5d70351..2a3934d 100644 --- a/TPTBox/spine/spinestats/angles.py +++ b/TPTBox/spine/spinestats/angles.py @@ -15,7 +15,7 @@ from TPTBox.spine.snapshot2D.snapshot_modular import Snapshot_Frame, create_snapshot IVD_MORE_ACCURATE = 15 -VERT_START_COBB = Vertebra_Instance.C2 +VERT_START_COBB = Vertebra_Instance.C3 class MoveTo(Enum): @@ -254,8 +254,12 @@ def compute_angel_between_two_points_( return None assert id1 != id2, id1 - # Ensure id1 is less than id2 - if id1 > id2: + # Ensure id1 is anatomically above id2 (not just numerically smaller). + # T13 has label value 28 but sits between T12 (19) and L1 (20) in anatomical + # order; using the label value directly would misplace it and swap the wrong + # MoveTo semantics onto TOP/BOTTOM. + _order = Vertebra_Instance.order_dict() + if _order.get(id1, id1) > _order.get(id2, id2): id1, id2 = id2, id1 # Reorient and rescale the POI data poi.reorient_().rescale_(verbose=False) @@ -388,13 +392,59 @@ def compute_lordosis_and_kyphosis(poi: POI, project_2D=True) -> dict[str, float poi = poi.copy() for k, i in curvature_definition.items(): - angle = compute_angel_between_two_points_( - poi, i.get_start_vert(poi), i.get_stop_vert(poi), "P", i.start_move, i.stop_move, project_2D - ) + start = i.get_start_vert(poi) + stop = i.get_stop_vert(poi) + angle = compute_angel_between_two_points_(poi, start, stop, "P", i.start_move, i.stop_move, project_2D) out[k] = round(angle, 4) if angle is not None else None + out[f"{k}_apex"] = _find_curve_apex(poi, start, stop, i.start_move, i.stop_move, Location.Vertebra_Direction_Posterior) return out +def _find_curve_apex( + poi: POI, + from_vert: Vertebra_Instance | int | None, + to_vert: Vertebra_Instance | int | None, + from_mv: MoveTo, + to_mv: MoveTo, + location: Location, +) -> int | None: + """Return the vertebra between ``from_vert`` and ``to_vert`` whose direction is closest to the endpoint bisector. + + Same apex heuristic as :func:`compute_max_cobb_angle`, just parameterised by + the direction location so it also works for lordosis / kyphosis + (``Vertebra_Direction_Posterior``). Returns ``None`` when either endpoint + direction is unavailable or no intermediate vertebra is present. + """ + if from_vert is None or to_vert is None: + return None + from_v = from_vert if isinstance(from_vert, Vertebra_Instance) else Vertebra_Instance(from_vert) + to_v = to_vert if isinstance(to_vert, Vertebra_Instance) else Vertebra_Instance(to_vert) + a = _get_norm(poi, from_v, from_mv, location, 1) + b = _get_norm(poi, to_v, to_mv, location, 1) + if a is None or b is None: + return None + apex_v = (a + b) / 2 + order = Vertebra_Instance.order() + try: + i_from = order.index(from_v) + i_to = order.index(to_v) + except ValueError: + return None + if i_from > i_to: + i_from, i_to = i_to, i_from + apex: int | None = None + cos_dis = -np.inf + for v in order[i_from : i_to + 1]: + n = _get_norm(poi, v, to_mv, location, 1) + if n is None: + continue + cos_new = cosine_distance(n, apex_v) + if cos_new > cos_dis: + cos_dis = cos_new + apex = v.value + return apex + + def _single_endplate_ap_direction(poi: POI, vert: Vertebra_Instance, side: MoveTo) -> np.ndarray | None: """A/P direction of *one* endplate (superior for TOP, inferior for BOTTOM). @@ -476,6 +526,85 @@ def _endplate_ap_direction(poi: POI, vert: Vertebra_Instance, mv: MoveTo) -> np. return avg / n +def _single_endplate_r_direction(poi: POI, vert: Vertebra_Instance, side: MoveTo) -> np.ndarray | None: + """R/L direction of *one* endplate (superior for TOP, inferior for BOTTOM). + + Builds a unit vector that lies in the endplate plane along the vertebra's + right/left axis: takes ``corpus - Vertebra_Direction_Right`` (matching the + WK path's ``inv=1`` sign convention, which points *left*) and projects it + onto the plane perpendicular to the endplate normal (``endplate_point - + corpus``). Returns ``None`` if any required POI landmark is missing. + """ + if side == MoveTo.TOP: + endplate_loc = Location.Vertebral_Body_Endplate_Superior + elif side == MoveTo.BOTTOM: + endplate_loc = Location.Vertebral_Body_Endplate_Inferior + else: + return None + if (vert, 50) not in poi or (vert, endplate_loc) not in poi or (vert, Location.Vertebra_Direction_Right) not in poi: + return None + corpus = np.array(poi[vert, 50], dtype=float) + ep = np.array(poi[vert, endplate_loc], dtype=float) + r_pt = np.array(poi[vert, Location.Vertebra_Direction_Right], dtype=float) + n = ep - corpus + if side == MoveTo.BOTTOM: + n = -n # flip inferior endplate so both cases point superior + n_norm = np.linalg.norm(n) + # WK path uses ``corpus - right_pt`` (points anatomical LEFT); mirror that + # convention so both paths agree when they meet in + # ``compute_angel_between_two_points_``. + r_vec = corpus - r_pt + r_norm = np.linalg.norm(r_vec) + if n_norm < 1e-8 or r_norm < 1e-8: + return None + n /= n_norm + r_vec /= r_norm + # Project the vertebra right/left vector onto the endplate plane + r_ep = r_vec - np.dot(r_vec, n) * n + r_ep_norm = np.linalg.norm(r_ep) + if r_ep_norm < 1e-8: + return None + return r_ep / r_ep_norm + + +def _endplate_r_direction(poi: POI, vert: Vertebra_Instance, mv: MoveTo) -> np.ndarray | None: + """R/L direction in the endplate plane at a vertebra-disc *boundary*. + + Analogue of :func:`_endplate_ap_direction` for the coronal (right) direction, + used to obtain a classical endplate-line orientation for Cobb angles. Averages + the two flanking endplate R directions at a disc: + + - :attr:`MoveTo.BOTTOM` at vertebra ``V`` averages ``V``'s inferior endplate + with the superior endplate of the next vertebra. + - :attr:`MoveTo.TOP` at vertebra ``V`` averages ``V``'s superior endplate + with the inferior endplate of the previous vertebra. + + Falls back to the single-endplate direction when the neighbour is missing. + Returns ``None`` when even the local endplate is unavailable. + """ + if isinstance(vert, int): + vert = Vertebra_Instance(vert) + if mv == MoveTo.BOTTOM: + neighbour = vert.get_next_poi(poi) + neighbour_side = MoveTo.TOP + elif mv == MoveTo.TOP: + neighbour = vert.get_previous_poi(poi) + neighbour_side = MoveTo.BOTTOM + else: + return _single_endplate_r_direction(poi, vert, mv) + own = _single_endplate_r_direction(poi, vert, mv) + other = _single_endplate_r_direction(poi, neighbour, neighbour_side) if neighbour is not None else None + if own is None: + return other + if other is None: + return own + avg = own + other + n = np.linalg.norm(avg) + if n < 1e-8: + return own + return avg / n + + def _get_norm(poi: POI, id1: int | Vertebra_Instance, mv: MoveTo, location: Location, inv: int = 1) -> np.ndarray | None: # noqa: ARG001 """Return the normalised direction vector from a location POI to the vertebra centroid. @@ -484,7 +613,10 @@ def _get_norm(poi: POI, id1: int | Vertebra_Instance, mv: MoveTo, location: Loca landmark (``Vertebral_Body_Endplate_Superior`` / ``_Inferior``) is preferred over the averaged vertebral-body posterior direction — this yields the classical endplate-line orientation used in Cobb-style lordosis/kyphosis measurements. - The endplate direction is used only when both the relevant endplate point and + The same automatic switch applies to ``Location.Vertebra_Direction_Right``: it + is projected into the endplate plane so Cobb (scoliosis) angles are measured + between endplate lines, analogous to the sagittal case. The endplate direction + is used only when both the relevant endplate point and ``Vertebra_Direction_Right`` are present in ``poi`` for that vertebra; otherwise the code falls back to the WK-based averaged direction below. """ @@ -494,6 +626,10 @@ def _get_norm(poi: POI, id1: int | Vertebra_Instance, mv: MoveTo, location: Loca ep_norm = _endplate_ap_direction(poi, id1, mv) if ep_norm is not None: return ep_norm * inv + if location == Location.Vertebra_Direction_Right and mv in (MoveTo.TOP, MoveTo.BOTTOM): + ep_norm = _endplate_r_direction(poi, id1, mv) + if ep_norm is not None: + return ep_norm * inv subreg = 50 if location in [Location.Vertebra_Disc_Inferior, Location.Vertebra_Disc_Superior]: subreg = 100 @@ -710,8 +846,13 @@ def compute_max_cobb_angle_multi( assert from_vert is not None assert to_vert is not None out_list.append((max_angle, from_vert, to_vert, apex)) + # Exclusive split: neither endpoint of the just-found curve may participate + # in a subsequent curve. Prevents overlaps like (T1-T5) + (T5-T7); a + # sibling curve below the current one starts strictly caudal to to_vert, + # a sibling above ends strictly cranial to from_vert. Drop the ``+ 1`` + # on the ``below`` slice to restore the textbook (endpoint-shared) split. above = vertebrae_list[: vertebrae_list.index(Vertebra_Instance(from_vert))] - below = vertebrae_list[vertebrae_list.index(Vertebra_Instance(to_vert)) :] + below = vertebrae_list[vertebrae_list.index(Vertebra_Instance(to_vert)) + 1 :] compute_max_cobb_angle_multi( poi, above, @@ -846,8 +987,15 @@ def plot_compute_lordosis_and_kyphosis( out.append((id1.value, s, (-a[0] * line_len * 3, -a[1] * line_len * 3))) out2 = compute_lordosis_and_kyphosis(poi, project_2D=project_2D) for name, v in out2.items(): - if v is None: + if v is None or name not in curvature_definition: + # Skip auxiliary keys like ``*_apex`` that live alongside the angles + # in the same dict but have no curve definition of their own. continue + # Apex annotation: mark the apex vertebra body with a star + label so + # the reader can see which vertebra the ``*_apex`` json key refers to. + apex_v = out2.get(f"{name}_apex") + if apex_v is not None and (apex_v, 50) in poi: + text_out.append((apex_v, ("*apex", -60))) id1 = curvature_definition[name].get_start_vert(poi) id2 = curvature_definition[name].get_stop_vert(poi) @@ -935,32 +1083,34 @@ def plot_cobb_angle( for id1, mv in zip([from_vert, to_vert], [vert_id1_mv, vert_id2_mv]): c = mv.get_location(id1, poi) - if use_ivd_direction and id1 > IVD_MORE_ACCURATE: - norm1_post = _get_norm(poi, id1, mv, Location.Vertebra_Direction_Posterior) - a = _get_norm(poi, id1, mv, Location.Vertebra_Disc_Inferior) - a = np.cross(a, norm1_post) - else: - a = _get_norm(poi, id1, mv, Location.Vertebra_Direction_Right) - - # print(a, id1, mv, c) + # Always go through Vertebra_Direction_Right so _get_norm routes + # to the endplate-plane right direction (chain-closed across the + # shared disc: `_endplate_r_direction` averages both flanking + # endplates). The old ``use_ivd_direction`` branch pulled + # ``Vertebra_Disc_Inferior`` from each vertebra separately — + # T9-BOTTOM used the T9/T10 disc but T10-TOP used the T10/T11 + # disc, so the same anatomic boundary got two different lines. + a = _get_norm(poi, id1, mv, Location.Vertebra_Direction_Right) assert a is not None out.append((apex, c, (-a[2] * line_len, a[1] * line_len))) out.append((apex, c, (a[2] * line_len, -a[1] * line_len))) - # a = _get_norm(poi, id1, mv, Location.Vertebra_Disc_Inferior) - # out.append((apex, c, (a[2] * line_len, -a[1] * line_len))) if apex is not None: - cord = poi[apex, 50] - s = f"copp angle: {max_angle:.1f}° {Vertebra_Instance(from_vert)} - {Vertebra_Instance(to_vert)}" - text_out.append((apex, (s, 25, cord[1]))) + # Align the label with the disc below the apex vertebra (IVD height) + # rather than the vertebra body centre, so the text sits at the same + # cranio-caudal level as the drawn Cobb line at the apex. + cord = poi[apex, Location.Vertebra_Disc.value] if (apex, Location.Vertebra_Disc.value) in poi else poi[apex, 50] + s = f"copp angle\n{max_angle:.1f}° {Vertebra_Instance(from_vert)} - {Vertebra_Instance(to_vert)}" + text_out.append((apex, (s, 35, cord[1]))) poi.info["line_segments_cor"] = out + poi.info.get("line_segments_cor", []) poi.info["text_cor"] = text_out + poi.info.get("text_cor", []) axis = poi.get_axis("R") width = poi.shape[axis] / poi.zoom[axis] / 2 - if width < 50: + min_half_width_mm = 80 + if width < min_half_width_mm: padd = [(0, 0) for _ in range(3)] - padd[axis] = (int(50 - width), int(50 - width)) + padd[axis] = (int(min_half_width_mm - width), int(min_half_width_mm - width)) img = to_nii(img).apply_pad(padd, verbose=False) seg = to_nii(seg, True).apply_pad(padd, verbose=False) poi = poi.resample_from_to(seg) diff --git a/TPTBox/spine/spinestats/veridah_angles.py b/TPTBox/spine/spinestats/veridah_angles.py index a607319..2fa366d 100644 --- a/TPTBox/spine/spinestats/veridah_angles.py +++ b/TPTBox/spine/spinestats/veridah_angles.py @@ -97,7 +97,7 @@ def _compute_k_variant(poi: POI, k: int, project_2D: bool = False) -> dict[str, if not relabeled.centroids: return {"lumbar_lordosis": None} full = compute_lordosis_and_kyphosis(relabeled, project_2D=project_2D) - return {"lumbar_lordosis": full.get("lumbar_lordosis")} + return {"lumbar_lordosis": full.get("lumbar_lordosis"), "lumbar_lordosis_apex": full.get("lumbar_lordosis_apex")} def _veridah_region_map(veridah_json_path: Path) -> dict[int, int] | None: From 01379ece805f9e23b3a5c12011195b94427a8935 Mon Sep 17 00:00:00 2001 From: Robert Graf Date: Tue, 22 Sep 2026 11:18:59 +0000 Subject: [PATCH 11/29] style: ruff --fix + ruff format across the branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Manual fixes in ``TPTBox/spine/spinestats/_load_nako_wh.py``: - D205 (verify_missing_images, verify_hardlink): reword docstrings so the summary line stands alone from the description body. - PLW2901 (``v = preferred`` at line 787): add ``# noqa: PLW2901`` to match the existing noqa on the sibling ``k = mapping.get(k, k)`` rebinding — the loop deliberately overrides both bindings. - PERF102 (line 1385): iterate the ``t2w_chunk`` mapping via ``.values()`` since only the value is used. - TRY300 (``_grid_worker``): move the success ``return`` out of the ``try`` block; only ``_add_grid_info_to_json`` may raise. Everything else is ``ruff check --fix`` and ``ruff format`` output. Co-Authored-By: Claude Opus 4.7 --- TPTBox/core/dicom/dicom_extract.py | 6 +-- TPTBox/core/internal/train_nnUnet/train.py | 8 +-- TPTBox/core/poi_fun/vector_fields.py | 2 +- TPTBox/spine/spinestats/_load_nako_wh.py | 21 ++++---- TPTBox/spine/spinestats/_qc_report.py | 37 +++++++------- .../measure_ivd_and_vertebra_geometry.py | 12 ++--- docs/api/poi_fun.md | 8 +-- unit_tests/test_poi_vector_fields.py | 51 +++++++++++++++---- 8 files changed, 85 insertions(+), 60 deletions(-) diff --git a/TPTBox/core/dicom/dicom_extract.py b/TPTBox/core/dicom/dicom_extract.py index 12eeb17..943f5ff 100644 --- a/TPTBox/core/dicom/dicom_extract.py +++ b/TPTBox/core/dicom/dicom_extract.py @@ -1031,11 +1031,7 @@ def extract_dicom_folder( # → no DICOM headers read for this source. Folders fingerprint their # rglob'd file list; zips fingerprint (size, mtime_ns) of the archive # itself (see `_source_fingerprint`). - if ( - skip_already_extracted - and not force_rescan - and _is_already_extracted(Path(dicom_path), Path(dataset_path_out)) - ): + if skip_already_extracted and not force_rescan and _is_already_extracted(Path(dicom_path), Path(dataset_path_out)): logger.print(f"Skip {dicom_path} (already extracted; fingerprint matches)", verbose=verbose) continue # Track the original source path so the marker below is keyed to the diff --git a/TPTBox/core/internal/train_nnUnet/train.py b/TPTBox/core/internal/train_nnUnet/train.py index 25ee589..a9677dc 100644 --- a/TPTBox/core/internal/train_nnUnet/train.py +++ b/TPTBox/core/internal/train_nnUnet/train.py @@ -190,9 +190,7 @@ def _ensure_smauglab_trainer_installed(trainer_class_name: str) -> None: f"`sudo smauglab_add_nnunettrainer --trainer {module_name} --overwrite` once." ) from e except OSError as e: - raise RuntimeError( - f"Failed to install SmaugLab trainer module {module_name!r} into nnunetv2 at {dst}: {e}" - ) from e + raise RuntimeError(f"Failed to install SmaugLab trainer module {module_name!r} into nnunetv2 at {dst}: {e}") from e def _apply_smauglab_params_env(trainer_class_name: str, dataset_folder: Path) -> None: @@ -278,9 +276,7 @@ def _run_training( assert not disable_checkpointing, "--val_best is not compatible with --disable_checkpointing" try: - nnunet_trainer = get_trainer_from_args( - dataset_name_or_id, configuration, fold, trainer_class_name, plans_identifier, device=device - ) + nnunet_trainer = get_trainer_from_args(dataset_name_or_id, configuration, fold, trainer_class_name, plans_identifier, device=device) except RuntimeError as e: hint = "" if trainer_class_name in _SMAUGLAB_TRAINERS: diff --git a/TPTBox/core/poi_fun/vector_fields.py b/TPTBox/core/poi_fun/vector_fields.py index 078cc69..e3271d4 100644 --- a/TPTBox/core/poi_fun/vector_fields.py +++ b/TPTBox/core/poi_fun/vector_fields.py @@ -107,7 +107,7 @@ def _remap_vector_field_keys_inplace(info: dict, region_map: dict | None, subreg Keys may be integer labels or ``Vertebra_Instance``-name strings; both are matched against the int-keyed maps. No-op if there is nothing to do. """ - from TPTBox.core.poi_fun.poi_abstract import LABEL_NAME, _GROUP_NAME_KEY, label_name_dict + from TPTBox.core.poi_fun.poi_abstract import _GROUP_NAME_KEY, LABEL_NAME, label_name_dict if not region_map and not subregion_map: return diff --git a/TPTBox/spine/spinestats/_load_nako_wh.py b/TPTBox/spine/spinestats/_load_nako_wh.py index fa18c64..6746b9c 100644 --- a/TPTBox/spine/spinestats/_load_nako_wh.py +++ b/TPTBox/spine/spinestats/_load_nako_wh.py @@ -199,8 +199,9 @@ def resolve_pick( def verify_missing_images(cache: DecisionCache, sub: str, subj_dict: dict) -> None: - """For each expected base image absent from ``subj_dict``, ask the user whether it's - really missing. If confirmed missing, drop the base and its dependent seg keys from + """For each expected base image absent from ``subj_dict``, ask the user whether it's really missing. + + If confirmed missing, drop the base and its dependent seg keys from ``subj_dict`` (set to None). Decisions are cached per (subject, image). """ for base, deps in EXPECTED_IMAGES.items(): @@ -784,7 +785,7 @@ def loop_over_repaired_nako( if k in _MEVIBE_EXTRA_KEYS and len(v) > 1: preferred = [bf for bf in v if "/derivatives_mevibe/" in _fmt_file(bf)] if preferred: - v = preferred + v = preferred # noqa: PLW2901 k = mapping.get(k, k) # noqa: PLW2901 if len(v) > 1: picked = resolve_pick(cache, sub, f"mevibe:{k}", f"Multiple mevibe files for {k}; pick one.", v) @@ -1344,10 +1345,12 @@ def _apply_corrections_to_subj_dict(sub: str, subj_dict: dict, index: dict) -> d def verify_hardlink(sub: str, corrected_index_path: Path = _DEFAULT_CORRECTED_INDEX) -> None: - """Run the loop for a single subject, apply corrections, then trace what - :func:`hard_link` *would* do and check every source exists and every target - would land on the same filesystem as its source (so :func:`os.link` won't - hit ``EXDEV``). Prints one line per file — no writes are performed. + """Run the loop for a single subject and verify what :func:`hard_link` would do. + + Applies corrections, traces what :func:`hard_link` *would* do, and checks + every source exists and every target would land on the same filesystem as + its source (so :func:`os.link` won't hit ``EXDEV``). Prints one line per + file — no writes are performed. """ index = load_corrected_index(corrected_index_path) sub = str(sub) @@ -1382,7 +1385,7 @@ def _check(bf, parent: str, info: dict | None = None) -> None: status = "ok" if src_exists and same_fs else ("cross-fs" if src_exists else "src-missing") print(f" [{status:>10s}] {src} -> {target}") - for _key, t2w in (d.get("t2w_chunk") or {}).items(): + for t2w in (d.get("t2w_chunk") or {}).values(): if t2w: _check(t2w[0], parent="rawdata", info={"ses": "baseline"}) seg_keys = ( @@ -1433,9 +1436,9 @@ def _grid_worker(nii_path: str) -> tuple[str, str]: sidecar = Path(str(p).split(".")[0] + ".json") try: _add_grid_info_to_json(p, sidecar, add=True) - return nii_path, "ok" except Exception as e: # noqa: BLE001 return nii_path, f"error: {type(e).__name__}: {e}" + return nii_path, "ok" def _iter_grid_targets(subj_dict: dict): diff --git a/TPTBox/spine/spinestats/_qc_report.py b/TPTBox/spine/spinestats/_qc_report.py index fd5eb02..54d3b12 100644 --- a/TPTBox/spine/spinestats/_qc_report.py +++ b/TPTBox/spine/spinestats/_qc_report.py @@ -79,9 +79,18 @@ def _summary_row(df: pd.DataFrame, col: str, lo: float, hi: float) -> dict: n_out = int(mask_out.sum()) if n_valid == 0: return { - "column": col, "n_total": n_total, "n_valid": 0, "n_missing": n_total, - "median": None, "iqr_low": None, "iqr_high": None, "min": None, "max": None, - "outlier_range": f"[{lo}, {hi}]", "n_outliers": 0, "pct_outliers": None, + "column": col, + "n_total": n_total, + "n_valid": 0, + "n_missing": n_total, + "median": None, + "iqr_low": None, + "iqr_high": None, + "min": None, + "max": None, + "outlier_range": f"[{lo}, {hi}]", + "n_outliers": 0, + "pct_outliers": None, } return { "column": col, @@ -99,9 +108,7 @@ def _summary_row(df: pd.DataFrame, col: str, lo: float, hi: float) -> dict: } -def _outlier_frame( - df: pd.DataFrame, col: str, lo: float, hi: float, extra_cols: list[str] -) -> pd.DataFrame: +def _outlier_frame(df: pd.DataFrame, col: str, lo: float, hi: float, extra_cols: list[str]) -> pd.DataFrame: v = pd.to_numeric(df[col], errors="coerce") mask = v.notna() & ((v < lo) | (v > hi)) if not mask.any(): @@ -133,8 +140,10 @@ def build_qc_report(folder: Path) -> Path: {"item": "per_subject_cols", "value": len(sub.columns)}, {"item": "n_vertebra_rows", "value": len(vert)}, {"item": "n_ivd_rows", "value": len(ivd)}, - {"item": "pelvic_error_rate_%", - "value": round(100.0 * sub.get("pelvic_parameters.error", pd.Series([np.nan] * len(sub))).notna().sum() / len(sub), 3)}, + { + "item": "pelvic_error_rate_%", + "value": round(100.0 * sub.get("pelvic_parameters.error", pd.Series([np.nan] * len(sub))).notna().sum() / len(sub), 3), + }, ] ) @@ -163,15 +172,9 @@ def build_qc_report(folder: Path) -> Path: # ------------------------------------------------------------------ # Per-column summary # ------------------------------------------------------------------ - sub_summary = pd.DataFrame( - [_summary_row(sub, c, lo, hi) for c, (lo, hi) in OUTLIER_SUBJECT.items() if c in sub.columns] - ) - vert_summary = pd.DataFrame( - [_summary_row(vert, c, lo, hi) for c, (lo, hi) in OUTLIER_LABEL.items() if c in vert.columns] - ) - ivd_summary = pd.DataFrame( - [_summary_row(ivd, c, lo, hi) for c, (lo, hi) in OUTLIER_LABEL.items() if c in ivd.columns] - ) + sub_summary = pd.DataFrame([_summary_row(sub, c, lo, hi) for c, (lo, hi) in OUTLIER_SUBJECT.items() if c in sub.columns]) + vert_summary = pd.DataFrame([_summary_row(vert, c, lo, hi) for c, (lo, hi) in OUTLIER_LABEL.items() if c in vert.columns]) + ivd_summary = pd.DataFrame([_summary_row(ivd, c, lo, hi) for c, (lo, hi) in OUTLIER_LABEL.items() if c in ivd.columns]) # ------------------------------------------------------------------ # Outlier rows diff --git a/TPTBox/spine/spinestats/measure_ivd_and_vertebra_geometry.py b/TPTBox/spine/spinestats/measure_ivd_and_vertebra_geometry.py index 163a72b..1d08bae 100644 --- a/TPTBox/spine/spinestats/measure_ivd_and_vertebra_geometry.py +++ b/TPTBox/spine/spinestats/measure_ivd_and_vertebra_geometry.py @@ -197,9 +197,7 @@ def measure_ivd_and_vertebra_geometry( raw = _compute_directional_heights_widths(vert, poi, label, step_size_mm=step_size_mm, raw=raw) # 3. normalized T2 signal if t2w_arr is not None: - raw = _compute_t2_signal_ratio( - t2w_arr, vert, label, spinal_canal_signal, spinal_canal_signal_old, raw=raw, erode=erode - ) + raw = _compute_t2_signal_ratio(t2w_arr, vert, label, spinal_canal_signal, spinal_canal_signal_old, raw=raw, erode=erode) results[label] = _result_from_info(info) except Exception as e: results[label] = _nan_result(error=str(e)) @@ -459,9 +457,7 @@ def _batched_ray_segments(mesh: trimesh.Trimesh, ray_direction: np.ndarray, orig """ n = origins.shape[0] directions = np.broadcast_to(ray_direction, (n, 3)) - locations, index_ray, _ = mesh.ray.intersects_location( - ray_origins=origins, ray_directions=directions, multiple_hits=True - ) + locations, index_ray, _ = mesh.ray.intersects_location(ray_origins=origins, ray_directions=directions, multiple_hits=True) lengths = np.zeros(n) first_pts = np.zeros((n, 3)) last_pts = np.zeros((n, 3)) @@ -484,7 +480,9 @@ def _batched_ray_segments(mesh: trimesh.Trimesh, ray_direction: np.ndarray, orig return lengths, first_pts, last_pts -def _grid_origins(base: np.ndarray, v1: np.ndarray, v2: np.ndarray, xs: np.ndarray, ys: np.ndarray) -> tuple[np.ndarray, np.ndarray, np.ndarray]: +def _grid_origins( + base: np.ndarray, v1: np.ndarray, v2: np.ndarray, xs: np.ndarray, ys: np.ndarray +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: """Return (origins, flat_x, flat_y) for a 2D grid of ray origins in the (v1, v2) plane.""" grid_x, grid_y = np.meshgrid(xs, ys, indexing="ij") flat_x = grid_x.ravel() diff --git a/docs/api/poi_fun.md b/docs/api/poi_fun.md index b4cd34a..f81f5ae 100644 --- a/docs/api/poi_fun.md +++ b/docs/api/poi_fun.md @@ -99,11 +99,11 @@ Use the accessors on `Abstract_POI` (available on both `POI` and `POI_Global`) instead of writing the dict directly: ```python -poi.set_label_name(region=2, subregion=10, name="FLCPC") # per-point label -poi.set_level_one_name(region=2, name="Femur") # region group name +poi.set_label_name(region=2, subregion=10, name="FLCPC") # per-point label +poi.set_level_one_name(region=2, name="Femur") # region group name -poi.label_name(2, 10) # -> "FLCPC" -poi.level_one_name(2) # -> "Femur" +poi.label_name(2, 10) # -> "FLCPC" +poi.level_one_name(2) # -> "Femur" ``` `region` / `subregion` accept `int`, numeric string, or `Enum` members. A diff --git a/unit_tests/test_poi_vector_fields.py b/unit_tests/test_poi_vector_fields.py index cac5fdb..9892251 100644 --- a/unit_tests/test_poi_vector_fields.py +++ b/unit_tests/test_poi_vector_fields.py @@ -269,16 +269,44 @@ class Test_LabelName_LegacyMigration(unittest.TestCase): """ _ATLAS_FLAT: ClassVar[dict[str, str]] = { - "(1, 1)": "TGT", "(1, 2)": "FHC", "(1, 3)": "FNC", "(1, 4)": "FAAP", - "(2, 1)": "FLCD", "(2, 2)": "FMCD", "(2, 3)": "FLCP", "(2, 4)": "FMCP", - "(2, 5)": "FNP", "(2, 6)": "FADP", "(2, 7)": "TGPP", "(2, 8)": "TGCP", - "(2, 9)": "FMCPC", "(2, 10)": "FLCPC", "(2, 11)": "TRMP", "(2, 12)": "TRLP", - "(3, 1)": "TLCL", "(3, 2)": "TMCM", "(3, 3)": "TKC", "(3, 4)": "TLCA", - "(3, 5)": "TLCP", "(3, 6)": "TMCA", "(3, 7)": "TMCP", "(3, 8)": "TTP", - "(3, 9)": "TAAP", "(3, 10)": "TMIT", "(3, 11)": "TLIT", - "(4, 1)": "FLM", "(4, 2)": "TMM", "(4, 3)": "TAC", "(4, 4)": "TADP", - "(5, 1)": "PPP", "(5, 2)": "PDP", "(5, 3)": "PMP", "(5, 4)": "PLP", - "(5, 5)": "PRPP", "(5, 6)": "PRDP", "(5, 7)": "PRHP", + "(1, 1)": "TGT", + "(1, 2)": "FHC", + "(1, 3)": "FNC", + "(1, 4)": "FAAP", + "(2, 1)": "FLCD", + "(2, 2)": "FMCD", + "(2, 3)": "FLCP", + "(2, 4)": "FMCP", + "(2, 5)": "FNP", + "(2, 6)": "FADP", + "(2, 7)": "TGPP", + "(2, 8)": "TGCP", + "(2, 9)": "FMCPC", + "(2, 10)": "FLCPC", + "(2, 11)": "TRMP", + "(2, 12)": "TRLP", + "(3, 1)": "TLCL", + "(3, 2)": "TMCM", + "(3, 3)": "TKC", + "(3, 4)": "TLCA", + "(3, 5)": "TLCP", + "(3, 6)": "TMCA", + "(3, 7)": "TMCP", + "(3, 8)": "TTP", + "(3, 9)": "TAAP", + "(3, 10)": "TMIT", + "(3, 11)": "TLIT", + "(4, 1)": "FLM", + "(4, 2)": "TMM", + "(4, 3)": "TAC", + "(4, 4)": "TADP", + "(5, 1)": "PPP", + "(5, 2)": "PDP", + "(5, 3)": "PMP", + "(5, 4)": "PLP", + "(5, 5)": "PRPP", + "(5, 6)": "PRDP", + "(5, 7)": "PRHP", } def test_normalize_label_name_migrates_flat_atlas(self): @@ -376,7 +404,8 @@ def test_migration_then_map_labels(self): class Test_LabelName_Accessors(unittest.TestCase): """`set_label_name` / `set_level_one_name` write into ``info['label_name']`` and get read back by ``label_name`` / ``level_one_name`` and by the - Slicer/mkr exporter.""" + Slicer/mkr exporter. + """ def _poi_with_enums(self) -> POI: from TPTBox.core.vert_constants import Location, Vertebra_Instance From 1a723a803ebc513c187ef43d16c1f700d1196847 Mon Sep 17 00:00:00 2001 From: Robert Graf Date: Tue, 22 Sep 2026 11:33:17 +0000 Subject: [PATCH 12/29] fix example test case compile --- TPTBox/spine/spinestats/_load_nako_wh.py | 14 ++++++++++++-- docs/api/poi_fun.md | 4 ++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/TPTBox/spine/spinestats/_load_nako_wh.py b/TPTBox/spine/spinestats/_load_nako_wh.py index 6746b9c..dd73963 100644 --- a/TPTBox/spine/spinestats/_load_nako_wh.py +++ b/TPTBox/spine/spinestats/_load_nako_wh.py @@ -761,8 +761,18 @@ def loop_over_repaired_nako( q.filter_format("mevibe") # q.filter("sequ", "me1") mevibe_fams = list(q.loop_dict(key_addendum=["mod", "part", "desc"])) - # Drop derivative-only families that don't carry the raw echo images. - mevibe_fams = [f for f in mevibe_fams if "mevibe_part-eco0-opp1" in f] + # Drop derivative-only families and incomplete acquisitions: get_corrected_mevibe + # unconditionally indexes all six echoes, so a family missing any of them would crash. + _echo_keys = [f"mevibe_part-{k}" for k in ("eco0-opp1", "eco1-pip1", "eco2-opp2", "eco3-in1", "eco4-pop1", "eco5-arb1")] + _kept = [] + for f in mevibe_fams: + missing = [k for k in _echo_keys if k not in f] + if missing: + if "mevibe_part-eco0-opp1" in f: + log.on_warning(f"sub-{sub}: incomplete mevibe family {f.family_id!r} (missing {missing}); skipping") + continue + _kept.append(f) + mevibe_fams = _kept if len(mevibe_fams) > 1: labels = [str(f.get("mevibe_part-eco0-opp1", f)) for f in mevibe_fams] cached_pick = _cached_pick(cache.get(sub, "mevibe_fam")) diff --git a/docs/api/poi_fun.md b/docs/api/poi_fun.md index f81f5ae..675c4fb 100644 --- a/docs/api/poi_fun.md +++ b/docs/api/poi_fun.md @@ -82,13 +82,13 @@ from TPTBox.core.poi_fun.vector_fields import ( vec_fields = poi.info.setdefault(POI_INFO_VECTOR_FIELDS_KEY, []) if "my_vector_field" not in vec_fields: vec_fields.append("my_vector_field") -poi.info["my_vector_field"] = {"L1": (0.09, -0.99, -0.02), ...} +poi.info["my_vector_field"] = {"L1": (0.09, -0.99, -0.02)} # ... # Scalar per-vertebra metadata (key-remapped by map_labels only): lbl_fields = poi.info.setdefault(POI_INFO_LABEL_KEYED_FIELDS_KEY, []) if "my_scalar_field" not in lbl_fields: lbl_fields.append("my_scalar_field") -poi.info["my_scalar_field"] = {"L1": 3.14, ...} +poi.info["my_scalar_field"] = {"L1": 3.14} # ... ``` **Assigning names (`info["label_name"]`):** From cbf9afa60e597080738c0b78820ef94fa2098677 Mon Sep 17 00:00:00 2001 From: Robert Graf Date: Tue, 22 Sep 2026 12:08:21 +0000 Subject: [PATCH 13/29] skipt T13 for apex --- TPTBox/spine/spinestats/angles.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/TPTBox/spine/spinestats/angles.py b/TPTBox/spine/spinestats/angles.py index 2a3934d..2ac3553 100644 --- a/TPTBox/spine/spinestats/angles.py +++ b/TPTBox/spine/spinestats/angles.py @@ -751,6 +751,8 @@ def compute_max_cobb_angle( assert b is not None apex_v = (a + b) / 2 for i in vertebrae_list[vertebrae_list.index(Vertebra_Instance(from_vert)) : vertebrae_list.index(Vertebra_Instance(to_vert)) + 1]: + if i.value not in poi.keys_region(): + continue try: a = _get_norm(poi, i, vert_id2_mv, Location.Vertebra_Direction_Right, 1) if a is None: From d58bfd027040de97c4648e18b9a84068bea5df86 Mon Sep 17 00:00:00 2001 From: Robert Graf Date: Tue, 22 Sep 2026 12:27:42 +0000 Subject: [PATCH 14/29] last_vert must be in poi --- TPTBox/spine/spinestats/poi_fun/endplates.py | 37 ++++++++++++-------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/TPTBox/spine/spinestats/poi_fun/endplates.py b/TPTBox/spine/spinestats/poi_fun/endplates.py index b14ec2b..b2dab43 100644 --- a/TPTBox/spine/spinestats/poi_fun/endplates.py +++ b/TPTBox/spine/spinestats/poi_fun/endplates.py @@ -454,9 +454,16 @@ def calc_endplate_points_( endplate_nii = c.extract_label(superior_label) cms_local_override = None - last_vert = max(vert_ids) - - if (last_vert, Location.Vertebral_Body_Endplate_Inferior.value) in poi: + # A vertebra can appear in the segmentation (vert_ids) but have no POI + # entry (e.g. too few voxels for a centroid). Pick the highest vertebra + # that actually has a centroid in poi so the fallback to Vertebra_Corpus + # below cannot KeyError. + vert_ids_in_poi = [v for v in vert_ids if (v, Location.Vertebra_Corpus.value) in poi] + last_vert = max(vert_ids_in_poi) if vert_ids_in_poi else None + + if last_vert is None: + pass # no anchor available; skip the sacrum endplate override + elif (last_vert, Location.Vertebral_Body_Endplate_Inferior.value) in poi: cms_local_override = poi[last_vert, Location.Vertebral_Body_Endplate_Inferior] elif (last_vert, Location.Vertebra_Disc.value) in poi: cms_local_override = poi[last_vert, Location.Vertebra_Disc.value] @@ -464,17 +471,19 @@ def calc_endplate_points_( cms_local_override = vert.extract_label(100 + last_vert).center_of_masses()[1] else: cms_local_override = poi[last_vert, Location.Vertebra_Corpus] - _endplate( - poi, - endplate_nii, - Location.Vertebral_Body_Endplate_Superior, - Vertebra_Instance.S1.value, - log, - normals_by_vert, - cms_local_override=cms_local_override, - flip_direction=True, - compute_curvature=compute_curvature, - ) + + if last_vert is not None: + _endplate( + poi, + endplate_nii, + Location.Vertebral_Body_Endplate_Superior, + Vertebra_Instance.S1.value, + log, + normals_by_vert, + cms_local_override=cms_local_override, + flip_direction=True, + compute_curvature=compute_curvature, + ) # Angle between superior and inferior endplate normals, per vertebra. for vert_id, normals in normals_by_vert.items(): n_sup = normals.get(Location.Vertebral_Body_Endplate_Superior) From b0df6f0d4ce29e950ee94f235bf6475d5513bd7c Mon Sep 17 00:00:00 2001 From: robert Date: Thu, 24 Sep 2026 10:36:00 +0200 Subject: [PATCH 15/29] fix min_value, smaller overlap nii to save RAM --- TPTBox/stitching/stitching.py | 184 +++++++++++++++++++++------- TPTBox/stitching/stitching_tools.py | 17 ++- 2 files changed, 157 insertions(+), 44 deletions(-) diff --git a/TPTBox/stitching/stitching.py b/TPTBox/stitching/stitching.py index 1fcf676..0f1fb33 100755 --- a/TPTBox/stitching/stitching.py +++ b/TPTBox/stitching/stitching.py @@ -464,15 +464,25 @@ def _auto_output_dtype(niis: list[nib.nifti1.Nifti1Image]) -> type: """Pick the smallest lossless output dtype from the inputs' on-disk dtypes. Reads only the NIfTI headers — no pixel data is loaded. If every input is - integer-typed, returns the widest integer dtype that covers them all; - otherwise falls back to float32. This lets the stitcher store magnitude - MR outputs as uint16 (or int16) when the source already fit in 16 bits, - halving the on-disk and downstream RAM footprint versus float64. + integer-typed AND has a trivial slope/inter (so the raw storage range is + the true value range), returns the widest integer dtype that covers them + all; otherwise falls back to float32. This lets the stitcher store + magnitude MR outputs as uint16 (or int16) when the source already fit in + 16 bits, halving the on-disk and downstream RAM footprint versus float64. """ dtypes = [np.dtype(nii.get_data_dtype()) for nii in niis] - if all(np.issubdtype(d, np.integer) for d in dtypes): - return max(dtypes, key=lambda d: d.itemsize).type # e.g. np.uint16 - return np.float32 + if not all(np.issubdtype(d, np.integer) for d in dtypes): + return np.float32 + for nii in niis: + slope = getattr(nii.dataobj, "slope", 1.0) + inter = getattr(nii.dataobj, "inter", 0.0) + if slope is None or inter is None: + return np.float32 + if not (np.isfinite(slope) and np.isfinite(inter)): + return np.float32 + if float(slope) != 1.0 or float(inter) != 0.0: + return np.float32 + return max(dtypes, key=lambda d: d.itemsize).type # e.g. np.uint16 def main( # noqa: C901 @@ -481,7 +491,7 @@ def main( # noqa: C901 match_histogram: bool = False, store_ramp: bool = False, verbose: bool = False, - min_value: float = 0, + min_value: float | None = None, bias_field: bool = True, crop_to_bias_field: bool = False, crop_empty: bool = False, @@ -517,8 +527,16 @@ def main( # noqa: C901 store_ramp: If True, also saves the per-volume blend weights as a 4-D NIfTI alongside the stitched output. verbose: If True, prints progress messages to stdout. - min_value: Background value (0 for MRI, -1024 for CT). Voxels at or - below this value are replaced by it in the output. + min_value: Background fill used as ``cval`` when resampling each chunk + into the target space, and — when explicitly set — a hard floor + applied to the stitched output. Pass ``0`` for MR, ``-1024`` for + CT. Default ``None``: use ``0`` internally as the background / + NaN-fill and only apply a hard floor when the output dtype cannot + represent negatives (unsigned integer types), which prevents + negative-to-huge-positive wraparound on the cast. Explicit values + (segmentations force ``0``, CT callers pass ``-1024``) are always + enforced; ``None`` lets signed / float outputs keep legitimate + negative signal (Philips-scaled fat-fraction, phase, B0 offsets). bias_field: If True, applies N4 bias-field correction to each input before stitching. Forced to False for segmentations. crop_to_bias_field: If True, crops each bias-corrected volume to the @@ -554,6 +572,7 @@ def main( # noqa: C901 min_value = 0 match_histogram = False histogram = None + _bg_value: float = 0.0 if min_value is None else float(min_value) if len(images) == 0 or len(images) == 1: print("!!! Need at least two images (-i ...nii.gz ...nii.gz) to stitch!!!\n Got " + str(images)) return None, None @@ -564,8 +583,20 @@ def main( # noqa: C901 for f_name in images: if isinstance(f_name, (Path, str)): print("Load ", f_name, Path(f_name)) if verbose else None - # Load Nii - nii: nib.nifti1.Nifti1Image = nib.load(f_name) # type: ignore + # Load NII + try: + from TPTBox.core.nii_wrapper import to_nii as _to_nii_load + + _nii_wrap = _to_nii_load(Path(f_name)) + _arr_corrected = _nii_wrap.get_array() + nii = nib.Nifti1Image(_arr_corrected, _nii_wrap.affine) + nii.set_data_dtype(_arr_corrected.dtype) + try: + nii.header.set_slope_inter(1.0, 0.0) + except Exception: # noqa: BLE001 + pass + except Exception: # noqa: BLE001 + nii: nib.nifti1.Nifti1Image = nib.load(f_name) # type: ignore else: nii = f_name @@ -589,7 +620,7 @@ def main( # noqa: C901 image = get_array(nii) matched = match_histograms(image.astype(float), reference.astype(float)) - matched[matched <= min_value] = min_value + matched[matched <= _bg_value] = _bg_value nii = set_array(nii, matched) niis.append(nii) @@ -617,11 +648,12 @@ def main( # noqa: C901 dtype = dtype2 else: # Auto-detect the output dtype from the input headers when the caller - # asked for "auto". Blending math still runs in float; only the final - # cast at save time uses the picked dtype (e.g. uint16 for magnitude MR). + # asked for "auto". Blending math runs in float32 (was float64) — + # halves peak RAM of target_list/occupancy_list on large stitched + # volumes; the final cast at save time uses `dtype` (uint16 etc.). if isinstance(dtype, str) and dtype == "auto": dtype = _auto_output_dtype(niis) - dtype2 = float + dtype2 = np.float32 nii_out = get_max_affine_and_shape(corners_current, affines, min_spacing=min_spacing, dtype=dtype2, verbose=verbose) target_list = [] occupancy_list = [] @@ -629,10 +661,10 @@ def main( # noqa: C901 print("### resample to new space ###") if verbose else None for i, nii in enumerate(niis, 1): print(f"{i:2}/{len(niis):2} resampled", end="\r") if verbose else None - nii_new = nip.resample_from_to(nii, nii_out, 0 if is_segmentation else 3, mode="constant", cval=min_value) + nii_new = nip.resample_from_to(nii, nii_out, 0 if is_segmentation else 3, mode="constant", cval=_bg_value) arr_new = get_array(nii_new) if not is_segmentation and np.issubdtype(arr_new.dtype, np.floating): - np.nan_to_num(arr_new, copy=False, nan=min_value, posinf=min_value, neginf=min_value) + np.nan_to_num(arr_new, copy=False, nan=_bg_value, posinf=_bg_value, neginf=_bg_value) target_list.append(arr_new) b = nib.Nifti1Image(np.ones(nii.shape, dtype=np.float32), affine=nii.affine) # type: ignore b = nip.resample_from_to(b, nii_new, 0, cval=0, mode="constant") @@ -649,22 +681,50 @@ def main( # noqa: C901 # inside the ramp loop for pairs that don't touch. `_occupancy_bbox` # returns None for an empty occupancy — treated as "no overlap possible". bboxes = [_occupancy_bbox(occ) for occ in occupancy_list] + grid_shape_arr = np.asarray(occupancy_list[0].shape, dtype=np.int64) + # Padding around the joint AABB: `ramp_edge_min_value` so binary_opening's + # erode+dilate at the crop boundary yields the same result as on the full + # volume; +1 slack for the distance transform. + _ramp_pad = max(int(ramp_edge_min_value), 1) + 1 # ramp stitching combinations = list(itertools.combinations(range(len(target_list)), 2)) + _ramp_done = 0 + _ramp_skipped_aabb = 0 + _ramp_skipped_no_voxel_overlap = 0 for idx, item in enumerate(combinations, 1): print(f"{idx:2}/{len(combinations):2} ramp stitching", end="\r") if verbose else None # Skip disjoint pairs before touching the full-volume arrays. if not _aabb_overlaps(bboxes[item[0]], bboxes[item[1]]): + _ramp_skipped_aabb += 1 continue + # Work on the union-AABB sub-volume of the two chunks. Outside the + # union `arr_i / sum_` equals the original `arr_i_full` (there, + # overlap = 0, arr_i_ = binary mask of arr_i and the "other" mask + # is 0, so sum_ = arr_i_ and arr_i / sum_ = arr_i). And chunk i's + # occupancy support is fully contained in bboxes[i] ⊆ union, so + # writing the result back only at the sub-slice is functionally + # identical to the previous full-volume compute — but the ramp's + # peak RAM drops from ~full-volume to ~union-AABB size (typically + # 2 adjacent chunks tall). + lo_i, hi_i = bboxes[item[0]] + lo_j, hi_j = bboxes[item[1]] + lo = np.maximum(np.minimum(lo_i, lo_j) - _ramp_pad, 0) + hi = np.minimum(np.maximum(hi_i, hi_j) + _ramp_pad, grid_shape_arr - 1) + sub = ( + slice(int(lo[0]), int(hi[0]) + 1), + slice(int(lo[1]), int(hi[1]) + 1), + slice(int(lo[2]), int(hi[2]) + 1), + ) # TODO fix intersection with more than two occupancies arr_1_full = occupancy_list[item[0]] arr_2_full = occupancy_list[item[1]] ### structure = np.ones((ramp_edge_min_value, ramp_edge_min_value, ramp_edge_min_value), dtype=bool) - arr_1: np.ndarray = arr_1_full.copy() - arr_2: np.ndarray = arr_2_full.copy() + arr_1: np.ndarray = arr_1_full[sub].astype(np.float32, copy=True) + arr_2: np.ndarray = arr_2_full[sub].astype(np.float32, copy=True) overlap = (arr_1 * arr_2) > 0.0 if overlap.sum() > 0: + _ramp_done += 1 arr_1_ = (arr_1 > 0.0).astype(np.float32) - overlap arr_2_ = (arr_2 > 0.0).astype(np.float32) - overlap if ramp_edge_min_value == 0: @@ -680,28 +740,32 @@ def main( # noqa: C901 arr_2_[overlap] = arr_2[overlap] sum_ = arr_1_ + arr_2_ sum_[sum_ == 0] = 1.0 - arr_1_full = arr_1 / sum_ - arr_2_full = arr_2 / sum_ - if arr_1_full.max() != 1: + arr_1_sub = arr_1 / sum_ + arr_2_sub = arr_2 / sum_ + # Chunk i's occupancy is fully contained inside bboxes[i] ⊆ sub, + # so the sub-array max equals the volume-wide max. + max_1 = float(arr_1_sub.max()) + max_2 = float(arr_2_sub.max()) + if max_1 != 1: import warnings warnings.warn( - str((arr_1_full.min(), arr_1_full.max())) + " the image in fully incorporated insight of an other " + str(images), + str((float(arr_1_sub.min()), max_1)) + " the image in fully incorporated insight of an other " + str(images), stacklevel=4, ) if kick_out_fully_integrated_images: images.pop(item[0]) - elif arr_2_full.max() != 1: + elif max_2 != 1: import warnings warnings.warn( - str((arr_2_full.min(), arr_2_full.max())) + " the image in fully incorporated insight of an other " + str(images), + str((float(arr_2_sub.min()), max_2)) + " the image in fully incorporated insight of an other " + str(images), stacklevel=4, ) if kick_out_fully_integrated_images: images.pop(item[1]) - if (arr_1_full.max() != 1 or arr_2_full.max() != 1) and kick_out_fully_integrated_images: + if (max_1 != 1 or max_2 != 1) and kick_out_fully_integrated_images: print("kick_out_fully_integrated_images") print(images) @@ -721,20 +785,45 @@ def main( # noqa: C901 kick_out_fully_integrated_images, save, ) - # assert arr_1_full.max() == 1, (arr_1_full.min(), arr_1_full.max()) - # assert arr_2_full.max() == 1, (arr_2_full.min(), arr_2_full.max()) - occupancy_list[item[0]] = arr_1_full - occupancy_list[item[1]] = arr_2_full + arr_1_full[sub] = arr_1_sub.astype(arr_1_full.dtype, copy=False) + arr_2_full[sub] = arr_2_sub.astype(arr_2_full.dtype, copy=False) else: + _ramp_skipped_no_voxel_overlap += 1 continue - occupancy_arr = np.stack(occupancy_list) - if is_segmentation: - occupancy_arr = np.round(occupancy_arr) # TODO assuming only two intersecting regions - target_arr = np.stack(target_list) * occupancy_arr + if verbose: + print( + f"\nramp summary: {_ramp_done} computed, " + f"{_ramp_skipped_aabb} skipped (disjoint AABB), " + f"{_ramp_skipped_no_voxel_overlap} skipped (no voxel overlap) " + f"of {len(combinations)} pairs" + ) + # Aggregate: in-place accumulate `t * occupancy` per chunk instead of + # `np.stack(target_list) * np.stack(occupancy_list)` which would peak at + # ~2 × N × volume of temporary float arrays. + target_arr = np.zeros(target_list[0].shape, dtype=dtype2) if is_segmentation: - target_arr = target_arr.astype(dtype2) - target_arr = target_arr.sum(0) - target_arr[target_arr <= min_value] = min_value + for t, o in zip(target_list, occupancy_list): + target_arr += (t * np.round(o)).astype(dtype2) # TODO assuming only two intersecting regions + else: + for t, o in zip(target_list, occupancy_list): + target_arr += t * o + # Hard-floor policy: + # * If the caller passed `min_value` explicitly (segmentations force 0, + # CT typically passes -1024), always apply that floor. + # * If `min_value is None` (the default), only apply a floor when the + # output dtype cannot represent negatives (unsigned integer types) — + # otherwise negative-to-huge-positive wraparound would silently corrupt + # the save. Signed/float outputs keep legitimate negative signal + # (Philips-scaled fat-fraction, phase, B0 offsets). + _floor: float | None + if min_value is not None: + _floor = float(min_value) + elif np.issubdtype(np.dtype(dtype), np.unsignedinteger): + _floor = 0.0 + else: + _floor = None + if _floor is not None: + target_arr[target_arr <= _floor] = _floor print("\n### Save ###") if verbose else None if output is not None: output = str(output) @@ -748,9 +837,22 @@ def main( # noqa: C901 if bias_field: nii_out = n4_bias_field_correction(nii_out) if crop_empty: - nii_occ = set_array(nii_out, occupancy_arr) - ex_slice = compute_crop_slice(nii_occ) - nii_out = nii_out.slicer[ex_slice] + # Crop to the union of per-chunk occupancy AABBs. The previous path + # went through compute_crop_slice on a 4-D (N, X, Y, Z) stack, which + # sliced the wrong axes when applied to 3-D nii_out; deriving the + # crop directly from `bboxes` is both cheaper and correct. + valid_bboxes = [b for b in bboxes if b is not None] + if valid_bboxes: + lo = np.stack([b[0] for b in valid_bboxes]).min(axis=0) + hi = np.stack([b[1] for b in valid_bboxes]).max(axis=0) + ex_slice = ( + slice(int(lo[0]), int(hi[0]) + 1), + slice(int(lo[1]), int(hi[1]) + 1), + slice(int(lo[2]), int(hi[2]) + 1), + ) + nii_out = nii_out.slicer[ex_slice] + else: + ex_slice = () else: ex_slice = () diff --git a/TPTBox/stitching/stitching_tools.py b/TPTBox/stitching/stitching_tools.py index a59d8ec..d42c67d 100755 --- a/TPTBox/stitching/stitching_tools.py +++ b/TPTBox/stitching/stitching_tools.py @@ -23,6 +23,7 @@ def stitching( match_histogram: bool = False, store_ramp: bool = False, ramp_path=None, + min_value: float | None = None, ) -> tuple: """Stitch a list of BIDS/NII volumes into a single output NIfTI file. @@ -37,8 +38,18 @@ def stitching( :class:`BIDS_FILE` is provided, the ``"nii.gz"`` file path is used. is_seg: If True, treats the inputs as segmentation images (disables bias field and histogram matching, uses integer dtypes). - is_ct: If True, sets the background ``min_value`` to ``-1024`` (CT - air) instead of ``0`` (MRI). + is_ct: If True and ``min_value`` was not passed explicitly, sets the + background ``min_value`` to ``-1024`` (CT air). + min_value: Explicit background/floor for :func:`stitching_raw`. + ``None`` (the default, and what non-CT MR normally uses) lets the + stitcher only apply a hard floor when the output dtype cannot + represent negatives (unsigned int), so quantitative maps + (fat-fraction, phase, B0) keep their legitimate negatives. Pass + ``0`` to floor the output at 0 — the right choice for magnitude + MR (in/out-phase, water, fat, per-echo magnitude), where the + cubic-spline resample can leave small negative ringing artefacts + that a downstream network wasn't trained on. Overrides ``is_ct`` + when both are given. verbose_stitching: If True, forwards verbose output from the low-level stitching routine. bias_field: If True, applies N4 bias-field correction to each input. @@ -66,7 +77,7 @@ def stitching( match_histogram=match_histogram, store_ramp=store_ramp, verbose=verbose_stitching, - min_value=-1024 if is_ct else 0, + min_value=(min_value if min_value is not None else (-1024 if is_ct else None)), bias_field=bias_field, kick_out_fully_integrated_images=kick_out_fully_integrated_images, is_segmentation=is_seg, From ad09d9e73806a2b34f513e10a05df05d71fcbb1d Mon Sep 17 00:00:00 2001 From: robert Date: Fri, 25 Sep 2026 09:17:28 +0200 Subject: [PATCH 16/29] add c_val option --- TPTBox/core/internal/nii_help.py | 3 ++- TPTBox/core/nii_wrapper.py | 8 ++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/TPTBox/core/internal/nii_help.py b/TPTBox/core/internal/nii_help.py index 5f8b081..ba79b0d 100644 --- a/TPTBox/core/internal/nii_help.py +++ b/TPTBox/core/internal/nii_help.py @@ -205,6 +205,7 @@ def _resample_from_to( mode: MODES = "nearest", align_corners: bool | Sentinel = Sentinel(), # noqa: B008 out_dtype: np.dtype | type | str | None = None, + c_val: float | None = None, ) -> tuple[np.ndarray, np.ndarray, object]: """Resample *from_img* into the voxel space defined by *to_img*. @@ -323,7 +324,7 @@ def _resample_from_to( to_shape, order=order, mode=mode, - cval=from_img.get_c_val(), + cval=from_img.get_c_val(c_val), output=scipy_out, ) if post_cast is not None: diff --git a/TPTBox/core/nii_wrapper.py b/TPTBox/core/nii_wrapper.py index 3cbb826..93c6512 100755 --- a/TPTBox/core/nii_wrapper.py +++ b/TPTBox/core/nii_wrapper.py @@ -999,7 +999,7 @@ def pad_to(self, target_shape: list[int] | tuple[int, int, int] | Self, mode: MO s = s.apply_crop(tuple(crop),inplace=inplace) return s.apply_pad(padding,inplace=inplace,mode=mode) - def apply_pad(self, padd: Sequence[tuple[int | None, int | None]] | int | None, mode: MODES = "constant", inplace=False, verbose: logging = True,) -> Self: + def apply_pad(self, padd: Sequence[tuple[int | None, int | None]] | int | None, mode: MODES = "constant", inplace=False, verbose: logging = True, c_val: float | None = None) -> Self: """Pads the image with explicit per-axis ``(before, after)`` amounts. The affine is updated so that the world-space origin is preserved (i.e. the @@ -1064,7 +1064,7 @@ def apply_pad(self, padd: Sequence[tuple[int | None, int | None]] | int | None, args = {} if mode == "constant": - args["constant_values"] = self.get_c_val() + args["constant_values"] = self.get_c_val(c_val) if mode == "nearest": mode = "edge" @@ -1249,7 +1249,7 @@ def resample_from_to(self, to_vox_map:Image_Reference|Has_Grid|tuple[SHAPE,AFFIN pad_after = dst_shape - shift - src_shape pad = tuple((int(b), int(a)) for b, a in zip(pad_before, pad_after)) try: - ret = s.apply_pad(pad,mode=mode,inplace=inplace,verbose=verbose) + ret = s.apply_pad(pad,mode=mode,inplace=inplace,verbose=verbose,c_val=c_val) valid = ret.assert_affine(mapping,raise_error=False,origin_tolerance=0.0001,error_tolerance=0.0001,shape_tolerance=0) if valid: log.print(f"resample_from_to only needs padding/cropping {pad}",verbose=verbose) @@ -1263,7 +1263,7 @@ def resample_from_to(self, to_vox_map:Image_Reference|Has_Grid|tuple[SHAPE,AFFIN log.print(f"resample_from_to: {self} to {mapping}",verbose=verbose) if order is None: order = 0 if self.seg else 3 - nii = _resample_from_to(self, mapping,order=order, mode=mode,align_corners=align_corners, out_dtype=out_dtype) + nii = _resample_from_to(self, mapping,order=order, mode=mode,align_corners=align_corners, out_dtype=out_dtype, c_val=c_val) if inplace: From be514be118c0c7be9ca37fcd1bd8128c23be3bf7 Mon Sep 17 00:00:00 2001 From: robert Date: Fri, 25 Sep 2026 09:17:56 +0200 Subject: [PATCH 17/29] only warn if memory is not trival --- .../segmentation/VibeSeg/inference_nnunet.py | 43 +++++++++++++++---- 1 file changed, 34 insertions(+), 9 deletions(-) diff --git a/TPTBox/segmentation/VibeSeg/inference_nnunet.py b/TPTBox/segmentation/VibeSeg/inference_nnunet.py index 3a14dae..0f0711b 100644 --- a/TPTBox/segmentation/VibeSeg/inference_nnunet.py +++ b/TPTBox/segmentation/VibeSeg/inference_nnunet.py @@ -267,13 +267,6 @@ def run_inference_on_file( if "memory_factor" not in ds_info: missing_mem_keys.append("memory_factor") memory_factor = float(ds_info.get("memory_factor", 160)) - if missing_mem_keys: - _suggest_memory_estimation_script( - idx, - model_path, - f"Memory parameter(s) {missing_mem_keys} not set in the model's dataset.json; falling back to defaults. {memory_base=}, {memory_factor=}", - logger=logger, - ) use_folds_arg = tuple(folds) if len(folds) != 5 else None # Include every setting that changes the loaded predictor so a cache hit is always equivalent @@ -362,11 +355,43 @@ def run_inference_on_file( if padd != 0: p = (padd, padd) input_nii = [i.apply_pad([p, p, p], mode="reflect") for i in input_nii] + + def _defaults_fit_gpu(memory: float, gpu: int | None, safety_factor: float = 2.0) -> bool: + """Whether the fallback ``memory_base`` reservation is a small fraction of GPU memory. + + Returns ``True`` when ``memory_base * safety_factor <= total_gpu_memory_mb`` — + i.e. the fallback reservation leaves ample headroom for tile scheduling and + the missing ``memory_base``/``memory_factor`` defaults are safe to use + without warning. Returns ``False`` on CUDA-unavailable systems or when GPU + memory can't be probed, so the load-time warning still fires on + memory-constrained setups (CPU-only, small GPUs, or driver errors). + """ + try: + import torch + + if not torch.cuda.is_available(): + return False + device = torch.device(f"cuda:{gpu}") if gpu is not None else torch.device("cuda:0") + _, total_bytes = torch.cuda.mem_get_info(device) + total_mb = total_bytes / (1024**2) + except Exception: # noqa: BLE001 + return False + return memory * safety_factor <= total_mb + + num_classes = int(nnunet.label_manager.num_segmentation_heads) + est_full_mb = estimate_peak_ram_mb(input_nii[0].shape, num_classes, len(input_nii)) + # Only warn if memory requirement is non-trivial. + if missing_mem_keys and not _defaults_fit_gpu(est_full_mb, gpu): + _suggest_memory_estimation_script( + idx, + model_path, + f"Memory parameter(s) {missing_mem_keys} not set in the model's dataset.json; falling back to defaults. {memory_base=}, {memory_factor=}", + logger=logger, + ) + if _cpu_chunks is None or _cpu_chunks <= 1: - num_classes = int(nnunet.label_manager.num_segmentation_heads) total_ram_mb = _get_total_ram_mb() target_ram_mb = total_ram_mb * 0.5 - est_full_mb = estimate_peak_ram_mb(input_nii[0].shape, num_classes, len(input_nii)) if est_full_mb > target_ram_mb: shape = input_nii[0].shape split_axis = int(np.argmax(shape)) From 7436d9a427dd3efba846d6714d75988f5980e151 Mon Sep 17 00:00:00 2001 From: robert Date: Fri, 25 Sep 2026 09:18:18 +0200 Subject: [PATCH 18/29] Refactor stiching to us TPTBox function which are more efficent. --- TPTBox/stitching/stitching.py | 589 +++++++++++++--------------------- 1 file changed, 218 insertions(+), 371 deletions(-) diff --git a/TPTBox/stitching/stitching.py b/TPTBox/stitching/stitching.py index 0f1fb33..e69e011 100755 --- a/TPTBox/stitching/stitching.py +++ b/TPTBox/stitching/stitching.py @@ -1,17 +1,45 @@ from __future__ import annotations +import contextlib import itertools +import warnings +from functools import cache from pathlib import Path -import nibabel as nib -import nibabel.processing as nip import numpy as np from nibabel.affines import apply_affine -from nibabel.nifti1 import Nifti1Image from scipy.ndimage import binary_opening, distance_transform_edt from scipy.spatial import ConvexHull from skimage.exposure import match_histograms +from TPTBox.core.nii_wrapper import NII, to_nii +from TPTBox.core.np_utils import np_bbox_binary +from TPTBox.logger import Print_Logger + +logger = Print_Logger() + + +@contextlib.contextmanager +def _suppress_dtype_warning(): + """Silence the "Loaded NIfTY: incorrect dtype detected" ``UserWarning``. + + ``NII._check_if_nifty_is_lying_about_its_dtype`` (nii_wrapper.py, three + ``warnings.warn`` sites around line 151/163/172) fires this warning + during ``_unpack`` whenever the on-disk dtype doesn't match the array's + actual range — typical for Philips-scaled magnitude MR arriving as + ``int16`` with a large ``scl_slope``. The stitching pipeline handles + that case explicitly (rebuilds a scale-1 image with the widened dtype + in ``main``'s loading loop), so the warning is noise here — but we + only suppress it for the current call's stack, not globally. + """ + with warnings.catch_warnings(): + warnings.filterwarnings( + "ignore", + message=r"Loaded NIfTY: incorrect dtype detected.*", + category=UserWarning, + ) + yield + def get_rotation_and_spacing_from_affine(affine: np.ndarray) -> tuple[np.ndarray, np.ndarray]: """Decompose a NIfTI affine into its rotation matrix and voxel spacing. @@ -72,69 +100,23 @@ def get_all_corner_points(affine: np.ndarray, shape: tuple[int, ...]) -> np.ndar return apply_affine(affine, lst) -def get_array(nii: Nifti1Image) -> np.ndarray: - """Extract the voxel data from a NIfTI image as a writable NumPy array. - - Args: - nii: Source NIfTI image. - - Returns: - A copy of the image data array with the original dtype preserved. - """ - return np.asanyarray(nii.dataobj, dtype=nii.dataobj.dtype).copy() # type: ignore - - -def set_array(nii: Nifti1Image, arr: np.ndarray) -> Nifti1Image: - """Return a new NIfTI image with the given array, preserving header and affine. - - If the dtype of ``arr`` differs from the existing image, the header dtype is - updated accordingly. - - Args: - nii: Source NIfTI image whose header and affine are reused. - arr: Replacement voxel data array. - - Returns: - A new :class:`Nifti1Image` backed by ``arr``. - """ - if nii.dataobj.dtype == arr.dtype: # type: ignore - nii = Nifti1Image(arr, nii.affine, nii.header) - else: - nii = Nifti1Image(get_array(nii), nii.affine, nii.header) - nii.set_data_dtype(arr.dtype) - nii = Nifti1Image(arr, nii.affine, nii.header) - return nii - - -def argmin(lst: list) -> int: - """Return the index of the minimum element in a list. - - Args: - lst: Input list of comparable elements. - - Returns: - Zero-based index of the smallest element. - """ - return lst.index(min(lst)) - - def _occupancy_bbox(occ: np.ndarray) -> tuple[np.ndarray, np.ndarray] | None: """Axis-aligned bounding box (min, max inclusive) of the nonzero region of `occ`. - Uses three 1-D `np.any` reductions rather than `np.where`, so cost is O(N) - with cache-friendly access. Returns ``None`` for an all-zero occupancy. + Thin wrapper around :func:`TPTBox.core.np_utils.np_bbox_binary` (which + shares a 2-D projection between two of the three axis reductions for 3-D + inputs, so ~2× faster than three independent ``np.any`` reductions). The + slice tuple is repacked as ``(lo, hi)`` int64 arrays because the ramp + math further down works on plain vectors, and ``None`` is returned for + an all-zero occupancy so ``_aabb_overlaps`` can treat it as "no overlap + possible". """ - mask = occ > 0 - axes = mask.ndim - lo = np.empty(axes, dtype=np.int64) - hi = np.empty(axes, dtype=np.int64) - for ax in range(axes): - collapsed = np.any(mask, axis=tuple(i for i in range(axes) if i != ax)) - idx = np.flatnonzero(collapsed) - if idx.size == 0: - return None - lo[ax] = idx[0] - hi[ax] = idx[-1] + try: + slices = np_bbox_binary(occ > 0) + except ValueError: + return None + lo = np.array([s.start for s in slices], dtype=np.int64) + hi = np.array([s.stop - 1 for s in slices], dtype=np.int64) return lo, hi @@ -153,7 +135,7 @@ def get_max_affine_and_shape( min_spacing: float | None = None, dtype: type = float, verbose: bool = False, -) -> Nifti1Image: +) -> NII: """Determine the optimal output affine and shape that encloses all input volumes. Iterates over all input affines and selects the rotation that minimises the @@ -170,7 +152,7 @@ def get_max_affine_and_shape( rotation to stdout. Returns: - A zeroed :class:`Nifti1Image` with the computed affine and shape, ready + A zeroed :class:`NII` with the computed affine and shape, ready to be used as a resampling target. Raises: @@ -219,248 +201,50 @@ def get_max_affine_and_shape( new_spacing = np.maximum(min_spacing, new_spacing) shape: np.ndarray = np.ceil(min_shape / new_spacing) - print("Choose the following spacing:", new_spacing) if verbose else None - print(f"Output shape is {shape}, which utilizes {min_possible_volume / min_volume * 100:.1f} % of all voxels.") if verbose else None + logger.on_neutral("Choose the following spacing:", new_spacing, verbose=verbose) + logger.on_neutral( + f"Output shape is {shape}, which utilizes {min_possible_volume / min_volume * 100:.1f} % of all voxels.", + verbose=verbose, + ) affine = get_ras_affine(min_rotation, new_spacing, origen[0]) - print("The new origin is ", np.round(affine[:3, 3], 2)) if verbose else None - print("The optimal rotation came from file number ", opt_id, " ", np.round(min_rotation.reshape(-1), 2)) if verbose else None - return nib.Nifti1Image(np.zeros(shape.astype(int), dtype=dtype), affine) # type: ignore - - -def compute_crop_slice(nii: Nifti1Image, minimum: float = 0, dist: int = 0) -> tuple[slice, slice, slice]: - """Compute the tight 3-D crop slice that removes empty space from a NIfTI volume. - - A voxel is considered "filled" when its value is strictly greater than - ``minimum``. The returned slice tuple can be applied via ``nii.slicer`` - to crop the image. - - Args: - nii: Input NIfTI image whose bounding box is computed. - minimum: Background threshold. Voxels above this value delimit the crop - (0 for MRI, -1024 for CT). - dist: Padding in millimetres added on every side of the crop. Converted - to voxels using the image's zooms. - - Returns: - A 3-tuple of ``slice`` objects to apply along the (X, Y, Z) axes. - - Raises: - ValueError: If no voxels exceed ``minimum`` (crop would be empty). - """ - shp = nii.shape - zms = nii.header.get_zooms() # type: ignore - d = np.around(dist / np.asarray(zms)).astype(int) - array = get_array(nii) # + minimum - msk_bin = np.zeros(array.shape, dtype=bool) - # bool_arr[array minimum] = 1 - # msk_bin = np.asanyarray(bool_arr, dtype=bool) - msk_bin[np.isnan(msk_bin)] = 0 - cor_msk = np.where(msk_bin > 0) - if cor_msk[0].shape[0] == 0: - raise ValueError("Array would be reduced to zero size") - c_min = [cor_msk[0].min(), cor_msk[1].min(), cor_msk[2].min()] - c_max = [cor_msk[0].max(), cor_msk[1].max(), cor_msk[2].max()] - x0 = max(0, c_min[0] - d[0]) - y0 = max(0, c_min[1] - d[1]) - z0 = max(0, c_min[2] - d[2]) - x1 = min(shp[0], c_max[0] + d[0]) - y1 = min(shp[1], c_max[1] + d[1]) - z1 = min(shp[2], c_max[2] + d[2]) - ex_slice = (slice(x0, x1 + 1), slice(y0, y1 + 1), slice(z0, z1 + 1)) - return ex_slice - - -def dilate_msk(msk_i_data: np.ndarray, mm: int = 5, connectivity: int = 3) -> np.ndarray: - """Dilate each label in a segmentation mask by a fixed number of voxels. - - Args: - msk_i_data: Integer-valued 3-D segmentation array. Label 0 is background. - mm: Number of dilation iterations to apply per label. - connectivity: Structuring-element connectivity (1 = face-connected, - 3 = fully-connected including diagonals). - - Returns: - A dilated ``uint8`` array of the same shape as ``msk_i_data``. - """ - from scipy.ndimage import binary_dilation, generate_binary_structure - - struct = generate_binary_structure(3, connectivity) - out = msk_i_data.copy() * 0 - for i in np.unique(msk_i_data): - if i == 0: - continue - data = msk_i_data.copy() - data[i != data] = 0 - msk_ibe_data = binary_dilation(data, structure=struct, iterations=mm) - out[out == 0] = msk_ibe_data[out == 0] - return out.astype(np.uint8) - - -def n4_bias_field_correction( - nib: Nifti1Image, - mask: np.ndarray | None = None, - threshold: int = 60, - shrink_factor: int = 4, - convergence: dict | None = None, - spline_param: int = 150, - verbose: bool = False, - weight_mask: np.ndarray | None = None, - crop: bool = False, -) -> Nifti1Image: - """Apply N4 bias-field correction to a NIfTI image using ANTsPy. - - A binary mask is derived automatically from voxels above ``threshold`` - and dilated by 3 voxels before correction is applied. - - Args: - nib: Input NIfTI image to correct. - mask: Optional pre-computed binary mask passed to ANTsPy. Overridden - when ``threshold != 0``. - threshold: Voxel intensity threshold for automatic mask generation. - Set to 0 to disable automatic masking. - shrink_factor: Image downsampling factor used inside ANTsPy to - speed up computation. - convergence: ANTsPy convergence dict with keys ``"iters"`` and - ``"tol"``. Defaults to ``{"iters": [50, 50, 50, 50], "tol": 1e-7}``. - spline_param: B-spline control point spacing for the bias field model. - verbose: If True, ANTsPy prints progress information. - weight_mask: Optional spatial weight mask passed to ANTsPy. - crop: If True, crops the corrected image to the region where the bias - field differed from the input. - - Returns: - The bias-field-corrected NIfTI image. - - Raises: - ModuleNotFoundError: If ``antspyx`` is not installed. - """ - try: - import ants - import ants.utils.bias_correction as bc # pip install antspyx==0.4.2 - from ants.utils.convert_nibabel import from_nibabel, to_nibabel - - except ModuleNotFoundError as err: - raise ModuleNotFoundError("n4 bias field correction uses ants install it with pip install antspyx==0.4.2") from err - # 5.3 or higher - import ants - import ants.ops.bias_correction as bc # pip install antspyx - - # TODO add conversion and remove this - - def from_nibabel(nib_image): - """Converts a given Nifti image into an ANTsPy image. - - Parameters - ---------- - nib_image: nibabel Nifti1Image - - Returns: - ------- - ants_image: ANTsImage - """ - ndim = nib_image.ndim - - if ndim < 3: - print("Dimensionality is less than 3.") - return None - - q_form = nib_image.get_qform() - spacing = nib_image.header["pixdim"][1 : ndim + 1] - - origin = np.zeros(ndim) - origin[:3] = q_form[:3, 3] - - direction = np.diag(np.ones(ndim)) - direction[:3, :3] = q_form[:3, :3] / spacing[:3] - - ants_img = ants.from_numpy(data=nib_image.get_fdata(), origin=origin.tolist(), spacing=spacing.tolist(), direction=direction) - - return ants_img - - def to_nibabel(img: ants.core.ants_image.ANTsImage): - try: - from nibabel.nifti1 import Nifti1Image - except ModuleNotFoundError as e: - raise ModuleNotFoundError( - "Could not import nibabel, for conversion to nibabel. Install nibabel with pip install nibabel" - ) from e - affine = get_ras_affine(rotation=img.direction, spacing=img.spacing, origin=img.origin) - return Nifti1Image(img.numpy(), affine, nib.header) - - if convergence is None: - convergence = {"iters": [50, 50, 50, 50], "tol": 1e-07} - input_ants = from_nibabel(nib) - - if threshold != 0: - mask = get_array(nib) - mask[mask < threshold] = 0 - mask[mask != 0] = 1 - mask = mask.astype(np.uint8) - mask = dilate_msk(mask, mm=3) - mask = from_nibabel(set_array(nib, mask)) - - out = bc.n4_bias_field_correction( - input_ants, - mask=mask, - shrink_factor=shrink_factor, - convergence=convergence, - spline_param=spline_param, + logger.on_neutral("The new origin is ", np.round(affine[:3, 3], 2), verbose=verbose) + logger.on_neutral( + "The optimal rotation came from file number ", + opt_id, + " ", + np.round(min_rotation.reshape(-1), 2), verbose=verbose, - weight_mask=weight_mask, ) - out_nib = to_nibabel(out) - if crop: - # Crop to regions that had a normalization applied. Removes a lot of dead space - dif = to_nibabel(input_ants - out) - da = get_array(dif) - da[da != 0] = 1 - dif = set_array(dif, da) - ex_slice = compute_crop_slice(dif) - out_nib = out_nib.slicer[ex_slice] - - return out_nib - + return NII((np.zeros(shape.astype(int), dtype=dtype), affine, None)) # type: ignore -buffer_references = {} - -def buffer_reference(path: str | Path, bias_field: bool, crop: bool = False) -> np.ndarray | Nifti1Image: +@cache +def buffer_reference(path: str | Path, bias_field: bool, crop: bool = False) -> NII: """Load (and optionally bias-correct) a NIfTI file, caching the result. - Subsequent calls with the same ``path`` return the cached result without - re-reading or re-correcting the file. + Subsequent calls with the same ``(path, bias_field, crop)`` return the + cached NII without re-reading or re-correcting the file. Caching by all + three arguments (rather than just ``path`` as the previous module-level + dict did) means a call with ``crop=True`` no longer poisons a later call + with ``crop=False`` for the same path. ``lru_cache`` is also thread-safe, + so this is safe under a ``ThreadPoolExecutor``-based dispatcher. Args: path: File path of the NIfTI image. bias_field: If True, applies N4 bias-field correction before caching. - crop: Passed to :func:`n4_bias_field_correction` when ``bias_field`` is True. + crop: Passed to :meth:`NII.n4_bias_field_correction` when ``bias_field`` + is True. Returns: - The image data array (if ``bias_field`` is False) or the corrected - :class:`Nifti1Image` (if ``bias_field`` is True). + The loaded (and optionally bias-corrected) :class:`NII`. """ - if path in buffer_references: - return buffer_references[path] - reference = n4_bias_field_correction(nib.load(path), crop) if bias_field else get_array(nib.load(path)) # type: ignore - buffer_references[path] = reference + reference = NII.load(path, False) + if bias_field: + reference = reference.n4_bias_field_correction(crop=crop) return reference -type_mapping = { - "float": float, - "uint8": np.uint8, - "uint16": np.uint16, - "uint32": np.uint32, - "uint64": np.uint64, - "int8": np.int8, - "int16": np.int16, - "int32": np.int32, - "int64": np.int64, -} - - -def _auto_output_dtype(niis: list[nib.nifti1.Nifti1Image]) -> type: +def _auto_output_dtype(niis: list[NII]) -> type: """Pick the smallest lossless output dtype from the inputs' on-disk dtypes. Reads only the NIfTI headers — no pixel data is loaded. If every input is @@ -469,13 +253,26 @@ def _auto_output_dtype(niis: list[nib.nifti1.Nifti1Image]) -> type: all; otherwise falls back to float32. This lets the stitcher store magnitude MR outputs as uint16 (or int16) when the source already fit in 16 bits, halving the on-disk and downstream RAM footprint versus float64. + + The slope check is load-bearing: Philips exports the axial VIBE-DIXON as + ``int16`` with a large ``scl_slope`` (~641 or ~2260) so the raw bytes span + the signed range but ``fdata = raw * slope`` reaches ~1e6. Returning + ``int16`` (or ``uint32`` after `_check_if_nifty_is_lying_about_its_dtype` + widens the array) and casting the blended float back to it wraps values + outside the target range into garbage — the stitched output ends up as a + static-noise pattern or an unexpectedly wide dtype (e.g. ``uint32`` for + the 6-echo mDIX magnitudes). When ANY input has slope != 1 or inter != 0 + we bail to float32 so the accumulator's true range survives the save. + ``dataobj.slope`` / ``dataobj.inter`` on the underlying ``Nifti1Image`` + give the ORIGINAL header values (before NII's dtype-widening pass), which + is what we need for this decision. """ - dtypes = [np.dtype(nii.get_data_dtype()) for nii in niis] + dtypes = [np.dtype(nii.dtype) for nii in niis] if not all(np.issubdtype(d, np.integer) for d in dtypes): return np.float32 for nii in niis: - slope = getattr(nii.dataobj, "slope", 1.0) - inter = getattr(nii.dataobj, "inter", 0.0) + slope = getattr(nii.nii.dataobj, "slope", 1.0) + inter = getattr(nii.nii.dataobj, "inter", 0.0) if slope is None or inter is None: return np.float32 if not (np.isfinite(slope) and np.isfinite(inter)): @@ -486,7 +283,7 @@ def _auto_output_dtype(niis: list[nib.nifti1.Nifti1Image]) -> type: def main( # noqa: C901 - images: list[str] | list[Path] | list[nib.nifti1.Nifti1Image], + images: list[str] | list[Path] | list[NII], output: str | None, match_histogram: bool = False, store_ramp: bool = False, @@ -503,7 +300,7 @@ def main( # noqa: C901 dtype: type | str = float, save: bool = True, ramp_path=None, -) -> tuple[Nifti1Image | None, Nifti1Image | None]: +) -> tuple[NII | None, NII | None]: """Stitch multiple overlapping NIfTI volumes into a single output volume. The algorithm: @@ -517,8 +314,8 @@ def main( # noqa: C901 5. Combines all resampled volumes with those weights and saves the result. Args: - images: Input volumes as file paths or pre-loaded :class:`Nifti1Image` - objects. At least two are required. + images: Input volumes as file paths or pre-loaded :class:`NII` (or + :class:`Nifti1Image`) objects. At least two are required. output: Output file path (``".nii.gz"`` extension is appended if absent). If None the result is returned without writing to disk (``save`` must also be False). @@ -565,6 +362,54 @@ def main( # noqa: C901 unless ``store_ramp`` is True. Returns ``(None, None)`` when fewer than two images are supplied. """ + # Suppress `_check_if_nifty_is_lying_about_its_dtype` UserWarnings for + # the whole main() call — stitching handles the "dtype mismatches + # actual range" case explicitly, and the warning is just noise here. + # See `_suppress_dtype_warning` for scope / rationale. + with _suppress_dtype_warning(): + return _main( + images, + output, + match_histogram, + store_ramp, + verbose, + min_value, + bias_field, + crop_to_bias_field, + crop_empty, + histogram, + ramp_edge_min_value, + min_spacing, + kick_out_fully_integrated_images, + is_segmentation, + dtype, + save, + ramp_path, + ) + + +def _main( # noqa: C901 + images: list[str] | list[Path] | list[NII], + output: str | None, + match_histogram: bool = False, + store_ramp: bool = False, + verbose: bool = False, + min_value: float | None = None, + bias_field: bool = True, + crop_to_bias_field: bool = False, + crop_empty: bool = False, + histogram: str | None = None, + ramp_edge_min_value: int = 5, + min_spacing: int | None = None, + kick_out_fully_integrated_images: bool = False, + is_segmentation: bool = False, + dtype: type | str = float, + save: bool = True, + ramp_path=None, +) -> tuple[NII | None, NII | None]: + """Body of :func:`main`, split out so :func:`main` can wrap it in the + dtype-warning suppression context. + """ np.set_printoptions(precision=2, floatmode="fixed") if is_segmentation: bias_field = False @@ -574,54 +419,44 @@ def main( # noqa: C901 histogram = None _bg_value: float = 0.0 if min_value is None else float(min_value) if len(images) == 0 or len(images) == 1: - print("!!! Need at least two images (-i ...nii.gz ...nii.gz) to stitch!!!\n Got " + str(images)) + logger.on_fail("Need at least two images (-i ...nii.gz ...nii.gz) to stitch. Got:", images) return None, None corners = [] affines = [] - niis: list[nib.nifti1.Nifti1Image] = [] - print("### loading ###") if verbose else None + niis: list[NII] = [] + logger.on_log("### loading ###", verbose=verbose) for f_name in images: if isinstance(f_name, (Path, str)): - print("Load ", f_name, Path(f_name)) if verbose else None + logger.on_neutral("Load ", f_name, Path(f_name), verbose=verbose) # Load NII - try: - from TPTBox.core.nii_wrapper import to_nii as _to_nii_load - - _nii_wrap = _to_nii_load(Path(f_name)) - _arr_corrected = _nii_wrap.get_array() - nii = nib.Nifti1Image(_arr_corrected, _nii_wrap.affine) - nii.set_data_dtype(_arr_corrected.dtype) - try: - nii.header.set_slope_inter(1.0, 0.0) - except Exception: # noqa: BLE001 - pass - except Exception: # noqa: BLE001 - nii: nib.nifti1.Nifti1Image = nib.load(f_name) # type: ignore - - else: + nii = to_nii(Path(f_name), seg=is_segmentation) + elif isinstance(f_name, (NII)): nii = f_name + nii.seg = is_segmentation + else: + nii = NII(f_name, seg=is_segmentation) if bias_field: - nii = n4_bias_field_correction(nii, crop=crop_to_bias_field) + nii = nii.n4_bias_field_correction(crop=crop_to_bias_field) ## Histogram equalization. if match_histogram: if histogram is None: if len(niis) == 0: reference = None else: - print("Histogram equalization with previous file") if verbose else None - reference = get_array(niis[-1]) + logger.on_neutral("Histogram equalization with previous file", verbose=verbose) + reference = niis[-1].get_array() elif histogram.isdigit(): - print("Histogram equalization", images[int(histogram)]) if verbose else None + logger.on_neutral("Histogram equalization", images[int(histogram)], verbose=verbose) reference = buffer_reference(images[int(histogram)], bias_field=bias_field, crop=crop_to_bias_field) # type: ignore else: - print("Histogram equalization with file", histogram) if verbose else None + logger.on_neutral("Histogram equalization with file", histogram, verbose=verbose) reference = buffer_reference(histogram, bias_field=bias_field, crop=crop_to_bias_field) # type: ignore if reference is not None: - image = get_array(nii) + image = nii.get_array() matched = match_histograms(image.astype(float), reference.astype(float)) matched[matched <= _bg_value] = _bg_value - nii = set_array(nii, matched) + nii = nii.set_array(matched) niis.append(nii) # Get affine and points for minimum enclosing Rectangle calculation @@ -634,9 +469,9 @@ def main( # noqa: C901 corners_current = np.concatenate(corners, axis=0) # compute output shape and affine - print("### compute output shape and affine ###") if verbose else None + logger.on_log("### compute output shape and affine ###", verbose=verbose) if is_segmentation: - max_value = max([x.get_fdata().max() for x in niis]) + max_value = max([x.max() for x in niis]) if max_value < 256: dtype2 = np.uint8 elif max_value < 256 * 256: @@ -658,24 +493,24 @@ def main( # noqa: C901 target_list = [] occupancy_list = [] # get resampled arrays and occupancy - print("### resample to new space ###") if verbose else None + logger.on_log("### resample to new space ###", verbose=verbose) for i, nii in enumerate(niis, 1): - print(f"{i:2}/{len(niis):2} resampled", end="\r") if verbose else None - nii_new = nip.resample_from_to(nii, nii_out, 0 if is_segmentation else 3, mode="constant", cval=_bg_value) - arr_new = get_array(nii_new) + logger.on_neutral(f"{i:2}/{len(niis):2} resampled", end="\r", verbose=verbose) + nii_new = nii.resample_from_to(nii_out, order=0 if is_segmentation else 3, mode="constant", c_val=_bg_value, verbose=False) + arr_new = nii_new.get_array() if not is_segmentation and np.issubdtype(arr_new.dtype, np.floating): np.nan_to_num(arr_new, copy=False, nan=_bg_value, posinf=_bg_value, neginf=_bg_value) target_list.append(arr_new) - b = nib.Nifti1Image(np.ones(nii.shape, dtype=np.float32), affine=nii.affine) # type: ignore - b = nip.resample_from_to(b, nii_new, 0, cval=0, mode="constant") + b = NII((np.ones(nii.shape, dtype=np.float32), nii.affine, None)) # type: ignore + b = b.resample_from_to(nii_new, order=0, c_val=0, mode="constant", verbose=False) if is_segmentation: x = arr_new > 0 - occupancy_list.append((get_array(b) * x.astype(np.int8)).astype(np.float32)) # Keep segmentation if other is 0 + occupancy_list.append((b.get_array() * x.astype(np.int8)).astype(np.float32)) # Keep segmentation if other is 0 else: - occupancy_list.append(get_array(b).astype(np.float32)) + occupancy_list.append(b.get_array().astype(np.float32)) - print("\n### ramp stitching ###") if verbose else None + logger.on_log("\n### ramp stitching ###", verbose=verbose) # Per-chunk axis-aligned bounding box in target-space voxel coords. # Precomputing once avoids the O(N_chunks^2) full-volume copy + multiply # inside the ramp loop for pairs that don't touch. `_occupancy_bbox` @@ -692,7 +527,7 @@ def main( # noqa: C901 _ramp_skipped_aabb = 0 _ramp_skipped_no_voxel_overlap = 0 for idx, item in enumerate(combinations, 1): - print(f"{idx:2}/{len(combinations):2} ramp stitching", end="\r") if verbose else None + logger.on_neutral(f"{idx:2}/{len(combinations):2} ramp stitching", end="\r", verbose=verbose) # Skip disjoint pairs before touching the full-volume arrays. if not _aabb_overlaps(bboxes[item[0]], bboxes[item[1]]): _ramp_skipped_aabb += 1 @@ -766,37 +601,44 @@ def main( # noqa: C901 if kick_out_fully_integrated_images: images.pop(item[1]) if (max_1 != 1 or max_2 != 1) and kick_out_fully_integrated_images: - print("kick_out_fully_integrated_images") - - print(images) + logger.on_warning("kick_out_fully_integrated_images") + logger.on_warning(images) + # Pass EVERY argument by keyword — the positional form used + # to silently drop `is_segmentation`, `dtype`, `ramp_path` and + # shift `save` onto `is_segmentation`, which flipped the + # recursion into the segmentation code path and produced an + # unexpected uint dtype for what was actually magnitude MR. return main( - images, - output, - match_histogram, - store_ramp, - verbose, - min_value, - bias_field, - crop_to_bias_field, - crop_empty, - histogram, - ramp_edge_min_value, - min_spacing, - kick_out_fully_integrated_images, - save, + images=images, + output=output, + match_histogram=match_histogram, + store_ramp=store_ramp, + verbose=verbose, + min_value=min_value, + bias_field=bias_field, + crop_to_bias_field=crop_to_bias_field, + crop_empty=crop_empty, + histogram=histogram, + ramp_edge_min_value=ramp_edge_min_value, + min_spacing=min_spacing, + kick_out_fully_integrated_images=kick_out_fully_integrated_images, + is_segmentation=is_segmentation, + dtype=dtype, + save=save, + ramp_path=ramp_path, ) arr_1_full[sub] = arr_1_sub.astype(arr_1_full.dtype, copy=False) arr_2_full[sub] = arr_2_sub.astype(arr_2_full.dtype, copy=False) else: _ramp_skipped_no_voxel_overlap += 1 continue - if verbose: - print( - f"\nramp summary: {_ramp_done} computed, " - f"{_ramp_skipped_aabb} skipped (disjoint AABB), " - f"{_ramp_skipped_no_voxel_overlap} skipped (no voxel overlap) " - f"of {len(combinations)} pairs" - ) + logger.on_neutral( + f"\nramp summary: {_ramp_done} computed, " + f"{_ramp_skipped_aabb} skipped (disjoint AABB), " + f"{_ramp_skipped_no_voxel_overlap} skipped (no voxel overlap) " + f"of {len(combinations)} pairs", + verbose=verbose, + ) # Aggregate: in-place accumulate `t * occupancy` per chunk instead of # `np.stack(target_list) * np.stack(occupancy_list)` which would peak at # ~2 × N × volume of temporary float arrays. @@ -824,7 +666,7 @@ def main( # noqa: C901 _floor = None if _floor is not None: target_arr[target_arr <= _floor] = _floor - print("\n### Save ###") if verbose else None + logger.on_log("\n### Save ###", verbose=verbose) if output is not None: output = str(output) if not output.endswith(".nii.gz"): @@ -832,10 +674,15 @@ def main( # noqa: C901 if "/" not in output and "\\" not in output: assert isinstance(images[0], (str, Path)), "automatic path fetching only possible if images are strings or Path, not objects" output = str(Path(Path(images[0]).parent, output)) - dtype = type_mapping.get(dtype, dtype) # type: ignore - nii_out = set_array(nii_out, target_arr.astype(dtype)) + # Accept a string dtype name ("uint8", "float", …) from the CLI argparse + # path. `np.dtype(...)` covers every alias `type_mapping` used to define + # explicitly. Non-string dtypes (types picked by `_auto_output_dtype` or + # passed in directly) pass through unchanged. + if isinstance(dtype, str): + dtype = np.dtype(dtype).type + nii_out = nii_out.set_array(target_arr.astype(dtype)) if bias_field: - nii_out = n4_bias_field_correction(nii_out) + nii_out = nii_out.n4_bias_field_correction() if crop_empty: # Crop to the union of per-chunk occupancy AABBs. The previous path # went through compute_crop_slice on a 4-D (N, X, Y, Z) stack, which @@ -850,31 +697,31 @@ def main( # noqa: C901 slice(int(lo[1]), int(hi[1]) + 1), slice(int(lo[2]), int(hi[2]) + 1), ) - nii_out = nii_out.slicer[ex_slice] + nii_out = nii_out[ex_slice] else: ex_slice = () else: ex_slice = () - nii_out.set_data_dtype(dtype) + nii_out.set_dtype_(dtype) if save: - nib.save(nii_out, output) # type: ignore - print("Saved ", output) if verbose else None + nii_out.save(output) # type: ignore + logger.on_save("Saved ", output, verbose=verbose) if store_ramp: occupancy_arr = np.stack(occupancy_list, -1) if crop_empty: occupancy_arr = occupancy_arr[ex_slice] assert output is not None - nii_occ = set_array(nii_out, occupancy_arr) - nii_occ.set_data_dtype(np.int8) + nii_occ = nii_out.set_array(occupancy_arr) + nii_occ.set_dtype_(np.int8) output = output.replace(".nii.gz", "_ramps.nii.gz").replace("_msk_", "_") if ramp_path is None else ramp_path if save: - nib.save(nii_occ, output) # type: ignore - print("Saved ", output) if verbose else None + nii_occ.save(output) # type: ignore + logger.on_save("Saved ", output, verbose=verbose) return nii_out, nii_occ - print("\n### Finished ###") if verbose else None + logger.on_ok("\n### Finished ###", verbose=verbose) return nii_out, None From 664183bb56b433b237767d4a7519e7eb43160932 Mon Sep 17 00:00:00 2001 From: robert Date: Mon, 28 Sep 2026 10:44:57 +0200 Subject: [PATCH 19/29] more 4D chatches in DICOM --- TPTBox/core/dicom/dicom_extract.py | 89 ++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/TPTBox/core/dicom/dicom_extract.py b/TPTBox/core/dicom/dicom_extract.py index 9290b61..3f29838 100644 --- a/TPTBox/core/dicom/dicom_extract.py +++ b/TPTBox/core/dicom/dicom_extract.py @@ -413,6 +413,16 @@ def _extract_nii_from_dicom(dicom_out_path, nii_path): return False except Exception as e: logger.on_debug("Multi-Frame DICOM did not work:", e) + # Multi-echo Philips DIXON (e.g. mDIX quant): N slices × M echoes at + # the same ImagePositionPatient. dicom2nifti's own stack detector + # collapses to N/2 fake groups of 2 slices, then fails inside + # `four_d_to_nifti → create_affine` with `NOT_A_VOLUME`. Convert + # per-echo to 3-D and stack into a 4-D NIfTI ourselves; the + # downstream `_split_multi_echo_dixon` will then split it into + # per-echo `-eco` files. + if _dicoms_are_multi_echo(dicom_out_path) and _convert_multi_echo_dicoms_to_4d(dicom_out_path, nii_path): + logger.print("Save ", nii_path, Log_Type.SAVE) + return True ## The PDF dicom lands here convert_dicom.dicom_array_to_nifti(dicom_out_path, nii_path, True) else: @@ -646,6 +656,85 @@ def _from_dicom_to_nii( return nii_path if add_grid else None +def _dicoms_are_multi_echo(dcm_list) -> bool: + """True when ``dcm_list`` carries slices from more than one ``EchoNumbers`` value.""" + if not isinstance(dcm_list, list) or len(dcm_list) < 2: + return False + seen: set[int] = set() + for d in dcm_list: + try: + en = int(getattr(d, "EchoNumbers", 0) or 0) + except (TypeError, ValueError): + continue + if en > 0: + seen.add(en) + if len(seen) > 1: + return True + return False + + +def _convert_multi_echo_dicoms_to_4d(dcm_list: list, nii_path: str | Path) -> bool: + """Convert a multi-echo DICOM list to a 4-D NIfTI (echoes on axis 3). + + Per-echo buckets are converted separately with ``dicom2nifti`` (each is a + normal 3-D stack with no repeated ImagePositionPatient), then stacked along + a new fourth axis in ascending ``EchoNumbers``. Written to *nii_path*. The + caller's post-processing (``_split_multi_echo_dixon``) then splits the 4-D + file into per-echo ``-eco`` files. + + Returns True on success, False if any per-echo conversion fails or the + per-echo grids don't match. + """ + per_echo: dict[int, list] = {} + for d in dcm_list: + try: + en = int(getattr(d, "EchoNumbers", 0) or 0) + except (TypeError, ValueError): + continue + if en > 0: + per_echo.setdefault(en, []).append(d) + if len(per_echo) < 2: + return False + + nii_path = Path(nii_path) + tmp_dir = Path(tempfile.mkdtemp(prefix=f"{nii_path.stem}.echoes.", dir=str(nii_path.parent))) + try: + arrays: list[np.ndarray] = [] + ref_affine: np.ndarray | None = None + ref_header = None + ref_shape: tuple | None = None + for en in sorted(per_echo): + tmp_out = tmp_dir / f"echo{en}.nii.gz" + try: + convert_dicom.dicom_array_to_nifti(per_echo[en], str(tmp_out), True) + except Exception as e: # noqa: BLE001 + logger.on_warning(f"multi-echo split: per-echo conversion failed for EchoNumbers={en}: {type(e).__name__}: {e}") + return False + if not tmp_out.exists(): + logger.on_warning(f"multi-echo split: per-echo NIfTI missing for EchoNumbers={en}") + return False + n = nib.load(str(tmp_out)) + arr = np.asanyarray(n.dataobj, dtype=n.get_data_dtype()) + if ref_affine is None: + ref_affine = n.affine + ref_header = n.header + ref_shape = arr.shape + elif arr.shape != ref_shape or not np.allclose(n.affine, ref_affine, atol=1e-3): + logger.on_warning( + f"multi-echo split: grid mismatch between echoes (echo {en}: shape={arr.shape}); " + f"aborting per-echo stack." + ) + return False + arrays.append(arr) + stacked = np.stack(arrays, axis=-1) + out_img = nib.Nifti1Image(stacked, ref_affine, ref_header) + out_img.set_data_dtype(arrays[0].dtype) + out_img.to_filename(str(nii_path)) + return True + finally: + shutil.rmtree(tmp_dir, ignore_errors=True) + + def _split_multi_echo_dixon(nii_path: Path, json_path: Path, dcm_data_l) -> list[Path] | None: """Split a 4-D multi-echo DIXON (magnitude/phase) NIfTI into per-echo 3-D files. From cf0d29db915817f9618cf0c90e816384dbf0f37f Mon Sep 17 00:00:00 2001 From: Robert Graf Date: Mon, 28 Sep 2026 08:49:58 +0000 Subject: [PATCH 20/29] skip missing plane/source POIs; widen sacrum-endplate crop; bump run_all defaults Co-Authored-By: Claude Opus 4.7 --- TPTBox/core/poi_fun/vertebra_direction.py | 4 ++++ TPTBox/spine/spinestats/_run_all.py | 6 +++--- TPTBox/spine/spinestats/poi_fun/endplates.py | 2 +- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/TPTBox/core/poi_fun/vertebra_direction.py b/TPTBox/core/poi_fun/vertebra_direction.py index f2d3b04..b307ed5 100644 --- a/TPTBox/core/poi_fun/vertebra_direction.py +++ b/TPTBox/core/poi_fun/vertebra_direction.py @@ -252,6 +252,10 @@ def calc_orientation_of_vertebra_PIR( poi.info["vert_orientation_PIR"] = poi._vert_orientation_pir # calc posterior vector and the crossproduct for vert_id, normal_down in down_vector.items(): + # Missing plane/source POIs at this level (e.g. sacrum without a spinal-canal + # intersection) are legitimate — skip silently instead of logging a failure. + if (vert_id, subreg_id.value) not in ret or (vert_id, source_subreg_point_id.value) not in ret: + continue try: # get two points and compute the direction: a = np.array(ret[vert_id : subreg_id.value]) - 1 diff --git a/TPTBox/spine/spinestats/_run_all.py b/TPTBox/spine/spinestats/_run_all.py index c1d0e1f..4098e6f 100644 --- a/TPTBox/spine/spinestats/_run_all.py +++ b/TPTBox/spine/spinestats/_run_all.py @@ -1056,9 +1056,9 @@ def _run_one(args: tuple[dict, bool, bool]) -> tuple[str, str | None, dict]: os.nice(20) OUT_FOLDER = Path("/DATA/NAS/ongoing_projects/robert/test/NAKO-stats") OUT_FOLDER.mkdir(parents=True, exist_ok=True) - N_CPUS = 1 # set >1 to parallelize + N_CPUS = 20 # set >1 to parallelize OVERRIDE = False - aggregate = False + aggregate = True do_not_update = False test = False collector: ExcelCollector | None = None @@ -1095,7 +1095,7 @@ def _run_one(args: tuple[dict, bool, bool]) -> tuple[str, str | None, dict]: from itertools import islice with ProcessPoolExecutor(max_workers=N_CPUS, max_tasks_per_child=100) as ex: - batch_size = 100 + batch_size = 500 l = tqdm(total=total) while True: gc.collect() diff --git a/TPTBox/spine/spinestats/poi_fun/endplates.py b/TPTBox/spine/spinestats/poi_fun/endplates.py index b2dab43..17a9e02 100644 --- a/TPTBox/spine/spinestats/poi_fun/endplates.py +++ b/TPTBox/spine/spinestats/poi_fun/endplates.py @@ -445,7 +445,7 @@ def calc_endplate_points_( # Location.Sacrum_Endplate, endplate_nii = spine.extract_label(Location.Sacrum_Endplate) # vert * - endplate_nii = endplate_nii.apply_crop(endplate_nii.compute_crop(0, 2, raise_error=False)) + endplate_nii = endplate_nii.apply_crop(endplate_nii.compute_crop(0, 3, raise_error=False)) if endplate_nii.max() > 0: c = endplate_nii.dilate_msk(2).get_connected_components() if c.max() != 1: From 449ebcd87f5e631505b94500d0dc98fa0d2fe30c Mon Sep 17 00:00:00 2001 From: gu53lif Date: Wed, 30 Sep 2026 10:19:46 +0200 Subject: [PATCH 21/29] bug fix _rib_assingn --- TPTBox/segmentation/rib/_rib_assign.py | 133 +++++++++++++++++++------ 1 file changed, 104 insertions(+), 29 deletions(-) diff --git a/TPTBox/segmentation/rib/_rib_assign.py b/TPTBox/segmentation/rib/_rib_assign.py index 426880d..39025bc 100644 --- a/TPTBox/segmentation/rib/_rib_assign.py +++ b/TPTBox/segmentation/rib/_rib_assign.py @@ -47,7 +47,9 @@ def _thoracic_like_labels(labels: Iterable[int]) -> list[int]: return [*sorted(v for v in labels if 7 <= int(v) <= 20), 28] -def _split_ccs_by_side(rib_cc: NII, cms_cc: POI, cms_cc2: POI, cms_vert: POI) -> tuple[list[RibCandidate], list[RibCandidate]]: +def _split_ccs_by_side( + rib_cc: NII, cms_cc: POI, cms_cc2: POI, cms_vert: POI +) -> tuple[list[RibCandidate], list[RibCandidate]]: right_axis = cms_vert.get_axis("R") inf_axis = cms_vert.get_axis("I") # ref_x = cms_vert[ref_vert, 50][right_axis] @@ -59,7 +61,12 @@ def _split_ccs_by_side(rib_cc: NII, cms_cc: POI, cms_cc2: POI, cms_vert: POI) -> continue center = cms_cc[cc, 50] center2 = cms_cc2[cc, 50] if (cc, 50) in cms_cc2 else center # noqa: SIM401 - cand = RibCandidate(cc_label=cc, volume=vols.get(cc, 0), z=center[inf_axis], x=center2[right_axis]) + cand = RibCandidate( + cc_label=cc, + volume=vols.get(cc, 0), + z=center[inf_axis], + x=center2[right_axis], + ) distances = cms_vert.calculate_distances_cord(center2) min_key = min(distances, key=distances.get) # type: ignore # x < vertebra center => patient right in RAS-like orientation @@ -74,14 +81,20 @@ def _split_ccs_by_side(rib_cc: NII, cms_cc: POI, cms_cc2: POI, cms_vert: POI) -> return left, right -def _touching_surface(mask_a: np.ndarray, mask_b: np.ndarray, nii: NII, axis_weights=None) -> float: +def _touching_surface( + mask_a: np.ndarray, mask_b: np.ndarray, nii: NII, axis_weights=None +) -> float: """Return weighted number of voxel faces shared by two masks. Touching along ``up_down_axis`` is weighted by ``up_down_weight``. All other axes have weight 1.0. """ if axis_weights is None: - axis_weights = {nii.get_axis("S"): 0.01, nii.get_axis("A"): 0.1, nii.get_axis("R"): 1} + axis_weights = { + nii.get_axis("S"): 0.01, + nii.get_axis("A"): 0.1, + nii.get_axis("R"): 1, + } surface = 0.0 for axis in range(mask_a.ndim): @@ -92,7 +105,8 @@ def _touching_surface(mask_a: np.ndarray, mask_b: np.ndarray, nii: NII, axis_wei sl2[axis] = slice(1, None) touching = ( - np.logical_and(mask_a[tuple(sl1)], mask_b[tuple(sl2)]).sum() + np.logical_and(mask_b[tuple(sl1)], mask_a[tuple(sl2)]).sum() + np.logical_and(mask_a[tuple(sl1)], mask_b[tuple(sl2)]).sum() + + np.logical_and(mask_b[tuple(sl1)], mask_a[tuple(sl2)]).sum() ) weight = axis_weights.get(axis, 1) @@ -102,7 +116,12 @@ def _touching_surface(mask_a: np.ndarray, mask_b: np.ndarray, nii: NII, axis_wei def _try_erosion_split( - cc_label: int, binary_cc: NII, erosion_pixels: int, min_volume: int, _pass=0, verbose=True + cc_label: int, + binary_cc: NII, + erosion_pixels: int, + min_volume: int, + _pass=0, + verbose=True, ) -> tuple[int, list[np.ndarray]] | None: """Try to split one CC by erosion. @@ -123,9 +142,13 @@ def _try_erosion_split( elif _pass == 1: eroded = cc_nii.erode_msk(n_pixel=erosion_pixels, verbose=False) elif _pass == 2: - eroded = cc_nii.erode_msk(n_pixel=erosion_pixels, verbose=False, ignore_direction="R") + eroded = cc_nii.erode_msk( + n_pixel=erosion_pixels, verbose=False, ignore_direction="R" + ) elif _pass == 3: - eroded = cc_nii.erode_msk(n_pixel=erosion_pixels, verbose=False, ignore_direction="A") + eroded = cc_nii.erode_msk( + n_pixel=erosion_pixels, verbose=False, ignore_direction="A" + ) else: eroded = cc_nii.erode_msk(n_pixel=erosion_pixels, verbose=False) except Exception as e: @@ -144,7 +167,9 @@ def _try_erosion_split( for sub in sub_labels.copy(): count = int((infected_arr == sub).sum()) - should_merge = count * cc_nii.voxel_volume() < min_volume or count / full_size < 0.2 + should_merge = ( + count * cc_nii.voxel_volume() < min_volume or count / full_size < 0.2 + ) if not should_merge: continue # print("merge", count, full_size, count / full_size) @@ -157,7 +182,12 @@ def _try_erosion_split( target = remaining[0] # print("merge", target) else: - target = max(remaining, key=lambda candidate: _touching_surface(sub_mask, infected_arr == candidate, infected)) + target = max( + remaining, + key=lambda candidate: _touching_surface( + sub_mask, infected_arr == candidate, infected + ), + ) # print("merge of many", target) infected_arr[infected_arr == sub] = target sub_labels.remove(sub) @@ -219,7 +249,9 @@ def split_touching_rib_ccs( next_label = int(rib_cc.max()) + 1 if rib_cc.shape and rib_cc.max() > 0 else 1 u = {int(x) for x in rib_cc.unique() if x != 0} # ribs counte twice (left/right) - expected_number_of_ccs = len([a for a in Vertebra_Instance.thoracic() if a.value in vert_ids]) * 2 + expected_number_of_ccs = ( + len([a for a in Vertebra_Instance.thoracic() if a.value in vert_ids]) * 2 + ) if expected_number_of_ccs == 0: expected_number_of_ccs = 24 @@ -231,8 +263,12 @@ def split_touching_rib_ccs( binary_ccs = [(cc_label, rib_cc.extract_label(cc_label)) for cc_label in labels] if num_workers == 1: results = [] - for cc_label, binary_cc in tqdm(binary_ccs, total=len(binary_ccs), desc="Separate RIBs"): - result = _try_erosion_split(cc_label, binary_cc, erosion_pixels, min_volume, _pass) + for cc_label, binary_cc in tqdm( + binary_ccs, total=len(binary_ccs), desc="Separate RIBs" + ): + result = _try_erosion_split( + cc_label, binary_cc, erosion_pixels, min_volume, _pass + ) if result is not None: results.append(result) else: @@ -240,12 +276,21 @@ def split_touching_rib_ccs( with ThreadPoolExecutor(max_workers=num_workers) as executor: futures = [ - executor.submit(_try_erosion_split, cc_label, binary_cc, erosion_pixels, min_volume, _pass) + executor.submit( + _try_erosion_split, + cc_label, + binary_cc, + erosion_pixels, + min_volume, + _pass, + ) for cc_label, binary_cc in binary_ccs ] # tqdm advances immediately whenever an individual future completes. - for future in tqdm(as_completed(futures), total=len(futures), desc="Separate RIBs"): + for future in tqdm( + as_completed(futures), total=len(futures), desc="Separate RIBs" + ): result = future.result() if result is not None: results.append(result) @@ -268,7 +313,9 @@ def split_touching_rib_ccs( u.add(new_lbl) if verbose: - logger.print(f"split CC {cc_label} via erosion -> {new_lbl} (voxels={int(m.sum())})") + logger.print( + f"split CC {cc_label} via erosion -> {new_lbl} (voxels={int(m.sum())})" + ) if short_cut and rib_cc.max() >= expected_number_of_ccs: break @@ -338,18 +385,29 @@ def assign_ribs_to_vert_segmentation( vert_seg = vert_seg.reorient() sem_seg = sem_seg.reorient() rib_seg = rib_seg.reorient(verbose=verbose) - vert_pred = vert_seg.extract_label([Vertebra_Instance.C7, *Vertebra_Instance.thoracic(), Vertebra_Instance.L1]) + vert_pred = vert_seg.extract_label( + [Vertebra_Instance.C7, *Vertebra_Instance.thoracic(), Vertebra_Instance.L1] + ) rib_seg = rib_seg.extract_label([left_id, right_id], keep_label=True) # type: ignore logger.on_debug(f"{rib_seg.unique()=}") # Remove rib voxels overlapping vertebrae rib_seg[vert_pred != 0] = 0 - rib_cc = rib_seg.filter_connected_components(None, min_volume=min_volume, keep_label=False) + rib_cc = rib_seg.filter_connected_components( + None, min_volume=min_volume, keep_label=False + ) cms_vert = calc_centroids(vert_seg) if split_touching: rib_cc = split_touching_rib_ccs( - rib_cc, erosion_pixels=erosion_pixels, min_volume=min_volume, vert_ids=vert_ids, short_cut=short_cut, verbose=verbose + rib_cc, + erosion_pixels=erosion_pixels, + min_volume=min_volume, + vert_ids=vert_ids, + short_cut=short_cut, + verbose=verbose, ) - cms_cc = calc_centroids(rib_cc * vert_pred.calc_convex_hull(None).dilate_msk_euclid(5)) + cms_cc = calc_centroids( + rib_cc * vert_pred.calc_convex_hull(None).dilate_msk_euclid(5) + ) cms_cc2 = calc_centroids(rib_cc) # .dilate_msk_euclid(5) vert_labels = _thoracic_like_labels(vert_seg.unique()) @@ -373,12 +431,17 @@ def assign_ribs_to_vert_segmentation( assert isinstance(vert_labels, (tuple, list)), type(vert_labels) if vert_labels and ( - (vert_labels[0] != 28 and vert_labels[1] != 28) or ((vert_labels[0], 50) in vert_labels and (vert_labels[1], 50) in vert_labels) + (vert_labels[0] != 28 and vert_labels[1] != 28) + or ((vert_labels[0], 50) in vert_labels and (vert_labels[1], 50) in vert_labels) ): inf_axis = cms_vert.get_axis("I") t1_z = cms_vert[vert_labels[0], 50][inf_axis] # use median vertebral spacing as adaptive threshold - spacing = abs(cms_vert[vert_labels[1], 50][inf_axis] - t1_z) if len(vert_labels) > 1 else 30 + spacing = ( + abs(cms_vert[vert_labels[1], 50][inf_axis] - t1_z) + if len(vert_labels) > 1 + else 30 + ) threshold = max(5, spacing * 0.6) nearest_left = abs(left_ccs[0].z - t1_z) if left_ccs else float("inf") @@ -405,20 +468,32 @@ def assign_ribs_to_vert_segmentation( logger.on_debug(f"{rib_inst.unique()=}") # Merge rib assignments back into the original vert / sem segmentations # without disturbing existing (non-rib) labels. Skip unmatched CCs (sentinel error_value=255). - matched = (rib_inst != 0) & (rib_inst != error_value) + print("rib_inst", rib_inst.dtype, rib_inst.seg) + matched = np.logical_and(rib_inst != 0, rib_inst != error_value) vert_seg[matched] = rib_inst[matched] - sem_seg[rib_seg != 0] = rib_seg.map_labels({left_id: Location.Rib_Left.value, right_id: Location.Rib_Right.value})[rib_seg != 0] # type: ignore + sem_seg[rib_seg != 0] = rib_seg.map_labels( + {left_id: Location.Rib_Left.value, right_id: Location.Rib_Right.value} + )[rib_seg != 0] # type: ignore undefined = sum(v == error_value for v in rib_vert_map.values()) logger.print(f"Unmatched rib CCs: {undefined}") if add_error: # or split_touching: vert_seg[rib_inst == error_value] = error_value if np.any(vert_seg.get_seg_array() == error_value): - vert_seg2 = vert_seg.remove_labels(error_value).infect(vert_seg.extract_label(error_value), verbose=False) + vert_seg2 = vert_seg.remove_labels(error_value).infect( + vert_seg.extract_label(error_value), verbose=False + ) vert_seg[vert_seg != vert_seg2] = vert_seg2[vert_seg != vert_seg2] - vert_seg[np.logical_and(rib_inst == error_value, vert_seg == 0)] = error_value - vert_seg[np.logical_and(vert_seg == 0, sem_seg.extract_label([Location.Rib_Left.value, Location.Rib_Right.value] == 1))] = ( - error_value - ) + vert_seg[np.logical_and(rib_inst == error_value, vert_seg == 0)] = ( + error_value + ) + vert_seg[ + np.logical_and( + vert_seg == 0, + sem_seg.extract_label( + [Location.Rib_Left.value, Location.Rib_Right.value] + ) == 1, + ) + ] = error_value return vert_seg.reorient_(ori), sem_seg.reorient_(ori) From 41e4a15aa7e9ede360e24813e1e8a6c6aed68253 Mon Sep 17 00:00:00 2001 From: TanjaLe <105376287+TanjaLe@users.noreply.github.com> Date: Wed, 30 Sep 2026 08:21:34 +0000 Subject: [PATCH 22/29] style fixes by ruff --- TPTBox/segmentation/rib/_rib_assign.py | 84 +++++++------------------- 1 file changed, 21 insertions(+), 63 deletions(-) diff --git a/TPTBox/segmentation/rib/_rib_assign.py b/TPTBox/segmentation/rib/_rib_assign.py index 39025bc..44302c9 100644 --- a/TPTBox/segmentation/rib/_rib_assign.py +++ b/TPTBox/segmentation/rib/_rib_assign.py @@ -47,9 +47,7 @@ def _thoracic_like_labels(labels: Iterable[int]) -> list[int]: return [*sorted(v for v in labels if 7 <= int(v) <= 20), 28] -def _split_ccs_by_side( - rib_cc: NII, cms_cc: POI, cms_cc2: POI, cms_vert: POI -) -> tuple[list[RibCandidate], list[RibCandidate]]: +def _split_ccs_by_side(rib_cc: NII, cms_cc: POI, cms_cc2: POI, cms_vert: POI) -> tuple[list[RibCandidate], list[RibCandidate]]: right_axis = cms_vert.get_axis("R") inf_axis = cms_vert.get_axis("I") # ref_x = cms_vert[ref_vert, 50][right_axis] @@ -81,9 +79,7 @@ def _split_ccs_by_side( return left, right -def _touching_surface( - mask_a: np.ndarray, mask_b: np.ndarray, nii: NII, axis_weights=None -) -> float: +def _touching_surface(mask_a: np.ndarray, mask_b: np.ndarray, nii: NII, axis_weights=None) -> float: """Return weighted number of voxel faces shared by two masks. Touching along ``up_down_axis`` is weighted by ``up_down_weight``. @@ -105,8 +101,7 @@ def _touching_surface( sl2[axis] = slice(1, None) touching = ( - np.logical_and(mask_a[tuple(sl1)], mask_b[tuple(sl2)]).sum() - + np.logical_and(mask_b[tuple(sl1)], mask_a[tuple(sl2)]).sum() + np.logical_and(mask_a[tuple(sl1)], mask_b[tuple(sl2)]).sum() + np.logical_and(mask_b[tuple(sl1)], mask_a[tuple(sl2)]).sum() ) weight = axis_weights.get(axis, 1) @@ -142,13 +137,9 @@ def _try_erosion_split( elif _pass == 1: eroded = cc_nii.erode_msk(n_pixel=erosion_pixels, verbose=False) elif _pass == 2: - eroded = cc_nii.erode_msk( - n_pixel=erosion_pixels, verbose=False, ignore_direction="R" - ) + eroded = cc_nii.erode_msk(n_pixel=erosion_pixels, verbose=False, ignore_direction="R") elif _pass == 3: - eroded = cc_nii.erode_msk( - n_pixel=erosion_pixels, verbose=False, ignore_direction="A" - ) + eroded = cc_nii.erode_msk(n_pixel=erosion_pixels, verbose=False, ignore_direction="A") else: eroded = cc_nii.erode_msk(n_pixel=erosion_pixels, verbose=False) except Exception as e: @@ -167,9 +158,7 @@ def _try_erosion_split( for sub in sub_labels.copy(): count = int((infected_arr == sub).sum()) - should_merge = ( - count * cc_nii.voxel_volume() < min_volume or count / full_size < 0.2 - ) + should_merge = count * cc_nii.voxel_volume() < min_volume or count / full_size < 0.2 if not should_merge: continue # print("merge", count, full_size, count / full_size) @@ -184,9 +173,7 @@ def _try_erosion_split( else: target = max( remaining, - key=lambda candidate: _touching_surface( - sub_mask, infected_arr == candidate, infected - ), + key=lambda candidate: _touching_surface(sub_mask, infected_arr == candidate, infected), ) # print("merge of many", target) infected_arr[infected_arr == sub] = target @@ -249,9 +236,7 @@ def split_touching_rib_ccs( next_label = int(rib_cc.max()) + 1 if rib_cc.shape and rib_cc.max() > 0 else 1 u = {int(x) for x in rib_cc.unique() if x != 0} # ribs counte twice (left/right) - expected_number_of_ccs = ( - len([a for a in Vertebra_Instance.thoracic() if a.value in vert_ids]) * 2 - ) + expected_number_of_ccs = len([a for a in Vertebra_Instance.thoracic() if a.value in vert_ids]) * 2 if expected_number_of_ccs == 0: expected_number_of_ccs = 24 @@ -263,12 +248,8 @@ def split_touching_rib_ccs( binary_ccs = [(cc_label, rib_cc.extract_label(cc_label)) for cc_label in labels] if num_workers == 1: results = [] - for cc_label, binary_cc in tqdm( - binary_ccs, total=len(binary_ccs), desc="Separate RIBs" - ): - result = _try_erosion_split( - cc_label, binary_cc, erosion_pixels, min_volume, _pass - ) + for cc_label, binary_cc in tqdm(binary_ccs, total=len(binary_ccs), desc="Separate RIBs"): + result = _try_erosion_split(cc_label, binary_cc, erosion_pixels, min_volume, _pass) if result is not None: results.append(result) else: @@ -288,9 +269,7 @@ def split_touching_rib_ccs( ] # tqdm advances immediately whenever an individual future completes. - for future in tqdm( - as_completed(futures), total=len(futures), desc="Separate RIBs" - ): + for future in tqdm(as_completed(futures), total=len(futures), desc="Separate RIBs"): result = future.result() if result is not None: results.append(result) @@ -313,9 +292,7 @@ def split_touching_rib_ccs( u.add(new_lbl) if verbose: - logger.print( - f"split CC {cc_label} via erosion -> {new_lbl} (voxels={int(m.sum())})" - ) + logger.print(f"split CC {cc_label} via erosion -> {new_lbl} (voxels={int(m.sum())})") if short_cut and rib_cc.max() >= expected_number_of_ccs: break @@ -385,16 +362,12 @@ def assign_ribs_to_vert_segmentation( vert_seg = vert_seg.reorient() sem_seg = sem_seg.reorient() rib_seg = rib_seg.reorient(verbose=verbose) - vert_pred = vert_seg.extract_label( - [Vertebra_Instance.C7, *Vertebra_Instance.thoracic(), Vertebra_Instance.L1] - ) + vert_pred = vert_seg.extract_label([Vertebra_Instance.C7, *Vertebra_Instance.thoracic(), Vertebra_Instance.L1]) rib_seg = rib_seg.extract_label([left_id, right_id], keep_label=True) # type: ignore logger.on_debug(f"{rib_seg.unique()=}") # Remove rib voxels overlapping vertebrae rib_seg[vert_pred != 0] = 0 - rib_cc = rib_seg.filter_connected_components( - None, min_volume=min_volume, keep_label=False - ) + rib_cc = rib_seg.filter_connected_components(None, min_volume=min_volume, keep_label=False) cms_vert = calc_centroids(vert_seg) if split_touching: rib_cc = split_touching_rib_ccs( @@ -405,9 +378,7 @@ def assign_ribs_to_vert_segmentation( short_cut=short_cut, verbose=verbose, ) - cms_cc = calc_centroids( - rib_cc * vert_pred.calc_convex_hull(None).dilate_msk_euclid(5) - ) + cms_cc = calc_centroids(rib_cc * vert_pred.calc_convex_hull(None).dilate_msk_euclid(5)) cms_cc2 = calc_centroids(rib_cc) # .dilate_msk_euclid(5) vert_labels = _thoracic_like_labels(vert_seg.unique()) @@ -431,17 +402,12 @@ def assign_ribs_to_vert_segmentation( assert isinstance(vert_labels, (tuple, list)), type(vert_labels) if vert_labels and ( - (vert_labels[0] != 28 and vert_labels[1] != 28) - or ((vert_labels[0], 50) in vert_labels and (vert_labels[1], 50) in vert_labels) + (vert_labels[0] != 28 and vert_labels[1] != 28) or ((vert_labels[0], 50) in vert_labels and (vert_labels[1], 50) in vert_labels) ): inf_axis = cms_vert.get_axis("I") t1_z = cms_vert[vert_labels[0], 50][inf_axis] # use median vertebral spacing as adaptive threshold - spacing = ( - abs(cms_vert[vert_labels[1], 50][inf_axis] - t1_z) - if len(vert_labels) > 1 - else 30 - ) + spacing = abs(cms_vert[vert_labels[1], 50][inf_axis] - t1_z) if len(vert_labels) > 1 else 30 threshold = max(5, spacing * 0.6) nearest_left = abs(left_ccs[0].z - t1_z) if left_ccs else float("inf") @@ -472,28 +438,20 @@ def assign_ribs_to_vert_segmentation( matched = np.logical_and(rib_inst != 0, rib_inst != error_value) vert_seg[matched] = rib_inst[matched] - sem_seg[rib_seg != 0] = rib_seg.map_labels( - {left_id: Location.Rib_Left.value, right_id: Location.Rib_Right.value} - )[rib_seg != 0] # type: ignore + sem_seg[rib_seg != 0] = rib_seg.map_labels({left_id: Location.Rib_Left.value, right_id: Location.Rib_Right.value})[rib_seg != 0] # type: ignore undefined = sum(v == error_value for v in rib_vert_map.values()) logger.print(f"Unmatched rib CCs: {undefined}") if add_error: # or split_touching: vert_seg[rib_inst == error_value] = error_value if np.any(vert_seg.get_seg_array() == error_value): - vert_seg2 = vert_seg.remove_labels(error_value).infect( - vert_seg.extract_label(error_value), verbose=False - ) + vert_seg2 = vert_seg.remove_labels(error_value).infect(vert_seg.extract_label(error_value), verbose=False) vert_seg[vert_seg != vert_seg2] = vert_seg2[vert_seg != vert_seg2] - vert_seg[np.logical_and(rib_inst == error_value, vert_seg == 0)] = ( - error_value - ) + vert_seg[np.logical_and(rib_inst == error_value, vert_seg == 0)] = error_value vert_seg[ np.logical_and( vert_seg == 0, - sem_seg.extract_label( - [Location.Rib_Left.value, Location.Rib_Right.value] - ) == 1, + sem_seg.extract_label([Location.Rib_Left.value, Location.Rib_Right.value]) == 1, ) ] = error_value return vert_seg.reorient_(ori), sem_seg.reorient_(ori) From 2716437a05c4de22b8211d6836ef2acc99fe195a Mon Sep 17 00:00:00 2001 From: gu53lif Date: Fri, 2 Oct 2026 11:23:38 +0200 Subject: [PATCH 23/29] deepali point registration and clean up --- .../_deformable/multilabel_segmentation.py | 146 ++++++++++++++---- TPTBox/segmentation/rib/_rib_assign.py | 86 ++++++++--- 2 files changed, 180 insertions(+), 52 deletions(-) diff --git a/TPTBox/registration/_deformable/multilabel_segmentation.py b/TPTBox/registration/_deformable/multilabel_segmentation.py index d4b7949..973679c 100644 --- a/TPTBox/registration/_deformable/multilabel_segmentation.py +++ b/TPTBox/registration/_deformable/multilabel_segmentation.py @@ -10,8 +10,10 @@ from TPTBox.core.poi_fun.poi_global import POI_Global from TPTBox.registration._deformable.deformable_reg import Deformable_Registration from TPTBox.registration._ridged_intensity.affine_deepali import Tether_Seg -from TPTBox.registration._ridged_points.deepali_point_registration import Deepali_Point_Registration -from TPTBox.registration._ridged_points.point_registration import Point_Registration +from TPTBox.registration._ridged_points.deepali_point_registration import ( + Deepali_Point_Registration as Point_Registration, +) +# from TPTBox.registration._ridged_points.point_registration import Point_Registration _NIIOrPOI = TypeVar("_NIIOrPOI", NII, POI) @@ -45,7 +47,9 @@ def _flip_r_axis(x: _NIIOrPOI) -> _NIIOrPOI: A new object of the same type with the R-axis reversed. """ if isinstance(x, POI_Global): - raise TypeError("_flip_r_axis: POI_Global has no shape/axis; resample to a voxel grid first.") + raise TypeError( + "_flip_r_axis: POI_Global has no shape/axis; resample to a voxel grid first." + ) axis = x.get_axis("R") if isinstance(x, NII): slicer = _r_axis_slicer(axis) @@ -171,12 +175,20 @@ def __init__( # noqa: C901 if poi_target_cms is not None: poi_target_cms = _flip_r_axis(poi_target_cms) if poi_target_cms is None: - x = target_seg.extract_label(cms_ids, keep_label=True) if cms_ids else target_seg + x = ( + target_seg.extract_label(cms_ids, keep_label=True) + if cms_ids + else target_seg + ) poi_target = calc_centroids(x, second_stage=40, bar=True) # TODO REMOVE else: poi_target = poi_target_cms.resample_from_to(target_seg) if poi_cms is None: - x = atlas_seg.extract_label(cms_ids, keep_label=True) if cms_ids else atlas_seg + x = ( + atlas_seg.extract_label(cms_ids, keep_label=True) + if cms_ids + else atlas_seg + ) poi_cms = calc_centroids(x, second_stage=40, bar=True) if not poi_cms.assert_affine(atlas_seg, raise_error=False): poi_cms = poi_cms.resample_from_to(atlas_seg) @@ -191,7 +203,11 @@ def __init__( # noqa: C901 resize_param: tuple | None = None target_tmp = target_seg - atlas_seg_ = atlas_seg.apply_pad(((1, 1), (1, 1), (1, 1))) if atlas_seg.is_segmentation_in_border() else atlas_seg + atlas_seg_ = ( + atlas_seg.apply_pad(((1, 1), (1, 1), (1, 1))) + if atlas_seg.is_segmentation_in_border() + else atlas_seg + ) for i in range(_max_iter): if resize_mode == "crop": @@ -200,7 +216,9 @@ def __init__( # noqa: C901 # --- try crop first --- t_crop = target_seg.compute_crop(0, crop_pad_size) - cropped = target_seg.apply_crop(t_crop).apply_pad(crop_pad_size - 50 // 4) + cropped = target_seg.apply_crop(t_crop).apply_pad( + crop_pad_size - 50 // 4 + ) if any(c < o for c, o in zip(cropped.shape, target_seg.shape)): resize_mode = "crop" @@ -224,7 +242,11 @@ def __init__( # noqa: C901 poi_target = poi_target.resample_from_to(target_tmp) if poi_cms is None: - x = atlas_seg_.extract_label(cms_ids, keep_label=True) if cms_ids else atlas_seg_ + x = ( + atlas_seg_.extract_label(cms_ids, keep_label=True) + if cms_ids + else atlas_seg_ + ) poi_cms = calc_centroids(x, second_stage=40, bar=True) if not poi_cms.assert_affine(atlas_seg_, raise_error=False): @@ -242,26 +264,46 @@ def __init__( # noqa: C901 # --- FINAL STEP: apply once to original target --- if resize_mode == "crop": target_seg = target_seg.apply_crop(resize_param) - target_img = target_img.apply_crop(resize_param) if target_img is not None else None + target_img = ( + target_img.apply_crop(resize_param) + if target_img is not None + else None + ) elif resize_mode == "pad": target_seg = target_seg.apply_pad(resize_param) - target_img = target_img.apply_pad(resize_param) if target_img is not None else None - - self.reg_point = Point_Registration(poi_target.resample_from_to(target_seg), poi_cms.resample_from_to(atlas_seg)) + target_img = ( + target_img.apply_pad(resize_param) + if target_img is not None + else None + ) + + self.reg_point = Point_Registration( + poi_target.resample_from_to(target_seg), poi_cms.resample_from_to(atlas_seg) + ) atlas_reg = self.reg_point.transform_nii(atlas_seg, c_val=0) - atlas_img_reg = self.reg_point.transform_nii(atlas_img) if atlas_img is not None else None + atlas_img_reg = ( + self.reg_point.transform_nii(atlas_img) if atlas_img is not None else None + ) if crop: self.crop = (target_seg + atlas_reg).compute_crop(0, 5) target_seg = target_seg.apply_crop(self.crop) - target_img = target_img.apply_crop(self.crop) if target_img is not None else None + target_img = ( + target_img.apply_crop(self.crop) if target_img is not None else None + ) atlas_reg = atlas_reg.apply_crop(self.crop) - atlas_img_reg = atlas_img_reg.apply_crop(self.crop) if atlas_img_reg is not None else None + atlas_img_reg = ( + atlas_img_reg.apply_crop(self.crop) + if atlas_img_reg is not None + else None + ) else: self.crop = None self.target_grid = target_seg.to_gird() - target_seg, atlas_reg, target_img, atlas_img_reg = change_after_point_reg(target_seg, atlas_reg, target_img, atlas_img_reg) + target_seg, atlas_reg, target_img, atlas_img_reg = change_after_point_reg( + target_seg, atlas_reg, target_img, atlas_img_reg + ) self.reg_deform = Deformable_Registration( target_seg if target_img is None else target_img, atlas_reg if atlas_img_reg is None else atlas_img_reg, @@ -350,7 +392,12 @@ def load_(cls, w: tuple) -> Template_Registration: return self - def transform_nii(self, nii_atlas: NII, allow_only_same_grid_as_moving: bool = True, only_rigid=False) -> NII: + def transform_nii( + self, + nii_atlas: NII, + allow_only_same_grid_as_moving: bool = True, + only_rigid=False, + ) -> NII: """Apply both rigid and deformable registration to a NII image. Args: @@ -363,7 +410,9 @@ def transform_nii(self, nii_atlas: NII, allow_only_same_grid_as_moving: bool = T Returns: Transformed ``NII`` aligned with the original target image space. """ - nii_atlas = self.reg_point.transform_nii(nii_atlas, allow_only_same_grid_as_moving=allow_only_same_grid_as_moving) + nii_atlas = self.reg_point.transform_nii( + nii_atlas, allow_only_same_grid_as_moving=allow_only_same_grid_as_moving + ) if only_rigid: return nii_atlas @@ -427,7 +476,9 @@ def transform_poi_inverse(self, poi_target: POI_Global | POI): # poi = poi.apply_crop_inverse(self.crop) # --- inverse rigid point registration --- - poi = self.reg_point.transform_poi_inverse(poi, allow_only_same_grid_as_moving=False) + poi = self.reg_point.transform_poi_inverse( + poi, allow_only_same_grid_as_moving=False + ) # --- back to atlas grid --- poi = poi.resample_from_to(self.atlas_org) @@ -543,18 +594,28 @@ def __init__( # noqa: C901 self.reg_point = pre_registration else: if poi_target_cms is None: - x_seg = target_seg.extract_label(cms_ids, keep_label=True) if cms_ids else target_seg + x_seg = ( + target_seg.extract_label(cms_ids, keep_label=True) + if cms_ids + else target_seg + ) poi_target = calc_centroids(x_seg, second_stage=40, bar=True) else: poi_target = poi_target_cms.resample_from_to(target_seg) if poi_cms is None: - x_seg = atlas_seg.extract_label(cms_ids, keep_label=True) if cms_ids else atlas_seg + x_seg = ( + atlas_seg.extract_label(cms_ids, keep_label=True) + if cms_ids + else atlas_seg + ) poi_cms_local = calc_centroids(x_seg, second_stage=40, bar=True) else: poi_cms_local = poi_cms if not poi_cms_local.assert_affine(atlas_seg, raise_error=False): poi_cms_local = poi_cms_local.resample_from_to(atlas_seg) - self.reg_point = Deepali_Point_Registration(poi_target, poi_cms_local, verbose=False, ddevice=ddevice, gpu=gpu) + self.reg_point = Deepali_Point_Registration( + poi_target, poi_cms_local, verbose=False, ddevice=ddevice, gpu=gpu + ) # --- optional crop ----------------------------------------------------------- # Unlike Template_Registration we do NOT pre-resample the atlas: the rigid @@ -575,11 +636,23 @@ def __init__( # noqa: C901 # on their native grids using same_space=False. We still let the deformable # registration warm-start from the rigid transform by applying the rigid to # the atlas *only for loss evaluation via the deformable pyramid*. - atlas_moved_seg = self.reg_point.transform_nii(atlas_seg, allow_only_same_grid_as_moving=False) - atlas_moved_img = self.reg_point.transform_nii(atlas_img, allow_only_same_grid_as_moving=False) if atlas_img is not None else None + atlas_moved_seg = self.reg_point.transform_nii( + atlas_seg, allow_only_same_grid_as_moving=False + ) + atlas_moved_img = ( + self.reg_point.transform_nii( + atlas_img, allow_only_same_grid_as_moving=False + ) + if atlas_img is not None + else None + ) if crop: atlas_moved_seg = atlas_moved_seg.apply_crop(self.crop) - atlas_moved_img = atlas_moved_img.apply_crop(self.crop) if atlas_moved_img is not None else None + atlas_moved_img = ( + atlas_moved_img.apply_crop(self.crop) + if atlas_moved_img is not None + else None + ) self.reg_deform = Deformable_Registration( target_seg if target_img is None else target_img, @@ -622,12 +695,16 @@ def save(self, path: str | Path) -> None: pickle.dump(self.get_dump(), w) @classmethod - def load(cls, path: str | Path, gpu: int = 0, ddevice: DEVICES = "cuda") -> Template_Registration2: + def load( + cls, path: str | Path, gpu: int = 0, ddevice: DEVICES = "cuda" + ) -> Template_Registration2: with open(path, "rb") as w: return cls.load_(pickle.load(w), gpu=gpu, ddevice=ddevice) @classmethod - def load_(cls, w: tuple, gpu: int = 0, ddevice: DEVICES = "cuda") -> Template_Registration2: + def load_( + cls, w: tuple, gpu: int = 0, ddevice: DEVICES = "cuda" + ) -> Template_Registration2: version, t0, t1, x = w assert version == 1, f"Version mismatch {version=}" self = cls.__new__(cls) @@ -643,9 +720,16 @@ def load_(cls, w: tuple, gpu: int = 0, ddevice: DEVICES = "cuda") -> Template_Re return self # ------------------------------------------------------------------------- warping - def transform_nii(self, nii_atlas: NII, allow_only_same_grid_as_moving: bool = True, only_rigid: bool = False) -> NII: + def transform_nii( + self, + nii_atlas: NII, + allow_only_same_grid_as_moving: bool = True, + only_rigid: bool = False, + ) -> NII: """Warp an atlas NII into the target space (rigid + deformable).""" - nii_atlas = self.reg_point.transform_nii(nii_atlas, allow_only_same_grid_as_moving=allow_only_same_grid_as_moving) + nii_atlas = self.reg_point.transform_nii( + nii_atlas, allow_only_same_grid_as_moving=allow_only_same_grid_as_moving + ) if only_rigid: return nii_atlas if self.crop is not None: @@ -679,6 +763,8 @@ def transform_poi_inverse(self, poi_target: POI_Global | POI) -> POI: poi = poi.resample_from_to(self.target_grid) reg_deform_inv = self.reg_deform.inverse() poi = reg_deform_inv.transform_poi(poi) - poi = self.reg_point.transform_poi_inverse(poi, allow_only_same_grid_as_moving=False) + poi = self.reg_point.transform_poi_inverse( + poi, allow_only_same_grid_as_moving=False + ) poi = poi.resample_from_to(self.atlas_org) return poi diff --git a/TPTBox/segmentation/rib/_rib_assign.py b/TPTBox/segmentation/rib/_rib_assign.py index 44302c9..3b1c0ca 100644 --- a/TPTBox/segmentation/rib/_rib_assign.py +++ b/TPTBox/segmentation/rib/_rib_assign.py @@ -47,7 +47,9 @@ def _thoracic_like_labels(labels: Iterable[int]) -> list[int]: return [*sorted(v for v in labels if 7 <= int(v) <= 20), 28] -def _split_ccs_by_side(rib_cc: NII, cms_cc: POI, cms_cc2: POI, cms_vert: POI) -> tuple[list[RibCandidate], list[RibCandidate]]: +def _split_ccs_by_side( + rib_cc: NII, cms_cc: POI, cms_cc2: POI, cms_vert: POI +) -> tuple[list[RibCandidate], list[RibCandidate]]: right_axis = cms_vert.get_axis("R") inf_axis = cms_vert.get_axis("I") # ref_x = cms_vert[ref_vert, 50][right_axis] @@ -79,7 +81,9 @@ def _split_ccs_by_side(rib_cc: NII, cms_cc: POI, cms_cc2: POI, cms_vert: POI) -> return left, right -def _touching_surface(mask_a: np.ndarray, mask_b: np.ndarray, nii: NII, axis_weights=None) -> float: +def _touching_surface( + mask_a: np.ndarray, mask_b: np.ndarray, nii: NII, axis_weights=None +) -> float: """Return weighted number of voxel faces shared by two masks. Touching along ``up_down_axis`` is weighted by ``up_down_weight``. @@ -101,7 +105,8 @@ def _touching_surface(mask_a: np.ndarray, mask_b: np.ndarray, nii: NII, axis_wei sl2[axis] = slice(1, None) touching = ( - np.logical_and(mask_a[tuple(sl1)], mask_b[tuple(sl2)]).sum() + np.logical_and(mask_b[tuple(sl1)], mask_a[tuple(sl2)]).sum() + np.logical_and(mask_a[tuple(sl1)], mask_b[tuple(sl2)]).sum() + + np.logical_and(mask_b[tuple(sl1)], mask_a[tuple(sl2)]).sum() ) weight = axis_weights.get(axis, 1) @@ -137,9 +142,13 @@ def _try_erosion_split( elif _pass == 1: eroded = cc_nii.erode_msk(n_pixel=erosion_pixels, verbose=False) elif _pass == 2: - eroded = cc_nii.erode_msk(n_pixel=erosion_pixels, verbose=False, ignore_direction="R") + eroded = cc_nii.erode_msk( + n_pixel=erosion_pixels, verbose=False, ignore_direction="R" + ) elif _pass == 3: - eroded = cc_nii.erode_msk(n_pixel=erosion_pixels, verbose=False, ignore_direction="A") + eroded = cc_nii.erode_msk( + n_pixel=erosion_pixels, verbose=False, ignore_direction="A" + ) else: eroded = cc_nii.erode_msk(n_pixel=erosion_pixels, verbose=False) except Exception as e: @@ -158,7 +167,9 @@ def _try_erosion_split( for sub in sub_labels.copy(): count = int((infected_arr == sub).sum()) - should_merge = count * cc_nii.voxel_volume() < min_volume or count / full_size < 0.2 + should_merge = ( + count * cc_nii.voxel_volume() < min_volume or count / full_size < 0.2 + ) if not should_merge: continue # print("merge", count, full_size, count / full_size) @@ -173,7 +184,9 @@ def _try_erosion_split( else: target = max( remaining, - key=lambda candidate: _touching_surface(sub_mask, infected_arr == candidate, infected), + key=lambda candidate: _touching_surface( + sub_mask, infected_arr == candidate, infected + ), ) # print("merge of many", target) infected_arr[infected_arr == sub] = target @@ -236,7 +249,9 @@ def split_touching_rib_ccs( next_label = int(rib_cc.max()) + 1 if rib_cc.shape and rib_cc.max() > 0 else 1 u = {int(x) for x in rib_cc.unique() if x != 0} # ribs counte twice (left/right) - expected_number_of_ccs = len([a for a in Vertebra_Instance.thoracic() if a.value in vert_ids]) * 2 + expected_number_of_ccs = ( + len([a for a in Vertebra_Instance.thoracic() if a.value in vert_ids]) * 2 + ) if expected_number_of_ccs == 0: expected_number_of_ccs = 24 @@ -248,8 +263,12 @@ def split_touching_rib_ccs( binary_ccs = [(cc_label, rib_cc.extract_label(cc_label)) for cc_label in labels] if num_workers == 1: results = [] - for cc_label, binary_cc in tqdm(binary_ccs, total=len(binary_ccs), desc="Separate RIBs"): - result = _try_erosion_split(cc_label, binary_cc, erosion_pixels, min_volume, _pass) + for cc_label, binary_cc in tqdm( + binary_ccs, total=len(binary_ccs), desc="Separate RIBs" + ): + result = _try_erosion_split( + cc_label, binary_cc, erosion_pixels, min_volume, _pass + ) if result is not None: results.append(result) else: @@ -269,7 +288,9 @@ def split_touching_rib_ccs( ] # tqdm advances immediately whenever an individual future completes. - for future in tqdm(as_completed(futures), total=len(futures), desc="Separate RIBs"): + for future in tqdm( + as_completed(futures), total=len(futures), desc="Separate RIBs" + ): result = future.result() if result is not None: results.append(result) @@ -292,7 +313,9 @@ def split_touching_rib_ccs( u.add(new_lbl) if verbose: - logger.print(f"split CC {cc_label} via erosion -> {new_lbl} (voxels={int(m.sum())})") + logger.print( + f"split CC {cc_label} via erosion -> {new_lbl} (voxels={int(m.sum())})" + ) if short_cut and rib_cc.max() >= expected_number_of_ccs: break @@ -362,12 +385,16 @@ def assign_ribs_to_vert_segmentation( vert_seg = vert_seg.reorient() sem_seg = sem_seg.reorient() rib_seg = rib_seg.reorient(verbose=verbose) - vert_pred = vert_seg.extract_label([Vertebra_Instance.C7, *Vertebra_Instance.thoracic(), Vertebra_Instance.L1]) + vert_pred = vert_seg.extract_label( + [Vertebra_Instance.C7, *Vertebra_Instance.thoracic(), Vertebra_Instance.L1] + ) rib_seg = rib_seg.extract_label([left_id, right_id], keep_label=True) # type: ignore logger.on_debug(f"{rib_seg.unique()=}") # Remove rib voxels overlapping vertebrae rib_seg[vert_pred != 0] = 0 - rib_cc = rib_seg.filter_connected_components(None, min_volume=min_volume, keep_label=False) + rib_cc = rib_seg.filter_connected_components( + None, min_volume=min_volume, keep_label=False + ) cms_vert = calc_centroids(vert_seg) if split_touching: rib_cc = split_touching_rib_ccs( @@ -378,7 +405,9 @@ def assign_ribs_to_vert_segmentation( short_cut=short_cut, verbose=verbose, ) - cms_cc = calc_centroids(rib_cc * vert_pred.calc_convex_hull(None).dilate_msk_euclid(5)) + cms_cc = calc_centroids( + rib_cc * vert_pred.calc_convex_hull(None).dilate_msk_euclid(5) + ) cms_cc2 = calc_centroids(rib_cc) # .dilate_msk_euclid(5) vert_labels = _thoracic_like_labels(vert_seg.unique()) @@ -402,12 +431,17 @@ def assign_ribs_to_vert_segmentation( assert isinstance(vert_labels, (tuple, list)), type(vert_labels) if vert_labels and ( - (vert_labels[0] != 28 and vert_labels[1] != 28) or ((vert_labels[0], 50) in vert_labels and (vert_labels[1], 50) in vert_labels) + (vert_labels[0] != 28 and vert_labels[1] != 28) + or ((vert_labels[0], 50) in vert_labels and (vert_labels[1], 50) in vert_labels) ): inf_axis = cms_vert.get_axis("I") t1_z = cms_vert[vert_labels[0], 50][inf_axis] # use median vertebral spacing as adaptive threshold - spacing = abs(cms_vert[vert_labels[1], 50][inf_axis] - t1_z) if len(vert_labels) > 1 else 30 + spacing = ( + abs(cms_vert[vert_labels[1], 50][inf_axis] - t1_z) + if len(vert_labels) > 1 + else 30 + ) threshold = max(5, spacing * 0.6) nearest_left = abs(left_ccs[0].z - t1_z) if left_ccs else float("inf") @@ -434,24 +468,32 @@ def assign_ribs_to_vert_segmentation( logger.on_debug(f"{rib_inst.unique()=}") # Merge rib assignments back into the original vert / sem segmentations # without disturbing existing (non-rib) labels. Skip unmatched CCs (sentinel error_value=255). - print("rib_inst", rib_inst.dtype, rib_inst.seg) matched = np.logical_and(rib_inst != 0, rib_inst != error_value) vert_seg[matched] = rib_inst[matched] - sem_seg[rib_seg != 0] = rib_seg.map_labels({left_id: Location.Rib_Left.value, right_id: Location.Rib_Right.value})[rib_seg != 0] # type: ignore + sem_seg[rib_seg != 0] = rib_seg.map_labels( + {left_id: Location.Rib_Left.value, right_id: Location.Rib_Right.value} + )[rib_seg != 0] # type: ignore undefined = sum(v == error_value for v in rib_vert_map.values()) logger.print(f"Unmatched rib CCs: {undefined}") if add_error: # or split_touching: vert_seg[rib_inst == error_value] = error_value if np.any(vert_seg.get_seg_array() == error_value): - vert_seg2 = vert_seg.remove_labels(error_value).infect(vert_seg.extract_label(error_value), verbose=False) + vert_seg2 = vert_seg.remove_labels(error_value).infect( + vert_seg.extract_label(error_value), verbose=False + ) vert_seg[vert_seg != vert_seg2] = vert_seg2[vert_seg != vert_seg2] - vert_seg[np.logical_and(rib_inst == error_value, vert_seg == 0)] = error_value + vert_seg[np.logical_and(rib_inst == error_value, vert_seg == 0)] = ( + error_value + ) vert_seg[ np.logical_and( vert_seg == 0, - sem_seg.extract_label([Location.Rib_Left.value, Location.Rib_Right.value]) == 1, + sem_seg.extract_label( + [Location.Rib_Left.value, Location.Rib_Right.value] + ) + == 1, ) ] = error_value return vert_seg.reorient_(ori), sem_seg.reorient_(ori) From 4d1e0d96b195582735d3bf80b7a0ce0bc394be22 Mon Sep 17 00:00:00 2001 From: Robert Graf Date: Wed, 7 Oct 2026 11:00:59 +0000 Subject: [PATCH 24/29] rib gen fix --- TPTBox/segmentation/rib/_rib_assign.py | 5 +- TPTBox/segmentation/spineps.py | 28 +++-- TPTBox/spine/spinestats/_load_nako_wh.py | 128 +++++++++++++++++++---- 3 files changed, 128 insertions(+), 33 deletions(-) diff --git a/TPTBox/segmentation/rib/_rib_assign.py b/TPTBox/segmentation/rib/_rib_assign.py index 426880d..da64e7b 100644 --- a/TPTBox/segmentation/rib/_rib_assign.py +++ b/TPTBox/segmentation/rib/_rib_assign.py @@ -13,12 +13,9 @@ from __future__ import annotations -import os from collections.abc import Iterable from concurrent.futures import ThreadPoolExecutor, as_completed from dataclasses import dataclass -from enum import Enum -from functools import partial import numpy as np from tqdm import tqdm @@ -405,7 +402,7 @@ def assign_ribs_to_vert_segmentation( logger.on_debug(f"{rib_inst.unique()=}") # Merge rib assignments back into the original vert / sem segmentations # without disturbing existing (non-rib) labels. Skip unmatched CCs (sentinel error_value=255). - matched = (rib_inst != 0) & (rib_inst != error_value) + matched = np.logical_and((rib_inst != 0), (rib_inst != error_value)) vert_seg[matched] = rib_inst[matched] sem_seg[rib_seg != 0] = rib_seg.map_labels({left_id: Location.Rib_Left.value, right_id: Location.Rib_Right.value})[rib_seg != 0] # type: ignore diff --git a/TPTBox/segmentation/spineps.py b/TPTBox/segmentation/spineps.py index dca938f..45129d0 100644 --- a/TPTBox/segmentation/spineps.py +++ b/TPTBox/segmentation/spineps.py @@ -49,7 +49,9 @@ def get_outpaths_spineps( if not isinstance(file_path, BIDS_FILE): file_path = Path(file_path) - file_path = BIDS_FILE(file_path, file_path.parent if dataset is None else dataset) + file_path = BIDS_FILE( + file_path, file_path.parent if dataset is None else dataset + ) output_paths = output_paths_from_input( file_path, derivative_name, @@ -74,7 +76,7 @@ def run_spineps( save_debug_data: bool = False, verbose: bool = False, save_raw: bool = False, - ignore_compatibility_issues: bool = False, + ignore_compatibility_issues: bool = True, use_cpu: bool = False, **args, ) -> dict[ @@ -124,9 +126,10 @@ def run_spineps( Returns: The output paths dictionary returned by SPINEPS' ``process_img_nii``. """ - from spineps import get_instance_model, get_semantic_model from spineps.get_models import get_actual_model + from spineps import get_instance_model, get_semantic_model + try: from spineps import process_img_nii as segment_image except Exception: @@ -137,13 +140,17 @@ def run_spineps( from spineps.get_models import get_labeling_model if model_labeling is not None: - label = {"model_labeling": get_labeling_model(model_labeling, use_cpu=use_cpu)} + label = { + "model_labeling": get_labeling_model(model_labeling, use_cpu=use_cpu) + } except Exception: pass # TODO remove when spineps has officially adopted labeling if not isinstance(file_path, BIDS_FILE): file_path = Path(file_path) - file_path = BIDS_FILE(file_path, file_path.parent if dataset is None else dataset) + file_path = BIDS_FILE( + file_path, file_path.parent if dataset is None else dataset + ) elif dataset is not None: file_path.dataset = dataset if isinstance(model_semantic, Path): @@ -235,7 +242,9 @@ def _run_spineps_internal( image_nii.apply_crop_(crop) logger.print(f"Cropped down to {image_nii.shape}", verbose=verbose) if proc_pad_size > 0: - image_nii = image_nii.pad_to(tuple(image_nii.shape[i] + (2 * proc_pad_size) for i in range(3))) + image_nii = image_nii.pad_to( + tuple(image_nii.shape[i] + (2 * proc_pad_size) for i in range(3)) + ) # arr = image_nii.get_array() # arr = np.pad(arr, proc_pad_size, mode="edge") # image_nii.set_array_(arr) @@ -256,7 +265,9 @@ def _run_spineps_internal( if proc_fillholes: for i in seg_nii.unique(): seg_nii.fill_holes_(labels=i, verbose=verbose) # inferior direction (axial) - orig_img_nii.assert_affine(shape=seg_nii.shape, orientation=seg_nii.orientation, zoom=seg_nii.zoom) + orig_img_nii.assert_affine( + shape=seg_nii.shape, orientation=seg_nii.orientation, zoom=seg_nii.zoom + ) # seg_nii.reorient(ori, verbose=verbose) seg_nii.affine = orig_img_nii.affine seg_nii.origin = orig_img_nii.origin @@ -304,12 +315,13 @@ def _run_spineps_vert( Returns: A tuple of (cleaned_semantic_NII, cleaned_vertebra_NII, raw_vertebra_NII). """ + from spineps.get_models import get_actual_model + from spineps import ( get_instance_model, phase_postprocess_combined, predict_instance_mask, ) - from spineps.get_models import get_actual_model if isinstance(model_instance, Path): model_instance = get_actual_model(model_instance, use_cpu=use_cpu) diff --git a/TPTBox/spine/spinestats/_load_nako_wh.py b/TPTBox/spine/spinestats/_load_nako_wh.py index 0da1501..939a610 100644 --- a/TPTBox/spine/spinestats/_load_nako_wh.py +++ b/TPTBox/spine/spinestats/_load_nako_wh.py @@ -1042,10 +1042,42 @@ def is_hard_linked(sub: str) -> bool: return _hard_link_done_marker(str(sub)).exists() +def _stale_hardlink_targets(srcs: dict[str, Path], new_path: Path) -> list[Path]: + """Return targets that exist on disk but no longer share an inode with their source. + + A hardlink pair shares an inode (``os.path.samefile``); a stale target either points at + a previous inode (source was rewritten, not overwritten in place) or at a different + source entirely (e.g. flipped between ``dataset-nako`` and ``rawdata-corrected``). + Callers unlink the returned paths before re-running ``bf.symlink_files``. + """ + base = str(new_path)[: -len(".nii.gz")] + stale: list[Path] = [] + for ext, src in srcs.items(): + t = Path(base + "." + ext) + if not t.exists(): + continue + try: + if not t.samefile(Path(src)): + stale.append(t) + except OSError: + stale.append(t) + return stale + + def hard_link( d: dict, dataset="/DATA/NAS/datasets_processed/NAKO/dataset-nako/", + refresh_stale: bool = False, ): + """Materialize a subject's file dict into ``dataset-nako-canonical`` via hardlinks. + + When ``refresh_stale=True`` every already-linked target is checked against its + current source with :func:`os.path.samefile`. Mismatches (source was rewritten, + or the ``subj_dict`` slot flipped between ``dataset-nako`` and + ``rawdata-corrected``) are unlinked and re-linked so ``-canonical`` matches + the current state. Default is ``False`` — cheaper, but stale references + survive between runs. + """ subj = d.pop("id") d.pop("dataset") log.on_log(subj) @@ -1062,7 +1094,14 @@ def hard_link( info={"ses": "baseline"}, dataset_path="/DATA/NAS/datasets_processed/NAKO/dataset-nako-canonical", ) - if not new_path.exists(): + stale: list[Path] = [] + if refresh_stale and new_path.exists(): + srcs_t2w = {ext: Path(src) for ext, src in bf.file.items() if Path(src).exists()} + stale = _stale_hardlink_targets(srcs_t2w, new_path) + for t in stale: + t.unlink() + print("Refreshing stale hardlink:", t) + if stale or not new_path.exists(): new_path.parent.mkdir(parents=True, exist_ok=True) bf.symlink_files(new_path, hard_link=True) # exist_ok=True, print("Hard linked:", new_path) @@ -1138,7 +1177,11 @@ def hard_link( # Compute the real target paths (per extension) and skip if all already exist. base = str(new_path)[: -len(".nii.gz")] targets = {ext: Path(base + "." + ext) for ext in srcs} - if all(t.exists() for t in targets.values()): + stale = _stale_hardlink_targets(srcs, new_path) if refresh_stale else [] + for t in stale: + t.unlink() + print("Refreshing stale hardlink:", t) + if not stale and all(t.exists() for t in targets.values()): continue new_path.parent.mkdir(parents=True, exist_ok=True) # Temporarily restrict bf.file to just the sources we want linked, then restore. @@ -1288,9 +1331,6 @@ def _restitch_vibe_water_fat( result: dict[str, Path] = {} for part in ("water", "fat"): out_path = out_dir / f"sub-{sub}_sequ-stitched_acq-ax_part-{part}_desc-{tag}_vibe.nii.gz" - if out_path.exists(): - result[part] = out_path - continue images: list[Path] = [] for c in chunks_sorted: if c in corrected_set: @@ -1301,23 +1341,59 @@ def _restitch_vibe_water_fat( p = raw_dir / f"sub-{sub}_acq-ax_chunk-{c}_part-{part}_vibe.nii.gz" if p.exists(): images.append(p) - if len(images) < 2: - log.on_warning(f"sub-{sub} part-{part}: only {len(images)} chunk(s) available; cannot stitch") + # Re-stitch when a chunk (corrected or raw) is newer than the cached output; + # covers the case where nako_export regenerated a corrected chunk in place. + needs_stitch = True + if out_path.exists() and images: + out_mtime = out_path.stat().st_mtime + if all(p.stat().st_mtime <= out_mtime for p in images): + needs_stitch = False + _delete_stale_corrected_stitches(out_dir, sub, part, keep=out_path) + if needs_stitch: + if len(images) < 2: + log.on_warning(f"sub-{sub} part-{part}: only {len(images)} chunk(s) available; cannot stitch") + continue + try: + _stitching_fn( + [str(p) for p in images], + str(out_path), + is_seg=False, + bias_field=False, + verbose=False, + verbose_stitching=False, + ) + except Exception as e: # noqa: BLE001 + log.on_warning(f"sub-{sub} part-{part}: stitching failed: {type(e).__name__}: {e}") + continue + result[part] = out_path + return result + + +def _delete_stale_corrected_stitches(out_dir: Path, sub: str, part: str, keep: Path) -> None: + """Remove older ``desc-corrected*`` re-stitches whose tag no longer matches ``keep``. + + Called after resolving the current tag: whenever a new set of corrected chunks + yields a different ``desc-{tag}`` filename, the file(s) written for the previous + set are no longer reachable through the index and must not shadow later runs. + """ + keep_resolved = keep.resolve() if keep.exists() else keep + pattern = f"sub-{sub}_sequ-stitched_acq-ax_part-{part}_desc-corrected*_vibe.nii.gz" + for stale in out_dir.glob(pattern): + if stale.exists() and stale.resolve() == keep_resolved: continue try: - _stitching_fn( - [str(p) for p in images], - str(out_path), - is_seg=False, - bias_field=False, - verbose=False, - verbose_stitching=False, - ) - except Exception as e: # noqa: BLE001 - log.on_warning(f"sub-{sub} part-{part}: stitching failed: {type(e).__name__}: {e}") + stale.unlink() + except OSError as e: + log.on_warning(f"sub-{sub} part-{part}: could not delete stale {stale.name}: {e}") continue - result[part] = out_path - return result + base = stale.name[: -len(".nii.gz")] + for sidecar in stale.parent.glob(base + ".*"): + if sidecar.name == stale.name: + continue + try: + sidecar.unlink() + except OSError: + pass def _corrected_bids_file(path: Path, dataset_root: Path) -> BIDS_FILE: @@ -1587,6 +1663,15 @@ def _drain(fs): f"(defaults to {_VIBE_MISMATCH_SNAP_DIR}) unless one already exists there. " "Pass '' to disable.", ) + parser.add_argument( + "--no-refresh-stale", + dest="refresh_stale", + action="store_false", + help="Disable per-target samefile check. When set, subjects already flagged .hardlink_done are " + "skipped and existing targets in -canonical are never re-linked, even if the source flipped " + "between dataset-nako and rawdata-corrected or was rewritten with a new inode.", + ) + parser.set_defaults(refresh_stale=True) args = parser.parse_args() test = False @@ -1598,10 +1683,11 @@ def _drain(fs): precompute_grid_info_parallel(num_workers=args.workers, test=test) else: corrected = load_corrected_index() + skip = None if args.refresh_stale else is_hard_linked for d in loop_over_repaired_nako( test=test, corrected_index=corrected, - skip_subject=is_hard_linked, + skip_subject=skip, vibe_mismatch_snap_dir=args.vibe_mismatch_snaps or None, ): - hard_link(d) + hard_link(d, refresh_stale=args.refresh_stale) From 00c8bbe1a7302482a124f75e2681535e46c3dbcc Mon Sep 17 00:00:00 2001 From: Robert Graf Date: Wed, 7 Oct 2026 17:39:59 +0000 Subject: [PATCH 25/29] better cms handling --- TPTBox/core/nii_wrapper.py | 13 +++-- TPTBox/core/poi_fun/poi_abstract.py | 43 ++++++++++------ .../_deformable/multilabel_segmentation.py | 50 ++++++++++++++++--- 3 files changed, 83 insertions(+), 23 deletions(-) diff --git a/TPTBox/core/nii_wrapper.py b/TPTBox/core/nii_wrapper.py index 93c6512..9601cad 100755 --- a/TPTBox/core/nii_wrapper.py +++ b/TPTBox/core/nii_wrapper.py @@ -2564,7 +2564,8 @@ def to_stl( to_world: bool = True, include_normals: bool = False, number_path=False, - _crop = True + _crop = True, + _raise_error=False ) -> Mesh: """Convert a binary segmentation label into an STL surface mesh using marching cubes. @@ -2618,7 +2619,13 @@ def to_stl( seg = self.extract_label(label) # Prepare binary mask if _crop and to_world: # this speed up the stl generation - crop = seg.compute_crop(0,1) + try: + crop = seg.compute_crop(0,1) + except ValueError: + log.on_fail(f"to_stl: empty segmentation. {label=}; Nothing will be saved") + if _raise_error: + raise + return None # type: ignore seg = seg.apply_crop(crop) seg_arr = np.pad(seg.clamp(0, 1).get_array(), 1) # Marching cubes (voxel coordinates) @@ -2633,7 +2640,7 @@ def to_stl( verts += np.array([b.start for b in bb]) # Convert to world coordinates using affine if to_world: - affine = self.affine # (4, 4) + affine = seg.affine # (4, 4) verts_h = np.c_[verts, np.ones(len(verts))] # homogeneous coords verts = (affine @ verts_h.T).T[:, :3] diff --git a/TPTBox/core/poi_fun/poi_abstract.py b/TPTBox/core/poi_fun/poi_abstract.py index 6139891..ebece0f 100755 --- a/TPTBox/core/poi_fun/poi_abstract.py +++ b/TPTBox/core/poi_fun/poi_abstract.py @@ -154,28 +154,30 @@ def normalize_label_name(d: dict | None) -> dict[int, dict]: Target: ``{region:int -> {subregion:int -> name:str, "name": group_name:str}}``. Accepts and migrates: the old flat ``{"(1, 2)": "C2"}`` form, a JSON-loaded nested - form with string keys ``{"1": {"2": "C2", "name": "Spine"}}``, and the already-nested - form (idempotent). + form with string keys ``{"1": {"2": "C2", "name": "Spine"}}``, the already-nested + form (idempotent), and any mix of the three in the same dict (produced e.g. when + ``join_left_`` merges a nested POI into a dict that still receives flat + ``"(region, subregion)"`` string entries). """ import ast if not d: return {} out: dict[int, dict] = {} - # old flat format: every key is a "(region, subregion)" tuple string - if all(isinstance(k, str) and k.strip().startswith("(") for k in d): - for k, name in d.items(): + for k, v in d.items(): + # legacy flat entry: "(region, subregion)" -> "name" + if isinstance(k, str) and k.strip().startswith("("): region, subregion = ast.literal_eval(k) - out.setdefault(int(region), {})[int(subregion)] = name - return out - # nested format (region keys may be JSON strings) - for region, sub in d.items(): - target = out.setdefault(int(region), {}) - if isinstance(sub, dict): - for s, name in sub.items(): + out.setdefault(int(region), {})[int(subregion)] = v + continue + # nested entry: region -> {subregion: name, "name": group_name} (or degenerate region -> name) + region_i = int(k) + target = out.setdefault(region_i, {}) + if isinstance(v, dict): + for s, name in v.items(): target[_GROUP_NAME_KEY if s == _GROUP_NAME_KEY else int(s)] = name - else: # degenerate {region: name} -> treat as the region group name - target[_GROUP_NAME_KEY] = sub + else: + target[_GROUP_NAME_KEY] = v return out @@ -870,6 +872,19 @@ def set_label_name(self, region, subregion, name: str) -> None: d.setdefault(_id_of(region), {})[_id_of(subregion)] = name self.info[LABEL_NAME] = d + def iter_label_names(self) -> Iterator[tuple[int, int, str]]: + """Yield ``(region, subregion, name)`` triples for every custom per-point name stored in ``info["label_name"]``. + + The per-region group name (stored under the reserved ``"name"`` key) is + skipped; use :meth:`level_one_name` / :meth:`iter_level_one_names` for + that. Normalizes any legacy flat ``{"(r, s)": "name"}`` form on the fly. + """ + for region, subs in label_name_dict(self.info).items(): + for sub, name in subs.items(): + if sub == _GROUP_NAME_KEY: + continue + yield region, sub, name + def level_one_name(self, region) -> str | None: """Return the group (level-one) name of ``region``. diff --git a/TPTBox/registration/_deformable/multilabel_segmentation.py b/TPTBox/registration/_deformable/multilabel_segmentation.py index d4b7949..2468d48 100644 --- a/TPTBox/registration/_deformable/multilabel_segmentation.py +++ b/TPTBox/registration/_deformable/multilabel_segmentation.py @@ -4,7 +4,9 @@ from pathlib import Path from typing import TypeVar -from TPTBox import NII, POI +import numpy as np + +from TPTBox import NII, POI, Print_Logger from TPTBox.core.internal.deep_learning_utils import DEVICES from TPTBox.core.poi import calc_centroids from TPTBox.core.poi_fun.poi_global import POI_Global @@ -14,6 +16,7 @@ from TPTBox.registration._ridged_points.point_registration import Point_Registration _NIIOrPOI = TypeVar("_NIIOrPOI", NII, POI) +logger = Print_Logger() def _r_axis_slicer(axis: int) -> tuple[slice, ...]: @@ -435,6 +438,40 @@ def transform_poi_inverse(self, poi_target: POI_Global | POI): return poi +def _filter_pois(data_target: NII, nii_atlas_fov: NII, poi_target_cms, poi_atlas_cms): + u = data_target.unique() + cms_ids = [int(l) for l in u if not data_target.extract_label(l).is_segmentation_in_border()] + dropped = [int(l) for l in u if int(l) not in cms_ids] + if dropped: + logger.on_warning(f"labels {dropped} touch target FOV border - excluded from rigid CMS") + poi_target_cms_fit: POI | None = None + if not cms_ids: + # All target labels truncated. Fall back to the innermost point of each + # mask (argmax of the EDT) so the rigid stage at least matches the + # robust interior of the visible region, not the CMS of a truncated blob. + from scipy.ndimage import distance_transform_edt + + cms_ids = [int(l) for l in u] + logger.on_warning(" all labels truncated - using innermost-point fallback for rigid anchor") + poi_target_cms_fit = data_target.make_empty_POI() + poi_atlas_cms_fit = nii_atlas_fov.make_empty_POI() + for lbl in cms_ids: + tmask = data_target.extract_label(lbl).get_seg_array().astype(bool) + amask = nii_atlas_fov.extract_label(lbl).get_seg_array().astype(bool) + if not tmask.any() or not amask.any(): + continue + ti = np.unravel_index(int(np.argmax(distance_transform_edt(tmask))), tmask.shape) + ai = np.unravel_index(int(np.argmax(distance_transform_edt(amask))), amask.shape) + poi_target_cms_fit[lbl, 40] = tuple(float(x) for x in ti) + poi_atlas_cms_fit[lbl, 40] = tuple(float(x) for x in ai) + return (poi_target_cms_fit, poi_atlas_cms_fit) + else: + return ( + poi_target_cms.extract_region(cms_ids), + poi_atlas_cms.extract_region(cms_ids), + ) + + class Template_Registration2: """Atlas-to-target multi-stage registration with a pluggable pre-registration. @@ -542,16 +579,17 @@ def __init__( # noqa: C901 if pre_registration is not None: self.reg_point = pre_registration else: - if poi_target_cms is None: - x_seg = target_seg.extract_label(cms_ids, keep_label=True) if cms_ids else target_seg - poi_target = calc_centroids(x_seg, second_stage=40, bar=True) - else: - poi_target = poi_target_cms.resample_from_to(target_seg) if poi_cms is None: x_seg = atlas_seg.extract_label(cms_ids, keep_label=True) if cms_ids else atlas_seg poi_cms_local = calc_centroids(x_seg, second_stage=40, bar=True) else: poi_cms_local = poi_cms + if poi_target_cms is None: + x_seg = target_seg.extract_label(cms_ids, keep_label=True) if cms_ids else target_seg + poi_target = calc_centroids(x_seg, second_stage=40, bar=True) + poi_target, poi_cms_local = _filter_pois(target_seg, atlas_seg, poi_target, poi_cms_local) + else: + poi_target = poi_target_cms.resample_from_to(target_seg) if not poi_cms_local.assert_affine(atlas_seg, raise_error=False): poi_cms_local = poi_cms_local.resample_from_to(atlas_seg) self.reg_point = Deepali_Point_Registration(poi_target, poi_cms_local, verbose=False, ddevice=ddevice, gpu=gpu) From 08aff66e82d189ae150bb5af647d69bb4af92caf Mon Sep 17 00:00:00 2001 From: Robert Graf Date: Wed, 7 Oct 2026 17:40:07 +0000 Subject: [PATCH 26/29] ruff --- TPTBox/core/dicom/dicom_extract.py | 5 ++--- TPTBox/core/internal/nii_help.py | 1 - TPTBox/core/internal/train_nnUnet/train.py | 3 --- TPTBox/segmentation/spineps.py | 26 ++++++---------------- 4 files changed, 9 insertions(+), 26 deletions(-) diff --git a/TPTBox/core/dicom/dicom_extract.py b/TPTBox/core/dicom/dicom_extract.py index 3f29838..37b12cd 100644 --- a/TPTBox/core/dicom/dicom_extract.py +++ b/TPTBox/core/dicom/dicom_extract.py @@ -413,7 +413,7 @@ def _extract_nii_from_dicom(dicom_out_path, nii_path): return False except Exception as e: logger.on_debug("Multi-Frame DICOM did not work:", e) - # Multi-echo Philips DIXON (e.g. mDIX quant): N slices × M echoes at + # Multi-echo Philips DIXON (e.g. mDIX quant): N slices x M echoes at # the same ImagePositionPatient. dicom2nifti's own stack detector # collapses to N/2 fake groups of 2 slices, then fails inside # `four_d_to_nifti → create_affine` with `NOT_A_VOLUME`. Convert @@ -721,8 +721,7 @@ def _convert_multi_echo_dicoms_to_4d(dcm_list: list, nii_path: str | Path) -> bo ref_shape = arr.shape elif arr.shape != ref_shape or not np.allclose(n.affine, ref_affine, atol=1e-3): logger.on_warning( - f"multi-echo split: grid mismatch between echoes (echo {en}: shape={arr.shape}); " - f"aborting per-echo stack." + f"multi-echo split: grid mismatch between echoes (echo {en}: shape={arr.shape}); aborting per-echo stack." ) return False arrays.append(arr) diff --git a/TPTBox/core/internal/nii_help.py b/TPTBox/core/internal/nii_help.py index ba79b0d..e814884 100644 --- a/TPTBox/core/internal/nii_help.py +++ b/TPTBox/core/internal/nii_help.py @@ -133,7 +133,6 @@ def _convert(obj): def _save_json(data, filepath: str | Path | bids_files.BIDS_FILE, indent=4, convert=_convert): - if isinstance(filepath, bids_files.BIDS_FILE): if "json" in filepath.file: filepath = filepath.file["json"] diff --git a/TPTBox/core/internal/train_nnUnet/train.py b/TPTBox/core/internal/train_nnUnet/train.py index a9677dc..126ede4 100644 --- a/TPTBox/core/internal/train_nnUnet/train.py +++ b/TPTBox/core/internal/train_nnUnet/train.py @@ -248,7 +248,6 @@ def _run_training( enable_deep_supervision=True, save_every=1, # 50 ): - try: from nnunetv2.run.run_training import get_trainer_from_args, join, maybe_load_checkpoint except ImportError as e: @@ -409,7 +408,6 @@ def _patch_plans(self): # ---------------------------------------------------------- def _train_fold(self, fold: int | str): - # from nnunetv2.run.run_training import run_training print(f"Training fold {fold}") @@ -440,7 +438,6 @@ def _train_fold(self, fold: int | str): # ---------------------------------------------------------- def _train(self): - if self.cfg.single_gpu: os.environ["CUDA_VISIBLE_DEVICES"] = self.cfg.gpus[0] diff --git a/TPTBox/segmentation/spineps.py b/TPTBox/segmentation/spineps.py index 45129d0..0df6fb7 100644 --- a/TPTBox/segmentation/spineps.py +++ b/TPTBox/segmentation/spineps.py @@ -49,9 +49,7 @@ def get_outpaths_spineps( if not isinstance(file_path, BIDS_FILE): file_path = Path(file_path) - file_path = BIDS_FILE( - file_path, file_path.parent if dataset is None else dataset - ) + file_path = BIDS_FILE(file_path, file_path.parent if dataset is None else dataset) output_paths = output_paths_from_input( file_path, derivative_name, @@ -126,9 +124,8 @@ def run_spineps( Returns: The output paths dictionary returned by SPINEPS' ``process_img_nii``. """ - from spineps.get_models import get_actual_model - from spineps import get_instance_model, get_semantic_model + from spineps.get_models import get_actual_model try: from spineps import process_img_nii as segment_image @@ -140,17 +137,13 @@ def run_spineps( from spineps.get_models import get_labeling_model if model_labeling is not None: - label = { - "model_labeling": get_labeling_model(model_labeling, use_cpu=use_cpu) - } + label = {"model_labeling": get_labeling_model(model_labeling, use_cpu=use_cpu)} except Exception: pass # TODO remove when spineps has officially adopted labeling if not isinstance(file_path, BIDS_FILE): file_path = Path(file_path) - file_path = BIDS_FILE( - file_path, file_path.parent if dataset is None else dataset - ) + file_path = BIDS_FILE(file_path, file_path.parent if dataset is None else dataset) elif dataset is not None: file_path.dataset = dataset if isinstance(model_semantic, Path): @@ -242,9 +235,7 @@ def _run_spineps_internal( image_nii.apply_crop_(crop) logger.print(f"Cropped down to {image_nii.shape}", verbose=verbose) if proc_pad_size > 0: - image_nii = image_nii.pad_to( - tuple(image_nii.shape[i] + (2 * proc_pad_size) for i in range(3)) - ) + image_nii = image_nii.pad_to(tuple(image_nii.shape[i] + (2 * proc_pad_size) for i in range(3))) # arr = image_nii.get_array() # arr = np.pad(arr, proc_pad_size, mode="edge") # image_nii.set_array_(arr) @@ -265,9 +256,7 @@ def _run_spineps_internal( if proc_fillholes: for i in seg_nii.unique(): seg_nii.fill_holes_(labels=i, verbose=verbose) # inferior direction (axial) - orig_img_nii.assert_affine( - shape=seg_nii.shape, orientation=seg_nii.orientation, zoom=seg_nii.zoom - ) + orig_img_nii.assert_affine(shape=seg_nii.shape, orientation=seg_nii.orientation, zoom=seg_nii.zoom) # seg_nii.reorient(ori, verbose=verbose) seg_nii.affine = orig_img_nii.affine seg_nii.origin = orig_img_nii.origin @@ -315,13 +304,12 @@ def _run_spineps_vert( Returns: A tuple of (cleaned_semantic_NII, cleaned_vertebra_NII, raw_vertebra_NII). """ - from spineps.get_models import get_actual_model - from spineps import ( get_instance_model, phase_postprocess_combined, predict_instance_mask, ) + from spineps.get_models import get_actual_model if isinstance(model_instance, Path): model_instance = get_actual_model(model_instance, use_cpu=use_cpu) From 8f88511e4987494882ee96aa600dace4d9e6f0a7 Mon Sep 17 00:00:00 2001 From: Robert Graf Date: Wed, 28 Oct 2026 18:39:20 +0000 Subject: [PATCH 27/29] add 2D support for dicom --- TPTBox/core/dicom/dicom_extract.py | 128 +++++++++++++++++++++++++++-- 1 file changed, 120 insertions(+), 8 deletions(-) diff --git a/TPTBox/core/dicom/dicom_extract.py b/TPTBox/core/dicom/dicom_extract.py index 37b12cd..6221f81 100644 --- a/TPTBox/core/dicom/dicom_extract.py +++ b/TPTBox/core/dicom/dicom_extract.py @@ -158,6 +158,88 @@ def _make_fname(k: dict): return fname.file["json"], fname +# DICOM anatomical direction letter → unit vector in LPS (DICOM) space. +# L = +X (patient left), P = +Y (posterior), H = +Z (superior/head). +_DCM_LETTER_TO_LPS_VEC = { + "L": np.array([1.0, 0.0, 0.0]), + "R": np.array([-1.0, 0.0, 0.0]), + "P": np.array([0.0, 1.0, 0.0]), + "A": np.array([0.0, -1.0, 0.0]), + "H": np.array([0.0, 0.0, 1.0]), + "F": np.array([0.0, 0.0, -1.0]), +} +# Negate X and Y to turn DICOM LPS cosines into NIfTI RAS cosines. +_LPS_TO_RAS = np.array([-1.0, -1.0, 1.0]) + + +def _cosines_from_patient_orientation(patient_orientation) -> tuple[np.ndarray, np.ndarray] | tuple[None, None]: + """DICOM PatientOrientation (0020,0020) → RAS row/column cosines. + + The tag's two values are single-letter anatomical codes (L/R, A/P, H/F) + describing the direction the row and column axes point in patient space. + Returns (None, None) if the tag is absent, has blanks, or uses an + unknown letter — the caller then falls back to the next heuristic. + """ + if not patient_orientation or len(patient_orientation) < 2: + return None, None + row_letter = str(patient_orientation[0]).strip().upper()[:1] + col_letter = str(patient_orientation[1]).strip().upper()[:1] + row = _DCM_LETTER_TO_LPS_VEC.get(row_letter) + col = _DCM_LETTER_TO_LPS_VEC.get(col_letter) + if row is None or col is None: + return None, None + return row * _LPS_TO_RAS, col * _LPS_TO_RAS + + +_VIEW_TOKEN_TO_LETTERS = { + "ap": ("L", "F"), + "pa": ("L", "F"), + "ll": ("P", "F"), + "llat": ("P", "F"), + "lateral": ("P", "F"), # unspecified side defaults to left-lateral (clinical norm). + "lat": ("P", "F"), + "rl": ("A", "F"), + "rlat": ("A", "F"), +} + + +def _cosines_from_view_position(view_position) -> tuple[np.ndarray, np.ndarray] | tuple[None, None]: + """Fallback for projection X-rays whose PatientOrientation is blank. + + Uses the canonical radiography convention for the given ViewPosition + (0018,5101): rows run horizontally in the image, columns run top-to- + bottom (head→feet). Returns (None, None) for unknown or ambiguous + codes (e.g. a bare ``LAT`` that does not say which side). + """ + if not view_position: + return None, None + view = str(view_position).strip().lower() + letters = _VIEW_TOKEN_TO_LETTERS.get(view) + if letters is None: + return None, None + return _cosines_from_patient_orientation(list(letters)) + + +def _cosines_from_text_tokens(*texts) -> tuple[np.ndarray, np.ndarray] | tuple[None, None]: + """Last-ditch fallback: scan SeriesDescription / StudyDescription for AP/PA/LL/LAT tokens. + + Some scanners drop both PatientOrientation and ViewPosition for projection + X-rays, leaving only free-text descriptions. Scan them as whitespace- / + underscore- / dash-separated tokens so we only match standalone words, + not substrings like 'ap' inside 'scaphoid'. Returns (None, None) if no + recognisable projection token is found. + """ + import re + for text in texts: + if not text: + continue + for token in re.split(r"[\s_\-]+", str(text).lower()): + letters = _VIEW_TOKEN_TO_LETTERS.get(token) + if letters is not None: + return _cosines_from_patient_orientation(list(letters)) + return None, None + + def dicom_to_nifti_multiframe_2d(ds, nii_path, pixel_array) -> None: """Convert a 2D multiframe DICOM dataset to a NIfTI file using its affine metadata. @@ -170,18 +252,48 @@ def dicom_to_nifti_multiframe_2d(ds, nii_path, pixel_array) -> None: dy, dx = map(float, ds.PixelSpacing) affine = np.eye(4) - if hasattr(ds, "ImageOrientationPatient"): - orientation = list(map(float, ds.ImageOrientationPatient)) - row_cosines = np.array(orientation[:3]) - col_cosines = np.array(orientation[3:]) - affine[:3, 0] = row_cosines * dx - affine[:3, 1] = col_cosines * dy + row_cos = col_cos = None + iop = getattr(ds, "ImageOrientationPatient", None) + if iop is not None: + orientation = list(map(float, iop)) + # ImageOrientationPatient is in DICOM LPS; flip to NIfTI RAS. + row_cos = np.array(orientation[:3]) * _LPS_TO_RAS + col_cos = np.array(orientation[3:]) * _LPS_TO_RAS + + # Projection X-rays (CR/DX) typically ship without ImageOrientationPatient + # but set PatientOrientation (0020,0020) or ViewPosition (0018,5101) + # instead. Without this fallback the affine stayed identity and viewers + # showed each series rotated or mirrored depending on how the modality + # happened to lay out the pixel buffer. + if row_cos is None: + row_cos, col_cos = _cosines_from_patient_orientation(getattr(ds, "PatientOrientation", None)) + if row_cos is None: + row_cos, col_cos = _cosines_from_view_position(getattr(ds, "ViewPosition", None)) + if row_cos is None: + row_cos, col_cos = _cosines_from_text_tokens( + getattr(ds, "SeriesDescription", None), + getattr(ds, "StudyDescription", None), + getattr(ds, "ProtocolName", None), + ) + + if row_cos is not None: + affine[:3, 0] = row_cos * dx + affine[:3, 1] = col_cos * dy + # The 2D image lies in the row×col plane; the "slice" axis must be + # the normal to that plane so the affine stays non-singular. + # Leaving np.eye's default (0,0,1) produces a degenerate affine + # whenever row/col already span X-Z or Y-Z (e.g. AP X-rays with + # row=L, col=F) — ITK refuses to open such a NIfTI. + slice_cos = np.cross(row_cos, col_cos) + dz = float(getattr(ds, "SliceThickness", 1.0) or 1.0) + affine[:3, 2] = slice_cos * dz else: affine[0, 0] = dx affine[1, 1] = dy - if hasattr(ds, "ImagePositionPatient"): - affine[:3, 3] = np.array(list(map(float, ds.ImagePositionPatient))) + ipp = getattr(ds, "ImagePositionPatient", None) + if ipp is not None: + affine[:3, 3] = np.array(list(map(float, ipp))) * _LPS_TO_RAS elif hasattr(ds, "ImagerPixelSpacing"): dy, dx = map(float, ds.ImagerPixelSpacing) From 017a054c8592cedddce649567ca75b8716dc0b84 Mon Sep 17 00:00:00 2001 From: Robert Graf Date: Wed, 28 Oct 2026 18:55:31 +0000 Subject: [PATCH 28/29] ruff and pin ubuntu version for 3.9 --- .github/workflows/tests.yml | 6 +++++- .github/workflows/tests_mr.yml | 6 +++++- TPTBox/core/dicom/dicom_extract.py | 3 ++- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 91343a6..9ba09fd 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -14,7 +14,11 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, windows-latest] - python-version: ["3.9", "3.10", "3.11", "3.12"] + python-version: ["3.10", "3.11", "3.12"] + include: + # Python 3.9 is not available on ubuntu-latest (26.04); pin that cell to 22.04. + - os: ubuntu-22.04 + python-version: "3.9" steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/tests_mr.yml b/.github/workflows/tests_mr.yml index 4945b6a..619dd3f 100644 --- a/.github/workflows/tests_mr.yml +++ b/.github/workflows/tests_mr.yml @@ -14,7 +14,11 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest] - python-version: ["3.9","3.10","3.12"] + python-version: ["3.10", "3.12"] + include: + # Python 3.9 is not available on ubuntu-latest (26.04); pin that cell to 22.04. + - os: ubuntu-22.04 + python-version: "3.9" steps: - uses: actions/checkout@v4 diff --git a/TPTBox/core/dicom/dicom_extract.py b/TPTBox/core/dicom/dicom_extract.py index 6221f81..66350a6 100644 --- a/TPTBox/core/dicom/dicom_extract.py +++ b/TPTBox/core/dicom/dicom_extract.py @@ -230,6 +230,7 @@ def _cosines_from_text_tokens(*texts) -> tuple[np.ndarray, np.ndarray] | tuple[N recognisable projection token is found. """ import re + for text in texts: if not text: continue @@ -279,7 +280,7 @@ def dicom_to_nifti_multiframe_2d(ds, nii_path, pixel_array) -> None: if row_cos is not None: affine[:3, 0] = row_cos * dx affine[:3, 1] = col_cos * dy - # The 2D image lies in the row×col plane; the "slice" axis must be + # The 2D image lies in the row x col plane; the "slice" axis must be # the normal to that plane so the affine stays non-singular. # Leaving np.eye's default (0,0,1) produces a degenerate affine # whenever row/col already span X-Z or Y-Z (e.g. AP X-rays with From aae34bc018ed796cf9a601b86c65bd3e5daebdac Mon Sep 17 00:00:00 2001 From: Robert Graf Date: Wed, 28 Oct 2026 18:59:24 +0000 Subject: [PATCH 29/29] change in behavior; update test --- unit_tests/test_spinestats_angles.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/unit_tests/test_spinestats_angles.py b/unit_tests/test_spinestats_angles.py index d415f47..1b66df9 100644 --- a/unit_tests/test_spinestats_angles.py +++ b/unit_tests/test_spinestats_angles.py @@ -256,10 +256,11 @@ def test_has_point(self): class TestLordosisAndKyphosis(unittest.TestCase): def test_straight_spine_has_no_curvature(self): out = compute_lordosis_and_kyphosis(spine_poi()) - self.assertEqual(sorted(out), ["cervical_lordosis", "lumbar_lordosis", "thoracic_kyphosis"]) - for key, value in out.items(): + angle_keys = sorted(k for k in out if not k.endswith("_apex")) + self.assertEqual(angle_keys, ["cervical_lordosis", "lumbar_lordosis", "thoracic_kyphosis"]) + for key in angle_keys: with self.subTest(key=key): - self.assertEqual(value, 0.0) + self.assertEqual(out[key], 0.0) def test_tilting_c2_only_moves_the_cervical_value(self): out = compute_lordosis_and_kyphosis(spine_poi(sagittal_tilt={V.C2.value: 20.0})) @@ -660,7 +661,8 @@ def test_lordosis_plot_returns_the_same_numbers_as_the_bare_computation(self): def test_lordosis_plot_without_a_path_renders_nothing(self): values, _ = angles_mod.plot_compute_lordosis_and_kyphosis(None, self.poi.copy(), self.ct, self.vert) - self.assertEqual(sorted(values), ["cervical_lordosis", "lumbar_lordosis", "thoracic_kyphosis"]) + angle_keys = sorted(k for k in values if not k.endswith("_apex")) + self.assertEqual(angle_keys, ["cervical_lordosis", "lumbar_lordosis", "thoracic_kyphosis"]) def test_cobb_plot_returns_the_segments_and_a_frame(self): from TPTBox.spine.snapshot2D.snapshot_modular import Snapshot_Frame # noqa: PLC0415