segresnet-mlx 0.2.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of segresnet-mlx might be problematic. Click here for more details.
- segresnet_mlx/__init__.py +67 -0
- segresnet_mlx/_arrays.py +36 -0
- segresnet_mlx/_nifti.py +63 -0
- segresnet_mlx/_records.py +78 -0
- segresnet_mlx/_runtime.py +22 -0
- segresnet_mlx/_types.py +58 -0
- segresnet_mlx/inference.py +37 -0
- segresnet_mlx/medmlx.py +669 -0
- segresnet_mlx/network.py +195 -0
- segresnet_mlx/predictor.py +303 -0
- segresnet_mlx/py.typed +0 -0
- segresnet_mlx/telemetry.py +166 -0
- segresnet_mlx/topology.py +49 -0
- segresnet_mlx/transforms.py +669 -0
- segresnet_mlx/weights.py +183 -0
- segresnet_mlx-0.2.1.dist-info/METADATA +94 -0
- segresnet_mlx-0.2.1.dist-info/RECORD +21 -0
- segresnet_mlx-0.2.1.dist-info/WHEEL +4 -0
- segresnet_mlx-0.2.1.dist-info/entry_points.txt +2 -0
- segresnet_mlx-0.2.1.dist-info/licenses/LICENSE +202 -0
- segresnet_mlx-0.2.1.dist-info/licenses/THIRD_PARTY_NOTICES.md +21 -0
segresnet_mlx/network.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""MONAI 1.6 SegResNet inference graph in MLX, using NCDHW tensors."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from typing import TYPE_CHECKING, cast
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from medmlx_core.compensated import CompensatedFloat32Operators, CompensatedFloat48Operators
|
|
10
|
+
|
|
11
|
+
from segresnet_mlx._types import MLX, Array
|
|
12
|
+
|
|
13
|
+
from medmlx_core.layout import require_ncdhw
|
|
14
|
+
from medmlx_core.ops import conv_transpose3d_ncdhw, upsample_trilinear_ncdhw
|
|
15
|
+
from medmlx_core.precision import Float32Operators
|
|
16
|
+
|
|
17
|
+
from segresnet_mlx.topology import ArithmeticProfile, SegResNetSpec
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def group_mean(values: Array, mx: MLX) -> Array:
|
|
21
|
+
"""Mean over the last axis of ``(N, G, S)`` values, summed in 256-element tiles.
|
|
22
|
+
|
|
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
|
|
25
|
+
comes from the ``medmlx_core`` operators); it is exercised by
|
|
26
|
+
``tests/test_equivalence.py``.
|
|
27
|
+
"""
|
|
28
|
+
size = int(values.shape[-1])
|
|
29
|
+
padded = mx.pad(values, [(0, 0), (0, 0), (0, (-size) % 256)])
|
|
30
|
+
tiles = mx.reshape(padded, (*values.shape[:2], -1, 256))
|
|
31
|
+
return mx.sum(mx.sum(tiles, axis=-1), axis=-1, keepdims=True) / size
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class SegResNet:
|
|
35
|
+
"""Raw-logit forward pass for MONAI's 3-D, group-normalized SegResNet.
|
|
36
|
+
|
|
37
|
+
Tensors are channels-first ``(N, C, D, H, W)`` float32 at every layer boundary.
|
|
38
|
+
``weights`` uses MONAI's state-dict keys and Torch parameter layouts
|
|
39
|
+
(convolution weights are ``(out, in, kD, kH, kW)``); ``required_shapes`` in
|
|
40
|
+
``segresnet_mlx.weights`` lists them. Inference only: dropout is omitted and
|
|
41
|
+
no activation is applied to the output.
|
|
42
|
+
|
|
43
|
+
``arithmetic_profile`` selects the accumulation scheme (see the README):
|
|
44
|
+
``"torch-order-fp32"`` (default), ``"compensated-fp32"``, or
|
|
45
|
+
``"compensated-fp48"``, which needs ``upsample_mode="deconv"`` and raises
|
|
46
|
+
``ValueError`` otherwise.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
spec: SegResNetSpec,
|
|
52
|
+
weights: Mapping[str, Array],
|
|
53
|
+
*,
|
|
54
|
+
mx: MLX,
|
|
55
|
+
arithmetic_profile: ArithmeticProfile = "torch-order-fp32",
|
|
56
|
+
) -> None:
|
|
57
|
+
self.spec = spec
|
|
58
|
+
self._weights = weights
|
|
59
|
+
self._mx = mx
|
|
60
|
+
self.arithmetic_profile = arithmetic_profile
|
|
61
|
+
# Both convolution and normalization accumulation order affect sparse
|
|
62
|
+
# released-checkpoint logits; correcting only one leaves amplified drift.
|
|
63
|
+
if arithmetic_profile == "torch-order-fp32":
|
|
64
|
+
self._operators = Float32Operators(mx, spec.num_groups, spec.norm_eps)
|
|
65
|
+
elif arithmetic_profile == "compensated-fp32":
|
|
66
|
+
from medmlx_core.compensated import CompensatedFloat32Operators as AccuracyOperators
|
|
67
|
+
|
|
68
|
+
self._operators = AccuracyOperators(mx, spec.num_groups, spec.norm_eps)
|
|
69
|
+
elif arithmetic_profile == "compensated-fp48":
|
|
70
|
+
if spec.upsample_mode != "deconv":
|
|
71
|
+
raise ValueError("Paired precision requires transpose-convolution upsampling")
|
|
72
|
+
from medmlx_core.compensated import CompensatedFloat48Operators as PairedOperators
|
|
73
|
+
|
|
74
|
+
self._operators = PairedOperators(mx, spec.num_groups, spec.norm_eps)
|
|
75
|
+
else:
|
|
76
|
+
raise ValueError(f"Unknown SegResNet arithmetic profile {arithmetic_profile!r}")
|
|
77
|
+
|
|
78
|
+
def __call__(self, values: Array) -> Array:
|
|
79
|
+
"""Return float32 logits ``(N, out_channels, D, H, W)`` for an ``(N, C, D, H, W)`` input.
|
|
80
|
+
|
|
81
|
+
Raises:
|
|
82
|
+
ValueError: If the input is not rank 5, has the wrong channel count, or a
|
|
83
|
+
spatial size is not divisible by ``2 ** len(spec.blocks_up)``.
|
|
84
|
+
"""
|
|
85
|
+
if self.arithmetic_profile == "compensated-fp48":
|
|
86
|
+
operators = cast("CompensatedFloat48Operators", self._operators)
|
|
87
|
+
return operators.round(self.paired_forward(values))
|
|
88
|
+
return self._forward(values)
|
|
89
|
+
|
|
90
|
+
def paired_forward(self, values: Array) -> Array:
|
|
91
|
+
"""Run the paired-precision graph without the final rounding.
|
|
92
|
+
|
|
93
|
+
Returns ``(2N, out_channels, D, H, W)``: the high words of the ``N`` logit
|
|
94
|
+
volumes followed by their low words. Their float64 sum is the logit; the
|
|
95
|
+
caller (``predict_wide_logits``) does that sum on the host.
|
|
96
|
+
"""
|
|
97
|
+
if self.arithmetic_profile != "compensated-fp48":
|
|
98
|
+
raise ValueError("Paired logits require the compensated-fp48 profile")
|
|
99
|
+
operators = cast("CompensatedFloat48Operators", self._operators)
|
|
100
|
+
return self._forward(operators.pack(values))
|
|
101
|
+
|
|
102
|
+
def _forward(self, values: Array) -> Array:
|
|
103
|
+
require_ncdhw(values, name="SegResNet input")
|
|
104
|
+
if int(values.shape[1]) != self.spec.in_channels:
|
|
105
|
+
raise ValueError(
|
|
106
|
+
f"SegResNet input has {values.shape[1]} channels; expected {self.spec.in_channels}"
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
divisor = 2 ** len(self.spec.blocks_up)
|
|
110
|
+
if any(int(size) % divisor for size in values.shape[2:]):
|
|
111
|
+
raise ValueError(f"SegResNet spatial sizes must be divisible by {divisor}")
|
|
112
|
+
hidden = self.conv(values, "convInit.conv", padding=1)
|
|
113
|
+
skips: list[Array] = []
|
|
114
|
+
for stage, block_count in enumerate(self.spec.blocks_down):
|
|
115
|
+
if stage > 0:
|
|
116
|
+
hidden = self.conv(hidden, f"down_layers.{stage}.0.conv", padding=1, stride=2)
|
|
117
|
+
block_offset = 1
|
|
118
|
+
for block in range(block_count):
|
|
119
|
+
hidden = self.residual(hidden, f"down_layers.{stage}.{block + block_offset}")
|
|
120
|
+
skips.append(hidden)
|
|
121
|
+
|
|
122
|
+
for stage, block_count in enumerate(self.spec.blocks_up):
|
|
123
|
+
hidden = self.conv(hidden, f"up_samples.{stage}.0.conv", padding=0)
|
|
124
|
+
if self.spec.upsample_mode == "deconv":
|
|
125
|
+
prefix = f"up_samples.{stage}.1.deconv"
|
|
126
|
+
if self.arithmetic_profile != "torch-order-fp32":
|
|
127
|
+
hidden = cast("CompensatedFloat32Operators", self._operators).conv_transpose3d(
|
|
128
|
+
hidden, self._weights[f"{prefix}.weight"], self._weights[f"{prefix}.bias"]
|
|
129
|
+
)
|
|
130
|
+
else:
|
|
131
|
+
hidden = conv_transpose3d_ncdhw(
|
|
132
|
+
hidden,
|
|
133
|
+
self._weights[f"{prefix}.weight"],
|
|
134
|
+
self._weights[f"{prefix}.bias"],
|
|
135
|
+
padding=0,
|
|
136
|
+
output_padding=0,
|
|
137
|
+
stride=2,
|
|
138
|
+
mx=self._mx,
|
|
139
|
+
)
|
|
140
|
+
hidden = self._add(hidden, skips[-(stage + 2)])
|
|
141
|
+
else:
|
|
142
|
+
hidden = upsample_trilinear_ncdhw(hidden, mx=self._mx) + skips[-(stage + 2)]
|
|
143
|
+
for block in range(block_count):
|
|
144
|
+
hidden = self.residual(hidden, f"up_layers.{stage}.{block}")
|
|
145
|
+
|
|
146
|
+
hidden = self._norm(hidden, "conv_final.0")
|
|
147
|
+
hidden = self._relu(hidden)
|
|
148
|
+
return self.conv(hidden, "conv_final.2.conv", padding=0, bias=True)
|
|
149
|
+
|
|
150
|
+
def residual(self, values: Array, prefix: str) -> Array:
|
|
151
|
+
"""Pre-activation residual block: (norm, ReLU, 3x3x3 conv) twice, plus the input."""
|
|
152
|
+
hidden = self._norm(values, f"{prefix}.norm1")
|
|
153
|
+
hidden = self._relu(hidden)
|
|
154
|
+
hidden = self.conv(hidden, f"{prefix}.conv1.conv", padding=1)
|
|
155
|
+
hidden = self._norm(hidden, f"{prefix}.norm2")
|
|
156
|
+
hidden = self._relu(hidden)
|
|
157
|
+
hidden = self.conv(hidden, f"{prefix}.conv2.conv", padding=1)
|
|
158
|
+
return self._add(hidden, values)
|
|
159
|
+
|
|
160
|
+
def _relu(self, values: Array) -> Array:
|
|
161
|
+
return (
|
|
162
|
+
cast("CompensatedFloat48Operators", self._operators).relu(values)
|
|
163
|
+
if self.arithmetic_profile == "compensated-fp48"
|
|
164
|
+
else self._mx.maximum(values, 0.0)
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
def _add(self, values: Array, other: Array) -> Array:
|
|
168
|
+
return (
|
|
169
|
+
cast("CompensatedFloat48Operators", self._operators).add(values, other)
|
|
170
|
+
if self.arithmetic_profile == "compensated-fp48"
|
|
171
|
+
else values + other
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
def _norm(self, values: Array, prefix: str) -> Array:
|
|
175
|
+
return self._operators.norm(
|
|
176
|
+
values, self._weights[f"{prefix}.weight"], self._weights[f"{prefix}.bias"]
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
def conv(
|
|
180
|
+
self,
|
|
181
|
+
values: Array,
|
|
182
|
+
prefix: str,
|
|
183
|
+
*,
|
|
184
|
+
padding: int,
|
|
185
|
+
stride: int = 1,
|
|
186
|
+
bias: bool = False,
|
|
187
|
+
) -> Array:
|
|
188
|
+
"""Convolve with ``{prefix}.weight`` (plus ``{prefix}.bias`` if ``bias``)."""
|
|
189
|
+
return self._operators.conv(
|
|
190
|
+
values,
|
|
191
|
+
self._weights[f"{prefix}.weight"],
|
|
192
|
+
self._weights.get(f"{prefix}.bias") if bias else None,
|
|
193
|
+
padding=padding,
|
|
194
|
+
stride=stride,
|
|
195
|
+
)
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
"""Measured MLX window execution and NumPy full-volume raw-logit inference."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from contextlib import nullcontext
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import TYPE_CHECKING, Literal, cast
|
|
10
|
+
|
|
11
|
+
from segresnet_mlx._records import BundleConfig, InfererConfig
|
|
12
|
+
from segresnet_mlx._runtime import import_mlx
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from ignite.engine import Engine, Events
|
|
16
|
+
|
|
17
|
+
from segresnet_mlx._types import MLX, Array, CoreSlidingWindow
|
|
18
|
+
from segresnet_mlx.network import SegResNet
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
from medmlx_core.errors import InvalidInputError
|
|
22
|
+
from medmlx_core.layout import require_ncdhw
|
|
23
|
+
from medmlx_core.typing import ArrayFactory
|
|
24
|
+
|
|
25
|
+
from segresnet_mlx._arrays import Float32Array, HostArray
|
|
26
|
+
from segresnet_mlx.medmlx import BUNDLES
|
|
27
|
+
from segresnet_mlx.telemetry import SCHEMA_VERSION, WindowTelemetry
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, slots=True)
|
|
31
|
+
class SlidingWindowConfig:
|
|
32
|
+
"""The bundle's ``SlidingWindowInferer`` settings; other options use MONAI defaults.
|
|
33
|
+
|
|
34
|
+
Attributes:
|
|
35
|
+
roi_size: Window size in voxels, ``(D, H, W)`` of the preprocessed volume.
|
|
36
|
+
sw_batch_size: Windows sent to the network per call.
|
|
37
|
+
overlap: Fraction of each window shared with its neighbour, in ``[0, 1)``.
|
|
38
|
+
mode: Window blending, ``"constant"`` or ``"gaussian"`` (sigma scale 0.125).
|
|
39
|
+
padding_mode: How a volume smaller than ``roi_size`` is padded: ``"constant"``
|
|
40
|
+
(zeros), ``"reflect"``, ``"replicate"`` or ``"circular"``.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
roi_size: tuple[int, int, int]
|
|
44
|
+
sw_batch_size: int = 1
|
|
45
|
+
overlap: float = 0.25
|
|
46
|
+
mode: str = "constant"
|
|
47
|
+
padding_mode: str = "constant"
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
def from_bundle_config(cls, config: InfererConfig | BundleConfig) -> SlidingWindowConfig:
|
|
51
|
+
"""Read the staged JSON's inferer section without evaluating bundle expressions.
|
|
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.
|
|
55
|
+
"""
|
|
56
|
+
inferer = cast(InfererConfig, config.get("inferer", config))
|
|
57
|
+
return cls(
|
|
58
|
+
cast(tuple[int, int, int], tuple(inferer["roi_size"])),
|
|
59
|
+
inferer.get("sw_batch_size", 1),
|
|
60
|
+
inferer.get("overlap", 0.25),
|
|
61
|
+
inferer.get("mode", "constant"),
|
|
62
|
+
inferer.get("padding_mode", "constant"),
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# Window settings of each bundle in ``segresnet_mlx.medmlx.BUNDLES``.
|
|
67
|
+
BUNDLE_CONFIGS = {
|
|
68
|
+
name: SlidingWindowConfig(
|
|
69
|
+
bundle.roi_size, bundle.sw_batch_size, bundle.overlap, bundle.mode, bundle.padding_mode
|
|
70
|
+
)
|
|
71
|
+
for name, bundle in BUNDLES.items()
|
|
72
|
+
}
|
|
73
|
+
|
|
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
|
+
|
|
82
|
+
class BundlePredictor:
|
|
83
|
+
"""Synchronized MLX window callback with an injected network.
|
|
84
|
+
|
|
85
|
+
``forward`` retains the MLX-array callback API; ``predict_logits`` passes
|
|
86
|
+
synchronized NumPy logits directly to the host stitcher. Telemetry
|
|
87
|
+
lifecycle remains caller-owned here, allowing an engine to finish only after
|
|
88
|
+
postprocessing. The module-level ``predict_logits`` owns a direct-call session.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
schema_version = SCHEMA_VERSION
|
|
92
|
+
network_label = "SegResNet"
|
|
93
|
+
|
|
94
|
+
def __init__(
|
|
95
|
+
self,
|
|
96
|
+
network: Callable[[Array], Array],
|
|
97
|
+
*,
|
|
98
|
+
telemetry: WindowTelemetry | None = None,
|
|
99
|
+
mx: MLX | None = None,
|
|
100
|
+
) -> None:
|
|
101
|
+
self.network = network
|
|
102
|
+
self.telemetry = telemetry
|
|
103
|
+
try:
|
|
104
|
+
self.mx = import_mlx() if mx is None else mx
|
|
105
|
+
self.mx.reset_peak_memory()
|
|
106
|
+
if telemetry is not None:
|
|
107
|
+
telemetry.set_metadata(
|
|
108
|
+
{
|
|
109
|
+
"schema_version": self.schema_version,
|
|
110
|
+
"dtype": "float32",
|
|
111
|
+
"network_device": str(self.mx.default_device()),
|
|
112
|
+
"transforms_and_stitching_device": "cpu",
|
|
113
|
+
"compile": False,
|
|
114
|
+
"synchronization": "mx.eval and mx.synchronize before host export",
|
|
115
|
+
"window_timing_scope": (
|
|
116
|
+
"synchronized network, MLX input creation and host export; "
|
|
117
|
+
"excludes validation and telemetry"
|
|
118
|
+
),
|
|
119
|
+
"peak_memory_scope": "predictor lifetime, including warmup",
|
|
120
|
+
}
|
|
121
|
+
)
|
|
122
|
+
except BaseException as error:
|
|
123
|
+
if telemetry is not None:
|
|
124
|
+
telemetry.fail(error)
|
|
125
|
+
raise
|
|
126
|
+
|
|
127
|
+
def attach(self, engine: Engine, *, events: type[Events]) -> None:
|
|
128
|
+
"""Attach the telemetry (if any) to an Ignite ``engine`` using the caller's ``events``."""
|
|
129
|
+
if self.telemetry is not None:
|
|
130
|
+
self.telemetry.attach(engine, events=events)
|
|
131
|
+
|
|
132
|
+
def __call__(self, values: HostArray | Array) -> Array:
|
|
133
|
+
return self.forward(values)
|
|
134
|
+
|
|
135
|
+
def forward(self, values: HostArray | Array) -> Array:
|
|
136
|
+
"""Run one finite float32 window and return its logits as an MLX array.
|
|
137
|
+
|
|
138
|
+
The logits are exported to host NumPy for the finite check and timing, then
|
|
139
|
+
wrapped again as an MLX array for callers that expect one.
|
|
140
|
+
"""
|
|
141
|
+
try:
|
|
142
|
+
return cast(ArrayFactory, self.mx.array)(self._forward_window(values))
|
|
143
|
+
except BaseException as error:
|
|
144
|
+
if self.telemetry is not None:
|
|
145
|
+
self.telemetry.fail(error)
|
|
146
|
+
raise
|
|
147
|
+
|
|
148
|
+
def _forward_window(self, values: HostArray | Array) -> Float32Array:
|
|
149
|
+
host = self._host_input(values)
|
|
150
|
+
self.mx.synchronize()
|
|
151
|
+
started = time.perf_counter()
|
|
152
|
+
result = self.network(cast(ArrayFactory, self.mx.array)(host))
|
|
153
|
+
self.mx.eval(result)
|
|
154
|
+
self.mx.synchronize()
|
|
155
|
+
output = cast(Float32Array, np.array(result))
|
|
156
|
+
elapsed = time.perf_counter() - started
|
|
157
|
+
if not np.isfinite(output).all():
|
|
158
|
+
raise RuntimeError(f"{self.network_label} produced non-finite logits")
|
|
159
|
+
if self.telemetry is not None:
|
|
160
|
+
self.telemetry.observe(elapsed, self.mx.get_peak_memory())
|
|
161
|
+
# The allocator cache is kept across windows: every window has the same
|
|
162
|
+
# shapes, so later windows reuse the buffers instead of reallocating
|
|
163
|
+
# them. ``predict_logits`` releases the cache once the volume is done.
|
|
164
|
+
del result
|
|
165
|
+
return output
|
|
166
|
+
|
|
167
|
+
def _host_input(self, values: HostArray | Array) -> Float32Array:
|
|
168
|
+
host = np.asarray(values)
|
|
169
|
+
if host.dtype != np.float32 or not np.isfinite(host).all():
|
|
170
|
+
raise InvalidInputError(f"{self.network_label} input must be finite float32")
|
|
171
|
+
return cast(Float32Array, host)
|
|
172
|
+
|
|
173
|
+
def predict_logits(
|
|
174
|
+
self, values: HostArray | Array, configuration: SlidingWindowConfig
|
|
175
|
+
) -> Float32Array:
|
|
176
|
+
"""Stitch preprocessed ``(N, C, D, H, W)`` float32 data into full-volume logits.
|
|
177
|
+
|
|
178
|
+
Returns float32 NumPy ``(N, out_channels, D, H, W)`` at the input spatial
|
|
179
|
+
size. Volumes smaller than ``roi_size`` are padded and cropped back. Window
|
|
180
|
+
placement and blending follow MONAI's ``SlidingWindowInferer`` via the
|
|
181
|
+
shared core. Callers own telemetry completion.
|
|
182
|
+
|
|
183
|
+
Raises:
|
|
184
|
+
InvalidInputError: If ``values`` is not finite float32.
|
|
185
|
+
ValueError: If ``values`` is not rank 5.
|
|
186
|
+
"""
|
|
187
|
+
import medmlx_core.sliding_window as core_sliding_window
|
|
188
|
+
|
|
189
|
+
sliding_window_inference = cast(
|
|
190
|
+
"CoreSlidingWindow", core_sliding_window
|
|
191
|
+
).sliding_window_inference
|
|
192
|
+
|
|
193
|
+
try:
|
|
194
|
+
host = self._host_input(values)
|
|
195
|
+
require_ncdhw(host, name="SegResNet input")
|
|
196
|
+
if self.telemetry is not None:
|
|
197
|
+
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
|
+
return sliding_window_inference(
|
|
231
|
+
host,
|
|
232
|
+
configuration.roi_size,
|
|
233
|
+
configuration.sw_batch_size,
|
|
234
|
+
callback,
|
|
235
|
+
overlap=configuration.overlap,
|
|
236
|
+
mode=configuration.mode,
|
|
237
|
+
padding_mode=configuration.padding_mode,
|
|
238
|
+
with_coord=use_coordinates,
|
|
239
|
+
predictor_input="numpy",
|
|
240
|
+
)
|
|
241
|
+
except BaseException as error:
|
|
242
|
+
if self.telemetry is not None:
|
|
243
|
+
self.telemetry.fail(error)
|
|
244
|
+
raise
|
|
245
|
+
finally:
|
|
246
|
+
self.mx.clear_cache()
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def predict_logits(
|
|
250
|
+
values: HostArray | Array,
|
|
251
|
+
configuration: SlidingWindowConfig,
|
|
252
|
+
*,
|
|
253
|
+
network: Callable[[Array], Array],
|
|
254
|
+
telemetry: WindowTelemetry | None = None,
|
|
255
|
+
) -> Float32Array:
|
|
256
|
+
"""Return float32 NumPy NCDHW logits, completing optional window telemetry.
|
|
257
|
+
|
|
258
|
+
``values`` is preprocessed, finite float32 ``(N, C, D, H, W)``; the result has the
|
|
259
|
+
same spatial size. Input preparation, activation, label mapping, image I/O and QC
|
|
260
|
+
are external.
|
|
261
|
+
"""
|
|
262
|
+
with telemetry.session() if telemetry is not None else nullcontext():
|
|
263
|
+
predictor = BundlePredictor(network, telemetry=telemetry)
|
|
264
|
+
return predictor.predict_logits(values, configuration)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def predict_wide_logits(
|
|
268
|
+
network: SegResNet, values: Float32Array, configuration: SlidingWindowConfig, *, mx: MLX
|
|
269
|
+
) -> Float32Array:
|
|
270
|
+
"""Carry paired Metal logits into FP64 host blending, rounding only at export.
|
|
271
|
+
|
|
272
|
+
Preprocessed values and checkpoint parameters remain the same FP32 inputs.
|
|
273
|
+
The original MONAI float64 network/inferer is the independent oracle for this
|
|
274
|
+
explicit profile. The learned graph runs on Metal; host work is stitching.
|
|
275
|
+
"""
|
|
276
|
+
from medmlx_core.sliding_window import sliding_window_inference
|
|
277
|
+
|
|
278
|
+
def window(
|
|
279
|
+
patch: np.ndarray[tuple[int, ...], np.dtype[np.float64]],
|
|
280
|
+
) -> np.ndarray[tuple[int, ...], np.dtype[np.float64]]:
|
|
281
|
+
result = network.paired_forward(cast(ArrayFactory, mx.array)(patch.astype(np.float32)))
|
|
282
|
+
mx.eval(result)
|
|
283
|
+
mx.synchronize()
|
|
284
|
+
packed = np.array(result, dtype=np.float32)
|
|
285
|
+
batch = packed.shape[0] // 2
|
|
286
|
+
output = packed[:batch].astype(np.float64) + packed[batch:].astype(np.float64)
|
|
287
|
+
if not np.isfinite(output).all():
|
|
288
|
+
raise RuntimeError("SegResNet produced non-finite paired logits")
|
|
289
|
+
del result
|
|
290
|
+
mx.clear_cache()
|
|
291
|
+
return output
|
|
292
|
+
|
|
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)
|
segresnet_mlx/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Bounded progress records and explicit SegResNet inference lifecycle."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
import os
|
|
8
|
+
import tempfile
|
|
9
|
+
from collections.abc import Callable, Generator
|
|
10
|
+
from contextlib import contextmanager
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import TYPE_CHECKING, Literal, Protocol, cast
|
|
13
|
+
|
|
14
|
+
from segresnet_mlx._records import Json
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from ignite.engine import Events
|
|
18
|
+
|
|
19
|
+
import numpy as np
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class EventEngine(Protocol):
|
|
23
|
+
def add_event_handler(self, event_name: Events, handler: Callable[..., None]) -> object: ...
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
SCHEMA_VERSION = "medmlx.segresnet.mlx.v1"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class WindowTelemetry:
|
|
30
|
+
"""Atomic JSON progress record for sliding-window inference, times in seconds.
|
|
31
|
+
|
|
32
|
+
``record`` is updated in place and written to ``path`` (parent directories are
|
|
33
|
+
created). While running it holds window counts and totals, refreshed after the
|
|
34
|
+
first window and then every 32nd. The raw ``window_seconds`` samples and their
|
|
35
|
+
p50/p95 are written only when the session finishes: samples stay in memory so the
|
|
36
|
+
growing list is not rewritten per window.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self, path: Path, record: dict[str, Json]) -> None:
|
|
40
|
+
self.path = path
|
|
41
|
+
self.record = record
|
|
42
|
+
self._metadata = {"schema_version": SCHEMA_VERSION, **record}
|
|
43
|
+
self.timings: list[float] = []
|
|
44
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
45
|
+
self.begin()
|
|
46
|
+
|
|
47
|
+
def set_metadata(self, values: dict[str, Json]) -> None:
|
|
48
|
+
"""Merge run-level metadata that is kept across ``begin`` calls."""
|
|
49
|
+
self._metadata.update(values)
|
|
50
|
+
self.record.update(values)
|
|
51
|
+
self._write()
|
|
52
|
+
|
|
53
|
+
def begin(self) -> None:
|
|
54
|
+
"""Discard samples and start a new running record (``__init__`` calls this)."""
|
|
55
|
+
self.timings = []
|
|
56
|
+
self.record.clear()
|
|
57
|
+
self.record.update(self._metadata)
|
|
58
|
+
self.record.update(
|
|
59
|
+
status="running",
|
|
60
|
+
window_count=0,
|
|
61
|
+
window_total_seconds=0.0,
|
|
62
|
+
window_max_seconds=None,
|
|
63
|
+
peak_mlx_bytes=None,
|
|
64
|
+
window_samples_complete=False,
|
|
65
|
+
)
|
|
66
|
+
self._write()
|
|
67
|
+
|
|
68
|
+
def observe(self, seconds: float, peak_bytes: int | None) -> None:
|
|
69
|
+
"""Add one window's duration (seconds) and the MLX peak memory so far (bytes).
|
|
70
|
+
|
|
71
|
+
Raises:
|
|
72
|
+
RuntimeError: If the session is closed.
|
|
73
|
+
ValueError: If ``seconds`` is negative or not finite.
|
|
74
|
+
"""
|
|
75
|
+
if self.record["status"] != "running":
|
|
76
|
+
raise RuntimeError("SegResNet telemetry is closed; begin a new session")
|
|
77
|
+
if not math.isfinite(seconds) or seconds < 0:
|
|
78
|
+
raise ValueError("Window duration must be finite and nonnegative")
|
|
79
|
+
self.timings.append(seconds)
|
|
80
|
+
self.record["window_count"] = len(self.timings)
|
|
81
|
+
self.record["window_total_seconds"] = (
|
|
82
|
+
cast(float, self.record["window_total_seconds"]) + seconds
|
|
83
|
+
)
|
|
84
|
+
self.record["window_max_seconds"] = max(
|
|
85
|
+
cast(float | None, self.record["window_max_seconds"]) or 0, seconds
|
|
86
|
+
)
|
|
87
|
+
if peak_bytes is not None:
|
|
88
|
+
self.record["peak_mlx_bytes"] = max(
|
|
89
|
+
cast(int | None, self.record["peak_mlx_bytes"]) or 0, peak_bytes
|
|
90
|
+
)
|
|
91
|
+
# Constant-size snapshots. Quantiles and full raw samples are final-only.
|
|
92
|
+
if len(self.timings) == 1 or len(self.timings) % 32 == 0:
|
|
93
|
+
self._write()
|
|
94
|
+
|
|
95
|
+
def finish(self, status: Literal["completed", "failed", "interrupted"] = "completed") -> None:
|
|
96
|
+
"""Close the record with ``status`` and write the raw samples and quantiles.
|
|
97
|
+
|
|
98
|
+
Does nothing if the record already has that status or has failed, or if a
|
|
99
|
+
completed finish follows an interruption.
|
|
100
|
+
"""
|
|
101
|
+
previous = self.record["status"]
|
|
102
|
+
if previous in {status, "failed"} or (previous == "interrupted" and status == "completed"):
|
|
103
|
+
return
|
|
104
|
+
p50, p95 = np.percentile(self.timings, [50, 95]) if self.timings else (None, None)
|
|
105
|
+
self.record.update(
|
|
106
|
+
status=status,
|
|
107
|
+
window_seconds=list(self.timings),
|
|
108
|
+
window_samples_complete=True,
|
|
109
|
+
window_p50_seconds=None if p50 is None else float(p50),
|
|
110
|
+
window_p95_seconds=None if p95 is None else float(p95),
|
|
111
|
+
)
|
|
112
|
+
self._write()
|
|
113
|
+
|
|
114
|
+
def fail(self, error: BaseException) -> None:
|
|
115
|
+
"""Record ``error``'s type and finish as failed, without masking ``error``."""
|
|
116
|
+
self.record["error_type"] = type(error).__name__
|
|
117
|
+
try:
|
|
118
|
+
self.finish("failed")
|
|
119
|
+
except Exception as record_error:
|
|
120
|
+
# An unavailable output directory must not replace the inference error.
|
|
121
|
+
error.add_note(f"Could not finalize SegResNet telemetry: {record_error!r}")
|
|
122
|
+
|
|
123
|
+
@contextmanager
|
|
124
|
+
def session(self) -> Generator[None]:
|
|
125
|
+
"""Use around direct inferer calls that are not owned by a MONAI engine."""
|
|
126
|
+
self.begin()
|
|
127
|
+
try:
|
|
128
|
+
yield
|
|
129
|
+
self.finish()
|
|
130
|
+
except BaseException as error:
|
|
131
|
+
self.fail(error)
|
|
132
|
+
raise
|
|
133
|
+
|
|
134
|
+
def attach(self, engine: object, *, events: type[Events]) -> None:
|
|
135
|
+
"""Attach caller-provided engine events before handlers that re-raise failures."""
|
|
136
|
+
attached = cast(EventEngine, engine)
|
|
137
|
+
attached.add_event_handler(events.STARTED, self._started)
|
|
138
|
+
attached.add_event_handler(events.COMPLETED, self._completed)
|
|
139
|
+
attached.add_event_handler(events.EXCEPTION_RAISED, self._failed)
|
|
140
|
+
for event in (events.TERMINATE, events.TERMINATE_SINGLE_EPOCH, events.INTERRUPT):
|
|
141
|
+
attached.add_event_handler(event, self._interrupted)
|
|
142
|
+
|
|
143
|
+
def _started(self, engine: object) -> None:
|
|
144
|
+
self.begin()
|
|
145
|
+
|
|
146
|
+
def _completed(self, engine: object) -> None:
|
|
147
|
+
self.finish()
|
|
148
|
+
|
|
149
|
+
def _failed(self, engine: object, error: BaseException) -> None:
|
|
150
|
+
self.fail(error)
|
|
151
|
+
# Registering EXCEPTION_RAISED otherwise makes Ignite swallow the error.
|
|
152
|
+
raise error
|
|
153
|
+
|
|
154
|
+
def _interrupted(self, engine: object, **kwargs: object) -> None:
|
|
155
|
+
self.finish("interrupted")
|
|
156
|
+
|
|
157
|
+
def _write(self) -> None:
|
|
158
|
+
# Atomic visibility for progress readers, not a crash-durability promise.
|
|
159
|
+
descriptor, name = tempfile.mkstemp(prefix=f".{self.path.name}.", dir=self.path.parent)
|
|
160
|
+
try:
|
|
161
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
|
162
|
+
json.dump(self.record, handle, indent=2, allow_nan=False)
|
|
163
|
+
handle.write("\n")
|
|
164
|
+
os.replace(name, self.path)
|
|
165
|
+
finally:
|
|
166
|
+
Path(name).unlink(missing_ok=True)
|