Skip to content

Fix RGB<->YCbCr/GRAY color conversion for signed and float pixel types - #6514

Open
jantonguirao wants to merge 5 commits into
NVIDIA:mainfrom
jantonguirao:fix/color-space-conversion-signed-types
Open

jantonguirao wants to merge 5 commits into
NVIDIA:mainfrom
jantonguirao:fix/color-space-conversion-signed-types

Conversation

@jantonguirao

Copy link
Copy Markdown
Collaborator

Category:

Bug fix (non-breaking change which fixes an issue)

Description:

kernels::color::itu_r_bt_601/jpeg (dali/kernels/imgproc/color_manipulation/color_space_conversion_impl.h)
implement the RGB↔YCbCr/GRAY color-space math used by both the CPU and GPU imgcodec decode paths.
The coefficients/biases are calibrated for a channel value in [0, max_value<T>()], as for a
typical unsigned pixel type, but the code had no notion of a signed Input whose raw value can be
negative. On the CPU path (ConvertCPU in dali/operators/imgcodec/util/convert.h), this matrix
ran directly on raw signed samples: a negative sample contributed a large negative term to the dot
product instead of a small positive one, producing wildly wrong, unclamped Y/Cb/Cr values (e.g. a
signed int32 TIFF decoded to YCbCr gave [0, 255, 255] instead of a sane value on cpu, while
mixed/GPU decoded it correctly).

Investigation showed the GPU path (ConvertGPU/convert_gpu.cu) already avoids this bug: it
normalizes to the output dtype first (scale + saturating clamp, matching the SNORM→UNORM
convention used elsewhere in DALI's own dali/core/convert.h and matching OpenGL/Vulkan/D3D's
signed-normalized-texture convention) and only then runs the color conversion on already-clamped,
same-typed data. This PR ports that same clamp into the shared color-math itself
(detail::saturate_norm<Output>(Input)), so the fix is in one place and both backends' <Output, Input> color functions can be called directly on raw signed (or float) samples with correct,
consistent results — mathematically equivalent to "convert to Out first, then color-convert",
but in a single step with no intermediate rounding.

This generalizes the narrower CPU-only workaround from #6510 (which pre-scales to Out via
ConvertSatNorm before calling the color matrix) into the shared math, so it benefits every
caller of color_space_conversion_impl.h, not just the imgcodec CPU decode path.

Additional information:

Affected modules and functionalities:

  • dali/kernels/imgproc/color_manipulation/color_space_conversion_impl.h — added
    detail::saturate_norm<Output>(Input), applied in rgb_to_y, rgb_to_cb, rgb_to_cr,
    rgb_to_ycbcr, y_to_gray, gray_to_y, ycbcr_to_rgb, ycbcr_to_gray, gray_to_ycbcr (both
    itu_r_bt_601 and jpeg coefficient sets), and the free rgb_to_gray. Public <Output, Input>
    signatures are unchanged.
  • dali/kernels/imgproc/color_manipulation/color_space_conversion_impl_test.cc — extensive new
    coverage (see Tests below); also fixes an inexact integer-division bug in the existing
    itu_ref::gray_to_y test-reference helper for int16.
  • dali/operators/imgcodec/util/convert_gpu_test.cc — new CPU-vs-GPU agreement tests across dtype
    pairs and format conversions.
  • dali/test/python/decoder/test_imgcodec.py — new end-to-end regression test decoding a signed
    int32 TIFF to YCbCr/GRAY on cpu and mixed and checking both against a numpy reference.

Key points relevant for the review:

  • The core semantic decision: signed integer samples are treated as SNORM (clamped to
    [-1, 1] via division by max_value<Input>(), with the single extra negative code
    -2^(n-1) clamping to -1), matching ConvertSatNorm/ConvertNorm elsewhere in DALI and
    matching how the (already-correct) GPU path behaves — not a signed→unsigned
    0.5*(1+snorm) shift, which would make 0 decode as mid-gray instead of black and be
    inconsistent with plain RGB/ANY_DATA decode of the same source.
  • Verified byte-for-byte equivalence with the pre-existing behavior for all unsigned-integer and
    float inputs (no regression for the common uint8_t case, which the pre-existing
    static_asserts in color_space_conversion_impl_test.cc continue to pin).
  • Two small pre-existing GPU/CPU rounding gaps are documented but not changed here (both under
    1 LSB forward / 3 LSB inverse, or a sub-0.2% bias interpretation difference for
    differing-bit-depth YCbCr inputs) — CPU is the more numerically correct of the two; test
    tolerances account for the difference.
  • GRAY decoding of a signed TIFF source is currently handled directly by nvImageCodec (not by
    this DALI color-conversion code) and is linear/unclamped there, which is inconsistent with the
    RGB/YCbCr convention fixed here — that would need a follow-up on the nvImageCodec side, out of
    scope for this PR.
  • The new Python test depends on Fix uint32/int32 TIFF decoding (missing dtype mapping) #6510 (int32/uint32 dtype mapping) and on nvImageCodec CVE
    MR !676 (CPU signed-TIFF decode support) to actually exercise the cpu backend; until both are
    merged/released it skips itself rather than failing.

Tests:

  • Existing tests apply

  • New tests added

    • Python tests
    • GTests
    • Benchmark
    • Other
  • N/A

  • color_space_conversion_impl_test.cc: a double-precision SNORM/UNORM reference checked across
    all 7×7 Input×Output type pairs (uint8_t/int8_t/uint16_t/int16_t/uint32_t/
    int32_t/float) × {ITU-R BT.601, JPEG} coefficient sets, for every conversion function
    (rgb_to_y/cb/cr, rgb_to_ycbcr, ycbcr_to_rgb, gray_to_y, y_to_gray, gray_to_ycbcr,
    ycbcr_to_gray, rgb_to_gray), at edge values (min, min+1, -max, -max/3, -1, 0,
    1, max/3, max/2+1, max-1, max) plus randomized values over the full range; an
    equivalence check against "convert-to-Out-first" (GPU semantics); static_asserts on the new
    saturation helper; and pinned hand-computed values (including the originally-failing int32
    pixel, 0 decoding to black, the SNORM -128 clamp, and float clamping).

  • convert_gpu_test.cc: ConvertCPU vs ConvertGPU agreement across 16 dtype pairs × 8 color
    format conversions, plus the originally-failing int32 pixel on both backends.

  • test_imgcodec.py: test_tiff_signed_color_conversion — decodes a signed int32 TIFF (the
    real fixture plus a synthetic all-edge-values one) to YCbCr/GRAY on cpu and mixed,
    checked against numpy.

  • Full existing dali_kernel_test and test_imgcodec.py suites run with zero new regressions
    (the only remaining Python failures are 48 pre-existing test_image_decoder_fused errors from
    an unrelated nvImageCodec-version mismatch in the local dev environment, and the int32/uint32
    fixture-dependent tests that require Fix uint32/int32 TIFF decoding (missing dtype mapping) #6510/MR !676 to be present, which correctly skip here).

Checklist

Documentation

  • Existing documentation applies
  • Documentation updated
    • Docstring
    • Doxygen
    • RST
    • Jupyter
    • Other
  • N/A

DALI team only

Requirements

  • Implements new requirements
  • Affects existing requirements
  • N/A

REQ IDs: N/A

JIRA TASK: N/A

@copy-pr-bot

copy-pr-bot Bot commented Sep 29, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

@jantonguirao
jantonguirao marked this pull request as ready for review September 29, 2026 16:04
Copilot AI balanced review requested due to automatic review settings September 29, 2026 16:04

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Signed minimum values remain inconsistent between CPU and GPU when producing floating-point output.

Review effort: Balanced
Findings: 1 Medium severity

Open (1)
What changed in this PR

Fixes signed and floating-point RGB/YCbCr/GRAY conversion by applying normalized saturation within shared color-conversion math.

Changes:

  • Adds normalized input saturation across color conversions.
  • Adds exhaustive type-pair and CPU/GPU agreement tests.
  • Adds signed TIFF decoding regressions.
File Description
color_space_conversion_impl.h Implements shared saturation logic.
color_space_conversion_impl_test.cc Tests conversion types and edge values.
convert_gpu_test.cc Compares CPU and GPU conversions.
test_imgcodec.py Tests signed TIFF decoding end-to-end.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@greptile-apps

greptile-apps Bot commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerConfidence Score: 5/5

[Medium risk] Fixes color space conversion for signed and float pixel types.

The PR appears safe to merge based on the current changes and resolved review threads.

Summary

The PR saturates signed and floating-point samples before shared RGB↔YCbCr/GRAY conversion, aligns the signed-minimum floating-point case with GPU normalization, and adds conversion and decoder tests. It also skips zero-extent GPU color-conversion and flip launches.

Reviews (2) · Last reviewed commit: "Address review feedback: align CPU/GPU n..."

Comment thread dali/test/python/decoder/test_imgcodec.py Outdated
Comment thread dali/test/python/decoder/test_imgcodec.py
Comment thread dali/test/python/decoder/test_imgcodec.py
The color conversion helpers in color_space_conversion_impl.h scale the
input by max_value<Output>() / max_value<Input>(), i.e. they already treat
integer samples as normalized (UNORM/SNORM) values, but they fed the
normalized value straight into the conversion matrix. For a signed input
and an unsigned output this means that a negative sample contributes a
large negative term to the dot product, producing a color that is not
representable in the output and saturates to a wrong value - e.g. the
imgcodec CPU decoder produced YCbCr [0, 255, 255] instead of
[106, 202, 221] for an int32 RGB pixel [2122218880, -2088533504,
2139061888], while the GPU path (which normalizes to the output type
first, then converts the color space) produced the latter.

Saturate each input sample to the range of normalized values that
ConvertSatNorm<Output> keeps, before the color transform:
 - signed integers are SNORM (v / max_value), min_value is clamped to -1,
 - [0, 1] for unsigned outputs, [-1, 1] for signed integer outputs,
 - floating point outputs are unbounded (float -> float is unchanged).

This makes the conversion from any input type equivalent to
ConvertSatNorm<Output> followed by the conversion in Output type (which is
what the GPU decoder path does, and consistent with decoding to RGB), but
done in a single step, without the intermediate rounding.

Unsigned inputs are never modified, so results for unsigned inputs (and
for float -> float) are bit-identical to before.

Signed-off-by: Joaquin Anton Guirao <janton@nvidia.com>
Add a double-precision reference of the UNORM/SNORM convention and check
rgb_to_y/cb/cr, rgb_to_ycbcr, ycbcr_to_rgb, gray_to_y, y_to_gray,
gray_to_ycbcr, ycbcr_to_gray and rgb_to_gray (ITU-R BT.601 and JPEG) for
every Input x Output combination of uint8/int8/uint16/int16/uint32/int32/
float, over all combinations of edge values (min, min+1, -max, -max/3,
-1, 0, 1, max/3, max/2+1, max-1, max) and random values over the full
range of the type.

Also check that converting directly is equivalent to ConvertSatNorm to the
output type followed by the conversion in the output type (the GPU decoder
path), pin hand-computed values for signed and cross-type conversions
(incl. a pixel from a real int32 TIFF), statically check the input
saturation helper, and extend the existing round-trip tests with signed
types. Fix an integer division in the ITU reference gray_to_y, which made
it inexact for types whose max_value is not a multiple of 255 (int16).

Signed-off-by: Joaquin Anton Guirao <janton@nvidia.com>
ConvertGPU normalizes the data to the output type before converting the
color space, while ConvertCPU converts directly from the input type. Check
that both agree (up to the GPU's intermediate rounding) for signed,
unsigned and float inputs and outputs, for all RGB/BGR/YCbCr/GRAY
conversions, and pin the int32 RGB pixel for which CPU and GPU decoding
to YCbCr/GRAY used to differ.

Signed-off-by: Joaquin Anton Guirao <janton@nvidia.com>
Decode an int32 TIFF (the DALI_extra fixture and a synthetic image with
all combinations of edge values) to uint8 YCbCr and GRAY on both backends
and compare them with each other and with an independent numpy reference
of the SNORM convention.

The test requires int32 nvImageCodec sample types to be mapped to DALI
types (and the DALI_extra fixture) and, for the CPU backend, an
nvImageCodec whose libTIFF extension decodes signed samples; it is skipped
otherwise.

Signed-off-by: Joaquin Anton Guirao <janton@nvidia.com>
…loat output

Both Copilot and Greptile independently flagged the same real
inconsistency: saturate_norm clamped a signed sample's minimum value
to -1 unconditionally, but ConvertGPU/ConvertSatNorm<float> only clamp
for an integer Output, leaving it unclamped (e.g. int8_t(-128) ->
-128/127) for a floating point Output. Only apply the clamp when
Output is an integer type, matching GPU, and update the reference
implementation, static_asserts, pinned values and the CPU/GPU
equivalence test accordingly (it now also covers float outputs, which
were previously excluded specifically because of this mismatch).

Also address the remaining review comments:
- Bump the copyright year on the substantially-modified test file.
- Replace the broad "any RuntimeError containing 'Failed to decode'
  becomes a skip" pattern in the new Python test with a one-time
  capability probe on a tiny synthetic image; the real per-image
  assertions no longer swallow exceptions, so a genuine decoding
  regression now fails the test instead of silently skipping it.
- Add a test for empty and corrupt (non-TIFF) input rejection on both
  backends.

Signed-off-by: Joaquin Anton Guirao <janton@nvidia.com>
@jantonguirao
jantonguirao force-pushed the fix/color-space-conversion-signed-types branch from 75a9012 to 94ad44d Compare September 29, 2026 16:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants