Skip to content

Fix ICC profile tagging across export formats#599

Merged
marcinz606 merged 1 commit into
marcinz606:mainfrom
linkmodo:fix/export-icc-correctness
Jul 22, 2026
Merged

Fix ICC profile tagging across export formats#599
marcinz606 merged 1 commit into
marcinz606:mainfrom
linkmodo:fix/export-icc-correctness

Conversation

@linkmodo

Copy link
Copy Markdown
Contributor

Summary

An audit of every export format × colour space combination (encoding synthetic buffers through _encode_export and inspecting the resulting bytes) found four colour-profile defects. The mainstream paths — JPEG/PNG/TIFF in sRGB / Adobe RGB / ProPhoto / P3 D65 / Rec 2020, plus custom output ICC overrides — were already correct: right profile embedded, pixels converted. These were the broken cases:

1. Greyscale JPEG/WebP exported untagged, untransformed pixels. The L-mode image was handed to a CMS transform whose source profile is the RGB working-space profile; lcms refuses to build an RGB→gray transform ("cannot build transform" on every greyscale JPEG/WebP export), the exception was swallowed, and the file shipped working-TRC luma with no embedded profile — visibly inconsistent with the greyscale TIFF/PNG of the same edit. Both formats now run the same 16-bit grey re-encode as TIFF/PNG (new _greyscale_to_pil_u8 helper) and embed the grey profile.

2. Greyscale encode used a pure 1/2.2 power, but the tag carries the sRGB TRC. The bundled GrayGamma2.2.icc actually holds the sRGB TRC despite its name — the existing _JXL_COLOR comment documents this, and I verified it by probing the profile through lcms (L* at input 8/255 is 2.35, matching sRGB's 2.19, not pure-2.2's 0.44). Pixels encoded 2.2 but tagged sRGB-TRC read wrong in the shadows. The re-encode now uses the sRGB OETF, which also makes JXL's GRAY/SRGB enumerated tagging consistent with its pixels.

3. ACES/XYZ export targets silently produced untagged Adobe RGB. Neither space has a mapped/bundled ICC profile, so CMS logged "ICC profile not found", skipped the conversion, and wrote untagged working-space pixels. Two-part fix: the encoder now falls back to exporting the working space with its profile embedded (warning logged) so stale presets/DB configs can never produce a mistagged file, and the export colour-space combo no longer offers unmappable spaces (new EXPORT_COLOR_SPACES; ACES/XYZ remain available where they belong, as rawpy decode spaces).

4. Contact-sheet JPEGs were untagged. Tiles are converted to sRGB display space before compositing; the sheet JPEG now embeds the bundled sRGB profile.

Test plan

  • New tests/test_export_icc.py (6 tests): greyscale JPEG tagged + matches greyscale TIFF tonally, greyscale WebP tagged, shadow-probe proving the sRGB TRC (and rejecting the old 2.2 curve), ACES fallback produces working-space pixels with the exact working-space profile bytes, EXPORT_COLOR_SPACES excludes unmappable spaces, contact-sheet sRGB bytes resolve. All four behavioral tests verified to fail against the unfixed encoder.
  • Full re-run of the format × space audit: every combination now embeds a correct profile; no CMS errors.
  • Existing export suites pass (test_export_color_management, test_export_jxl, test_export_webp, test_export_settings_form, test_contact_sheet, test_icc_lut, …) — 122 passed; the only failure is the known pre-existing Windows path-separator issue in test_output_dir_subfolder_of_source, unrelated.
  • ruff check + ruff format clean.

Four related color-profile defects found by auditing every export
format x color space combination:

- Greyscale JPEG/WebP shipped untagged, untransformed pixels: the L-mode
  image was fed a CMS transform whose source is the RGB working-space
  profile, which lcms rejects ("cannot build transform"); the swallowed
  error left working-TRC luma with no embedded profile, inconsistent
  with the greyscale TIFF/PNG of the same edit. Both formats now run the
  16-bit grey re-encode (new _greyscale_to_pil_u8) and embed the grey
  profile.
- The greyscale re-encode used a pure 1/2.2 power, but the bundled
  GrayGamma2.2.icc actually carries the sRGB TRC (as the _JXL_COLOR note
  documents, and verified by probing the profile) — shadows didn't match
  the tag. Encode with the sRGB OETF; this also makes JXL's GRAY/SRGB
  tagging consistent.
- ACES/XYZ targets have no ICC profile, so export skipped CMS and wrote
  untagged working-space pixels. The encoder now falls back to the
  working space with its profile embedded (warning logged), and the
  export colour-space combo no longer offers unmappable spaces
  (EXPORT_COLOR_SPACES).
- Contact-sheet JPEGs (sRGB display-space tiles) now embed the sRGB
  profile instead of shipping untagged.

Tests cover each defect and were verified to fail against the unfixed
encoder.
@marcinz606
marcinz606 merged commit a9e5169 into marcinz606:main Jul 22, 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