Skip to content

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

Open
wayfarer3130 wants to merge 2 commits into
mainfrom
feat/libjxl-jpeg-recompression
Open

wayfarer3130 wants to merge 2 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.
  • 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).
  • JpegXLEncoder.setProgressive(bool) uses the same settings as cjxl -p:
    • a lossy frame gets PROGRESSIVE_DC and QPROGRESSIVE_AC;
    • a lossless frame gets 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 .111 codec exports recompressJpeg() and reconstructJpeg().
  • 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 four new tests:
    • recompression and reconstruction of a colour 4:2:0 JPEG, byte for byte;
    • the same test for a greyscale JPEG;
    • decodeToJpeg() refuses a stream without reconstruction data;
    • a progressive lossy frame decodes at full size.
  • packages/dicom-codec/test/jpegxl-fixtures.test.js has one new test: .50 → .111 → .50 through the public transcode().
  • Results on Node 24.20.0, with the libjxl build from tools/docker/build.sh (emsdk 3.1.74):
    • libjxl: 9 passed, 1 skipped (the CI-only check);
    • dicom-codec: 103 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.

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 produce progressive streams, allowing compatible decoders to show a preview before the full image is available.
    • 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.

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: acfc5576-695a-40d9-9db7-62e310761492
📥 Commits

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

📒 Files selected for processing (8)
  • 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
  • tools/dist-size/baseline.json

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

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.

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.
JPEG reconstruction output
packages/libjxl/src/jpegxl_decode.cpp, packages/libjxl/test/module.test.js, packages/libjxl/README.md
The decoder exposes reconstructed JPEG bytes and throws when reconstruction data is unavailable or decoding does not complete. Tests check byte-identical reconstruction, pixel decoding, and rejection when reconstruction data is absent.
DICOM JPEG recompression and transcoding
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 copies imageInfo to each result. The fixture test checks compressed size and byte-for-byte reconstruction.

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 060c9

No identified issue blocks merging after normal checks.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 26 functions across 6 files. (2 skipped: … Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ 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 primary changes: JPEG recompression and progressive encoding in libjxl.
Full details: Docstring Coverage

Explanation

Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 26 functions across 6 files. (2 skipped: 2 unsupported.)

  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 2
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
🛠️ Fix failing CI checks 💡
  • 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.

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.

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).

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