Skip to content

Fix matrix/TRC ICC input bypass: dead D65 gate, wrong PCS reference, no-op OETF - #916

Merged
marcinz606 merged 2 commits into
marcinz606:mainfrom
thetalkingdrum:feature/icc-trc-bypass
Aug 19, 2026
Merged

Fix matrix/TRC ICC input bypass: dead D65 gate, wrong PCS reference, no-op OETF#916
marcinz606 merged 2 commits into
marcinz606:mainfrom
thetalkingdrum:feature/icc-trc-bypass

Conversation

@thetalkingdrum

Copy link
Copy Markdown
Contributor

Summary

  • Closes the loop on [Bug]: Color Management seems broken #909 — Input ICC profiles were double-gamma'd because the buffer's TRC is always the working-space OETF, never the selected profile's own declared TRC, but the pipeline was decoding through the profile's TRC anyway.
  • An earlier attempt at this (e2f4b7d) added a primaries-only bypass for matrix/TRC profiles, but its D65 whitepoint gate never actually fired: it compared 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 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.
  • This PR drops the whitepoint gate entirely (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 (via chad when 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.
  • Also fixes 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) and sRGB-*-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 bundled RGBScan.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 without chad, TRC-independence, transform correctness, and coverage against the app's own bundled ICC profiles.
  • make lint, make type, full pytest suite — clean (2 pre-existing failures on this machine are an unrelated missing optional pyopticfilm dependency, present on main too).
  • Manually verified against a real-world D50-native matrix/TRC profile with no chad tag (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

Mats 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.
@marcinz606
marcinz606 merged commit 62334b2 into marcinz606:main Aug 19, 2026
1 check passed
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.

2 participants