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.

@@ -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)