Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions test/test_transforms_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -2462,6 +2462,62 @@ def test_functional_image_fast_path_correctness(self, size, angle, expand):

torch.testing.assert_close(actual, expected)

@pytest.mark.parametrize("size", [(100, 100), (120, 80)])
@pytest.mark.parametrize("angle", [15.0, 30.0, 45.0])
def test_transform_crop_removes_fill(self, size, angle):
# Output of crop=True should contain no fill pixels when input is fully non-zero
h, w = size
image = tv_tensors.Image(torch.full((3, h, w), 200, dtype=torch.uint8))
transform = transforms.RandomRotation((angle, angle), fill=0, crop=True)
output = transform(image)
assert output.min().item() > 0, "crop=True output should have no fill pixels"
assert output.shape[-2] < h or output.shape[-1] < w, "crop=True should reduce at least one dimension"
Comment on lines +2473 to +2474
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't need specify anything beyond the assert, pytest is already good at showing the right thing. Please remove the message after assert.


@pytest.mark.parametrize("size", [(100, 100), (120, 80)])
@pytest.mark.parametrize("angle", [15.0, 30.0, 45.0])
def test_transform_crop_consistent_across_inputs(self, size, angle):
# Image, mask, and bounding boxes should all be cropped to the same canvas size
h, w = size
image = tv_tensors.Image(torch.full((3, h, w), 200, dtype=torch.uint8))
mask = tv_tensors.Mask(torch.ones(1, h, w, dtype=torch.uint8))
boxes = tv_tensors.BoundingBoxes(
torch.tensor([[10.0, 10.0, 50.0, 50.0]]),
format=tv_tensors.BoundingBoxFormat.XYXY,
canvas_size=(h, w),
)
transform = transforms.RandomRotation((angle, angle), crop=True)
out_image, out_mask, out_boxes = transform(image, mask, boxes)
assert out_image.shape[-2:] == out_mask.shape[-2:]
assert out_boxes.canvas_size == (out_image.shape[-2], out_image.shape[-1])

def test_transform_crop_and_expand_mutually_exclusive(self):
with pytest.raises(ValueError, match="crop and expand are mutually exclusive"):
transforms.RandomRotation(30, expand=True, crop=True)

@pytest.mark.parametrize("angle", [0.0, 90.0, 180.0, 270.0])
def test_transform_crop_zero_angle_preserves_size(self, angle):
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test name "test_transform_crop_zero_angle_preserves_size" says "zero_angle" but it tests 0°, 90°, 180°, and 270°, which is a bit misleading. Could you please rename it?

Comment on lines +2497 to +2498
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we add non-square sizes by parametrizing over non-square sizes like (120, 80) here?

# Multiples of 90° should not reduce the image size
image = tv_tensors.Image(torch.zeros(3, 100, 100, dtype=torch.uint8))
transform = transforms.RandomRotation((angle, angle), crop=True)
output = transform(image)
assert output.shape == image.shape

def test_largest_inscribed_crop_size(self):
from torchvision.transforms.v2.functional._geometry import _largest_inscribed_crop_size
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We import the functions on the top of the file. For consistency, please move the _largest_inscribed_crop_size import to the top of the file too.


# No rotation: crop equals original size
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment is obvious from the code, so we can remove it.

assert _largest_inscribed_crop_size(100, 100, 0) == (100, 100)
assert _largest_inscribed_crop_size(200, 100, 0) == (100, 200)

# 45° square: inscribed square has side = 100 / sqrt(2) ≈ 70.71 → floor to 70
crop_h, crop_w = _largest_inscribed_crop_size(100, 100, 45)
assert crop_h == crop_w == 70

# Crop is always smaller than or equal to original dimensions
for w, h, a in [(200, 100, 20), (640, 480, 15), (50, 50, 37)]:
ch, cw = _largest_inscribed_crop_size(w, h, a)
assert ch <= h and cw <= w


class TestContainerTransforms:
class BuiltinTransform(transforms.Transform):
Expand Down
29 changes: 26 additions & 3 deletions torchvision/transforms/v2/_geometry.py
Original file line number Diff line number Diff line change
Expand Up @@ -608,6 +608,9 @@ class RandomRotation(Transform):
Fill value can be also a dictionary mapping data type to the fill value, e.g.
``fill={tv_tensors.Image: 127, tv_tensors.Mask: 0}`` where ``Image`` will be filled with 127 and
``Mask`` will be filled with 0.
crop (bool, optional): If ``True``, the rotated output is center-cropped to the largest axis-aligned
rectangle that fits entirely within the rotated image, removing any fill/padding regions introduced
by the rotation. Mutually exclusive with ``expand``. Default is ``False``.

.. _filters: https://pillow.readthedocs.io/en/latest/handbook/concepts.html#filters

Expand All @@ -622,11 +625,15 @@ def __init__(
expand: bool = False,
center: Optional[list[float]] = None,
fill: Union[_FillType, dict[Union[type, str], _FillType]] = 0,
crop: bool = False,
) -> None:
super().__init__()
if crop and expand:
raise ValueError("crop and expand are mutually exclusive")
self.degrees = _setup_angle(degrees, name="degrees", req_sizes=(2,))
self.interpolation = interpolation
self.expand = expand
self.crop = crop

self.fill = fill
self._fill = _setup_fill_arg(fill)
Expand All @@ -636,21 +643,37 @@ def __init__(

self.center = center

def _extract_params_for_v1_transform(self) -> dict[str, Any]:
params = super()._extract_params_for_v1_transform()
if params.pop("crop"):
raise ValueError(
f"{type(self).__name__}() cannot be scripted when crop=True, "
"as this feature is not supported by the v1 transform."
)
return params

def make_params(self, flat_inputs: list[Any]) -> dict[str, Any]:
angle = torch.empty(1).uniform_(self.degrees[0], self.degrees[1]).item()
return dict(angle=angle)
params: dict[str, Any] = dict(angle=angle)
if self.crop:
height, width = query_size(flat_inputs)
params["crop_hw"] = F._geometry._largest_inscribed_crop_size(width, height, angle)
return params

def transform(self, inpt: Any, params: dict[str, Any]) -> Any:
fill = _get_fill(self._fill, type(inpt))
return self._call_kernel(
output = self._call_kernel(
F.rotate,
inpt,
**params,
angle=params["angle"],
interpolation=self.interpolation,
expand=self.expand,
center=self.center,
fill=fill,
)
if self.crop:
output = self._call_kernel(F.center_crop, output, output_size=list(params["crop_hw"]))
return output


class RandomAffine(Transform):
Expand Down
42 changes: 42 additions & 0 deletions torchvision/transforms/v2/functional/_geometry.py
Original file line number Diff line number Diff line change
Expand Up @@ -1343,6 +1343,48 @@ def affine_video(
)


def _largest_inscribed_crop_size(width: int, height: int, angle: float) -> tuple[int, int]:
"""Compute the largest axis-aligned rectangle inscribed in a rotated width x height rectangle.

Returns ``(crop_height, crop_width)`` as integers.

Approach taken from https://stackoverflow.com/questions/16702966/rotate-image-and-crop-out-black-borders
"""
angle_rad = math.radians(angle)
sin_a = abs(math.sin(angle_rad))
cos_a = abs(math.cos(angle_rad))

# Guard against small values of sin_a or cos_a that will cause us to truncate a pixel
# off rotations that introduce no padding (e.g. 90° or 180°).
if sin_a < 1e-10:
return height, width
if cos_a < 1e-10:
return width, height

width_is_longer = width >= height
side_long = width if width_is_longer else height
side_short = height if width_is_longer else width

if side_short <= 2.0 * sin_a * cos_a * side_long or abs(sin_a - cos_a) < 1e-10:
# Half-constrained: two crop corners touch the longer side.
# Also handles the 45° case where sin_a == cos_a and the denominator
# in the fully constrained solution goes to zero.
x = 0.5 * side_short
if width_is_longer:
crop_w, crop_h = x / sin_a, x / cos_a
else:
crop_w, crop_h = x / cos_a, x / sin_a
else:
# Fully constrained: crop touches all four sides
cos_2a = cos_a * cos_a - sin_a * sin_a
crop_w = (width * cos_a - height * sin_a) / cos_2a
crop_h = (height * cos_a - width * sin_a) / cos_2a

# Use floor (int()) to guarantee the crop region contains no fill pixels.
# Clamp to image dimensions for edge cases like wide images rotated near 90°.
return min(int(crop_h), height), min(int(crop_w), width)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would math.floor() be more appropriate here? int() truncates toward zero rather than rounding down, and math.floor() would make the "round down" intent more explicit. (Both behave the same for positive values, which is always the case here, so this is just a readability suggestion.)



def rotate(
inpt: torch.Tensor,
angle: float,
Expand Down
Loading