Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion tools/AREG/AREG_IOSCBCT/src/sadt_areg_ioscbct/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
47 changes: 43 additions & 4 deletions tools/AREG/AREG_IOSCBCT/src/sadt_areg_ioscbct/dispatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 `<scan>_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.
Expand All @@ -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 {}

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
51 changes: 48 additions & 3 deletions tools/AREG/AREG_IOSCBCT/src/sadt_areg_ioscbct/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import json
import logging
import os
import re

import numpy as np

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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.

Expand Down
36 changes: 34 additions & 2 deletions tools/AREG/AREG_IOSCBCT/tests/test_pairing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ---------------------------------------------------------------------------
Expand All @@ -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 == {}


Expand All @@ -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):
Expand Down
Loading
Loading