Skip to content
19 changes: 15 additions & 4 deletions src/rfdetr/datasets/aug_configs.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,14 +87,25 @@
| ``Sharpen`` | ``K.RandomSharpness`` | ``sharpness = 1.0 + alpha`` (1.0-pivoted); ``lightness``/``method`` ignored |
| ``Equalize`` | ``K.RandomEqualize`` | Only ``p`` honored; ``mode``/``by_channels``/``mask`` ignored |
| ``CLAHE`` | ``K.RandomClahe`` | ``clip_limit`` and ``tile_grid_size`` map directly |
| ``Perspective`` | ``K.RandomPerspective`` | Approximate; see the note below. ``keep_size=False`` raises |

``Perspective`` is the one entry in the table that is not a faithful mapping. Albumentations samples each
corner offset from ``abs(N(0, scale))``, Kornia samples uniformly from ``distortion_scale``, so the two
produce different distortion distributions for the same config. The upper bound of ``scale`` is used as
``distortion_scale`` and a scalar ``scale`` is read as ``(0, scale)``; the divergence is logged for every
config, not only for a range. ``keep_size=False`` raises rather than silently resizing, and
``fit_output``, ``interpolation``,
``mask_interpolation``, ``border_mode``, ``fill`` and ``fill_mask`` are ignored. Use the Albumentations
backend when the exact Albumentations semantics matter.

Not yet supported on Kornia: ``HueSaturationValue`` (Albumentations shifts hue/saturation/value additively,
Kornia's ``ColorJiggle`` scales them multiplicatively, so there is no faithful mapping), and the geometric
group ``ShiftScaleRotate``, ``RandomCrop``, ``CenterCrop``, ``RandomResizedCrop``, ``Perspective``,
``ElasticTransform`` and ``GridDistortion``, which move boxes and masks and need the auxiliary-target
handling settled first. These still work on the Albumentations backend.
group ``ShiftScaleRotate``, ``RandomCrop``, ``CenterCrop``, ``RandomResizedCrop``,
``ElasticTransform`` and ``GridDistortion``, whose CPU behavior is not faithfully mapped by the current
Kornia pipeline. These still work on the Albumentations backend.

Segmentation models are supported by the GPU augmentation path; masks are augmented in sync with images and boxes.
The GPU augmentation path transports the padded-batch mask with every geometric transform. Segmentation models also
carry instance-mask channels in the same synchronized mask tensor.
"""

# ---------------------------------------------------------------------------
Expand Down
79 changes: 76 additions & 3 deletions src/rfdetr/datasets/kornia_transforms.py
Original file line number Diff line number Diff line change
Expand Up @@ -598,6 +598,77 @@ def _make_clahe(params: dict[str, Any]) -> Any:
)


#: Albumentations ``Perspective`` options with no ``K.RandomPerspective`` equivalent.
_PERSPECTIVE_IGNORED_KEYS = (
"fit_output",
"interpolation",
"mask_interpolation",
"border_mode",
"fill",
"fill_mask",
)


def _make_perspective(params: dict[str, Any]) -> Any:
"""Build a ``K.RandomPerspective`` from aug_config ``Perspective`` params.

Both libraries displace the corners by a fraction of the image side, but they do not sample that fraction the same
way, so this is an approximation rather than a parameter rename. Albumentations treats ``scale`` as the standard
deviation of a normal and takes ``abs(N(0, sigma))`` per corner (normalizing a scalar ``v`` to ``(0, v)``), so small
displacements dominate and large ones are possible but rare. Kornia draws uniformly from ``[0, distortion_scale]``.
Passing the upper bound of ``scale`` as ``distortion_scale`` keeps the worst-case distortion roughly aligned while
making the typical distortion noticeably stronger on the GPU path; there is no setting that makes the two
distributions equal. The divergence is logged so a run does not silently change character when it moves onto the
GPU.

Other Albumentations ``Perspective`` options (``fit_output``, ``interpolation``, ``mask_interpolation``,
``border_mode``, ``fill``, ``fill_mask``) have no ``RandomPerspective`` equivalent and are ignored with a warning
when set to a non-default value.

``keep_size`` is not accepted. Albumentations defaults it to ``True`` (the output keeps the input's height and
width) and Kornia's ``RandomPerspective`` always behaves that way, so the default maps cleanly; ``keep_size=False``
would change the output resolution, which this pipeline cannot express (see the note in
:func:`build_kornia_pipeline` about size-preserving transforms), so it is refused rather than silently ignored.
"""
from kornia.augmentation import RandomPerspective

if params.get("keep_size") is False:
raise ValueError(
"Perspective(keep_size=False) is not supported on the Kornia GPU backend: it changes the output "
"resolution, but the GPU augmentation path requires a fixed batch height and width. Use "
"keep_size=True (the Albumentations default), or run this augmentation on the CPU "
"(albumentations) backend."
)

ignored = [k for k in _PERSPECTIVE_IGNORED_KEYS if k in params]
if ignored:
logger.warning(
"GPU augmentation (Kornia) Perspective ignores %s "
"(Kornia's RandomPerspective exposes only distortion_scale and p). "
"CPU augmentation (albumentations) honors them.",
", ".join(repr(k) for k in ignored),
)

# Albumentations reads a scalar ``scale`` as ``(0, v)`` rather than ``(v, v)``, so it is normalized here
# instead of going through ``_as_range``; otherwise the reported CPU-side range would be wrong.
raw_scale = params.get("scale", (0.05, 0.1))
scale = (0.0, float(raw_scale)) if isinstance(raw_scale, (int, float)) else _as_range(raw_scale)
logger.warning(
"GPU augmentation (Kornia) Perspective uses distortion_scale=%.3f sampled uniformly from "
"[0, %.3f]. CPU augmentation (albumentations) samples each corner offset from abs(N(0, sigma)) "
"over sigma in [%.3f, %.3f], so the GPU path distorts more on a typical sample. "
"Use the albumentations backend if the exact distribution matters.",
scale[1],
scale[1],
scale[0],
scale[1],
)
return RandomPerspective(
distortion_scale=scale[1],
p=params.get("p", 0.5),
)


_REGISTRY: dict[str, Callable[[dict[str, Any]], Any]] = {
"HorizontalFlip": _make_horizontal_flip,
"VerticalFlip": _make_vertical_flip,
Expand All @@ -612,6 +683,7 @@ def _make_clahe(params: dict[str, Any]) -> Any:
"Sharpen": _make_sharpen,
"Equalize": _make_equalize,
"CLAHE": _make_clahe,
"Perspective": _make_perspective,
}


Expand All @@ -637,9 +709,10 @@ def build_kornia_pipeline(
resolution: Target image resolution in pixels (currently reserved for
future resolution-aware augmentations).
with_masks: When ``True``, include ``"mask"`` in ``data_keys`` so
instance segmentation masks are augmented in sync with images and boxes. The pipeline then expects three
inputs ``(img, boxes, masks)`` and returns three outputs. Defaults to ``False`` (detection-only, two
inputs/outputs).
auxiliary masks are augmented in sync with images and boxes. The training DataModule always enables this
to transport its padding mask; segmentation batches concatenate instance-mask channels before the final
padding channel. The pipeline then expects three inputs ``(img, boxes, masks)`` and returns three outputs.
Defaults to ``False`` for direct detection-only callers.
include_keypoints: When ``True``, keypoint-unsafe horizontal-flip
transforms are dropped with a warning before the Kornia pipeline is built.

Expand Down
22 changes: 16 additions & 6 deletions src/rfdetr/training/module_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -665,7 +665,8 @@ def _setup_kornia_pipeline(self) -> None:
self._kornia_pipeline = build_kornia_pipeline(
self.train_config.aug_config if self.train_config.aug_config is not None else AUG_CONFIG,
self.model_config.resolution,
with_masks=self.model_config.segmentation_head,
# The padding mask must receive every geometric warp, even for detection-only batches.
with_masks=True,
)
self._kornia_normalize = build_normalize()
logger.info("Kornia augmentation pipeline built (resolved=%s)", resolved)
Expand All @@ -676,8 +677,9 @@ def on_after_batch_transfer(self, batch: tuple[Any, Any], dataloader_idx: int) -
When ``_kornia_pipeline`` is set and the trainer is in training mode, augmentation and normalization are applied
on the GPU. Validation and test batches pass through unchanged.

Segmentation models use a mask-aware pipeline (``with_masks=True``) so images, boxes, and per-instance masks are
augmented in sync.
The pipeline carries the ``NestedTensor`` padding mask with every batch, so geometric transforms keep valid
image regions and padding aligned. Segmentation batches concatenate their instance masks before that final
padding-mask channel, then split it back out before target unpacking.

Args:
batch: Tuple of ``(NestedTensor, list[dict])`` already on device.
Expand All @@ -701,18 +703,26 @@ def on_after_batch_transfer(self, batch: tuple[Any, Any], dataloader_idx: int) -
kornia_pipeline.to(img.device)
kornia_normalize.to(img.device)
boxes_padded, valid = collate_boxes(targets, img.device)
padding_mask = samples.mask
if padding_mask is None:
padding_mask = torch.zeros(img.shape[0], *img.shape[-2:], dtype=torch.bool, device=img.device)
padding_masks = padding_mask.unsqueeze(1).to(torch.float32)

if self.model_config.segmentation_head:
image_height, image_width = img.shape[-2:]
masks_padded = collate_masks(
targets, img.device, n_max=valid.shape[1], image_height=image_height, image_width=image_width
)
img_aug, boxes_aug, masks_aug = kornia_pipeline(img, boxes_padded, masks_padded)
auxiliary_masks = torch.cat((masks_padded, padding_masks), dim=1)
img_aug, boxes_aug, auxiliary_masks_aug = kornia_pipeline(img, boxes_padded, auxiliary_masks)
masks_aug = auxiliary_masks_aug[:, : valid.shape[1]]
padding_mask_aug = auxiliary_masks_aug[:, valid.shape[1]] > 0.5
img_aug = kornia_normalize(img_aug)
aug_height, aug_width = img_aug.shape[-2:]
targets = unpack_boxes(boxes_aug, valid, targets, aug_height, aug_width, masks_aug=masks_aug)
else:
img_aug, boxes_aug = kornia_pipeline(img, boxes_padded)
img_aug, boxes_aug, padding_masks_aug = kornia_pipeline(img, boxes_padded, padding_masks)
padding_mask_aug = padding_masks_aug[:, 0] > 0.5
img_aug = kornia_normalize(img_aug)
aug_height, aug_width = img_aug.shape[-2:]
targets = unpack_boxes(boxes_aug, valid, targets, aug_height, aug_width)
Expand All @@ -724,7 +734,7 @@ def on_after_batch_transfer(self, batch: tuple[Any, Any], dataloader_idx: int) -
continue
scale = boxes.new_tensor([width, height, width, height])
target["boxes"] = box_xyxy_to_cxcywh(boxes) / scale
batch = (NestedTensor(img_aug, samples.mask), targets)
batch = (NestedTensor(img_aug, padding_mask_aug), targets)
return batch

# ------------------------------------------------------------------
Expand Down
115 changes: 115 additions & 0 deletions tests/datasets/test_kornia_transforms.py
Original file line number Diff line number Diff line change
Expand Up @@ -1202,3 +1202,118 @@ def test_gpu_without_kornia_raises_import_error(self) -> None:
pytest.raises(ImportError, match=r"rfdetr\[augment\]"),
):
resolve_backend_for_build("gpu", has_cuda=True)


class TestPerspectiveFactory:
"""`Perspective` on the Kornia backend (issue #1252).

Perspective preserves output resolution, and the DataModule carries the batch padding mask through the same Kornia
sequence. Transforms that resize output remain unsupported because this path only transports fixed-size batches.
"""

@pytest.mark.parametrize("scale", [(0.05, 0.2), 0.2], ids=["range", "scalar"])
def test_distribution_divergence_is_always_reported(self, scale) -> None:
"""The GPU path draws uniformly where the CPU path draws a half-normal, so every config diverges.

This holds for a scalar too: albumentations reads ``0.2`` as ``sigma`` in ``(0, 0.2)`` and samples
``abs(N(0, sigma))``, so even an "exact" request is not the same distribution Kornia produces.
"""
from unittest import mock

from rfdetr.datasets import kornia_transforms

with mock.patch.object(kornia_transforms.logger, "warning") as warn:
kornia_transforms.build_kornia_pipeline({"Perspective": {"scale": scale}}, 560)

messages = [call[0][0] for call in warn.call_args_list]
assert any("Perspective" in m and "abs(N(0, sigma))" in m for m in messages), messages

@pytest.mark.parametrize(
"key,value",
[
("fit_output", True),
("interpolation", 1),
("mask_interpolation", 0),
("border_mode", 0),
("fill", 0),
("fill_mask", 0),
],
)
def test_unmappable_options_are_reported_not_silently_dropped(self, key, value) -> None:
"""Kornia's RandomPerspective exposes only distortion_scale and p; the rest must not vanish quietly."""
from unittest import mock

from rfdetr.datasets import kornia_transforms

with mock.patch.object(kornia_transforms.logger, "warning") as warn:
kornia_transforms.build_kornia_pipeline({"Perspective": {key: value}}, 560)

messages = [call[0][0] % call[0][1:] if len(call[0]) > 1 else call[0][0] for call in warn.call_args_list]
assert any("ignores" in m and key in m for m in messages), messages

def test_keep_size_false_is_refused_not_ignored(self) -> None:
"""keep_size=False changes the output resolution, which this pipeline cannot express."""
from rfdetr.datasets.kornia_transforms import build_kornia_pipeline

with pytest.raises(ValueError, match="keep_size=False"):
build_kornia_pipeline({"Perspective": {"keep_size": False}}, 560)

def test_output_keeps_the_input_resolution(self) -> None:
"""The property the whole mapping rests on: image height and width survive the transform."""
from rfdetr.datasets.kornia_transforms import build_kornia_pipeline

img = torch.rand(2, 3, 64, 64)
boxes = torch.tensor([[[8.0, 8.0, 40.0, 40.0]], [[4.0, 4.0, 20.0, 20.0]]])

pipeline = build_kornia_pipeline({"Perspective": {"scale": 0.3, "p": 1.0}}, 560)
img_out, _ = pipeline(img, boxes)

assert img_out.shape[-2:] == img.shape[-2:]

def test_boxes_follow_the_warp(self) -> None:
"""A geometric transform that left the boxes where they were would silently mislabel every image."""
from rfdetr.datasets.kornia_transforms import build_kornia_pipeline

img = torch.rand(1, 3, 64, 64)
boxes = torch.tensor([[[8.0, 8.0, 40.0, 40.0]]])

pipeline = build_kornia_pipeline({"Perspective": {"scale": 0.4, "p": 1.0}}, 560)
_, boxes_out = pipeline(img, boxes)

assert not torch.allclose(boxes_out, boxes), "boxes must be warped with the image"

def test_padding_mask_follows_the_perspective_warp(self) -> None:
"""Batch padding stays an auxiliary mask under the same Perspective parameters as image and boxes."""
from rfdetr.datasets.kornia_transforms import build_kornia_pipeline

torch.manual_seed(7)
image = torch.zeros(1, 3, 64, 64)
image[:, :, :48, :48] = 1.0
boxes = torch.tensor([[[4.0, 4.0, 44.0, 44.0]]])
instance_mask = torch.zeros(1, 1, 64, 64)
instance_mask[:, :, 12:36, 12:36] = 1.0
padding_mask = torch.ones(1, 1, 64, 64)
padding_mask[:, :, :48, :48] = 0.0
auxiliary_masks = torch.cat((instance_mask, padding_mask), dim=1)

pipeline = build_kornia_pipeline({"Perspective": {"scale": 0.4, "p": 1.0}}, 560, with_masks=True)
image_aug, boxes_aug, auxiliary_masks_aug = pipeline(image, boxes, auxiliary_masks)
instance_mask_aug = auxiliary_masks_aug[:, :1]
padding_mask_aug = auxiliary_masks_aug[:, 1:]

assert image_aug.shape == image.shape
assert boxes_aug.shape == boxes.shape
assert padding_mask_aug.shape == padding_mask.shape
assert instance_mask_aug.shape == instance_mask.shape
assert not torch.equal(padding_mask_aug, padding_mask)
assert not torch.equal(instance_mask_aug, instance_mask)
bright_pixels = image_aug[:, 0] > 0.99
assert not padding_mask_aug[:, 0].to(torch.bool)[bright_pixels].any()

@pytest.mark.parametrize("name", ["RandomCrop", "CenterCrop", "RandomResizedCrop"])
def test_crop_names_from_1252_remain_unsupported(self, name) -> None:
"""Guard for the reason Perspective ships alone: the crops resize, so they are still rejected."""
from rfdetr.datasets.kornia_transforms import build_kornia_pipeline

with pytest.raises(ValueError, match="Unknown augmentation key"):
build_kornia_pipeline({name: {"height": 32, "width": 32}}, 560)
Loading
Loading