Skip to content

Gamut clamping in apply_gamut_matrix masks codec gamut-clipping regressions #17

Description

@lilith

Summary

apply_gamut_matrix (zensim/src/color.rs:73-85) clamps gamut-converted RGB to [0, 1] after multiplying by the BT.2020→sRGB / P3→sRGB matrix. The comment at line 71 calls this "acceptable for SDR" because a real sRGB display can't show out-of-gamut colors anyway.

This is correct for the post-display-clamp interpretation of the metric. But it has a non-obvious failure mode: it masks codec gamut-clipping regressions.

Failure case

A codec (or pre-encoding pipeline) might clip out-of-gamut colors to sRGB gamut as a destructive operation. Currently zensim's gamut conversion clips the reference identically before XYB, so the destructive clip in the encoded output produces zero metric difference — even though information was lost.

Concretely: suppose the source has a BT.2020-saturated red region. A faithful encoder preserves the wide-gamut signal; a lossy encoder clips to sRGB before encoding. If the user feeds both decoded outputs into zensim with ColorPrimaries::Bt2020, both go through apply_gamut_matrix and clip to (1, 0, 0). zensim returns 100.0 — same result for the lossless and lossy encoder. The metric cannot distinguish them.

This is exactly what bt2020_saturated_colors_differ_from_srgb and p3_green_outside_srgb_gamut_differs in tests/icc_coverage.rs are measuring (and failing on, because the design as currently shipped doesn't promise to detect this).

Adjacent test that does work

bt2020_vs_srgb_interpretation_differs uses non-saturated colors that are in-gamut on both sides, and passes. So the metric correctly distinguishes BT.2020 vs sRGB metadata for any color that's within both gamuts; only the saturation boundary is invisible.

Options

A. Keep clamping, document explicitly

Add a clear note in the Zensim / apply_gamut_matrix rustdoc that the metric measures post-display-clamp perception, and that out-of-gamut clipping during encoding is below the metric's measurement floor for SDR output. Update the failing tests in icc_coverage.rs to use in-gamut colors. Pros: matches existing behavior; low churn. Cons: leaves a real codec regression class undetected.

B. Drop the clamp; let out-of-gamut linear flow into XYB

Remove .clamp(0.0, 1.0) in apply_gamut_matrix. The downstream opsin matrix can handle negative / >1 linear values; the existing .max(0.0) clamp on mixed (color.rs:244-250) on the post-opsin sum still keeps the cube-root domain valid. Codec clip vs. preserve becomes a measurable XYB difference. Pros: detects gamut-clipping regressions. Cons: produces non-zero metric scores for content that's visually identical on an sRGB display. Score values shift for any wide-gamut content with saturated colors — a metric behavior change worth a minor version bump and a Changed changelog note.

C. Two-pass scoring

Compute the metric twice: once with the current clamping (post-display fidelity), once without (gamut-loss detection). Combine or expose both. Pros: best of both worlds. Cons: higher compute cost; new public API.

D. Configurable

Zensim gains a constructor option / profile knob to choose clamp vs. no-clamp. Pros: explicit. Cons: another knob; users have to know to set it.

Recommendation

I'd start with B in a 0.3.x → 0.4.0 release with a Changed note, and file follow-up tickets to verify the new behavior on real wide-gamut codec corpus pairs. The clipping protection is more valuable than the post-display-clamp simplification, and downstream consumers tracking exact scores already have to rebase on the cbrt_midp swap.

A is acceptable as an interim if (B) needs more validation work.

C/D feel like over-engineering for a binary decision the metric should make once.

Tasks

  • Decide on option (A/B/C/D)
  • Implement
  • Update tests/icc_coverage.rs (bt2020_saturated_colors_differ_from_srgb, p3_green_outside_srgb_gamut_differs) — either to assert the new behavior, or to use in-gamut colors per option A
  • Verify against real wide-gamut codec output if possible (mozjpeg / jpegli / zenavif at saturated content)
  • CHANGELOG entry under Changed if option B/C/D

Related

  • The two icc_coverage tests have been failing in CI since they were added (commit 62aa350). They've been the load-bearing red light pointing at this design question.
  • Companion to: precise score targets noted in docs/scale-invariance.md — wide-gamut sources are a different drift axis but adjacent.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions