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/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/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..adc5ec2c --- /dev/null +++ b/TPTBox/core/internal/optional_deps.py @@ -0,0 +1,82 @@ +"""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 | 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 | 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, + 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__ + + 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 | 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. + """ + 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..3cbb8264 100755 --- a/TPTBox/core/nii_wrapper.py +++ b/TPTBox/core/nii_wrapper.py @@ -951,18 +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 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``). @@ -1573,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 90c538ba..e5047611 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,23 @@ 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 "TPTBox[reg]" # torch + hf-deepali +# or directly: +pip install torch hf-deepali # needed by every entry point except Point_Registration ``` ## Example 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/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/TPTBox/segmentation/README.md b/TPTBox/segmentation/README.md index 9edad320..f087a88d 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,33 @@ 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. + +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 ```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..b17cd260 100644 --- a/TPTBox/segmentation/__init__.py +++ b/TPTBox/segmentation/__init__.py @@ -1,5 +1,56 @@ +"""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] + +# `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, 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 ( + 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..c5c7dfe0 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,13 @@ 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..57bab4e7 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 | +| `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](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/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 6bc65f6e..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 @@ -110,7 +113,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()] diff --git a/docs/getting-started.md b/docs/getting-started.md index cd941669..b7ef3782 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -19,14 +19,50 @@ poetry install --with dev ### Optional dependencies +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 -# Deep learning registration (DeepALI) -pip install hf-deepali +# 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 + +# Intensity-based and deformable registration (DeepALI) +pip install "TPTBox[reg]" # torch, hf-deepali + +# Several at once +pip install "TPTBox[dicom,seg,reg]" + +# SPINEPS spine segmentation - no extra, see the note below +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` 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 ### 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/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 96c104e3..f320a1e4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,6 +44,36 @@ 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 } +# 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" # 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=2.8`, so do **not** pin 2.4.2 there.\n" ] }, { @@ -551,8 +563,8 @@ "outputs": [], "source": [ "# If your model is BIDS compliant you can auto run spineps\n", - "# from TPTBox.segmentation import run_spineps_all\n", - "# run_spineps_all(dataset)" + "# from TPTBox.segmentation import _run_spineps_all\n", + "# _run_spineps_all(dataset)" ] }, { @@ -1002,7 +1014,7 @@ "from pathlib import Path\n", "\n", "from TPTBox import to_nii\n", - "from TPTBox.registration.deformable import Deformable_Registration\n", + "from TPTBox.registration import Deformable_Registration\n", "\n", "moving_nii = to_nii(Path(\"tutorial_data_processing/PixelPandemonium/mr_0.nii.gz\").absolute(), False)\n", "fixed_nii = to_nii(Path(\"tutorial_data_processing/PixelPandemonium/mr_1.nii.gz\").absolute(), False)\n", @@ -1097,4 +1109,4 @@ }, "nbformat": 4, "nbformat_minor": 0 -} \ No newline at end of file +} diff --git a/unit_tests/test_docs_examples.py b/unit_tests/test_docs_examples.py new file mode 100644 index 00000000..6774f417 --- /dev/null +++ b/unit_tests/test_docs_examples.py @@ -0,0 +1,299 @@ +"""Static drift gate for the public tutorials, examples and documentation. + +The unit tests cover the core abstractions thoroughly, but nothing checked that +the *published* material still matched the API. Renames such as +``TPTBox.registration.deformable`` -> ``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", + # 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", + "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}") + + +# 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): + """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. + + 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 + if ".tests" in name or name.endswith(("__main__", "script_ax2sag")): + continue + try: + importlib.import_module(name) + except ImportError as e: + # 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, [], "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)] + 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_registration_deepali.py b/unit_tests/test_registration_deepali.py index f736d549..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, @@ -666,8 +662,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 +675,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__": 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()