unest-mlx 0.2.3__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 unest-mlx might be problematic. Click here for more details.

unest_mlx/__init__.py ADDED
@@ -0,0 +1,43 @@
1
+ """Native MLX implementation of UNesT.
2
+
3
+ Public names load on first access, so importing the package (or the medmlx
4
+ declaration in ``unest_mlx.medmlx``) does not import MLX.
5
+ """
6
+
7
+ from importlib import import_module
8
+ from typing import TYPE_CHECKING
9
+
10
+ if TYPE_CHECKING:
11
+ from unest_mlx.mlx_model import UNesT as UNesT
12
+ from unest_mlx.mlx_predictor import UNesTPredictor as UNesTPredictor
13
+ from unest_mlx.mlx_weights import convert_state_dict as convert_state_dict
14
+ from unest_mlx.mlx_weights import required_shapes as required_shapes
15
+
16
+ __version__ = "0.2.2"
17
+
18
+ _EXPORTS = {
19
+ "UNesT": "unest_mlx.mlx_model",
20
+ "UNesTPredictor": "unest_mlx.mlx_predictor",
21
+ "convert_state_dict": "unest_mlx.mlx_weights",
22
+ "required_shapes": "unest_mlx.mlx_weights",
23
+ }
24
+
25
+ __all__ = [
26
+ "UNesT",
27
+ "UNesTPredictor",
28
+ "convert_state_dict",
29
+ "required_shapes",
30
+ ]
31
+
32
+
33
+ def __getattr__(name: str) -> object:
34
+ module = _EXPORTS.get(name)
35
+ if module is None:
36
+ raise AttributeError(f"module 'unest_mlx' has no attribute {name!r}")
37
+ value = getattr(import_module(module), name)
38
+ globals()[name] = value
39
+ return value
40
+
41
+
42
+ def __dir__() -> list[str]:
43
+ return sorted({*globals(), *_EXPORTS})
unest_mlx/_typing.py ADDED
@@ -0,0 +1,156 @@
1
+ """Native tensor, image geometry and result contracts for the renal bundle."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Mapping, Sequence
6
+ from os import PathLike
7
+ from typing import TYPE_CHECKING, Literal, Protocol, TypedDict, overload
8
+
9
+ import numpy as np
10
+ from medmlx_core.typing import MlxRuntime
11
+ from numpy.typing import ArrayLike, NDArray
12
+
13
+ if TYPE_CHECKING:
14
+ from mlx.core import array
15
+
16
+
17
+ type HostArray = NDArray[np.generic]
18
+ type RealArray = NDArray[np.bool_ | np.integer | np.floating]
19
+ type FloatArray = NDArray[np.float32]
20
+ type DoubleArray = NDArray[np.float64]
21
+ type IntArray = NDArray[np.int_]
22
+ type NativeNetwork = Callable[[array], array]
23
+
24
+
25
+ class NativeRuntime(MlxRuntime, Protocol):
26
+ """The subset of ``mlx.core`` this package calls, typed structurally."""
27
+
28
+ def eval(self, *args: array | Mapping[str, array] | Sequence[array]) -> None: ...
29
+ def synchronize(self) -> None: ...
30
+ def load(self, file: str) -> array | dict[str, array]: ...
31
+ def save_safetensors(self, file: str, arrays: dict[str, array]) -> None: ...
32
+ def softmax(self, x: array, *, axis: int) -> array: ...
33
+
34
+
35
+ class NativeOperation(Protocol):
36
+ def __call__(self, x: array) -> array: ...
37
+
38
+
39
+ class NeuralRuntime(Protocol):
40
+ def MaxPool3d(self, *, kernel_size: int, stride: int, padding: int) -> NativeOperation: ...
41
+ def leaky_relu(self, x: array, *, negative_slope: float) -> array: ...
42
+ def gelu(self, x: array) -> array: ...
43
+
44
+
45
+ class SlidingWindow(Protocol):
46
+ def __call__(
47
+ self,
48
+ inputs: FloatArray,
49
+ *,
50
+ roi_size: tuple[int, int, int],
51
+ sw_batch_size: int,
52
+ predictor: NativeNetwork,
53
+ overlap: float,
54
+ ) -> FloatArray: ...
55
+
56
+
57
+ class SlidingWindowNamespace(Protocol):
58
+ sliding_window_inference: SlidingWindow
59
+
60
+
61
+ class NiftiHeader(Protocol):
62
+ def __getitem__(self, key: str) -> HostArray: ...
63
+ def __setitem__(self, key: str, value: int | HostArray) -> None: ...
64
+ def get(self, key: str) -> HostArray | None: ...
65
+ def get_zooms(self) -> tuple[float, ...]: ...
66
+ def set_zooms(self, zooms: ArrayLike) -> None: ...
67
+ def set_slope_inter(self, slope: float, inter: float) -> None: ...
68
+
69
+
70
+ class NiftiImage(Protocol):
71
+ affine: DoubleArray
72
+ shape: tuple[int, ...]
73
+ dataobj: ArrayLike
74
+ header: NiftiHeader
75
+
76
+ @overload
77
+ def get_sform(self, coded: Literal[True]) -> tuple[DoubleArray | None, int]: ...
78
+ @overload
79
+ def get_sform(self, coded: Literal[False] = False) -> DoubleArray: ...
80
+ @overload
81
+ def get_qform(self, coded: Literal[True]) -> tuple[DoubleArray | None, int]: ...
82
+ @overload
83
+ def get_qform(self, coded: Literal[False] = False) -> DoubleArray: ...
84
+ def set_sform(self, affine: DoubleArray | None, code: int | None = None) -> None: ...
85
+ def set_qform(self, affine: DoubleArray | None, code: int | None = None) -> None: ...
86
+ def get_data_dtype(self) -> np.dtype[np.generic]: ...
87
+
88
+
89
+ class Orientations(Protocol):
90
+ def aff2axcodes(self, affine: DoubleArray) -> tuple[str, ...]: ...
91
+ def axcodes2ornt(self, axcodes: str | tuple[str, ...]) -> DoubleArray: ...
92
+ def ornt_transform(self, start: DoubleArray, end: DoubleArray) -> DoubleArray: ...
93
+ def inv_ornt_aff(self, ornt: DoubleArray, shape: tuple[int, ...]) -> DoubleArray: ...
94
+
95
+
96
+ class NiftiIo(Protocol):
97
+ orientations: Orientations
98
+
99
+ def load(self, filename: str | PathLike[str]) -> NiftiImage: ...
100
+ def save(self, image: NiftiImage, filename: str | PathLike[str]) -> None: ...
101
+ def Nifti1Image(self, data: ArrayLike, affine: ArrayLike | None) -> NiftiImage: ...
102
+ def aff2axcodes(self, affine: DoubleArray) -> tuple[str, ...]: ...
103
+ def io_orientation(self, affine: DoubleArray) -> DoubleArray: ...
104
+
105
+
106
+ class NumpyFlip(Protocol):
107
+ def __call__[T: np.generic](self, values: NDArray[T], *, axis: list[int]) -> NDArray[T]: ...
108
+
109
+
110
+ class Label(TypedDict):
111
+ """One entry of the mask's label table: integer voxel value and structure name."""
112
+
113
+ value: int
114
+ name: str
115
+
116
+
117
+ class SlidingWindowDetails(TypedDict):
118
+ roi_size: list[int]
119
+ sw_batch_size: int
120
+ overlap: float
121
+
122
+
123
+ class RunDetails(TypedDict):
124
+ """JSON-compatible run metadata returned under ``details`` by ``medmlx.run``.
125
+
126
+ ``voxel_counts`` is keyed by label name. ``shape`` and ``dtype`` describe the
127
+ written mask (the input grid, float32). ``input_orientation`` is the axis-code string
128
+ of the reader affine before reorientation to RAS (for example ``"LAS"``).
129
+ ``network_shape`` is the voxel grid the network ran on after RAS reorientation and
130
+ 1 mm resampling. ``mlx_device`` is ``str(mx.default_device())`` and
131
+ ``duration_seconds`` is wall time from the start of the call until the mask has been
132
+ written, so it includes image reading and weight loading.
133
+ """
134
+
135
+ labels: list[Label]
136
+ voxel_counts: dict[str, int]
137
+ shape: list[int]
138
+ dtype: str
139
+ input_orientation: str
140
+ network_shape: list[int]
141
+ sliding_window: SlidingWindowDetails
142
+ mlx_device: str
143
+ duration_seconds: float
144
+
145
+
146
+ class RunOutputs(TypedDict):
147
+ """Files written by a run; ``mask`` is the absolute path of ``mask.nii.gz``."""
148
+
149
+ mask: str
150
+
151
+
152
+ class RunResult(TypedDict):
153
+ """Return value of ``unest_mlx.medmlx.run``."""
154
+
155
+ outputs: RunOutputs
156
+ details: RunDetails
@@ -0,0 +1,90 @@
1
+ """Convert the bundle's Torch checkpoint to the safetensors file ``run`` loads.
2
+
3
+ Requires the ``conversion`` extra (Torch). Run once per checkpoint:
4
+
5
+ python -m unest_mlx.conversion \\
6
+ renalStructures_UNEST_segmentation/models/model.pt unest.safetensors
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import os
13
+ from pathlib import Path
14
+ from typing import cast
15
+
16
+ from medmlx_core.conversion import load_torch_checkpoint
17
+ from medmlx_core.errors import InvalidInputError
18
+ from medmlx_core.runtime import import_mlx
19
+
20
+ from unest_mlx import mlx_weights
21
+ from unest_mlx._typing import NativeRuntime
22
+
23
+
24
+ def convert_checkpoint(
25
+ source: str | os.PathLike[str],
26
+ destination: str | os.PathLike[str],
27
+ *,
28
+ overwrite: bool = False,
29
+ ) -> Path:
30
+ """Write graph-compatible float32 tensors, with their original keys, as safetensors.
31
+
32
+ The checkpoint is read with ``weights_only=True`` and admitted against the
33
+ upstream network's parameter names and shapes by ``convert_state_dict``. Tensors
34
+ keep the Torch layout and values; nothing is transposed, cast or quantized. The
35
+ inventory check does not establish checkpoint identity or origin.
36
+ Alias checks occur before writing and do not prevent concurrent path changes;
37
+ the destination write itself is not atomic.
38
+
39
+ Args:
40
+ source: The bundle's ``models/model.pt``.
41
+ destination: Output path; must end in ``.safetensors``. Parent directories are
42
+ created.
43
+ overwrite: Replace ``destination`` if it already exists; aliases of the
44
+ source checkpoint are rejected even when overwrite is enabled.
45
+
46
+ Returns:
47
+ The destination path.
48
+
49
+ Raises:
50
+ InvalidInputError: ``source`` is not a file, ``destination`` has the wrong suffix,
51
+ aliases the source, or exists without ``overwrite``, or the checkpoint does
52
+ not match the released parameter inventory.
53
+ """
54
+
55
+ source_path = Path(source).expanduser()
56
+ target = Path(destination).expanduser()
57
+ if not source_path.is_file():
58
+ raise InvalidInputError(f"UNesT checkpoint not found: {source_path}")
59
+ if target.suffix != ".safetensors":
60
+ raise InvalidInputError(f"Destination must end in .safetensors: {target}")
61
+ if target.resolve() == source_path.resolve() or (
62
+ target.exists() and target.samefile(source_path)
63
+ ):
64
+ raise InvalidInputError("UNesT conversion output must differ from the source checkpoint")
65
+ if target.exists() and not overwrite:
66
+ raise InvalidInputError(f"Refusing to replace {target}; pass overwrite=True")
67
+ weights = mlx_weights.convert_state_dict(load_torch_checkpoint(source_path, weights_only=True))
68
+ import_mlx()
69
+ import mlx.core as mx
70
+
71
+ target.parent.mkdir(parents=True, exist_ok=True)
72
+ cast(NativeRuntime, mx).save_safetensors(
73
+ str(target), {key: mx.array(value) for key, value in weights.items()}
74
+ )
75
+ return target
76
+
77
+
78
+ def main(argv: list[str] | None = None) -> None:
79
+ """Command-line entry point: ``SOURCE DESTINATION [--overwrite]``; prints the output path."""
80
+
81
+ parser = argparse.ArgumentParser(description=cast(str, __doc__).splitlines()[0])
82
+ parser.add_argument("source", help="Bundle checkpoint, models/model.pt")
83
+ parser.add_argument("destination", help="Output .safetensors path")
84
+ parser.add_argument("--overwrite", action="store_true")
85
+ args = parser.parse_args(argv)
86
+ print(convert_checkpoint(args.source, args.destination, overwrite=args.overwrite))
87
+
88
+
89
+ if __name__ == "__main__":
90
+ main()
unest_mlx/medmlx.py ADDED
@@ -0,0 +1,279 @@
1
+ """medmlx declaration and end-to-end runner for renal UNesT segmentation.
2
+
3
+ Follows ``renalStructures_UNEST_segmentation`` 0.2.7's inference configuration:
4
+ the NIfTI reader, RAS/1 mm preprocessing, batches of four 96-cubed windows,
5
+ softmax, inverse transforms, argmax, and float32 NIfTI writing.
6
+
7
+ The model is intended for contrast-enhanced CT in the arterial or portal venous phase,
8
+ as in the upstream bundle's training data. It has failed on the non-contrast scans tried
9
+ so far (the kidneys were missed), so check enhancement before running it. This is research
10
+ software, not a diagnostic tool.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ import time
17
+ from pathlib import Path
18
+ from typing import cast
19
+
20
+ import nibabel as nib_runtime
21
+ import numpy as np
22
+ from medmlx_core.errors import AssetNotReadyError, InvalidInputError, ModelExecutionError
23
+ from medmlx_core.runtime import import_mlx
24
+ from nibabel.filebasedimages import ImageFileError
25
+
26
+ from unest_mlx._typing import (
27
+ DoubleArray,
28
+ FloatArray,
29
+ HostArray,
30
+ Label,
31
+ NativeNetwork,
32
+ NativeRuntime,
33
+ NiftiImage,
34
+ NiftiIo,
35
+ RunResult,
36
+ )
37
+
38
+ nib = cast(NiftiIo, nib_runtime)
39
+
40
+ MODEL = {
41
+ "id": "unest",
42
+ "label": "Renal structures UNesT segmentation",
43
+ "task": "segmentation",
44
+ "run": "unest_mlx.medmlx:run",
45
+ "summary": (
46
+ "Kidney cortex, medulla, and pelvicalyceal system segmentation for one CT "
47
+ "volume with the MONAI renalStructures_UNEST_segmentation 0.2.7 bundle."
48
+ ),
49
+ }
50
+
51
+ OUTPUT_NAME = "mask.nii.gz"
52
+ # The bundle's configs/metadata.json network_data_format.outputs.pred.channel_def.
53
+ LABELS: tuple[Label, ...] = (
54
+ {"value": 0, "name": "background"},
55
+ {"value": 1, "name": "kidney cortex"},
56
+ {"value": 2, "name": "medulla"},
57
+ {"value": 3, "name": "pelvicalyceal system"},
58
+ )
59
+ # configs/inference.json: SlidingWindowInferer(roi_size=[96, 96, 96], sw_batch_size=4,
60
+ # overlap=0.5). The roi size and overlap are fixed in UNesTPredictor.
61
+ SW_BATCH_SIZE = 4
62
+ _CONVERT_HINT = (
63
+ "Convert the bundle checkpoint once with the conversion extra: python -m "
64
+ "unest_mlx.conversion <bundle>/models/model.pt unest.safetensors"
65
+ )
66
+
67
+
68
+ def run(
69
+ *,
70
+ image_path: str | os.PathLike[str],
71
+ weights_path: str | os.PathLike[str],
72
+ output_dir: str | os.PathLike[str],
73
+ overwrite: bool = False,
74
+ ) -> RunResult:
75
+ """Segment kidney cortex, medulla, and pelvicalyceal system in one CT volume.
76
+
77
+ Preprocessing follows the bundle: reorient to RAS, resample to 1 mm spacing
78
+ (bilinear), clip -175 to 250 HU and scale to 0-1. Windows of 96x96x96 voxels with 0.5
79
+ overlap are run through the network, softmaxed, resampled back to the input grid and
80
+ reduced with argmax. The model targets contrast-enhanced CT (arterial or portal venous
81
+ phase) and has failed on non-contrast scans.
82
+
83
+ Args:
84
+ image_path: 3D CT NIfTI (``.nii`` or ``.nii.gz``) in Hounsfield units, with
85
+ contrast enhancement. Header slope and intercept are applied on read.
86
+ Affine coordinates are interpreted as millimeters without converting
87
+ the NIfTI spatial-unit field; supply a millimeter affine.
88
+ weights_path: ``.safetensors`` converted from the bundle's ``models/model.pt``
89
+ by ``unest_mlx.conversion.convert_checkpoint``.
90
+ output_dir: Directory for the mask; created if missing.
91
+ overwrite: Replace an existing mask in ``output_dir``; input aliases are refused.
92
+
93
+ Returns:
94
+ ``{"outputs": {"mask": path}, "details": {...}}``. The mask keeps the input's
95
+ shape and reader affine; upstream header correction may replace a conflicting
96
+ affine with the form matching the header zooms. Like ``SaveImaged`` it stores
97
+ float32 label values 0-3 with sform and qform code 1. The new output header
98
+ leaves spatial units unknown.
99
+
100
+ Raises:
101
+ InvalidInputError: The image is missing, not a finite 3D NIfTI with a valid
102
+ affine, ``overwrite`` is not a bool, ``output_dir`` is not a directory, or the
103
+ mask exists and ``overwrite`` is false.
104
+ AssetNotReadyError: The weights are missing or do not match the release.
105
+ ModelExecutionError: The network produced non-finite logits or invalid labels.
106
+ MissingDependencyError: MLX or its Metal GPU is unavailable (no CPU fallback).
107
+ """
108
+
109
+ started = time.perf_counter()
110
+ source = _require_nifti(image_path)
111
+ weights = _require_weights(weights_path)
112
+ if not isinstance(cast(object, overwrite), bool):
113
+ raise InvalidInputError("overwrite must be true or false")
114
+ directory = Path(output_dir).expanduser().absolute()
115
+ if directory.exists() and not directory.is_dir():
116
+ raise InvalidInputError(f"output_dir is not a directory: {directory}")
117
+ mask_path = directory / OUTPUT_NAME
118
+ if mask_path.resolve() == source.resolve() or (
119
+ mask_path.exists() and mask_path.samefile(source)
120
+ ):
121
+ raise InvalidInputError("UNesT output must differ from the input image")
122
+ if mask_path.exists() and not overwrite:
123
+ raise InvalidInputError(f"{mask_path} already exists; pass overwrite=true to replace it")
124
+ from unest_mlx import transforms
125
+
126
+ image, data = read_volume(source)
127
+ reader_affine = transforms.correct_nifti_header_if_necessary(image).affine
128
+ network_input, geometry = transforms.preprocess(data, reader_affine)
129
+ mx = cast(NativeRuntime, import_mlx())
130
+ from unest_mlx.mlx_predictor import UNesTPredictor
131
+
132
+ network = _load_network(weights, mx=mx)
133
+ predictor = UNesTPredictor(network, mx=mx, sw_batch_size=SW_BATCH_SIZE)
134
+ try:
135
+ logits = predictor(network_input[None])
136
+ except RuntimeError as error:
137
+ raise ModelExecutionError(str(error)) from error
138
+ labels = transforms.postprocess(logits[0], geometry)
139
+ if labels.shape != geometry.original_shape:
140
+ raise ModelExecutionError("UNesT mask does not match the input grid")
141
+ if not set(np.unique(labels).tolist()) <= {label["value"] for label in LABELS}:
142
+ raise ModelExecutionError("UNesT exported labels outside the bundle contract")
143
+ write_mask(mask_path, labels, reader_affine)
144
+ return {
145
+ "outputs": {"mask": mask_path.resolve().as_posix()},
146
+ "details": {
147
+ "labels": [cast(Label, dict(label)) for label in LABELS],
148
+ "voxel_counts": {
149
+ str(label["name"]): int(np.count_nonzero(labels == label["value"]))
150
+ for label in LABELS
151
+ },
152
+ "shape": [int(value) for value in labels.shape],
153
+ "dtype": "float32",
154
+ "input_orientation": "".join(nib.aff2axcodes(reader_affine)),
155
+ "network_shape": [int(value) for value in geometry.network_shape],
156
+ "sliding_window": {
157
+ "roi_size": [96, 96, 96],
158
+ "sw_batch_size": SW_BATCH_SIZE,
159
+ "overlap": 0.5,
160
+ },
161
+ "mlx_device": str(mx.default_device()),
162
+ "duration_seconds": round(time.perf_counter() - started, 3),
163
+ },
164
+ }
165
+
166
+
167
+ def _require_nifti(path: str | os.PathLike[str]) -> Path:
168
+ source = Path(path).expanduser().absolute()
169
+ if not source.name.endswith((".nii", ".nii.gz")):
170
+ raise InvalidInputError(f"image_path must be a .nii or .nii.gz file: {source}")
171
+ if not source.is_file():
172
+ raise InvalidInputError(f"image_path does not exist: {source}")
173
+ return source
174
+
175
+
176
+ def _require_weights(path: str | os.PathLike[str]) -> Path:
177
+ weights = Path(path).expanduser().absolute()
178
+ if not weights.is_file():
179
+ raise AssetNotReadyError(
180
+ f"UNesT weights not found: {weights}; expected the .safetensors converted "
181
+ "from the bundle's models/model.pt",
182
+ reason=f"missing {weights}",
183
+ hint=_CONVERT_HINT,
184
+ )
185
+ if weights.suffix != ".safetensors":
186
+ raise AssetNotReadyError(
187
+ f"UNesT weights must be a converted .safetensors file, got {weights.name}",
188
+ reason=f"unconverted {weights.name}",
189
+ hint=_CONVERT_HINT,
190
+ )
191
+ return weights
192
+
193
+
194
+ def read_volume(path: Path) -> tuple[NiftiImage, HostArray]:
195
+ """Read a finite 3D NIfTI volume and return the nibabel image and its voxel array.
196
+
197
+ The array is ``np.asanyarray(image.dataobj)`` in file axis order, with the header's
198
+ slope and intercept applied. Which affine (sform or qform) defines the grid is decided
199
+ later by ``transforms.correct_nifti_header_if_necessary``, as in the upstream reader.
200
+
201
+ Raises:
202
+ InvalidInputError: The file cannot be read as NIfTI, is not 3D, has non-finite
203
+ voxels, has a non-finite or singular affine, or has non-positive header
204
+ spacing.
205
+ """
206
+
207
+ try:
208
+ image = nib.load(path.as_posix())
209
+ data = np.asanyarray(image.dataobj)
210
+ except (OSError, ValueError, ImageFileError) as error:
211
+ raise InvalidInputError(f"Invalid UNesT input NIfTI: {path}") from error
212
+ if not isinstance(image, nib_runtime.Nifti1Image | nib_runtime.Nifti2Image):
213
+ raise InvalidInputError(f"UNesT input must be NIfTI: {path}")
214
+ if data.ndim != 3:
215
+ raise InvalidInputError(f"UNesT input must be a 3D NIfTI; got shape {data.shape}")
216
+ if not np.issubdtype(data.dtype, np.number) or not np.all(np.isfinite(data)):
217
+ raise InvalidInputError("UNesT input contains non-finite voxels")
218
+ affine = np.asarray(image.affine, dtype=np.float64)
219
+ if affine.shape != (4, 4) or not np.all(np.isfinite(affine)):
220
+ raise InvalidInputError("UNesT input affine must be a finite 4x4 matrix")
221
+ if np.linalg.det(affine[:3, :3]) == 0:
222
+ raise InvalidInputError("UNesT input affine must be invertible")
223
+ if any(float(value) <= 0.0 for value in image.header.get_zooms()[:3]):
224
+ raise InvalidInputError("UNesT input spacing_mm values must be positive")
225
+ return cast(NiftiImage, image), data
226
+
227
+
228
+ def _load_network(path: Path, *, mx: NativeRuntime) -> NativeNetwork:
229
+ from unest_mlx import mlx_weights
230
+ from unest_mlx.mlx_model import UNesT
231
+
232
+ try:
233
+ loaded = mx.load(str(path))
234
+ except (OSError, RuntimeError, ValueError) as error:
235
+ raise AssetNotReadyError(
236
+ f"Cannot read UNesT weights {path}: {error}",
237
+ reason=f"unreadable {path.name}",
238
+ hint=_CONVERT_HINT,
239
+ ) from error
240
+ if not isinstance(loaded, dict):
241
+ raise AssetNotReadyError(
242
+ f"UNesT weights are not a tensor mapping: {path}",
243
+ reason=f"unreadable {path.name}",
244
+ hint=_CONVERT_HINT,
245
+ )
246
+ try:
247
+ state = {str(key): cast(HostArray, np.array(value)) for key, value in loaded.items()}
248
+ weights = mlx_weights.convert_state_dict(state)
249
+ except (InvalidInputError, RuntimeError, TypeError, ValueError) as error:
250
+ raise AssetNotReadyError(
251
+ f"UNesT weights do not match the released 0.2.7 checkpoint: {error}",
252
+ reason=f"mismatched {path.name}",
253
+ hint=_CONVERT_HINT,
254
+ ) from error
255
+ arrays = {key: mx.array(value) for key, value in weights.items()}
256
+ mx.eval(arrays)
257
+ return UNesT(arrays, mx=mx)
258
+
259
+
260
+ def write_mask(path: Path, labels: FloatArray, affine: DoubleArray) -> None:
261
+ """Write a label map like MONAI's ``NibabelWriter``, replacing ``path`` atomically.
262
+
263
+ Args:
264
+ path: Destination; must end in ``.nii.gz``. Parent directories are created.
265
+ labels: Label values on the reader grid, stored as float32.
266
+ affine: 4x4 voxel-to-world affine, written as both sform and qform with code 1.
267
+ """
268
+
269
+ image = nib.Nifti1Image(np.asarray(labels, dtype=np.float32), affine)
270
+ image.set_sform(affine, code=1)
271
+ image.set_qform(affine, code=1)
272
+ path.parent.mkdir(parents=True, exist_ok=True)
273
+ # Write beside the target and rename, so a failed run never leaves a truncated mask.
274
+ partial = path.with_name(f".{path.name[: -len('.nii.gz')]}.{os.getpid()}.partial.nii.gz")
275
+ try:
276
+ nib.save(image, partial)
277
+ os.replace(partial, path)
278
+ finally:
279
+ partial.unlink(missing_ok=True)