From 6178510ba34fb5d9a2390fd5f0f8e974decfbf97 Mon Sep 17 00:00:00 2001 From: iback Date: Wed, 2 Sep 2026 09:42:55 +0000 Subject: [PATCH 1/6] Fix reviewer findings: CPU inference, MIP slab thickness, optional deps, doc drift Addresses an external review of the public tutorials. Both reported implementation defects were real, and each turned out to also affect a sibling function. Bug 1 - CUDA-only call on the CPU inference path get_gpu_memory_MB/get_gpu_util called torch.cuda.mem_get_info() unconditionally. That API rejects its *argument* rather than probing the environment, so run_vibeseg(ddevice="cpu") died with "ValueError: Expected a cuda device, but got: cpu" before predicting a single tile - even on a machine with a GPU. Both helpers now dispatch on device.type like the neighbouring empty_cache(), and export_prediction's fallback guard tests the requested device instead of merely torch.cuda.is_available(). Bug 2 - compounding mm->voxel conversion in the curved-planar projections curve_projected_mip converted thick_t from mm to voxels inside its per-slice loop and assigned the result back to the same name, so slice n divided what slice n-1 had already divided. At 0.5 mm spacing the slab grew geometrically until int() raised "OverflowError: int too big to convert"; at 3 mm it shrank to the 1-voxel floor within four slices, silently projecting a far thinner slab than requested with no error at all. curve_projected_mean had the identical defect, unguarded. Both now convert from the mm source via a shared helper, and the try/except that was printing "thick infinity bug" is gone. Optional dependencies pydicom/dicom2nifti and the nnU-Net stack were imported at module top level but declared nowhere, so `import TPTBox.segmentation` failed outright on a clean install - the actual root cause of what looked like a SPINEPS/nnU-Net conflict. Added [tool.poetry.extras] dicom and seg (nnunetv2 pinned >=2.4,<2.5) plus import guards, so sub-packages import and only entry points raise, naming the extra. _add_grid_info_to_json moved to core/internal/nii_help.py: it uses no DICOM library, yet its old home made the public BIDS_FILE.get_grid_info() - which the stitching tutorial calls - require pydicom. Documentation and examples Fixed 14 confirmed-broken snippets, including the homepage's first code sample (.first() on a filter() that returns None), the stitching tutorial's ProcessPoolExecutor cell (unpicklable under Windows/Jupyter spawn, and its lazy map() swallowed every worker exception - now joblib), the obsolete TPTBox.registration.deformable import, and seg_all.ipynb's four dead imports. docs/modules/{spine,stitching}.md were hand-copies that had already diverged from the in-package READMEs; they are now --8<-- includes. Regression coverage unit_tests/test_snapshot_mip.py pins both projection bugs (verified failing before the fix in both spacing directions). unit_tests/test_docs_examples.py statically parses every notebook and Markdown code block and asserts each TPTBox module and symbol still exists - it flags all of the above when pointed at the previous tree, and passes with or without the optional stack installed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GrWjhK9FCgmRGkHhhVFd2V --- TPTBox/core/README_POI.md | 4 +- TPTBox/core/bids_files.py | 2 +- TPTBox/core/dicom/__init__.py | 22 +- TPTBox/core/dicom/dicom2nii_utils.py | 7 +- TPTBox/core/dicom/dicom_extract.py | 45 +--- TPTBox/core/internal/nii_help.py | 58 +++++ TPTBox/core/internal/optional_deps.py | 76 ++++++ TPTBox/core/nii_wrapper.py | 6 + TPTBox/registration/README.md | 28 ++- TPTBox/segmentation/README.md | 27 +- TPTBox/segmentation/__init__.py | 53 +++- .../nnUnet_utils/export_prediction.py | 14 +- TPTBox/segmentation/nnUnet_utils/predictor.py | 30 ++- TPTBox/spine/README.md | 8 +- TPTBox/spine/snapshot2D/README.md | 16 +- TPTBox/spine/snapshot2D/snapshot_modular.py | 45 +++- TPTBox/stitching/README.md | 4 +- docs/getting-started.md | 26 +- docs/index.md | 8 +- docs/modules/spine.md | 28 +-- docs/modules/stitching.md | 94 +------ pyproject.toml | 18 ++ tutorials/seg_all.ipynb | 81 +----- tutorials/tutorial_Dataset_processing.ipynb | 28 ++- unit_tests/test_docs_examples.py | 237 ++++++++++++++++++ unit_tests/test_nii_wrapper_auto.py | 8 +- unit_tests/test_snapshot_mip.py | 142 +++++++++++ 27 files changed, 809 insertions(+), 306 deletions(-) create mode 100644 TPTBox/core/internal/optional_deps.py create mode 100644 unit_tests/test_docs_examples.py create mode 100644 unit_tests/test_snapshot_mip.py diff --git a/TPTBox/core/README_POI.md b/TPTBox/core/README_POI.md index 3e2e86a4..47df98e4 100644 --- a/TPTBox/core/README_POI.md +++ b/TPTBox/core/README_POI.md @@ -44,10 +44,10 @@ poi_full.to_global().save_mrk("poi_as_markup.mrk.json", split_by_region=True, po ```python from TPTBox import NII, POI, Location, POI_Global, calc_poi_from_subreg_vert from TPTBox.core.vert_constants import v_name2idx -from TPTBox.segmentation.spineps import run_spineps_single +from TPTBox.segmentation import run_spineps # This requires that spineps is installed -output_paths = run_spineps_single( +output_paths = run_spineps( "file-path-of_T2w.nii.gz", model_semantic="t2w", ignore_compatibility_issues=True, diff --git a/TPTBox/core/bids_files.py b/TPTBox/core/bids_files.py index 44b536aa..19b4fbe2 100755 --- a/TPTBox/core/bids_files.py +++ b/TPTBox/core/bids_files.py @@ -1434,7 +1434,7 @@ def get_grid_info(self, add_grid_info_to_json: bool = True) -> Grid | None: A :class:`~TPTBox.core.nii_poi_abstract.Grid` instance, or ``None`` if no NIfTI file is present. """ - from TPTBox.core.dicom.dicom_extract import _add_grid_info_to_json + from TPTBox.core.internal.nii_help import _add_grid_info_to_json from TPTBox.core.nii_poi_abstract import Grid nii_file = self.get_nii_file() diff --git a/TPTBox/core/dicom/__init__.py b/TPTBox/core/dicom/__init__.py index 3c182b2c..9eaa2d97 100644 --- a/TPTBox/core/dicom/__init__.py +++ b/TPTBox/core/dicom/__init__.py @@ -1 +1,21 @@ -from TPTBox.core.dicom.dicom_extract import extract_dicom_folder +"""DICOM import/export helpers. + +Requires the optional ``dicom`` extra (``pydicom`` + ``dicom2nifti``). Importing +this package without them succeeds; calling ``extract_dicom_folder`` then raises +an ``ImportError`` naming the extra to install. +""" + +from __future__ import annotations + +from TPTBox.core.internal.optional_deps import missing_dependency_func + +_DICOM_PACKAGES = "pydicom dicom2nifti" + +try: + from TPTBox.core.dicom.dicom_extract import extract_dicom_folder +except ImportError as _e_dicom: + extract_dicom_folder = missing_dependency_func( # type: ignore[assignment] + "extract_dicom_folder", _e_dicom, "dicom", _DICOM_PACKAGES + ) + +__all__ = ["extract_dicom_folder"] diff --git a/TPTBox/core/dicom/dicom2nii_utils.py b/TPTBox/core/dicom/dicom2nii_utils.py index 41ce673e..7fb7b657 100755 --- a/TPTBox/core/dicom/dicom2nii_utils.py +++ b/TPTBox/core/dicom/dicom2nii_utils.py @@ -5,11 +5,14 @@ from copy import deepcopy from datetime import date from pathlib import Path +from typing import TYPE_CHECKING import numpy as np -import pydicom from tqdm import tqdm +if TYPE_CHECKING: + import pydicom + from TPTBox import BIDS_FILE, NII, BIDS_Global_info, Print_Logger from TPTBox.core.internal.nii_help import save_json as secure_save_json @@ -253,6 +256,8 @@ def get_json_from_dicom(data: list[pydicom.FileDataset] | pydicom.FileDataset) - def _get_json_from_dicom(py_dict: dict): """Rearrange a pydicom ``to_json_dict`` output into a JSON-serialisable form.""" + import pydicom # only this helper actually needs the library at runtime + data1 = {} for key, value in py_dict.items(): try: diff --git a/TPTBox/core/dicom/dicom_extract.py b/TPTBox/core/dicom/dicom_extract.py index 505d29cc..c91c98f6 100644 --- a/TPTBox/core/dicom/dicom_extract.py +++ b/TPTBox/core/dicom/dicom_extract.py @@ -25,6 +25,10 @@ from TPTBox import BIDS_FILE, Log_Type, Print_Logger from TPTBox.core.compat import zip_strict from TPTBox.core.dicom.dicom_header_to_keys import extract_keys_from_json + +# _add_grid_info_to_json lives in nii_help (it needs no DICOM library at all), so that +# BIDS_FILE.get_grid_info() does not drag pydicom/dicom2nifti in. Re-exported for compat. +from TPTBox.core.internal.nii_help import _add_grid_info_to_json from TPTBox.core.nii_wrapper import NII sys.path.append(str(Path(__file__).parent)) @@ -654,47 +658,6 @@ def _with_echo_suffix(p: Path, eco_index: int) -> Path: return p.with_name(name) -def _add_grid_info_to_json(nii_path: Path | str, simp_json: Path | str, force_update: bool = False, add: bool = True) -> dict: - """Append grid metadata (shape, spacing, orientation, affine) to a sidecar JSON file. - - Args: - nii_path: Path to the NIfTI file from which grid info is read. - simp_json: Path to the JSON sidecar file to update. - force_update: Re-compute and overwrite existing grid info when ``True``. - add: Write the updated dictionary back to disk when ``True``. - - Returns: - The updated JSON dictionary including the ``"grid"`` key. - """ - nii_path = Path(nii_path) - simp_json = Path(simp_json) - - # Always preserve the existing JSON contents (DICOM metadata written by save_json). - # The mtime comparison is only used to short-circuit re-computing the grid when the - # sidecar is already up to date; it must NOT decide whether to keep the DICOM keys. - json_dict = load_json(simp_json) if simp_json.exists() else {} - json_up_to_date = ( - simp_json.exists() - and nii_path.exists() - and datetime.fromtimestamp(simp_json.stat().st_mtime) > datetime.fromtimestamp(nii_path.stat().st_mtime) - ) - if "grid" in json_dict and not force_update and json_up_to_date: - return json_dict - print("Read Grid info") - nii = NII.load(nii_path, False) - gird = { - "shape": nii.shape, - "spacing": nii.spacing, - "orientation": nii.orientation, - "rotation": nii.rotation.reshape(-1).tolist(), - "origin": nii.origin, - "dims": nii.get_num_dims(), - } - json_dict["grid"] = gird - save_json(json_dict, simp_json, override=add) - return json_dict - - _EXTRACT_CACHE_DIR = ".extract_cache" diff --git a/TPTBox/core/internal/nii_help.py b/TPTBox/core/internal/nii_help.py index f2cdb6a6..5f8b081d 100644 --- a/TPTBox/core/internal/nii_help.py +++ b/TPTBox/core/internal/nii_help.py @@ -329,3 +329,61 @@ def _resample_from_to( if post_cast is not None: data = data.astype(post_cast, copy=False) return data, to_affine, from_img.header + + +def _add_grid_info_to_json(nii_path: Path | str, simp_json: Path | str, force_update: bool = False, add: bool = True) -> dict: + """Append grid metadata (shape, spacing, orientation, affine) to a sidecar JSON file. + + This lives here rather than next to the DICOM converters because it needs no + DICOM library at all - only ``NII`` and the JSON helpers. It used to sit in + ``TPTBox.core.dicom.dicom_extract``, which made the fully public + ``BIDS_FILE.get_grid_info()`` drag in ``pydicom`` and ``dicom2nifti`` for + users who never touched DICOM data. + + Args: + nii_path: Path to the NIfTI file from which grid info is read. + simp_json: Path to the JSON sidecar file to update. + force_update: Re-compute and overwrite existing grid info when ``True``. + add: Write the updated dictionary back to disk when ``True``. + + Returns: + The updated JSON dictionary including the ``"grid"`` key. + """ + from datetime import datetime + + from TPTBox.core.nii_wrapper import NII + + nii_path = Path(nii_path) + simp_json = Path(simp_json) + + # Always preserve the existing JSON contents (DICOM metadata written by save_json). + # The mtime comparison is only used to short-circuit re-computing the grid when the + # sidecar is already up to date; it must NOT decide whether to keep the DICOM keys. + json_dict: dict = {} + if simp_json.exists(): + with open(simp_json, encoding="utf-8") as f: + json_dict = json.load(f) + json_up_to_date = ( + simp_json.exists() + and nii_path.exists() + and datetime.fromtimestamp(simp_json.stat().st_mtime) > datetime.fromtimestamp(nii_path.stat().st_mtime) + ) + if "grid" in json_dict and not force_update and json_up_to_date: + return json_dict + nii = NII.load(nii_path, False) + json_dict["grid"] = { + "shape": nii.shape, + "spacing": nii.spacing, + "orientation": nii.orientation, + "rotation": nii.rotation.reshape(-1).tolist(), + "origin": nii.origin, + "dims": nii.get_num_dims(), + } + # Matches the previous `save_json(..., override=add)` semantics: write when + # `add` is set, or whenever the sidecar does not exist yet. + if add or not simp_json.exists(): + from TPTBox.logger import Print_Logger + + Print_Logger().on_save("save json with grid info", simp_json) + save_json(simp_json, json_dict, indent=4) + return json_dict diff --git a/TPTBox/core/internal/optional_deps.py b/TPTBox/core/internal/optional_deps.py new file mode 100644 index 00000000..f6efe50e --- /dev/null +++ b/TPTBox/core/internal/optional_deps.py @@ -0,0 +1,76 @@ +"""Helpers for degrading gracefully when an optional dependency is absent. + +TPTBox's core is deliberately installable without the heavy optional stacks +(DICOM conversion, nnU-Net/SPINEPS inference, deepali registration). Importing a +sub-package must therefore succeed even when its backend is missing - only +*using* an entry point should fail, and it should fail with an actionable +message instead of a bare ``ModuleNotFoundError`` from three frames deep. + +``TPTBox.registration`` grew the original version of this pattern; these two +factories are the reusable form of it. +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +__all__ = ["missing_dependency_class", "missing_dependency_func"] + + +def _message(name: str, extra: str, packages: str, original_error: str, call: str) -> str: + return ( + f"`{name}{call}` requires optional dependencies that are not installed.\n" + f" pip install 'TPTBox[{extra}]'\n" + f"or install them directly:\n" + f" pip install {packages}\n" + f"Original import error was: {original_error}" + ) + + +def missing_dependency_func(name: str, exc: BaseException, extra: str, packages: str) -> Callable[..., Any]: + """Return a callable stub that raises a helpful ``ImportError`` when called. + + Args: + name: Name of the entry point being replaced. + exc: The original ``ImportError``, quoted in the message. + extra: The ``pip install 'TPTBox[...]'`` extra that provides the backend. + packages: Space-separated package names, for a direct pip install. + """ + original_error = str(exc) or exc.__class__.__name__ + + def _stub(*_args: Any, **_kwargs: Any) -> Any: + raise ImportError(_message(name, extra, packages, original_error, "()")) + + _stub.__name__ = name + _stub.__qualname__ = name + _stub._tptbox_missing_extra = extra # type: ignore[attr-defined] + _stub._tptbox_import_error = original_error # type: ignore[attr-defined] + return _stub + + +def missing_dependency_class(name: str, exc: BaseException, extra: str, packages: str) -> type: + """Return a class placeholder that raises on instantiation or attribute access. + + ``isinstance``/``issubclass`` checks stay safe - the stub is a plain class. + """ + original_error = str(exc) or exc.__class__.__name__ + + class _Missing: + __name__ = name + __qualname__ = name + _tptbox_missing_extra = extra + _tptbox_import_error = original_error + + def __init__(self, *_args: Any, **_kwargs: Any) -> None: + raise ImportError(_message(name, extra, packages, original_error, "()")) + + def __class_getitem__(cls, item): # keep type annotations happy + return cls + + def __getattr__(self, item): + raise ImportError(_message(f"{name}.{item}", extra, packages, original_error, "")) + + _Missing.__name__ = name + _Missing.__qualname__ = name + return _Missing diff --git a/TPTBox/core/nii_wrapper.py b/TPTBox/core/nii_wrapper.py index db5e6f1c..b0973673 100755 --- a/TPTBox/core/nii_wrapper.py +++ b/TPTBox/core/nii_wrapper.py @@ -951,6 +951,12 @@ def apply_center_crop(self, center_shape: tuple[int, int, int], inplace=False, v return self.set_array(arr_cropped, inplace=inplace) #return self.apply_crop(crop_slices, inplace=inplace) + def compute_crop_slice(self, *args, **qargs) -> tuple[slice, slice, slice]: + """Deprecated alias for `compute_crop`.""" + import warnings + warnings.warn("compute_crop_slice is deprecated use compute_crop instead",stacklevel=5) #TODO remove in version 1.0 + return self.compute_crop(*args,**qargs) + def apply_crop_slice(self, *args, **qargs) -> Self: """Deprecated alias for `apply_crop`.""" import warnings diff --git a/TPTBox/registration/README.md b/TPTBox/registration/README.md index 90c538ba..f350749f 100644 --- a/TPTBox/registration/README.md +++ b/TPTBox/registration/README.md @@ -1,7 +1,9 @@ # Registration (`TPTBox.registration`) Image registration utilities supporting rigid (point- and intensity-based) and deformable -registration. Wraps ANTs (via SimpleITK) and the optional DeepALI deep learning backend. +registration. Point registration is built on SimpleITK; every intensity-based and +deformable backend is built on [DeepALI](https://github.com/BioMedIA/deepali) (PyTorch) and +needs the optional `hf-deepali` package. ## Public API @@ -10,8 +12,12 @@ from TPTBox.registration import ( Point_Registration, ridged_points_from_poi, ridged_points_from_subreg_vert, - Deformable_Registration, - Template_Registration, + Deepali_Point_Registration, # requires hf-deepali + ridged_points_from_poi_deepali, # requires hf-deepali + ridged_points_from_subreg_vert_deepali, # requires hf-deepali + Deformable_Registration, # requires hf-deepali + Template_Registration, # requires hf-deepali + Template_Registration2, # requires hf-deepali General_Registration, # requires hf-deepali Rigid_Elements_Registration, # requires hf-deepali ) @@ -22,17 +28,21 @@ from TPTBox.registration import ( | Symbol | Module | Description | |---|---|---| | `Point_Registration` | `_ridged_points/point_registration.py` | Rigid registration from paired 3D landmark sets | -| `ridged_points_from_poi(fixed, moving, poi_fixed, poi_moving)` | same | Convenience wrapper: align two NIIs using POI correspondences | +| `ridged_points_from_poi(poi_fixed, poi_moving, ...)` | same | Convenience wrapper: rigid transform from two POI sets | | `ridged_points_from_subreg_vert(...)` | same | Same but derives POIs from vertebra+subregion segmentations automatically | -| `Deformable_Registration` | `_deformable/deformable_reg.py` | ANTs-based deformable (SyN) registration | -| `Template_Registration` | `_deformable/deformable_reg.py` | Deformable registration to an atlas/template | -| `General_Registration` | `_deepali/` | DeepALI deep-learning registration (requires `hf-deepali`) | -| `Rigid_Elements_Registration` | `_deepali/` | Per-element rigid registration via DeepALI | +| `Deepali_Point_Registration` | `_ridged_points/deepali_point_registration.py` | Closed-form point registration on the DeepALI backend (requires `hf-deepali`) | +| `ridged_points_from_poi_deepali(...)` | same | DeepALI variant of `ridged_points_from_poi` (requires `hf-deepali`) | +| `ridged_points_from_subreg_vert_deepali(...)` | same | DeepALI variant of `ridged_points_from_subreg_vert` (requires `hf-deepali`) | +| `Deformable_Registration` | `_deformable/deformable_reg.py` | DeepALI/PyTorch deformable registration (requires `hf-deepali`) | +| `Template_Registration` | `_deformable/multilabel_segmentation.py` | Deformable registration to an atlas/template (requires `hf-deepali`) | +| `Template_Registration2` | `_deformable/multilabel_segmentation.py` | Variant of `Template_Registration` with an optional pre-registration (requires `hf-deepali`) | +| `General_Registration` | `_deepali/deepali_model.py` | DeepALI deep-learning registration (requires `hf-deepali`) | +| `Rigid_Elements_Registration` | `_deepali/spine_rigid_elements_reg.py` | Per-element rigid registration via DeepALI (requires `hf-deepali`) | ## Installation of optional dependency ```bash -pip install hf-deepali # only needed for General_Registration / Rigid_Elements_Registration +pip install torch hf-deepali # needed by every entry point except Point_Registration ``` ## Example diff --git a/TPTBox/segmentation/README.md b/TPTBox/segmentation/README.md index 9edad320..90767909 100644 --- a/TPTBox/segmentation/README.md +++ b/TPTBox/segmentation/README.md @@ -9,7 +9,6 @@ over SPINEPS, VibeSeg/TotalVibeSeg, and nnU-Net. from TPTBox.segmentation import ( run_spineps, run_vibeseg, - run_totalvibeseg, run_nnunet, run_inference_on_file, extract_vertebra_bodies_from_VibeSeg, @@ -21,9 +20,8 @@ from TPTBox.segmentation import ( | Function | Module | Description | |---|---|---| -| `run_spineps(img_nii, model, ...)` | `spineps.py` | Run SPINEPS spine segmentation on a NIfTI; returns vertebra + subregion masks | +| `run_spineps(file_path, dataset=None, ...)` | `spineps.py` | Run SPINEPS spine segmentation on a file path / `BIDS_FILE`; returns a `dict` of output paths | | `run_vibeseg(img_nii, ...)` | `VibeSeg/vibeseg.py` | Run VibeSeg body composition segmentation | -| `run_totalvibeseg(img_nii, ...)` | `VibeSeg/vibeseg.py` | Run TotalVibeSeg — extended label set | | `run_nnunet(img_nii, model_dir, ...)` | `VibeSeg/vibeseg.py` | Generic nnU-Net inference on a single NIfTI | | `run_inference_on_file(path, ...)` | `nnUnet_utils/inference_api.py` | Low-level nnU-Net inference on a file path | | `add_ribs_to_vert_spine(vert, spine, ...)` | `rib/add_ribs.py` | Merge left/right rib labels into an existing vertebra + spine segmentation; optionally runs VibeSeg (dataset 12) on the source CT to obtain the raw rib mask | @@ -33,22 +31,29 @@ from TPTBox.segmentation import ( | Pipeline | Requirement | |---|---| | SPINEPS | `pip install spineps` + model weights | -| VibeSeg | `pip install nnunetv2` + model weights (auto-downloaded on first run) | -| Generic nnU-Net | `pip install nnunetv2` + custom model directory | +| VibeSeg | `pip install "TPTBox[seg]"` + model weights (auto-downloaded on first run) | +| Generic nnU-Net | `pip install "TPTBox[seg]"` + custom model directory | | Rib assignment (`add_ribs_to_vert_spine`) | calls into VibeSeg/SPINEPS if the segmentation is missing. | -All external tools are imported lazily — the core TPTBox package installs and imports cleanly -without them. +All external tools are optional: `import TPTBox.segmentation` succeeds without them, and each +entry point only raises (naming the extra to install) when it is actually called. + +Note the nnU-Net pin. `TPTBox.segmentation.nnUnet_utils` mirrors the nnU-Net v2.4 plans/trainer +layout; the `seg` extra therefore requires `nnunetv2>=2.4,<2.5`. ## Example ```python -from TPTBox import NII +from TPTBox import to_nii from TPTBox.segmentation import run_spineps -ct = NII.load("ct.nii.gz", seg=False) -vert_seg, subreg_seg = run_spineps(ct, model="small") -vert_seg.save("vertebrae.nii.gz") +# run_spineps takes a *path* (or BIDS_FILE), not an NII, and writes its results +# into the dataset's derivatives folder. It returns the output paths it produced. +output_paths = run_spineps("sub-001_T2w.nii.gz", model_semantic="t2w") + +vert_seg = to_nii(output_paths["out_vert"], seg=True) +subreg_seg = to_nii(output_paths["out_spine"], seg=True) +print(vert_seg.unique()) ``` diff --git a/TPTBox/segmentation/__init__.py b/TPTBox/segmentation/__init__.py index 0dd197d8..be46367b 100644 --- a/TPTBox/segmentation/__init__.py +++ b/TPTBox/segmentation/__init__.py @@ -1,5 +1,52 @@ +"""Segmentation integrations (SPINEPS, VibeSeg, nnU-Net, ribs). + +These entry points need the optional ``seg`` extra (``torch``, ``nnunetv2``, +``acvl_utils``, ``batchgenerators``) and, for SPINEPS, the ``spineps`` package. +Importing this sub-package must not require any of them - otherwise a plain +``import TPTBox.segmentation`` fails on a clean install - so each entry point is +replaced by a stub that explains what to install if its backend is missing. +""" + from __future__ import annotations -from TPTBox.segmentation.rib.add_ribs import add_ribs_to_vert_spine -from TPTBox.segmentation.spineps import _run_spineps_all, get_outpaths_spineps, run_spineps -from TPTBox.segmentation.VibeSeg.vibeseg import extract_vertebra_bodies_from_VibeSeg, run_inference_on_file, run_nnunet, run_vibeseg +from TPTBox.core.internal.optional_deps import missing_dependency_func + +_SEG_PACKAGES = "torch nnunetv2 acvl_utils batchgenerators" + +try: + from TPTBox.segmentation.rib.add_ribs import add_ribs_to_vert_spine +except ImportError as _e_rib: + add_ribs_to_vert_spine = missing_dependency_func("add_ribs_to_vert_spine", _e_rib, "seg", _SEG_PACKAGES) # type: ignore[assignment] + +try: + from TPTBox.segmentation.spineps import _run_spineps_all, get_outpaths_spineps, run_spineps +except ImportError as _e_spineps: + _run_spineps_all = missing_dependency_func("_run_spineps_all", _e_spineps, "seg", "spineps") # type: ignore[assignment] + get_outpaths_spineps = missing_dependency_func("get_outpaths_spineps", _e_spineps, "seg", "spineps") # type: ignore[assignment] + run_spineps = missing_dependency_func("run_spineps", _e_spineps, "seg", "spineps") # type: ignore[assignment] + +try: + from TPTBox.segmentation.VibeSeg.vibeseg import ( + extract_vertebra_bodies_from_VibeSeg, + run_inference_on_file, + run_nnunet, + run_vibeseg, + ) +except ImportError as _e_vibe: + extract_vertebra_bodies_from_VibeSeg = missing_dependency_func( # noqa: N816 # type: ignore[assignment] + "extract_vertebra_bodies_from_VibeSeg", _e_vibe, "seg", _SEG_PACKAGES + ) + run_inference_on_file = missing_dependency_func("run_inference_on_file", _e_vibe, "seg", _SEG_PACKAGES) # type: ignore[assignment] + run_nnunet = missing_dependency_func("run_nnunet", _e_vibe, "seg", _SEG_PACKAGES) # type: ignore[assignment] + run_vibeseg = missing_dependency_func("run_vibeseg", _e_vibe, "seg", _SEG_PACKAGES) # type: ignore[assignment] + +__all__ = [ + "_run_spineps_all", + "add_ribs_to_vert_spine", + "extract_vertebra_bodies_from_VibeSeg", + "get_outpaths_spineps", + "run_inference_on_file", + "run_nnunet", + "run_spineps", + "run_vibeseg", +] diff --git a/TPTBox/segmentation/nnUnet_utils/export_prediction.py b/TPTBox/segmentation/nnUnet_utils/export_prediction.py index a7503fdd..4aa3ba90 100755 --- a/TPTBox/segmentation/nnUnet_utils/export_prediction.py +++ b/TPTBox/segmentation/nnUnet_utils/export_prediction.py @@ -32,8 +32,10 @@ def _argmax_with_gpu_fallback( empty_cache(device) def _get_free_vram(device: torch.device) -> int: + """Return free VRAM in bytes, or 0 when the device has none to report.""" + if device is None or device.type != "cuda": + return 0 try: - """Returns free VRAM in bytes.""" free, _ = torch.cuda.mem_get_info(device) return int(free * SAFETY_FACTOR) except Exception: @@ -72,7 +74,15 @@ def _chunked_argmax_cpu(t: torch.Tensor | np.ndarray) -> np.ndarray: t = _to_cpu_tensor(predicted_logits) - if device is None or not torch.cuda.is_available(): + # Dispatch on the *requested* device, not merely on CUDA availability: an + # explicit device="cpu" on a machine that happens to have a GPU must still + # take the CPU path (torch.cuda.mem_get_info would reject the argument). + if isinstance(device, str): + device = torch.device(device) + _accel = device is not None and ( + (device.type == "cuda" and torch.cuda.is_available()) or device.type == "mps" + ) + if not _accel: return _chunked_argmax_cpu(t) full_bytes = _array_bytes(t.shape) diff --git a/TPTBox/segmentation/nnUnet_utils/predictor.py b/TPTBox/segmentation/nnUnet_utils/predictor.py index 610381a3..3009ec46 100755 --- a/TPTBox/segmentation/nnUnet_utils/predictor.py +++ b/TPTBox/segmentation/nnUnet_utils/predictor.py @@ -32,15 +32,41 @@ logger = Print_Logger() +def _is_cuda(device) -> bool: + """Return ``True`` only for CUDA devices. + + ``torch.cuda.mem_get_info`` rejects its *argument* rather than probing the + environment, so it raises ``ValueError: Expected a cuda device, but got: cpu`` + even on a machine that has a GPU. Every caller of the two helpers below must + therefore dispatch on the device type, exactly like :func:`empty_cache`. + """ + if isinstance(device, str): + device = torch.device(device) + return isinstance(device, torch.device) and device.type == "cuda" + + def get_gpu_memory_MB(device) -> float: - """Return the amount of free GPU memory in megabytes for the given device.""" + """Return the amount of free GPU memory in megabytes for the given device. + + Non-CUDA devices (CPU, MPS) have no VRAM to report and return ``inf``, which + makes the ``check_mem`` sizing heuristic fall back to the caller's + ``memory_max`` cap instead of chunking against a meaningless number. + """ + if not _is_cuda(device): + return float("inf") free, total = torch.cuda.mem_get_info(device) # print(f"{free=}", f"{total=}") return free / 1024**2 def get_gpu_util(device) -> float: - """Return the fraction of GPU memory currently in use (0.0 = idle, 1.0 = full).""" + """Return the fraction of GPU memory currently in use (0.0 = idle, 1.0 = full). + + Non-CUDA devices report ``0.0`` so the "wait until the GPU frees up" loop is + skipped entirely on CPU/MPS. + """ + if not _is_cuda(device): + return 0.0 free, total = torch.cuda.mem_get_info(device) # print(f"{free=}", f"{total=}") return 1 - free / total diff --git a/TPTBox/spine/README.md b/TPTBox/spine/README.md index 1ddca3ac..bed13a92 100644 --- a/TPTBox/spine/README.md +++ b/TPTBox/spine/README.md @@ -7,13 +7,13 @@ Contains two sub-modules: 2D snapshot generation and statistical spine measureme | Sub-module | Description | |---|---| -| [`snapshot2D/`](snapshot2D/README.md) | Modular 2D image snapshot generation (slices, MIPs, overlays) | -| [`spinestats/`](spinestats/README.md) | Clinical measurements: distances, Cobb angles, IVD POIs, endplates | +| [`snapshot2D/`](https://github.com/Hendrik-code/TPTBox/blob/main/TPTBox/spine/snapshot2D/README.md) | Modular 2D image snapshot generation (slices, MIPs, overlays) | +| [`spinestats/`](https://github.com/Hendrik-code/TPTBox/blob/main/TPTBox/spine/spinestats/README.md) | Clinical measurements: distances, Cobb angles, IVD POIs, endplates | ## Quick Example ```python -from TPTBox import NII, calc_centroids +from TPTBox import NII from TPTBox.spine.snapshot2D.snapshot_modular import Snapshot_Frame, create_snapshot ct = NII.load("ct.nii.gz", seg=False) @@ -21,7 +21,7 @@ seg = NII.load("seg.nii.gz", seg=True) # Generate a 2D sagittal snapshot with a segmentation overlay create_snapshot( + "snapshot.png", [Snapshot_Frame(image=ct, segmentation=seg, mode="CT")], - to="snapshot.png", ) ``` diff --git a/TPTBox/spine/snapshot2D/README.md b/TPTBox/spine/snapshot2D/README.md index 9f06df6b..ea0e7cc6 100644 --- a/TPTBox/spine/snapshot2D/README.md +++ b/TPTBox/spine/snapshot2D/README.md @@ -11,20 +11,24 @@ maximum intensity projections (MIPs), and segmentation overlays. |---|---|---| | `create_snapshot` | `snapshot_modular.py` | Main entry point — renders a list of `Snapshot_Frame` objects to a PNG | | `Snapshot_Frame` | `snapshot_modular.py` | Configuration for one image panel (image, overlay, view direction, …) | -| `Plane` | `snapshot_modular.py` | Enum: `Plane.axial`, `Plane.sagittal`, `Plane.coronal` | -| `to_image_nii` | `snapshot_modular.py` | Convert a NIfTI slice to a matplotlib-ready RGB array | +| `Visualization_Type` | `snapshot_modular.py` | Enum selecting how a frame is rendered: `Slice`, `Maximum_Intensity`, `Mean_Intensity`, … | | Pre-built templates | `snapshot_templates.py` | Ready-to-use snapshot configurations for common spine workflows | ## Example ```python -from TPTBox.spine.snapshot2D.snapshot_modular import Snapshot_Frame, create_snapshot, Plane +from TPTBox import to_nii +from TPTBox.spine.snapshot2D import Snapshot_Frame, create_snapshot +ct = to_nii("ct.nii.gz") +seg = to_nii("seg.nii.gz", seg=True) + +# The views are boolean flags on the frame, and the output path comes first. frames = [ - Snapshot_Frame(image=ct, segmentation=seg, mode="CT", plane=Plane.sagittal), - Snapshot_Frame(image=ct, mode="CT", plane=Plane.axial), + Snapshot_Frame(image=ct, segmentation=seg, mode="CT", sagittal=True), + Snapshot_Frame(image=ct, mode="CT", sagittal=False, axial=True), ] -create_snapshot(frames, to="output.png") +create_snapshot("output.png", frames) ``` More extensive: diff --git a/TPTBox/spine/snapshot2D/snapshot_modular.py b/TPTBox/spine/snapshot2D/snapshot_modular.py index 2dc11e48..f1f14333 100755 --- a/TPTBox/spine/snapshot2D/snapshot_modular.py +++ b/TPTBox/spine/snapshot2D/snapshot_modular.py @@ -268,6 +268,27 @@ def curve_projected_slice( ) +def _mm_to_voxel_thickness(thick_mm, y_zoom: float) -> list[int]: + """Convert slab half-widths from millimetres to voxels (ceiling division). + + Always call this with the *millimetre* values. Feeding the returned voxel + counts back in compounds the division once per call: with ``y_zoom < 1`` the + slab grows geometrically until ``int()`` raises ``OverflowError``, and with + ``y_zoom > 1`` it shrinks towards the 1-voxel floor below, so the projection + quietly uses a far thinner slab than the caller asked for. + + Args: + thick_mm: Anterior/posterior slab half-widths in mm. + y_zoom: Voxel spacing along the anterior-posterior axis, in mm/voxel. + + Returns: + The half-widths in voxels, at least 1 voxel each. + """ + if not np.isfinite(y_zoom) or y_zoom <= 0: + y_zoom = 1.0 + return [max(1, int(i // y_zoom) + int(i % y_zoom > 0)) for i in thick_mm] + + def curve_projected_mean( img_data: np.ndarray, zms: tuple[float, float, float], @@ -302,7 +323,9 @@ def curve_projected_mean( cor_plane = np.zeros((shp[0], shp[2])) sag_plane = np.zeros((shp[0], shp[1])) y_zoom = zms[1] # 0.9 = 1px = 0.9 mm # 10cm = 112px - thick = (*thick_t,) + # `thick_mm` stays in millimetres for the whole loop; the voxel counts go to a + # separate variable so the mm->voxel conversion is never applied to its own result. + thick_mm = (*thick_t,) for x in range(shp[0] - 1): if x < min(x_ctd): # higher @@ -313,12 +336,13 @@ def curve_projected_mean( y_ref = y_cord[x - min(x_ctd)] if 23 in ctd_list and x > int(ctd_list[23][1]): - thick = (100, 50) + thick_mm = (100, 50) - thick = [int(i // y_zoom) + int(i % y_zoom > 0) for i in thick] + thick = _mm_to_voxel_thickness(thick_mm, y_zoom) y_post_rel_to_border = y_ref + int(0.4 * (shp[1] - 1 - y_ref)) # one-third distance to border y_range_low = int(max(0, y_ref - thick[1])) # sagittal left y_range_high = int(min(y_ref + thick[0], y_post_rel_to_border)) # sagittal right + y_range_high = max(y_range_high, y_range_low + 1) # never hand np.nansum an empty axis cor_cut = img_data[x, y_range_low:y_range_high, :] plane_bool = np.zeros_like(cor_cut).astype(bool) @@ -376,7 +400,10 @@ def curve_projected_mip( sag_plane = np.zeros((shp[0], shp[1])) sag_depth_plane = np.zeros((shp[0], shp[1])) y_zoom = zms[1] # 0.9 = 1px = 0.9 mm # 10cm = 112px - thick = (*thick_t,) + # `thick_t` is in millimetres and never changes here, so convert once up front. + # (Converting inside the loop and assigning back to the same name divides the + # already-divided value once per slice - see _mm_to_voxel_thickness.) + thick = _mm_to_voxel_thickness(thick_t, y_zoom) for x in range(shp[0] - 1): if x < min(x_ctd): # higher @@ -387,18 +414,12 @@ def curve_projected_mip( y_ref = y_cord[x - min(x_ctd)] # if 23 in ctd_list and x > int(ctd_list[23][1]) and not make_colored_depth: - # thick = (100, 50) + # thick = _mm_to_voxel_thickness((100, 50), y_zoom) - # TODO set y_zoom for broken sample, see if it works - try: - thicke = [int(i // y_zoom) + int(i % y_zoom > 0) for i in thick] - except Exception: - print("thick infinity bug", y_zoom, thick_t, thick) - thicke = (*thick_t,) - thick = thicke y_post_rel_to_border = y_ref + int(0.4 * (shp[1] - 1 - y_ref)) # one-third distance to border y_range_low = int(max(0, y_ref - thick[1])) # sagittal left y_range_high = int(min(y_ref + thick[0], y_post_rel_to_border)) # sagittal right + y_range_high = max(y_range_high, y_range_low + 1) # never hand np.max an empty axis # print("range", y_range_low, y_range_high) cor_cut = img_data[x, y_range_low:y_range_high, :] diff --git a/TPTBox/stitching/README.md b/TPTBox/stitching/README.md index 18ebd8db..2191afb4 100644 --- a/TPTBox/stitching/README.md +++ b/TPTBox/stitching/README.md @@ -10,9 +10,9 @@ You can verify alignment by opening the images in ITKSnap with "open additional |---|---| | `stitching(nii_list, out, ...)` | Stitch a list of `NII` objects; returns `(result_nii, ramp_nii)` | | `stitching_raw(paths, out, ...)` | Stitch from file paths directly | -| `NAKO_stitch_T2w(nii_list, ...)` | NAKO-based stitching optimised for T2w spine MRI | +| `GNC_stitch_T2w(HWS, BWS, LWS, n4_after_stitch=False)` | Stitch the three German National Cohort sagittal T2w spine stations (cervical, thoracic, lumbar) into one volume | -![Example of a stitching](stitching.jpg?raw=true "Example of a stitching") +![Example of a stitching](https://raw.githubusercontent.com/Hendrik-code/TPTBox/main/TPTBox/stitching/stitching.jpg "Example of a stitching") ### Standalone diff --git a/docs/getting-started.md b/docs/getting-started.md index cd941669..1eb45da6 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -19,14 +19,38 @@ poetry install --with dev ### Optional dependencies +The core install stays light. Every optional backend is guarded: importing a sub-package always +succeeds, and only *calling* an entry point that needs a missing backend raises an `ImportError` +naming what to install. + ```bash +# DICOM -> NIfTI conversion (TPTBox.core.dicom) +pip install "TPTBox[dicom]" # pydicom, dicom2nifti + +# nnU-Net / VibeSeg inference (TPTBox.segmentation) +pip install "TPTBox[seg]" # torch, nnunetv2, acvl_utils, batchgenerators + +# Both at once +pip install "TPTBox[dicom,seg]" + # Deep learning registration (DeepALI) -pip install hf-deepali +pip install torch hf-deepali + +# SPINEPS spine segmentation +pip install spineps # 3D mesh visualisation pip install pyvista vtk + +# N4 bias-field correction and some NII resampling helpers +pip install antspyx ``` +!!! note "nnU-Net version" + `TPTBox.segmentation.nnUnet_utils` mirrors the nnU-Net v2.4 plans/trainer layout, so the + `seg` extra pins `nnunetv2>=2.4,<2.5`. Installing a newer nnU-Net alongside SPINEPS is the + usual cause of "SPINEPS and nnU-Net do not work together" errors. + ## Core Concepts ### NII — NIfTI image wrapper diff --git a/docs/index.md b/docs/index.md index edf2ec5e..8e6a0f72 100644 --- a/docs/index.md +++ b/docs/index.md @@ -16,7 +16,7 @@ TPTBox provides a unified interface for the most common tasks in medical image p - **Points of Interest (POI)** — compute and manipulate anatomical landmarks on vertebrae - **2D snapshots** — modular, multi-view MIP and overlay image generation - **3D mesh generation** — surface meshes from segmentations with configurable rendering -- **Registration** — rigid (point- and intensity-based) and deformable registration via ANTs and DeepALI +- **Registration** — rigid point registration via SimpleITK, plus intensity-based and deformable registration via DeepALI (optional `hf-deepali`) - **Segmentation** — integration with SPINEPS and nnU-Net inference pipelines - **Image stitching** — multi-station field-of-view stitching - **Logging** — structured, consistent logging across long-running pipelines @@ -34,8 +34,10 @@ nii_1mm = nii_ras.rescale((1.0, 1.0, 1.0)) # Iterate over a BIDS dataset bids = BIDS_Global_info(["path/to/dataset"], parents=["rawdata"]) for subject, container in bids.enumerate_subjects(): - t2w = container.new_query().filter("format", "T2w").first() - if t2w is not None: + query = container.new_query(flatten=True) + query.filter_format("T2w") + query.filter_filetype("nii.gz") + for t2w in query.loop_list(): nii = t2w.open_nii() ``` diff --git a/docs/modules/spine.md b/docs/modules/spine.md index 5a03170d..f0d6e4fb 100644 --- a/docs/modules/spine.md +++ b/docs/modules/spine.md @@ -1,27 +1 @@ -# Spine (`TPTBox.spine`) - -Spine-specific utilities built on top of the core `NII` and `POI` abstractions. -Contains two sub-modules: 2D snapshot generation and statistical spine measurements. - -## Sub-modules - -| Sub-module | Description | -|---|---| -| [`snapshot2D/`](snapshot2d.md) | Modular 2D image snapshot generation (slices, MIPs, overlays) | -| [`spinestats/`](spinestats.md) | Clinical measurements: distances, Cobb angles, IVD POIs, endplates | - -## Quick Example - -```python -from TPTBox import NII, calc_centroids -from TPTBox.spine.snapshot2D.snapshot_modular import Snapshot_Frame, create_snapshot - -ct = NII.load("ct.nii.gz", seg=False) -seg = NII.load("seg.nii.gz", seg=True) - -# Generate a 2D sagittal snapshot with a segmentation overlay -create_snapshot( - [Snapshot_Frame(image=ct, segmentation=seg, mode="CT")], - to="snapshot.png", -) -``` +--8<-- "TPTBox/spine/README.md" diff --git a/docs/modules/stitching.md b/docs/modules/stitching.md index 3c9149f4..b6092864 100644 --- a/docs/modules/stitching.md +++ b/docs/modules/stitching.md @@ -1,93 +1 @@ -# Stitching (`TPTBox.stitching`) - -Merges multiple NIfTI images that are already aligned in global space into a single volume. -Useful for whole-body or long-spine multi-station acquisitions. -You can verify alignment by opening the images in ITKSnap with "open additional image." - -## API - -| Function | Description | -|---|---| -| `stitching(nii_list, out, ...)` | Stitch a list of `NII` objects; returns `(result_nii, ramp_nii)` | -| `stitching_raw(paths, out, ...)` | Stitch from file paths directly | -| `GNC_stitch_T2w(nii_list, ...)` | GNC-based stitching optimised for T2w spine MRI | - -![Example of a stitching](https://raw.githubusercontent.com/Hendrik-code/TPTBox/main/TPTBox/stitching/stitching.jpg "Example of a stitching") - - -### Standalone -This script can be run directly from the console. Copy 'stitching.py' and install the necessary package. - -``` -stitching.py -[-h] print the help message -[-i IMAGES [IMAGES ...]] a list of input image paths -[-o OUTPUT] The output image path -[-v] verbose - if set, there will be more printouts. -[-min_value MIN_VALUE] New pixels not present will get this value. Recommended 0 for MRI and for CT -1024 or the known min-value. -[-seg] This flag is required if you merge segmentation Niftis. -Switches: -[-no_bias] If set: Do not use n4_bias_field_correction. It speeds up the process, but n4_bias_field_correction helps in roughly aligning the histogram. -[-bias_crop] crop empty spaces by the bias field mask. -[-crop] crop empty space away -[-sr] Store the ramp and stitching of the images in a 4d nii.gz -Optional: -[-hists] Use histogram matching to put the images in the roughly same histogram. The previous image is used when hist_n is not set. -[-hist_n HISTOGRAM_NAME] path to an image that should be used for histogram matching -[-ramp_e RAMP_EDGE_MIN_VALUE] The ramp is only considering values above this minimum value -[-ms MIN_SPACING] Set the minimum Spacing (in mm) -[-dtype DTYPE] Force a dtype -``` - -Example: - -Given the image a.nii.gz,b.nii.gz,c.nii.gz and the segmentations a_msk.nii.gz,b_msk.nii.gz,c_msk.nii.gz. The images can be merged with: - -```bash -stitching.py -i a.nii.gz b.nii.gz c.nii.gz -o out.nii.gz -stitching.py -i a_msk.nii.gz b_msk.nii.gz c_msk.nii.gz -o out_msk.nii.gz -seg -``` - -### Install as a package - -Install on Python 3.10 or higher -```bash -pip install TPTBox -``` - -```python -from TPTBox import NII -from TPTBox.stitching import stitching - -out_nii, _ = stitching( - [NII.load("a.nii.gz", seg=False), NII.load("b.nii.gz", seg=False), NII.load("c.nii.gz", seg=False)], out="out.nii.gz" -) -``` - -or - - -```python -from TPTBox.stitching import stitching_raw - -stitching_raw(["a.nii.gz", "b.nii.gz", "c.nii.gz"], "out.nii.gz", is_segmentation=False) -``` - - -### Cite -``` -Graf, R., Platzek, PS., Riedel, E.O. et al. Generating synthetic high-resolution spinal STIR and T1w images from T2w FSE and low-resolution axial Dixon. Eur Radiol (2024). https://doi.org/10.1007/s00330-024-11047-1 - -``` - -``` -@article{graf2024generating, - title={Generating synthetic high-resolution spinal STIR and T1w images from T2w FSE and low-resolution axial Dixon}, - author={Graf, Robert and Platzek, Paul-S{\"o}ren and Riedel, Evamaria Olga and Kim, Su Hwan and Lenhart, Nicolas and Ramsch{\"u}tz, Constanze and Paprottka, Karolin Johanna and Kertels, Olivia Ruriko and M{\"o}ller, Hendrik Kristian and Atad, Matan and others}, - journal={European Radiology}, - pages={1--11}, - year={2024}, - publisher={Springer} -} - -``` +--8<-- "TPTBox/stitching/README.md" diff --git a/pyproject.toml b/pyproject.toml index 96c104e3..5287118e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,6 +44,24 @@ scikit-image = [ { version = ">=0.24", python = ">=3.11" } ] +# --- OPTIONAL EXTRAS ------------------------------------------------------- +# Declared here so `pip install "TPTBox[dicom]"` resolves them, but marked +# optional so a plain install stays light. The matching sub-packages +# (TPTBox.core.dicom, TPTBox.segmentation) import successfully without these +# and only raise - naming the extra - when an entry point is actually called. +pydicom = { version = "*", optional = true } +dicom2nifti = { version = "*", optional = true } +torch = { version = "*", optional = true } +# nnU-Net 2.5 changed the plans/trainer layout that TPTBox.segmentation.nnUnet_utils +# mirrors; 2.4.2 is the version the tutorials are written against. +nnunetv2 = { version = ">=2.4,<2.5", optional = true } +acvl-utils = { version = "*", optional = true } +batchgenerators = { version = "*", optional = true } + +[tool.poetry.extras] +dicom = ["pydicom", "dicom2nifti"] +seg = ["torch", "nnunetv2", "acvl-utils", "batchgenerators"] + [tool.poetry.group.dev.dependencies] pytest = ">=8.1.1" # vtk 9.7.0 dropped cp39 wheels (and publishes no requires-python metadata, diff --git a/tutorials/seg_all.ipynb b/tutorials/seg_all.ipynb index 45418463..a5d53587 100644 --- a/tutorials/seg_all.ipynb +++ b/tutorials/seg_all.ipynb @@ -29,7 +29,7 @@ "\n", "# Segmentation\n", "run_spineps = False # Must be installed https://github.com/Hendrik-code/spineps\n", - "run_totalvibeseg = True # Must be installed https://github.com/Hendrik-code/spineps\n", + "run_vibeseg = True # Uses the bundled VibeSeg weights (auto-downloaded); needs TPTBox[seg]\n", "\n", "# Analysis\n", "run_cobb_and_lordosis_and_kyphosis = True and run_spineps" @@ -44,7 +44,7 @@ "nii_dataset = Path(nii_dataset)\n", "### extract dicom ###\n", "if dicom_dataset is not None:\n", - " dicom_extract.extract_folder(dicom_dataset, nii_dataset)" + " dicom_extract.extract_dicom_folder(dicom_dataset, nii_dataset)" ] }, { @@ -68,7 +68,7 @@ " )\n", " else:\n", " assert l[0].get_num_dims() == 3, l[0].shape\n", - " stitching(*l, out=out, dtype=l[0].dtype)\n", + " stitching(l, out=out, dtype=l[0].dtype)\n", " sequence_splitting_keys.remove(\"sequ\")\n", " print(\"Files are splitted by this keywords:\",sequence_splitting_keys)\n", " bgi = BIDS_Global_info(nii_dataset, sequence_splitting_keys=sequence_splitting_keys)\n", @@ -100,8 +100,8 @@ "outputs": [], "source": [ "if run_spineps:\n", - " from TPTBox.segmentation.spineps import run_spineps_all\n", - " run_spineps_all(nii_dataset)" + " from TPTBox.segmentation import _run_spineps_all\n", + " _run_spineps_all(nii_dataset)" ] }, { @@ -110,7 +110,7 @@ "metadata": {}, "outputs": [], "source": [ - "if run_totalvibeseg:\n", + "if run_vibeseg:\n", " bgi = BIDS_Global_info(nii_dataset, sequence_splitting_keys=sequence_splitting_keys)\n", " for _name, subject in bgi.enumerate_subjects():\n", " q = subject.new_query(flatten=True)\n", @@ -121,7 +121,7 @@ " out_seg = i.get_changed_path(\"nii.gz\",\"msk\",parent=\"derivatives\",info={\"seg\":\"TotalVibeSeg80\"},non_strict_mode=True)\n", " if out_seg.exists():\n", " continue\n", - " from TPTBox.segmentation.TotalVibeSeg.inference_nnunet import run_inference_on_file\n", + " from TPTBox.segmentation import run_inference_on_file\n", " run_inference_on_file(80,[to_nii(i)],out_file=out_seg)" ] }, @@ -137,7 +137,7 @@ "if run_cobb_and_lordosis_and_kyphosis:\n", " ### TODO ###\n", " ############\n", - " from TPTBox.spine.statistics.angles import plot_cobb_and_lordosis_and_kyphosis\n", + " from TPTBox.spine.spinestats.angles import plot_cobb_and_lordosis_and_kyphosis\n", " bgi = BIDS_Global_info(nii_dataset)\n", " for _name, subject in bgi.enumerate_subjects():\n", " q = subject.new_query()\n", @@ -197,71 +197,6 @@ "outputs": [], "source": [] }, - { - "cell_type": "code", - "execution_count": 1, - "metadata": {}, - "outputs": [], - "source": [ - "from TPTBox.segmentation.oar_segmentator.run import run_oar_segmentor\n", - "\n" - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "resample\n", - "start\n" - ] - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "Predict oar segmentation: 100%|██████████| 9/9 [01:38<00:00, 10.92s/it]\n", - "Saving segmentations: 0%| | 0/9 [00:00 ``TPTBox.registration._deformable`` or +``run_totalvibeseg`` -> ``run_vibeseg`` therefore invalidated notebooks and +README snippets silently. + +These tests parse (never execute) every notebook and Markdown code block and +assert that: + +* every ``TPTBox.*`` module referenced actually exists; +* every symbol imported from a TPTBox module actually exists; +* every Markdown ``python`` block still compiles. + +Nothing here needs a dataset, model weights, or a GPU, so it runs in the normal +pytest job. Executing the notebooks end to end is a separate concern - they +expect real BIDS datasets at site-specific paths. +""" + +from __future__ import annotations + +import ast +import importlib +import importlib.util +import json +import pkgutil +import re +import unittest +from pathlib import Path + +import TPTBox + +REPO_ROOT = Path(__file__).resolve().parent.parent + +NOTEBOOKS = sorted(REPO_ROOT.glob("tutorials/**/*.ipynb")) + sorted(REPO_ROOT.glob("examples/**/*.ipynb")) +MARKDOWN = sorted(REPO_ROOT.glob("docs/**/*.md")) + sorted(REPO_ROOT.glob("TPTBox/**/*.md")) + [REPO_ROOT / "README.md"] + +# Third-party imports that the examples legitimately use but TPTBox does not depend on. +# Their availability is not what these tests are about. +OPTIONAL_THIRD_PARTY = { + "spineps", + "torch", + "nnunetv2", + "acvl_utils", + "batchgenerators", + "deepali", + "pydicom", + "dicom2nifti", + "antspyx", + "ants", + "elasticdeform", + "networkx", + "IPython", + "ruamel", + "configargparse", + "TypeSaveArgParse", + "cv2", + "pandas", + "seaborn", + "plotly", + "torchio", + "monai", + "nnunet", + "totalsegmentator", + # dev-group extras: present in CI, absent for a plain `pip install TPTBox` + "pyvista", + "vtk", + "fury", + "xvfbwrapper", + "PIL", + "Pillow", +} + + +def _iter_code_cells(nb_path: Path): + """Yield the source of every code cell in a notebook, IPython magics stripped.""" + nb = json.loads(nb_path.read_text(encoding="utf-8")) + for idx, cell in enumerate(nb.get("cells", [])): + if cell.get("cell_type") != "code": + continue + lines = [ln for ln in cell.get("source", []) if not ln.lstrip().startswith(("%", "!", "?"))] + src = "".join(lines) + if src.strip(): + yield idx, src + + +def _iter_python_blocks(md_path: Path): + """Yield every fenced ```python block in a Markdown file.""" + text = md_path.read_text(encoding="utf-8") + for match in re.finditer(r"```(?:python|py)\n(.*?)```", text, re.DOTALL): + block = match.group(1) + if block.strip(): + yield text[: match.start()].count("\n") + 1, block + + +def _parse(src: str) -> ast.Module | None: + """Parse a snippet, tolerating the fragments that documentation is written in.""" + try: + return ast.parse(src) + except SyntaxError: + return None + + +def _tptbox_imports(tree: ast.Module): + """Yield ``(module, [names])`` for every TPTBox import in the tree. + + ``names`` is empty for a plain ``import TPTBox.x.y``. + """ + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + if alias.name.split(".")[0] == "TPTBox": + yield alias.name, [] + elif isinstance(node, ast.ImportFrom): + if node.level: # relative import inside a snippet - nothing to resolve + continue + if node.module and node.module.split(".")[0] == "TPTBox": + yield node.module, [a.name for a in node.names] + + +def _module_exists(name: str) -> bool: + """Does ``name`` resolve to a real module? + + ``find_spec`` imports the parent packages on the way down, so a parent whose + optional backend is missing raises here. That is not the same thing as the + module being gone, and must not be reported as drift - otherwise this gate + would pass only on machines with the full ML stack installed. + """ + try: + return importlib.util.find_spec(name) is not None + except ImportError as e: + missing = getattr(e, "name", None) + if not missing: # some libraries raise a bare ImportError + m = re.search(r"No module named '([^']+)'", str(e)) + missing = m.group(1) if m else "" + return bool(missing) and missing.split(".")[0] in OPTIONAL_THIRD_PARTY + except ValueError: + return False + + +def _symbol_exists(module: str, symbol: str) -> bool: + """Is ``symbol`` importable from ``module`` (as an attribute or a sub-module)?""" + try: + mod = importlib.import_module(module) + except Exception: + return True # an optional backend is missing; not a drift failure + if hasattr(mod, symbol): + return True + return _module_exists(f"{module}.{symbol}") + + +class TestPackageImports(unittest.TestCase): + """Every TPTBox sub-package must import on a bare install.""" + + def test_all_subpackages_import(self): + failures = [] + for info in pkgutil.walk_packages(TPTBox.__path__, prefix="TPTBox."): + name = info.name + # Scripts and vendored backends are allowed to need their optional stack. + if any(part.startswith("_") for part in name.split(".")[1:]): + continue + if ".tests" in name or ".nnUnet_utils" in name or name.endswith(("__main__", "script_ax2sag")): + continue + try: + importlib.import_module(name) + except ImportError as e: + if any(dep in str(e) for dep in OPTIONAL_THIRD_PARTY): + continue + failures.append(f"{name}: {e}") + except Exception as e: # noqa: BLE001 - report, do not mask + failures.append(f"{name}: {type(e).__name__}: {e}") + self.assertEqual(failures, [], "sub-packages failed to import:\n" + "\n".join(failures)) + + def test_public_api_names_resolve(self): + missing = [n for n in TPTBox.__all__ if not hasattr(TPTBox, n)] + self.assertEqual(missing, [], f"TPTBox.__all__ names that do not exist: {missing}") + + +class TestNotebooks(unittest.TestCase): + """Notebooks must reference modules and symbols that still exist.""" + + def test_notebooks_parse(self): + for nb in NOTEBOOKS: + for idx, src in _iter_code_cells(nb): + with self.subTest(notebook=nb.name, cell=idx): + self.assertIsNotNone(_parse(src), f"cell {idx} of {nb.name} is not valid Python") + + def test_notebook_tptbox_imports_resolve(self): + failures = [] + for nb in NOTEBOOKS: + for idx, src in _iter_code_cells(nb): + tree = _parse(src) + if tree is None: + continue + for module, names in _tptbox_imports(tree): + if not _module_exists(module): + failures.append(f"{nb.relative_to(REPO_ROOT)} cell {idx}: no module {module!r}") + continue + failures.extend( + f"{nb.relative_to(REPO_ROOT)} cell {idx}: {module!r} has no {n!r}" for n in names if not _symbol_exists(module, n) + ) + self.assertEqual(failures, [], "notebook API drift:\n" + "\n".join(failures)) + + +class TestMarkdownSnippets(unittest.TestCase): + """README and docs code blocks must compile and reference real symbols.""" + + def test_python_blocks_compile(self): + failures = [] + for md in MARKDOWN: + for line, block in _iter_python_blocks(md): + try: + compile(block, f"{md.name}:{line}", "exec") + except SyntaxError as e: + failures.append(f"{md.relative_to(REPO_ROOT)}:{line}: {e.msg}") + self.assertEqual(failures, [], "Markdown snippets with syntax errors:\n" + "\n".join(failures)) + + def test_markdown_tptbox_imports_resolve(self): + failures = [] + for md in MARKDOWN: + for line, block in _iter_python_blocks(md): + tree = _parse(block) + if tree is None: + continue + for module, names in _tptbox_imports(tree): + if not _module_exists(module): + failures.append(f"{md.relative_to(REPO_ROOT)}:{line}: no module {module!r}") + continue + failures.extend( + f"{md.relative_to(REPO_ROOT)}:{line}: {module!r} has no {n!r}" for n in names if not _symbol_exists(module, n) + ) + self.assertEqual(failures, [], "documentation API drift:\n" + "\n".join(failures)) + + +if __name__ == "__main__": + unittest.main() diff --git a/unit_tests/test_nii_wrapper_auto.py b/unit_tests/test_nii_wrapper_auto.py index 9941b831..a21dc387 100755 --- a/unit_tests/test_nii_wrapper_auto.py +++ b/unit_tests/test_nii_wrapper_auto.py @@ -181,8 +181,8 @@ def test_map_labels_with_none_nii(self): nii = NII(None) nii.map_labels({1: 2}) - # Test that the compute_crop_slice method returns the correct crop slice for the image. - def test_compute_crop_slice(self): + # Test that the compute_crop method returns the correct crop slice for the image. + def test_compute_crop(self): # Create a test image arr = np.zeros((100, 100, 100)) s = (slice(5, 95), slice(5, 95), slice(5, 95)) @@ -212,8 +212,8 @@ def test_map_labels(self): expected_data = np.array([[[0, 10, 10, 0], [0, 20, 20, 0], [0, 30, 30, 0], [0, 0, 0, 0]]], dtype=np.uint16) assert np.array_equal(mapped_mask.get_seg_array(), expected_data) - # Test that the apply_crop_slice method correctly applies the crop slice to the image. - def test_apply_crop_slice(self): + # Test that the apply_crop method correctly applies the crop slice to the image. + def test_apply_crop(self): # Create a test image image = NII(Nifti1Image(np.zeros((100, 100, 100)), np.eye(4)), seg=True) diff --git a/unit_tests/test_snapshot_mip.py b/unit_tests/test_snapshot_mip.py new file mode 100644 index 00000000..a47437f1 --- /dev/null +++ b/unit_tests/test_snapshot_mip.py @@ -0,0 +1,142 @@ +"""Regression tests for the mm -> voxel slab-thickness conversion in the curved-planar projections. + +Both ``curve_projected_mip`` and ``curve_projected_mean`` used to convert the slab +half-width from millimetres to voxels *inside* their per-slice loop and assign the +result back to the same variable, so every slice re-divided the already-divided +value. With ``y_zoom < 1`` the slab grew geometrically until ``int()`` raised +``OverflowError: int too big to convert``; with ``y_zoom > 1`` it shrank towards the +1-voxel floor imposed by the ceiling term, so the projection quietly used a far +thinner slab than the caller asked for and raised nothing at all. +""" + +from __future__ import annotations + +import unittest + +import numpy as np + +from TPTBox.spine.snapshot2D.snapshot_modular import ( + _mm_to_voxel_thickness, + curve_projected_mean, + curve_projected_mip, +) + + +class _FakePOI(dict): + """Minimal stand-in for ``POI``: the projections only test ``23 in ctd_list``.""" + + +def _synthetic_volume(nx: int = 64, ny: int = 48, nz: int = 32) -> np.ndarray: + """A volume in IPL orientation with a bright anterior-posterior band per slice.""" + rng = np.random.default_rng(0) + vol = rng.random((nx, ny, nz)) * 10.0 + vol[:, ny // 2 - 4 : ny // 2 + 4, :] += 100.0 # a structure inside every slab + return vol + + +def _curve(nx: int, ny: int) -> tuple[np.ndarray, np.ndarray]: + """Centroid x-indices and the interpolated y coordinate for each x between them.""" + x_ctd = np.arange(4, nx - 4, dtype=int) + y_cord = np.full(len(x_ctd), ny // 2, dtype=int) + return x_ctd, y_cord + + +class TestMmToVoxelThickness(unittest.TestCase): + def test_conversion_is_a_pure_function_of_the_mm_value(self): + # Repeated application must be idempotent in its *input*: the bug was that + # the output was fed back in as the next input. + for zoom in (0.25, 0.5, 1.0, 1.5, 3.0): + first = _mm_to_voxel_thickness((100, 300), zoom) + second = _mm_to_voxel_thickness((100, 300), zoom) + self.assertEqual(first, second, f"not deterministic at {zoom=}") + + def test_ceiling_division(self): + self.assertEqual(_mm_to_voxel_thickness((100, 300), 1.0), [100, 300]) + self.assertEqual(_mm_to_voxel_thickness((100, 300), 0.5), [200, 600]) + self.assertEqual(_mm_to_voxel_thickness((10, 10), 3.0), [4, 4]) # ceil(10/3) + + def test_never_returns_zero(self): + # A zero-width slab yields an empty array slice, which np.max cannot reduce. + self.assertEqual(_mm_to_voxel_thickness((1, 1), 1000.0), [1, 1]) + + def test_degenerate_zoom_is_tolerated(self): + for bad in (0.0, -1.0, float("nan"), float("inf")): + self.assertEqual(_mm_to_voxel_thickness((100, 300), bad), [100, 300]) + + +class TestCurveProjections(unittest.TestCase): + """The two projections must survive every voxel spacing and stay non-degenerate.""" + + def _run(self, fn, zoom: float, **kwargs): + nx, ny, nz = 64, 48, 32 + img = _synthetic_volume(nx, ny, nz) + x_ctd, y_cord = _curve(nx, ny) + return fn( + img, + (1.0, zoom, 1.0), + x_ctd, + y_cord, + _FakePOI(), + thick_t=(20, 30), + **kwargs, + ) + + def test_mip_fine_spacing_does_not_overflow(self): + # Pre-fix: OverflowError: int too big to convert. + sag, cor, _ = self._run(curve_projected_mip, 0.5) + self.assertTrue(np.any(cor > 0), "coronal MIP is empty") + self.assertTrue(np.any(sag > 0), "sagittal MIP is empty") + + def test_mean_fine_spacing_does_not_overflow(self): + sag, cor, _ = self._run(curve_projected_mean, 0.5) + self.assertTrue(np.any(cor > 0), "coronal mean projection is empty") + self.assertTrue(np.any(sag > 0), "sagittal mean projection is empty") + + def test_coarse_spacing_keeps_the_requested_slab_width(self): + # Pre-fix the slab shrank once per slice towards the 1-voxel floor that the + # ceiling term imposes: for thick_t=(20, 30) mm at 3 mm/voxel it went + # [7, 10] -> [3, 4] -> [1, 2] -> [1, 1] and stayed there. No exception, but + # every slice after the third projected a 1-voxel slab instead of the + # requested one. Place the only signal at the far edge of the intended slab + # so a collapsed slab simply cannot see it. + nx, ny, nz = 64, 48, 32 + y_ref = ny // 2 # 24; intended voxel slab at 3 mm is [y_ref - 10, y_ref + 7] + for fn in (curve_projected_mip, curve_projected_mean): + with self.subTest(fn=fn.__name__): + img = np.zeros((nx, ny, nz)) + img[:, y_ref - 9 : y_ref - 7, :] = 100.0 # inside [14, 31], outside [23, 25] + x_ctd, y_cord = _curve(nx, ny) + _sag, cor, _ = fn(img, (1.0, 3.0, 1.0), x_ctd, y_cord, _FakePOI(), thick_t=(20, 30)) + rows_with_signal = np.count_nonzero(cor.max(axis=1) > 0) + self.assertGreater( + rows_with_signal, + cor.shape[0] // 2, + f"{fn.__name__} lost the requested slab width at 3 mm spacing " + f"(only {rows_with_signal}/{cor.shape[0]} rows saw the structure)", + ) + + def test_slab_width_is_constant_across_slices(self): + # The direct expression of the bug: the slab used for the first slice and + # the slab used for the last slice must be the same width. + for zoom in (0.5, 1.0, 3.0): + with self.subTest(zoom=zoom): + _sag, cor, _ = self._run(curve_projected_mip, zoom) + per_row = cor.max(axis=1) + self.assertGreater(per_row[5], 0) + self.assertGreater(per_row[-5], 0, f"last slices lost their slab at {zoom=}") + + def test_all_zooms_agree_on_signal_presence(self): + for zoom in (0.25, 0.5, 0.9, 1.0, 1.5, 3.0): + with self.subTest(zoom=zoom): + _sag, cor, _ = self._run(curve_projected_mip, zoom) + self.assertTrue(np.isfinite(cor).all()) + self.assertTrue(np.any(cor > 0)) + + def test_colored_depth_variant(self): + sag, cor, _ = self._run(curve_projected_mip, 0.5, make_colored_depth=True) + self.assertEqual(sag.shape[-1], 3) + self.assertEqual(cor.shape[-1], 3) + + +if __name__ == "__main__": + unittest.main() From 1e5426a54ab5f37d540abfe743847162394be913 Mon Sep 17 00:00:00 2001 From: Hendrik-code <19862882+Hendrik-code@users.noreply.github.com> Date: Wed, 2 Sep 2026 11:11:19 +0000 Subject: [PATCH 2/6] style fixes by ruff --- TPTBox/segmentation/nnUnet_utils/export_prediction.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/TPTBox/segmentation/nnUnet_utils/export_prediction.py b/TPTBox/segmentation/nnUnet_utils/export_prediction.py index 4aa3ba90..c5c7dfe0 100755 --- a/TPTBox/segmentation/nnUnet_utils/export_prediction.py +++ b/TPTBox/segmentation/nnUnet_utils/export_prediction.py @@ -79,9 +79,7 @@ def _chunked_argmax_cpu(t: torch.Tensor | np.ndarray) -> np.ndarray: # take the CPU path (torch.cuda.mem_get_info would reject the argument). if isinstance(device, str): device = torch.device(device) - _accel = device is not None and ( - (device.type == "cuda" and torch.cuda.is_available()) or device.type == "mps" - ) + _accel = device is not None and ((device.type == "cuda" and torch.cuda.is_available()) or device.type == "mps") if not _accel: return _chunked_argmax_cpu(t) From 8fc3e50d1b8a9f1347c80c745a7463a60a48868b Mon Sep 17 00:00:00 2001 From: iback Date: Wed, 2 Sep 2026 11:27:52 +0000 Subject: [PATCH 3/6] Remove the deprecated crop-slice aliases from NII compute_crop_slice, apply_crop_slice and apply_crop_slice_ were thin deprecated wrappers around compute_crop / apply_crop / apply_crop_, carrying a "remove in version 1.0" marker. Nothing in the repository called them: the tutorials were migrated to the current names in 876980c, and the only remaining mention was a commented-out line in stitching_tools.py, updated here for consistency. Note that TPTBox.stitching.stitching.compute_crop_slice is unrelated and stays - it is a module-level helper taking a raw Nifti1Image, not an NII method. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GrWjhK9FCgmRGkHhhVFd2V --- TPTBox/core/nii_wrapper.py | 18 ------------------ TPTBox/stitching/stitching_tools.py | 2 +- 2 files changed, 1 insertion(+), 19 deletions(-) diff --git a/TPTBox/core/nii_wrapper.py b/TPTBox/core/nii_wrapper.py index b0973673..51e2906e 100755 --- a/TPTBox/core/nii_wrapper.py +++ b/TPTBox/core/nii_wrapper.py @@ -951,24 +951,6 @@ def apply_center_crop(self, center_shape: tuple[int, int, int], inplace=False, v return self.set_array(arr_cropped, inplace=inplace) #return self.apply_crop(crop_slices, inplace=inplace) - def compute_crop_slice(self, *args, **qargs) -> tuple[slice, slice, slice]: - """Deprecated alias for `compute_crop`.""" - import warnings - warnings.warn("compute_crop_slice is deprecated use compute_crop instead",stacklevel=5) #TODO remove in version 1.0 - return self.compute_crop(*args,**qargs) - - def apply_crop_slice(self, *args, **qargs) -> Self: - """Deprecated alias for `apply_crop`.""" - import warnings - warnings.warn("apply_crop_slice id deprecated use apply_crop instead",stacklevel=5) #TODO remove in version 1.0 - return self.apply_crop(*args,**qargs) - - def apply_crop_slice_(self, *args, **qargs) -> Self: - """Deprecated alias for `apply_crop_`.""" - import warnings - warnings.warn("apply_crop_slice_ id deprecated use apply_crop_ instead",stacklevel=5) #TODO remove in version 1.0 - return self.apply_crop_(*args,**qargs) - def apply_crop(self,ex_slice:tuple[slice,slice,slice]|Sequence[slice]|None , inplace=False) -> Self: """Crop the NIfTI volume by a per-axis slice tuple (thin wrapper around ``nibabel``'s ``.slicer``). diff --git a/TPTBox/stitching/stitching_tools.py b/TPTBox/stitching/stitching_tools.py index 6bc65f6e..4b9ab4de 100755 --- a/TPTBox/stitching/stitching_tools.py +++ b/TPTBox/stitching/stitching_tools.py @@ -110,7 +110,7 @@ def GNC_stitch_T2w( chunks["LWS"]["nii"] = NII.load(LWS, seg=False).reorient_() # for k in chunks.keys(): # # chunks[k]["n4"] = _crop_borders(n4_bias(chunks[k]["nii"], spline_param=200)[0], k, cut) - # # chunks[k]["n4"].apply_crop_slice_(cut[k]) + # # chunks[k]["n4"].apply_crop_(cut[k]) # chunks_m = {k: chunks[k]["n4"] for k in chunks.keys()} # chunks_a = list([l.nii for l in chunks_m.values()]) chunks_a = [a["nii"].nii for a in chunks.values()] From 0854aaed64f543579506888e639ea2cb2b0e5550 Mon Sep 17 00:00:00 2001 From: iback Date: Wed, 2 Sep 2026 12:01:23 +0000 Subject: [PATCH 4/6] Rename NAKO_stitch_T2w, unpin nnU-Net, add a reg extra Three corrections to the packaging/naming choices in 6178510. Stitching function name Renamed GNC_stitch_T2w -> NAKO_stitch_T2w in stitching_tools.py, the TPTBox.stitching re-export, examples/nako/stitching_T2w.py and the README. Clean rename with no deprecated alias: the symbol was only ever public at the TPTBox.stitching level. (For the record, 6178510 did not rename anything - it corrected the README, which b214cc2 had changed to a name the code never had.) nnU-Net version constraint The previous >=2.4,<2.5 pin was asserted on an unverified premise and was actively harmful. nnUnet_utils is a self-contained fork: it reads the checkpoint's own plans.json and builds its own PlansManager, so it is not tied to the installed nnU-Net's plans layout. Checked against the 2.8.1 wheel, every symbol TPTBox imports still exists; the one signature change (recursive_find_python_class) only added defaulted parameters, and all three call sites pass three arguments. Meanwhile SPINEPS requires nnunetv2>=2.8.0 on Python 3.10+, so the old pin made TPTBox[seg] and spineps mutually uninstallable - it created the conflict the note blamed on the user. Now mirrors SPINEPS' own split: ==2.4.2 below Python 3.10 (the last release supporting it) and >=2.8,<3.0 from 3.10 on. Verified the lock carries both markers. The three places repeating the old claim are corrected, and the tutorial note is marked as Python-3.9-only advice. Not proven end to end: this is static evidence (import surface, signatures, wheel diff), not an inference run on 2.8.x. reg extra and correct install hints Added a reg extra (torch + hf-deepali), closing the gap where four docs told users to install hf-deepali while it was declared nowhere. spineps stays undeclared on purpose - it depends on TPTBox itself, so an extra would be circular; that is now recorded in pyproject and in the code. Fixed a bug from 6178510: the SPINEPS stubs pointed at pip install 'TPTBox[seg]', an extra that does not and cannot contain spineps, so following the message could not fix the error. optional_deps now accepts extra=None for backends with no extra. Migrated registration/__init__.py onto the shared optional_deps factories (-43 lines of duplication) and pointed its message at the new extra; test_registration_deepali's stub assertions updated to the unified message and strengthened to also check the extra and the original error. Also: nii_wrapper's antspyx failure message named hf-deepali, and getting-started claimed every optional backend is guarded when antspyx is not. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GrWjhK9FCgmRGkHhhVFd2V --- README.md | 4 +- TPTBox/core/internal/optional_deps.py | 28 ++++--- TPTBox/core/nii_wrapper.py | 2 +- TPTBox/registration/README.md | 2 + TPTBox/registration/__init__.py | 83 +++++---------------- TPTBox/segmentation/README.md | 8 +- TPTBox/segmentation/__init__.py | 10 ++- TPTBox/stitching/README.md | 2 +- TPTBox/stitching/__init__.py | 2 +- TPTBox/stitching/stitching_tools.py | 7 +- docs/getting-started.md | 34 ++++++--- examples/nako/stitching_T2w.py | 2 +- pyproject.toml | 18 ++++- tutorials/tutorial_Dataset_processing.ipynb | 2 +- unit_tests/test_registration_deepali.py | 9 ++- 15 files changed, 108 insertions(+), 105 deletions(-) diff --git a/README.md b/README.md index 26f43e6a..1343a0f7 100755 --- a/README.md +++ b/README.md @@ -39,8 +39,8 @@ The Torso Processing ToolBox (TPTBox) is a multi-functional package to handle an conda create -n 3.10 python=3.10 conda activate 3.10 pip install TPTBox -# Optional dependency Registration -pip install hf-deepali +# Optional dependency Registration (deepali backend) +pip install "TPTBox[reg]" ``` ### Install via github: (you should be in the project folder) diff --git a/TPTBox/core/internal/optional_deps.py b/TPTBox/core/internal/optional_deps.py index f6efe50e..adc5ec2c 100644 --- a/TPTBox/core/internal/optional_deps.py +++ b/TPTBox/core/internal/optional_deps.py @@ -18,23 +18,29 @@ __all__ = ["missing_dependency_class", "missing_dependency_func"] -def _message(name: str, extra: str, packages: str, original_error: str, call: str) -> str: - return ( - f"`{name}{call}` requires optional dependencies that are not installed.\n" - f" pip install 'TPTBox[{extra}]'\n" - f"or install them directly:\n" - f" pip install {packages}\n" - f"Original import error was: {original_error}" - ) +def _message(name: str, extra: str | None, packages: str, original_error: str, call: str) -> str: + """Build the install hint. + + ``extra`` is ``None`` for backends that have no TPTBox extra - notably + ``spineps``, which depends on TPTBox itself and so cannot be declared as one. + Naming an extra that does not contain the package would send the user to an + install command that cannot fix their error. + """ + lines = [f"`{name}{call}` requires optional dependencies that are not installed."] + if extra is not None: + lines += [f" pip install 'TPTBox[{extra}]'", "or install them directly:"] + lines += [f" pip install {packages}", f"Original import error was: {original_error}"] + return "\n".join(lines) -def missing_dependency_func(name: str, exc: BaseException, extra: str, packages: str) -> Callable[..., Any]: +def missing_dependency_func(name: str, exc: BaseException, extra: str | None, packages: str) -> Callable[..., Any]: """Return a callable stub that raises a helpful ``ImportError`` when called. Args: name: Name of the entry point being replaced. exc: The original ``ImportError``, quoted in the message. - extra: The ``pip install 'TPTBox[...]'`` extra that provides the backend. + extra: The ``pip install 'TPTBox[...]'`` extra that provides the backend, + or ``None`` when no extra provides it (install the packages directly). packages: Space-separated package names, for a direct pip install. """ original_error = str(exc) or exc.__class__.__name__ @@ -49,7 +55,7 @@ def _stub(*_args: Any, **_kwargs: Any) -> Any: return _stub -def missing_dependency_class(name: str, exc: BaseException, extra: str, packages: str) -> type: +def missing_dependency_class(name: str, exc: BaseException, extra: str | None, packages: str) -> type: """Return a class placeholder that raises on instantiation or attribute access. ``isinstance``/``issubclass`` checks stay safe - the stub is a plain class. diff --git a/TPTBox/core/nii_wrapper.py b/TPTBox/core/nii_wrapper.py index 51e2906e..3cbb8264 100755 --- a/TPTBox/core/nii_wrapper.py +++ b/TPTBox/core/nii_wrapper.py @@ -1561,7 +1561,7 @@ def to_ants(self) -> Any: import ants except Exception: log.print_error() - log.on_fail("run 'pip install antspyx' to install hf-deepali") + log.on_fail("this function needs antspyx: run 'pip install antspyx'") raise try: from ants.utils.convert_nibabel import from_nibabel diff --git a/TPTBox/registration/README.md b/TPTBox/registration/README.md index f350749f..e5047611 100644 --- a/TPTBox/registration/README.md +++ b/TPTBox/registration/README.md @@ -42,6 +42,8 @@ from TPTBox.registration import ( ## Installation of optional dependency ```bash +pip install "TPTBox[reg]" # torch + hf-deepali +# or directly: pip install torch hf-deepali # needed by every entry point except Point_Registration ``` diff --git a/TPTBox/registration/__init__.py b/TPTBox/registration/__init__.py index 2fb6c351..36727bbb 100755 --- a/TPTBox/registration/__init__.py +++ b/TPTBox/registration/__init__.py @@ -1,73 +1,28 @@ from __future__ import annotations -from typing import Any +from TPTBox.core.internal.optional_deps import missing_dependency_class, missing_dependency_func # --------------------------------------------------------------------------- -# Some of the registration entry points require ``hf-deepali`` (and therefore -# also PyTorch). ``hf-deepali`` is an *optional* dependency: importing this -# package must succeed even when it is missing, and only *using* one of the -# deepali-backed classes should surface an error. -# -# For each optional class we try to import it. On failure we replace it with a -# stub that raises a clear ``ImportError`` the first time the caller touches it -# (instantiation *or* attribute access). Users then get "install hf-deepali -# (also needs PyTorch)" instead of a vague ``NameError`` from Python. +# Most registration entry points require ``hf-deepali`` (and therefore PyTorch), +# which is the optional ``reg`` extra. Importing this package must succeed even +# when it is missing; only *using* a deepali-backed entry point should fail, and +# it should say what to install. The stub factories live in +# TPTBox.core.internal.optional_deps and are shared with TPTBox.segmentation and +# TPTBox.core.dicom. # --------------------------------------------------------------------------- +_REG_EXTRA = "reg" +_REG_PACKAGES = "torch hf-deepali" + def _make_missing_deepali_stub(name: str, exc: BaseException): - """Return a class placeholder for a deepali-backed entry point. - - Any instantiation or attribute access raises an ``ImportError`` explaining - that ``hf-deepali`` (and PyTorch) must be installed. ``isinstance``/subclass - checks are safe – the stub is a plain class. - """ - original_error = str(exc) or exc.__class__.__name__ - - class _MissingDeepali: - __name__ = name - __qualname__ = name - _tptbox_optional_dep = "hf-deepali" - _tptbox_import_error = original_error - - def __init__(self, *_args: Any, **_kwargs: Any) -> None: # noqa: D401 - raise ImportError( - f"`{name}` requires the optional dependency `hf-deepali` " - f"(which in turn requires PyTorch). Install both with:\n" - f" pip install torch hf-deepali\n" - f"Original import error was: {original_error}" - ) - - def __class_getitem__(cls, item): # keep type-annotations happy - return cls - - def __getattr__(self, item): - raise ImportError( - f"`{name}.{item}` requires `hf-deepali` (and PyTorch). " - f"Install with: pip install torch hf-deepali\n" - f"Original import error was: {original_error}" - ) - - _MissingDeepali.__name__ = name - _MissingDeepali.__qualname__ = name - return _MissingDeepali + """Class placeholder for a deepali-backed entry point.""" + return missing_dependency_class(name, exc, _REG_EXTRA, _REG_PACKAGES) def _make_missing_deepali_func(name: str, exc: BaseException): - """Return a callable stub for a deepali-backed helper function.""" - original_error = str(exc) or exc.__class__.__name__ - - def _stub(*_args: Any, **_kwargs: Any) -> Any: - raise ImportError( - f"`{name}()` requires the optional dependency `hf-deepali` " - f"(which in turn requires PyTorch). Install both with:\n" - f" pip install torch hf-deepali\n" - f"Original import error was: {original_error}" - ) - - _stub.__name__ = name - _stub.__qualname__ = name - return _stub + """Callable stub for a deepali-backed helper function.""" + return missing_dependency_func(name, exc, _REG_EXTRA, _REG_PACKAGES) # --- SITK point registration (no deepali needed) --------------------------- @@ -77,10 +32,12 @@ def _stub(*_args: Any, **_kwargs: Any) -> Any: ridged_points_from_poi, ridged_points_from_subreg_vert, ) -except ImportError as _e_sitk: # SimpleITK missing - very unlikely, still guard. - Point_Registration = _make_missing_deepali_stub("Point_Registration", _e_sitk) # type: ignore[misc,assignment] - ridged_points_from_poi = _make_missing_deepali_func("ridged_points_from_poi", _e_sitk) # type: ignore[assignment] - ridged_points_from_subreg_vert = _make_missing_deepali_func("ridged_points_from_subreg_vert", _e_sitk) # type: ignore[assignment] +except ImportError as _e_sitk: # SimpleITK is a hard dependency - very unlikely, still guard. + Point_Registration = missing_dependency_class("Point_Registration", _e_sitk, None, "SimpleITK") # type: ignore[misc,assignment] + ridged_points_from_poi = missing_dependency_func("ridged_points_from_poi", _e_sitk, None, "SimpleITK") # type: ignore[assignment] + ridged_points_from_subreg_vert = missing_dependency_func( # type: ignore[assignment] + "ridged_points_from_subreg_vert", _e_sitk, None, "SimpleITK" + ) # --- Deepali closed-form point registration -------------------------------- try: diff --git a/TPTBox/segmentation/README.md b/TPTBox/segmentation/README.md index 90767909..f087a88d 100644 --- a/TPTBox/segmentation/README.md +++ b/TPTBox/segmentation/README.md @@ -38,8 +38,12 @@ from TPTBox.segmentation import ( All external tools are optional: `import TPTBox.segmentation` succeeds without them, and each entry point only raises (naming the extra to install) when it is actually called. -Note the nnU-Net pin. `TPTBox.segmentation.nnUnet_utils` mirrors the nnU-Net v2.4 plans/trainer -layout; the `seg` extra therefore requires `nnunetv2>=2.4,<2.5`. +About the nnU-Net version. `TPTBox.segmentation.nnUnet_utils` is a self-contained fork: it reads +the checkpoint's own `plans.json` and builds its own `PlansManager`, so it is not tied to the +plans layout of whichever nnU-Net you have installed. The `seg` extra therefore mirrors SPINEPS' +own constraint rather than imposing one of its own — `nnunetv2>=2.8,<3.0` on Python 3.10+, and +`nnunetv2==2.4.2` on Python 3.9 (the last release that still supports it). That way +`TPTBox[seg]` and `spineps` can be installed side by side. ## Example diff --git a/TPTBox/segmentation/__init__.py b/TPTBox/segmentation/__init__.py index be46367b..b17cd260 100644 --- a/TPTBox/segmentation/__init__.py +++ b/TPTBox/segmentation/__init__.py @@ -18,12 +18,16 @@ except ImportError as _e_rib: add_ribs_to_vert_spine = missing_dependency_func("add_ribs_to_vert_spine", _e_rib, "seg", _SEG_PACKAGES) # type: ignore[assignment] +# `spineps` has no TPTBox extra on purpose: it declares a dependency on TPTBox +# itself, so `TPTBox[spineps]` would be circular and Poetry would refuse to solve +# it. Pass extra=None so the hint says `pip install spineps` rather than pointing +# at an extra that does not contain it. try: from TPTBox.segmentation.spineps import _run_spineps_all, get_outpaths_spineps, run_spineps except ImportError as _e_spineps: - _run_spineps_all = missing_dependency_func("_run_spineps_all", _e_spineps, "seg", "spineps") # type: ignore[assignment] - get_outpaths_spineps = missing_dependency_func("get_outpaths_spineps", _e_spineps, "seg", "spineps") # type: ignore[assignment] - run_spineps = missing_dependency_func("run_spineps", _e_spineps, "seg", "spineps") # type: ignore[assignment] + _run_spineps_all = missing_dependency_func("_run_spineps_all", _e_spineps, None, "spineps") # type: ignore[assignment] + get_outpaths_spineps = missing_dependency_func("get_outpaths_spineps", _e_spineps, None, "spineps") # type: ignore[assignment] + run_spineps = missing_dependency_func("run_spineps", _e_spineps, None, "spineps") # type: ignore[assignment] try: from TPTBox.segmentation.VibeSeg.vibeseg import ( diff --git a/TPTBox/stitching/README.md b/TPTBox/stitching/README.md index 2191afb4..57bab4e7 100644 --- a/TPTBox/stitching/README.md +++ b/TPTBox/stitching/README.md @@ -10,7 +10,7 @@ You can verify alignment by opening the images in ITKSnap with "open additional |---|---| | `stitching(nii_list, out, ...)` | Stitch a list of `NII` objects; returns `(result_nii, ramp_nii)` | | `stitching_raw(paths, out, ...)` | Stitch from file paths directly | -| `GNC_stitch_T2w(HWS, BWS, LWS, n4_after_stitch=False)` | Stitch the three German National Cohort sagittal T2w spine stations (cervical, thoracic, lumbar) into one volume | +| `NAKO_stitch_T2w(HWS, BWS, LWS, n4_after_stitch=False)` | Stitch the three NAKO sagittal T2w spine stations (HWS cervical, BWS thoracic, LWS lumbar) into one volume | ![Example of a stitching](https://raw.githubusercontent.com/Hendrik-code/TPTBox/main/TPTBox/stitching/stitching.jpg "Example of a stitching") diff --git a/TPTBox/stitching/__init__.py b/TPTBox/stitching/__init__.py index 3573619d..e61a7365 100755 --- a/TPTBox/stitching/__init__.py +++ b/TPTBox/stitching/__init__.py @@ -1,3 +1,3 @@ from __future__ import annotations -from TPTBox.stitching.stitching_tools import GNC_stitch_T2w, stitching, stitching_raw +from TPTBox.stitching.stitching_tools import NAKO_stitch_T2w, stitching, stitching_raw diff --git a/TPTBox/stitching/stitching_tools.py b/TPTBox/stitching/stitching_tools.py index 4b9ab4de..a59d8ec3 100755 --- a/TPTBox/stitching/stitching_tools.py +++ b/TPTBox/stitching/stitching_tools.py @@ -83,7 +83,7 @@ def _crop_borders(nii: NII, chunk_info: str, cut: dict[str, tuple[slice, slice, return nii.reorient_().apply_crop_(cut[chunk_info]).reorient_(ori) -def GNC_stitch_T2w( +def NAKO_stitch_T2w( HWS: Image_Reference, # noqa: N803 BWS: Image_Reference, # noqa: N803 LWS: Image_Reference, # noqa: N803 @@ -94,7 +94,10 @@ def GNC_stitch_T2w( # "LWS": (slice(None), slice(48, 448), slice(None)), # }, ) -> NII: - """Apply N4 bias correction to each chunk, stitch them, then apply N4 again. + """Stitch the three NAKO sagittal T2w spine stations into a single volume. + + Applies N4 bias correction to each chunk, stitches them, then optionally applies + N4 again. The chunk names follow the NAKO acquisition protocol. Args: HWS (NII | str | Path): Cervical region diff --git a/docs/getting-started.md b/docs/getting-started.md index 1eb45da6..b7ef3782 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -19,9 +19,10 @@ poetry install --with dev ### Optional dependencies -The core install stays light. Every optional backend is guarded: importing a sub-package always -succeeds, and only *calling* an entry point that needs a missing backend raises an `ImportError` -naming what to install. +The core install stays light. The DICOM, segmentation and registration backends are guarded: +importing `TPTBox.core.dicom`, `TPTBox.segmentation` or `TPTBox.registration` always succeeds, and +only *calling* an entry point whose backend is missing raises an `ImportError` naming what to +install. (`antspyx` is the exception — its call sites still surface a plain `ModuleNotFoundError`.) ```bash # DICOM -> NIfTI conversion (TPTBox.core.dicom) @@ -30,13 +31,13 @@ pip install "TPTBox[dicom]" # pydicom, dicom2nifti # nnU-Net / VibeSeg inference (TPTBox.segmentation) pip install "TPTBox[seg]" # torch, nnunetv2, acvl_utils, batchgenerators -# Both at once -pip install "TPTBox[dicom,seg]" +# Intensity-based and deformable registration (DeepALI) +pip install "TPTBox[reg]" # torch, hf-deepali -# Deep learning registration (DeepALI) -pip install torch hf-deepali +# Several at once +pip install "TPTBox[dicom,seg,reg]" -# SPINEPS spine segmentation +# SPINEPS spine segmentation - no extra, see the note below pip install spineps # 3D mesh visualisation @@ -47,9 +48,20 @@ pip install antspyx ``` !!! note "nnU-Net version" - `TPTBox.segmentation.nnUnet_utils` mirrors the nnU-Net v2.4 plans/trainer layout, so the - `seg` extra pins `nnunetv2>=2.4,<2.5`. Installing a newer nnU-Net alongside SPINEPS is the - usual cause of "SPINEPS and nnU-Net do not work together" errors. + `TPTBox.segmentation.nnUnet_utils` is a self-contained fork of nnU-Net's inference code: it + reads the checkpoint's own `plans.json` and builds its own `PlansManager`, so it is not tied + to the plans layout of the installed nnU-Net. TPTBox therefore does not impose a version of + its own — the `seg` extra mirrors what SPINEPS asks for: `nnunetv2>=2.8,<3.0` on Python 3.10+, + and `nnunetv2==2.4.2` on Python 3.9, which is the last release supporting it. `TPTBox[seg]` + and `spineps` can be installed side by side. + + If you also use `totalspineseg`, note that it requires `nnunetv2<=2.4.2`, which cannot be + satisfied together with SPINEPS on Python 3.10+. That constraint comes from `totalspineseg`, + not from TPTBox. + +!!! note "SPINEPS has no extra" + `spineps` depends on TPTBox itself, so a `TPTBox[spineps]` extra would be a circular + dependency. Install it directly with `pip install spineps`. ## Core Concepts diff --git a/examples/nako/stitching_T2w.py b/examples/nako/stitching_T2w.py index 85de67e1..a4321d62 100644 --- a/examples/nako/stitching_T2w.py +++ b/examples/nako/stitching_T2w.py @@ -60,7 +60,7 @@ already_stitched += 1 continue print("Stich", out) - nii = st.GNC_stitch_T2w(files["HWS"], files["BWS"], files["LWS"]) + nii = st.NAKO_stitch_T2w(files["HWS"], files["BWS"], files["LWS"]) crop = nii.compute_crop() nii.apply_crop_(crop) nii.save(out) diff --git a/pyproject.toml b/pyproject.toml index 5287118e..f320a1e4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,15 +52,27 @@ scikit-image = [ pydicom = { version = "*", optional = true } dicom2nifti = { version = "*", optional = true } torch = { version = "*", optional = true } -# nnU-Net 2.5 changed the plans/trainer layout that TPTBox.segmentation.nnUnet_utils -# mirrors; 2.4.2 is the version the tutorials are written against. -nnunetv2 = { version = ">=2.4,<2.5", optional = true } +# Mirror SPINEPS' own constraint so `TPTBox[seg]` and `spineps` can co-install. +# nnunetv2 2.8+ requires Python >=3.10, so 3.9 stays on the last release supporting it. +# TPTBox.segmentation.nnUnet_utils is a self-contained fork: it builds its own +# PlansManager from the checkpoint's plans.json and never instantiates nnU-Net's, +# so it is not tied to a particular nnU-Net plans layout. +nnunetv2 = [ + { version = "==2.4.2", python = "<3.10", optional = true }, + { version = ">=2.8,<3.0", python = ">=3.10", optional = true }, +] acvl-utils = { version = "*", optional = true } batchgenerators = { version = "*", optional = true } +# Distribution is `hf-deepali`; the import name is `deepali`. Do NOT shorten it - +# a bare `deepali` on PyPI is an unrelated project. +hf-deepali = { version = "*", optional = true } +# `spineps` is deliberately absent: it depends on TPTBox itself, so declaring it +# here would be a circular dependency. It stays a documented `pip install spineps`. [tool.poetry.extras] dicom = ["pydicom", "dicom2nifti"] seg = ["torch", "nnunetv2", "acvl-utils", "batchgenerators"] +reg = ["torch", "hf-deepali"] [tool.poetry.group.dev.dependencies] pytest = ">=8.1.1" diff --git a/tutorials/tutorial_Dataset_processing.ipynb b/tutorials/tutorial_Dataset_processing.ipynb index 0fde06b8..e49bc724 100644 --- a/tutorials/tutorial_Dataset_processing.ipynb +++ b/tutorials/tutorial_Dataset_processing.ipynb @@ -451,7 +451,7 @@ "\n", "```pip install SPINEPS ruamel.yaml configargparse```\n", "\n", - "trouble shouting: nnunetv2==2.4.2\n" + "troubleshooting: on Python 3.9 pin `nnunetv2==2.4.2` (the last release supporting 3.9).\nOn Python 3.10+ SPINEPS requires `nnunetv2>=2.8`, so do **not** pin 2.4.2 there.\n" ] }, { diff --git a/unit_tests/test_registration_deepali.py b/unit_tests/test_registration_deepali.py index f736d549..f47ac705 100644 --- a/unit_tests/test_registration_deepali.py +++ b/unit_tests/test_registration_deepali.py @@ -666,8 +666,8 @@ def test_deepali_vs_sitk_point_registration(self): class TestOptionalDeepaliStubs(unittest.TestCase): """When ``hf-deepali`` isn't installed the deepali-backed entry points must still *import* – they should raise a helpful ``ImportError`` only - when actually used, and the error message must mention that PyTorch is a - prerequisite too. + when actually used, and the message must give an install command that + covers torch as well, plus the ``reg`` extra that provides both. """ def test_stub_factory_message(self): @@ -679,14 +679,17 @@ def test_stub_factory_message(self): msg = str(ctx.exception) self.assertIn("Foo", msg) self.assertIn("hf-deepali", msg) - self.assertIn("PyTorch", msg) + # torch is a prerequisite, so the suggested command must install it too. self.assertIn("pip install torch hf-deepali", msg) + self.assertIn("TPTBox[reg]", msg) + self.assertIn("no module named 'deepali'", msg) stub_fn = _reg_init._make_missing_deepali_func("bar", ImportError("boom")) with self.assertRaises(ImportError) as ctx: stub_fn(1, 2) self.assertIn("bar()", str(ctx.exception)) self.assertIn("pip install torch hf-deepali", str(ctx.exception)) + self.assertIn("TPTBox[reg]", str(ctx.exception)) if __name__ == "__main__": From 85c1c5b459be67a4a71281112711d0a8bbde8cb3 Mon Sep 17 00:00:00 2001 From: iback Date: Wed, 2 Sep 2026 12:10:44 +0000 Subject: [PATCH 5/6] Fix test that pointed at a hardcoded developer path test_poi_landmark_registration_converges loaded the sample CT from /media/data/robert/code/TPTBox/... , an absolute path on one developer's machine, so it failed with FileNotFoundError for everyone else and in CI. The files it wants are packaged in the repo at TPTBox/tests/sample_ct/, and every other test in this file already reaches them via get_test_ct(). Switched to get_test_ct(), which returns exactly the (ct, subreg, vert) trio the test built by hand. The full suite is now green: 594 passed, 5 skipped, 0 failed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GrWjhK9FCgmRGkHhhVFd2V --- unit_tests/test_registration_deepali.py | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/unit_tests/test_registration_deepali.py b/unit_tests/test_registration_deepali.py index f47ac705..e4306803 100644 --- a/unit_tests/test_registration_deepali.py +++ b/unit_tests/test_registration_deepali.py @@ -299,18 +299,14 @@ def test_poi_landmark_registration_converges(self): """ import torch # noqa: PLC0415 - from TPTBox import Location, calc_poi_from_subreg_vert, to_nii # noqa: PLC0415 + from TPTBox import Location, calc_poi_from_subreg_vert # noqa: PLC0415 from TPTBox.registration import General_Registration # noqa: PLC0415 + from TPTBox.tests.test_utils import get_test_ct # noqa: PLC0415 - ct = to_nii("/media/data/robert/code/TPTBox/TPTBox/tests/sample_ct/sub-ct_label-22_ct.nii.gz", False) - vert = to_nii( - "/media/data/robert/code/TPTBox/TPTBox/tests/sample_ct/sub-ct_seg-vert_label-22_msk.nii.gz", - True, - ) - sub = to_nii( - "/media/data/robert/code/TPTBox/TPTBox/tests/sample_ct/sub-ct_seg-subreg_label-22_msk.nii.gz", - True, - ) + # Use the packaged sample CT, like every other test in this file. This used + # to hardcode an absolute path on one developer's machine, so it failed for + # everyone else and in CI. + ct, sub, vert, _ = get_test_ct() poi_fix = calc_poi_from_subreg_vert( vert, sub, From 239688d582c9b0c4d9f862af764c59ce9d34b356 Mon Sep 17 00:00:00 2001 From: iback Date: Wed, 2 Sep 2026 12:41:16 +0000 Subject: [PATCH 6/6] Fix the CI-failing import gate, plus the bug it then found The tests workflow failed on all four matrix jobs with the same error: test_all_subpackages_import AssertionError: ["TPTBox.core.internal.train_nnUnet.fastProcessor: No module named 'blosc2'"] fastProcessor imports blosc2, a transitive dependency of nnunetv2 >= 2.6. It is installed on the dev box but not in CI, so the gate passed locally and failed remotely. The underlying flaw was the design: the test allowlisted third-party package names, which is unbounded and varies per machine and per CI job. Inverted it to enumerate TPTBox's own surface instead, which is bounded and is what the test actually cares about: - test_required_entry_points_import asserts the public entry points (TPTBox, .segmentation, .core.dicom, .registration, ...) import with no optional backend present. This is the real regression guard - `import TPTBox.segmentation` failing on a clean install was the original bug. - test_no_subpackage_has_broken_internal_imports walks every module but fails only when the missing module is positively identified as a TPTBox one. A leaf module needing blosc2/torch/xvfbwrapper is an environment fact, not drift. Both real drift modes still trip it: a stale path sets name="TPTBox.gone", and a removed symbol sets name="TPTBox.mod" with "cannot import name" - verified by injecting both. The wider walk (the old one skipped underscore-prefixed packages) immediately found a genuine pre-existing bug: registration/_deformable/grid_search.py imported _load_config from deformable_reg, where it does not exist - it lives in _deepali/deepali_model.py, which deformable_reg itself already imports from. The module was un-importable on main too. Repointed at the real location. Also renamed the tests_mr workflow to "tests (pull request)". Both test workflows were named "tests" with a job called "build", so they reported indistinguishable checks; `gh run list --workflow tests` fails outright with "could not resolve to a unique workflow". Verified main has no branch protection, so no required status check depends on the old name. Local suite: 595 passed, 5 skipped, 0 failed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GrWjhK9FCgmRGkHhhVFd2V --- .github/workflows/tests_mr.yml | 2 +- .../registration/_deformable/grid_search.py | 3 +- unit_tests/test_docs_examples.py | 78 +++++++++++++++++-- 3 files changed, 73 insertions(+), 10 deletions(-) diff --git a/.github/workflows/tests_mr.yml b/.github/workflows/tests_mr.yml index a5e14b09..ebc3677f 100644 --- a/.github/workflows/tests_mr.yml +++ b/.github/workflows/tests_mr.yml @@ -1,7 +1,7 @@ # This workflow will install Python dependencies, run tests and lint with a variety of Python versions # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python -name: tests +name: tests (pull request) on: pull_request: diff --git a/TPTBox/registration/_deformable/grid_search.py b/TPTBox/registration/_deformable/grid_search.py index 1f2526e1..abe97edd 100644 --- a/TPTBox/registration/_deformable/grid_search.py +++ b/TPTBox/registration/_deformable/grid_search.py @@ -30,8 +30,9 @@ from tqdm import tqdm from TPTBox.core.nii_wrapper import to_nii +from TPTBox.registration._deepali.deepali_model import _load_config from TPTBox.registration._deformable._deepali.metrics import NMILOSS, calculate_jacobian_metrics, dice -from TPTBox.registration._deformable.deformable_reg import Deformable_Registration, Image_Reference, _load_config +from TPTBox.registration._deformable.deformable_reg import Deformable_Registration, Image_Reference @dataclass diff --git a/unit_tests/test_docs_examples.py b/unit_tests/test_docs_examples.py index 9ecbc0b3..6774f417 100644 --- a/unit_tests/test_docs_examples.py +++ b/unit_tests/test_docs_examples.py @@ -63,6 +63,10 @@ "monai", "nnunet", "totalsegmentator", + # transitive deps of the nnU-Net stack (nnunetv2 >= 2.6 pulls these in) + "blosc2", + "batchgeneratorsv2", + "dynamic_network_architectures", # dev-group extras: present in CI, absent for a plain `pip install TPTBox` "pyvista", "vtk", @@ -150,27 +154,85 @@ def _symbol_exists(module: str, symbol: str) -> bool: return _module_exists(f"{module}.{symbol}") +# The public entry points a user imports. Each of these MUST import on a bare +# install - that is the guarantee the optional-dependency stubs exist to provide, +# and `import TPTBox.segmentation` failing on a clean install was a real bug. +REQUIRED_IMPORTS = [ + "TPTBox", + "TPTBox.core", + "TPTBox.core.bids_files", + "TPTBox.core.dicom", + "TPTBox.core.nii_wrapper", + "TPTBox.core.np_utils", + "TPTBox.core.poi", + "TPTBox.logger", + "TPTBox.mesh3D", + "TPTBox.registration", + "TPTBox.segmentation", + "TPTBox.spine", + "TPTBox.spine.snapshot2D", + "TPTBox.spine.spinestats", + "TPTBox.stitching", +] + + +def _missing_module_name(exc: BaseException) -> str: + """Best-effort name of the module an ImportError is complaining about.""" + name = getattr(exc, "name", None) + if name: + return name + m = re.search(r"No module named '([^']+)'", str(exc)) + return m.group(1) if m else "" + + class TestPackageImports(unittest.TestCase): - """Every TPTBox sub-package must import on a bare install.""" + """TPTBox must import without any optional backend installed.""" + + def test_required_entry_points_import(self): + """The public sub-packages must import even with no optional backend. - def test_all_subpackages_import(self): + This is the regression guard: these are the modules whose entry points are + replaced by stubs when a backend is missing, so a missing backend must never + stop the *import* itself. + """ + failures = [] + for name in REQUIRED_IMPORTS: + try: + importlib.import_module(name) + except Exception as e: # noqa: BLE001 - report, do not mask + failures.append(f"{name}: {type(e).__name__}: {e}") + self.assertEqual(failures, [], "public entry points failed to import:\n" + "\n".join(failures)) + + def test_no_subpackage_has_broken_internal_imports(self): + """Walk every module and fail only on breakage *inside* TPTBox. + + A leaf module may legitimately require an optional third-party backend + (fastProcessor needs blosc2, vibeseg needs torch, snapshot3D needs + xvfbwrapper), and which of those happen to be installed varies by machine + and by CI job. Enumerating them was brittle - this asserts the thing we + actually care about instead: that no TPTBox module fails to import because + of a *TPTBox* problem, such as a stale path after a rename. + """ failures = [] for info in pkgutil.walk_packages(TPTBox.__path__, prefix="TPTBox."): name = info.name - # Scripts and vendored backends are allowed to need their optional stack. - if any(part.startswith("_") for part in name.split(".")[1:]): - continue - if ".tests" in name or ".nnUnet_utils" in name or name.endswith(("__main__", "script_ax2sag")): + if ".tests" in name or name.endswith(("__main__", "script_ax2sag")): continue try: importlib.import_module(name) except ImportError as e: - if any(dep in str(e) for dep in OPTIONAL_THIRD_PARTY): + # Only fail when the failure is positively identified as a TPTBox + # module. Both real drift modes set it: a stale path gives + # name="TPTBox.gone", and a removed symbol gives + # name="TPTBox.mod" with "cannot import name ...". Anything else - + # a missing backend, or a hand-raised hint like + # elastic_deform's NumPy-2 message - is an environment fact. + if not _missing_module_name(e).startswith("TPTBox"): continue failures.append(f"{name}: {e}") except Exception as e: # noqa: BLE001 - report, do not mask failures.append(f"{name}: {type(e).__name__}: {e}") - self.assertEqual(failures, [], "sub-packages failed to import:\n" + "\n".join(failures)) + self.assertEqual(failures, [], "modules broken inside TPTBox:\n" + "\n".join(failures)) def test_public_api_names_resolve(self): missing = [n for n in TPTBox.__all__ if not hasattr(TPTBox, n)]