Convert torchvision ColorJitter hue to degrees for fn.color_twist - #6471
Convert torchvision ColorJitter hue to degrees for fn.color_twist#6471Kayvan-Zahiri wants to merge 4 commits into
Conversation
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DQughgr95y4B9H1jfaQH8o Signed-off-by: Kayvan Zahiri <kayvanandre@gmail.com>
|
| Filename | Overview |
|---|---|
| dali/python/nvidia/dali/experimental/torchvision/v2/color.py | Converts validated hue ranges from fractions of a turn to degrees before sampling and invoking color_twist. |
| dali/test/python/torchvision/test_tv_color.py | Adds reference-based CPU/GPU regression tests for fixed and ranged hue rotations. |
Reviews (4): Last reviewed commit: "Address review: drop the cached hue, tig..." | Re-trigger Greptile
| return float(np.median(shift[colorful])) | ||
|
|
||
|
|
||
| @cartesian_params((0.05, 0.1, -0.1), ("cpu", "gpu")) |
There was a problem hiding this comment.
Hue edge cases remain uncovered
The new regression test covers both devices but only fixed interior hue values on the existing JPEG inputs. Add the required ±0.5 boundaries, empty input, and explicit batch_size=1 coverage so failures at the ±180-degree conversion and required input shapes do not pass undetected.
Rule Used: New operator tests must cover: empty input, single... (source)
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DQughgr95y4B9H1jfaQH8o Signed-off-by: Kayvan Zahiri <kayvanandre@gmail.com>
|
Added the boundary cases, but the assertion needed fixing first or they would have failed.
So with the old The assertion now compares the shorter way around, and On the other two suggestions, I think the rule is aimed at new operators and this PR is a flake8 is clean on the file. The DALI side of the test runs in CI, since DALI needs Linux |
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DQughgr95y4B9H1jfaQH8o Signed-off-by: Kayvan Zahiri <kayvanandre@gmail.com>
|
Correcting my last comment. I said the DALI side of the boundary cases only runs in CI, so DALI rotates hue linearly in YIQ, so its drift from torchvision's HSV shift grows with the
So the error is bounded as a share of the rotation, not as a fixed angle, and the tolerance flake8 and black are clean on both changed files. |
|
!build |
|
@Kayvan-Zahiri thank you for your contribution. |
|
CI MESSAGE: [65990723]: BUILD STARTED |
|
CI MESSAGE: [65990723]: BUILD PASSED |
|
|
||
| # 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) |
There was a problem hiding this comment.
[Minor] The fix itself is right (color_twist documents hue as degrees, h_rad = hue * M_PI / 180), but caching the converted value in a second attribute duplicates state that can silently go stale: anything that reassigns self.hue after construction (subclass, test helper, user code poking at the public attribute) will keep using the __init__-time _hue_degrees. Since _kernel is the only consumer, consider dropping the extra attribute and converting at the point of use:
hue_degrees = tuple(h * 360.0 for h in self.hue)
brightness, contrast, saturation, hue = _get_BrightnessContrastSaturationHue(
self.brightness, self.contrast, self.saturation, hue_degrees, fn.random.uniform
)Same comment, one attribute, and self.hue stays the single source of truth.
| """ | ||
| 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 |
There was a problem hiding this comment.
[Minor] Now that hue actually rotates, the class docstring above (the hue parameter description) is worth a sentence noting that DALI's color_twist rotates hue linearly in YIQ, so results are close to but not identical to torchvision.transforms.v2.functional.adjust_hue's HSV rotation — your own measurements show up to ~24 degrees of drift at a 90 degree rotation. Users migrating from torchvision and diffing outputs will hit this, and the docstring is the place they'll look.
| 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, ( |
There was a problem hiding this comment.
[Moderate] A tolerance of 0.5 * requested is very loose and, more importantly, scales with the thing being measured — so it can never detect a unit error smaller than 1.5x. At hue=0.5 it permits a 90 degree disagreement; at hue=0.05 it permits 9 degrees on an 18 degree rotation. It catches the 360x bug this PR fixes, but it would happily pass a future regression that, say, used 2*pi instead of 360 in some path, or lost the sign for one device.
Two suggestions, either is fine:
- Bound the tolerance with an absolute floor plus a smaller relative term, e.g.
max(4.0, 0.35 * requested)— you already measured the worst-case YIQ-vs-HSV drift at 35%, so 0.35 with a floor is both tighter and directly justified by the data. - Better: pin the exact conversion against DALI's own reference.
ref_color_twistintest_color_twist.pymodels whatfn.color_twistshould do; comparingColorJitter(hue=h)toref_color_twist(..., hue=h*360)admits a tight per-pixel tolerance and locks the unit conversion precisely, leaving the torchvision comparison as a loose sanity check.
| # 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)]) |
There was a problem hiding this comment.
[Moderate] Every case uses hue=(h, h), which takes the hue[0] == hue[1] short-circuit in _get_BrightnessContrastSaturationHue and passes a Python scalar to color_twist. The random branch — fn.random.uniform(range=hue_degrees) — is the one that actually consumes the converted range, and it is never exercised here. That's the path most users hit, since ColorJitter(hue=0.1) expands to (-0.1, 0.1).
Worth adding one case with a genuine range, e.g. hue=(0.1, 0.2), asserting the measured shift lands inside [36 - tol, 72 + tol] degrees. That also guards against a future change converting only one endpoint.
| 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 |
There was a problem hiding this comment.
[Minor] If colorful ever selects nothing (a near-gray input added to test_files later), np.median on the empty slice emits a RuntimeWarning and returns nan; hue_error(nan, ...) < tol is then False and the test fails with a message full of nan rather than saying what went wrong. A one-line guard — assert colorful.any(), f"{before} has no colorful pixels to measure hue on" — makes that failure self-explanatory.
…ge 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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YWsrLEQNXzWcbgnf1qz6Mo Signed-off-by: Kayvan Zahiri <kayvanandre@gmail.com>
08b37c3 to
0f78dcd
Compare
|
All five are fair. Addressed in 0f78dcd. Cached hue. Dropped Tolerance. Took I tried to check 0.35 independently rather than take it on trust. I modeled both rotations Worst ratio 0.06, so 0.35 has real headroom on that input. Caveat worth stating: this is my Range branch. Added Empty mask. Added the assert, so a near-gray input fails saying so instead of Docstring. Noted the YIQ versus HSV difference and that the gap grows with the angle. One process note: my first push of this had two blank-line findings, E303 and E305, from |
Category:
Bug fix (non-breaking change which fixes an issue)
Description:
ColorJitter._kernelforwards the torchvisionhuefactor straight intofn.color_twist(hue=...). torchvision expresses hue as a fraction of a full turn(
|hue| <= 0.5), but the ColorTwisthueargument is a delta in degrees(
color_twist.cc: "Hue change, in degrees.";color_twist.h:h_rad = hue * M_PI / 180).The jitter is therefore 360x too small, and the largest legal value,
hue=0.5, rotatesby half a degree instead of 180.
Measured on the three
db/single/jpeg/113images the tests already use, comparing themedian HSV hue shift against
torchvision.transforms.v2.functional.adjust_hue:Additional information:
Affected modules and functionalities:
ColorJitterinexperimental/torchvision/v2/color.py. The sampled range is convertedto degrees once in
__init__; the publicself.huestays in torchvision units sovalidation is unchanged.
Key points relevant for the review:
DALI rotates hue linearly in YIQ, so its drift from torchvision's HSV shift grows with the
angle rather than staying under a fixed number of degrees. The tolerance is therefore half
the requested rotation. Measured against
ref_color_twistfromtest_color_twist.py, thedrift peaks at 23.9 degrees for a 90 degree rotation but stays under 35% of the rotation
everywhere.
Tests:
test_colorjitter_hue_rotationcompares againstadjust_hueathueof+-0.05,+-0.1,+-0.25and+-0.5, on cpu and gpu. It passes on all 21 image and hue cases with theconversion and fails on all 21 without it, since the unconverted value rotates by 0 and the
error is then the full requested angle.
test_colorjitter_imagesonly ran the pipeline andcould not catch this. The other
test_tv_color.pycases are unaffected: the sameValueErrors are raised for invalid parameters and the no-jitter path still passeshue=-0.0.Checklist
Documentation
DALI team only
Requirements
REQ IDs: N/A
JIRA TASK: N/A
🤖 Generated with Claude Code