From c9971ca887550cc2e75e15d5c2757c9b3bdd3b59 Mon Sep 17 00:00:00 2001 From: Kayvan Zahiri Date: Wed, 2 Sep 2026 12:51:06 -0700 Subject: [PATCH 1/4] Convert torchvision ColorJitter hue to degrees for fn.color_twist torchvision expresses hue as a fraction of a full turn, while the fn.color_twist "hue" argument is a delta in degrees. ColorJitter passed the value through unscaled, so the jitter was 360x too small and the maximal legal hue of 0.5 rotated the image by half a degree. Add a parity test against torchvision.transforms.v2.functional.adjust_hue that compares the median HSV hue rotation. The existing test_colorjitter_images only checks that the pipeline runs. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01DQughgr95y4B9H1jfaQH8o Signed-off-by: Kayvan Zahiri --- .../dali/experimental/torchvision/v2/color.py | 6 +++- dali/test/python/torchvision/test_tv_color.py | 30 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/dali/python/nvidia/dali/experimental/torchvision/v2/color.py b/dali/python/nvidia/dali/experimental/torchvision/v2/color.py index 4c91bba4eb2..642b25224f8 100644 --- a/dali/python/nvidia/dali/experimental/torchvision/v2/color.py +++ b/dali/python/nvidia/dali/experimental/torchvision/v2/color.py @@ -208,12 +208,16 @@ def __init__( if isinstance(hue, (int, float)): self.hue = (-float(hue), float(hue)) + # torchvision expresses hue as a fraction of a full turn (|hue| <= 0.5), while + # fn.color_twist takes the hue delta in degrees. + self._hue_degrees = tuple(float(h) * 360.0 for h in self.hue) + def _kernel(self, data_input): """ Performs the color jitter using the ``fn.color_twist`` operator. """ brightness, contrast, saturation, hue = _get_BrightnessContrastSaturationHue( - self.brightness, self.contrast, self.saturation, self.hue, fn.random.uniform + self.brightness, self.contrast, self.saturation, self._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..335157643da 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,35 @@ 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 + shift = (hue_after - hue_before + 180.0) % 360.0 - 180.0 + return float(np.median(shift[colorful])) + + +@cartesian_params((0.05, 0.1, -0.1), ("cpu", "gpu")) +def test_colorjitter_hue_rotation(hue, device): + # torchvision expresses hue as a fraction of a full turn, fn.color_twist takes degrees. + # The tolerance covers DALI's linear YIQ approximation of the hue rotation. + 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 abs(actual - expected) < 15.0, ( + f"hue={hue} rotated by {actual:.2f} degrees, torchvision rotates by " + f"{expected:.2f} degrees: {fn}" + ) + + """ TODO (https://github.com/NVIDIA/DALI/issues/DALI-4656): DALI ColorJitter does not currently work on CHW layout From f61ac181b78164a2d9683cf98555d1ed6bffe220 Mon Sep 17 00:00:00 2001 From: Kayvan Zahiri Date: Wed, 2 Sep 2026 13:08:57 -0700 Subject: [PATCH 2/4] Cover the hue boundary and compare rotations circularly hue=+-0.5 maps to +-180 degrees, where the wrapped median shift flips sign. Two implementations agreeing to within a degree there can report +178.6 and -178.6, so a plain subtraction reads as a 357 degree error. Compare the shorter way around instead, then add +-0.25 and +-0.5 to the cases. The circular form still fails on real errors: a 90 or 180 degree disagreement is unaffected. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01DQughgr95y4B9H1jfaQH8o Signed-off-by: Kayvan Zahiri --- dali/test/python/torchvision/test_tv_color.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/dali/test/python/torchvision/test_tv_color.py b/dali/test/python/torchvision/test_tv_color.py index 335157643da..8173d8a4ced 100644 --- a/dali/test/python/torchvision/test_tv_color.py +++ b/dali/test/python/torchvision/test_tv_color.py @@ -163,17 +163,24 @@ def median_hue_shift(before: Image.Image, after: Image.Image) -> float: return float(np.median(shift[colorful])) -@cartesian_params((0.05, 0.1, -0.1), ("cpu", "gpu")) +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. - # The tolerance covers DALI's linear YIQ approximation of the hue rotation. + # The tolerance covers DALI's linear YIQ approximation of the hue rotation. The + # comparison is circular because hue=+-0.5 lands on +-180 degrees, where two + # implementations that agree to within a degree can report opposite signs. 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 abs(actual - expected) < 15.0, ( + assert hue_error(actual, expected) < 15.0, ( f"hue={hue} rotated by {actual:.2f} degrees, torchvision rotates by " f"{expected:.2f} degrees: {fn}" ) From aedaeb48533a698b3fd32922e3b19f11ddd2bc47 Mon Sep 17 00:00:00 2001 From: Kayvan Zahiri Date: Wed, 2 Sep 2026 15:46:06 -0700 Subject: [PATCH 3/4] Scale the hue parity tolerance with the requested rotation The fixed 15 degree tolerance fails at hue=+-0.25. DALI rotates linearly in YIQ, so its drift from torchvision's HSV shift grows with the angle: measured against ref_color_twist from test_color_twist.py on the three test images, the drift reaches 23.9 degrees at 90 degrees but stays under 35% of the requested rotation everywhere. A tolerance of half the requested rotation passes all 21 image and hue cases and still fails all 21 without the degree conversion, where the rotation is 0 and the error is the full requested angle. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01DQughgr95y4B9H1jfaQH8o Signed-off-by: Kayvan Zahiri --- dali/test/python/torchvision/test_tv_color.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/dali/test/python/torchvision/test_tv_color.py b/dali/test/python/torchvision/test_tv_color.py index 8173d8a4ced..fa9d7a0ac4a 100644 --- a/dali/test/python/torchvision/test_tv_color.py +++ b/dali/test/python/torchvision/test_tv_color.py @@ -171,16 +171,17 @@ def hue_error(actual: float, expected: float) -> float: @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. - # The tolerance covers DALI's linear YIQ approximation of the hue rotation. The - # comparison is circular because hue=+-0.5 lands on +-180 degrees, where two - # implementations that agree to within a degree can report opposite signs. + # 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 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) < 15.0, ( + assert hue_error(actual, expected) < 0.5 * requested, ( f"hue={hue} rotated by {actual:.2f} degrees, torchvision rotates by " f"{expected:.2f} degrees: {fn}" ) From 0f78dcd31924873e2f7e6904fc4abb94e26f9342 Mon Sep 17 00:00:00 2001 From: Kayvan Zahiri Date: Thu, 3 Sep 2026 09:53:18 -0700 Subject: [PATCH 4/4] Address review: drop the cached hue, tighten the bound, cover the range branch Convert hue at the point of use instead of caching _hue_degrees in __init__, so self.hue stays the single source of truth and reassigning it cannot leave stale state behind. Replace the purely relative 0.5 * requested bound with max(4.0, 0.35 * requested). A relative-only bound can never catch a unit error smaller than 1.5x, and at hue=0.05 it admitted 9 degrees on an 18 degree rotation. Add a genuine-range case. Every existing case passes hue=(h, h), which short-circuits to a scalar, so fn.random.uniform(range=hue_degrees) was never exercised even though ColorJitter(hue=0.1) expands to exactly that. Also assert the colorful mask is non-empty, so a near-gray input fails with a readable message rather than a nan, and note the YIQ versus HSV difference in the hue docstring. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01YWsrLEQNXzWcbgnf1qz6Mo Signed-off-by: Kayvan Zahiri --- .../dali/experimental/torchvision/v2/color.py | 12 +++++---- dali/test/python/torchvision/test_tv_color.py | 26 ++++++++++++++++++- 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/dali/python/nvidia/dali/experimental/torchvision/v2/color.py b/dali/python/nvidia/dali/experimental/torchvision/v2/color.py index 642b25224f8..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"``. """ @@ -208,16 +211,15 @@ def __init__( if isinstance(hue, (int, float)): self.hue = (-float(hue), float(hue)) - # torchvision expresses hue as a fraction of a full turn (|hue| <= 0.5), while - # fn.color_twist takes the hue delta in degrees. - self._hue_degrees = tuple(float(h) * 360.0 for h in self.hue) - 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_degrees, 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 fa9d7a0ac4a..5698df32081 100644 --- a/dali/test/python/torchvision/test_tv_color.py +++ b/dali/test/python/torchvision/test_tv_color.py @@ -159,6 +159,7 @@ def median_hue_shift(before: Image.Image, after: Image.Image) -> float: 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])) @@ -175,18 +176,41 @@ def test_colorjitter_hue_rotation(hue, device): # 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) < 0.5 * requested, ( + 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