From f29499c83243edc40e87a63bd31a2e211eeadf8e Mon Sep 17 00:00:00 2001 From: Adnane Belmadiaf Date: Sat, 15 Aug 2026 21:26:06 +0200 Subject: [PATCH] feat: JPEG XL encoder/decoder --- .github/workflows/bench.yml | 2 +- .github/workflows/pr-checks.yml | 23 +- .gitmodules | 3 + packages/dicom-codec/README.md | 14 +- packages/dicom-codec/package.json | 1 + .../dicom-codec/src/codecs/codecFactory.js | 7 +- packages/dicom-codec/src/codecs/index.js | 11 + packages/dicom-codec/src/codecs/jpegxl.js | 127 ++++++++ packages/dicom-codec/test/dispatch.test.js | 5 + .../test/transcode-and-pixeldata.test.js | 26 ++ packages/libjxl/.gitignore | 3 + packages/libjxl/CMakeLists.txt | 120 +++++++ packages/libjxl/README.md | 88 +++++ packages/libjxl/build.sh | 54 ++++ packages/libjxl/package.json | 40 +++ packages/libjxl/src/frame_info.cpp | 14 + packages/libjxl/src/frame_info.h | 22 ++ packages/libjxl/src/frame_size.h | 46 +++ packages/libjxl/src/jpegxl_decode.cpp | 263 +++++++++++++++ packages/libjxl/src/jpegxl_encode.cpp | 305 ++++++++++++++++++ packages/libjxl/src/raw_buffer.h | 68 ++++ tools/dist-size/baseline.json | 18 ++ 22 files changed, 1250 insertions(+), 10 deletions(-) create mode 100644 packages/dicom-codec/src/codecs/jpegxl.js create mode 100644 packages/libjxl/.gitignore create mode 100644 packages/libjxl/CMakeLists.txt create mode 100644 packages/libjxl/README.md create mode 100644 packages/libjxl/build.sh create mode 100644 packages/libjxl/package.json create mode 100644 packages/libjxl/src/frame_info.cpp create mode 100644 packages/libjxl/src/frame_info.h create mode 100644 packages/libjxl/src/frame_size.h create mode 100644 packages/libjxl/src/jpegxl_decode.cpp create mode 100644 packages/libjxl/src/jpegxl_encode.cpp create mode 100644 packages/libjxl/src/raw_buffer.h diff --git a/.github/workflows/bench.yml b/.github/workflows/bench.yml index fa61568b..9c5f064d 100644 --- a/.github/workflows/bench.yml +++ b/.github/workflows/bench.yml @@ -107,7 +107,7 @@ jobs: ci_touched=true ;; esac case "$f" in - .github/workflows/*|package.json|yarn.lock|vitest.workspace.mjs|babel.config.json|lerna.json|tools/ci/*|tools/dist-size/*|tools/browser-smoke/*|tools/fixture-verification/*) + .github/workflows/*|package.json|yarn.lock|vitest.workspace.mjs|babel.config.json|lerna.json|tools/ci/*|tools/csp/*|tools/dist-size/*|tools/browser-smoke/*|tools/fixture-verification/*) toolchain_touched=true ;; packages/*) pkg=${f#packages/}; pkg=${pkg%%/*} diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index b57de411..365e4586 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -79,8 +79,10 @@ jobs: REF: ${{ github.ref }} run: | set -e - ALL=(charls libjpeg-turbo-8bit libjpeg-turbo-12bit openjpeg openjphjs little-endian big-endian dicom-codec) + ALL=(charls libjpeg-turbo-8bit libjpeg-turbo-12bit libjxl openjpeg openjphjs little-endian big-endian dicom-codec) + BENCHABLE=(charls libjpeg-turbo-8bit libjpeg-turbo-12bit openjpeg openjphjs little-endian big-endian dicom-codec) ALL_JSON=$(printf '%s\n' "${ALL[@]}" | jq -R . | jq -s -c .) + BENCHABLE_JSON=$(printf '%s\n' "${BENCHABLE[@]}" | jq -R . | jq -s -c .) # Baseline runs: on a manual dispatch or any commit landing on # main, build/test/bench every package. The "diff vs main" trick @@ -90,7 +92,7 @@ jobs: if [ "$EVENT_NAME" = "workflow_dispatch" ] || [ "$REF" = "refs/heads/main" ]; then echo "Baseline run ($EVENT_NAME on $REF): forcing all packages" echo "packages=$ALL_JSON" >> "$GITHUB_OUTPUT" - echo "bench=$ALL_JSON" >> "$GITHUB_OUTPUT" + echo "bench=$BENCHABLE_JSON" >> "$GITHUB_OUTPUT" echo "any=true" >> "$GITHUB_OUTPUT" exit 0 fi @@ -127,16 +129,20 @@ jobs: if ! git diff --quiet "$BASE"..HEAD -- "$p"; then echo "Toolchain change detected in $p: forcing all packages (build/test/bench)" echo "packages=$ALL_JSON" >> "$GITHUB_OUTPUT" - echo "bench=$ALL_JSON" >> "$GITHUB_OUTPUT" + echo "bench=$BENCHABLE_JSON" >> "$GITHUB_OUTPUT" echo "any=true" >> "$GITHUB_OUTPUT" exit 0 fi done changed=() + bench_changed=() for pkg in "${ALL[@]}"; do if ! git diff --quiet "$BASE"..HEAD -- "packages/$pkg/"; then changed+=("$pkg") + for known in "${BENCHABLE[@]}"; do + [ "$pkg" = "$known" ] && bench_changed+=("$pkg") && break + done fi done if [ ${#changed[@]} -eq 0 ]; then @@ -145,8 +151,13 @@ jobs: echo 'bench=[]' >> "$GITHUB_OUTPUT" echo "any=false" >> "$GITHUB_OUTPUT" else - bench_json=$(printf '%s\n' "${changed[@]}" | jq -R . | jq -s -c .) - echo "Changed: $bench_json — building all packages, benching changed only" + changed_json=$(printf '%s\n' "${changed[@]}" | jq -R . | jq -s -c .) + if [ ${#bench_changed[@]} -gt 0 ]; then + bench_json=$(printf '%s\n' "${bench_changed[@]}" | jq -R . | jq -s -c .) + else + bench_json='[]' + fi + echo "Changed: $changed_json — building all packages, benching supported changes only" echo "packages=$ALL_JSON" >> "$GITHUB_OUTPUT" echo "bench=$bench_json" >> "$GITHUB_OUTPUT" echo "any=true" >> "$GITHUB_OUTPUT" @@ -347,7 +358,7 @@ jobs: # time. Enable macro runners for the org on app.codspeed.io first # (the repo is public — also make sure the runner group allows public # repositories), then: gh variable set CODSPEED_MACRO_ENABLED --body true - if: needs.detect-changes.outputs.any == 'true' && vars.CODSPEED_MACRO_ENABLED == 'true' + if: needs.detect-changes.outputs.any == 'true' && needs.detect-changes.outputs.bench != '[]' && vars.CODSPEED_MACRO_ENABLED == 'true' # Advisory instrument: real wall-clock numbers (V8 JIT active, real # cache/branch behavior) that complement the simulation gate (bench.yml) — # simulation catches small algorithmic slips deterministically, diff --git a/.gitmodules b/.gitmodules index 3511dba9..767bf228 100644 --- a/.gitmodules +++ b/.gitmodules @@ -13,3 +13,6 @@ [submodule "packages/charls/extern/charls"] path = packages/charls/extern/charls url = https://github.com/cornerstonejs/charls.git +[submodule "packages/libjxl/extern/libjxl"] + path = packages/libjxl/extern/libjxl + url = https://github.com/libjxl/libjxl.git diff --git a/packages/dicom-codec/README.md b/packages/dicom-codec/README.md index 07479391..9e30e78e 100644 --- a/packages/dicom-codec/README.md +++ b/packages/dicom-codec/README.md @@ -12,6 +12,7 @@ DICOM codecs for JavaScript, with browser and Node.js support. | JpegLossless | 1.2.840.10008.1.2.4.57 1.2.840.10008.1.2.4.70 | X | - | X | - | | Jpegls | 1.2.840.10008.1.2.4.80 1.2.840.10008.1.2.4.81 | X | X | X | X | | Jpeg2000 | 1.2.840.10008.1.2.4.90 1.2.840.10008.1.2.4.91 | X | X | X | X | +| JpegXL | 1.2.840.10008.1.2.4.110 1.2.840.10008.1.2.4.111 1.2.840.10008.1.2.4.112 | X | X except .111 | X | X | | RleLossless | 1.2.840.10008.1.2.5 | X | - | - | - | | HTJ2K | 1.2.840.10008.1.2.202 (201,203 as well) | X | X | X | X | @@ -177,7 +178,18 @@ Parameters (**It does not mutate any param** ): ## TransferSyntax Specific Encoding Options -TBD +### JPEG XL + +Transfer syntax `1.2.840.10008.1.2.4.110` always uses lossless encoding. +Transfer syntax `1.2.840.10008.1.2.4.111` supports decoding only because the +pixel encoder does not create JPEG reconstruction data. + +Transfer syntax `1.2.840.10008.1.2.4.112` accepts these encoding options: + +- `lossless`: Boolean, defaults to `true`. +- `distance`: Number from 0 to 25 for lossy encoding. +- `effort`: Integer from 1 to 9. +- `decodingSpeed`: Integer from 0 to 4. ## Testing diff --git a/packages/dicom-codec/package.json b/packages/dicom-codec/package.json index 862455b0..56514e66 100644 --- a/packages/dicom-codec/package.json +++ b/packages/dicom-codec/package.json @@ -29,6 +29,7 @@ "@cornerstonejs/codec-big-endian": "^0.1.1", "@cornerstonejs/codec-charls": "^1.2.5", "@cornerstonejs/codec-libjpeg-turbo-8bit": "^1.2.4", + "@cornerstonejs/codec-libjxl": "^1.0.0", "@cornerstonejs/codec-little-endian": "^0.0.7", "@cornerstonejs/codec-openjpeg": "^1.3.2", "@cornerstonejs/codec-openjph": "^2.4.9", diff --git a/packages/dicom-codec/src/codecs/codecFactory.js b/packages/dicom-codec/src/codecs/codecFactory.js index 25fc2d54..a1d5c483 100644 --- a/packages/dicom-codec/src/codecs/codecFactory.js +++ b/packages/dicom-codec/src/codecs/codecFactory.js @@ -223,6 +223,8 @@ function encode(context, codecConfig, imageFrame, imageInfo, options = {}) { "Encoded is a Typed array of: " + encodedTypedArray.constructor.name ); + const encodedImageFrame = getImageFrame(encodedTypedArray).slice(); + // cleanup allocated memory encoderInstance.delete(); @@ -231,7 +233,7 @@ function encode(context, codecConfig, imageFrame, imageInfo, options = {}) { }; return { - imageFrame: getImageFrame(encodedTypedArray), + imageFrame: encodedImageFrame, imageInfo: getTargetImageInfo(imageInfo, imageInfo), processInfo, }; @@ -276,6 +278,7 @@ function decode(context, codecConfig, imageFrame, imageInfo) { // get information about the decoded image const decodedImageInfo = decoderInstance.getFrameInfo(); + const decodedImageFrame = getImageFrame(decodedTypedArray).slice(); // cleanup allocated memory decoderInstance.delete(); @@ -285,7 +288,7 @@ function decode(context, codecConfig, imageFrame, imageInfo) { }; return { - imageFrame: getImageFrame(decodedTypedArray), + imageFrame: decodedImageFrame, imageInfo: getTargetImageInfo(imageInfo, decodedImageInfo), processInfo, }; diff --git a/packages/dicom-codec/src/codecs/index.js b/packages/dicom-codec/src/codecs/index.js index 1ab34a12..1bc096ab 100644 --- a/packages/dicom-codec/src/codecs/index.js +++ b/packages/dicom-codec/src/codecs/index.js @@ -7,6 +7,14 @@ const rleLosslessCodec = require("./rleLossless"); const bigEndianCodec = require("./bigEndian"); const libjpegTurbo8BitCodec = require("./libjpegTurbo8bit"); const libjpegTurbo12BitCodec = require("./libjpegTurbo12bit"); +const jpegxlCodec = require("./jpegxl"); + +const jpegxlLosslessCodec = Object.assign({}, jpegxlCodec, { + encode: jpegxlCodec.encodeLossless, +}); +const jpegxlJpegRecompressionCodec = Object.assign({}, jpegxlCodec, { + encode: jpegxlCodec.encodeJpegRecompression, +}); /** * Wrapper to codec. It holds current codec, encoder, decoder, name for each. @@ -42,6 +50,9 @@ const codecsMap = { "1.2.840.10008.1.2.4.81": jpeglsCodec, "1.2.840.10008.1.2.4.90": jpeg2000Codec, "1.2.840.10008.1.2.4.91": jpeg2000Codec, + "1.2.840.10008.1.2.4.110": jpegxlLosslessCodec, + "1.2.840.10008.1.2.4.111": jpegxlJpegRecompressionCodec, + "1.2.840.10008.1.2.4.112": jpegxlCodec, // Private Transfer Syntax - update to final ID when released by WG-06 "3.2.840.10008.1.2.4.96": htj2kCodec, // The three official HTJ2K transfer syntaxes diff --git a/packages/dicom-codec/src/codecs/jpegxl.js b/packages/dicom-codec/src/codecs/jpegxl.js new file mode 100644 index 00000000..52a66af9 --- /dev/null +++ b/packages/dicom-codec/src/codecs/jpegxl.js @@ -0,0 +1,127 @@ +const codecFactory = require("./codecFactory"); +const { isNode } = require("browser-or-node"); + +const decoderWrapper = { + codec: undefined, + Decoder: undefined, + Encoder: undefined, + encoderName: "", + decoderName: "JpegXLDecoder", +}; + +const encoderWrapper = { + codec: undefined, + Decoder: undefined, + Encoder: undefined, + encoderName: "JpegXLEncoder", + decoderName: "", +}; + +async function loadDecoder() { + const module = await import("@cornerstonejs/codec-libjxl/decodewasmjs"); + return module.default(getModuleOptions("@cornerstonejs/codec-libjxl/decodewasm")); +} + +async function loadEncoder() { + const module = await import("@cornerstonejs/codec-libjxl/encodewasmjs"); + return module.default(getModuleOptions("@cornerstonejs/codec-libjxl/encodewasm")); +} + +function getModuleOptions(wasmModule) { + if (!isNode) { + return {}; + } + + const nodeRequire = eval("require"); + const fs = nodeRequire("fs"); + return { wasmBinary: fs.readFileSync(nodeRequire.resolve(wasmModule)) }; +} + +async function decode(imageFrame, imageInfo) { + return codecFactory.runProcess( + decoderWrapper, + loadDecoder, + null, + decoderWrapper.decoderName, + (context) => { + const result = codecFactory.decode( + context, + decoderWrapper, + imageFrame, + imageInfo + ); + + result.imageInfo.signed = imageInfo.signed; + result.imageInfo.isSigned = imageInfo.isSigned; + result.imageInfo.pixelRepresentation = imageInfo.pixelRepresentation; + return result; + } + ); +} + +async function encode(imageFrame, imageInfo, options = {}) { + return codecFactory.runProcess( + encoderWrapper, + loadEncoder, + null, + encoderWrapper.encoderName, + (context) => { + function beforeEncode(encoderInstance) { + const { + lossless = true, + distance, + effort, + decodingSpeed, + } = options; + + encoderInstance.setLossless(lossless); + if (distance !== undefined) { + encoderInstance.setDistance(distance); + } + if (effort !== undefined) { + encoderInstance.setEffort(effort); + } + if (decodingSpeed !== undefined) { + encoderInstance.setDecodingSpeed(decodingSpeed); + } + } + + const codecImageInfo = Object.assign({}, imageInfo, { isSigned: false }); + const result = codecFactory.encode( + context, + encoderWrapper, + imageFrame, + codecImageInfo, + Object.assign({}, options, { beforeEncode }) + ); + + result.imageInfo.signed = imageInfo.signed; + result.imageInfo.isSigned = imageInfo.isSigned; + return result; + } + ); +} + +function encodeLossless(imageFrame, imageInfo, options = {}) { + return encode( + imageFrame, + imageInfo, + Object.assign({}, options, { lossless: true }) + ); +} + +async function encodeJpegRecompression() { + throw new Error( + "JPEG XL JPEG Recompression encoding is not supported by the pixel encoder" + ); +} + +function getPixelData(imageFrame, imageInfo) { + return codecFactory.getPixelData(imageFrame, imageInfo); +} + +exports.decode = decode; +exports.encode = encode; +exports.encodeLossless = encodeLossless; +exports.encodeJpegRecompression = encodeJpegRecompression; +exports.getPixelData = getPixelData; diff --git a/packages/dicom-codec/test/dispatch.test.js b/packages/dicom-codec/test/dispatch.test.js index 1169a573..3d587988 100644 --- a/packages/dicom-codec/test/dispatch.test.js +++ b/packages/dicom-codec/test/dispatch.test.js @@ -11,6 +11,8 @@ const REQUIRED_BUILDS = [ "libjpeg-turbo-8bit/dist/libjpegturbojs.js", "openjpeg/dist/openjpegjs.js", "openjphjs/dist/openjphjs.js", + "libjxl/dist/jpegxlwasm_decode.js", + "libjxl/dist/jpegxlwasm_encode.js", "little-endian/dist/index.js", "big-endian/dist/index.js", ] @@ -30,6 +32,9 @@ const SUPPORTED_UIDS = [ "1.2.840.10008.1.2.4.81", "1.2.840.10008.1.2.4.90", "1.2.840.10008.1.2.4.91", + "1.2.840.10008.1.2.4.110", + "1.2.840.10008.1.2.4.111", + "1.2.840.10008.1.2.4.112", "1.2.840.10008.1.2.4.201", "1.2.840.10008.1.2.4.202", "1.2.840.10008.1.2.4.203", diff --git a/packages/dicom-codec/test/transcode-and-pixeldata.test.js b/packages/dicom-codec/test/transcode-and-pixeldata.test.js index e2fef74e..3dd343df 100644 --- a/packages/dicom-codec/test/transcode-and-pixeldata.test.js +++ b/packages/dicom-codec/test/transcode-and-pixeldata.test.js @@ -8,6 +8,8 @@ const packagesRoot = resolve(__dirname, "../..") const REQUIRED = [ "charls/dist/charlsjs.js", + "libjxl/dist/jpegxlwasm_decode.js", + "libjxl/dist/jpegxlwasm_encode.js", ] const ALL_BUILT = REQUIRED.every((p) => existsSync(resolve(packagesRoot, p))) @@ -41,6 +43,30 @@ describe.skipIf(!ALL_BUILT)("dicom-codec encode", () => { const decoded = await dicomCodec.decode(encoded.imageFrame, ctImageInfo, "1.2.840.10008.1.2.4.80") expect(frameBytes(decoded.imageFrame).equals(ct1Raw)).toBe(true) }) + + it("encode() to JPEG XL Lossless (.110) round-trips byte-exact", async () => { + const encoded = await dicomCodec.encode( + new Uint8Array(ct1Raw), + ctImageInfo, + "1.2.840.10008.1.2.4.110" + ) + const decoded = await dicomCodec.decode( + encoded.imageFrame, + ctImageInfo, + "1.2.840.10008.1.2.4.110" + ) + expect(frameBytes(decoded.imageFrame).equals(ct1Raw)).toBe(true) + }) + + it("rejects JPEG XL JPEG Recompression encoding (.111)", async () => { + await expect( + dicomCodec.encode( + new Uint8Array(ct1Raw), + ctImageInfo, + "1.2.840.10008.1.2.4.111" + ) + ).rejects.toThrow(/JPEG Recompression encoding is not supported/) + }) }) describe.skipIf(!ALL_BUILT)("dicom-codec getPixelData typed-array contract", () => { diff --git a/packages/libjxl/.gitignore b/packages/libjxl/.gitignore new file mode 100644 index 00000000..2b8fca5c --- /dev/null +++ b/packages/libjxl/.gitignore @@ -0,0 +1,3 @@ +build/ +!dist/ +!dist/** diff --git a/packages/libjxl/CMakeLists.txt b/packages/libjxl/CMakeLists.txt new file mode 100644 index 00000000..430bf846 --- /dev/null +++ b/packages/libjxl/CMakeLists.txt @@ -0,0 +1,120 @@ +cmake_minimum_required(VERSION 3.16) +project(cornerstone-codec-libjxl CXX) + +if(NOT EMSCRIPTEN) + message(FATAL_ERROR "This project must be configured with emcmake (Emscripten).") +endif() + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +# Where the libjxl submodule lives. +set(LIBJXL_SOURCE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/extern/libjxl" + CACHE PATH "Path to a libjxl source checkout") + +if(NOT EXISTS "${LIBJXL_SOURCE_DIR}/lib/include/jxl/decode.h") + message(FATAL_ERROR + "libjxl sources not found at ${LIBJXL_SOURCE_DIR}. Update the Git " + "submodules before configuring.") +endif() + +# Emscripten's ENVIRONMENT. The shipped module targets the browser and the +# decode web worker only: including `node` makes the glue emit +# `await import("node:module")`, which rspack/webpack reject as an unhandled +# scheme. Configure a throwaway build dir with -DJXL_WASM_ENVIRONMENT= +# web,worker,node to get a module that can be benchmarked or tested under Node. +set(JXL_WASM_ENVIRONMENT "web,worker" CACHE STRING + "Value passed to Emscripten's -sENVIRONMENT") + +# These apply to libjxl and Highway as well as to the wrapper below, so they +# have to be set before add_subdirectory. Without -msimd128 Highway compiles +# only its scalar fallback and decoding is several times slower; SIMD is +# supported by every browser that can run this module, so it is on by default. +option(JXL_WASM_SIMD "Compile with WebAssembly SIMD (much faster decode)" ON) + +if(JXL_WASM_SIMD) + add_compile_options(-msimd128) +endif() + +add_compile_options(-O3) + +# Static, no tools/tests. Everything the command line tools would pull in is +# turned off. +set(BUILD_SHARED_LIBS OFF CACHE BOOL "" FORCE) +set(BUILD_TESTING OFF CACHE BOOL "" FORCE) +set(JPEGXL_STATIC ON CACHE BOOL "" FORCE) +set(JPEGXL_ENABLE_TOOLS OFF CACHE BOOL "" FORCE) +set(JPEGXL_ENABLE_EXAMPLES OFF CACHE BOOL "" FORCE) +set(JPEGXL_ENABLE_BENCHMARK OFF CACHE BOOL "" FORCE) +set(JPEGXL_ENABLE_DOXYGEN OFF CACHE BOOL "" FORCE) +set(JPEGXL_ENABLE_MANPAGES OFF CACHE BOOL "" FORCE) +set(JPEGXL_ENABLE_JNI OFF CACHE BOOL "" FORCE) +set(JPEGXL_ENABLE_SJPEG OFF CACHE BOOL "" FORCE) +set(JPEGXL_ENABLE_OPENEXR OFF CACHE BOOL "" FORCE) +set(JPEGXL_ENABLE_TCMALLOC OFF CACHE BOOL "" FORCE) +set(JPEGXL_ENABLE_PLUGINS OFF CACHE BOOL "" FORCE) +set(JPEGXL_ENABLE_DEVTOOLS OFF CACHE BOOL "" FORCE) +set(JPEGXL_ENABLE_COVERAGE OFF CACHE BOOL "" FORCE) +set(JPEGXL_ENABLE_FUZZERS OFF CACHE BOOL "" FORCE) +set(JPEGXL_BUNDLE_LIBPNG OFF CACHE BOOL "" FORCE) +set(JPEGXL_ENABLE_SKCMS ON CACHE BOOL "" FORCE) +set(JPEGXL_FORCE_SYSTEM_BROTLI OFF CACHE BOOL "" FORCE) +set(JPEGXL_FORCE_SYSTEM_HWY OFF CACHE BOOL "" FORCE) +# Needed for the JPEG XL JPEG Recompression transfer syntax +# (1.2.840.10008.1.2.4.111), whose streams carry a jbrd box. +set(JPEGXL_ENABLE_TRANSCODE_JPEG ON CACHE BOOL "" FORCE) + +add_subdirectory("${LIBJXL_SOURCE_DIR}" "${CMAKE_CURRENT_BINARY_DIR}/libjxl" + EXCLUDE_FROM_ALL) + +# Decoder and encoder are separate modules. A viewer only ever decodes, and +# every decode worker fetches and compiles whatever it loads: linking the +# encoder in as well takes the module from 1.1 MB to 2.7 MB, which is paid per +# worker, per session, for code that never runs there. +# +# Each module compiles frame_info.cpp for the shared FrameInfo binding - once +# per module, since embind throws if a value_object name is registered twice. +function(add_jxl_wasm_module target source export_name) + add_executable(${target} src/frame_info.cpp "${source}") + + # jxl_cms carries JxlGetDefaultCms(), which both halves need. + target_link_libraries(${target} PRIVATE ${ARGN} jxl_cms) + + target_include_directories(${target} PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}/src" + "${LIBJXL_SOURCE_DIR}/lib/include" + "${CMAKE_CURRENT_BINARY_DIR}/libjxl/lib/include") + + target_compile_options(${target} PRIVATE -fexceptions -O3) + if(JXL_WASM_SIMD) + target_compile_options(${target} PRIVATE -msimd128) + endif() + + set_target_properties(${target} PROPERTIES + OUTPUT_NAME "${target}" + SUFFIX ".js" + LINK_FLAGS "\ +-O3 \ +-fexceptions \ +-lembind \ +-sWASM=1 \ +-sMODULARIZE=1 \ +-sEXPORT_ES6=1 \ +-sEXPORT_NAME=${export_name} \ +-sALLOW_MEMORY_GROWTH=1 \ +-sMALLOC=emmalloc \ +-sFILESYSTEM=0 \ +-sENVIRONMENT=${JXL_WASM_ENVIRONMENT} \ +-sEXPORTED_RUNTIME_METHODS=[getExceptionMessage,decrementExceptionRefcount] \ +-sDYNAMIC_EXECUTION=0 \ +-sTEXTDECODER=2 \ +-sINCOMING_MODULE_JS_API=[locateFile,instantiateWasm,wasmBinary,print,printErr] \ +") +endfunction() + +# jxl_dec is the decode-only public library, so the decode module carries no +# encoder code at all; jxl is the full one (see lib/jxl.cmake). +add_jxl_wasm_module(jpegxlwasm_decode src/jpegxl_decode.cpp + createJpegXLDecoder jxl_dec) +add_jxl_wasm_module(jpegxlwasm_encode src/jpegxl_encode.cpp + createJpegXLEncoder jxl) diff --git a/packages/libjxl/README.md b/packages/libjxl/README.md new file mode 100644 index 00000000..95911236 --- /dev/null +++ b/packages/libjxl/README.md @@ -0,0 +1,88 @@ +# @cornerstonejs/codec-libjxl + +JavaScript/WebAssembly build of [libjxl](https://github.com/libjxl/libjxl) +with separate decoder and encoder modules for JPEG XL DICOM images. + +## Installing + +Using npm: + +```bash +npm install @cornerstonejs/codec-libjxl +``` + +Using yarn: + +```bash +yarn add @cornerstonejs/codec-libjxl +``` + +## Usage + +The package exports separate JavaScript loaders and WASM binaries for decoding +and encoding: + +- `@cornerstonejs/codec-libjxl/decodewasmjs` +- `@cornerstonejs/codec-libjxl/decodewasm` +- `@cornerstonejs/codec-libjxl/encodewasmjs` +- `@cornerstonejs/codec-libjxl/encodewasm` + +### Decoder + +```js +import createJpegXLDecoder from '@cornerstonejs/codec-libjxl/decodewasmjs'; + +const codec = await createJpegXLDecoder(); +const decoder = new codec.JpegXLDecoder(); + +decoder.getEncodedBuffer(bitstream.length).set(bitstream); +decoder.decode(); + +const frameInfo = decoder.getFrameInfo(); +const decodedPixels = decoder.getDecodedBuffer(); +``` + +### Encoder + +```js +import createJpegXLEncoder from '@cornerstonejs/codec-libjxl/encodewasmjs'; + +const codec = await createJpegXLEncoder(); +const encoder = new codec.JpegXLEncoder(); +const frameInfo = { + width, + height, + bitsPerSample, + componentCount, + isSigned: false, +}; + +encoder.getDecodedBuffer(frameInfo).set(decodedPixels); +encoder.setLossless(true); +encoder.encode(); + +const bitstream = encoder.getEncodedBuffer(); +``` + +The encoder accepts greyscale samples with one channel or RGB samples with +three channels at bit depths from 1 to 16. Copy returned buffer views before +the next operation or before calling `releaseBuffers()`. + +## Building + +This project uses a Git submodule for libjxl. Initialize the submodules from +the repository root: + +```bash +git submodule update --init --recursive +``` + +An activated Emscripten SDK is required. Build both WASM modules with: + +```bash +cd packages/libjxl +yarn build +``` + +The generated decoder and encoder `.js` and `.wasm` files are written to +`dist/`. diff --git a/packages/libjxl/build.sh b/packages/libjxl/build.sh new file mode 100644 index 00000000..e920ccf5 --- /dev/null +++ b/packages/libjxl/build.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# +# Builds the libjxl decoder and encoder WASM modules into dist/. +# +# Requires an activated Emscripten SDK. Either have emcmake on PATH already, or +# point EMSDK at an emsdk checkout and this script will source its env for you. +# +# EMSDK=/c/Apps/emsdk ./build.sh +# +# The build output (dist/*.js and dist/*.wasm) is committed, so only someone +# changing the sources in src/ or updating the libjxl submodule needs to run it. + +set -euo pipefail + +cd "$(dirname "$0")" + +LIBJXL_DIR="extern/libjxl" +BUILD_DIR="build" + +if ! command -v emcmake >/dev/null 2>&1; then + if [ -n "${EMSDK:-}" ] && [ -f "${EMSDK}/emsdk_env.sh" ]; then + # shellcheck disable=SC1091 + source "${EMSDK}/emsdk_env.sh" + else + echo "emcmake not found. Activate the Emscripten SDK, or set EMSDK to an" >&2 + echo "emsdk checkout (e.g. EMSDK=/c/Apps/emsdk $0)." >&2 + exit 1 + fi +fi + +if [ ! -f "${LIBJXL_DIR}/lib/include/jxl/decode.h" ]; then + echo "libjxl submodule not found. Run:" >&2 + echo " git submodule update --init --recursive" >&2 + exit 1 +fi + +rm -rf "${BUILD_DIR}" dist + +echo "Configuring..." +emcmake cmake -S . -B "${BUILD_DIR}" \ + -DCMAKE_BUILD_TYPE=Release \ + -DLIBJXL_SOURCE_DIR="$(pwd)/${LIBJXL_DIR}" + +echo "Building..." +cmake --build "${BUILD_DIR}" \ + --target jpegxlwasm_decode jpegxlwasm_encode --parallel + +mkdir -p dist +for module in jpegxlwasm_decode jpegxlwasm_encode; do + cp "${BUILD_DIR}/${module}.js" "${BUILD_DIR}/${module}.wasm" dist/ +done + +echo "Done:" +ls -la dist/ diff --git a/packages/libjxl/package.json b/packages/libjxl/package.json new file mode 100644 index 00000000..a0c4ceb9 --- /dev/null +++ b/packages/libjxl/package.json @@ -0,0 +1,40 @@ +{ + "name": "@cornerstonejs/codec-libjxl", + "version": "1.0.0", + "description": "WASM build of libjxl (JPEG XL) for DICOM, with the decoder and encoder as separate modules", + "license": "BSD-3-Clause", + "type": "module", + "publishConfig": { + "access": "public" + }, + "files": [ + "package.json", + "README.md", + "dist" + ], + "exports": { + ".": "./dist/jpegxlwasm_decode.js", + "./decodewasm": "./dist/jpegxlwasm_decode.wasm", + "./decodewasmjs": "./dist/jpegxlwasm_decode.js", + "./encodewasm": "./dist/jpegxlwasm_encode.wasm", + "./encodewasmjs": "./dist/jpegxlwasm_encode.js", + "./dist/*": "./dist/*" + }, + "scripts": { + "build": "bash build.sh", + "build:ci": "yarn run build", + "clean": "shx rm -rf build dist" + }, + "keywords": [ + "JPEG XL", + "JPEGXL", + "JXL", + "libjxl", + "DICOM", + "WASM" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/cornerstonejs/codecs.git" + } +} diff --git a/packages/libjxl/src/frame_info.cpp b/packages/libjxl/src/frame_info.cpp new file mode 100644 index 00000000..b48dcf76 --- /dev/null +++ b/packages/libjxl/src/frame_info.cpp @@ -0,0 +1,14 @@ +#include "frame_info.h" + +#include + +using namespace emscripten; + +EMSCRIPTEN_BINDINGS(JpegXLFrameInfoBindings) { + value_object("FrameInfo") + .field("width", &FrameInfo::width) + .field("height", &FrameInfo::height) + .field("bitsPerSample", &FrameInfo::bitsPerSample) + .field("componentCount", &FrameInfo::componentCount) + .field("isSigned", &FrameInfo::isSigned); +} diff --git a/packages/libjxl/src/frame_info.h b/packages/libjxl/src/frame_info.h new file mode 100644 index 00000000..cb973c52 --- /dev/null +++ b/packages/libjxl/src/frame_info.h @@ -0,0 +1,22 @@ +#ifndef CORNERSTONE_CODEC_LIBJXL_FRAME_INFO_H_ +#define CORNERSTONE_CODEC_LIBJXL_FRAME_INFO_H_ + +#include + +struct FrameInfo { + uint32_t width; + uint32_t height; + // Sample depth, BitsStored in DICOM terms: 1..16. Samples occupy one byte + // each up to 8 bits and two bytes each above that, right aligned - a 12 bit + // sample is a value in 0..4095, not one shifted up into the top of a uint16. + uint32_t bitsPerSample; + // Colour channels only: 1 (greyscale) or 3 (RGB). Alpha and extra channels + // are neither decoded nor encoded. + uint32_t componentCount; + // JPEG XL has no signed integer samples. The decoder always reports false + // and the caller applies PixelRepresentation from the data set; the encoder + // rejects true, since offsetting into unsigned range is the caller's job. + bool isSigned; +}; + +#endif // CORNERSTONE_CODEC_LIBJXL_FRAME_INFO_H_ diff --git a/packages/libjxl/src/frame_size.h b/packages/libjxl/src/frame_size.h new file mode 100644 index 00000000..ab774551 --- /dev/null +++ b/packages/libjxl/src/frame_size.h @@ -0,0 +1,46 @@ +#ifndef CORNERSTONE_CODEC_LIBJXL_FRAME_SIZE_H_ +#define CORNERSTONE_CODEC_LIBJXL_FRAME_SIZE_H_ + +#include +#include +#include +#include + +namespace cornerstone_jxl { + +// Largest frame this module will allocate for, in bytes. Well above any DICOM +// frame (a 16 bit 8192x8192 mammogram is 128 MB) while staying inside what a +// 32 bit heap can actually hold alongside the decoder's own state. +constexpr uint64_t kMaxFrameBytes = 1024ull * 1024ull * 1024ull; + +/// Returns width * height * channels * bytesPerSample, or throws if the +/// dimensions are absent or the product is beyond what can be allocated. +inline size_t CheckedFrameSize(const char* who, uint64_t width, uint64_t height, + uint64_t channels, uint64_t bytesPerSample) { + if (width == 0 || height == 0 || channels == 0 || bytesPerSample == 0) { + throw std::runtime_error(std::string(who) + ": empty frame (" + + std::to_string(width) + "x" + + std::to_string(height) + "x" + + std::to_string(channels) + ")"); + } + + uint64_t frameBytes = width; + const auto multiply = [&](uint64_t factor) { + if (frameBytes > kMaxFrameBytes / factor) { + throw std::runtime_error(std::string(who) + ": frame of " + + std::to_string(width) + "x" + + std::to_string(height) + + " exceeds the supported size limit"); + } + frameBytes *= factor; + }; + multiply(height); + multiply(channels); + multiply(bytesPerSample); + + return static_cast(frameBytes); +} + +} // namespace cornerstone_jxl + +#endif // CORNERSTONE_CODEC_LIBJXL_FRAME_SIZE_H_ diff --git a/packages/libjxl/src/jpegxl_decode.cpp b/packages/libjxl/src/jpegxl_decode.cpp new file mode 100644 index 00000000..b3631589 --- /dev/null +++ b/packages/libjxl/src/jpegxl_decode.cpp @@ -0,0 +1,263 @@ +#include +#include + +#include +#include +#include + +#include +#include +#include + +#include "frame_info.h" +#include "frame_size.h" +#include "raw_buffer.h" + +using namespace emscripten; +using cornerstone_jxl::CheckedFrameSize; +using cornerstone_jxl::RawBuffer; + +namespace { + +// Frees the decoder however decode() leaves the loop, including via throw. +class DecoderHandle { + public: + DecoderHandle() : dec_(JxlDecoderCreate(nullptr)) { + if (!dec_) { + throw std::runtime_error("JpegXLDecoder: failed to create decoder"); + } + } + ~DecoderHandle() { + if (dec_) { + JxlDecoderDestroy(dec_); + } + } + DecoderHandle(const DecoderHandle&) = delete; + DecoderHandle& operator=(const DecoderHandle&) = delete; + + JxlDecoder* get() const { return dec_; } + + private: + JxlDecoder* dec_; +}; + +void check(JxlDecoderStatus status, const char* what) { + if (status != JXL_DEC_SUCCESS) { + throw std::runtime_error(std::string("JpegXLDecoder: ") + what + " failed"); + } +} + +// True when the stream's data is already in the colour space the samples are +// wanted in, so no conversion has to be requested. +bool IsSrgb(const JxlColorEncoding& e) { + return e.color_space == JXL_COLOR_SPACE_RGB && + e.white_point == JXL_WHITE_POINT_D65 && + e.primaries == JXL_PRIMARIES_SRGB && + e.transfer_function == JXL_TRANSFER_FUNCTION_SRGB; +} + +} // namespace + +class JpegXLDecoder { + public: + JpegXLDecoder() : frameInfo_{} {} + + /// Resizes the encoded buffer to `encodedSize` and returns a view of it in + /// WASM memory for the caller to copy the bitstream into. + val getEncodedBuffer(size_t encodedSize) { + encoded_.resize(encodedSize); + return val(typed_memory_view(encoded_.size(), encoded_.data())); + } + + /// A view of the decoded samples. Interpret according to getFrameInfo(): + /// bitsPerSample <= 8 gives one byte per sample, otherwise two bytes per + /// sample in native (little) endianness. + val getDecodedBuffer() { + return val(typed_memory_view(decoded_.size(), decoded_.data())); + } + + const FrameInfo& getFrameInfo() const { return frameInfo_; } + + /// Hands both buffers back to the allocator. Both are otherwise kept at + /// their high water mark so that decoding a series does not reallocate; call + /// this when a worker is going idle. Any view returned earlier by + /// getEncodedBuffer()/getDecodedBuffer() dangles afterwards. + void releaseBuffers() { + encoded_.release(); + decoded_.release(); + frameInfo_ = {}; + } + + void decode() { + // The decoder is kept for the lifetime of this object and reset per frame: + // creating one costs an allocation of libjxl's internal state, which is + // not worth paying once per frame of a volume. + JxlDecoder* dec = handle_.get(); + JxlDecoderReset(dec); + + check(JxlDecoderSetCms(dec, *JxlGetDefaultCms()), "JxlDecoderSetCms"); + check(JxlDecoderSubscribeEvents(dec, JXL_DEC_BASIC_INFO | + JXL_DEC_COLOR_ENCODING | + JXL_DEC_FULL_IMAGE), + "JxlDecoderSubscribeEvents"); + // DICOM frames carry no spot colours; rendering them would only add work. + check(JxlDecoderSetRenderSpotcolors(dec, JXL_FALSE), + "JxlDecoderSetRenderSpotcolors"); + check(JxlDecoderSetInput(dec, encoded_.data(), encoded_.size()), + "JxlDecoderSetInput"); + JxlDecoderCloseInput(dec); + + JxlPixelFormat pixelFormat = {}; + size_t frameSize = 0; + frameInfo_ = {}; + + for (;;) { + const JxlDecoderStatus status = JxlDecoderProcessInput(dec); + + switch (status) { + case JXL_DEC_BASIC_INFO: { + JxlBasicInfo basicInfo; + check(JxlDecoderGetBasicInfo(dec, &basicInfo), + "JxlDecoderGetBasicInfo"); + + // Neither of these can come out of a DICOM encoder, and both would + // be silently mangled by the integer output path below (floats clamp + // to [0,1]; anything past 16 bit truncates), so refuse them outright + // rather than hand back wrong pixels. + if (basicInfo.exponent_bits_per_sample != 0) { + throw std::runtime_error( + "JpegXLDecoder: floating point samples are not supported"); + } + if (basicInfo.bits_per_sample == 0 || + basicInfo.bits_per_sample > 16) { + throw std::runtime_error( + "JpegXLDecoder: unsupported bit depth " + + std::to_string(basicInfo.bits_per_sample)); + } + if (basicInfo.num_color_channels != 1 && + basicInfo.num_color_channels != 3) { + throw std::runtime_error( + "JpegXLDecoder: unsupported colour channel count " + + std::to_string(basicInfo.num_color_channels)); + } + + frameInfo_.width = basicInfo.xsize; + frameInfo_.height = basicInfo.ysize; + frameInfo_.bitsPerSample = basicInfo.bits_per_sample; + frameInfo_.componentCount = basicInfo.num_color_channels; + frameInfo_.isSigned = false; + + // Ask for the samples as-is: greyscale stays single channel and + // anything deeper than 8 bit comes back as 16 bit rather than being + // squashed into a byte. + pixelFormat.num_channels = basicInfo.num_color_channels; + pixelFormat.data_type = basicInfo.bits_per_sample <= 8 + ? JXL_TYPE_UINT8 + : JXL_TYPE_UINT16; + pixelFormat.endianness = JXL_NATIVE_ENDIAN; + pixelFormat.align = 0; + + // Checked here, in 64 bit, because size_t is 32 bit under wasm32 and + // the dimensions come straight off the wire. + frameSize = CheckedFrameSize( + "JpegXLDecoder", basicInfo.xsize, basicInfo.ysize, + basicInfo.num_color_channels, + basicInfo.bits_per_sample <= 8 ? 1 : 2); + break; + } + + case JXL_DEC_COLOR_ENCODING: { + // Multi-channel frames are resolved to sRGB so the samples handed + // back are plain RGB whatever colour transform the stream used - but + // only when that is not already what the data is in, since the + // conversion is a full floating point pass over the frame. + // Greyscale is left alone - remapping it would alter pixel values + // that carry meaning (Hounsfield units and friends). + if (frameInfo_.componentCount > 1) { + JxlColorEncoding data = {}; + const bool known = + JxlDecoderGetColorAsEncodedProfile( + dec, JXL_COLOR_PROFILE_TARGET_DATA, &data) == + JXL_DEC_SUCCESS; + if (!known || !IsSrgb(data)) { + JxlColorEncoding srgb = {}; + srgb.color_space = JXL_COLOR_SPACE_RGB; + srgb.white_point = JXL_WHITE_POINT_D65; + srgb.primaries = JXL_PRIMARIES_SRGB; + srgb.transfer_function = JXL_TRANSFER_FUNCTION_SRGB; + srgb.rendering_intent = JXL_RENDERING_INTENT_RELATIVE; + check(JxlDecoderSetOutputColorProfile(dec, &srgb, nullptr, 0), + "JxlDecoderSetOutputColorProfile"); + } + } + break; + } + + case JXL_DEC_NEED_IMAGE_OUT_BUFFER: { + size_t bufferSize = 0; + check(JxlDecoderImageOutBufferSize(dec, &pixelFormat, &bufferSize), + "JxlDecoderImageOutBufferSize"); + if (bufferSize != frameSize) { + throw std::runtime_error( + "JpegXLDecoder: output size disagrees with the frame header"); + } + + decoded_.resize(bufferSize); + check(JxlDecoderSetImageOutBuffer(dec, &pixelFormat, decoded_.data(), + decoded_.size()), + "JxlDecoderSetImageOutBuffer"); + + // Scale the samples to the depth the codestream declares rather than + // to the full range of the output type. Without this a 12 bit frame + // asked for as UINT16 comes back multiplied by 65535/4095, which is + // both wrong and not even an exact shift. This is what djxl does by + // default, and the inverse of what cjxl does when reading 12 bit + // input. Must be called after SetImageOutBuffer. + JxlBitDepth bitDepth = {}; + bitDepth.type = JXL_BIT_DEPTH_FROM_CODESTREAM; + check(JxlDecoderSetImageOutBitDepth(dec, &bitDepth), + "JxlDecoderSetImageOutBitDepth"); + break; + } + + case JXL_DEC_FULL_IMAGE: + // Only the first frame of an animation is of interest; DICOM frames + // are encoded one per bitstream. Returning here rather than carrying + // on also stops a later frame overwriting the decoded samples. + JxlDecoderReleaseInput(dec); + return; + + case JXL_DEC_SUCCESS: + // Reached without a full image: the codestream held no frame. + throw std::runtime_error("JpegXLDecoder: no image in the bitstream"); + + case JXL_DEC_ERROR: + throw std::runtime_error("JpegXLDecoder: decoding failed"); + + case JXL_DEC_NEED_MORE_INPUT: + throw std::runtime_error("JpegXLDecoder: unexpected end of input"); + + default: + throw std::runtime_error( + "JpegXLDecoder: unexpected decoder status " + + std::to_string(static_cast(status))); + } + } + } + + private: + DecoderHandle handle_; + RawBuffer encoded_; + RawBuffer decoded_; + FrameInfo frameInfo_; +}; + +EMSCRIPTEN_BINDINGS(JpegXLDecoderBindings) { + class_("JpegXLDecoder") + .constructor<>() + .function("getEncodedBuffer", &JpegXLDecoder::getEncodedBuffer) + .function("getDecodedBuffer", &JpegXLDecoder::getDecodedBuffer) + .function("getFrameInfo", &JpegXLDecoder::getFrameInfo) + .function("releaseBuffers", &JpegXLDecoder::releaseBuffers) + .function("decode", &JpegXLDecoder::decode); +} diff --git a/packages/libjxl/src/jpegxl_encode.cpp b/packages/libjxl/src/jpegxl_encode.cpp new file mode 100644 index 00000000..6173a1dc --- /dev/null +++ b/packages/libjxl/src/jpegxl_encode.cpp @@ -0,0 +1,305 @@ +#include +#include + +#include +#include +#include +#include + +#include +#include + +#include "frame_info.h" +#include "frame_size.h" +#include "raw_buffer.h" + +using namespace emscripten; +using cornerstone_jxl::CheckedFrameSize; +using cornerstone_jxl::RawBuffer; + +namespace { + +// Frees the encoder however encode() leaves, including via throw. +class EncoderHandle { + public: + EncoderHandle() : enc_(JxlEncoderCreate(nullptr)) { + if (!enc_) { + throw std::runtime_error("JpegXLEncoder: failed to create encoder"); + } + } + ~EncoderHandle() { + if (enc_) { + JxlEncoderDestroy(enc_); + } + } + EncoderHandle(const EncoderHandle&) = delete; + EncoderHandle& operator=(const EncoderHandle&) = delete; + + JxlEncoder* get() const { return enc_; } + + private: + JxlEncoder* enc_; +}; + +void check(JxlEncoder* enc, JxlEncoderStatus status, const char* what) { + if (status != JXL_ENC_SUCCESS) { + throw std::runtime_error(std::string("JpegXLEncoder: ") + what + + " failed (error " + + std::to_string(static_cast( + JxlEncoderGetError(enc))) + + ")"); + } +} + +} // namespace + +class JpegXLEncoder { + public: + JpegXLEncoder() : frameInfo_{} {} + + /// Sizes the source buffer for `frameInfo` and returns a view of it in WASM + /// memory for the caller to copy samples into. Must be called before + /// encode(); the frame info is remembered until the next call. + val getDecodedBuffer(const FrameInfo& frameInfo) { + validate(frameInfo); + frameInfo_ = frameInfo; + decoded_.resize(CheckedFrameSize("JpegXLEncoder", frameInfo.width, + frameInfo.height, + frameInfo.componentCount, + bytesPerSample(frameInfo))); + return val(typed_memory_view(decoded_.size(), decoded_.data())); + } + + /// A view of the encoded bitstream in WASM memory. Valid until the next + /// encode() or releaseBuffers(). + val getEncodedBuffer() { + return val(typed_memory_view(encoded_.size(), encoded_.data())); + } + + const FrameInfo& getFrameInfo() const { return frameInfo_; } + + /// Mathematically lossless encoding (the default). Turning it off makes + /// `distance` meaningful. + void setLossless(bool lossless) { lossless_ = lossless; } + + /// Butteraugli distance for lossy encoding: 0 is lossless, 1 is visually + /// lossless, up to 25. Ignored while setLossless(true) is in effect. + void setDistance(float distance) { + if (!(distance >= 0.0f) || distance > 25.0f) { + throw std::runtime_error("JpegXLEncoder: distance must be 0..25"); + } + distance_ = distance; + } + + /// Encoder effort, 1 (fastest) to 9 (densest). 7 by default, matching cjxl. + void setEffort(int effort) { + if (effort < 1 || effort > 9) { + throw std::runtime_error("JpegXLEncoder: effort must be 1..9"); + } + effort_ = effort; + } + + /// Trades compression density for decode speed: 0 (densest, the default) to + /// 4 (fastest to decode). Worth raising for data that is read far more often + /// than it is written. + void setDecodingSpeed(int tier) { + if (tier < 0 || tier > 4) { + throw std::runtime_error("JpegXLEncoder: decoding speed must be 0..4"); + } + decodingSpeed_ = tier; + } + + /// Hands both buffers back to the allocator; they are otherwise kept at + /// their high water mark so that encoding a series does not reallocate. + void releaseBuffers() { + decoded_.release(); + encoded_.release(); + frameInfo_ = {}; + } + + void encode() { + validate(frameInfo_); + const size_t sourceSize = + CheckedFrameSize("JpegXLEncoder", frameInfo_.width, frameInfo_.height, + frameInfo_.componentCount, bytesPerSample(frameInfo_)); + if (decoded_.size() != sourceSize) { + throw std::runtime_error( + "JpegXLEncoder: call getDecodedBuffer() before encode()"); + } + + // Kept for the lifetime of this object and reset per frame - creating an + // encoder allocates internal state that is not worth paying for once per + // frame of a series. Reset clears the frame settings, so those are made + // fresh below. + JxlEncoder* enc = handle_.get(); + JxlEncoderReset(enc); + + const bool gray = frameInfo_.componentCount == 1; + + JxlBasicInfo basicInfo; + JxlEncoderInitBasicInfo(&basicInfo); + basicInfo.xsize = frameInfo_.width; + basicInfo.ysize = frameInfo_.height; + basicInfo.bits_per_sample = frameInfo_.bitsPerSample; + basicInfo.exponent_bits_per_sample = 0; // Integer samples + basicInfo.uses_original_profile = + lossless_ || gray ? JXL_TRUE : JXL_FALSE; + basicInfo.num_color_channels = frameInfo_.componentCount; + basicInfo.num_extra_channels = 0; + basicInfo.alpha_bits = 0; + check(enc, JxlEncoderSetBasicInfo(enc, &basicInfo), + "JxlEncoderSetBasicInfo"); + + // The colour encoding is metadata for the lossless path - modular with + // uses_original_profile stores the integer samples verbatim - but it does + // steer the lossy path, so it should describe the source rather than + // assert one value for everything: + // - greyscale medical data holds raw intensities whose display is driven + // by the DICOM modality LUT and window, not by an embedded profile, so + // signal LINEAR; + // - DICOM RGB without an ICC profile is assumed to be sRGB display data. + // libjxl rejects UNKNOWN here when uses_original_profile is set. RELATIVE + // rendering intent suits measured data better than PERCEPTUAL. + JxlColorEncoding colorEncoding = {}; + colorEncoding.color_space = + gray ? JXL_COLOR_SPACE_GRAY : JXL_COLOR_SPACE_RGB; + colorEncoding.white_point = JXL_WHITE_POINT_D65; + colorEncoding.primaries = JXL_PRIMARIES_SRGB; // ignored for greyscale + colorEncoding.transfer_function = + gray ? JXL_TRANSFER_FUNCTION_LINEAR : JXL_TRANSFER_FUNCTION_SRGB; + colorEncoding.rendering_intent = JXL_RENDERING_INTENT_RELATIVE; + check(enc, JxlEncoderSetColorEncoding(enc, &colorEncoding), + "JxlEncoderSetColorEncoding"); + + JxlEncoderFrameSettings* settings = + JxlEncoderFrameSettingsCreate(enc, nullptr); + if (!settings) { + throw std::runtime_error("JpegXLEncoder: failed to create frame settings"); + } + + check(enc, + JxlEncoderFrameSettingsSetOption(settings, + JXL_ENC_FRAME_SETTING_EFFORT, + effort_), + "setting effort"); + check(enc, + JxlEncoderFrameSettingsSetOption(settings, + JXL_ENC_FRAME_SETTING_DECODING_SPEED, + decodingSpeed_), + "setting decoding speed"); + + if (lossless_) { + // Modular is what makes lossless integer coding cheap; VarDCT at + // distance 0 would round trip exactly too, but far more slowly and + // usually larger for this kind of data. + check(enc, JxlEncoderSetFrameLossless(settings, JXL_TRUE), + "JxlEncoderSetFrameLossless"); + check(enc, JxlEncoderSetFrameDistance(settings, 0.0f), + "JxlEncoderSetFrameDistance"); + check(enc, + JxlEncoderFrameSettingsSetOption(settings, + JXL_ENC_FRAME_SETTING_MODULAR, 1), + "selecting modular mode"); + } else { + check(enc, JxlEncoderSetFrameLossless(settings, JXL_FALSE), + "JxlEncoderSetFrameLossless"); + check(enc, JxlEncoderSetFrameDistance(settings, distance_), + "JxlEncoderSetFrameDistance"); + } + + JxlPixelFormat pixelFormat = {}; + pixelFormat.num_channels = frameInfo_.componentCount; + pixelFormat.data_type = + frameInfo_.bitsPerSample <= 8 ? JXL_TYPE_UINT8 : JXL_TYPE_UINT16; + pixelFormat.endianness = JXL_NATIVE_ENDIAN; + pixelFormat.align = 0; + + // Read the samples at the depth the codestream declares instead of + // normalising them against the full range of the buffer's type. Without + // this a 12 bit sample in a uint16 buffer is divided by 65535 while the + // header says 12 bits, and every conformant decoder then reproduces it + // 16x too dark. + JxlBitDepth bitDepth = {}; + bitDepth.type = JXL_BIT_DEPTH_FROM_CODESTREAM; + check(enc, JxlEncoderSetFrameBitDepth(settings, &bitDepth), + "JxlEncoderSetFrameBitDepth"); + + check(enc, + JxlEncoderAddImageFrame(settings, &pixelFormat, decoded_.data(), + decoded_.size()), + "JxlEncoderAddImageFrame"); + JxlEncoderCloseInput(enc); + + // Start at an eighth of the source, which comfortably covers a losslessly + // compressed frame, and double from there. The capacity survives across + // calls, so a series pays this at most once. + encoded_.grow(std::max(64u * 1024u, sourceSize / 8)); + + size_t written = 0; + for (;;) { + uint8_t* nextOut = encoded_.data() + written; + size_t availOut = encoded_.size() - written; + const JxlEncoderStatus status = + JxlEncoderProcessOutput(enc, &nextOut, &availOut); + written = encoded_.size() - availOut; + + if (status == JXL_ENC_SUCCESS) { + break; + } + if (status == JXL_ENC_NEED_MORE_OUTPUT) { + encoded_.grow(encoded_.size() * 2); + continue; + } + check(enc, status, "JxlEncoderProcessOutput"); + } + + // Trim to what was actually written; the capacity is kept. + encoded_.grow(written); + } + + private: + static uint32_t bytesPerSample(const FrameInfo& info) { + return info.bitsPerSample <= 8 ? 1 : 2; + } + + static void validate(const FrameInfo& info) { + if (info.componentCount != 1 && info.componentCount != 3) { + throw std::runtime_error( + "JpegXLEncoder: componentCount must be 1 or 3, got " + + std::to_string(info.componentCount)); + } + if (info.bitsPerSample < 1 || info.bitsPerSample > 16) { + throw std::runtime_error("JpegXLEncoder: bitsPerSample must be 1..16, " + "got " + + std::to_string(info.bitsPerSample)); + } + if (info.isSigned) { + throw std::runtime_error( + "JpegXLEncoder: signed samples must be offset into unsigned range " + "by the caller"); + } + } + + EncoderHandle handle_; + RawBuffer decoded_; + RawBuffer encoded_; + FrameInfo frameInfo_; + bool lossless_ = true; + float distance_ = 0.0f; + int effort_ = 7; + int decodingSpeed_ = 0; +}; + +EMSCRIPTEN_BINDINGS(JpegXLEncoderBindings) { + class_("JpegXLEncoder") + .constructor<>() + .function("getDecodedBuffer", &JpegXLEncoder::getDecodedBuffer) + .function("getEncodedBuffer", &JpegXLEncoder::getEncodedBuffer) + .function("getFrameInfo", &JpegXLEncoder::getFrameInfo) + .function("setLossless", &JpegXLEncoder::setLossless) + .function("setDistance", &JpegXLEncoder::setDistance) + .function("setEffort", &JpegXLEncoder::setEffort) + .function("setDecodingSpeed", &JpegXLEncoder::setDecodingSpeed) + .function("releaseBuffers", &JpegXLEncoder::releaseBuffers) + .function("encode", &JpegXLEncoder::encode); +} diff --git a/packages/libjxl/src/raw_buffer.h b/packages/libjxl/src/raw_buffer.h new file mode 100644 index 00000000..605744ff --- /dev/null +++ b/packages/libjxl/src/raw_buffer.h @@ -0,0 +1,68 @@ +#ifndef CORNERSTONE_CODEC_LIBJXL_RAW_BUFFER_H_ +#define CORNERSTONE_CODEC_LIBJXL_RAW_BUFFER_H_ + +#include +#include +#include +#include + +namespace cornerstone_jxl { + +class RawBuffer { + public: + RawBuffer() = default; + ~RawBuffer() { release(); } + + RawBuffer(const RawBuffer&) = delete; + RawBuffer& operator=(const RawBuffer&) = delete; + + // Makes the buffer `size` bytes long. The contents are undefined - every + // caller here fills the whole range before reading it back. + void resize(size_t size) { + if (size > capacity_) { + // Not realloc: the old bytes are never worth copying. + uint8_t* fresh = static_cast(std::malloc(size)); + if (!fresh) { + throw std::bad_alloc(); + } + std::free(data_); + data_ = fresh; + capacity_ = size; + } + size_ = size; + } + + // Grows to at least `size` bytes, preserving the first `size_` bytes. Used by + // the encoder, which discovers the output size as it writes. + void grow(size_t size) { + if (size > capacity_) { + uint8_t* fresh = static_cast(std::realloc(data_, size)); + if (!fresh) { + throw std::bad_alloc(); + } + data_ = fresh; + capacity_ = size; + } + size_ = size; + } + + void release() { + std::free(data_); + data_ = nullptr; + size_ = 0; + capacity_ = 0; + } + + uint8_t* data() { return data_; } + const uint8_t* data() const { return data_; } + size_t size() const { return size_; } + + private: + uint8_t* data_ = nullptr; + size_t size_ = 0; + size_t capacity_ = 0; +}; + +} // namespace cornerstone_jxl + +#endif // CORNERSTONE_CODEC_LIBJXL_RAW_BUFFER_H_ diff --git a/tools/dist-size/baseline.json b/tools/dist-size/baseline.json index b109eee1..ad78429c 100644 --- a/tools/dist-size/baseline.json +++ b/tools/dist-size/baseline.json @@ -71,6 +71,24 @@ "gzip": 69946 } }, + "libjxl": { + "jpegxlwasm_decode.js": { + "raw": 47784, + "gzip": 12453 + }, + "jpegxlwasm_decode.wasm": { + "raw": 1070362, + "gzip": 375287 + }, + "jpegxlwasm_encode.js": { + "raw": 49166, + "gzip": 12995 + }, + "jpegxlwasm_encode.wasm": { + "raw": 2448810, + "gzip": 915455 + } + }, "little-endian": { "index.js": { "raw": 877,