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
|
@@ -0,0 +1,669 @@
|
|
|
1
|
+
"""NumPy ports of the MONAI 1.6 transforms used by the SegResNet bundles.
|
|
2
|
+
|
|
3
|
+
These functions reproduce the renal CECT, BraTS MRI and whole-body CT bundles'
|
|
4
|
+
``preprocessing`` and ``postprocessing`` without Torch or MONAI: ``LoadImage``
|
|
5
|
+
(nibabel reader), ``Orientation``, ``Spacing`` and its inverse, ``ScaleIntensityRange``,
|
|
6
|
+
``ScaleIntensity``, ``NormalizeIntensity``, ``Activations``, ``AsDiscrete``, the renal
|
|
7
|
+
bundle's ``MergeClassesd`` and the NIfTI writer. Spatial resampling follows MONAI's
|
|
8
|
+
``AffineTransform`` path, that is ``torch.nn.functional.affine_grid`` and trilinear
|
|
9
|
+
``grid_sample`` with border padding and ``align_corners=False``, computed in float64
|
|
10
|
+
as ``Spacing`` does and returned as float32.
|
|
11
|
+
|
|
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.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from collections.abc import Sequence
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from fractions import Fraction
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import cast
|
|
23
|
+
|
|
24
|
+
import numpy as np
|
|
25
|
+
import numpy.typing as npt
|
|
26
|
+
|
|
27
|
+
from segresnet_mlx._arrays import HostArray, Int64Array
|
|
28
|
+
from segresnet_mlx._nifti import NiftiImage, nib
|
|
29
|
+
|
|
30
|
+
type Float32Array = npt.NDArray[np.float32]
|
|
31
|
+
type Float64Array = npt.NDArray[np.float64]
|
|
32
|
+
|
|
33
|
+
# monai.data.utils.AFFINE_TOL
|
|
34
|
+
AFFINE_TOL = 1e-3
|
|
35
|
+
_LABELS = (("L", "R"), ("P", "A"), ("I", "S"))
|
|
36
|
+
# Bound the float64 corner arrays of one resampling chunk.
|
|
37
|
+
_CHUNK_VOXELS = 1 << 20
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True, slots=True)
|
|
41
|
+
class Orientation:
|
|
42
|
+
"""What ``Orientation`` records for its inverse: the affine it started from."""
|
|
43
|
+
|
|
44
|
+
original_affine: Float64Array
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True, slots=True)
|
|
48
|
+
class Resampling:
|
|
49
|
+
"""What ``SpatialResample`` records for its inverse."""
|
|
50
|
+
|
|
51
|
+
src_affine: Float64Array
|
|
52
|
+
orig_size: tuple[int, ...]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def image_arrays(image: NiftiImage) -> tuple[Float32Array, Float64Array]:
|
|
56
|
+
"""MONAI's nibabel reader: correct the 3D header, then export float32 voxels."""
|
|
57
|
+
correct_nifti_header(image)
|
|
58
|
+
data = np.asanyarray(image.dataobj, order="C").astype(np.float32, copy=False)
|
|
59
|
+
return data, np.array(image.affine, dtype=np.float64, copy=True)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def correct_nifti_header(image: NiftiImage) -> None:
|
|
63
|
+
"""Apply MONAI's spacing/form correction to a loaded 3D NIfTI, in memory."""
|
|
64
|
+
zooms = np.asarray(image.header.get_zooms())[:3]
|
|
65
|
+
if np.allclose(zooms, affine_to_spacing(image.affine, 3)):
|
|
66
|
+
return
|
|
67
|
+
sform, qform = image.get_sform(), image.get_qform()
|
|
68
|
+
sform_matches = np.allclose(affine_to_spacing(sform, 3), zooms)
|
|
69
|
+
qform_matches = np.allclose(affine_to_spacing(qform, 3), zooms)
|
|
70
|
+
if image.header["sform_code"] != 0:
|
|
71
|
+
if sform_matches:
|
|
72
|
+
return
|
|
73
|
+
if qform_matches:
|
|
74
|
+
image.set_sform(qform)
|
|
75
|
+
return
|
|
76
|
+
if image.header["qform_code"] != 0:
|
|
77
|
+
if qform_matches:
|
|
78
|
+
return
|
|
79
|
+
if sform_matches:
|
|
80
|
+
image.set_qform(sform)
|
|
81
|
+
return
|
|
82
|
+
image.header.set_zooms(affine_to_spacing(image.affine, 3))
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def to_affine_nd(rank: int, affine: npt.ArrayLike) -> Float64Array:
|
|
86
|
+
"""``monai.data.utils.to_affine_nd`` for an integer rank."""
|
|
87
|
+
source = np.array(affine, dtype=np.float64, copy=True)
|
|
88
|
+
result = np.eye(rank + 1, dtype=np.float64)
|
|
89
|
+
size = max(min(len(result) - 1, len(source) - 1), 1)
|
|
90
|
+
result[:size, :size] = source[:size, :size]
|
|
91
|
+
if size > 1:
|
|
92
|
+
result[:size, -1] = source[:size, -1]
|
|
93
|
+
return result
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def affine_to_spacing(affine: Float64Array, rank: int) -> Float64Array:
|
|
97
|
+
"""``monai.data.utils.affine_to_spacing`` with zeros suppressed to one."""
|
|
98
|
+
part = np.asarray(affine[:rank, :rank], dtype=np.float64)
|
|
99
|
+
spacing = np.sqrt(np.sum(part * part, axis=0))
|
|
100
|
+
spacing[spacing == 0] = 1.0
|
|
101
|
+
return spacing
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def zoom_affine(affine: Float64Array, scale: Float64Array) -> Float64Array:
|
|
105
|
+
"""``monai.data.utils.zoom_affine`` with ``diagonal=False``."""
|
|
106
|
+
affine = np.array(affine, dtype=float, copy=True)
|
|
107
|
+
scale_np = np.array(scale, dtype=float, copy=True)
|
|
108
|
+
size = len(affine) - 1
|
|
109
|
+
norm = affine_to_spacing(affine, size)
|
|
110
|
+
if len(scale_np) < size:
|
|
111
|
+
scale_np = np.append(scale_np, norm[len(scale_np) :])
|
|
112
|
+
scale_np = scale_np[:size]
|
|
113
|
+
scale_np = np.where(scale_np > 0, scale_np, norm)
|
|
114
|
+
scale_np[scale_np == 0] = 1.0
|
|
115
|
+
rzs = affine[:-1, :-1]
|
|
116
|
+
zs = np.linalg.cholesky(rzs.T @ rzs).T
|
|
117
|
+
rotation = rzs @ np.linalg.inv(zs)
|
|
118
|
+
signed = np.sign(np.diag(zs)) * np.abs(scale_np)
|
|
119
|
+
result = np.eye(len(affine))
|
|
120
|
+
result[:-1, :-1] = rotation @ np.diag(signed)
|
|
121
|
+
return result
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def compute_shape_offset(
|
|
125
|
+
spatial_shape: Sequence[int],
|
|
126
|
+
in_affine: Float64Array,
|
|
127
|
+
out_affine: Float64Array,
|
|
128
|
+
) -> tuple[npt.NDArray[np.int_], Float64Array]:
|
|
129
|
+
"""``monai.data.utils.compute_shape_offset`` with ``scale_extent=False``."""
|
|
130
|
+
shape = np.array(tuple(spatial_shape), copy=True, dtype=float)
|
|
131
|
+
rank = len(shape)
|
|
132
|
+
in_affine_ = to_affine_nd(rank, in_affine)
|
|
133
|
+
out_affine_ = to_affine_nd(rank, out_affine)
|
|
134
|
+
in_coords = [(0.0, dim - 1.0) for dim in shape]
|
|
135
|
+
corners: Float64Array = np.asarray(np.meshgrid(*in_coords, indexing="ij")).reshape((rank, -1))
|
|
136
|
+
corners = np.concatenate((corners, np.ones_like(corners[:1])))
|
|
137
|
+
corners_out = np.linalg.solve(out_affine_, in_affine_) @ corners
|
|
138
|
+
corners = in_affine_ @ corners
|
|
139
|
+
all_dist = corners_out[:-1].copy()
|
|
140
|
+
corners_out = corners_out[:-1] / corners_out[-1]
|
|
141
|
+
out_shape = np.round(cast(Float64Array, np.ptp(corners_out, axis=1)) + 1.0)
|
|
142
|
+
offset = None
|
|
143
|
+
for index in range(corners.shape[1]):
|
|
144
|
+
min_corner = np.min(all_dist - all_dist[:, index : index + 1], 1)
|
|
145
|
+
if np.allclose(min_corner, 0.0, rtol=AFFINE_TOL):
|
|
146
|
+
offset = corners[:-1, index]
|
|
147
|
+
break
|
|
148
|
+
if offset is None:
|
|
149
|
+
offset = (
|
|
150
|
+
in_affine_[:-1, :-1] @ (shape / 2.0)
|
|
151
|
+
+ in_affine_[:-1, -1]
|
|
152
|
+
- out_affine_[:-1, :-1] @ (out_shape / 2.0)
|
|
153
|
+
)
|
|
154
|
+
return out_shape.astype(int, copy=False), offset
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def orient(
|
|
158
|
+
data: Float32Array,
|
|
159
|
+
affine: Float64Array,
|
|
160
|
+
axcodes: str,
|
|
161
|
+
) -> tuple[Float32Array, Float64Array, Orientation]:
|
|
162
|
+
"""``Orientation(axcodes)`` on channel-first data; returns data, new affine, inverse."""
|
|
163
|
+
rank = data.ndim - 1
|
|
164
|
+
affine_np = np.array(affine, dtype=np.float64, copy=True)
|
|
165
|
+
affine_ = to_affine_nd(rank, affine_np)
|
|
166
|
+
source = nib.io_orientation(affine_)
|
|
167
|
+
target = nib.orientations.axcodes2ornt(axcodes[:rank], labels=_LABELS)
|
|
168
|
+
spatial_ornt = nib.orientations.ornt_transform(source, target)
|
|
169
|
+
xform = nib.orientations.inv_ornt_aff(spatial_ornt, data.shape[1:])
|
|
170
|
+
spatial_ornt[:, 0] += 1
|
|
171
|
+
spatial_ornt = np.concatenate([np.array([[0, 1]]), spatial_ornt])
|
|
172
|
+
axes = [axis for axis, flip in enumerate(spatial_ornt[:, 1]) if flip == -1]
|
|
173
|
+
full_transpose = np.arange(data.ndim)
|
|
174
|
+
full_transpose[: len(spatial_ornt)] = np.argsort(spatial_ornt[:, 0])
|
|
175
|
+
result = np.flip(data, tuple(axes)) if axes else data
|
|
176
|
+
if not np.all(full_transpose == np.arange(data.ndim)):
|
|
177
|
+
result = result.transpose(full_transpose)
|
|
178
|
+
new_affine = _mm(affine_np, to_affine_nd(len(affine_np) - 1, xform))
|
|
179
|
+
return np.ascontiguousarray(result), new_affine, Orientation(affine_np)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def invert_orientation(
|
|
183
|
+
data: Float32Array,
|
|
184
|
+
affine: Float64Array,
|
|
185
|
+
record: Orientation,
|
|
186
|
+
) -> tuple[Float32Array, Float64Array]:
|
|
187
|
+
"""``Orientation.inverse``: reorient to the recorded original axis codes."""
|
|
188
|
+
axcodes = "".join(
|
|
189
|
+
cast(tuple[str, ...], nib.orientations.aff2axcodes(record.original_affine, labels=_LABELS))
|
|
190
|
+
)
|
|
191
|
+
result, new_affine, _ = orient(data, affine, axcodes)
|
|
192
|
+
return result, new_affine
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def spacing(
|
|
196
|
+
data: Float32Array,
|
|
197
|
+
affine: Float64Array,
|
|
198
|
+
pixdim: Sequence[float],
|
|
199
|
+
output_spatial_shape: Sequence[int] | None = None,
|
|
200
|
+
) -> tuple[Float32Array, Float64Array, Resampling]:
|
|
201
|
+
"""``Spacing(pixdim, mode="bilinear")`` with MONAI defaults (border, float64)."""
|
|
202
|
+
rank = data.ndim - 1
|
|
203
|
+
affine_ = to_affine_nd(rank, affine)
|
|
204
|
+
out_d = np.array(tuple(pixdim), dtype=np.float64)[:rank].copy()
|
|
205
|
+
if out_d.size < rank:
|
|
206
|
+
out_d = np.append(out_d, [out_d[-1]] * (rank - out_d.size))
|
|
207
|
+
original = affine_to_spacing(affine_, rank)
|
|
208
|
+
for index, current in enumerate(original):
|
|
209
|
+
target = out_d[index]
|
|
210
|
+
keep = (target - AFFINE_TOL) <= current <= (target + AFFINE_TOL)
|
|
211
|
+
out_d[index] = current if keep else target
|
|
212
|
+
new_affine = zoom_affine(affine_, out_d)
|
|
213
|
+
output_shape, offset = compute_shape_offset(data.shape[1:], affine_, new_affine)
|
|
214
|
+
new_affine[:rank, -1] = offset[:rank]
|
|
215
|
+
shape = list(output_shape) if output_spatial_shape is None else list(output_spatial_shape)
|
|
216
|
+
return spatial_resample(data, affine, new_affine, shape)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def invert_spacing(
|
|
220
|
+
data: Float32Array,
|
|
221
|
+
affine: Float64Array,
|
|
222
|
+
record: Resampling,
|
|
223
|
+
mode: str = "bilinear",
|
|
224
|
+
) -> tuple[Float32Array, Float64Array]:
|
|
225
|
+
"""``SpatialResample.inverse``: resample back to the recorded affine and size.
|
|
226
|
+
|
|
227
|
+
``mode="nearest"`` matches ``Invertd(nearest_interp=True)``, used to invert a
|
|
228
|
+
discrete label map (e.g. the whole-body bundle's argmax output).
|
|
229
|
+
"""
|
|
230
|
+
result, new_affine, _ = spatial_resample(
|
|
231
|
+
data, affine, record.src_affine, record.orig_size, mode
|
|
232
|
+
)
|
|
233
|
+
return result, new_affine
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def spatial_resample(
|
|
237
|
+
data: Float32Array,
|
|
238
|
+
src_affine: Float64Array,
|
|
239
|
+
dst_affine: Float64Array,
|
|
240
|
+
spatial_size: Sequence[int],
|
|
241
|
+
mode: str = "bilinear",
|
|
242
|
+
) -> tuple[Float32Array, Float64Array, Resampling]:
|
|
243
|
+
"""``monai.transforms.spatial.functional.spatial_resample`` (border, float64).
|
|
244
|
+
|
|
245
|
+
``mode`` is ``"bilinear"`` (trilinear in 3D) or ``"nearest"``.
|
|
246
|
+
"""
|
|
247
|
+
rank = min(data.ndim - 1, len(src_affine) - 1, 3, len(spatial_size))
|
|
248
|
+
source = to_affine_nd(rank, src_affine)
|
|
249
|
+
target = to_affine_nd(rank, dst_affine)
|
|
250
|
+
in_size = tuple(int(size) for size in data.shape[1 : rank + 1])
|
|
251
|
+
size = tuple(int(value) for value in spatial_size[:rank])
|
|
252
|
+
record = Resampling(source, in_size)
|
|
253
|
+
xform = np.linalg.solve(source, target)
|
|
254
|
+
same_size = np.allclose(size, in_size)
|
|
255
|
+
unchanged = (np.allclose(source, target, atol=AFFINE_TOL) and same_size) or (
|
|
256
|
+
np.allclose(xform, np.eye(len(xform)), atol=AFFINE_TOL) and same_size
|
|
257
|
+
)
|
|
258
|
+
if unchanged:
|
|
259
|
+
return data.astype(np.float32), np.array(src_affine, dtype=np.float64), record
|
|
260
|
+
values = _grid_sample(data, xform, in_size, size, mode)
|
|
261
|
+
affine = np.array(src_affine, dtype=np.float64)
|
|
262
|
+
new_affine = _mm(affine, to_affine_nd(len(affine) - 1, xform))
|
|
263
|
+
return values, new_affine, record
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def concatenate(channels: Sequence[Float32Array]) -> Float32Array:
|
|
267
|
+
"""The renal bundle's ``ConcatImages`` (``np.concatenate`` along channels)."""
|
|
268
|
+
return np.concatenate(list(channels))
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def scale_intensity_range(
|
|
272
|
+
data: Float32Array,
|
|
273
|
+
a_min: float,
|
|
274
|
+
a_max: float,
|
|
275
|
+
b_min: float,
|
|
276
|
+
b_max: float,
|
|
277
|
+
) -> Float32Array:
|
|
278
|
+
"""``ScaleIntensityRange(..., clip=True)`` in float32."""
|
|
279
|
+
result = (data - np.float32(a_min)) / np.float32(a_max - a_min)
|
|
280
|
+
result = result * np.float32(b_max - b_min) + np.float32(b_min)
|
|
281
|
+
return np.clip(result, np.float32(b_min), np.float32(b_max)).astype(np.float32, copy=False)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def normalize_intensity_nonzero(data: Float32Array) -> Float32Array:
|
|
285
|
+
"""``NormalizeIntensity(nonzero=True, channel_wise=True)`` on channel-first data.
|
|
286
|
+
|
|
287
|
+
Torch takes the float32 sum of the nonzero voxels divided in float32, and the
|
|
288
|
+
population standard deviation around that mean accumulated in float64. The
|
|
289
|
+
sums here use NumPy's float64 order, so the statistics can differ from Torch's
|
|
290
|
+
in the last float32 bit.
|
|
291
|
+
"""
|
|
292
|
+
result = np.array(data, dtype=np.float32, copy=True)
|
|
293
|
+
for channel in result:
|
|
294
|
+
selected = channel != 0
|
|
295
|
+
if not selected.any():
|
|
296
|
+
continue
|
|
297
|
+
masked = channel[selected]
|
|
298
|
+
total = np.float32(np.sum(masked, dtype=np.float64))
|
|
299
|
+
mean = np.float32(total / np.float32(masked.size))
|
|
300
|
+
deviation = masked.astype(np.float64) - np.float64(mean)
|
|
301
|
+
std = np.float32(np.sqrt(cast(np.float64, np.sum(deviation * deviation)) / masked.size))
|
|
302
|
+
divisor = np.float32(1.0) if std == 0.0 else std
|
|
303
|
+
channel[selected] = (masked - mean) / divisor
|
|
304
|
+
return result
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def scale_intensity_minmax(data: Float32Array, minv: float, maxv: float) -> Float32Array:
|
|
308
|
+
"""``ScaleIntensity(minv, maxv)``: ``monai.transforms.utils.rescale_array`` in float32."""
|
|
309
|
+
arr = np.asarray(data, dtype=np.float32)
|
|
310
|
+
mina = arr.min()
|
|
311
|
+
maxa = arr.max()
|
|
312
|
+
if mina == maxa:
|
|
313
|
+
return (arr * np.float32(minv)).astype(np.float32, copy=False)
|
|
314
|
+
norm = (arr - mina) / (maxa - mina)
|
|
315
|
+
return (norm * np.float32(maxv - minv) + np.float32(minv)).astype(np.float32, copy=False)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def softmax_channel(data: Float32Array) -> Float32Array:
|
|
319
|
+
"""``Activations(softmax=True)``: ``torch.softmax(img, dim=0)`` in float32."""
|
|
320
|
+
shifted = data - data.max(axis=0, keepdims=True)
|
|
321
|
+
exponentiated = np.exp(shifted)
|
|
322
|
+
return (exponentiated / exponentiated.sum(axis=0, keepdims=True)).astype(
|
|
323
|
+
np.float32, copy=False
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def argmax_channel(data: Float32Array) -> Float32Array:
|
|
328
|
+
"""``AsDiscrete(argmax=True)``: ``torch.argmax(img, dim=0, keepdim=True)`` as float32."""
|
|
329
|
+
return np.argmax(data, axis=0)[None].astype(np.float32, copy=False)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def sigmoid(data: Float32Array) -> Float32Array:
|
|
333
|
+
"""Float32 sigmoid with a stable negative tail and source threshold rounding."""
|
|
334
|
+
one = np.float32(1.0)
|
|
335
|
+
result = np.empty_like(data)
|
|
336
|
+
negative_tail = data < np.float32(-1.0)
|
|
337
|
+
tail = np.exp(data[negative_tail])
|
|
338
|
+
result[negative_tail] = tail / (one + tail)
|
|
339
|
+
# Keep the original expression near zero: equivalent sigmoid formulas can
|
|
340
|
+
# round to opposite sides of the bundle's >= 0.5 threshold there.
|
|
341
|
+
result[~negative_tail] = one / (one + np.exp(-data[~negative_tail]))
|
|
342
|
+
return result
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def threshold(data: Float32Array, value: float = 0.5) -> Float32Array:
|
|
346
|
+
"""``AsDiscrete(threshold=value)``: ``x >= value`` as float32."""
|
|
347
|
+
return (data >= value).astype(np.float32)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def merge_classes(binary: Float32Array) -> Float32Array:
|
|
351
|
+
"""The renal bundle's ``MergeClassesd``: the lowest nonzero channel index wins.
|
|
352
|
+
|
|
353
|
+
Returns a float32 ``(1, *spatial)`` label map; channel 0 contributes nothing.
|
|
354
|
+
"""
|
|
355
|
+
weighted = binary * np.arange(binary.shape[0], dtype=np.float32).reshape(
|
|
356
|
+
-1, *([1] * (binary.ndim - 1))
|
|
357
|
+
)
|
|
358
|
+
merged = weighted[0]
|
|
359
|
+
for channel in weighted[1:]:
|
|
360
|
+
merged = merged + channel * ~((merged != 0) & (channel != 0))
|
|
361
|
+
return merged[None].astype(np.float32, copy=False)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def brats_labels(binary: Float32Array) -> npt.NDArray[np.int64]:
|
|
365
|
+
"""BraTS bundle ``Lambdad``: ET (channel 2) -> 4, TC (0) -> 1, WT (1) -> 2.
|
|
366
|
+
|
|
367
|
+
``binary`` is ``(3, *spatial)`` with channels (TC, WT, ET). Returns int64
|
|
368
|
+
``(1, *spatial)`` with labels 0, 1, 2 and 4.
|
|
369
|
+
"""
|
|
370
|
+
return np.where(
|
|
371
|
+
binary[[2]] > 0, 4, np.where(binary[[0]] > 0, 1, np.where(binary[[1]] > 0, 2, 0))
|
|
372
|
+
).astype(np.int64)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
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
|
+
array = np.asarray(data).astype(dtype, copy=False)
|
|
378
|
+
matrix = to_affine_nd(3, affine)
|
|
379
|
+
image = nib.Nifti1Image(array, matrix)
|
|
380
|
+
image.set_sform(matrix, code=1)
|
|
381
|
+
image.set_qform(matrix, code=1)
|
|
382
|
+
nib.save(image, str(path))
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _mm(left: Float64Array, right: Float64Array) -> Float64Array:
|
|
386
|
+
"""A MetaTensor affine update, ``torch.mm`` of two 4x4 float64 matrices.
|
|
387
|
+
|
|
388
|
+
The reference Torch build's MKL computes each entry as
|
|
389
|
+
``fma(a2, b2, a0 * b0) + fma(a3, b3, a1 * b1)``.
|
|
390
|
+
"""
|
|
391
|
+
if left.shape != (4, 4) or right.shape != (4, 4):
|
|
392
|
+
return left @ right
|
|
393
|
+
result = np.empty((4, 4), dtype=np.float64)
|
|
394
|
+
for row in range(4):
|
|
395
|
+
a = [float(value) for value in left[row]]
|
|
396
|
+
for column in range(4):
|
|
397
|
+
b = [float(value) for value in right[:, column]]
|
|
398
|
+
result[row, column] = _fma(a[2], b[2], a[0] * b[0]) + _fma(a[3], b[3], a[1] * b[1])
|
|
399
|
+
return result
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _bmm(left: Float64Array, right: Float64Array) -> Float64Array:
|
|
403
|
+
"""Torch's small-matrix ``bmm`` kernel: unfused sequential float64 accumulation."""
|
|
404
|
+
rows, inner = left.shape
|
|
405
|
+
columns = right.shape[1]
|
|
406
|
+
result = np.empty((rows, columns), dtype=np.float64)
|
|
407
|
+
for row in range(rows):
|
|
408
|
+
for column in range(columns):
|
|
409
|
+
total = 0.0
|
|
410
|
+
for index in range(inner):
|
|
411
|
+
total += float(left[row, index]) * float(right[index, column])
|
|
412
|
+
result[row, column] = total
|
|
413
|
+
return result
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _normalize_transform(shape: Sequence[int]) -> Float64Array:
|
|
417
|
+
"""``monai.networks.utils.normalize_transform`` (``align_corners=False``)."""
|
|
418
|
+
size = np.asarray(shape, dtype=np.float64)
|
|
419
|
+
norm = size.copy()
|
|
420
|
+
norm[norm <= 0.0] = 2.0
|
|
421
|
+
norm = 2.0 / norm
|
|
422
|
+
result = np.diag(np.concatenate((norm, np.ones(1, dtype=np.float64))))
|
|
423
|
+
result[:-1, -1] = 1.0 / size - 1.0
|
|
424
|
+
return result
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _fma(left: float, right: float, addend: float) -> float:
|
|
428
|
+
"""Correctly rounded ``left * right + addend``."""
|
|
429
|
+
return float(Fraction(left) * Fraction(right) + Fraction(addend))
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _linspace_from_neg_one(steps: int) -> Float64Array:
|
|
433
|
+
"""ATen ``linspace(-1, 1, steps) * (steps - 1) / steps`` in float64.
|
|
434
|
+
|
|
435
|
+
ATen evaluates ``start + step * i`` below the midpoint and
|
|
436
|
+
``end - step * (steps - i - 1)`` above it, each as one fused multiply-add.
|
|
437
|
+
"""
|
|
438
|
+
if steps <= 1:
|
|
439
|
+
return np.zeros(1, dtype=np.float64)
|
|
440
|
+
step = (1.0 - (-1.0)) / (steps - 1)
|
|
441
|
+
halfway = steps // 2
|
|
442
|
+
values = np.array(
|
|
443
|
+
[
|
|
444
|
+
_fma(step, index, -1.0) if index < halfway else _fma(-step, steps - index - 1, 1.0)
|
|
445
|
+
for index in range(steps)
|
|
446
|
+
],
|
|
447
|
+
dtype=np.float64,
|
|
448
|
+
)
|
|
449
|
+
return values * (steps - 1) / steps
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _theta(
|
|
453
|
+
xform: Float64Array,
|
|
454
|
+
in_size: Sequence[int],
|
|
455
|
+
out_size: Sequence[int],
|
|
456
|
+
) -> Float64Array:
|
|
457
|
+
"""``AffineTransform`` normalization and reverse indexing of a voxel transform."""
|
|
458
|
+
theta = _bmm(
|
|
459
|
+
_bmm(_normalize_transform(in_size), xform), np.linalg.inv(_normalize_transform(out_size))
|
|
460
|
+
)
|
|
461
|
+
rank = len(in_size)
|
|
462
|
+
reverse = list(range(rank - 1, -1, -1))
|
|
463
|
+
theta[:rank] = theta[reverse]
|
|
464
|
+
theta[:, :rank] = theta[:, reverse]
|
|
465
|
+
return theta[:rank]
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def _grid_sample(
|
|
469
|
+
data: Float32Array,
|
|
470
|
+
xform: Float64Array,
|
|
471
|
+
in_size: Sequence[int],
|
|
472
|
+
out_size: Sequence[int],
|
|
473
|
+
mode: str = "bilinear",
|
|
474
|
+
) -> Float32Array:
|
|
475
|
+
"""``affine_grid`` then ``grid_sample`` (border, ``align_corners=False``).
|
|
476
|
+
|
|
477
|
+
MONAI samples a float64 copy of the image and rounds the result to float32; here
|
|
478
|
+
each output chunk is computed in float64 from the gathered float32 voxels, which
|
|
479
|
+
gives the same values without a float64 copy of the whole image.
|
|
480
|
+
The grid's four-term dot products follow the reference Torch build's MKL order,
|
|
481
|
+
``fma(z, t2, x * t0) + fma(1, t3, y * t1)``; with zeros off the diagonal every
|
|
482
|
+
order gives the same coordinates. ``mode="nearest"`` matches
|
|
483
|
+
``grid_sample(mode="nearest")``: the single closest voxel, rounded to even.
|
|
484
|
+
"""
|
|
485
|
+
if len(in_size) != 3:
|
|
486
|
+
raise ValueError("SegResNet bundle resampling is three-dimensional")
|
|
487
|
+
if mode not in ("bilinear", "nearest"):
|
|
488
|
+
raise ValueError(f"Unsupported grid_sample mode: {mode}")
|
|
489
|
+
theta = _theta(xform, in_size, out_size)
|
|
490
|
+
depth, height, width = (int(size) for size in out_size)
|
|
491
|
+
# Base grid: x follows the last data axis, z the first.
|
|
492
|
+
x = np.broadcast_to(_linspace_from_neg_one(width), (width,))
|
|
493
|
+
y = np.broadcast_to(_linspace_from_neg_one(height), (height,))
|
|
494
|
+
z = np.broadcast_to(_linspace_from_neg_one(depth), (depth,))
|
|
495
|
+
# Per output row: the (z, x) part of the dot product and the y part.
|
|
496
|
+
zx = [_fma_array(z[:, None], theta[row, 2], x[None, :] * theta[row, 0]) for row in range(3)]
|
|
497
|
+
ys = [y * theta[row, 1] + theta[row, 3] for row in range(3)]
|
|
498
|
+
separable = all(
|
|
499
|
+
theta[row, column] == 0 for row in range(3) for column in range(3) if row != column
|
|
500
|
+
)
|
|
501
|
+
result = np.empty((data.shape[0], depth, height, width), dtype=np.float32)
|
|
502
|
+
step = max(1, _CHUNK_VOXELS // max(1, height * width * data.shape[0]))
|
|
503
|
+
separable_fn = _sample_separable if mode == "bilinear" else _sample_separable_nearest
|
|
504
|
+
general_fn = _sample if mode == "bilinear" else _sample_nearest
|
|
505
|
+
for start in range(0, depth, step):
|
|
506
|
+
stop = min(depth, start + step)
|
|
507
|
+
if separable:
|
|
508
|
+
gx = zx[0][0] + ys[0][0]
|
|
509
|
+
gy = zx[1][0, 0] + ys[1]
|
|
510
|
+
gz = zx[2][start:stop, 0] + ys[2][0]
|
|
511
|
+
result[:, start:stop] = separable_fn(data, gx, gy, gz)
|
|
512
|
+
else:
|
|
513
|
+
coordinates = [
|
|
514
|
+
zx[row][start:stop, None, :] + ys[row][None, :, None] for row in range(3)
|
|
515
|
+
]
|
|
516
|
+
result[:, start:stop] = general_fn(data, *coordinates)
|
|
517
|
+
return result
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def _fma_array(left: Float64Array, right: float, addend: Float64Array) -> Float64Array:
|
|
521
|
+
"""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)
|
|
524
|
+
product = left * right_value
|
|
525
|
+
if right_value == 0 or not np.any(left):
|
|
526
|
+
return product + addend
|
|
527
|
+
error = _product_error(left, right_value, product)
|
|
528
|
+
total = product + addend
|
|
529
|
+
virtual = total - product
|
|
530
|
+
sum_error = (product - (total - virtual)) + (addend - virtual)
|
|
531
|
+
return total + (sum_error + error)
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def _product_error(left: Float64Array, right: np.float64, product: Float64Array) -> Float64Array:
|
|
535
|
+
"""Dekker's exact remainder ``left * right - product``."""
|
|
536
|
+
|
|
537
|
+
def split[T: (Float64Array, np.float64)](value: T) -> tuple[T, T]:
|
|
538
|
+
scaled = 134217729.0 * value
|
|
539
|
+
high = scaled - (scaled - value)
|
|
540
|
+
return cast(T, high), cast(T, value - high)
|
|
541
|
+
|
|
542
|
+
left_high, left_low = split(left)
|
|
543
|
+
right_high, right_low = split(right)
|
|
544
|
+
return (
|
|
545
|
+
((left_high * right_high - product) + left_high * right_low) + left_low * right_high
|
|
546
|
+
) + left_low * right_low
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def _source_index(coordinate: Float64Array, size: int) -> Float64Array:
|
|
550
|
+
"""``grid_sampler_compute_source_index`` for border padding, ``align_corners=False``."""
|
|
551
|
+
unnormalized = ((coordinate + 1) * size - 1) / 2
|
|
552
|
+
return np.minimum(np.float64(size - 1), np.maximum(unnormalized, 0.0))
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def _corners(
|
|
556
|
+
index: Float64Array,
|
|
557
|
+
) -> tuple[tuple[Int64Array, Int64Array], tuple[Float64Array, Float64Array]]:
|
|
558
|
+
"""Lower/upper voxel indices and their ATen weights ``(upper - i, i - lower)``."""
|
|
559
|
+
low = np.floor(index)
|
|
560
|
+
high = low + 1
|
|
561
|
+
return (low.astype(np.int64), high.astype(np.int64)), (high - index, index - low)
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
# ATen's corner order (tnw, tne, tsw, tse, bnw, bne, bsw, bse) as (z, y, x) picks.
|
|
565
|
+
_CORNERS = (
|
|
566
|
+
(0, 0, 0),
|
|
567
|
+
(0, 0, 1),
|
|
568
|
+
(0, 1, 0),
|
|
569
|
+
(0, 1, 1),
|
|
570
|
+
(1, 0, 0),
|
|
571
|
+
(1, 0, 1),
|
|
572
|
+
(1, 1, 0),
|
|
573
|
+
(1, 1, 1),
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def _sample_separable(
|
|
578
|
+
data: Float32Array,
|
|
579
|
+
gx: Float64Array,
|
|
580
|
+
gy: Float64Array,
|
|
581
|
+
gz: Float64Array,
|
|
582
|
+
) -> Float64Array:
|
|
583
|
+
"""Trilinear corners for an axis-aligned grid, in ATen's weight and sum order."""
|
|
584
|
+
depth, height, width = data.shape[1:]
|
|
585
|
+
x_index, x_weight = _corners(_source_index(gx, width))
|
|
586
|
+
y_index, y_weight = _corners(_source_index(gy, height))
|
|
587
|
+
z_index, z_weight = _corners(_source_index(gz, depth))
|
|
588
|
+
along_d, along_h, along_w = (
|
|
589
|
+
(slice(None), None, None),
|
|
590
|
+
(None, slice(None), None),
|
|
591
|
+
(None, None, slice(None)),
|
|
592
|
+
)
|
|
593
|
+
result = np.zeros((data.shape[0], len(gz), len(gy), len(gx)), dtype=np.float64)
|
|
594
|
+
for cz, cy, cx in _CORNERS:
|
|
595
|
+
# (x * y) * z, left to right, as in grid_sampler_3d_cpu_impl.
|
|
596
|
+
weight = (x_weight[cx][along_w] * y_weight[cy][along_h]) * z_weight[cz][along_d]
|
|
597
|
+
zi, yi, xi = z_index[cz], y_index[cy], x_index[cx]
|
|
598
|
+
values = (
|
|
599
|
+
data.take(np.minimum(zi, depth - 1), axis=1)
|
|
600
|
+
.take(np.minimum(yi, height - 1), axis=2)
|
|
601
|
+
.take(np.minimum(xi, width - 1), axis=3)
|
|
602
|
+
.astype(np.float64)
|
|
603
|
+
)
|
|
604
|
+
inside = ((zi < depth)[along_d] & (yi < height)[along_h]) & (xi < width)[along_w]
|
|
605
|
+
result += np.where(inside, values * weight, 0.0)
|
|
606
|
+
return result
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def _sample(
|
|
610
|
+
data: Float32Array,
|
|
611
|
+
gx: Float64Array,
|
|
612
|
+
gy: Float64Array,
|
|
613
|
+
gz: Float64Array,
|
|
614
|
+
) -> Float64Array:
|
|
615
|
+
"""Trilinear corners for a general grid, in ATen's weight and sum order."""
|
|
616
|
+
depth, height, width = data.shape[1:]
|
|
617
|
+
x_index, x_weight = _corners(_source_index(gx, width))
|
|
618
|
+
y_index, y_weight = _corners(_source_index(gy, height))
|
|
619
|
+
z_index, z_weight = _corners(_source_index(gz, depth))
|
|
620
|
+
corners = [
|
|
621
|
+
(z_index[cz], y_index[cy], x_index[cx], (x_weight[cx] * y_weight[cy]) * z_weight[cz])
|
|
622
|
+
for cz, cy, cx in _CORNERS
|
|
623
|
+
]
|
|
624
|
+
shape = np.broadcast_shapes(gx.shape, gy.shape, gz.shape)
|
|
625
|
+
result = np.zeros((data.shape[0], *shape), dtype=np.float64)
|
|
626
|
+
for zi, yi, xi, weight in corners:
|
|
627
|
+
inside = (zi < depth) & (yi < height) & (xi < width)
|
|
628
|
+
values = data[
|
|
629
|
+
:,
|
|
630
|
+
np.minimum(zi, depth - 1),
|
|
631
|
+
np.minimum(yi, height - 1),
|
|
632
|
+
np.minimum(xi, width - 1),
|
|
633
|
+
].astype(np.float64)
|
|
634
|
+
result += np.where(inside, values * weight, 0.0)
|
|
635
|
+
return result
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def _nearest_index(coordinate: Float64Array, size: int) -> Int64Array:
|
|
639
|
+
"""``nearbyint`` of the border-clamped source index: round half to even."""
|
|
640
|
+
return np.rint(_source_index(coordinate, size)).astype(np.int64)
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def _sample_separable_nearest(
|
|
644
|
+
data: Float32Array,
|
|
645
|
+
gx: Float64Array,
|
|
646
|
+
gy: Float64Array,
|
|
647
|
+
gz: Float64Array,
|
|
648
|
+
) -> Float64Array:
|
|
649
|
+
"""Nearest-voxel pick for an axis-aligned grid: one index per axis."""
|
|
650
|
+
depth, height, width = data.shape[1:]
|
|
651
|
+
x_index = _nearest_index(gx, width)
|
|
652
|
+
y_index = _nearest_index(gy, height)
|
|
653
|
+
z_index = _nearest_index(gz, depth)
|
|
654
|
+
return data.take(z_index, axis=1).take(y_index, axis=2).take(x_index, axis=3).astype(
|
|
655
|
+
np.float64
|
|
656
|
+
)
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def _sample_nearest(
|
|
660
|
+
data: Float32Array,
|
|
661
|
+
gx: Float64Array,
|
|
662
|
+
gy: Float64Array,
|
|
663
|
+
gz: Float64Array,
|
|
664
|
+
) -> Float64Array:
|
|
665
|
+
"""Nearest-voxel pick for a general grid: one (z, y, x) index per output site."""
|
|
666
|
+
x_index = _nearest_index(gx, data.shape[3])
|
|
667
|
+
y_index = _nearest_index(gy, data.shape[2])
|
|
668
|
+
z_index = _nearest_index(gz, data.shape[1])
|
|
669
|
+
return data[:, z_index, y_index, x_index].astype(np.float64)
|