Files
wehub-resource-sync 16031aae96
CPU tests Workflow / Testing (ubuntu-latest, 3.12) (push) Failing after 1s
CPU tests Workflow / Testing (ubuntu-latest, 3.13) (push) Failing after 0s
Mypy Type Check / Type Check (push) Failing after 0s
Docs/Test WorkFlow / Test docs build (push) Failing after 1s
PR Conflict Labeler / labeling (push) Failing after 1s
Dependency resolution / Resolve [tflite] extra — Python 3.12 (push) Failing after 0s
Smoke Tests / try-all-models (ubuntu-latest, 3.10) (push) Failing after 0s
Smoke Tests / try-all-models (ubuntu-latest, 3.13) (push) Failing after 1s
CPU tests Workflow / build-pkg (push) Failing after 1s
CPU tests Workflow / Testing (ubuntu-latest, 3.10) (push) Failing after 0s
CPU tests Workflow / Testing (ubuntu-latest, 3.11) (push) Failing after 0s
Smoke Tests / try-all-models (macos-latest, 3.10) (push) Has been cancelled
Smoke Tests / try-all-models (macos-latest, 3.13) (push) Has been cancelled
Smoke Tests / try-all-models (windows-latest, 3.10) (push) Has been cancelled
Smoke Tests / try-all-models (windows-latest, 3.13) (push) Has been cancelled
CPU tests Workflow / Testing (macos-latest, 3.10) (push) Has been cancelled
CPU tests Workflow / Testing (macos-latest, 3.13) (push) Has been cancelled
CPU tests Workflow / Testing (windows-latest, 3.10) (push) Has been cancelled
CPU tests Workflow / Testing (windows-latest, 3.13) (push) Has been cancelled
CPU tests Workflow / testing-guardian (push) Has been cancelled
GPU tests Workflow / Testing (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:26:24 +08:00

2249 lines
90 KiB
Python

# ------------------------------------------------------------------------
# RF-DETR
# Copyright (c) 2025 Roboflow. All Rights Reserved.
# Licensed under the Apache License, Version 2.0 [see LICENSE for details]
# ------------------------------------------------------------------------
"""Tests for Albumentations augmentation wrappers."""
from unittest import mock
import albumentations as alb
import numpy as np
import pytest
import torch
from PIL import Image
from torch.utils.data import DataLoader
from torchvision.transforms.v2 import Compose
from rfdetr.datasets._aug_utils import filter_keypoint_hflip_augmentations
from rfdetr.datasets._develop import _SimpleDataset
from rfdetr.datasets.aug_configs import AUG_AGGRESSIVE, AUG_CONFIG
from rfdetr.datasets.coco import make_coco_transforms, make_coco_transforms_square_div_64
from rfdetr.datasets.transforms import AlbumentationsWrapper, Normalize, _build_albu_transform
from rfdetr.utilities import collate_fn
class _FakeRandomSizedCropV2:
"""Test double for Albumentations 2.x-style RandomSizedCrop API."""
def __init__(self, *, min_max_height, size, p=1.0):
self.min_max_height = min_max_height
self.size = size
self.p = p
class _FakeRandomSizedCropV1:
"""Test double for Albumentations 1.x-style RandomSizedCrop API."""
def __init__(self, *, min_max_height, height, width, p=1.0):
self.min_max_height = min_max_height
self.height = height
self.width = width
self.p = p
class TestKeypointHFlipFiltering:
"""Shared keypoint hflip filtering behavior for augmentation backends."""
def test_drops_hflip_and_warns_for_keypoint_pipeline(self) -> None:
"""Keypoint augmentation config drops hflip transforms while keeping other transforms."""
config = {"HorizontalFlip": {"p": 0.5}, "VerticalFlip": {"p": 0.5}}
warning = mock.Mock()
filtered = filter_keypoint_hflip_augmentations(config, warn=warning)
assert filtered == {"VerticalFlip": {"p": 0.5}}
warning.assert_called_once()
assert "HorizontalFlip" in str(warning.call_args)
def test_keeps_hflip_when_keypoints_are_not_active(self) -> None:
"""Detection augmentation config keeps hflip transforms unchanged."""
config = {"HorizontalFlip": {"p": 0.5}}
warning = mock.Mock()
filtered = filter_keypoint_hflip_augmentations(config, warn=warning, include_keypoints=False)
assert filtered == config
warning.assert_not_called()
def test_drops_nested_hflip_from_container(self) -> None:
"""Keypoint augmentation config drops hflip transforms inside containers."""
config = {
"OneOf": {
"transforms": [
{"HorizontalFlip": {"p": 1.0}},
{"VerticalFlip": {"p": 1.0}},
],
"p": 0.5,
}
}
warning = mock.Mock()
filtered = filter_keypoint_hflip_augmentations(config, warn=warning)
assert filtered == {
"OneOf": {
"transforms": [
{"VerticalFlip": {"p": 1.0}},
],
"p": 0.5,
}
}
warning.assert_called_once()
assert "HorizontalFlip" in str(warning.call_args)
class TestAlbumentationsWrapper:
"""Tests for AlbumentationsWrapper class."""
@pytest.mark.parametrize(
"transform_class,params,box_in,box_out",
[
(alb.HorizontalFlip, {"p": 1.0}, [10.0, 20.0, 30.0, 40.0], [70.0, 20.0, 90.0, 40.0]),
(alb.VerticalFlip, {"p": 1.0}, [10.0, 20.0, 30.0, 40.0], [10.0, 60.0, 30.0, 80.0]),
],
)
def test_flip_transforms_with_boxes(self, transform_class, params, box_in, box_out):
"""Test flip transforms correctly transform bounding boxes."""
transform = transform_class(**params)
wrapper = AlbumentationsWrapper(transform)
image = Image.new("RGB", (100, 100))
target = {"boxes": torch.tensor([box_in]), "labels": torch.tensor([1])}
aug_image, aug_target = wrapper(image, target)
assert isinstance(aug_image, Image.Image)
assert torch.allclose(aug_target["boxes"], torch.tensor([box_out]), atol=1.0)
assert torch.equal(aug_target["labels"], target["labels"])
def test_resize_transforms_keypoint_coordinates(self):
"""Resize scales keypoint coordinates and preserves visibility values."""
wrapper = AlbumentationsWrapper(alb.Resize(height=100, width=200, p=1.0))
image = Image.new("RGB", (100, 50))
target = {
"boxes": torch.tensor([[10.0, 5.0, 30.0, 25.0]]),
"labels": torch.tensor([1]),
"keypoints": torch.tensor([[[15.0, 10.0, 2.0], [0.0, 0.0, 0.0]]]),
}
_, transformed = wrapper(image, target)
torch.testing.assert_close(
transformed["boxes"],
torch.tensor([[20.0, 10.0, 60.0, 50.0]]),
rtol=1e-4,
atol=1e-6,
)
torch.testing.assert_close(
transformed["keypoints"],
torch.tensor([[[30.0, 20.0, 2.0], [0.0, 0.0, 0.0]]]),
rtol=1e-4,
atol=1e-6,
)
def test_horizontal_flip_transforms_keypoint_coordinates(self):
"""Horizontal flip mirrors keypoint coordinates using Albumentations geometry."""
wrapper = AlbumentationsWrapper(alb.HorizontalFlip(p=1.0))
image = Image.new("RGB", (100, 50))
target = {
"boxes": torch.tensor([[10.0, 5.0, 30.0, 25.0]]),
"labels": torch.tensor([1]),
"keypoints": torch.tensor([[[10.0, 5.0, 2.0], [30.0, 25.0, 2.0], [0.0, 0.0, 0.0]]]),
}
_, transformed = wrapper(image, target)
torch.testing.assert_close(
transformed["boxes"],
torch.tensor([[70.0, 5.0, 90.0, 25.0]]),
rtol=1e-4,
atol=1e-6,
)
torch.testing.assert_close(
transformed["keypoints"],
torch.tensor([[[89.0, 5.0, 2.0], [69.0, 25.0, 2.0], [0.0, 0.0, 0.0]]]),
rtol=1e-4,
atol=1e-6,
)
@pytest.mark.parametrize(
"num_instances",
[
pytest.param(0, id="zero_instances"),
pytest.param(1, id="one_instance"),
pytest.param(2, id="two_instances"),
],
)
def test_horizontal_flip_with_keypoint_flip_pairs_handles_ndarray_bboxes(self, num_instances):
"""Regression test for #1125.
Albumentations 2.x returns ``bboxes`` as a NumPy ndarray of shape (N, 4); 1.x returned a list of tuples. The
horizontal-flip swap path used to inspect ``bboxes`` with list-style truthiness, which raised ``ValueError: The
truth value of an array with more than one element is ambiguous`` on any ndarray with more than one element.
This test exercises that path across multi/single/empty instance counts.
"""
wrapper = AlbumentationsWrapper(
alb.HorizontalFlip(p=1.0),
keypoint_flip_pairs=[0, 1],
)
image = Image.new("RGB", (100, 50))
if num_instances == 0:
target = {
"boxes": torch.zeros((0, 4), dtype=torch.float32),
"labels": torch.zeros((0,), dtype=torch.long),
"keypoints": torch.zeros((0, 2, 3), dtype=torch.float32),
}
else:
boxes = []
keypoints = []
for i in range(num_instances):
x = 10.0 + i * 30.0
boxes.append([x, 5.0, x + 20.0, 25.0])
keypoints.append([[x + 5.0, 10.0, 2.0], [x + 15.0, 20.0, 2.0]])
target = {
"boxes": torch.tensor(boxes, dtype=torch.float32),
"labels": torch.tensor([1] * num_instances, dtype=torch.long),
"keypoints": torch.tensor(keypoints, dtype=torch.float32),
}
# Pre-fix: this call raised ValueError for num_instances >= 1 under Albumentations 2.x.
aug_image, aug_target = wrapper(image, target)
assert isinstance(aug_image, Image.Image)
assert aug_target["boxes"].shape[0] == num_instances
assert aug_target["keypoints"].shape[0] == num_instances
def test_horizontal_flip_swaps_paired_keypoints(self):
"""HFlip with keypoint_flip_pairs exchanges keypoint slots for the configured pair."""
wrapper = AlbumentationsWrapper(
alb.HorizontalFlip(p=1.0),
keypoint_flip_pairs=[0, 1],
)
image = Image.new("RGB", (100, 50))
target = {
"boxes": torch.tensor([[5.0, 5.0, 95.0, 45.0]]),
"labels": torch.tensor([1]),
# kp0 at x=10 (left), kp1 at x=80 (right)
"keypoints": torch.tensor([[[10.0, 10.0, 2.0], [80.0, 30.0, 2.0]]]),
}
_, transformed = wrapper(image, target)
kp = transformed["keypoints"][0] # shape [2, 3]
# After HFlip (W=100): kp0→x=89, kp1→x=19. After swap: slot0 gets kp1's flipped x=19,
# slot1 gets kp0's flipped x=89. Without swap the ordering would be inverted (89 > 19).
torch.testing.assert_close(kp[0, 0], torch.tensor(19.0), rtol=1e-4, atol=1e-6)
torch.testing.assert_close(kp[1, 0], torch.tensor(89.0), rtol=1e-4, atol=1e-6)
def test_nested_horizontal_flip_swaps_slots_after_all_geometry(self):
"""Nested HFlip+VFlip should mirror coordinates once, then swap only the left/right slots."""
wrapper = AlbumentationsWrapper(
alb.Sequential([alb.HorizontalFlip(p=1.0), alb.VerticalFlip(p=1.0)], p=1.0),
keypoint_flip_pairs=[0, 1],
)
image = Image.new("RGB", (100, 50))
target = {
"boxes": torch.tensor([[5.0, 5.0, 95.0, 45.0]]),
"labels": torch.tensor([1]),
"keypoints": torch.tensor(
[
[
[10.0, 10.0, 2.0],
[80.0, 30.0, 2.0],
[50.0, 20.0, 1.0],
]
]
),
}
_, transformed = wrapper(image, target)
torch.testing.assert_close(
transformed["keypoints"],
torch.tensor([[[19.0, 19.0, 2.0], [89.0, 39.0, 2.0], [49.0, 29.0, 1.0]]]),
rtol=1e-4,
atol=1e-6,
)
@pytest.mark.parametrize(
"transform,expected_keypoints",
[
pytest.param(
alb.HorizontalFlip(p=0.0),
torch.tensor([[[10.0, 10.0, 2.0], [80.0, 30.0, 2.0]]]),
id="disabled-horizontal-flip",
),
pytest.param(
alb.VerticalFlip(p=1.0),
torch.tensor([[[10.0, 39.0, 2.0], [80.0, 19.0, 2.0]]]),
id="vertical-flip",
),
pytest.param(
alb.Resize(height=50, width=100, p=1.0),
torch.tensor([[[10.0, 10.0, 2.0], [80.0, 30.0, 2.0]]]),
id="resize",
),
pytest.param(
alb.Crop(x_min=0, y_min=0, x_max=100, y_max=50, p=1.0),
torch.tensor([[[10.0, 10.0, 2.0], [80.0, 30.0, 2.0]]]),
id="full-image-crop",
),
],
)
def test_non_horizontal_geometry_does_not_swap_paired_keypoints(self, transform, expected_keypoints):
"""Configured HFlip pairs should not swap slots when no horizontal flip applied."""
wrapper = AlbumentationsWrapper(transform, keypoint_flip_pairs=[0, 1])
image = Image.new("RGB", (100, 50))
target = {
"boxes": torch.tensor([[5.0, 5.0, 95.0, 45.0]]),
"labels": torch.tensor([1]),
"keypoints": torch.tensor([[[10.0, 10.0, 2.0], [80.0, 30.0, 2.0]]]),
}
_, transformed = wrapper(image, target)
torch.testing.assert_close(transformed["keypoints"], expected_keypoints, rtol=1e-4, atol=1e-6)
def test_crop_filters_keypoints_with_removed_boxes(self):
"""When a crop removes a box, its keypoints are removed with the same instance."""
wrapper = AlbumentationsWrapper(alb.Crop(x_min=0, y_min=0, x_max=50, y_max=50, p=1.0))
image = Image.new("RGB", (100, 50))
target = {
"boxes": torch.tensor([[10.0, 5.0, 30.0, 25.0], [60.0, 5.0, 80.0, 25.0]]),
"labels": torch.tensor([1, 1]),
"area": torch.tensor([400.0, 400.0]),
"keypoints": torch.tensor(
[
[[15.0, 10.0, 2.0], [25.0, 20.0, 2.0]],
[[65.0, 10.0, 2.0], [75.0, 20.0, 2.0]],
]
),
}
_, transformed = wrapper(image, target)
assert transformed["boxes"].shape == (1, 4)
assert transformed["labels"].tolist() == [1]
torch.testing.assert_close(
transformed["keypoints"],
torch.tensor([[[15.0, 10.0, 2.0], [25.0, 20.0, 2.0]]]),
rtol=1e-4,
atol=1e-6,
)
def test_non_geometric_transform_preserves_boxes(self):
"""Test that non-geometric transforms preserve bounding boxes."""
transform = alb.GaussianBlur(blur_limit=3, p=1.0)
wrapper = AlbumentationsWrapper(transform)
image = Image.new("RGB", (100, 100))
target = {"boxes": torch.tensor([[10.0, 20.0, 30.0, 40.0]]), "labels": torch.tensor([1])}
aug_image, aug_target = wrapper(image, target)
assert isinstance(aug_image, Image.Image)
# Boxes should be unchanged
assert torch.equal(aug_target["boxes"], target["boxes"])
assert torch.equal(aug_target["labels"], target["labels"])
def test_empty_boxes_handling(self):
"""Test wrapper handles empty boxes correctly."""
transform = alb.HorizontalFlip(p=1.0)
wrapper = AlbumentationsWrapper(transform)
image = Image.new("RGB", (100, 100))
target = {"boxes": torch.zeros((0, 4)), "labels": torch.zeros((0,), dtype=torch.long)}
aug_image, aug_target = wrapper(image, target)
assert isinstance(aug_image, Image.Image)
assert aug_target["boxes"].shape == (0, 4)
assert aug_target["labels"].shape == (0,)
def test_multiple_boxes(self):
"""Test wrapper handles multiple bounding boxes."""
transform = alb.HorizontalFlip(p=1.0)
wrapper = AlbumentationsWrapper(transform)
image = Image.new("RGB", (100, 100))
target = {
"boxes": torch.tensor(
[
[10.0, 20.0, 30.0, 40.0],
[50.0, 60.0, 70.0, 80.0],
]
),
"labels": torch.tensor([1, 2]),
}
aug_image, aug_target = wrapper(image, target)
assert isinstance(aug_image, Image.Image)
assert aug_target["boxes"].shape == (2, 4)
assert aug_target["labels"].shape == (2,)
assert torch.equal(aug_target["labels"], target["labels"])
def test_none_target_inference_mode(self):
"""Test wrapper accepts None target for inference (no ground-truth annotations)."""
transform = alb.Resize(height=64, width=64)
wrapper = AlbumentationsWrapper(transform)
image = Image.new("RGB", (100, 100))
aug_image, aug_target = wrapper(image, None)
assert isinstance(aug_image, Image.Image)
assert aug_image.size == (64, 64)
assert aug_target is None
def test_invalid_target_type(self):
"""Test wrapper raises error for invalid target type."""
transform = alb.HorizontalFlip(p=1.0)
wrapper = AlbumentationsWrapper(transform)
image = Image.new("RGB", (100, 100))
with pytest.raises(TypeError, match="target must be a dictionary"):
wrapper(image, "invalid_target")
def test_missing_labels_key(self):
"""Test wrapper raises error when labels key is missing."""
transform = alb.HorizontalFlip(p=1.0)
wrapper = AlbumentationsWrapper(transform)
image = Image.new("RGB", (100, 100))
target = {"boxes": torch.tensor([[10.0, 20.0, 30.0, 40.0]])}
with pytest.raises(KeyError, match="target must contain 'labels' key"):
wrapper(image, target)
def test_invalid_boxes_shape(self):
"""Test wrapper raises error for invalid boxes shape."""
transform = alb.HorizontalFlip(p=1.0)
wrapper = AlbumentationsWrapper(transform)
image = Image.new("RGB", (100, 100))
target = {
"boxes": torch.tensor([10.0, 20.0, 30.0]), # Invalid shape
"labels": torch.tensor([1]),
}
with pytest.raises(ValueError, match="boxes must have shape"):
wrapper(image, target)
def test_orig_size_preserved_with_two_boxes(self):
"""Test that orig_size is not treated as per-instance field when num_boxes=2.
Regression test for bug where orig_size (shape [2] for [h, w]) was incorrectly treated as a per-instance field
when there were exactly 2 boxes, causing orig_size to be filtered/indexed incorrectly and leading to
inconsistent tensor shapes in batches.
"""
transform = alb.HorizontalFlip(p=1.0)
wrapper = AlbumentationsWrapper(transform)
image = Image.new("RGB", (640, 480))
target = {
"boxes": torch.tensor([[10.0, 20.0, 100.0, 200.0], [300.0, 100.0, 500.0, 400.0]], dtype=torch.float32),
"labels": torch.tensor([1, 2]),
"orig_size": torch.tensor([480, 640]), # shape [2], same as num_boxes!
"size": torch.tensor([480, 640]),
"image_id": torch.tensor([123]),
"area": torch.tensor([100.0, 200.0]),
"iscrowd": torch.tensor([0, 0]),
}
aug_image, aug_target = wrapper(image, target)
# Verify orig_size is still [2] elements (h, w), not filtered as per-instance
assert aug_target["orig_size"].shape == torch.Size([2]), (
f"orig_size should have shape [2], got {aug_target['orig_size'].shape}"
)
assert torch.equal(aug_target["orig_size"], target["orig_size"]), "orig_size should be unchanged"
# Verify other global fields are also preserved
assert aug_target["size"].shape == torch.Size([2])
assert aug_target["image_id"].shape == torch.Size([1])
assert torch.equal(aug_target["image_id"], target["image_id"])
def test_orig_size_preserved_with_two_boxes_and_masks(self):
"""Test that orig_size and masks are handled correctly when num_boxes=2.
Critical regression test: With 2 boxes, both orig_size and masks have first dimension = 2, but they must be
treated differently:
- orig_size (shape [2]): global field, should NOT be filtered
- masks (shape [2, H, W]): per-instance field, SHOULD be transformed
"""
transform = alb.HorizontalFlip(p=1.0)
wrapper = AlbumentationsWrapper(transform)
image = Image.new("RGB", (640, 480))
# Create masks for 2 boxes (use uint8 for Albumentations compatibility)
masks = torch.zeros((2, 480, 640), dtype=torch.uint8)
masks[0, 50:150, 50:150] = 1 # Mask for first box
masks[1, 200:300, 300:500] = 1 # Mask for second box
target = {
"boxes": torch.tensor([[10.0, 20.0, 100.0, 200.0], [300.0, 100.0, 500.0, 400.0]], dtype=torch.float32),
"labels": torch.tensor([1, 2]),
"masks": masks, # shape [2, 480, 640], same first dim as orig_size!
"orig_size": torch.tensor([480, 640]), # shape [2]
"size": torch.tensor([480, 640]),
"image_id": torch.tensor([123]),
"area": torch.tensor([100.0, 200.0]),
"iscrowd": torch.tensor([0, 0]),
}
aug_image, aug_target = wrapper(image, target)
# Verify orig_size is preserved (global field)
assert aug_target["orig_size"].shape == torch.Size([2]), (
f"orig_size should have shape [2], got {aug_target['orig_size'].shape}"
)
assert torch.equal(aug_target["orig_size"], target["orig_size"]), "orig_size should be unchanged"
# Verify masks are transformed (per-instance field)
assert aug_target["masks"].shape == torch.Size([2, 480, 640]), (
f"masks should have shape [2, 480, 640], got {aug_target['masks'].shape}"
)
assert aug_target["masks"].dtype == torch.bool, "masks should be converted to bool after transform"
# Masks should be flipped - verify they're different
assert not torch.equal(aug_target["masks"], target["masks"].bool()), (
"masks should be transformed (flipped) for geometric transform"
)
# Verify we still have 2 boxes and 2 masks
assert len(aug_target["boxes"]) == 2, "Should have 2 boxes after transform"
assert len(aug_target["labels"]) == 2, "Should have 2 labels after transform"
assert aug_target["masks"].shape[0] == 2, "Should have 2 masks after transform"
# Verify other global fields are preserved
assert aug_target["size"].shape == torch.Size([2])
assert aug_target["image_id"].shape == torch.Size([1])
assert torch.equal(aug_target["image_id"], target["image_id"])
@pytest.mark.parametrize(
"transform_class,params",
[
(alb.HorizontalFlip, {"p": 1.0}),
(alb.VerticalFlip, {"p": 1.0}),
(alb.Rotate, {"limit": 45, "p": 1.0}),
],
)
def test_various_geometric_transforms(self, transform_class, params):
"""Test various geometric transforms work correctly."""
transform = transform_class(**params)
wrapper = AlbumentationsWrapper(transform)
image = Image.new("RGB", (100, 100))
target = {"boxes": torch.tensor([[10.0, 20.0, 30.0, 40.0]]), "labels": torch.tensor([1])}
aug_image, aug_target = wrapper(image, target)
assert isinstance(aug_image, Image.Image)
# Albumentations can return multiple boxes for a single input box on some Python versions.
assert aug_target["boxes"].shape[1] == 4
assert aug_target["labels"].shape[0] == aug_target["boxes"].shape[0]
assert aug_target["labels"].numel() >= 1
def test_masks_transform_with_horizontal_flip(self):
"""Masks should be transformed consistently with boxes for geometric transforms."""
transform = alb.HorizontalFlip(p=1.0)
wrapper = AlbumentationsWrapper(transform)
# Create test image (100x100)
height, width = 100, 100
image = Image.new("RGB", (width, height), color="red")
# Single box and corresponding mask
box = torch.tensor([[10.0, 20.0, 30.0, 40.0]]) # x1, y1, x2, y2
masks = torch.zeros((1, height, width), dtype=torch.uint8)
# Fill the mask inside the box region
x1, y1, x2, y2 = box[0].to(dtype=torch.long)
masks[0, y1:y2, x1:x2] = 1
target = {
"boxes": box,
"labels": torch.tensor([1]),
"masks": masks,
}
aug_image, aug_target = wrapper(image, target)
assert isinstance(aug_image, Image.Image)
assert "masks" in aug_target
assert aug_target["masks"].shape[0] == aug_target["boxes"].shape[0]
# Check that the transformed mask's bounding box matches the transformed box
aug_mask = aug_target["masks"][0]
ys, xs = torch.nonzero(aug_mask, as_tuple=True)
assert ys.numel() > 0 and xs.numel() > 0
mask_bbox = torch.tensor(
[
xs.min().item(),
ys.min().item(),
xs.max().item() + 1,
ys.max().item() + 1,
],
dtype=torch.float32,
)
assert torch.allclose(mask_bbox, aug_target["boxes"][0].to(dtype=torch.float32), atol=1.0)
@pytest.mark.parametrize(
"transform_class,params",
[
(alb.HorizontalFlip, {"p": 1.0}),
(alb.VerticalFlip, {"p": 1.0}),
(alb.Rotate, {"limit": 15, "p": 1.0}), # Small angle to avoid boxes going out
],
)
def test_various_geometric_transforms_with_masks(self, transform_class, params):
"""Test various geometric transforms correctly transform masks."""
transform = transform_class(**params)
wrapper = AlbumentationsWrapper(transform)
height, width = 100, 100
image = Image.new("RGB", (width, height))
# Create mask covering the box region (more centered to avoid edge issues with rotation)
masks = torch.zeros((1, height, width), dtype=torch.uint8)
masks[0, 40:60, 40:60] = 1
target = {
"boxes": torch.tensor([[40.0, 40.0, 60.0, 60.0]]),
"labels": torch.tensor([1]),
"masks": masks,
}
aug_image, aug_target = wrapper(image, target)
assert isinstance(aug_image, Image.Image)
assert "masks" in aug_target
# Number of boxes may change with rotation (boxes can be removed if they go out of bounds)
assert aug_target["masks"].shape[0] == aug_target["boxes"].shape[0]
if aug_target["boxes"].shape[0] > 0:
# Mask should still have content (not all zeros)
assert aug_target["masks"].any()
@pytest.mark.parametrize(
"transform_class,params",
[
(alb.GaussianBlur, {"blur_limit": 3, "p": 1.0}),
(alb.RandomBrightnessContrast, {"p": 1.0}),
(alb.GaussNoise, {"p": 1.0}),
],
)
def test_pixel_transforms_preserve_masks(self, transform_class, params):
"""Test pixel-level transforms preserve masks unchanged."""
transform = transform_class(**params)
wrapper = AlbumentationsWrapper(transform)
height, width = 100, 100
image = Image.new("RGB", (width, height))
masks = torch.zeros((1, height, width), dtype=torch.uint8)
masks[0, 20:40, 10:30] = 1
target = {
"boxes": torch.tensor([[10.0, 20.0, 30.0, 40.0]]),
"labels": torch.tensor([1]),
"masks": masks,
}
aug_image, aug_target = wrapper(image, target)
assert isinstance(aug_image, Image.Image)
# Pixel transforms should not modify masks
assert torch.equal(aug_target["masks"], target["masks"])
def test_multiple_masks_with_geometric_transform(self):
"""Test multiple masks are correctly transformed together."""
transform = alb.HorizontalFlip(p=1.0)
wrapper = AlbumentationsWrapper(transform)
height, width = 100, 100
image = Image.new("RGB", (width, height))
# Two masks for two boxes
masks = torch.zeros((2, height, width), dtype=torch.uint8)
masks[0, 10:30, 10:30] = 1 # First mask
masks[1, 50:70, 50:70] = 1 # Second mask
target = {
"boxes": torch.tensor(
[
[10.0, 10.0, 30.0, 30.0],
[50.0, 50.0, 70.0, 70.0],
]
),
"labels": torch.tensor([1, 2]),
"masks": masks,
}
aug_image, aug_target = wrapper(image, target)
assert aug_target["masks"].shape == (2, height, width)
assert aug_target["boxes"].shape[0] == 2
assert aug_target["labels"].shape[0] == 2
def test_empty_masks_handling(self):
"""Test wrapper correctly handles empty masks (no 'masks' key when empty)."""
transform = alb.HorizontalFlip(p=1.0)
wrapper = AlbumentationsWrapper(transform)
height, width = 100, 100
image = Image.new("RGB", (width, height))
# When boxes are empty, don't include masks field
target = {
"boxes": torch.zeros((0, 4)),
"labels": torch.zeros((0,), dtype=torch.long),
}
aug_image, aug_target = wrapper(image, target)
assert isinstance(aug_image, Image.Image)
assert aug_target["boxes"].shape == (0, 4)
assert aug_target["labels"].shape == (0,)
def test_geometric_transform_with_empty_masks_tensor(self):
"""Test that a geometric transform does not crash when masks tensor is empty (0 instances).
Regression test for: when a prior crop removes all annotations, target["masks"] has shape (0, H, W). Passing an
empty list to albumentations raises ValueError: masks cannot be empty.
"""
transform = alb.HorizontalFlip(p=1.0)
wrapper = AlbumentationsWrapper(transform)
height, width = 100, 100
image = Image.new("RGB", (width, height))
# Simulate what happens after RandomSizeCrop removes all annotations:
# target["masks"] has shape (0, H, W)
target = {
"boxes": torch.zeros((0, 4), dtype=torch.float32),
"labels": torch.zeros((0,), dtype=torch.long),
"masks": torch.zeros((0, height, width), dtype=torch.uint8),
}
# Should not raise ValueError: masks cannot be empty
aug_image, aug_target = wrapper(image, target)
assert isinstance(aug_image, Image.Image)
assert aug_target["boxes"].shape == (0, 4)
assert aug_target["labels"].shape == (0,)
assert "masks" in aug_target
assert aug_target["masks"].shape[0] == 0
assert aug_target["masks"].dtype == torch.bool
def test_pixel_transform_with_masks_no_boxes(self):
"""Test that pixel transforms work with masks but no boxes."""
# Use a non-geometric transform which doesn't need boxes
transform = alb.GaussianBlur(blur_limit=3, p=1.0)
wrapper = AlbumentationsWrapper(transform)
height, width = 100, 100
image = Image.new("RGB", (width, height))
masks_orig = torch.zeros((1, height, width), dtype=torch.uint8)
masks_orig[0, 20:40, 10:30] = 1
target = {
"labels": torch.tensor([1]),
"masks": masks_orig.clone(), # No boxes!
}
aug_image, aug_target = wrapper(image, target)
# Pixel transforms should preserve masks
assert torch.equal(aug_target["masks"], masks_orig)
def test_invalid_mask_shape_raises_error(self):
"""Test that invalid mask shape raises ValueError."""
transform = alb.HorizontalFlip(p=1.0)
wrapper = AlbumentationsWrapper(transform)
height, width = 100, 100
image = Image.new("RGB", (width, height))
# Invalid mask shape (2D instead of 3D)
target = {
"boxes": torch.tensor([[10.0, 20.0, 30.0, 40.0]]),
"labels": torch.tensor([1]),
"masks": torch.zeros((height, width), dtype=torch.uint8),
}
with pytest.raises(ValueError, match="masks must have shape"):
wrapper(image, target)
@pytest.mark.parametrize("mask_dtype", [torch.uint8, torch.float32])
def test_mask_dtype_handling(self, mask_dtype):
"""Test wrapper handles different mask dtypes correctly (uint8, float32)."""
transform = alb.HorizontalFlip(p=1.0)
wrapper = AlbumentationsWrapper(transform)
height, width = 100, 100
image = Image.new("RGB", (width, height))
masks = torch.zeros((1, height, width), dtype=mask_dtype)
masks[0, 20:40, 10:30] = 1
target = {
"boxes": torch.tensor([[10.0, 20.0, 30.0, 40.0]]),
"labels": torch.tensor([1]),
"masks": masks,
}
aug_image, aug_target = wrapper(image, target)
assert isinstance(aug_image, Image.Image)
assert "masks" in aug_target
# Output masks should be bool after Albumentations processing
assert aug_target["masks"].dtype == torch.bool
def test_masks_transform_with_dropped_boxes(self):
"""Test wrapper filters masks appropriately when boxes are dropped by transform."""
# Use a crop transform to ensure a box is dropped
# Original image 100x100
# Box 1: [10, 10, 20, 20] (will be kept if we crop top-left)
# Box 2: [80, 80, 90, 90] (will be dropped if we crop top-left to 50x50)
transform = alb.Crop(x_min=0, y_min=0, x_max=50, y_max=50, p=1.0)
wrapper = AlbumentationsWrapper(transform)
height, width = 100, 100
image = Image.new("RGB", (width, height))
masks = torch.zeros((2, height, width), dtype=torch.uint8)
masks[0, 10:20, 10:20] = 1
masks[1, 80:90, 80:90] = 1
target = {
"boxes": torch.tensor([[10.0, 10.0, 20.0, 20.0], [80.0, 80.0, 90.0, 90.0]]),
"labels": torch.tensor([1, 2]),
"masks": masks,
}
aug_image, aug_target = wrapper(image, target)
assert isinstance(aug_image, Image.Image)
assert len(aug_target["boxes"]) == 1
assert len(aug_target["labels"]) == 1
assert "masks" in aug_target
assert len(aug_target["masks"]) == 1
assert aug_target["masks"].shape == (1, 50, 50)
def test_degenerate_bbox_at_image_boundary_is_silently_dropped(self):
"""Degenerate boxes (x_min == x_max or y_min == y_max) must not raise ValueError.
Regression test: COCO annotations sometimes place a box exactly on the image boundary so that both x coordinates
equal the image width (normalized: 1.0). Albumentations' check_bboxes rejects these with "x_max is less than or
equal to x_min", crashing the DataLoader worker.
"""
transform = alb.HorizontalFlip(p=1.0)
wrapper = AlbumentationsWrapper(transform)
width, height = 100, 100
image = Image.new("RGB", (width, height))
target = {
# box 0: valid box 1: x_min==x_max (right edge)
# box 2: y_min==y_max (bottom edge)
"boxes": torch.tensor(
[
[10.0, 20.0, 50.0, 60.0], # valid — should survive
[100.0, 14.0, 100.0, 17.0], # degenerate: x_min == x_max
[10.0, 100.0, 50.0, 100.0], # degenerate: y_min == y_max
]
),
"labels": torch.tensor([1, 2, 3]),
"area": torch.tensor([1600.0, 0.0, 0.0]),
}
# Must not raise ValueError
aug_image, aug_target = wrapper(image, target)
assert isinstance(aug_image, Image.Image)
# Only the valid box survives
assert aug_target["boxes"].shape[0] == 1, f"Expected 1 valid box, got {aug_target['boxes'].shape[0]}"
assert aug_target["labels"].tolist() == [1]
assert aug_target["area"].shape[0] == 1
def test_degenerate_bbox_mixed_with_masks(self):
"""Degenerate boxes are dropped together with their corresponding masks."""
transform = alb.HorizontalFlip(p=1.0)
wrapper = AlbumentationsWrapper(transform)
width, height = 100, 100
image = Image.new("RGB", (width, height))
masks = torch.zeros((2, height, width), dtype=torch.uint8)
masks[0, 20:60, 10:50] = 1 # valid mask
target = {
"boxes": torch.tensor(
[
[10.0, 20.0, 50.0, 60.0], # valid
[100.0, 14.0, 100.0, 17.0], # degenerate: x_min == x_max
]
),
"labels": torch.tensor([1, 2]),
"masks": masks,
}
aug_image, aug_target = wrapper(image, target)
assert aug_target["boxes"].shape[0] == 1
assert aug_target["labels"].tolist() == [1]
assert aug_target["masks"].shape[0] == 1
class TestAlbumentationsWrapperFromConfig:
"""Tests for AlbumentationsWrapper.from_config() static method."""
def test_build_from_valid_config(self):
"""Test building transforms from valid configuration."""
config = {
"HorizontalFlip": {"p": 0.5},
"VerticalFlip": {"p": 0.3},
}
transforms = AlbumentationsWrapper.from_config(config)
assert len(transforms) == 2
assert all(isinstance(t, AlbumentationsWrapper) for t in transforms)
# Validate transform names match config in correct order
transform_names = [t.transform.transforms[0].__class__.__name__ for t in transforms]
assert transform_names == list(config.keys())
def test_build_from_empty_config(self):
"""Test building from empty config returns empty list."""
config = {}
transforms = AlbumentationsWrapper.from_config(config)
assert len(transforms) == 0
def test_unknown_transform_skipped(self):
"""Test that unknown transforms are skipped with warning."""
config = {
"HorizontalFlip": {"p": 0.5},
"NonExistentTransform": {"p": 0.5},
}
transforms = AlbumentationsWrapper.from_config(config)
# Only valid transform should be included
assert len(transforms) == 1
transform_names = [t.transform.transforms[0].__class__.__name__ for t in transforms]
assert transform_names == ["HorizontalFlip"]
def test_invalid_params_skipped(self):
"""Test that transforms with invalid parameters are skipped."""
config = {
"HorizontalFlip": {"p": 0.5},
"Rotate": {"invalid_param": "value"}, # Will fail initialization
}
transforms = AlbumentationsWrapper.from_config(config)
# At least HorizontalFlip should succeed
assert len(transforms) >= 1
transform_names = [t.transform.transforms[0].__class__.__name__ for t in transforms]
assert transform_names[0] == "HorizontalFlip"
def test_invalid_config_type(self):
"""Test that invalid config type raises TypeError."""
with pytest.raises(TypeError, match="config_dict must be a dictionary or list"):
AlbumentationsWrapper.from_config("invalid")
def test_mixed_geometric_and_pixel_transforms(self):
"""Test building mix of geometric and pixel-level transforms."""
config = {
"HorizontalFlip": {"p": 1.0}, # Geometric
"GaussianBlur": {"p": 1.0}, # Pixel-level
}
transforms = AlbumentationsWrapper.from_config(config)
assert len(transforms) == 2
# Validate transform names match config in correct order
transform_names = [t.transform.transforms[0].__class__.__name__ for t in transforms]
assert transform_names == list(config.keys())
def test_config_with_complex_params(self):
"""Test building transforms with complex parameter structures."""
config = {
"Rotate": {"limit": (90, 90), "p": 0.5},
"Affine": {"scale": (0.9, 1.1), "translate_percent": (-0.1, 0.1), "p": 0.3},
}
transforms = AlbumentationsWrapper.from_config(config)
assert len(transforms) == 2
# Validate transform names match config in correct order
transform_names = [t.transform.transforms[0].__class__.__name__ for t in transforms]
assert transform_names == list(config.keys())
def test_non_dict_params_skipped(self):
"""Test that transforms with non-dict params are skipped."""
config = {
"HorizontalFlip": {"p": 0.5},
"InvalidTransform": "not_a_dict",
}
transforms = AlbumentationsWrapper.from_config(config)
assert len(transforms) == 1
transform_names = [t.transform.transforms[0].__class__.__name__ for t in transforms]
assert transform_names == ["HorizontalFlip"]
class TestRandomSizedCropCompat:
"""Tests for RandomSizedCrop cross-version parameter normalization edge cases."""
@pytest.mark.parametrize(
"params, expected_missing",
[
pytest.param(
{"min_max_height": [100, 200], "height": 256},
"width",
id="height_without_width",
),
pytest.param(
{"min_max_height": [100, 200], "width": 256},
"height",
id="width_without_height",
),
],
)
@mock.patch("rfdetr.datasets.transforms.alb.RandomSizedCrop", new=_FakeRandomSizedCropV2)
def test_errors_on_partial_hw_with_v2_api(self, params, expected_missing):
with pytest.raises(ValueError, match=f"missing '{expected_missing}'"):
_build_albu_transform("RandomSizedCrop", params)
@pytest.mark.parametrize(
"params",
[
pytest.param(
{"min_max_height": [100, 200], "size": (256, 256), "height": 256},
id="size_and_height",
),
pytest.param(
{"min_max_height": [100, 200], "size": (256, 256), "width": 256},
id="size_and_width",
),
pytest.param(
{
"min_max_height": [100, 200],
"size": (256, 256),
"height": 256,
"width": 256,
},
id="size_and_height_and_width",
),
],
)
@mock.patch("rfdetr.datasets.transforms.alb.RandomSizedCrop", new=_FakeRandomSizedCropV2)
def test_size_takes_precedence_over_hw_on_v2_api(self, params):
# No TypeError means height/width were correctly dropped before instantiation
transform = _build_albu_transform("RandomSizedCrop", params)
assert transform.size == (256, 256)
@mock.patch("rfdetr.datasets.transforms.alb.RandomSizedCrop", new=_FakeRandomSizedCropV1)
def test_scalar_size_passes_through_on_v1_legacy_path(self):
# Scalar size=640 does not match isinstance(size, Sequence), so the v1
# legacy branch leaves it in the params dict. FakeV1 does not accept
# ``size`` so this should raise a TypeError from the constructor — our
# normalization code does NOT raise a ValueError for this case.
with pytest.raises(TypeError):
_build_albu_transform(
"RandomSizedCrop",
{"min_max_height": [100, 200], "size": 640},
)
@mock.patch("rfdetr.datasets.transforms.alb.RandomSizedCrop", new=_FakeRandomSizedCropV2)
def test_adapts_height_width_for_v2_api(self):
"""RandomSizedCrop config with height/width is adapted to the Albumentations 2.x size API."""
transform = _build_albu_transform(
"RandomSizedCrop",
{"min_max_height": [384, 600], "height": 640, "width": 640},
)
assert isinstance(transform, _FakeRandomSizedCropV2)
assert transform.min_max_height == [384, 600]
assert transform.size == (640, 640)
@mock.patch("rfdetr.datasets.transforms.alb.RandomSizedCrop", new=_FakeRandomSizedCropV1)
def test_adapts_size_for_v1_api(self):
"""RandomSizedCrop config with size is adapted to the Albumentations 1.x height/width API."""
transform = _build_albu_transform(
"RandomSizedCrop",
{"min_max_height": [384, 600], "size": (640, 640)},
)
assert isinstance(transform, _FakeRandomSizedCropV1)
assert transform.min_max_height == [384, 600]
assert transform.height == 640
assert transform.width == 640
@mock.patch("rfdetr.datasets.transforms.alb.RandomSizedCrop", new=_FakeRandomSizedCropV2)
def test_from_config_partial_height_is_silently_skipped(self):
"""from_config swallows the ValueError for partial height-only config and skips the transform.
This documents the intentional silent-skip behavior: from_config wraps _build_albu_transform in a broad except
clause so bad configs produce a warning rather than an exception.
"""
config = {
"HorizontalFlip": {"p": 0.5},
"RandomSizedCrop": {"min_max_height": [100, 200], "height": 256},
}
transforms = AlbumentationsWrapper.from_config(config)
# The invalid RandomSizedCrop is silently dropped; only HorizontalFlip survives.
assert len(transforms) == 1
transform_names = [t.transform.transforms[0].__class__.__name__ for t in transforms]
assert transform_names == ["HorizontalFlip"]
class TestAlbumentationsWrapperNestedConfig:
"""Tests for nested container (OneOf, SomeOf, Sequential) support in from_config."""
def test_one_of_geometric_detection(self):
"""OneOf containing a geometric transform is treated as geometric."""
wrapper = AlbumentationsWrapper(alb.OneOf([alb.HorizontalFlip(p=1.0), alb.GaussianBlur(p=1.0)]))
assert wrapper._is_geometric is True
def test_one_of_pixel_detection(self):
"""OneOf containing only pixel transforms is treated as pixel-level."""
wrapper = AlbumentationsWrapper(alb.OneOf([alb.GaussianBlur(p=1.0), alb.Blur(p=1.0)]))
assert wrapper._is_geometric is False
def test_sequential_geometric_detection(self):
"""Sequential containing a geometric transform is treated as geometric."""
wrapper = AlbumentationsWrapper(alb.Sequential([alb.Rotate(limit=45, p=1.0), alb.GaussianBlur(p=1.0)]))
assert wrapper._is_geometric is True
def test_from_config_nested_one_of(self):
"""from_config builds a OneOf wrapper from nested config; p is ignored."""
config = {
"OneOf": {
"transforms": [
{"HorizontalFlip": {"p": 1.0}},
{"VerticalFlip": {"p": 1.0}},
],
}
}
transforms = AlbumentationsWrapper.from_config(config)
assert len(transforms) == 1
wrapper = transforms[0]
assert isinstance(wrapper, AlbumentationsWrapper)
assert wrapper._is_geometric is True
# The inner Albumentations transform should be OneOf
inner = wrapper.transform.transforms[0]
assert isinstance(inner, alb.OneOf)
assert len(inner.transforms) == 2
def test_from_config_nested_one_of_pixel_only(self):
"""from_config OneOf with only pixel transforms is non-geometric."""
config = {
"OneOf": {
"transforms": [
{"GaussianBlur": {"p": 1.0}},
{"Blur": {"p": 1.0}},
],
}
}
transforms = AlbumentationsWrapper.from_config(config)
assert len(transforms) == 1
assert transforms[0]._is_geometric is False
def test_from_config_deeply_nested(self):
"""from_config handles nested containers (OneOf inside Sequential)."""
config = {
"Sequential": {
"transforms": [
{
"OneOf": {
"transforms": [
{"HorizontalFlip": {"p": 1.0}},
{"VerticalFlip": {"p": 1.0}},
],
}
},
{"GaussianBlur": {"p": 1.0}},
],
}
}
transforms = AlbumentationsWrapper.from_config(config)
assert len(transforms) == 1
assert transforms[0]._is_geometric is True
inner = transforms[0].transform.transforms[0]
assert isinstance(inner, alb.Sequential)
assert isinstance(inner.transforms[0], alb.OneOf)
def test_from_config_shorthand_list(self):
"""from_config supports shorthand {OneOf: [...]} without explicit transforms key."""
config = {
"OneOf": [
{"HorizontalFlip": {"p": 1.0}},
{"VerticalFlip": {"p": 1.0}},
]
}
transforms = AlbumentationsWrapper.from_config(config)
assert len(transforms) == 1
inner = transforms[0].transform.transforms[0]
assert isinstance(inner, alb.OneOf)
assert len(inner.transforms) == 2
def test_from_config_nested_sequential(self):
"""from_config builds a Sequential wrapper from nested config."""
config = {
"Sequential": {
"transforms": [
{"Rotate": {"limit": 45, "p": 1.0}},
{"GaussianBlur": {"p": 1.0}},
],
}
}
transforms = AlbumentationsWrapper.from_config(config)
assert len(transforms) == 1
inner = transforms[0].transform.transforms[0]
assert isinstance(inner, alb.Sequential)
assert len(inner.transforms) == 2
def test_from_config_list_format(self):
"""from_config accepts list-of-single-key-dicts format."""
config = [
{"HorizontalFlip": {"p": 0.5}},
{
"OneOf": {
"transforms": [
{"VerticalFlip": {"p": 1.0}},
{"Rotate": {"limit": 45, "p": 1.0}},
],
}
},
]
transforms = AlbumentationsWrapper.from_config(config)
assert len(transforms) == 2
assert isinstance(transforms[0], AlbumentationsWrapper)
assert isinstance(transforms[1].transform.transforms[0], alb.OneOf)
def test_from_config_mixed_flat_and_nested(self):
"""from_config handles mix of flat and nested transforms."""
config = {
"HorizontalFlip": {"p": 0.5},
"OneOf": {
"transforms": [
{"GaussianBlur": {"p": 1.0}},
{"Blur": {"p": 1.0}},
],
},
"Rotate": {"limit": 15, "p": 0.3},
}
transforms = AlbumentationsWrapper.from_config(config)
assert len(transforms) == 3
def test_from_config_one_of_applies_correctly_geometric(self):
"""OneOf geometric wrapper correctly transforms boxes (always fires)."""
config = {
"OneOf": {
"transforms": [
{"HorizontalFlip": {"p": 1.0}},
{"VerticalFlip": {"p": 0.0}},
],
}
}
transforms = AlbumentationsWrapper.from_config(config)
wrapper = transforms[0]
image = Image.new("RGB", (100, 80))
target = {
"boxes": torch.tensor([[10.0, 20.0, 50.0, 60.0]]),
"labels": torch.tensor([1]),
}
aug_image, aug_target = wrapper(image, target)
assert isinstance(aug_image, Image.Image)
expected_boxes = torch.tensor([[50.0, 20.0, 90.0, 60.0]])
torch.testing.assert_close(aug_target["boxes"], expected_boxes)
def test_from_config_one_of_applies_correctly_pixel(self):
"""OneOf pixel-level wrapper preserves boxes unchanged."""
config = {
"OneOf": {
"transforms": [
{"GaussianBlur": {"blur_limit": 3, "p": 1.0}},
{"Blur": {"blur_limit": 3, "p": 1.0}},
],
}
}
transforms = AlbumentationsWrapper.from_config(config)
wrapper = transforms[0]
image = Image.new("RGB", (100, 80))
original_boxes = torch.tensor([[10.0, 20.0, 50.0, 60.0]])
target = {
"boxes": original_boxes.clone(),
"labels": torch.tensor([1]),
}
aug_image, aug_target = wrapper(image, target)
assert isinstance(aug_image, Image.Image)
torch.testing.assert_close(aug_target["boxes"], original_boxes)
def test_one_of_p_in_config_is_ignored(self):
"""Any p supplied for OneOf in config is ignored; container always fires."""
config = {
"OneOf": {
"transforms": [{"HorizontalFlip": {"p": 1.0}}],
"p": 0.0, # would suppress the container if respected
}
}
transforms = AlbumentationsWrapper.from_config(config)
inner = transforms[0].transform.transforms[0]
assert isinstance(inner, alb.OneOf)
assert inner.p == pytest.approx(1.0)
def test_one_of_empty_transforms_raises(self):
"""OneOf with no transforms raises ValueError."""
with pytest.raises(ValueError, match="at least one"):
_build_albu_transform("OneOf", {"transforms": []})
def test_sequential_p_in_config_is_ignored(self):
"""Any p supplied for Sequential in config is ignored; container always fires."""
config = {
"Sequential": {
"transforms": [{"HorizontalFlip": {"p": 1.0}}],
"p": 0.0, # would suppress the container if respected
}
}
transforms = AlbumentationsWrapper.from_config(config)
inner = transforms[0].transform.transforms[0]
assert isinstance(inner, alb.Sequential)
assert inner.p == pytest.approx(1.0)
def test_some_of_single_p_still_works(self):
"""SomeOf with a plain p (block probability) still works without probs."""
config = {
"SomeOf": {
"transforms": [
{"HorizontalFlip": {}},
{"VerticalFlip": {}},
],
"n": 1,
"p": 0.5,
}
}
transforms = AlbumentationsWrapper.from_config(config)
inner = transforms[0].transform.transforms[0]
assert isinstance(inner, alb.SomeOf)
assert inner.p == pytest.approx(0.5)
@pytest.mark.parametrize(
"hflip_name",
[
pytest.param("HorizontalFlip", id="HorizontalFlip"),
pytest.param("Flip", id="Flip"),
pytest.param("D4", id="D4"),
],
)
def test_hflip_disabled_for_keypoint_pipeline(self, hflip_name: str) -> None:
"""HFlip-type transforms are skipped when keypoint_flip_pairs is provided."""
config = {hflip_name: {"p": 0.5}, "GaussianBlur": {"p": 0.5}}
transforms = AlbumentationsWrapper.from_config(config, keypoint_flip_pairs=[])
names = [t.transform.transforms[0].__class__.__name__ for t in transforms]
assert hflip_name not in names
assert "GaussianBlur" in names
def test_hflip_disable_logs_warning(self) -> None:
"""from_config logs a warning when HorizontalFlip is disabled for a keypoint pipeline."""
config = {"HorizontalFlip": {"p": 0.5}}
with mock.patch("rfdetr.datasets.transforms.logger") as mock_log:
AlbumentationsWrapper.from_config(config, keypoint_flip_pairs=[])
assert mock_log.warning.called
call_args = mock_log.warning.call_args[0]
assert "HorizontalFlip" in str(call_args)
def test_hflip_included_when_no_keypoints(self) -> None:
"""HorizontalFlip is NOT skipped when keypoint_flip_pairs is None (detection pipeline)."""
config = {"HorizontalFlip": {"p": 0.5}}
transforms = AlbumentationsWrapper.from_config(config, keypoint_flip_pairs=None)
names = [t.transform.transforms[0].__class__.__name__ for t in transforms]
assert "HorizontalFlip" in names
def test_hflip_included_when_keypoint_flip_pairs_are_configured(self) -> None:
"""HorizontalFlip is safe for keypoint pipelines when semantic flip pairs are configured."""
config = {"HorizontalFlip": {"p": 0.5}}
transforms = AlbumentationsWrapper.from_config(config, keypoint_flip_pairs=[0, 1])
names = [t.transform.transforms[0].__class__.__name__ for t in transforms]
assert "HorizontalFlip" in names
class TestIntegration:
"""Integration tests for full augmentation pipeline."""
def test_full_pipeline_from_config(self):
"""Test complete pipeline from config to application."""
config = {
"HorizontalFlip": {"p": 1.0},
"VerticalFlip": {"p": 0.0}, # Will not apply
}
# Build transforms from config
transforms = AlbumentationsWrapper.from_config(config)
# Validate transform names match config in correct order
assert len(transforms) == 2
transform_names = [t.transform.transforms[0].__class__.__name__ for t in transforms]
assert transform_names == list(config.keys())
# Compose them
composed = Compose(transforms)
# Apply to data
image = Image.new("RGB", (100, 100))
target = {
"boxes": torch.tensor([[10.0, 20.0, 30.0, 40.0]]),
"labels": torch.tensor([1]),
}
aug_image, aug_target = composed(image, target)
assert isinstance(aug_image, Image.Image)
assert aug_target["boxes"].shape == (1, 4)
assert aug_target["labels"].shape == (1,)
def test_pipeline_with_no_boxes(self):
"""Test pipeline works when target has no boxes."""
config = {
"GaussianBlur": {"p": 1.0},
}
transforms = AlbumentationsWrapper.from_config(config)
# Validate transform names match config
assert len(transforms) == 1
transform_names = [t.transform.transforms[0].__class__.__name__ for t in transforms]
assert transform_names == list(config.keys())
composed = Compose(transforms)
image = Image.new("RGB", (100, 100))
target = {"labels": torch.tensor([1])}
aug_image, aug_target = composed(image, target)
assert isinstance(aug_image, Image.Image)
assert "labels" in aug_target
def test_realistic_augmentation_config(self):
"""Test with realistic augmentation configuration."""
aug_config = {
"HorizontalFlip": {"p": 0.5},
"VerticalFlip": {"p": 0.5},
"Rotate": {"limit": 15, "p": 0.5}, # Better keep small angles
}
transforms = AlbumentationsWrapper.from_config(aug_config)
# Validate transform names match in correct order
assert len(transforms) == 3
transform_names = [t.transform.transforms[0].__class__.__name__ for t in transforms]
assert transform_names == list(aug_config.keys())
composed = Compose(transforms)
image = Image.new("RGB", (640, 480))
target = {
"boxes": torch.tensor([[50.0, 60.0, 200.0, 300.0], [300.0, 100.0, 500.0, 400.0]]),
"labels": torch.tensor([1, 2]),
}
aug_image, aug_target = composed(image, target)
assert isinstance(aug_image, Image.Image)
# Boxes might be filtered out by augmentations, so check shape is valid
assert aug_target["boxes"].shape[1] == 4
assert aug_target["labels"].shape[0] == aug_target["boxes"].shape[0]
def test_full_pipeline_with_masks(self):
"""Test complete pipeline with masks from config to application."""
config = {
"HorizontalFlip": {"p": 1.0},
"VerticalFlip": {"p": 0.0}, # Don't apply to make test deterministic
}
transforms = AlbumentationsWrapper.from_config(config)
composed = Compose(transforms)
height, width = 100, 100
image = Image.new("RGB", (width, height))
masks = torch.zeros((2, height, width), dtype=torch.uint8)
masks[0, 10:30, 10:30] = 1
masks[1, 50:70, 50:70] = 1
target = {
"boxes": torch.tensor([[10.0, 10.0, 30.0, 30.0], [50.0, 50.0, 70.0, 70.0]]),
"labels": torch.tensor([1, 2]),
"masks": masks,
}
aug_image, aug_target = composed(image, target)
assert isinstance(aug_image, Image.Image)
assert "masks" in aug_target
assert aug_target["boxes"].shape[0] == aug_target["masks"].shape[0]
assert aug_target["labels"].shape[0] == aug_target["masks"].shape[0]
assert aug_target["masks"].any() # Masks should have content
def test_pipeline_mixed_geometric_pixel_with_masks(self):
"""Test pipeline with mix of geometric and pixel transforms on masks."""
config = {
"HorizontalFlip": {"p": 1.0}, # Geometric
"GaussianBlur": {"p": 1.0}, # Pixel
}
transforms = AlbumentationsWrapper.from_config(config)
composed = Compose(transforms)
height, width = 100, 100
image = Image.new("RGB", (width, height))
masks = torch.zeros((1, height, width), dtype=torch.uint8)
masks[0, 20:40, 10:30] = 1
target = {
"boxes": torch.tensor([[10.0, 20.0, 30.0, 40.0]]),
"labels": torch.tensor([1]),
"masks": masks,
}
aug_image, aug_target = composed(image, target)
assert isinstance(aug_image, Image.Image)
assert "masks" in aug_target
assert aug_target["masks"].shape == (1, height, width)
assert aug_target["masks"].any()
@pytest.mark.parametrize("num_instances", [1, 2, 5])
def test_pipeline_scales_with_instances(self, num_instances):
"""Test pipeline handles varying numbers of instances correctly."""
config = {
"HorizontalFlip": {"p": 1.0},
}
transforms = AlbumentationsWrapper.from_config(config)
composed = Compose(transforms)
height, width = 100, 100
image = Image.new("RGB", (width, height))
# Create multiple boxes and masks
boxes = []
masks = torch.zeros((num_instances, height, width), dtype=torch.uint8)
for i in range(num_instances):
x = i * 15 + 10
y = i * 15 + 10
boxes.append([float(x), float(y), float(x + 15), float(y + 15)])
x1, y1, x2, y2 = int(x), int(y), int(x + 15), int(y + 15)
masks[i, y1:y2, x1:x2] = 1
target = {
"boxes": torch.tensor(boxes),
"labels": torch.arange(1, num_instances + 1),
"masks": masks,
}
aug_image, aug_target = composed(image, target)
assert isinstance(aug_image, Image.Image)
assert aug_target["boxes"].shape[0] <= num_instances # May be filtered
assert aug_target["masks"].shape[0] == aug_target["boxes"].shape[0]
assert aug_target["labels"].shape[0] == aug_target["boxes"].shape[0]
class TestTrainingLoop:
"""Test augmentations work correctly in training loop scenario."""
def test_augmentation_in_dataloader(self):
"""Test that augmentations work correctly when used with DataLoader.
This is a critical integration test that simulates actual training conditions where multiple samples with
different numbers of boxes are batched together. It specifically tests that orig_size remains consistent across
the batch.
"""
# Create augmentations
aug_transforms = [
AlbumentationsWrapper(alb.HorizontalFlip(p=0.5)),
AlbumentationsWrapper(alb.Rotate(limit=10, p=0.5)),
]
transforms = Compose(aug_transforms)
# Create dataset and dataloader
dataset = _SimpleDataset(num_samples=12, transforms=transforms)
dataloader = DataLoader(dataset, batch_size=4, shuffle=True, collate_fn=collate_fn, num_workers=0)
# Run through batches
for batch_idx, (images, targets) in enumerate(dataloader):
# Check orig_size consistency
orig_sizes = [t["orig_size"] for t in targets]
# Verify all orig_sizes have shape [2]
for i, orig_size in enumerate(orig_sizes):
assert orig_size.shape == torch.Size([2]), (
f"Batch {batch_idx}, target {i}: orig_size has shape {orig_size.shape}, expected [2]"
)
# Critical test: This is what fails in training if orig_size is corrupted
orig_target_sizes = torch.stack(orig_sizes, dim=0)
assert orig_target_sizes.shape == torch.Size([len(targets), 2]), (
f"Batch {batch_idx}: stacked orig_sizes has shape {orig_target_sizes.shape}"
)
# Verify images and targets are valid
assert images.tensors.shape[0] == len(targets)
num_boxes = [len(t["boxes"]) for t in targets]
assert all(n > 0 for n in num_boxes), "All targets should have at least one box"
# Only test a few batches for speed
if batch_idx >= 1:
break
def test_augmentation_with_varying_box_counts(self):
"""Test that samples with 1, 2, and 3 boxes all work correctly in same batch.
This specifically tests the edge case where some samples have 2 boxes (which matches orig_size shape [2]),
ensuring they don't get mixed up.
"""
aug_transforms = [AlbumentationsWrapper(alb.HorizontalFlip(p=0.5))]
transforms = Compose(aug_transforms)
# Create dataset with samples that have different numbers of boxes
dataset = _SimpleDataset(num_samples=9, transforms=transforms) # Will cycle through 1,2,3 boxes
dataloader = DataLoader(
dataset,
batch_size=6, # Batch will contain mix of 1,2,3 box samples
shuffle=False,
collate_fn=collate_fn,
num_workers=0,
)
# Get one batch
images, targets = next(iter(dataloader))
# Verify we have samples with different numbers of boxes
num_boxes_list = [len(t["boxes"]) for t in targets]
assert 1 in num_boxes_list, "Should have samples with 1 box"
assert 2 in num_boxes_list, "Should have samples with 2 boxes (critical edge case)"
assert 3 in num_boxes_list, "Should have samples with 3 boxes"
# Verify all orig_sizes are consistent
for i, target in enumerate(targets):
assert target["orig_size"].shape == torch.Size([2]), (
f"Target {i} (with {num_boxes_list[i]} boxes): orig_size shape is {target['orig_size'].shape}"
)
# Verify they can be stacked
orig_sizes = torch.stack([t["orig_size"] for t in targets], dim=0)
assert orig_sizes.shape == torch.Size([len(targets), 2])
@pytest.mark.parametrize(
"transform_class,transform_kwargs",
[
pytest.param(alb.HorizontalFlip, {"p": 1.0}, id="horizontal_flip"),
pytest.param(alb.VerticalFlip, {"p": 1.0}, id="vertical_flip"),
pytest.param(alb.RandomRotate90, {"p": 1.0}, id="random_rotate_90"),
],
)
@pytest.mark.parametrize(
"include_masks", [pytest.param(False, id="detection"), pytest.param(True, id="segmentation")]
)
def test_geometric_dataloader_compatibility(self, include_masks, transform_class, transform_kwargs):
"""Test geometric Albumentations transforms work in DataLoader for detection and segmentation."""
class _TinyTrainDataset:
def __init__(self, transforms):
self._transforms = transforms
def __len__(self):
return 2
def __getitem__(self, idx):
height, width = 64, 64
image = Image.new("RGB", (width, height))
target = {
"boxes": torch.tensor([[8.0, 12.0, 24.0, 28.0]], dtype=torch.float32),
"labels": torch.tensor([1], dtype=torch.int64),
"orig_size": torch.tensor([height, width]),
"size": torch.tensor([height, width]),
"image_id": torch.tensor([idx]),
"area": torch.tensor([256.0]),
"iscrowd": torch.tensor([0]),
}
if include_masks:
masks = torch.zeros((1, height, width), dtype=torch.bool)
masks[0, 12:28, 8:24] = True
target["masks"] = masks
image, target = self._transforms(image, target)
image = torch.from_numpy(np.array(image)).permute(2, 0, 1).float() / 255.0
return image, target
transforms = Compose([AlbumentationsWrapper(transform_class(**transform_kwargs))])
dataloader = DataLoader(_TinyTrainDataset(transforms), batch_size=2, collate_fn=collate_fn, num_workers=0)
images, targets = next(iter(dataloader))
assert images.tensors.shape[0] == 2
for target in targets:
assert target["boxes"].shape == (1, 4)
assert target["labels"].shape == (1,)
if include_masks:
assert target["masks"].shape == (1, 64, 64)
assert target["masks"].dtype == torch.bool
class TestMakeCocoTransformsAugConfig:
"""Tests for aug_config propagation in make_coco_transforms / make_coco_transforms_square_div_64."""
@pytest.mark.parametrize(
"make_transforms",
[
make_coco_transforms,
make_coco_transforms_square_div_64,
],
)
def test_default_none_uses_aug_config(self, make_transforms):
"""Omitting aug_config uses the module-level AUG_CONFIG default (HorizontalFlip)."""
pipeline = make_transforms("train", 640)
# Train pipeline: [resize_wrapper, *aug_wrappers, normalize]
# First AlbumentationsWrapper is the resize OneOf; remaining are from aug_config.
wrappers = [t for t in pipeline.transforms if isinstance(t, AlbumentationsWrapper)]
aug_wrappers = wrappers[1:]
expected_names = list(AUG_CONFIG.keys())
actual_names = [w.transform.transforms[0].__class__.__name__ for w in aug_wrappers]
assert actual_names == expected_names
@pytest.mark.parametrize(
"make_transforms",
[
make_coco_transforms,
make_coco_transforms_square_div_64,
],
)
def test_empty_dict_disables_augmentations(self, make_transforms):
"""aug_config={} means no aug wrappers beyond the resize wrapper."""
pipeline = make_transforms("train", 640, aug_config={})
wrappers = [t for t in pipeline.transforms if isinstance(t, AlbumentationsWrapper)]
aug_wrappers = wrappers[1:] # skip resize wrapper
assert aug_wrappers == []
@pytest.mark.parametrize(
"make_transforms",
[
make_coco_transforms,
make_coco_transforms_square_div_64,
],
)
def test_custom_dict_is_used(self, make_transforms):
"""aug_config with a custom dict wires up exactly those transforms."""
custom = {"HorizontalFlip": {"p": 1.0}}
pipeline = make_transforms("train", 640, aug_config=custom)
wrappers = [t for t in pipeline.transforms if isinstance(t, AlbumentationsWrapper)]
aug_wrappers = wrappers[1:] # skip resize wrapper
assert len(aug_wrappers) == 1
assert aug_wrappers[0].transform.transforms[0].__class__.__name__ == "HorizontalFlip"
@pytest.mark.parametrize(
"make_transforms,expected_resize_wrappers",
[
# make_coco_transforms val: SmallestMaxSize + LongestMaxSize = 2 wrappers
pytest.param(make_coco_transforms, 2, id="make_coco_transforms"),
# make_coco_transforms_square_div_64 val: Resize = 1 wrapper
pytest.param(make_coco_transforms_square_div_64, 1, id="make_coco_transforms_square_div_64"),
],
)
def test_aug_config_not_applied_on_val(self, make_transforms, expected_resize_wrappers):
"""aug_config is ignored for val splits — only resize wrappers are present."""
pipeline = make_transforms("val", 640, aug_config={"HorizontalFlip": {"p": 1.0}})
wrappers = [t for t in pipeline.transforms if isinstance(t, AlbumentationsWrapper)]
assert len(wrappers) == expected_resize_wrappers
@pytest.mark.parametrize(
"make_transforms",
[
make_coco_transforms,
make_coco_transforms_square_div_64,
],
)
def test_aug_config_not_applied_on_val_speed(self, make_transforms):
"""aug_config is ignored for val_speed splits — only the resize wrapper is present."""
pipeline = make_transforms("val_speed", 640, aug_config={"HorizontalFlip": {"p": 1.0}})
wrappers = [t for t in pipeline.transforms if isinstance(t, AlbumentationsWrapper)]
assert len(wrappers) == 1
@pytest.mark.parametrize(
"make_transforms,expected_resize_wrappers",
[
# make_coco_transforms test: SmallestMaxSize + LongestMaxSize = 2 wrappers
pytest.param(make_coco_transforms, 2, id="make_coco_transforms"),
# make_coco_transforms_square_div_64 test: Resize = 1 wrapper
pytest.param(make_coco_transforms_square_div_64, 1, id="make_coco_transforms_square_div_64"),
],
)
def test_aug_config_not_applied_on_test(self, make_transforms, expected_resize_wrappers):
"""aug_config is ignored for test splits — only resize wrappers are present."""
pipeline = make_transforms("test", 640, aug_config={"HorizontalFlip": {"p": 1.0}})
wrappers = [t for t in pipeline.transforms if isinstance(t, AlbumentationsWrapper)]
assert len(wrappers) == expected_resize_wrappers
class TestMakeCocoTransformsOutputSize:
"""Regression tests for #979: transforms must resize high-resolution images to the target resolution.
These tests verify that ``make_coco_transforms`` and ``make_coco_transforms_square_div_64`` actually produce output
images at the requested ``resolution``, not at the original image size. Existing tests only check pipeline
*structure*; these check actual output *dimensions*.
"""
# 1920x1080 (landscape) — larger than any typical training resolution.
# PIL size is (width, height), so Image.new("RGB", (1920, 1080)) gives a 1920-wide, 1080-tall image.
_INPUT_W = 1920
_INPUT_H = 1080
_RESOLUTION = 640
def _make_image(self) -> Image.Image:
return Image.new("RGB", (self._INPUT_W, self._INPUT_H))
def test_square_val_resizes_large_image(self) -> None:
"""Square val transform resizes 1920x1080 to exactly 640x640."""
transform = make_coco_transforms_square_div_64("val", self._RESOLUTION)
tensor, _ = transform(self._make_image(), None)
assert tensor.shape[-2:] == (self._RESOLUTION, self._RESOLUTION)
def test_square_train_resizes_large_image(self) -> None:
"""Square train transform resizes 1920x1080 to 640x640 regardless of OneOf branch."""
transform = make_coco_transforms_square_div_64("train", self._RESOLUTION, aug_config={})
tensor, _ = transform(self._make_image(), None)
assert tensor.shape[-2:] == (self._RESOLUTION, self._RESOLUTION)
def test_nonsquare_val_resizes_and_caps_longest_side(self) -> None:
"""Non-square val transform resizes the image and keeps the longest side within 1333 px.
Avoid asserting an exact output dimension here because Albumentations resize behavior can vary across supported
versions. The stable contract is that the image is resized and the longest side does not exceed the configured
maximum.
"""
transform = make_coco_transforms("val", self._RESOLUTION)
tensor, _ = transform(self._make_image(), None)
height, width = tensor.shape[-2], tensor.shape[-1]
assert (height, width) != (self._INPUT_H, self._INPUT_W)
assert max(height, width) <= 1333
def test_nonsquare_val_longest_side_at_most_1333(self) -> None:
"""Non-square val transform caps the longest side at 1333 px.
Use an input that still exceeds 1333 px on its longest side after SmallestMaxSize(640), so this assertion
specifically validates that LongestMaxSize(1333) is applied.
"""
transform = make_coco_transforms("val", self._RESOLUTION)
image = Image.new("RGB", (4000, 1000))
tensor, _ = transform(image, None)
height, width = tensor.shape[-2], tensor.shape[-1]
assert max(height, width) <= 1333
def test_nonsquare_val_does_not_pass_original_dimensions(self) -> None:
"""Non-square val transform must not emit the original 1920x1080 dimensions — the core regression."""
transform = make_coco_transforms("val", self._RESOLUTION)
tensor, _ = transform(self._make_image(), None)
height, width = tensor.shape[-2], tensor.shape[-1]
assert (height, width) != (self._INPUT_H, self._INPUT_W), (
f"Transform emitted original {self._INPUT_H}x{self._INPUT_W} — resize was not applied"
)
def test_square_val_does_not_pass_original_dimensions(self) -> None:
"""Square val transform must not emit the original 1920x1080 dimensions — the core regression."""
transform = make_coco_transforms_square_div_64("val", self._RESOLUTION)
tensor, _ = transform(self._make_image(), None)
height, width = tensor.shape[-2], tensor.shape[-1]
assert (height, width) != (self._INPUT_H, self._INPUT_W), (
f"Transform emitted original {self._INPUT_H}x{self._INPUT_W} — resize was not applied"
)
def test_output_is_float_tensor(self) -> None:
"""Transform pipeline produces a float32 tensor, not a PIL Image."""
transform = make_coco_transforms_square_div_64("val", self._RESOLUTION)
tensor, _ = transform(self._make_image(), None)
assert isinstance(tensor, torch.Tensor)
assert tensor.dtype == torch.float32
class TestAugPresets:
"""Regression tests for built-in augmentation presets."""
def test_aug_aggressive_translate_percent_is_bidirectional(self) -> None:
"""AUG_AGGRESSIVE translate_percent must allow both positive and negative translations.
(0.1, 0.1) is a degenerate range that only shifts right/down; the correct range is (-0.1, 0.1).
"""
translate = AUG_AGGRESSIVE["Affine"]["translate_percent"]
lo, hi = translate
assert lo < 0, (
f"AUG_AGGRESSIVE translate_percent lower bound must be negative to allow "
f"left/up translation; got {translate!r}"
)
assert hi > 0, (
f"AUG_AGGRESSIVE translate_percent upper bound must be positive to allow "
f"right/down translation; got {translate!r}"
)
assert lo < hi, f"AUG_AGGRESSIVE translate_percent must be a non-degenerate range; got {translate!r}"
class TestKeypointScalingAcrossResolutions:
"""Keypoint coordinates are correctly normalised when training at non-default resolutions.
The full transform pipeline — Resize → ToImage → ToDtype → Normalize — applies Normalize last. Normalize divides
keypoint x by image width and y by image height, so the model always receives relative [0, 1] coordinates regardless
of the configured training resolution. These tests confirm that contract holds for all supported resolutions (576
default, 640, 768, 960).
"""
# Input image: 480 wide, 360 tall. Box occupies the centre quadrant.
_INPUT_W = 480
_INPUT_H = 360
# Keypoint at exactly 60 % of each axis — well inside the box, visibility=2.
_KP_FRAC = 0.6
_KP_X = _KP_FRAC * _INPUT_W # 288.0
_KP_Y = _KP_FRAC * _INPUT_H # 216.0
def _make_target(self) -> dict:
return {
"boxes": torch.tensor([[120.0, 90.0, 360.0, 270.0]]),
"labels": torch.tensor([1]),
"keypoints": torch.tensor([[[self._KP_X, self._KP_Y, 2.0], [0.0, 0.0, 0.0]]]),
}
@pytest.mark.parametrize(
"resolution",
[
pytest.param(576, id="default_576"),
pytest.param(640, id="larger_640"),
pytest.param(768, id="larger_768"),
pytest.param(960, id="larger_960"),
],
)
def test_val_pipeline_keypoints_normalised_to_unit_range(self, resolution: int) -> None:
"""After Normalize, visible keypoint coords are in [0, 1] for every resolution."""
transform = make_coco_transforms_square_div_64("val", resolution)
image = Image.new("RGB", (self._INPUT_W, self._INPUT_H))
_, transformed = transform(image, self._make_target())
kp = transformed["keypoints"]
assert kp[0, 0, 0].item() == pytest.approx(self._KP_FRAC, abs=1e-3), (
f"normalised keypoint x must equal original fraction {self._KP_FRAC} at resolution={resolution}"
)
assert kp[0, 0, 1].item() == pytest.approx(self._KP_FRAC, abs=1e-3), (
f"normalised keypoint y must equal original fraction {self._KP_FRAC} at resolution={resolution}"
)
assert kp[0, 0, 2].item() == pytest.approx(2.0), "visibility must be preserved after normalisation"
@pytest.mark.parametrize(
"resolution",
[
pytest.param(576, id="default_576"),
pytest.param(640, id="larger_640"),
pytest.param(768, id="larger_768"),
pytest.param(960, id="larger_960"),
],
)
def test_val_pipeline_invisible_keypoints_stay_zero(self, resolution: int) -> None:
"""Zero-visibility keypoints must remain at (0, 0, 0) after the full pipeline."""
transform = make_coco_transforms_square_div_64("val", resolution)
image = Image.new("RGB", (self._INPUT_W, self._INPUT_H))
_, transformed = transform(image, self._make_target())
kp = transformed["keypoints"]
torch.testing.assert_close(
kp[0, 1],
torch.zeros(3),
rtol=0.0,
atol=0.0,
)
@pytest.mark.parametrize(
"resolution",
[
pytest.param(576, id="default_576"),
pytest.param(640, id="larger_640"),
pytest.param(768, id="larger_768"),
pytest.param(960, id="larger_960"),
],
)
def test_train_pipeline_visible_keypoints_normalised_in_unit_range(self, resolution: int) -> None:
"""Train transform (random resize/crop) must produce normalised keypoints in [0, 1]."""
transform = make_coco_transforms_square_div_64("train", resolution, aug_config={})
image = Image.new("RGB", (self._INPUT_W, self._INPUT_H))
_, transformed = transform(image, self._make_target())
kp = transformed["keypoints"] # shape (N, K, 3); x and y already normalised by Normalize
visible_mask = kp[:, :, 2] > 0 # (N, K) bool
if visible_mask.any():
visible_x = kp[:, :, 0][visible_mask]
visible_y = kp[:, :, 1][visible_mask]
assert (visible_x >= 0).all() and (visible_x <= 1.0).all(), (
f"normalised keypoint x out of [0, 1] at resolution={resolution}: {visible_x}"
)
assert (visible_y >= 0).all() and (visible_y <= 1.0).all(), (
f"normalised keypoint y out of [0, 1] at resolution={resolution}: {visible_y}"
)
@pytest.mark.parametrize(
"resolution",
[
pytest.param(576, id="default_576"),
pytest.param(640, id="larger_640"),
pytest.param(768, id="larger_768"),
pytest.param(960, id="larger_960"),
],
)
def test_output_tensor_shape_matches_resolution(self, resolution: int) -> None:
"""Transform output image tensor must be (C, resolution, resolution) for all resolutions."""
transform = make_coco_transforms_square_div_64("val", resolution, aug_config={})
image = Image.new("RGB", (self._INPUT_W, self._INPUT_H))
tensor, _ = transform(image, None)
assert tensor.shape[-2:] == (resolution, resolution), (
f"expected (C, {resolution}, {resolution}), got {tuple(tensor.shape)}"
)
class TestNormalize:
"""Unit tests for Normalize.__call__."""
def test_normalize_call_is_bound_method(self) -> None:
"""Normalize.__call__ must be a class method, not a module-level function."""
import inspect
normalize = Normalize()
assert callable(normalize), "Normalize instance must be callable"
assert inspect.ismethod(normalize.__call__), "__call__ must be a bound method"
def test_normalize_call_image_only_returns_normalized_tensor(self) -> None:
"""Normalize(image, None) returns (tensor, None) without raising."""
normalize = Normalize()
image = torch.zeros(3, 64, 64)
out_img, out_tgt = normalize(image, None)
assert isinstance(out_img, torch.Tensor)
assert out_tgt is None
@pytest.mark.parametrize(
"boxes,image_hw,expected_cxcywh_norm",
[
pytest.param(
torch.tensor([[0.0, 0.0, 100.0, 50.0]]),
(50, 100),
torch.tensor([[0.5, 0.5, 1.0, 1.0]]),
id="full_image_box",
),
pytest.param(
torch.tensor([[10.0, 10.0, 30.0, 40.0]]),
(100, 100),
torch.tensor([[0.2, 0.25, 0.2, 0.3]]),
id="non_square_box",
),
],
)
def test_normalize_call_normalizes_boxes(
self,
boxes: torch.Tensor,
image_hw: tuple[int, int],
expected_cxcywh_norm: torch.Tensor,
) -> None:
"""Normalize.__call__ converts boxes from xyxy pixel coords to normalized cxcywh."""
height, width = image_hw
normalize = Normalize()
image = torch.zeros(3, height, width)
target = {"boxes": boxes.clone()}
_, out_tgt = normalize(image, target)
torch.testing.assert_close(out_tgt["boxes"], expected_cxcywh_norm, atol=1e-4, rtol=0.0)
def test_normalize_call_normalizes_keypoints_to_unit_range(self) -> None:
"""Normalize.__call__ divides keypoint x by width and y by height."""
normalize = Normalize()
height, width = 100, 200
image = torch.zeros(3, height, width)
kp = torch.tensor([[[100.0, 50.0, 2.0]]]) # x=100 of 200w, y=50 of 100h
target = {"boxes": torch.zeros(1, 4), "keypoints": kp}
_, out_tgt = normalize(image, target)
assert out_tgt["keypoints"][0, 0, 0].item() == pytest.approx(0.5, abs=1e-5)
assert out_tgt["keypoints"][0, 0, 1].item() == pytest.approx(0.5, abs=1e-5)
assert out_tgt["keypoints"][0, 0, 2].item() == pytest.approx(2.0)
def test_normalize_call_does_not_mutate_original_target(self) -> None:
"""Normalize.__call__ must not mutate the caller's target dict."""
normalize = Normalize()
image = torch.zeros(3, 50, 100)
boxes_original = torch.tensor([[0.0, 0.0, 100.0, 50.0]])
target = {"boxes": boxes_original.clone()}
normalize(image, target)
torch.testing.assert_close(target["boxes"], boxes_original, rtol=0.0, atol=0.0)
class TestReplayContainsHorizontalFlip:
"""Unit tests for AlbumentationsWrapper._replay_contains_horizontal_flip using fixture dicts."""
@pytest.mark.parametrize(
"replay,expected",
[
pytest.param(
{"__class_fullname__": "HorizontalFlip", "applied": True, "params": {}},
True,
id="horizontal-flip-applied",
),
pytest.param(
{"__class_fullname__": "HorizontalFlip", "applied": False, "params": {}},
False,
id="horizontal-flip-not-applied",
),
pytest.param(
{"__class_fullname__": "Flip", "applied": True, "params": {"axis": 1}},
True,
id="flip-horizontal-axis",
),
pytest.param(
{"__class_fullname__": "Flip", "applied": True, "params": {"axis": 0}},
False,
id="flip-vertical-axis",
),
pytest.param(
{"__class_fullname__": "Flip", "applied": False, "params": {"axis": 1}},
False,
id="flip-not-applied",
),
pytest.param(
{
"__class_fullname__": "D4",
"applied": True,
"params": {"group_element": "h"},
},
True,
id="d4-horizontal-element",
),
pytest.param(
{
"__class_fullname__": "D4",
"applied": True,
"params": {"group_element": "r90"},
},
False,
id="d4-rotation-element",
),
pytest.param(
{
"__class_fullname__": "D4",
"applied": False,
"params": {"group_element": "h"},
},
False,
id="d4-not-applied",
),
pytest.param(
{
"__class_fullname__": "SquareSymmetry",
"applied": True,
"params": {"group_element": "h"},
},
True,
id="square-symmetry-horizontal",
),
pytest.param(
{
"__class_fullname__": "SquareSymmetry",
"applied": True,
"params": {"group_element": "r90"},
},
False,
id="square-symmetry-rotation",
),
pytest.param(
None,
False,
id="none-replay",
),
pytest.param(
"not-a-dict",
False,
id="non-dict-replay",
),
pytest.param(
{
"transforms": [
{"__class_fullname__": "HorizontalFlip", "applied": True, "params": {}},
]
},
True,
id="nested-horizontal-flip",
),
pytest.param(
{
"transforms": [
{"__class_fullname__": "HorizontalFlip", "applied": False, "params": {}},
]
},
False,
id="nested-horizontal-flip-not-applied",
),
],
)
def test_replay_contains_horizontal_flip(self, replay: object, expected: bool) -> None:
"""Fixture replay dicts should be correctly classified as horizontal flip or not."""
assert AlbumentationsWrapper._replay_contains_horizontal_flip(replay) == expected
class TestFromConfigStrict:
"""AlbumentationsWrapper.from_config strict mode raises on required-transform failures instead of skipping."""
def test_strict_false_skips_unbuildable_transform(self):
"""Lenient mode (default) logs and skips a transform that cannot be built."""
result = AlbumentationsWrapper.from_config([{"NotARealTransform": {"p": 1.0}}])
assert result == []
def test_strict_true_raises_on_unbuildable_transform(self):
"""Strict mode surfaces a RuntimeError so a corrupt required pipeline fails loudly."""
with pytest.raises(RuntimeError, match="NotARealTransform"):
AlbumentationsWrapper.from_config([{"NotARealTransform": {"p": 1.0}}], strict=True)
def test_strict_true_builds_valid_config(self):
"""Strict mode still returns wrappers when every transform builds successfully."""
result = AlbumentationsWrapper.from_config([{"HorizontalFlip": {"p": 0.5}}], strict=True)
assert len(result) == 1