totalsegmentator-mlx 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,23 @@
1
+ """TotalSegmentator v2 ``total`` CT inference on Apple Silicon (MLX), research software.
2
+
3
+ ``segment`` runs a CT through the native workflow and returns uint8 global label IDs
4
+ (1-117) on the input grid; ``convert_model`` converts upstream fold-0 checkpoints to
5
+ the layout ``segment`` reads. Names load on first access, so importing the package
6
+ does not import the MLX engine.
7
+ """
8
+
9
+ from importlib import import_module
10
+ from typing import TYPE_CHECKING
11
+
12
+ if TYPE_CHECKING:
13
+ from totalsegmentator_mlx.conversion import convert_model
14
+ from totalsegmentator_mlx.pipeline import segment
15
+
16
+ __all__ = ["convert_model", "segment"]
17
+
18
+
19
+ def __getattr__(name: str) -> object:
20
+ owner = {"segment": "pipeline", "convert_model": "conversion"}.get(name)
21
+ if owner is None:
22
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
23
+ return getattr(import_module(f"{__name__}.{owner}"), name)
@@ -0,0 +1,6 @@
1
+ """Run the installed command through python -m totalsegmentator_mlx."""
2
+
3
+ from totalsegmentator_mlx.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -0,0 +1,128 @@
1
+ """Lazy command-line entry point for conversion, CT prediction and label discovery."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import TYPE_CHECKING, cast
10
+
11
+ from totalsegmentator_mlx.labels import LABELS, MODEL_PARTS, QUALITY_MODELS
12
+
13
+ if TYPE_CHECKING:
14
+ from totalsegmentator_mlx.models import Mode
15
+
16
+
17
+ class Arguments(argparse.Namespace):
18
+ """Typed namespace for the ``predict``, ``convert`` and ``labels`` subcommands.
19
+
20
+ Only the fields of the chosen subcommand are set by argparse.
21
+ """
22
+
23
+ command: str
24
+ input_path: Path
25
+ output_path: Path
26
+ model_dir: Path
27
+ mode: str
28
+ model_part: str | None
29
+ organ: list[str] | None
30
+ roi_subset: list[str] | None
31
+ robust_crop: bool
32
+ body_seg: bool
33
+ models: list[str] | None
34
+ force_split: bool
35
+ overwrite: bool
36
+ probabilities_path: Path | None
37
+ source_model_dir: Path
38
+ output_model_dir: Path
39
+
40
+
41
+ def _parser() -> argparse.ArgumentParser:
42
+ parser = argparse.ArgumentParser(description="Native TotalSegmentator v2 CT inference on Metal")
43
+ subparsers = parser.add_subparsers(dest="command", required=True)
44
+ predict = subparsers.add_parser("predict", help="Segment a NIfTI CT on its original grid")
45
+ predict.add_argument("-i", "--input", dest="input_path", type=Path, required=True)
46
+ predict.add_argument("-o", "--output", dest="output_path", type=Path, required=True)
47
+ predict.add_argument("--model-dir", type=Path, required=True)
48
+ predict.add_argument(
49
+ "--mode",
50
+ choices=["standard", *QUALITY_MODELS],
51
+ default="standard",
52
+ help="standard: five 1.5 mm parts; fast: 3 mm model; coarse: 6 mm model (upstream fastest)",
53
+ )
54
+ predict.add_argument("--model-part", choices=list(MODEL_PARTS))
55
+ predict.add_argument("--organ", nargs="+", help="Filter canonical organ names after inference")
56
+ predict.add_argument(
57
+ "--roi-subset",
58
+ nargs="+",
59
+ help="Crop to these structures with a rough 6 mm segmentation and run only their parts",
60
+ )
61
+ predict.add_argument(
62
+ "--robust-crop", action="store_true", help="Locate --roi-subset with the 3 mm model"
63
+ )
64
+ predict.add_argument(
65
+ "--body-seg", action="store_true", help="Crop to the body with the Dataset300 model"
66
+ )
67
+ predict.add_argument("--force-split", action="store_true")
68
+ predict.add_argument("--overwrite", action="store_true")
69
+ predict.add_argument("--probabilities-path", type=Path)
70
+ convert = subparsers.add_parser("convert", help="Convert trusted local fold-0 checkpoints")
71
+ convert.add_argument("--source-model-dir", type=Path, required=True)
72
+ convert.add_argument("--output-model-dir", type=Path, required=True)
73
+ convert_models = convert.add_mutually_exclusive_group()
74
+ convert_models.add_argument("--model-part", choices=[*MODEL_PARTS, *QUALITY_MODELS, "body"])
75
+ convert_models.add_argument(
76
+ "--models",
77
+ nargs="+",
78
+ choices=[*MODEL_PARTS, *QUALITY_MODELS, "body"],
79
+ help="Models to convert into one new root (default: the five standard parts)",
80
+ )
81
+ subparsers.add_parser("labels", help="Print released global label IDs as JSON")
82
+ return parser
83
+
84
+
85
+ def main(argv: list[str] | None = None) -> None:
86
+ """Run a subcommand; ``labels`` prints the global ID-to-name table as JSON.
87
+
88
+ Exceptions that are ``OSError``, ``ValueError``, ``RuntimeError`` or ``ImportError``
89
+ (which includes the ``medmlx_core`` errors) are reported on stderr with exit status 2.
90
+ ``predict`` prints the output path on success and ``convert`` the new model root.
91
+ """
92
+ parser = _parser()
93
+ args = parser.parse_args(argv, namespace=Arguments())
94
+ if args.command == "labels":
95
+ print(json.dumps(LABELS, indent=2))
96
+ return
97
+ try:
98
+ if args.command == "convert":
99
+ from totalsegmentator_mlx.conversion import convert_model
100
+
101
+ output = convert_model(
102
+ args.source_model_dir,
103
+ args.output_model_dir,
104
+ model_part=args.model_part,
105
+ models=args.models,
106
+ )
107
+ print(output)
108
+ return
109
+ from totalsegmentator_mlx.pipeline import segment
110
+
111
+ segment(
112
+ args.input_path,
113
+ model_dir=args.model_dir,
114
+ output_path=args.output_path,
115
+ mode=cast("Mode", args.mode),
116
+ model_part=args.model_part,
117
+ prompt=args.organ,
118
+ roi_subset=args.roi_subset,
119
+ robust_crop=args.robust_crop,
120
+ body_seg=args.body_seg,
121
+ force_split=args.force_split,
122
+ overwrite=args.overwrite,
123
+ probabilities_path=args.probabilities_path,
124
+ progress=lambda message: print(message, file=sys.stderr, flush=True),
125
+ )
126
+ print(args.output_path)
127
+ except (OSError, ValueError, RuntimeError, ImportError) as error:
128
+ parser.exit(2, f"{parser.prog}: {error}\n")
@@ -0,0 +1,88 @@
1
+ """Convert trusted local CT checkpoint folders through the public nnunet-mlx API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+ from pathlib import Path
7
+ from tempfile import TemporaryDirectory
8
+
9
+ from medmlx_core.errors import AssetNotReadyError, InvalidInputError
10
+
11
+ from totalsegmentator_mlx.models import (
12
+ CONVERTIBLE,
13
+ MODEL_PARTS,
14
+ SOURCE_PROFILE,
15
+ model_folder,
16
+ validate_metadata,
17
+ )
18
+
19
+
20
+ def _source_folder(source: Path, task_id: int) -> Path:
21
+ """Find a trainer folder as ``<id>/`` or in upstream's ``Dataset<id>_*/`` results layout."""
22
+ folder = model_folder(source, task_id)
23
+ if folder.exists():
24
+ return folder
25
+ matches = sorted(source.glob(f"Dataset{task_id}_*/*__nnUNetPlans__3d_fullres"))
26
+ if len(matches) != 1:
27
+ raise AssetNotReadyError(f"Expected one Dataset{task_id} trainer folder in {source}")
28
+ return matches[0]
29
+
30
+
31
+ def convert_model(
32
+ source_model_dir: str | Path,
33
+ output_model_dir: str | Path,
34
+ *,
35
+ model_part: str | None = None,
36
+ models: Sequence[str] | None = None,
37
+ ) -> Path:
38
+ """Convert fold 0 into a new model root, publishing it only after every model succeeds.
39
+
40
+ ``models`` names any of the five parts, ``fast`` (297), ``coarse`` (298)
41
+ and ``body`` (300); the default converts the five standard parts. Each
42
+ source is an nnU-Net trainer folder with plans.json, dataset.json and
43
+ fold_0/checkpoint_final.pth, found as ``<id>/`` or in upstream's
44
+ ``Dataset<id>_*/<trainer>__nnUNetPlans__3d_fullres`` results layout. A
45
+ single ``model_part`` also accepts its trainer folder directly. Conversion
46
+ needs the optional conversion extra; inference never imports Torch.
47
+
48
+ Returns:
49
+ ``output_model_dir``, containing one converted folder per model, named by dataset
50
+ ID (``291`` ... ``295``, ``297``, ``298``, ``300``).
51
+
52
+ Raises:
53
+ InvalidInputError: Both ``model_part`` and ``models`` are given, a name is unknown,
54
+ or the destination exists or lies inside the source folder.
55
+ AssetNotReadyError: A source folder, its metadata or its fold-0 checkpoint is
56
+ missing or does not match the expected dataset.
57
+ """
58
+ source, output = Path(source_model_dir), Path(output_model_dir)
59
+ if model_part is not None and models is not None:
60
+ raise InvalidInputError("Choose model_part or models, not both")
61
+ names = [model_part] if model_part is not None else list(models or MODEL_PARTS)
62
+ if not names or set(names) - set(CONVERTIBLE):
63
+ raise InvalidInputError(f"Models must be among {', '.join(CONVERTIBLE)}")
64
+ task_ids = tuple(dict.fromkeys(CONVERTIBLE[name] for name in names))
65
+ if output.exists() or output.resolve().is_relative_to(source.resolve()):
66
+ raise InvalidInputError("Conversion requires a new destination outside the source folder")
67
+ folders = {task_id: _source_folder(source, task_id) for task_id in task_ids}
68
+ for task_id, folder in folders.items():
69
+ validate_metadata(folder, task_id)
70
+ checkpoint = folder / "fold_0" / "checkpoint_final.pth"
71
+ if not checkpoint.is_file():
72
+ raise AssetNotReadyError(f"Source fold-0 checkpoint is missing: {checkpoint}")
73
+ from nnunet_mlx import convert_upstream_model_folder
74
+
75
+ output.parent.mkdir(parents=True, exist_ok=True)
76
+ with TemporaryDirectory(prefix=".totalsegmentator_convert_", dir=output.parent) as temporary:
77
+ staged = Path(temporary) / "models"
78
+ staged.mkdir()
79
+ for task_id, folder in folders.items():
80
+ convert_upstream_model_folder(
81
+ folder,
82
+ staged / str(task_id),
83
+ folds=[0],
84
+ configuration="3d_fullres",
85
+ source_profile=SOURCE_PROFILE,
86
+ )
87
+ staged.rename(output)
88
+ return output
@@ -0,0 +1,140 @@
1
+ """Delegate each CT network to the public nnunet-mlx Metal inference boundary."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import replace
6
+ from functools import lru_cache
7
+ from pathlib import Path
8
+ from threading import RLock
9
+ from typing import TYPE_CHECKING
10
+
11
+ import nibabel as nib
12
+ import numpy as np
13
+ from medmlx_core.errors import ModelExecutionError
14
+
15
+ from totalsegmentator_mlx.imageio import load_nifti, spatial_affine
16
+ from totalsegmentator_mlx.labels import BODY_MODEL
17
+ from totalsegmentator_mlx.models import ARITHMETIC, SOURCE_PROFILE, TASK_SPACING
18
+
19
+ if TYPE_CHECKING:
20
+ from nnunet_mlx import ModelFolderPredictor
21
+
22
+ _runtime_lock = RLock()
23
+
24
+
25
+ def _model_identity(folder: Path) -> tuple[tuple[int, int, int, int, int] | None, ...]:
26
+ """Invalidate cached weights/metadata on replacement or an in-place update."""
27
+ paths = (
28
+ folder / "plans.json",
29
+ folder / "dataset.json",
30
+ folder / "fold_0/checkpoint_final/state.json",
31
+ folder / "fold_0/checkpoint_final/weights.safetensors",
32
+ folder / "conversion.json",
33
+ )
34
+ identities: list[tuple[int, int, int, int, int] | None] = []
35
+ for path in paths:
36
+ if path.name == "conversion.json" and not path.exists():
37
+ identities.append(None)
38
+ continue
39
+ info = path.stat()
40
+ identities.append(
41
+ (info.st_dev, info.st_ino, info.st_size, info.st_mtime_ns, info.st_ctime_ns)
42
+ )
43
+ return tuple(identities)
44
+
45
+
46
+ def _step_fraction(task_id: int) -> float:
47
+ """Return upstream's tile step: 0.8 below a 3 mm grid and 0.5 otherwise."""
48
+ return 0.8 if TASK_SPACING[task_id] < 3.0 else 0.5
49
+
50
+
51
+ # maxsize 8 keeps all five parts, both single-model tiers and the body model loaded.
52
+ @lru_cache(maxsize=8)
53
+ def _load_predictor(
54
+ folder: Path,
55
+ task_id: int,
56
+ identity: tuple[tuple[int, int, int, int, int] | None, ...],
57
+ ) -> ModelFolderPredictor:
58
+ """Load one fold-0 model onto Metal; ``identity`` is only the cache key (file stats).
59
+
60
+ Raises:
61
+ ModelExecutionError: Metal is unavailable, the model does not take one CT channel, or a
62
+ non-body model carries test-time mirroring axes.
63
+ """
64
+ from nnunet_mlx import ModelFolderPredictor, load_model_bundle
65
+ from nnunet_mlx.runtime import runtime_info
66
+
67
+ if runtime_info().backend != "metal":
68
+ raise ModelExecutionError("TotalSegmentator requires an Apple Metal GPU")
69
+ bundle = load_model_bundle(
70
+ folder,
71
+ configuration="3d_fullres",
72
+ folds=[0],
73
+ source_profile=SOURCE_PROFILE,
74
+ arithmetic=ARITHMETIC,
75
+ )
76
+ if bundle.image_channels != 1 or bundle.input_channels != 1:
77
+ raise ModelExecutionError("CT models require exactly one CT input channel")
78
+ if task_id == BODY_MODEL:
79
+ # The body model was trained with mirroring; upstream predicts it without TTA.
80
+ bundle = replace(bundle, mirroring_axes=())
81
+ elif bundle.mirroring_axes:
82
+ raise ModelExecutionError("TotalSegmentator CT requires a no-mirroring model bundle")
83
+ # Upstream nnU-Net blends every model's tiles in float16; float32 blending
84
+ # changed 549 voxels of AMOS22 case amos_0008 against the upstream standard pipeline.
85
+ return ModelFolderPredictor(
86
+ bundle,
87
+ step_fraction=_step_fraction(task_id),
88
+ compile_models=False,
89
+ accumulation="nnunet-fp16",
90
+ )
91
+
92
+
93
+ def predict_image(image: nib.Nifti1Image, folder: Path, task_id: int) -> nib.Nifti1Image:
94
+ """Return local labels on the supplied XYZ grid, retaining loaded networks.
95
+
96
+ ``image`` is float32 CT on the model grid (1.5, 3 or 6 mm); the returned labels are
97
+ this model's own IDs (``model_labels(task_id)``), not yet merged into global IDs.
98
+
99
+ The public engine NIfTI API transfers final labels from Metal. The lock owns
100
+ the cached predictors' single-GPU lifecycle across concurrent API callers.
101
+ """
102
+ with _runtime_lock:
103
+ folder = folder.resolve()
104
+ predictor = _load_predictor(folder, task_id, _model_identity(folder))
105
+ return predictor.predict_nifti(image)
106
+
107
+
108
+ def predict_part(
109
+ inputs: Path,
110
+ output: Path,
111
+ folder: Path,
112
+ task_id: int,
113
+ *,
114
+ save_probabilities: bool,
115
+ ) -> None:
116
+ """Export file-backed predictions and optional local-label probabilities.
117
+
118
+ Reads ``*_0000.nii.gz`` from ``inputs`` and writes ``<case>.nii.gz`` (plus ``.npz`` and
119
+ ``.pkl`` when ``save_probabilities``) to ``output``, then checks that each result keeps
120
+ its input's shape and affine.
121
+ """
122
+ with _runtime_lock:
123
+ folder = folder.resolve()
124
+ predictor = _load_predictor(folder, task_id, _model_identity(folder))
125
+ predictor.predict_folder(
126
+ inputs,
127
+ output,
128
+ save_probabilities=save_probabilities,
129
+ overwrite=False,
130
+ )
131
+ for path in sorted(inputs.glob("*_0000.nii.gz")):
132
+ source = load_nifti(path)
133
+ result = load_nifti(output / path.name.replace("_0000", ""))
134
+ if result.shape != source.shape or not np.allclose(
135
+ spatial_affine(result),
136
+ spatial_affine(source),
137
+ rtol=0,
138
+ atol=1e-5,
139
+ ):
140
+ raise ModelExecutionError("Engine segmentation geometry differs from its CT input")
@@ -0,0 +1,214 @@
1
+ """Outer CT resampling and reversible NIfTI orientation in voxel (x, y, z) order.
2
+
3
+ Volumes are 3D arrays in NIfTI storage order ``(x, y, z)``. ``canonical_ct`` reorients to
4
+ the closest RAS+ axes, ``resample_ct`` moves to an isotropic model grid (mm) and
5
+ ``restore_labels`` returns labels to the source shape, orientation and affine.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Callable, Sequence
11
+ from importlib import import_module
12
+ from pathlib import Path
13
+ from typing import Protocol, cast
14
+
15
+ import nibabel as nib
16
+ import numpy as np
17
+ from medmlx_core.errors import InvalidInputError, ModelExecutionError
18
+ from numpy.typing import NDArray
19
+
20
+ from totalsegmentator_mlx.imageio import load_nifti, spatial_affine
21
+ from totalsegmentator_mlx.resampling import cubic_zoom, nearest_labels
22
+
23
+ type Labels = NDArray[np.uint8]
24
+
25
+
26
+ class DtypeSetter(Protocol):
27
+ def set_data_dtype(self, datatype: object) -> None: ...
28
+
29
+
30
+ def load_ct(source: str | Path | nib.Nifti1Image) -> nib.Nifti1Image:
31
+ """Load a finite, nonempty 3D CT with an invertible affine; voxel spacing is in mm.
32
+
33
+ ``source`` is a path or an in-memory NIfTI-1 image. Intensities are expected in
34
+ Hounsfield units (not checked) and must be integer or float and finite. Spatial units
35
+ must be millimeters or unspecified. Retain validated proxy values so canonicalization
36
+ does not decode them again. Array-backed inputs are returned unchanged; caller data
37
+ and headers are not modified.
38
+
39
+ Raises:
40
+ InvalidInputError: The volume is not 3D, is empty or non-numeric, has non-finite
41
+ values, has a non-invertible or non-finite affine, or has non-mm spatial units.
42
+ """
43
+ image = source if isinstance(source, nib.Nifti1Image) else load_nifti(source)
44
+ values = np.asanyarray(image.dataobj)
45
+ if values.ndim != 3 or not values.size or values.dtype.kind not in "iuf":
46
+ raise InvalidInputError("Input must be a nonempty three-dimensional CT")
47
+ if not np.isfinite(values).all():
48
+ raise InvalidInputError("CT intensities must be finite")
49
+ affine = image.affine
50
+ if (
51
+ affine is None
52
+ or not np.isfinite(affine).all()
53
+ or abs(float(np.linalg.det(affine[:3, :3]))) < 1e-12
54
+ or not np.allclose(affine[3], [0, 0, 0, 1])
55
+ ):
56
+ raise InvalidInputError("CT requires a finite, invertible spatial affine")
57
+ if image.header.get_xyzt_units()[0] not in {"unknown", "mm"}:
58
+ raise InvalidInputError("CT spatial units must be millimeters")
59
+ return (
60
+ image
61
+ if cast(object, image.dataobj) is values
62
+ else nib.Nifti1Image(values, affine, image.header)
63
+ )
64
+
65
+
66
+ def canonical_ct(source: nib.Nifti1Image) -> nib.Nifti1Image:
67
+ """Reorient to the closest canonical (RAS+) axes, dropping the header.
68
+
69
+ Only the voxel array and affine are kept (axes are flipped or permuted, nothing is
70
+ interpolated). Restore the source orientation with ``restore_labels``.
71
+ """
72
+ # Match upstream's header-free staging before canonical reorientation.
73
+ clean = nib.Nifti1Image(np.asanyarray(source.dataobj), source.affine)
74
+ canonicalize = cast(
75
+ Callable[[nib.Nifti1Image], object],
76
+ import_module("nibabel").as_closest_canonical,
77
+ )
78
+ canonical = canonicalize(clean)
79
+ if not isinstance(canonical, nib.Nifti1Image):
80
+ raise InvalidInputError("Canonical CT must remain a NIfTI-1 image")
81
+ return canonical
82
+
83
+
84
+ def resample_ct(image: nib.Nifti1Image, target: float) -> nib.Nifti1Image:
85
+ """Stage cubic CT values on an isotropic ``target`` mm grid as int32, as upstream does.
86
+
87
+ ``image`` is canonical-orientation CT whose header zooms are the voxel size in mm.
88
+ Resampling is a cubic spline (SciPy ``zoom`` semantics, nearest boundary) in float64;
89
+ results are then cast to int32, which truncates toward zero. When the spacing already
90
+ equals ``target`` the values are only cast. Raises ``InvalidInputError`` if the field
91
+ of view is smaller than one model voxel.
92
+ """
93
+ spacing = np.asarray(image.header.get_zooms(), dtype=np.float64)
94
+ factors = spacing / target
95
+ shape = np.rint(np.asarray(image.shape) * factors)
96
+ if np.any(shape < 1):
97
+ raise InvalidInputError(f"CT field of view is too small for the {target} mm model grid")
98
+ if np.array_equal(spacing, [target] * 3):
99
+ values = np.asanyarray(image.dataobj).astype(np.int32)
100
+ else:
101
+ values = cubic_zoom(np.asarray(image.get_fdata(), dtype=np.float64), factors).astype(
102
+ np.int32
103
+ )
104
+ affine = np.array(image.affine, copy=True)
105
+ affine[:3, :3] /= factors
106
+ return nib.Nifti1Image(values, affine)
107
+
108
+
109
+ def split_ct(
110
+ image: nib.Nifti1Image, *, force_split: bool, multimodel: bool
111
+ ) -> tuple[nib.Nifti1Image, ...]:
112
+ """Use upstream's three overlapping z slabs for large multi-model volumes or on request.
113
+
114
+ Splitting happens for multi-model runs whose grid exceeds 512*512*900 voxels with more
115
+ than 200 slices, or when ``force_split``. The slabs cover the thirds of the z axis, each
116
+ extended by a margin of about 20 slices into its neighbors (float64 values);
117
+ ``join_slabs`` drops the margins again. Raises ``InvalidInputError`` if a split is
118
+ requested for fewer than 60 slices.
119
+ """
120
+ depth = image.shape[2]
121
+ split = force_split or (multimodel and np.prod(image.shape) > 512 * 512 * 900 and depth > 200)
122
+ if not split:
123
+ return (image,)
124
+ if depth < 60:
125
+ raise InvalidInputError("Splitting requires at least 60 slices on the model grid")
126
+ third, margin = depth // 3, 20
127
+ values = image.get_fdata()
128
+ # Each slab retains the upstream staging affine; merge uses voxel indices.
129
+ slices = (
130
+ slice(None, third + margin),
131
+ slice(third + 1 - margin, 2 * third + margin),
132
+ slice(2 * third + 1 - margin, None),
133
+ )
134
+ return tuple(nib.Nifti1Image(values[:, :, item], image.affine) for item in slices)
135
+
136
+
137
+ def join_slabs(slabs: Sequence[Labels], shape: tuple[int, ...]) -> Labels:
138
+ """Join one volume, or three ``split_ct`` slab predictions, into labels of ``shape``.
139
+
140
+ The 20-slice overlaps are discarded, so each z slice comes from exactly one slab.
141
+ """
142
+ if len(slabs) == 1:
143
+ return slabs[0]
144
+ if len(slabs) != 3:
145
+ raise ModelExecutionError("Expected one volume or three prediction slabs")
146
+ third, margin = shape[2] // 3, 20
147
+ joined = np.empty(shape, dtype=np.uint8)
148
+ joined[:, :, :third] = slabs[0][:, :, :-margin]
149
+ joined[:, :, third : 2 * third] = slabs[1][:, :, margin - 1 : -margin]
150
+ joined[:, :, 2 * third :] = slabs[2][:, :, margin - 1 :]
151
+ return joined
152
+
153
+
154
+ def restore_labels(
155
+ labels: Labels,
156
+ canonical: nib.Nifti1Image,
157
+ source: nib.Nifti1Image,
158
+ ) -> nib.Nifti1Image:
159
+ """Restore nearest-neighbor labels to the exact source shape, affine and orientation.
160
+
161
+ ``labels`` is on the resampled grid of ``canonical`` (the ``canonical_ct`` image of
162
+ ``source``). Labels are scaled up to the canonical shape with nearest-neighbor
163
+ sampling, then reoriented to match ``source``. Raises ``ModelExecutionError`` if the
164
+ result does not have the source's shape and affine.
165
+ """
166
+ factors = np.asarray(canonical.shape) / np.asarray(labels.shape)
167
+ restored = nearest_labels(labels, factors)
168
+ image = nib.Nifti1Image(restored, canonical.affine)
169
+ orientations = import_module("nibabel.orientations")
170
+ orientation = cast(Callable[[object], NDArray[np.float64]], orientations.io_orientation)
171
+ ras = cast(Callable[[str], NDArray[np.float64]], orientations.axcodes2ornt)
172
+ transform = cast(
173
+ Callable[[NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]],
174
+ orientations.ornt_transform,
175
+ )(ras("RAS"), orientation(source.affine))
176
+ image = image.as_reoriented(cast(Sequence[Sequence[int]], transform))
177
+ if image.shape != source.shape or not np.allclose(
178
+ spatial_affine(image), spatial_affine(source), rtol=0, atol=1e-5
179
+ ):
180
+ raise ModelExecutionError("Restored segmentation geometry differs from its CT input")
181
+ return source_labels(np.asanyarray(image.dataobj), source)
182
+
183
+
184
+ def source_labels(labels: Labels, source: nib.Nifti1Image) -> nib.Nifti1Image:
185
+ """Wrap uint8 labels with the source affine and a copy of its header."""
186
+ header = source.header.copy()
187
+ cast(DtypeSetter, header).set_data_dtype(np.uint8)
188
+ return nib.Nifti1Image(labels, source.affine, header)
189
+
190
+
191
+ def crop_ct(
192
+ source: nib.Nifti1Image, mask: NDArray[np.bool_], margin_mm: float
193
+ ) -> tuple[nib.Nifti1Image, tuple[slice, slice, slice]]:
194
+ """Crop CT to a nonempty source-grid mask's bounding box plus a margin, staged as int32.
195
+
196
+ The margin converts to whole voxels per source axis by truncation, using
197
+ affine column norms as upstream's header-free copy does. Cropped float64
198
+ values truncate to int32 before reorientation and resampling, and the
199
+ affine translation moves to the first retained voxel.
200
+ """
201
+ probe = nib.Nifti1Image(np.zeros((1, 1, 1), dtype=np.uint8), source.affine)
202
+ zooms = np.asarray(probe.header.get_zooms())
203
+ margin = (np.full(3, margin_mm) / zooms).astype(int)
204
+ occupied = [np.flatnonzero(mask.any(axis=other)) for other in ((1, 2), (0, 2), (0, 1))]
205
+ lower = [max(0, int(axis[0]) - pad) for axis, pad in zip(occupied, margin, strict=True)]
206
+ upper = [
207
+ min(size, int(axis[-1]) + 1 + pad)
208
+ for axis, pad, size in zip(occupied, margin, mask.shape, strict=True)
209
+ ]
210
+ box = (slice(lower[0], upper[0]), slice(lower[1], upper[1]), slice(lower[2], upper[2]))
211
+ values = np.asarray(np.asanyarray(source.dataobj)[box], dtype=np.float64).astype(np.int32)
212
+ affine = np.array(source.affine, dtype=np.float64, copy=True)
213
+ affine[:3, 3] = (affine @ np.array([*lower, 1], dtype=np.float64))[:3]
214
+ return nib.Nifti1Image(values, affine), box
@@ -0,0 +1,45 @@
1
+ """Typed NIfTI boundaries for NiBabel's partially annotated load/save API."""
2
+
3
+ from collections.abc import Callable
4
+ from importlib import import_module
5
+ from pathlib import Path
6
+ from typing import Protocol, cast
7
+
8
+ import nibabel as nib
9
+ import numpy as np
10
+ from medmlx_core.errors import InvalidInputError
11
+ from numpy.typing import NDArray
12
+
13
+
14
+ class ImageWriter(Protocol):
15
+ def to_filename(self, filename: Path) -> None: ...
16
+
17
+
18
+ def load_nifti(path: str | Path) -> nib.Nifti1Image:
19
+ """Load a NIfTI file lazily (voxels stay an array proxy).
20
+
21
+ Accepts both NIfTI-1 and NIfTI-2 files: NiBabel's ``Nifti2Image`` subclasses
22
+ ``Nifti1Image`` and exposes the same affine/header/dataobj interface, and
23
+ upstream TotalSegmentator's own loader (``nifti_io.require_nifti1``) applies the
24
+ identical ``isinstance(image, Nifti1Image)`` check, so this matches its behavior.
25
+
26
+ Raises ``InvalidInputError`` if the file does not load as a NIfTI image.
27
+ """
28
+ loader = cast(Callable[[str], object], import_module("nibabel").load)
29
+ image = loader(str(path))
30
+ if not isinstance(image, nib.Nifti1Image):
31
+ raise InvalidInputError("Input must be a NIfTI image")
32
+ return image
33
+
34
+
35
+ def save_nifti(image: nib.Nifti1Image, path: Path) -> None:
36
+ """Write ``image`` to ``path``; compression follows the file extension (``.nii.gz``)."""
37
+ cast(ImageWriter, image).to_filename(path)
38
+
39
+
40
+ def spatial_affine(image: nib.Nifti1Image) -> NDArray[np.float64]:
41
+ """Return the image's 4x4 voxel-to-world affine (mm) as float64."""
42
+ affine = image.affine
43
+ if affine is None:
44
+ raise InvalidInputError("NIfTI image requires a spatial affine")
45
+ return np.asarray(affine, dtype=np.float64)