Repository navigation
feat(libjxl): JPEG recompression and progressive encoding - #97
Conversation
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>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info
📝 Walkthrough
Merge Risk: ⚪ Minimal · up to
|
Merging this PR will regress 2 benchmarks
Warning Please fix the performance issues or acknowledge them on CodSpeed. Performance Changes
Tip Investigate this regression by commenting Comparing Footnotes
|
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>
igoroctaviano
left a comment
There was a problem hiding this comment.
Nice work on this PR. Left a few comments inline.
| } | ||
|
|
||
| case JXL_DEC_FULL_IMAGE: { | ||
| if (!reconstructing) { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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())); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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: "", |
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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 🚀
Summary
This PR adds two features to
@cornerstonejs/codec-libjxl, and it uses them in@cornerstonejs/dicom-codec:1.2.840.10008.1.2.4.111). The encoder recompresses a JPEG bitstream without loss. The decoder gives back the original JPEG bytes.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_JPEGisON. Before this PR, the embind bindings did not expose that code.Changes
@cornerstonejs/codec-libjxlJpegXLEncoder.getJpegBuffer(size)andJpegXLEncoder.encodeJpeg()callJxlEncoderStoreJPEGMetadataandJxlEncoderAddJPEGFrame. Effort and decoding speed apply. Lossless, distance, and progressive do not apply.encodeJpeg()call consumes the JPEG buffer. The caller must callgetJpegBuffer()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 toencode().encodeJpeg()does not read or set the frame info.JpegXLDecoder.decodeToJpeg()andJpegXLDecoder.getJpegBuffer()useJxlDecoderSetJPEGBuffer. The decoder does not decode the pixels.decodeToJpeg()throws when the stream has no JPEG reconstruction data (nojbrdbox).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)setsRESPONSIVEandQPROGRESSIVE_ACfor each frame. These are the same settings ascjxl -p(seetools/cjxl_main.ccin libjxl). A VarDCT (lossy) frame usesQPROGRESSIVE_AC, and a modular (lossless) frame usesRESPONSIVE.@cornerstonejs/dicom-codectranscode()goes directly from.50to.111, and from.111to.50. The transcode does not decode the pixels, and the result is byte for byte the same JPEG.imageInfoshape as the pixel path (width,height,bitsPerSample,componentCount, and the other fields)..111stream 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..50to.111has no fallback, because the.111pixel encoder does not exist. A JPEG that libjxl cannot recompress makestranscode()throw..111codec exportsrecompressJpeg()andreconstructJpeg(). Each function accepts any typed-array view of the bytes, and throws on an empty frame.progressiveoption.encode()to.111from pixels still throws. The message keeps the text "is not supported by the pixel encoder", and the message now tells the caller to usetranscode()orrecompressJpeg().Tests
packages/libjxl/test/module.test.jshas five new tests:encodeJpeg()withoutgetJpegBuffer()throws;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;packages/dicom-codec/test/jpegxl-fixtures.test.jshas four new tests:.50→.111→.50through the publictranscode(), byte for byte, with the normalisedimageInfo;Uint16Arrayview;.111without reconstruction data →.50through the pixel path;progressiveoption changes the encoded stream.tools/docker/build.sh(emsdk 3.1.74):Size
The JPEG transcode code of libjxl is now linked into the modules. The
dist-sizecheck failed on the encoder, so this PR updates thelibjxlentry oftools/dist-size/baseline.json. The new values come from thedist-libjxlartifact of the CI build of this PR.jpegxlwasm_encode.wasmjpegxlwasm_decode.wasmThe 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-sizecheck passes.Notes
jxl_dec.dcmjs transcodecommand and adcmjs wsiresizecommand. Those commands use these methods.🤖 Generated with Claude Code
Summary by CodeRabbit