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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/tests_mr.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions TPTBox/core/README_POI.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion TPTBox/core/bids_files.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
22 changes: 21 additions & 1 deletion TPTBox/core/dicom/__init__.py
Original file line number Diff line number Diff line change
@@ -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"]
7 changes: 6 additions & 1 deletion TPTBox/core/dicom/dicom2nii_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down
45 changes: 4 additions & 41 deletions TPTBox/core/dicom/dicom_extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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"


Expand Down
58 changes: 58 additions & 0 deletions TPTBox/core/internal/nii_help.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
82 changes: 82 additions & 0 deletions TPTBox/core/internal/optional_deps.py
Original file line number Diff line number Diff line change
@@ -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
14 changes: 1 addition & 13 deletions TPTBox/core/nii_wrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -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``).

Expand Down Expand Up @@ -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
Expand Down
30 changes: 21 additions & 9 deletions TPTBox/registration/README.md
Original file line number Diff line number Diff line change
@@ -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

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