Skip to content

Add JPEG XL support: input loader and lossless output for Linear and Flat exports - #774

Merged
marcinz606 merged 5 commits into
marcinz606:mainfrom
thetalkingdrum:feature/jxl
Aug 8, 2026
Merged

Add JPEG XL support: input loader and lossless output for Linear and Flat exports#774
marcinz606 merged 5 commits into
marcinz606:mainfrom
thetalkingdrum:feature/jxl

Conversation

@thetalkingdrum

Copy link
Copy Markdown
Contributor

Summary

JPEG XL is a modern image format that's lossless-capable and compresses meaningfully smaller than TIFF for the same data, with tool support (browsers, OSes, editors) steadily growing. For NegPy it's a straightforward win: it does everything TIFF does for our export/archival use cases, just at a noticeably smaller file size, with no quality tradeoff since we only ever use it losslessly.

  • Input (new): JxlLoader reads lossless JPEG XL (8/16-bit int, float32) into the normal editing pipeline.
  • Linear Output (new): JXL selectable alongside TIFF, lossless, with an Effort slider (1–9, speed vs. compression).
  • Flat export (new — was effectively unreachable): same rationale as Linear/Print — lossless, smaller, no real downside (Flat/Print JXL doesn't carry any of the Linear-specific caveats below, since it's already colour-managed to a real supported space). Print's Format: JPEG XL already worked before this branch; Flat's Format row was hidden entirely (JXL only reachable through the presets dialog), plus a bug shared with Print — "Same as Source" resolving to a working space JXL can't tag used to hard-fail deep in the encoder. Both fixed here.
  • Colour space choices (sRGB / P3 D65 / Rec 2020 / Greyscale) aren't really a compromise: ProPhoto doesn't add much over Rec 2020 for how these files get used, and P3's gamut sits close to Adobe RGB's — the four supported spaces cover what matters.

Tested against a real folder of Coolscan-scanned TIFFs through Linear Output — average size dropped from ~140MB to ~80-90MB (~35-40% smaller), losslessly.

Known limitations (documented, not blocking)

Digging into why JXL export couldn't take an arbitrary ICC profile turned up some real spec/library constraints, now covered in docs/PIPELINE.md / docs/USER_GUIDE.md:

  • No arbitrary ICC on export. imagecodecs.jpegxl_encode() only exposes JXL's enumerated colour tags (the four spaces above), not an embedded ICC profile — even though the JXL spec and libjxl's own C API both support one. Non-taggable spaces are rejected with a clear error rather than silently mistagging the file.
  • Linear Output JXL can never be fully untagged, unlike TIFF — verified against this binding and the independent reference cjxl CLI: JPEG XL's ColourEncoding has no legal "unspecified" state. transfer=LINEAR is pinned explicitly (correct); primaries sits at the library's sRGB default, not literally true for camera/scanner-native data. This is the same situation linear DNG is already in — DNG doesn't offer a real opt-out either, and its baked-in colour info is typically generic boilerplate too, not an actual per-scanner calibration. JXL isn't introducing a new problem here, just inheriting one archival formats already live with. It also matters less than it sounds in practice: NegPy never reads the tag back in (below), no tool does real per-scanner ICC profiling, and a "correct" primaries tag is its own imperfect rabbit hole.
  • Linear JXL carries no processing-history metadata — no description, no XMP, no Make/Model/DateTime, notably no record of whether ICE ran. TIFF has all of it. This one's a real downside, not a wash like the primaries point above — fixing it means shelling out to cjxl instead of imagecodecs (verified it round-trips Exif/XMP) — real cross-platform packaging work, out of scope here.
  • JxlLoader doesn't read any embedded colour tag on import. Doesn't matter for what we want (the linear data), and since JXL can't express "untagged" to begin with, nothing it claims can be trusted anyway — treating it as untagged is the only sound option here.

Test plan

  • make all: 3434 passed, 1 pre-existing unrelated test_overflow_bar.py failure (confirmed failing identically on main before this branch)
  • New tests: test_jxl_loader.py, test_export_jxl.py, test_linear_output.py (IR sidecar follows main format)
  • Manual: real Coolscan TIFF folder re-exported via Linear Output as JXL, ~35-40% smaller losslessly
  • Manual: JXL round-tripped through the app (export → re-import) with no visible artifacts

Mats and others added 5 commits August 6, 2026 16:31
Decodes via imagecodecs.jpegxl_decode, normalises to float32,
no sRGB linearisation or colour-space assumptions — raw bytes in,
raw float out, matching the loader contract.
Linear output: standalone lossless JXL and TIFF with JXL compression,
with format selector and effort slider in the sidebar.

Print/flat export: TIFF compression option (zlib or JXL) with effort
slider in the export settings form. Flat presets can now select JXL
format for taggable colour spaces.
Bug fixes found by exercising the export sidebar in the real app:
- Flat output intent hid the FORMAT row entirely, so JXL was only
  reachable through the presets dialog. set_flat_mode() now shows a
  constrained TIFF/JXL choice in both places.
- "Same as Source" resolves per-file to the Adobe RGB working space
  for scans/raws with no embedded profile, which JPEG XL can't tag —
  it hard-failed deep in the encoder instead of being caught upfront.
  Dropped from JXL_TAGGABLE_SPACES; the colour-space combo now greys
  it out and self-heals to sRGB.
- Flat masters are always encoded lossless (flat_export_config()
  already forced it); the lossy toggle/distance row is now hidden
  instead of showing a control with no effect, and the flat format
  combo reads "JXL (lossless)" so there's no ambiguity.

Also retracts TIFF-with-JXL-compression from linear/print/flat export:
too few TIFF readers support the jpegxl compression tag for it to be
useful. TIFF is zlib-only again across all three export paths;
standalone lossless JPEG XL remains as the alternative format.
_write_jxl now explicitly pins photometric=RGB / transfer=LINEAR
instead of relying on imagecodecs' implicit default for those fields
(currently the same value, but undocumented and one library update
away from silently changing — the raw libjxl default for unset fields
is actually nonlinear sRGB for integer data, confirmed against the
reference cjxl CLI).

Docs corrected to match: JPEG XL has no legal "untagged" state (unlike
TIFF), so the Linear Output JXL path always asserts a colour tag —
sRGB primaries is not actually true for camera/scanner-native data.
Also documented that JXL export can't carry an arbitrary ICC profile
(only sRGB/P3/Rec 2020/Greyscale — imagecodecs.jpegxl_encode() has no
iccprofile= parameter), and that JxlLoader doesn't read colour
metadata from JXL files on import either way, so this doesn't affect
NegPy's own round-trip.
Adds _write_ir_jxl (single-channel lossless JPEG XL, same binding and
pattern as the RGB path) and dispatches on output_format instead of
hardcoding TIFF for the IR sidecar. Verified round-trip exact against
a real encode/decode. Greyscale has no primaries field to get wrong,
so transfer=LINEAR is the only (correct) tag asserted.

Docs updated to match, and corrected an inaccuracy the previous JXL
doc pass introduced: the "description field records source format/
expansion/WB/corrections" claim is TIFF-only — _write_jxl and
_write_ir_jxl take no metadata parameters at all, so a linear JXL
export has zero processing history baked in (notably: no record of
whether ICE ran). Fixing that needs the same encoder rework as the
ICC limitation already documented (imagecodecs.jpegxl_encode() has no
hook for Exif/XMP boxes either), not a small patch — left as a known,
documented gap rather than bundled into this fix.
@marcinz606
marcinz606 merged commit 239b5b6 into marcinz606:main Aug 8, 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