Fix matrix/TRC ICC input bypass: dead D65 gate, wrong PCS reference, no-op OETF - #916
Merged
marcinz606 merged 2 commits intoAug 19, 2026
Merged
Conversation
added 2 commits
August 19, 2026 22:21
…sform For matrix/TRC (shaper-matrix) D65 input ICC profiles, extract the 3x3 RGB→XYZ primaries matrix directly and apply it as a plain matrix multiply, bypassing the TRC decode/encode round trip that caused double-gamma errors when the profile's declared TRC didn't match the pipeline's working OETF. LUT-based profiles (A2B0/B2A0) and non-D65 profiles fall through to the existing full CMS path unchanged.
…OETF The bypass added in e2f4b7d never actually fired: its D65 whitepoint gate checked wtpt against literal D65 XYZ, but ICC's PCS is D50-relative, so a conformant profile's wtpt/colorant tags read D50 for v4 profiles (and, inconsistently, native white for v2 — so the gate did fire for some v2 profiles, backwards). Every real run fell through to the pre-existing full-CMS double-TRC bug regardless of Input ICC selection. Fixes: - Drop the D65 gate; is_matrix_trc_profile (matrix/TRC vs LUT) is the only real discriminator. - extract_primaries_matrix now derives each profile's native white via its chad tag (assuming D50 when chad is absent) and Bradford-adapts from that native white to D65, instead of directly combining PCS-D50 colorant tags with the D65-native working-space matrix. An earlier single-step inv(chad) attempt only got this right for D65-native profiles (sRGB, Adobe RGB) by coincidence; it silently produced wrong-reference matrices for D60-native (ACES/ACEScg) and D50-native (ProPhoto) profiles. - apply_primaries_transform now decodes via the working OETF before the matrix multiply and re-encodes after, instead of applying a linear-light operator directly to gamma-encoded data. The full-CMS path is intentionally left untouched for LUT (A2B0/B2A0) profiles: the bundled RGBScan.icc's A2B0 input curve is authored assuming exactly the working-space boundary encoding, so decoding through it there is correct behavior, not the bug being fixed. Rewrote tests/test_icc_trc_bypass.py fixtures around real PCS-D50 colorant + chad values (extracted from real sRGB/ACES/ProPhoto profiles) instead of literal-D65 tags no real file would contain, and added regression coverage for GRAY-space profiles and the app's own bundled ICC profiles. Documented the resulting primaries-only/TRC-inert semantics in docs/PIPELINE.md and docs/USER_GUIDE.md.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
e2f4b7d) added a primaries-only bypass for matrix/TRC profiles, but its D65 whitepoint gate never actually fired: it comparedwtptagainst literal D65 XYZ, but ICC's PCS is D50-relative, so a conformant profile'swtpt/colorant tags read D50 for v4 profiles (and inconsistently read native white for v2 — so the gate fired backwards for some v2 profiles). Every real run fell through to the pre-existing full-CMS double-TRC bug regardless of Input ICC selection.is_matrix_trc_profile— matrix/TRC vs LUT — is the only real discriminator needed) and fixes the primaries extraction to properly chromatically adapt PCS(D50) → native white (viachadwhen present, else assumed D50) → D65, instead of naively combining D50-referenced tags with a D65 working-space matrix. That combination only happened to work for D65-native profiles (sRGB, Adobe RGB) by coincidence; it silently produced wrong-reference matrices for D60-native (ACES/ACEScg) and D50-native (ProPhoto) profiles.apply_primaries_transform, which was applying a linear-light matrix operator directly to gamma-encoded data (a no-op OETF bug) — it now decodes via the working OETF before the matrix multiply and re-encodes after.What this means for users
An Input ICC profile now only ever contributes its primaries; its own declared TRC is inert, because the buffer is never actually encoded that way. Concretely,
sRGB-*-g10.icc(linear TRC) andsRGB-*-srgbtrc.icc(sRGB TRC) — identical primaries, different declared TRC — now render identically as Input ICC. This only applies to matrix/TRC profiles; LUT-based (A2B0/B2A0) profiles are untouched and still run through the full CMS path, honoring their authored input curves (needed for something like the bundledRGBScan.icc).Follow-up (out of scope here)
There's a more structural fix on the table — moving the working OETF to the very last pipeline step so everything runs linear until the end, prototyped at
proto/linear-boundary. It resolves matrix/TRC profiles the same way this PR does, for free, once the OETF sits past the ICC boundary instead of before it. LUT (A2B0) profiles don't get that for free, though: an A2B0 tag's input curve is its TRC, so linear-boundary alone doesn't fix them — the prototype's experimental answer is to rewrite the A2B0 input curve at load time to expect linear input (_compose_oetf_into_a2b0_input_curve, composing the working OETF into the curve), so the existing full-CMS path can then consume it unchanged. That's a bigger scope change (touches both CPU and GPU engines, plus this LUT-curve rewrite) and isn't part of this PR.Test plan
tests/test_icc_trc_bypass.py— 22 tests: profile detection, D50/D60/D65-native primaries extraction with and withoutchad, TRC-independence, transform correctness, and coverage against the app's own bundled ICC profiles.make lint,make type, fullpytestsuite — clean (2 pre-existing failures on this machine are an unrelated missing optionalpyopticfilmdependency, present onmaintoo).chadtag (a Fuji X-E4 IT8.7 target profile) — confirmed its primaries correctly sum to PCS D50 as required by the ICC spec, so the "no chad → assume D50" fallback applies correctly, and the rendered result looks correct.🤖 Generated with Claude Code