Add JPEG XL support: input loader and lossless output for Linear and Flat exports - #774
Merged
Merged
Conversation
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.
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
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.
JxlLoaderreads lossless JPEG XL (8/16-bit int, float32) into the normal editing pipeline.Format: JPEG XLalready 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.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: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.cjxlCLI: JPEG XL'sColourEncodinghas no legal "unspecified" state.transfer=LINEARis pinned explicitly (correct);primariessits 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.cjxlinstead ofimagecodecs(verified it round-trips Exif/XMP) — real cross-platform packaging work, out of scope here.JxlLoaderdoesn'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 unrelatedtest_overflow_bar.pyfailure (confirmed failing identically onmainbefore this branch)test_jxl_loader.py,test_export_jxl.py,test_linear_output.py(IR sidecar follows main format)