Skip to content

feat(libjxl): JPEG recompression and progressive encoding - #97

Merged
wayfarer3130 merged 3 commits into
mainfrom
feat/libjxl-jpeg-recompression
Oct 14, 2026
Merged

wayfarer3130 merged 3 commits into
mainfrom
feat/libjxl-jpeg-recompression

Conversation

@wayfarer3130

@wayfarer3130 wayfarer3130 commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

Summary

This PR adds two features to @cornerstonejs/codec-libjxl, and it uses them in @cornerstonejs/dicom-codec:

  • JPEG XL JPEG Recompression (DICOM transfer syntax 1.2.840.10008.1.2.4.111). The encoder recompresses a JPEG bitstream without loss. The decoder gives back the original JPEG bytes.
  • Progressive encoding. A decoder can show a preview from the first part of the stream.

A whole-slide imaging (WSI) tool needs these features. The tool converts JPEG tiles to JPEG XL without loss, and it writes progressive JPEG XL pyramids.

libjxl 0.11.1 already compiles the transcode code, because JPEGXL_ENABLE_TRANSCODE_JPEG is ON. Before this PR, the embind bindings did not expose that code.

Changes

@cornerstonejs/codec-libjxl

  • JpegXLEncoder.getJpegBuffer(size) and JpegXLEncoder.encodeJpeg() call JxlEncoderStoreJPEGMetadata and JxlEncoderAddJPEGFrame. Effort and decoding speed apply. Lossless, distance, and progressive do not apply.
    • Each encodeJpeg() call consumes the JPEG buffer. The caller must call getJpegBuffer() again before the next frame. Thus a reused encoder cannot recompress the previous frame again by accident.
    • getJpegBuffer(0) throws.
    • getFrameInfo() gives the frame info of the pixel input to encode(). encodeJpeg() does not read or set the frame info.
  • JpegXLDecoder.decodeToJpeg() and JpegXLDecoder.getJpegBuffer() use JxlDecoderSetJPEGBuffer. The decoder does not decode the pixels. decodeToJpeg() throws when the stream has no JPEG reconstruction data (no jbrd box).
    • decodeToJpeg() clears the JPEG buffer first. After a failed call, getJpegBuffer() gives an empty buffer, not the JPEG of the previous frame.
  • JpegXLEncoder.setProgressive(bool) sets RESPONSIVE and QPROGRESSIVE_AC for each frame. These are the same settings as cjxl -p (see tools/cjxl_main.cc in libjxl). A VarDCT (lossy) frame uses QPROGRESSIVE_AC, and a modular (lossless) frame uses RESPONSIVE.
  • The encoder now has one shared helper for the frame settings and one shared helper for the output loop.

@cornerstonejs/dicom-codec

  • transcode() goes directly from .50 to .111, and from .111 to .50. The transcode does not decode the pixels, and the result is byte for byte the same JPEG.
    • The result has the same imageInfo shape as the pixel path (width, height, bitsPerSample, componentCount, and the other fields).
    • User-visible behaviour: a .111 stream without reconstruction data (for example, a stream from a different encoder) still transcodes to .50. In that case, transcode() decodes the pixels and encodes a new JPEG, as it did before this PR. The result is then not byte for byte the same as a source JPEG.
    • .50 to .111 has no fallback, because the .111 pixel encoder does not exist. A JPEG that libjxl cannot recompress makes transcode() throw.
  • The .111 codec exports recompressJpeg() and reconstructJpeg(). Each function accepts any typed-array view of the bytes, and throws on an empty frame.
  • All JPEG XL transfer syntaxes share one encoder wrapper and one decoder wrapper. Thus each WASM module loads only one time.
  • The JPEG XL encodes accept a progressive option.
  • encode() to .111 from pixels still throws. The message keeps the text "is not supported by the pixel encoder", and the message now tells the caller to use transcode() or recompressJpeg().

Tests

  • packages/libjxl/test/module.test.js has five new tests:
    • recompression and reconstruction of a colour 4:2:0 JPEG, byte for byte, and a second encodeJpeg() without getJpegBuffer() throws;
    • the same test for a greyscale JPEG;
    • decodeToJpeg() refuses a stream without reconstruction data, and a reused decoder then gives an empty JPEG buffer;
    • setProgressive(true) changes a lossy stream, and the stream decodes at full size;
    • the same test for a lossless stream.
  • packages/dicom-codec/test/jpegxl-fixtures.test.js has four new tests:
    • .50 → .111 → .50 through the public transcode(), byte for byte, with the normalised imageInfo;
    • the same round trip with the JPEG in a Uint16Array view;
    • .111 without reconstruction data → .50 through the pixel path;
    • the progressive option changes the encoded stream.
  • Results on Node 24.20.0, with the libjxl build from tools/docker/build.sh (emsdk 3.1.74):
    • libjxl: 10 passed, 1 skipped (the CI-only check);
    • dicom-codec: 106 passed, 7 skipped.

Size

The JPEG transcode code of libjxl is now linked into the modules. The dist-size check failed on the encoder, so this PR updates the libjxl entry of tools/dist-size/baseline.json. The new values come from the dist-libjxl artifact of the CI build of this PR.

File Raw Gzip
jpegxlwasm_encode.wasm 2,550,720 → 2,598,167 (+1.86%) 927,454 → 946,918 (+2.10%)
jpegxlwasm_decode.wasm 1,090,788 → 1,092,412 (+0.15%) 377,101 → 377,733 (+0.17%)

The decoder change is in the tolerance of the check, but the PR records it so that the baseline matches the build. The baseline of the other packages does not change.

The review fixes change the local build by +103 bytes (encoder) and +7 bytes (decoder). These changes are in the tolerance, and the local dist-size check passes.

Notes

  • The encode module and the decode module stay separate. The decode module still links only jxl_dec.
  • After the release of this PR, a follow-up PR in dcmjs-commands adds a dcmjs transcode command and a dcmjs wsiresize command. Those commands use these methods.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • JPEG XL encoding can optionally produce progressive streams, allowing compatible decoders to show a preview before the full image is available. Progressive encoding is off by default.
    • Convert JPEG Baseline images to JPEG XL JPEG Recompression and reconstruct the original JPEG bitstream without pixel-based re-encoding.
    • Transcode between these formats while retaining image information; JPEG XL JPEG Recompression streams can also be decoded to pixels.
    • Reconstructing a JPEG requires a stream that contains reconstruction data; otherwise, conversion to JPEG uses pixel-based decoding and encoding.

Adds embind methods for the JPEG XL JPEG Recompression transfer syntax
(1.2.840.10008.1.2.4.111) and for progressive output:

- JpegXLEncoder.getJpegBuffer() / encodeJpeg(): recompress a JPEG bitstream
  losslessly (JxlEncoderAddJPEGFrame with JPEG metadata stored).
- JpegXLDecoder.decodeToJpeg() / getJpegBuffer(): rebuild the original JPEG
  bytes from the jbrd box (JxlDecoderSetJPEGBuffer).
- JpegXLEncoder.setProgressive(): progressive DC + QPROGRESSIVE_AC for lossy,
  RESPONSIVE for lossless, as cjxl -p.

dicom-codec: transcode() goes directly between .50 and .111 without a
pixel decode, the .111 codec exposes recompressJpeg()/reconstructJpeg(),
and JPEG XL encodes accept a `progressive` option.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 5e47735b-6045-4f4e-a2e8-25ad1fac45f6
📥 Commits

Reviewing files that changed from the base of the PR and between 060c972 and 7cea72d.

📒 Files selected for processing (8)
  • packages/dicom-codec/src/codecs/codecFactory.js
  • packages/dicom-codec/src/codecs/jpegxl.js
  • packages/dicom-codec/src/index.js
  • packages/dicom-codec/test/jpegxl-fixtures.test.js
  • packages/libjxl/README.md
  • packages/libjxl/src/jpegxl_decode.cpp
  • packages/libjxl/src/jpegxl_encode.cpp
  • packages/libjxl/test/module.test.js

Included review availability: This review used your included allowance. Your plan provides up to 1 included review per hour; 0 remain after this review.

📝 Walkthrough

The JPEG XL encoder now supports progressive output and JPEG bitstream input. The decoder can reconstruct JPEG bytes from streams that contain reconstruction data. The DICOM codec adds direct transcoding between JPEG Baseline and JPEG XL JPEG Recompression, with pixel-based fallback when reconstruction data is unavailable.

Changes: JPEG XL recompression

Layer / File(s) Summary
Progressive and JPEG input encoding
packages/libjxl/src/jpegxl_encode.cpp, packages/dicom-codec/src/codecs/jpegxl.js, packages/libjxl/test/module.test.js, packages/libjxl/README.md, tools/dist-size/baseline.json
The encoder adds progressive settings and JPEG bitstream input encoding. The DICOM wrapper forwards the progressive option. Tests and documentation cover progressive output and JPEG input encoding. The WebAssembly size baselines are updated.
JPEG reconstruction output
packages/libjxl/src/jpegxl_decode.cpp, packages/libjxl/test/module.test.js
The decoder exposes reconstructed JPEG bytes and throws when reconstruction data is unavailable or decoding does not complete. Tests check byte-identical reconstruction and rejection when reconstruction data is absent.
DICOM JPEG recompression and transcoding
packages/dicom-codec/src/codecs/codecFactory.js, packages/dicom-codec/src/codecs/jpegxl.js, packages/dicom-codec/src/index.js, packages/dicom-codec/test/jpegxl-fixtures.test.js
The DICOM codec adds JPEG recompression and reconstruction operations. transcode uses them for JPEG Baseline and JPEG XL JPEG Recompression, and falls back to pixel decoding and encoding if reconstruction fails. The fixture tests cover byte-exact round trips and the fallback path.

Priority: ➖ Normal

Estimated code review effort: 3 (Moderate) | ~25 minutes

Change: Feature

Sequence Diagram(s)

sequenceDiagram
  participant transcode
  participant recompressJpeg
  participant JpegXLEncoder
  participant reconstructJpeg
  participant JpegXLDecoder
  transcode->>recompressJpeg: JPEG Baseline bytes and encode options
  recompressJpeg->>JpegXLEncoder: JPEG bytes
  JpegXLEncoder-->>recompressJpeg: JPEG XL bytes
  transcode->>reconstructJpeg: JPEG XL bytes
  reconstructJpeg->>JpegXLDecoder: JPEG XL bytes
  JpegXLDecoder-->>reconstructJpeg: reconstructed JPEG bytes
Loading
Merge Risk: ⚪ Minimal · up to 7cea7

No confirmed issue currently prevents merging. The behavior of fallback on malformed reconstruction metadata remains unverified.

Pre-merge checks · Passed 4 · Failed 1

Failed Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage Warning Docstring coverage is 53.33% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 30 functions across 7 files. (1 skipped: … Write docstrings for the functions missing them to satisfy the coverage threshold.
Passed Passed checks (4 passed)
Check name Status Explanation
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Description Check Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check Passed The title clearly and concisely describes the two main changes: JPEG recompression and progressive JPEG XL encoding.
Full details: Docstring Coverage

Explanation

Docstring coverage is 53.33% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 30 functions across 7 files. (1 skipped: 1 unsupported.)

  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codspeed

codspeed Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

Merging this PR will regress 2 benchmarks

⚡ 1 improved benchmark
❌ 2 regressed benchmarks
✅ 64 untouched benchmarks
⏩ 66 skipped benchmarks1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Benchmark BASE HEAD Efficiency
❌ instantiate+destroy JpegXLDecoder x50 5.8 ms 7.1 ms -18.56%
❌ encode to JPEG XL lossy d=1.0 (.112) 1.3 s 1.5 s -10.9%
⚡ HTJ2K Lossless (.201) 26.5 ms 14.6 ms +81.52%

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing feat/libjxl-jpeg-recompression (060c972) with main (acc43f1)

Open in CodSpeed

Footnotes

  1. 66 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩

The encoder now links libjxl's JPEG transcode path (JxlEncoderAddJPEGFrame
and the JPEG metadata store), and the decoder links JPEG reconstruction.

- jpegxlwasm_encode.wasm: 2550720 -> 2598167 raw (+1.86%),
  927454 -> 946918 gzip (+2.10%)
- jpegxlwasm_decode.wasm: 1090788 -> 1092412 raw (+0.15%),
  377101 -> 377733 gzip (+0.17%)

Sizes are from the dist-libjxl artifact of the PR CI build (emsdk 3.1.74).
Only the libjxl entry changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@wayfarer3130
wayfarer3130 requested a review from jbocce October 8, 2026 15:31

@igoroctaviano igoroctaviano left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work on this PR. Left a few comments inline.

}

case JXL_DEC_FULL_IMAGE: {
if (!reconstructing) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This branch appears unreachable. If the stream lacks a jbrd box, the decoder fires JXL_DEC_NEED_IMAGE_OUT_BUFFER first (handled below at line 151), so we never reach JXL_DEC_FULL_IMAGE with reconstructing == false. Harmless but could be removed for clarity, or kept as defensive code with a comment explaining the intent.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you. You are correct: without a jbrd box, the decoder sends JXL_DEC_NEED_IMAGE_OUT_BUFFER first, and that branch throws. I kept the !reconstructing check as defensive code, and I added a short comment that tells this. If a future libjxl version changes the event order, the check stops a stale buffer. In 7cea72d, decodeToJpeg() also clears the JPEG buffer at the start. Thus a failed call does not leave the JPEG of the previous frame in the buffer.

JxlEncoderCloseInput(enc);

// Recompression saves about 20%, so the JPEG size is a close first guess.
processOutput(enc, std::max<size_t>(64u * 1024u, jpeg_.size()));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After encodeJpeg(), calling getFrameInfo() returns stale/empty data since frameInfo_ isn't populated here. Might be worth either documenting this limitation or clearing frameInfo_ explicitly so callers don't get confused by leftover state from a previous encode() call.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you. I documented the limit in 7cea72d, and I did not clear frameInfo_. The doc comment of getFrameInfo() now tells that the frame info is for the pixel input of encode() only, and that encodeJpeg() does not read or set the frame info. If encodeJpeg() clears frameInfo_, then encode() after encodeJpeg() on the same encoder fails until the caller calls getDecodedBuffer() again. The same commit also makes encodeJpeg() consume its input buffer. Thus a reused encoder cannot recompress the previous JPEG by accident.

Decoder: undefined,
Encoder: undefined,
encoderName: "JpegXLEncoder",
decoderName: "",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: empty strings for unused names work fine, but null would be more explicit about the intent that these wrappers are single-direction (encode-only or decode-only).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you. I changed the unused names to null in 7cea72d. In the same commit, all of the JPEG XL transfer syntaxes now share one encoder wrapper and one decoder wrapper. Thus each WASM module loads only one time, and the file has two wrappers, not four.

…oding

- setProgressive now sets RESPONSIVE and QPROGRESSIVE_AC for every frame,
  the same as cjxl -p (it previously set PROGRESSIVE_DC for lossy frames).
- encodeJpeg() consumes the JPEG buffer, so a reused encoder cannot
  recompress the previous frame; getJpegBuffer(0) throws.
- decodeToJpeg() clears the JPEG buffer first, so a failed call does not
  leave the previous frame readable.
- Document that getFrameInfo() describes encode() input only.
- dicom-codec: size the WASM buffers in bytes, so non-byte typed-array
  views work; reject an empty frame; use copyFromWasm.
- dicom-codec: the .50 <-> .111 transcode shortcuts return the same
  imageInfo shape as the pixel path.
- dicom-codec: .111 -> .50 falls back to decode and encode when the stream
  has no JPEG reconstruction data.
- dicom-codec: one shared encoder and decoder wrapper for all JPEG XL
  syntaxes, so each WASM module loads once; null for unused names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@igoroctaviano igoroctaviano left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All previous review comments have been addressed:

Verified fixes:

Comment Resolution
Unreachable !reconstructing branch in decode ✅ Kept as defensive code with explanatory comment: // Defensive: without a jbrd box, NEED_IMAGE_OUT_BUFFER throws first. Also clears JPEG buffer at start to prevent stale data.
Stale frameInfo_ after encodeJpeg() ✅ Documented in getFrameInfo(): "The frame info of the last getDecodedBuffer()... encodeJpeg() neither reads nor sets it." Also added jpegPending_ flag to consume the buffer.
Empty strings vs null for unused wrapper names ✅ Changed to null for explicit intent. Also consolidated to shared wrappers so each WASM module loads only once.

Code quality observations:

  • Clean separation between pixel encoding and JPEG recompression paths
  • Proper resource management with jpegPending_ flag preventing accidental reuse
  • jpeg_.resize(0) on failed decode prevents stale buffer reads
  • Comprehensive test coverage:
    • Byte-for-byte round-trip for color and greyscale JPEG
    • TypedArray view handling (Uint16Array)
    • Fallback path when reconstruction data is missing
    • Progressive encoding for both lossy and lossless
  • README updated with JPEG recompression examples
  • WASM baseline updated (+2% encoder, +0.2% decoder)

Note on CodSpeed regressions: The instantiation benchmark regression is expected due to larger WASM modules. The encode regression may be noise or due to additional code paths; the actual recompression path should be faster than pixel-based encoding since it skips DCT.

LGTM 🚀

@wayfarer3130
wayfarer3130 merged commit 9a07471 into main Oct 14, 2026
16 of 17 checks passed
@wayfarer3130
wayfarer3130 deleted the feat/libjxl-jpeg-recompression branch October 14, 2026 12:12
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