Skip to content

Commit 02201b8

Browse files
aymuos15ericspod
andauthored
fix(transforms): make Crop.compute_slices torch.compile-friendly (#8960)
`CenterSpatialCrop` blows up under `torch.compile` while the other crop transforms are fine (#8191). It fails in `Crop.compute_slices` with `The tensor has a non-zero number of elements, but its data is not allocated yet`. The cause is that `compute_slices` ran its start/end math through CPU tensors (`convert_to_tensor(..., device="cpu")`). For `CenterSpatialCrop` the ROI values come from the input shape, so under tracing they're fake tensors with no storage, and moving them to the CPU asks Dynamo for data that isn't there. Since it's just integer math, I moved it to plain Python. A small `_to_int_list` helper handles the input forms (scalar, sequence, tensor, ndarray), with the same clamping and broadcasting as before. `CenterSpatialCrop` now compiles like the rest of the transforms. Added a regression test that compiles `CenterSpatialCrop` and checks the shape (fails before, passes after), guarded for PyTorch versions with `torch.compile`. Fixes #8191. --------- Signed-off-by: Soumya Snigdha Kundu <soumya_snigdha.kundu@kcl.ac.uk> Signed-off-by: Soumya Snigdha Kundu <soumyawork15@gmail.com> Co-authored-by: Eric Kerfoot <17726042+ericspod@users.noreply.github.com>
1 parent 7c23098 commit 02201b8

2 files changed

Lines changed: 50 additions & 16 deletions

File tree

monai/transforms/croppad/array.py

Lines changed: 25 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,24 @@ def compute_pad_width(self, spatial_shape: Sequence[int]) -> tuple[tuple[int, in
342342
return spatial_pad.compute_pad_width(spatial_shape)
343343

344344

345+
def _to_int_list(data: Sequence[int] | int | NdarrayOrTensor) -> list[int]:
346+
"""Coerce an ROI spec (scalar, sequence, tensor or ndarray) to a list of Python ints."""
347+
if isinstance(data, (str, bytes)):
348+
raise TypeError("ROI specs must be integers or sequences of integers, not strings.")
349+
return [int(i) for i in ensure_tuple(data)]
350+
351+
352+
def _broadcast_int_pair(
353+
a: Sequence[int] | int | NdarrayOrTensor, b: Sequence[int] | int | NdarrayOrTensor
354+
) -> tuple[list[int], list[int]]:
355+
"""Coerce a pair of ROI specs to two equal-length int lists, broadcasting a scalar to match."""
356+
list_a, list_b = _to_int_list(a), _to_int_list(b)
357+
n = max(len(list_a), len(list_b))
358+
if len(list_a) not in (1, n) or len(list_b) not in (1, n):
359+
raise ValueError(f"ROI specs must have matching lengths or be scalar, got {len(list_a)} and {len(list_b)}.")
360+
return (list_a * n if len(list_a) == 1 else list_a), (list_b * n if len(list_b) == 1 else list_b)
361+
362+
345363
class Crop(InvertibleTransform, LazyTransform):
346364
"""
347365
Perform crop operations on the input image.
@@ -379,31 +397,22 @@ def compute_slices(
379397
roi_slices: list of slices for each of the spatial dimensions.
380398
381399
"""
382-
roi_start_t: torch.Tensor
383-
384400
if roi_slices:
385401
if not all(s.step is None or s.step == 1 for s in roi_slices):
386402
raise ValueError(f"only slice steps of 1/None are currently supported, got {roi_slices}.")
387403
return ensure_tuple(roi_slices)
388404
else:
389405
if roi_center is not None and roi_size is not None:
390-
roi_center_t = convert_to_tensor(data=roi_center, dtype=torch.int16, wrap_sequence=True, device="cpu")
391-
roi_size_t = convert_to_tensor(data=roi_size, dtype=torch.int16, wrap_sequence=True, device="cpu")
392-
_zeros = torch.zeros_like(roi_center_t)
393-
half = torch.divide(roi_size_t, 2, rounding_mode="floor")
394-
roi_start_t = torch.maximum(roi_center_t - half, _zeros)
395-
roi_end_t = torch.maximum(roi_start_t + roi_size_t, roi_start_t)
406+
centers, sizes = _broadcast_int_pair(roi_center, roi_size)
407+
starts = [max(c - s // 2, 0) for c, s in zip(centers, sizes)]
408+
ends = [st + s for st, s in zip(starts, sizes)]
396409
else:
397410
if roi_start is None or roi_end is None:
398411
raise ValueError("please specify either roi_center, roi_size or roi_start, roi_end.")
399-
roi_start_t = convert_to_tensor(data=roi_start, dtype=torch.int16, wrap_sequence=True)
400-
roi_start_t = torch.maximum(roi_start_t, torch.zeros_like(roi_start_t))
401-
roi_end_t = convert_to_tensor(data=roi_end, dtype=torch.int16, wrap_sequence=True)
402-
roi_end_t = torch.maximum(roi_end_t, roi_start_t)
403-
# convert to slices (accounting for 1d)
404-
if roi_start_t.numel() == 1:
405-
return ensure_tuple([slice(int(roi_start_t.item()), int(roi_end_t.item()))])
406-
return ensure_tuple([slice(int(s), int(e)) for s, e in zip(roi_start_t.tolist(), roi_end_t.tolist())])
412+
starts, ends = _broadcast_int_pair(roi_start, roi_end)
413+
starts = [max(s, 0) for s in starts]
414+
# clamp each end to its own start so no slice has negative width
415+
return ensure_tuple(slice(s, max(e, s)) for s, e in zip(starts, ends))
407416

408417
def __call__( # type: ignore[override]
409418
self, img: torch.Tensor, slices: tuple[slice, ...], lazy: bool | None = None

tests/transforms/test_center_spatial_crop.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,12 @@
1414
import unittest
1515

1616
import numpy as np
17+
import torch
1718
from parameterized import parameterized
1819

20+
from monai.data.meta_obj import get_track_meta, set_track_meta
1921
from monai.transforms import CenterSpatialCrop
22+
from monai.transforms.croppad.array import Crop
2023
from tests.croppers import CropTest
2124

2225
TEST_SHAPES = [
@@ -50,6 +53,28 @@ def test_value(self, input_param, input_arr, expected_arr):
5053
def test_pending_ops(self, input_param, input_shape, _, align_corners):
5154
self.crop_test_pending_ops(input_param, input_shape, align_corners)
5255

56+
def test_compute_slices_broadcast(self):
57+
self.assertEqual(Crop.compute_slices(roi_center=2, roi_size=(4, 6, 8)), (slice(0, 4), slice(0, 6), slice(0, 8)))
58+
self.assertEqual(Crop.compute_slices(roi_start=1, roi_end=(3, 5, 7)), (slice(1, 3), slice(1, 5), slice(1, 7)))
59+
with self.assertRaises(ValueError):
60+
Crop.compute_slices(roi_center=(2, 3), roi_size=(4, 5, 6))
61+
with self.assertRaises(ValueError):
62+
Crop.compute_slices(roi_start=(1, 2), roi_end=(3, 5, 7))
63+
with self.assertRaises(TypeError):
64+
Crop.compute_slices(roi_center="10", roi_size=(4, 6))
65+
66+
def test_torch_compile(self):
67+
prev_track_meta = get_track_meta()
68+
set_track_meta(False)
69+
try:
70+
# eager backend traces the transform without needing the Inductor C++ compiler
71+
cropper = torch.compile(CenterSpatialCrop(roi_size=(1, 16, 16)), backend="eager")
72+
img = torch.rand(1, 1, 32, 32, dtype=torch.float32)
73+
self.assertEqual(tuple(cropper(img).shape), (1, 1, 16, 16))
74+
finally:
75+
set_track_meta(prev_track_meta)
76+
torch._dynamo.reset()
77+
5378

5479
if __name__ == "__main__":
5580
unittest.main()

0 commit comments

Comments
 (0)