From e077b5fc79efe2cc3adb3deaa1c850ea997fd196 Mon Sep 17 00:00:00 2001 From: Jules GRIVOT PELISSON Date: Tue, 27 Oct 2026 09:33:11 -0400 Subject: [PATCH] FIX: name each AREG IOS-CBCT patient folder as its files do and hand back the oriented CBCT beside the meshes --- .../src/sadt_areg_ioscbct/__init__.py | 5 +- .../src/sadt_areg_ioscbct/dispatch.py | 47 +++++++++++++-- .../src/sadt_areg_ioscbct/pipeline.py | 51 +++++++++++++++- tools/AREG/AREG_IOSCBCT/tests/test_pairing.py | 36 ++++++++++- .../AREG/AREG_IOSCBCT/tests/test_register.py | 60 +++++++++---------- tools/AREG/AREG_IOSCBCT/tests/test_run.py | 6 +- tools/AREG/AREG_IOSCBCT/tests/test_tools.py | 40 ++++++++++++- 7 files changed, 199 insertions(+), 46 deletions(-) diff --git a/tools/AREG/AREG_IOSCBCT/src/sadt_areg_ioscbct/__init__.py b/tools/AREG/AREG_IOSCBCT/src/sadt_areg_ioscbct/__init__.py index bd94bfb..a6e18ef 100644 --- a/tools/AREG/AREG_IOSCBCT/src/sadt_areg_ioscbct/__init__.py +++ b/tools/AREG/AREG_IOSCBCT/src/sadt_areg_ioscbct/__init__.py @@ -80,7 +80,10 @@ def run( digits of the patient identifier, the two modalities being named by different conventions. output_dir: Where the registered meshes, their 4x4 matrices and - `AREG_report.json` are written. Nothing is written outside it. + `AREG_report.json` are written, one folder per patient named as + its files name it (`case_042`). When the CBCT was oriented first, + the oriented scan the meshes are registered onto and its `.tfm` + are written beside them. Nothing is written outside it. automation: Registration takes both landmark sets and predicts nothing -- no other tool is called and no GPU is needed. Semi-Automated labels the crowns and predicts both sets. Fully-Automated orients the CBCT diff --git a/tools/AREG/AREG_IOSCBCT/src/sadt_areg_ioscbct/dispatch.py b/tools/AREG/AREG_IOSCBCT/src/sadt_areg_ioscbct/dispatch.py index a72f2cf..2785279 100644 --- a/tools/AREG/AREG_IOSCBCT/src/sadt_areg_ioscbct/dispatch.py +++ b/tools/AREG/AREG_IOSCBCT/src/sadt_areg_ioscbct/dispatch.py @@ -21,7 +21,7 @@ import numpy as np -from sadt_areg_common import catalogs +from sadt_areg_common import catalogs, pairing from sadt_areg_common.errors import ToolInputError from . import geometry, pipeline, progress, tools, where @@ -294,9 +294,36 @@ def _spans(automation: str, predict_ios: bool, predict_cbct: bool) -> dict: return spans +def _carry_cbct(cbct_path: str, folder: str, output_dir: str) -> dict: + """Copy the CBCT the meshes were registered onto into the patient's folder. + + In the Fully-Automated mode that is ASO's oriented scan, not the caller's: + every arch is written in its frame, and it lived only in the job's work + directory. Opened over the original scan, the meshes were off by the whole + rotation ASO applied, with nothing in the results to put them back on. Its + transform (original scan to oriented one, ASO's `_transform.tfm`) + comes too, for a reader who wants the meshes on the original scan instead. + + Returns the report fields naming what was copied, relative to `output_dir`. + """ + os.makedirs(folder, exist_ok=True) + copied = {} + stem = pairing.split_scan_extension(os.path.basename(cbct_path))[0] + transform = os.path.join(os.path.dirname(cbct_path), f"{stem}_transform.tfm") + for field, source in (("oriented_cbct", cbct_path), ("cbct_transform", transform)): + if not os.path.isfile(source): + logger.warning("No %s to copy beside the registered meshes", field.replace("_", " ")) + continue + destination = os.path.join(folder, os.path.basename(source)) + shutil.copy2(source, destination) + copied[field] = os.path.relpath(destination, output_dir) + return copied + + def register(ios_dir: str, cbct_dir: str, ios_landmark_dir: str, cbct_landmark_dir: str, output_dir: str, suffix: str, report: dict, max_dist: float, - progress_span: tuple = (0.0, 1.0), produced_by: dict = None) -> None: + progress_span: tuple = (0.0, 1.0), produced_by: dict = None, + carry_cbct: bool = False) -> None: """The registration proper, once every landmark exists. `progress_span` is the slice of the run's progress bar this phase fills. @@ -311,6 +338,9 @@ def register(ios_dir: str, cbct_dir: str, ios_landmark_dir: str, cbct_landmark_d source the message names, and its class -- an empty folder the caller sent is theirs to fix (ToolInputError), one a tool returned is a fault on this side (RuntimeError). + + `carry_cbct` copies each patient's CBCT, and the transform beside it, into + that patient's folder: see `_carry_cbct`. """ produced_by = produced_by or {} @@ -357,6 +387,7 @@ def error_for(*roles): progress.report(index, len(paired), "patient", start=progress_span[0], end=progress_span[1]) position = f"patient {index}/{len(paired)}" + folder = os.path.join(output_dir, data["name"]) entry = {"cbct": os.path.basename(data["cbct"]), "meshes": {}} # Narrowed to this patient BEFORE the jaw is looked at: see _for_patient. own_ios = _for_patient(ios_landmarks, patient, sole_patient) @@ -386,7 +417,7 @@ def error_for(*roles): mesh, moving, fixed, cbct_surface, on_enamel, max_dist=max_dist ) destination = os.path.join( - output_dir, patient, f"{os.path.splitext(name)[0]}_{suffix}.vtk" + folder, f"{os.path.splitext(name)[0]}_{suffix}.vtk" ) _write_mesh(mesh.transform(matrix, inplace=False), destination) # splitext, not `destination.replace(".vtk", ...)`: str.replace @@ -415,7 +446,11 @@ def error_for(*roles): statuses = [m["status"] for m in entry["meshes"].values()] entry["status"] = ("ok" if "ok" in statuses else "review" if "review" in statuses else "failed") - report["patients"][patient] = entry + if carry_cbct and entry["status"] != "failed": + entry.update(_carry_cbct(data["cbct"], folder, output_dir)) + # Under the name the files carry, never the matching key: see + # pipeline.patient_name. + report["patients"][data["name"]] = entry report["meshes_registered"] = total_meshes - len(failures) report["meshes_failed"] = len(failures) @@ -596,6 +631,10 @@ def main(ios, cbct, output_dir, automation=None, ios_landmarks=None, cbct_landma # Registration mode, which makes none of them. progress_span=spans["register"], produced_by=produced_by, + # The meshes are registered onto the CBCT ASO oriented, a volume + # the caller never had: without it beside them, they sit nowhere + # near the scan the caller opens them over. + carry_cbct=produced_by.get("cbct") == "ASO", ) finally: shutil.rmtree(work_dir, ignore_errors=True) diff --git a/tools/AREG/AREG_IOSCBCT/src/sadt_areg_ioscbct/pipeline.py b/tools/AREG/AREG_IOSCBCT/src/sadt_areg_ioscbct/pipeline.py index 558fc6f..2e3f399 100644 --- a/tools/AREG/AREG_IOSCBCT/src/sadt_areg_ioscbct/pipeline.py +++ b/tools/AREG/AREG_IOSCBCT/src/sadt_areg_ioscbct/pipeline.py @@ -13,6 +13,7 @@ import json import logging import os +import re import numpy as np @@ -53,10 +54,37 @@ def patient_key(filename: str) -> str: return digits.lstrip("0") or digits or stem +_WORD = re.compile(r"[A-Za-z0-9]+") + + +def patient_name(filename: str) -> str: + """The patient as the file's own name spells it: `case_042`, not `42`. + + `patient_key` is what MATCHES the two modalities, and it has to be crude to + do it; it is no name to show anyone. A folder called `42` beside a scan + called `case_042_T1_CBCT.nii.gz` broke the one thing a reader relies on to + find a patient again. This keeps the name up to the end of its first word + holding a digit -- the patient's number -- skipping a timepoint word, so + `case_042_T1_L.stl` gives `case_042` and `P_0001_T2.nii.gz` gives `P_0001`. + A name with no digit at all is kept whole. + """ + stem = pairing.split_scan_extension(os.path.basename(filename))[0] + for word in _WORD.finditer(stem): + if word.group().lower() in ("t0", "t1", "t2"): + continue + if any(c.isdigit() for c in word.group()): + return stem[: word.end()] + return stem + + def discover(ios_dir: str, cbct_dir: str, ios_source: str = "'ios'", cbct_source: str = "'cbct'", ios_error=ToolInputError, cbct_error=ToolInputError) -> tuple: - """`({patient: {"ios": [paths], "cbct": path}}, {patient: why})`. + """`({key: {"ios": [paths], "cbct": path, "name": name}}, {name: why})`. + + Keyed by `patient_key`, which is what the landmark files are matched on; + `name` is `patient_name` of the patient's first intraoral mesh -- the files + the output folder holds -- or of its CBCT when it has none. `ios_source` and `cbct_source` name where each folder came from in the refusal -- the caller's argument, or the tool that produced it -- and @@ -96,10 +124,12 @@ def discover(ios_dir: str, cbct_dir: str, ios_source: str = "'ios'", paired, unpaired = {}, {} for key in sorted(set(ios) | set(cbct)): + name = patient_name(min(ios[key]) if key in ios else cbct[key]) if key in ios and key in cbct: - paired[key] = {"ios": sorted(ios[key]), "cbct": cbct[key]} + paired[key] = {"ios": sorted(ios[key]), "cbct": cbct[key], "name": name} else: - unpaired[key] = "no CBCT" if key in ios else "no intraoral scan" + unpaired[name] = "no CBCT" if key in ios else "no intraoral scan" + _distinct_names(paired) if not paired: # Counts, never the keys: a key is built from the caller's file names, @@ -123,6 +153,21 @@ def discover(ios_dir: str, cbct_dir: str, ios_source: str = "'ios'", return paired, unpaired +def _distinct_names(paired: dict) -> None: + """Two patients never share a folder. + + The key reads every digit of a name and the name stops at the first word + holding one, so `case_042_scan2` and `case_042_scan3` are two keys and one + name. The second then takes its key too, rather than writing its arches + over the first patient's. + """ + seen = set() + for key, data in paired.items(): + if data["name"] in seen: + data["name"] = f"{data['name']}_{key}" + seen.add(data["name"]) + + def read_landmarks(path: str) -> dict: """`{label: [x, y, z]}` from a Slicer markups file or a plain JSON one. diff --git a/tools/AREG/AREG_IOSCBCT/tests/test_pairing.py b/tools/AREG/AREG_IOSCBCT/tests/test_pairing.py index 24dd484..315a7f0 100644 --- a/tools/AREG/AREG_IOSCBCT/tests/test_pairing.py +++ b/tools/AREG/AREG_IOSCBCT/tests/test_pairing.py @@ -83,6 +83,38 @@ def test_the_compound_extension_is_split_as_one_unit(): assert pipeline.patient_key("P7.nii.gz") == pipeline.patient_key("P7.vtk") == "7" +# --------------------------------------------------------------------------- +# patient_name +# --------------------------------------------------------------------------- + +def test_the_patient_name_keeps_the_files_own_spelling(): + """A folder called `42` beside `case_042_...` files was no name to find a + patient by: the name keeps its prefix and its zeros.""" + assert pipeline.patient_name("case_042_T1_L.stl") == "case_042" + assert pipeline.patient_name("case_042_T1_CBCT.nii.gz") == "case_042" + assert pipeline.patient_name("P_0001_T2.nii.gz") == "P_0001" + assert pipeline.patient_name("P001_T2_U_Seg.vtk") == "P001" + + +def test_a_leading_timepoint_is_not_taken_for_the_patient(): + assert pipeline.patient_name("T1_case_042_U.vtk") == "T1_case_042" + + +def test_a_name_with_no_digit_is_kept_whole(): + assert pipeline.patient_name("upper_scan.vtk") == "upper_scan" + + +def test_two_patients_never_share_a_folder(tmp_path): + """Two keys, one name: the second keeps its key so it cannot overwrite.""" + write_mesh(tmp_path / "ios" / "case_042_scan2_U.vtk") + write_mesh(tmp_path / "ios" / "case_042_scan3_U.vtk") + write_volume(tmp_path / "cbct" / "case_042_scan2.nii.gz") + write_volume(tmp_path / "cbct" / "case_042_scan3.nii.gz") + + paired, _unpaired = pipeline.discover(str(tmp_path / "ios"), str(tmp_path / "cbct")) + assert sorted(data["name"] for data in paired.values()) == ["case_042", "case_042_423"] + + # --------------------------------------------------------------------------- # discover # --------------------------------------------------------------------------- @@ -92,7 +124,7 @@ def test_a_patient_present_in_both_modalities_is_paired(tmp_path): volume = write_volume(tmp_path / "cbct" / "P_0001_T2.nii.gz") paired, unpaired = pipeline.discover(str(tmp_path / "ios"), str(tmp_path / "cbct")) - assert paired == {"1": {"ios": [mesh], "cbct": volume}} + assert paired == {"1": {"ios": [mesh], "cbct": volume, "name": "P001"}} assert unpaired == {} @@ -119,7 +151,7 @@ def test_a_patient_with_only_one_modality_is_reported_not_dropped(tmp_path): paired, unpaired = pipeline.discover(str(tmp_path / "ios"), str(tmp_path / "cbct")) assert set(paired) == {"1"} - assert unpaired == {"2": "no CBCT", "3": "no intraoral scan"} + assert unpaired == {"P002": "no CBCT", "P_0003": "no intraoral scan"} def test_no_pair_at_all_gives_both_counts_and_no_key(tmp_path): diff --git a/tools/AREG/AREG_IOSCBCT/tests/test_register.py b/tools/AREG/AREG_IOSCBCT/tests/test_register.py index 34e9874..da2a9ac 100644 --- a/tools/AREG/AREG_IOSCBCT/tests/test_register.py +++ b/tools/AREG/AREG_IOSCBCT/tests/test_register.py @@ -30,8 +30,8 @@ def test_a_run_writes_the_mesh_its_matrix_and_the_report(tmp_path): run_registration(tmp_path) assert tree_of(tmp_path / "out") == sorted([ "AREG_report.json", - os.path.join("1", "P001_T2_U_Reg.vtk"), - os.path.join("1", "P001_T2_U_Reg_matrix.npy"), + os.path.join("P001", "P001_T2_U_Reg.vtk"), + os.path.join("P001", "P001_T2_U_Reg_matrix.npy"), ]) @@ -43,8 +43,8 @@ def test_the_written_mesh_is_the_input_moved_by_the_written_matrix(tmp_path): run_registration(tmp_path) before = read_mesh_points(tmp_path / "ios" / "P001_T2_U.vtk") - after = read_mesh_points(tmp_path / "out" / "1" / "P001_T2_U_Reg.vtk") - matrix = np.load(str(tmp_path / "out" / "1" / "P001_T2_U_Reg_matrix.npy")) + after = read_mesh_points(tmp_path / "out" / "P001" / "P001_T2_U_Reg.vtk") + matrix = np.load(str(tmp_path / "out" / "P001" / "P001_T2_U_Reg_matrix.npy")) assert geometry.apply(before, matrix) == pytest.approx(after, abs=1e-4) @@ -55,7 +55,7 @@ def test_the_matrix_is_the_one_the_landmarks_asked_for(tmp_path): cohort(tmp_path) run_registration(tmp_path) - matrix = np.load(str(tmp_path / "out" / "1" / "P001_T2_U_Reg_matrix.npy")) + matrix = np.load(str(tmp_path / "out" / "P001" / "P001_T2_U_Reg_matrix.npy")) assert matrix[:3, :3] == pytest.approx(np.eye(3), abs=1e-6) assert matrix[:3, 3] == pytest.approx([3.0, -2.0, 1.0], abs=1e-6) @@ -65,10 +65,10 @@ def test_each_patient_gets_a_folder_of_its_own(tmp_path): run_registration(tmp_path) assert tree_of(tmp_path / "out") == sorted([ "AREG_report.json", - os.path.join("1", "P001_T2_U_Reg.vtk"), - os.path.join("1", "P001_T2_U_Reg_matrix.npy"), - os.path.join("2", "P002_T2_U_Reg.vtk"), - os.path.join("2", "P002_T2_U_Reg_matrix.npy"), + os.path.join("P001", "P001_T2_U_Reg.vtk"), + os.path.join("P001", "P001_T2_U_Reg_matrix.npy"), + os.path.join("P002", "P002_T2_U_Reg.vtk"), + os.path.join("P002", "P002_T2_U_Reg_matrix.npy"), ]) @@ -78,7 +78,7 @@ def test_both_arches_of_one_patient_are_registered_separately(tmp_path): run_registration(tmp_path) report = read_report(tmp_path / "out") - assert set(report["patients"]["1"]["meshes"]) == {"P001_T2_U.vtk", "P001_T2_L.vtk"} + assert set(report["patients"]["P001"]["meshes"]) == {"P001_T2_U.vtk", "P001_T2_L.vtk"} def test_the_suffix_reaches_every_name_it_writes(tmp_path): @@ -86,8 +86,8 @@ def test_the_suffix_reaches_every_name_it_writes(tmp_path): run_registration(tmp_path, output_suffix="Study7") assert tree_of(tmp_path / "out") == sorted([ "AREG_report.json", - os.path.join("1", "P001_T2_U_Study7.vtk"), - os.path.join("1", "P001_T2_U_Study7_matrix.npy"), + os.path.join("P001", "P001_T2_U_Study7.vtk"), + os.path.join("P001", "P001_T2_U_Study7_matrix.npy"), ]) @@ -96,7 +96,7 @@ def test_a_blank_suffix_falls_back_to_reg(tmp_path, given): cohort(tmp_path) run_registration(tmp_path, output_suffix=given) assert read_report(tmp_path / "out")["output_suffix"] == "Reg" - assert os.path.join("1", "P001_T2_U_Reg.vtk") in tree_of(tmp_path / "out") + assert os.path.join("P001", "P001_T2_U_Reg.vtk") in tree_of(tmp_path / "out") @pytest.mark.parametrize("given", ["../escape", "a/b", os.sep + "abs"]) @@ -117,7 +117,7 @@ def test_a_mesh_whose_own_stem_carries_vtk_still_names_its_matrix_correctly(tmp_ str(tmp_path / "ios" / "P001_T2_U.vtk"), str(tmp_path / "ios" / "P001_T2_U.vtk.vtk") ) run_registration(tmp_path) - assert os.path.join("1", "P001_T2_U.vtk_Reg_matrix.npy") in tree_of(tmp_path / "out") + assert os.path.join("P001", "P001_T2_U.vtk_Reg_matrix.npy") in tree_of(tmp_path / "out") def test_the_output_directory_is_created_when_it_does_not_exist(tmp_path): @@ -184,14 +184,14 @@ def test_the_report_says_what_ran_and_on_what(tmp_path): assert report["output_suffix"] == "Reg" assert isinstance(report["duration_seconds"], float) - patient = report["patients"]["1"] + patient = report["patients"]["P001"] assert patient["status"] == "ok" assert patient["cbct"] == "P_0001_T2.nii.gz" mesh = patient["meshes"]["P001_T2_U.vtk"] assert mesh["status"] == "ok" assert mesh["landmarks_used"] == sorted(UPPER_LABELS) assert mesh["landmarks_dropped"] == [] - assert mesh["output"] == os.path.join("1", "P001_T2_U_Reg.vtk") + assert mesh["output"] == os.path.join("P001", "P001_T2_U_Reg.vtk") def test_the_report_carries_no_server_side_path(tmp_path): @@ -211,7 +211,7 @@ def test_a_landmark_only_one_side_names_is_reported_as_dropped(tmp_path): write_markups(tmp_path / "cbct_lm" / "P_0001_T2_lm_Pred.mrk.json", extended) run_registration(tmp_path) - mesh = read_report(tmp_path / "out")["patients"]["1"]["meshes"]["P001_T2_U.vtk"] + mesh = read_report(tmp_path / "out")["patients"]["P001"]["meshes"]["P001_T2_U.vtk"] assert mesh["landmarks_dropped"] == ["EXTRA"] @@ -220,7 +220,7 @@ def test_a_patient_with_only_one_modality_is_named_in_the_report(tmp_path): write_mesh(tmp_path / "ios" / "P009_T2_U.vtk") run_registration(tmp_path) - assert read_report(tmp_path / "out")["unpaired"] == {"9": "no CBCT"} + assert read_report(tmp_path / "out")["unpaired"] == {"P009": "no CBCT"} def test_the_icp_refines_on_the_cbct_the_run_was_given(tmp_path): @@ -242,7 +242,7 @@ def test_the_icp_refines_on_the_cbct_the_run_was_given(tmp_path): box=((3.0, -2.0, 1.0), (23.0, 12.0, 11.0))) run_registration(tmp_path) - icp = read_report(tmp_path / "out")["patients"]["1"]["meshes"][ + icp = read_report(tmp_path / "out")["patients"]["P001"]["meshes"][ "P001_T2_U.vtk"]["icp"] assert icp is not None assert icp["pairs"] >= 3 and icp["fitness"] > 0 @@ -260,7 +260,7 @@ def test_a_cbct_no_reader_can_open_falls_back_to_the_landmarks_and_says_so(tmp_p cohort(tmp_path) run_registration(tmp_path) - patient = read_report(tmp_path / "out")["patients"]["1"] + patient = read_report(tmp_path / "out")["patients"]["P001"] assert patient["meshes"]["P001_T2_U.vtk"]["icp"] is None assert "could not be contoured" in patient["cbct_surface_error"] assert str(tmp_path) not in patient["cbct_surface_error"] @@ -283,11 +283,11 @@ def test_one_mesh_failing_does_not_cost_the_batch(tmp_path): run_registration(tmp_path) report = read_report(tmp_path / "out") - assert report["patients"]["1"]["status"] == "ok" - failed = report["patients"]["2"] + assert report["patients"]["P001"]["status"] == "ok" + failed = report["patients"]["P002"] assert failed["status"] == "failed" assert "share only 1 landmark(s)" in failed["meshes"]["P002_T2_U.vtk"]["error"] - assert os.path.join("1", "P001_T2_U_Reg.vtk") in tree_of(tmp_path / "out") + assert os.path.join("P001", "P001_T2_U_Reg.vtk") in tree_of(tmp_path / "out") def test_a_batch_where_nothing_registered_raises_with_the_cause_in_it(tmp_path): @@ -328,7 +328,7 @@ def test_a_mesh_with_no_landmarks_of_its_own_names_the_patient_and_the_side(tmp_ os.remove(str(tmp_path / "ios_lm" / "P002_T2_U_lm_Pred.mrk.json")) run_registration(tmp_path) - failed = read_report(tmp_path / "out")["patients"]["2"]["meshes"]["P002_T2_U.vtk"] + failed = read_report(tmp_path / "out")["patients"]["P002"]["meshes"]["P002_T2_U.vtk"] assert failed["status"] == "failed" assert "this patient" in failed["error"] assert "the intraoral" in failed["error"] @@ -355,8 +355,8 @@ def test_an_unreadable_landmark_file_is_skipped_with_its_position(tmp_path, capl with caplog.at_level(logging.INFO, logger="sadt_areg_ioscbct"): run_registration(tmp_path) report = read_report(tmp_path / "out") - assert report["patients"]["1"]["status"] == "ok" - assert report["patients"]["2"]["status"] == "failed" + assert report["patients"]["P001"]["status"] == "ok" + assert report["patients"]["P002"]["status"] == "failed" unreadable = [r.getMessage() for r in caplog.records if "landmark file 2 of 2" in r.getMessage()] assert len(unreadable) == 1 assert unreadable[0].startswith( @@ -391,7 +391,7 @@ def test_each_patient_is_registered_on_its_own_landmarks(tmp_path): ) run_registration(tmp_path) - second = np.load(str(tmp_path / "out" / "2" / "P002_T2_U_Reg_matrix.npy")) + second = np.load(str(tmp_path / "out" / "P002" / "P002_T2_U_Reg_matrix.npy")) assert second[:3, 3] == pytest.approx([100.0, 100.0, 100.0], abs=1e-5) @@ -410,8 +410,8 @@ def test_a_patients_two_arches_do_not_borrow_each_others_points(tmp_path): os.remove(str(tmp_path / "cbct_lm" / "P_0001_T2_lm_Pred.mrk.json")) run_registration(tmp_path) - upper = np.load(str(tmp_path / "out" / "1" / "P001_T2_U_Reg_matrix.npy")) - lower = np.load(str(tmp_path / "out" / "1" / "P001_T2_L_Reg_matrix.npy")) + upper = np.load(str(tmp_path / "out" / "P001" / "P001_T2_U_Reg_matrix.npy")) + lower = np.load(str(tmp_path / "out" / "P001" / "P001_T2_L_Reg_matrix.npy")) assert upper[:3, 3] == pytest.approx([3.0, -2.0, 1.0], abs=1e-5) assert lower[:3, 3] == pytest.approx([50.0, 0.0, 0.0], abs=1e-5) @@ -427,7 +427,7 @@ def test_one_cbct_landmark_file_serves_both_arches(tmp_path): ) run_registration(tmp_path) - report = read_report(tmp_path / "out")["patients"]["1"]["meshes"] + report = read_report(tmp_path / "out")["patients"]["P001"]["meshes"] assert report["P001_T2_U.vtk"]["landmarks_used"] == sorted(UPPER_LABELS) assert report["P001_T2_L.vtk"]["landmarks_used"] == sorted(LOWER_LABELS) diff --git a/tools/AREG/AREG_IOSCBCT/tests/test_run.py b/tools/AREG/AREG_IOSCBCT/tests/test_run.py index b28bfb5..8b565a8 100644 --- a/tools/AREG/AREG_IOSCBCT/tests/test_run.py +++ b/tools/AREG/AREG_IOSCBCT/tests/test_run.py @@ -586,7 +586,7 @@ def drifted(*args, **kwargs): run_registration(tmp_path) report = json.loads((tmp_path / "out" / "AREG_report.json").read_text()) - patient = report["patients"]["1"] + patient = report["patients"]["P001"] assert patient["status"] == "review" for mesh in patient["meshes"].values(): assert mesh["status"] == "review" @@ -603,6 +603,6 @@ def test_an_arch_close_to_its_landmarks_stays_ok(tmp_path): run_registration(tmp_path) report = json.loads((tmp_path / "out" / "AREG_report.json").read_text()) - assert report["patients"]["1"]["status"] == "ok" + assert report["patients"]["P001"]["status"] == "ok" assert report["meshes_to_review"] == 0 - assert all("review" not in mesh for mesh in report["patients"]["1"]["meshes"].values()) + assert all("review" not in mesh for mesh in report["patients"]["P001"]["meshes"].values()) diff --git a/tools/AREG/AREG_IOSCBCT/tests/test_tools.py b/tools/AREG/AREG_IOSCBCT/tests/test_tools.py index cf95d63..73893d0 100644 --- a/tools/AREG/AREG_IOSCBCT/tests/test_tools.py +++ b/tools/AREG/AREG_IOSCBCT/tests/test_tools.py @@ -17,7 +17,9 @@ import pytest -from conftest import FakeSup, cohort, read_report, write_markups, write_mesh, UPPER_LABELS, moved +from conftest import ( + FakeSup, cohort, read_report, write_markups, write_mesh, write_volume, UPPER_LABELS, moved, +) from sadt_areg_ioscbct import dispatch, tools from sadt_areg_common import catalogs from sadt_areg_common.errors import SupervisorRequired, ToolInputError @@ -269,7 +271,7 @@ def test_semi_automated_labels_the_crowns_and_predicts_both_landmark_sets(tmp_pa dispatch.main(**arguments) assert [name for name, _params in sup.calls] == ["Crown_Seg", "ALI_IOS", "ALI_CBCT"] - assert read_report(tmp_path / "out")["patients"]["1"]["status"] == "ok" + assert read_report(tmp_path / "out")["patients"]["P001"]["status"] == "ok" def test_semi_automated_does_not_orient_anything(tmp_path): @@ -327,6 +329,38 @@ def test_fully_automated_asks_aso_for_its_frame_and_no_bundle(tmp_path): assert "reference" not in params +def test_fully_automated_hands_back_the_cbct_the_meshes_were_registered_onto(tmp_path): + """The meshes are in the frame of ASO's oriented scan. Without it beside + them they were opened over the original CBCT, off by ASO's whole rotation, + and the only copy had gone with the job's work directory.""" + oriented = tmp_path / "oriented" + write_volume(oriented / "P_0001_T2_Or.nii.gz") + (oriented / "P_0001_T2_Or_transform.tfm").write_text("#Insight Transform File V1.0\n") + _sup, arguments = semi_automated( + tmp_path, + automation=catalogs.AUTOMATION_FULLY, + outputs={"ASO": lambda params: oriented}, + ) + dispatch.main(**arguments) + + folder = tmp_path / "out" / "P001" + assert (folder / "P_0001_T2_Or.nii.gz").is_file() + assert (folder / "P_0001_T2_Or_transform.tfm").is_file() + patient = read_report(tmp_path / "out")["patients"]["P001"] + assert patient["oriented_cbct"] == os.path.join("P001", "P_0001_T2_Or.nii.gz") + assert patient["cbct_transform"] == os.path.join("P001", "P_0001_T2_Or_transform.tfm") + + +def test_a_cbct_the_caller_sent_is_not_sent_back(tmp_path): + """Semi-Automated registers onto the caller's own scan: copying it back + would only double the archive.""" + _sup, arguments = semi_automated(tmp_path) + dispatch.main(**arguments) + + assert not (tmp_path / "out" / "P001" / "P_0001_T2.nii.gz").exists() + assert "oriented_cbct" not in read_report(tmp_path / "out")["patients"]["P001"] + + def test_a_sibling_tool_failing_takes_the_run_with_it(tmp_path): """Nothing downstream can be salvaged: there are no landmarks to register on, and inventing some would be a confident wrong answer.""" @@ -374,7 +408,7 @@ def test_the_predicted_landmarks_really_reach_the_registration(tmp_path): import numpy as np - matrix = np.load(str(tmp_path / "out" / "1" / "P001_T2_U_Reg_matrix.npy")) + matrix = np.load(str(tmp_path / "out" / "P001" / "P001_T2_U_Reg_matrix.npy")) assert matrix[0, 3] == pytest.approx(7.0, abs=1e-5)