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.

@@ -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"]