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.
- {segresnet_mlx-0.2.1 → segresnet_mlx-0.2.3}/PKG-INFO +12 -19
- {segresnet_mlx-0.2.1 → segresnet_mlx-0.2.3}/README.md +10 -17
- {segresnet_mlx-0.2.1 → segresnet_mlx-0.2.3}/pyproject.toml +3 -3
- {segresnet_mlx-0.2.1 → segresnet_mlx-0.2.3}/src/segresnet_mlx/__init__.py +1 -1
- {segresnet_mlx-0.2.1 → segresnet_mlx-0.2.3}/src/segresnet_mlx/_types.py +2 -1
- {segresnet_mlx-0.2.1 → segresnet_mlx-0.2.3}/src/segresnet_mlx/medmlx.py +44 -8
- {segresnet_mlx-0.2.1 → segresnet_mlx-0.2.3}/src/segresnet_mlx/network.py +9 -4
- {segresnet_mlx-0.2.1 → segresnet_mlx-0.2.3}/src/segresnet_mlx/predictor.py +24 -56
- {segresnet_mlx-0.2.1 → segresnet_mlx-0.2.3}/src/segresnet_mlx/topology.py +1 -1
- {segresnet_mlx-0.2.1 → segresnet_mlx-0.2.3}/src/segresnet_mlx/transforms.py +26 -11
- {segresnet_mlx-0.2.1 → segresnet_mlx-0.2.3}/src/segresnet_mlx/weights.py +22 -4
- {segresnet_mlx-0.2.1 → segresnet_mlx-0.2.3}/.gitignore +0 -0
- {segresnet_mlx-0.2.1 → segresnet_mlx-0.2.3}/LICENSE +0 -0
- {segresnet_mlx-0.2.1 → segresnet_mlx-0.2.3}/THIRD_PARTY_NOTICES.md +0 -0
- {segresnet_mlx-0.2.1 → segresnet_mlx-0.2.3}/src/segresnet_mlx/_arrays.py +0 -0
- {segresnet_mlx-0.2.1 → segresnet_mlx-0.2.3}/src/segresnet_mlx/_nifti.py +0 -0
- {segresnet_mlx-0.2.1 → segresnet_mlx-0.2.3}/src/segresnet_mlx/_records.py +0 -0
- {segresnet_mlx-0.2.1 → segresnet_mlx-0.2.3}/src/segresnet_mlx/_runtime.py +0 -0
- {segresnet_mlx-0.2.1 → segresnet_mlx-0.2.3}/src/segresnet_mlx/inference.py +0 -0
- {segresnet_mlx-0.2.1 → segresnet_mlx-0.2.3}/src/segresnet_mlx/py.typed +0 -0
- {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.
|
|
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.
|
|
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
|
|
50
|
+
uv venv --python 3.13
|
|
51
51
|
source .venv/bin/activate
|
|
52
|
-
|
|
52
|
+
uv pip install segresnet-mlx huggingface-hub
|
|
53
53
|
```
|
|
54
54
|
|
|
55
|
-
|
|
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(
|
|
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.
|
|
84
|
-
|
|
85
|
-
|
|
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
|
|
21
|
+
uv venv --python 3.13
|
|
22
22
|
source .venv/bin/activate
|
|
23
|
-
|
|
23
|
+
uv pip install segresnet-mlx huggingface-hub
|
|
24
24
|
```
|
|
25
25
|
|
|
26
|
-
|
|
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(
|
|
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.
|
|
55
|
-
|
|
56
|
-
|
|
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.
|
|
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.
|
|
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 = "
|
|
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.
|
|
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
|
|
296
|
-
``
|
|
297
|
-
|
|
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
|
|
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
|
-
|
|
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``)
|
|
560
|
-
|
|
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
|
|
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``
|
|
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)
|
|
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
|
|
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,
|
|
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
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
|
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
|
|
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
|
-
"""
|
|
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(
|
|
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(
|
|
523
|
-
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
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
|
-
"""
|
|
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
|
|
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``.
|
|
121
|
-
|
|
122
|
-
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|