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
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.
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 throughapply_gamut_matrixand 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_srgbandp3_green_outside_srgb_gamut_differsintests/icc_coverage.rsare 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_differsuses 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_matrixrustdoc 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 inicc_coverage.rsto 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)inapply_gamut_matrix. The downstream opsin matrix can handle negative / >1 linear values; the existing.max(0.0)clamp onmixed(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 aChangedchangelog 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
Zensimgains 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
Changednote, 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
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 AChangedif option B/C/DRelated
icc_coveragetests have been failing in CI since they were added (commit 62aa350). They've been the load-bearing red light pointing at this design question.