From f757276a0041358114e7e0578f2925894e2d911f Mon Sep 17 00:00:00 2001 From: Jules GRIVOT PELISSON Date: Tue, 18 Aug 2026 13:35:39 -0400 Subject: [PATCH 01/19] ADD: rule that a sup.run() call must live in the tool's own src/ --- CONTRIBUTING.md | 28 +++++++++++++++++++ .../src/sadt_areg_cbct}/__init__.py | 0 .../src/sadt_areg_cbct}/dicom.py | 0 .../src/sadt_areg_cbct}/elastix.py | 0 .../src/sadt_areg_cbct}/pipeline.py | 0 .../src/sadt_areg_ios}/__init__.py | 0 .../src/sadt_areg_ios}/butterfly.py | 0 .../ios => AREG_IOS/src/sadt_areg_ios}/icp.py | 0 .../src/sadt_areg_ios}/landmarks.py | 0 .../ios => AREG_IOS/src/sadt_areg_ios}/mgl.py | 0 .../ios => AREG_IOS/src/sadt_areg_ios}/net.py | 0 .../src/sadt_areg_ios}/orientation.py | 0 .../src/sadt_areg_ios}/pipeline.py | 0 .../src/sadt_areg_ios}/postprocess.py | 0 .../src/sadt_areg_ios}/surfaces.py | 0 .../src/sadt_areg_ios}/tools.py | 0 .../src/sadt_areg_common}/catalogs.py | 0 .../src/sadt_areg_common}/errors.py | 0 .../src/sadt_areg_common}/pairing.py | 0 .../src/sadt_areg_common}/scans.py | 0 20 files changed, 28 insertions(+) rename tools/AREG/{src/sadt_areg/cbct => AREG_CBCT/src/sadt_areg_cbct}/__init__.py (100%) rename tools/AREG/{src/sadt_areg => AREG_CBCT/src/sadt_areg_cbct}/dicom.py (100%) rename tools/AREG/{src/sadt_areg/cbct => AREG_CBCT/src/sadt_areg_cbct}/elastix.py (100%) rename tools/AREG/{src/sadt_areg/cbct => AREG_CBCT/src/sadt_areg_cbct}/pipeline.py (100%) rename tools/AREG/{src/sadt_areg/ios => AREG_IOS/src/sadt_areg_ios}/__init__.py (100%) rename tools/AREG/{src/sadt_areg/ios => AREG_IOS/src/sadt_areg_ios}/butterfly.py (100%) rename tools/AREG/{src/sadt_areg/ios => AREG_IOS/src/sadt_areg_ios}/icp.py (100%) rename tools/AREG/{src/sadt_areg => AREG_IOS/src/sadt_areg_ios}/landmarks.py (100%) rename tools/AREG/{src/sadt_areg/ios => AREG_IOS/src/sadt_areg_ios}/mgl.py (100%) rename tools/AREG/{src/sadt_areg/ios => AREG_IOS/src/sadt_areg_ios}/net.py (100%) rename tools/AREG/{src/sadt_areg/ios => AREG_IOS/src/sadt_areg_ios}/orientation.py (100%) rename tools/AREG/{src/sadt_areg/ios => AREG_IOS/src/sadt_areg_ios}/pipeline.py (100%) rename tools/AREG/{src/sadt_areg/ios => AREG_IOS/src/sadt_areg_ios}/postprocess.py (100%) rename tools/AREG/{src/sadt_areg/ios => AREG_IOS/src/sadt_areg_ios}/surfaces.py (100%) rename tools/AREG/{src/sadt_areg => AREG_IOS/src/sadt_areg_ios}/tools.py (100%) rename tools/AREG/{src/sadt_areg => common/src/sadt_areg_common}/catalogs.py (100%) rename tools/AREG/{src/sadt_areg => common/src/sadt_areg_common}/errors.py (100%) rename tools/AREG/{src/sadt_areg => common/src/sadt_areg_common}/pairing.py (100%) rename tools/AREG/{src/sadt_areg => common/src/sadt_areg_common}/scans.py (100%) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a62e994..f30f134 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -364,6 +364,34 @@ tool environments whose pins are deliberately incompatible, and anything it pulled in would have to be satisfiable by all of them at once — which is the constraint this repository exists to remove. +### A `sup.run()` call must live in the tool's own `src/` + +Never in a shared package, however much orchestration two tools appear to have +in common. `describe.py` derives the schema's `calls` field by **reading each +tool's own source** — the call sites sit in branches only a real run reaches, so +there is nothing to introspect at import time — and the server refuses to start +when a declared call names a tool it does not serve. Orchestration moved into a +shared package is invisible to both: the names never reach `calls`, and the +startup check silently has nothing to verify. + +That is worse than no check. A tool would then declare `supervisor = true` with +an empty `calls`, and a renamed sibling would break it at run time again — +which is exactly what the check was added to prevent. + +AREG is the case that settles it. Its two engines share four of the six helpers +in `tools.py` (`require`, `_output`, `_returned`, `orient_scans`), and only +`segment_masks` is CBCT-specific against `label_crowns` and +`predict_mucogingival` on the IOS side. Sharing the four would have been the +obvious move; it would have hidden `sup.run("ASO", ...)` from the generator for +both tools. Each engine carries its own copy, holding only what it calls, and +the two `calls` lists come out right: + + AREG_CBCT calls = ["AMASSS", "ASO"] + AREG_IOS calls = ["ALI_IOS", "ASO", "Crown_Seg"] + +This is the standing rule applied, not an exception to it: orchestration is +**implementation**, and it has to stay where the tooling can see it. + ### `[tool.uv.sources]` only applies to DECLARED dependencies A source says *where* a package comes from. It does not make the package a diff --git a/tools/AREG/src/sadt_areg/cbct/__init__.py b/tools/AREG/AREG_CBCT/src/sadt_areg_cbct/__init__.py similarity index 100% rename from tools/AREG/src/sadt_areg/cbct/__init__.py rename to tools/AREG/AREG_CBCT/src/sadt_areg_cbct/__init__.py diff --git a/tools/AREG/src/sadt_areg/dicom.py b/tools/AREG/AREG_CBCT/src/sadt_areg_cbct/dicom.py similarity index 100% rename from tools/AREG/src/sadt_areg/dicom.py rename to tools/AREG/AREG_CBCT/src/sadt_areg_cbct/dicom.py diff --git a/tools/AREG/src/sadt_areg/cbct/elastix.py b/tools/AREG/AREG_CBCT/src/sadt_areg_cbct/elastix.py similarity index 100% rename from tools/AREG/src/sadt_areg/cbct/elastix.py rename to tools/AREG/AREG_CBCT/src/sadt_areg_cbct/elastix.py diff --git a/tools/AREG/src/sadt_areg/cbct/pipeline.py b/tools/AREG/AREG_CBCT/src/sadt_areg_cbct/pipeline.py similarity index 100% rename from tools/AREG/src/sadt_areg/cbct/pipeline.py rename to tools/AREG/AREG_CBCT/src/sadt_areg_cbct/pipeline.py diff --git a/tools/AREG/src/sadt_areg/ios/__init__.py b/tools/AREG/AREG_IOS/src/sadt_areg_ios/__init__.py similarity index 100% rename from tools/AREG/src/sadt_areg/ios/__init__.py rename to tools/AREG/AREG_IOS/src/sadt_areg_ios/__init__.py diff --git a/tools/AREG/src/sadt_areg/ios/butterfly.py b/tools/AREG/AREG_IOS/src/sadt_areg_ios/butterfly.py similarity index 100% rename from tools/AREG/src/sadt_areg/ios/butterfly.py rename to tools/AREG/AREG_IOS/src/sadt_areg_ios/butterfly.py diff --git a/tools/AREG/src/sadt_areg/ios/icp.py b/tools/AREG/AREG_IOS/src/sadt_areg_ios/icp.py similarity index 100% rename from tools/AREG/src/sadt_areg/ios/icp.py rename to tools/AREG/AREG_IOS/src/sadt_areg_ios/icp.py diff --git a/tools/AREG/src/sadt_areg/landmarks.py b/tools/AREG/AREG_IOS/src/sadt_areg_ios/landmarks.py similarity index 100% rename from tools/AREG/src/sadt_areg/landmarks.py rename to tools/AREG/AREG_IOS/src/sadt_areg_ios/landmarks.py diff --git a/tools/AREG/src/sadt_areg/ios/mgl.py b/tools/AREG/AREG_IOS/src/sadt_areg_ios/mgl.py similarity index 100% rename from tools/AREG/src/sadt_areg/ios/mgl.py rename to tools/AREG/AREG_IOS/src/sadt_areg_ios/mgl.py diff --git a/tools/AREG/src/sadt_areg/ios/net.py b/tools/AREG/AREG_IOS/src/sadt_areg_ios/net.py similarity index 100% rename from tools/AREG/src/sadt_areg/ios/net.py rename to tools/AREG/AREG_IOS/src/sadt_areg_ios/net.py diff --git a/tools/AREG/src/sadt_areg/ios/orientation.py b/tools/AREG/AREG_IOS/src/sadt_areg_ios/orientation.py similarity index 100% rename from tools/AREG/src/sadt_areg/ios/orientation.py rename to tools/AREG/AREG_IOS/src/sadt_areg_ios/orientation.py diff --git a/tools/AREG/src/sadt_areg/ios/pipeline.py b/tools/AREG/AREG_IOS/src/sadt_areg_ios/pipeline.py similarity index 100% rename from tools/AREG/src/sadt_areg/ios/pipeline.py rename to tools/AREG/AREG_IOS/src/sadt_areg_ios/pipeline.py diff --git a/tools/AREG/src/sadt_areg/ios/postprocess.py b/tools/AREG/AREG_IOS/src/sadt_areg_ios/postprocess.py similarity index 100% rename from tools/AREG/src/sadt_areg/ios/postprocess.py rename to tools/AREG/AREG_IOS/src/sadt_areg_ios/postprocess.py diff --git a/tools/AREG/src/sadt_areg/ios/surfaces.py b/tools/AREG/AREG_IOS/src/sadt_areg_ios/surfaces.py similarity index 100% rename from tools/AREG/src/sadt_areg/ios/surfaces.py rename to tools/AREG/AREG_IOS/src/sadt_areg_ios/surfaces.py diff --git a/tools/AREG/src/sadt_areg/tools.py b/tools/AREG/AREG_IOS/src/sadt_areg_ios/tools.py similarity index 100% rename from tools/AREG/src/sadt_areg/tools.py rename to tools/AREG/AREG_IOS/src/sadt_areg_ios/tools.py diff --git a/tools/AREG/src/sadt_areg/catalogs.py b/tools/AREG/common/src/sadt_areg_common/catalogs.py similarity index 100% rename from tools/AREG/src/sadt_areg/catalogs.py rename to tools/AREG/common/src/sadt_areg_common/catalogs.py diff --git a/tools/AREG/src/sadt_areg/errors.py b/tools/AREG/common/src/sadt_areg_common/errors.py similarity index 100% rename from tools/AREG/src/sadt_areg/errors.py rename to tools/AREG/common/src/sadt_areg_common/errors.py diff --git a/tools/AREG/src/sadt_areg/pairing.py b/tools/AREG/common/src/sadt_areg_common/pairing.py similarity index 100% rename from tools/AREG/src/sadt_areg/pairing.py rename to tools/AREG/common/src/sadt_areg_common/pairing.py diff --git a/tools/AREG/src/sadt_areg/scans.py b/tools/AREG/common/src/sadt_areg_common/scans.py similarity index 100% rename from tools/AREG/src/sadt_areg/scans.py rename to tools/AREG/common/src/sadt_areg_common/scans.py From b430922dfe6ea64664bd8b928dbed56b0dbfb4a6 Mon Sep 17 00:00:00 2001 From: Jules GRIVOT PELISSON Date: Tue, 18 Aug 2026 14:00:14 -0400 Subject: [PATCH 02/19] ADD: split AREG into AREG_CBCT and AREG_IOS with a shared dependency-free common package --- tools/AREG/AREG_CBCT/pyproject.toml | 48 ++ .../AREG_CBCT/src/sadt_areg_cbct/__init__.py | 89 +++ .../AREG_CBCT/src/sadt_areg_cbct/dispatch.py | 401 +++++++++++ .../AREG_CBCT/src/sadt_areg_cbct/elastix.py | 2 +- .../AREG_CBCT/src/sadt_areg_cbct/layout.py | 58 ++ .../AREG_CBCT/src/sadt_areg_cbct/pipeline.py | 2 +- .../AREG_CBCT/src/sadt_areg_cbct/tools.py | 135 ++++ tools/AREG/AREG_CBCT/uv.lock | 634 ++++++++++++++++++ tools/AREG/AREG_IOS/pyproject.toml | 61 ++ .../AREG_IOS/src/sadt_areg_ios/__init__.py | 89 +++ .../AREG_IOS/src/sadt_areg_ios/butterfly.py | 2 +- .../src/sadt_areg_ios}/dispatch.py | 509 +++++--------- .../AREG_IOS/src/sadt_areg_ios/landmarks.py | 2 +- .../AREG/AREG_IOS/src/sadt_areg_ios/layout.py | 51 ++ tools/AREG/AREG_IOS/src/sadt_areg_ios/net.py | 2 +- .../AREG_IOS/src/sadt_areg_ios/pipeline.py | 3 +- .../AREG_IOS/src/sadt_areg_ios/surfaces.py | 2 +- .../AREG/AREG_IOS/src/sadt_areg_ios/tools.py | 39 +- tools/AREG/{ => AREG_IOS}/uv.lock | 410 ++++------- tools/AREG/common/README.md | 42 ++ tools/AREG/common/pyproject.toml | 26 + .../common/src/sadt_areg_common/__init__.py | 0 tools/AREG/pyproject.toml | 82 --- tools/AREG/src/sadt_areg/__init__.py | 146 ---- tools/AREG/src/sadt_areg/layout.py | 109 --- 25 files changed, 1942 insertions(+), 1002 deletions(-) create mode 100644 tools/AREG/AREG_CBCT/pyproject.toml create mode 100644 tools/AREG/AREG_CBCT/src/sadt_areg_cbct/dispatch.py create mode 100644 tools/AREG/AREG_CBCT/src/sadt_areg_cbct/layout.py create mode 100644 tools/AREG/AREG_CBCT/src/sadt_areg_cbct/tools.py create mode 100644 tools/AREG/AREG_CBCT/uv.lock create mode 100644 tools/AREG/AREG_IOS/pyproject.toml rename tools/AREG/{src/sadt_areg => AREG_IOS/src/sadt_areg_ios}/dispatch.py (60%) create mode 100644 tools/AREG/AREG_IOS/src/sadt_areg_ios/layout.py rename tools/AREG/{ => AREG_IOS}/uv.lock (67%) create mode 100644 tools/AREG/common/README.md create mode 100644 tools/AREG/common/pyproject.toml create mode 100644 tools/AREG/common/src/sadt_areg_common/__init__.py delete mode 100644 tools/AREG/pyproject.toml delete mode 100644 tools/AREG/src/sadt_areg/__init__.py delete mode 100644 tools/AREG/src/sadt_areg/layout.py diff --git a/tools/AREG/AREG_CBCT/pyproject.toml b/tools/AREG/AREG_CBCT/pyproject.toml new file mode 100644 index 0000000..efe8a71 --- /dev/null +++ b/tools/AREG/AREG_CBCT/pyproject.toml @@ -0,0 +1,48 @@ +[project] +name = "sadt-areg-cbct" +version = "0.1.0" +description = "Register a follow-up CBCT onto its baseline with elastix." +requires-python = ">=3.11,<3.12" +# No pytorch3d here, and so no reason to move torch. AREG_IOS is pinned to 2.11 +# because its pytorch3d wheel names that version exactly; this engine was only +# ever dragged along by sharing a virtualenv with it, which the split undid. +dependencies = [ + "sadt-areg-common", + "torch==2.8.0", + "itk==5.4.7", + "itk-elastix==0.23.0", + "SimpleITK==2.5.6", + "numpy==2.3.2", + "dicom2nifti==2.6.2", +] + +[tool.sadt] +tool = true +name = "AREG_CBCT" + +[dependency-groups] +dev = ["pytest==8.3.4", "sadt-testkit"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/sadt_areg_cbct"] + +[[tool.uv.index]] +name = "pytorch-cu128" +url = "https://download.pytorch.org/whl/cu128" +explicit = true + +[tool.uv.sources] +sadt-areg-common = { path = "../common" } +sadt-testkit = { path = "../../../testkit", editable = true } +torch = { index = "pytorch-cu128" } + +[tool.pytest.ini_options] +testpaths = ["tests"] +markers = [ + "gpu: needs a CUDA device. Skipped in CI (`-m 'not gpu'`); run it by hand and report the result in the PR.", + "models: needs a real model bundle and is skipped without it. See tests/data/README.md.", +] diff --git a/tools/AREG/AREG_CBCT/src/sadt_areg_cbct/__init__.py b/tools/AREG/AREG_CBCT/src/sadt_areg_cbct/__init__.py index e69de29..df74a7d 100644 --- a/tools/AREG/AREG_CBCT/src/sadt_areg_cbct/__init__.py +++ b/tools/AREG/AREG_CBCT/src/sadt_areg_cbct/__init__.py @@ -0,0 +1,89 @@ +"""AREG_CBCT -- register a follow-up CBCT onto its baseline. + +elastix, rigid, restricted to the anatomy that has not changed between the two +timepoints: the cranial base, the mandible or the maxilla, taken as masks. + +Split out of the former single `AREG`, which served both modalities from one +schema and one virtualenv. The reason is the same as ALI's: the intraoral +engine needs pytorch3d and therefore torch 2.11, this one needs neither, and +while they shared an environment neither could be pinned without the other. +They now share only `sadt_areg_common` -- the patient-key convention, the +catalogs, and the scan-extension table -- which has no dependencies at all. + +The masks this registers on, and the orientation both timepoints must share, +come from other tools reached through the supervisor; see the CBCT half of +`tools.py`. `AREG_CBCT -> ASO -> ALI_CBCT` is the deepest chain in the family. +""" + +from pathlib import Path +from typing import Literal + +from .dispatch import main + + +def run( + t1: Path, + t2: Path, + output_dir: Path, + automation: Literal[ + "Semi-Automated", "Fully-Automated", "Oriented + Fully-Automated" + ] = "Fully-Automated", + # Spelled out because `Literal` takes literals only -- it cannot be built + # from catalogs.REGION_CHOICES. That makes this a second declaration of the + # same set, which is the thing this contract otherwise avoids, so a test + # asserts the two agree. + regions: list[ + Literal["Cranial base", "Mandible", "Maxilla"] + ] = ["Cranial base"], + t1_masks: Path = "", + segmentation_model: Path = "", + segmentation_label: int = 0, + reference: Path = "", + landmark_model: Path = "", + dicom_input: bool = False, + output_suffix: str = "Reg", + *, + sup=None, +) -> Path: + """Register a follow-up CBCT onto its baseline, so the two can be compared. + + Args: + t1: The baseline scans -- one volume or a folder of them, searched + recursively. A DICOM series is converted when `dicom_input` is set. + t2: The follow-up scans, paired to T1 by patient key. + output_dir: Where the registered scans, their transforms and + `AREG_report.json` are written. Nothing is written outside it. + automation: Semi-Automated takes your own masks; Fully-Automated + segments them; Oriented + Fully-Automated orients both timepoints + first, which needs a reference. + regions: The anatomy to register on -- what has NOT changed between the + timepoints. The one argument a clinician must actually think about. + t1_masks: Semi-Automated only. Your own T1 segmentation masks. + segmentation_model: The mask model bundle, for the modes that segment. + segmentation_label: Which label value in the masks to register on. + reference: Oriented + Fully-Automated only. The orientation reference. + landmark_model: Oriented + Fully-Automated only. The landmark bundle the + orientation step predicts with. + dicom_input: The inputs are DICOM series rather than volumes. + output_suffix: Added to each output name, e.g. `scan_Reg.nii.gz`. + + Returns: + The output directory. + """ + # itk-elastix and SimpleITK are imported inside the engine: CI imports this + # module on every PR to publish the schema, and that must not cost them. + return main( + t1=t1, + t2=t2, + output_dir=output_dir, + automation=automation, + cbct_regions=regions, + t1_masks=t1_masks, + segmentation_model=segmentation_model, + segmentation_label=segmentation_label, + cbct_reference=reference, + landmark_model=landmark_model, + dicom_input=dicom_input, + output_suffix=output_suffix, + sup=sup, + ) diff --git a/tools/AREG/AREG_CBCT/src/sadt_areg_cbct/dispatch.py b/tools/AREG/AREG_CBCT/src/sadt_areg_cbct/dispatch.py new file mode 100644 index 0000000..b5ec2b3 --- /dev/null +++ b/tools/AREG/AREG_CBCT/src/sadt_areg_cbct/dispatch.py @@ -0,0 +1,401 @@ +"""AREG -- Automated REGistration of two timepoints. + +Ported from the Slicer extension's `AREG/` module and its CLI modules +(`AREG_CBCT`, `AREG_IOS`). One tool, two engines, five modes: + +| | Semi-Automated | Fully-Automated | Oriented + Fully-Automated | +|----------|-------------------------------|------------------------------|----------------------------| +| **CBCT** | your T1 masks, masked Elastix | AMASSS segments the T1 masks | ASO orients the T1 first | +| **IOS** | your segmented meshes | CrownSeg labels + ASO orients| -- | + +The Slicer envelope is gone entirely: no `` prints, no +`time.sleep(0.2)` progress theatre, no `sys.exit`, no log file the client +polls, and nothing written into the caller's input tree. + +Two entry points, for the same reason AMASSS and ASO have two: + +* `register(...)` -> `RegistrationRun`, the real API: the output directory plus + a structured report. This is what another server-side tool calls. +* `main(...)` -> the output directory's path, the schema adapter `AREG.py` uses. + +The Slicer widget built a list of CLI invocations per mode and ran them in +order, passing folders between them. That structure survives, but the steps are +the other packaged tools -- see `tools.py` for how they are called +in-process and through the registry. +""" + +import json +import logging +import os +import shutil + +from sadt_areg_common.errors import ToolInputError + +from sadt_areg_common import catalogs, pairing +from . import dicom, tools + +logger = logging.getLogger(__name__) + +# This tool IS the modality: it is no longer an argument, so the value the +# report carries and the automation table is keyed by is fixed here. +MODALITY = catalogs.MODALITY_CBCT + +REPORT_NAME = "AREG_report.json" + +# Intermediates live here, under the output directory the caller owns, and are +# removed before `register` returns. A surviving `.areg_work/` means a run +# crashed. +WORK_DIRNAME = ".areg_work" + + +class RegistrationRun: + """Result of `register()`: where the files are, and what actually happened. + + Reported per patient AND per region, because a CBCT run registering on the + cranial base and the mandible is two registrations of every patient and one + of them can fail on its own. The original caught each per-patient exception + into a log line and finished by printing a count; the archive said nothing. + """ + + def __init__(self, output_dir: str, report: dict): + self.output_dir = output_dir + self.report = report + + @property + def patients(self) -> dict: + return self.report["patients"] + + @property + def succeeded(self) -> list: + return [key for key, entry in self.patients.items() if entry.get("status") == "ok"] + + + +def _check_cbct(automation: str, regions: list, t1_masks, reference, sup=None) -> None: + if not regions: + raise ToolInputError( + "Select at least one anatomical region to register on in 'cbct_regions' " + f"({', '.join(catalogs.REGION_CHOICES)}). Each one is a separate " + "registration with its own output folder." + ) + + if automation == catalogs.AUTOMATION_SEMI: + if not t1_masks: + raise ToolInputError( + "Semi-Automated CBCT registers inside masks you provide: send the T1 " + "segmentations in 't1_masks', or use Fully-Automated mode to have " + "them produced server-side." + ) + return + + # Both automated modes need the segmentation; the oriented one also needs + # the orientation. Checked before the input is extracted -- with the tool + # absent, the answer is the same whatever the rest of the request says. + tools.require(sup, "AMASSS", f"{automation} CBCT registration") + if automation == catalogs.AUTOMATION_ORIENTED: + tools.require(sup, "ASO", "Oriented + Fully-Automated CBCT registration") + if not reference: + raise ToolInputError( + "Oriented + Fully-Automated CBCT orients the T1 scans before " + "registering onto them, which needs an orientation reference: name " + "one in 'cbct_reference' (see GET /tools/AREG/data)." + ) + + +def _run_cbct( + t1_root, t2_root, t1_masks_path, automation, regions, segmentation_model, + segmentation_label, orientation_reference, dicom_input, output_dir, work_dir, + suffix, report, sup=None, landmark_model=None, +) -> None: + # Imported here rather than at module level: the CBCT engine pulls in + # SimpleITK and itk-elastix, and AREG must load on a server without them so + # its schema is still published and its IOS mode still runs. + from . import elastix + from . import pipeline as cbct_pipeline + + elastix.check_dependencies() + + if dicom_input: + t1_root = dicom.convert_tree(t1_root, os.path.join(work_dir, "dicom_t1")) + t2_root = dicom.convert_tree(t2_root, os.path.join(work_dir, "dicom_t2")) + + codes = [catalogs.region_code(name) for name in regions] + report["regions"] = list(regions) + report["segmentation_label"] = segmentation_label or None + + # Step 1 -- orient the T1 scans, when the mode asks for it. The T2 is NOT + # oriented: it is about to be resampled into the T1's frame anyway, and + # orienting it first would be one more interpolation of the same data. + if automation == catalogs.AUTOMATION_ORIENTED: + oriented = tools.orient_scans( + sup, + t1_root, orientation_reference, catalogs.MODALITY_CBCT, + landmark_model=landmark_model or "", + ) + report["oriented_t1"] = True + t1_root = oriented + + # Step 2 -- the masks the registration is confined to. + mask_roots = [] + if t1_masks_path: + mask_roots.append(_as_directory(t1_masks_path, os.path.join(work_dir, "masks_input"))) + if automation == catalogs.AUTOMATION_SEMI: + # Where the original looked when no mask folder was given. + mask_roots.append(t1_root) + else: + structures = [catalogs.REGION_MASK_STRUCTURES[code] for code in codes] + mask_roots.append( + tools.segment_masks(sup, t1_root, segmentation_model, structures) + ) + report["segmented_t1"] = sorted(structures) + + # Step 3 -- pair the timepoints, then register once per region. + matched = pairing.pair(t1_root, t2_root, suffix) + report["unmatched"] = matched.unmatched_report() + if not matched: + raise ToolInputError( + "No subject appears in both the T1 and the T2 folder. They are paired by " + "name, up to the timepoint token and a trailing " + f"{', '.join(catalogs.PATIENT_SUFFIXES[:4])}... -- so 'P1_T1_scan.nii.gz' " + f"in one folder pairs with 'P1_T2.nii.gz' in the other. Found " + f"{len(matched.t1_only)} T1-only and {len(matched.t2_only)} T2-only subject(s)." + ) + + for code in codes: + masks = cbct_pipeline.find_masks(mask_roots, code, scan_keys=matched.matched) + for key, entry in sorted(matched.matched.items()): + record = report["patients"].setdefault(key, {"status": "ok", "regions": {}}) + mask_path = masks.get(key) + if not mask_path: + record["regions"][code] = { + "status": "failed", + "reason": _no_mask_reason(automation, code), + } + continue + try: + record["regions"][code] = cbct_pipeline.register_patient( + t1_path=entry["t1"], + t2_path=entry["t2"], + mask_path=mask_path, + region=code, + output_dir=output_dir, + relative_key=key, + suffix=suffix, + segmentation_label=segmentation_label or None, + ) + except elastix.RegistrationError as exc: + record["regions"][code] = {"status": "failed", "reason": str(exc)} + except RuntimeError as exc: + record["regions"][code] = {"status": "failed", "reason": f"registration failed: {exc}"} + + _roll_up_regions(report["patients"]) + + +def _no_mask_reason(automation: str, code: str) -> str: + region = catalogs.region_name(code) + if automation == catalogs.AUTOMATION_SEMI: + return ( + f"no {region} mask for this subject. A mask is matched to its scan by name " + f"and has to say both that it is a segmentation (mask/seg/pred) and which " + f"structure it covers ({'/'.join(catalogs.REGION_TOKENS[code][:2])}) -- " + f"e.g. 'P1_T1_{code}_seg.nii.gz' next to 'P1_T1_scan.nii.gz'" + ) + return ( + f"the segmentation step produced no {region} mask for this subject -- see the " + f"AMASSS report if one was included in this archive" + ) + + +def _roll_up_regions(patients: dict) -> None: + """A patient is 'ok' when at least one of its regions registered.""" + for entry in patients.values(): + statuses = [region.get("status") for region in entry["regions"].values()] + entry["status"] = "ok" if "ok" in statuses else "failed" + + +def _selected(value, choices: dict) -> list: + """The enabled options of a multichoice argument, in declaration order. + + Accepts the `Selection` validate() produces, a plain dict, or a sequence -- + so `register()` stays directly callable with `["Mandible"]`. + """ + if value is None: + return [name for name, on in choices.items() if on] + if isinstance(value, dict): + return [name for name in choices if value.get(name)] + wanted = set(value) + return [name for name in choices if name in wanted] + + +def _merge_into(source: str, destination: str) -> None: + """Copy every file of `source` under `destination`, keeping its tree. + + The two timepoints' landmarks end up in ONE folder on purpose: they are + matched to their scan by a key that carries the timepoint, so they cannot + collide, and one folder is one index for the painter to search. + """ + for directory, _, file_names in os.walk(source): + relative = os.path.relpath(directory, source) + target = os.path.join(destination, "" if relative == "." else relative) + os.makedirs(target, exist_ok=True) + for file_name in file_names: + shutil.copy2(os.path.join(directory, file_name), os.path.join(target, file_name)) + + +def _as_directory(path: str, destination: str) -> str: + """A directory holding the input, whatever shape it arrived in. + + A single uploaded file is linked into a directory of its own rather than + used from where it landed: main.py streams every upload of a request into + ONE work directory, so treating a file's parent as an input root would make + the T2 folder part of the T1 one. + """ + path = str(path) + if os.path.isdir(path): + return path + + os.makedirs(destination, exist_ok=True) + linked = os.path.join(destination, os.path.basename(path)) + try: + os.link(path, linked) + except OSError: + shutil.copy2(path, linked) + return destination + + +def _summarize(report: dict) -> None: + statuses = [entry.get("status") for entry in report["patients"].values()] + report["summary"] = { + "patients": len(statuses), + "registered": statuses.count("ok"), + "failed": statuses.count("failed"), + } + logger.info( + "AREG %s %s: %d/%d registered", + report["modality"], + report["automation"], + report["summary"]["registered"], + report["summary"]["patients"], + ) + + +def register( + t1_path: str, + t2_path: str, + automation: str, + regions=None, + t1_masks_path: str = None, + segmentation_model: str = None, + segmentation_label: int = 0, + orientation_reference: str = None, + landmark_model: str = None, + dicom_input: bool = False, + output_suffix: str = "Reg", + output_dir: str = None, + sup=None, +) -> RegistrationRun: + """Register every T2 under `t2_path` onto its T1 under `t1_path`. + + Each path is a directory or a `.zip`. `regions` are the display names + declared in `catalogs.REGION_CHOICES` (CBCT only). + """ + output_dir = os.path.abspath(output_dir) + os.makedirs(output_dir, exist_ok=True) + work_dir = os.path.join(output_dir, WORK_DIRNAME) + os.makedirs(work_dir, exist_ok=True) + + t1_root = _as_directory(t1_path, os.path.join(work_dir, "t1_input")) + t2_root = _as_directory(t2_path, os.path.join(work_dir, "t2_input")) + + report = { + "modality": MODALITY, + "automation": automation, + "output_suffix": output_suffix, + "patients": {}, + } + + _run_cbct( + t1_root=t1_root, + t2_root=t2_root, + t1_masks_path=t1_masks_path, + automation=automation, + regions=list(regions or ()), + segmentation_model=segmentation_model, + segmentation_label=int(segmentation_label or 0), + orientation_reference=orientation_reference, + dicom_input=dicom_input, + output_dir=output_dir, + work_dir=work_dir, + suffix=output_suffix, + report=report, + sup=sup, + landmark_model=landmark_model, + ) + + # Extracted inputs, converted DICOM, the oriented copies and whatever the + # tools it drove wrote. Removed whether or not the run succeeded, so what is + # left under output_dir is results and nothing else. + shutil.rmtree(work_dir, ignore_errors=True) + + _summarize(report) + with open(os.path.join(output_dir, REPORT_NAME), "w") as handle: + json.dump(report, handle, indent=2) + return RegistrationRun(output_dir, report) + + +def main( + automation, + t1, + t2, + t1_masks=None, + cbct_regions=None, + segmentation_label=0, + segmentation_model=None, + cbct_reference=None, + landmark_model=None, + dicom_input=False, + output_suffix="Reg", + output_dir=None, + sup=None, +) -> str: + """Translate the schema's arguments into `register()` and return its output + directory, which main.py zips and streams. + + Every cross-argument rule is checked HERE, before any file is read: a + request that cannot work must come back in a second, not after an hour of + registration. `require` in tools.py is part of that: a mode that needs + another tool fails at the door when there is no supervisor to reach it. + """ + automation = str(automation) + suffix = (output_suffix or "Reg").strip() or "Reg" + if os.sep in suffix or (os.altsep and os.altsep in suffix): + raise ToolInputError("'output_suffix' is a name fragment, not a path.") + + allowed = catalogs.AUTOMATION_BY_MODALITY.get(MODALITY, ()) + if automation not in allowed: + raise ToolInputError( + f"'{automation}' is not a mode {MODALITY} has. {MODALITY} offers: " + f"{', '.join(allowed)}." + ) + + regions = _selected(cbct_regions, catalogs.REGION_CHOICES) + reference = cbct_reference + _check_cbct(automation, regions, t1_masks, reference, sup) + + run = register( + t1_path=str(t1), + t2_path=str(t2), + automation=automation, + regions=regions, + t1_masks_path=str(t1_masks) if t1_masks else None, + segmentation_model=str(segmentation_model) if segmentation_model else None, + segmentation_label=int(segmentation_label or 0), + orientation_reference=str(reference) if reference else None, + landmark_model=landmark_model, + dicom_input=bool(dicom_input), + output_suffix=suffix, + output_dir=output_dir, + sup=sup, + ) + + return run.output_dir diff --git a/tools/AREG/AREG_CBCT/src/sadt_areg_cbct/elastix.py b/tools/AREG/AREG_CBCT/src/sadt_areg_cbct/elastix.py index 1376c61..a01ad31 100644 --- a/tools/AREG/AREG_CBCT/src/sadt_areg_cbct/elastix.py +++ b/tools/AREG/AREG_CBCT/src/sadt_areg_cbct/elastix.py @@ -22,7 +22,7 @@ import numpy as np import SimpleITK as sitk -from ..errors import ToolUnavailableError +from sadt_areg_common.errors import ToolUnavailableError logger = logging.getLogger(__name__) diff --git a/tools/AREG/AREG_CBCT/src/sadt_areg_cbct/layout.py b/tools/AREG/AREG_CBCT/src/sadt_areg_cbct/layout.py new file mode 100644 index 0000000..15257e6 --- /dev/null +++ b/tools/AREG/AREG_CBCT/src/sadt_areg_cbct/layout.py @@ -0,0 +1,58 @@ +"""How a client should lay this tool's panel out. Presentation only. + +Nothing here changes what `run()` accepts — `describe.py` merges these hints +into the published schema and refuses any that name an argument the signature +does not take. Delete this file and the tool still works; the panel gets worse. + +No `modality` condition anywhere, and that is the split showing through. The +merged AREG carried one on almost every field, because two modalities and three +automation modes shared a single schema and most arguments applied to exactly +one combination. There is no other modality here now, so what remains are the +conditions that are really about the MODE. +""" + +from sadt_areg_common import catalogs + +_INPUTS = "Inputs" +_REGISTRATION = "Registration" +_OUTPUTS = "Outputs" + +# Each mirrors a check in dispatch.py. An argument the chosen mode never reads +# is not merely noise: shown as optional beside the ones that matter, it reads +# as something the user chose not to fill, and the refusal then arrives at the +# end of a run instead of before it. +_SEGMENTED = { # the modes that produce their own masks + "automation": [catalogs.AUTOMATION_FULLY, catalogs.AUTOMATION_ORIENTED] +} +_ORIENTED = {"automation": catalogs.AUTOMATION_ORIENTED} +_SEMI = {"automation": catalogs.AUTOMATION_SEMI} + +LAYOUT = { + "t1": {"section": _INPUTS, "label": "T1 (baseline)"}, + "t2": {"section": _INPUTS, "label": "T2 (follow-up)"}, + "automation": {"section": _INPUTS, "label": "Mode"}, + "dicom_input": {"section": _INPUTS, "label": "Input is DICOM"}, + + # The one argument a clinician must actually think about: register on what + # has NOT changed between the two timepoints. + "regions": { + "section": _REGISTRATION, + "label": "Register on", + "ui": "inline", + }, + "t1_masks": {"section": _REGISTRATION, "label": "T1 masks", "visible_when": _SEMI}, + "segmentation_model": { + "section": _REGISTRATION, "label": "Segmentation model", "visible_when": _SEGMENTED, + }, + "segmentation_label": { + "section": _REGISTRATION, "label": "Mask label value", "visible_when": _SEGMENTED, + }, + "reference": { + "section": _REGISTRATION, "label": "Orientation reference", "visible_when": _ORIENTED, + }, + "landmark_model": { + "section": _REGISTRATION, "label": "Landmark model bundle", "visible_when": _ORIENTED, + }, + + "output_suffix": {"section": _OUTPUTS, "label": "Output suffix"}, +} diff --git a/tools/AREG/AREG_CBCT/src/sadt_areg_cbct/pipeline.py b/tools/AREG/AREG_CBCT/src/sadt_areg_cbct/pipeline.py index 3fadb17..375868a 100644 --- a/tools/AREG/AREG_CBCT/src/sadt_areg_cbct/pipeline.py +++ b/tools/AREG/AREG_CBCT/src/sadt_areg_cbct/pipeline.py @@ -29,7 +29,7 @@ import SimpleITK as sitk -from .. import catalogs, pairing +from sadt_areg_common import catalogs, pairing from . import elastix logger = logging.getLogger(__name__) diff --git a/tools/AREG/AREG_CBCT/src/sadt_areg_cbct/tools.py b/tools/AREG/AREG_CBCT/src/sadt_areg_cbct/tools.py new file mode 100644 index 0000000..f4f0002 --- /dev/null +++ b/tools/AREG/AREG_CBCT/src/sadt_areg_cbct/tools.py @@ -0,0 +1,135 @@ +"""The seam between AREG and the four tools it drives. + +AREG registers a T2 onto its T1. Getting there needs work it does not do +itself -- masks around the regions to register on, an orientation both +timepoints share, tooth labels, a mucogingival line -- and each of those is +another tool in this repository. AREG calls them; it does not contain them. + +Every call goes through the **supervisor**, the object whatever runs AREG hands +it as the keyword-only `sup`. Nothing here imports another tool: they have +different interpreters and irreconcilable dependency sets, which is the whole +reason the split exists. `sup.run("AMASSS", ...)` starts that tool in its own +venv and blocks until it is done. + +Three things worth knowing before changing anything here: + +* **Tools are named by string, never by attribute.** `sup.run("ASO", ...)`, not + `sup.ASO(...)`. A typo in a string is greppable and this file is the whole + call graph; a typo in an attribute is an `AttributeError` an hour into a job. +* **The arguments are the callee's published schema**, not AREG's vocabulary. + When a tool renames an argument this file is what breaks, which is the point: + it breaks in one place, with the name in it. +* **A missing supervisor is not a bad request.** Nothing about the caller's + arguments is wrong -- there is simply no way to reach the other tool. Each + `require_*` below says which mode to use instead, because that is a real + answer and "deploy a tool" usually is not. +""" + +import logging +import os + +from sadt_areg_common.errors import SupervisorRequired + +logger = logging.getLogger(__name__) + +# What each tool is asked for, and what to do when it cannot be reached. The +# advice is the useful half: a caller who cannot run AMASSS can still send their +# own masks, and saying so beats naming a deployment problem they cannot fix. +_ADVICE = { + "AMASSS": ( + "Send your own T1 segmentation masks in 't1_masks' and use Semi-Automated " + "mode instead." + ), + "ASO": ( + "Orient the T1 and T2 scans yourself beforehand, and use the mode that takes " + "them already oriented." + ), +} + + + +def require(sup, tool: str, mode: str) -> None: + """Refuse a mode that needs `tool` when there is no way to run it. + + Checked up front, before a single file is read: a request that cannot work + has to come back in a second, not after an hour of registration. + """ + if sup is not None: + return + raise SupervisorRequired( + f"{mode} needs the '{tool}' tool, and nothing here can run it: no supervisor " + f"was supplied. {_ADVICE.get(tool, '')}" + ) + + +def _output(sup, tool: str) -> str: + """A directory of the supervisor's scratch for one callee's results.""" + destination = os.path.join(str(sup.tmp), "tools", tool) + os.makedirs(destination, exist_ok=True) + return destination + + +def _returned(produced) -> str: + """A tool returns a Path, or a dict of named ones; AREG wants a directory.""" + if isinstance(produced, dict): + produced = next(iter(produced.values())) + return str(produced) + + +def orient_scans(sup, scan_dir: str, reference_path: str, modality: str, + landmark_model: str = "", **extra) -> str: + """Orient every case under `scan_dir` onto `reference_path`. + + Fully-Automated on both modalities: for CBCT that is ASO predicting the + landmarks through ALI, for IOS it is the tooth-centroid alignment. Either + way AREG hands over a folder and gets an oriented folder back. + + **This is the nested call.** ASO is itself supervised for CBCT, so the chain + is AREG -> ASO -> ALI, three tools and three venvs deep. Whatever supplies + `sup` supplies the callee's too; nothing here arranges that. + """ + logger.info("AREG: asking 'ASO' for oriented %s scans", modality) + parameters = { + "input": scan_dir, + "reference": reference_path, + "output_dir": _output(sup, "ASO"), + "modality": modality, + "automation": "Fully-Automated", + "output_suffix": "Or", + } + # CBCT orientation is itself landmark-driven, and ASO needs the bundle + # NAMED: it used to be optional because the server picked one matching the + # input, and a tool no longer resolves paths. Forgetting it is a failure + # three tools down, so it is passed explicitly and required by _check_cbct. + if modality == "CBCT" and landmark_model: + parameters["landmark_model"] = landmark_model + parameters.update(extra) + return _returned(sup.run("ASO", **parameters)) + + +def segment_masks(sup, scan_dir: str, model_path: str, mask_structures) -> str: + """Segment every scan under `scan_dir` into the requested mask structures. + + Returns the directory holding AMASSS's output, which `cbct.pipeline.find_masks` + then reads exactly as it reads a mask folder the caller sent -- the automated + and semi-automated paths differ only in where the masks came from. + + `mask_structures` are AMASSS structure codes (CBMASK/MANDMASK/MAXMASK), and + the packaged tool takes codes directly. The in-process version had to + translate them into display names through AMASSS's own table; the schema + publishes the codes now, so the translation is gone rather than restated. + """ + logger.info("AREG: asking 'AMASSS' for T1 masks (%s)", ", ".join(mask_structures)) + return _returned(sup.run( + "AMASSS", + scans=scan_dir, + model=model_path, + output_dir=_output(sup, "AMASSS"), + structures=list(mask_structures), + # One binary file per structure: `find_masks` looks each region's mask + # up by name, and a merged multi-label volume would make every region + # resolve to the same file. + merge=["SEPARATE"], + prediction_ID="seg", + generate_surface=False, + )) diff --git a/tools/AREG/AREG_CBCT/uv.lock b/tools/AREG/AREG_CBCT/uv.lock new file mode 100644 index 0000000..f572a6e --- /dev/null +++ b/tools/AREG/AREG_CBCT/uv.lock @@ -0,0 +1,634 @@ +version = 1 +revision = 3 +requires-python = "==3.11.*" + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "dicom2nifti" +version = "2.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nibabel" }, + { name = "numpy" }, + { name = "pydicom" }, + { name = "python-gdcm" }, + { name = "scipy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/26/3a/88d1339ccdf51d2547d4b4b9f511d8a80be989961878b3773fd2a4396c88/dicom2nifti-2.6.2.tar.gz", hash = "sha256:2a421efeacf1616a932f41047e588477b5de72fd2d90aa15b49dcc8f1e4ea544", size = 43193, upload-time = "2025-06-23T06:39:50.267Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/59/598237cb4a5b1fae3515ad8b402ae815a5551e236e530ba11b7a17b2c036/dicom2nifti-2.6.2-py3-none-any.whl", hash = "sha256:d2a328ac534cd424236660d3df1ad01c6fbd71587c620e93041d9ff07dceef4b", size = 43717, upload-time = "2025-06-23T06:39:48.871Z" }, +] + +[[package]] +name = "filelock" +version = "3.32.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/64/a02e6765de08964ed371eca577870593245afc9dfac16d037de7c10d18e6/filelock-3.32.3.tar.gz", hash = "sha256:0ffa185a3540854c95caa7fa76b76cb219d907415e2c5dc9af25fd970563487f", size = 218135, upload-time = "2026-08-13T16:00:05.577Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/8e/50f46a9c0ce8d2861a394c1347caae037ea0431d2f67d7feb151cbc4649a/filelock-3.32.3-py3-none-any.whl", hash = "sha256:7f0ca4bcc0e181c60dbbd8aa9ab5b120ebb99e4e064e83636340056f833a1f09", size = 98901, upload-time = "2026-08-13T16:00:03.974Z" }, +] + +[[package]] +name = "fsspec" +version = "2026.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/00/78/f34251dadb8f3921264a1d9b8946f5e542014ee2614b285261b4e40e6775/fsspec-2026.7.0.tar.gz", hash = "sha256:c803c40f4cf860b49dea58ee3e1c33cb9c790520e233537e1340049f89b82a88", size = 317040, upload-time = "2026-07-28T16:34:51.052Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/3c/6a2bf344106328fd04963664a60b9bb6496fc25df8e962fcdc1367285fb9/fsspec-2026.7.0-py3-none-any.whl", hash = "sha256:b57ddbafedfaef7018c1ecab32aa200a9d7ca26b77965f64e48b70061249d279", size = 206583, upload-time = "2026-07-28T16:34:49.538Z" }, +] + +[[package]] +name = "importlib-resources" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/06/b56dfa750b44e86157093bc8fca0ab81dccbf5260510de4eaf1cb69b5b99/importlib_resources-7.1.0.tar.gz", hash = "sha256:0722d4c6212489c530f2a145a34c0a7a3b4721bc96a15fada5930e2a0b760708", size = 44985, upload-time = "2026-04-12T16:36:09.232Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/db/55a262f3606bebcae07cc14095338471ad7c0bbcaa37707e6f0ee49725b7/importlib_resources-7.1.0-py3-none-any.whl", hash = "sha256:1bd7b48b4088eddb2cd16382150bb515af0bd2c70128194392725f82ad2c96a1", size = 37232, upload-time = "2026-04-12T16:36:08.219Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "itk" +version = "5.4.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "itk-core" }, + { name = "itk-filtering" }, + { name = "itk-io" }, + { name = "itk-numerics" }, + { name = "itk-registration" }, + { name = "itk-segmentation" }, + { name = "numpy" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/bf/7202a7b3caca14237035b588a614c364a2a0d815692f0b918bdb1a2e9957/itk-5.4.7-cp311-abi3-macosx_10_9_x86_64.whl", hash = "sha256:d4c3311b3294697adef1785867f0c60f2314bc2ce202ff528dfab38f2b0a4e5b", size = 16784, upload-time = "2026-08-07T00:31:18.706Z" }, + { url = "https://files.pythonhosted.org/packages/d0/65/41a94f4feefe21c7fd42604b35940c8c2a139fcb92f5c80dcc3fcb7fb07e/itk-5.4.7-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:c7c254419071b178bdf3b50b5542aaef5de3b54bc524b1f6fea72c62f531e590", size = 16785, upload-time = "2026-08-07T00:31:19.629Z" }, + { url = "https://files.pythonhosted.org/packages/cb/83/d29f05796ccc6abc05b0780f57234b0d984bc923285c2c2e959ba87589e1/itk-5.4.7-cp311-abi3-manylinux2014_x86_64.whl", hash = "sha256:9297419ccac8f0fd455ce08f4ba0580632f7be43c28c02ad0cbdeb19cd0c211e", size = 16795, upload-time = "2026-08-07T00:31:20.425Z" }, + { url = "https://files.pythonhosted.org/packages/4a/b4/e2e0611a677cc4682807a7bc48dd67369ff9d0a6218d11c217c110062658/itk-5.4.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f2ae79ad0a63ab4ad61fa630e8564ab5bac4fea1c4aad4db2f52bac4f460dffc", size = 16797, upload-time = "2026-08-07T00:31:21.211Z" }, + { url = "https://files.pythonhosted.org/packages/07/a7/98fcd0046d3249de328395a96b9a0428c11813eebe631e6a1299a9bafe85/itk-5.4.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:d74574c8a0196e82788e6f9991ee05ed5935d2d42e2209ef540fce9274cf2eb4", size = 16796, upload-time = "2026-08-07T00:31:22.13Z" }, + { url = "https://files.pythonhosted.org/packages/4b/48/1989c170cb5a74d06c0eb9fcc47812b0ba013cb73b514ab8892ddcc3b0f4/itk-5.4.7-cp311-abi3-win_amd64.whl", hash = "sha256:56ca4981924ed2ba30503c319dcdb63981504c0927aa68afd33468f33c44ecb3", size = 16780, upload-time = "2026-08-07T00:31:22.909Z" }, +] + +[[package]] +name = "itk-core" +version = "5.4.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/28/114f2b0da5f846939756db0e4bda79ec951597dd45c9b234d6ae9f20a65d/itk_core-5.4.7-cp311-abi3-macosx_10_9_x86_64.whl", hash = "sha256:7e6e7f42f7c7017c19c0462080909ebb3e39ffefc131f4048aa8f749812cc112", size = 71065728, upload-time = "2026-08-07T00:31:50.504Z" }, + { url = "https://files.pythonhosted.org/packages/3e/ad/977096c45990de9fd9e7406c9dfac7f009e8dc51c5abb26ceb82530e0923/itk_core-5.4.7-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:648749d347b9d0673e87edbe934bc32e93c9cd82ee1c5546291a9eca921b9186", size = 60240109, upload-time = "2026-08-07T00:31:53.786Z" }, + { url = "https://files.pythonhosted.org/packages/09/d0/aa498f30e459828fa4a23dec423dbbfd32adef60d0df8eacc53ac87a1fb3/itk_core-5.4.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8b8c42cfd86d0be761b4a9e8b13e72d14f4e7ec8eff96fbd7b775f42f8daed2a", size = 83571354, upload-time = "2026-08-07T00:31:57.169Z" }, + { url = "https://files.pythonhosted.org/packages/8c/91/7b3e868c4e6d9d6538e3425b41ceacb9de25d21ea3c0c4ab2d4b9259bff8/itk_core-5.4.7-cp311-abi3-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d16110e8fc530a0eb53c6b967ec05310bb6ef909fed1cedb5988b9231d5e3e3f", size = 73324257, upload-time = "2026-08-07T00:32:00.735Z" }, + { url = "https://files.pythonhosted.org/packages/d0/58/f8f997384254b39d369ce63d369d9cd262b411d32d901e4aa0d39de41c91/itk_core-5.4.7-cp311-abi3-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a48dd8ab99de8d4a758932c1923ef05bbc148d0aba6f07425c2fc69c5c323787", size = 81457408, upload-time = "2026-08-07T00:32:04.216Z" }, + { url = "https://files.pythonhosted.org/packages/0e/94/438dff9330683fcc529c2b4ccfe0b3f90b4e7f53c4997e7a5a08aad80a7f/itk_core-5.4.7-cp311-abi3-win_amd64.whl", hash = "sha256:c16055aa7f7c528d0e474987c54a440b12009e5d72ae313719ca8bc3f36f7678", size = 37568287, upload-time = "2026-08-07T00:32:09.458Z" }, +] + +[[package]] +name = "itk-elastix" +version = "0.23.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "itk" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/ca/f91ca4c037fac506f45270c3d09aa96ce0feffc0f6ef418cae14d8e7c366/itk_elastix-0.23.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:baaf61a1adcaf2225ccfc2a40a259b82005b37541ec7000070b9c35fdf6ccc8f", size = 13215551, upload-time = "2025-04-15T10:54:10.147Z" }, + { url = "https://files.pythonhosted.org/packages/a1/07/218fa776e4ffa6083cce8acd584c20f01e3a2acff5e0946024039b8bad44/itk_elastix-0.23.0-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b529f17f8f97c22575dbe9ad5987b2bba47999bbfe8ea391dace018e351d2164", size = 21150061, upload-time = "2025-04-15T10:54:12.557Z" }, + { url = "https://files.pythonhosted.org/packages/54/19/5f8c9ebd49b7cb00bea7012c5469cdf885af232d37777947614a596ebdd5/itk_elastix-0.23.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:b37a5a767655089ae6d941ec223e39c96dfbe79a790fdfe8045f91d81cf17244", size = 19548452, upload-time = "2025-04-15T10:54:15.019Z" }, + { url = "https://files.pythonhosted.org/packages/c5/46/a8aab730d8d75ccaf155be543e2e314fcf87fc6272f5abe19f1565f78a20/itk_elastix-0.23.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:9b1befecc1d3d8c911913a9eecf946cb977abcc9558bd2e51ae7095e636dcb0c", size = 21382392, upload-time = "2025-04-15T10:54:17.139Z" }, + { url = "https://files.pythonhosted.org/packages/b6/79/7937004d1a2a875bb9360280d5b6922b28af8e671904cd8d94c8e679f374/itk_elastix-0.23.0-cp311-abi3-win_amd64.whl", hash = "sha256:b96120d7402e3550db2a686f3af21034bb60ea67df45185e5f6021b9491ed60d", size = 7749495, upload-time = "2025-04-15T10:54:19.148Z" }, +] + +[[package]] +name = "itk-filtering" +version = "5.4.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "itk-numerics" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/a2/af38f70252ab8b310727d6cb8f4271ffe08e7c92c0194b7af1531c9ccf87/itk_filtering-5.4.7-cp311-abi3-macosx_10_9_x86_64.whl", hash = "sha256:3141e34cbbcb0de3c97d5b80919f05a87459943779888da93569c015c5fd57f9", size = 46751640, upload-time = "2026-08-07T00:32:50.524Z" }, + { url = "https://files.pythonhosted.org/packages/20/ee/d6b45ac88e554f31cfeeb30b457680991948f6a720dd9e6785c2b1bfd18f/itk_filtering-5.4.7-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:4039494c48938d20ed7ce2a0c5680902b7160af1e5f8d4c511be5b8507404b8e", size = 38992990, upload-time = "2026-08-07T00:32:53.341Z" }, + { url = "https://files.pythonhosted.org/packages/6a/28/c09c756e80d4c2ca9cb6ba5899116719a4167001e8b5ee252ca3c776fcfe/itk_filtering-5.4.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3c29348abc0a24d668818c0651702e11c69a946053511baf588713ec5f60fdfa", size = 69480739, upload-time = "2026-08-07T00:32:56.85Z" }, + { url = "https://files.pythonhosted.org/packages/88/d4/c7afce042901422cf87930cc591ff898a6b9221c676b06c6e5c96ea18182/itk_filtering-5.4.7-cp311-abi3-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ced346599ebf3f0437263c167099615304519f279e01036fa428919df070cea8", size = 63912362, upload-time = "2026-08-07T00:33:00.042Z" }, + { url = "https://files.pythonhosted.org/packages/5c/76/bac5e891715e47ffc415a5010aaf1282a78142c466762b2da5a9162ac679/itk_filtering-5.4.7-cp311-abi3-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5b88431bc519c1f5c3988e53a70ca16767e1e6333a26ef3bc12b35ab4721496", size = 67828457, upload-time = "2026-08-07T00:33:03.058Z" }, + { url = "https://files.pythonhosted.org/packages/39/38/c55a9d5938f137481be657b77cd31f7080363a60c176df4568199b666fd0/itk_filtering-5.4.7-cp311-abi3-win_amd64.whl", hash = "sha256:00a4a98a877ab7ee55610b18b912720f5cbfed90581d059e16a5cc8df4c4b0b8", size = 23571185, upload-time = "2026-08-07T00:33:05.587Z" }, +] + +[[package]] +name = "itk-io" +version = "5.4.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "itk-core" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/be/fe6d74ee8c778df437b60e6ca2ef436db46b3356ecb59a4608a33a67b18e/itk_io-5.4.7-cp311-abi3-macosx_10_9_x86_64.whl", hash = "sha256:3de7b45085dbd25281a590ebf9c6b1dafbaeff080eceabc8eb0d7fb3c5394876", size = 22352038, upload-time = "2026-08-07T00:33:43.103Z" }, + { url = "https://files.pythonhosted.org/packages/eb/a9/10e4bb2d9e9cfb558de75a71e9f634f109de4e078086d8622eda61d9e5cc/itk_io-5.4.7-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:d158bb1cd9746238a11717ec63a05006b5dfd14c4699a0edc2ab42c02c492532", size = 17788177, upload-time = "2026-08-07T00:33:45.417Z" }, + { url = "https://files.pythonhosted.org/packages/3f/57/a4fb7ad91765efb8fe76084a0cadacf6846206430f4d9968379a3306f845/itk_io-5.4.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b5242e7cf3b7dd4a01478844a5421e637c9b860ad5c7620ef4019398d96efdc0", size = 27681500, upload-time = "2026-08-07T00:33:47.575Z" }, + { url = "https://files.pythonhosted.org/packages/e2/77/68b94a420cb6faaceb6d9880fe3001da7ebe60288b306adb914d314a0acb/itk_io-5.4.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a1b67764abd71a0dd5b9827ad400db2eae6a9a777a185b51681d61961c5bde2a", size = 25597098, upload-time = "2026-08-07T00:33:50.324Z" }, + { url = "https://files.pythonhosted.org/packages/ee/3c/335d93a3137e1ff1c1468ff760f862a5d43c9778e1441e6d177e4495d8ad/itk_io-5.4.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2af543cc0e2dea6549fca6af0fd08ed84cd92b34c3f25bda5e01b4000ec7d2f", size = 28014426, upload-time = "2026-08-07T00:33:53.336Z" }, + { url = "https://files.pythonhosted.org/packages/05/07/e42792e040812ec2030b8971f4f06919f6055e651facdeecb9d2fdac6659/itk_io-5.4.7-cp311-abi3-win_amd64.whl", hash = "sha256:8c7e6a7842137fd360d80e80d9c26618df9662114d6c98f12b24be9be769da43", size = 8681668, upload-time = "2026-08-07T00:33:55.659Z" }, +] + +[[package]] +name = "itk-numerics" +version = "5.4.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "itk-core" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/93/aa995c45578aeccd9f3d812b462dbd0f13833c819e1c9f3fb2e6c3dd1216/itk_numerics-5.4.7-cp311-abi3-macosx_10_9_x86_64.whl", hash = "sha256:1c4ec9266c4c071aa1477e37ea5a936c49df77e52ac3efd3ba418200b7d5bf03", size = 35826600, upload-time = "2026-08-07T00:34:30.358Z" }, + { url = "https://files.pythonhosted.org/packages/19/a9/7ffd72245838a3cbf9c83f37f9245ed4541b3f5d3c979e8e6c0d3efef6bf/itk_numerics-5.4.7-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:184dea905f5a6af6fa72750d5831baf48b59c2cc7070f345281e18918b33fed3", size = 30873563, upload-time = "2026-08-07T00:34:32.961Z" }, + { url = "https://files.pythonhosted.org/packages/70/aa/d72ab2e3a34bb90ddb9eb58b17e3d644a39a948fcc5f752b504272d052c1/itk_numerics-5.4.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:86586ff1ff2a07c2c2fbfc3ba50e1fd21efc705a30ba015d1a30972cfa218308", size = 58139915, upload-time = "2026-08-07T00:34:35.778Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d3/23ed57a416da484791083745d1f81ef7c4539eedf8ed8cefdea34dec848a/itk_numerics-5.4.7-cp311-abi3-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e37390e4527b62bd6bbe162676c1cad818635794c24742547cb64fc3c7e23860", size = 53995183, upload-time = "2026-08-07T00:34:38.666Z" }, + { url = "https://files.pythonhosted.org/packages/eb/b8/1ed64dea0b253ec431f4b9289394242e3cbc186a1fa73d72f24328094615/itk_numerics-5.4.7-cp311-abi3-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:473925bdabcb7c5feebef03c6fde4bc916c577776f8b9bfea69e826d7855d668", size = 57197252, upload-time = "2026-08-07T00:34:41.785Z" }, + { url = "https://files.pythonhosted.org/packages/cf/19/a81c006fba7091805e96311291f2f04d19382d4d769cf6fc1fc352b438ff/itk_numerics-5.4.7-cp311-abi3-win_amd64.whl", hash = "sha256:09b3dc9efbf75ba827c03edbfc6214bfad86a643c517a19014eb37969bb139a5", size = 19725436, upload-time = "2026-08-07T00:34:45.142Z" }, +] + +[[package]] +name = "itk-registration" +version = "5.4.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "itk-filtering" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/66/75/5de9e78a905797080ebb8bbd6b2e41374f609a51e8f527bfd1b69a73f7c0/itk_registration-5.4.7-cp311-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2efa16df50323f2f46c9e9ad20201ba959e36629821da0bca4261eb343cecd34", size = 22012924, upload-time = "2026-08-07T00:44:36.056Z" }, + { url = "https://files.pythonhosted.org/packages/ed/34/47d7eebd21548a604dfe519fa59e27bc493feaca7e2bc1d6405668c11c3a/itk_registration-5.4.7-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:1dc2bcf5efc8a4e6cea88a8054a3b02cb079dbe1bf88971f1a2519ba12ccc6ef", size = 17848682, upload-time = "2026-08-07T00:44:38.549Z" }, + { url = "https://files.pythonhosted.org/packages/da/df/8ef08a92b78b39cf1691d33442d2069e7efbfcd8579c3ae809a43e0dd0e4/itk_registration-5.4.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3190ab55c41e9c33d036979cc384bd02d5f25c4b58896906da24700d10eca057", size = 29007536, upload-time = "2026-08-07T00:44:40.829Z" }, + { url = "https://files.pythonhosted.org/packages/08/f6/103b6a530cb85bea70bbbf2871067be1b8ab36a014676c6dd4bbfeb3116a/itk_registration-5.4.7-cp311-abi3-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d2756c2a86218aca1682ad13ec9d7b4fd32120f0ff335080691e42d11fb506b8", size = 26115954, upload-time = "2026-08-07T00:44:43.765Z" }, + { url = "https://files.pythonhosted.org/packages/58/22/e343caf0e1a18758806cdef060909f0691f7b17f59dcdc07dac8f6289650/itk_registration-5.4.7-cp311-abi3-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d71e4af418aaf5d4846aaf2615e3bace9aef75ee78a9667a9455ed3016a3f645", size = 28541907, upload-time = "2026-08-07T00:44:46.674Z" }, + { url = "https://files.pythonhosted.org/packages/99/b7/ae39f4e1d3c57526686de56f41a8c351f1632ed230b8932e6085869b3993/itk_registration-5.4.7-cp311-abi3-win_amd64.whl", hash = "sha256:1675047bc637bfb039ebd6fe08f2567ac28c04ecabf0ece88067b1e182ba7ce8", size = 9527196, upload-time = "2026-08-07T00:44:48.956Z" }, +] + +[[package]] +name = "itk-segmentation" +version = "5.4.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "itk-filtering" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/db/989ea924748a7953efc38902d7a071cfe6750c983e8bf8ed8520e68541de/itk_segmentation-5.4.7-cp311-abi3-macosx_10_9_x86_64.whl", hash = "sha256:05954ee97e7df4da96b0195723d91baeba1907bcd1d5fc433e700c8dbb599f9f", size = 13067851, upload-time = "2026-08-07T00:45:18.207Z" }, + { url = "https://files.pythonhosted.org/packages/e0/8f/1dade3ec293fa00bc3ce1776888146dabe1bcd79db63cb1c6abadc6dd752/itk_segmentation-5.4.7-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:52d0517ebee2b087f2dd3f4c5fceb2213853bbd0579a247bb0c441782f4aa776", size = 11040197, upload-time = "2026-08-07T00:45:20.649Z" }, + { url = "https://files.pythonhosted.org/packages/2d/77/bf9d20771e0714c77a092d43a4b2f627f08e674d4552cf26adb4532b1137/itk_segmentation-5.4.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51162aad2728fc009acac81cbcfa2ed21bd14ccfda74423806efee3a0c3f2506", size = 16467231, upload-time = "2026-08-07T00:45:22.993Z" }, + { url = "https://files.pythonhosted.org/packages/d3/36/d83ee821342339f4347f38bd3ab44d8b23da00368941533982f73e0ed551/itk_segmentation-5.4.7-cp311-abi3-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1393bfad55807673a7976eeb3edb4f77afd7ea003439b2ae5abe2ebd74f2ecd9", size = 14650848, upload-time = "2026-08-07T00:45:25.332Z" }, + { url = "https://files.pythonhosted.org/packages/5f/48/bdf880537d06b06918e696e90e1a963708aef6094788e0836df7b45c617f/itk_segmentation-5.4.7-cp311-abi3-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9746ada767eac9f118b386b58c7e4416765683b3e5e9fe46e20c46d5db503754", size = 15895887, upload-time = "2026-08-07T00:45:27.544Z" }, + { url = "https://files.pythonhosted.org/packages/d8/ab/434d673c5254bd5227a2cb9f8faa282373947139431f172e23c690fa9e75/itk_segmentation-5.4.7-cp311-abi3-win_amd64.whl", hash = "sha256:79705efb3c160ad65648a2dff7a2ee8c5cafeb1af32aac5eaff9e481fbbac743", size = 5034085, upload-time = "2026-08-07T00:45:29.94Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, +] + +[[package]] +name = "mpmath" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, +] + +[[package]] +name = "networkx" +version = "3.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" }, +] + +[[package]] +name = "nibabel" +version = "5.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-resources" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/01/3d2cc510c616bc8e27be17a063070d9126f69407961594a9ae734ea51121/nibabel-5.4.2.tar.gz", hash = "sha256:d5f4b9076a13178ae7f7acf18c8dbd503ee1c4d5c0c23b85df7be87efcbb49da", size = 4663132, upload-time = "2026-03-11T13:31:52.42Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/d7/601b6396b33536811668935faa790112266c70661be94555999be431f86f/nibabel-5.4.2-py3-none-any.whl", hash = "sha256:553482c5f1e1034fc312edf6fb7f32236c0056439845d1c29293b7e8c98d4854", size = 3300985, upload-time = "2026-03-11T13:31:50.028Z" }, +] + +[[package]] +name = "numpy" +version = "2.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/37/7d/3fec4199c5ffb892bed55cff901e4f39a58c81df9c44c280499e92cad264/numpy-2.3.2.tar.gz", hash = "sha256:e0486a11ec30cdecb53f184d496d1c6a20786c81e55e41640270130056f8ee48", size = 20489306, upload-time = "2025-07-24T21:32:07.553Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/26/1320083986108998bd487e2931eed2aeedf914b6e8905431487543ec911d/numpy-2.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:852ae5bed3478b92f093e30f785c98e0cb62fa0a939ed057c31716e18a7a22b9", size = 21259016, upload-time = "2025-07-24T20:24:35.214Z" }, + { url = "https://files.pythonhosted.org/packages/c4/2b/792b341463fa93fc7e55abbdbe87dac316c5b8cb5e94fb7a59fb6fa0cda5/numpy-2.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7a0e27186e781a69959d0230dd9909b5e26024f8da10683bd6344baea1885168", size = 14451158, upload-time = "2025-07-24T20:24:58.397Z" }, + { url = "https://files.pythonhosted.org/packages/b7/13/e792d7209261afb0c9f4759ffef6135b35c77c6349a151f488f531d13595/numpy-2.3.2-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:f0a1a8476ad77a228e41619af2fa9505cf69df928e9aaa165746584ea17fed2b", size = 5379817, upload-time = "2025-07-24T20:25:07.746Z" }, + { url = "https://files.pythonhosted.org/packages/49/ce/055274fcba4107c022b2113a213c7287346563f48d62e8d2a5176ad93217/numpy-2.3.2-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:cbc95b3813920145032412f7e33d12080f11dc776262df1712e1638207dde9e8", size = 6913606, upload-time = "2025-07-24T20:25:18.84Z" }, + { url = "https://files.pythonhosted.org/packages/17/f2/e4d72e6bc5ff01e2ab613dc198d560714971900c03674b41947e38606502/numpy-2.3.2-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f75018be4980a7324edc5930fe39aa391d5734531b1926968605416ff58c332d", size = 14589652, upload-time = "2025-07-24T20:25:40.356Z" }, + { url = "https://files.pythonhosted.org/packages/c8/b0/fbeee3000a51ebf7222016e2939b5c5ecf8000a19555d04a18f1e02521b8/numpy-2.3.2-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20b8200721840f5621b7bd03f8dcd78de33ec522fc40dc2641aa09537df010c3", size = 16938816, upload-time = "2025-07-24T20:26:05.721Z" }, + { url = "https://files.pythonhosted.org/packages/a9/ec/2f6c45c3484cc159621ea8fc000ac5a86f1575f090cac78ac27193ce82cd/numpy-2.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1f91e5c028504660d606340a084db4b216567ded1056ea2b4be4f9d10b67197f", size = 16370512, upload-time = "2025-07-24T20:26:30.545Z" }, + { url = "https://files.pythonhosted.org/packages/b5/01/dd67cf511850bd7aefd6347aaae0956ed415abea741ae107834aae7d6d4e/numpy-2.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:fb1752a3bb9a3ad2d6b090b88a9a0ae1cd6f004ef95f75825e2f382c183b2097", size = 18884947, upload-time = "2025-07-24T20:26:58.24Z" }, + { url = "https://files.pythonhosted.org/packages/a7/17/2cf60fd3e6a61d006778735edf67a222787a8c1a7842aed43ef96d777446/numpy-2.3.2-cp311-cp311-win32.whl", hash = "sha256:4ae6863868aaee2f57503c7a5052b3a2807cf7a3914475e637a0ecd366ced220", size = 6599494, upload-time = "2025-07-24T20:27:09.786Z" }, + { url = "https://files.pythonhosted.org/packages/d5/03/0eade211c504bda872a594f045f98ddcc6caef2b7c63610946845e304d3f/numpy-2.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:240259d6564f1c65424bcd10f435145a7644a65a6811cfc3201c4a429ba79170", size = 13087889, upload-time = "2025-07-24T20:27:29.558Z" }, + { url = "https://files.pythonhosted.org/packages/13/32/2c7979d39dafb2a25087e12310fc7f3b9d3c7d960df4f4bc97955ae0ce1d/numpy-2.3.2-cp311-cp311-win_arm64.whl", hash = "sha256:4209f874d45f921bde2cff1ffcd8a3695f545ad2ffbef6d3d3c6768162efab89", size = 10459560, upload-time = "2025-07-24T20:27:46.803Z" }, + { url = "https://files.pythonhosted.org/packages/cf/ea/50ebc91d28b275b23b7128ef25c3d08152bc4068f42742867e07a870a42a/numpy-2.3.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:14a91ebac98813a49bc6aa1a0dfc09513dcec1d97eaf31ca21a87221a1cdcb15", size = 21130338, upload-time = "2025-07-24T20:57:54.37Z" }, + { url = "https://files.pythonhosted.org/packages/9f/57/cdd5eac00dd5f137277355c318a955c0d8fb8aa486020c22afd305f8b88f/numpy-2.3.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:71669b5daae692189540cffc4c439468d35a3f84f0c88b078ecd94337f6cb0ec", size = 14375776, upload-time = "2025-07-24T20:58:16.303Z" }, + { url = "https://files.pythonhosted.org/packages/83/85/27280c7f34fcd305c2209c0cdca4d70775e4859a9eaa92f850087f8dea50/numpy-2.3.2-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:69779198d9caee6e547adb933941ed7520f896fd9656834c300bdf4dd8642712", size = 5304882, upload-time = "2025-07-24T20:58:26.199Z" }, + { url = "https://files.pythonhosted.org/packages/48/b4/6500b24d278e15dd796f43824e69939d00981d37d9779e32499e823aa0aa/numpy-2.3.2-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:2c3271cc4097beb5a60f010bcc1cc204b300bb3eafb4399376418a83a1c6373c", size = 6818405, upload-time = "2025-07-24T20:58:37.341Z" }, + { url = "https://files.pythonhosted.org/packages/9b/c9/142c1e03f199d202da8e980c2496213509291b6024fd2735ad28ae7065c7/numpy-2.3.2-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8446acd11fe3dc1830568c941d44449fd5cb83068e5c70bd5a470d323d448296", size = 14419651, upload-time = "2025-07-24T20:58:59.048Z" }, + { url = "https://files.pythonhosted.org/packages/8b/95/8023e87cbea31a750a6c00ff9427d65ebc5fef104a136bfa69f76266d614/numpy-2.3.2-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aa098a5ab53fa407fded5870865c6275a5cd4101cfdef8d6fafc48286a96e981", size = 16760166, upload-time = "2025-07-24T21:28:56.38Z" }, + { url = "https://files.pythonhosted.org/packages/78/e3/6690b3f85a05506733c7e90b577e4762517404ea78bab2ca3a5cb1aeb78d/numpy-2.3.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6936aff90dda378c09bea075af0d9c675fe3a977a9d2402f95a87f440f59f619", size = 12977811, upload-time = "2025-07-24T21:29:18.234Z" }, +] + +[[package]] +name = "nvidia-cublas-cu12" +version = "12.8.4.1" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/61/e24b560ab2e2eaeb3c839129175fb330dfcfc29e5203196e5541a4c44682/nvidia_cublas_cu12-12.8.4.1-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:8ac4e771d5a348c551b2a426eda6193c19aa630236b418086020df5ba9667142", size = 594346921, upload-time = "2025-03-07T01:44:31.254Z" }, +] + +[[package]] +name = "nvidia-cuda-cupti-cu12" +version = "12.8.90" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/02/2adcaa145158bf1a8295d83591d22e4103dbfd821bcaf6f3f53151ca4ffa/nvidia_cuda_cupti_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ea0cb07ebda26bb9b29ba82cda34849e73c166c18162d3913575b0c9db9a6182", size = 10248621, upload-time = "2025-03-07T01:40:21.213Z" }, +] + +[[package]] +name = "nvidia-cuda-nvrtc-cu12" +version = "12.8.93" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/6b/32f747947df2da6994e999492ab306a903659555dddc0fbdeb9d71f75e52/nvidia_cuda_nvrtc_cu12-12.8.93-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:a7756528852ef889772a84c6cd89d41dfa74667e24cca16bb31f8f061e3e9994", size = 88040029, upload-time = "2025-03-07T01:42:13.562Z" }, +] + +[[package]] +name = "nvidia-cuda-runtime-cu12" +version = "12.8.90" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/9b/a997b638fcd068ad6e4d53b8551a7d30fe8b404d6f1804abf1df69838932/nvidia_cuda_runtime_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:adade8dcbd0edf427b7204d480d6066d33902cab2a4707dcfc48a2d0fd44ab90", size = 954765, upload-time = "2025-03-07T01:40:01.615Z" }, +] + +[[package]] +name = "nvidia-cudnn-cu12" +version = "9.10.2.21" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-cublas-cu12" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/51/e123d997aa098c61d029f76663dedbfb9bc8dcf8c60cbd6adbe42f76d049/nvidia_cudnn_cu12-9.10.2.21-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:949452be657fa16687d0930933f032835951ef0892b37d2d53824d1a84dc97a8", size = 706758467, upload-time = "2025-06-06T21:54:08.597Z" }, +] + +[[package]] +name = "nvidia-cufft-cu12" +version = "11.3.3.83" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-nvjitlink-cu12" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/13/ee4e00f30e676b66ae65b4f08cb5bcbb8392c03f54f2d5413ea99a5d1c80/nvidia_cufft_cu12-11.3.3.83-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d2dd21ec0b88cf61b62e6b43564355e5222e4a3fb394cac0db101f2dd0d4f74", size = 193118695, upload-time = "2025-03-07T01:45:27.821Z" }, +] + +[[package]] +name = "nvidia-cufile-cu12" +version = "1.13.1.3" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bb/fe/1bcba1dfbfb8d01be8d93f07bfc502c93fa23afa6fd5ab3fc7c1df71038a/nvidia_cufile_cu12-1.13.1.3-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1d069003be650e131b21c932ec3d8969c1715379251f8d23a1860554b1cb24fc", size = 1197834, upload-time = "2025-03-07T01:45:50.723Z" }, +] + +[[package]] +name = "nvidia-curand-cu12" +version = "10.3.9.90" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/aa/6584b56dc84ebe9cf93226a5cde4d99080c8e90ab40f0c27bda7a0f29aa1/nvidia_curand_cu12-10.3.9.90-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:b32331d4f4df5d6eefa0554c565b626c7216f87a06a4f56fab27c3b68a830ec9", size = 63619976, upload-time = "2025-03-07T01:46:23.323Z" }, +] + +[[package]] +name = "nvidia-cusolver-cu12" +version = "11.7.3.90" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-cublas-cu12" }, + { name = "nvidia-cusparse-cu12" }, + { name = "nvidia-nvjitlink-cu12" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/48/9a13d2975803e8cf2777d5ed57b87a0b6ca2cc795f9a4f59796a910bfb80/nvidia_cusolver_cu12-11.7.3.90-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:4376c11ad263152bd50ea295c05370360776f8c3427b30991df774f9fb26c450", size = 267506905, upload-time = "2025-03-07T01:47:16.273Z" }, +] + +[[package]] +name = "nvidia-cusparse-cu12" +version = "12.5.8.93" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-nvjitlink-cu12" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/f5/e1854cb2f2bcd4280c44736c93550cc300ff4b8c95ebe370d0aa7d2b473d/nvidia_cusparse_cu12-12.5.8.93-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1ec05d76bbbd8b61b06a80e1eaf8cf4959c3d4ce8e711b65ebd0443bb0ebb13b", size = 288216466, upload-time = "2025-03-07T01:48:13.779Z" }, +] + +[[package]] +name = "nvidia-cusparselt-cu12" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/79/12978b96bd44274fe38b5dde5cfb660b1d114f70a65ef962bcbbed99b549/nvidia_cusparselt_cu12-0.7.1-py3-none-manylinux2014_x86_64.whl", hash = "sha256:f1bb701d6b930d5a7cea44c19ceb973311500847f81b634d802b7b539dc55623", size = 287193691, upload-time = "2025-02-26T00:15:44.104Z" }, +] + +[[package]] +name = "nvidia-nccl-cu12" +version = "2.27.3" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/5b/4e4fff7bad39adf89f735f2bc87248c81db71205b62bcc0d5ca5b606b3c3/nvidia_nccl_cu12-2.27.3-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:adf27ccf4238253e0b826bce3ff5fa532d65fc42322c8bfdfaf28024c0fbe039", size = 322364134, upload-time = "2025-06-03T21:58:04.013Z" }, +] + +[[package]] +name = "nvidia-nvjitlink-cu12" +version = "12.8.93" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f6/74/86a07f1d0f42998ca31312f998bd3b9a7eff7f52378f4f270c8679c77fb9/nvidia_nvjitlink_cu12-12.8.93-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:81ff63371a7ebd6e6451970684f916be2eab07321b73c9d244dc2b4da7f73b88", size = 39254836, upload-time = "2025-03-07T01:49:55.661Z" }, +] + +[[package]] +name = "nvidia-nvtx-cu12" +version = "12.8.90" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/eb/86626c1bbc2edb86323022371c39aa48df6fd8b0a1647bc274577f72e90b/nvidia_nvtx_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5b17e2001cc0d751a5bc2c6ec6d26ad95913324a4adb86788c944f8ce9ba441f", size = 89954, upload-time = "2025-03-07T01:42:44.131Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pydicom" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7a/de/52aaf905f1f0ae7aba85996e2592ea2c1fe49157f3cfbcd1871965bdb51d/pydicom-3.0.2.tar.gz", hash = "sha256:5942bfc2d72c6fa4b3b5b62c527f54b7f2355f21d6f5d296df6bb30188df6a4f", size = 2886792, upload-time = "2026-03-19T21:46:20.935Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/e0/60466c6d712dad2cf807df315e39863e91609ffd1064ecb835994460bbda/pydicom-3.0.2-py3-none-any.whl", hash = "sha256:abf971a5440f84dbaf42c4b6758e30e62480902584f8b270b9a5d146e278a07b", size = 2376822, upload-time = "2026-03-19T21:46:19.042Z" }, +] + +[[package]] +name = "pytest" +version = "8.3.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/05/35/30e0d83068951d90a01852cb1cef56e5d8a09d20c7f511634cc2f7e0372a/pytest-8.3.4.tar.gz", hash = "sha256:965370d062bce11e73868e0335abac31b4d3de0e82f4007408d242b4f8610761", size = 1445919, upload-time = "2024-12-01T12:54:25.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/92/76a1c94d3afee238333bc0a42b82935dd8f9cf8ce9e336ff87ee14d9e1cf/pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6", size = 343083, upload-time = "2024-12-01T12:54:19.735Z" }, +] + +[[package]] +name = "python-gdcm" +version = "3.2.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c1/99/83550191a6bd7278c13c623270d69b0ee99b8b998e81e10f6924dce130c6/python_gdcm-3.2.6.tar.gz", hash = "sha256:2f16eaea7a8c736279492f829d7f95002237a7fb2ea9c3ae85175498e1cd3dad", size = 3384598, upload-time = "2026-05-11T00:49:17.23Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/0d/4dea3029176dc0d1cc2a7229668c92a0ee391e3736c028498b55af45d5c0/python_gdcm-3.2.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b22521413ee0b61acdf7f8ea7fe3d51ba7173d4754eb1f62d29b782059877ac6", size = 11457927, upload-time = "2026-05-11T00:39:44.949Z" }, + { url = "https://files.pythonhosted.org/packages/68/58/8b5cc3c650555ed6ac4f8bcb540f31fecf077832a521f7df142196bef320/python_gdcm-3.2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a68bb0f582294cf21297b45856e0a20c7742fdedfa5ba4d313d226b6b5e6c36a", size = 10715454, upload-time = "2026-05-11T00:39:48.798Z" }, + { url = "https://files.pythonhosted.org/packages/d1/39/d512c0aec6379eea36f17e8ae4f0d2877bfaae32efe0a47d610c20f04036/python_gdcm-3.2.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d6033cc01b62e96b928eb8e35b520f2f7a640919f3a569b043c1e64010de0c7", size = 12277638, upload-time = "2026-05-11T00:39:53.108Z" }, + { url = "https://files.pythonhosted.org/packages/2c/33/b925a15ac7597ff24f1601830a0cd8dd8f488348bdf208cf613fe2bc2fe8/python_gdcm-3.2.6-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:716e8a20de03e6a90035ee4575b19bb4b204e219a428509b686c1c28ef6de2d9", size = 13278697, upload-time = "2026-05-11T00:39:57.455Z" }, + { url = "https://files.pythonhosted.org/packages/95/23/87bf04e52112e6eb3ef6e95417cb508bea77332c3312197d3a9a59574ac4/python_gdcm-3.2.6-cp311-cp311-win_amd64.whl", hash = "sha256:c752d94d92239c6bda313a514edd8bd5a17167c751d4ab1bdad82c6b559c5213", size = 34235777, upload-time = "2026-05-11T00:40:03.888Z" }, +] + +[[package]] +name = "sadt-areg-cbct" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "dicom2nifti" }, + { name = "itk" }, + { name = "itk-elastix" }, + { name = "numpy" }, + { name = "sadt-areg-common" }, + { name = "simpleitk" }, + { name = "torch" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "sadt-testkit" }, +] + +[package.metadata] +requires-dist = [ + { name = "dicom2nifti", specifier = "==2.6.2" }, + { name = "itk", specifier = "==5.4.7" }, + { name = "itk-elastix", specifier = "==0.23.0" }, + { name = "numpy", specifier = "==2.3.2" }, + { name = "sadt-areg-common", directory = "../common" }, + { name = "simpleitk", specifier = "==2.5.6" }, + { name = "torch", specifier = "==2.8.0", index = "https://download.pytorch.org/whl/cu128" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = "==8.3.4" }, + { name = "sadt-testkit", editable = "../../../testkit" }, +] + +[[package]] +name = "sadt-areg-common" +version = "0.1.0" +source = { directory = "../common" } + +[package.metadata] + +[package.metadata.requires-dev] +dev = [{ name = "pytest", specifier = "==8.3.4" }] + +[[package]] +name = "sadt-testkit" +version = "0.1.0" +source = { editable = "../../../testkit" } + +[package.metadata] + +[package.metadata.requires-dev] +dev = [ + { name = "numpy", specifier = "==1.26.4" }, + { name = "pytest", specifier = "==8.3.4" }, +] + +[[package]] +name = "scipy" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/75/b4ce781849931fef6fd529afa6b63711d5a733065722d0c3e2724af9e40a/scipy-1.17.1-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:1f95b894f13729334fb990162e911c9e5dc1ab390c58aa6cbecb389c5b5e28ec", size = 31613675, upload-time = "2026-02-23T00:16:00.13Z" }, + { url = "https://files.pythonhosted.org/packages/f7/58/bccc2861b305abdd1b8663d6130c0b3d7cc22e8d86663edbc8401bfd40d4/scipy-1.17.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:e18f12c6b0bc5a592ed23d3f7b891f68fd7f8241d69b7883769eb5d5dfb52696", size = 28162057, upload-time = "2026-02-23T00:16:09.456Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ee/18146b7757ed4976276b9c9819108adbc73c5aad636e5353e20746b73069/scipy-1.17.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:a3472cfbca0a54177d0faa68f697d8ba4c80bbdc19908c3465556d9f7efce9ee", size = 20334032, upload-time = "2026-02-23T00:16:17.358Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e6/cef1cf3557f0c54954198554a10016b6a03b2ec9e22a4e1df734936bd99c/scipy-1.17.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:766e0dc5a616d026a3a1cffa379af959671729083882f50307e18175797b3dfd", size = 22709533, upload-time = "2026-02-23T00:16:25.791Z" }, + { url = "https://files.pythonhosted.org/packages/4d/60/8804678875fc59362b0fb759ab3ecce1f09c10a735680318ac30da8cd76b/scipy-1.17.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:744b2bf3640d907b79f3fd7874efe432d1cf171ee721243e350f55234b4cec4c", size = 33062057, upload-time = "2026-02-23T00:16:36.931Z" }, + { url = "https://files.pythonhosted.org/packages/09/7d/af933f0f6e0767995b4e2d705a0665e454d1c19402aa7e895de3951ebb04/scipy-1.17.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43af8d1f3bea642559019edfe64e9b11192a8978efbd1539d7bc2aaa23d92de4", size = 35349300, upload-time = "2026-02-23T00:16:49.108Z" }, + { url = "https://files.pythonhosted.org/packages/b4/3d/7ccbbdcbb54c8fdc20d3b6930137c782a163fa626f0aef920349873421ba/scipy-1.17.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd96a1898c0a47be4520327e01f874acfd61fb48a9420f8aa9f6483412ffa444", size = 35127333, upload-time = "2026-02-23T00:17:01.293Z" }, + { url = "https://files.pythonhosted.org/packages/e8/19/f926cb11c42b15ba08e3a71e376d816ac08614f769b4f47e06c3580c836a/scipy-1.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4eb6c25dd62ee8d5edf68a8e1c171dd71c292fdae95d8aeb3dd7d7de4c364082", size = 37741314, upload-time = "2026-02-23T00:17:12.576Z" }, + { url = "https://files.pythonhosted.org/packages/95/da/0d1df507cf574b3f224ccc3d45244c9a1d732c81dcb26b1e8a766ae271a8/scipy-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:d30e57c72013c2a4fe441c2fcb8e77b14e152ad48b5464858e07e2ad9fbfceff", size = 36607512, upload-time = "2026-02-23T00:17:23.424Z" }, + { url = "https://files.pythonhosted.org/packages/68/7f/bdd79ceaad24b671543ffe0ef61ed8e659440eb683b66f033454dcee90eb/scipy-1.17.1-cp311-cp311-win_arm64.whl", hash = "sha256:9ecb4efb1cd6e8c4afea0daa91a87fbddbce1b99d2895d151596716c0b2e859d", size = 24599248, upload-time = "2026-02-23T00:17:34.561Z" }, +] + +[[package]] +name = "setuptools" +version = "84.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/44/f5da03a8ef95d369145c5bb53050e7877c9f3d312e128605fd9504829143/setuptools-84.0.0.tar.gz", hash = "sha256:f4695c21257f0d9b537ec2692c941d02ee143b7cc1276941349a546573b2ef73", size = 1168449, upload-time = "2026-08-08T18:27:58.365Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/9c/c510029fc6ef33a6275cd2c5d3cecd6613dfd6aa401d57c54f1c18852ccf/setuptools-84.0.0-py3-none-any.whl", hash = "sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670", size = 818216, upload-time = "2026-08-08T18:27:56.719Z" }, +] + +[[package]] +name = "simpleitk" +version = "2.5.6" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/30/cb0eec647afea94c1d95bed3c0014a96565404e4225dba2c0c63bbdd6b9a/simpleitk-2.5.6-cp311-abi3-macosx_10_9_x86_64.whl", hash = "sha256:36658792fe2a62814cbfedb3b236ac722ca7e00953241726bc4a3a26cc7ef5a7", size = 42685058, upload-time = "2026-07-30T16:52:12.477Z" }, + { url = "https://files.pythonhosted.org/packages/68/e8/18d2351ef7b6a17c921f1fbac3a19dc3a27f6ea7749c2f5a5877e38202b9/simpleitk-2.5.6-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:afcda49474748548fa1b7dbb9f557c3b9feafeb0dd3c2e05d771cc935c86646e", size = 38252322, upload-time = "2026-07-30T16:52:16.719Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c3/9025397ec8638c261ba1fe56ffed06983df707a3bc961da5ef90157e5a25/simpleitk-2.5.6-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:af7ca101b23233745b813481ea91694ecea95c3e3b6eed8dca37be39a60f6894", size = 48070098, upload-time = "2026-07-30T16:52:21.316Z" }, + { url = "https://files.pythonhosted.org/packages/f4/ec/301532fb2003e6557e6a12106eb1df572ed6f74c08c05c2e7a8913353383/simpleitk-2.5.6-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:99242c333ed17138134e9749f3a484518f41baebab0fc0f0fe7c657ab8090c07", size = 52798369, upload-time = "2026-07-30T16:52:25.774Z" }, + { url = "https://files.pythonhosted.org/packages/16/d0/a746280d0987413e443c26f19fe5c559fb3034160ee0a7307ae2d85e8d59/simpleitk-2.5.6-cp311-abi3-win_amd64.whl", hash = "sha256:0002b298efb31332f99587cf26ed9f42c2c3ec006a0570f492dfc9ea27303d73", size = 18925827, upload-time = "2026-07-30T16:52:29.496Z" }, +] + +[[package]] +name = "sympy" +version = "1.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mpmath" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" }, +] + +[[package]] +name = "torch" +version = "2.8.0+cu128" +source = { registry = "https://download.pytorch.org/whl/cu128" } +dependencies = [ + { name = "filelock" }, + { name = "fsspec" }, + { name = "jinja2" }, + { name = "networkx" }, + { name = "nvidia-cublas-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cuda-cupti-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cuda-nvrtc-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cuda-runtime-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cudnn-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cufft-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cufile-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-curand-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cusolver-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cusparse-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cusparselt-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-nccl-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-nvjitlink-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-nvtx-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "sympy" }, + { name = "triton", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "typing-extensions" }, +] +wheels = [ + { url = "https://download-r2.pytorch.org/whl/cu128/torch-2.8.0%2Bcu128-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:039b9dcdd6bdbaa10a8a5cd6be22c4cb3e3589a341e5f904cbb571ca28f55bed", upload-time = "2025-10-01T23:49:06Z" }, + { url = "https://download-r2.pytorch.org/whl/cu128/torch-2.8.0%2Bcu128-cp311-cp311-win_amd64.whl", hash = "sha256:34c55443aafd31046a7963b63d30bc3b628ee4a704f826796c865fdfd05bb596", upload-time = "2025-10-01T23:49:30Z" }, +] + +[[package]] +name = "triton" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "setuptools" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/39/43325b3b651d50187e591eefa22e236b2981afcebaefd4f2fc0ea99df191/triton-3.4.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b70f5e6a41e52e48cfc087436c8a28c17ff98db369447bcaff3b887a3ab4467", size = 155531138, upload-time = "2025-07-30T19:58:29.908Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] diff --git a/tools/AREG/AREG_IOS/pyproject.toml b/tools/AREG/AREG_IOS/pyproject.toml new file mode 100644 index 0000000..4d390bd --- /dev/null +++ b/tools/AREG/AREG_IOS/pyproject.toml @@ -0,0 +1,61 @@ +[project] +name = "sadt-areg-ios" +version = "0.1.0" +description = "Register a follow-up intraoral scan onto its baseline by ICP on a stable patch." +requires-python = ">=3.11,<3.12" +# Aligned with upstream's shared shapeaxi env, where every pytorch3d-dependent +# module lives. Upstream leaves torch unpinned there; we are stricter, because +# the pytorch3d wheel tag (`+pt2110cu128`) names one torch version and one CUDA +# variant exactly -- the two move together or the C extension does not load. +dependencies = [ + "sadt-areg-common", + "torch==2.11.0", + # Transitive, listed because `tool.uv.sources` applies to DIRECT + # dependencies only. Left transitive it comes from PyPI, built against the + # default torch rather than the cu128 one: right version, wrong build, and + # it fails at runtime on `operator torchvision::nms does not exist`. + "torchvision==0.26.0", + "pytorch3d==0.7.9+pt2110cu128", + "monai==1.6.0", + "SimpleITK==2.5.6", + "numpy==2.3.2", + "vtk==9.6.2", +] + +[tool.sadt] +tool = true +name = "AREG_IOS" + +[dependency-groups] +dev = ["pytest==8.3.4", "sadt-testkit"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/sadt_areg_ios"] + +[[tool.uv.index]] +name = "pytorch-cu128" +url = "https://download.pytorch.org/whl/cu128" +explicit = true + +[[tool.uv.index]] +name = "pytorch3d-wheels" +url = "https://ImageMindAnalytics.github.io/pytorch3d-wheels/simple/" +explicit = true + +[tool.uv.sources] +sadt-areg-common = { path = "../common" } +sadt-testkit = { path = "../../../testkit", editable = true } +torch = { index = "pytorch-cu128" } +torchvision = { index = "pytorch-cu128" } +pytorch3d = { index = "pytorch3d-wheels" } + +[tool.pytest.ini_options] +testpaths = ["tests"] +markers = [ + "gpu: needs a CUDA device. Skipped in CI (`-m 'not gpu'`); run it by hand and report the result in the PR.", + "models: needs a real model bundle and is skipped without it. See tests/data/README.md.", +] diff --git a/tools/AREG/AREG_IOS/src/sadt_areg_ios/__init__.py b/tools/AREG/AREG_IOS/src/sadt_areg_ios/__init__.py index e69de29..e1654a6 100644 --- a/tools/AREG/AREG_IOS/src/sadt_areg_ios/__init__.py +++ b/tools/AREG/AREG_IOS/src/sadt_areg_ios/__init__.py @@ -0,0 +1,89 @@ +"""AREG_IOS -- register a follow-up intraoral scan onto its baseline. + +A patch of the arch that does not move with growth or treatment -- the palate, +or the band around the mucogingival line -- matched by ICP. + +Split out of the former single `AREG`. The reason is concrete: this engine +needs pytorch3d, which ships as a wheel built against one exact torch +(`+pt2110cu128`), so it is pinned to torch 2.11. The CBCT engine needs neither +and has no reason to move, and while the two shared a virtualenv neither could +be pinned without the other. They now share only `sadt_areg_common`, which has +no dependencies at all. + +The tooth labels and the mucogingival landmarks this needs come from other +tools reached through the supervisor; see `tools.py`. +""" + +from pathlib import Path +from typing import Literal + +from .dispatch import main + + +def run( + t1: Path, + t2: Path, + output_dir: Path, + automation: Literal["Semi-Automated", "Fully-Automated"] = "Fully-Automated", + reference: Path = "", + patch: Literal[ + "Palate (upper arch)", "Mucogingival line (lower arch)" + ] = "Palate (upper arch)", + registration_model: Path = "", + crown_model: Path = "", + mgl_model: Path = "", + mgl_landmarks: Path = "", + mgl_patch_height: float = 0.0, + output_suffix: str = "Reg", + *, + sup=None, +) -> Path: + """Register a follow-up intraoral scan onto its baseline, so the two compare. + + Args: + t1: The baseline meshes -- one surface or a folder of them, searched + recursively. Each must say its jaw in its name. + t2: The follow-up meshes, paired to T1 by patient key. + output_dir: Where the registered meshes, their transforms and + `AREG_report.json` are written. Nothing is written outside it. + automation: Semi-Automated takes meshes that already carry their tooth + labels and orientation; Fully-Automated labels and orients them + first, through the crown-segmentation and orientation tools. + reference: Fully-Automated only. The orientation reference. + patch: Which part of the arch to match on -- the palate for an upper + arch, the band around the mucogingival line for a lower one. + registration_model: The model that finds the palatal patch. Not used by + the mucogingival patch, which is built from landmarks and involves + no network at all. + crown_model: Fully-Automated only. The checkpoint the crown-labelling + tool runs with. A mesh that already carries its tooth-label array + needs none. + mgl_model: Mucogingival patch only. The landmark bundle the line is + predicted from, when no landmarks are sent. + mgl_landmarks: Mucogingival patch only. Your own 13 landmarks per lower + scan, instead of having them predicted. + mgl_patch_height: Mucogingival patch only. How far the band extends + from the line, in millimetres. 0 uses the engine's default. + output_suffix: Added to each output name, e.g. `scan_Reg.vtk`. + + Returns: + The output directory. + """ + # torch, pytorch3d and monai are imported inside the engine: CI imports this + # module on every PR to publish the schema, and that must not cost a CUDA + # stack. + return main( + t1=t1, + t2=t2, + output_dir=output_dir, + automation=automation, + ios_reference=reference, + ios_patch=patch, + registration_model=registration_model, + crown_model=crown_model, + mgl_model=mgl_model, + mgl_landmarks=mgl_landmarks, + mgl_patch_height=mgl_patch_height, + output_suffix=output_suffix, + sup=sup, + ) diff --git a/tools/AREG/AREG_IOS/src/sadt_areg_ios/butterfly.py b/tools/AREG/AREG_IOS/src/sadt_areg_ios/butterfly.py index 7429629..85a7b9f 100644 --- a/tools/AREG/AREG_IOS/src/sadt_areg_ios/butterfly.py +++ b/tools/AREG/AREG_IOS/src/sadt_areg_ios/butterfly.py @@ -30,7 +30,7 @@ import vtk from vtk.util.numpy_support import numpy_to_vtk, vtk_to_numpy -from ..errors import ToolInputError +from sadt_areg_common.errors import ToolInputError from . import net, orientation, postprocess, surfaces diff --git a/tools/AREG/src/sadt_areg/dispatch.py b/tools/AREG/AREG_IOS/src/sadt_areg_ios/dispatch.py similarity index 60% rename from tools/AREG/src/sadt_areg/dispatch.py rename to tools/AREG/AREG_IOS/src/sadt_areg_ios/dispatch.py index 708a911..d9af6ba 100644 --- a/tools/AREG/src/sadt_areg/dispatch.py +++ b/tools/AREG/AREG_IOS/src/sadt_areg_ios/dispatch.py @@ -29,12 +29,17 @@ import os import shutil -from .errors import ToolInputError +from sadt_areg_common.errors import ToolInputError -from . import catalogs, dicom, pairing, tools +from sadt_areg_common import catalogs, pairing +from . import tools logger = logging.getLogger(__name__) +# This tool IS the modality: it is no longer an argument, so the value the +# report carries and the automation table is keyed by is fixed here. +MODALITY = catalogs.MODALITY_IOS + REPORT_NAME = "AREG_report.json" # Intermediates live here, under the output directory the caller owns, and are @@ -65,236 +70,6 @@ def succeeded(self) -> list: return [key for key, entry in self.patients.items() if entry.get("status") == "ok"] -# --------------------------------------------------------------------------- -# The reusable API -# --------------------------------------------------------------------------- - -def register( - t1_path: str, - t2_path: str, - modality: str, - automation: str, - regions=None, - t1_masks_path: str = None, - segmentation_model: str = None, - segmentation_label: int = 0, - orientation_reference: str = None, - landmark_model: str = None, - registration_model: str = None, - crown_model: str = None, - mgl_model: str = None, - ios_patch: str = catalogs.PATCH_PALATE, - mgl_landmarks_path: str = None, - mgl_patch_height: float = None, - dicom_input: bool = False, - output_suffix: str = "Reg", - output_dir: str = None, - sup=None, -) -> RegistrationRun: - """Register every T2 under `t2_path` onto its T1 under `t1_path`. - - Each path is a directory or a `.zip`. `regions` are the display names - declared in `catalogs.REGION_CHOICES` (CBCT only). - """ - output_dir = os.path.abspath(output_dir) - os.makedirs(output_dir, exist_ok=True) - work_dir = os.path.join(output_dir, WORK_DIRNAME) - os.makedirs(work_dir, exist_ok=True) - - t1_root = _as_directory(t1_path, os.path.join(work_dir, "t1_input")) - t2_root = _as_directory(t2_path, os.path.join(work_dir, "t2_input")) - - report = { - "modality": modality, - "automation": automation, - "output_suffix": output_suffix, - "patients": {}, - } - - if modality == catalogs.MODALITY_CBCT: - _run_cbct( - t1_root=t1_root, - t2_root=t2_root, - t1_masks_path=t1_masks_path, - automation=automation, - regions=list(regions or ()), - segmentation_model=segmentation_model, - segmentation_label=int(segmentation_label or 0), - orientation_reference=orientation_reference, - dicom_input=dicom_input, - output_dir=output_dir, - work_dir=work_dir, - suffix=output_suffix, - report=report, - sup=sup, - landmark_model=landmark_model, - ) - else: - from .ios import mgl - - _run_ios( - t1_root=t1_root, - t2_root=t2_root, - automation=automation, - registration_model=registration_model, - crown_model=crown_model, - mgl_model=mgl_model, - orientation_reference=orientation_reference, - ios_patch=ios_patch, - mgl_landmarks_path=mgl_landmarks_path, - mgl_patch_height=( - mgl.DEFAULT_HEIGHT if mgl_patch_height is None else float(mgl_patch_height) - ), - output_dir=output_dir, - work_dir=work_dir, - suffix=output_suffix, - report=report, - sup=sup, - ) - - # Extracted inputs, converted DICOM, the oriented copies and whatever the - # tools it drove wrote. Removed whether or not the run succeeded, so what is - # left under output_dir is results and nothing else. - shutil.rmtree(work_dir, ignore_errors=True) - - _summarize(report) - with open(os.path.join(output_dir, REPORT_NAME), "w") as handle: - json.dump(report, handle, indent=2) - return RegistrationRun(output_dir, report) - - -# --------------------------------------------------------------------------- -# The schema adapter -# --------------------------------------------------------------------------- - -def main( - modality, - automation, - t1, - t2, - t1_masks=None, - cbct_regions=None, - segmentation_label=0, - segmentation_model=None, - cbct_reference=None, - landmark_model=None, - ios_reference=None, - registration_model=None, - crown_model=None, - mgl_model=None, - ios_patch=None, - mgl_landmarks=None, - mgl_patch_height=None, - dicom_input=False, - output_suffix="Reg", - output_dir=None, - sup=None, -) -> str: - """Translate the schema's arguments into `register()` and return its output - directory, which main.py zips and streams. - - Every cross-argument rule is checked HERE, before any file is read: a - request that cannot work must come back in a second, not after an hour of - registration. `require` in tools.py is part of that: a mode that needs - another tool fails at the door when there is no supervisor to reach it. - """ - modality, automation = str(modality), str(automation) - suffix = (output_suffix or "Reg").strip() or "Reg" - if os.sep in suffix or (os.altsep and os.altsep in suffix): - raise ToolInputError("'output_suffix' is a name fragment, not a path.") - - allowed = catalogs.AUTOMATION_BY_MODALITY.get(modality, ()) - if automation not in allowed: - raise ToolInputError( - f"'{automation}' is not a mode {modality} has. {modality} offers: " - f"{', '.join(allowed)}." - ) - - patch = str(ios_patch or catalogs.PATCH_PALATE) - if modality == catalogs.MODALITY_CBCT: - regions = _selected(cbct_regions, catalogs.REGION_CHOICES) - reference = cbct_reference - _check_cbct(automation, regions, t1_masks, reference, sup) - else: - regions = [] - reference = ios_reference - _check_ios(automation, patch, registration_model, reference, mgl_landmarks, - mgl_patch_height, sup) - - run = register( - t1_path=str(t1), - t2_path=str(t2), - modality=modality, - automation=automation, - regions=regions, - t1_masks_path=str(t1_masks) if t1_masks else None, - segmentation_model=str(segmentation_model) if segmentation_model else None, - segmentation_label=int(segmentation_label or 0), - orientation_reference=str(reference) if reference else None, - landmark_model=landmark_model, - registration_model=str(registration_model) if registration_model else None, - crown_model=str(crown_model) if crown_model else None, - mgl_model=str(mgl_model) if mgl_model else None, - ios_patch=patch, - mgl_landmarks_path=str(mgl_landmarks) if mgl_landmarks else None, - mgl_patch_height=mgl_patch_height, - dicom_input=bool(dicom_input), - output_suffix=suffix, - output_dir=output_dir, - sup=sup, - ) - - return run.output_dir - - -# --------------------------------------------------------------------------- -# Argument rules -# --------------------------------------------------------------------------- - -def _selected(value, choices: dict) -> list: - """The enabled options of a multichoice argument, in declaration order. - - Accepts the `Selection` validate() produces, a plain dict, or a sequence -- - so `register()` stays directly callable with `["Mandible"]`. - """ - if value is None: - return [name for name, on in choices.items() if on] - if isinstance(value, dict): - return [name for name in choices if value.get(name)] - wanted = set(value) - return [name for name in choices if name in wanted] - - -def _check_cbct(automation: str, regions: list, t1_masks, reference, sup=None) -> None: - if not regions: - raise ToolInputError( - "Select at least one anatomical region to register on in 'cbct_regions' " - f"({', '.join(catalogs.REGION_CHOICES)}). Each one is a separate " - "registration with its own output folder." - ) - - if automation == catalogs.AUTOMATION_SEMI: - if not t1_masks: - raise ToolInputError( - "Semi-Automated CBCT registers inside masks you provide: send the T1 " - "segmentations in 't1_masks', or use Fully-Automated mode to have " - "them produced server-side." - ) - return - - # Both automated modes need the segmentation; the oriented one also needs - # the orientation. Checked before the input is extracted -- with the tool - # absent, the answer is the same whatever the rest of the request says. - tools.require(sup, "AMASSS", f"{automation} CBCT registration") - if automation == catalogs.AUTOMATION_ORIENTED: - tools.require(sup, "ASO", "Oriented + Fully-Automated CBCT registration") - if not reference: - raise ToolInputError( - "Oriented + Fully-Automated CBCT orients the T1 scans before " - "registering onto them, which needs an orientation reference: name " - "one in 'cbct_reference' (see GET /tools/AREG/data)." - ) - def _check_ios(automation, patch, registration_model, reference, mgl_landmarks, height, sup=None) -> None: @@ -338,125 +113,6 @@ def _check_ios(automation, patch, registration_model, reference, mgl_landmarks, ) -# --------------------------------------------------------------------------- -# CBCT -# --------------------------------------------------------------------------- - -def _run_cbct( - t1_root, t2_root, t1_masks_path, automation, regions, segmentation_model, - segmentation_label, orientation_reference, dicom_input, output_dir, work_dir, - suffix, report, sup=None, landmark_model=None, -) -> None: - # Imported here rather than at module level: the CBCT engine pulls in - # SimpleITK and itk-elastix, and AREG must load on a server without them so - # its schema is still published and its IOS mode still runs. - from .cbct import elastix - from .cbct import pipeline as cbct_pipeline - - elastix.check_dependencies() - - if dicom_input: - t1_root = dicom.convert_tree(t1_root, os.path.join(work_dir, "dicom_t1")) - t2_root = dicom.convert_tree(t2_root, os.path.join(work_dir, "dicom_t2")) - - codes = [catalogs.region_code(name) for name in regions] - report["regions"] = list(regions) - report["segmentation_label"] = segmentation_label or None - - # Step 1 -- orient the T1 scans, when the mode asks for it. The T2 is NOT - # oriented: it is about to be resampled into the T1's frame anyway, and - # orienting it first would be one more interpolation of the same data. - if automation == catalogs.AUTOMATION_ORIENTED: - oriented = tools.orient_scans( - sup, - t1_root, orientation_reference, catalogs.MODALITY_CBCT, - landmark_model=landmark_model or "", - ) - report["oriented_t1"] = True - t1_root = oriented - - # Step 2 -- the masks the registration is confined to. - mask_roots = [] - if t1_masks_path: - mask_roots.append(_as_directory(t1_masks_path, os.path.join(work_dir, "masks_input"))) - if automation == catalogs.AUTOMATION_SEMI: - # Where the original looked when no mask folder was given. - mask_roots.append(t1_root) - else: - structures = [catalogs.REGION_MASK_STRUCTURES[code] for code in codes] - mask_roots.append( - tools.segment_masks(sup, t1_root, segmentation_model, structures) - ) - report["segmented_t1"] = sorted(structures) - - # Step 3 -- pair the timepoints, then register once per region. - matched = pairing.pair(t1_root, t2_root, suffix) - report["unmatched"] = matched.unmatched_report() - if not matched: - raise ToolInputError( - "No subject appears in both the T1 and the T2 folder. They are paired by " - "name, up to the timepoint token and a trailing " - f"{', '.join(catalogs.PATIENT_SUFFIXES[:4])}... -- so 'P1_T1_scan.nii.gz' " - f"in one folder pairs with 'P1_T2.nii.gz' in the other. Found " - f"{len(matched.t1_only)} T1-only and {len(matched.t2_only)} T2-only subject(s)." - ) - - for code in codes: - masks = cbct_pipeline.find_masks(mask_roots, code, scan_keys=matched.matched) - for key, entry in sorted(matched.matched.items()): - record = report["patients"].setdefault(key, {"status": "ok", "regions": {}}) - mask_path = masks.get(key) - if not mask_path: - record["regions"][code] = { - "status": "failed", - "reason": _no_mask_reason(automation, code), - } - continue - try: - record["regions"][code] = cbct_pipeline.register_patient( - t1_path=entry["t1"], - t2_path=entry["t2"], - mask_path=mask_path, - region=code, - output_dir=output_dir, - relative_key=key, - suffix=suffix, - segmentation_label=segmentation_label or None, - ) - except elastix.RegistrationError as exc: - record["regions"][code] = {"status": "failed", "reason": str(exc)} - except RuntimeError as exc: - record["regions"][code] = {"status": "failed", "reason": f"registration failed: {exc}"} - - _roll_up_regions(report["patients"]) - - -def _no_mask_reason(automation: str, code: str) -> str: - region = catalogs.region_name(code) - if automation == catalogs.AUTOMATION_SEMI: - return ( - f"no {region} mask for this subject. A mask is matched to its scan by name " - f"and has to say both that it is a segmentation (mask/seg/pred) and which " - f"structure it covers ({'/'.join(catalogs.REGION_TOKENS[code][:2])}) -- " - f"e.g. 'P1_T1_{code}_seg.nii.gz' next to 'P1_T1_scan.nii.gz'" - ) - return ( - f"the segmentation step produced no {region} mask for this subject -- see the " - f"AMASSS report if one was included in this archive" - ) - - -def _roll_up_regions(patients: dict) -> None: - """A patient is 'ok' when at least one of its regions registered.""" - for entry in patients.values(): - statuses = [region.get("status") for region in entry["regions"].values()] - entry["status"] = "ok" if "ok" in statuses else "failed" - - -# --------------------------------------------------------------------------- -# IOS -# --------------------------------------------------------------------------- - def _run_ios( t1_root, t2_root, automation, registration_model, crown_model, mgl_model, orientation_reference, ios_patch, mgl_landmarks_path, mgl_patch_height, @@ -466,9 +122,9 @@ def _run_ios( # monai and pytorch3d, and AREG must load (and register CBCT scans) on a # server without them. from . import landmarks as landmark_files - from .ios import butterfly, icp, mgl, net - from .ios import pipeline as ios_pipeline - from .ios import surfaces + from . import butterfly, icp, mgl, net + from . import pipeline as ios_pipeline + from . import surfaces registered_jaw = catalogs.PATCH_JAW[ios_patch] on_palate = ios_patch == catalogs.PATCH_PALATE @@ -576,7 +232,7 @@ def _collect_transforms(oriented_root: str, suffix: str) -> dict: sent rather than to the oriented copy AREG made -- see `ios.icp.write_transform`. """ - from .ios import surfaces # for the jaw vocabulary only + from . import surfaces # for the jaw vocabulary only found: dict = {} for directory, _, file_names in os.walk(oriented_root): @@ -594,9 +250,19 @@ def _collect_transforms(oriented_root: str, suffix: str) -> dict: return found -# --------------------------------------------------------------------------- -# Input handling -# --------------------------------------------------------------------------- +def _selected(value, choices: dict) -> list: + """The enabled options of a multichoice argument, in declaration order. + + Accepts the `Selection` validate() produces, a plain dict, or a sequence -- + so `register()` stays directly callable with `["Mandible"]`. + """ + if value is None: + return [name for name, on in choices.items() if on] + if isinstance(value, dict): + return [name for name in choices if value.get(name)] + wanted = set(value) + return [name for name in choices if name in wanted] + def _merge_into(source: str, destination: str) -> None: """Copy every file of `source` under `destination`, keeping its tree. @@ -648,3 +314,130 @@ def _summarize(report: dict) -> None: report["summary"]["registered"], report["summary"]["patients"], ) + + +def register( + t1_path: str, + t2_path: str, + automation: str, + orientation_reference: str = None, + registration_model: str = None, + crown_model: str = None, + mgl_model: str = None, + ios_patch: str = catalogs.PATCH_PALATE, + mgl_landmarks_path: str = None, + mgl_patch_height: float = None, + output_suffix: str = "Reg", + output_dir: str = None, + sup=None, +) -> RegistrationRun: + """Register every T2 under `t2_path` onto its T1 under `t1_path`. + + Each path is a directory or a `.zip`. + declared in `catalogs.REGION_CHOICES` (CBCT only). + """ + output_dir = os.path.abspath(output_dir) + os.makedirs(output_dir, exist_ok=True) + work_dir = os.path.join(output_dir, WORK_DIRNAME) + os.makedirs(work_dir, exist_ok=True) + + t1_root = _as_directory(t1_path, os.path.join(work_dir, "t1_input")) + t2_root = _as_directory(t2_path, os.path.join(work_dir, "t2_input")) + + report = { + "modality": MODALITY, + "automation": automation, + "output_suffix": output_suffix, + "patients": {}, + } + + from . import mgl + + _run_ios( + t1_root=t1_root, + t2_root=t2_root, + automation=automation, + registration_model=registration_model, + crown_model=crown_model, + mgl_model=mgl_model, + orientation_reference=orientation_reference, + ios_patch=ios_patch, + mgl_landmarks_path=mgl_landmarks_path, + mgl_patch_height=( + mgl.DEFAULT_HEIGHT if mgl_patch_height is None else float(mgl_patch_height) + ), + output_dir=output_dir, + work_dir=work_dir, + suffix=output_suffix, + report=report, + sup=sup, + ) + + # Extracted inputs, converted DICOM, the oriented copies and whatever the + # tools it drove wrote. Removed whether or not the run succeeded, so what is + # left under output_dir is results and nothing else. + shutil.rmtree(work_dir, ignore_errors=True) + + _summarize(report) + with open(os.path.join(output_dir, REPORT_NAME), "w") as handle: + json.dump(report, handle, indent=2) + return RegistrationRun(output_dir, report) + + +def main( + automation, + t1, + t2, + ios_reference=None, + registration_model=None, + crown_model=None, + mgl_model=None, + ios_patch=None, + mgl_landmarks=None, + mgl_patch_height=None, + output_suffix="Reg", + output_dir=None, + sup=None, +) -> str: + """Translate the schema's arguments into `register()` and return its output + directory, which main.py zips and streams. + + Every cross-argument rule is checked HERE, before any file is read: a + request that cannot work must come back in a second, not after an hour of + registration. `require` in tools.py is part of that: a mode that needs + another tool fails at the door when there is no supervisor to reach it. + """ + automation = str(automation) + suffix = (output_suffix or "Reg").strip() or "Reg" + if os.sep in suffix or (os.altsep and os.altsep in suffix): + raise ToolInputError("'output_suffix' is a name fragment, not a path.") + + allowed = catalogs.AUTOMATION_BY_MODALITY.get(MODALITY, ()) + if automation not in allowed: + raise ToolInputError( + f"'{automation}' is not a mode {MODALITY} has. {MODALITY} offers: " + f"{', '.join(allowed)}." + ) + + patch = str(ios_patch or catalogs.PATCH_PALATE) + reference = ios_reference + _check_ios(automation, patch, registration_model, reference, + mgl_landmarks, mgl_patch_height, sup) + + run = register( + t1_path=str(t1), + t2_path=str(t2), + automation=automation, + orientation_reference=str(reference) if reference else None, + registration_model=str(registration_model) if registration_model else None, + crown_model=str(crown_model) if crown_model else None, + mgl_model=str(mgl_model) if mgl_model else None, + ios_patch=patch, + mgl_landmarks_path=str(mgl_landmarks) if mgl_landmarks else None, + mgl_patch_height=mgl_patch_height, + output_suffix=suffix, + output_dir=output_dir, + sup=sup, + ) + + return run.output_dir diff --git a/tools/AREG/AREG_IOS/src/sadt_areg_ios/landmarks.py b/tools/AREG/AREG_IOS/src/sadt_areg_ios/landmarks.py index 754aafc..310c75e 100644 --- a/tools/AREG/AREG_IOS/src/sadt_areg_ios/landmarks.py +++ b/tools/AREG/AREG_IOS/src/sadt_areg_ios/landmarks.py @@ -23,7 +23,7 @@ import numpy as np -from . import pairing +from sadt_areg_common import pairing MARKUPS_EXTENSIONS = (".mrk.json", ".json") diff --git a/tools/AREG/AREG_IOS/src/sadt_areg_ios/layout.py b/tools/AREG/AREG_IOS/src/sadt_areg_ios/layout.py new file mode 100644 index 0000000..2286335 --- /dev/null +++ b/tools/AREG/AREG_IOS/src/sadt_areg_ios/layout.py @@ -0,0 +1,51 @@ +"""How a client should lay this tool's panel out. Presentation only. + +Short, because this tool's schema is short. Before the split it shared a panel +with the CBCT arguments and needed a `modality` condition on every field; now +the only arguments published are the ones an intraoral run reads. +""" + +from sadt_areg_common import catalogs + +_INPUTS = "Inputs" +_REGISTRATION = "Registration" +_OUTPUTS = "Outputs" + +# The patch decides the rest of the panel: the mucogingival band is built from +# landmarks and involves no network at all, while the palate is predicted and +# involves nothing else. Asking for a checkpoint on the MGL side is how a user +# comes to believe that mode needs one. Listed rather than negated: +# `visible_when` compares, so "every patch but MGL" is written by naming them. +_MGL = {"patch": catalogs.PATCH_MGL} +_PREDICTED = {"patch": [p for p in catalogs.PATCH_CHOICES if p != catalogs.PATCH_MGL]} +# Only the fully-automated mode labels and orients the meshes itself; the +# semi-automated one takes meshes that already carry both. +_FULLY = {"automation": catalogs.AUTOMATION_FULLY} + +LAYOUT = { + "t1": {"section": _INPUTS, "label": "T1 (baseline)"}, + "t2": {"section": _INPUTS, "label": "T2 (follow-up)"}, + "automation": {"section": _INPUTS, "label": "Mode"}, + + "patch": {"section": _REGISTRATION, "label": "Registration patch"}, + "reference": { + "section": _REGISTRATION, "label": "Orientation reference", "visible_when": _FULLY, + }, + "registration_model": { + "section": _REGISTRATION, "label": "Patch model", "visible_when": _PREDICTED, + }, + "crown_model": { + "section": _REGISTRATION, "label": "Crown segmentation model", "visible_when": _FULLY, + }, + "mgl_model": { + "section": _REGISTRATION, "label": "Mucogingival landmark bundle", "visible_when": _MGL, + }, + "mgl_landmarks": { + "section": _REGISTRATION, "label": "Mucogingival landmarks", "visible_when": _MGL, + }, + "mgl_patch_height": { + "section": _REGISTRATION, "label": "Patch height (mm)", "visible_when": _MGL, + }, + + "output_suffix": {"section": _OUTPUTS, "label": "Output suffix"}, +} diff --git a/tools/AREG/AREG_IOS/src/sadt_areg_ios/net.py b/tools/AREG/AREG_IOS/src/sadt_areg_ios/net.py index 601a1b9..a740ee4 100644 --- a/tools/AREG/AREG_IOS/src/sadt_areg_ios/net.py +++ b/tools/AREG/AREG_IOS/src/sadt_areg_ios/net.py @@ -21,7 +21,7 @@ import logging -from ..errors import ToolUnavailableError +from sadt_areg_common.errors import ToolUnavailableError logger = logging.getLogger(__name__) diff --git a/tools/AREG/AREG_IOS/src/sadt_areg_ios/pipeline.py b/tools/AREG/AREG_IOS/src/sadt_areg_ios/pipeline.py index 5efadb3..7036685 100644 --- a/tools/AREG/AREG_IOS/src/sadt_areg_ios/pipeline.py +++ b/tools/AREG/AREG_IOS/src/sadt_areg_ios/pipeline.py @@ -28,7 +28,8 @@ import logging import os -from .. import catalogs, landmarks as landmark_files, pairing +from sadt_areg_common import catalogs, pairing +from . import landmarks as landmark_files from . import butterfly, icp, mgl, surfaces logger = logging.getLogger(__name__) diff --git a/tools/AREG/AREG_IOS/src/sadt_areg_ios/surfaces.py b/tools/AREG/AREG_IOS/src/sadt_areg_ios/surfaces.py index 58b33aa..a17b234 100644 --- a/tools/AREG/AREG_IOS/src/sadt_areg_ios/surfaces.py +++ b/tools/AREG/AREG_IOS/src/sadt_areg_ios/surfaces.py @@ -30,7 +30,7 @@ import vtk from vtk.util.numpy_support import vtk_to_numpy -from .. import catalogs +from sadt_areg_common import catalogs SURFACE_EXTENSIONS = (".vtk", ".vtp", ".stl", ".obj") diff --git a/tools/AREG/AREG_IOS/src/sadt_areg_ios/tools.py b/tools/AREG/AREG_IOS/src/sadt_areg_ios/tools.py index 28e1a9b..52cdf85 100644 --- a/tools/AREG/AREG_IOS/src/sadt_areg_ios/tools.py +++ b/tools/AREG/AREG_IOS/src/sadt_areg_ios/tools.py @@ -28,7 +28,7 @@ import logging import os -from .errors import SupervisorRequired +from sadt_areg_common.errors import SupervisorRequired logger = logging.getLogger(__name__) @@ -36,10 +36,6 @@ # advice is the useful half: a caller who cannot run AMASSS can still send their # own masks, and saying so beats naming a deployment problem they cannot fix. _ADVICE = { - "AMASSS": ( - "Send your own T1 segmentation masks in 't1_masks' and use Semi-Automated " - "mode instead." - ), "ASO": ( "Orient the T1 and T2 scans yourself beforehand, and use the mode that takes " "them already oriented." @@ -54,6 +50,7 @@ } + def require(sup, tool: str, mode: str) -> None: """Refuse a mode that needs `tool` when there is no way to run it. @@ -82,38 +79,6 @@ def _returned(produced) -> str: return str(produced) -# --------------------------------------------------------------------------- -# The four calls -# --------------------------------------------------------------------------- - -def segment_masks(sup, scan_dir: str, model_path: str, mask_structures) -> str: - """Segment every scan under `scan_dir` into the requested mask structures. - - Returns the directory holding AMASSS's output, which `cbct.pipeline.find_masks` - then reads exactly as it reads a mask folder the caller sent -- the automated - and semi-automated paths differ only in where the masks came from. - - `mask_structures` are AMASSS structure codes (CBMASK/MANDMASK/MAXMASK), and - the packaged tool takes codes directly. The in-process version had to - translate them into display names through AMASSS's own table; the schema - publishes the codes now, so the translation is gone rather than restated. - """ - logger.info("AREG: asking 'AMASSS' for T1 masks (%s)", ", ".join(mask_structures)) - return _returned(sup.run( - "AMASSS", - scans=scan_dir, - model=model_path, - output_dir=_output(sup, "AMASSS"), - structures=list(mask_structures), - # One binary file per structure: `find_masks` looks each region's mask - # up by name, and a merged multi-label volume would make every region - # resolve to the same file. - merge=["SEPARATE"], - prediction_ID="seg", - generate_surface=False, - )) - - def orient_scans(sup, scan_dir: str, reference_path: str, modality: str, landmark_model: str = "", **extra) -> str: """Orient every case under `scan_dir` onto `reference_path`. diff --git a/tools/AREG/uv.lock b/tools/AREG/AREG_IOS/uv.lock similarity index 67% rename from tools/AREG/uv.lock rename to tools/AREG/AREG_IOS/uv.lock index 021c9f6..bc4d7a3 100644 --- a/tools/AREG/uv.lock +++ b/tools/AREG/AREG_IOS/uv.lock @@ -39,28 +39,75 @@ wheels = [ ] [[package]] -name = "cycler" -version = "0.12.1" +name = "cuda-bindings" +version = "12.9.7" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615, upload-time = "2023-10-07T05:32:18.335Z" } +dependencies = [ + { name = "cuda-pathfinder" }, +] wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, + { url = "https://files.pythonhosted.org/packages/40/f3/f9d1095f90d2a4df24cfcafe7487fd9444c6dacb94e3722be6fedd8ac26c/cuda_bindings-12.9.7-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16043ef5b15ab88fe9954c5c2061b1d8007591b27f2c916331056de0ebc6187e", size = 7114834, upload-time = "2026-05-27T18:44:07.746Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8a/1251e1794b69865aacd5629936006b18ea0816a495de4ecea9a825556eb3/cuda_bindings-12.9.7-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6496a88d84b1209d6651b0370c19c26319e157c22f6d018bf9a358cd8049041", size = 7647147, upload-time = "2026-05-27T18:44:09.4Z" }, ] [[package]] -name = "dicom2nifti" -version = "2.6.2" +name = "cuda-pathfinder" +version = "1.6.1" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "nibabel" }, - { name = "numpy" }, - { name = "pydicom" }, - { name = "python-gdcm" }, - { name = "scipy" }, +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/1e/2942a0d6d52240d439f44384a40c65d359d9ca70325b65b265c020113121/cuda_pathfinder-1.6.1-py3-none-any.whl", hash = "sha256:cc8ec4cb0881fa5bcbf96b6dd75d50e55352b74dd33d4f3d884e192e7c2b6ef8", size = 60238, upload-time = "2026-08-18T02:57:50.087Z" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/26/3a/88d1339ccdf51d2547d4b4b9f511d8a80be989961878b3773fd2a4396c88/dicom2nifti-2.6.2.tar.gz", hash = "sha256:2a421efeacf1616a932f41047e588477b5de72fd2d90aa15b49dcc8f1e4ea544", size = 43193, upload-time = "2025-06-23T06:39:50.267Z" } + +[[package]] +name = "cuda-toolkit" +version = "12.8.1" +source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ab/59/598237cb4a5b1fae3515ad8b402ae815a5551e236e530ba11b7a17b2c036/dicom2nifti-2.6.2-py3-none-any.whl", hash = "sha256:d2a328ac534cd424236660d3df1ad01c6fbd71587c620e93041d9ff07dceef4b", size = 43717, upload-time = "2025-06-23T06:39:48.871Z" }, + { url = "https://files.pythonhosted.org/packages/d4/c8/7dce3a0b15b42a3b58e7d96eb22a687d3bf2c44e01d149a6874629cd9938/cuda_toolkit-12.8.1-py2.py3-none-any.whl", hash = "sha256:adc7906af4ecbf9a352f9dca5734eceb21daec281ccfcf5675e1d2f724fc2cba", size = 2283, upload-time = "2025-08-13T02:03:07.842Z" }, +] + +[package.optional-dependencies] +cublas = [ + { name = "nvidia-cublas-cu12" }, +] +cudart = [ + { name = "nvidia-cuda-runtime-cu12" }, +] +cufft = [ + { name = "nvidia-cufft-cu12" }, +] +cufile = [ + { name = "nvidia-cufile-cu12" }, +] +cupti = [ + { name = "nvidia-cuda-cupti-cu12" }, +] +curand = [ + { name = "nvidia-curand-cu12" }, +] +cusolver = [ + { name = "nvidia-cusolver-cu12" }, +] +cusparse = [ + { name = "nvidia-cusparse-cu12" }, +] +nvjitlink = [ + { name = "nvidia-nvjitlink-cu12" }, +] +nvrtc = [ + { name = "nvidia-cuda-nvrtc-cu12" }, +] +nvtx = [ + { name = "nvidia-nvtx-cu12" }, +] + +[[package]] +name = "cycler" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615, upload-time = "2023-10-07T05:32:18.335Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, ] [[package]] @@ -98,15 +145,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fd/3c/6a2bf344106328fd04963664a60b9bb6496fc25df8e962fcdc1367285fb9/fsspec-2026.7.0-py3-none-any.whl", hash = "sha256:b57ddbafedfaef7018c1ecab32aa200a9d7ca26b77965f64e48b70061249d279", size = 206583, upload-time = "2026-07-28T16:34:49.538Z" }, ] -[[package]] -name = "importlib-resources" -version = "7.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e4/06/b56dfa750b44e86157093bc8fca0ab81dccbf5260510de4eaf1cb69b5b99/importlib_resources-7.1.0.tar.gz", hash = "sha256:0722d4c6212489c530f2a145a34c0a7a3b4721bc96a15fada5930e2a0b760708", size = 44985, upload-time = "2026-04-12T16:36:09.232Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8a/db/55a262f3606bebcae07cc14095338471ad7c0bbcaa37707e6f0ee49725b7/importlib_resources-7.1.0-py3-none-any.whl", hash = "sha256:1bd7b48b4088eddb2cd16382150bb515af0bd2c70128194392725f82ad2c96a1", size = 37232, upload-time = "2026-04-12T16:36:08.219Z" }, -] - [[package]] name = "iniconfig" version = "2.3.0" @@ -127,139 +165,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/72/73/b3d451dfc523756cf177d3ebb0af76dc7751b341c60e2a21871be400ae29/iopath-0.1.10.tar.gz", hash = "sha256:3311c16a4d9137223e20f141655759933e1eda24f8bff166af834af3c645ef01", size = 42226, upload-time = "2022-07-09T19:00:50.866Z" } -[[package]] -name = "itk" -version = "5.4.7" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "itk-core" }, - { name = "itk-filtering" }, - { name = "itk-io" }, - { name = "itk-numerics" }, - { name = "itk-registration" }, - { name = "itk-segmentation" }, - { name = "numpy" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/13/bf/7202a7b3caca14237035b588a614c364a2a0d815692f0b918bdb1a2e9957/itk-5.4.7-cp311-abi3-macosx_10_9_x86_64.whl", hash = "sha256:d4c3311b3294697adef1785867f0c60f2314bc2ce202ff528dfab38f2b0a4e5b", size = 16784, upload-time = "2026-08-07T00:31:18.706Z" }, - { url = "https://files.pythonhosted.org/packages/d0/65/41a94f4feefe21c7fd42604b35940c8c2a139fcb92f5c80dcc3fcb7fb07e/itk-5.4.7-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:c7c254419071b178bdf3b50b5542aaef5de3b54bc524b1f6fea72c62f531e590", size = 16785, upload-time = "2026-08-07T00:31:19.629Z" }, - { url = "https://files.pythonhosted.org/packages/cb/83/d29f05796ccc6abc05b0780f57234b0d984bc923285c2c2e959ba87589e1/itk-5.4.7-cp311-abi3-manylinux2014_x86_64.whl", hash = "sha256:9297419ccac8f0fd455ce08f4ba0580632f7be43c28c02ad0cbdeb19cd0c211e", size = 16795, upload-time = "2026-08-07T00:31:20.425Z" }, - { url = "https://files.pythonhosted.org/packages/4a/b4/e2e0611a677cc4682807a7bc48dd67369ff9d0a6218d11c217c110062658/itk-5.4.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f2ae79ad0a63ab4ad61fa630e8564ab5bac4fea1c4aad4db2f52bac4f460dffc", size = 16797, upload-time = "2026-08-07T00:31:21.211Z" }, - { url = "https://files.pythonhosted.org/packages/07/a7/98fcd0046d3249de328395a96b9a0428c11813eebe631e6a1299a9bafe85/itk-5.4.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:d74574c8a0196e82788e6f9991ee05ed5935d2d42e2209ef540fce9274cf2eb4", size = 16796, upload-time = "2026-08-07T00:31:22.13Z" }, - { url = "https://files.pythonhosted.org/packages/4b/48/1989c170cb5a74d06c0eb9fcc47812b0ba013cb73b514ab8892ddcc3b0f4/itk-5.4.7-cp311-abi3-win_amd64.whl", hash = "sha256:56ca4981924ed2ba30503c319dcdb63981504c0927aa68afd33468f33c44ecb3", size = 16780, upload-time = "2026-08-07T00:31:22.909Z" }, -] - -[[package]] -name = "itk-core" -version = "5.4.7" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/78/28/114f2b0da5f846939756db0e4bda79ec951597dd45c9b234d6ae9f20a65d/itk_core-5.4.7-cp311-abi3-macosx_10_9_x86_64.whl", hash = "sha256:7e6e7f42f7c7017c19c0462080909ebb3e39ffefc131f4048aa8f749812cc112", size = 71065728, upload-time = "2026-08-07T00:31:50.504Z" }, - { url = "https://files.pythonhosted.org/packages/3e/ad/977096c45990de9fd9e7406c9dfac7f009e8dc51c5abb26ceb82530e0923/itk_core-5.4.7-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:648749d347b9d0673e87edbe934bc32e93c9cd82ee1c5546291a9eca921b9186", size = 60240109, upload-time = "2026-08-07T00:31:53.786Z" }, - { url = "https://files.pythonhosted.org/packages/09/d0/aa498f30e459828fa4a23dec423dbbfd32adef60d0df8eacc53ac87a1fb3/itk_core-5.4.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8b8c42cfd86d0be761b4a9e8b13e72d14f4e7ec8eff96fbd7b775f42f8daed2a", size = 83571354, upload-time = "2026-08-07T00:31:57.169Z" }, - { url = "https://files.pythonhosted.org/packages/8c/91/7b3e868c4e6d9d6538e3425b41ceacb9de25d21ea3c0c4ab2d4b9259bff8/itk_core-5.4.7-cp311-abi3-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d16110e8fc530a0eb53c6b967ec05310bb6ef909fed1cedb5988b9231d5e3e3f", size = 73324257, upload-time = "2026-08-07T00:32:00.735Z" }, - { url = "https://files.pythonhosted.org/packages/d0/58/f8f997384254b39d369ce63d369d9cd262b411d32d901e4aa0d39de41c91/itk_core-5.4.7-cp311-abi3-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a48dd8ab99de8d4a758932c1923ef05bbc148d0aba6f07425c2fc69c5c323787", size = 81457408, upload-time = "2026-08-07T00:32:04.216Z" }, - { url = "https://files.pythonhosted.org/packages/0e/94/438dff9330683fcc529c2b4ccfe0b3f90b4e7f53c4997e7a5a08aad80a7f/itk_core-5.4.7-cp311-abi3-win_amd64.whl", hash = "sha256:c16055aa7f7c528d0e474987c54a440b12009e5d72ae313719ca8bc3f36f7678", size = 37568287, upload-time = "2026-08-07T00:32:09.458Z" }, -] - -[[package]] -name = "itk-elastix" -version = "0.23.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "itk" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/e1/ca/f91ca4c037fac506f45270c3d09aa96ce0feffc0f6ef418cae14d8e7c366/itk_elastix-0.23.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:baaf61a1adcaf2225ccfc2a40a259b82005b37541ec7000070b9c35fdf6ccc8f", size = 13215551, upload-time = "2025-04-15T10:54:10.147Z" }, - { url = "https://files.pythonhosted.org/packages/a1/07/218fa776e4ffa6083cce8acd584c20f01e3a2acff5e0946024039b8bad44/itk_elastix-0.23.0-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b529f17f8f97c22575dbe9ad5987b2bba47999bbfe8ea391dace018e351d2164", size = 21150061, upload-time = "2025-04-15T10:54:12.557Z" }, - { url = "https://files.pythonhosted.org/packages/54/19/5f8c9ebd49b7cb00bea7012c5469cdf885af232d37777947614a596ebdd5/itk_elastix-0.23.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:b37a5a767655089ae6d941ec223e39c96dfbe79a790fdfe8045f91d81cf17244", size = 19548452, upload-time = "2025-04-15T10:54:15.019Z" }, - { url = "https://files.pythonhosted.org/packages/c5/46/a8aab730d8d75ccaf155be543e2e314fcf87fc6272f5abe19f1565f78a20/itk_elastix-0.23.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:9b1befecc1d3d8c911913a9eecf946cb977abcc9558bd2e51ae7095e636dcb0c", size = 21382392, upload-time = "2025-04-15T10:54:17.139Z" }, - { url = "https://files.pythonhosted.org/packages/b6/79/7937004d1a2a875bb9360280d5b6922b28af8e671904cd8d94c8e679f374/itk_elastix-0.23.0-cp311-abi3-win_amd64.whl", hash = "sha256:b96120d7402e3550db2a686f3af21034bb60ea67df45185e5f6021b9491ed60d", size = 7749495, upload-time = "2025-04-15T10:54:19.148Z" }, -] - -[[package]] -name = "itk-filtering" -version = "5.4.7" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "itk-numerics" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/c8/a2/af38f70252ab8b310727d6cb8f4271ffe08e7c92c0194b7af1531c9ccf87/itk_filtering-5.4.7-cp311-abi3-macosx_10_9_x86_64.whl", hash = "sha256:3141e34cbbcb0de3c97d5b80919f05a87459943779888da93569c015c5fd57f9", size = 46751640, upload-time = "2026-08-07T00:32:50.524Z" }, - { url = "https://files.pythonhosted.org/packages/20/ee/d6b45ac88e554f31cfeeb30b457680991948f6a720dd9e6785c2b1bfd18f/itk_filtering-5.4.7-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:4039494c48938d20ed7ce2a0c5680902b7160af1e5f8d4c511be5b8507404b8e", size = 38992990, upload-time = "2026-08-07T00:32:53.341Z" }, - { url = "https://files.pythonhosted.org/packages/6a/28/c09c756e80d4c2ca9cb6ba5899116719a4167001e8b5ee252ca3c776fcfe/itk_filtering-5.4.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3c29348abc0a24d668818c0651702e11c69a946053511baf588713ec5f60fdfa", size = 69480739, upload-time = "2026-08-07T00:32:56.85Z" }, - { url = "https://files.pythonhosted.org/packages/88/d4/c7afce042901422cf87930cc591ff898a6b9221c676b06c6e5c96ea18182/itk_filtering-5.4.7-cp311-abi3-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ced346599ebf3f0437263c167099615304519f279e01036fa428919df070cea8", size = 63912362, upload-time = "2026-08-07T00:33:00.042Z" }, - { url = "https://files.pythonhosted.org/packages/5c/76/bac5e891715e47ffc415a5010aaf1282a78142c466762b2da5a9162ac679/itk_filtering-5.4.7-cp311-abi3-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5b88431bc519c1f5c3988e53a70ca16767e1e6333a26ef3bc12b35ab4721496", size = 67828457, upload-time = "2026-08-07T00:33:03.058Z" }, - { url = "https://files.pythonhosted.org/packages/39/38/c55a9d5938f137481be657b77cd31f7080363a60c176df4568199b666fd0/itk_filtering-5.4.7-cp311-abi3-win_amd64.whl", hash = "sha256:00a4a98a877ab7ee55610b18b912720f5cbfed90581d059e16a5cc8df4c4b0b8", size = 23571185, upload-time = "2026-08-07T00:33:05.587Z" }, -] - -[[package]] -name = "itk-io" -version = "5.4.7" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "itk-core" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/e2/be/fe6d74ee8c778df437b60e6ca2ef436db46b3356ecb59a4608a33a67b18e/itk_io-5.4.7-cp311-abi3-macosx_10_9_x86_64.whl", hash = "sha256:3de7b45085dbd25281a590ebf9c6b1dafbaeff080eceabc8eb0d7fb3c5394876", size = 22352038, upload-time = "2026-08-07T00:33:43.103Z" }, - { url = "https://files.pythonhosted.org/packages/eb/a9/10e4bb2d9e9cfb558de75a71e9f634f109de4e078086d8622eda61d9e5cc/itk_io-5.4.7-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:d158bb1cd9746238a11717ec63a05006b5dfd14c4699a0edc2ab42c02c492532", size = 17788177, upload-time = "2026-08-07T00:33:45.417Z" }, - { url = "https://files.pythonhosted.org/packages/3f/57/a4fb7ad91765efb8fe76084a0cadacf6846206430f4d9968379a3306f845/itk_io-5.4.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b5242e7cf3b7dd4a01478844a5421e637c9b860ad5c7620ef4019398d96efdc0", size = 27681500, upload-time = "2026-08-07T00:33:47.575Z" }, - { url = "https://files.pythonhosted.org/packages/e2/77/68b94a420cb6faaceb6d9880fe3001da7ebe60288b306adb914d314a0acb/itk_io-5.4.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a1b67764abd71a0dd5b9827ad400db2eae6a9a777a185b51681d61961c5bde2a", size = 25597098, upload-time = "2026-08-07T00:33:50.324Z" }, - { url = "https://files.pythonhosted.org/packages/ee/3c/335d93a3137e1ff1c1468ff760f862a5d43c9778e1441e6d177e4495d8ad/itk_io-5.4.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2af543cc0e2dea6549fca6af0fd08ed84cd92b34c3f25bda5e01b4000ec7d2f", size = 28014426, upload-time = "2026-08-07T00:33:53.336Z" }, - { url = "https://files.pythonhosted.org/packages/05/07/e42792e040812ec2030b8971f4f06919f6055e651facdeecb9d2fdac6659/itk_io-5.4.7-cp311-abi3-win_amd64.whl", hash = "sha256:8c7e6a7842137fd360d80e80d9c26618df9662114d6c98f12b24be9be769da43", size = 8681668, upload-time = "2026-08-07T00:33:55.659Z" }, -] - -[[package]] -name = "itk-numerics" -version = "5.4.7" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "itk-core" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/93/aa995c45578aeccd9f3d812b462dbd0f13833c819e1c9f3fb2e6c3dd1216/itk_numerics-5.4.7-cp311-abi3-macosx_10_9_x86_64.whl", hash = "sha256:1c4ec9266c4c071aa1477e37ea5a936c49df77e52ac3efd3ba418200b7d5bf03", size = 35826600, upload-time = "2026-08-07T00:34:30.358Z" }, - { url = "https://files.pythonhosted.org/packages/19/a9/7ffd72245838a3cbf9c83f37f9245ed4541b3f5d3c979e8e6c0d3efef6bf/itk_numerics-5.4.7-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:184dea905f5a6af6fa72750d5831baf48b59c2cc7070f345281e18918b33fed3", size = 30873563, upload-time = "2026-08-07T00:34:32.961Z" }, - { url = "https://files.pythonhosted.org/packages/70/aa/d72ab2e3a34bb90ddb9eb58b17e3d644a39a948fcc5f752b504272d052c1/itk_numerics-5.4.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:86586ff1ff2a07c2c2fbfc3ba50e1fd21efc705a30ba015d1a30972cfa218308", size = 58139915, upload-time = "2026-08-07T00:34:35.778Z" }, - { url = "https://files.pythonhosted.org/packages/f6/d3/23ed57a416da484791083745d1f81ef7c4539eedf8ed8cefdea34dec848a/itk_numerics-5.4.7-cp311-abi3-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e37390e4527b62bd6bbe162676c1cad818635794c24742547cb64fc3c7e23860", size = 53995183, upload-time = "2026-08-07T00:34:38.666Z" }, - { url = "https://files.pythonhosted.org/packages/eb/b8/1ed64dea0b253ec431f4b9289394242e3cbc186a1fa73d72f24328094615/itk_numerics-5.4.7-cp311-abi3-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:473925bdabcb7c5feebef03c6fde4bc916c577776f8b9bfea69e826d7855d668", size = 57197252, upload-time = "2026-08-07T00:34:41.785Z" }, - { url = "https://files.pythonhosted.org/packages/cf/19/a81c006fba7091805e96311291f2f04d19382d4d769cf6fc1fc352b438ff/itk_numerics-5.4.7-cp311-abi3-win_amd64.whl", hash = "sha256:09b3dc9efbf75ba827c03edbfc6214bfad86a643c517a19014eb37969bb139a5", size = 19725436, upload-time = "2026-08-07T00:34:45.142Z" }, -] - -[[package]] -name = "itk-registration" -version = "5.4.7" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "itk-filtering" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/66/75/5de9e78a905797080ebb8bbd6b2e41374f609a51e8f527bfd1b69a73f7c0/itk_registration-5.4.7-cp311-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2efa16df50323f2f46c9e9ad20201ba959e36629821da0bca4261eb343cecd34", size = 22012924, upload-time = "2026-08-07T00:44:36.056Z" }, - { url = "https://files.pythonhosted.org/packages/ed/34/47d7eebd21548a604dfe519fa59e27bc493feaca7e2bc1d6405668c11c3a/itk_registration-5.4.7-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:1dc2bcf5efc8a4e6cea88a8054a3b02cb079dbe1bf88971f1a2519ba12ccc6ef", size = 17848682, upload-time = "2026-08-07T00:44:38.549Z" }, - { url = "https://files.pythonhosted.org/packages/da/df/8ef08a92b78b39cf1691d33442d2069e7efbfcd8579c3ae809a43e0dd0e4/itk_registration-5.4.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3190ab55c41e9c33d036979cc384bd02d5f25c4b58896906da24700d10eca057", size = 29007536, upload-time = "2026-08-07T00:44:40.829Z" }, - { url = "https://files.pythonhosted.org/packages/08/f6/103b6a530cb85bea70bbbf2871067be1b8ab36a014676c6dd4bbfeb3116a/itk_registration-5.4.7-cp311-abi3-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d2756c2a86218aca1682ad13ec9d7b4fd32120f0ff335080691e42d11fb506b8", size = 26115954, upload-time = "2026-08-07T00:44:43.765Z" }, - { url = "https://files.pythonhosted.org/packages/58/22/e343caf0e1a18758806cdef060909f0691f7b17f59dcdc07dac8f6289650/itk_registration-5.4.7-cp311-abi3-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d71e4af418aaf5d4846aaf2615e3bace9aef75ee78a9667a9455ed3016a3f645", size = 28541907, upload-time = "2026-08-07T00:44:46.674Z" }, - { url = "https://files.pythonhosted.org/packages/99/b7/ae39f4e1d3c57526686de56f41a8c351f1632ed230b8932e6085869b3993/itk_registration-5.4.7-cp311-abi3-win_amd64.whl", hash = "sha256:1675047bc637bfb039ebd6fe08f2567ac28c04ecabf0ece88067b1e182ba7ce8", size = 9527196, upload-time = "2026-08-07T00:44:48.956Z" }, -] - -[[package]] -name = "itk-segmentation" -version = "5.4.7" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "itk-filtering" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/74/db/989ea924748a7953efc38902d7a071cfe6750c983e8bf8ed8520e68541de/itk_segmentation-5.4.7-cp311-abi3-macosx_10_9_x86_64.whl", hash = "sha256:05954ee97e7df4da96b0195723d91baeba1907bcd1d5fc433e700c8dbb599f9f", size = 13067851, upload-time = "2026-08-07T00:45:18.207Z" }, - { url = "https://files.pythonhosted.org/packages/e0/8f/1dade3ec293fa00bc3ce1776888146dabe1bcd79db63cb1c6abadc6dd752/itk_segmentation-5.4.7-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:52d0517ebee2b087f2dd3f4c5fceb2213853bbd0579a247bb0c441782f4aa776", size = 11040197, upload-time = "2026-08-07T00:45:20.649Z" }, - { url = "https://files.pythonhosted.org/packages/2d/77/bf9d20771e0714c77a092d43a4b2f627f08e674d4552cf26adb4532b1137/itk_segmentation-5.4.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51162aad2728fc009acac81cbcfa2ed21bd14ccfda74423806efee3a0c3f2506", size = 16467231, upload-time = "2026-08-07T00:45:22.993Z" }, - { url = "https://files.pythonhosted.org/packages/d3/36/d83ee821342339f4347f38bd3ab44d8b23da00368941533982f73e0ed551/itk_segmentation-5.4.7-cp311-abi3-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1393bfad55807673a7976eeb3edb4f77afd7ea003439b2ae5abe2ebd74f2ecd9", size = 14650848, upload-time = "2026-08-07T00:45:25.332Z" }, - { url = "https://files.pythonhosted.org/packages/5f/48/bdf880537d06b06918e696e90e1a963708aef6094788e0836df7b45c617f/itk_segmentation-5.4.7-cp311-abi3-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9746ada767eac9f118b386b58c7e4416765683b3e5e9fe46e20c46d5db503754", size = 15895887, upload-time = "2026-08-07T00:45:27.544Z" }, - { url = "https://files.pythonhosted.org/packages/d8/ab/434d673c5254bd5227a2cb9f8faa282373947139431f172e23c690fa9e75/itk_segmentation-5.4.7-cp311-abi3-win_amd64.whl", hash = "sha256:79705efb3c160ad65648a2dff7a2ee8c5cafeb1af32aac5eaff9e481fbbac743", size = 5034085, upload-time = "2026-08-07T00:45:29.94Z" }, -] - [[package]] name = "jinja2" version = "3.1.6" @@ -379,21 +284,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" }, ] -[[package]] -name = "nibabel" -version = "5.4.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "importlib-resources" }, - { name = "numpy" }, - { name = "packaging" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a3/01/3d2cc510c616bc8e27be17a063070d9126f69407961594a9ae734ea51121/nibabel-5.4.2.tar.gz", hash = "sha256:d5f4b9076a13178ae7f7acf18c8dbd503ee1c4d5c0c23b85df7be87efcbb49da", size = 4663132, upload-time = "2026-03-11T13:31:52.42Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/d7/601b6396b33536811668935faa790112266c70661be94555999be431f86f/nibabel-5.4.2-py3-none-any.whl", hash = "sha256:553482c5f1e1034fc312edf6fb7f32236c0056439845d1c29293b7e8c98d4854", size = 3300985, upload-time = "2026-03-11T13:31:50.028Z" }, -] - [[package]] name = "numpy" version = "2.3.2" @@ -425,6 +315,7 @@ name = "nvidia-cublas-cu12" version = "12.8.4.1" source = { registry = "https://pypi.org/simple" } wheels = [ + { url = "https://files.pythonhosted.org/packages/29/99/db44d685f0e257ff0e213ade1964fc459b4a690a73293220e98feb3307cf/nvidia_cublas_cu12-12.8.4.1-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:b86f6dd8935884615a0683b663891d43781b819ac4f2ba2b0c9604676af346d0", size = 590537124, upload-time = "2025-03-07T01:43:53.556Z" }, { url = "https://files.pythonhosted.org/packages/dc/61/e24b560ab2e2eaeb3c839129175fb330dfcfc29e5203196e5541a4c44682/nvidia_cublas_cu12-12.8.4.1-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:8ac4e771d5a348c551b2a426eda6193c19aa630236b418086020df5ba9667142", size = 594346921, upload-time = "2025-03-07T01:44:31.254Z" }, ] @@ -433,6 +324,7 @@ name = "nvidia-cuda-cupti-cu12" version = "12.8.90" source = { registry = "https://pypi.org/simple" } wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/1f/b3bd73445e5cb342727fd24fe1f7b748f690b460acadc27ea22f904502c8/nvidia_cuda_cupti_cu12-12.8.90-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4412396548808ddfed3f17a467b104ba7751e6b58678a4b840675c56d21cf7ed", size = 9533318, upload-time = "2025-03-07T01:40:10.421Z" }, { url = "https://files.pythonhosted.org/packages/f8/02/2adcaa145158bf1a8295d83591d22e4103dbfd821bcaf6f3f53151ca4ffa/nvidia_cuda_cupti_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ea0cb07ebda26bb9b29ba82cda34849e73c166c18162d3913575b0c9db9a6182", size = 10248621, upload-time = "2025-03-07T01:40:21.213Z" }, ] @@ -442,6 +334,7 @@ version = "12.8.93" source = { registry = "https://pypi.org/simple" } wheels = [ { url = "https://files.pythonhosted.org/packages/05/6b/32f747947df2da6994e999492ab306a903659555dddc0fbdeb9d71f75e52/nvidia_cuda_nvrtc_cu12-12.8.93-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:a7756528852ef889772a84c6cd89d41dfa74667e24cca16bb31f8f061e3e9994", size = 88040029, upload-time = "2025-03-07T01:42:13.562Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d1/e50d0acaab360482034b84b6e27ee83c6738f7d32182b987f9c7a4e32962/nvidia_cuda_nvrtc_cu12-12.8.93-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fc1fec1e1637854b4c0a65fb9a8346b51dd9ee69e61ebaccc82058441f15bce8", size = 43106076, upload-time = "2025-03-07T01:41:59.817Z" }, ] [[package]] @@ -449,18 +342,20 @@ name = "nvidia-cuda-runtime-cu12" version = "12.8.90" source = { registry = "https://pypi.org/simple" } wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/75/f865a3b236e4647605ea34cc450900854ba123834a5f1598e160b9530c3a/nvidia_cuda_runtime_cu12-12.8.90-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:52bf7bbee900262ffefe5e9d5a2a69a30d97e2bc5bb6cc866688caa976966e3d", size = 965265, upload-time = "2025-03-07T01:39:43.533Z" }, { url = "https://files.pythonhosted.org/packages/0d/9b/a997b638fcd068ad6e4d53b8551a7d30fe8b404d6f1804abf1df69838932/nvidia_cuda_runtime_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:adade8dcbd0edf427b7204d480d6066d33902cab2a4707dcfc48a2d0fd44ab90", size = 954765, upload-time = "2025-03-07T01:40:01.615Z" }, ] [[package]] name = "nvidia-cudnn-cu12" -version = "9.10.2.21" +version = "9.19.0.56" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "nvidia-cublas-cu12" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/ba/51/e123d997aa098c61d029f76663dedbfb9bc8dcf8c60cbd6adbe42f76d049/nvidia_cudnn_cu12-9.10.2.21-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:949452be657fa16687d0930933f032835951ef0892b37d2d53824d1a84dc97a8", size = 706758467, upload-time = "2025-06-06T21:54:08.597Z" }, + { url = "https://files.pythonhosted.org/packages/09/b8/277c51962ee46fa3e5b203ac5f76107c650f781d6891e681e28e6f3e9fe6/nvidia_cudnn_cu12-9.19.0.56-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:08caaf27fe556aca82a3ee3b5aa49a77e7de0cfcb7ff4e5c29da426387a8267e", size = 656910700, upload-time = "2026-02-03T20:40:25.508Z" }, + { url = "https://files.pythonhosted.org/packages/c5/41/65225d42fba06fb3dd3972485ea258e7dd07a40d6e01c95da6766ad87354/nvidia_cudnn_cu12-9.19.0.56-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:ac6ad90a075bb33a94f2b4cf4622eac13dd4dc65cf6dd9c7572a318516a36625", size = 657906812, upload-time = "2026-02-03T20:44:12.638Z" }, ] [[package]] @@ -471,6 +366,7 @@ dependencies = [ { name = "nvidia-nvjitlink-cu12" }, ] wheels = [ + { url = "https://files.pythonhosted.org/packages/60/bc/7771846d3a0272026c416fbb7e5f4c1f146d6d80704534d0b187dd6f4800/nvidia_cufft_cu12-11.3.3.83-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:848ef7224d6305cdb2a4df928759dca7b1201874787083b6e7550dd6765ce69a", size = 193109211, upload-time = "2025-03-07T01:44:56.873Z" }, { url = "https://files.pythonhosted.org/packages/1f/13/ee4e00f30e676b66ae65b4f08cb5bcbb8392c03f54f2d5413ea99a5d1c80/nvidia_cufft_cu12-11.3.3.83-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d2dd21ec0b88cf61b62e6b43564355e5222e4a3fb394cac0db101f2dd0d4f74", size = 193118695, upload-time = "2025-03-07T01:45:27.821Z" }, ] @@ -480,6 +376,7 @@ version = "1.13.1.3" source = { registry = "https://pypi.org/simple" } wheels = [ { url = "https://files.pythonhosted.org/packages/bb/fe/1bcba1dfbfb8d01be8d93f07bfc502c93fa23afa6fd5ab3fc7c1df71038a/nvidia_cufile_cu12-1.13.1.3-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1d069003be650e131b21c932ec3d8969c1715379251f8d23a1860554b1cb24fc", size = 1197834, upload-time = "2025-03-07T01:45:50.723Z" }, + { url = "https://files.pythonhosted.org/packages/1e/f5/5607710447a6fe9fd9b3283956fceeee8a06cda1d2f56ce31371f595db2a/nvidia_cufile_cu12-1.13.1.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:4beb6d4cce47c1a0f1013d72e02b0994730359e17801d395bdcbf20cfb3bb00a", size = 1120705, upload-time = "2025-03-07T01:45:41.434Z" }, ] [[package]] @@ -487,6 +384,7 @@ name = "nvidia-curand-cu12" version = "10.3.9.90" source = { registry = "https://pypi.org/simple" } wheels = [ + { url = "https://files.pythonhosted.org/packages/45/5e/92aa15eca622a388b80fbf8375d4760738df6285b1e92c43d37390a33a9a/nvidia_curand_cu12-10.3.9.90-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:dfab99248034673b779bc6decafdc3404a8a6f502462201f2f31f11354204acd", size = 63625754, upload-time = "2025-03-07T01:46:10.735Z" }, { url = "https://files.pythonhosted.org/packages/fb/aa/6584b56dc84ebe9cf93226a5cde4d99080c8e90ab40f0c27bda7a0f29aa1/nvidia_curand_cu12-10.3.9.90-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:b32331d4f4df5d6eefa0554c565b626c7216f87a06a4f56fab27c3b68a830ec9", size = 63619976, upload-time = "2025-03-07T01:46:23.323Z" }, ] @@ -500,6 +398,7 @@ dependencies = [ { name = "nvidia-nvjitlink-cu12" }, ] wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/32/f7cd6ce8a7690544d084ea21c26e910a97e077c9b7f07bf5de623ee19981/nvidia_cusolver_cu12-11.7.3.90-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:db9ed69dbef9715071232caa9b69c52ac7de3a95773c2db65bdba85916e4e5c0", size = 267229841, upload-time = "2025-03-07T01:46:54.356Z" }, { url = "https://files.pythonhosted.org/packages/85/48/9a13d2975803e8cf2777d5ed57b87a0b6ca2cc795f9a4f59796a910bfb80/nvidia_cusolver_cu12-11.7.3.90-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:4376c11ad263152bd50ea295c05370360776f8c3427b30991df774f9fb26c450", size = 267506905, upload-time = "2025-03-07T01:47:16.273Z" }, ] @@ -511,6 +410,7 @@ dependencies = [ { name = "nvidia-nvjitlink-cu12" }, ] wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/f7/cd777c4109681367721b00a106f491e0d0d15cfa1fd59672ce580ce42a97/nvidia_cusparse_cu12-12.5.8.93-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9b6c161cb130be1a07a27ea6923df8141f3c295852f4b260c65f18f3e0a091dc", size = 288117129, upload-time = "2025-03-07T01:47:40.407Z" }, { url = "https://files.pythonhosted.org/packages/c2/f5/e1854cb2f2bcd4280c44736c93550cc300ff4b8c95ebe370d0aa7d2b473d/nvidia_cusparse_cu12-12.5.8.93-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1ec05d76bbbd8b61b06a80e1eaf8cf4959c3d4ce8e711b65ebd0443bb0ebb13b", size = 288216466, upload-time = "2025-03-07T01:48:13.779Z" }, ] @@ -519,15 +419,17 @@ name = "nvidia-cusparselt-cu12" version = "0.7.1" source = { registry = "https://pypi.org/simple" } wheels = [ + { url = "https://files.pythonhosted.org/packages/73/b9/598f6ff36faaece4b3c50d26f50e38661499ff34346f00e057760b35cc9d/nvidia_cusparselt_cu12-0.7.1-py3-none-manylinux2014_aarch64.whl", hash = "sha256:8878dce784d0fac90131b6817b607e803c36e629ba34dc5b433471382196b6a5", size = 283835557, upload-time = "2025-02-26T00:16:54.265Z" }, { url = "https://files.pythonhosted.org/packages/56/79/12978b96bd44274fe38b5dde5cfb660b1d114f70a65ef962bcbbed99b549/nvidia_cusparselt_cu12-0.7.1-py3-none-manylinux2014_x86_64.whl", hash = "sha256:f1bb701d6b930d5a7cea44c19ceb973311500847f81b634d802b7b539dc55623", size = 287193691, upload-time = "2025-02-26T00:15:44.104Z" }, ] [[package]] name = "nvidia-nccl-cu12" -version = "2.27.3" +version = "2.28.9" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5c/5b/4e4fff7bad39adf89f735f2bc87248c81db71205b62bcc0d5ca5b606b3c3/nvidia_nccl_cu12-2.27.3-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:adf27ccf4238253e0b826bce3ff5fa532d65fc42322c8bfdfaf28024c0fbe039", size = 322364134, upload-time = "2025-06-03T21:58:04.013Z" }, + { url = "https://files.pythonhosted.org/packages/08/c4/120d2dfd92dff2c776d68f361ff8705fdea2ca64e20b612fab0fd3f581ac/nvidia_nccl_cu12-2.28.9-py3-none-manylinux_2_18_aarch64.whl", hash = "sha256:50a36e01c4a090b9f9c47d92cec54964de6b9fcb3362d0e19b8ffc6323c21b60", size = 296766525, upload-time = "2025-11-18T05:49:16.094Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4e/44dbb46b3d1b0ec61afda8e84837870f2f9ace33c564317d59b70bc19d3e/nvidia_nccl_cu12-2.28.9-py3-none-manylinux_2_18_x86_64.whl", hash = "sha256:485776daa8447da5da39681af455aa3b2c2586ddcf4af8772495e7c532c7e5ab", size = 296782137, upload-time = "2025-11-18T05:49:34.248Z" }, ] [[package]] @@ -536,6 +438,16 @@ version = "12.8.93" source = { registry = "https://pypi.org/simple" } wheels = [ { url = "https://files.pythonhosted.org/packages/f6/74/86a07f1d0f42998ca31312f998bd3b9a7eff7f52378f4f270c8679c77fb9/nvidia_nvjitlink_cu12-12.8.93-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:81ff63371a7ebd6e6451970684f916be2eab07321b73c9d244dc2b4da7f73b88", size = 39254836, upload-time = "2025-03-07T01:49:55.661Z" }, + { url = "https://files.pythonhosted.org/packages/2a/a2/8cee5da30d13430e87bf99bb33455d2724d0a4a9cb5d7926d80ccb96d008/nvidia_nvjitlink_cu12-12.8.93-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:adccd7161ace7261e01bb91e44e88da350895c270d23f744f0820c818b7229e7", size = 38386204, upload-time = "2025-03-07T01:49:43.612Z" }, +] + +[[package]] +name = "nvidia-nvshmem-cu12" +version = "3.4.5" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/6a/03aa43cc9bd3ad91553a88b5f6fb25ed6a3752ae86ce2180221962bc2aa5/nvidia_nvshmem_cu12-3.4.5-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0b48363fc6964dede448029434c6abed6c5e37f823cb43c3bcde7ecfc0457e15", size = 138936938, upload-time = "2025-09-06T00:32:05.589Z" }, + { url = "https://files.pythonhosted.org/packages/b5/09/6ea3ea725f82e1e76684f0708bbedd871fc96da89945adeba65c3835a64c/nvidia_nvshmem_cu12-3.4.5-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:042f2500f24c021db8a06c5eec2539027d57460e1c1a762055a6554f72c369bd", size = 139103095, upload-time = "2025-09-06T00:32:31.266Z" }, ] [[package]] @@ -543,6 +455,7 @@ name = "nvidia-nvtx-cu12" version = "12.8.90" source = { registry = "https://pypi.org/simple" } wheels = [ + { url = "https://files.pythonhosted.org/packages/10/c0/1b303feea90d296f6176f32a2a70b5ef230f9bdeb3a72bddb0dc922dc137/nvidia_nvtx_cu12-12.8.90-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d7ad891da111ebafbf7e015d34879f7112832fc239ff0d7d776b6cb685274615", size = 91161, upload-time = "2025-03-07T01:42:23.922Z" }, { url = "https://files.pythonhosted.org/packages/a2/eb/86626c1bbc2edb86323022371c39aa48df6fd8b0a1647bc274577f72e90b/nvidia_nvtx_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5b17e2001cc0d751a5bc2c6ec6d26ad95913324a4adb86788c944f8ce9ba441f", size = 89954, upload-time = "2025-03-07T01:42:44.131Z" }, ] @@ -595,15 +508,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/61/91/288c883303be067c1648f1e63ea38e5f8eb5ab7123fd3a9a7366148e58b7/portalocker-4.1.0-py3-none-any.whl", hash = "sha256:d985a430d265adf31adf12bc0bf3501aea59efc495e9104c057e5dfb7394c226", size = 65914, upload-time = "2026-08-02T15:05:23.525Z" }, ] -[[package]] -name = "pydicom" -version = "3.0.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7a/de/52aaf905f1f0ae7aba85996e2592ea2c1fe49157f3cfbcd1871965bdb51d/pydicom-3.0.2.tar.gz", hash = "sha256:5942bfc2d72c6fa4b3b5b62c527f54b7f2355f21d6f5d296df6bb30188df6a4f", size = 2886792, upload-time = "2026-03-19T21:46:20.935Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/46/e0/60466c6d712dad2cf807df315e39863e91609ffd1064ecb835994460bbda/pydicom-3.0.2-py3-none-any.whl", hash = "sha256:abf971a5440f84dbaf42c4b6758e30e62480902584f8b270b9a5d146e278a07b", size = 2376822, upload-time = "2026-03-19T21:46:19.042Z" }, -] - [[package]] name = "pyparsing" version = "3.3.2" @@ -640,47 +544,43 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] -[[package]] -name = "python-gdcm" -version = "3.2.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c1/99/83550191a6bd7278c13c623270d69b0ee99b8b998e81e10f6924dce130c6/python_gdcm-3.2.6.tar.gz", hash = "sha256:2f16eaea7a8c736279492f829d7f95002237a7fb2ea9c3ae85175498e1cd3dad", size = 3384598, upload-time = "2026-05-11T00:49:17.23Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/70/0d/4dea3029176dc0d1cc2a7229668c92a0ee391e3736c028498b55af45d5c0/python_gdcm-3.2.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b22521413ee0b61acdf7f8ea7fe3d51ba7173d4754eb1f62d29b782059877ac6", size = 11457927, upload-time = "2026-05-11T00:39:44.949Z" }, - { url = "https://files.pythonhosted.org/packages/68/58/8b5cc3c650555ed6ac4f8bcb540f31fecf077832a521f7df142196bef320/python_gdcm-3.2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a68bb0f582294cf21297b45856e0a20c7742fdedfa5ba4d313d226b6b5e6c36a", size = 10715454, upload-time = "2026-05-11T00:39:48.798Z" }, - { url = "https://files.pythonhosted.org/packages/d1/39/d512c0aec6379eea36f17e8ae4f0d2877bfaae32efe0a47d610c20f04036/python_gdcm-3.2.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d6033cc01b62e96b928eb8e35b520f2f7a640919f3a569b043c1e64010de0c7", size = 12277638, upload-time = "2026-05-11T00:39:53.108Z" }, - { url = "https://files.pythonhosted.org/packages/2c/33/b925a15ac7597ff24f1601830a0cd8dd8f488348bdf208cf613fe2bc2fe8/python_gdcm-3.2.6-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:716e8a20de03e6a90035ee4575b19bb4b204e219a428509b686c1c28ef6de2d9", size = 13278697, upload-time = "2026-05-11T00:39:57.455Z" }, - { url = "https://files.pythonhosted.org/packages/95/23/87bf04e52112e6eb3ef6e95417cb508bea77332c3312197d3a9a59574ac4/python_gdcm-3.2.6-cp311-cp311-win_amd64.whl", hash = "sha256:c752d94d92239c6bda313a514edd8bd5a17167c751d4ab1bdad82c6b559c5213", size = 34235777, upload-time = "2026-05-11T00:40:03.888Z" }, -] - [[package]] name = "pytorch3d" -version = "0.7.9" -source = { git = "https://github.com/facebookresearch/pytorch3d.git?tag=v0.7.9#33824be3cbc87a7dd1db0f6a9a9de9ac81b2d0ba" } +version = "0.7.9+pt2110cu128" +source = { registry = "https://imagemindanalytics.github.io/pytorch3d-wheels/simple/" } dependencies = [ { name = "iopath" }, ] +wheels = [ + { url = "https://imagemindanalytics.github.io/pytorch3d-wheels/simple/pytorch3d/pytorch3d-0.7.9+pt2110cu128-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:be2007afd5fba1872f9ab13146c107ee285ddab1a3310f64abd27c7889786905" }, + { url = "https://imagemindanalytics.github.io/pytorch3d-wheels/simple/pytorch3d/pytorch3d-0.7.9+pt2110cu128-cp311-cp311-win_amd64.whl", hash = "sha256:4d268d9622deca60736c4ad80c8c3a8c1a9e0e9274015d08b7f96bc4a747bc67" }, +] + +[[package]] +name = "sadt-areg-common" +version = "0.1.0" +source = { directory = "../common" } + +[package.metadata] + +[package.metadata.requires-dev] +dev = [{ name = "pytest", specifier = "==8.3.4" }] [[package]] -name = "sadt-areg" +name = "sadt-areg-ios" version = "0.1.0" source = { editable = "." } dependencies = [ - { name = "dicom2nifti" }, - { name = "itk" }, - { name = "itk-elastix" }, { name = "monai" }, { name = "numpy" }, + { name = "pytorch3d" }, + { name = "sadt-areg-common" }, { name = "simpleitk" }, { name = "torch" }, + { name = "torchvision" }, { name = "vtk" }, ] -[package.optional-dependencies] -ios = [ - { name = "pytorch3d" }, -] - [package.dev-dependencies] dev = [ { name = "pytest" }, @@ -689,28 +589,26 @@ dev = [ [package.metadata] requires-dist = [ - { name = "dicom2nifti", specifier = "==2.6.2" }, - { name = "itk", specifier = "==5.4.7" }, - { name = "itk-elastix", specifier = "==0.23.0" }, { name = "monai", specifier = "==1.6.0" }, { name = "numpy", specifier = "==2.3.2" }, - { name = "pytorch3d", marker = "extra == 'ios'", git = "https://github.com/facebookresearch/pytorch3d.git?tag=v0.7.9" }, + { name = "pytorch3d", specifier = "==0.7.9+pt2110cu128", index = "https://imagemindanalytics.github.io/pytorch3d-wheels/simple/" }, + { name = "sadt-areg-common", directory = "../common" }, { name = "simpleitk", specifier = "==2.5.6" }, - { name = "torch", specifier = "==2.8.0", index = "https://download.pytorch.org/whl/cu128" }, + { name = "torch", specifier = "==2.11.0", index = "https://download.pytorch.org/whl/cu128" }, + { name = "torchvision", specifier = "==0.26.0", index = "https://download.pytorch.org/whl/cu128" }, { name = "vtk", specifier = "==9.6.2" }, ] -provides-extras = ["ios"] [package.metadata.requires-dev] dev = [ { name = "pytest", specifier = "==8.3.4" }, - { name = "sadt-testkit", editable = "../../testkit" }, + { name = "sadt-testkit", editable = "../../../testkit" }, ] [[package]] name = "sadt-testkit" version = "0.1.0" -source = { editable = "../../testkit" } +source = { editable = "../../../testkit" } [package.metadata] @@ -720,34 +618,13 @@ dev = [ { name = "pytest", specifier = "==8.3.4" }, ] -[[package]] -name = "scipy" -version = "1.17.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/df/75/b4ce781849931fef6fd529afa6b63711d5a733065722d0c3e2724af9e40a/scipy-1.17.1-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:1f95b894f13729334fb990162e911c9e5dc1ab390c58aa6cbecb389c5b5e28ec", size = 31613675, upload-time = "2026-02-23T00:16:00.13Z" }, - { url = "https://files.pythonhosted.org/packages/f7/58/bccc2861b305abdd1b8663d6130c0b3d7cc22e8d86663edbc8401bfd40d4/scipy-1.17.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:e18f12c6b0bc5a592ed23d3f7b891f68fd7f8241d69b7883769eb5d5dfb52696", size = 28162057, upload-time = "2026-02-23T00:16:09.456Z" }, - { url = "https://files.pythonhosted.org/packages/6d/ee/18146b7757ed4976276b9c9819108adbc73c5aad636e5353e20746b73069/scipy-1.17.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:a3472cfbca0a54177d0faa68f697d8ba4c80bbdc19908c3465556d9f7efce9ee", size = 20334032, upload-time = "2026-02-23T00:16:17.358Z" }, - { url = "https://files.pythonhosted.org/packages/ec/e6/cef1cf3557f0c54954198554a10016b6a03b2ec9e22a4e1df734936bd99c/scipy-1.17.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:766e0dc5a616d026a3a1cffa379af959671729083882f50307e18175797b3dfd", size = 22709533, upload-time = "2026-02-23T00:16:25.791Z" }, - { url = "https://files.pythonhosted.org/packages/4d/60/8804678875fc59362b0fb759ab3ecce1f09c10a735680318ac30da8cd76b/scipy-1.17.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:744b2bf3640d907b79f3fd7874efe432d1cf171ee721243e350f55234b4cec4c", size = 33062057, upload-time = "2026-02-23T00:16:36.931Z" }, - { url = "https://files.pythonhosted.org/packages/09/7d/af933f0f6e0767995b4e2d705a0665e454d1c19402aa7e895de3951ebb04/scipy-1.17.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43af8d1f3bea642559019edfe64e9b11192a8978efbd1539d7bc2aaa23d92de4", size = 35349300, upload-time = "2026-02-23T00:16:49.108Z" }, - { url = "https://files.pythonhosted.org/packages/b4/3d/7ccbbdcbb54c8fdc20d3b6930137c782a163fa626f0aef920349873421ba/scipy-1.17.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd96a1898c0a47be4520327e01f874acfd61fb48a9420f8aa9f6483412ffa444", size = 35127333, upload-time = "2026-02-23T00:17:01.293Z" }, - { url = "https://files.pythonhosted.org/packages/e8/19/f926cb11c42b15ba08e3a71e376d816ac08614f769b4f47e06c3580c836a/scipy-1.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4eb6c25dd62ee8d5edf68a8e1c171dd71c292fdae95d8aeb3dd7d7de4c364082", size = 37741314, upload-time = "2026-02-23T00:17:12.576Z" }, - { url = "https://files.pythonhosted.org/packages/95/da/0d1df507cf574b3f224ccc3d45244c9a1d732c81dcb26b1e8a766ae271a8/scipy-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:d30e57c72013c2a4fe441c2fcb8e77b14e152ad48b5464858e07e2ad9fbfceff", size = 36607512, upload-time = "2026-02-23T00:17:23.424Z" }, - { url = "https://files.pythonhosted.org/packages/68/7f/bdd79ceaad24b671543ffe0ef61ed8e659440eb683b66f033454dcee90eb/scipy-1.17.1-cp311-cp311-win_arm64.whl", hash = "sha256:9ecb4efb1cd6e8c4afea0daa91a87fbddbce1b99d2895d151596716c0b2e859d", size = 24599248, upload-time = "2026-02-23T00:17:34.561Z" }, -] - [[package]] name = "setuptools" -version = "84.0.0" +version = "81.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6d/44/f5da03a8ef95d369145c5bb53050e7877c9f3d312e128605fd9504829143/setuptools-84.0.0.tar.gz", hash = "sha256:f4695c21257f0d9b537ec2692c941d02ee143b7cc1276941349a546573b2ef73", size = 1168449, upload-time = "2026-08-08T18:27:58.365Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/1c/73e719955c59b8e424d015ab450f51c0af856ae46ea2da83eba51cc88de1/setuptools-81.0.0.tar.gz", hash = "sha256:487b53915f52501f0a79ccfd0c02c165ffe06631443a886740b91af4b7a5845a", size = 1198299, upload-time = "2026-02-06T21:10:39.601Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/95/9c/c510029fc6ef33a6275cd2c5d3cecd6613dfd6aa401d57c54f1c18852ccf/setuptools-84.0.0-py3-none-any.whl", hash = "sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670", size = 818216, upload-time = "2026-08-08T18:27:56.719Z" }, + { url = "https://files.pythonhosted.org/packages/e1/e3/c164c88b2e5ce7b24d667b9bd83589cf4f3520d97cad01534cd3c4f55fdb/setuptools-81.0.0-py3-none-any.whl", hash = "sha256:fdd925d5c5d9f62e4b74b30d6dd7828ce236fd6ed998a08d81de62ce5a6310d6", size = 1062021, upload-time = "2026-02-06T21:10:37.175Z" }, ] [[package]] @@ -785,34 +662,43 @@ wheels = [ [[package]] name = "torch" -version = "2.8.0+cu128" +version = "2.11.0+cu128" source = { registry = "https://download.pytorch.org/whl/cu128" } dependencies = [ + { name = "cuda-bindings", marker = "sys_platform == 'linux'" }, + { name = "cuda-toolkit", extra = ["cublas", "cudart", "cufft", "cufile", "cupti", "curand", "cusolver", "cusparse", "nvjitlink", "nvrtc", "nvtx"], marker = "sys_platform == 'linux'" }, { name = "filelock" }, { name = "fsspec" }, { name = "jinja2" }, { name = "networkx" }, - { name = "nvidia-cublas-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-cuda-cupti-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-cuda-nvrtc-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-cuda-runtime-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-cudnn-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-cufft-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-cufile-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-curand-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-cusolver-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-cusparse-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-cusparselt-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-nccl-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-nvjitlink-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-nvtx-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cudnn-cu12", marker = "sys_platform == 'linux'" }, + { name = "nvidia-cusparselt-cu12", marker = "sys_platform == 'linux'" }, + { name = "nvidia-nccl-cu12", marker = "sys_platform == 'linux'" }, + { name = "nvidia-nvshmem-cu12", marker = "sys_platform == 'linux'" }, + { name = "setuptools" }, { name = "sympy" }, - { name = "triton", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "triton", marker = "sys_platform == 'linux'" }, { name = "typing-extensions" }, ] wheels = [ - { url = "https://download-r2.pytorch.org/whl/cu128/torch-2.8.0%2Bcu128-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:039b9dcdd6bdbaa10a8a5cd6be22c4cb3e3589a341e5f904cbb571ca28f55bed", upload-time = "2025-10-01T23:49:06Z" }, - { url = "https://download-r2.pytorch.org/whl/cu128/torch-2.8.0%2Bcu128-cp311-cp311-win_amd64.whl", hash = "sha256:34c55443aafd31046a7963b63d30bc3b628ee4a704f826796c865fdfd05bb596", upload-time = "2025-10-01T23:49:30Z" }, + { url = "https://download-r2.pytorch.org/whl/cu128/torch-2.11.0%2Bcu128-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:d76f08e212285bd84c4c5a3472417f8eb4ee72e4067a604f7508dbfa2119771f", upload-time = "2026-04-27T17:36:45Z" }, + { url = "https://download-r2.pytorch.org/whl/cu128/torch-2.11.0%2Bcu128-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:c9a7ca4c74fae10a58e6175b4b2cea953f9322bb6562bbf339ad6a05f52190ad", upload-time = "2026-04-27T17:37:32Z" }, + { url = "https://download-r2.pytorch.org/whl/cu128/torch-2.11.0%2Bcu128-cp311-cp311-win_amd64.whl", hash = "sha256:90ef0c2454e5296a9fb021ddd42252e4ce1abe2c0a4988a173ef90a6cded0bf5", upload-time = "2026-04-27T17:39:29Z" }, +] + +[[package]] +name = "torchvision" +version = "0.26.0+cu128" +source = { registry = "https://download.pytorch.org/whl/cu128" } +dependencies = [ + { name = "numpy" }, + { name = "pillow" }, + { name = "torch" }, +] +wheels = [ + { url = "https://download-r2.pytorch.org/whl/cu128/torchvision-0.26.0%2Bcu128-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:ed1324dbbbecb5a0149ed4ce8f9308465a1eef85ca2d2370dbb14805bf1c90aa", upload-time = "2026-04-09T23:21:34Z" }, + { url = "https://download-r2.pytorch.org/whl/cu128/torchvision-0.26.0%2Bcu128-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:8f2629d056570c929b0a1d5473d9cb0320b90bda1764bda353553a72cc6b2069", upload-time = "2026-03-23T15:36:22Z" }, + { url = "https://download-r2.pytorch.org/whl/cu128/torchvision-0.26.0%2Bcu128-cp311-cp311-win_amd64.whl", hash = "sha256:d26091b15cd6e3c74c148d9b68c9a901ad6fb9b0f66fa3ea3ab09f04132a07d3", upload-time = "2026-04-09T23:21:35Z" }, ] [[package]] @@ -829,13 +715,11 @@ wheels = [ [[package]] name = "triton" -version = "3.4.0" +version = "3.6.0" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "setuptools" }, -] wheels = [ - { url = "https://files.pythonhosted.org/packages/7d/39/43325b3b651d50187e591eefa22e236b2981afcebaefd4f2fc0ea99df191/triton-3.4.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b70f5e6a41e52e48cfc087436c8a28c17ff98db369447bcaff3b887a3ab4467", size = 155531138, upload-time = "2025-07-30T19:58:29.908Z" }, + { url = "https://files.pythonhosted.org/packages/0f/2c/96f92f3c60387e14cc45aed49487f3486f89ea27106c1b1376913c62abe4/triton-3.6.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49df5ef37379c0c2b5c0012286f80174fcf0e073e5ade1ca9a86c36814553651", size = 176081190, upload-time = "2026-01-20T16:16:00.523Z" }, + { url = "https://files.pythonhosted.org/packages/e0/12/b05ba554d2c623bffa59922b94b0775673de251f468a9609bc9e45de95e9/triton-3.6.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8e323d608e3a9bfcc2d9efcc90ceefb764a82b99dea12a86d643c72539ad5d3", size = 188214640, upload-time = "2026-01-20T16:00:35.869Z" }, ] [[package]] diff --git a/tools/AREG/common/README.md b/tools/AREG/common/README.md new file mode 100644 index 0000000..4e95a33 --- /dev/null +++ b/tools/AREG/common/README.md @@ -0,0 +1,42 @@ +# sadt-areg-common + +What `AREG_CBCT` and `AREG_IOS` must not disagree about. + +## Why these four and not the others + +The split duplicated both engines' implementations and shared almost nothing — +that is the standing rule, and it is right. These four are the exception, and +each earns it differently. + +**`pairing.py` is the one that matters.** It was nearly duplicated on the +reasoning that "the two modes pair different things — volumes against meshes", +which sounds obviously true and is wrong. The functions that really are +modality-specific, `pair()` and `discover()`, are called by **neither engine**: +they belong to the dispatcher, which the split separated anyway. What the +engines actually share is `patient_stem()` — how a patient's identity is derived +from a filename, by stripping timepoint and jaw tokens. + +That is a **convention**, and a divergence in it does not fail: it makes +`AREG_CBCT` and `AREG_IOS` derive different keys from the same name, so a +cross-modality registration pairs a patient with almost-themselves and says +nothing. `AREG_IOSCBCT` will lean on it from both sides at once, which is what +settles it. `is_previous_output()` is the same family: two copies that drift +means one tool re-ingesting what the other produced. + +**`catalogs.py`** holds the modality and automation tables. Published in the +schema, keyed on by the server, and read by both — a second copy is a panel +offering a mode the tool no longer has. + +**`scans.py`** is the file-extension vocabulary, the same contract with the +outside world that `ALI/common/discovery.py` carries. + +**`errors.py`** is here rather than duplicated only because it is three lines +and travels with `pairing`'s raises. Note that ALI keeps its copy duplicated: +errors cross the process boundary by class NAME, so sharing the class is never +the mechanism — it is a convenience here, not a requirement. + +## The constraint + +`dependencies = []`, and it has to stay that way. This installs into two +environments whose pins are deliberately incompatible; anything it pulled in +would have to satisfy both at once, which is exactly what the split removed. diff --git a/tools/AREG/common/pyproject.toml b/tools/AREG/common/pyproject.toml new file mode 100644 index 0000000..a94e621 --- /dev/null +++ b/tools/AREG/common/pyproject.toml @@ -0,0 +1,26 @@ +[project] +name = "sadt-areg-common" +version = "0.1.0" +description = "What AREG's engines must not disagree about: the patient key, the catalogs, the scan vocabulary." +requires-python = ">=3.9" +# Deliberately empty, and it must stay that way. This installs into BOTH +# AREG_CBCT's and AREG_IOS's environments, whose whole reason for being separate +# is that their pins are incompatible -- AREG_IOS needs torch 2.11 for pytorch3d +# while AREG_CBCT has no reason to move. A dependency here would have to be +# satisfiable by both at once, which is the constraint the split removed. +# `pairing`, `catalogs`, `scans` and `errors` import `os` and `re`; that is the +# budget. +dependencies = [] + +[dependency-groups] +dev = ["pytest==8.3.4"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/sadt_areg_common"] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/tools/AREG/common/src/sadt_areg_common/__init__.py b/tools/AREG/common/src/sadt_areg_common/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tools/AREG/pyproject.toml b/tools/AREG/pyproject.toml deleted file mode 100644 index ccaa108..0000000 --- a/tools/AREG/pyproject.toml +++ /dev/null @@ -1,82 +0,0 @@ -[project] -name = "sadt-areg" -version = "0.1.0" -description = "Register a follow-up scan onto its baseline, so the two can be compared." -# The deployment image's interpreter (lab-ai:2026.08, python 3.11.13), and the -# only one pytorch3d is built for there. -requires-python = ">=3.11,<3.12" -# The CBCT engine's stack, plus VTK for the meshes. Pinned to what the deployed -# server runs and what every sibling tool already locks. -dependencies = [ - "torch==2.8.0", - "monai==1.6.0", - "itk==5.4.7", - # elastix is reached as `itk.ElastixRegistrationMethod`, which plain itk - # does not carry -- this is the package that adds it, and the CBCT engine is - # nothing without it. - "itk-elastix==0.23.0", - "SimpleITK==2.5.6", - "numpy==2.3.2", - "vtk==9.6.2", - "dicom2nifti==2.6.2", -] - -# The IOS engine's renderer, behind an extra for the same reason as ALI's and -# Crown_Seg's: pytorch3d publishes no usable wheel and compiles from source -# against the pinned torch. A plain `uv sync` stays fast, CI can still import the -# package and publish its schema, and the CBCT engine works without it. -# What makes this directory a tool. A directory holding a pyproject.toml -# WITHOUT this section -- a shared path dependency, the testkit -- is a plain -# package: importable, installable, never discovered or served. -# -# `name` is the API identity: what a client sends, what deployment.toml is keyed -# by, and what another tool passes to `sup.run()`. Declared rather than derived -# from the directory so a folder can be reorganised without breaking the HTTP -# contract -- moving ALI_CBCT under a grouping folder is exactly that case. -[tool.sadt] -tool = true -name = "AREG" - -[project.optional-dependencies] -ios = ["pytorch3d"] - -[dependency-groups] -dev = ["pytest==8.3.4", "sadt-testkit"] - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.hatch.build.targets.wheel] -packages = ["src/sadt_areg"] - -# `explicit` is load-bearing: without it uv looks for EVERY package on the -# PyTorch index, and most of them are not there. -[[tool.uv.index]] -name = "pytorch-cu128" -url = "https://download.pytorch.org/whl/cu128" -explicit = true - -[tool.uv.sources] -# Development only: lets this tool's tests run ANOTHER tool through that tool's -# own venv, as a subprocess, the way the server does -- which is how the -# supervisor is exercised end to end. It has no dependencies of its own, so it -# cannot perturb the resolution above. Never move it into [project] -# dependencies -- see testkit/README.md. -sadt-testkit = { path = "../../testkit", editable = true } -torch = { index = "pytorch-cu128" } -# Same tag ALI and Crown_Seg pin, so the three share one build. -pytorch3d = { git = "https://github.com/facebookresearch/pytorch3d.git", tag = "v0.7.9" } - -# pytorch3d's setup.py imports torch at build time and does not declare it, so -# under uv's build isolation it fails with ModuleNotFoundError: torch. -[tool.uv.extra-build-dependencies] -pytorch3d = ["torch"] - -[tool.pytest.ini_options] -testpaths = ["tests"] -markers = [ - "gpu: needs a CUDA device. Skipped in CI (`-m 'not gpu'`); run it by hand and report the result in the PR.", - "models: needs a real model bundle and is skipped without it. See tests/data/README.md.", - "ios: needs the `ios` extra (pytorch3d compiled). Skipped without it.", -] diff --git a/tools/AREG/src/sadt_areg/__init__.py b/tools/AREG/src/sadt_areg/__init__.py deleted file mode 100644 index 1b08543..0000000 --- a/tools/AREG/src/sadt_areg/__init__.py +++ /dev/null @@ -1,146 +0,0 @@ -"""AREG -- Automated Registration of a follow-up scan onto its baseline. - -Registers every T2 onto its T1 so the two timepoints share one coordinate -system and can be measured against each other. One tool, two modalities: - -* **CBCT** -- elastix, rigid, restricted to the anatomy that has not changed: - the cranial base, the mandible or the maxilla, taken as masks; -* **IOS** -- a patch of the arch that does not move with growth or treatment - (the palate, or the band around the mucogingival line), matched by ICP. - -The pipeline is in dispatch.py; only `run` is public. - -AREG does the registration and nothing else. The masks it registers on, the -orientation both timepoints must share, the tooth labels and the mucogingival -line each come from another tool, reached through the supervisor -- see -tools.py. That is what makes this the deepest chain in the family: -`AREG -> ASO -> ALI`, three tools and three virtualenvs. -""" - -from pathlib import Path -from typing import Literal - -REGIONS = ["Cranial base", "Mandible", "Maxilla"] - - -def run( - t1: Path, - t2: Path, - output_dir: Path, - modality: Literal["CBCT", "IOS"] = "CBCT", - automation: Literal[ - "Semi-Automated", "Fully-Automated", "Oriented + Fully-Automated" - ] = "Fully-Automated", - # Spelled out because `Literal` takes literals only -- it cannot be built - # from catalogs.REGION_CHOICES. That makes this a second declaration of the - # same set, which is the thing this contract otherwise avoids, so a test - # asserts the two agree. - cbct_regions: list[ - Literal["Cranial base", "Mandible", "Maxilla"] - ] = ["Cranial base"], - t1_masks: Path = "", - segmentation_model: Path = "", - segmentation_label: int = 0, - cbct_reference: Path = "", - landmark_model: Path = "", - ios_reference: Path = "", - ios_patch: Literal[ - "Palate (upper arch)", "Mucogingival line (lower arch)" - ] = "Palate (upper arch)", - registration_model: Path = "", - crown_model: Path = "", - mgl_model: Path = "", - mgl_landmarks: Path = "", - mgl_patch_height: float = 0.0, - dicom_input: bool = False, - output_suffix: str = "Reg", - *, - sup=None, -) -> Path: - """Register a follow-up scan onto its baseline, so the two can be compared. - - Args: - t1: The baseline timepoint — a folder of CBCT scans, or of intra-oral - meshes. Searched recursively; T1 and T2 are paired by patient name. - t2: The follow-up timepoint, same shape as `t1`. - output_dir: Where results are written — per patient, the registered T2 - and the transform that produced it — plus `AREG_report.json`. - Nothing is written outside it. - modality: CBCT volumes or intra-oral surface scans. Never inferred from - the file extension: a folder can hold either, and guessing wrong - registers a patient against the wrong anatomy and calls it success. - automation: Semi-Automated takes what it needs from you. Fully-Automated - asks the other tools for it. "Oriented + Fully-Automated" (CBCT - only) is the middle ground: the scans are already oriented, so the - orientation step is skipped and the masks are still segmented. - cbct_regions: CBCT only. The anatomy to register on — pick what has NOT - changed between the two timepoints. The cranial base is the usual - choice; the mandible or maxilla suit a patient whose growth is - elsewhere. - t1_masks: CBCT Semi-Automated only. Your own T1 segmentation masks, one - binary file per region, instead of having them segmented. - segmentation_model: CBCT only. The AMASSS bundle used to segment the T1 - masks when they are not supplied. - segmentation_label: CBCT only. The label value to read out of a - multi-label mask file. 0 means "any non-zero voxel", which is what a - binary mask needs. - cbct_reference: CBCT only. The reference the scans are oriented onto - before registering, when the mode orients them. - landmark_model: CBCT only, and only when the mode orients. The bundle - the orientation tool predicts its landmarks with — it names weights - it cannot resolve itself, so forgetting this fails three tools down - rather than here. - ios_reference: IOS only, and the same idea. - ios_patch: IOS only. Which part of the arch to match on — the palate for - an upper arch, the band around the mucogingival line for a lower one. - registration_model: IOS only. The model that finds the patch. - mgl_model: IOS only. The landmark bundle the mucogingival line is - predicted from, when no landmarks are sent. - crown_model: IOS only. The checkpoint the crown-labelling tool runs - with, for the Fully-Automated mode that labels the meshes itself. - A mesh that already carries its tooth-label array needs none. - mgl_landmarks: IOS only, and only for the mucogingival patch. Your own - 13 landmarks per lower scan, instead of having them predicted. - mgl_patch_height: IOS only. How far the band extends from the - mucogingival line, in millimetres. 0 uses the model's own default. - dicom_input: CBCT only. Convert DICOM series found in the input to NIfTI - before registering. - output_suffix: Added to each output name, e.g. `patient1_Reg.nii.gz`. - - Returns: - The output directory, holding the registered cases and the run report. - - A Fully-Automated run needs the tools it drives to be reachable. Without a - supervisor it says so up front, and names the mode that works instead — - see tools.py. - """ - # elastix, torch, monai, pytorch3d, SimpleITK and VTK are all imported - # inside the pipelines: describe.py imports this module on every CI run to - # publish the schema, and that must not cost a CUDA stack. - from .dispatch import main - - output_dir = Path(output_dir) - main( - modality=modality, - automation=automation, - t1=str(t1), - t2=str(t2), - t1_masks=str(t1_masks) if t1_masks else None, - cbct_regions=list(cbct_regions), - segmentation_label=segmentation_label, - segmentation_model=str(segmentation_model) if segmentation_model else None, - cbct_reference=str(cbct_reference) if cbct_reference else None, - landmark_model=str(landmark_model) if landmark_model else None, - ios_reference=str(ios_reference) if ios_reference else None, - registration_model=str(registration_model) if registration_model else None, - crown_model=str(crown_model) if crown_model else None, - mgl_model=str(mgl_model) if mgl_model else None, - ios_patch=ios_patch, - mgl_landmarks=str(mgl_landmarks) if mgl_landmarks else None, - mgl_patch_height=mgl_patch_height or None, - dicom_input=dicom_input, - output_suffix=output_suffix, - output_dir=str(output_dir), - sup=sup, - ) - return output_dir diff --git a/tools/AREG/src/sadt_areg/layout.py b/tools/AREG/src/sadt_areg/layout.py deleted file mode 100644 index 4da5b0f..0000000 --- a/tools/AREG/src/sadt_areg/layout.py +++ /dev/null @@ -1,109 +0,0 @@ -"""How a client should lay this tool's panel out. Presentation only. - -Nothing here changes what `run()` accepts — `describe.py` merges these hints -into the published schema and refuses any that name an argument or an option -the signature does not offer. - -AREG has the same problem ASO has, one worse: **two modalities and three -automation modes share one schema**, and most of its arguments apply to exactly -one combination. Without conditions a panel asks a CBCT user about the palate -patch and an IOS user about DICOM. The conditions below are what the Slicer -module expressed as separate pages. - -Derived, not restated: the region tabs come from `catalogs`, so a region added -there appears with no edit here. -""" - -from . import catalogs - -_INPUTS = "Inputs" -_CBCT = "CBCT registration" -_IOS = "IOS registration" -_OUTPUTS = "Outputs" - -_CBCT_ONLY = {"modality": catalogs.MODALITY_CBCT} -_IOS_ONLY = {"modality": catalogs.MODALITY_IOS} - -# Narrower still, and each one mirrors a check in dispatch.py. An argument the -# chosen mode never reads is not merely noise: shown as optional beside the -# ones that matter, it reads as something the user chose not to fill, and the -# refusal arrives at the end of a run instead of before it. -_CBCT_SEGMENTED = { # AMASSS produces the masks; Semi-Automated takes yours - "modality": catalogs.MODALITY_CBCT, - "automation": [catalogs.AUTOMATION_FULLY, catalogs.AUTOMATION_ORIENTED], -} -_CBCT_ORIENTED = { # ASO orients the T1 first, and needs a reference for it - "modality": catalogs.MODALITY_CBCT, - "automation": catalogs.AUTOMATION_ORIENTED, -} -_IOS_ORIENTED = { # the same, for the meshes - "modality": catalogs.MODALITY_IOS, - "automation": catalogs.AUTOMATION_FULLY, -} -# The patch decides the rest of the IOS panel: the mucogingival band is built -# from landmarks and involves no network at all, while the palate is predicted -# and involves nothing else. Asking for a checkpoint on the MGL side is how a -# user comes to believe that mode needs one -- dispatch.py says as much where -# it refuses. Listed rather than negated: `visible_when` compares, so "every -# patch but MGL" is written by naming them. -_IOS_MGL = {"modality": catalogs.MODALITY_IOS, "ios_patch": catalogs.PATCH_MGL} -_IOS_PREDICTED = { - "modality": catalogs.MODALITY_IOS, - "ios_patch": [p for p in catalogs.PATCH_CHOICES if p != catalogs.PATCH_MGL], -} - -LAYOUT = { - "t1": {"section": _INPUTS, "label": "T1 (baseline)"}, - "t2": {"section": _INPUTS, "label": "T2 (follow-up)"}, - "modality": {"section": _INPUTS, "label": "Input Type"}, - "automation": {"section": _INPUTS, "label": "Mode"}, - - # -- CBCT --------------------------------------------------------------- - # The one argument a clinician must actually think about: register on what - # has NOT changed between the two timepoints. - "cbct_regions": { - "section": _CBCT, - "label": "Register on", - "ui": "inline", - "visible_when": _CBCT_ONLY, - }, - "t1_masks": {"section": _CBCT, "label": "T1 masks", "visible_when": _CBCT_ONLY}, - "segmentation_model": { - "section": _CBCT, "label": "Segmentation model", "visible_when": _CBCT_SEGMENTED, - }, - "segmentation_label": { - "section": _CBCT, "label": "Mask label value", "visible_when": _CBCT_SEGMENTED, - }, - "cbct_reference": { - "section": _CBCT, "label": "Orientation reference", "visible_when": _CBCT_ORIENTED, - }, - "landmark_model": { - "section": _CBCT, "label": "Landmark model bundle", "visible_when": _CBCT_ORIENTED, - }, - "dicom_input": {"section": _INPUTS, "label": "Input is DICOM", "visible_when": _CBCT_ONLY}, - - # -- IOS ---------------------------------------------------------------- - "ios_reference": { - "section": _IOS, "label": "Orientation reference", "visible_when": _IOS_ORIENTED, - }, - "ios_patch": {"section": _IOS, "label": "Registration patch", "visible_when": _IOS_ONLY}, - "registration_model": { - "section": _IOS, "label": "Patch model", "visible_when": _IOS_PREDICTED, - }, - # Only the Fully-Automated mode labels the crowns itself; the others take - # meshes that already carry the array, whatever the patch. - "crown_model": { - "section": _IOS, "label": "Crown segmentation model", "visible_when": _IOS_ORIENTED, - }, - "mgl_model": { - "section": _IOS, "label": "Mucogingival landmark bundle", "visible_when": _IOS_MGL, - }, - "mgl_landmarks": { - "section": _IOS, "label": "Mucogingival landmarks", "visible_when": _IOS_MGL, - }, - "mgl_patch_height": { - "section": _IOS, "label": "Patch height (mm)", "visible_when": _IOS_MGL, - }, - - "output_suffix": {"section": _OUTPUTS, "label": "Output suffix"}, -} From 63c053860cfc372397504b5a060171f59a271a21 Mon Sep 17 00:00:00 2001 From: Jules GRIVOT PELISSON Date: Tue, 18 Aug 2026 14:24:40 -0400 Subject: [PATCH 03/19] FIX: split a concatenated jaw and timepoint token so patient keys and jaws resolve --- CONTRIBUTING.md | 16 ++++++ tools/AREG/AREG_IOS/README.md | 41 ++++++++++++++++ .../AREG_IOS/src/sadt_areg_ios/surfaces.py | 10 ++-- .../common/src/sadt_areg_common/pairing.py | 46 ++++++++++++++++- tools/AREG/common/tests/test_pairing.py | 49 +++++++++++++++++++ 5 files changed, 157 insertions(+), 5 deletions(-) create mode 100644 tools/AREG/AREG_IOS/README.md create mode 100644 tools/AREG/common/tests/test_pairing.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f30f134..a33736f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -364,6 +364,22 @@ tool environments whose pins are deliberately incompatible, and anything it pulled in would have to be satisfiable by all of them at once — which is the constraint this repository exists to remove. +### A path dependency is installed as a COPY, not a link + +`sadt-areg-common = { path = "../common" }` installs a snapshot. Editing +`common/src/.../pairing.py` changes nothing in a tool's virtualenv until: + +```bash +uv sync --reinstall-package sadt-areg-common +``` + +Without it you edit, re-run, and see the OLD behaviour — which reads as "my fix +did not work" and sends you rewriting a correct patch. It cost a cycle the first +time it came up. `editable = true` avoids it, and is why the dev-only +`sadt-testkit` entries carry it; a shared runtime package deliberately does not, +so what a tool runs against is a fixed copy rather than whatever the working +tree happens to hold. + ### A `sup.run()` call must live in the tool's own `src/` Never in a shared package, however much orchestration two tools appear to have diff --git a/tools/AREG/AREG_IOS/README.md b/tools/AREG/AREG_IOS/README.md new file mode 100644 index 0000000..c071a01 --- /dev/null +++ b/tools/AREG/AREG_IOS/README.md @@ -0,0 +1,41 @@ +# sadt-areg-ios + +Registers a follow-up intraoral scan onto its baseline, by ICP on a patch of the +arch that does not move with growth or treatment — the palate, or the band +around the mucogingival line. + +Split out of the former single `AREG`; see `../common/README.md` for what the +two engines still share and why. + +## The ICP is not deterministic, and here is its measured spread + +Registering the same pair twice, with the same code and the same weights, +does not give the same mesh. Measured on upstream's own `AREG_test_scans` +(`A2_UpperT1.vtk` / `A2_UpperT2.vtk`, 75 867 points, 57.5 mm across), on the +registered T2 — the mesh the ICP actually moves: + +| comparison | mean | p95 | max | identical points | +|---|---|---|---|---| +| direct vs direct | 0.1879 mm | 0.3376 mm | 0.3797 mm | 0 / 75 867 | +| direct vs HTTP | 0.1172 mm | 0.2086 mm | 0.2605 mm | 0 / 75 867 | +| direct2 vs HTTP | 0.1896 mm | 0.3236 mm | 0.3614 mm | 0 / 75 867 | + +**Two direct runs differ from each other MORE than a direct run differs from +the same job dispatched over HTTP.** That is the number that matters: it says +the spread belongs to the ICP, not to the server. Had the dispatch, the +virtualenv or the environment perturbed convergence, direct-vs-direct would sit +near zero and the other two would not. + +So the reference for this tool is **not** zero. It is ~0.19 mm mean and ~0.38 mm +max on this pair, about 0.7 % of the mesh's own extent, and a comparison should +be read against that rather than against bit-equality. The registered T1 IS +bit-identical, being the mesh nothing moves. + +Note what this is not: the CBCT engine registers with elastix on the CPU and is +bit-exact, volume and `.tfm` alike. The difference is the algorithm, not the +plumbing. + +**Not measured here:** whether the spread is the same on other pairs, or whether +it grows on a harder registration. One pair is enough to establish that a +tolerance is needed and roughly where it sits; it is not enough to publish a +bound. Anything clinical should re-measure on its own data. diff --git a/tools/AREG/AREG_IOS/src/sadt_areg_ios/surfaces.py b/tools/AREG/AREG_IOS/src/sadt_areg_ios/surfaces.py index a17b234..9b7ade8 100644 --- a/tools/AREG/AREG_IOS/src/sadt_areg_ios/surfaces.py +++ b/tools/AREG/AREG_IOS/src/sadt_areg_ios/surfaces.py @@ -30,7 +30,7 @@ import vtk from vtk.util.numpy_support import vtk_to_numpy -from sadt_areg_common import catalogs +from sadt_areg_common import catalogs, pairing SURFACE_EXTENSIONS = (".vtk", ".vtp", ".stl", ".obj") @@ -119,8 +119,12 @@ def jaw_of(filename: str) -> str: subject folder named `Mdx` made every mesh in it a mandible. """ stem = os.path.splitext(os.path.basename(filename))[0] - for token in _SEPARATORS.split(stem): - jaw = catalogs.JAW_TOKENS.get(token.lower()) + # `pairing.tokens` rather than a local split: it also separates a jaw run + # together with a timepoint, `UpperT1` -> `upper` + `t1`, which upstream's + # own test set is named with. A local split saw one token, `uppert1`, and + # reported no jaw at all. + for token in pairing.tokens(stem): + jaw = catalogs.JAW_TOKENS.get(token) if jaw: return jaw return None diff --git a/tools/AREG/common/src/sadt_areg_common/pairing.py b/tools/AREG/common/src/sadt_areg_common/pairing.py index 591fd5b..a2a97a8 100644 --- a/tools/AREG/common/src/sadt_areg_common/pairing.py +++ b/tools/AREG/common/src/sadt_areg_common/pairing.py @@ -51,6 +51,48 @@ def is_scan_file(filename: str) -> bool: return filename.lower().endswith(SCAN_EXTENSIONS) +def _split_jaw_timepoint(part: str) -> list: + """`UpperT1` -> `['Upper', 'T1']`. One token in, one or two out. + + Upstream's own AREG test set is named `A2_UpperT1.vtk` / `A2_UpperT2.vtk`: + the jaw and the timepoint run together with no separator, so the whole thing + is a single token, `uppert1`, matching neither the jaw table nor the + timepoint one. Two consequences, both silent until a run failed: + `patient_stem` dropped nothing and made the two timepoints two patients, and + `jaw_of` found no jaw at all. + + Deliberately narrow, and keyed on the two STATIC tables rather than on what + a caller asked to drop: the split fires only when the prefix is a known jaw + token AND the suffix is a known timepoint. `PAT1` is therefore untouched + (`pa` is not a jaw), and so is any identifier that merely ends in something + timepoint-shaped. Splitting on every camelCase boundary would start eating + patient identifiers, which is the failure this module exists to prevent. + """ + lowered = part.lower() + for timepoint in catalogs.TIMEPOINT_TOKENS: + if not lowered.endswith(timepoint) or len(lowered) <= len(timepoint): + continue + if lowered[: -len(timepoint)] in catalogs.JAW_TOKENS: + cut = len(lowered) - len(timepoint) + return [part[:cut], part[cut:]] + return [part] + + +def split_parts(stem: str) -> list: + """`_SEPARATORS.split`, plus the concatenated jaw+timepoint split. + + Separators AND their surroundings, so `_drop_tokens` can rebuild the stem + from what it keeps. `tokens()` is the same thing without the separators. + """ + out = [] + for part in _SEPARATORS.split(stem): + if part and not _SEPARATORS.fullmatch(part): + out.extend(_split_jaw_timepoint(part)) + else: + out.append(part) + return out + + def tokens(stem: str) -> tuple: """The lowercase words of a file stem, split on _ - . and whitespace. @@ -59,7 +101,7 @@ def tokens(stem: str) -> tuple: """ return tuple( part.lower() - for part in _SEPARATORS.split(stem) + for part in split_parts(stem) if part and not _SEPARATORS.fullmatch(part) ) @@ -75,7 +117,7 @@ def _drop_tokens(stem: str, unwanted) -> str: Case is preserved for what survives: the key ends up in output paths, and a patient folder should keep the name its owner gave it. """ - parts = _SEPARATORS.split(stem) + parts = split_parts(stem) kept = [ part for part in parts diff --git a/tools/AREG/common/tests/test_pairing.py b/tools/AREG/common/tests/test_pairing.py new file mode 100644 index 0000000..725d901 --- /dev/null +++ b/tools/AREG/common/tests/test_pairing.py @@ -0,0 +1,49 @@ +"""The patient key, which is the one thing AREG's engines must agree on.""" + +from sadt_areg_common import catalogs, pairing + +JAW = set(catalogs.JAW_TOKENS) + + +def test_the_upstream_test_set_pairs_its_two_timepoints(): + """`A2_UpperT1.vtk` and `A2_UpperT2.vtk` are one patient, not two. + + These are upstream's own AREG_test_scans filenames, verbatim -- not a + renamed version, because the point is that the tool reads the data its own + project publishes. The jaw and the timepoint run together with no + separator, so `uppert1` used to match neither the jaw table nor the + timepoint one, nothing was dropped, and AREG_IOS refused with "no subject + has a upper arch at both timepoints". + """ + t1 = pairing.patient_stem("A2_UpperT1.vtk", also_drop=JAW) + t2 = pairing.patient_stem("A2_UpperT2.vtk", also_drop=JAW) + assert t1 == t2 == "A2" + + +def test_an_identifier_that_merely_ends_in_a_timepoint_is_left_alone(): + """`PAT1` is a patient, not a jaw plus a timepoint. + + The split is attempted only when the PREFIX is a known jaw token: `pa` is + not one, so nothing happens. Widening it to any camelCase-ish boundary + would start eating patient identifiers, which is the failure this function + exists to prevent. + """ + assert pairing.patient_stem("PAT1.vtk", also_drop=JAW) == "PAT1" + + +def test_the_separated_spelling_still_works(): + """The common case, unchanged: separators do the job on their own.""" + assert pairing.patient_stem("P1_Upper_T1.vtk", also_drop=JAW) == "P1" + assert pairing.patient_stem("C_0001_T1_Or.nii.gz") == "C_0001" + + +def test_a_lone_jaw_or_timepoint_token_is_not_split(): + """Nothing to split when the token is already one thing.""" + assert pairing.patient_stem("Lower_gold.vtk", also_drop=JAW) == "gold" + assert pairing.patient_stem("subject_T0.nii.gz") == "subject" + + +def test_the_split_only_fires_when_both_halves_are_droppable(): + """A jaw token glued to something that is not a timepoint stays whole.""" + # `upperx` is not jaw+timepoint, so it survives as one token. + assert pairing.patient_stem("A2_UpperX.vtk", also_drop=JAW) == "A2_UpperX" From 7216a3dbd81624a7dc973c8bbd6f38c8a906fba8 Mon Sep 17 00:00:00 2001 From: Jules GRIVOT PELISSON Date: Wed, 19 Aug 2026 08:10:49 -0400 Subject: [PATCH 04/19] FIX: restore ALI_IOS's missing import, torch helpers, monai dependency and orphaned semaphore --- tools/ALI/ALI_IOS/pyproject.toml | 4 ++ .../ALI/ALI_IOS/src/sadt_ali_ios/dispatch.py | 9 ++++ tools/ALI/ALI_IOS/src/sadt_ali_ios/engine.py | 37 +++++++------- tools/ALI/ALI_IOS/src/sadt_ali_ios/render.py | 12 ++--- tools/ALI/ALI_IOS/src/sadt_ali_ios/surface.py | 2 +- .../ALI_IOS/src/sadt_ali_ios/torch_helpers.py | 49 +++++++++++++++++++ tools/ALI/ALI_IOS/uv.lock | 15 ++++++ 7 files changed, 104 insertions(+), 24 deletions(-) create mode 100644 tools/ALI/ALI_IOS/src/sadt_ali_ios/torch_helpers.py diff --git a/tools/ALI/ALI_IOS/pyproject.toml b/tools/ALI/ALI_IOS/pyproject.toml index ca0ad23..7a10a10 100644 --- a/tools/ALI/ALI_IOS/pyproject.toml +++ b/tools/ALI/ALI_IOS/pyproject.toml @@ -22,6 +22,10 @@ dependencies = [ # `operator torchvision::nms does not exist`. "torchvision==0.26.0", "pytorch3d==0.7.9+pt2110cu128", + # The 2D UNet that predicts the per-view masks. A different network from the + # CBCT engine's DenseNet, from the same library -- which is why the split + # does NOT let this tool drop monai. + "monai==1.6.0", "vtk==9.6.2", "numpy==2.3.2", ] diff --git a/tools/ALI/ALI_IOS/src/sadt_ali_ios/dispatch.py b/tools/ALI/ALI_IOS/src/sadt_ali_ios/dispatch.py index d2ae106..46ef214 100644 --- a/tools/ALI/ALI_IOS/src/sadt_ali_ios/dispatch.py +++ b/tools/ALI/ALI_IOS/src/sadt_ali_ios/dispatch.py @@ -17,6 +17,15 @@ import shutil import time +from sadt_ali_common.discovery import ( + IOS, + SURFACE_EXTENSIONS, + VOLUME_EXTENSIONS, + WORK_DIRNAME, + classify, + keyed, +) + from .errors import ToolInputError from . import catalog as ios_catalog diff --git a/tools/ALI/ALI_IOS/src/sadt_ali_ios/engine.py b/tools/ALI/ALI_IOS/src/sadt_ali_ios/engine.py index f2eb81a..0727034 100644 --- a/tools/ALI/ALI_IOS/src/sadt_ali_ios/engine.py +++ b/tools/ALI/ALI_IOS/src/sadt_ali_ios/engine.py @@ -26,7 +26,7 @@ import os import time -from .brain import import_torch, resolve_device +from .torch_helpers import import_torch, resolve_device from .errors import ToolInputError, ToolUnavailableError from sadt_ali_common.markups import MARKUPS_EXTENSION from sadt_ali_common.markups import write as write_markups @@ -439,23 +439,26 @@ def _predict_mucogingival(unet, renderer, mesh, tooth_number, label_name, vertic normal, aim = render.mg_frame(vertices, center, tangent, tooth_number, device) - with _GPU_SEMAPHORE: - images, pix_to_face = render.render_mg_views( - renderer=renderer, - mesh=mesh, - aim=aim, - directions=render.mg_camera_directions(normal, device), - radius=catalog.CAMERA_RADIUS["MG"], - device=device, + # The GPU semaphore went with the split: each tool is its own process now, + # so an in-process one serialised nothing. Its definition was removed and + # this use site survived -- a NameError on the mucogingival path, which had + # never run until AREG_IOSCBCT called it. + images, pix_to_face = render.render_mg_views( + renderer=renderer, + mesh=mesh, + aim=aim, + directions=render.mg_camera_directions(normal, device), + radius=catalog.CAMERA_RADIUS["MG"], + device=device, + ) + # The three views become the 12 channels of ONE input, not a batch of + # three: (1, 3, 4, H, W) -> (1, 12, H, W). + views = images[0].unsqueeze(0) + batch, cameras, channels, height, width = views.shape + with torch.no_grad(): + predictions = unet( + views.reshape(batch, cameras * channels, height, width).float().to(device) ) - # The three views become the 12 channels of ONE input, not a batch of - # three: (1, 3, 4, H, W) -> (1, 12, H, W). - views = images[0].unsqueeze(0) - batch, cameras, channels, height, width = views.shape - with torch.no_grad(): - predictions = unet( - views.reshape(batch, cameras * channels, height, width).float().to(device) - ) # argmax on the raw scores. Casting the logits to int16 first -- what the # crown path inherited and this deliberately does not -- truncates every diff --git a/tools/ALI/ALI_IOS/src/sadt_ali_ios/render.py b/tools/ALI/ALI_IOS/src/sadt_ali_ios/render.py index d9ba41b..6cd9fbd 100644 --- a/tools/ALI/ALI_IOS/src/sadt_ali_ios/render.py +++ b/tools/ALI/ALI_IOS/src/sadt_ali_ios/render.py @@ -174,7 +174,7 @@ def arch_tangent(labels, vertices, tooth_number: int, device): missing and a one-sided difference is used. Returns None when neither neighbour is present -- the caller then falls back to the radial direction. """ - from .brain import import_torch + from .torch_helpers import import_torch torch = import_torch() @@ -208,7 +208,7 @@ def mg_frame(vertices, center, tangent, tooth_number: int, device): aim point is where the landmark is expected (`MG_AIM_OFFSET`), so the cameras frame the gingival margin rather than the crown. """ - from .brain import import_torch + from .torch_helpers import import_torch from . import catalog @@ -248,7 +248,7 @@ def mg_camera_directions(normal, device): the training code: the network sees three images in a fixed order and a different geometry is a different input. """ - from .brain import import_torch + from .torch_helpers import import_torch torch = import_torch() @@ -275,7 +275,7 @@ def render_mg_views(renderer, mesh, aim, directions, radius: float, device): camera is baked into the meshes it is handed; here the cameras differ per view, so the pairing has to be explicit. """ - from .brain import import_torch + from .torch_helpers import import_torch torch = import_torch() p3d = import_pytorch3d() @@ -319,7 +319,7 @@ def estimate_missing_teeth(labels, vertices, wanted, device) -> dict: extrapolation is not trustworthy and `{}` is returned -- the caller then skips those teeth, which is what happened to all of them before. """ - from .brain import import_torch + from .torch_helpers import import_torch torch = import_torch() @@ -359,7 +359,7 @@ def render_views(renderer, mesh, center, radius: float, camera_positions, device each rendered pixel came from, which is how a predicted mask gets back onto the mesh. """ - from .brain import import_torch + from .torch_helpers import import_torch torch = import_torch() p3d = import_pytorch3d() diff --git a/tools/ALI/ALI_IOS/src/sadt_ali_ios/surface.py b/tools/ALI/ALI_IOS/src/sadt_ali_ios/surface.py index 3e5f97f..a54d8f5 100644 --- a/tools/ALI/ALI_IOS/src/sadt_ali_ios/surface.py +++ b/tools/ALI/ALI_IOS/src/sadt_ali_ios/surface.py @@ -136,7 +136,7 @@ def surface_properties(scaled_surface, device): found and the run ends reporting no landmarks with no reason given. The engine checks every mesh for labels up front, so this is the backstop. """ - from .brain import import_torch + from .torch_helpers import import_torch torch = import_torch() _, vtk_to_numpy = import_vtk() diff --git a/tools/ALI/ALI_IOS/src/sadt_ali_ios/torch_helpers.py b/tools/ALI/ALI_IOS/src/sadt_ali_ios/torch_helpers.py new file mode 100644 index 0000000..d436b44 --- /dev/null +++ b/tools/ALI/ALI_IOS/src/sadt_ali_ios/torch_helpers.py @@ -0,0 +1,49 @@ +"""The two torch helpers both ALI engines need, and nothing else. + +Duplicated from the CBCT engine's `brain.py` rather than shared, and the split +between the two is the point: `brain.py` also holds the deep-RL agent and the +monai DenseNet it walks the volume with. This engine uses monai too -- a 2D +UNet, in `engine.py` -- but a different network entirely, so importing +`brain.py` for two eleven-line guards would pull in the agent as well. + +`resolve_device`'s own docstring already said "shared by both engines" before +the split -- what it was NOT was shared through a package. These are eleven +lines of guard; a copy costs nothing and an import would couple two virtualenvs +pinned to different torch versions. +""" + +import logging + +from .errors import ToolUnavailableError + +logger = logging.getLogger(__name__) + +# Names THIS tool, not the one it was copied from: an intraoral run that cannot +# import torch should be told where to fix it. +_INSTALL_HINT = ( + "ALI's intraoral engine needs torch and pytorch3d. Run `uv sync` in " + "tools/ALI/ALI_IOS." +) + +def import_torch(): + try: + import torch + except ImportError as exc: # pragma: no cover - depends on the deployment + raise ToolUnavailableError(f"{_INSTALL_HINT} (missing: torch)") from exc + return torch + + +def resolve_device(requested: str = None) -> str: + """The device to actually use, falling back to CPU when CUDA is absent. + + Decided once, from the caller's `device` argument, rather than by + `torch.cuda.is_available()` deep in the code -- which the original did + independently in five modules, so a run asked for CPU still used a card + that happened to be present. Shared by both engines. + """ + torch = import_torch() + wanted = (requested or "cpu").strip().lower() + if wanted.startswith("cuda") and not torch.cuda.is_available(): + logger.warning("DEVICE=%s requested but CUDA is unavailable; falling back to CPU", wanted) + return "cpu" + return wanted diff --git a/tools/ALI/ALI_IOS/uv.lock b/tools/ALI/ALI_IOS/uv.lock index d4cfd4e..87f7b56 100644 --- a/tools/ALI/ALI_IOS/uv.lock +++ b/tools/ALI/ALI_IOS/uv.lock @@ -253,6 +253,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/6d/69552382fcc8e93d1f2763ef2665980a900a48b7f3a4c57ed290726d1cbc/matplotlib-3.11.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e4b9ac2f1f607ecda2af90a5232beee2af7582fce1cc30c4b6a1b012dc21ee99", size = 10019439, upload-time = "2026-07-18T03:39:43.78Z" }, ] +[[package]] +name = "monai" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "torch" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f4/ac/b14b7bcc888526537698c5f4ff49354eb19bec74dcc30d7e73881a3839a8/monai-1.6.0.tar.gz", hash = "sha256:eded72f0a73531abb6887b9473b081a42c78ec8c80dc5f2da43df159da265b9b", size = 1275977, upload-time = "2026-06-22T16:47:51.668Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/34/7c86652d06062d97bf30752ee5b77d68c78ea984210f1c28848fc1afcef0/monai-1.6.0-202606221745-py3-none-any.whl", hash = "sha256:8880fb294827448a15299f9313b96c057d87179a4efe7ae0c80085f21ec494ea", size = 1612831, upload-time = "2026-06-22T16:47:49.049Z" }, +] + [[package]] name = "mpmath" version = "1.3.0" @@ -558,6 +571,7 @@ name = "sadt-ali-ios" version = "0.1.0" source = { editable = "." } dependencies = [ + { name = "monai" }, { name = "numpy" }, { name = "pytorch3d" }, { name = "sadt-ali-common" }, @@ -574,6 +588,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "monai", specifier = "==1.6.0" }, { name = "numpy", specifier = "==2.3.2" }, { name = "pytorch3d", specifier = "==0.7.9+pt2110cu128", index = "https://imagemindanalytics.github.io/pytorch3d-wheels/simple/" }, { name = "sadt-ali-common", directory = "../common" }, From e22737cb5a62b77ff940e79882b702fbbbe61009 Mon Sep 17 00:00:00 2001 From: Jules GRIVOT PELISSON Date: Wed, 19 Aug 2026 08:11:13 -0400 Subject: [PATCH 05/19] ADD: AREG_IOSCBCT registering an intraoral scan onto a CBCT through the supervisor --- tools/AREG/AREG_IOSCBCT/pyproject.toml | 50 ++ .../src/sadt_areg_ioscbct/__init__.py | 78 +++ .../src/sadt_areg_ioscbct/dispatch.py | 255 ++++++++ .../src/sadt_areg_ioscbct/geometry.py | 145 +++++ .../src/sadt_areg_ioscbct/layout.py | 44 ++ .../src/sadt_areg_ioscbct/pipeline.py | 153 +++++ .../src/sadt_areg_ioscbct/tools.py | 185 ++++++ tools/AREG/AREG_IOSCBCT/uv.lock | 580 ++++++++++++++++++ .../common/src/sadt_areg_common/catalogs.py | 19 +- 9 files changed, 1508 insertions(+), 1 deletion(-) create mode 100644 tools/AREG/AREG_IOSCBCT/pyproject.toml create mode 100644 tools/AREG/AREG_IOSCBCT/src/sadt_areg_ioscbct/__init__.py create mode 100644 tools/AREG/AREG_IOSCBCT/src/sadt_areg_ioscbct/dispatch.py create mode 100644 tools/AREG/AREG_IOSCBCT/src/sadt_areg_ioscbct/geometry.py create mode 100644 tools/AREG/AREG_IOSCBCT/src/sadt_areg_ioscbct/layout.py create mode 100644 tools/AREG/AREG_IOSCBCT/src/sadt_areg_ioscbct/pipeline.py create mode 100644 tools/AREG/AREG_IOSCBCT/src/sadt_areg_ioscbct/tools.py create mode 100644 tools/AREG/AREG_IOSCBCT/uv.lock diff --git a/tools/AREG/AREG_IOSCBCT/pyproject.toml b/tools/AREG/AREG_IOSCBCT/pyproject.toml new file mode 100644 index 0000000..2cdf91d --- /dev/null +++ b/tools/AREG/AREG_IOSCBCT/pyproject.toml @@ -0,0 +1,50 @@ +[project] +name = "sadt-areg-ioscbct" +version = "0.1.0" +description = "Register an intraoral scan onto a CBCT of the same patient." +requires-python = ">=3.11,<3.12" +# No torch, no pytorch3d, no nnUNet -- and that is the whole design. This tool +# predicts nothing: the landmarks it registers on are produced by ALI_CBCT and +# ALI_IOS, the tooth labels by Crown_Seg and the orientation by ASO, each in its +# own virtualenv, reached through the supervisor. What is left here is geometry: +# a landmark-based alignment and a point-to-plane ICP. +# +# That is why it can call both an engine pinned to torch 2.8 (AREG_CBCT's side +# of the family) and one pinned to 2.11 (AREG_IOS's) without being pinned to +# either. Containing both stacks would have reintroduced exactly the defect the +# split removed. +dependencies = [ + "sadt-areg-common", + # >=0.45, not the 0.44.2 upstream carries: 0.44 pins vtk<9.4.0 while the + # rest of this family is on 9.6.2, and one tool holding the whole repo to an + # older vtk is the shape the split exists to avoid. 0.45 lifted that bound. + "pyvista>=0.45", + "SimpleITK==2.5.6", + "numpy==2.3.2", + "scipy==1.16.2", + "vtk==9.6.2", +] + +[tool.sadt] +tool = true +name = "AREG_IOSCBCT" + +[dependency-groups] +dev = ["pytest==8.3.4", "sadt-testkit"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/sadt_areg_ioscbct"] + +[tool.uv.sources] +sadt-areg-common = { path = "../common" } +sadt-testkit = { path = "../../../testkit", editable = true } + +[tool.pytest.ini_options] +testpaths = ["tests"] +markers = [ + "models: needs the real bundles the supervised tools run with.", +] diff --git a/tools/AREG/AREG_IOSCBCT/src/sadt_areg_ioscbct/__init__.py b/tools/AREG/AREG_IOSCBCT/src/sadt_areg_ioscbct/__init__.py new file mode 100644 index 0000000..d215597 --- /dev/null +++ b/tools/AREG/AREG_IOSCBCT/src/sadt_areg_ioscbct/__init__.py @@ -0,0 +1,78 @@ +"""AREG_IOSCBCT -- register an intraoral scan onto a CBCT of the same patient. + +NOT longitudinal, unlike its two siblings: AREG_CBCT and AREG_IOS take a +baseline and a follow-up of one modality, this takes ONE timepoint imaged two +ways. Upstream's own test set says so -- `P001_T2_U.vtk` beside +`P_0001_T2.nii.gz`, both T2 -- which is why the arguments are `ios` and `cbct` +rather than `t1` and `t2`. + +**It has no engine of its own, and that is the design.** No torch, no pytorch3d, +no nnUNet: the landmarks come from ALI_CBCT and ALI_IOS, the tooth labels from +Crown_Seg and the orientation from ASO, each in its own virtualenv, reached +through the supervisor. Containing both stacks would mean one environment +holding torch 2.8 for one half and 2.11 for the other -- the exact defect the +AREG and ALI splits removed. What is left here is geometry. +""" + +from pathlib import Path +from typing import Literal + +from .dispatch import main + + +def run( + ios: Path, + cbct: Path, + output_dir: Path, + automation: Literal[ + "Registration", "Semi-Automated", "Fully-Automated" + ] = "Registration", + ios_landmarks: Path = "", + cbct_landmarks: Path = "", + cbct_reference: Path = "", + landmark_model: Path = "", + ios_landmark_model: Path = "", + crown_model: Path = "", + max_dist: float = 0.0, + output_suffix: str = "Reg", + *, + sup=None, +) -> Path: + """Register an intraoral scan onto a CBCT of the same patient. + + Args: + ios: The intraoral surfaces (.vtk/.stl), one folder, searched + recursively. Upper and lower arches are registered separately and + matched to their landmarks by the jaw token in the name. + cbct: The CBCT volumes, one folder. Paired to the intraoral scans on the + 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. + 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 + first, which needs a reference. + ios_landmarks: Registration mode. Your own intraoral landmarks. + cbct_landmarks: Registration mode. Your own CBCT landmarks. + cbct_reference: Fully-Automated only. The orientation reference. + landmark_model: The CBCT landmark bundle, for the modes that predict. + ios_landmark_model: The intraoral landmark bundle. + crown_model: The crown-labelling checkpoint, for the modes that label. + max_dist: How far a point may be from its nearest neighbour and still + count as an ICP correspondence, in millimetres. 0 uses 1.5, which is + upstream's. + output_suffix: Added to each output name, e.g. `scan_Reg.vtk`. + + Returns: + The output directory. + """ + output_dir = Path(output_dir) + main( + ios=ios, cbct=cbct, output_dir=output_dir, automation=automation, + ios_landmarks=ios_landmarks, cbct_landmarks=cbct_landmarks, + cbct_reference=cbct_reference, landmark_model=landmark_model, + ios_landmark_model=ios_landmark_model, crown_model=crown_model, + max_dist=max_dist, output_suffix=output_suffix, sup=sup, + ) + return output_dir diff --git a/tools/AREG/AREG_IOSCBCT/src/sadt_areg_ioscbct/dispatch.py b/tools/AREG/AREG_IOSCBCT/src/sadt_areg_ioscbct/dispatch.py new file mode 100644 index 0000000..16294af --- /dev/null +++ b/tools/AREG/AREG_IOSCBCT/src/sadt_areg_ioscbct/dispatch.py @@ -0,0 +1,255 @@ +"""Everything AREG_IOSCBCT does around the registration itself. + +Three modes, and they differ only in where the landmarks come from: + + Registration you supply both sets. Nothing is predicted, no other + tool is called, and this needs no GPU at all. + Semi-Automated the intraoral meshes are labelled and the landmarks + predicted; the CBCT is taken as it is. + Fully-Automated the CBCT is oriented first, then both sides predicted. + +That progression is why this tool has no engine of its own: each step it does +not do itself is a `sup.run()` into a tool that has one. +""" + +import json +import logging +import os +import shutil +import time + +import numpy as np + +from sadt_areg_common import catalogs +from sadt_areg_common.errors import ToolInputError + +from . import geometry, pipeline, tools + +logger = logging.getLogger(__name__) + +MODALITY = catalogs.MODALITY_IOSCBCT +REPORT_NAME = "AREG_report.json" +WORK_DIRNAME = ".areg_work" + + +def _surface_points(path: str): + """The mesh's points, and the mesh, read once.""" + import vtk + from vtk.util.numpy_support import vtk_to_numpy + + reader = vtk.vtkPolyDataReader() if path.lower().endswith(".vtk") else vtk.vtkSTLReader() + reader.SetFileName(path) + if hasattr(reader, "ReadAllScalarsOn"): + reader.ReadAllScalarsOn() + reader.Update() + surface = reader.GetOutput() + return surface, vtk_to_numpy(surface.GetPoints().GetData()) + + +def _write_surface(surface, points, path: str) -> None: + import vtk + from vtk.util.numpy_support import numpy_to_vtk + + moved = vtk.vtkPolyData() + moved.DeepCopy(surface) + array = numpy_to_vtk(np.ascontiguousarray(points, dtype=float), deep=True) + vtk_points = vtk.vtkPoints() + vtk_points.SetData(array) + moved.SetPoints(vtk_points) + + os.makedirs(os.path.dirname(path), exist_ok=True) + writer = vtk.vtkPolyDataWriter() + writer.SetFileName(path) + writer.SetInputData(moved) + # Binary, not the writer's ASCII default: it round-trips float32 exactly + # while ASCII prints six significant digits, and it parses far faster. + writer.SetFileTypeToBinary() + writer.Write() + + +def _landmarks_by_jaw(directory: str) -> dict: + """`{jaw hint: {label: position}}` for every landmark file in a folder.""" + found = {} + if not directory or not os.path.isdir(directory): + return found + for root, _dirs, files in os.walk(directory): + for name in sorted(files): + if not name.lower().endswith(".json"): + continue + path = os.path.join(root, name) + points = pipeline.read_landmarks(path) + if points: + found[name] = points + return found + + +_JAW_TOKENS = {"u", "upper", "l", "lower"} + + +def _match_landmarks(mesh_path: str, candidates: dict) -> dict: + """The landmark set belonging to this mesh. + + Two shapes, because the two sides genuinely differ and only one of them can + name a jaw: + + - **per-jaw files**, which is what ALI_IOS writes (`..._U_lm_Pred.mrk.json`) + and what upstream's own RegTestFiles carry on both sides. Matched on the + jaw token, never on sort order: pairing by position is how an upper mesh + gets registered against a lower arch's points. + - **one file for everything**, which is what ALI_CBCT writes. A CBCT covers + both arches in one volume, so its landmark file has no jaw to name. The + labels themselves carry it -- `UR1O` against `LR1O` -- and + `shared_landmarks` intersects, so the upper mesh takes the upper points + out of the same file the lower mesh takes the lower ones from. + + So a jaw match wins when there is one, and a single unlabelled file is + accepted as covering every jaw rather than refused. + """ + from sadt_areg_common import pairing + + mesh_tokens = set(pairing.tokens(os.path.basename(mesh_path))) + for name, points in sorted(candidates.items()): + if mesh_tokens & set(pairing.tokens(name)) & _JAW_TOKENS: + return points + + unlabelled = [ + points for name, points in sorted(candidates.items()) + if not (set(pairing.tokens(name)) & _JAW_TOKENS) + ] + if len(unlabelled) == 1: + return unlabelled[0] + if len(candidates) == 1: + return next(iter(candidates.values())) + return {} + + +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) -> None: + """The registration proper, once every landmark exists.""" + paired, unpaired = pipeline.discover(ios_dir, cbct_dir) + report["unpaired"] = unpaired + + ios_landmarks = _landmarks_by_jaw(ios_landmark_dir) + cbct_landmarks = _landmarks_by_jaw(cbct_landmark_dir) + if not ios_landmarks or not cbct_landmarks: + raise ToolInputError( + "Both landmark folders must hold at least one file: found " + f"{len(ios_landmarks)} intraoral and {len(cbct_landmarks)} CBCT." + ) + + for patient, data in paired.items(): + entry = {"cbct": os.path.basename(data["cbct"]), "meshes": {}} + for mesh_path in data["ios"]: + name = os.path.basename(mesh_path) + try: + moving = _match_landmarks(mesh_path, ios_landmarks) + fixed = _match_landmarks(mesh_path, cbct_landmarks) + if not moving or not fixed: + raise ToolInputError( + "No landmark file matches this mesh's jaw on " + f"{'the intraoral' if not moving else 'the CBCT'} side." + ) + surface, points = _surface_points(mesh_path) + matrix, detail = pipeline.register_one( + points, moving, fixed, max_dist=max_dist + ) + destination = os.path.join( + output_dir, patient, f"{os.path.splitext(name)[0]}_{suffix}.vtk" + ) + _write_surface(surface, geometry.apply(points, matrix), destination) + np.save(destination.replace(".vtk", "_matrix.npy"), matrix) + entry["meshes"][name] = dict( + detail, status="ok", output=os.path.relpath(destination, output_dir) + ) + except Exception as exc: # noqa: BLE001 - one mesh must not cost the batch + logger.exception("AREG_IOSCBCT failed on one mesh") + entry["meshes"][name] = {"status": "failed", "error": str(exc)} + registered = [m for m in entry["meshes"].values() if m["status"] == "ok"] + entry["status"] = "ok" if registered else "failed" + report["patients"][patient] = entry + + produced = [p for p in report["patients"].values() if p["status"] == "ok"] + if not produced: + raise RuntimeError( + "AREG_IOSCBCT registered no mesh for any patient. The per-mesh errors " + "are in the report." + ) + + +def main(ios, cbct, output_dir, automation=None, ios_landmarks=None, cbct_landmarks=None, + cbct_reference=None, landmark_model=None, ios_landmark_model=None, + crown_model=None, max_dist=None, output_suffix="Reg", sup=None): + """Validate, fetch whatever the mode does not supply, then register.""" + started_at = time.monotonic() + automation = str(automation or catalogs.AUTOMATION_REGISTRATION) + allowed = catalogs.AUTOMATION_BY_MODALITY[MODALITY] + if automation not in allowed: + raise ToolInputError( + f"'{automation}' is not a mode {MODALITY} has. It offers: {', '.join(allowed)}." + ) + + suffix = (output_suffix or "Reg").strip() or "Reg" + if os.sep in suffix or (os.altsep and os.altsep in suffix): + raise ToolInputError("'output_suffix' is a name fragment, not a path.") + + output_dir = os.path.abspath(str(output_dir)) + os.makedirs(output_dir, exist_ok=True) + work_dir = os.path.join(output_dir, WORK_DIRNAME) + os.makedirs(work_dir, exist_ok=True) + + report = { + "modality": MODALITY, + "automation": automation, + "output_suffix": suffix, + "patients": {}, + } + + try: + ios_root, cbct_root = str(ios), str(cbct) + ios_lm = str(ios_landmarks) if ios_landmarks else None + cbct_lm = str(cbct_landmarks) if cbct_landmarks else None + + if automation != catalogs.AUTOMATION_REGISTRATION: + # Everything the caller did not supply is fetched from the tool that + # produces it. Checked up front so a request that cannot work comes + # back in a second rather than after the first prediction. + for name in ("Crown_Seg", "ALI_IOS", "ALI_CBCT"): + tools.require(sup, name, f"{automation} IOSCBCT registration") + if automation == catalogs.AUTOMATION_FULLY: + tools.require(sup, "ASO", "Fully-Automated IOSCBCT registration") + if not cbct_reference: + raise ToolInputError( + "Fully-Automated orients the CBCT first and needs " + "'cbct_reference'." + ) + cbct_root = tools.orient_cbct(sup, cbct_root, cbct_reference, landmark_model) + + labelled = tools.label_crowns(sup, ios_root, crown_model or "") + if not ios_lm: + ios_lm = tools.predict_ios_landmarks(sup, labelled, ios_landmark_model or "") + if not cbct_lm: + cbct_lm = tools.predict_cbct_landmarks(sup, cbct_root, landmark_model or "") + ios_root = labelled + + if not ios_lm or not cbct_lm: + raise ToolInputError( + "The Registration mode takes the landmarks already computed: send " + "both 'ios_landmarks' and 'cbct_landmarks', or use a mode that " + "predicts them." + ) + + register( + ios_dir=ios_root, cbct_dir=cbct_root, + ios_landmark_dir=ios_lm, cbct_landmark_dir=cbct_lm, + output_dir=output_dir, suffix=suffix, report=report, + max_dist=float(max_dist) if max_dist else geometry.DEFAULT_MAX_DIST, + ) + finally: + shutil.rmtree(work_dir, ignore_errors=True) + + report["duration_seconds"] = round(time.monotonic() - started_at, 2) + with open(os.path.join(output_dir, REPORT_NAME), "w", encoding="utf-8") as handle: + json.dump(report, handle, indent=2) + logger.info("AREG_IOSCBCT: %d patient(s) in %.1fs", + len(report["patients"]), report["duration_seconds"]) + return output_dir diff --git a/tools/AREG/AREG_IOSCBCT/src/sadt_areg_ioscbct/geometry.py b/tools/AREG/AREG_IOSCBCT/src/sadt_areg_ioscbct/geometry.py new file mode 100644 index 0000000..b7ec11f --- /dev/null +++ b/tools/AREG/AREG_IOSCBCT/src/sadt_areg_ioscbct/geometry.py @@ -0,0 +1,145 @@ +"""The registration itself: landmarks first, then a point-to-plane ICP. + +Ported from upstream's `AREG_IOSCBCT/AREG_IOSCBCT.py`, which is a Slicer CLI +module. Nothing here predicts anything -- the landmarks, the tooth labels and +the orientation all arrive as inputs, produced by other tools. That is what +lets this tool depend on neither torch nor pytorch3d while driving engines +pinned to both. + +Two stages, and the order is load-bearing: the landmark transform puts the two +meshes in roughly the same place so the ICP starts inside its capture range. An +ICP started on unaligned meshes converges to whatever local minimum it happens +to reach. +""" + +import logging + +import numpy as np + +logger = logging.getLogger(__name__) + +# How far a point may be from its nearest neighbour and still count as a +# correspondence, in millimetres. Upstream's value. +DEFAULT_MAX_DIST = 1.5 + +# Upstream's loop bounds, kept as they are: the thresholds decide when the +# registration stops moving, and changing them changes results. +_MAX_ITERATIONS = 2000 +_RMSE_THRESHOLD = 1e-8 +_FITNESS_THRESHOLD = 1e-8 + + +def align_by_landmarks(moving_points: np.ndarray, moving_lms, fixed_lms) -> np.ndarray: + """The 4x4 that best maps `moving_lms` onto `fixed_lms`, rigid. + + `vtkLandmarkTransform` in RigidBody mode, which is a closed-form fit rather + than a search: same landmarks in, same matrix out, every time. + """ + import vtk + + if len(moving_lms) != len(fixed_lms): + raise ValueError( + "Landmark alignment needs the same points on both sides: " + f"{len(moving_lms)} moving against {len(fixed_lms)} fixed." + ) + if len(moving_lms) < 3: + raise ValueError( + f"Landmark alignment needs at least 3 shared points, got {len(moving_lms)}." + ) + + source, target = vtk.vtkPoints(), vtk.vtkPoints() + for point in moving_lms: + source.InsertNextPoint(*point) + for point in fixed_lms: + target.InsertNextPoint(*point) + + transform = vtk.vtkLandmarkTransform() + transform.SetSourceLandmarks(source) + transform.SetTargetLandmarks(target) + transform.SetModeToRigidBody() + transform.Update() + + matrix = np.eye(4) + vtk_matrix = transform.GetMatrix() + for row in range(4): + for column in range(4): + matrix[row, column] = vtk_matrix.GetElement(row, column) + return matrix + + +def icp_point_to_point(moving_points: np.ndarray, fixed_points: np.ndarray, + max_dist: float = DEFAULT_MAX_DIST) -> tuple: + """Refine an alignment; return `(4x4 matrix, {rmse, fitness, iterations})`. + + **Renamed from upstream's `run_icp_point_to_plane`, which is not what it + computes.** The update below is the point-to-point SVD -- centre both point + sets, take the SVD of their covariance, repair a reflection. A true + point-to-plane step minimises distance along the fixed surface's NORMALS and + solves a linearised 6x6 system; upstream computes the fixed mesh's normals + and then never uses them. + + The arithmetic is kept exactly as upstream wrote it: this is a repackaging, + and swapping the estimator would change every result the tool has produced. + Only the name is corrected, which changes nothing and stops the next reader + trusting a label that contradicts the code under it. + + Returns the metrics as well as the matrix, so a caller can report whether + the registration actually converged rather than only that it returned. + """ + from scipy.spatial import cKDTree + + if len(moving_points) < 3 or len(fixed_points) < 3: + raise ValueError("ICP needs at least 3 points on each mesh.") + + transformation = np.eye(4) + current = np.asarray(moving_points, dtype=float).copy() + tree = cKDTree(np.asarray(fixed_points, dtype=float)) + + previous_rmse, previous_fitness = np.inf, 0.0 + rmse, fitness, iteration = np.inf, 0.0, 0 + + for iteration in range(_MAX_ITERATIONS): + distances, indices = tree.query(current, k=1) + valid = distances < max_dist + if valid.sum() < 3: + logger.warning("ICP stopped at iteration %d: fewer than 3 correspondences", iteration) + break + + rmse = float(np.sqrt(np.mean(distances[valid] ** 2))) + fitness = float(valid.sum() / len(current)) + if (abs(previous_rmse - rmse) < _RMSE_THRESHOLD + and abs(previous_fitness - fitness) < _FITNESS_THRESHOLD): + break + previous_rmse, previous_fitness = rmse, fitness + + source = current[valid] + target = np.asarray(fixed_points, dtype=float)[indices[valid]] + source_centre, target_centre = source.mean(axis=0), target.mean(axis=0) + covariance = (source - source_centre).T @ (target - target_centre) + u, _s, vt = np.linalg.svd(covariance) + rotation = vt.T @ u.T + if np.linalg.det(rotation) < 0: + # A reflection is not a rigid motion: flipping the smallest singular + # vector is the standard repair, and without it a mesh can come back + # mirrored with a perfectly good RMSE. + vt[-1, :] *= -1 + rotation = vt.T @ u.T + translation = target_centre - rotation @ source_centre + + step = np.eye(4) + step[:3, :3] = rotation + step[:3, 3] = translation + transformation = step @ transformation + current = (rotation @ current.T).T + translation + + return transformation, { + "rmse": rmse, + "fitness": fitness, + "iterations": iteration + 1, + } + + +def apply(points: np.ndarray, matrix: np.ndarray) -> np.ndarray: + """`points` through a 4x4, as a new array.""" + points = np.asarray(points, dtype=float) + return (matrix[:3, :3] @ points.T).T + matrix[:3, 3] diff --git a/tools/AREG/AREG_IOSCBCT/src/sadt_areg_ioscbct/layout.py b/tools/AREG/AREG_IOSCBCT/src/sadt_areg_ioscbct/layout.py new file mode 100644 index 0000000..345597c --- /dev/null +++ b/tools/AREG/AREG_IOSCBCT/src/sadt_areg_ioscbct/layout.py @@ -0,0 +1,44 @@ +"""How a client should lay this tool's panel out. Presentation only.""" + +from sadt_areg_common import catalogs + +_INPUTS = "Inputs" +_LANDMARKS = "Landmarks" +_MODELS = "Models" +_OUTPUTS = "Outputs" + +# Registration takes the landmarks; the other two predict them. Showing the +# landmark folders in a mode that overwrites them is how a user comes to believe +# their files were used. +_SUPPLIED = {"automation": catalogs.AUTOMATION_REGISTRATION} +_PREDICTED = {"automation": [catalogs.AUTOMATION_SEMI, catalogs.AUTOMATION_FULLY]} +_ORIENTED = {"automation": catalogs.AUTOMATION_FULLY} + +LAYOUT = { + "ios": {"section": _INPUTS, "label": "Intraoral scans"}, + "cbct": {"section": _INPUTS, "label": "CBCT volumes"}, + "automation": {"section": _INPUTS, "label": "Mode"}, + + "ios_landmarks": { + "section": _LANDMARKS, "label": "Intraoral landmarks", "visible_when": _SUPPLIED, + }, + "cbct_landmarks": { + "section": _LANDMARKS, "label": "CBCT landmarks", "visible_when": _SUPPLIED, + }, + + "crown_model": { + "section": _MODELS, "label": "Crown segmentation model", "visible_when": _PREDICTED, + }, + "ios_landmark_model": { + "section": _MODELS, "label": "Intraoral landmark bundle", "visible_when": _PREDICTED, + }, + "landmark_model": { + "section": _MODELS, "label": "CBCT landmark bundle", "visible_when": _PREDICTED, + }, + "cbct_reference": { + "section": _MODELS, "label": "Orientation reference", "visible_when": _ORIENTED, + }, + + "max_dist": {"section": _OUTPUTS, "label": "ICP match distance (mm)"}, + "output_suffix": {"section": _OUTPUTS, "label": "Output suffix"}, +} diff --git a/tools/AREG/AREG_IOSCBCT/src/sadt_areg_ioscbct/pipeline.py b/tools/AREG/AREG_IOSCBCT/src/sadt_areg_ioscbct/pipeline.py new file mode 100644 index 0000000..20f8705 --- /dev/null +++ b/tools/AREG/AREG_IOSCBCT/src/sadt_areg_ioscbct/pipeline.py @@ -0,0 +1,153 @@ +"""Pair an intraoral scan with a CBCT of the same patient, and register it. + +NOT longitudinal, unlike its two siblings. AREG_CBCT and AREG_IOS take a +baseline and a follow-up of one modality; this takes ONE timepoint imaged two +ways, and puts the intraoral scan into the CBCT's frame. Upstream's own test +set says so plainly -- `P001_T2_U.vtk` beside `P_0001_T2.nii.gz`, both T2. + +That is why the arguments are `ios` and `cbct` rather than `t1` and `t2`, and +why `pairing.pair()` is not what pairs them: there is no timepoint to strip, +only a patient to match across two naming conventions. +""" + +import json +import logging +import os + +import numpy as np + +from sadt_areg_common import pairing +from sadt_areg_common.errors import ToolInputError + +from . import geometry + +logger = logging.getLogger(__name__) + +SURFACE_EXTENSIONS = (".vtk", ".stl") +LANDMARK_EXTENSIONS = (".json", ".mrk.json") + + +def _patient_key(filename: str) -> str: + """`P001_T2_U.vtk` and `P_0001_T2.nii.gz` are the same patient. + + The two modalities are named by different conventions -- the intraoral files + by the scanner, the CBCT by the acquisition -- so the digits are what they + genuinely share. Everything that is not a digit is dropped and leading zeros + go with it, which makes `P001` and `P_0001` both `1`. + + Deliberately cruder than `pairing.patient_stem`, and only used here: that + function matches two files that came from the SAME source and can rely on a + shared stem. Across modalities there is no shared stem to rely on. + """ + stem = pairing.split_scan_extension(os.path.basename(filename))[0] + digits = "".join(character for character in stem if character.isdigit()) + # The trailing timepoint digit is part of the name, not the patient: strip + # the tokens that name one before reducing to digits. + tokens = [t for t in pairing.tokens(stem) if t not in ("t0", "t1", "t2")] + digits = "".join(c for token in tokens for c in token if c.isdigit()) + return digits.lstrip("0") or digits or stem + + +def discover(ios_dir: str, cbct_dir: str) -> dict: + """`{patient: {"ios": [paths], "cbct": path}}`, for what is present in both. + + A patient with only one modality is reported rather than silently dropped: + a batch that registered half of what was sent and said nothing is the + failure this repository keeps finding. + """ + ios: dict = {} + for root, _dirs, files in os.walk(ios_dir): + for name in sorted(files): + if name.lower().endswith(SURFACE_EXTENSIONS): + ios.setdefault(_patient_key(name), []).append(os.path.join(root, name)) + + cbct: dict = {} + for root, _dirs, files in os.walk(cbct_dir): + for name in sorted(files): + if pairing.is_scan_file(name): + cbct[_patient_key(name)] = os.path.join(root, name) + + paired, unpaired = {}, {} + for key in sorted(set(ios) | set(cbct)): + if key in ios and key in cbct: + paired[key] = {"ios": sorted(ios[key]), "cbct": cbct[key]} + else: + unpaired[key] = "no CBCT" if key in ios else "no intraoral scan" + + if not paired: + raise ToolInputError( + "No patient has both an intraoral scan and a CBCT. Found " + f"{len(ios)} intraoral key(s) and {len(cbct)} CBCT key(s): {unpaired}." + ) + if unpaired: + logger.warning("Not registered, only one modality present: %s", unpaired) + return paired, unpaired + + +def read_landmarks(path: str) -> dict: + """`{label: [x, y, z]}` from a Slicer markups file or a plain JSON one. + + Both spellings are in upstream's own test set -- `.mrk.json` for the CBCT + side, `.json` for the intraoral -- so both are read rather than one being + declared canonical. + """ + with open(path, encoding="utf-8") as handle: + payload = json.load(handle) + + points = {} + for markup in payload.get("markups", []): + for control_point in markup.get("controlPoints", []): + label = control_point.get("label") + position = control_point.get("position") + if label and position: + points[label] = [float(value) for value in position] + if points: + return points + + # The plainer shape: {label: [x, y, z]} at the top level. + for label, position in payload.items(): + if isinstance(position, (list, tuple)) and len(position) == 3: + points[label] = [float(value) for value in position] + return points + + +def shared_landmarks(moving: dict, fixed: dict) -> tuple: + """The points both sides name, in one order, plus what was dropped. + + Intersected rather than assumed equal: the two modalities are landmarked by + different networks, and one missing point on one side used to be an + IndexError three frames down instead of a line in a report. + """ + common = sorted(set(moving) & set(fixed)) + dropped = sorted((set(moving) | set(fixed)) - set(common)) + if len(common) < 3: + raise ToolInputError( + f"The two modalities share only {len(common)} landmark(s), and an " + f"alignment needs 3. Intraoral has {sorted(moving)}; CBCT has {sorted(fixed)}." + ) + return ( + np.array([moving[label] for label in common], dtype=float), + np.array([fixed[label] for label in common], dtype=float), + common, + dropped, + ) + + +def register_one(mesh_points: np.ndarray, ios_landmarks: dict, cbct_landmarks: dict, + cbct_points: np.ndarray = None, max_dist: float = geometry.DEFAULT_MAX_DIST): + """Align by landmarks, then refine by ICP when there is a surface to refine on. + + The landmark stage alone is what upstream's "Registration" mode does when no + CBCT surface is available; the ICP is the refinement, and it needs points + sampled from the CBCT rather than the volume itself. + """ + moving, fixed, used, dropped = shared_landmarks(ios_landmarks, cbct_landmarks) + matrix = geometry.align_by_landmarks(mesh_points, moving, fixed) + report = {"landmarks_used": used, "landmarks_dropped": dropped, "icp": None} + + if cbct_points is not None and len(cbct_points) >= 3: + moved = geometry.apply(mesh_points, matrix) + refinement, stats = geometry.icp_point_to_point(moved, cbct_points, max_dist=max_dist) + matrix = refinement @ matrix + report["icp"] = stats + return matrix, report diff --git a/tools/AREG/AREG_IOSCBCT/src/sadt_areg_ioscbct/tools.py b/tools/AREG/AREG_IOSCBCT/src/sadt_areg_ioscbct/tools.py new file mode 100644 index 0000000..19cf191 --- /dev/null +++ b/tools/AREG/AREG_IOSCBCT/src/sadt_areg_ioscbct/tools.py @@ -0,0 +1,185 @@ +"""How this tool reaches the four it depends on, through the supervisor. + +AREG_IOSCBCT predicts nothing itself. The landmarks it registers on come from +ALI_CBCT and ALI_IOS, the tooth labels from Crown_Seg and the orientation from +ASO -- each in its own virtualenv, started as a subprocess by the supervisor. +That is what lets this tool drive an engine pinned to torch 2.8 and another +pinned to 2.11 while depending on neither. + +**Tools are named by string, never imported.** A tool cannot import another -- +separate virtualenvs are the reason the split exists -- so the name is a free +string, and `describe.py` reads THIS FILE to publish the `calls` list the server +checks at startup. Which is why the calls live here rather than in +`../common/`: shared, they would be invisible to that check. + +The mapping from upstream, made deliberately rather than discovered during a +run, because getting it wrong has cost three separate defects already: + + upstream module this tool asks for + CrownSegmentationcli Crown_Seg + ALI_CBCT ALI_CBCT + ALI_IOS ALI_IOS + PRE_ASO_CBCT \\ + SEMI_ASO_CBCT > ASO, with modality and automation as arguments + PRE_ASO_IOS / + +Upstream's three ASO variants are three CLI modules; ours is one tool that +takes the mode as data. And note that `ALI_CBCT` / `ALI_IOS` are upstream's own +names -- our merged `ALI` was the anomaly, which is why an earlier +`sup.run("ALI", ...)` was wrong on both sides of the split. +""" + +import logging +import os + +from sadt_areg_common.errors import ( + SupervisorRequired, + ToolInputError, + ToolUnavailableError, +) + +logger = logging.getLogger(__name__) + +# What to tell a caller who cannot run each tool. The advice is the useful half: +# somebody who cannot reach ALI_CBCT can still send their own CBCT landmarks. +# The CBCT landmarks the cross-modality alignment matches on, verbatim from +# upstream's IOSCBCT parameter dict. Twelve occlusal crown points, three per +# quadrant -- the central incisor, the canine and the first molar -- chosen +# because they are the points visible in BOTH modalities: a crown tip is a crown +# tip whether it was imaged by a CBCT or scanned intra-orally. That is also why +# the intraoral side asks for the Occlusal network alone. +CBCT_LANDMARKS = ( + "UR1O", "UR3O", "UR6O", + "UL1O", "UL3O", "UL6O", + "LR1O", "LR3O", "LR6O", + "LL1O", "LL3O", "LL6O", +) + +_ADVICE = { + "Crown_Seg": ( + "Send meshes that already carry a per-point tooth-label array, and use " + "the Registration mode instead." + ), + "ALI_CBCT": ( + "Send your own CBCT landmarks in 'cbct_landmarks' and use the " + "Registration mode instead." + ), + "ALI_IOS": ( + "Send your own intraoral landmarks in 'ios_landmarks' and use the " + "Registration mode instead." + ), + "ASO": ( + "Orient the CBCT yourself beforehand, and use a mode that takes it " + "already oriented." + ), +} +def require(sup, tool: str, mode: str) -> None: + """Refuse a mode that needs `tool` when there is no way to run it. + + Checked up front, before a single file is read: a request that cannot work + has to come back in a second, not after an hour of registration. + """ + if sup is not None: + return + raise SupervisorRequired( + f"{mode} needs the '{tool}' tool, and nothing here can run it: no supervisor " + f"was supplied. {_ADVICE.get(tool, '')}" + ) + + +def _output(sup, tool: str) -> str: + """A directory of the supervisor's scratch for one callee's results.""" + destination = os.path.join(str(sup.tmp), "tools", tool) + os.makedirs(destination, exist_ok=True) + return destination + + +def _returned(produced) -> str: + """A tool returns a Path, or a dict of named ones; AREG wants a directory.""" + if isinstance(produced, dict): + produced = next(iter(produced.values())) + return str(produced) + + +def label_crowns(sup, mesh_dir: str, model_path: str = "") -> str: + """Label the crowns of every mesh under `mesh_dir`. + + `skip_segmented` is left at its default: a mesh already carrying a + tooth-label array passes through untouched, so a batch mixing segmented and + raw meshes costs network time only for the ones that need it. The original + did that by hand -- `__BypassCrownseg__` copied files into two directories + and merged them afterwards -- which is work that belongs inside the tool + doing the segmenting. + """ + logger.info("AREG: asking 'Crown_Seg' for tooth-labelled meshes") + parameters = { + "meshes": mesh_dir, + "output_dir": _output(sup, "Crown_Seg"), + "suffix": "Seg", + } + if model_path: + parameters["model"] = model_path + return _returned(sup.run("Crown_Seg", **parameters)) + + +def predict_cbct_landmarks(sup, scan_dir: str, model_path: str) -> str: + """The CBCT landmarks the cross-modality alignment registers on. + + Asked for BY NAME, not by region: the registration uses a handful of points, + and asking by region would run every agent of every region containing one of + them. One agent is a full two-scale walk of the volume. + """ + if sup is not None and hasattr(sup, "progress"): + sup.progress(0.1, "predicting CBCT landmarks with ALI_CBCT") + parameters = { + "input": scan_dir, + "output_dir": _output(sup, "ALI_CBCT"), + "landmarks": list(CBCT_LANDMARKS), + "prediction_ID": "Pred", + } + if model_path: + parameters["model"] = model_path + return _returned(sup.run("ALI_CBCT", **parameters)) + + +def predict_ios_landmarks(sup, mesh_dir: str, model_path: str) -> str: + """The intraoral landmarks, the other half of the correspondence. + + `networks` names the occlusal family alone: the cross-modality alignment + matches crown points against their CBCT counterparts, and the cervical and + mucogingival passes would cost a run over every mesh for points nothing + here reads. + """ + if sup is not None and hasattr(sup, "progress"): + sup.progress(0.3, "predicting intraoral landmarks with ALI_IOS") + parameters = { + "input": mesh_dir, + "output_dir": _output(sup, "ALI_IOS"), + "networks": ["Occlusal"], + "prediction_ID": "Pred", + } + if model_path: + parameters["model"] = model_path + return _returned(sup.run("ALI_IOS", **parameters)) + + +def orient_cbct(sup, scan_dir: str, reference_path: str, landmark_model: str = "") -> str: + """Put the CBCT in the reference frame before anything is matched onto it. + + ASO's fully-automated CBCT mode: it predicts its own landmarks and registers + the scan onto the gold reference. Upstream reaches this through three + separate CLI modules (PRE_ASO_CBCT, SEMI_ASO_CBCT, PRE_ASO_IOS); ours is one + tool taking the mode as data. + """ + if sup is not None and hasattr(sup, "progress"): + sup.progress(0.5, "orienting the CBCT with ASO") + parameters = { + "input": scan_dir, + "reference": reference_path, + "output_dir": _output(sup, "ASO"), + "modality": "CBCT", + "automation": "Fully-Automated", + } + if landmark_model: + parameters["landmark_model"] = landmark_model + return _returned(sup.run("ASO", **parameters)) diff --git a/tools/AREG/AREG_IOSCBCT/uv.lock b/tools/AREG/AREG_IOSCBCT/uv.lock new file mode 100644 index 0000000..f20db46 --- /dev/null +++ b/tools/AREG/AREG_IOSCBCT/uv.lock @@ -0,0 +1,580 @@ +version = 1 +revision = 3 +requires-python = "==3.11.*" + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/3f/143b048436775b0f76ac3eec145c019e8173ccc2885c8f20319b996d5e83/charset_normalizer-3.5.1.tar.gz", hash = "sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3", size = 171764, upload-time = "2026-08-15T08:20:44.807Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/b6/034f6802e9c3f6418966cfabb7db8c9252cc2429c5098f41cc43af804149/charset_normalizer-3.5.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:eda059b6bc8bc0812d626fd91a7ce01bf583df0a61296eff390fd94141a34e30", size = 363585, upload-time = "2026-08-15T08:16:46.646Z" }, + { url = "https://files.pythonhosted.org/packages/d5/fa/6a7e2a7c4b5451912b8c417732df79574354443592a88d616de03da66ae5/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa2bb0b37202dca27175591f761108b5d34096ade1191ffe4808bdf6b1571488", size = 251189, upload-time = "2026-08-15T08:16:48.287Z" }, + { url = "https://files.pythonhosted.org/packages/a4/c8/ab42b07cfd82e919f427fcfaa7c41abae8242833ad1aad66d42bae40b669/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b2b1b3fa5670c127b246df1d0c059defd41f689a868a3b9d79df9b1cac42d22", size = 239724, upload-time = "2026-08-15T08:16:49.67Z" }, + { url = "https://files.pythonhosted.org/packages/e7/80/b9348b5d3041209f98b4cdad7655766369233f1d533f4f4f7558e9717bec/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6e5e4d73d588ca5ed09df1b7dcd1b203d1df3c542e3f50d126c947d432b10731", size = 280078, upload-time = "2026-08-15T08:16:51.228Z" }, + { url = "https://files.pythonhosted.org/packages/82/38/083a24028304bc85bb9e376fed801178423dcbb67495f73b6ea0624e1894/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b54e7e13267d49ffbfe68e25b3cbd774dab38fa37238f71265e91b36146eb21c", size = 276650, upload-time = "2026-08-15T08:16:52.625Z" }, + { url = "https://files.pythonhosted.org/packages/0d/35/731ac04aa0a097fc1c97f0994c375bdb230c6c96619db794208fe664e9ce/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c7b742bf31c88566b4bb6335a7f393bb322e580b6bb98df7bd0c25e6e3519ce8", size = 262325, upload-time = "2026-08-15T08:16:54.085Z" }, + { url = "https://files.pythonhosted.org/packages/f5/28/c2028e7021fb89c6e56868ed0e387b8e9aa811abdd2ab3208d6578d2c930/charset_normalizer-3.5.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6ba32c4d2abf1d2fe7cf27d280f4cca5664233b0f885549c7761719eb977f486", size = 261140, upload-time = "2026-08-15T08:16:55.604Z" }, + { url = "https://files.pythonhosted.org/packages/28/f0/0c0ceec6d98b7daa62e361e418135d59685811d79ba11529aad5cdf15e84/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0722590aabf9dc6a6c0343d523c05458fa2b5047dbe6302fd526bb570600753f", size = 252791, upload-time = "2026-08-15T08:16:57.103Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3e/48f4cd187b1c33189d86039e9cbe4f92c05454175504b44ff81806d4d1bf/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:aa1099b956fb795e686d073568f6dc002a0bb89765ea6d5b055dd7d9bf1b116c", size = 240730, upload-time = "2026-08-15T08:16:58.418Z" }, + { url = "https://files.pythonhosted.org/packages/42/85/f9e22af69af67c54cce42be9455d9c81294f918b4ccc454db01f66efcac2/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:bd6c173f04743d483881bffa1478d5a4624475b8cd1d2194956a75548e191c18", size = 280791, upload-time = "2026-08-15T08:16:59.918Z" }, + { url = "https://files.pythonhosted.org/packages/fd/4c/9044135f42127630b6fa742feb51256353f6ab87a78f2fdd1de3de955a7f/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:f298e218441525d3794428b4c8b8fb8662c6d3ea79925d4807ee6b9a96a3bca5", size = 259598, upload-time = "2026-08-15T08:17:01.421Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ed/1dd7cfebb4e75812934c49ca3b79757d11948053f7937ab7070c151f3c55/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:6e2912d4babbc65196ac13c2f53468dc57fb8b9c25ef913e8c59ddf7c6dc0e1b", size = 278217, upload-time = "2026-08-15T08:17:02.782Z" }, + { url = "https://files.pythonhosted.org/packages/bf/eb/239c84503cc9e3ba6eb34686a24bc66e84f3924efdd7e38e751a19f6bc10/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3d27167433c0d5f18dc850f07d0b3816221984fecdc405d6c157a6f0b8f8e9e6", size = 263417, upload-time = "2026-08-15T08:17:04.216Z" }, + { url = "https://files.pythonhosted.org/packages/37/ab/4e4510e1e288478e2c8333131d1c1382382ba8cd2165053c79e39d1da961/charset_normalizer-3.5.1-cp311-cp311-win32.whl", hash = "sha256:ac00177c4831ffa650f8609e4bdddd5fe09c03b1c0c47acece7e6ea20421598b", size = 181774, upload-time = "2026-08-15T08:17:05.58Z" }, + { url = "https://files.pythonhosted.org/packages/e3/57/32f0ccea59e8612057c61d6fd22ef2cb63cca93c9fe594094919696ac170/charset_normalizer-3.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:f9b1e28d0e8dbfa858abdba91d6b547beaf2df1a59bec6da6faae7b96a4991a9", size = 206653, upload-time = "2026-08-15T08:17:07.075Z" }, + { url = "https://files.pythonhosted.org/packages/17/d4/b65c433fc521e58b5f54293982a5e51c05cb5f2dd3f1c7a6acb65b75324e/charset_normalizer-3.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:ae31a1a1db2ee6cc2942fccaf695c934bc7f3db9f2133a3fef1f367cf1a4ab10", size = 185630, upload-time = "2026-08-15T08:17:08.502Z" }, + { url = "https://files.pythonhosted.org/packages/5b/97/fb4e82231aba271ffd775a1b4993b0defc4e3059f286ae41d9433409fe85/charset_normalizer-3.5.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2", size = 331467, upload-time = "2026-08-15T08:19:50.959Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2f/fe3f187327aac18e2d54e9d2b08e15d27bf9b642d9e51c219f130fc34d1a/charset_normalizer-3.5.1-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99", size = 253057, upload-time = "2026-08-15T08:19:52.654Z" }, + { url = "https://files.pythonhosted.org/packages/d7/c7/9e48cee5c161fe24da823b61bf381921d77cb994a0a4de148e95018c1984/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2", size = 240930, upload-time = "2026-08-15T08:19:54.163Z" }, + { url = "https://files.pythonhosted.org/packages/49/e0/716601f3cc69be7b198951150c75ead1ece33c3c8036ff6ffa46029659a0/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:343fb4f2821043bd87095f7b08a1a181febc8e36ac64212143bbfd0a0e1bc235", size = 230822, upload-time = "2026-08-15T08:19:55.807Z" }, + { url = "https://files.pythonhosted.org/packages/d3/05/71bfc5caa0abcc45aea1f6a4d50ac68e59605ddc7666fe8494f4cd229665/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ae4a097991662cd4fff0ddc74e0fe7874f82e00042fa0ea00855645ed0c79598", size = 260037, upload-time = "2026-08-15T08:19:57.312Z" }, + { url = "https://files.pythonhosted.org/packages/c3/92/de7e32ed05341e7a9c4c877c318418197b7f2d66a3b68d561bf2ac57ca3e/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b599739b93b2cbeded49645ae3c8d1405c29ddfbceac1545c87a3f9580a9e96", size = 255097, upload-time = "2026-08-15T08:19:59.056Z" }, + { url = "https://files.pythonhosted.org/packages/f5/7b/ade0a122600319dfa0b1000ab0f9731c94a817904cf3c5de408c73a4ede7/charset_normalizer-3.5.1-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b39b69b347e5e47a3b5b8cfc005c68c1ba347474e3960236c4944a8ecd174962", size = 250166, upload-time = "2026-08-15T08:20:00.612Z" }, + { url = "https://files.pythonhosted.org/packages/75/9c/019fbb9f4834491a160951349b1a3714439376f66e5f7cf18b4f18f0c7aa/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a2028475ba855475b8b4d3cfeb4994269c967aea8b9892dfba907f4263a863a3", size = 241821, upload-time = "2026-08-15T08:20:02.321Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b8/11d4840bfc99330cc7fbcc2681ee5a044553a6e77655508d8f9b2bff7b34/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:36047af20e17097c3bb9476c2b7655f2f7aa51322c0ba58c07695bedf755a950", size = 232529, upload-time = "2026-08-15T08:20:04.008Z" }, + { url = "https://files.pythonhosted.org/packages/18/96/2b3a21492d9f65171ac75d872f5018260013d00bfa0ff70ec9f179148cbd/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c4fb141a727957c93edfe5c32a26ceb6b5f6461d67146e2d39f51e16170bea8", size = 260348, upload-time = "2026-08-15T08:20:05.877Z" }, + { url = "https://files.pythonhosted.org/packages/d6/aa/a69a2028e8bd052476c245460ab19d7de595de084dd968f2d75cd50c3e25/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:2f293479cce755c75f1697e87c409b7ae4c555c7dfecb6e988ad13abba943031", size = 247234, upload-time = "2026-08-15T08:20:07.487Z" }, + { url = "https://files.pythonhosted.org/packages/35/8a/3d130aeabcaf3d2466af76b7b141c08d9e89c9016ab4b7cdd0f7dc2d1c62/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:3588e376b3ea2eea84976f67273d679f229e24c66dce7b82ae45aef04ff6e072", size = 256917, upload-time = "2026-08-15T08:20:09.142Z" }, + { url = "https://files.pythonhosted.org/packages/80/c2/a7379b840292d0c1ab9fbd17d1f3967aa81794dc95bc74be8999d7fedcf7/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e199fb99720074809a7720f1c0b4d919eea8b87e88713e0f8f602f7bef543d9d", size = 254846, upload-time = "2026-08-15T08:20:10.727Z" }, + { url = "https://files.pythonhosted.org/packages/01/65/d43b714731bb2f40d4053dfa00ecfc1c5a301f8e3316c5db3a09af59fe94/charset_normalizer-3.5.1-cp37-abi3-win32.whl", hash = "sha256:dd732602a7009217f658d5863d12d79d373a4de0eebc111094bcdd3bb8e0a6cc", size = 174216, upload-time = "2026-08-15T08:20:12.334Z" }, + { url = "https://files.pythonhosted.org/packages/35/4f/b911ed898b26a09789eba9c9200c999aff6c61b4bafaf4838e56d1a1e1a3/charset_normalizer-3.5.1-cp37-abi3-win_amd64.whl", hash = "sha256:70055ff39b97c99e7ae40ea3e393fb62aa2e44dbd9b29f8d14f42fb0025c3959", size = 199764, upload-time = "2026-08-15T08:20:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/f0/a7/920baf467bfd9bf689f3b318340f37aee4572a71f162bd8db51da55ba4fa/charset_normalizer-3.5.1-cp37-abi3-win_arm64.whl", hash = "sha256:87e4f41d375c0b9be2fb5251aee4b8a689169e134535aed81bf085c3b647451e", size = 287318, upload-time = "2026-08-15T08:20:15.551Z" }, + { url = "https://files.pythonhosted.org/packages/cc/61/d01fc49b8dea277640b55a9e15960dbca9fdc8c9fde18e572d39c59f4019/charset_normalizer-3.5.1-py3-none-any.whl", hash = "sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6", size = 68658, upload-time = "2026-08-15T08:20:43.306Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "contourpy" +version = "1.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/2e/c4390a31919d8a78b90e8ecf87cd4b4c4f05a5b48d05ec17db8e5404c6f4/contourpy-1.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:709a48ef9a690e1343202916450bc48b9e51c049b089c7f79a267b46cffcdaa1", size = 288773, upload-time = "2025-07-26T12:01:02.277Z" }, + { url = "https://files.pythonhosted.org/packages/0d/44/c4b0b6095fef4dc9c420e041799591e3b63e9619e3044f7f4f6c21c0ab24/contourpy-1.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:23416f38bfd74d5d28ab8429cc4d63fa67d5068bd711a85edb1c3fb0c3e2f381", size = 270149, upload-time = "2025-07-26T12:01:04.072Z" }, + { url = "https://files.pythonhosted.org/packages/30/2e/dd4ced42fefac8470661d7cb7e264808425e6c5d56d175291e93890cce09/contourpy-1.3.3-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:929ddf8c4c7f348e4c0a5a3a714b5c8542ffaa8c22954862a46ca1813b667ee7", size = 329222, upload-time = "2025-07-26T12:01:05.688Z" }, + { url = "https://files.pythonhosted.org/packages/f2/74/cc6ec2548e3d276c71389ea4802a774b7aa3558223b7bade3f25787fafc2/contourpy-1.3.3-cp311-cp311-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e999574eddae35f1312c2b4b717b7885d4edd6cb46700e04f7f02db454e67c1", size = 377234, upload-time = "2025-07-26T12:01:07.054Z" }, + { url = "https://files.pythonhosted.org/packages/03/b3/64ef723029f917410f75c09da54254c5f9ea90ef89b143ccadb09df14c15/contourpy-1.3.3-cp311-cp311-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf67e0e3f482cb69779dd3061b534eb35ac9b17f163d851e2a547d56dba0a3a", size = 380555, upload-time = "2025-07-26T12:01:08.801Z" }, + { url = "https://files.pythonhosted.org/packages/5f/4b/6157f24ca425b89fe2eb7e7be642375711ab671135be21e6faa100f7448c/contourpy-1.3.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51e79c1f7470158e838808d4a996fa9bac72c498e93d8ebe5119bc1e6becb0db", size = 355238, upload-time = "2025-07-26T12:01:10.319Z" }, + { url = "https://files.pythonhosted.org/packages/98/56/f914f0dd678480708a04cfd2206e7c382533249bc5001eb9f58aa693e200/contourpy-1.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:598c3aaece21c503615fd59c92a3598b428b2f01bfb4b8ca9c4edeecc2438620", size = 1326218, upload-time = "2025-07-26T12:01:12.659Z" }, + { url = "https://files.pythonhosted.org/packages/fb/d7/4a972334a0c971acd5172389671113ae82aa7527073980c38d5868ff1161/contourpy-1.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:322ab1c99b008dad206d406bb61d014cf0174df491ae9d9d0fac6a6fda4f977f", size = 1392867, upload-time = "2025-07-26T12:01:15.533Z" }, + { url = "https://files.pythonhosted.org/packages/75/3e/f2cc6cd56dc8cff46b1a56232eabc6feea52720083ea71ab15523daab796/contourpy-1.3.3-cp311-cp311-win32.whl", hash = "sha256:fd907ae12cd483cd83e414b12941c632a969171bf90fc937d0c9f268a31cafff", size = 183677, upload-time = "2025-07-26T12:01:17.088Z" }, + { url = "https://files.pythonhosted.org/packages/98/4b/9bd370b004b5c9d8045c6c33cf65bae018b27aca550a3f657cdc99acdbd8/contourpy-1.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:3519428f6be58431c56581f1694ba8e50626f2dd550af225f82fb5f5814d2a42", size = 225234, upload-time = "2025-07-26T12:01:18.256Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b6/71771e02c2e004450c12b1120a5f488cad2e4d5b590b1af8bad060360fe4/contourpy-1.3.3-cp311-cp311-win_arm64.whl", hash = "sha256:15ff10bfada4bf92ec8b31c62bf7c1834c244019b4a33095a68000d7075df470", size = 193123, upload-time = "2025-07-26T12:01:19.848Z" }, + { url = "https://files.pythonhosted.org/packages/a5/29/8dcfe16f0107943fa92388c23f6e05cff0ba58058c4c95b00280d4c75a14/contourpy-1.3.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:cd5dfcaeb10f7b7f9dc8941717c6c2ade08f587be2226222c12b25f0483ed497", size = 278809, upload-time = "2025-07-26T12:02:52.74Z" }, + { url = "https://files.pythonhosted.org/packages/85/a9/8b37ef4f7dafeb335daee3c8254645ef5725be4d9c6aa70b50ec46ef2f7e/contourpy-1.3.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:0c1fc238306b35f246d61a1d416a627348b5cf0648648a031e14bb8705fcdfe8", size = 261593, upload-time = "2025-07-26T12:02:54.037Z" }, + { url = "https://files.pythonhosted.org/packages/0a/59/ebfb8c677c75605cc27f7122c90313fd2f375ff3c8d19a1694bda74aaa63/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70f9aad7de812d6541d29d2bbf8feb22ff7e1c299523db288004e3157ff4674e", size = 302202, upload-time = "2025-07-26T12:02:55.947Z" }, + { url = "https://files.pythonhosted.org/packages/3c/37/21972a15834d90bfbfb009b9d004779bd5a07a0ec0234e5ba8f64d5736f4/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ed3657edf08512fc3fe81b510e35c2012fbd3081d2e26160f27ca28affec989", size = 329207, upload-time = "2025-07-26T12:02:57.468Z" }, + { url = "https://files.pythonhosted.org/packages/0c/58/bd257695f39d05594ca4ad60df5bcb7e32247f9951fd09a9b8edb82d1daa/contourpy-1.3.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:3d1a3799d62d45c18bafd41c5fa05120b96a28079f2393af559b843d1a966a77", size = 225315, upload-time = "2025-07-26T12:02:58.801Z" }, +] + +[[package]] +name = "cycler" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615, upload-time = "2023-10-07T05:32:18.335Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, +] + +[[package]] +name = "cyclopts" +version = "4.23.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "docstring-parser" }, + { name = "rich" }, + { name = "rich-rst" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/62/1b160d5e8c20174392a3a5e3e7e6542e02e6f6922b35ba0962829a6b5c90/cyclopts-4.23.0.tar.gz", hash = "sha256:2f764bbd90f1888073971c09576f90e594f80353588e10aa615b7d59bc009821", size = 195257, upload-time = "2026-08-17T19:44:21.218Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/1f/70aeb4f9a420cb62726d943b0d893c2bf9e9ab9c81a93f7542d3beb5d69c/cyclopts-4.23.0-py3-none-any.whl", hash = "sha256:1581758c5b9982c3b2ae7df6d87243e3a6fc3265ea621f96caeb349b6fa43ad2", size = 234671, upload-time = "2026-08-17T19:44:19.604Z" }, +] + +[[package]] +name = "docstring-parser" +version = "0.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/4d/f332313098c1de1b2d2ff91cf2674415cc7cddab2ca1b01ae29774bd5fdf/docstring_parser-0.18.0.tar.gz", hash = "sha256:292510982205c12b1248696f44959db3cdd1740237a968ea1e2e7a900eeb2015", size = 29341, upload-time = "2026-04-14T04:09:19.867Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" }, +] + +[[package]] +name = "fonttools" +version = "4.63.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/84/69/c97f2c18e0db87d2c7b15da1974dace76ae938f1cfa22e2727a648b7ed43/fonttools-4.63.0.tar.gz", hash = "sha256:caeb583deeb5168e694b65cda8b4ee62abedfa66cf88488734466f2366b9c4e0", size = 3597189, upload-time = "2026-05-14T12:04:30.958Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/2b/a7f1545bdf5da69c4bda0cea2a5781f0ad2a6623e0277267672db43c5fe6/fonttools-4.63.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2b8ae05d9eacf6081414d759c0a352769ac28ce31280d6bb8e77b03f9e3c449f", size = 2881793, upload-time = "2026-05-14T12:02:56.645Z" }, + { url = "https://files.pythonhosted.org/packages/49/50/965308c703f085f225db2886813b27e015b8b3438c350b22dd65b52c2a2c/fonttools-4.63.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:79cdc9f567aec74a72918fd060283911406750cbc9fd28c1316023deb6ce31a9", size = 2428130, upload-time = "2026-05-14T12:02:58.891Z" }, + { url = "https://files.pythonhosted.org/packages/d8/38/6937fbd7f2dc3a6b48725851bc2c15ec949b9af14d9bbcb5fe83cdf9bdf9/fonttools-4.63.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c14b4fd138c4bafcca294765c547914e1aa431ae1ca94ab99d8db08c958bd3b", size = 5111952, upload-time = "2026-05-14T12:03:01.263Z" }, + { url = "https://files.pythonhosted.org/packages/0b/43/a81f20050a3115b57d62c8e781446949512eac36690dc384ccea65ff4cc1/fonttools-4.63.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d76ac49f929aecaf82d83250b8347e099d7aecba0f4726c1d9b6df3b8bb5fe18", size = 5082308, upload-time = "2026-05-14T12:03:03.211Z" }, + { url = "https://files.pythonhosted.org/packages/67/00/cdd9d4944ca6ae280d01e69cc37bde3bf663630b837a6fc6d2cd65d80e0e/fonttools-4.63.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dcf076a4474fe0d7367e5bbf5b052c7284fa1feca729c04176ce513521afd8a0", size = 5087932, upload-time = "2026-05-14T12:03:05.147Z" }, + { url = "https://files.pythonhosted.org/packages/f5/f1/0aa0dbea778c75adbef223c42019fd47d22262b905974d62d829545d485f/fonttools-4.63.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7dd683fef0663e9f0f45cf541d788d24caa3ec9db50796b588e1757d8b3bc007", size = 5213271, upload-time = "2026-05-14T12:03:07.238Z" }, + { url = "https://files.pythonhosted.org/packages/a8/99/253e4056e1f0e67b9390125a154b73b5eb73ad521bece95c004858fdeec2/fonttools-4.63.0-cp311-cp311-win32.whl", hash = "sha256:afefc1ed0a59785a7fb06ea7e1678e849c193e1e387db783579bc7b3056fcfcb", size = 2304473, upload-time = "2026-05-14T12:03:09.271Z" }, + { url = "https://files.pythonhosted.org/packages/08/60/defa5e69641db890a63be281f41345f4c33b157824eaf0b9fad3e08b0dcb/fonttools-4.63.0-cp311-cp311-win_amd64.whl", hash = "sha256:063e08bd17bd5a90127a14123de0d6a952dbc847695fd98b63c043d58057f90c", size = 2356389, upload-time = "2026-05-14T12:03:11.53Z" }, + { url = "https://files.pythonhosted.org/packages/2c/47/c99d5268f354002ce80f8d029cd9d7d872969da1de8b93d32de4dc56d6f4/fonttools-4.63.0-py3-none-any.whl", hash = "sha256:445af2eab030a16b9171ea8bdda7ebf7d96bda2df88ee182a464252f6e05e20d", size = 1164562, upload-time = "2026-05-14T12:04:29.092Z" }, +] + +[[package]] +name = "idna" +version = "3.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15", size = 215237, upload-time = "2026-08-18T05:14:24.27Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "kiwisolver" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/67/9c61eccb13f0bdca9307614e782fec49ffdde0f7a2314935d489fa93cd9c/kiwisolver-1.5.0.tar.gz", hash = "sha256:d4193f3d9dc3f6f79aaed0e5637f45d98850ebf01f7ca20e69457f3e8946b66a", size = 103482, upload-time = "2026-03-09T13:15:53.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/dd/a495a9c104be1c476f0386e714252caf2b7eca883915422a64c50b88c6f5/kiwisolver-1.5.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9eed0f7edbb274413b6ee781cca50541c8c0facd3d6fd289779e494340a2b85c", size = 122798, upload-time = "2026-03-09T13:12:58.963Z" }, + { url = "https://files.pythonhosted.org/packages/11/60/37b4047a2af0cf5ef6d8b4b26e91829ae6fc6a2d1f74524bcb0e7cd28a32/kiwisolver-1.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3c4923e404d6bcd91b6779c009542e5647fef32e4a5d75e115e3bbac6f2335eb", size = 66216, upload-time = "2026-03-09T13:13:00.155Z" }, + { url = "https://files.pythonhosted.org/packages/0a/aa/510dc933d87767584abfe03efa445889996c70c2990f6f87c3ebaa0a18c5/kiwisolver-1.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0df54df7e686afa55e6f21fb86195224a6d9beb71d637e8d7920c95cf0f89aac", size = 63911, upload-time = "2026-03-09T13:13:01.671Z" }, + { url = "https://files.pythonhosted.org/packages/80/46/bddc13df6c2a40741e0cc7865bb1c9ed4796b6760bd04ce5fae3928ef917/kiwisolver-1.5.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2517e24d7315eb51c10664cdb865195df38ab74456c677df67bb47f12d088a27", size = 1438209, upload-time = "2026-03-09T13:13:03.385Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d6/76621246f5165e5372f02f5e6f3f48ea336a8f9e96e43997d45b240ed8cd/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff710414307fefa903e0d9bdf300972f892c23477829f49504e59834f4195398", size = 1248888, upload-time = "2026-03-09T13:13:05.231Z" }, + { url = "https://files.pythonhosted.org/packages/b2/c1/31559ec6fb39a5b48035ce29bb63ade628f321785f38c384dee3e2c08bc1/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6176c1811d9d5a04fa391c490cc44f451e240697a16977f11c6f722efb9041db", size = 1266304, upload-time = "2026-03-09T13:13:06.743Z" }, + { url = "https://files.pythonhosted.org/packages/5e/ef/1cb8276f2d29cc6a41e0a042f27946ca347d3a4a75acf85d0a16aa6dcc82/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50847dca5d197fcbd389c805aa1a1cf32f25d2e7273dc47ab181a517666b68cc", size = 1319650, upload-time = "2026-03-09T13:13:08.607Z" }, + { url = "https://files.pythonhosted.org/packages/4c/e4/5ba3cecd7ce6236ae4a80f67e5d5531287337d0e1f076ca87a5abe4cd5d0/kiwisolver-1.5.0-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:01808c6d15f4c3e8559595d6d1fe6411c68e4a3822b4b9972b44473b24f4e679", size = 970949, upload-time = "2026-03-09T13:13:10.299Z" }, + { url = "https://files.pythonhosted.org/packages/5a/69/dc61f7ae9a2f071f26004ced87f078235b5507ab6e5acd78f40365655034/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f1f9f4121ec58628c96baa3de1a55a4e3a333c5102c8e94b64e23bf7b2083309", size = 2199125, upload-time = "2026-03-09T13:13:11.841Z" }, + { url = "https://files.pythonhosted.org/packages/e5/7b/abbe0f1b5afa85f8d084b73e90e5f801c0939eba16ac2e49af7c61a6c28d/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b7d335370ae48a780c6e6a6bbfa97342f563744c39c35562f3f367665f5c1de2", size = 2293783, upload-time = "2026-03-09T13:13:14.399Z" }, + { url = "https://files.pythonhosted.org/packages/8a/80/5908ae149d96d81580d604c7f8aefd0e98f4fd728cf172f477e9f2a81744/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:800ee55980c18545af444d93fdd60c56b580db5cc54867d8cbf8a1dc0829938c", size = 1960726, upload-time = "2026-03-09T13:13:16.047Z" }, + { url = "https://files.pythonhosted.org/packages/84/08/a78cb776f8c085b7143142ce479859cfec086bd09ee638a317040b6ef420/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:c438f6ca858697c9ab67eb28246c92508af972e114cac34e57a6d4ba17a3ac08", size = 2464738, upload-time = "2026-03-09T13:13:17.897Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e1/65584da5356ed6cb12c63791a10b208860ac40a83de165cb6a6751a686e3/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8c63c91f95173f9c2a67c7c526b2cea976828a0e7fced9cdcead2802dc10f8a4", size = 2270718, upload-time = "2026-03-09T13:13:19.421Z" }, + { url = "https://files.pythonhosted.org/packages/be/6c/28f17390b62b8f2f520e2915095b3c94d88681ecf0041e75389d9667f202/kiwisolver-1.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:beb7f344487cdcb9e1efe4b7a29681b74d34c08f0043a327a74da852a6749e7b", size = 73480, upload-time = "2026-03-09T13:13:20.818Z" }, + { url = "https://files.pythonhosted.org/packages/d8/0e/2ee5debc4f77a625778fec5501ff3e8036fe361b7ee28ae402a485bb9694/kiwisolver-1.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:ad4ae4ffd1ee9cd11357b4c66b612da9888f4f4daf2f36995eda64bd45370cac", size = 64930, upload-time = "2026-03-09T13:13:21.997Z" }, + { url = "https://files.pythonhosted.org/packages/e9/eb/5fcbbbf9a0e2c3a35effb88831a483345326bbc3a030a3b5b69aee647f84/kiwisolver-1.5.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ec4c85dc4b687c7f7f15f553ff26a98bfe8c58f5f7f0ac8905f0ba4c7be60232", size = 59532, upload-time = "2026-03-09T13:15:47.047Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9b/e17104555bb4db148fd52327feea1e96be4b88e8e008b029002c281a21ab/kiwisolver-1.5.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:12e91c215a96e39f57989c8912ae761286ac5a9584d04030ceb3368a357f017a", size = 57420, upload-time = "2026-03-09T13:15:48.199Z" }, + { url = "https://files.pythonhosted.org/packages/48/44/2b5b95b7aa39fb2d8d9d956e0f3d5d45aef2ae1d942d4c3ffac2f9cfed1a/kiwisolver-1.5.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:be4a51a55833dc29ab5d7503e7bcb3b3af3402d266018137127450005cdfe737", size = 79892, upload-time = "2026-03-09T13:15:49.694Z" }, + { url = "https://files.pythonhosted.org/packages/52/7d/7157f9bba6b455cfb4632ed411e199fc8b8977642c2b12082e1bd9e6d173/kiwisolver-1.5.0-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:daae526907e262de627d8f70058a0f64acc9e2641c164c99c8f594b34a799a16", size = 77603, upload-time = "2026-03-09T13:15:50.945Z" }, + { url = "https://files.pythonhosted.org/packages/0a/dd/8050c947d435c8d4bc94e3252f4d8bb8a76cfb424f043a8680be637a57f1/kiwisolver-1.5.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:59cd8683f575d96df5bb48f6add94afc055012c29e28124fcae2b63661b9efb1", size = 73558, upload-time = "2026-03-09T13:15:52.112Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "matplotlib" +version = "3.11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "contourpy" }, + { name = "cycler" }, + { name = "fonttools" }, + { name = "kiwisolver" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "pyparsing" }, + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/64/f9a391af28f518b11ad45a8a712353c94a0aefce09d3703200e5c54b610a/matplotlib-3.11.1.tar.gz", hash = "sha256:69647db5746941c793d6e445a4cd349323ffb87d9cc958c2ad84a659b4832d30", size = 32612045, upload-time = "2026-07-18T03:39:46.63Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/d0/791aa183dd88491555cf7d4be0b52b0bcf6c3c2a2c22c815a2e819bf53e2/matplotlib-3.11.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:b7cf158e7add54a8d51ac9b5a84abd6d4e13ed4951b4f25f1c5139f41c2addb2", size = 9440302, upload-time = "2026-07-18T03:38:03.844Z" }, + { url = "https://files.pythonhosted.org/packages/35/74/82bbdf683a301f4478384c8aaba6903631a2ca18294b2d7655c9a542bffb/matplotlib-3.11.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d2ace7273b9a5061a3b420918a16fae1f2dc5dfee1abcc13aba71b5d94b1820c", size = 9268549, upload-time = "2026-07-18T03:38:06.144Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f0/9b4298911303f74e6d83e64a81d996c0616405ec95046fac7f17e4258b9e/matplotlib-3.11.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aee55e9041211bf84302ab55ec3965df18dd90ae19f8b58332a7feaf208bfe83", size = 10024922, upload-time = "2026-07-18T03:38:08.236Z" }, + { url = "https://files.pythonhosted.org/packages/84/6f/0bc3c3d05b021db44c14bc379a7c0df7d57302aa15380c16fd4e63fd6a9b/matplotlib-3.11.1-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f4bdeea33a8d15a071dbfe6d119451b1d719c733ac666d65357082901a9099", size = 10832170, upload-time = "2026-07-18T03:38:10.276Z" }, + { url = "https://files.pythonhosted.org/packages/db/4d/e375f39acdb2af5a9342730618608e39790ec842e6f1b392863028781459/matplotlib-3.11.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b4c78ceb2f11bcac7389d305cda17aeb1f4586a857854ab5780bd3dd8dbfc407", size = 10916701, upload-time = "2026-07-18T03:38:12.512Z" }, + { url = "https://files.pythonhosted.org/packages/bc/be/fa26ed085b41298f64a8f9b7592c671bbf1acc8b0df124c1c5de96b859f8/matplotlib-3.11.1-cp311-cp311-win_amd64.whl", hash = "sha256:7f33a781e12b1e53b278deb2f5373c2e55ec4f10727be3440c0cfb5cda9f944f", size = 9315331, upload-time = "2026-07-18T03:38:14.949Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f3/eb5bdf3b6e191b200db298b08bbc1638b7f3c82cdc8680f9d88bf72559ae/matplotlib-3.11.1-cp311-cp311-win_arm64.whl", hash = "sha256:67e4c3cd578c65ebd81bdc09a1b6592ceafee6dfafe116dc85dfcb647b5bbb18", size = 9003475, upload-time = "2026-07-18T03:38:17.205Z" }, + { url = "https://files.pythonhosted.org/packages/ee/38/ceb1d637c4db6d06141f3739e93af3321e7caaabe69b57ae48ffe3ee95b1/matplotlib-3.11.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:427258425f9a3fc4ed79a91f9e9b9aaf5a82cb6571e85dc14063cc6fbb993741", size = 9438045, upload-time = "2026-07-18T03:39:39.491Z" }, + { url = "https://files.pythonhosted.org/packages/89/25/72ad8b58602d3a6ef1dfc4b65ecd01634ab65a2bdf494c9fe0e966dbf081/matplotlib-3.11.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:1ac697e591c11b6ad04679a73c2d2f9980fe9d9f0311fb414a2e329706343dfb", size = 9266127, upload-time = "2026-07-18T03:39:41.597Z" }, + { url = "https://files.pythonhosted.org/packages/8a/6d/69552382fcc8e93d1f2763ef2665980a900a48b7f3a4c57ed290726d1cbc/matplotlib-3.11.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e4b9ac2f1f607ecda2af90a5232beee2af7582fce1cc30c4b6a1b012dc21ee99", size = 10019439, upload-time = "2026-07-18T03:39:43.78Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "numpy" +version = "2.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/37/7d/3fec4199c5ffb892bed55cff901e4f39a58c81df9c44c280499e92cad264/numpy-2.3.2.tar.gz", hash = "sha256:e0486a11ec30cdecb53f184d496d1c6a20786c81e55e41640270130056f8ee48", size = 20489306, upload-time = "2025-07-24T21:32:07.553Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/26/1320083986108998bd487e2931eed2aeedf914b6e8905431487543ec911d/numpy-2.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:852ae5bed3478b92f093e30f785c98e0cb62fa0a939ed057c31716e18a7a22b9", size = 21259016, upload-time = "2025-07-24T20:24:35.214Z" }, + { url = "https://files.pythonhosted.org/packages/c4/2b/792b341463fa93fc7e55abbdbe87dac316c5b8cb5e94fb7a59fb6fa0cda5/numpy-2.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7a0e27186e781a69959d0230dd9909b5e26024f8da10683bd6344baea1885168", size = 14451158, upload-time = "2025-07-24T20:24:58.397Z" }, + { url = "https://files.pythonhosted.org/packages/b7/13/e792d7209261afb0c9f4759ffef6135b35c77c6349a151f488f531d13595/numpy-2.3.2-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:f0a1a8476ad77a228e41619af2fa9505cf69df928e9aaa165746584ea17fed2b", size = 5379817, upload-time = "2025-07-24T20:25:07.746Z" }, + { url = "https://files.pythonhosted.org/packages/49/ce/055274fcba4107c022b2113a213c7287346563f48d62e8d2a5176ad93217/numpy-2.3.2-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:cbc95b3813920145032412f7e33d12080f11dc776262df1712e1638207dde9e8", size = 6913606, upload-time = "2025-07-24T20:25:18.84Z" }, + { url = "https://files.pythonhosted.org/packages/17/f2/e4d72e6bc5ff01e2ab613dc198d560714971900c03674b41947e38606502/numpy-2.3.2-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f75018be4980a7324edc5930fe39aa391d5734531b1926968605416ff58c332d", size = 14589652, upload-time = "2025-07-24T20:25:40.356Z" }, + { url = "https://files.pythonhosted.org/packages/c8/b0/fbeee3000a51ebf7222016e2939b5c5ecf8000a19555d04a18f1e02521b8/numpy-2.3.2-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20b8200721840f5621b7bd03f8dcd78de33ec522fc40dc2641aa09537df010c3", size = 16938816, upload-time = "2025-07-24T20:26:05.721Z" }, + { url = "https://files.pythonhosted.org/packages/a9/ec/2f6c45c3484cc159621ea8fc000ac5a86f1575f090cac78ac27193ce82cd/numpy-2.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1f91e5c028504660d606340a084db4b216567ded1056ea2b4be4f9d10b67197f", size = 16370512, upload-time = "2025-07-24T20:26:30.545Z" }, + { url = "https://files.pythonhosted.org/packages/b5/01/dd67cf511850bd7aefd6347aaae0956ed415abea741ae107834aae7d6d4e/numpy-2.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:fb1752a3bb9a3ad2d6b090b88a9a0ae1cd6f004ef95f75825e2f382c183b2097", size = 18884947, upload-time = "2025-07-24T20:26:58.24Z" }, + { url = "https://files.pythonhosted.org/packages/a7/17/2cf60fd3e6a61d006778735edf67a222787a8c1a7842aed43ef96d777446/numpy-2.3.2-cp311-cp311-win32.whl", hash = "sha256:4ae6863868aaee2f57503c7a5052b3a2807cf7a3914475e637a0ecd366ced220", size = 6599494, upload-time = "2025-07-24T20:27:09.786Z" }, + { url = "https://files.pythonhosted.org/packages/d5/03/0eade211c504bda872a594f045f98ddcc6caef2b7c63610946845e304d3f/numpy-2.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:240259d6564f1c65424bcd10f435145a7644a65a6811cfc3201c4a429ba79170", size = 13087889, upload-time = "2025-07-24T20:27:29.558Z" }, + { url = "https://files.pythonhosted.org/packages/13/32/2c7979d39dafb2a25087e12310fc7f3b9d3c7d960df4f4bc97955ae0ce1d/numpy-2.3.2-cp311-cp311-win_arm64.whl", hash = "sha256:4209f874d45f921bde2cff1ffcd8a3695f545ad2ffbef6d3d3c6768162efab89", size = 10459560, upload-time = "2025-07-24T20:27:46.803Z" }, + { url = "https://files.pythonhosted.org/packages/cf/ea/50ebc91d28b275b23b7128ef25c3d08152bc4068f42742867e07a870a42a/numpy-2.3.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:14a91ebac98813a49bc6aa1a0dfc09513dcec1d97eaf31ca21a87221a1cdcb15", size = 21130338, upload-time = "2025-07-24T20:57:54.37Z" }, + { url = "https://files.pythonhosted.org/packages/9f/57/cdd5eac00dd5f137277355c318a955c0d8fb8aa486020c22afd305f8b88f/numpy-2.3.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:71669b5daae692189540cffc4c439468d35a3f84f0c88b078ecd94337f6cb0ec", size = 14375776, upload-time = "2025-07-24T20:58:16.303Z" }, + { url = "https://files.pythonhosted.org/packages/83/85/27280c7f34fcd305c2209c0cdca4d70775e4859a9eaa92f850087f8dea50/numpy-2.3.2-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:69779198d9caee6e547adb933941ed7520f896fd9656834c300bdf4dd8642712", size = 5304882, upload-time = "2025-07-24T20:58:26.199Z" }, + { url = "https://files.pythonhosted.org/packages/48/b4/6500b24d278e15dd796f43824e69939d00981d37d9779e32499e823aa0aa/numpy-2.3.2-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:2c3271cc4097beb5a60f010bcc1cc204b300bb3eafb4399376418a83a1c6373c", size = 6818405, upload-time = "2025-07-24T20:58:37.341Z" }, + { url = "https://files.pythonhosted.org/packages/9b/c9/142c1e03f199d202da8e980c2496213509291b6024fd2735ad28ae7065c7/numpy-2.3.2-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8446acd11fe3dc1830568c941d44449fd5cb83068e5c70bd5a470d323d448296", size = 14419651, upload-time = "2025-07-24T20:58:59.048Z" }, + { url = "https://files.pythonhosted.org/packages/8b/95/8023e87cbea31a750a6c00ff9427d65ebc5fef104a136bfa69f76266d614/numpy-2.3.2-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aa098a5ab53fa407fded5870865c6275a5cd4101cfdef8d6fafc48286a96e981", size = 16760166, upload-time = "2025-07-24T21:28:56.38Z" }, + { url = "https://files.pythonhosted.org/packages/78/e3/6690b3f85a05506733c7e90b577e4762517404ea78bab2ca3a5cb1aeb78d/numpy-2.3.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6936aff90dda378c09bea075af0d9c675fe3a977a9d2402f95a87f440f59f619", size = 12977811, upload-time = "2025-07-24T21:29:18.234Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pillow" +version = "12.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/c8/0a78b0e02d7ac54bc03e5321c9220da52f0c2ea83b21f7c40e7f3169c502/pillow-12.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756", size = 5392415, upload-time = "2026-07-01T11:53:47.162Z" }, + { url = "https://files.pythonhosted.org/packages/b2/5b/a02d30018abd97ced9f5a6c63d28597694a00d066516b9c1c6de45859fc9/pillow-12.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6", size = 4785266, upload-time = "2026-07-01T11:53:49.079Z" }, + { url = "https://files.pythonhosted.org/packages/c8/98/766667a4be768150a202836acd9fad19c06824ca86c4286d3cf6b274964e/pillow-12.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd", size = 6263814, upload-time = "2026-07-01T11:53:51.32Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2d/ede717bc1144f63886c21fd349bb95860b0d1a21149ff16f2bb362b612b6/pillow-12.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd", size = 6934408, upload-time = "2026-07-01T11:53:53.487Z" }, + { url = "https://files.pythonhosted.org/packages/a3/48/9c58b685e69d49c31af6c8eb9012055fab7e665785165c84796e2c73ce72/pillow-12.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c", size = 6337160, upload-time = "2026-07-01T11:53:55.457Z" }, + { url = "https://files.pythonhosted.org/packages/ff/fa/dc2a5c0ba6df93f67c31d34b808b7ce440b40cdbf96f0b81cde1d1e6fa93/pillow-12.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5", size = 7045172, upload-time = "2026-07-01T11:53:57.736Z" }, + { url = "https://files.pythonhosted.org/packages/86/a5/444817a4d4c4c2417df00513086ca196f388d8f9ef40c2e4ccd1ad1af54b/pillow-12.3.0-cp311-cp311-win32.whl", hash = "sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b", size = 6472232, upload-time = "2026-07-01T11:53:59.767Z" }, + { url = "https://files.pythonhosted.org/packages/63/c6/4bad1b18d132a50b27e1365e1ab163616f7a5bb56d330f66f9d1d9d4f9d4/pillow-12.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a", size = 7233653, upload-time = "2026-07-01T11:54:02.066Z" }, + { url = "https://files.pythonhosted.org/packages/fd/16/00f91ab7760dc842f5aad55217e80fc4a7067a0604535249bc8a2d6d9870/pillow-12.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26", size = 2568195, upload-time = "2026-07-01T11:54:04.622Z" }, + { url = "https://files.pythonhosted.org/packages/75/18/2e8b40223153ccbc60df07f9e8928dc0c76202aa4e55ae9f53962b6510d6/pillow-12.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468", size = 5302510, upload-time = "2026-07-01T11:56:25.736Z" }, + { url = "https://files.pythonhosted.org/packages/46/3e/51fabf59d5ab801ceab709453d3ab6b180083496579549de4c45ced6528a/pillow-12.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94", size = 4736058, upload-time = "2026-07-01T11:56:28.041Z" }, + { url = "https://files.pythonhosted.org/packages/bf/20/22fe9384b7949e25fb1293bcfc84fb82590ff4ea6b37c95b24d26d793d86/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e", size = 5237776, upload-time = "2026-07-01T11:56:30.263Z" }, + { url = "https://files.pythonhosted.org/packages/08/14/f6ba68107680ffa74b39985f3f30884e41318fbc4250caa423c79b4788bb/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3", size = 5860358, upload-time = "2026-07-01T11:56:32.68Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0169bc772ec491108b62f644f8ecf1fe5d8ae5ebafde2ee2142210166903/pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a", size = 7231786, upload-time = "2026-07-01T11:56:35.046Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.11.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b8/d7/e7bfbc86e9f99ff7807e24de7703f032e9c9ba80bb355cf26e0e9bc5a75e/platformdirs-4.11.3.tar.gz", hash = "sha256:66a73d38a849810252df809a3d8bcbda8e26f6c189920e7535ad608a48dbb5ab", size = 33050, upload-time = "2026-08-13T22:43:27.52Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/a9/c34aebedd3a4c9afe5101b1b8713710b3fec18087c8a36c35d2f909861bd/platformdirs-4.11.3-py3-none-any.whl", hash = "sha256:5ed065d443751de711da036041a7a214122efc4a4de393b3f4137ba5576540e7", size = 23491, upload-time = "2026-08-13T22:43:26.121Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pooch" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "platformdirs" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/43/85ef45e8b36c6a48546af7b266592dc32d7f67837a6514d111bced6d7d75/pooch-1.9.0.tar.gz", hash = "sha256:de46729579b9857ffd3e741987a2f6d5e0e03219892c167c6578c0091fb511ed", size = 61788, upload-time = "2026-01-30T19:15:09.649Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/2d/d4bf65e47cea8ff2c794a600c4fd1273a7902f268757c531e0ee9f18aa58/pooch-1.9.0-py3-none-any.whl", hash = "sha256:f265597baa9f760d25ceb29d0beb8186c243d6607b0f60b83ecf14078dbc703b", size = 67175, upload-time = "2026-01-30T19:15:08.36Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pyparsing" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, +] + +[[package]] +name = "pytest" +version = "8.3.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/05/35/30e0d83068951d90a01852cb1cef56e5d8a09d20c7f511634cc2f7e0372a/pytest-8.3.4.tar.gz", hash = "sha256:965370d062bce11e73868e0335abac31b4d3de0e82f4007408d242b4f8610761", size = 1445919, upload-time = "2024-12-01T12:54:25.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/92/76a1c94d3afee238333bc0a42b82935dd8f9cf8ce9e336ff87ee14d9e1cf/pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6", size = 343083, upload-time = "2024-12-01T12:54:19.735Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "pyvista" +version = "0.48.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cyclopts" }, + { name = "matplotlib" }, + { name = "numpy" }, + { name = "pillow" }, + { name = "pooch" }, + { name = "scooby" }, + { name = "typing-extensions" }, + { name = "vtk" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/11/554ae45f79d45039c733d93acb36a433b71e8c63a79bbf2f414b3685de18/pyvista-0.48.4.tar.gz", hash = "sha256:c639dad1bddff5e366d77371f66f783f6e6a0581446810a66439902222d8db07", size = 2581423, upload-time = "2026-05-18T02:25:51.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/91/696d869e4df2e25a5b201a69ce69a2204d37fad3e90c2b731f7b3f1d7c68/pyvista-0.48.4-py3-none-any.whl", hash = "sha256:a46eda178e10e279afda550c341676a82dcee607c86db74565fa455ac0bd23e2", size = 2629373, upload-time = "2026-05-18T02:25:49.919Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "rich-rst" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pygments" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e2/d6/d0b9fafc73b65767200da027acab1db1bdb1048f4fea5ebf659df01c700e/rich_rst-2.1.0.tar.gz", hash = "sha256:f4d117b49697f338769759fa5cacf5197da4888b347b9fda2e50aef5cd8d93bd", size = 302732, upload-time = "2026-07-05T02:59:44.308Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/68/1fc93dd759605b5d00fc98b50200739e41ed32bd22d6ba35ca6c3932371b/rich_rst-2.1.0-py3-none-any.whl", hash = "sha256:7ecd1343ee12c879d0e7ae74c3eb6d263b023d2929c6d114212eb1fd91057255", size = 272987, upload-time = "2026-07-05T02:59:42.792Z" }, +] + +[[package]] +name = "sadt-areg-common" +version = "0.1.0" +source = { directory = "../common" } + +[package.metadata] + +[package.metadata.requires-dev] +dev = [{ name = "pytest", specifier = "==8.3.4" }] + +[[package]] +name = "sadt-areg-ioscbct" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "numpy" }, + { name = "pyvista" }, + { name = "sadt-areg-common" }, + { name = "scipy" }, + { name = "simpleitk" }, + { name = "vtk" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "sadt-testkit" }, +] + +[package.metadata] +requires-dist = [ + { name = "numpy", specifier = "==2.3.2" }, + { name = "pyvista", specifier = ">=0.45" }, + { name = "sadt-areg-common", directory = "../common" }, + { name = "scipy", specifier = "==1.16.2" }, + { name = "simpleitk", specifier = "==2.5.6" }, + { name = "vtk", specifier = "==9.6.2" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = "==8.3.4" }, + { name = "sadt-testkit", editable = "../../../testkit" }, +] + +[[package]] +name = "sadt-testkit" +version = "0.1.0" +source = { editable = "../../../testkit" } + +[package.metadata] + +[package.metadata.requires-dev] +dev = [ + { name = "numpy", specifier = "==1.26.4" }, + { name = "pytest", specifier = "==8.3.4" }, +] + +[[package]] +name = "scipy" +version = "1.16.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4c/3b/546a6f0bfe791bbb7f8d591613454d15097e53f906308ec6f7c1ce588e8e/scipy-1.16.2.tar.gz", hash = "sha256:af029b153d243a80afb6eabe40b0a07f8e35c9adc269c019f364ad747f826a6b", size = 30580599, upload-time = "2025-09-11T17:48:08.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/ef/37ed4b213d64b48422df92560af7300e10fe30b5d665dd79932baebee0c6/scipy-1.16.2-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:6ab88ea43a57da1af33292ebd04b417e8e2eaf9d5aa05700be8d6e1b6501cd92", size = 36619956, upload-time = "2025-09-11T17:39:20.5Z" }, + { url = "https://files.pythonhosted.org/packages/85/ab/5c2eba89b9416961a982346a4d6a647d78c91ec96ab94ed522b3b6baf444/scipy-1.16.2-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:c95e96c7305c96ede73a7389f46ccd6c659c4da5ef1b2789466baeaed3622b6e", size = 28931117, upload-time = "2025-09-11T17:39:29.06Z" }, + { url = "https://files.pythonhosted.org/packages/80/d1/eed51ab64d227fe60229a2d57fb60ca5898cfa50ba27d4f573e9e5f0b430/scipy-1.16.2-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:87eb178db04ece7c698220d523c170125dbffebb7af0345e66c3554f6f60c173", size = 20921997, upload-time = "2025-09-11T17:39:34.892Z" }, + { url = "https://files.pythonhosted.org/packages/be/7c/33ea3e23bbadde96726edba6bf9111fb1969d14d9d477ffa202c67bec9da/scipy-1.16.2-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:4e409eac067dcee96a57fbcf424c13f428037827ec7ee3cb671ff525ca4fc34d", size = 23523374, upload-time = "2025-09-11T17:39:40.846Z" }, + { url = "https://files.pythonhosted.org/packages/96/0b/7399dc96e1e3f9a05e258c98d716196a34f528eef2ec55aad651ed136d03/scipy-1.16.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e574be127bb760f0dad24ff6e217c80213d153058372362ccb9555a10fc5e8d2", size = 33583702, upload-time = "2025-09-11T17:39:49.011Z" }, + { url = "https://files.pythonhosted.org/packages/1a/bc/a5c75095089b96ea72c1bd37a4497c24b581ec73db4ef58ebee142ad2d14/scipy-1.16.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f5db5ba6188d698ba7abab982ad6973265b74bb40a1efe1821b58c87f73892b9", size = 35883427, upload-time = "2025-09-11T17:39:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/ab/66/e25705ca3d2b87b97fe0a278a24b7f477b4023a926847935a1a71488a6a6/scipy-1.16.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ec6e74c4e884104ae006d34110677bfe0098203a3fec2f3faf349f4cb05165e3", size = 36212940, upload-time = "2025-09-11T17:40:06.013Z" }, + { url = "https://files.pythonhosted.org/packages/d6/fd/0bb911585e12f3abdd603d721d83fc1c7492835e1401a0e6d498d7822b4b/scipy-1.16.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:912f46667d2d3834bc3d57361f854226475f695eb08c08a904aadb1c936b6a88", size = 38865092, upload-time = "2025-09-11T17:40:15.143Z" }, + { url = "https://files.pythonhosted.org/packages/d6/73/c449a7d56ba6e6f874183759f8483cde21f900a8be117d67ffbb670c2958/scipy-1.16.2-cp311-cp311-win_amd64.whl", hash = "sha256:91e9e8a37befa5a69e9cacbe0bcb79ae5afb4a0b130fd6db6ee6cc0d491695fa", size = 38687626, upload-time = "2025-09-11T17:40:24.041Z" }, + { url = "https://files.pythonhosted.org/packages/68/72/02f37316adf95307f5d9e579023c6899f89ff3a051fa079dbd6faafc48e5/scipy-1.16.2-cp311-cp311-win_arm64.whl", hash = "sha256:f3bf75a6dcecab62afde4d1f973f1692be013110cad5338007927db8da73249c", size = 25503506, upload-time = "2025-09-11T17:40:30.703Z" }, +] + +[[package]] +name = "scooby" +version = "0.11.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/06/9a8600207fd72a29ee965e9a4c61b750cc3fa106768f14a7b3ee3e36cb61/scooby-0.11.2.tar.gz", hash = "sha256:0575c73636ec4c2587bea1f8a038798ddcb249e02067fae897dac3bf4f4e444d", size = 242928, upload-time = "2026-04-22T23:13:12.307Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/bc/1173f502f1870e3bae81c148326c5cbcc19ec77df79a9aaf17a59911355c/scooby-0.11.2-py3-none-any.whl", hash = "sha256:f34c36bbee749b2c55816a080521f216d88304e635017e911c12249607d38c49", size = 20142, upload-time = "2026-04-22T23:13:10.705Z" }, +] + +[[package]] +name = "simpleitk" +version = "2.5.6" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/30/cb0eec647afea94c1d95bed3c0014a96565404e4225dba2c0c63bbdd6b9a/simpleitk-2.5.6-cp311-abi3-macosx_10_9_x86_64.whl", hash = "sha256:36658792fe2a62814cbfedb3b236ac722ca7e00953241726bc4a3a26cc7ef5a7", size = 42685058, upload-time = "2026-07-30T16:52:12.477Z" }, + { url = "https://files.pythonhosted.org/packages/68/e8/18d2351ef7b6a17c921f1fbac3a19dc3a27f6ea7749c2f5a5877e38202b9/simpleitk-2.5.6-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:afcda49474748548fa1b7dbb9f557c3b9feafeb0dd3c2e05d771cc935c86646e", size = 38252322, upload-time = "2026-07-30T16:52:16.719Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c3/9025397ec8638c261ba1fe56ffed06983df707a3bc961da5ef90157e5a25/simpleitk-2.5.6-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:af7ca101b23233745b813481ea91694ecea95c3e3b6eed8dca37be39a60f6894", size = 48070098, upload-time = "2026-07-30T16:52:21.316Z" }, + { url = "https://files.pythonhosted.org/packages/f4/ec/301532fb2003e6557e6a12106eb1df572ed6f74c08c05c2e7a8913353383/simpleitk-2.5.6-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:99242c333ed17138134e9749f3a484518f41baebab0fc0f0fe7c657ab8090c07", size = 52798369, upload-time = "2026-07-30T16:52:25.774Z" }, + { url = "https://files.pythonhosted.org/packages/16/d0/a746280d0987413e443c26f19fe5c559fb3034160ee0a7307ae2d85e8d59/simpleitk-2.5.6-cp311-abi3-win_amd64.whl", hash = "sha256:0002b298efb31332f99587cf26ed9f42c2c3ec006a0570f492dfc9ea27303d73", size = 18925827, upload-time = "2026-07-30T16:52:29.496Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "vtk" +version = "9.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "matplotlib" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/15/910b90b0b44d474f7cc71ccfe6e63393421fc0d161aabb490afe871830a3/vtk-9.6.2-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:ab2848c26c70fe57c41656d5ab48f47e8fa4f78ccbb113cf86c8c6de71c3118d", size = 114703453, upload-time = "2026-05-19T04:46:46.114Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e5/8b4a37663aacd242c70c7a8feb2d2a4140ef5ded39ec0193af2d5a673098/vtk-9.6.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:40fb9d9172cbd0b85a7f39df3646029449e563c61f54bedd3244427035f55ba3", size = 106906589, upload-time = "2026-05-19T04:46:51.081Z" }, + { url = "https://files.pythonhosted.org/packages/df/5c/148d54b90a2cd39809512d63d70b9b672f0e58f49d937f964a3167d63cf8/vtk-9.6.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0fd9fa3f851192619ac0cec05591ab88adbed67ba063903297d2bb40b457bd00", size = 145980985, upload-time = "2026-05-19T04:46:56.229Z" }, + { url = "https://files.pythonhosted.org/packages/45/ee/9a4f42a8b98cfb095570ef203685186843389bb66d0da6d36581821888e8/vtk-9.6.2-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:e640839fa24fc7c2153387d535527cfcd7d270e17e0d47f00fefd80b3b043577", size = 135731405, upload-time = "2026-05-19T04:47:01.547Z" }, + { url = "https://files.pythonhosted.org/packages/27/bb/e511d83d6b4d5b0acbce5e6a82110c510e3b416b39c667e6a52b1f78291a/vtk-9.6.2-cp311-cp311-win_amd64.whl", hash = "sha256:b935949cfc80f1d300d0b0ed8ccab47fb45c337910966de33a486d342e3c7daa", size = 81290711, upload-time = "2026-05-19T04:47:06.029Z" }, +] diff --git a/tools/AREG/common/src/sadt_areg_common/catalogs.py b/tools/AREG/common/src/sadt_areg_common/catalogs.py index e0df2b9..912660d 100644 --- a/tools/AREG/common/src/sadt_areg_common/catalogs.py +++ b/tools/AREG/common/src/sadt_areg_common/catalogs.py @@ -13,12 +13,23 @@ MODALITY_CBCT = "CBCT" MODALITY_IOS = "IOS" +# Registering an intraoral scan onto a CBCT of the same patient -- a third +# modality, not a mode of either. It was missing entirely until an audit against +# upstream found it: upstream ships AREG_Method/IOSCBCT.py (829 lines) and three +# method classes for it, and nothing on this side named it at all. +MODALITY_IOSCBCT = "IOSCBCT" -MODALITY_CHOICES = {MODALITY_CBCT: True, MODALITY_IOS: False} +MODALITY_CHOICES = {MODALITY_CBCT: True, MODALITY_IOS: False, MODALITY_IOSCBCT: False} AUTOMATION_SEMI = "Semi-Automated" AUTOMATION_FULLY = "Fully-Automated" AUTOMATION_ORIENTED = "Oriented + Fully-Automated" +# IOSCBCT only, and NOT the same thing as Semi-Automated: it takes the landmarks +# already computed on both modalities and does the cross-modality registration +# alone, predicting nothing. Upstream labels it plainly "Registration" in the +# panel, against "Semi Automated Registration" and "Fully Automated +# Registration" beside it. +AUTOMATION_REGISTRATION = "Registration" # Fully-Automated is the default rather than the Slicer module's # Or_Auto_CBCT: the oriented mode additionally needs an orientation reference @@ -28,6 +39,7 @@ AUTOMATION_SEMI: False, AUTOMATION_FULLY: True, AUTOMATION_ORIENTED: False, + AUTOMATION_REGISTRATION: False, } # Which automation levels each modality actually has. The schema cannot say @@ -37,6 +49,11 @@ AUTOMATION_BY_MODALITY = { MODALITY_CBCT: (AUTOMATION_SEMI, AUTOMATION_FULLY, AUTOMATION_ORIENTED), MODALITY_IOS: (AUTOMATION_SEMI, AUTOMATION_FULLY), + # No "Oriented + Fully-Automated" here: orienting before registering is a + # CBCT step, and there is nothing to orient an intraoral scan onto in this + # mode. Its third value is Registration instead -- landmarks in, no + # prediction at all. + MODALITY_IOSCBCT: (AUTOMATION_SEMI, AUTOMATION_FULLY, AUTOMATION_REGISTRATION), } From e3191458ab4507808d331550cb375a5509131f20 Mon Sep 17 00:00:00 2001 From: Jules GRIVOT PELISSON Date: Wed, 19 Aug 2026 08:11:43 -0400 Subject: [PATCH 06/19] ADD: pyflakes to the repository checks to catch undefined names --- CONTRIBUTING.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a33736f..c906b23 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -565,8 +565,21 @@ server keeps working off its copy until this one is proven. ```bash uv run --no-project --python 3.12 --with pytest -- pytest scripts/tests -q uv run scripts/audit.py +uv run --no-project --python 3.11 --with pyflakes -- python -m pyflakes tools/*/src tools/*/*/src ``` +**pyflakes, and specifically for undefined names.** An import proves a module +loads; it says nothing about a name referenced inside a branch only a real run +reaches, and that is exactly where this repository keeps finding them. Splitting +ALI left four such defects — a constant whose import went with the block that +defined it, a module that moved to the other engine, a dependency dropped from a +pyproject, and a semaphore whose definition was removed while one use survived. +All four passed `import`, all four passed schema generation, and the first three +were found one per run until a single pyflakes sweep found the rest at once. + +Run it across every tool, including the ones nested under a grouping folder — +`tools/*/src tools/*/*/src` covers both depths. + `audit.py` is read-only by design: it reports distinct torch and Python versions and what dropping one would save, and it never edits a lockfile. Aligning two tools onto one runtime means revalidating both against reference data first. From 83932dd4aa086b8d0ca4012b15a7f05c0d0785b0 Mon Sep 17 00:00:00 2001 From: Jules GRIVOT PELISSON Date: Wed, 19 Aug 2026 09:39:39 -0400 Subject: [PATCH 07/19] CLEAN: replace em-dashes with plain dashes across markdown and Python sources --- CONTRIBUTING.md | 114 +++++++++--------- PROVENANCE.md | 24 ++-- README.md | 24 ++-- docs/SERVER_CONTRACT.md | 86 ++++++------- testkit/README.md | 10 +- .../ALI/ALI_CBCT/src/sadt_ali_cbct/layout.py | 4 +- tools/ALI/ALI_IOS/src/sadt_ali_ios/layout.py | 2 +- tools/ALI/README.md | 54 ++++----- tools/ALI/common/README.md | 8 +- tools/AMASSS/README.md | 24 ++-- tools/AMASSS/tests/data/README.md | 2 +- .../AREG_CBCT/src/sadt_areg_cbct/layout.py | 2 +- tools/AREG/AREG_IOS/README.md | 4 +- tools/AREG/README.md | 30 ++--- tools/AREG/common/README.md | 10 +- tools/AREG/tests/data/README.md | 4 +- tools/ASO/README.md | 46 +++---- tools/ASO/src/sadt_aso/__init__.py | 8 +- tools/ASO/src/sadt_aso/ios/pipeline.py | 2 +- tools/ASO/src/sadt_aso/layout.py | 4 +- tools/ASO/tests/data/README.md | 10 +- tools/Batch_Dental_Seg/README.md | 22 ++-- .../src/sadt_batchdentalseg/__init__.py | 4 +- tools/Batch_Dental_Seg/tests/data/README.md | 2 +- tools/Crown_Seg/README.md | 36 +++--- tools/Crown_Seg/src/sadt_crownseg/__init__.py | 4 +- tools/Crown_Seg/tests/data/README.md | 2 +- tools/Surg_Mov_Pred/README.md | 14 +-- tools/Surg_Mov_Pred/tests/data/README.md | 4 +- tools/_template/README.md | 2 +- tools/_template/tests/test_integration.py | 4 +- 31 files changed, 283 insertions(+), 283 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c906b23..c9bb17f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,9 +9,9 @@ follows exists to keep those two things apart. One branch and one pull request per tool, off `main`, never on `main`: -- `tool/` — migrating or changing a tool -- `infra/` — repository-level work -- `fix/` — corrections +- `tool/` -- migrating or changing a tool +- `infra/` -- repository-level work +- `fix/` -- corrections Commit messages are a single short sentence prefixed with `ADD :`, `FIX :`, `CLEAN :` or `UPDATE :`, in English, with no body unless the change genuinely @@ -22,7 +22,7 @@ ADD : AMASSS tool package with uv lockfile FIX : describe.py silently accepted unsupported annotations ``` -No AI attribution of any kind — no `Co-Authored-By`, no generated-with trailer, +No AI attribution of any kind -- no `Co-Authored-By`, no generated-with trailer, no emoji. Never merge your own PR, and never force-push a branch that has one open. @@ -53,7 +53,7 @@ tool = true name = "Crown_Seg" ``` -The section is also what makes the directory a tool at all — a `pyproject.toml` +The section is also what makes the directory a tool at all -- a `pyproject.toml` without it is a plain package, importable and installable but never discovered or served, which is how `tools/ALI/common/` and `testkit/` sit beside tools without becoming ones. @@ -61,7 +61,7 @@ without becoming ones. Resolution order, most explicit first: 1. `[tool.sadt] name`, when declared; -2. otherwise the directory name, with acronyms preserved as today — `ALI`, +2. otherwise the directory name, with acronyms preserved as today -- `ALI`, `ASO`, `AMASSS`. A new tool should declare it rather than lean on the fallback, so the API name @@ -71,20 +71,20 @@ is a **decision** and not an accident of directory casing. declared: the interpreter is looked up at `/[/]/.venv/bin/python`, so a folder called anything else registers a tool that cannot be run. What declaring the name buys -is that the tool's DEPTH may change — `ALI_CBCT` moved under the `ALI/` grouping -folder and kept its name — and that renaming a folder without meaning to change +is that the tool's DEPTH may change -- `ALI_CBCT` moved under the `ALI/` grouping +folder and kept its name -- and that renaming a folder without meaning to change the API name fails at startup instead of silently renaming the tool clients ask for. The naming convention: -- an acronym stays as it is — `ALI`, `ASO`, `AMASSS`; -- anything else is capitalised words joined by underscores — +- an acronym stays as it is -- `ALI`, `ASO`, `AMASSS`; +- anything else is capitalised words joined by underscores -- `Batch_Dental_Seg`, `Crown_Seg`, `Surg_Mov_Pred`, `Example_Tool`; - a client renders the name with the underscores as spaces where it can. -The **Python package** under `src/` stays lowercase — `sadt_batch_dental_seg`, -not `sadt_Batch_Dental_Seg` — because it is a Python identifier and PEP 8 +The **Python package** under `src/` stays lowercase -- `sadt_batch_dental_seg`, +not `sadt_Batch_Dental_Seg` -- because it is a Python identifier and PEP 8 applies. `describe.py` finds it as "the single package under src/", so the two spellings never have to agree. @@ -105,7 +105,7 @@ rather than publish a schema the client will render wrongly. **A fixed set of options is a `Literal`.** `list[Literal["MAND", "MAX", ...]]` is several-of, a bare `Literal["MERGED", "SEPARATE"]` is exactly-one, and describe.py publishes both as `choices` so the client can render a picker. -Saying it in the annotation is the point — the `ArgSpec.choices` tables this +Saying it in the annotation is the point -- the `ArgSpec.choices` tables this replaces were a second declaration, and they drifted. The default is checked against the options, so a picker can always produce the value the tool starts from. @@ -122,7 +122,7 @@ comes from, so there is no second declaration to contradict the signature. decides from the argument's NAME whether a `Path` is something it already holds or something the caller uploads: `model`, `*_model` and `*_reference` are picked from `DATA//models/`, everything else gets a file picker. That is a safety -property — a clinician must not be able to send model weights from a laptop — +property -- a clinician must not be able to send model weights from a laptop -- and it is one letter wide: `ASO`'s `landmark_models`, plural, missed it and would have asked for a 4.7 GB bundle as an upload. Check every `Path` argument against that rule before opening the PR. @@ -154,17 +154,17 @@ LAYOUT = { Six keys, nothing else: `section`, `ui`, `groups`, `visible_when`, `label`, `hidden`. `describe.py` merges them into the published arguments and **refuses** any that names an argument the signature does not take, an option it does not -offer, or a value a condition could never match. Absent is fine — the schema is +offer, or a value a condition could never match. Absent is fine -- the schema is then exactly what it was before. The set is a **joint** decision with the server: a key it does not name is -dropped silently on the way through — that happened once, and was invisible -from both ends — so adding a seventh here does nothing until it is added there +dropped silently on the way through -- that happened once, and was invisible +from both ends -- so adding a seventh here does nothing until it is added there too. One is pending in that direction: **`options_when`**, `{other_arg: {value: [options]}}`, which narrows a choice argument's own options instead of hiding the whole field. The server accepts it and the client renders it; `LAYOUT_KEYS` does not emit it. It is what `AREG` wants for its -three automation modes — all meaningful, none of which offers "Oriented + +three automation modes -- all meaningful, none of which offers "Oriented + Fully-Automated" on IOS, so today the combo box offers a mode that fails at the end of a run. @@ -192,14 +192,14 @@ after it is for maintainers and never reaches the UI. **Write only under `output_dir`.** Every tool takes it as a required `Path` argument. `run()` returns a `Path`, or a `dict[str, Path]` when there are several named outputs, and it must not write beside its inputs, into the working -directory or anywhere else. `output/` at the repository root is gitignored — +directory or anywhere else. `output/` at the repository root is gitignored -- point a manual run at it rather than scattering results through the tree. **`run()` does not read the environment and does not know about `/DATA`.** Path resolution belongs to the server. Model weights are not packaged either: the server fetches them into `/DATA//models` and passes the path in. -**Tool sequencing belongs to the server too — with one exception.** Where one +**Tool sequencing belongs to the server too -- with one exception.** Where one tool's output is another's input, the server chains them and neither tool knows the other exists. That covers almost every case: `Crown_Seg → ALI` is two calls with a folder in between. The exception is a tool that needs another *in the @@ -219,7 +219,7 @@ def run(scans: Path, reference: Path, output_dir: Path, *, sup=None) -> Path: instead, so a runner that cannot inject one refuses the tool rather than calling it and failing halfway. A `sup` that is positional or annotated is a hard error, not a schema entry. -- It is **duck-typed**. Never import a supervisor type — that would need a +- It is **duck-typed**. Never import a supervisor type -- that would need a package shared with the server, which is what the split removes. Three implementations produce the same shape and a tool cannot tell them apart: the server's (`server/execution/runner.py`), `scripts/run_tool.py`, and the dozen @@ -250,7 +250,7 @@ python ../../scripts/run_tool.py --help # the CLI it implies ``` `run_tool.py` builds its parser from the same signature, so `--help` is the -fastest way to see what you have actually declared — and running it is the +fastest way to see what you have actually declared -- and running it is the fastest way to exercise a supervisor without a server. ## 4. Port the implementation @@ -264,19 +264,19 @@ explicitly in the PR description and in the tool's README. ### Enumerate, never pattern-match, when comparing against upstream Comparing our port against an upstream module means listing what each side -declares. List it — do not filter it through a regex you wrote from memory. +declares. List it -- do not filter it through a regex you wrote from memory. The cost is not a missed line, it is a **confident wrong answer**. An audit of every tool's arguments used `^\s+([a-z_]+)\s*:` to pull parameter names out of a `run()` signature. It silently dropped every argument containing a capital, -which is exactly `prediction_ID` — and the report that came out of it named +which is exactly `prediction_ID` -- and the report that came out of it named AMASSS's missing `prediction_ID` as an API inconsistency across tools meant to compose with each other. AMASSS has had it all along, in its signature, documented and wired. So did Batch_Dental_Seg. Upstream CLI parameters mix case freely (`DCMInput`, `SegmentInput`, `save_in_folder`, `lm_type`), and so do ours. A filter that assumes otherwise -produces a table that looks like coverage and is not — the same defect the +produces a table that looks like coverage and is not -- the same defect the audit itself exists to find, turned on the instrument. Use `[A-Za-z_][A-Za-z0-9_]*`, or better, parse rather than grep: `ast` for a @@ -285,8 +285,8 @@ on, re-derive it a second way before reporting it. ### A guard counts what the tool produced, not what it walked past -Every tool here tolerates a partial failure — one unreadable scan must not cost -the other 199 — and every tool therefore ends with a guard that refuses to +Every tool here tolerates a partial failure -- one unreadable scan must not cost +the other 199 -- and every tool therefore ends with a guard that refuses to return when nothing worked. Three of them counted the wrong noun, and all three returned a clean report on a run that had produced nothing: @@ -298,7 +298,7 @@ returned a clean report on a run that had produced nothing: Each is one noun away from correct, and each failure is invisible from the outside: the response is a 200, the report is full of successes, and the output -directory has files in it. `ALI_CBCT` shows what that costs — a scan on which +directory has files in it. `ALI_CBCT` shows what that costs -- a scan on which every agent failed to converge was recorded `ok`, so a torch upgrade that left two landmarks unplaced reported success, and the only thing that caught it was comparing coordinates by hand against a reference. @@ -306,7 +306,7 @@ comparing coordinates by hand against a reference. Two habits follow: - **Name the guard after the output.** `if not written`, `if not - predictions_by_target` — not `if not processed`. The noun you count is the + predictions_by_target` -- not `if not processed`. The noun you count is the claim you are making. - **A guard at one stage does not cover the next.** `Surg_Mov_Pred` refused to continue when no model could be *loaded*, then predicted nothing perfectly @@ -317,14 +317,14 @@ A fourth case is not a counting mistake and is worth recognising separately: a guard that is never reached. `Crown_Seg` imports its segmentation engine inside the branch that segments, so a batch of already-labelled meshes returned a clean report on a deployment where the engine could not run at all. **A tool's -availability must not depend on its input data** — one that can serve some +availability must not depend on its input data** -- one that can serve some batches and not others is not available, it has only not been asked the right question yet. When a tool records that condition, name it with the vocabulary the server already has: `ToolUnavailableError` is answered as a 501, so a per-item status of `engine_unavailable` reads with that and `degraded` reads beside it. And -remember what recording buys and what it does not — a field is only a signal if +remember what recording buys and what it does not -- a field is only a signal if something reads it. Putting the fact in the status rather than in a side field is the right *place*; it is not a guarantee anyone sees it. @@ -338,7 +338,7 @@ out of the registry. A copy costs a divergence; a coupling costs an entire class of failure. The copy usually wins. **The exception is anything that defines the shape of bytes leaving this -repository.** File formats, extension vocabularies, on-disk layouts — anything +repository.** File formats, extension vocabularies, on-disk layouts -- anything a third party reads or writes. Those go in a shared package, because a divergence there does not fail, it produces output that one consumer accepts and another silently mis-reads. @@ -347,7 +347,7 @@ The example that settles it. Before the split, ALI's two engines both wrote Slicer markups files, and both set `display.visibility: false` in them. That switches the markups display node off: Slicer loads the file, builds the node, and draws nothing. The bug was invisible for as long as nobody opened a result -outside the module, and it was in **both** copies — one mistake, written twice, +outside the module, and it was in **both** copies -- one mistake, written twice, because the format was duplicated rather than shared. It is now in `tools/ALI/common/`, imported by `ALI_CBCT` and `ALI_IOS` alike, along with the table of which file extensions count as a CBCT volume and which as a surface @@ -355,13 +355,13 @@ table of which file extensions count as a CBCT volume and which as a surface silently ignored by the CLI). What stays duplicated even between two halves of one tool: `errors.py`. Errors -cross the process boundary by exception class **name** — the runner records the -name, the server maps it to an HTTP status — so a shared base class is not +cross the process boundary by exception class **name** -- the runner records the +name, the server maps it to an HTTP status -- so a shared base class is not merely unnecessary, it is not the mechanism. A shared package must declare **no dependencies**. It installs into several tool environments whose pins are deliberately incompatible, and anything it -pulled in would have to be satisfiable by all of them at once — which is the +pulled in would have to be satisfiable by all of them at once -- which is the constraint this repository exists to remove. ### A path dependency is installed as a COPY, not a link @@ -373,7 +373,7 @@ constraint this repository exists to remove. uv sync --reinstall-package sadt-areg-common ``` -Without it you edit, re-run, and see the OLD behaviour — which reads as "my fix +Without it you edit, re-run, and see the OLD behaviour -- which reads as "my fix did not work" and sends you rewriting a correct patch. It cost a cycle the first time it came up. `editable = true` avoids it, and is why the dev-only `sadt-testkit` entries carry it; a shared runtime package deliberately does not, @@ -384,14 +384,14 @@ tree happens to hold. Never in a shared package, however much orchestration two tools appear to have in common. `describe.py` derives the schema's `calls` field by **reading each -tool's own source** — the call sites sit in branches only a real run reaches, so -there is nothing to introspect at import time — and the server refuses to start +tool's own source** -- the call sites sit in branches only a real run reaches, so +there is nothing to introspect at import time -- and the server refuses to start when a declared call names a tool it does not serve. Orchestration moved into a shared package is invisible to both: the names never reach `calls`, and the startup check silently has nothing to verify. That is worse than no check. A tool would then declare `supervisor = true` with -an empty `calls`, and a renamed sibling would break it at run time again — +an empty `calls`, and a renamed sibling would break it at run time again -- which is exactly what the check was added to prevent. AREG is the case that settles it. Its two engines share four of the six helpers @@ -413,7 +413,7 @@ This is the standing rule applied, not an exception to it: orchestration is A source says *where* a package comes from. It does not make the package a dependency. Name it in a source and forget to name it in `dependencies`, and uv resolves without complaint, installs nothing, and the failure arrives at -runtime as a `ModuleNotFoundError` or — worse — as a *different* build of the +runtime as a `ModuleNotFoundError` or -- worse -- as a *different* build of the package pulled in transitively from PyPI. It has caught three different things in this repository, which is what makes it @@ -422,13 +422,13 @@ a rule rather than an anecdote: | package | left undeclared | what happened | |---|---|---| | `pytorch3d` | pulled transitively by `shapeaxi` | source ignored, *"no wheels with a matching Python version tag"* | -| `torchvision` | pulled transitively by the torch stack | came from PyPI, built against the default torch instead of cu128 — imports fine, then `RuntimeError: operator torchvision::nms does not exist` | +| `torchvision` | pulled transitively by the torch stack | came from PyPI, built against the default torch instead of cu128 -- imports fine, then `RuntimeError: operator torchvision::nms does not exist` | | `sadt-ali-common` | a path dependency of ALI_CBCT/ALI_IOS | installed nothing at all; `ModuleNotFoundError` on first import | The rule: **if it has a `[tool.uv.sources]` entry, it must also be in `[project] dependencies` (or in an extra).** Transitive is not enough, and the -two failure modes it produces — a missing module, and a right-version wrong-build -C extension — look nothing like each other, so recognising one does not help you +two failure modes it produces -- a missing module, and a right-version wrong-build +C extension -- look nothing like each other, so recognising one does not help you recognise the next. ### A directory is a tool when its pyproject says so @@ -442,7 +442,7 @@ tool = true ``` A shared path dependency (`tools/ALI/common/`, `testkit/`) has a -`pyproject.toml` — it must, to be installable — and no `[tool.sadt]`, so it is +`pyproject.toml` -- it must, to be installable -- and no `[tool.sadt]`, so it is importable, installable, and never discovered or served. This is what lets a grouping folder like `tools/ALI/` hold `ALI_CBCT/`, `ALI_IOS/` and `common/` side by side. Single-engine tools stay flat: there is no `tools/AMASSS/AMASSS/`. @@ -452,14 +452,14 @@ side by side. Single-engine tools stay flat: there is no `tools/AMASSS/AMASSS/`. **Do not bump torch, monai or numpy to "something newer that works".** Changing them can change model outputs, and outputs must be revalidated against reference data before any version moves. If upstream's pins are ambiguous or -contradictory, ask — do not pick one yourself. +contradictory, ask -- do not pick one yourself. `requires-python` must be accurate: bound it by what the pins actually support, or uv will pick an interpreter with no wheels for them and spend a quarter of an hour building from source. CUDA-variant wheels need their own index, per tool, and `explicit` is -load-bearing — without it uv looks for *every* package on that index: +load-bearing -- without it uv looks for *every* package on that index: ```toml [[tool.uv.index]] @@ -473,7 +473,7 @@ torch = { index = "pytorch-cu118" } `pytorch3d` needs torch present at build time and fails under uv's build isolation. Install it with `--no-build-isolation` and an explicit order, and -write the exact working incantation into the tool's README — deployment +write the exact working incantation into the tool's README -- deployment precompiles it once into a local `/wheels` directory. If it will not build and the only workaround is a different torch, stop and ask. @@ -481,7 +481,7 @@ the only workaround is a different torch, stop and ask. lockfile across its members, which is precisely what must not happen here: uv cannot resolve torch 1.13 and torch 2.4 in one lock. These tools live in one repository for convenience and share no dependency resolution. For the same -reason there is no shared `sadt-core` package — small helpers like `iter_scans` +reason there is no shared `sadt-core` package -- small helpers like `iter_scans` are copied between tools on purpose. Commit `uv.lock`. CI runs `uv sync --frozen`, which fails if it is stale. @@ -493,13 +493,13 @@ output: the expected files exist, they are a plausible size, and where a reference output exists, results match within a documented tolerance. Test data lives in `tools//tests/data/` as **a download script plus -checksums** — never large binaries, never patient data, and the fixtures must be +checksums** -- never large binaries, never patient data, and the fixtures must be anonymised and public-domain. If no suitable public sample exists, ask before committing anything. **When a tool's input is another tool's output**, test against the real thing rather than a stand-in. `sadt-testkit` runs the other tool through *its* venv as -a subprocess — the way the server does — so nothing is imported across tools: +a subprocess -- the way the server does -- so nothing is imported across tools: ```python from sadt_testkit import is_built, run_tool @@ -523,7 +523,7 @@ GPU tests carry `@pytest.mark.gpu`. CI skips them (`-m "not gpu"`) because the runner has no CUDA device, which makes them your responsibility: run them by hand and state in the PR that you did and what came out. -The tool's README records what you validated against — which input, which model +The tool's README records what you validated against -- which input, which model weights, which reference, what tolerance. You may use the **Slicer Cloud** application to exercise a tool end to end @@ -534,8 +534,8 @@ and credentials rather than guessing, and never commit them. The tool README opens with the block from `tools/_template/README.md`, filled in, and the same PR adds the tool's row to [PROVENANCE.md](PROVENANCE.md). -Upstream history is not grafted into this repository — it is one history for -sixteen unrelated modules and the result would be unreadable — so this table is +Upstream history is not grafted into this repository -- it is one history for +sixteen unrelated modules and the result would be unreadable -- so this table is the only record of where an algorithm came from. ## 8. Open the pull request @@ -549,7 +549,7 @@ State: Prefer an open question in the PR over a silent decision. Once the tool is merged here, open a companion PR on -`slicer-remote-tool-server` deleting it there. **Never delete first** — the +`slicer-remote-tool-server` deleting it there. **Never delete first** -- the server keeps working off its copy until this one is proven. ## Stop and ask when @@ -571,13 +571,13 @@ uv run --no-project --python 3.11 --with pyflakes -- python -m pyflakes tools/*/ **pyflakes, and specifically for undefined names.** An import proves a module loads; it says nothing about a name referenced inside a branch only a real run reaches, and that is exactly where this repository keeps finding them. Splitting -ALI left four such defects — a constant whose import went with the block that +ALI left four such defects -- a constant whose import went with the block that defined it, a module that moved to the other engine, a dependency dropped from a pyproject, and a semaphore whose definition was removed while one use survived. All four passed `import`, all four passed schema generation, and the first three were found one per run until a single pyflakes sweep found the rest at once. -Run it across every tool, including the ones nested under a grouping folder — +Run it across every tool, including the ones nested under a grouping folder -- `tools/*/src tools/*/*/src` covers both depths. `audit.py` is read-only by design: it reports distinct torch and Python versions diff --git a/PROVENANCE.md b/PROVENANCE.md index 856d725..1a30886 100644 --- a/PROVENANCE.md +++ b/PROVENANCE.md @@ -3,7 +3,7 @@ Where each tool's algorithm came from. Upstream history is deliberately **not** grafted into this repository: it is a single history covering sixteen unrelated modules, and merging it would make neither history readable. This table is the -record instead, and it matters more here than commit history does — it is what +record instead, and it matters more here than commit history does -- it is what tells you whether a result came from upstream code or from something we changed. Upstream is @@ -13,13 +13,13 @@ information in full, including the pins kept and the changes made. | Tool | Upstream path | Upstream commit | Ported | Algorithm modified | |---|---|---|---|---| -| [Surg_Mov_Pred](tools/Surg_Mov_Pred/) | `SurgMovPred_CLI/SurgMovPred_CLI.py` | `d7702ae` (2026-06-24) | 2026-08-12 | no — repackaging only. Model load order sorted for reproducibility, both result tables returned instead of one; predictions bit-identical to the pre-port implementation. | -| [AMASSS](tools/AMASSS/) | `AMASSS_CLI/` | `21a62a8` (2026-05-22) | 2026-08-12 | no — repackaging only. Pinned to the deployed stack (torch 2.8.0+cu128, nnunetv2 2.8.1) rather than upstream's declared torch 2.2.0 / nnunetv2 2.8.0; masks bit-identical to the pre-port implementation, within nnUNet's own CUDA nondeterminism. | -| [ASO](tools/ASO/) | `ASO/`, `ASO_CBCT/{PRE,SEMI}_ASO_CBCT/`, `ASO_IOS/{PRE,SEMI}_ASO_IOS/` | **unrecorded** — see below | 2026-08-13 | no — repackaging only. Nothing is pinned upstream; pinned to the imaging stack the sibling tools lock. Fully-automated CBCT reaches the landmark tool through the supervisor at the point it always ran, so the order is unchanged. Driven end to end through a real supervisor on the real bundle and a real card; not yet diffed numerically against the pre-port ASO. | -| [ALI](tools/ALI/) | `ALI_CBCT/`, `ALI_CBCT_utils/`, `ALI_IOS/`, `ALI_IOS_utils/` | **unrecorded** — see below | 2026-08-13 | no — repackaging only. Pinned to the deployed stack (torch 2.8.0+cu128, monai 1.6.0, itk 5.4.7). One visible behaviour change: an unlabelled IOS mesh is refused naming `Crown_Seg` instead of being segmented in-process. CBCT landmarks **bit-identical** to the pre-port implementation on a real scan (0.0000 mm across 16/16 run pairs, both sides deterministic); the IOS half is unvalidated, pytorch3d needing a CUDA toolkit. Mucogingival (a third IOS network, mandible only) added from the server's unmerged `AREG` branch, where it had been written against the in-process ALI. IOS crown networks validated on a real mesh; MG's own predictions are not, for want of a lower arch. | -| [AREG](tools/AREG/) | `AREG/` and its CLI modules, by way of the server's unmerged `AREG` branch | **unrecorded** — see below | 2026-08-14 | no — repackaging only. Pinned to the deployed stack, plus `itk-elastix` which no sibling needs. Drives four tools (AMASSS, ASO, Crown_Seg, ALI) through the supervisor, where the in-process version used `registry.TOOLS`. CBCT engine validated end to end against a known transform; the IOS engine and any comparison with the pre-port implementation are **not**. | -| [Crown_Seg](tools/Crown_Seg/) | — (written against `shapeaxi` directly) | shapeaxi 2.0.2 | 2026-08-12 | no — the network is untouched and its raw output is bit-identical. Carries a two-line workaround for a shapeaxi 2.0.x bug that breaks the tool upstream and downstream alike. | -| [Batch_Dental_Seg](tools/Batch_Dental_Seg/) | `BATCHDENTALSEG/BATCHDENTALSEGLib/SegmentationWidget.py` | `6df3fab` (2026-08-05) | 2026-08-12 | no — repackaging only. Same stack as AMASSS (torch 2.8.0+cu128, nnunetv2 2.8.1); labels compared against the pre-port implementation. | +| [Surg_Mov_Pred](tools/Surg_Mov_Pred/) | `SurgMovPred_CLI/SurgMovPred_CLI.py` | `d7702ae` (2026-06-24) | 2026-08-12 | no -- repackaging only. Model load order sorted for reproducibility, both result tables returned instead of one; predictions bit-identical to the pre-port implementation. | +| [AMASSS](tools/AMASSS/) | `AMASSS_CLI/` | `21a62a8` (2026-05-22) | 2026-08-12 | no -- repackaging only. Pinned to the deployed stack (torch 2.8.0+cu128, nnunetv2 2.8.1) rather than upstream's declared torch 2.2.0 / nnunetv2 2.8.0; masks bit-identical to the pre-port implementation, within nnUNet's own CUDA nondeterminism. | +| [ASO](tools/ASO/) | `ASO/`, `ASO_CBCT/{PRE,SEMI}_ASO_CBCT/`, `ASO_IOS/{PRE,SEMI}_ASO_IOS/` | **unrecorded** -- see below | 2026-08-13 | no -- repackaging only. Nothing is pinned upstream; pinned to the imaging stack the sibling tools lock. Fully-automated CBCT reaches the landmark tool through the supervisor at the point it always ran, so the order is unchanged. Driven end to end through a real supervisor on the real bundle and a real card; not yet diffed numerically against the pre-port ASO. | +| [ALI](tools/ALI/) | `ALI_CBCT/`, `ALI_CBCT_utils/`, `ALI_IOS/`, `ALI_IOS_utils/` | **unrecorded** -- see below | 2026-08-13 | no -- repackaging only. Pinned to the deployed stack (torch 2.8.0+cu128, monai 1.6.0, itk 5.4.7). One visible behaviour change: an unlabelled IOS mesh is refused naming `Crown_Seg` instead of being segmented in-process. CBCT landmarks **bit-identical** to the pre-port implementation on a real scan (0.0000 mm across 16/16 run pairs, both sides deterministic); the IOS half is unvalidated, pytorch3d needing a CUDA toolkit. Mucogingival (a third IOS network, mandible only) added from the server's unmerged `AREG` branch, where it had been written against the in-process ALI. IOS crown networks validated on a real mesh; MG's own predictions are not, for want of a lower arch. | +| [AREG](tools/AREG/) | `AREG/` and its CLI modules, by way of the server's unmerged `AREG` branch | **unrecorded** -- see below | 2026-08-14 | no -- repackaging only. Pinned to the deployed stack, plus `itk-elastix` which no sibling needs. Drives four tools (AMASSS, ASO, Crown_Seg, ALI) through the supervisor, where the in-process version used `registry.TOOLS`. CBCT engine validated end to end against a known transform; the IOS engine and any comparison with the pre-port implementation are **not**. | +| [Crown_Seg](tools/Crown_Seg/) | -- (written against `shapeaxi` directly) | shapeaxi 2.0.2 | 2026-08-12 | no -- the network is untouched and its raw output is bit-identical. Carries a two-line workaround for a shapeaxi 2.0.x bug that breaks the tool upstream and downstream alike. | +| [Batch_Dental_Seg](tools/Batch_Dental_Seg/) | `BATCHDENTALSEG/BATCHDENTALSEGLib/SegmentationWidget.py` | `6df3fab` (2026-08-05) | 2026-08-12 | no -- repackaging only. Same stack as AMASSS (torch 2.8.0+cu128, nnunetv2 2.8.1); labels compared against the pre-port implementation. | A row is filled in by the PR that migrates the tool, in the same commit that adds the package. "Algorithm modified" is `no` for a pure repackaging and @@ -33,16 +33,16 @@ The server-side source is preserved at the `archive/AREG` tag in **ALI's, ASO's and AREG's upstream commits are unrecorded, and that is a gap, not a style.** Both server-side ports landed with no upstream revision in the commit -message and none in the tree — ALI as `ADD ALI & CrownSeg` (`a0ed474`, -2026-07-31) — so which upstream commit each algorithm came from cannot be +message and none in the tree -- ALI as `ADD ALI & CrownSeg` (`a0ed474`, +2026-07-31) -- so which upstream commit each algorithm came from cannot be recovered from either repository. The per-module mappings are exact and are in [tools/ALI/README.md](tools/ALI/README.md) and [tools/ASO/README.md](tools/ASO/README.md); only the revisions are missing, and -they need filling in by whoever made those ports. Until then, "no — repackaging +they need filling in by whoever made those ports. Until then, "no -- repackaging only" is a claim about code that cannot be pointed at. `tools/_template/` has no row: it is the reference package the others are copied from, not a port. **Every tool is now in this table.** AREG was the last, and it was the one this -document used to say was deliberately absent — it is migrated as of 2026-08-14. +document used to say was deliberately absent -- it is migrated as of 2026-08-14. diff --git a/README.md b/README.md index f85a72b..ca94c2f 100644 --- a/README.md +++ b/README.md @@ -63,8 +63,8 @@ The server runs it out of process, one interpreter per tool: /tools//.venv/bin/python /opt/sadt/runner.py --job /jobs//job.json ``` -`runner.py` ships with the server and is injected by absolute path — never -installed into a tool venv — so runner and server are always the same version +`runner.py` ships with the server and is injected by absolute path -- never +installed into a tool venv -- so runner and server are always the same version and there is no cross-repo skew to manage. That is also why there is no shared `sadt-core` package: adding one would put a version of *ours* inside every tool venv, and it would solve a problem that does not exist. @@ -72,11 +72,11 @@ venv, and it would solve a problem that does not exist. A tool that needs another tool **mid-run** declares `*, sup` and is handed a supervisor; the call re-enters the same runner with the sibling's interpreter. `ASO` and `AREG` are the two that do. Everything the server has to hold up on -its side of that — and everything else it took over when the tools stopped -doing it — is in [docs/SERVER_CONTRACT.md](docs/SERVER_CONTRACT.md). +its side of that -- and everything else it took over when the tools stopped +doing it -- is in [docs/SERVER_CONTRACT.md](docs/SERVER_CONTRACT.md). -The full set of rules — annotations, defaults, batch inputs, where output may be -written — is in [CONTRIBUTING.md](CONTRIBUTING.md). `tools/_template/` is a +The full set of rules -- annotations, defaults, batch inputs, where output may be +written -- is in [CONTRIBUTING.md](CONTRIBUTING.md). `tools/_template/` is a working example of all of them. ## Layout @@ -103,7 +103,7 @@ thing without importing it. See [testkit/README.md](testkit/README.md). ## Scripts `scripts/describe.py` emits the JSON schema the server publishes for a tool, -read from `run()`'s signature — so the schema cannot drift from the code. It +read from `run()`'s signature -- so the schema cannot drift from the code. It runs with the tool's own interpreter, because importing a tool needs the tool's dependencies: @@ -125,7 +125,7 @@ $ tools/_template/.venv/bin/python scripts/describe.py tools/_template ``` An argument annotated `Literal[...]` publishes its options as `choices`, so the -client can render a picker without a second declaration to keep in step — +client can render a picker without a second declaration to keep in step -- `list[Literal[...]]` for several-of, a bare `Literal[...]` for exactly-one. It exits 2 on anything it cannot represent rather than emitting a schema that is @@ -163,12 +163,12 @@ out/ The second command is the whole point: ASO needs landmarks mid-run, so it is given a **supervisor**, and `sup.run("ALI_CBCT", ...)` re-enters this same script with that tool's interpreter. Chaining and nesting are the same -recursion — `AREG → ASO → ALI_CBCT` is three levels of it with no special case. +recursion -- `AREG → ASO → ALI_CBCT` is three levels of it with no special case. **Developer convenience, not the deployment path.** In production the server's `execution/runner.py` does this, and a tool cannot tell the two apart: five members, duck-typed, nothing shared. It is still the shortest readable -reference for what a supervisor has to be — and the place to reproduce a +reference for what a supervisor has to be -- and the place to reproduce a chaining bug without standing a server up. ## Getting started @@ -184,6 +184,6 @@ uv run pytest # runs run() end to end ## Provenance [PROVENANCE.md](PROVENANCE.md) records, for every tool, the upstream path and -commit it was ported from and whether the algorithm was modified. That table — -not this repository's commit history — is what tells you six months from now +commit it was ported from and whether the algorithm was modified. That table -- +not this repository's commit history -- is what tells you six months from now whether a result came from upstream code or from something we changed. diff --git a/docs/SERVER_CONTRACT.md b/docs/SERVER_CONTRACT.md index 4e5af19..5c559ba 100644 --- a/docs/SERVER_CONTRACT.md +++ b/docs/SERVER_CONTRACT.md @@ -15,8 +15,8 @@ kill, and `peak_vram_bytes` instrumentation). Read-only inspection. three gaps in order of how much they mattered: - **The server injects a supervisor.** `server/execution/runner.py` builds one - when `run()` declares `*, sup` — keyword-only and unannotated, the same rule - `describe.py` uses — and `sup.run(tool, **params)` re-enters that same file + when `run()` declares `*, sup` -- keyword-only and unannotated, the same rule + `describe.py` uses -- and `sup.run(tool, **params)` re-enters that same file with the sibling's interpreter. Five members, duck-typed, exactly as §5 asked for. `ASO`'s fully-automated CBCT mode and all of `AREG` work under it. - **`supervisor` is a recognised top-level key**, not an ignored one @@ -26,22 +26,22 @@ three gaps in order of how much they mattered: `server/registry/conventions.py` derives from argument NAMES what `deployment.toml` used to have to state, `server/deployment.toml` is an empty file of comments, and `DATA/` is resolved by the tool's name with underscores - stripped — so `Batch_Dental_Seg` reads `DATA/BatchDentalSeg/` with nothing + stripped -- so `Batch_Dental_Seg` reads `DATA/BatchDentalSeg/` with nothing written down. The lowercase-vs-capitalised mismatch this document warned about no longer exists on either side. **The presentation keys travel.** `label`, `section`, `ui`, `groups`, `visible_when` and `hidden` are published through `GET /tools` and read by the -Slicer client. There was a period where the server dropped them silently — -this repository published them, the client read them, and nothing arrived — so +Slicer client. There was a period where the server dropped them silently -- +this repository published them, the client read them, and nothing arrived -- so each key is now named explicitly in `schema_tool.py` rather than passed through wholesale. **Adding a seventh key needs a one-line change on the server**, and until it lands the key does not exist as far as any client is concerned. There is one such key already: **`options_when`**, which the server accepts and the client renders, and which `describe.py`'s `LAYOUT_KEYS` does not emit. It -narrows a choice argument's own options instead of hiding the whole field — -`{"modality": {"IOS": ["Semi-Automated", "Fully-Automated"]}}` — and it exists +narrows a choice argument's own options instead of hiding the whole field -- +`{"modality": {"IOS": ["Semi-Automated", "Fully-Automated"]}}` -- and it exists because `AREG`'s three automation modes are all meaningful while IOS has no "Oriented + Fully-Automated". Without it the combo box offers a mode that fails at the end of a run. **This is the one live divergence between the two @@ -59,7 +59,7 @@ repositories.** look exactly one level below `TOOLS_DIR`, so `tools/ALI/ALI_CBCT` is discovered as neither `ALI` nor `ALI_CBCT`. Only the supervisor's own lookup descends into a group. **The ALI split therefore needs either a flattening - step when the tools are staged, or two more lookups on the server** — + step when the tools are staged, or two more lookups on the server** -- whichever is chosen has to be chosen deliberately, because the failure is a tool that simply does not appear in `GET /tools`. - **VRAM is counted, not budgeted.** `MAX_CONCURRENT_GPU_JOBS` is a job @@ -77,7 +77,7 @@ A tool that hosts nothing gets this for free, but two names are load-bearing: | `model`, `*_model`, `*_reference` | a name picked from `DATA//models/` | | any other `path` | a file the caller may upload | -`ASO` shipped `landmark_models` — plural — which misses `*_model` by one letter +`ASO` shipped `landmark_models` -- plural -- which misses `*_model` by one letter and would have put a file picker in front of a 4.7 GB weight bundle. It is `landmark_model` now, with a test guarding it. Check a new tool's path arguments against this table before opening the PR; nothing else will. @@ -88,7 +88,7 @@ repositories are written against. ## 1. Discovery `scripts/describe.py` in this repository turns a tool's `run()` signature into -the JSON the server publishes. Run it with the **tool's own interpreter** — +the JSON the server publishes. Run it with the **tool's own interpreter** -- importing a tool needs the tool's dependencies: ```bash @@ -97,7 +97,7 @@ importing a tool needs the tool's dependencies: It is dependency-free and stays Python 3.9-compatible so it can run inside any tool venv, including one pinned to an old interpreter. It exits **2** with a -message on stderr for anything it cannot represent, and prints nothing — treat a +message on stderr for anything it cannot represent, and prints nothing -- treat a non-zero exit as "this tool is not loadable" and say so, rather than serving a partial schema. @@ -122,7 +122,7 @@ partial schema. } ``` -**`name` is the folder name**, spelled as a client sends it — `AMASSS`, +**`name` is the folder name**, spelled as a client sends it -- `AMASSS`, `ALI_CBCT`, `Batch_Dental_Seg`. It is not lowercased anywhere: the server looks up the interpreter at `//.venv/bin/python`, and a Slicer module holds the same string in `TOOL_NAME`. @@ -143,7 +143,7 @@ Three things to build against: `ui`, `groups`, `visible_when` and `hidden` are merged in from the tool's `layout.py`; the server publishes them untouched and `validate()` ignores every one. A key the server does not name is silently dropped, so the set is - a joint decision rather than something either side can extend alone — + a joint decision rather than something either side can extend alone -- `options_when` is currently accepted by the server and not emitted here. **Argument order is the signature's order.** Render forms in it. @@ -155,13 +155,13 @@ Three things to build against: ``` `uv sync` installs each tool into its own venv, so `import sadt_` works -directly — no `sys.path` juggling needed, though adding `/tools//src` +directly -- no `sys.path` juggling needed, though adding `/tools//src` first is harmless and makes the runner work against an unsynced checkout too. `server/execution/runner.py` implements this, and [`testkit/src/sadt_testkit/_driver.py`](../testkit/src/sadt_testkit/_driver.py) in this repository is the same contract, ~60 lines, stdlib-only. The two were -written independently and agree — same coercion by annotation, same result +written independently and agree -- same coercion by annotation, same result file, same error-class-name convention. **Keep them in step deliberately**: the driver is what every tool's integration tests run against, so if they drift, the tests here pass while production fails. @@ -172,7 +172,7 @@ Two details from it worth carrying over: must become `Path` for parameters annotated `Path` (or `list[Path]`). `typing.get_type_hints(run)` is how the driver decides. - **An empty string is ABSENCE and must stay a string.** `Path("")` is - `PosixPath(".")` — the current directory, and truthy — so coercing the + `PosixPath(".")` -- the current directory, and truthy -- so coercing the "not supplied" default of an optional path hands the tool a real directory. This is not hypothetical: it made `ASO` read an unset `landmarks=""` as a supplied landmark folder and walk the entire checkout, `.venv` included. @@ -184,7 +184,7 @@ Two details from it worth carrying over: ``` `run_tool.py` and the testkit driver both do this; the server's runner must - too. It is the price of `describe.py` refusing `None` defaults — an optional + too. It is the price of `describe.py` refusing `None` defaults -- an optional path has no other way to say "unset". - **Never parse the result off stdout.** These tools print progress bars, nnUNet banners and shapeaxi chatter. The driver writes the result to a file whose path @@ -193,12 +193,12 @@ Two details from it worth carrying over: `run()` returns a `Path` or a `dict[str, Path]`; `returns` in the schema says which. Today: `surgmovpred` returns `dict[str, path]` (`excel` and `csv`), the -others return a `path` — the output directory they were given. +others return a `path` -- the output directory they were given. ## 3. Work that moved to the server Each of these used to happen inside a tool and now happens nowhere unless the -server does it. **All of them are already implemented** — the file references +server does it. **All of them are already implemented** -- the file references are in the status table above. They are kept here because they are the reasoning behind the code, and the first person to touch that code will want it. @@ -217,8 +217,8 @@ passes a real file or directory. Two behaviours have to come with it: **Implemented**: `settings.MAX_CONCURRENT_GPU_JOBS` (default 1), one counter **across** tools, held in `execution/dispatch.py`. Every tool used to hold a -`threading.BoundedSemaphore` — `AMASSS_MAX_GPU_JOBS`, `BATCHDENTALSEG_MAX_GPU_JOBS`, -`CROWNSEG_MAX_GPU_JOBS`, `ALI_MAX_GPU_JOBS`, all defaulting to 1 — because every +`threading.BoundedSemaphore` -- `AMASSS_MAX_GPU_JOBS`, `BATCHDENTALSEG_MAX_GPU_JOBS`, +`CROWNSEG_MAX_GPU_JOBS`, `ALI_MAX_GPU_JOBS`, all defaulting to 1 -- because every tool shared one server process. A tool is now its own process, so an in-process semaphore would cap nothing and they have all been removed. An AMASSS run and a `Crown_Seg` run compete for the same device, which is why the counter cannot be @@ -227,7 +227,7 @@ per tool. **A run is assumed to want the card** unless it declares `device` and resolves it to a CPU value. That default is deliberately the strict one: a tool that imports torch without declaring `device` would otherwise never queue at all. -The consequence for this repository is concrete — **a GPU tool must declare +The consequence for this repository is concrete -- **a GPU tool must declare `device`**, or every run of it, CPU or not, takes a GPU slot; and a CPU-only tool that declares one gets to say so and never queues. @@ -238,7 +238,7 @@ at once), and the counter is jobs rather than memory. ### Creating and owning the output directory -`output_dir` is a required argument on every tool. Create it (or let the tool — +`output_dir` is a required argument on every tool. Create it (or let the tool -- they all `mkdir(parents=True, exist_ok=True)`), pass it, and archive what comes back. Tools write **only** there; each has a test asserting it. @@ -249,20 +249,20 @@ run crashed. ### Resolving model weights Tools no longer touch `data_store` or `settings.CROWNSEG_MODEL`. The server -resolves the name and passes a path — but **what kind of path differs per tool**: +resolves the name and passes a path -- but **what kind of path differs per tool**: | Tool | `model` is | Note | |---|---|---| | `surgmovpred` | a folder | every `stacking_package.pkl` under it is loaded, recursively | | `amasss` | the bundle root | one subfolder per structure code (`MAND/`, `MAX/`, …); a single wrapper folder is descended into | -| `batchdentalseg` | **the bundle folder itself** | its *name* selects the model and its label table — see below | +| `batchdentalseg` | **the bundle folder itself** | its *name* selects the model and its label table -- see below | | `crownseg` | a `.pth` file | not a folder | **`batchdentalseg` is the sharp edge**: the folder's basename must equal a key in its `catalogs.MODELS` (`DentalSegmentator`, `PediatricDentalSeg`, `NasoMaxillaDentSeg`, `UniversalLab`), which must in turn equal the folder `scripts/data-manifest.yml` downloads that bundle into. The server-side test that -enforced that has no home any more — this repository cannot read the manifest. +enforced that has no home any more -- this repository cannot read the manifest. **This no longer needs a `deployment.toml`.** Tool directories here are the tool's name as a client sends it (`AMASSS`, `Batch_Dental_Seg`, `Crown_Seg`, @@ -274,7 +274,7 @@ a `data_dir` line. ### Configuring logging -Tools use a plain module logger and attach no handlers — a library that +Tools use a plain module logger and attach no handlers -- a library that configures logging takes the decision away from whatever runs it. The runner owns handlers, levels and formatting. Without that, tool logs go nowhere. @@ -290,7 +290,7 @@ their previous defaults. The server passes them only to override: | `settings.AMASSS_GPU_RESAMPLING` | `gpu_resampling` (amasss) | `true` | | `settings.BATCHDENTALSEG_TILE_STEP_SIZE` | `tile_step_size` (batchdentalseg) | `0.5` | | `settings.CROWNSEG_NUM_WORKERS` | `num_workers` (Crown_Seg) | `2` | -| `settings.*_MAX_GPU_JOBS` | — | gone; see "Capping GPU work" | +| `settings.*_MAX_GPU_JOBS` | -- | gone; see "Capping GPU work" | `tile_step_size` and `gpu_resampling` **change the segmentation**. The tools record what was used in their run report, so a mask stays reproducible. @@ -317,7 +317,7 @@ should write into its result file: - anything else → **500**, message not passed through. If you would rather not match on names, the alternative is a `type` field the -tools set explicitly — but that is a shared convention either way, and names are +tools set explicitly -- but that is a shared convention either way, and names are already the convention. ## 5. Sequencing tools @@ -337,13 +337,13 @@ naming `Crown_Seg`, rather than failing per mesh. **`ASO` fully-automated CBCT needs `ALI` from the middle of its own run.** It recentres each scan, predicts landmarks **on the centred volumes**, then -registers — the order the Slicer chain used (`PRE_ASO_CBCT` before `ALI_CBCT`). +registers -- the order the Slicer chain used (`PRE_ASO_CBCT` before `ALI_CBCT`). Running ALI first and handing ASO the markups reorders those two steps. That reordering *ought* to be exact: recentring resamples onto a grid shifted by the same offset, so the voxel array is untouched and only the origin metadata moves. But ALI's `physical_position` takes `abs(origin / spacing)`, which does -not commute with moving the origin — [issue #11]. Rather than bet a clinical +not commute with moving the origin -- [issue #11]. Rather than bet a clinical result on it, ASO calls ALI where it always ran. So ASO takes a **supervisor**, and the server provides one @@ -356,8 +356,8 @@ predictions = sup.run("ALI_CBCT", input=centered_root, model=bundle, output_dir= Every requirement this section used to list is met, and each was met the way it asked: -- **Five members, duck-typed** — `run(tool, **params)`, `out`, `tmp`, - `progress(fraction, message)`, `log(message)` — passed as the keyword-only +- **Five members, duck-typed** -- `run(tool, **params)`, `out`, `tmp`, + `progress(fraction, message)`, `log(message)` -- passed as the keyword-only `sup`. Nothing is imported across the two repositories. [`scripts/run_tool.py`](../scripts/run_tool.py) produces the same shape, and a tool cannot tell the three implementations apart. @@ -368,12 +368,12 @@ asked: - **Absolute paths and a neutral working directory.** Each nested call gets its own job directory under `/sup/NN_/`, and runs with that as `cwd`. - **The scratch is `sup.tmp`**, a sibling of `output/` inside the job directory, - removed with it — so the tool stays held to writing only under `output_dir`. + removed with it -- so the tool stays held to writing only under `output_dir`. - **`sup.run` returns what the tool returned**, a `Path` or a `dict[str, Path]`, reconstructed from the callee's `result.json`. - **Concurrency is answered by not queueing at all.** A nested call is a subprocess of its parent, so it never re-enters the server's admission queue - and cannot wait for a slot the parent is holding — the deadlock this section + and cannot wait for a slot the parent is holding -- the deadlock this section described is structurally impossible rather than mitigated. The cost is that nested work is invisible to `MAX_CONCURRENT_GPU_JOBS`. @@ -392,20 +392,20 @@ errors a tool author will read: `describe.py` publishes `"supervisor": true` for a tool that takes one, and the server now reads that key. Passing `landmarks` (a folder of `.mrk.json`) still makes fully-automated CBCT work with no supervisor at all, which is what lets -ASO be used standalone — keep that door open in every tool that takes a `sup`. +ASO be used standalone -- keep that door open in every tool that takes a `sup`. [issue #11]: https://github.com/Jules-GP/sadt-tools/issues/11 ## 6. What was deleted, and what was not -This happened, tool by tool, each after its PR merged *here* — never before, +This happened, tool by tool, each after its PR merged *here* -- never before, because the server kept working off its own copy until this one was proven. **Gone from the server**: every clinical tool. `server/tools/` holds `Test_Tool` and `Example_Tool` (in-process demos of the old path, kept deliberately), a `_dispatch_probe` fixture, and a parked `_AREG` kept only for its history. The server's own suite went from 461 tests to 220 in the same -movement — a packaged tool's tests belong to that tool and run in ITS +movement -- a packaged tool's tests belong to that tool and run in ITS interpreter. **Still there, and for a reason**: `base.py`'s `Tool`, `ArgSpec`, `Selection`, @@ -417,8 +417,8 @@ That layer is not legacy; it is the shape everything becomes. What is legacy is the *import* half of discovery, and the `SADT_DISPATCH_MODE` flag, both of which now only concern the two demos. -`requirements-api.txt` is what the API actually needs — fastapi, uvicorn, -python-multipart, pydantic-settings — and a test asserts it stays that way. +`requirements-api.txt` is what the API actually needs -- fastapi, uvicorn, +python-multipart, pydantic-settings -- and a test asserts it stays that way. `requirements.txt` is still the heavy one for a dev checkout, which is the last piece of this list outstanding. @@ -429,8 +429,8 @@ piece of this list outstanding. but in the whole 2.0.x line the class lives in `shapeaxi.saxi_nets_lightning`. The pre-port tool fails the same way on the deployed image. This repository carries a two-line workaround (`_restore_moved_class`) guarded so it vanishes - when upstream fixes it. Anything downstream of crown segmentation — ALI's IOS - half, the IOS modes of ASO/AREG/FlexReg — has been broken on the current image. -- **Disk.** Each torch venv is 7.2–7.7 GB at cu128. Deduplication across them is + when upstream fixes it. Anything downstream of crown segmentation -- ALI's IOS + half, the IOS modes of ASO/AREG/FlexReg -- has been broken on the current image. +- **Disk.** Each torch venv is 7.2-7.7 GB at cu128. Deduplication across them is not automatic and depends on `UV_CACHE_DIR` sitting on the same filesystem as the venvs; see the repository README. diff --git a/testkit/README.md b/testkit/README.md index 1e3c892..3ff28b9 100644 --- a/testkit/README.md +++ b/testkit/README.md @@ -14,7 +14,7 @@ out of `tools/ALI`. Before the split each of them simply imported the next (`ALILogic.py` did `from tools.CrownSeg.src import CrownSegLogic`), and that is the coupling the split removed: tools are sequenced by the server now. -That leaves a real gap — a tool whose input is another tool's output has nothing +That leaves a real gap -- a tool whose input is another tool's output has nothing realistic to test against. This closes it **without** putting the coupling back: the other tool is run as a subprocess, through its own `.venv`, exactly the way the server runs it. @@ -44,8 +44,8 @@ def test_ali_on_freshly_segmented_meshes(tmp_path): run(scans=segmented, model=ALI_MODEL, output_dir=tmp_path / "landmarks") ``` -`run_tool` returns what the tool's `run()` returned — a `Path`, or a -`dict[str, Path]` — which is the same value the server's runner would hand to +`run_tool` returns what the tool's `run()` returned -- a `Path`, or a +`dict[str, Path]` -- which is the same value the server's runner would hand to the next tool. | | | @@ -69,7 +69,7 @@ Two properties are only testable from outside the process, so `_template`'s one, so a stray write shows up. - **that the published schema and the callable agree.** The server reads the schema and calls `run(**params)` from it. An argument renamed in one place and - not the other breaks the chain in production, not in the tool's own tests — + not the other breaks the chain in production, not in the tool's own tests -- unless something compares them. ## Wiring it into a tool @@ -83,7 +83,7 @@ sadt-testkit = { path = "../../testkit", editable = true } ``` `_driver.py` is executed **by** the tool's interpreter and never imported by it, -so it is stdlib-only and stays 3.9-compatible — a tool may pin an old Python. +so it is stdlib-only and stays 3.9-compatible -- a tool may pin an old Python. It is deliberately a miniature of the server's runner: same job, same argument coercion. If the two ever drift, an integration test here would pass while the server failed, so keep it boring and keep it matching. diff --git a/tools/ALI/ALI_CBCT/src/sadt_ali_cbct/layout.py b/tools/ALI/ALI_CBCT/src/sadt_ali_cbct/layout.py index dfa7f3d..9a73c15 100644 --- a/tools/ALI/ALI_CBCT/src/sadt_ali_cbct/layout.py +++ b/tools/ALI/ALI_CBCT/src/sadt_ali_cbct/layout.py @@ -1,13 +1,13 @@ """How a client should lay this tool's panel out. Presentation only. -Nothing here changes what `run()` accepts — `describe.py` merges these hints +Nothing here changes what `run()` accepts -- `describe.py` merges these hints into the published schema and refuses any that name an argument or an option the signature does not offer. Delete this file and the tool still works; the panel just gets worse. **Everything is DERIVED, never restated.** That is the whole difference from the `ArgSpec` tables this replaces. Those listed the anatomical tabs by hand, and a -landmark added to the catalog was then reachable through no tab at all — offered +landmark added to the catalog was then reachable through no tab at all -- offered by the schema, invisible in the UI. Here the tabs are computed from `catalog.GROUP_LABELS`, so a landmark added there appears in its tab with no edit here and no client release. diff --git a/tools/ALI/ALI_IOS/src/sadt_ali_ios/layout.py b/tools/ALI/ALI_IOS/src/sadt_ali_ios/layout.py index 26269d6..1aaff3a 100644 --- a/tools/ALI/ALI_IOS/src/sadt_ali_ios/layout.py +++ b/tools/ALI/ALI_IOS/src/sadt_ali_ios/layout.py @@ -1,6 +1,6 @@ """How a client should lay this tool's panel out. Presentation only. -Nothing here changes what `run()` accepts — `describe.py` merges these hints +Nothing here changes what `run()` accepts -- `describe.py` merges these hints into the published schema and refuses any that name an argument or an option the signature does not offer. Delete this file and the tool still works; the panel just gets worse. diff --git a/tools/ALI/README.md b/tools/ALI/README.md index 0792a7d..c9c26cf 100644 --- a/tools/ALI/README.md +++ b/tools/ALI/README.md @@ -3,11 +3,11 @@ Places anatomical landmarks and writes Slicer markups files (`.mrk.json`). One tool, two engines that share nothing but their output format: -- **CBCT** — one deep-RL agent per landmark walks the volume at 1 mm and then at +- **CBCT** -- one deep-RL agent per landmark walks the volume at 1 mm and then at 0.3 mm until it converges on the point. 119 landmarks across four regions. -- **IOS** — per tooth, the mesh is rendered from a dozen viewpoints and a 2D +- **IOS** -- per tooth, the mesh is rendered from a dozen viewpoints and a 2D UNet predicts masks that are projected back onto the surface. Three networks: - **Occlusal**, **Cervical**, and **Mucogingival** — the last one on the + **Occlusal**, **Cervical**, and **Mucogingival** -- the last one on the gingival margin rather than the crown, mandible only, off by default. Which engine runs is decided from the data, never from an argument. @@ -16,7 +16,7 @@ Which engine runs is decided from the data, never from an argument. Ported from DCBIA-OrthoLab/SlicerAutomatedDentalTools, paths `ALI_CBCT/`, `ALI_CBCT_utils/`, `ALI_IOS/` and `ALI_IOS_utils/`, by way of -`slicer-remote-tool-server`'s `tools/ALI/` — whose history this repository +`slicer-remote-tool-server`'s `tools/ALI/` -- whose history this repository carries, so `git log --follow` on `src/sadt_ali/cbct/engine.py` reaches back through it to `a0ed474` (2026-07-31). @@ -43,7 +43,7 @@ through it to `a0ed474` (2026-07-31). Upstream pins **not** kept: this package pins torch 2.8.0+cu128, monai 1.6.0, itk 5.4.7 and Python 3.11. See "Versions" for why. -Changes from upstream — the algorithm is untouched, the envelope is not. The +Changes from upstream -- the algorithm is untouched, the envelope is not. The first four were made during the server-side port and are unchanged here; the rest are this migration's. @@ -56,11 +56,11 @@ rest are this migration's. itself; fatal for anyone opening a returned file. - **Scans are keyed by path relative to the input root**, not by base name. Two patients called `scan.nii.gz` in different folders used to overwrite each - other, twice — in the working dictionary and again in the flat output folder. + other, twice -- in the working dictionary and again in the flat output folder. - **Both impacted-canine spellings resolve** (`UR3OI` ≡ `UR3OIP`) and `group_of()` never raises. The unguarded `LABEL_GROUPS[...]` lookup this replaces threw a `KeyError` caught far above, and *nothing at all* was written - for that scan — including every landmark already found. + for that scan -- including every landmark already found. - **Zip extraction removed.** The server unpacks archives before `run()` is called, with the bomb cap and `strip_single_root` that used to live in `ALILogic._extracted`. @@ -70,7 +70,7 @@ rest are this migration's. The layout check survives: a bundle of the wrong kind is still refused with a message naming both kinds, which is what that code was really for. - **Per-tooth selection is not exposed.** Upstream's IOS CLI takes `teeth` and - `teeth_mg` — which teeth to predict on, and which to predict the mucogingival + `teeth_mg` -- which teeth to predict on, and which to predict the mucogingival point for. `ALI_IOS` takes neither: every tooth the mesh carries a label for is predicted, on every run. @@ -90,7 +90,7 @@ rest are this migration's. `tools.CrownSeg` in-process and segmented an unlabelled mesh on the fly. Tools do not call each other; `ios.engine.require_labels()` refuses the batch up front instead, naming `Crown_Seg` and the array it looked for. **This is - the one behaviour change a user can see** — see "The Crown_Seg chain". + the one behaviour change a user can see** -- see "The Crown_Seg chain". - **The GPU semaphore is gone.** Both engines held a `threading.BoundedSemaphore(ALI_MAX_GPU_JOBS)`, which serialised inference when every tool shared one process. A tool is its own process now, so an @@ -107,13 +107,13 @@ rest are this migration's. | Inputs | `input`: one CBCT (`.nii`/`.nii.gz`/`.nrrd`/`.nrrd.gz`/`.gipl`/`.gipl.gz`), one intraoral surface (`.vtk`/`.stl`), or a folder of either, searched recursively. A DICOM series inside a folder is converted automatically. `model`: the bundle. `output_dir`: where results go. | | Outputs | One `_lm_.mrk.json` per scan, mirroring the input's folder tree, plus `run_report.json`. | | Model files | CBCT: `/**///*.pth`, scale folders named `1` and `0-3`, both required per landmark. IOS: flat checkpoints carrying an `O`/`C` token and an `Upper`/`Lower` one, e.g. `Upper_O_model.pth`. Fetched by the server into `/DATA/ALI/models`. | -| GPU | Used when available; `device="cpu"` works and is much slower — the per-landmark search budget defaults to 60 s on CPU against 15 s on CUDA for that reason. | +| GPU | Used when available; `device="cpu"` works and is much slower -- the per-landmark search budget defaults to 60 s on CPU against 15 s on CUDA for that reason. | Three behaviours worth knowing before reading a result: - **`landmarks` replaces `cbct_regions`, it does not narrow it.** Naming any landmark makes the region selection inert. That is what lets a caller ask for - the seven points it needs instead of running 58 agents to use seven — one + the seven points it needs instead of running 58 agents to use seven -- one agent being a full two-scale walk of the volume. - **A landmark missing from the bundle and a landmark that never converged are different things**, and look identical in the Slicer scene. The first is in @@ -132,13 +132,13 @@ Crown_Seg → ALI (IOS) ``` Crown_Seg's `run_report.json` lists every labelled mesh under `segmented_meshes`, -whether this run produced the labels or found them already there — so re-running +whether this run produced the labels or found them already there -- so re-running the chain on a mixed batch is cheap and safe. Feed its output directory straight in as ALI's `input`. Skipping it is not a silent failure: `require_labels()` checks the whole batch before any weights load and refuses it with a message naming `Crown_Seg` and the -three array names it looked for. Checking up front matters — discovering it on +three array names it looked for. Checking up front matters -- discovering it on mesh 40 of 40 costs an hour of inference first. `tests/test_integration.py` runs the real chain, each tool in its own venv, the @@ -165,8 +165,8 @@ rather than worked around. ## Versions -Pinned to what the deployed server actually runs — torch 2.8.0+cu128, -monai 1.6.0, itk 5.4.7, SimpleITK 2.5.6, vtk 9.6.2, numpy 2.3.2, Python 3.11 — +Pinned to what the deployed server actually runs -- torch 2.8.0+cu128, +monai 1.6.0, itk 5.4.7, SimpleITK 2.5.6, vtk 9.6.2, numpy 2.3.2, Python 3.11 -- which is the same reasoning as [AMASSS](../AMASSS/README.md#versions): the Slicer module installs into Slicer's shared interpreter, where the pins are a truce with fifteen other modules, and no ALI result has ever been produced on @@ -181,7 +181,7 @@ removed years ago; at 1.6.0 only the former exists and the branch is gone. ### pytorch3d Only the IOS engine needs it, so it sits behind an extra and a plain `uv sync` -stays fast — CI can import the package and publish its schema, and the CBCT +stays fast -- CI can import the package and publish its schema, and the CBCT engine works with no pytorch3d at all. ```toml @@ -198,12 +198,12 @@ pytorch3d = ["torch"] Same incantation as [Crown_Seg](../Crown_Seg/README.md), same tag, so the two tools share one build: -- PyPI's newest pytorch3d is 0.7.4, with wheels for cp38–cp310 only, built +- PyPI's newest pytorch3d is 0.7.4, with wheels for cp38-cp310 only, built against a torch generations older than ours. It is compiled from source. - `extra-build-dependencies` puts torch into pytorch3d's isolated build environment. Its `setup.py` imports torch without declaring it, so without this the lock fails with `ModuleNotFoundError: No module named 'torch'`. - `no-build-isolation-package` does **not** work here — it stops uv from + `no-build-isolation-package` does **not** work here -- it stops uv from providing torch rather than making it available. `uv lock` resolves 56 packages in about a second. `uv sync --extra ios` then @@ -225,7 +225,7 @@ deduplicating that across tools at build time. output-containment rule and every cross-argument rule run for real, with no checkpoint and no card. - **Weight discovery, against the real 14 GB bundle**: `discover_weights` finds - **119 landmarks carrying both scales** — exactly the 119 this package's + **119 landmarks carrying both scales** -- exactly the 119 this package's catalog declares, with nothing left ungrouped, so the bundle's folder names and the vocabulary agree completely. The IOS bundle resolves all four (network, jaw) pairs and reports `Lower_MG_v6.pth` as unrecognised, which is @@ -233,7 +233,7 @@ deduplicating that across tools at build time. - **Against the pre-port implementation: bit-identical.** - **Input**: `DATA/ALI/testfiles/MG_test_scan.nii.gz`, one real CBCT. - **Weights**: `DATA/ALI/models/ALI_CBCT_Models`, the seven landmarks ASO - registers on (`Ba`, `S`, `N`, `RPo`, `LPo`, `ROr`, `LOr`) — the set that + registers on (`Ba`, `S`, `N`, `RPo`, `LPo`, `ROr`, `LOr`) -- the set that matters most, since another tool depends on it. - **Reference**: `slicer-remote-tool-server`'s `tools/ALI/`, verified byte-identical to the revision this repository imported, run **inside this @@ -252,27 +252,27 @@ deduplicating that across tools at build time. | port vs reference (×4) | 0.0000 mm | 0.0000 mm | 7/7 | - **Tolerance**: none needed. **Treat any non-zero difference as a - regression** — unlike AMASSS, where nnUNet's CUDA nondeterminism sets a + regression** -- unlike AMASSS, where nnUNet's CUDA nondeterminism sets a noise floor, nothing here is nondeterministic on a scan whose agents converge without leaving the volume. - **GPU tests were run**: `uv run pytest -m "gpu and models"` on an RTX 6000 Ada - — passed in 38 s, all seven landmarks found, `device=cuda`, every point inside + -- passed in 38 s, all seven landmarks found, `device=cuda`, every point inside the scan's own physical extent. -- **The IOS half, on real weights and a real card** — run inside +- **The IOS half, on real weights and a real card** -- run inside `ghcr.io/jules-gp/lab-ai:2026.08`, which already carries pytorch3d 0.7.9 (the tag this package pins) and CUDA 12.8, so no source build was needed: - **Input**: `DATA/ALI/testfiles/T1_01_U_segmented.vtk`, a real segmented upper arch. **Weights**: `DATA/ALI/models/ALI_IOS_Models`. - - **Occlusal**: **42 landmarks** — 14 teeth × 3 types, every tooth the mesh - carries — in 21 s, `device=cuda`, one markups file, no failures. + - **Occlusal**: **42 landmarks** -- 14 teeth × 3 types, every tooth the mesh + carries -- in 21 s, `device=cuda`, one markups file, no failures. - **Mucogingival on that same maxilla**: correctly produces **nothing** and does not fail the run. `NETWORK_JAWS` restricts it to the mandible, so an upper arch is not a missing model but a question the network cannot be - asked — `jaws_without_model` stays empty and the occlusal pass is + asked -- `jaws_without_model` stays empty and the occlusal pass is unaffected. - **Mucogingival's predictions are NOT validated.** The only intraoral fixture on hand is an upper arch, and MG runs on the mandible alone, so nothing here - has ever placed one of its points. What is verified is the plumbing — the + has ever placed one of its points. What is verified is the plumbing -- the network is offered, off by default, restricted to the lower jaw, its label table is positional, and a degraded point carries its caveat into the file. **A lower-arch mesh is all that is missing**; see tests/data/README.md. diff --git a/tools/ALI/common/README.md b/tools/ALI/common/README.md index 6cb9989..c31a966 100644 --- a/tools/ALI/common/README.md +++ b/tools/ALI/common/README.md @@ -6,7 +6,7 @@ markups file they both write. ## Why this is shared rather than duplicated The two engines share nothing else. They have different dependencies, different -inference, different inputs, and — since the split — different virtualenvs and +inference, different inputs, and -- since the split -- different virtualenvs and different torch versions. Duplicating 129 lines between them would cost nothing in maintenance and would follow this repository's usual instinct, which is that a second copy beats a coupling (`nnunet_runner.py` is deliberately @@ -16,7 +16,7 @@ duplicated between AMASSS and BatchDentalSeg for exactly that reason). **contract with a third party**: the `.mrk.json` file Slicer opens. Its schema URL, its `LPS` coordinate system, its display block and its control-point structure are all things Slicer reads, and a divergence between the two engines -does not fail — it produces a file that opens for one modality and not for the +does not fail -- it produces a file that opens for one modality and not for the other. That is not hypothetical here. The pre-port CLIs both set @@ -32,8 +32,8 @@ that decides what to compute belongs to its engine. ## What is deliberately NOT here - **`errors.py`** stays duplicated in both tools. Errors cross the process - boundary by exception class *name* — the runner records the name and the - server maps it to an HTTP status — so a shared base class is not merely + boundary by exception class *name* -- the runner records the name and the + server maps it to an HTTP status -- so a shared base class is not merely unnecessary, it is not the mechanism. Twenty-one lines, and sharing them would couple two virtualenvs for nothing. - **Anything with a dependency.** `dependencies` is empty and must stay so: diff --git a/tools/AMASSS/README.md b/tools/AMASSS/README.md index 43e6861..cf3c80e 100644 --- a/tools/AMASSS/README.md +++ b/tools/AMASSS/README.md @@ -8,14 +8,14 @@ consumes. One nnUNet v2 model per structure. Ported from DCBIA-OrthoLab/SlicerAutomatedDentalTools, path `AMASSS_CLI/`, commit `21a62a8` (2026-05-22), by way of `slicer-remote-tool-server`'s -`tools/AMASSS/` — whose history this repository carries, so `git log --follow` +`tools/AMASSS/` -- whose history this repository carries, so `git log --follow` on `src/sadt_amasss/pipeline.py` reaches back through it. Upstream pins **not** kept: upstream declares torch 2.2.0 / torchvision 0.17.0 / nnunetv2 2.8.0; this package pins torch 2.8.0+cu128 and nnunetv2 2.8.1. See "Versions" for why. -Changes from upstream — the algorithm is untouched, the envelope is not: +Changes from upstream -- the algorithm is untouched, the envelope is not: - **Scratch space lives under `output_dir`** (`.amasss_work/`, removed before returning). The server-side version took a scratch directory the server owned; @@ -35,7 +35,7 @@ Changes from upstream — the algorithm is untouched, the envelope is not: what the shared image could not. The "FIX:" comments in `catalog.py` and `pipeline.py` record defects of the -original Slicer CLI corrected during the first port — recursive folder scanning, +original Slicer CLI corrected during the first port -- recursive folder scanning, the missing label colours, the `CAN` code that matched nothing, the `sys.exit(1)`, the batch that aborted on its last scan. They are unchanged here. @@ -49,13 +49,13 @@ the missing label colours, the `CAN` code that matched nothing, the | GPU | Used when available; `device="cpu"` works and is much slower. CUDA falls back to CPU with a warning when no card is visible. | Structure codes: `MAND`, `MAX`, `CB`, `CV`, `UAW`, `SKIN`, `CBMASK`, -`MANDMASK`, `MAXMASK` — published as the argument's `choices`, so a client can +`MANDMASK`, `MAXMASK` -- published as the argument's `choices`, so a client can render them without a second declaration. The display names the old schema published ("Cranial base", …) are still accepted but not offered. `Literal` cannot be built from `catalog.STRUCTURE_CODES` (it takes literals only), so the set is written twice and a test asserts the two agree. A structure -added to the catalog and not to `run()` would be unselectable from the client. `TEETH`, `RC` and `MCAN` are deliberately absent — +added to the catalog and not to `run()` would be unselectable from the client. `TEETH`, `RC` and `MCAN` are deliberately absent -- no model ships for them, and offering them produced either a KeyError during surface export or a silent collision onto the mandible's label. @@ -72,8 +72,8 @@ Three behaviours worth knowing before reading a result: ## Versions -Pinned to what the deployed server actually runs — torch 2.8.0+cu128, -nnunetv2 2.8.1, SimpleITK 2.5.6, numpy 2.3.2, vtk 9.6.2, Python 3.11 — rather +Pinned to what the deployed server actually runs -- torch 2.8.0+cu128, +nnunetv2 2.8.1, SimpleITK 2.5.6, numpy 2.3.2, vtk 9.6.2, Python 3.11 -- rather than to upstream's declared torch 2.2.0 / nnunetv2 2.8.0. The reason is that no AMASSS result has ever been produced on upstream's pins. @@ -85,7 +85,7 @@ nothing to validate it against. Upstream's numbers are recorded above, and moving to them is available to whoever wants to revalidate. The CUDA wheels come from an explicit index, and `explicit = true` is -load-bearing — without it uv looks for every package on the PyTorch index: +load-bearing -- without it uv looks for every package on the PyTorch index: ```toml [[tool.uv.index]] @@ -114,8 +114,8 @@ across tools at build time. run-to-run spread. **nnUNet on CUDA is not bit-deterministic**, so a single comparison would have -been meaningless. The reference was run four times and this package four times — -eight runs of identical code, identical package versions and the same card — and +been meaningless. The reference was run four times and this package four times -- +eight runs of identical code, identical package versions and the same card -- and both sets scatter across the same handful of states. 5 of the 16 port×reference pairs are bit-identical; the rest differ by the same margin two *reference* runs differ by: @@ -127,7 +127,7 @@ differ by: | `_CB` | 1 900 614 | 41 | 41 | 0.999989 | | `_MERGED` | 5 131 306 | 66 | 68 | 0.999994 | -- **Tolerance**: none of the difference is attributable to the repackaging — +- **Tolerance**: none of the difference is attributable to the repackaging -- port-vs-reference is indistinguishable from reference-vs-reference, to within one or two voxels in five million. The cause is nnUNet's sliding-window accumulation, a float reduction CUDA does not order deterministically; @@ -136,7 +136,7 @@ differ by: regression**, and anything above it as this same noise floor. **GPU tests were run**: `uv run pytest -m models` with the real bundle on an -RTX 6000 Ada — passed, all three structures predicted, merged volume carrying +RTX 6000 Ada -- passed, all three structures predicted, merged volume carrying exactly labels {0, 1, 2, 4}. CI skips them (`-m "not gpu"`); the 48 remaining tests stub `nnunet_runner.predict_folder` and need no checkpoint. diff --git a/tools/AMASSS/tests/data/README.md b/tools/AMASSS/tests/data/README.md index 3a817bb..dfaa642 100644 --- a/tools/AMASSS/tests/data/README.md +++ b/tools/AMASSS/tests/data/README.md @@ -1,7 +1,7 @@ # Test data Nothing is committed here. The model bundle is 2.1 GB and `MG_test_scan.nii.gz` -is a real CBCT — patient data, which never goes into this repository. +is a real CBCT -- patient data, which never goes into this repository. Everything the CI suite needs, it builds: `test_run.py` writes 8³ synthetic volumes and stubs `nnunet_runner.predict_folder`, so input discovery, model diff --git a/tools/AREG/AREG_CBCT/src/sadt_areg_cbct/layout.py b/tools/AREG/AREG_CBCT/src/sadt_areg_cbct/layout.py index 15257e6..32c8590 100644 --- a/tools/AREG/AREG_CBCT/src/sadt_areg_cbct/layout.py +++ b/tools/AREG/AREG_CBCT/src/sadt_areg_cbct/layout.py @@ -1,6 +1,6 @@ """How a client should lay this tool's panel out. Presentation only. -Nothing here changes what `run()` accepts — `describe.py` merges these hints +Nothing here changes what `run()` accepts -- `describe.py` merges these hints into the published schema and refuses any that name an argument the signature does not take. Delete this file and the tool still works; the panel gets worse. diff --git a/tools/AREG/AREG_IOS/README.md b/tools/AREG/AREG_IOS/README.md index c071a01..1aec4c4 100644 --- a/tools/AREG/AREG_IOS/README.md +++ b/tools/AREG/AREG_IOS/README.md @@ -1,7 +1,7 @@ # sadt-areg-ios Registers a follow-up intraoral scan onto its baseline, by ICP on a patch of the -arch that does not move with growth or treatment — the palate, or the band +arch that does not move with growth or treatment -- the palate, or the band around the mucogingival line. Split out of the former single `AREG`; see `../common/README.md` for what the @@ -12,7 +12,7 @@ two engines still share and why. Registering the same pair twice, with the same code and the same weights, does not give the same mesh. Measured on upstream's own `AREG_test_scans` (`A2_UpperT1.vtk` / `A2_UpperT2.vtk`, 75 867 points, 57.5 mm across), on the -registered T2 — the mesh the ICP actually moves: +registered T2 -- the mesh the ICP actually moves: | comparison | mean | p95 | max | identical points | |---|---|---|---|---| diff --git a/tools/AREG/README.md b/tools/AREG/README.md index 3c7cb85..3972bea 100644 --- a/tools/AREG/README.md +++ b/tools/AREG/README.md @@ -3,9 +3,9 @@ Registers a follow-up scan onto its baseline, so two timepoints of the same patient share one coordinate system and can be measured against each other. -- **CBCT** — elastix, rigid, restricted to the anatomy that has *not* changed: +- **CBCT** -- elastix, rigid, restricted to the anatomy that has *not* changed: the cranial base, the mandible or the maxilla, taken as masks. -- **IOS** — a patch of the arch that does not move with growth or treatment +- **IOS** -- a patch of the arch that does not move with growth or treatment (the palate, or the band around the mucogingival line), matched by ICP. ## Provenance @@ -15,22 +15,22 @@ Ported from DCBIA-OrthoLab/SlicerAutomatedDentalTools by way of branch and is now parked there as `server/tools/_AREG/`. > **Unlike the other six, this history does not follow.** AREG was never part -> of the `git subtree split` that carried the tools into this repository — it -> was on a branch at the time — so `git log --follow` stops at the commit that +> of the `git subtree split` that carried the tools into this repository -- it +> was on a branch at the time -- so `git log --follow` stops at the commit that > copied these files in. The server-side source is preserved at the > `archive/AREG` tag in that repository. **The upstream commit is unrecorded**, > as it is for [ALI](../ALI/README.md#provenance) and [ASO](../ASO/README.md). Upstream pins **not** kept: pinned to the deployed stack, which is what every -sibling tool locks — torch 2.8.0+cu128, monai 1.6.0, itk 5.4.7, Python 3.11. +sibling tool locks -- torch 2.8.0+cu128, monai 1.6.0, itk 5.4.7, Python 3.11. -Changes from upstream — the algorithm is untouched, the envelope is not. The +Changes from upstream -- the algorithm is untouched, the envelope is not. The first group were made during the server-side port and are unchanged here. - **The elastix centre of rotation is honoured.** `MatrixRetrieval` read elastix's three angles and its translation and *dropped* its `CenterOfRotationPoint`, so the transform it built rotated about the physical - origin instead. The two differ by `(I − R)c` — invisible on centred data, and + origin instead. The two differ by `(I − R)c` -- invisible on centred data, and a gross misregistration on anything else. - **The masked image never reaches the disk.** It was written to `/fixed_image_masked.nii.gz`: one fixed name shared by every patient of @@ -56,7 +56,7 @@ And this migration's: ## The four tools it drives AREG registers. It does not segment, orient, label crowns or find a -mucogingival line — each of those is another tool here, reached through the +mucogingival line -- each of those is another tool here, reached through the **supervisor**: | Asked for | Tool | When | @@ -67,7 +67,7 @@ mucogingival line — each of those is another tool here, reached through the | the 13 mucogingival landmarks per lower arch | `ALI` | IOS, mucogingival patch | **This is the deepest chain in the family.** `ASO` is itself supervised for -CBCT, so a fully-automated CBCT run is `AREG → ASO → ALI` — three tools, three +CBCT, so a fully-automated CBCT run is `AREG → ASO → ALI` -- three tools, three virtualenvs, three interpreters. The runner's supervisor handles that by recursion and caps it at four deep; nothing here arranges it. @@ -77,14 +77,14 @@ Every call is in one file, by string: predictions = sup.run("ALI", input=meshes, output_dir=..., ios_networks=["Mucogingival"]) ``` -`sup.run("ALI", ...)`, never `sup.ALI(...)` — a typo in a string is greppable +`sup.run("ALI", ...)`, never `sup.ALI(...)` -- a typo in a string is greppable and `tools.py` is the whole call graph; a typo in an attribute is an `AttributeError` an hour into a job. **Without a supervisor, an automated mode refuses at the door** and names the mode that works instead: send your own masks and use Semi-Automated, or send the landmarks in `mgl_landmarks`. That is a real answer, where "deploy a tool" -usually is not — and it is what makes this usable standalone. +usually is not -- and it is what makes this usable standalone. ## What it does @@ -106,7 +106,7 @@ Three behaviours worth knowing before reading a result: ## Versions torch 2.8.0+cu128, monai 1.6.0, itk 5.4.7, SimpleITK 2.5.6, vtk 9.6.2, -numpy 2.3.2, dicom2nifti 2.6.2, Python 3.11 — the same stack every sibling +numpy 2.3.2, dicom2nifti 2.6.2, Python 3.11 -- the same stack every sibling locks, so this adds no new runtime to the image. One package no other tool here needs: **`itk-elastix`**. The CBCT engine reaches @@ -124,15 +124,15 @@ three share one build. The CBCT engine works without it. the per-patient reporting. - **Tests**: 88 passing. Pairing, mask discovery, elastix, the CBCT mode end to end, every argument rule, the checkpoint lookup, and the four supervisor - calls — each asserted on the parameters the callee actually publishes. + calls -- each asserted on the parameters the callee actually publishes. - **The seam against the real schemas**: `test_the_arguments_it_sends_are_the_arguments_they_publish` reads all four tools' published schemas out of process and checks every - argument AREG sends exists — including that `Mucogingival` is one of ALI's + argument AREG sends exists -- including that `Mucogingival` is one of ALI's offered networks. It skips unless the four are built. - **Not run end to end against another tool.** No supervised chain has been executed with the real four; the calls are covered by a fake supervisor asserting the parameters, and the schemas by the test above. **The IOS engine - is unvalidated** — it needs pytorch3d and a segmented lower arch, neither of + is unvalidated** -- it needs pytorch3d and a segmented lower arch, neither of which is staged here. - **No comparison against the pre-port implementation**, on any modality. diff --git a/tools/AREG/common/README.md b/tools/AREG/common/README.md index 4e95a33..8a72583 100644 --- a/tools/AREG/common/README.md +++ b/tools/AREG/common/README.md @@ -4,16 +4,16 @@ What `AREG_CBCT` and `AREG_IOS` must not disagree about. ## Why these four and not the others -The split duplicated both engines' implementations and shared almost nothing — +The split duplicated both engines' implementations and shared almost nothing -- that is the standing rule, and it is right. These four are the exception, and each earns it differently. **`pairing.py` is the one that matters.** It was nearly duplicated on the -reasoning that "the two modes pair different things — volumes against meshes", +reasoning that "the two modes pair different things -- volumes against meshes", which sounds obviously true and is wrong. The functions that really are modality-specific, `pair()` and `discover()`, are called by **neither engine**: they belong to the dispatcher, which the split separated anyway. What the -engines actually share is `patient_stem()` — how a patient's identity is derived +engines actually share is `patient_stem()` -- how a patient's identity is derived from a filename, by stripping timepoint and jaw tokens. That is a **convention**, and a divergence in it does not fail: it makes @@ -24,7 +24,7 @@ settles it. `is_previous_output()` is the same family: two copies that drift means one tool re-ingesting what the other produced. **`catalogs.py`** holds the modality and automation tables. Published in the -schema, keyed on by the server, and read by both — a second copy is a panel +schema, keyed on by the server, and read by both -- a second copy is a panel offering a mode the tool no longer has. **`scans.py`** is the file-extension vocabulary, the same contract with the @@ -33,7 +33,7 @@ outside world that `ALI/common/discovery.py` carries. **`errors.py`** is here rather than duplicated only because it is three lines and travels with `pairing`'s raises. Note that ALI keeps its copy duplicated: errors cross the process boundary by class NAME, so sharing the class is never -the mechanism — it is a convenience here, not a requirement. +the mechanism -- it is a convenience here, not a requirement. ## The constraint diff --git a/tools/AREG/tests/data/README.md b/tools/AREG/tests/data/README.md index e4814da..6b99b8d 100644 --- a/tools/AREG/tests/data/README.md +++ b/tools/AREG/tests/data/README.md @@ -2,7 +2,7 @@ Nothing is committed here. The suite builds its own 48³ synthetic phantoms with SimpleITK, moves them by a known rigid transform, and checks the recovered -transform against it — `itk-elastix` is a wheel and fast enough on a phantom +transform against it -- `itk-elastix` is a wheel and fast enough on a phantom that the CBCT registration is exercised for real rather than mocked. That covers all 88 tests. The IOS patch network is stubbed; it needs pytorch3d @@ -20,7 +20,7 @@ uv run pytest -k arguments_it_sends ``` Running an actual chain needs more than this repository holds: the AMASSS -bundle, an orientation reference, and — for the IOS half — the `ios` extra plus +bundle, an orientation reference, and -- for the IOS half -- the `ios` extra plus a segmented lower arch. See `../../ALI/tests/data/README.md` for the container recipe that avoids compiling pytorch3d. diff --git a/tools/ASO/README.md b/tools/ASO/README.md index 679651e..125233d 100644 --- a/tools/ASO/README.md +++ b/tools/ASO/README.md @@ -1,7 +1,7 @@ # sadt-aso Orients CBCT volumes or intra-oral meshes onto a standard reference frame, so -that two timepoints of the same patient — or two patients — can be compared in +that two timepoints of the same patient -- or two patients -- can be compared in the same coordinate system. One tool, two engines, four modes: | | Semi-Automated | Fully-Automated | @@ -13,7 +13,7 @@ the same coordinate system. One tool, two engines, four modes: Ported from DCBIA-OrthoLab/SlicerAutomatedDentalTools, paths `ASO/`, `ASO_CBCT/{PRE,SEMI}_ASO_CBCT/` and `ASO_IOS/{PRE,SEMI}_ASO_IOS/`, by way of -`slicer-remote-tool-server`'s `tools/ASO/` — whose history this repository +`slicer-remote-tool-server`'s `tools/ASO/` -- whose history this repository carries, so `git log --follow` on `src/sadt_aso/cbct/pipeline.py` reaches back through it. @@ -24,10 +24,10 @@ through it. > [PROVENANCE.md](../../PROVENANCE.md). Upstream pins **not** kept: nothing is pinned upstream. This package pins -SimpleITK 2.5.6, vtk 9.6.2, numpy 2.3.2 and dicom2nifti 2.6.2 — the first three +SimpleITK 2.5.6, vtk 9.6.2, numpy 2.3.2 and dicom2nifti 2.6.2 -- the first three being what every sibling tool already locks. -Changes from upstream — the algorithm is untouched, the envelope is not. The +Changes from upstream -- the algorithm is untouched, the envelope is not. The first group were made during the server-side port and are unchanged here. - **The whole Slicer envelope is gone**: no `` prints, no @@ -45,7 +45,7 @@ first group were made during the server-side port and are unchanged here. - **The reference is checked against the selection up front.** The two published reference bundles carry disjoint landmark sets, so picking the second without changing the selection made every patient fail separately with "0 usable - landmarks" — forty identical failures for one wrong choice. + landmarks" -- forty identical failures for one wrong choice. - **The semi-automated CLI registered centred volumes against uncentred points**, because it recentred nothing and then read a `.tfm` only the fully-automated chain ever produced. `center_landmarks` fixes that. @@ -60,7 +60,7 @@ And this migration's: into a directory of its own, because its neighbours are not necessarily part of the same input. - **`src/ali_client.py` is gone.** It reached into the server's `registry.TOOLS` - to call ALI in-process. Replaced by one `sup.run("ALI", ...)` — see below. + to call ALI in-process. Replaced by one `sup.run("ALI", ...)` -- see below. - **`landmarks` is a new argument**, and it is what makes the tool usable standalone. - **`max_triplets` and `seed` are arguments**, not settings, with the defaults @@ -78,8 +78,8 @@ Fully-automated CBCT needs landmarks it does not place itself, and it needs them recentre every scan → predict landmarks on the CENTRED scans → register ``` -That ordering is upstream's — the Slicer chain ran `PRE_ASO_CBCT` before -`ALI_CBCT` — and it is why this tool cannot be expressed as "run ALI first, then +That ordering is upstream's -- the Slicer chain ran `PRE_ASO_CBCT` before +`ALI_CBCT` -- and it is why this tool cannot be expressed as "run ALI first, then run ASO on its output". Recentring is a pure metadata change, so the reordering *ought* to be exact; ALI's `physical_position` takes the absolute value of the origin, which does not commute with moving it. That is @@ -94,7 +94,7 @@ predictions = sup.run("ALI", input=centered_root, model=..., output_dir=..., lan ``` - `sup` is **keyword-only and unannotated**. That is the marker `describe.py` - reads to keep it out of the schema — it is not data, and no client sends one. + reads to keep it out of the schema -- it is not data, and no client sends one. The schema instead publishes `"supervisor": true`, so a runner that cannot inject one refuses the tool rather than calling it and failing halfway. - It is **duck-typed**. Nothing here imports a supervisor type; doing so would @@ -106,9 +106,9 @@ predictions = sup.run("ALI", input=centered_root, model=..., output_dir=..., lan and the call graph stays inspectable. - ALI is asked for landmarks **by name**, not by region. ASO's seven points straddle two of ALI's regions, so asking by region would run 58 agents to use - seven — and one agent is a full two-scale walk of the volume. + seven -- and one agent is a full two-scale walk of the volume. -**The server does not implement supervisors yet** — re-checked 2026-08-14 against +**The server does not implement supervisors yet** -- re-checked 2026-08-14 against `newArch`: one occurrence of the word in the whole repository, and it is a line of documentation. Until that changes, fully-automated CBCT is not servable and this package's other three modes are, including fully-automated **with @@ -117,7 +117,7 @@ this package's other three modes are, including fully-automated **with ### From a checkout, with a supervisor -`scripts/run_tool.py` builds one and chains for you — no server, no Docker: +`scripts/run_tool.py` builds one and chains for you -- no server, no Docker: ```bash python scripts/run_tool.py ASO \ @@ -152,7 +152,7 @@ that already has the points does not spend a GPU re-predicting them. |---|---| | Inputs | `input`: one scan (`.nii`/`.nii.gz`/`.nrrd`/`.nrrd.gz`/`.gipl`/`.gipl.gz`), one mesh (`.vtk`/`.stl`), or a folder of either. `reference`: the already-oriented case defining the target frame. `output_dir`: where results go. | | Outputs | Per patient: the oriented scan or mesh, its landmarks (`_lm_Or.mrk.json`) and the transform (`_Or_transform.tfm`), mirroring the input tree, plus `ASO_report.json`. | -| Model files | None for three of the four modes — a *reference bundle* is data, not weights. Fully-automated CBCT needs ALI's bundle, named in `landmark_model` and passed straight through. Both `reference` and `landmark_model` are named so the server publishes them as hosted names rather than uploads. | +| Model files | None for three of the four modes -- a *reference bundle* is data, not weights. Fully-automated CBCT needs ALI's bundle, named in `landmark_model` and passed straight through. Both `reference` and `landmark_model` are named so the server publishes them as hosted names rather than uploads. | | GPU | None. This is the one migrated tool with no torch in it; the venv is 1.2 GB. | Three behaviours worth knowing before reading a result: @@ -171,7 +171,7 @@ The old `ArgSpec` schema published presentation metadata `describe.py` has no field for. ASO is the tool that used it most, because its four modes share one schema: -- **`visible_when`** hid each mode's arguments when the other was selected — +- **`visible_when`** hid each mode's arguments when the other was selected -- the Slicer module's four-page `QStackedWidget`, expressed as data. Without it a panel shows all 115 CBCT landmarks next to all 32 teeth, whichever mode is chosen. @@ -181,14 +181,14 @@ schema: boxes, and the CBCT landmarks as one tab per region. None of this affects a result, and every cross-argument rule is still *enforced* -— `_check_cbct` and `_check_ios` refuse an impossible combination before a file +-- `_check_cbct` and `_check_ios` refuse an impossible combination before a file is read. What is lost is the panel preventing it in the first place. Raised here rather than worked around; the same question as [ALI's](../ALI/README.md#what-the-client-loses). ## Versions -SimpleITK 2.5.6, vtk 9.6.2, numpy 2.3.2, dicom2nifti 2.6.2, Python 3.11 — the +SimpleITK 2.5.6, vtk 9.6.2, numpy 2.3.2, dicom2nifti 2.6.2, Python 3.11 -- the deployment image's interpreter, and the same imaging stack the sibling tools lock, so this adds nothing new to the image. @@ -196,7 +196,7 @@ Nothing upstream is pinned, so there is no upstream pin to keep or discard. `dicom2nifti` is the one package no other tool here uses; it is a small pure-python wheel and only the `dicom_input` path touches it. -`uv lock` resolves 26 packages. The venv is 1.2 GB — a twentieth of a torch +`uv lock` resolves 26 packages. The venv is 1.2 GB -- a twentieth of a torch tool's, which is what makes this the cheapest tool in the repository to deploy. ## Validated against @@ -207,21 +207,21 @@ tool's, which is what makes this the cheapest tool in the repository to deploy. (3), `modality` (2) and `automation` (2). `sup` is absent from `arguments`, as it must be. Asserted out of process against the real venv. - **Tests**: 81 passing, 1 GPU test deselected. Six of them run the tool **out of - process, in its own venv**, the way the server does — including a complete + process, in its own venv**, the way the server does -- including a complete semi-automated CBCT registration on synthetic data, so the registration itself is exercised for real rather than stubbed. - **The supervisor seam**: covered in-process by a fake supervisor that writes the markups files the real tool would write, into the directory it is handed, so the whole seam (call → write → read back → merge per patient → recentre) is exercised. `test_the_landmark_tool_is_run_on_the_recentred_scans` asserts what - ALI is actually handed is centred on the physical origin — the ordering this + ALI is actually handed is centred on the physical origin -- the ordering this whole design exists to preserve. -- **Geometry**: the registration is checked against known rotations — +- **Geometry**: the registration is checked against known rotations -- `test_registration_recovers_the_reference_frame`, and `test_the_transform_file_maps_the_result_back_to_the_original` inverts the written `.tfm` and lands back on the acquisition. - **The real chain, on real weights and a real card.** ASO fully-automated CBCT - was driven end to end through a real supervisor — one that runs `ALI` in + was driven end to end through a real supervisor -- one that runs `ALI` in `tools/ALI/.venv` as a subprocess, through `sadt_testkit`'s driver, exactly as the server's runner will. Input `DATA/ALI/testfiles/MG_test_scan.nii.gz` with the 4.7 GB `ALI_CBCT_Models` bundle on an RTX 6000 Ada: @@ -234,8 +234,8 @@ tool's, which is what makes this the cheapest tool in the repository to deploy. work dir removed: True ``` - All seven landmarks survived the round trip — recentre, hand ALI the centred - volumes, read its markups back, merge per patient, register — none dropped as + All seven landmarks survived the round trip -- recentre, hand ALI the centred + volumes, read its markups back, merge per patient, register -- none dropped as missing or as an outlier. Nothing was imported across the two tools; they have different dependency sets and different venvs. - **ALI itself is bit-identical to its pre-port implementation** on the same diff --git a/tools/ASO/src/sadt_aso/__init__.py b/tools/ASO/src/sadt_aso/__init__.py index 4456ad7..1d237d7 100644 --- a/tools/ASO/src/sadt_aso/__init__.py +++ b/tools/ASO/src/sadt_aso/__init__.py @@ -71,10 +71,10 @@ def run( Folders are searched recursively and the output keeps their tree. In Semi-Automated mode the landmark files (.mrk.json) travel beside the scans, paired by name. - reference: The already-oriented case defining the target frame — its + reference: The already-oriented case defining the target frame -- its landmark file for CBCT, its landmarks and meshes for IOS. - output_dir: Where results are written — per patient, the oriented scan, - its landmarks and the transform (.tfm) — plus `ASO_report.json`. + output_dir: Where results are written -- per patient, the oriented scan, + its landmarks and the transform (.tfm) -- plus `ASO_report.json`. Nothing is written outside it. modality: CBCT volumes or intra-oral surface scans. Never inferred from the file extension: a folder can hold either, and guessing wrong @@ -84,7 +84,7 @@ def run( Fully-Automated predicts CBCT landmarks first, and orients IOS meshes from the tooth labels they already carry. landmarks: Optional folder of landmark files (.mrk.json) to use instead - of the ones beside the scans — which is what makes Fully-Automated + of the ones beside the scans -- which is what makes Fully-Automated CBCT work with no supervisor: run the landmark tool yourself and pass its output here. Paired to scans by name, like the ones in the input tree. diff --git a/tools/ASO/src/sadt_aso/ios/pipeline.py b/tools/ASO/src/sadt_aso/ios/pipeline.py index 174af56..09b2dad 100644 --- a/tools/ASO/src/sadt_aso/ios/pipeline.py +++ b/tools/ASO/src/sadt_aso/ios/pipeline.py @@ -51,7 +51,7 @@ def patient_and_jaw(filename: str) -> tuple: **The jaw token may also come first**, and that is not a corner case: the published IOS reference bundle is `Upper_gold.vtk` / `Lower_gold.vtk`. Requiring something before the token rejected the whole bundle with "no mesh - whose name says which jaw it is" — verified against + whose name says which jaw it is" -- verified against HUTIN1/ASO v1.0.0 Gold_file.zip. When nothing precedes the token, what follows it is the identifier rather than decoration. diff --git a/tools/ASO/src/sadt_aso/layout.py b/tools/ASO/src/sadt_aso/layout.py index 5abb7c1..f91b4da 100644 --- a/tools/ASO/src/sadt_aso/layout.py +++ b/tools/ASO/src/sadt_aso/layout.py @@ -1,6 +1,6 @@ """How a client should lay this tool's panel out. Presentation only. -Nothing here changes what `run()` accepts — `describe.py` merges these hints +Nothing here changes what `run()` accepts -- `describe.py` merges these hints into the published schema and refuses any that name an argument or an option the signature does not offer. Delete this file and the tool still works; the panel just gets worse. @@ -11,7 +11,7 @@ to ignore. The conditions below are the old Slicer module's four-page `QStackedWidget` expressed as data instead of as widget code. -**Everything is DERIVED, never restated** — the tabs come from `catalogs`, so a +**Everything is DERIVED, never restated** -- the tabs come from `catalogs`, so a landmark or a tooth added there appears in its tab with no edit here. That is the difference from the `ArgSpec` tables this replaces, which listed options by hand and drifted from the code they described. diff --git a/tools/ASO/tests/data/README.md b/tools/ASO/tests/data/README.md index 736679c..09e0d74 100644 --- a/tools/ASO/tests/data/README.md +++ b/tools/ASO/tests/data/README.md @@ -1,7 +1,7 @@ # Test data Nothing is committed here. The suite builds its own 16³ synthetic CBCT volumes -with SimpleITK, its own labelled meshes with VTK, and its own landmark sets — +with SimpleITK, its own labelled meshes with VTK, and its own landmark sets -- seven points in a plausible skull-ish arrangement, no three collinear, so the coarse alignment can always find a usable triplet. A known rotation is applied and the registration has to recover it. @@ -26,7 +26,7 @@ scripts/setup-models.sh --tool ALI # from a slicer-remote-tool-server checkout The chain test is pointed at its data by environment variable, and uses a **real scan**: the synthetic 16³ volume the rest of the suite builds is not a head, and -the landmark agent converges on nothing in it — a fact about the fixture, not +the landmark agent converges on nothing in it -- a fact about the fixture, not about the chain. ```bash @@ -37,12 +37,12 @@ uv run pytest -m "gpu and models" It skips rather than fails when either is unset, or when `tools/ALI` has no venv. It drives ASO through `scripts/run_tool.py` rather than -`sadt_testkit.run_tool`, because only the former supplies a supervisor — the +`sadt_testkit.run_tool`, because only the former supplies a supervisor -- the latter is the server runner's contract, which does not. Only the seven landmarks in `DEFAULT_CBCT_LANDMARKS` are asked for, so a partial bundle carrying `Ba`, `S`, `N`, `RPo`, `LPo`, `ROr` and `LOr` at both scales is -enough — about 300 MB rather than 4.7 GB. +enough -- about 300 MB rather than 4.7 GB. ## Reference bundles @@ -57,7 +57,7 @@ weights, and the reference scan is not read. The two published bundles carry `run()`'s defaults are the first set. Choosing the second without changing `cbct_landmarks` is refused up front with a message naming what the reference -actually offers — the failure that used to be forty identical per-patient ones. +actually offers -- the failure that used to be forty identical per-patient ones. Write manual runs into the repository's gitignored `output/` directory, never next to the inputs. diff --git a/tools/Batch_Dental_Seg/README.md b/tools/Batch_Dental_Seg/README.md index bca6ce1..48bf033 100644 --- a/tools/Batch_Dental_Seg/README.md +++ b/tools/Batch_Dental_Seg/README.md @@ -8,7 +8,7 @@ you pick chooses the label table with it. Ported from DCBIA-OrthoLab/SlicerAutomatedDentalTools, path `BATCHDENTALSEG/BATCHDENTALSEGLib/SegmentationWidget.py`, commit `6df3fab` -(2026-08-05), by way of `slicer-remote-tool-server`'s `tools/BatchDentalSeg/` — +(2026-08-05), by way of `slicer-remote-tool-server`'s `tools/BatchDentalSeg/` -- whose history this repository carries, so `git log --follow` on `src/sadt_batchdentalseg/pipeline.py` reaches back through it. @@ -19,7 +19,7 @@ torch 2.8.0+cu128 and nnunetv2 2.8.1, the stack the deployed server runs. See Upstream is a 2940-line Qt widget and most of it is not this pipeline. Already absent before this port, each for a stated reason: the queue table, the RAM watchdog, killing nnUNet processes a crashed scan left behind, the "free -memory" button, the per-scan cool-down, restoring the queue from disk — all of +memory" button, the per-scan cool-down, restoring the queue from disk -- all of which exist because the widget runs inside Slicer on a clinician's laptop and has to survive being out of memory. Also not ported: the runtime model download from GitHub releases (a tool holding patient data does not make outbound calls @@ -27,14 +27,14 @@ mid-run), the auto-crop (upstream applies it only when its RAM preflight fails, and it changes what the network sees), the mirroring resolution (a button the user presses after looking at the result), and the mesh exports. -Changes made by this port — the algorithm is untouched: +Changes made by this port -- the algorithm is untouched: - **Scratch space lives under `output_dir`** (`.batchdentalseg_work/`, removed before returning). A tool must not write outside the directory it is given. - **`segment()` returns the run report** instead of a `SegmentationRun`; tools no longer call each other. - **Zip extraction removed.** The server unpacks archives before `run()`. -- **The GPU semaphore is gone** — each call is its own process now. +- **The GPU semaphore is gone** -- each call is its own process now. - **`device` is a `Literal["cuda", "cpu"]`**, so the schema publishes both options. `model` deliberately stays a plain `Path`: which bundles exist is a property of the deployment, not of this package, so the picker comes from the @@ -62,7 +62,7 @@ Models, and what each labels: |---|---| | `DentalSegmentator` | Adult. Upper Skull (maxilla included), Mandible, Upper Teeth, Lower Teeth, Mandibular canal | | `PediatricDentalSeg` | Paediatric, the same five | -| `NasoMaxillaDentSeg` | Six — the maxilla is split out of the Upper Skull, which shifts every later value | +| `NasoMaxillaDentSeg` | Six -- the maxilla is split out of the Upper Skull, which shifts every later value | | `UniversalLab` | Every tooth individually in Universal numbering, deciduous included, plus Mandible, Maxilla and Mandibular canal | Three things worth knowing before reading a result: @@ -72,7 +72,7 @@ Three things worth knowing before reading a result: caller pair bundle X with the labels of Y, and the result would be a plausible volume with every structure named wrong. - **The label values are part of the trained weights**, not a presentation - choice — they are the integers the network emits. Renaming a catalog entry is + choice -- they are the integers the network emits. Renaming a catalog entry is safe; renumbering one silently mislabels anatomy. The report ships the table next to the results, because the segmentation is a volume of integers and without it they mean nothing. @@ -90,11 +90,11 @@ cannot reach it, so the check is gone and only this note remains. ## Versions -torch 2.8.0+cu128, nnunetv2 2.8.1, SimpleITK 2.5.6, numpy 2.3.2, Python 3.11 — +torch 2.8.0+cu128, nnunetv2 2.8.1, SimpleITK 2.5.6, numpy 2.3.2, Python 3.11 -- the stack the deployed server runs, which is where every BatchDentalSeg result to date was produced. No vtk: the mesh exports are not ported. -The CUDA wheels come from an explicit index (`explicit = true` is load-bearing — +The CUDA wheels come from an explicit index (`explicit = true` is load-bearing -- without it uv looks for every package on the PyTorch index); the venv is 7.2 GB. The reasoning is the same as AMASSS's, at length in `tools/AMASSS/README.md`. @@ -112,8 +112,8 @@ The reasoning is the same as AMASSS's, at length in `tools/AMASSS/README.md`. As with AMASSS, nnUNet on CUDA is not bit-deterministic, so the reference was run three times and this package three times before any conclusion was drawn. -Both sets scatter across the same states — 2 of the 9 port×reference pairs are -bit-identical on the label volume — and the worst port-vs-reference difference +Both sets scatter across the same states -- 2 of the 9 port×reference pairs are +bit-identical on the label volume -- and the worst port-vs-reference difference is the *same number* as the worst reference-vs-reference difference on five of the six files: @@ -130,7 +130,7 @@ the six files: above it is nnUNet's own CUDA noise floor, which the pre-port tool shares. **GPU tests were run**: `uv run pytest -m models` with the PediatricDentalSeg -bundle on an RTX 6000 Ada — passed. CI skips them (`-m "not gpu"`); the other 24 +bundle on an RTX 6000 Ada -- passed. CI skips them (`-m "not gpu"`); the other 24 tests stub `nnunet_runner.predict_folder` and need no checkpoint. ## Working on it diff --git a/tools/Batch_Dental_Seg/src/sadt_batchdentalseg/__init__.py b/tools/Batch_Dental_Seg/src/sadt_batchdentalseg/__init__.py index 963760c..6a84acf 100644 --- a/tools/Batch_Dental_Seg/src/sadt_batchdentalseg/__init__.py +++ b/tools/Batch_Dental_Seg/src/sadt_batchdentalseg/__init__.py @@ -32,7 +32,7 @@ def run( PediatricDentalSeg (paediatric, the same five), NasoMaxillaDentSeg (six, maxilla split from the upper skull) or UniversalLab (every tooth in Universal numbering). Pairing one bundle with another's - labels is impossible by construction — the choice is one thing. + labels is impossible by construction -- the choice is one thing. output_dir: Where results are written, one file per scan plus `BatchDentalSeg_report.json`. Nothing is written outside it. separate_segments: Also write one binary file per label the network @@ -49,7 +49,7 @@ def run( Returns: The output directory, holding the segmentations and the run report. The - report carries the model's label table — the segmentation is a volume + report carries the model's label table -- the segmentation is a volume of integers, and without that table they mean nothing. """ # torch, nnunetv2, SimpleITK and numpy are imported inside the pipeline: CI diff --git a/tools/Batch_Dental_Seg/tests/data/README.md b/tools/Batch_Dental_Seg/tests/data/README.md index 925a6f5..6af71aa 100644 --- a/tools/Batch_Dental_Seg/tests/data/README.md +++ b/tools/Batch_Dental_Seg/tests/data/README.md @@ -18,7 +18,7 @@ sha256s. Stage them with the server's script: scripts/setup-models.sh --tool BatchDentalSeg ``` -Or fetch one bundle by hand — PediatricDentalSeg is the smallest at 250 MB, and +Or fetch one bundle by hand -- PediatricDentalSeg is the smallest at 250 MB, and the layout matters: the checkpoint goes under `fold_0/`, not beside the two JSON files, because that is the tree nnUNet's `initialize_from_trained_model_folder` walks. diff --git a/tools/Crown_Seg/README.md b/tools/Crown_Seg/README.md index 57bbf84..efcdcac 100644 --- a/tools/Crown_Seg/README.md +++ b/tools/Crown_Seg/README.md @@ -15,7 +15,7 @@ shell out to here, so `shapeaxi.dental_model_seg.main` is called directly with the namespace its own `cml()` would have built. Carried over from `slicer-remote-tool-server`'s `tools/CrownSeg/`, whose history -this repository holds — `git log --follow` on `src/sadt_crownseg/pipeline.py` +this repository holds -- `git log --follow` on `src/sadt_crownseg/pipeline.py` reaches back through it. Changes made by this port: @@ -27,16 +27,16 @@ Changes made by this port: - **Scratch space lives under `output_dir`** (`.crownseg_work/`, removed before returning), and `segment_crowns()` returns the run report rather than a `CrownSegRun`. -- **Zip extraction removed** — the server unpacks archives before `run()`. +- **Zip extraction removed** -- the server unpacks archives before `run()`. - **The model is a required argument.** It used to be a name resolved through the data store, with `default_model_path()` reading `settings.CROWNSEG_MODEL`; weights now arrive as a `Path`, like every other tool. -- **The GPU semaphore is gone** — each call is its own process. +- **The GPU semaphore is gone** -- each call is its own process. ## Working around a shapeaxi bug **CrownSeg does not currently run in the deployment, and did not before this -port either.** `shapeaxi.dental_model_seg` — the only supported way in — reads +port either.** `shapeaxi.dental_model_seg` -- the only supported way in -- reads `DentalModelSeg` off `shapeaxi.saxi_nets`, but in the 2.0.x line the class lives in `shapeaxi.saxi_nets_lightning`. Every 2.0.x release (2.0.0, 2.0.1, 2.0.2) has this; 1.x referenced the right module. Running the pre-port tool in the deployed @@ -68,8 +68,8 @@ Three things worth knowing before reading a result: - **An already-labelled mesh is passed through, not re-predicted.** That is what makes a mixed batch of raw and pre-segmented meshes one call. Pass `skip_segmented=False` to force the network to run anyway. -- **A pre-segmented `.stl` is impossible** — STL carries no point data by - construction — so every output is `.vtk`, whichever branch a mesh took. +- **A pre-segmented `.stl` is impossible** -- STL carries no point data by + construction -- so every output is `.vtk`, whichever branch a mesh took. - **`numbering` changes the integers written into the array**, not the mesh. Whatever consumes the result has to agree; the report records which was used. @@ -96,8 +96,8 @@ segmentation = [ **We are deliberately stricter than upstream on one point**: upstream leaves torch unpinned, so it arrives as a shapeaxi dependency and resolves to whatever -is current. We pin `torch==2.11.0`. An unpinned torch is not reproducible — the -same `uv sync` two months apart gives two different runtimes — and the pytorch3d +is current. We pin `torch==2.11.0`. An unpinned torch is not reproducible -- the +same `uv sync` two months apart gives two different runtimes -- and the pytorch3d wheel tag names one torch version and one CUDA variant *exactly*. Bumping torch therefore means bumping the pytorch3d tag in the same commit; they are one decision, not two. @@ -106,7 +106,7 @@ decision, not two. Upstream moved this env to 3.12 because shapeaxi 1.0.10 pinned `grpcio==1.51.1`, which has no cp312 wheel. shapeaxi 2.0.2 dropped that pin, so -grpcio resolves to 1.83.0, which publishes cp311 wheels — the reason for the +grpcio resolves to 1.83.0, which publishes cp311 wheels -- the reason for the move no longer applies. Verified rather than assumed: the full set (shapeaxi 2.0.2, ocnn 2.2.1, torch 2.11.0, pytorch3d, monai, itk) resolves on 3.11 in under a second. @@ -114,8 +114,8 @@ move no longer applies. Verified rather than assumed: the full set ### The four numbers that must agree pytorch3d and torchvision both ship compiled extensions linked against one -specific torch build. Three numbers have to line up — torch version, CUDA -variant, Python ABI — and a mismatch is **not** an install error: the package +specific torch build. Three numbers have to line up -- torch version, CUDA +variant, Python ABI -- and a mismatch is **not** an install error: the package imports fine and dies on the first CUDA kernel. ```toml @@ -157,7 +157,7 @@ pytorch3d 0.7.9+pt2110cu128 knn_points on cuda -> OK End to end on `T1_01_U_segmented.vtk` (294,260 points) with `skip_segmented=False` to force the engine: **52.3 s**, 15 distinct tooth -labels, and `PredictedID` **bit-identical to the reference — 0 of 294,260 +labels, and `PredictedID` **bit-identical to the reference -- 0 of 294,260 points differ**. `PredictedID` is the array to compare, and the only one. It is the network's @@ -165,7 +165,7 @@ raw per-point prediction; `Universal_ID` is shapeaxi's post-processed labelling, which the section below shows is not deterministic between two runs of the *same* code. Bit-identical `PredictedID` under torch 2.11 + pytorch3d 0.7.9+pt2110cu128 therefore says the version move does not perturb inference at -all — a stronger statement than any agreement percentage, and the same result +all -- a stronger statement than any agreement percentage, and the same result the pre-port validation recorded against torch 2.8. `uv sync --extra segmentation` no longer compiles anything: the wheel is @@ -174,7 +174,7 @@ prebuilt, so there is nothing to cache in a local `/wheels` directory. ## Validated against - **Input**: `DATA/CrownSeg/testfiles/T1_01_U_segmented.vtk` (294 260 points), - with `skip_segmented=False` so the network actually runs — the published test + with `skip_segmented=False` so the network actually runs -- the published test mesh is already labelled. - **Weights**: `07-21-22_val-loss0.169.pth`, staged from the release named in the server's manifest and checked against its recorded size. @@ -200,16 +200,16 @@ port 930 2607 826 0 ``` - **Tolerance**: none of the difference is attributable to the repackaging. - `PredictedID` — everything the network produces — is identical every time, so + `PredictedID` -- everything the network produces -- is identical every time, so the port demonstrably does not touch inference. `Universal_ID` comes out of shapeaxi's own "closing operation", which is not deterministic: two - *reference* runs differ by 1 216–2 361 points, and the port's spread against - them (826–2 607) is the same range. **Treat a difference in `PredictedID` as a + *reference* runs differ by 1 216-2 361 points, and the port's spread against + them (826-2 607) is the same range. **Treat a difference in `PredictedID` as a regression**; `Universal_ID` alone is this noise floor. **GPU tests**: `test_real_model_labels_a_real_mesh` is marked `gpu`/`models` and needs the extra. It was exercised through the run above rather than through -pytest, because pytorch3d cannot be built on this workstation — no CUDA toolkit. +pytest, because pytorch3d cannot be built on this workstation -- no CUDA toolkit. The 23 other tests stub the engine and run anywhere, which is what CI does. ## Working on it diff --git a/tools/Crown_Seg/src/sadt_crownseg/__init__.py b/tools/Crown_Seg/src/sadt_crownseg/__init__.py index bf26fd2..063f393 100644 --- a/tools/Crown_Seg/src/sadt_crownseg/__init__.py +++ b/tools/Crown_Seg/src/sadt_crownseg/__init__.py @@ -34,7 +34,7 @@ def run( suffix: Added to each output name, e.g. `arch_Seg.vtk`. numbering: Universal or FDI tooth numbering. This changes the integers written into the array, not the mesh, and whatever consumes the - result has to agree — the report records which was used. + result has to agree -- the report records which was used. skip_segmented: Pass a mesh that already carries labels through unchanged instead of spending minutes re-predicting it. That is what makes a mixed batch of raw and pre-segmented meshes one call. @@ -47,7 +47,7 @@ def run( Returns: The output directory, holding the labelled meshes and the run report. The report lists every mesh that now carries labels under - `segmented_meshes` — that is what a caller sequencing this before ALI + `segmented_meshes` -- that is what a caller sequencing this before ALI reads. """ # shapeaxi, torch and vtk are imported inside the pipeline: CI imports this diff --git a/tools/Crown_Seg/tests/data/README.md b/tools/Crown_Seg/tests/data/README.md index 8ab2ecf..4aebeee 100644 --- a/tools/Crown_Seg/tests/data/README.md +++ b/tools/Crown_Seg/tests/data/README.md @@ -19,7 +19,7 @@ the URLs and sizes: scripts/setup-models.sh --tool CrownSeg # from a slicer-remote-tool-server checkout ``` -Note that the published test mesh is **already segmented** — it carries a +Note that the published test mesh is **already segmented** -- it carries a `Universal_ID` array. It exercises the passthrough branch as it is; pass `skip_segmented=False` to force the network to run on it anyway, which is what the real-model test does. diff --git a/tools/Surg_Mov_Pred/README.md b/tools/Surg_Mov_Pred/README.md index 489eacc..8c6f884 100644 --- a/tools/Surg_Mov_Pred/README.md +++ b/tools/Surg_Mov_Pred/README.md @@ -8,12 +8,12 @@ measurement, each shipped with the scaler it was trained against. Ported from DCBIA-OrthoLab/SlicerAutomatedDentalTools, path `SurgMovPred_CLI/SurgMovPred_CLI.py`, commit `d7702ae` (2026-06-24), by way of -`slicer-remote-tool-server`'s `tools/SurgMovPred/` — whose history this +`slicer-remote-tool-server`'s `tools/SurgMovPred/` -- whose history this repository carries, so `git log --follow` on `src/sadt_surgmovpred/pipeline.py` reaches back through it. Upstream pins kept as-is: none are pinned upstream except `numpy==2.4.0`, which -was **not** adopted — see "Versions" below. +was **not** adopted -- see "Versions" below. Changes from upstream: @@ -41,8 +41,8 @@ The algorithm itself is unchanged: `clean_name`, `find_id_column`, | | | |---|---| | Inputs | `measurements`: one CSV/XLSX/ODS table, one row per patient, or a folder of them for a batch. `model`: folder of model packages. `output_dir`: where results go. | -| Outputs | `predictions_outputs.xlsx` and `predictions_outputs.csv` — one row per patient, one column per predicted measurement, plus `IDPatient`. | -| Model files | One subfolder per predicted measurement, each holding a `stacking_package.pkl` of `{target_name, features_names, scaler, model}`. Fetched by the server into `/DATA/SurgMovPred/models`; the shipped set is `all_models/` — 112 packages, 1.4 GB. | +| Outputs | `predictions_outputs.xlsx` and `predictions_outputs.csv` -- one row per patient, one column per predicted measurement, plus `IDPatient`. | +| Model files | One subfolder per predicted measurement, each holding a `stacking_package.pkl` of `{target_name, features_names, scaler, model}`. Fetched by the server into `/DATA/SurgMovPred/models`; the shipped set is `all_models/` -- 112 packages, 1.4 GB. | | GPU | None. This tool is CPU-only. | Two behaviours worth knowing before reading a result: @@ -65,8 +65,8 @@ openpyxl 3.1.5, odfpy 1.4.1. sub-estimators, so `joblib.load` fails outright without it. It is absent from the tool's import list because nothing imports it by name. - **The models were pickled under scikit-learn 1.6.1 and run under 1.7.2.** That - mismatch is upstream's own design — it silences `InconsistentVersionWarning` - because compatibility is checked before a model ships — and moving the pin to + mismatch is upstream's own design -- it silences `InconsistentVersionWarning` + because compatibility is checked before a model ships -- and moving the pin to 1.6.1 would be a change, not a fix. - **Upstream's `numpy==2.4.0` was not adopted.** The deployed server runs 2.2.6, and 2.2.6 is what produced every number this port was checked against. @@ -95,7 +95,7 @@ was chosen for support life rather than to reproduce a number. max absolute difference 0.000e+00 across the 11 312 predicted values, and the `IDPatient` column matches exactly. Column *order* differs by design (see Changes from upstream); the column sets are equal. -- **Tolerance**: none needed — equality was exact, so any future difference is a +- **Tolerance**: none needed -- equality was exact, so any future difference is a regression rather than noise. GPU tests: none exist, the tool is CPU-only. diff --git a/tools/Surg_Mov_Pred/tests/data/README.md b/tools/Surg_Mov_Pred/tests/data/README.md index da05375..2e1db02 100644 --- a/tools/Surg_Mov_Pred/tests/data/README.md +++ b/tools/Surg_Mov_Pred/tests/data/README.md @@ -5,7 +5,7 @@ fixture can be published: - the model packages are 1.4 GB (112 × ~13 MB), well past what belongs in git; - `patients_to_predict.xlsx` is a table of real cephalometric measurements for - 101 patients — patient data, which never goes into this repository. + 101 patients -- patient data, which never goes into this repository. The tests that need neither build their own scikit-learn models in `test_run.py`, which is why the suite runs in CI without any of this. @@ -23,7 +23,7 @@ uv run pytest -m models Both live under the deployment's `/DATA` mount, or under `DATA/SurgMovPred/` in a `slicer-remote-tool-server` checkout. Write results to the repository's -gitignored `output/` directory if you run the tool by hand — never next to the +gitignored `output/` directory if you run the tool by hand -- never next to the inputs. If a public, anonymised sample of the input format ever exists, it belongs here diff --git a/tools/_template/README.md b/tools/_template/README.md index d8bb60f..d344490 100644 --- a/tools/_template/README.md +++ b/tools/_template/README.md @@ -84,7 +84,7 @@ uv run pytest -m gpu # skipped in CI, run by hand before opening a PR ## Validated against Synthetic arrays built in `tests/test_run.py`, asserted exactly (`mean=50.0`, -`max=99.0` over `arange(100)` above threshold 0) — no tolerance needed because +`max=99.0` over `arange(100)` above threshold 0) -- no tolerance needed because there is no model. A real tool records here: which input, which model weights, which reference diff --git a/tools/_template/tests/test_integration.py b/tools/_template/tests/test_integration.py index b86081c..a3ff3e4 100644 --- a/tools/_template/tests/test_integration.py +++ b/tools/_template/tests/test_integration.py @@ -2,7 +2,7 @@ Copy this file into a new tool and point `run_tool` at whatever produces its input. `tools/ALI`, for instance, needs meshes that carry tooth labels, so its -version of this runs `Crown_Seg` first and feeds the result straight in — the +version of this runs `Crown_Seg` first and feeds the result straight in -- the real handoff, not a fixture standing in for one. Two things are only testable from out here: @@ -12,7 +12,7 @@ was going to create anyway; `run_tool` runs it from a neutral directory. * that the published schema and the callable agree. The server reads the schema and calls `run(**params)` from it, so an argument renamed in one place and not - the other breaks the chain there, not here — unless something checks. + the other breaks the chain there, not here -- unless something checks. """ import numpy as np From 4abd9bc234beabb7f222a26d9aa1dfdd7d47522d Mon Sep 17 00:00:00 2001 From: Jules GRIVOT PELISSON Date: Wed, 19 Aug 2026 12:37:38 -0400 Subject: [PATCH 08/19] FIX: point AREG error messages at each split tool's own data endpoint --- tools/AREG/AREG_CBCT/src/sadt_areg_cbct/dispatch.py | 2 +- tools/AREG/AREG_IOS/src/sadt_areg_ios/dispatch.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tools/AREG/AREG_CBCT/src/sadt_areg_cbct/dispatch.py b/tools/AREG/AREG_CBCT/src/sadt_areg_cbct/dispatch.py index b5ec2b3..f168e08 100644 --- a/tools/AREG/AREG_CBCT/src/sadt_areg_cbct/dispatch.py +++ b/tools/AREG/AREG_CBCT/src/sadt_areg_cbct/dispatch.py @@ -98,7 +98,7 @@ def _check_cbct(automation: str, regions: list, t1_masks, reference, sup=None) - raise ToolInputError( "Oriented + Fully-Automated CBCT orients the T1 scans before " "registering onto them, which needs an orientation reference: name " - "one in 'cbct_reference' (see GET /tools/AREG/data)." + "one in 'cbct_reference' (see GET /tools/AREG_CBCT/data)." ) diff --git a/tools/AREG/AREG_IOS/src/sadt_areg_ios/dispatch.py b/tools/AREG/AREG_IOS/src/sadt_areg_ios/dispatch.py index d9af6ba..cc8a838 100644 --- a/tools/AREG/AREG_IOS/src/sadt_areg_ios/dispatch.py +++ b/tools/AREG/AREG_IOS/src/sadt_areg_ios/dispatch.py @@ -98,7 +98,7 @@ def _check_ios(automation, patch, registration_model, reference, mgl_landmarks, elif not registration_model: raise ToolInputError( "Registering on the palate needs its patch-prediction checkpoint: name " - "one in 'registration_model' (see GET /tools/AREG/data)." + "one in 'registration_model' (see GET /tools/AREG_IOS/data)." ) if automation != catalogs.AUTOMATION_FULLY: @@ -109,7 +109,7 @@ def _check_ios(automation, patch, registration_model, reference, mgl_landmarks, raise ToolInputError( "Fully-Automated IOS orients both timepoints before registering, which " "needs an orientation reference: name one in 'ios_reference' (see " - "GET /tools/AREG/data)." + "GET /tools/AREG_IOS/data)." ) From a98d6ddcb5e754a5098fd8bf5bd94b97743760d4 Mon Sep 17 00:00:00 2001 From: Jules GRIVOT PELISSON Date: Wed, 19 Aug 2026 12:37:38 -0400 Subject: [PATCH 09/19] UPDATE: Crown_Seg install hint, now that pytorch3d ships as a prebuilt wheel --- tools/Crown_Seg/src/sadt_crownseg/pipeline.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tools/Crown_Seg/src/sadt_crownseg/pipeline.py b/tools/Crown_Seg/src/sadt_crownseg/pipeline.py index 64e69b1..3b5c0ff 100644 --- a/tools/Crown_Seg/src/sadt_crownseg/pipeline.py +++ b/tools/Crown_Seg/src/sadt_crownseg/pipeline.py @@ -61,9 +61,10 @@ WORK_DIRNAME = ".crownseg_work" _INSTALL_HINT = ( - "CrownSeg's engine is an optional extra: shapeaxi pulls pytorch3d, which " - "publishes no usable wheel and is compiled from source. Install it with " - "`uv sync --extra segmentation` (needs a CUDA toolkit, see README.md)." + "CrownSeg's engine is an optional extra. Install it with " + "`uv sync --extra segmentation` in tools/Crown_Seg. A deployment image " + "builds it with `--all-extras`; if this appears there, the image was built " + "without them." ) From 6d24de922427980a43bb0edf0c2c6d6a7cb71dbb Mon Sep 17 00:00:00 2001 From: Jules GRIVOT PELISSON Date: Wed, 19 Aug 2026 13:17:06 -0400 Subject: [PATCH 10/19] CLEAN: drop four imports no code in these modules uses --- tools/ALI/ALI_CBCT/src/sadt_ali_cbct/environment.py | 1 - tools/AREG/AREG_IOSCBCT/src/sadt_areg_ioscbct/tools.py | 6 +----- tools/ASO/src/sadt_aso/cbct/pipeline.py | 2 +- 3 files changed, 2 insertions(+), 7 deletions(-) diff --git a/tools/ALI/ALI_CBCT/src/sadt_ali_cbct/environment.py b/tools/ALI/ALI_CBCT/src/sadt_ali_cbct/environment.py index e85a3cb..c619d74 100644 --- a/tools/ALI/ALI_CBCT/src/sadt_ali_cbct/environment.py +++ b/tools/ALI/ALI_CBCT/src/sadt_ali_cbct/environment.py @@ -9,7 +9,6 @@ """ import logging -import sys import numpy as np import SimpleITK as sitk diff --git a/tools/AREG/AREG_IOSCBCT/src/sadt_areg_ioscbct/tools.py b/tools/AREG/AREG_IOSCBCT/src/sadt_areg_ioscbct/tools.py index 19cf191..0ac6bf9 100644 --- a/tools/AREG/AREG_IOSCBCT/src/sadt_areg_ioscbct/tools.py +++ b/tools/AREG/AREG_IOSCBCT/src/sadt_areg_ioscbct/tools.py @@ -32,11 +32,7 @@ import logging import os -from sadt_areg_common.errors import ( - SupervisorRequired, - ToolInputError, - ToolUnavailableError, -) +from sadt_areg_common.errors import SupervisorRequired logger = logging.getLogger(__name__) diff --git a/tools/ASO/src/sadt_aso/cbct/pipeline.py b/tools/ASO/src/sadt_aso/cbct/pipeline.py index 06101f4..946a37f 100644 --- a/tools/ASO/src/sadt_aso/cbct/pipeline.py +++ b/tools/ASO/src/sadt_aso/cbct/pipeline.py @@ -18,7 +18,7 @@ import SimpleITK as sitk from .. import markups -from ..scans import SCAN_EXTENSIONS, compressed_extension, split_scan_extension +from ..scans import SCAN_EXTENSIONS, compressed_extension from . import icp logger = logging.getLogger(__name__) From cd8bbed7e85e0a42de3cccca9038d39c518b07f7 Mon Sep 17 00:00:00 2001 From: Jules GRIVOT PELISSON Date: Wed, 19 Aug 2026 13:26:28 -0400 Subject: [PATCH 11/19] FIX: ASO reads split_scan_extension from scans, not through the CBCT pipeline --- tools/ASO/src/sadt_aso/dispatch.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tools/ASO/src/sadt_aso/dispatch.py b/tools/ASO/src/sadt_aso/dispatch.py index 862cd33..5157a92 100644 --- a/tools/ASO/src/sadt_aso/dispatch.py +++ b/tools/ASO/src/sadt_aso/dispatch.py @@ -39,6 +39,7 @@ import shutil from . import catalogs +from .scans import split_scan_extension from .cbct import dicom from .cbct import pipeline as cbct_pipeline from .errors import SupervisorRequired, ToolInputError @@ -505,7 +506,7 @@ def _run_cbct( # mode they have to reach disk before it is called. prepared = {} for key, entry in sorted(patients.items()): - _, extension = cbct_pipeline.split_scan_extension(os.path.basename(entry["scan"])) + _, extension = split_scan_extension(os.path.basename(entry["scan"])) destination = ( os.path.join( centered_root, From 88753b213d3a58a71c81a25d1ea3e1163029ced5 Mon Sep 17 00:00:00 2001 From: Jules GRIVOT PELISSON Date: Wed, 19 Aug 2026 13:48:24 -0400 Subject: [PATCH 12/19] FIX: AREG_IOS asked the supervisor for 'ALI', renamed to ALI_IOS by the split --- tools/AREG/AREG_IOS/src/sadt_areg_ios/dispatch.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/AREG/AREG_IOS/src/sadt_areg_ios/dispatch.py b/tools/AREG/AREG_IOS/src/sadt_areg_ios/dispatch.py index cc8a838..8c1b0cc 100644 --- a/tools/AREG/AREG_IOS/src/sadt_areg_ios/dispatch.py +++ b/tools/AREG/AREG_IOS/src/sadt_areg_ios/dispatch.py @@ -89,7 +89,7 @@ def _check_ios(automation, patch, registration_model, reference, mgl_landmarks, # server. Sending them is for a folder that already has them, which also # lets a run be repeated without paying for the prediction again. if not mgl_landmarks: - tools.require(sup, "ALI", "Registering on the mucogingival line") + tools.require(sup, "ALI_IOS", "Registering on the mucogingival line") if height is not None and float(height) < 0: raise ToolInputError( "'mgl_patch_height' is a half-height in millimetres and cannot be " @@ -200,7 +200,7 @@ def _run_ios( tools.predict_mucogingival(sup, root, mgl_model or ""), landmark_root, ) - report["mgl_landmarks"] = "predicted by 'ALI'" + report["mgl_landmarks"] = "predicted by 'ALI_IOS'" painter = ios_pipeline.MGLPainter(landmark_root, height=mgl_patch_height) report["mgl_patch_height_mm"] = mgl_patch_height From 638f9283b78a7be6d4a5e07b7e2db01c8ed9d186 Mon Sep 17 00:00:00 2001 From: Jules GRIVOT PELISSON Date: Wed, 19 Aug 2026 13:48:24 -0400 Subject: [PATCH 13/19] ADD: describe.py collects require() tool names, not only sup.run() --- scripts/describe.py | 48 +++++++++++++++++++++++++++++++++ scripts/tests/test_describe.py | 49 ++++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+) diff --git a/scripts/describe.py b/scripts/describe.py index 48ddd78..93a4815 100644 --- a/scripts/describe.py +++ b/scripts/describe.py @@ -279,10 +279,56 @@ def supervised_calls(src_dir): if isinstance(target, ast.Name): constants[target.id] = node.value.value + # `for name in ("Crown_Seg", "ALI_IOS"): require(sup, name, ...)`, which + # is how AREG_IOSCBCT states the three tools one mode needs. The names + # are still literals, written once instead of three times; refusing the + # form would push a tool towards the more repetitive spelling to satisfy + # a reader that is only looking for strings. + loop_names = {} + for node in ast.walk(tree): + if not isinstance(node, ast.For) or not isinstance(node.target, ast.Name): + continue + if not isinstance(node.iter, (ast.Tuple, ast.List)): + continue + values = [element.value for element in node.iter.elts + if isinstance(element, ast.Constant) and isinstance(element.value, str)] + if len(values) == len(node.iter.elts): + loop_names.setdefault(node.target.id, set()).update(values) + for node in ast.walk(tree): if not isinstance(node, ast.Call): continue function = node.func + + # `require(sup, "AMASSS", ...)` states a dependency without making + # the call: it is how a tool refuses a mode early, before an hour of + # work, and it names a tool exactly as `sup.run` does. It was not + # collected here, so AREG_IOS went on asking for 'ALI' after the + # split renamed it, and neither the generator nor the server's + # startup check said anything -- the failure waited for a + # mucogingival run. That is the hole this function's own docstring + # warns about, so it is closed rather than documented. + require_name = function.attr if isinstance(function, ast.Attribute) else ( + function.id if isinstance(function, ast.Name) else None) + if require_name == "require" and len(node.args) >= 2: + first, second = node.args[0], node.args[1] + if isinstance(first, ast.Name) and first.id == SUPERVISOR: + if isinstance(second, ast.Constant) and isinstance(second.value, str): + names.add(second.value) + continue + if isinstance(second, ast.Name) and second.id in constants: + names.add(constants[second.id]) + continue + if isinstance(second, ast.Name) and second.id in loop_names: + names.update(loop_names[second.id]) + continue + raise SchemaError( + "{}:{}: require()'s tool name must be a literal or a " + "module-level string constant, so the server can check it " + "exists. Got {}.".format( + path.name, node.lineno, ast.dump(second)[:60]) + ) + if not isinstance(function, ast.Attribute) or function.attr != "run": continue if not isinstance(function.value, ast.Name) or function.value.id != SUPERVISOR: @@ -297,6 +343,8 @@ def supervised_calls(src_dir): names.add(first.value) elif isinstance(first, ast.Name) and first.id in constants: names.add(constants[first.id]) + elif isinstance(first, ast.Name) and first.id in loop_names: + names.update(loop_names[first.id]) else: raise SchemaError( "{}:{}: {}.run()'s tool name must be a literal or a module-level " diff --git a/scripts/tests/test_describe.py b/scripts/tests/test_describe.py index ac1cd81..d78876d 100644 --- a/scripts/tests/test_describe.py +++ b/scripts/tests/test_describe.py @@ -537,3 +537,52 @@ def test_groups_on_an_argument_with_no_choices_fails(tmp_path): make_layout(root, 'LAYOUT = {"scans": {"groups": {"Tab": ["a"]}}}') assert "has none" in describe(root).stderr + + +# --------------------------------------------------------------------------- +# require() states a dependency without making the call + +REQUIRES_LITERAL = """ + def _preflight(sup): + tools.require(sup, "AMASSS", "Fully-Automated registration") + + def run(scan: Path, output_dir: Path, *, sup=None) -> Path: + \"\"\"Register two timepoints.\"\"\" + _preflight(sup) + return output_dir +""" + +REQUIRES_LOOP = """ + def _preflight(sup): + for name in ("Crown_Seg", "ALI_IOS"): + tools.require(sup, name, "Fully-Automated registration") + + def run(scan: Path, output_dir: Path, *, sup=None) -> Path: + \"\"\"Register two timepoints.\"\"\" + _preflight(sup) + return output_dir +""" + + +def test_a_required_tool_is_published_like_a_called_one(tmp_path): + """`require` names a tool without running it, and the server checks names. + + Collected because it was not: AREG_IOS asked for 'ALI' through `require` + long after the split renamed it to 'ALI_IOS'. `sup.run` was scanned and + `require` was not, so neither the schema nor the server's startup check saw + a name that could never resolve, and the failure waited for a mucogingival + run to reach it. + """ + schema = json.loads(describe(make_tool(tmp_path, REQUIRES_LITERAL)).stdout) + assert schema["calls"] == ["AMASSS"] + + +def test_a_required_tool_named_in_a_loop_is_published(tmp_path): + """Several tools one mode needs, written once instead of three times. + + Refusing this form would push a tool towards the repetitive spelling purely + to satisfy the reader, so the loop is resolved when every element is a + literal. + """ + schema = json.loads(describe(make_tool(tmp_path, REQUIRES_LOOP)).stdout) + assert schema["calls"] == ["ALI_IOS", "Crown_Seg"] From 7ade429db69fce018b66ed830c8ac2e8f506c980 Mon Sep 17 00:00:00 2001 From: Jules GRIVOT PELISSON Date: Wed, 19 Aug 2026 13:48:24 -0400 Subject: [PATCH 14/19] FIX: split AREG's stranded test file into the three tools it became --- tools/AREG/AREG_CBCT/tests/test_run.py | 499 ++++++++++++ tools/AREG/{ => AREG_IOS}/tests/test_run.py | 734 +++--------------- tools/AREG/AREG_IOSCBCT/tests/test_run.py | 138 ++++ .../AREG/{tests/data/README.md => TESTING.md} | 5 +- 4 files changed, 762 insertions(+), 614 deletions(-) create mode 100644 tools/AREG/AREG_CBCT/tests/test_run.py rename tools/AREG/{ => AREG_IOS}/tests/test_run.py (54%) create mode 100644 tools/AREG/AREG_IOSCBCT/tests/test_run.py rename tools/AREG/{tests/data/README.md => TESTING.md} (88%) diff --git a/tools/AREG/AREG_CBCT/tests/test_run.py b/tools/AREG/AREG_CBCT/tests/test_run.py new file mode 100644 index 0000000..3f73d1e --- /dev/null +++ b/tools/AREG/AREG_CBCT/tests/test_run.py @@ -0,0 +1,499 @@ +"""AREG_CBCT's unit tests: no GPU, no weights, no network. + +Split out of the single `tools/AREG/tests/test_run.py` that AREG had before it +became three tools. That file went on importing `sadt_areg`, which no +virtualenv has provided since -- so it could not be collected, and all 84 of +its tests stopped running without anything failing. Found by running each +tool's suite in its own interpreter. + +elastix is fast enough on a 48^3 phantom that the registration itself is tested +rather than mocked. +""" + +import json +import os +from pathlib import Path + +import numpy as np +import pytest +import SimpleITK as sitk + +from sadt_areg_cbct import dispatch, elastix, run, tools +from sadt_areg_cbct import pipeline as cbct_pipeline +from sadt_areg_common import catalogs, pairing +from sadt_areg_common.errors import SupervisorRequired, ToolInputError + + +class FakeSup: + """A supervisor, as a tool sees one. Records what it was asked for. + + `outputs` maps a tool name to a callable taking the parameters it was sent + and returning the directory it "produced", so a test can plant results + without any of the real tools existing. + """ + + def __init__(self, tmp_path, outputs=None): + self.out = Path(tmp_path) / "out" + self.tmp = Path(tmp_path) / "tmp" + self.tmp.mkdir(parents=True, exist_ok=True) + self.outputs = outputs or {} + self.calls = [] + self.messages = [] + + def run(self, tool, **params): + self.calls.append((tool, params)) + maker = self.outputs.get(tool) + if maker is None: + raise AssertionError(f"nothing planted for {tool!r} in this test") + return Path(maker(params)) + + def progress(self, fraction, message): + self.messages.append((fraction, message)) + + def log(self, message): + self.messages.append((None, message)) + + +def _phantom(size=48, seed=0, spacing=0.8, origin=(-140.0, -90.0, 60.0)): + """A textured volume with an origin far from zero. + + Far from zero on purpose: that is the condition under which elastix's + centre of rotation matters, and a phantom centred on the origin would let + the bug this suite pins pass unnoticed. + """ + rng = np.random.default_rng(seed) + volume = rng.random((size,) * 3).astype(np.float32) * 120 + zz, yy, xx = np.meshgrid(*[np.arange(size)] * 3, indexing="ij") + half = size // 2 + volume += 1400 * ( + ((zz - half) ** 2 / 180 + (yy - half + 2) ** 2 / 140 + (xx - half - 2) ** 2 / 160) < 1 + ) + volume += 900 * ( + ((zz - half + 12) ** 2 / 40 + (yy - half - 10) ** 2 / 35 + (xx - half + 12) ** 2 / 30) < 1 + ) + image = sitk.GetImageFromArray(volume) + image.SetSpacing((spacing,) * 3) + image.SetOrigin(origin) + return image + + +def _moved(image, rotation=(0.04, -0.025, 0.03), translation=(1.2, -1.6, 0.9)): + """`image` displaced by a known rigid transform, and that transform.""" + truth = sitk.Euler3DTransform() + size = np.array(image.GetSize()) / 2.0 + truth.SetCenter(image.TransformContinuousIndexToPhysicalPoint(size.tolist())) + truth.SetRotation(*rotation) + truth.SetTranslation(translation) + + resampler = sitk.ResampleImageFilter() + resampler.SetReferenceImage(image) + resampler.SetTransform(truth.GetInverse()) + resampler.SetInterpolator(sitk.sitkLinear) + return resampler.Execute(image), truth + + +def _write(image, path): + os.makedirs(os.path.dirname(path), exist_ok=True) + sitk.WriteImage(image, path, useCompression=True) + return path + + +def _full_mask(image): + mask = sitk.GetImageFromArray(np.ones(sitk.GetArrayViewFromImage(image).shape, np.uint8)) + mask.CopyInformation(image) + return mask + + +def _grid_mesh(rows=12, columns=12, spacing=1.0): + """A flat triangulated grid, the smallest thing with a real adjacency.""" + points = vtk.vtkPoints() + for row in range(rows): + for column in range(columns): + points.InsertNextPoint(column * spacing, row * spacing, 0.0) + + triangles = vtk.vtkCellArray() + for row in range(rows - 1): + for column in range(columns - 1): + a = row * columns + column + for corners in ((a, a + 1, a + columns), (a + 1, a + columns + 1, a + columns)): + triangle = vtk.vtkTriangle() + for index, corner in enumerate(corners): + triangle.GetPointIds().SetId(index, corner) + triangles.InsertNextCell(triangle) + + mesh = vtk.vtkPolyData() + mesh.SetPoints(points) + mesh.SetPolys(triangles) + return mesh + + +class TestMaskDiscovery: + def test_a_cbct_in_the_name_is_not_a_cranial_base_mask(self, tmp_path): + """FIX: the region test was `"cb" in basename.lower()`, which makes + every file whose name contains CBCT a cranial-base mask.""" + image = _phantom(size=16) + _write(image, str(tmp_path / "P1_CBCT_seg.nii.gz")) + assert pairing.discover_masks(str(tmp_path), "CB") == {} + + def test_a_mask_keys_to_the_patient_its_scan_keys_to(self, tmp_path): + image = _phantom(size=16) + _write(image, str(tmp_path / "masks" / "P1_T1_MAND_seg.nii.gz")) + found = pairing.discover_masks(str(tmp_path / "masks"), "MAND") + assert list(found) == ["P1"] + + def test_a_mask_has_to_say_both_what_it_is_and_what_it_covers(self, tmp_path): + image = _phantom(size=16) + _write(image, str(tmp_path / "P1_MAND.nii.gz")) # no seg token + _write(image, str(tmp_path / "P2_seg.nii.gz")) # no region token + _write(image, str(tmp_path / "P3_MAND_seg.nii.gz")) # both + assert list(pairing.discover_masks(str(tmp_path), "MAND")) == ["P3"] + + def test_amasss_output_names_are_recognised(self, tmp_path): + """What the Fully-Automated path actually has to read back.""" + image = _phantom(size=16) + _write(image, str(tmp_path / "P1_T1_scan_seg_MANDMASK.nii.gz")) + found = pairing.discover_masks(str(tmp_path), "MAND") + assert list(found) == ["P1"] + + +class TestElastix: + def test_the_centre_of_rotation_is_honoured(self): + """FIX, and the headline one: `MatrixRetrieval` read elastix's three + angles and its translation and DROPPED its CenterOfRotationPoint, so + the SimpleITK transform it built rotated about the physical origin + instead. The two differ by (I - R)c -- invisible on centred data, + metres-per-radian off the further the scan sits from the origin. + + Measured here against a known ground truth on a phantom whose origin is + at (-140, -90, 60) mm: the centre-dropping version lands several + millimetres out, this one lands within a fifth of a voxel. + """ + fixed = _phantom(size=48) + moving, truth = _moved(fixed) + + transform = elastix.register(fixed, moving) + + # The transform elastix returns maps FIXED space to MOVING space, which + # is the direction sitk's resampler consumes -- and here that is `truth` + # itself, since `moving` is `fixed` displaced by it. + probes = [ + fixed.TransformContinuousIndexToPhysicalPoint([float(v) for v in index]) + for index in ([0, 0, 0], [24, 24, 24], [47, 47, 47], [4, 40, 12]) + ] + errors = [ + np.linalg.norm( + np.array(transform.TransformPoint(p)) - np.array(truth.TransformPoint(p)) + ) + for p in probes + ] + assert max(errors) < 0.2, f"registration is {max(errors):.3f} mm from the truth" + + # And the shipped behaviour, reconstructed, is not: dropping the centre + # is a real displacement, not a rounding difference. + without_centre = sitk.Euler3DTransform() + without_centre.SetRotation(*transform.GetParameters()[:3]) + without_centre.SetTranslation(transform.GetParameters()[3:6]) + dropped = max( + np.linalg.norm( + np.array(without_centre.TransformPoint(p)) - np.array(truth.TransformPoint(p)) + ) + for p in probes + ) + assert dropped > 1.0, "the phantom is too centred for this test to mean anything" + + def test_a_mask_of_a_different_size_is_refused(self): + """FIX: `fixed_seg.SetOrigin(fixed_image.GetOrigin())` forced the two + into agreement unconditionally, so a mask that is genuinely a different + sampling of the patient was applied several millimetres off in + silence.""" + image = _phantom(size=24) + mask = _full_mask(_phantom(size=20)) + with pytest.raises(elastix.RegistrationError, match="not the same sampling"): + elastix.apply_mask(image, mask) + + def test_a_label_the_mask_does_not_hold_is_refused(self): + """FIX: `if label is not None and label in np.unique(array)` fell + through to using the WHOLE mask when the label was absent -- asking for + label 4 of a two-label mask registered on everything and reported + success.""" + image = _phantom(size=24) + with pytest.raises(elastix.RegistrationError, match="no label 4"): + elastix.apply_mask(image, _full_mask(image), label=4) + + def test_a_multi_label_mask_used_whole_says_so(self): + image = _phantom(size=24) + array = np.zeros(sitk.GetArrayViewFromImage(image).shape, np.uint8) + array[4:12] = 1 + array[12:18] = 2 + mask = sitk.GetImageFromArray(array) + mask.CopyInformation(image) + + _masked, note = elastix.apply_mask(image, mask, label=0) + assert note and "several labels" in note + + def test_masking_keeps_only_the_masked_region(self): + image = _phantom(size=24) + array = np.zeros(sitk.GetArrayViewFromImage(image).shape, np.uint8) + array[6:14, 6:14, 6:14] = 1 + mask = sitk.GetImageFromArray(array) + mask.CopyInformation(image) + + masked, _note = elastix.apply_mask(image, mask, label=1) + kept = sitk.GetArrayViewFromImage(masked) + assert kept[6:14, 6:14, 6:14].any() + assert not kept[:6].any() and not kept[14:].any() + + def test_the_masked_image_never_reaches_the_disk(self, tmp_path, monkeypatch): + """FIX: `MaskedImage` wrote `/fixed_image_masked.nii.gz` -- one + FIXED name shared by every patient of a run and by every concurrent + request being served. + + Run from an empty directory rather than by patching a setting: the + masked image is held in memory now, so there is no temp directory to + point anywhere, and "wrote nothing at all" is the stronger claim. + """ + monkeypatch.chdir(tmp_path) + fixed = _phantom(size=32) + moving, _truth = _moved(fixed) + masked, _note = elastix.apply_mask(fixed, _full_mask(fixed)) + elastix.register(masked, moving) + assert list(tmp_path.iterdir()) == [] + + +class TestSemiAutomatedCBCT: + @staticmethod + def _cohort(tmp_path, subjects=("P1",)): + for subject in subjects: + fixed = _phantom(size=48, seed=abs(hash(subject)) % 100) + moving, _truth = _moved(fixed) + _write(fixed, str(tmp_path / "T1" / f"{subject}_T1_scan.nii.gz")) + _write(moving, str(tmp_path / "T2" / f"{subject}_T2_scan.nii.gz")) + _write(_full_mask(fixed), str(tmp_path / "masks" / f"{subject}_T1_CB_seg.nii.gz")) + return tmp_path + + def test_a_run_writes_a_registered_scan_a_transform_and_a_report(self, tmp_path): + self._cohort(tmp_path) + run = dispatch.register( + t1_path=str(tmp_path / "T1"), + t2_path=str(tmp_path / "T2"), + t1_masks_path=str(tmp_path / "masks"), + automation=catalogs.AUTOMATION_SEMI, + regions=["Cranial base"], + output_dir=str(tmp_path / "out"), + ) + assert run.succeeded == ["P1"] + produced = sorted( + os.path.relpath(os.path.join(directory, name), run.output_dir) + for directory, _, names in os.walk(run.output_dir) + for name in names + ) + assert produced == [ + "AREG_report.json", + os.path.join("CB", "P1_CB_Reg.nii.gz"), + os.path.join("CB", "P1_CB_Reg_transform.tfm"), + ] + + with open(os.path.join(run.output_dir, "AREG_report.json")) as handle: + report = json.load(handle) + assert report["summary"] == {"patients": 1, "registered": 1, "failed": 0} + assert report["patients"]["P1"]["regions"]["CB"]["status"] == "ok" + + def test_the_written_transform_moves_the_t2_onto_the_t1(self, tmp_path): + """The direction is asserted, not assumed: a transform written the + other way round still loads and still transforms, so nothing else in + the archive would show it. + + It is also what the original could NOT give you: it registered against + a recentred copy of the T2 living in a `_Center` folder next to the + caller's own data, and wrote the transform between the T1 and THAT -- + a volume the caller never received. + """ + self._cohort(tmp_path) + run = dispatch.register( + t1_path=str(tmp_path / "T1"), + t2_path=str(tmp_path / "T2"), + t1_masks_path=str(tmp_path / "masks"), + automation=catalogs.AUTOMATION_SEMI, + regions=["Cranial base"], + output_dir=str(tmp_path / "out"), + ) + transform = sitk.ReadTransform( + os.path.join(run.output_dir, "CB", "P1_CB_Reg_transform.tfm") + ) + registered = sitk.ReadImage(os.path.join(run.output_dir, "CB", "P1_CB_Reg.nii.gz")) + moving = sitk.ReadImage(str(tmp_path / "T2" / "P1_T2_scan.nii.gz")) + + # Resampling the ORIGINAL T2 with the written transform reproduces the + # registered volume the archive holds. It could only do that if the + # transform lives in the space of the file the caller sent. + resampler = sitk.ResampleImageFilter() + resampler.SetReferenceImage(moving) + resampler.SetTransform(transform) + resampler.SetInterpolator(sitk.sitkLinear) + reproduced = sitk.Cast(resampler.Execute(moving), sitk.sitkInt16) + assert np.array_equal( + sitk.GetArrayViewFromImage(reproduced), sitk.GetArrayViewFromImage(registered) + ) + + def test_a_subject_with_no_mask_is_reported_and_the_batch_goes_on(self, tmp_path): + self._cohort(tmp_path, subjects=("P1", "P2")) + os.remove(str(tmp_path / "masks" / "P2_T1_CB_seg.nii.gz")) + + run = dispatch.register( + t1_path=str(tmp_path / "T1"), + t2_path=str(tmp_path / "T2"), + t1_masks_path=str(tmp_path / "masks"), + automation=catalogs.AUTOMATION_SEMI, + regions=["Cranial base"], + output_dir=str(tmp_path / "out"), + ) + assert run.succeeded == ["P1"] + failure = run.patients["P2"]["regions"]["CB"] + assert failure["status"] == "failed" + assert "no Cranial base mask" in failure["reason"] + + def test_no_pair_at_all_is_a_422_naming_the_pairing_rule(self, tmp_path): + image = _phantom(size=16) + _write(image, str(tmp_path / "T1" / "alpha_T1.nii.gz")) + _write(image, str(tmp_path / "T2" / "beta_T2.nii.gz")) + with pytest.raises(ToolInputError, match="paired by name"): + dispatch.register( + t1_path=str(tmp_path / "T1"), + t2_path=str(tmp_path / "T2"), + t1_masks_path=str(tmp_path / "T1"), + automation=catalogs.AUTOMATION_SEMI, + regions=["Cranial base"], + output_dir=str(tmp_path / "out"), + ) + + +class TestMaskLookup: + def test_a_mask_under_amasss_own_output_folder_still_finds_its_scan(self, tmp_path): + """AMASSS writes one `__SegOut/` directory per scan, so a mask + discovered under it keys to `P1_seg_SegOut/P1` while its scan keys to + `P1`. Without the leaf fallback every Fully-Automated run would report + 'no mask for this subject' for every subject.""" + from sadt_areg_cbct import pipeline as cbct_pipeline + + image = _phantom(size=16) + _write( + image, + str(tmp_path / "P1_T1_scan_seg_SegOut" / "P1_T1_scan_seg_MANDMASK.nii.gz"), + ) + found = cbct_pipeline.find_masks([str(tmp_path)], "MAND", scan_keys=["P1"]) + assert os.path.basename(found["P1"]) == "P1_T1_scan_seg_MANDMASK.nii.gz" + + def test_an_ambiguous_leaf_is_not_guessed(self, tmp_path): + """Two subjects genuinely called P1 in different folders must not + borrow each other's mask.""" + from sadt_areg_cbct import pipeline as cbct_pipeline + + image = _phantom(size=16) + for site in ("siteA", "siteB"): + _write(image, str(tmp_path / site / "P1_T1_MAND_seg.nii.gz")) + found = cbct_pipeline.find_masks([str(tmp_path)], "MAND", scan_keys=["P1"]) + assert "P1" not in found + + +# Moved from AREG_IOSCBCT's suite: this package is the one that makes +# the call, so this is where a change to it should fail. +def test_the_mask_request_names_amasss_arguments(tmp_path): + """AREG sends structure CODES. The packaged AMASSS publishes codes as its + `choices`, so the display-name translation the in-process version needed + is gone rather than restated -- and this is what says so.""" + planted = tmp_path / "masks" + planted.mkdir() + sup = FakeSup(tmp_path, {"AMASSS": lambda params: planted}) + + tools.segment_masks(sup, str(tmp_path / "t1"), "/models/AMASSS", ["CBMASK"]) + + tool, params = sup.calls[0] + assert tool == "AMASSS" + assert params["structures"] == ["CBMASK"] + # One binary file per structure: `find_masks` looks each region up by + # name, and a merged multi-label volume makes every region resolve to + # the same file. + assert params["merge"] == ["SEPARATE"] + assert params["generate_surface"] is False + assert set(params) >= {"scans", "model", "output_dir"} + +def test_the_orientation_request_is_the_nested_one(tmp_path): + """ASO is itself supervised for CBCT, so this is AREG -> ASO -> ALI: + three tools and three venvs deep. Whatever supplies `sup` supplies the + callee's too; nothing here arranges that.""" + planted = tmp_path / "oriented" + planted.mkdir() + sup = FakeSup(tmp_path, {"ASO": lambda params: planted}) + + tools.orient_scans(sup, str(tmp_path / "scans"), "/models/gold", "CBCT") + + tool, params = sup.calls[0] + assert tool == "ASO" + assert params["automation"] == "Fully-Automated" + assert params["modality"] == "CBCT" + assert params["output_suffix"] == "Or" + + +# Moved from the single AREG suite when AREG became three tools. These drive +# `main()` with t1/t2, which is this tool's signature, not the orchestrator's; +# they were sitting in AREG_IOSCBCT's file only because the three used to share +# one. The three that crossed `modality` with `automation` are not ported: the +# split replaced that argument with the choice of which tool you call. + +class TestArgumentRules: + def _main(self, tmp_path=None, **overrides): + """Drive `main()` WITH a supervisor unless a test says otherwise. + + Every server run has one, and these tests are about the argument rules + rather than about reaching the other tools -- without a supervisor the + structural refusal fires first and hides the rule under test. The + absent-supervisor case is `TestTheToolsItDrives`. + """ + arguments = { + "automation": catalogs.AUTOMATION_SEMI, + "t1": "/nonexistent/t1", + "t2": "/nonexistent/t2", + "sup": FakeSup(tmp_path or "/tmp/areg-rules"), + } + arguments.update(overrides) + return dispatch.main(**arguments) + + def test_an_empty_region_selection_is_refused(self): + with pytest.raises(ToolInputError, match="at least one anatomical region"): + self._main(cbct_regions={name: False for name in catalogs.REGION_CHOICES}) + + def test_semi_automated_cbct_without_masks_is_refused(self): + with pytest.raises(ToolInputError, match="masks you provide"): + self._main() + + def test_the_oriented_mode_without_a_reference_is_refused(self): + with pytest.raises(ToolInputError, match="orientation reference"): + self._main(automation=catalogs.AUTOMATION_ORIENTED) + + def test_a_suffix_that_is_a_path_is_refused(self): + with pytest.raises(ToolInputError, match="name fragment"): + self._main(t1_masks="/nonexistent/masks", output_suffix="../escape") + + def test_the_rules_run_before_anything_is_read(self): + """Every case above passes paths that do not exist. Reaching the file + system would raise something else entirely, which is the point: an + unusable request comes back in a second, not after an hour.""" + assert not os.path.exists("/nonexistent/t1") + + def test_a_mode_needing_a_tool_says_so_when_there_is_no_supervisor(self): + """Nothing about the request is wrong -- there is simply no way to reach + the other tool. So the message names the mode that DOES work rather than + an argument to change, because "deploy a tool" is not something the + person who sent the request can act on.""" + with pytest.raises(SupervisorRequired) as raised: + tools.require(None, "AMASSS", "Fully-Automated CBCT registration") + + message = str(raised.value) + assert "Fully-Automated CBCT registration" in message + assert "AMASSS" in message + assert "Semi-Automated" in message # the mode that does work + assert "t1_masks" in message + + def test_a_supervisor_makes_the_same_mode_acceptable(self): + assert tools.require(FakeSup("/tmp"), "AMASSS", "anything") is None + diff --git a/tools/AREG/tests/test_run.py b/tools/AREG/AREG_IOS/tests/test_run.py similarity index 54% rename from tools/AREG/tests/test_run.py rename to tools/AREG/AREG_IOS/tests/test_run.py index d67c753..01c767f 100644 --- a/tools/AREG/tests/test_run.py +++ b/tools/AREG/AREG_IOS/tests/test_run.py @@ -1,17 +1,10 @@ -"""AREG's unit tests: no GPU, no weights, no network. +"""AREG_IOS's unit tests: no GPU, no weights, no network. -The IOS patch network is stubbed (it needs a checkpoint and pytorch3d); -everything around it runs for real, including the CBCT engine end to end -against synthetic volumes -- elastix is fast enough on a 48^3 phantom that the -registration itself is tested rather than mocked. +Split out of the single `tools/AREG/tests/test_run.py` AREG had before it +became three tools; see AREG_CBCT/tests/test_run.py for why none of them ran. -The four tools AREG drives are stood in for by a fake supervisor, which is all -a tool can see of them: five members, duck-typed, nothing imported across -venvs. - -Each test that pins a fixed defect says which one in its docstring. - - cd tools/AREG && uv run pytest +The patch network is stubbed (it needs a checkpoint and pytorch3d); everything +around it runs for real. """ import json @@ -21,9 +14,14 @@ import numpy as np import pytest import SimpleITK as sitk +import vtk -from sadt_areg import catalogs, dispatch, pairing, run, tools -from sadt_areg.errors import SupervisorRequired, ToolInputError +from sadt_areg_ios import butterfly, icp, mgl, orientation, postprocess, surfaces +from sadt_areg_ios import butterfly as butterfly_module +from sadt_areg_ios import dispatch, landmarks as landmark_files, run, tools +from sadt_areg_ios import pipeline as ios_pipeline +from sadt_areg_common import catalogs, pairing +from sadt_areg_common.errors import SupervisorRequired, ToolInputError class FakeSup: @@ -56,10 +54,6 @@ def log(self, message): self.messages.append((None, message)) -# --------------------------------------------------------------------------- -# Fixtures -# --------------------------------------------------------------------------- - def _phantom(size=48, seed=0, spacing=0.8, origin=(-140.0, -90.0, 60.0)): """A textured volume with an origin far from zero. @@ -110,450 +104,27 @@ def _full_mask(image): return mask -# --------------------------------------------------------------------------- -# Pairing -# --------------------------------------------------------------------------- - -class TestPairing: - def test_the_timepoint_token_is_what_pairs_two_folders(self): - assert pairing.patient_stem("P1_T1_scan.nii.gz") == "P1" - assert pairing.patient_stem("P1_T2.nii.gz") == "P1" - assert pairing.patient_stem("T1_01_U_Seg.vtk") == "01_U" - - def test_a_name_with_a_dot_in_it_survives(self): - """FIX: `basename.split(".")[0]` truncated at the FIRST dot, so - `P1.2_scan.nii.gz` and `P1.7_scan.nii.gz` were the same patient.""" - assert pairing.patient_stem("P1.2_scan.nii.gz") == "P1_2" - assert pairing.patient_stem("P1.7_scan.nii.gz") == "P1_7" - - def test_two_subfolders_may_hold_the_same_file_name(self, tmp_path): - """FIX: `GetPatients` keyed on the base name, so `scan.nii.gz` under - two subject folders became one patient -- in the working dict and again - in the flat output folder.""" - image = _phantom(size=16) - for subject in ("A", "B"): - _write(image, str(tmp_path / "T1" / subject / "scan_T1.nii.gz")) - _write(image, str(tmp_path / "T2" / subject / "scan_T2.nii.gz")) - - matched = pairing.pair(str(tmp_path / "T1"), str(tmp_path / "T2"), "Reg") - assert sorted(matched.matched) == [os.path.join("A", "scan"), os.path.join("B", "scan")] - - def test_a_subject_present_at_one_timepoint_only_is_named(self, tmp_path): - image = _phantom(size=16) - _write(image, str(tmp_path / "T1" / "P1_T1.nii.gz")) - _write(image, str(tmp_path / "T1" / "P2_T1.nii.gz")) - _write(image, str(tmp_path / "T2" / "P1_T2.nii.gz")) - - matched = pairing.pair(str(tmp_path / "T1"), str(tmp_path / "T2"), "Reg") - assert list(matched.matched) == ["P1"] - assert matched.unmatched_report()["t1_without_t2"] == ["P2"] - - def test_a_previous_run_is_not_re_registered(self, tmp_path): - """FIX: `P1_CB_Reg.nii.gz` sorts before `P1_scan.nii.gz`, so a second - run on the same folder took the first run's output as its input.""" - image = _phantom(size=16) - _write(image, str(tmp_path / "T2" / "P1_CB_Reg.nii.gz")) - _write(image, str(tmp_path / "T2" / "P1_scan_T2.nii.gz")) - - found = pairing.discover(str(tmp_path / "T2"), "Reg") - assert found["P1"].endswith("P1_scan_T2.nii.gz") - - def test_the_suffix_is_matched_as_a_token_not_a_substring(self): - """A patient called Regina is not a previous run of suffix 'Reg'.""" - assert pairing.is_previous_output("P1_Reg.nii.gz", "Reg") - assert pairing.is_previous_output("P1_Reg_transform.tfm", "Reg") - assert not pairing.is_previous_output("Regina_T1.nii.gz", "Reg") - - -class TestMaskDiscovery: - def test_a_cbct_in_the_name_is_not_a_cranial_base_mask(self, tmp_path): - """FIX: the region test was `"cb" in basename.lower()`, which makes - every file whose name contains CBCT a cranial-base mask.""" - image = _phantom(size=16) - _write(image, str(tmp_path / "P1_CBCT_seg.nii.gz")) - assert pairing.discover_masks(str(tmp_path), "CB") == {} - - def test_a_mask_keys_to_the_patient_its_scan_keys_to(self, tmp_path): - image = _phantom(size=16) - _write(image, str(tmp_path / "masks" / "P1_T1_MAND_seg.nii.gz")) - found = pairing.discover_masks(str(tmp_path / "masks"), "MAND") - assert list(found) == ["P1"] - - def test_a_mask_has_to_say_both_what_it_is_and_what_it_covers(self, tmp_path): - image = _phantom(size=16) - _write(image, str(tmp_path / "P1_MAND.nii.gz")) # no seg token - _write(image, str(tmp_path / "P2_seg.nii.gz")) # no region token - _write(image, str(tmp_path / "P3_MAND_seg.nii.gz")) # both - assert list(pairing.discover_masks(str(tmp_path), "MAND")) == ["P3"] - - def test_amasss_output_names_are_recognised(self, tmp_path): - """What the Fully-Automated path actually has to read back.""" - image = _phantom(size=16) - _write(image, str(tmp_path / "P1_T1_scan_seg_MANDMASK.nii.gz")) - found = pairing.discover_masks(str(tmp_path), "MAND") - assert list(found) == ["P1"] - - -# --------------------------------------------------------------------------- -# The CBCT engine -# --------------------------------------------------------------------------- - -elastix = pytest.importorskip( - "sadt_areg.cbct.elastix", reason="the CBCT engine needs itk-elastix" -) -try: - elastix.check_dependencies() -except Exception as exc: # pragma: no cover - depends on the deployment - pytest.skip(f"itk-elastix unavailable: {exc}", allow_module_level=True) - - -class TestElastix: - def test_the_centre_of_rotation_is_honoured(self): - """FIX, and the headline one: `MatrixRetrieval` read elastix's three - angles and its translation and DROPPED its CenterOfRotationPoint, so - the SimpleITK transform it built rotated about the physical origin - instead. The two differ by (I - R)c -- invisible on centred data, - metres-per-radian off the further the scan sits from the origin. - - Measured here against a known ground truth on a phantom whose origin is - at (-140, -90, 60) mm: the centre-dropping version lands several - millimetres out, this one lands within a fifth of a voxel. - """ - fixed = _phantom(size=48) - moving, truth = _moved(fixed) - - transform = elastix.register(fixed, moving) - - # The transform elastix returns maps FIXED space to MOVING space, which - # is the direction sitk's resampler consumes -- and here that is `truth` - # itself, since `moving` is `fixed` displaced by it. - probes = [ - fixed.TransformContinuousIndexToPhysicalPoint([float(v) for v in index]) - for index in ([0, 0, 0], [24, 24, 24], [47, 47, 47], [4, 40, 12]) - ] - errors = [ - np.linalg.norm( - np.array(transform.TransformPoint(p)) - np.array(truth.TransformPoint(p)) - ) - for p in probes - ] - assert max(errors) < 0.2, f"registration is {max(errors):.3f} mm from the truth" - - # And the shipped behaviour, reconstructed, is not: dropping the centre - # is a real displacement, not a rounding difference. - without_centre = sitk.Euler3DTransform() - without_centre.SetRotation(*transform.GetParameters()[:3]) - without_centre.SetTranslation(transform.GetParameters()[3:6]) - dropped = max( - np.linalg.norm( - np.array(without_centre.TransformPoint(p)) - np.array(truth.TransformPoint(p)) - ) - for p in probes - ) - assert dropped > 1.0, "the phantom is too centred for this test to mean anything" - - def test_a_mask_of_a_different_size_is_refused(self): - """FIX: `fixed_seg.SetOrigin(fixed_image.GetOrigin())` forced the two - into agreement unconditionally, so a mask that is genuinely a different - sampling of the patient was applied several millimetres off in - silence.""" - image = _phantom(size=24) - mask = _full_mask(_phantom(size=20)) - with pytest.raises(elastix.RegistrationError, match="not the same sampling"): - elastix.apply_mask(image, mask) - - def test_a_label_the_mask_does_not_hold_is_refused(self): - """FIX: `if label is not None and label in np.unique(array)` fell - through to using the WHOLE mask when the label was absent -- asking for - label 4 of a two-label mask registered on everything and reported - success.""" - image = _phantom(size=24) - with pytest.raises(elastix.RegistrationError, match="no label 4"): - elastix.apply_mask(image, _full_mask(image), label=4) - - def test_a_multi_label_mask_used_whole_says_so(self): - image = _phantom(size=24) - array = np.zeros(sitk.GetArrayViewFromImage(image).shape, np.uint8) - array[4:12] = 1 - array[12:18] = 2 - mask = sitk.GetImageFromArray(array) - mask.CopyInformation(image) - - _masked, note = elastix.apply_mask(image, mask, label=0) - assert note and "several labels" in note - - def test_masking_keeps_only_the_masked_region(self): - image = _phantom(size=24) - array = np.zeros(sitk.GetArrayViewFromImage(image).shape, np.uint8) - array[6:14, 6:14, 6:14] = 1 - mask = sitk.GetImageFromArray(array) - mask.CopyInformation(image) - - masked, _note = elastix.apply_mask(image, mask, label=1) - kept = sitk.GetArrayViewFromImage(masked) - assert kept[6:14, 6:14, 6:14].any() - assert not kept[:6].any() and not kept[14:].any() - - def test_the_masked_image_never_reaches_the_disk(self, tmp_path, monkeypatch): - """FIX: `MaskedImage` wrote `/fixed_image_masked.nii.gz` -- one - FIXED name shared by every patient of a run and by every concurrent - request being served. - - Run from an empty directory rather than by patching a setting: the - masked image is held in memory now, so there is no temp directory to - point anywhere, and "wrote nothing at all" is the stronger claim. - """ - monkeypatch.chdir(tmp_path) - fixed = _phantom(size=32) - moving, _truth = _moved(fixed) - masked, _note = elastix.apply_mask(fixed, _full_mask(fixed)) - elastix.register(masked, moving) - assert list(tmp_path.iterdir()) == [] - - -# --------------------------------------------------------------------------- -# The CBCT mode, end to end -# --------------------------------------------------------------------------- - -class TestSemiAutomatedCBCT: - @staticmethod - def _cohort(tmp_path, subjects=("P1",)): - for subject in subjects: - fixed = _phantom(size=48, seed=abs(hash(subject)) % 100) - moving, _truth = _moved(fixed) - _write(fixed, str(tmp_path / "T1" / f"{subject}_T1_scan.nii.gz")) - _write(moving, str(tmp_path / "T2" / f"{subject}_T2_scan.nii.gz")) - _write(_full_mask(fixed), str(tmp_path / "masks" / f"{subject}_T1_CB_seg.nii.gz")) - return tmp_path - - def test_a_run_writes_a_registered_scan_a_transform_and_a_report(self, tmp_path): - self._cohort(tmp_path) - run = dispatch.register( - t1_path=str(tmp_path / "T1"), - t2_path=str(tmp_path / "T2"), - t1_masks_path=str(tmp_path / "masks"), - modality=catalogs.MODALITY_CBCT, - automation=catalogs.AUTOMATION_SEMI, - regions=["Cranial base"], - output_dir=str(tmp_path / "out"), - ) - assert run.succeeded == ["P1"] - produced = sorted( - os.path.relpath(os.path.join(directory, name), run.output_dir) - for directory, _, names in os.walk(run.output_dir) - for name in names - ) - assert produced == [ - "AREG_report.json", - os.path.join("CB", "P1_CB_Reg.nii.gz"), - os.path.join("CB", "P1_CB_Reg_transform.tfm"), - ] - - with open(os.path.join(run.output_dir, "AREG_report.json")) as handle: - report = json.load(handle) - assert report["summary"] == {"patients": 1, "registered": 1, "failed": 0} - assert report["patients"]["P1"]["regions"]["CB"]["status"] == "ok" - - def test_the_written_transform_moves_the_t2_onto_the_t1(self, tmp_path): - """The direction is asserted, not assumed: a transform written the - other way round still loads and still transforms, so nothing else in - the archive would show it. - - It is also what the original could NOT give you: it registered against - a recentred copy of the T2 living in a `_Center` folder next to the - caller's own data, and wrote the transform between the T1 and THAT -- - a volume the caller never received. - """ - self._cohort(tmp_path) - run = dispatch.register( - t1_path=str(tmp_path / "T1"), - t2_path=str(tmp_path / "T2"), - t1_masks_path=str(tmp_path / "masks"), - modality=catalogs.MODALITY_CBCT, - automation=catalogs.AUTOMATION_SEMI, - regions=["Cranial base"], - output_dir=str(tmp_path / "out"), - ) - transform = sitk.ReadTransform( - os.path.join(run.output_dir, "CB", "P1_CB_Reg_transform.tfm") - ) - registered = sitk.ReadImage(os.path.join(run.output_dir, "CB", "P1_CB_Reg.nii.gz")) - moving = sitk.ReadImage(str(tmp_path / "T2" / "P1_T2_scan.nii.gz")) - - # Resampling the ORIGINAL T2 with the written transform reproduces the - # registered volume the archive holds. It could only do that if the - # transform lives in the space of the file the caller sent. - resampler = sitk.ResampleImageFilter() - resampler.SetReferenceImage(moving) - resampler.SetTransform(transform) - resampler.SetInterpolator(sitk.sitkLinear) - reproduced = sitk.Cast(resampler.Execute(moving), sitk.sitkInt16) - assert np.array_equal( - sitk.GetArrayViewFromImage(reproduced), sitk.GetArrayViewFromImage(registered) - ) - - def test_a_subject_with_no_mask_is_reported_and_the_batch_goes_on(self, tmp_path): - self._cohort(tmp_path, subjects=("P1", "P2")) - os.remove(str(tmp_path / "masks" / "P2_T1_CB_seg.nii.gz")) - - run = dispatch.register( - t1_path=str(tmp_path / "T1"), - t2_path=str(tmp_path / "T2"), - t1_masks_path=str(tmp_path / "masks"), - modality=catalogs.MODALITY_CBCT, - automation=catalogs.AUTOMATION_SEMI, - regions=["Cranial base"], - output_dir=str(tmp_path / "out"), - ) - assert run.succeeded == ["P1"] - failure = run.patients["P2"]["regions"]["CB"] - assert failure["status"] == "failed" - assert "no Cranial base mask" in failure["reason"] - - def test_no_pair_at_all_is_a_422_naming_the_pairing_rule(self, tmp_path): - image = _phantom(size=16) - _write(image, str(tmp_path / "T1" / "alpha_T1.nii.gz")) - _write(image, str(tmp_path / "T2" / "beta_T2.nii.gz")) - with pytest.raises(ToolInputError, match="paired by name"): - dispatch.register( - t1_path=str(tmp_path / "T1"), - t2_path=str(tmp_path / "T2"), - t1_masks_path=str(tmp_path / "T1"), - modality=catalogs.MODALITY_CBCT, - automation=catalogs.AUTOMATION_SEMI, - regions=["Cranial base"], - output_dir=str(tmp_path / "out"), - ) - - -# --------------------------------------------------------------------------- -# Cross-argument rules -- every one of these is a 422 before a file is read -# --------------------------------------------------------------------------- - -class TestArgumentRules: - def _main(self, tmp_path=None, **overrides): - """Drive `main()` WITH a supervisor unless a test says otherwise. - - Every server run has one, and these tests are about the argument rules - rather than about reaching the other tools -- without a supervisor the - structural refusal fires first and hides the rule under test. The - absent-supervisor case is `TestTheToolsItDrives`. - """ - arguments = { - "modality": catalogs.MODALITY_CBCT, - "automation": catalogs.AUTOMATION_SEMI, - "t1": "/nonexistent/t1", - "t2": "/nonexistent/t2", - "sup": FakeSup(tmp_path or "/tmp/areg-rules"), - } - arguments.update(overrides) - return dispatch.main(**arguments) - - def test_a_mode_a_modality_does_not_have_is_refused(self): - with pytest.raises(ToolInputError, match="not a mode IOS has"): - self._main( - modality=catalogs.MODALITY_IOS, automation=catalogs.AUTOMATION_ORIENTED - ) - - def test_an_empty_region_selection_is_refused(self): - with pytest.raises(ToolInputError, match="at least one anatomical region"): - self._main(cbct_regions={name: False for name in catalogs.REGION_CHOICES}) - - def test_semi_automated_cbct_without_masks_is_refused(self): - with pytest.raises(ToolInputError, match="masks you provide"): - self._main() - - def test_the_oriented_mode_without_a_reference_is_refused(self): - with pytest.raises(ToolInputError, match="orientation reference"): - self._main(automation=catalogs.AUTOMATION_ORIENTED) - - def test_the_palate_patch_without_its_checkpoint_is_refused(self): - with pytest.raises(ToolInputError, match="patch-prediction checkpoint"): - self._main(modality=catalogs.MODALITY_IOS, automation=catalogs.AUTOMATION_SEMI) - - def test_the_mucogingival_patch_predicts_its_own_landmarks(self): - """Sending nothing is the ORDINARY case: the landmarks are predicted by - the landmark tool. Reaching the file system is the pass condition -- - the rules let the request through.""" - with pytest.raises(Exception) as raised: - self._main( - modality=catalogs.MODALITY_IOS, - automation=catalogs.AUTOMATION_SEMI, - ios_patch=catalogs.PATCH_MGL, - ) - assert not isinstance(raised.value, ToolInputError), str(raised.value) - - def test_without_a_way_to_reach_the_landmark_tool_it_asks_for_the_landmarks(self): - """A deployment may legitimately not carry ALI, and must then say which - field to fill rather than fail from somewhere inside another tool.""" - with pytest.raises(SupervisorRequired, match="mgl_landmarks"): - self._main( - sup=None, - modality=catalogs.MODALITY_IOS, - automation=catalogs.AUTOMATION_SEMI, - ios_patch=catalogs.PATCH_MGL, - ) - - def test_the_mucogingival_patch_runs_without_a_registration_model(self): - """Reaching the file system is the pass condition: the rules let it - through, which is what proves the palatal checkpoint is not required.""" - with pytest.raises(Exception) as raised: - self._main( - modality=catalogs.MODALITY_IOS, - automation=catalogs.AUTOMATION_SEMI, - ios_patch=catalogs.PATCH_MGL, - mgl_landmarks="/nonexistent/landmarks", - ) - assert "registration_model" not in str(raised.value) - assert "checkpoint" not in str(raised.value) - - def test_a_negative_patch_height_is_refused(self): - with pytest.raises(ToolInputError, match="cannot be negative"): - self._main( - modality=catalogs.MODALITY_IOS, - automation=catalogs.AUTOMATION_SEMI, - ios_patch=catalogs.PATCH_MGL, - mgl_landmarks="/nonexistent/landmarks", - mgl_patch_height=-1.0, - ) - - def test_a_suffix_that_is_a_path_is_refused(self): - with pytest.raises(ToolInputError, match="name fragment"): - self._main(t1_masks="/nonexistent/masks", output_suffix="../escape") - - def test_the_rules_run_before_anything_is_read(self): - """Every case above passes paths that do not exist. Reaching the file - system would raise something else entirely, which is the point: an - unusable request comes back in a second, not after an hour.""" - assert not os.path.exists("/nonexistent/t1") - - -class TestMaskLookup: - def test_a_mask_under_amasss_own_output_folder_still_finds_its_scan(self, tmp_path): - """AMASSS writes one `__SegOut/` directory per scan, so a mask - discovered under it keys to `P1_seg_SegOut/P1` while its scan keys to - `P1`. Without the leaf fallback every Fully-Automated run would report - 'no mask for this subject' for every subject.""" - from sadt_areg.cbct import pipeline as cbct_pipeline - - image = _phantom(size=16) - _write( - image, - str(tmp_path / "P1_T1_scan_seg_SegOut" / "P1_T1_scan_seg_MANDMASK.nii.gz"), - ) - found = cbct_pipeline.find_masks([str(tmp_path)], "MAND", scan_keys=["P1"]) - assert os.path.basename(found["P1"]) == "P1_T1_scan_seg_MANDMASK.nii.gz" +def _grid_mesh(rows=12, columns=12, spacing=1.0): + """A flat triangulated grid, the smallest thing with a real adjacency.""" + points = vtk.vtkPoints() + for row in range(rows): + for column in range(columns): + points.InsertNextPoint(column * spacing, row * spacing, 0.0) - def test_an_ambiguous_leaf_is_not_guessed(self, tmp_path): - """Two subjects genuinely called P1 in different folders must not - borrow each other's mask.""" - from sadt_areg.cbct import pipeline as cbct_pipeline + triangles = vtk.vtkCellArray() + for row in range(rows - 1): + for column in range(columns - 1): + a = row * columns + column + for corners in ((a, a + 1, a + columns), (a + 1, a + columns + 1, a + columns)): + triangle = vtk.vtkTriangle() + for index, corner in enumerate(corners): + triangle.GetPointIds().SetId(index, corner) + triangles.InsertNextCell(triangle) - image = _phantom(size=16) - for site in ("siteA", "siteB"): - _write(image, str(tmp_path / site / "P1_T1_MAND_seg.nii.gz")) - found = cbct_pipeline.find_masks([str(tmp_path)], "MAND", scan_keys=["P1"]) - assert "P1" not in found + mesh = vtk.vtkPolyData() + mesh.SetPoints(points) + mesh.SetPolys(triangles) + return mesh class TestCheckpointLookup: @@ -561,7 +132,7 @@ def test_the_checkpoint_is_found_inside_the_bundle_folder(self, tmp_path): """A `server_selectable` name resolves to the hosted ENTRY, and the published AREG bundle is a folder -- so what reaches the predictor is a directory, not the .ckpt the network loads.""" - from sadt_areg.ios import butterfly + from sadt_areg_ios import butterfly bundle = tmp_path / "AREG_model" (bundle / "nested").mkdir(parents=True) @@ -569,7 +140,7 @@ def test_the_checkpoint_is_found_inside_the_bundle_folder(self, tmp_path): assert butterfly.find_checkpoint(str(bundle)).endswith("patch.ckpt") def test_a_bundle_with_no_checkpoint_names_the_setup_script(self, tmp_path): - from sadt_areg.ios import butterfly + from sadt_areg_ios import butterfly (tmp_path / "empty").mkdir() with pytest.raises(ToolInputError, match="setup-models.sh"): @@ -577,7 +148,7 @@ def test_a_bundle_with_no_checkpoint_names_the_setup_script(self, tmp_path): def test_several_checkpoints_are_a_422_naming_them(self, tmp_path): """Which weights registered a patient must never be a surprise.""" - from sadt_areg.ios import butterfly + from sadt_areg_ios import butterfly bundle = tmp_path / "two" bundle.mkdir() @@ -587,153 +158,6 @@ def test_several_checkpoints_are_a_422_naming_them(self, tmp_path): butterfly.find_checkpoint(str(bundle)) -class TestTheToolsItDrives: - """The seam is a contract, and a rename on the other side of one is exactly - the kind of drift nothing else would catch.""" - - def test_a_mode_needing_a_tool_says_so_when_there_is_no_supervisor(self): - """Nothing about the request is wrong -- there is simply no way to reach - the other tool. So the message names the mode that DOES work rather than - an argument to change, because "deploy a tool" is not something the - person who sent the request can act on.""" - with pytest.raises(SupervisorRequired) as raised: - tools.require(None, "AMASSS", "Fully-Automated CBCT registration") - - message = str(raised.value) - assert "Fully-Automated CBCT registration" in message - assert "AMASSS" in message - assert "Semi-Automated" in message # the mode that does work - assert "t1_masks" in message # and the argument that carries it - - def test_a_supervisor_makes_the_same_mode_acceptable(self): - assert tools.require(FakeSup("/tmp"), "AMASSS", "anything") is None - - def test_the_mask_request_names_amasss_arguments(self, tmp_path): - """AREG sends structure CODES. The packaged AMASSS publishes codes as its - `choices`, so the display-name translation the in-process version needed - is gone rather than restated -- and this is what says so.""" - planted = tmp_path / "masks" - planted.mkdir() - sup = FakeSup(tmp_path, {"AMASSS": lambda params: planted}) - - tools.segment_masks(sup, str(tmp_path / "t1"), "/models/AMASSS", ["CBMASK"]) - - tool, params = sup.calls[0] - assert tool == "AMASSS" - assert params["structures"] == ["CBMASK"] - # One binary file per structure: `find_masks` looks each region up by - # name, and a merged multi-label volume makes every region resolve to - # the same file. - assert params["merge"] == ["SEPARATE"] - assert params["generate_surface"] is False - assert set(params) >= {"scans", "model", "output_dir"} - - def test_the_landmark_request_asks_for_mucogingival_alone(self, tmp_path): - """Alone, because it is the one network ALI leaves off by default and - the crown networks would each cost another pass over every mesh.""" - planted = tmp_path / "mg" - planted.mkdir() - sup = FakeSup(tmp_path, {"ALI": lambda params: planted}) - - tools.predict_mucogingival(sup, str(tmp_path / "meshes")) - - tool, params = sup.calls[0] - assert tool == "ALI" - assert params["ios_networks"] == ["Mucogingival"] - assert params["prediction_ID"] == "MG_Pred" - # Not named, so ALI picks the bundle matching the input itself. - assert "model" not in params - - def test_the_orientation_request_is_the_nested_one(self, tmp_path): - """ASO is itself supervised for CBCT, so this is AREG -> ASO -> ALI: - three tools and three venvs deep. Whatever supplies `sup` supplies the - callee's too; nothing here arranges that.""" - planted = tmp_path / "oriented" - planted.mkdir() - sup = FakeSup(tmp_path, {"ASO": lambda params: planted}) - - tools.orient_scans(sup, str(tmp_path / "scans"), "/models/gold", "CBCT") - - tool, params = sup.calls[0] - assert tool == "ASO" - assert params["automation"] == "Fully-Automated" - assert params["modality"] == "CBCT" - assert params["output_suffix"] == "Or" - - def test_every_tool_is_named_by_string(self): - """`sup.run("ASO", ...)`, never `sup.ASO(...)`. A typo in a string is - greppable and tools.py is the whole call graph; a typo in an attribute - is an AttributeError an hour into a job.""" - source = open(tools.__file__, encoding="utf-8").read() - assert 'sup.run("' in source - for tool in ("AMASSS", "ASO", "Crown_Seg", "ALI"): - assert f'"{tool}"' in source, tool - - @pytest.mark.skipif( - not all(__import__("sadt_testkit").is_built(t) - for t in ("AMASSS", "ASO", "Crown_Seg", "ALI")), - reason="run `uv sync` in the four tools AREG drives", - ) - def test_the_arguments_it_sends_are_the_arguments_they_publish(self): - """Against the REAL schemas, out of process. This is the test that - catches a sibling renaming an argument -- the failure would otherwise - land an hour into a chain, inside another tool.""" - from sadt_testkit import tool_schema - - assert set(tool_schema("AMASSS")["arguments"]) >= { - "scans", "model", "output_dir", "structures", "merge", - "prediction_ID", "generate_surface", - } - assert set(tool_schema("ASO")["arguments"]) >= { - "input", "reference", "output_dir", "modality", "automation", - "output_suffix", - } - assert set(tool_schema("Crown_Seg")["arguments"]) >= { - "meshes", "output_dir", "suffix", "model", - } - ali = tool_schema("ALI") - assert set(ali["arguments"]) >= { - "input", "model", "output_dir", "ios_networks", "prediction_ID", - } - # And the network AREG asks for by name is one ALI actually offers. - assert "Mucogingival" in ali["arguments"]["ios_networks"]["choices"] - - -# --------------------------------------------------------------------------- -# The IOS engine, everything except the network -# --------------------------------------------------------------------------- - -vtk = pytest.importorskip("vtk", reason="the IOS engine needs VTK") - -from sadt_areg import landmarks as landmark_files # noqa: E402 -from sadt_areg.ios import butterfly as butterfly_module # noqa: E402 -from sadt_areg.ios import icp, mgl, orientation, postprocess, surfaces # noqa: E402 -from sadt_areg.ios import pipeline as ios_pipeline # noqa: E402 - - -def _grid_mesh(rows=12, columns=12, spacing=1.0): - """A flat triangulated grid, the smallest thing with a real adjacency.""" - points = vtk.vtkPoints() - for row in range(rows): - for column in range(columns): - points.InsertNextPoint(column * spacing, row * spacing, 0.0) - - triangles = vtk.vtkCellArray() - for row in range(rows - 1): - for column in range(columns - 1): - a = row * columns + column - for corners in ((a, a + 1, a + columns), (a + 1, a + columns + 1, a + columns)): - triangle = vtk.vtkTriangle() - for index, corner in enumerate(corners): - triangle.GetPointIds().SetId(index, corner) - triangles.InsertNextCell(triangle) - - mesh = vtk.vtkPolyData() - mesh.SetPoints(points) - mesh.SetPolys(triangles) - return mesh - - class TestJaws: def test_a_mesh_that_does_not_say_its_jaw_is_not_a_lower_arch(self): """FIX: `Sort` split on `isLowerUpper(file, "Upper")` and treated @@ -1216,7 +640,7 @@ def test_the_offset_is_read_rather_than_the_translation(self, tmp_path): class TestPatchCloud: def test_a_patch_selecting_nothing_says_so(self): - from sadt_areg.ios import butterfly + from sadt_areg_ios import butterfly from vtk.util.numpy_support import numpy_to_vtk mesh = _grid_mesh() @@ -1229,7 +653,7 @@ def test_a_patch_selecting_nothing_says_so(self): butterfly.patch_cloud(mesh) def test_the_cloud_holds_exactly_the_patch_points(self): - from sadt_areg.ios import butterfly + from sadt_areg_ios import butterfly from vtk.util.numpy_support import numpy_to_vtk mesh = _grid_mesh() @@ -1320,3 +744,89 @@ def test_the_mgl_run_writes_the_mandibles_only(self, tmp_path): assert entry["jaws"] == ["Lower"] assert len(entry["outputs"]) == 3 # two meshes plus the transform assert all("Upper" not in name for name in entry["outputs"]) + + +# Moved from AREG_IOSCBCT's suite: this package is the one that makes +# the call, so this is where a change to it should fail. +def test_the_landmark_request_asks_for_mucogingival_alone(tmp_path): + """Alone, because it is the one network ALI leaves off by default and + the crown networks would each cost another pass over every mesh.""" + planted = tmp_path / "mg" + planted.mkdir() + sup = FakeSup(tmp_path, {"ALI_IOS": lambda params: planted}) + + tools.predict_mucogingival(sup, str(tmp_path / "meshes")) + + tool, params = sup.calls[0] + assert tool == "ALI_IOS" + assert params["networks"] == ["Mucogingival"] + assert params["prediction_ID"] == "MG_Pred" + # Not named, so ALI picks the bundle matching the input itself. + assert "model" not in params + + +# Moved from the single AREG suite when AREG became three tools. These drive +# `main()` with t1/t2, which is this tool's signature, not the orchestrator's; +# they were sitting in AREG_IOSCBCT's file only because the three used to share +# one. The three that crossed `modality` with `automation` are not ported: the +# split replaced that argument with the choice of which tool you call. + +class TestArgumentRules: + def _main(self, tmp_path=None, **overrides): + """Drive `main()` WITH a supervisor unless a test says otherwise. + + Every server run has one, and these tests are about the argument rules + rather than about reaching the other tools -- without a supervisor the + structural refusal fires first and hides the rule under test. The + absent-supervisor case is `TestTheToolsItDrives`. + """ + arguments = { + "automation": catalogs.AUTOMATION_SEMI, + "t1": "/nonexistent/t1", + "t2": "/nonexistent/t2", + "sup": FakeSup(tmp_path or "/tmp/areg-rules"), + } + arguments.update(overrides) + return dispatch.main(**arguments) + + def test_the_mucogingival_patch_predicts_its_own_landmarks(self): + """Sending nothing is the ORDINARY case: the landmarks are predicted by + the landmark tool. Reaching the file system is the pass condition -- + the rules let the request through.""" + with pytest.raises(Exception) as raised: + self._main( + automation=catalogs.AUTOMATION_SEMI, + ios_patch=catalogs.PATCH_MGL, + ) + assert not isinstance(raised.value, ToolInputError), str(raised.value) + + def test_without_a_way_to_reach_the_landmark_tool_it_asks_for_the_landmarks(self): + """A deployment may legitimately not carry ALI, and must then say which + field to fill rather than fail from somewhere inside another tool.""" + with pytest.raises(SupervisorRequired, match="mgl_landmarks"): + self._main( + sup=None, + automation=catalogs.AUTOMATION_SEMI, + ios_patch=catalogs.PATCH_MGL, + ) + + def test_the_mucogingival_patch_runs_without_a_registration_model(self): + """Reaching the file system is the pass condition: the rules let it + through, which is what proves the palatal checkpoint is not required.""" + with pytest.raises(Exception) as raised: + self._main( + automation=catalogs.AUTOMATION_SEMI, + ios_patch=catalogs.PATCH_MGL, + mgl_landmarks="/nonexistent/landmarks", + ) + assert "registration_model" not in str(raised.value) + assert "checkpoint" not in str(raised.value) + + def test_a_negative_patch_height_is_refused(self): + with pytest.raises(ToolInputError, match="cannot be negative"): + self._main( + automation=catalogs.AUTOMATION_SEMI, + ios_patch=catalogs.PATCH_MGL, + mgl_landmarks="/nonexistent/landmarks", + mgl_patch_height=-1.0, + ) diff --git a/tools/AREG/AREG_IOSCBCT/tests/test_run.py b/tools/AREG/AREG_IOSCBCT/tests/test_run.py new file mode 100644 index 0000000..6d36f24 --- /dev/null +++ b/tools/AREG/AREG_IOSCBCT/tests/test_run.py @@ -0,0 +1,138 @@ +"""AREG_IOSCBCT's unit tests: no GPU, no weights, no network. + +Split out of the single `tools/AREG/tests/test_run.py` AREG had before it +became three tools; see AREG_CBCT/tests/test_run.py for why none of them ran. + +The tools this one drives are stood in for by a fake supervisor, which is all a +tool can see of them: five members, duck-typed, nothing imported across venvs. +""" + +import json +import os +from pathlib import Path + +import numpy as np +import pytest +import SimpleITK as sitk + +from sadt_areg_ioscbct import dispatch, run, tools +from sadt_areg_common import catalogs, pairing +from sadt_areg_common.errors import SupervisorRequired, ToolInputError + + +class FakeSup: + """A supervisor, as a tool sees one. Records what it was asked for. + + `outputs` maps a tool name to a callable taking the parameters it was sent + and returning the directory it "produced", so a test can plant results + without any of the real tools existing. + """ + + def __init__(self, tmp_path, outputs=None): + self.out = Path(tmp_path) / "out" + self.tmp = Path(tmp_path) / "tmp" + self.tmp.mkdir(parents=True, exist_ok=True) + self.outputs = outputs or {} + self.calls = [] + self.messages = [] + + def run(self, tool, **params): + self.calls.append((tool, params)) + maker = self.outputs.get(tool) + if maker is None: + raise AssertionError(f"nothing planted for {tool!r} in this test") + return Path(maker(params)) + + def progress(self, fraction, message): + self.messages.append((fraction, message)) + + def log(self, message): + self.messages.append((None, message)) + + +def _phantom(size=48, seed=0, spacing=0.8, origin=(-140.0, -90.0, 60.0)): + """A textured volume with an origin far from zero. + + Far from zero on purpose: that is the condition under which elastix's + centre of rotation matters, and a phantom centred on the origin would let + the bug this suite pins pass unnoticed. + """ + rng = np.random.default_rng(seed) + volume = rng.random((size,) * 3).astype(np.float32) * 120 + zz, yy, xx = np.meshgrid(*[np.arange(size)] * 3, indexing="ij") + half = size // 2 + volume += 1400 * ( + ((zz - half) ** 2 / 180 + (yy - half + 2) ** 2 / 140 + (xx - half - 2) ** 2 / 160) < 1 + ) + volume += 900 * ( + ((zz - half + 12) ** 2 / 40 + (yy - half - 10) ** 2 / 35 + (xx - half + 12) ** 2 / 30) < 1 + ) + image = sitk.GetImageFromArray(volume) + image.SetSpacing((spacing,) * 3) + image.SetOrigin(origin) + return image + + +def _moved(image, rotation=(0.04, -0.025, 0.03), translation=(1.2, -1.6, 0.9)): + """`image` displaced by a known rigid transform, and that transform.""" + truth = sitk.Euler3DTransform() + size = np.array(image.GetSize()) / 2.0 + truth.SetCenter(image.TransformContinuousIndexToPhysicalPoint(size.tolist())) + truth.SetRotation(*rotation) + truth.SetTranslation(translation) + + resampler = sitk.ResampleImageFilter() + resampler.SetReferenceImage(image) + resampler.SetTransform(truth.GetInverse()) + resampler.SetInterpolator(sitk.sitkLinear) + return resampler.Execute(image), truth + + +def _write(image, path): + os.makedirs(os.path.dirname(path), exist_ok=True) + sitk.WriteImage(image, path, useCompression=True) + return path + + +def _full_mask(image): + mask = sitk.GetImageFromArray(np.ones(sitk.GetArrayViewFromImage(image).shape, np.uint8)) + mask.CopyInformation(image) + return mask + + +def _grid_mesh(rows=12, columns=12, spacing=1.0): + """A flat triangulated grid, the smallest thing with a real adjacency.""" + points = vtk.vtkPoints() + for row in range(rows): + for column in range(columns): + points.InsertNextPoint(column * spacing, row * spacing, 0.0) + + triangles = vtk.vtkCellArray() + for row in range(rows - 1): + for column in range(columns - 1): + a = row * columns + column + for corners in ((a, a + 1, a + columns), (a + 1, a + columns + 1, a + columns)): + triangle = vtk.vtkTriangle() + for index, corner in enumerate(corners): + triangle.GetPointIds().SetId(index, corner) + triangles.InsertNextCell(triangle) + + mesh = vtk.vtkPolyData() + mesh.SetPoints(points) + mesh.SetPolys(triangles) + return mesh + + +def test_every_tool_is_named_by_string(): + """`sup.run("ASO", ...)`, never `sup.ASO(...)`. A typo in a string is + greppable and tools.py is the whole call graph; a typo in an attribute is an + AttributeError an hour into a job. + + Here rather than in AREG_CBCT, which was where the single pre-split suite + left it: this is the tool that drives all four, so it is the only one whose + tools.py can be expected to name all four. + """ + source = open(tools.__file__, encoding="utf-8").read() + assert 'sup.run("' in source + for tool in ("Crown_Seg", "ALI_CBCT", "ALI_IOS", "ASO"): + assert f'"{tool}"' in source, tool diff --git a/tools/AREG/tests/data/README.md b/tools/AREG/TESTING.md similarity index 88% rename from tools/AREG/tests/data/README.md rename to tools/AREG/TESTING.md index 6b99b8d..ca689b1 100644 --- a/tools/AREG/tests/data/README.md +++ b/tools/AREG/TESTING.md @@ -1,11 +1,12 @@ -# Test data +# AREG: how the three tools are tested Nothing is committed here. The suite builds its own 48³ synthetic phantoms with SimpleITK, moves them by a known rigid transform, and checks the recovered transform against it -- `itk-elastix` is a wheel and fast enough on a phantom that the CBCT registration is exercised for real rather than mocked. -That covers all 88 tests. The IOS patch network is stubbed; it needs pytorch3d +Each of the three tools now runs its own share of them in its own +virtualenv (AREG_CBCT 25, AREG_IOS 53, AREG_IOSCBCT 1). The IOS patch network is stubbed; it needs pytorch3d and a checkpoint. ## What the supervised chain would need From fa7ae703d8e02e3b510fa93462c1616093ca9f86 Mon Sep 17 00:00:00 2001 From: Jules GRIVOT PELISSON Date: Wed, 19 Aug 2026 13:48:30 -0400 Subject: [PATCH 15/19] FIX: move ALI's stranded IOS tests into ALI_IOS and update the CBCT suite to the split API --- .../ALI_CBCT/src/sadt_ali_cbct/__init__.py | 2 +- .../ALI/ALI_CBCT/src/sadt_ali_cbct/catalog.py | 4 +- .../ALI_CBCT/src/sadt_ali_cbct/dispatch.py | 10 +- tools/ALI/ALI_CBCT/tests/test_run.py | 248 +++--------------- tools/ALI/ALI_IOS/tests/test_run.py | 197 ++++++++++++++ tools/ASO/tests/test_run.py | 2 +- tools/Crown_Seg/tests/test_run.py | 3 +- 7 files changed, 248 insertions(+), 218 deletions(-) create mode 100644 tools/ALI/ALI_IOS/tests/test_run.py diff --git a/tools/ALI/ALI_CBCT/src/sadt_ali_cbct/__init__.py b/tools/ALI/ALI_CBCT/src/sadt_ali_cbct/__init__.py index 3c20a60..0c46640 100644 --- a/tools/ALI/ALI_CBCT/src/sadt_ali_cbct/__init__.py +++ b/tools/ALI/ALI_CBCT/src/sadt_ali_cbct/__init__.py @@ -88,7 +88,7 @@ def run( input_path=str(input), model_path=str(model), output_dir=str(output_dir), - cbct_regions=regions, + regions=regions, landmarks=landmarks, prediction_ID=prediction_ID, device=device, diff --git a/tools/ALI/ALI_CBCT/src/sadt_ali_cbct/catalog.py b/tools/ALI/ALI_CBCT/src/sadt_ali_cbct/catalog.py index c1d2ba1..c6317b5 100644 --- a/tools/ALI/ALI_CBCT/src/sadt_ali_cbct/catalog.py +++ b/tools/ALI/ALI_CBCT/src/sadt_ali_cbct/catalog.py @@ -96,7 +96,7 @@ def group_of(label: str) -> str: def region_codes(selection) -> tuple: - """Turn what `run()` received for `cbct_regions` into region codes. + """Turn what `run()` received for `regions` into region codes. The published options are the display names ("Cranial base"), because that is what a client renders; the codes ("CB") are accepted too, so a caller @@ -128,7 +128,7 @@ def region_codes(selection) -> tuple: def landmark_names(selection) -> tuple: """Turn what `run()` received for `landmarks` into canonical label names. - Empty is the normal case and means "not specified": `cbct_regions` decides. + Empty is the normal case and means "not specified": `regions` decides. Aliases are resolved here (`UR3OI` -> `UR3OIP`), the weights only ever using the canonical spelling. diff --git a/tools/ALI/ALI_CBCT/src/sadt_ali_cbct/dispatch.py b/tools/ALI/ALI_CBCT/src/sadt_ali_cbct/dispatch.py index 2a12383..f629ea1 100644 --- a/tools/ALI/ALI_CBCT/src/sadt_ali_cbct/dispatch.py +++ b/tools/ALI/ALI_CBCT/src/sadt_ali_cbct/dispatch.py @@ -174,7 +174,7 @@ def identify( input_path: str, model_path: str, output_dir: str, - cbct_regions=None, + regions=None, landmarks=None, prediction_ID: str = "Pred", device: str = "cuda", @@ -219,11 +219,11 @@ def identify( # them -- see engine.requested_landmarks for why, and for the eight-fold # cost that motivates it. chosen_landmarks = cbct_catalog.landmark_names(landmarks) - regions = cbct_catalog.region_codes(cbct_regions) - if not chosen_landmarks and not regions: + region_codes = cbct_catalog.region_codes(regions) + if not chosen_landmarks and not region_codes: # The cross-argument rule the schema cannot express. raise ToolInputError( - f"Select at least one region under 'cbct_regions' " + f"Select at least one region under 'regions' " f"({', '.join(cbct_catalog.REGION_NAMES)}), or name the points you " f"want under 'landmarks'." ) @@ -231,7 +231,7 @@ def identify( report = cbct_engine.predict_landmarks( scans=detected.scans, model_path=model_path, - regions=regions, + regions=region_codes, landmarks=chosen_landmarks, prediction_ID=prediction_ID, output_dir=output_dir, diff --git a/tools/ALI/ALI_CBCT/tests/test_run.py b/tools/ALI/ALI_CBCT/tests/test_run.py index be323e3..320c752 100644 --- a/tools/ALI/ALI_CBCT/tests/test_run.py +++ b/tools/ALI/ALI_CBCT/tests/test_run.py @@ -23,11 +23,12 @@ import pytest import SimpleITK as sitk -from sadt_ali_cbct import dispatch, markups, run +from sadt_ali_cbct import dispatch, run +from sadt_ali_common import markups from sadt_ali_cbct import catalog as cbct_catalog from sadt_ali_cbct.errors import ToolInputError, ToolUnavailableError -from sadt_ali.ios import catalog as ios_catalog -from sadt_ali.ios import engine as ios_engine + + ALL_REGIONS = list(cbct_catalog.REGION_NAMES) CRANIAL_BASE_ONLY = ["Cranial base"] @@ -192,31 +193,10 @@ def test_an_unknown_region_is_refused_not_dropped(): cbct_catalog.region_codes(["Cranial base", "Sagittal"]) -def test_ios_offers_only_landmark_types_a_model_predicts(): - """R, RIP and OIP were selectable in the Slicer UI and predicted by - nothing: no network produced them and no label table contained them. - Ticking them did literally nothing.""" - offered = {lm_type for types in ios_catalog.NETWORKS.values() for lm_type in types} - assert offered == {"O", "MB", "DB", "CL", "CB", "MG"} - assert not offered & {"R", "RIP", "OIP"} -def test_ios_tooth_numbering_matches_the_shipped_label_tables(): - assert ios_catalog.UNIVERSAL_NUMBERS["Upper"]["UL7"] == 15 - assert ios_catalog.UNIVERSAL_NUMBERS["Upper"]["UR7"] == 2 - assert ios_catalog.UNIVERSAL_NUMBERS["Lower"]["LL7"] == 18 - assert ios_catalog.UNIVERSAL_NUMBERS["Lower"]["LR7"] == 31 - # Tooth 8 is UR1: the occlusal network's three channels, in channel order. - assert ios_catalog.LABELS["O"]["8"] == ["UR1O", "UR1MB", "UR1DB"] - assert ios_catalog.LABELS["C"]["8"] == ["UR1CL", "UR1CB"] -def test_ios_network_codes_from_a_selection(): - assert ios_catalog.network_codes(["Occlusal"]) == ("O",) - assert ios_catalog.network_codes(["O"]) == ("O",) - assert ios_catalog.network_codes(None) == ios_catalog.NETWORK_CODES - with pytest.raises(ValueError, match="Occlusal"): - ios_catalog.network_codes(["Buccal"]) # --------------------------------------------------------------------------- @@ -236,15 +216,13 @@ def test_the_published_regions_are_the_catalogs_own(): That makes the signature a second declaration of the same set, and this is what keeps the two honest: a region added to one and not the other would be unselectable from the client, or offered and then refused.""" - assert _choices("cbct_regions") == list(cbct_catalog.REGION_NAMES) + assert _choices("regions") == list(cbct_catalog.REGION_NAMES) def test_the_published_landmarks_are_the_catalogs_own(): assert _choices("landmarks") == list(cbct_catalog.LABELS) -def test_the_published_networks_are_the_catalogs_own(): - assert _choices("ios_networks") == list(ios_catalog.NETWORK_NAMES) def test_every_published_default_is_one_of_its_own_options(): @@ -252,7 +230,7 @@ def test_every_published_default_is_one_of_its_own_options(): produce the value the tool starts from.""" import inspect - for argument in ("cbct_regions", "ios_networks", "device"): + for argument in ("regions", "landmarks", "device"): default = inspect.signature(run).parameters[argument].default options = _choices(argument) for value in (default if isinstance(default, list) else [default]): @@ -312,7 +290,9 @@ def test_an_input_with_nothing_recognizable_says_what_it_wanted(tmp_path): with pytest.raises(ToolInputError) as raised: dispatch.detect(str(tmp_path / "empty"), str(tmp_path / "work")) - assert ".vtk" in str(raised.value) and ".nii.gz" in str(raised.value) + # Volume extensions only: this tool no longer offers the surface half, so + # listing .vtk here would advertise something it refuses. + assert ".nii.gz" in str(raised.value) def test_detection_is_recursive(tmp_path): @@ -446,55 +426,12 @@ def test_a_bundle_of_the_wrong_kind_is_an_input_error(tmp_path, cbct_environment # IOS model bundles and the tooth-label precondition # --------------------------------------------------------------------------- -def test_ios_weight_discovery_reads_the_published_names(tmp_path): - """The real ALIDDM bundle: Upper_O_model.pth, Lower_C_model.pth, ...""" - bundle = write_ios_bundle( - tmp_path / "bundle", - ["Upper_O_model.pth", "Lower_O_model.pth", "Upper_C_model.pth", "Lower_C_model.pth"], - ) - weights, unrecognized = ios_engine.discover_weights(bundle) - - assert weights["O"].keys() == {"Upper", "Lower"} - assert weights["C"].keys() == {"Upper", "Lower"} - assert unrecognized == [] - - -def test_an_ios_checkpoint_with_no_jaw_token_is_reported_not_assumed_upper(tmp_path): - """The original treated every file not containing "Lower" as upper-jaw - weights, so a bundle missing its mandibular model quietly predicted the - lower arch with the maxillary one.""" - bundle = write_ios_bundle(tmp_path / "bundle", ["model_O.pth", "Upper_C_model.pth"]) - weights, unrecognized = ios_engine.discover_weights(bundle) - - assert unrecognized == ["model_O.pth"] - assert "O" not in weights - assert weights["C"] == {"Upper": str(tmp_path / "bundle" / "Upper_C_model.pth")} - -def test_a_mesh_without_tooth_labels_names_the_tool_that_makes_them(tmp_path): - """The handoff `ALILogic.ensure_segmented()` used to make in-process. - Tools do not call each other any more, so this cannot segment the mesh - itself -- but it can say exactly what to run, which is the difference - between a fixable request and "no known tooth number is present". - """ - labelled = write_surface(tmp_path / "in" / "good.vtk", labelled=True) - raw = write_surface(tmp_path / "in" / "raw.vtk", labelled=False) - with pytest.raises(ToolInputError) as raised: - ios_engine.require_labels([(labelled, "good.vtk"), (raw, "raw.vtk")]) - message = str(raised.value) - assert "Crown_Seg" in message - assert "1 of 2" in message - # The array names it looked for, so the fix is actionable without reading - # the source. - assert "Universal_ID" in message -def test_a_fully_labelled_batch_passes_the_check(tmp_path): - mesh = write_surface(tmp_path / "in" / "arch.vtk", labelled=True) - assert ios_engine.require_labels([(mesh, "arch.vtk")]) is None # --------------------------------------------------------------------------- @@ -570,7 +507,7 @@ def test_a_cbct_run_writes_one_file_per_scan(tmp_path, stub_agent, cbct_environm input=tmp_path / "cohort", model=bundle, output_dir=tmp_path / "out", - cbct_regions=CRANIAL_BASE_ONLY, + regions=CRANIAL_BASE_ONLY, ) report = json.loads((output_dir / dispatch.REPORT_NAME).read_text()) @@ -598,7 +535,7 @@ def test_run_returns_the_output_directory(tmp_path, stub_agent, cbct_environment input=tmp_path / "cohort", model=bundle, output_dir=tmp_path / "out", - cbct_regions=CRANIAL_BASE_ONLY, + regions=CRANIAL_BASE_ONLY, ) assert returned == tmp_path / "out" assert returned.is_dir() @@ -614,7 +551,7 @@ def test_nothing_is_written_beside_the_input(tmp_path, stub_agent, cbct_environm input=tmp_path / "cohort", model=bundle, output_dir=tmp_path / "out", - cbct_regions=CRANIAL_BASE_ONLY, + regions=CRANIAL_BASE_ONLY, ) assert sorted(p for p in (tmp_path / "cohort").rglob("*") if p.is_file()) == before @@ -629,7 +566,7 @@ def test_the_working_directory_does_not_survive_the_run(tmp_path, stub_agent, cb input=tmp_path / "cohort", model=bundle, output_dir=tmp_path / "out", - cbct_regions=CRANIAL_BASE_ONLY, + regions=CRANIAL_BASE_ONLY, ) assert not (output_dir / dispatch.WORK_DIRNAME).exists() @@ -644,7 +581,7 @@ def test_the_working_directory_is_removed_even_when_the_run_fails(tmp_path, cbct input=tmp_path / "cohort", model=empty_bundle, output_dir=tmp_path / "out", - cbct_regions=CRANIAL_BASE_ONLY, + regions=CRANIAL_BASE_ONLY, ) assert not (tmp_path / "out" / dispatch.WORK_DIRNAME).exists() @@ -660,7 +597,7 @@ def test_a_batch_keeps_its_tree_so_homonyms_cannot_collide( input=tmp_path / "cohort", model=bundle, output_dir=tmp_path / "out", - cbct_regions=CRANIAL_BASE_ONLY, + regions=CRANIAL_BASE_ONLY, ) produced = sorted(str(p.relative_to(output_dir)) for p in output_dir.rglob("*.mrk.json")) @@ -689,7 +626,7 @@ def test_the_report_tells_a_missing_model_from_a_failed_search( input=tmp_path / "cohort", model=bundle, output_dir=tmp_path / "out", - cbct_regions=CRANIAL_BASE_ONLY, + regions=CRANIAL_BASE_ONLY, ) report = json.loads((output_dir / dispatch.REPORT_NAME).read_text()) @@ -719,7 +656,7 @@ def test_a_landmark_that_never_converges_does_not_cost_the_others( input=tmp_path / "cohort", model=bundle, output_dir=tmp_path / "out", - cbct_regions=CRANIAL_BASE_ONLY, + regions=CRANIAL_BASE_ONLY, ) finally: del cbct_catalog.LABEL_GROUPS["XN"] @@ -836,45 +773,21 @@ def test_an_empty_cbct_selection_on_cbct_input_names_the_argument(tmp_path): input=tmp_path / "cohort", model=tmp_path, output_dir=tmp_path / "out", - cbct_regions=[], + regions=[], ) message = str(raised.value) - assert "CBCT" in message and "cbct_regions" in message + # The argument as PUBLISHED. It read 'cbct_regions' until the split renamed + # it, so the message was telling a caller to fill a field the schema does + # not have. + assert "regions" in message # The message lists what to tick, so a mode mismatch explains itself. for name in cbct_catalog.REGION_NAMES: assert name in message -def test_an_empty_ios_selection_on_ios_input_names_the_argument(tmp_path): - write_surface(tmp_path / "cohort" / "arch.vtk") - with pytest.raises(ToolInputError) as raised: - run( - input=tmp_path / "cohort", - model=tmp_path, - output_dir=tmp_path / "out", - modality="IOS", - ios_networks=[], - ) - message = str(raised.value) - assert "ios_networks" in message and "Occlusal" in message -def test_the_inactive_modes_empty_selection_is_ignored(tmp_path, stub_agent, cbct_environment): - """Both groups are always rendered by the client and one is always inert. - Emptying the inert one must not fail the run.""" - write_volume(tmp_path / "cohort" / "patient01.nii.gz") - bundle = write_cbct_bundle(tmp_path / "bundle", {"Cranial_Base": ["Ba"]}) - - output_dir = run( - input=tmp_path / "cohort", - model=bundle, - output_dir=tmp_path / "out", - ios_networks=[], - ) - report = json.loads((output_dir / dispatch.REPORT_NAME).read_text()) - assert report["mode"] == "CBCT" - # --------------------------------------------------------------------------- # `landmarks` -- asking for named points instead of whole regions @@ -1048,83 +961,18 @@ def test_the_real_cbct_bundle_places_landmarks_on_a_real_scan(tmp_path): assert low[axis] <= position[axis] <= high[axis], (label, axis, position) -@pytest.mark.gpu -@pytest.mark.models -@pytest.mark.ios -@pytest.mark.skipif( - not (REAL_IOS_MODELS and REAL_MESH), - reason="set SADT_ALI_IOS_MODELS and SADT_ALI_MESH, and `uv sync --extra ios`", -) -def test_the_real_ios_bundle_places_landmarks_on_a_real_mesh(tmp_path): - """The IOS half, which needs pytorch3d compiled -- see README.""" - from pathlib import Path - - output = run( - input=Path(REAL_MESH), - model=Path(REAL_IOS_MODELS), - output_dir=tmp_path / "out", - ios_networks=["Occlusal"], - device="cuda", - ) - - report = json.loads((output / dispatch.REPORT_NAME).read_text()) - assert report["mode"] == "IOS" - assert report["summary"]["processed"] == 1 - assert sorted(output.rglob("*.mrk.json")) # --------------------------------------------------------------------------- # Mucogingival # --------------------------------------------------------------------------- -def test_mucogingival_is_offered_but_not_on_by_default(): - """One point per lower tooth on the gingival margin, wanted by a mandible - registration and by nobody asking for crown landmarks. On by default would - add a third pass over every mesh of every existing request.""" - import inspect - assert ios_catalog.NETWORK_NAMES["Mucogingival"] == "MG" - assert "Mucogingival" in _choices("ios_networks") - assert inspect.signature(run).parameters["ios_networks"].default == [ - "Occlusal", "Cervical" - ] -def test_mucogingival_runs_on_the_mandible_only(): - """It was trained on the mandible alone, so a maxilla is not a missing - model -- it is a question the network cannot be asked.""" - assert ios_catalog.NETWORK_JAWS["MG"] == ("Lower",) - # The other two are unrestricted, and must stay that way. - assert "O" not in ios_catalog.NETWORK_JAWS - assert "C" not in ios_catalog.NETWORK_JAWS -def test_the_mucogingival_names_are_positional_not_derived(): - """Six MG output names collide with the TRAINING name of a DIFFERENT tooth - -- LR1MG is the training name of tooth 25 and the output name of tooth 26 -- - because tooth 25 carries the midline name L0MG and shifts the right side by - one. Deriving `` here would mislabel half the arch.""" - labels = ios_catalog.LABELS["MG"] - assert labels["19"] == ["LL6MG"] # first trained tooth - assert labels["25"] == ["L0MG"] # the midline, not "LR1MG" - assert labels["26"] == ["LR1MG"] # shifted by one against the numbers - assert labels["31"] == ["LR6MG"] - # Tooth 18 was excluded from training and has no MG label at all. - assert "18" not in labels - assert len(labels) == 13 == len(ios_catalog.MG_TEETH) - - -def test_every_mucogingival_tooth_has_an_aim_offset(): - """The cameras aim at the landmark's expected position rather than at a - flat drop below the tooth centre, which only ever matched the incisors: on - the molars the landmark is ~0.15 further buccal and fell outside the render - entirely. A tooth with no offset would be back to that.""" - assert set(ios_catalog.MG_AIM_OFFSET) == set(ios_catalog.MG_TEETH) - for tooth, offset in ios_catalog.MG_AIM_OFFSET.items(): - assert len(offset) == 3, tooth - # Below the crown, always: the gingival margin is under it. - assert offset[2] < 0, tooth def test_a_degraded_landmark_carries_its_caveat_into_the_file(tmp_path): @@ -1148,44 +996,28 @@ def test_a_degraded_landmark_carries_its_caveat_into_the_file(tmp_path): assert points["LL5MG"] == "" -def test_a_declared_modality_the_data_contradicts_is_refused(tmp_path): - """The mode is still read from the DATA. `modality` exists so a client can - show one half of the panel at a time, and it is CHECKED rather than - believed: declaring CBCT over a folder of meshes would otherwise run the - wrong engine and call it a success.""" - write_surface(tmp_path / "cohort" / "arch.vtk") - with pytest.raises(ToolInputError) as raised: - run( - input=tmp_path / "cohort", - model=tmp_path, - output_dir=tmp_path / "out", - modality="CBCT", - ) - message = str(raised.value) - assert "IOS" in message and "CBCT" in message -def test_a_declared_modality_that_agrees_runs(tmp_path, stub_agent, cbct_environment): - write_volume(tmp_path / "cohort" / "patient01.nii.gz") - bundle = write_cbct_bundle(tmp_path / "bundle", {"Cranial_Base": ["Ba"]}) - output_dir = run( - input=tmp_path / "cohort", model=bundle, output_dir=tmp_path / "out", - modality="CBCT", cbct_regions=CRANIAL_BASE_ONLY, - ) - report = json.loads((output_dir / dispatch.REPORT_NAME).read_text()) - assert report["mode"] == "CBCT" -def test_the_two_halves_of_the_panel_are_mutually_exclusive(): - """The visual complaint this argument exists to fix: both engines' - selections were shown at once, so a CBCT user had to know which half to - ignore.""" - from sadt_ali_cbct import layout +def test_an_intraoral_surface_is_refused_by_name(tmp_path): + """What the split put in place of the `modality` argument. + + ALI used to be one tool that read the mode from the data; five tests here + drove that through `modality=`, and they went on existing after the split + because a stale import stopped this file from being collected at all. The + behaviour they were guarding is now this: the wrong kind of data is refused + before any inference, and the message names the tool that does handle it. + """ + root = tmp_path / "scans" + root.mkdir() + write_surface(root / "patient.vtk") + + with pytest.raises(ToolInputError) as caught: + dispatch.discover(str(root)) - assert layout.LAYOUT["cbct_regions"]["visible_when"] == {"modality": "CBCT"} - assert layout.LAYOUT["landmarks"]["visible_when"] == {"modality": "CBCT"} - assert layout.LAYOUT["ios_networks"]["visible_when"] == {"modality": "IOS"} - # And the selector itself is always shown, at the top. - assert "visible_when" not in layout.LAYOUT["modality"] + message = str(caught.value) + assert "intraoral surface" in message + assert "ALI_IOS" in message diff --git a/tools/ALI/ALI_IOS/tests/test_run.py b/tools/ALI/ALI_IOS/tests/test_run.py new file mode 100644 index 0000000..e916786 --- /dev/null +++ b/tools/ALI/ALI_IOS/tests/test_run.py @@ -0,0 +1,197 @@ +"""ALI_IOS: the catalog it publishes, and the weights it recognises. + +These moved out of ALI_CBCT's test file when ALI became two tools. They were +still there, importing `sadt_ali.ios`, which no virtualenv has provided since +the split -- so ALI_CBCT's whole suite failed to collect and ALI_IOS had no +tests at all. Found by running each tool's suite in its own interpreter. +""" + +import os +import typing + +import pytest + +from sadt_ali_ios import catalog, engine +from sadt_ali_ios.errors import ToolInputError +from sadt_ali_ios import run + + +def _choices(argument): + """The `Literal` options `run()` publishes for one argument.""" + hint = typing.get_type_hints(run)[argument] + if typing.get_origin(hint) is list: + hint = typing.get_args(hint)[0] + return list(typing.get_args(hint)) + + +# Copied from ALI_CBCT's suite rather than shared: the two tools are +# separate packages with separate virtualenvs, and CONTRIBUTING.md says a +# test helper is duplicated rather than given a package of its own. +def write_surface(path, labelled=True): + """A minimal .vtk polydata, optionally carrying a tooth-label array.""" + vtk = pytest.importorskip("vtk") + + points = vtk.vtkPoints() + for coordinates in ((0, 0, 0), (1, 0, 0), (0, 1, 0), (1, 1, 1)): + points.InsertNextPoint(*coordinates) + + polys = vtk.vtkCellArray() + for triangle in ((0, 1, 2), (0, 1, 3), (1, 2, 3), (0, 2, 3)): + polys.InsertNextCell(3) + for point_id in triangle: + polys.InsertCellPoint(point_id) + + surface = vtk.vtkPolyData() + surface.SetPoints(points) + surface.SetPolys(polys) + + if labelled: + labels = vtk.vtkIntArray() + labels.SetName("Universal_ID") + for value in (8, 8, 8, 8): + labels.InsertNextValue(value) + surface.GetPointData().AddArray(labels) + + os.makedirs(os.path.dirname(str(path)), exist_ok=True) + writer = vtk.vtkPolyDataWriter() + writer.SetFileName(str(path)) + writer.SetInputData(surface) + writer.Write() + return str(path) + +def write_ios_bundle(root, names): + root.mkdir(parents=True, exist_ok=True) + for name in names: + (root / name).write_bytes(b"fake checkpoint") + return str(root) + +def test_ios_offers_only_landmark_types_a_model_predicts(): + """R, RIP and OIP were selectable in the Slicer UI and predicted by + nothing: no network produced them and no label table contained them. + Ticking them did literally nothing.""" + offered = {lm_type for types in catalog.NETWORKS.values() for lm_type in types} + assert offered == {"O", "MB", "DB", "CL", "CB", "MG"} + assert not offered & {"R", "RIP", "OIP"} + + +def test_ios_tooth_numbering_matches_the_shipped_label_tables(): + assert catalog.UNIVERSAL_NUMBERS["Upper"]["UL7"] == 15 + assert catalog.UNIVERSAL_NUMBERS["Upper"]["UR7"] == 2 + assert catalog.UNIVERSAL_NUMBERS["Lower"]["LL7"] == 18 + assert catalog.UNIVERSAL_NUMBERS["Lower"]["LR7"] == 31 + # Tooth 8 is UR1: the occlusal network's three channels, in channel order. + assert catalog.LABELS["O"]["8"] == ["UR1O", "UR1MB", "UR1DB"] + assert catalog.LABELS["C"]["8"] == ["UR1CL", "UR1CB"] + + +def test_ios_network_codes_from_a_selection(): + assert catalog.network_codes(["Occlusal"]) == ("O",) + assert catalog.network_codes(["O"]) == ("O",) + assert catalog.network_codes(None) == catalog.NETWORK_CODES + with pytest.raises(ValueError, match="Occlusal"): + catalog.network_codes(["Buccal"]) + + +def test_the_published_networks_are_the_catalogs_own(): + assert _choices("networks") == list(catalog.NETWORK_NAMES) + + +def test_ios_weight_discovery_reads_the_published_names(tmp_path): + """The real ALIDDM bundle: Upper_O_model.pth, Lower_C_model.pth, ...""" + bundle = write_ios_bundle( + tmp_path / "bundle", + ["Upper_O_model.pth", "Lower_O_model.pth", "Upper_C_model.pth", "Lower_C_model.pth"], + ) + weights, unrecognized = engine.discover_weights(bundle) + + assert weights["O"].keys() == {"Upper", "Lower"} + assert weights["C"].keys() == {"Upper", "Lower"} + assert unrecognized == [] + + +def test_an_ios_checkpoint_with_no_jaw_token_is_reported_not_assumed_upper(tmp_path): + """The original treated every file not containing "Lower" as upper-jaw + weights, so a bundle missing its mandibular model quietly predicted the + lower arch with the maxillary one.""" + bundle = write_ios_bundle(tmp_path / "bundle", ["model_O.pth", "Upper_C_model.pth"]) + weights, unrecognized = engine.discover_weights(bundle) + + assert unrecognized == ["model_O.pth"] + assert "O" not in weights + assert weights["C"] == {"Upper": str(tmp_path / "bundle" / "Upper_C_model.pth")} + + +def test_a_mesh_without_tooth_labels_names_the_tool_that_makes_them(tmp_path): + """The handoff `ALILogic.ensure_segmented()` used to make in-process. + + Tools do not call each other any more, so this cannot segment the mesh + itself -- but it can say exactly what to run, which is the difference + between a fixable request and "no known tooth number is present". + """ + labelled = write_surface(tmp_path / "in" / "good.vtk", labelled=True) + raw = write_surface(tmp_path / "in" / "raw.vtk", labelled=False) + + with pytest.raises(ToolInputError) as raised: + engine.require_labels([(labelled, "good.vtk"), (raw, "raw.vtk")]) + + message = str(raised.value) + assert "Crown_Seg" in message + assert "1 of 2" in message + # The array names it looked for, so the fix is actionable without reading + # the source. + assert "Universal_ID" in message + + +def test_a_fully_labelled_batch_passes_the_check(tmp_path): + mesh = write_surface(tmp_path / "in" / "arch.vtk", labelled=True) + assert engine.require_labels([(mesh, "arch.vtk")]) is None + + +def test_mucogingival_is_offered_but_not_on_by_default(): + """One point per lower tooth on the gingival margin, wanted by a mandible + registration and by nobody asking for crown landmarks. On by default would + add a third pass over every mesh of every existing request.""" + import inspect + + assert catalog.NETWORK_NAMES["Mucogingival"] == "MG" + assert "Mucogingival" in _choices("networks") + assert inspect.signature(run).parameters["networks"].default == [ + "Occlusal", "Cervical" + ] + + +def test_mucogingival_runs_on_the_mandible_only(): + """It was trained on the mandible alone, so a maxilla is not a missing + model -- it is a question the network cannot be asked.""" + assert catalog.NETWORK_JAWS["MG"] == ("Lower",) + # The other two are unrestricted, and must stay that way. + assert "O" not in catalog.NETWORK_JAWS + assert "C" not in catalog.NETWORK_JAWS + + +def test_the_mucogingival_names_are_positional_not_derived(): + """Six MG output names collide with the TRAINING name of a DIFFERENT tooth + -- LR1MG is the training name of tooth 25 and the output name of tooth 26 -- + because tooth 25 carries the midline name L0MG and shifts the right side by + one. Deriving `` here would mislabel half the arch.""" + labels = catalog.LABELS["MG"] + + assert labels["19"] == ["LL6MG"] # first trained tooth + assert labels["25"] == ["L0MG"] # the midline, not "LR1MG" + assert labels["26"] == ["LR1MG"] # shifted by one against the numbers + assert labels["31"] == ["LR6MG"] + # Tooth 18 was excluded from training and has no MG label at all. + assert "18" not in labels + assert len(labels) == 13 == len(catalog.MG_TEETH) + + +def test_every_mucogingival_tooth_has_an_aim_offset(): + """The cameras aim at the landmark's expected position rather than at a + flat drop below the tooth centre, which only ever matched the incisors: on + the molars the landmark is ~0.15 further buccal and fell outside the render + entirely. A tooth with no offset would be back to that.""" + assert set(catalog.MG_AIM_OFFSET) == set(catalog.MG_TEETH) + for tooth, offset in catalog.MG_AIM_OFFSET.items(): + assert len(offset) == 3, tooth + # Below the crown, always: the gingival margin is under it. + assert offset[2] < 0, tooth diff --git a/tools/ASO/tests/test_run.py b/tools/ASO/tests/test_run.py index 6c5c251..ad9680b 100644 --- a/tools/ASO/tests/test_run.py +++ b/tools/ASO/tests/test_run.py @@ -826,7 +826,7 @@ def test_the_landmark_tool_is_asked_by_name_for_the_points_aso_needs(tmp_path): tool, params = sup.calls[0] # A string, not a dynamic attribute: a typo here is greppable, and the call # graph stays inspectable. - assert tool == "ALI" + assert tool == "ALI_CBCT" assert set(params["landmarks"]) == set(_REFERENCE_POINTS) assert params["model"] == "/data/ALI/models/Bundle" assert "cbct_regions" not in params diff --git a/tools/Crown_Seg/tests/test_run.py b/tools/Crown_Seg/tests/test_run.py index 2ea801f..2997afc 100644 --- a/tools/Crown_Seg/tests/test_run.py +++ b/tools/Crown_Seg/tests/test_run.py @@ -153,7 +153,8 @@ def test_a_raw_mesh_is_segmented_and_reported(tmp_path, stub_shapeaxi): ) assert report["summary"] == { - "total": 1, "segmented": 1, "already_segmented": 0, "failed": 0 + "total": 1, "segmented": 1, "already_segmented": 0, "failed": 0, + "engine_unavailable": 0, } assert len(report["segmented_meshes"]) == 1 assert pipeline.is_segmented(report["segmented_meshes"][0]) From 4bbdef2b82b87d07596fad144dd2dec25382ed2d Mon Sep 17 00:00:00 2001 From: Jules GRIVOT PELISSON Date: Wed, 19 Aug 2026 14:26:44 -0400 Subject: [PATCH 16/19] FIX: CI discovers tools inside grouping folders and runs on Node 24 --- .github/workflows/ci.yml | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b59e084..88dd8f1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,11 +18,20 @@ jobs: outputs: tools: ${{ steps.list.outputs.tools }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - id: list run: | - tools=$(find tools -mindepth 2 -maxdepth 2 -name pyproject.toml -printf '%h\n' \ - | xargs -r -n1 basename | sort | jq -R . | jq -sc .) + # Depth 2 AND 3, and keyed on [tool.sadt], which is the same rule + # the server's registry and the deployment Dockerfile use. Depth 2 + # alone stopped matching when ALI and AREG became grouping folders: + # CI went on testing six tools and silently dropped the five the + # splits produced -- which is where the stale tests were found, months + # later, by hand. [tool.sadt] also drops _template, which CI was + # testing and no server ever serves. + tools=$(find tools -mindepth 2 -maxdepth 3 -name pyproject.toml -printf '%h\n' \ + | while read -r dir; do + grep -q '^\[tool.sadt\]' "$dir/pyproject.toml" && echo "$dir" + done | sed 's|^tools/||' | sort | jq -R . | jq -sc .) echo "tools=$tools" >> "$GITHUB_OUTPUT" echo "matrix: $tools" @@ -37,8 +46,8 @@ jobs: matrix: tool: ${{ fromJson(needs.discover.outputs.tools) }} steps: - - uses: actions/checkout@v4 - - uses: astral-sh/setup-uv@v5 + - uses: actions/checkout@v5 + - uses: astral-sh/setup-uv@v7 with: enable-cache: true @@ -67,8 +76,8 @@ jobs: scripts: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: astral-sh/setup-uv@v5 + - uses: actions/checkout@v5 + - uses: astral-sh/setup-uv@v7 with: enable-cache: true # --no-project: scripts/ is deliberately not a package. There is no @@ -84,8 +93,8 @@ jobs: testkit: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: astral-sh/setup-uv@v5 + - uses: actions/checkout@v5 + - uses: astral-sh/setup-uv@v7 with: enable-cache: true - name: Build the template it runs against From d64ab723284adb4003502b44ab7f898d8798deac Mon Sep 17 00:00:00 2001 From: Jules GRIVOT PELISSON Date: Wed, 19 Aug 2026 14:31:35 -0400 Subject: [PATCH 17/19] FIX: stub the engine import probe so Crown_Seg's tests do not depend on the extra --- tools/Crown_Seg/tests/test_run.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/tools/Crown_Seg/tests/test_run.py b/tools/Crown_Seg/tests/test_run.py index 2997afc..e3f3b4a 100644 --- a/tools/Crown_Seg/tests/test_run.py +++ b/tools/Crown_Seg/tests/test_run.py @@ -51,7 +51,18 @@ def write_surface(path, labelled=False, array_name="Universal_ID"): @pytest.fixture def stub_shapeaxi(monkeypatch, tmp_path): - """Write a labelled mesh wherever shapeaxi would have written one.""" + """Write a labelled mesh wherever shapeaxi would have written one. + + The import probe is stubbed too. Replacing only `_run_shapeaxi` left a + half-stubbed world: on a machine without the `segmentation` extra the probe + still failed, so an already-segmented mesh came back `engine_unavailable` + rather than `already_segmented` and the pass-through test failed for a + reason that had nothing to do with pass-through. It passed locally, where + the extra is installed, and failed in CI, where `uv sync --frozen` does not + install extras. A fixture that stands in for a working engine has to stand + in for all of it. + """ + monkeypatch.setattr(pipeline, "_import_dental_model_seg", lambda: None) calls = [] def fake_run(csv_path, output_dir, model_path, input_root, array_name, suffix, From 0b02fa198449175edfedd463419e52bf9ba2dc6a Mon Sep 17 00:00:00 2001 From: Jules GRIVOT PELISSON Date: Wed, 19 Aug 2026 16:01:08 -0400 Subject: [PATCH 18/19] CLEAN: drop NOTE prefixes, keeping what they warned about --- tools/Batch_Dental_Seg/src/sadt_batchdentalseg/nnunet_runner.py | 2 +- tools/Batch_Dental_Seg/tests/test_run.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/Batch_Dental_Seg/src/sadt_batchdentalseg/nnunet_runner.py b/tools/Batch_Dental_Seg/src/sadt_batchdentalseg/nnunet_runner.py index 9a04846..33f747e 100644 --- a/tools/Batch_Dental_Seg/src/sadt_batchdentalseg/nnunet_runner.py +++ b/tools/Batch_Dental_Seg/src/sadt_batchdentalseg/nnunet_runner.py @@ -102,7 +102,7 @@ def predict_folder(model_folder: str, input_dir: str, output_dir: str, device: s A whole folder per call, so the checkpoint is loaded once for the batch rather than once per scan. - NOTE: AMASSS additionally redirects nnUNet's resamplers to the GPU, which is + AMASSS additionally redirects nnUNet's resamplers to the GPU, which is worth ~2.5x there. It is deliberately not done here yet: it drops the input resampling from spline order 3 to order 1, and nothing has measured what that costs THESE models. diff --git a/tools/Batch_Dental_Seg/tests/test_run.py b/tools/Batch_Dental_Seg/tests/test_run.py index b18ecd6..a6ca27c 100644 --- a/tools/Batch_Dental_Seg/tests/test_run.py +++ b/tools/Batch_Dental_Seg/tests/test_run.py @@ -80,7 +80,7 @@ def _install(labels_present=(1, 2, 3)): # Catalog # --------------------------------------------------------------------------- -# NOTE: the server-side suite also asserted every catalog key appears in +# The server-side suite also asserted every catalog key appears in # `scripts/data-manifest.yml`, because a key that drifts from the manifest makes # an installed model unselectable. That file lives in the server repository and # a tool package cannot reach it, so the check is gone and the contract is now From 444bac991ea895efd5b749239c8a0b1ad4d087ac Mon Sep 17 00:00:00 2001 From: Jules GRIVOT PELISSON Date: Wed, 19 Aug 2026 16:11:59 -0400 Subject: [PATCH 19/19] FIX: refuse a fully-automated CBCT run with no segmentation weights named --- .../AREG_CBCT/src/sadt_areg_cbct/dispatch.py | 17 +++++++++++++++-- tools/AREG/AREG_CBCT/tests/test_run.py | 19 +++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/tools/AREG/AREG_CBCT/src/sadt_areg_cbct/dispatch.py b/tools/AREG/AREG_CBCT/src/sadt_areg_cbct/dispatch.py index f168e08..98cece3 100644 --- a/tools/AREG/AREG_CBCT/src/sadt_areg_cbct/dispatch.py +++ b/tools/AREG/AREG_CBCT/src/sadt_areg_cbct/dispatch.py @@ -71,7 +71,8 @@ def succeeded(self) -> list: -def _check_cbct(automation: str, regions: list, t1_masks, reference, sup=None) -> None: +def _check_cbct(automation: str, regions: list, t1_masks, reference, + segmentation_model=None, sup=None) -> None: if not regions: raise ToolInputError( "Select at least one anatomical region to register on in 'cbct_regions' " @@ -101,6 +102,18 @@ def _check_cbct(automation: str, regions: list, t1_masks, reference, sup=None) - "one in 'cbct_reference' (see GET /tools/AREG_CBCT/data)." ) + if not segmentation_model: + # Named here rather than left to AMASSS, which receives None and fails on + # `TypeError: expected str, bytes or os.PathLike object, not NoneType` -- + # fifteen seconds in, from inside a child process, and opaque to whoever + # sent the request. The tool is reachable; what is missing is which + # weights it should load. + raise ToolInputError( + f"{automation} CBCT segments the T1 scans before registering, which " + f"needs the segmentation weights: name a bundle in " + f"'segmentation_model' (see GET /tools/AREG_CBCT/data)." + ) + def _run_cbct( t1_root, t2_root, t1_masks_path, automation, regions, segmentation_model, @@ -380,7 +393,7 @@ def main( regions = _selected(cbct_regions, catalogs.REGION_CHOICES) reference = cbct_reference - _check_cbct(automation, regions, t1_masks, reference, sup) + _check_cbct(automation, regions, t1_masks, reference, segmentation_model, sup) run = register( t1_path=str(t1), diff --git a/tools/AREG/AREG_CBCT/tests/test_run.py b/tools/AREG/AREG_CBCT/tests/test_run.py index 3f73d1e..59ae2a9 100644 --- a/tools/AREG/AREG_CBCT/tests/test_run.py +++ b/tools/AREG/AREG_CBCT/tests/test_run.py @@ -497,3 +497,22 @@ def test_a_mode_needing_a_tool_says_so_when_there_is_no_supervisor(self): def test_a_supervisor_makes_the_same_mode_acceptable(self): assert tools.require(FakeSup("/tmp"), "AMASSS", "anything") is None + + +def test_fully_automated_without_segmentation_weights_is_refused_up_front(): + """AMASSS receives None otherwise, and fails on + + TypeError: expected str, bytes or os.PathLike object, not NoneType + + fifteen seconds in, inside a child process, reaching the caller as "Tool + execution failed". The tool is reachable; what is missing is which weights. + """ + with pytest.raises(ToolInputError, match="segmentation_model"): + dispatch._check_cbct( + automation=catalogs.AUTOMATION_FULLY, + regions=["Cranial base"], + t1_masks=None, + reference=None, + segmentation_model=None, + sup=FakeSup("/tmp/areg-rules"), + )