Skip to content

Commit e01966a

Browse files
committed
Merge branch 'weekly_preview2' of github.com:ericspod/MONAI into weekly_preview2
2 parents 41b049a + 125d890 commit e01966a

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)