segresnet-mlx 0.2.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.
Potentially problematic release.
This version of segresnet-mlx might be problematic. Click here for more details.
- segresnet_mlx/__init__.py +67 -0
- segresnet_mlx/_arrays.py +36 -0
- segresnet_mlx/_nifti.py +63 -0
- segresnet_mlx/_records.py +78 -0
- segresnet_mlx/_runtime.py +22 -0
- segresnet_mlx/_types.py +58 -0
- segresnet_mlx/inference.py +37 -0
- segresnet_mlx/medmlx.py +669 -0
- segresnet_mlx/network.py +195 -0
- segresnet_mlx/predictor.py +303 -0
- segresnet_mlx/py.typed +0 -0
- segresnet_mlx/telemetry.py +166 -0
- segresnet_mlx/topology.py +49 -0
- segresnet_mlx/transforms.py +669 -0
- segresnet_mlx/weights.py +183 -0
- segresnet_mlx-0.2.1.dist-info/METADATA +94 -0
- segresnet_mlx-0.2.1.dist-info/RECORD +21 -0
- segresnet_mlx-0.2.1.dist-info/WHEEL +4 -0
- segresnet_mlx-0.2.1.dist-info/entry_points.txt +2 -0
- segresnet_mlx-0.2.1.dist-info/licenses/LICENSE +202 -0
- segresnet_mlx-0.2.1.dist-info/licenses/THIRD_PARTY_NOTICES.md +21 -0
segresnet_mlx/medmlx.py
ADDED
|
@@ -0,0 +1,669 @@
|
|
|
1
|
+
"""medmlx declaration and runner for the original MONAI SegResNet bundles."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import tempfile
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import Mapping
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
from segresnet_mlx._arrays import Float32Array, Float64Array, MaskArray
|
|
14
|
+
from segresnet_mlx._records import RunResult, Runtime
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from segresnet_mlx.network import SegResNet
|
|
18
|
+
|
|
19
|
+
import numpy as np
|
|
20
|
+
from medmlx_core.errors import (
|
|
21
|
+
AssetNotReadyError,
|
|
22
|
+
InvalidInputError,
|
|
23
|
+
ModelExecutionError,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
from segresnet_mlx import transforms
|
|
27
|
+
from segresnet_mlx.topology import ArithmeticProfile, SegResNetSpec
|
|
28
|
+
|
|
29
|
+
MODEL = {
|
|
30
|
+
"id": "segresnet",
|
|
31
|
+
"label": "SegResNet",
|
|
32
|
+
"task": "segmentation",
|
|
33
|
+
"run": "segresnet_mlx.medmlx:run",
|
|
34
|
+
"summary": (
|
|
35
|
+
"MONAI SegResNet bundles: renal structures in three-phase contrast CT "
|
|
36
|
+
"(renalStructures_CECT_segmentation), brain tumor in BraTS MRI "
|
|
37
|
+
"(brats_mri_segmentation), and 104 whole-body structures in CT at 1.5 mm or "
|
|
38
|
+
"3 mm (wholeBody_ct_segmentation, wholeBody_ct_segmentation_lowres)."
|
|
39
|
+
),
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
# Label values of the 104 TotalSegmentator structures plus background, as listed in the
|
|
43
|
+
# whole-body bundle's metadata.json ``channel_def``.
|
|
44
|
+
_WHOLE_BODY_LABELS: Mapping[int, str] = {
|
|
45
|
+
0: "background",
|
|
46
|
+
1: "spleen",
|
|
47
|
+
2: "kidney_right",
|
|
48
|
+
3: "kidney_left",
|
|
49
|
+
4: "gallbladder",
|
|
50
|
+
5: "liver",
|
|
51
|
+
6: "stomach",
|
|
52
|
+
7: "aorta",
|
|
53
|
+
8: "inferior_vena_cava",
|
|
54
|
+
9: "portal_vein_and_splenic_vein",
|
|
55
|
+
10: "pancreas",
|
|
56
|
+
11: "adrenal_gland_right",
|
|
57
|
+
12: "adrenal_gland_left",
|
|
58
|
+
13: "lung_upper_lobe_left",
|
|
59
|
+
14: "lung_lower_lobe_left",
|
|
60
|
+
15: "lung_upper_lobe_right",
|
|
61
|
+
16: "lung_middle_lobe_right",
|
|
62
|
+
17: "lung_lower_lobe_right",
|
|
63
|
+
18: "vertebrae_L5",
|
|
64
|
+
19: "vertebrae_L4",
|
|
65
|
+
20: "vertebrae_L3",
|
|
66
|
+
21: "vertebrae_L2",
|
|
67
|
+
22: "vertebrae_L1",
|
|
68
|
+
23: "vertebrae_T12",
|
|
69
|
+
24: "vertebrae_T11",
|
|
70
|
+
25: "vertebrae_T10",
|
|
71
|
+
26: "vertebrae_T9",
|
|
72
|
+
27: "vertebrae_T8",
|
|
73
|
+
28: "vertebrae_T7",
|
|
74
|
+
29: "vertebrae_T6",
|
|
75
|
+
30: "vertebrae_T5",
|
|
76
|
+
31: "vertebrae_T4",
|
|
77
|
+
32: "vertebrae_T3",
|
|
78
|
+
33: "vertebrae_T2",
|
|
79
|
+
34: "vertebrae_T1",
|
|
80
|
+
35: "vertebrae_C7",
|
|
81
|
+
36: "vertebrae_C6",
|
|
82
|
+
37: "vertebrae_C5",
|
|
83
|
+
38: "vertebrae_C4",
|
|
84
|
+
39: "vertebrae_C3",
|
|
85
|
+
40: "vertebrae_C2",
|
|
86
|
+
41: "vertebrae_C1",
|
|
87
|
+
42: "esophagus",
|
|
88
|
+
43: "trachea",
|
|
89
|
+
44: "heart_myocardium",
|
|
90
|
+
45: "heart_atrium_left",
|
|
91
|
+
46: "heart_ventricle_left",
|
|
92
|
+
47: "heart_atrium_right",
|
|
93
|
+
48: "heart_ventricle_right",
|
|
94
|
+
49: "pulmonary_artery",
|
|
95
|
+
50: "brain",
|
|
96
|
+
51: "iliac_artery_left",
|
|
97
|
+
52: "iliac_artery_right",
|
|
98
|
+
53: "iliac_vena_left",
|
|
99
|
+
54: "iliac_vena_right",
|
|
100
|
+
55: "small_bowel",
|
|
101
|
+
56: "duodenum",
|
|
102
|
+
57: "colon",
|
|
103
|
+
58: "rib_left_1",
|
|
104
|
+
59: "rib_left_2",
|
|
105
|
+
60: "rib_left_3",
|
|
106
|
+
61: "rib_left_4",
|
|
107
|
+
62: "rib_left_5",
|
|
108
|
+
63: "rib_left_6",
|
|
109
|
+
64: "rib_left_7",
|
|
110
|
+
65: "rib_left_8",
|
|
111
|
+
66: "rib_left_9",
|
|
112
|
+
67: "rib_left_10",
|
|
113
|
+
68: "rib_left_11",
|
|
114
|
+
69: "rib_left_12",
|
|
115
|
+
70: "rib_right_1",
|
|
116
|
+
71: "rib_right_2",
|
|
117
|
+
72: "rib_right_3",
|
|
118
|
+
73: "rib_right_4",
|
|
119
|
+
74: "rib_right_5",
|
|
120
|
+
75: "rib_right_6",
|
|
121
|
+
76: "rib_right_7",
|
|
122
|
+
77: "rib_right_8",
|
|
123
|
+
78: "rib_right_9",
|
|
124
|
+
79: "rib_right_10",
|
|
125
|
+
80: "rib_right_11",
|
|
126
|
+
81: "rib_right_12",
|
|
127
|
+
82: "humerus_left",
|
|
128
|
+
83: "humerus_right",
|
|
129
|
+
84: "scapula_left",
|
|
130
|
+
85: "scapula_right",
|
|
131
|
+
86: "clavicula_left",
|
|
132
|
+
87: "clavicula_right",
|
|
133
|
+
88: "femur_left",
|
|
134
|
+
89: "femur_right",
|
|
135
|
+
90: "hip_left",
|
|
136
|
+
91: "hip_right",
|
|
137
|
+
92: "sacrum",
|
|
138
|
+
93: "face",
|
|
139
|
+
94: "gluteus_maximus_left",
|
|
140
|
+
95: "gluteus_maximus_right",
|
|
141
|
+
96: "gluteus_medius_left",
|
|
142
|
+
97: "gluteus_medius_right",
|
|
143
|
+
98: "gluteus_minimus_left",
|
|
144
|
+
99: "gluteus_minimus_right",
|
|
145
|
+
100: "autochthon_left",
|
|
146
|
+
101: "autochthon_right",
|
|
147
|
+
102: "iliopsoas_left",
|
|
148
|
+
103: "iliopsoas_right",
|
|
149
|
+
104: "urinary_bladder",
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@dataclass(frozen=True, slots=True)
|
|
154
|
+
class Bundle:
|
|
155
|
+
"""One MONAI bundle: network topology, inferer settings and file contract.
|
|
156
|
+
|
|
157
|
+
``roi_size``, ``sw_batch_size``, ``overlap``, ``mode`` and ``padding_mode`` are the
|
|
158
|
+
sliding-window settings described in ``SlidingWindowConfig``. ``roles`` names the
|
|
159
|
+
``run`` path arguments the bundle needs, in network channel order. ``labels`` maps
|
|
160
|
+
each value written to the mask to its name; ``output_dtype`` is the NIfTI dtype.
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
spec: SegResNetSpec
|
|
164
|
+
roi_size: tuple[int, int, int]
|
|
165
|
+
sw_batch_size: int
|
|
166
|
+
overlap: float
|
|
167
|
+
roles: tuple[str, ...]
|
|
168
|
+
labels: Mapping[int, str]
|
|
169
|
+
output_dtype: str
|
|
170
|
+
mode: str = "constant"
|
|
171
|
+
padding_mode: str = "constant"
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# Every bundle uses the MONAI SegResNet defaults for blocks_down (1, 2, 2, 4),
|
|
175
|
+
# blocks_up (1, 1, 1), group norm with 8 groups and eps 1e-5. They differ in channel
|
|
176
|
+
# counts, init_filters and (renal only) deconv instead of nontrainable upsampling.
|
|
177
|
+
BUNDLES: dict[str, Bundle] = {
|
|
178
|
+
"renalStructures_CECT_segmentation": Bundle(
|
|
179
|
+
spec=SegResNetSpec(3, 6, 32, upsample_mode="deconv"),
|
|
180
|
+
roi_size=(96, 96, 96),
|
|
181
|
+
sw_batch_size=4,
|
|
182
|
+
overlap=0.25,
|
|
183
|
+
roles=("arterial_path", "venous_path", "excretory_path"),
|
|
184
|
+
labels={
|
|
185
|
+
0: "background",
|
|
186
|
+
1: "artery",
|
|
187
|
+
2: "vein",
|
|
188
|
+
3: "ureter",
|
|
189
|
+
4: "neoplasm",
|
|
190
|
+
5: "parenchyma",
|
|
191
|
+
},
|
|
192
|
+
output_dtype="float32",
|
|
193
|
+
),
|
|
194
|
+
"brats_mri_segmentation": Bundle(
|
|
195
|
+
spec=SegResNetSpec(4, 3, 16),
|
|
196
|
+
roi_size=(240, 240, 160),
|
|
197
|
+
sw_batch_size=1,
|
|
198
|
+
overlap=0.5,
|
|
199
|
+
roles=("t1c_path", "t1_path", "t2_path", "flair_path"),
|
|
200
|
+
labels={
|
|
201
|
+
0: "background",
|
|
202
|
+
1: "tumor core, not enhancing",
|
|
203
|
+
2: "whole tumor outside the core",
|
|
204
|
+
4: "enhancing tumor",
|
|
205
|
+
},
|
|
206
|
+
output_dtype="uint8",
|
|
207
|
+
),
|
|
208
|
+
"wholeBody_ct_segmentation": Bundle(
|
|
209
|
+
spec=SegResNetSpec(1, 105, 32),
|
|
210
|
+
roi_size=(96, 96, 96),
|
|
211
|
+
sw_batch_size=1,
|
|
212
|
+
overlap=0.25,
|
|
213
|
+
roles=("image_path",),
|
|
214
|
+
labels=_WHOLE_BODY_LABELS,
|
|
215
|
+
output_dtype="float32",
|
|
216
|
+
mode="gaussian",
|
|
217
|
+
padding_mode="replicate",
|
|
218
|
+
),
|
|
219
|
+
"wholeBody_ct_segmentation_lowres": Bundle(
|
|
220
|
+
spec=SegResNetSpec(1, 105, 32),
|
|
221
|
+
roi_size=(96, 96, 96),
|
|
222
|
+
sw_batch_size=1,
|
|
223
|
+
overlap=0.25,
|
|
224
|
+
roles=("image_path",),
|
|
225
|
+
labels=_WHOLE_BODY_LABELS,
|
|
226
|
+
output_dtype="float32",
|
|
227
|
+
mode="gaussian",
|
|
228
|
+
padding_mode="replicate",
|
|
229
|
+
),
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
_ROLES = tuple(dict.fromkeys(role for bundle in BUNDLES.values() for role in bundle.roles))
|
|
233
|
+
# The renal bundle's Spacingd pixdim, in mm.
|
|
234
|
+
_RENAL_SPACING = (0.8, 0.8, 0.8)
|
|
235
|
+
# wholeBody_ct_segmentation's `displayable_configs#highres` toggle: true selects
|
|
236
|
+
# 1.5 mm (models/model.pt), false selects 3 mm (models/model_lowres.pt). Both share
|
|
237
|
+
# one network and inferer; only the resampling target and checkpoint differ.
|
|
238
|
+
_WHOLE_BODY_PIXDIM = {
|
|
239
|
+
"wholeBody_ct_segmentation": (1.5, 1.5, 1.5),
|
|
240
|
+
"wholeBody_ct_segmentation_lowres": (3.0, 3.0, 3.0),
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
@dataclass(frozen=True, slots=True)
|
|
245
|
+
class Volume:
|
|
246
|
+
"""A finite 3D NIfTI input and its float32 voxels on the reader-selected grid.
|
|
247
|
+
|
|
248
|
+
``data`` has no channel axis and keeps the file's voxel axis order; ``affine`` is
|
|
249
|
+
the voxel-to-world matrix chosen by the nibabel reader after MONAI's header
|
|
250
|
+
correction (see ``transforms.image_arrays``), and ``shape`` is the stored shape.
|
|
251
|
+
"""
|
|
252
|
+
|
|
253
|
+
path: Path
|
|
254
|
+
shape: tuple[int, int, int]
|
|
255
|
+
affine: Float64Array
|
|
256
|
+
data: Float32Array
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
@dataclass(frozen=True, slots=True)
|
|
260
|
+
class SpatialInverse:
|
|
261
|
+
"""The reference key's applied transforms, which the bundle's ``Invertd`` reverses.
|
|
262
|
+
|
|
263
|
+
``affine`` is the voxel-to-world matrix of the preprocessed grid that the network
|
|
264
|
+
sees (after reorientation and resampling).
|
|
265
|
+
"""
|
|
266
|
+
|
|
267
|
+
orientation: transforms.Orientation
|
|
268
|
+
resampling: transforms.Resampling
|
|
269
|
+
affine: Float64Array
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def run(
|
|
273
|
+
*,
|
|
274
|
+
bundle: str,
|
|
275
|
+
weights_path: str,
|
|
276
|
+
output_dir: str,
|
|
277
|
+
arterial_path: str | None = None,
|
|
278
|
+
venous_path: str | None = None,
|
|
279
|
+
excretory_path: str | None = None,
|
|
280
|
+
t1c_path: str | None = None,
|
|
281
|
+
t1_path: str | None = None,
|
|
282
|
+
t2_path: str | None = None,
|
|
283
|
+
flair_path: str | None = None,
|
|
284
|
+
image_path: str | None = None,
|
|
285
|
+
overwrite: bool = False,
|
|
286
|
+
arithmetic_profile: ArithmeticProfile = "torch-order-fp32",
|
|
287
|
+
) -> RunResult:
|
|
288
|
+
"""Segment one case with a MONAI SegResNet bundle and write the label map.
|
|
289
|
+
|
|
290
|
+
``bundle`` is ``renalStructures_CECT_segmentation`` (``arterial_path``,
|
|
291
|
+
``venous_path``, ``excretory_path``: co-registered CT phases on one grid),
|
|
292
|
+
``brats_mri_segmentation`` (``t1c_path``, ``t1_path``, ``t2_path``,
|
|
293
|
+
``flair_path``: co-registered MRI on one grid), or ``wholeBody_ct_segmentation``
|
|
294
|
+
/ ``wholeBody_ct_segmentation_lowres`` (``image_path``: one CT volume, segmented
|
|
295
|
+
at 1.5 mm or 3 mm respectively). ``weights_path`` is the bundle's
|
|
296
|
+
``models/model.pt`` (or ``models/model_lowres.pt`` for the lowres whole-body
|
|
297
|
+
variant) converted with :func:`segresnet_mlx.convert_checkpoint`.
|
|
298
|
+
|
|
299
|
+
Inputs must be finite 3D NIfTI volumes; multi-input bundles need identical shape
|
|
300
|
+
and affine (atol 1e-5). Writes ``<bundle>_mask.nii.gz`` in ``output_dir`` on the
|
|
301
|
+
first input's shape and reader-selected affine (see ``Volume``): renal labels 0-5
|
|
302
|
+
and whole-body labels 0-104 as float32, BraTS labels 0, 1, 2, 4 as uint8, as the
|
|
303
|
+
bundles' ``SaveImaged`` wrote them. An existing mask is replaced only when
|
|
304
|
+
``overwrite`` is true; output aliases of input images are always refused.
|
|
305
|
+
|
|
306
|
+
``arithmetic_profile="compensated-fp32"`` uses Metal float pairs for
|
|
307
|
+
convolution and GroupNorm accumulation, with float32 layer boundaries.
|
|
308
|
+
Its source comparison uses float64 internal accumulators and float32
|
|
309
|
+
outputs. The default retains the existing Torch-order FP32 arithmetic.
|
|
310
|
+
``compensated-fp48`` carries both words through the hidden layers and uses
|
|
311
|
+
float64 host stitching before one float32 logits export. This profile
|
|
312
|
+
requires transpose-convolution upsampling and compares with the unchanged
|
|
313
|
+
original graph evaluated in float64 throughout its network and inferer.
|
|
314
|
+
|
|
315
|
+
Returns:
|
|
316
|
+
``{"outputs": {"mask": path}, "details": {...}}``; ``details`` is described
|
|
317
|
+
by ``segresnet_mlx._records.Details`` and always has
|
|
318
|
+
``requires_human_review`` set to true.
|
|
319
|
+
|
|
320
|
+
Raises:
|
|
321
|
+
InvalidInputError: Unknown bundle or profile, missing or inapplicable input
|
|
322
|
+
paths, an existing output without ``overwrite``, or an unreadable,
|
|
323
|
+
non-3D, non-finite or mismatched-grid NIfTI input.
|
|
324
|
+
AssetNotReadyError: The weights file is missing, not ``.safetensors``, or
|
|
325
|
+
does not match the bundle's network.
|
|
326
|
+
ModelExecutionError: Inference failed or produced a mask off the source grid
|
|
327
|
+
or with labels outside the bundle's set.
|
|
328
|
+
"""
|
|
329
|
+
values = {
|
|
330
|
+
"arterial_path": arterial_path,
|
|
331
|
+
"venous_path": venous_path,
|
|
332
|
+
"excretory_path": excretory_path,
|
|
333
|
+
"t1c_path": t1c_path,
|
|
334
|
+
"t1_path": t1_path,
|
|
335
|
+
"t2_path": t2_path,
|
|
336
|
+
"flair_path": flair_path,
|
|
337
|
+
"image_path": image_path,
|
|
338
|
+
}
|
|
339
|
+
if bundle not in BUNDLES:
|
|
340
|
+
raise InvalidInputError(
|
|
341
|
+
f"Unknown SegResNet bundle {bundle!r}; expected one of: {', '.join(BUNDLES)}"
|
|
342
|
+
)
|
|
343
|
+
if arithmetic_profile not in {"torch-order-fp32", "compensated-fp32", "compensated-fp48"}:
|
|
344
|
+
raise InvalidInputError(f"Unknown SegResNet arithmetic profile {arithmetic_profile!r}")
|
|
345
|
+
config = BUNDLES[bundle]
|
|
346
|
+
if arithmetic_profile == "compensated-fp48" and config.spec.upsample_mode != "deconv":
|
|
347
|
+
raise InvalidInputError("Paired precision requires transpose-convolution upsampling")
|
|
348
|
+
missing = [role for role in config.roles if values[role] is None]
|
|
349
|
+
extra = [role for role in _ROLES if role not in config.roles and values[role] is not None]
|
|
350
|
+
if missing or extra:
|
|
351
|
+
raise InvalidInputError(
|
|
352
|
+
f"{bundle} requires {', '.join(config.roles)}"
|
|
353
|
+
+ (f"; missing {', '.join(missing)}" if missing else "")
|
|
354
|
+
+ (f"; not applicable: {', '.join(extra)}" if extra else "")
|
|
355
|
+
)
|
|
356
|
+
output_root = Path(output_dir).expanduser().resolve()
|
|
357
|
+
mask_path = output_root / f"{bundle}_mask.nii.gz"
|
|
358
|
+
for role in config.roles:
|
|
359
|
+
value = values[role]
|
|
360
|
+
assert value is not None
|
|
361
|
+
source = Path(value).expanduser().resolve()
|
|
362
|
+
if mask_path.resolve() == source or (
|
|
363
|
+
mask_path.exists() and source.exists() and mask_path.samefile(source)
|
|
364
|
+
):
|
|
365
|
+
raise InvalidInputError("SegResNet output must differ from every input image")
|
|
366
|
+
if mask_path.exists() and not overwrite:
|
|
367
|
+
raise InvalidInputError(f"Output exists: {mask_path}; pass overwrite=true to replace it")
|
|
368
|
+
weights = _weights_file(weights_path)
|
|
369
|
+
started = time.perf_counter()
|
|
370
|
+
volumes = load_inputs([(role, values[role]) for role in config.roles])
|
|
371
|
+
network = _load_network(weights, config.spec, arithmetic_profile=arithmetic_profile)
|
|
372
|
+
loaded = time.perf_counter()
|
|
373
|
+
if bundle == "renalStructures_CECT_segmentation":
|
|
374
|
+
image, inverse = preprocess_renal(volumes)
|
|
375
|
+
elif bundle in _WHOLE_BODY_PIXDIM:
|
|
376
|
+
image, inverse = preprocess_wholebody(volumes, _WHOLE_BODY_PIXDIM[bundle])
|
|
377
|
+
else:
|
|
378
|
+
image, inverse = preprocess_brats(volumes), None
|
|
379
|
+
prepared = time.perf_counter()
|
|
380
|
+
logits, runtime = predict(network, image, config)
|
|
381
|
+
predicted = time.perf_counter()
|
|
382
|
+
if bundle == "renalStructures_CECT_segmentation":
|
|
383
|
+
assert inverse is not None
|
|
384
|
+
mask = postprocess_renal(logits[0], inverse)
|
|
385
|
+
elif bundle in _WHOLE_BODY_PIXDIM:
|
|
386
|
+
assert inverse is not None
|
|
387
|
+
mask = postprocess_wholebody(logits[0], inverse)
|
|
388
|
+
else:
|
|
389
|
+
mask = postprocess_brats(logits[0])
|
|
390
|
+
_validate_mask(mask, volumes[0], config)
|
|
391
|
+
output_root.mkdir(parents=True, exist_ok=True)
|
|
392
|
+
write_atomic(mask_path, mask, volumes[0].affine, config.output_dtype)
|
|
393
|
+
finished = time.perf_counter()
|
|
394
|
+
present = sorted(int(value) for value in np.unique(mask))
|
|
395
|
+
return {
|
|
396
|
+
"outputs": {"mask": mask_path.as_posix()},
|
|
397
|
+
"details": {
|
|
398
|
+
"bundle": bundle,
|
|
399
|
+
"arithmetic_profile": arithmetic_profile,
|
|
400
|
+
"labels": {str(value): name for value, name in config.labels.items()},
|
|
401
|
+
"labels_present": present,
|
|
402
|
+
"input_shape": list(volumes[0].shape),
|
|
403
|
+
"network_input_shape": list(image.shape[2:]),
|
|
404
|
+
"output_dtype": config.output_dtype,
|
|
405
|
+
"sliding_window": {
|
|
406
|
+
"roi_size": list(config.roi_size),
|
|
407
|
+
"sw_batch_size": config.sw_batch_size,
|
|
408
|
+
"overlap": config.overlap,
|
|
409
|
+
"mode": config.mode,
|
|
410
|
+
"padding_mode": config.padding_mode,
|
|
411
|
+
},
|
|
412
|
+
"mlx_device": runtime["device"],
|
|
413
|
+
"peak_mlx_bytes": runtime["peak_bytes"],
|
|
414
|
+
"seconds": {
|
|
415
|
+
"load_inputs_and_weights": round(loaded - started, 4),
|
|
416
|
+
"preprocess": round(prepared - loaded, 4),
|
|
417
|
+
"inference": round(predicted - prepared, 4),
|
|
418
|
+
"postprocess_and_write": round(finished - predicted, 4),
|
|
419
|
+
},
|
|
420
|
+
"requires_human_review": True,
|
|
421
|
+
},
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def preprocess_renal(volumes: list[Volume]) -> tuple[Float32Array, SpatialInverse]:
|
|
426
|
+
"""The renal bundle's ``preprocessing`` from ``EnsureChannelFirstd`` onward.
|
|
427
|
+
|
|
428
|
+
Each phase is reoriented to LPS and resampled to 0.8 mm. A later phase reuses
|
|
429
|
+
the first phase's output shape when its oriented grid matches (as ``Spacingd``
|
|
430
|
+
does across keys). The phases are concatenated as channels, then intensities
|
|
431
|
+
are clipped and scaled from [-1000, 1000] HU to [0, 1]. Returns the
|
|
432
|
+
``(1, 3, *spatial)`` float32 network input, with spatial axes in (L, P, S)
|
|
433
|
+
order, and the arterial key's inverse record.
|
|
434
|
+
"""
|
|
435
|
+
channels: list[Float32Array] = []
|
|
436
|
+
record: SpatialInverse | None = None
|
|
437
|
+
first_shape: tuple[int, ...] | None = None
|
|
438
|
+
first_pixdim: Float64Array | None = None
|
|
439
|
+
output_shape: tuple[int, ...] | None = None
|
|
440
|
+
for volume in volumes:
|
|
441
|
+
oriented, affine, orientation = transforms.orient(volume.data[None], volume.affine, "LPS")
|
|
442
|
+
pixdim = transforms.affine_to_spacing(affine, 3)
|
|
443
|
+
if first_shape is None or first_pixdim is None:
|
|
444
|
+
first_shape, first_pixdim = oriented.shape[1:], pixdim
|
|
445
|
+
should_match = False
|
|
446
|
+
else:
|
|
447
|
+
should_match = np.allclose(first_shape, oriented.shape[1:]) and np.allclose(
|
|
448
|
+
first_pixdim, pixdim, atol=1e-3
|
|
449
|
+
)
|
|
450
|
+
resampled, affine, resampling = transforms.spacing(
|
|
451
|
+
oriented, affine, _RENAL_SPACING, output_shape if should_match else None
|
|
452
|
+
)
|
|
453
|
+
if output_shape is None:
|
|
454
|
+
output_shape = resampled.shape[1:]
|
|
455
|
+
if record is None:
|
|
456
|
+
record = SpatialInverse(orientation, resampling, affine)
|
|
457
|
+
channels.append(resampled)
|
|
458
|
+
assert record is not None
|
|
459
|
+
image = transforms.scale_intensity_range(
|
|
460
|
+
transforms.concatenate(channels), -1000, 1000, 0.0, 1.0
|
|
461
|
+
)
|
|
462
|
+
return image[None], record
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def postprocess_renal(logits: Float32Array, inverse: SpatialInverse) -> Float32Array:
|
|
466
|
+
"""``Invertd`` (0.8 mm grid back to the source grid), sigmoid, 0.5, ``MergeClassesd``.
|
|
467
|
+
|
|
468
|
+
``logits`` is ``(6, *spatial)`` on the resampled grid. Returns the float32 label
|
|
469
|
+
map (values 0-5) on the source grid. The inversion is applied to the logits
|
|
470
|
+
before the sigmoid, in the bundle's order.
|
|
471
|
+
"""
|
|
472
|
+
values, affine = transforms.invert_spacing(logits, inverse.affine, inverse.resampling)
|
|
473
|
+
values, _ = transforms.invert_orientation(values, affine, inverse.orientation)
|
|
474
|
+
binary = transforms.threshold(transforms.sigmoid(values), 0.5)
|
|
475
|
+
return transforms.merge_classes(binary)[0]
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def preprocess_brats(volumes: list[Volume]) -> Float32Array:
|
|
479
|
+
"""``LoadImaged`` stacks the four modalities; ``NormalizeIntensityd`` per channel.
|
|
480
|
+
|
|
481
|
+
The bundle has no reorientation or resampling, so the volumes stay on the file
|
|
482
|
+
grid. Each modality is z-scored over its nonzero voxels only (zeros stay zero).
|
|
483
|
+
Returns ``(1, 4, *spatial)`` float32 in the order of ``roles``.
|
|
484
|
+
"""
|
|
485
|
+
image = np.stack([volume.data for volume in volumes], axis=0)
|
|
486
|
+
return transforms.normalize_intensity_nonzero(image)[None]
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def postprocess_brats(logits: Float32Array) -> transforms.npt.NDArray[np.int64]:
|
|
490
|
+
"""Sigmoid, ``Invertd`` (no spatial transform to undo), 0.5, BraTS labels 1/2/4.
|
|
491
|
+
|
|
492
|
+
``logits`` is ``(3, *spatial)`` with channels (tumor core, whole tumor, enhancing
|
|
493
|
+
tumor). Returns an int64 label volume with values 0, 1, 2 and 4.
|
|
494
|
+
"""
|
|
495
|
+
binary = transforms.threshold(transforms.sigmoid(logits), 0.5)
|
|
496
|
+
return transforms.brats_labels(binary)[0]
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def preprocess_wholebody(
|
|
500
|
+
volumes: list[Volume], pixdim: tuple[float, float, float]
|
|
501
|
+
) -> tuple[Float32Array, SpatialInverse]:
|
|
502
|
+
"""The whole-body bundle's ``preprocessing`` from ``EnsureChannelFirstd`` onward.
|
|
503
|
+
|
|
504
|
+
The single CT channel is reoriented to RAS, resampled to ``pixdim`` (1.5 mm for
|
|
505
|
+
``wholeBody_ct_segmentation``, 3 mm for the ``_lowres`` variant), normalized over
|
|
506
|
+
its nonzero voxels (no HU window is applied), then rescaled to [-1, 1] by its own
|
|
507
|
+
min and max. Returns the ``(1, 1, *spatial)`` float32 network input, with spatial
|
|
508
|
+
axes in (R, A, S) order, and the image key's inverse record.
|
|
509
|
+
"""
|
|
510
|
+
(volume,) = volumes
|
|
511
|
+
oriented, affine, orientation = transforms.orient(volume.data[None], volume.affine, "RAS")
|
|
512
|
+
resampled, affine, resampling = transforms.spacing(oriented, affine, pixdim)
|
|
513
|
+
record = SpatialInverse(orientation, resampling, affine)
|
|
514
|
+
normalized = transforms.normalize_intensity_nonzero(resampled)
|
|
515
|
+
image = transforms.scale_intensity_minmax(normalized, -1.0, 1.0)
|
|
516
|
+
return image[None], record
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def postprocess_wholebody(logits: Float32Array, inverse: SpatialInverse) -> Float32Array:
|
|
520
|
+
"""Softmax, argmax, ``Invertd`` (nearest-neighbor, back to the source grid).
|
|
521
|
+
|
|
522
|
+
``logits`` is ``(105, *spatial)`` on the resampled grid. Returns the float32 label
|
|
523
|
+
map (values 0-104) on the source grid.
|
|
524
|
+
"""
|
|
525
|
+
# Kept to mirror the bundle's Activationsd(softmax=True) step; in exact
|
|
526
|
+
# arithmetic it does not change the argmax.
|
|
527
|
+
probabilities = transforms.softmax_channel(logits)
|
|
528
|
+
labels = transforms.argmax_channel(probabilities)
|
|
529
|
+
values, affine = transforms.invert_spacing(
|
|
530
|
+
labels, inverse.affine, inverse.resampling, "nearest"
|
|
531
|
+
)
|
|
532
|
+
values, _ = transforms.invert_orientation(values, affine, inverse.orientation)
|
|
533
|
+
return values[0]
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def _weights_file(weights_path: str) -> Path:
|
|
537
|
+
path = Path(weights_path).expanduser().resolve()
|
|
538
|
+
hint = (
|
|
539
|
+
"Convert the bundle's models/model.pt once with "
|
|
540
|
+
"segresnet_mlx.convert_checkpoint(model_pt, destination, spec=...) "
|
|
541
|
+
"(requires the conversion extra)."
|
|
542
|
+
)
|
|
543
|
+
if not path.is_file():
|
|
544
|
+
raise AssetNotReadyError(
|
|
545
|
+
f"SegResNet weights not found: {path}", reason=f"missing {path}", hint=hint
|
|
546
|
+
)
|
|
547
|
+
if path.suffix != ".safetensors":
|
|
548
|
+
raise AssetNotReadyError(
|
|
549
|
+
f"SegResNet weights must be converted MLX safetensors, got {path.name}",
|
|
550
|
+
reason=f"unconverted {path.name}",
|
|
551
|
+
hint=hint,
|
|
552
|
+
)
|
|
553
|
+
return path
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def load_inputs(roles: list[tuple[str, str | None]]) -> list[Volume]:
|
|
557
|
+
"""Load each ``(role, path)`` and require one shared voxel grid.
|
|
558
|
+
|
|
559
|
+
Every volume must match the first one's shape and affine (``atol=1e-5``); the
|
|
560
|
+
runner does not resample, so a mismatch raises ``InvalidInputError``.
|
|
561
|
+
"""
|
|
562
|
+
volumes = [load_volume(Path(str(path)).expanduser().resolve(), role) for role, path in roles]
|
|
563
|
+
reference = volumes[0]
|
|
564
|
+
for (role, _), volume in zip(roles[1:], volumes[1:], strict=True):
|
|
565
|
+
if volume.shape != reference.shape or not np.allclose(
|
|
566
|
+
volume.affine, reference.affine, atol=1e-5, rtol=0
|
|
567
|
+
):
|
|
568
|
+
raise InvalidInputError(
|
|
569
|
+
f"{role} must share {roles[0][0]}'s voxel grid (shape and affine); "
|
|
570
|
+
"resample it explicitly first"
|
|
571
|
+
)
|
|
572
|
+
return volumes
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def load_volume(path: Path, role: str) -> Volume:
|
|
576
|
+
"""Read finite 3D voxels using the affine selected by the upstream nibabel reader."""
|
|
577
|
+
from segresnet_mlx._nifti import ImageFileError, nib
|
|
578
|
+
|
|
579
|
+
if not path.is_file():
|
|
580
|
+
raise InvalidInputError(f"{role} not found: {path}")
|
|
581
|
+
try:
|
|
582
|
+
image = nib.load(path.as_posix())
|
|
583
|
+
except (OSError, ImageFileError) as error:
|
|
584
|
+
raise InvalidInputError(f"Invalid {role} NIfTI: {path}") from error
|
|
585
|
+
raw = np.asanyarray(image.dataobj)
|
|
586
|
+
if raw.ndim != 3:
|
|
587
|
+
raise InvalidInputError(f"{role} must be a 3D NIfTI; got shape {tuple(raw.shape)}")
|
|
588
|
+
if not np.all(np.isfinite(raw)):
|
|
589
|
+
raise InvalidInputError(f"{role} contains non-finite voxels")
|
|
590
|
+
affine = np.asarray(image.affine, dtype=np.float64)
|
|
591
|
+
if affine.shape != (4, 4) or not np.all(np.isfinite(affine)):
|
|
592
|
+
raise InvalidInputError(f"{role} affine must be a finite 4x4 matrix")
|
|
593
|
+
data, reader_affine = transforms.image_arrays(image)
|
|
594
|
+
shape = (int(raw.shape[0]), int(raw.shape[1]), int(raw.shape[2]))
|
|
595
|
+
return Volume(path=path, shape=shape, affine=reader_affine, data=data)
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def _load_network(
|
|
599
|
+
weights: Path,
|
|
600
|
+
spec: SegResNetSpec,
|
|
601
|
+
*,
|
|
602
|
+
arithmetic_profile: ArithmeticProfile = "torch-order-fp32",
|
|
603
|
+
) -> SegResNet:
|
|
604
|
+
"""Strictly load converted weights for the bundle topology."""
|
|
605
|
+
from segresnet_mlx.weights import load_model
|
|
606
|
+
|
|
607
|
+
try:
|
|
608
|
+
return load_model(weights, spec=spec, arithmetic_profile=arithmetic_profile)
|
|
609
|
+
except (InvalidInputError, ValueError, RuntimeError) as error:
|
|
610
|
+
raise AssetNotReadyError(
|
|
611
|
+
f"SegResNet weights {weights} do not match {spec}: {error}",
|
|
612
|
+
reason=f"mismatched {weights.name}",
|
|
613
|
+
hint="Convert the matching bundle's models/model.pt with convert_checkpoint.",
|
|
614
|
+
) from error
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def predict(
|
|
618
|
+
network: SegResNet, image: Float32Array, config: Bundle
|
|
619
|
+
) -> tuple[Float32Array, Runtime]:
|
|
620
|
+
"""Run the bundle's ``SlidingWindowInferer`` settings around the MLX network.
|
|
621
|
+
|
|
622
|
+
``image`` is the preprocessed ``(1, C, *spatial)`` array. Returns float32 logits
|
|
623
|
+
``(1, out_channels, *spatial)`` and the MLX device and peak memory in bytes.
|
|
624
|
+
Raises ``ModelExecutionError`` if inference fails.
|
|
625
|
+
"""
|
|
626
|
+
from segresnet_mlx._runtime import import_mlx
|
|
627
|
+
from segresnet_mlx.predictor import BundlePredictor, SlidingWindowConfig, predict_wide_logits
|
|
628
|
+
|
|
629
|
+
mx = import_mlx()
|
|
630
|
+
window = SlidingWindowConfig(
|
|
631
|
+
config.roi_size, config.sw_batch_size, config.overlap, config.mode, config.padding_mode
|
|
632
|
+
)
|
|
633
|
+
try:
|
|
634
|
+
host = np.ascontiguousarray(image, dtype=np.float32)
|
|
635
|
+
logits = (
|
|
636
|
+
predict_wide_logits(network, host, window, mx=mx)
|
|
637
|
+
if network.arithmetic_profile == "compensated-fp48"
|
|
638
|
+
else BundlePredictor(network, mx=mx).predict_logits(host, window)
|
|
639
|
+
)
|
|
640
|
+
except (RuntimeError, ValueError) as error:
|
|
641
|
+
raise ModelExecutionError(f"SegResNet MLX inference failed: {error}") from error
|
|
642
|
+
return logits, {"device": str(mx.default_device()), "peak_bytes": mx.get_peak_memory()}
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def _validate_mask(mask: MaskArray, reference: Volume, config: Bundle) -> None:
|
|
646
|
+
"""Require the source grid and only the bundle's labels."""
|
|
647
|
+
if tuple(mask.shape) != reference.shape:
|
|
648
|
+
raise ModelExecutionError(
|
|
649
|
+
f"SegResNet mask shape {tuple(mask.shape)} is not the input shape {reference.shape}"
|
|
650
|
+
)
|
|
651
|
+
if not set(np.unique(mask).tolist()) <= set(config.labels):
|
|
652
|
+
raise ModelExecutionError("SegResNet exported labels outside the bundle contract")
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def write_atomic(path: Path, mask: MaskArray, affine: Float64Array, dtype: str) -> None:
|
|
656
|
+
"""Write the mask NIfTI via a temporary file in ``path.parent``, then rename it.
|
|
657
|
+
|
|
658
|
+
A failure therefore never leaves a partial file at ``path``.
|
|
659
|
+
"""
|
|
660
|
+
descriptor, name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".nii.gz", dir=path.parent)
|
|
661
|
+
os.close(descriptor)
|
|
662
|
+
try:
|
|
663
|
+
transforms.write_nifti(Path(name), mask, affine, dtype)
|
|
664
|
+
os.replace(name, path)
|
|
665
|
+
finally:
|
|
666
|
+
Path(name).unlink(missing_ok=True)
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
__all__ = ["BUNDLES", "MODEL", "run"]
|