segresnet-mlx 0.2.1__tar.gz → 0.2.3__tar.gz

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.
Files changed (21) hide show
  1. {segresnet_mlx-0.2.1 → segresnet_mlx-0.2.3}/PKG-INFO +12 -19
  2. {segresnet_mlx-0.2.1 → segresnet_mlx-0.2.3}/README.md +10 -17
  3. {segresnet_mlx-0.2.1 → segresnet_mlx-0.2.3}/pyproject.toml +3 -3
  4. {segresnet_mlx-0.2.1 → segresnet_mlx-0.2.3}/src/segresnet_mlx/__init__.py +1 -1
  5. {segresnet_mlx-0.2.1 → segresnet_mlx-0.2.3}/src/segresnet_mlx/_types.py +2 -1
  6. {segresnet_mlx-0.2.1 → segresnet_mlx-0.2.3}/src/segresnet_mlx/medmlx.py +44 -8
  7. {segresnet_mlx-0.2.1 → segresnet_mlx-0.2.3}/src/segresnet_mlx/network.py +9 -4
  8. {segresnet_mlx-0.2.1 → segresnet_mlx-0.2.3}/src/segresnet_mlx/predictor.py +24 -56
  9. {segresnet_mlx-0.2.1 → segresnet_mlx-0.2.3}/src/segresnet_mlx/topology.py +1 -1
  10. {segresnet_mlx-0.2.1 → segresnet_mlx-0.2.3}/src/segresnet_mlx/transforms.py +26 -11
  11. {segresnet_mlx-0.2.1 → segresnet_mlx-0.2.3}/src/segresnet_mlx/weights.py +22 -4
  12. {segresnet_mlx-0.2.1 → segresnet_mlx-0.2.3}/.gitignore +0 -0
  13. {segresnet_mlx-0.2.1 → segresnet_mlx-0.2.3}/LICENSE +0 -0
  14. {segresnet_mlx-0.2.1 → segresnet_mlx-0.2.3}/THIRD_PARTY_NOTICES.md +0 -0
  15. {segresnet_mlx-0.2.1 → segresnet_mlx-0.2.3}/src/segresnet_mlx/_arrays.py +0 -0
  16. {segresnet_mlx-0.2.1 → segresnet_mlx-0.2.3}/src/segresnet_mlx/_nifti.py +0 -0
  17. {segresnet_mlx-0.2.1 → segresnet_mlx-0.2.3}/src/segresnet_mlx/_records.py +0 -0
  18. {segresnet_mlx-0.2.1 → segresnet_mlx-0.2.3}/src/segresnet_mlx/_runtime.py +0 -0
  19. {segresnet_mlx-0.2.1 → segresnet_mlx-0.2.3}/src/segresnet_mlx/inference.py +0 -0
  20. {segresnet_mlx-0.2.1 → segresnet_mlx-0.2.3}/src/segresnet_mlx/py.typed +0 -0
  21. {segresnet_mlx-0.2.1 → segresnet_mlx-0.2.3}/src/segresnet_mlx/telemetry.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: segresnet-mlx
3
- Version: 0.2.1
3
+ Version: 0.2.3
4
4
  Summary: Native MLX SegResNet with NumPy sliding-window logit inference
5
5
  Project-URL: Homepage, https://github.com/MedMLX/segresnet-mlx
6
6
  Project-URL: Documentation, https://github.com/MedMLX/segresnet-mlx/blob/main/docs/technical.md
@@ -12,7 +12,7 @@ License-File: LICENSE
12
12
  License-File: THIRD_PARTY_NOTICES.md
13
13
  Classifier: Operating System :: MacOS :: MacOS X
14
14
  Requires-Python: <3.15,>=3.13
15
- Requires-Dist: medmlx-core==0.2.3
15
+ Requires-Dist: medmlx-core==0.2.4
16
16
  Requires-Dist: nibabel<6,>=5.3
17
17
  Requires-Dist: numpy<3,>=2.0
18
18
  Provides-Extra: conversion
@@ -47,26 +47,22 @@ Segment brain tumors, renal structures and whole-body CT anatomy with MONAI SegR
47
47
  ## Quick start
48
48
 
49
49
  ```bash
50
- uv venv --python 3.13 --seed
50
+ uv venv --python 3.13
51
51
  source .venv/bin/activate
52
- python -m pip install "segresnet-mlx==0.2.1" huggingface-hub
52
+ uv pip install segresnet-mlx huggingface-hub
53
53
  ```
54
54
 
55
- The tagged GitHub release is an alternative installation route:
56
-
57
- ```bash
58
- repo="https://github.com/MedMLX/segresnet-mlx.git@v0.2.1"
59
- python -m pip install "segresnet-mlx @ git+$repo" huggingface-hub
60
- ```
61
-
62
- Download the public converted BraTS bundle separately, then run:
55
+ Download the converted [BraTS](https://huggingface.co/MedMLX/segresnet-brats-mlx) weights, then run:
63
56
 
64
57
  ```python
65
58
  from huggingface_hub import hf_hub_download
66
59
  from segresnet_mlx.medmlx import run
67
60
 
68
61
  bundle = "brats_mri_segmentation"
69
- weights = hf_hub_download("MedMLX/segresnet-brats-mlx", "segresnet-brats.safetensors")
62
+ weights = hf_hub_download(
63
+ "MedMLX/segresnet-brats-mlx", "segresnet-brats.safetensors",
64
+ revision="14f9cda3649965729663c7365c49fd839c81a01b",
65
+ )
70
66
 
71
67
  result = run(
72
68
  bundle=bundle,
@@ -80,12 +76,9 @@ result = run(
80
76
  print(result["outputs"]["mask"])
81
77
  ```
82
78
 
83
- Keep input volumes on the same voxel grid. The package contains code only.
84
- Public converted bundles are available separately for
85
- [BraTS](https://huggingface.co/MedMLX/segresnet-brats-mlx) and
86
- [renal CECT](https://huggingface.co/MedMLX/segresnet-renal-cect-mlx); follow each
87
- weight card and its terms. Docs cover local upstream checkpoint conversion and
88
- whole-body CT bundles. Qualification differs by bundle; see the technical notes.
79
+ Keep input volumes on the same voxel grid. Converted weights are also available for
80
+ [renal CECT](https://huggingface.co/MedMLX/segresnet-renal-cect-mlx). Docs cover
81
+ checkpoint conversion and whole-body CT.
89
82
 
90
83
  ## License
91
84
 
@@ -18,26 +18,22 @@ Segment brain tumors, renal structures and whole-body CT anatomy with MONAI SegR
18
18
  ## Quick start
19
19
 
20
20
  ```bash
21
- uv venv --python 3.13 --seed
21
+ uv venv --python 3.13
22
22
  source .venv/bin/activate
23
- python -m pip install "segresnet-mlx==0.2.1" huggingface-hub
23
+ uv pip install segresnet-mlx huggingface-hub
24
24
  ```
25
25
 
26
- The tagged GitHub release is an alternative installation route:
27
-
28
- ```bash
29
- repo="https://github.com/MedMLX/segresnet-mlx.git@v0.2.1"
30
- python -m pip install "segresnet-mlx @ git+$repo" huggingface-hub
31
- ```
32
-
33
- Download the public converted BraTS bundle separately, then run:
26
+ Download the converted [BraTS](https://huggingface.co/MedMLX/segresnet-brats-mlx) weights, then run:
34
27
 
35
28
  ```python
36
29
  from huggingface_hub import hf_hub_download
37
30
  from segresnet_mlx.medmlx import run
38
31
 
39
32
  bundle = "brats_mri_segmentation"
40
- weights = hf_hub_download("MedMLX/segresnet-brats-mlx", "segresnet-brats.safetensors")
33
+ weights = hf_hub_download(
34
+ "MedMLX/segresnet-brats-mlx", "segresnet-brats.safetensors",
35
+ revision="14f9cda3649965729663c7365c49fd839c81a01b",
36
+ )
41
37
 
42
38
  result = run(
43
39
  bundle=bundle,
@@ -51,12 +47,9 @@ result = run(
51
47
  print(result["outputs"]["mask"])
52
48
  ```
53
49
 
54
- Keep input volumes on the same voxel grid. The package contains code only.
55
- Public converted bundles are available separately for
56
- [BraTS](https://huggingface.co/MedMLX/segresnet-brats-mlx) and
57
- [renal CECT](https://huggingface.co/MedMLX/segresnet-renal-cect-mlx); follow each
58
- weight card and its terms. Docs cover local upstream checkpoint conversion and
59
- whole-body CT bundles. Qualification differs by bundle; see the technical notes.
50
+ Keep input volumes on the same voxel grid. Converted weights are also available for
51
+ [renal CECT](https://huggingface.co/MedMLX/segresnet-renal-cect-mlx). Docs cover
52
+ checkpoint conversion and whole-body CT.
60
53
 
61
54
  ## License
62
55
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "segresnet-mlx"
7
- version = "0.2.1"
7
+ version = "0.2.3"
8
8
  description = "Native MLX SegResNet with NumPy sliding-window logit inference"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.13,<3.15"
@@ -13,7 +13,7 @@ license = "Apache-2.0"
13
13
  license-files = ["LICENSE", "THIRD_PARTY_NOTICES.md"]
14
14
  authors = [{ name = "Joseph Sandoval" }, { name = "Alfredo Sandoval Jr." }]
15
15
  dependencies = [
16
- "medmlx-core==0.2.3",
16
+ "medmlx-core==0.2.4",
17
17
  "nibabel>=5.3,<6",
18
18
  "numpy>=2.0,<3",
19
19
  ]
@@ -78,7 +78,7 @@ ban-relative-imports = "all"
78
78
  "typing_extensions.Any" = {msg = "Use a precise type and parse untyped values at the boundary."}
79
79
 
80
80
  [tool.uv.sources]
81
- medmlx-core = { git = "https://github.com/MedMLX/medmlx.git", rev = "e765f0a94aa31e8dfcdc599e4a4206eaca84f8b0", subdirectory = "packages/core" }
81
+ medmlx-core = { git = "https://github.com/MedMLX/medmlx.git", rev = "f98e0924f50228e2b9509474947b9672054b3217", subdirectory = "packages/core" }
82
82
 
83
83
  [tool.hatch.build.targets.sdist]
84
84
  only-include = [
@@ -21,7 +21,7 @@ if TYPE_CHECKING:
21
21
  from segresnet_mlx.weights import load_model as load_model
22
22
  from segresnet_mlx.weights import required_shapes as required_shapes
23
23
 
24
- __version__ = "0.2.1"
24
+ __version__ = "0.2.3"
25
25
 
26
26
  _EXPORTS = {
27
27
  "BUNDLE_CONFIGS": "segresnet_mlx.predictor",
@@ -47,8 +47,9 @@ if TYPE_CHECKING:
47
47
  overlap: float,
48
48
  mode: str,
49
49
  padding_mode: str,
50
- with_coord: bool,
50
+ with_coord: bool = False,
51
51
  predictor_input: Literal["numpy"],
52
+ preserve_window_strides: bool = False,
52
53
  ) -> Float32Array: ...
53
54
 
54
55
  class CoreSlidingWindow(Protocol):
@@ -292,16 +292,23 @@ def run(
292
292
  ``brats_mri_segmentation`` (``t1c_path``, ``t1_path``, ``t2_path``,
293
293
  ``flair_path``: co-registered MRI on one grid), or ``wholeBody_ct_segmentation``
294
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`.
295
+ at 1.5 mm or 3 mm respectively). ``weights_path`` is a native ``.safetensors``
296
+ file: ``segresnet-brats.safetensors`` from ``MedMLX/segresnet-brats-mlx`` for
297
+ BraTS, or ``segresnet-renal-cect.safetensors`` from
298
+ ``MedMLX/segresnet-renal-cect-mlx`` for renal CECT. These published files need
299
+ no conversion. For other checkpoints, convert the original ``models/model.pt``
300
+ (``models/model_lowres.pt`` for lowres whole-body) with
301
+ :func:`segresnet_mlx.convert_checkpoint` and pass the resulting file.
298
302
 
299
303
  Inputs must be finite 3D NIfTI volumes; multi-input bundles need identical shape
300
304
  and affine (atol 1e-5). Writes ``<bundle>_mask.nii.gz`` in ``output_dir`` on the
301
305
  first input's shape and reader-selected affine (see ``Volume``): renal labels 0-5
302
306
  and whole-body labels 0-104 as float32, BraTS labels 0, 1, 2, 4 as uint8, as the
303
307
  bundles' ``SaveImaged`` wrote them. An existing mask is replaced only when
304
- ``overwrite`` is true; output aliases of input images are always refused.
308
+ ``overwrite`` is true; output aliases of input images are refused by a
309
+ preflight check. Spatial affine values are assumed to be in millimeters; the
310
+ NIfTI spatial-unit field is not checked or converted. Paths are not locked
311
+ against concurrent replacement after the alias check.
305
312
 
306
313
  ``arithmetic_profile="compensated-fp32"`` uses Metal float pairs for
307
314
  convolution and GroupNorm accumulation, with float32 layer boundaries.
@@ -312,6 +319,27 @@ def run(
312
319
  requires transpose-convolution upsampling and compares with the unchanged
313
320
  original graph evaluated in float64 throughout its network and inferer.
314
321
 
322
+ Args:
323
+ bundle: ``brats_mri_segmentation``, ``renalStructures_CECT_segmentation``,
324
+ ``wholeBody_ct_segmentation`` or ``wholeBody_ct_segmentation_lowres``.
325
+ weights_path: Path to the matching native ``.safetensors`` file, such as
326
+ ``segresnet-brats.safetensors`` or ``segresnet-renal-cect.safetensors``.
327
+ Hugging Face cache file paths are accepted; directories and Torch ``.pt``
328
+ checkpoints are not.
329
+ output_dir: Directory for ``<bundle>_mask.nii.gz``; created if missing.
330
+ arterial_path: Renal arterial-phase 3D NIfTI; required for the renal bundle.
331
+ venous_path: Renal venous-phase 3D NIfTI on the arterial grid; required for renal.
332
+ excretory_path: Renal excretory-phase 3D NIfTI on the arterial grid; required
333
+ for renal.
334
+ t1c_path: BraTS contrast-enhanced T1 3D NIfTI; required for BraTS.
335
+ t1_path: BraTS T1 3D NIfTI on the T1c grid; required for BraTS.
336
+ t2_path: BraTS T2 3D NIfTI on the T1c grid; required for BraTS.
337
+ flair_path: BraTS FLAIR 3D NIfTI on the T1c grid; required for BraTS.
338
+ image_path: Single 3D CT NIfTI; required for either whole-body bundle.
339
+ overwrite: Replace an existing output mask when true; defaults to false.
340
+ arithmetic_profile: ``torch-order-fp32`` (default), ``compensated-fp32``,
341
+ or ``compensated-fp48``; the last supports only the renal bundle.
342
+
315
343
  Returns:
316
344
  ``{"outputs": {"mask": path}, "details": {...}}``; ``details`` is described
317
345
  by ``segresnet_mlx._records.Details`` and always has
@@ -534,7 +562,9 @@ def postprocess_wholebody(logits: Float32Array, inverse: SpatialInverse) -> Floa
534
562
 
535
563
 
536
564
  def _weights_file(weights_path: str) -> Path:
537
- path = Path(weights_path).expanduser().resolve()
565
+ # MLX identifies the file format from the supplied name. Hub cache paths are
566
+ # named .safetensors symlinks whose underlying content-addressed blobs have no suffix.
567
+ path = Path(weights_path).expanduser().absolute()
538
568
  hint = (
539
569
  "Convert the bundle's models/model.pt once with "
540
570
  "segresnet_mlx.convert_checkpoint(model_pt, destination, spec=...) "
@@ -556,8 +586,9 @@ def _weights_file(weights_path: str) -> Path:
556
586
  def load_inputs(roles: list[tuple[str, str | None]]) -> list[Volume]:
557
587
  """Load each ``(role, path)`` and require one shared voxel grid.
558
588
 
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``.
589
+ Every volume must match the first one's shape and affine (``atol=1e-5``).
590
+ This check does not align mismatched inputs; bundle preprocessing may still
591
+ resample the admitted grid afterward.
561
592
  """
562
593
  volumes = [load_volume(Path(str(path)).expanduser().resolve(), role) for role, path in roles]
563
594
  reference = volumes[0]
@@ -573,7 +604,12 @@ def load_inputs(roles: list[tuple[str, str | None]]) -> list[Volume]:
573
604
 
574
605
 
575
606
  def load_volume(path: Path, role: str) -> Volume:
576
- """Read finite 3D voxels using the affine selected by the upstream nibabel reader."""
607
+ """Read 3D voxels using the affine selected by the upstream nibabel reader.
608
+
609
+ Source voxels are checked for finiteness before conversion to float32; values
610
+ outside the float32 range can overflow during that conversion. Spatial units
611
+ and affine invertibility are not checked here.
612
+ """
577
613
  from segresnet_mlx._nifti import ImageFileError, nib
578
614
 
579
615
  if not path.is_file():
@@ -21,7 +21,8 @@ def group_mean(values: Array, mx: MLX) -> Array:
21
21
  """Mean over the last axis of ``(N, G, S)`` values, summed in 256-element tiles.
22
22
 
23
23
  Tiling bounds the float32 accumulation depth of a group that spans a whole
24
- volume. Returns ``(N, G, 1)``. ``SegResNet`` does not call this (its GroupNorm
24
+ volume. The last dimension must be nonempty. Returns ``(N, G, 1)``. ``SegResNet``
25
+ does not call this (its GroupNorm
25
26
  comes from the ``medmlx_core`` operators); it is exercised by
26
27
  ``tests/test_equivalence.py``.
27
28
  """
@@ -34,16 +35,20 @@ def group_mean(values: Array, mx: MLX) -> Array:
34
35
  class SegResNet:
35
36
  """Raw-logit forward pass for MONAI's 3-D, group-normalized SegResNet.
36
37
 
37
- Tensors are channels-first ``(N, C, D, H, W)`` float32 at every layer boundary.
38
+ Tensors are channels-first ``(N, C, D, H, W)``; the supported inference
39
+ contract uses float32 inputs and returned logits. This class checks rank,
40
+ channel count and spatial divisibility but does not itself check input dtype
41
+ or finiteness; the public patch and sliding-window helpers do.
38
42
  ``weights`` uses MONAI's state-dict keys and Torch parameter layouts
39
43
  (convolution weights are ``(out, in, kD, kH, kW)``); ``required_shapes`` in
40
44
  ``segresnet_mlx.weights`` lists them. Inference only: dropout is omitted and
41
45
  no activation is applied to the output.
42
46
 
43
- ``arithmetic_profile`` selects the accumulation scheme (see the README):
47
+ ``arithmetic_profile`` selects the accumulation scheme (see ``docs/technical.md``):
44
48
  ``"torch-order-fp32"`` (default), ``"compensated-fp32"``, or
45
49
  ``"compensated-fp48"``, which needs ``upsample_mode="deconv"`` and raises
46
- ``ValueError`` otherwise.
50
+ ``ValueError`` otherwise. The first two profiles use float32 layer boundaries;
51
+ ``compensated-fp48`` carries paired float32 values through the hidden layers.
47
52
  """
48
53
 
49
54
  def __init__(
@@ -6,7 +6,7 @@ import time
6
6
  from collections.abc import Callable
7
7
  from contextlib import nullcontext
8
8
  from dataclasses import dataclass
9
- from typing import TYPE_CHECKING, Literal, cast
9
+ from typing import TYPE_CHECKING, cast
10
10
 
11
11
  from segresnet_mlx._records import BundleConfig, InfererConfig
12
12
  from segresnet_mlx._runtime import import_mlx
@@ -50,8 +50,9 @@ class SlidingWindowConfig:
50
50
  def from_bundle_config(cls, config: InfererConfig | BundleConfig) -> SlidingWindowConfig:
51
51
  """Read the staged JSON's inferer section without evaluating bundle expressions.
52
52
 
53
- Missing keys fall back to MONAI's defaults. Values are taken literally, so a
54
- reference such as ``@displayable_configs#sw_overlap`` is not resolved.
53
+ ``roi_size`` is required; the other listed keys default to MONAI's
54
+ values. Values are taken literally, so a reference such as
55
+ ``@displayable_configs#sw_overlap`` is not resolved.
55
56
  """
56
57
  inferer = cast(InfererConfig, config.get("inferer", config))
57
58
  return cls(
@@ -71,13 +72,6 @@ BUNDLE_CONFIGS = {
71
72
  for name, bundle in BUNDLES.items()
72
73
  }
73
74
 
74
- _PAD_MODES: dict[str, Literal["constant", "reflect", "edge", "wrap"]] = {
75
- "constant": "constant",
76
- "reflect": "reflect",
77
- "replicate": "edge",
78
- "circular": "wrap",
79
- }
80
-
81
75
 
82
76
  class BundlePredictor:
83
77
  """Synchronized MLX window callback with an injected network.
@@ -195,48 +189,18 @@ class BundlePredictor:
195
189
  require_ncdhw(host, name="SegResNet input")
196
190
  if self.telemetry is not None:
197
191
  self.telemetry.set_metadata({"sw_batch_size": configuration.sw_batch_size})
198
- callback: (
199
- Callable[[HostArray | Array], Float32Array]
200
- | Callable[[HostArray | Array, list[list[slice]]], Float32Array]
201
- ) = self._forward_window
202
- use_coordinates = configuration.sw_batch_size == 1
203
- if use_coordinates:
204
- # MONAI preserves the sliced input's strides for batch size 1;
205
- # the shared core concatenates even a single patch. MLX selects
206
- # different reduction kernels for strided/contiguous inputs.
207
- # Open gap: a core change that skips the single-patch
208
- # concatenation would make this workaround unnecessary.
209
- padding = [(0, 0), (0, 0)]
210
- for size, roi in zip(host.shape[2:], configuration.roi_size, strict=True):
211
- extra = max(roi - size, 0)
212
- padding.append((extra // 2, extra - extra // 2))
213
- # Torch padding materializes a contiguous array; unpadded data
214
- # retains its original strides. Match the bundle's padding_mode
215
- # so this fast path agrees with the core's own padding.
216
- pad_kind = _PAD_MODES[configuration.padding_mode]
217
- padded = (
218
- np.ascontiguousarray(np.pad(host, padding, mode=pad_kind))
219
- if any(before or after for before, after in padding)
220
- else host
221
- )
222
-
223
- def coordinate_callback(
224
- values: HostArray | Array, coordinates: list[list[slice]]
225
- ) -> Float32Array:
226
- return self._forward_window(padded[tuple(coordinates[0])])
227
-
228
- callback = coordinate_callback
229
-
230
192
  return sliding_window_inference(
231
193
  host,
232
194
  configuration.roi_size,
233
195
  configuration.sw_batch_size,
234
- callback,
196
+ self._forward_window,
235
197
  overlap=configuration.overlap,
236
198
  mode=configuration.mode,
237
199
  padding_mode=configuration.padding_mode,
238
- with_coord=use_coordinates,
239
200
  predictor_input="numpy",
201
+ # MLX reduction kernels depend on layout. Match MONAI's sliced
202
+ # batch-one input without a second padded volume or patch copy.
203
+ preserve_window_strides=True,
240
204
  )
241
205
  except BaseException as error:
242
206
  if self.telemetry is not None:
@@ -287,17 +251,21 @@ def predict_wide_logits(
287
251
  if not np.isfinite(output).all():
288
252
  raise RuntimeError("SegResNet produced non-finite paired logits")
289
253
  del result
290
- mx.clear_cache()
291
254
  return output
292
255
 
293
- logits = sliding_window_inference(
294
- values.astype(np.float64),
295
- configuration.roi_size,
296
- configuration.sw_batch_size,
297
- window,
298
- overlap=configuration.overlap,
299
- mode=configuration.mode,
300
- padding_mode=configuration.padding_mode,
301
- predictor_input="numpy",
302
- )
303
- return logits.astype(np.float32)
256
+ try:
257
+ logits = sliding_window_inference(
258
+ values.astype(np.float64),
259
+ configuration.roi_size,
260
+ configuration.sw_batch_size,
261
+ window,
262
+ overlap=configuration.overlap,
263
+ mode=configuration.mode,
264
+ padding_mode=configuration.padding_mode,
265
+ predictor_input="numpy",
266
+ )
267
+ return logits.astype(np.float32)
268
+ finally:
269
+ # Reuse same-shaped Metal allocations across windows, including when
270
+ # paired arithmetic requires a wider host accumulator.
271
+ mx.clear_cache()
@@ -6,7 +6,7 @@ import math
6
6
  from dataclasses import dataclass
7
7
  from typing import Literal
8
8
 
9
- # Accumulation scheme for convolution and GroupNorm; see the README for the three
9
+ # Accumulation scheme for convolution and GroupNorm; see docs/technical.md for the three
10
10
  # profiles. ``compensated-fp48`` additionally requires transpose-convolution upsampling.
11
11
  type ArithmeticProfile = Literal["torch-order-fp32", "compensated-fp32", "compensated-fp48"]
12
12
 
@@ -10,7 +10,8 @@ bundle's ``MergeClassesd`` and the NIfTI writer. Spatial resampling follows MONA
10
10
  as ``Spacing`` does and returned as float32.
11
11
 
12
12
  Image arrays are channel-first float32 ``(C, *spatial)`` and affines are 4x4 float64
13
- voxel-to-world matrices as stored in the NIfTI header; ``pixdim`` values are in mm.
13
+ voxel-to-world matrices as stored in the NIfTI header. Bundle ``pixdim`` targets
14
+ are in mm; these functions do not inspect or convert the input's spatial units.
14
15
  """
15
16
 
16
17
  from __future__ import annotations
@@ -373,7 +374,11 @@ def brats_labels(binary: Float32Array) -> npt.NDArray[np.int64]:
373
374
 
374
375
 
375
376
  def write_nifti(path: Path, data: HostArray, affine: Float64Array, dtype: npt.DTypeLike) -> None:
376
- """MONAI ``NibabelWriter``: cast, then set sform and qform (code 1) from the affine."""
377
+ """Cast and write a NIfTI with sform and qform code 1.
378
+
379
+ The sform stores the affine within NIfTI precision. The qform cannot
380
+ represent shear, which NiBabel removes when encoding that form.
381
+ """
377
382
  array = np.asarray(data).astype(dtype, copy=False)
378
383
  matrix = to_affine_nd(3, affine)
379
384
  image = nib.Nifti1Image(array, matrix)
@@ -517,12 +522,15 @@ def _grid_sample(
517
522
  return result
518
523
 
519
524
 
520
- def _fma_array(left: Float64Array, right: float, addend: Float64Array) -> Float64Array:
525
+ def _fma_array(
526
+ left: Float64Array, right: float | Float64Array, addend: Float64Array
527
+ ) -> Float64Array:
521
528
  """Elementwise ``left * right + addend`` with one rounding (error-free transforms)."""
522
- left, addend = np.broadcast_arrays(np.asarray(left, np.float64), np.asarray(addend, np.float64))
523
- right_value = np.float64(right)
529
+ left, right_value, addend = np.broadcast_arrays(
530
+ np.asarray(left, np.float64), np.asarray(right, np.float64), np.asarray(addend, np.float64)
531
+ )
524
532
  product = left * right_value
525
- if right_value == 0 or not np.any(left):
533
+ if not np.any(right_value) or not np.any(left):
526
534
  return product + addend
527
535
  error = _product_error(left, right_value, product)
528
536
  total = product + addend
@@ -531,7 +539,9 @@ def _fma_array(left: Float64Array, right: float, addend: Float64Array) -> Float6
531
539
  return total + (sum_error + error)
532
540
 
533
541
 
534
- def _product_error(left: Float64Array, right: np.float64, product: Float64Array) -> Float64Array:
542
+ def _product_error(
543
+ left: Float64Array, right: Float64Array, product: Float64Array
544
+ ) -> Float64Array:
535
545
  """Dekker's exact remainder ``left * right - product``."""
536
546
 
537
547
  def split[T: (Float64Array, np.float64)](value: T) -> tuple[T, T]:
@@ -580,7 +590,12 @@ def _sample_separable(
580
590
  gy: Float64Array,
581
591
  gz: Float64Array,
582
592
  ) -> Float64Array:
583
- """Trilinear corners for an axis-aligned grid, in ATen's weight and sum order."""
593
+ """Trilinear corners in ATen's order, with its fused multiply-add accumulation.
594
+
595
+ Separate product/sum rounding can move a float64 interpolation across a
596
+ float32 midpoint. Preserve the source CPU kernel's fusion before the final
597
+ float32 cast; the network still receives ordinary float32 values.
598
+ """
584
599
  depth, height, width = data.shape[1:]
585
600
  x_index, x_weight = _corners(_source_index(gx, width))
586
601
  y_index, y_weight = _corners(_source_index(gy, height))
@@ -602,7 +617,7 @@ def _sample_separable(
602
617
  .astype(np.float64)
603
618
  )
604
619
  inside = ((zi < depth)[along_d] & (yi < height)[along_h]) & (xi < width)[along_w]
605
- result += np.where(inside, values * weight, 0.0)
620
+ result = np.where(inside, _fma_array(values, weight, result), result)
606
621
  return result
607
622
 
608
623
 
@@ -612,7 +627,7 @@ def _sample(
612
627
  gy: Float64Array,
613
628
  gz: Float64Array,
614
629
  ) -> Float64Array:
615
- """Trilinear corners for a general grid, in ATen's weight and sum order."""
630
+ """General-grid corners with the same fused accumulation as the separable path."""
616
631
  depth, height, width = data.shape[1:]
617
632
  x_index, x_weight = _corners(_source_index(gx, width))
618
633
  y_index, y_weight = _corners(_source_index(gy, height))
@@ -631,7 +646,7 @@ def _sample(
631
646
  np.minimum(yi, height - 1),
632
647
  np.minimum(xi, width - 1),
633
648
  ].astype(np.float64)
634
- result += np.where(inside, values * weight, 0.0)
649
+ result = np.where(inside, _fma_array(values, weight, result), result)
635
650
  return result
636
651
 
637
652
 
@@ -78,6 +78,8 @@ def convert_state_dict(
78
78
 
79
79
  ``state`` is either a raw state dict or a checkpoint holding it under the
80
80
  ``"model"`` key. Arrays keep their Torch layout and are returned C-contiguous.
81
+ Validation checks the parameter inventory and values, not the checkpoint's
82
+ identity or origin; other checkpoints with the same topology are admissible.
81
83
 
82
84
  Raises:
83
85
  InvalidInputError: If a required parameter is missing, an unsupported one is
@@ -117,11 +119,26 @@ def convert_checkpoint(checkpoint_path: Path, destination: Path, *, spec: SegRes
117
119
 
118
120
  The checkpoint is read with the shared loader (``torch.load`` with its default
119
121
  ``weights_only=True``, so Torch from the ``conversion`` extra is required) and
120
- checked by ``convert_state_dict``. Parent directories of ``destination`` are
121
- created. MLX is also needed to write the file, so this runs only on a supported
122
- Apple Silicon host.
122
+ checked by ``convert_state_dict``. ``destination`` must end in
123
+ ``.safetensors``. Parent directories of ``destination`` are
124
+ created. An existing destination is replaced, except when it aliases the
125
+ source checkpoint. Alias checks occur before conversion and do not lock out
126
+ subsequent filesystem changes. MLX is also needed to write the file, so this
127
+ runs only on a supported Apple Silicon host.
128
+
129
+ Raises:
130
+ InvalidInputError: The destination has a different suffix or aliases the
131
+ source checkpoint, or checkpoint contents fail ``convert_state_dict``.
123
132
  """
124
133
 
134
+ if destination.suffix != ".safetensors":
135
+ raise InvalidInputError("Converted weights destination must end in .safetensors")
136
+ if checkpoint_path.resolve() == destination.resolve() or (
137
+ checkpoint_path.is_file()
138
+ and destination.is_file()
139
+ and checkpoint_path.samefile(destination)
140
+ ):
141
+ raise InvalidInputError("Converted weights must not overwrite their source checkpoint")
125
142
  mx = import_mlx()
126
143
  converted = convert_state_dict(load_torch_checkpoint(checkpoint_path), spec)
127
144
  destination.parent.mkdir(parents=True, exist_ok=True)
@@ -141,8 +158,9 @@ def load_model(
141
158
  """Load converted float32 safetensors into a ``SegResNet`` for ``spec``.
142
159
 
143
160
  Raises:
144
- InvalidInputError: If the file is not a mapping, its keys or shapes differ
161
+ InvalidInputError: If the loaded value is not a mapping, its keys or shapes differ
145
162
  from ``required_shapes(spec)``, or a parameter is not finite float32.
163
+ RuntimeError: If MLX cannot open or decode the weights file.
146
164
  """
147
165
  mx = import_mlx()
148
166
  raw: object = mx.load(str(weights_path))
File without changes
File without changes