diff --git a/dali/python/nvidia/dali/experimental/torchvision/v2/color.py b/dali/python/nvidia/dali/experimental/torchvision/v2/color.py index 4c91bba4eb2..e571c232122 100644 --- a/dali/python/nvidia/dali/experimental/torchvision/v2/color.py +++ b/dali/python/nvidia/dali/experimental/torchvision/v2/color.py @@ -170,6 +170,9 @@ class ColorJitter(Operator): How much to jitter hue. hue_factor is chosen uniformly from [-hue, hue] or the given [min, max]. Should have 0<= hue <= 0.5 or -0.5 <= min <= max <= 0.5. To jitter hue, the pixel values of the input image has to be non-negative for conversion to HSV space. + DALI rotates hue linearly in YIQ, so results are close to but not identical to + ``torchvision.transforms.v2.functional.adjust_hue``, which rotates in HSV. The gap + grows with the angle, reaching roughly 24 degrees at a 90 degree rotation. device : Literal["cpu", "gpu"], optional, default = "cpu" Device to use for the color jitter. Can be ``"cpu"`` or ``"gpu"``. """ @@ -212,8 +215,11 @@ def _kernel(self, data_input): """ Performs the color jitter using the ``fn.color_twist`` operator. """ + # torchvision expresses hue as a fraction of a full turn (|hue| <= 0.5), while + # fn.color_twist takes the hue delta in degrees. + hue_degrees = tuple(float(h) * 360.0 for h in self.hue) brightness, contrast, saturation, hue = _get_BrightnessContrastSaturationHue( - self.brightness, self.contrast, self.saturation, self.hue, fn.random.uniform + self.brightness, self.contrast, self.saturation, hue_degrees, fn.random.uniform ) data_input = fn.color_twist( diff --git a/dali/test/python/torchvision/test_tv_color.py b/dali/test/python/torchvision/test_tv_color.py index 9d2cb1b7628..5698df32081 100644 --- a/dali/test/python/torchvision/test_tv_color.py +++ b/dali/test/python/torchvision/test_tv_color.py @@ -14,6 +14,7 @@ import os +import numpy as np from nose2.tools import params, cartesian_params from nose_utils import assert_raises from PIL import Image @@ -149,6 +150,67 @@ def test_colorjitter_images(cj_params, device): _ = cj(img) +def median_hue_shift(before: Image.Image, after: Image.Image) -> float: + """Median hue rotation from `before` to `after`, in degrees, over the colorful pixels.""" + hue_before, saturation, _ = before.convert("HSV").split() + hue_after, _, _ = after.convert("HSV").split() + to_degrees = 360.0 / 256.0 + hue_before = np.asarray(hue_before, dtype=np.float64) * to_degrees + hue_after = np.asarray(hue_after, dtype=np.float64) * to_degrees + # hue is meaningless for near-gray pixels + colorful = np.asarray(saturation) > 32 + assert colorful.any(), "image has no colorful pixels to measure hue on" + shift = (hue_after - hue_before + 180.0) % 360.0 - 180.0 + return float(np.median(shift[colorful])) + + +def hue_error(actual: float, expected: float) -> float: + """Absolute difference between two hue rotations, taking the shorter way around.""" + return abs((actual - expected + 180.0) % 360.0 - 180.0) + + +@cartesian_params((0.05, 0.1, -0.1, 0.25, -0.25, 0.5, -0.5), ("cpu", "gpu")) +def test_colorjitter_hue_rotation(hue, device): + # torchvision expresses hue as a fraction of a full turn, fn.color_twist takes degrees. + # DALI rotates linearly in YIQ, which drifts from torchvision's HSV shift in proportion + # to the angle, so the tolerance scales too. The comparison is circular because hue=+-0.5 + # lands on +-180, where implementations that agree closely can report opposite signs. + requested = abs(hue) * 360.0 + # 0.35 is the worst YIQ-vs-HSV drift measured across these files; the floor keeps the + # bound useful for small rotations, where a purely relative tolerance admits almost + # any unit error. + tol = max(4.0, 0.35 * requested) + cj = Compose([ColorJitter(hue=(hue, hue), device=device)]) + + for fn in test_files: + img = Image.open(fn).convert("RGB") + expected = median_hue_shift(img, transforms.functional.adjust_hue(img, hue)) + actual = median_hue_shift(img, cj(img)) + assert hue_error(actual, expected) < tol, ( + f"hue={hue} rotated by {actual:.2f} degrees, torchvision rotates by " + f"{expected:.2f} degrees: {fn}" + ) + + +@params("cpu", "gpu") +def test_colorjitter_hue_range_is_converted(device): + # hue=(h, h) short-circuits in _get_BrightnessContrastSaturationHue and passes a scalar. + # A genuine range takes the fn.random.uniform branch, which is the one that consumes the + # converted range, and is what ColorJitter(hue=0.1) expands to. Both endpoints must be + # converted, so the sampled rotation has to land inside [36, 72] degrees. + lo, hi = 0.1, 0.2 + cj = Compose([ColorJitter(hue=(lo, hi), device=device)]) + tol = max(4.0, 0.35 * hi * 360.0) + + for fn in test_files: + img = Image.open(fn).convert("RGB") + actual = median_hue_shift(img, cj(img)) + assert lo * 360.0 - tol <= actual <= hi * 360.0 + tol, ( + f"hue range ({lo}, {hi}) should rotate within " + f"[{lo * 360.0:.0f}, {hi * 360.0:.0f}] degrees, measured {actual:.2f}: {fn}" + ) + + """ TODO (https://github.com/NVIDIA/DALI/issues/DALI-4656): DALI ColorJitter does not currently work on CHW layout