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
46 changes: 43 additions & 3 deletions src/rfdetr/datasets/kornia_transforms.py
Original file line number Diff line number Diff line change
Expand Up @@ -594,18 +594,58 @@ def _make_equalize(params: dict[str, Any]) -> Any:
return RandomEqualize(p=params.get("p", 0.5))


def _as_clahe_clip_limit(value: Any) -> tuple[float, float]:
"""Normalise ``CLAHE``'s ``clip_limit`` the way Albumentations does.

Albumentations applies ``to_tuple(clip_limit, low=1)``, so a scalar ``v`` means the range ``(1, v)``. This differs
from :func:`_as_range`, which expands a scalar to the degenerate ``(v, v)``; using that here would change the
distribution rather than the units.

Args:
value: A scalar, or a 2-element ``(min, max)`` pair.

Returns:
The ``(min, max)`` pair Albumentations would sample from for the same config.

Raises:
ValueError: If *value* is a sequence of any length other than two. Albumentations validates ``clip_limit`` as
either a float or an exact 2-tuple and rejects ``[4.0]``, so accepting it here (as a scalar, via
:func:`_as_range`) would make the same config train on the GPU backend and fail on the CPU one. The point
of this helper is that the two agree.

Examples:
>>> _as_clahe_clip_limit(4.0)
(1.0, 4.0)
>>> _as_clahe_clip_limit((2.0, 6.0))
(2.0, 6.0)
"""
if isinstance(value, (list, tuple)):
if len(value) != 2:
raise ValueError(
"CLAHE clip_limit must be a scalar or a 2-element (min, max) pair; "
f"got a {len(value)}-element sequence: {value!r}. Albumentations rejects this too, so the CPU "
"(albumentations) backend would fail on the same config."
)
return (float(value[0]), float(value[1]))
# A scalar means the range (1, v), which is what `to_tuple(v, low=1)` produces.
return (1.0, float(value))


def _make_clahe(params: dict[str, Any]) -> Any:
"""Build a ``K.RandomClahe`` from aug_config ``CLAHE`` params.

Both parameters map directly: Albumentations' ``clip_limit`` (a scalar or a pair) becomes Kornia's ``clip_limit``
range, and ``tile_grid_size`` becomes ``grid_size``.
``tile_grid_size`` becomes ``grid_size`` directly. ``clip_limit`` needs care: Albumentations reads a scalar ``v`` as
the range ``(1, v)`` and samples from it, not as the fixed value ``v``. Passing it through :func:`_as_range` would
produce the degenerate ``(v, v)`` and pin the GPU path to maximum contrast enhancement on every sample while the CPU
path varied it — and since ``4.0`` is the default on both sides, that divergence applied to the default
configuration rather than only to unusual ones. A pair is already a range and is used as given.
"""
import kornia.augmentation as kornia_augmentation

random_clahe = cast(Any, kornia_augmentation).RandomClahe
grid = params.get("tile_grid_size", (8, 8))
return random_clahe(
clip_limit=_as_range(params.get("clip_limit", 4.0)),
clip_limit=_as_clahe_clip_limit(params.get("clip_limit", 4.0)),
grid_size=(int(grid[0]), int(grid[1])),
p=params.get("p", 0.5),
)
Expand Down
61 changes: 61 additions & 0 deletions tests/datasets/test_kornia_transforms.py
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,67 @@ def test_clahe_maps_both_parameters(self):
# `clip_limit` attribute (set directly from the constructor arg), so no private access needed.
assert tuple(transform.clip_limit) == pytest.approx((2.0, 6.0))

@pytest.mark.parametrize(
"configured,expected",
[
(None, (1.0, 4.0)),
(4.0, (1.0, 4.0)),
(2.0, (1.0, 2.0)),
((1.0, 4.0), (1.0, 4.0)),
((2.0, 6.0), (2.0, 6.0)),
],
ids=["default", "scalar-default-value", "scalar", "pair", "pair-non-default"],
)
def test_clahe_scalar_clip_limit_is_a_range_not_a_fixed_value(
self, configured: float | tuple[float, float] | None, expected: tuple[float, float]
) -> None:
"""Albumentations reads a scalar clip_limit as (1, v), so the GPU path must too.

Passing it through `_as_range` produced the degenerate (v, v), which pins every sample to maximum contrast
enhancement while the CPU path varies it. 4.0 is the default on both sides, so that divergence applied with no
user config at all.
"""
from rfdetr.datasets.kornia_transforms import build_kornia_pipeline

params = {} if configured is None else {"clip_limit": configured}
pipeline = build_kornia_pipeline({"CLAHE": params}, 560)
transform = next(iter(pipeline.children()))

assert tuple(transform.clip_limit) == pytest.approx(expected)

@pytest.mark.parametrize(
"configured",
[4.0, 2.0, (2.0, 6.0)],
ids=["scalar-default-value", "scalar", "pair"],
)
def test_clahe_clip_limit_matches_albumentations(self, configured: float | tuple[float, float]) -> None:
"""The contract stated directly: same config, same range on both backends."""
albumentations = pytest.importorskip("albumentations")

from rfdetr.datasets.kornia_transforms import build_kornia_pipeline

pipeline = build_kornia_pipeline({"CLAHE": {"clip_limit": configured}}, 560)
transform = next(iter(pipeline.children()))
cpu = albumentations.CLAHE(clip_limit=configured)

assert tuple(transform.clip_limit) == pytest.approx(tuple(cpu.clip_limit)), (
f"backends disagree for clip_limit={configured!r}"
)

@pytest.mark.parametrize("configured", [[4.0], (4.0,), (1.0, 2.0, 3.0)], ids=["one-list", "one-tuple", "three"])
def test_clahe_rejects_sequences_that_albumentations_rejects(
self, configured: tuple[float, ...] | list[float]
) -> None:
"""A one-element sequence is not a scalar.

Albumentations validates `clip_limit` as a float or an exact 2-tuple and raises on `[4.0]`. Reading it as a
scalar here would accept a config the CPU backend refuses, which is the divergence this helper exists to remove.
"""
from rfdetr.datasets.kornia_transforms import build_kornia_pipeline

with pytest.raises(ValueError, match="2-element"):
build_kornia_pipeline({"CLAHE": {"clip_limit": configured}}, 560)

def test_hue_saturation_value_still_unsupported(self):
"""Deliberately out of scope: albumentations shifts additively, Kornia scales multiplicatively."""
from rfdetr.datasets.kornia_transforms import build_kornia_pipeline
Expand Down
Loading