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.
- totalsegmentator_mlx/__init__.py +23 -0
- totalsegmentator_mlx/__main__.py +6 -0
- totalsegmentator_mlx/cli.py +128 -0
- totalsegmentator_mlx/conversion.py +88 -0
- totalsegmentator_mlx/engine.py +140 -0
- totalsegmentator_mlx/geometry.py +214 -0
- totalsegmentator_mlx/imageio.py +45 -0
- totalsegmentator_mlx/labels.py +276 -0
- totalsegmentator_mlx/localization.py +59 -0
- totalsegmentator_mlx/medmlx.py +71 -0
- totalsegmentator_mlx/models.py +136 -0
- totalsegmentator_mlx/output.py +96 -0
- totalsegmentator_mlx/pipeline.py +290 -0
- totalsegmentator_mlx/py.typed +0 -0
- totalsegmentator_mlx/resampling.py +130 -0
- totalsegmentator_mlx-0.1.1.dist-info/METADATA +70 -0
- totalsegmentator_mlx-0.1.1.dist-info/RECORD +21 -0
- totalsegmentator_mlx-0.1.1.dist-info/WHEEL +4 -0
- totalsegmentator_mlx-0.1.1.dist-info/entry_points.txt +5 -0
- totalsegmentator_mlx-0.1.1.dist-info/licenses/LICENSE +201 -0
- totalsegmentator_mlx-0.1.1.dist-info/licenses/THIRD_PARTY_NOTICES.md +46 -0
|
@@ -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,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)
|