Skip to content

Commit 49eb361

Browse files
committed
feat: JPEG XL encoder/decoder
1 parent 8634194 commit 49eb361

14 files changed

Lines changed: 1074 additions & 6 deletions

File tree

.github/workflows/pr-checks.yml

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -79,8 +79,10 @@ jobs:
7979
REF: ${{ github.ref }}
8080
run: |
8181
set -e
82-
ALL=(charls libjpeg-turbo-8bit libjpeg-turbo-12bit openjpeg openjphjs little-endian big-endian dicom-codec)
82+
ALL=(charls libjpeg-turbo-8bit libjpeg-turbo-12bit libjxl openjpeg openjphjs little-endian big-endian dicom-codec)
83+
BENCHABLE=(charls libjpeg-turbo-8bit libjpeg-turbo-12bit openjpeg openjphjs little-endian big-endian dicom-codec)
8384
ALL_JSON=$(printf '%s\n' "${ALL[@]}" | jq -R . | jq -s -c .)
85+
BENCHABLE_JSON=$(printf '%s\n' "${BENCHABLE[@]}" | jq -R . | jq -s -c .)
8486
8587
# Baseline runs: on a manual dispatch or any commit landing on
8688
# main, build/test/bench every package. The "diff vs main" trick
@@ -90,7 +92,7 @@ jobs:
9092
if [ "$EVENT_NAME" = "workflow_dispatch" ] || [ "$REF" = "refs/heads/main" ]; then
9193
echo "Baseline run ($EVENT_NAME on $REF): forcing all packages"
9294
echo "packages=$ALL_JSON" >> "$GITHUB_OUTPUT"
93-
echo "bench=$ALL_JSON" >> "$GITHUB_OUTPUT"
95+
echo "bench=$BENCHABLE_JSON" >> "$GITHUB_OUTPUT"
9496
echo "any=true" >> "$GITHUB_OUTPUT"
9597
exit 0
9698
fi
@@ -127,16 +129,20 @@ jobs:
127129
if ! git diff --quiet "$BASE"..HEAD -- "$p"; then
128130
echo "Toolchain change detected in $p: forcing all packages (build/test/bench)"
129131
echo "packages=$ALL_JSON" >> "$GITHUB_OUTPUT"
130-
echo "bench=$ALL_JSON" >> "$GITHUB_OUTPUT"
132+
echo "bench=$BENCHABLE_JSON" >> "$GITHUB_OUTPUT"
131133
echo "any=true" >> "$GITHUB_OUTPUT"
132134
exit 0
133135
fi
134136
done
135137
136138
changed=()
139+
bench_changed=()
137140
for pkg in "${ALL[@]}"; do
138141
if ! git diff --quiet "$BASE"..HEAD -- "packages/$pkg/"; then
139142
changed+=("$pkg")
143+
for known in "${BENCHABLE[@]}"; do
144+
[ "$pkg" = "$known" ] && bench_changed+=("$pkg") && break
145+
done
140146
fi
141147
done
142148
if [ ${#changed[@]} -eq 0 ]; then
@@ -145,8 +151,13 @@ jobs:
145151
echo 'bench=[]' >> "$GITHUB_OUTPUT"
146152
echo "any=false" >> "$GITHUB_OUTPUT"
147153
else
148-
bench_json=$(printf '%s\n' "${changed[@]}" | jq -R . | jq -s -c .)
149-
echo "Changed: $bench_json — building all packages, benching changed only"
154+
changed_json=$(printf '%s\n' "${changed[@]}" | jq -R . | jq -s -c .)
155+
if [ ${#bench_changed[@]} -gt 0 ]; then
156+
bench_json=$(printf '%s\n' "${bench_changed[@]}" | jq -R . | jq -s -c .)
157+
else
158+
bench_json='[]'
159+
fi
160+
echo "Changed: $changed_json — building all packages, benching supported changes only"
150161
echo "packages=$ALL_JSON" >> "$GITHUB_OUTPUT"
151162
echo "bench=$bench_json" >> "$GITHUB_OUTPUT"
152163
echo "any=true" >> "$GITHUB_OUTPUT"
@@ -347,7 +358,7 @@ jobs:
347358
# time. Enable macro runners for the org on app.codspeed.io first
348359
# (the repo is public — also make sure the runner group allows public
349360
# repositories), then: gh variable set CODSPEED_MACRO_ENABLED --body true
350-
if: needs.detect-changes.outputs.any == 'true' && vars.CODSPEED_MACRO_ENABLED == 'true'
361+
if: needs.detect-changes.outputs.any == 'true' && needs.detect-changes.outputs.bench != '[]' && vars.CODSPEED_MACRO_ENABLED == 'true'
351362
# Advisory instrument: real wall-clock numbers (V8 JIT active, real
352363
# cache/branch behavior) that complement the simulation gate (bench.yml) —
353364
# simulation catches small algorithmic slips deterministically,

.gitmodules

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,6 @@
1313
[submodule "packages/charls/extern/charls"]
1414
path = packages/charls/extern/charls
1515
url = https://github.com/cornerstonejs/charls.git
16+
[submodule "packages/libjxl/extern/libjxl"]
17+
path = packages/libjxl/extern/libjxl
18+
url = https://github.com/libjxl/libjxl.git

packages/libjxl/.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
build/
2+
!dist/
3+
!dist/**

packages/libjxl/CMakeLists.txt

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
cmake_minimum_required(VERSION 3.16)
2+
project(cornerstone-codec-libjxl CXX)
3+
4+
if(NOT EMSCRIPTEN)
5+
message(FATAL_ERROR "This project must be configured with emcmake (Emscripten).")
6+
endif()
7+
8+
set(CMAKE_CXX_STANDARD 17)
9+
set(CMAKE_CXX_STANDARD_REQUIRED ON)
10+
11+
# Where the libjxl submodule lives.
12+
set(LIBJXL_SOURCE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/extern/libjxl"
13+
CACHE PATH "Path to a libjxl source checkout")
14+
15+
if(NOT EXISTS "${LIBJXL_SOURCE_DIR}/lib/include/jxl/decode.h")
16+
message(FATAL_ERROR
17+
"libjxl sources not found at ${LIBJXL_SOURCE_DIR}. Update the Git "
18+
"submodules before configuring.")
19+
endif()
20+
21+
# Emscripten's ENVIRONMENT. The shipped module targets the browser and the
22+
# decode web worker only: including `node` makes the glue emit
23+
# `await import("node:module")`, which rspack/webpack reject as an unhandled
24+
# scheme. Configure a throwaway build dir with -DJXL_WASM_ENVIRONMENT=
25+
# web,worker,node to get a module that can be benchmarked or tested under Node.
26+
set(JXL_WASM_ENVIRONMENT "web,worker" CACHE STRING
27+
"Value passed to Emscripten's -sENVIRONMENT")
28+
29+
# These apply to libjxl and Highway as well as to the wrapper below, so they
30+
# have to be set before add_subdirectory. Without -msimd128 Highway compiles
31+
# only its scalar fallback and decoding is several times slower; SIMD is
32+
# supported by every browser that can run this module, so it is on by default.
33+
option(JXL_WASM_SIMD "Compile with WebAssembly SIMD (much faster decode)" ON)
34+
35+
if(JXL_WASM_SIMD)
36+
add_compile_options(-msimd128)
37+
endif()
38+
39+
add_compile_options(-O3)
40+
41+
# Static, no tools/tests. Everything the command line tools would pull in is
42+
# turned off.
43+
set(BUILD_SHARED_LIBS OFF CACHE BOOL "" FORCE)
44+
set(BUILD_TESTING OFF CACHE BOOL "" FORCE)
45+
set(JPEGXL_STATIC ON CACHE BOOL "" FORCE)
46+
set(JPEGXL_ENABLE_TOOLS OFF CACHE BOOL "" FORCE)
47+
set(JPEGXL_ENABLE_EXAMPLES OFF CACHE BOOL "" FORCE)
48+
set(JPEGXL_ENABLE_BENCHMARK OFF CACHE BOOL "" FORCE)
49+
set(JPEGXL_ENABLE_DOXYGEN OFF CACHE BOOL "" FORCE)
50+
set(JPEGXL_ENABLE_MANPAGES OFF CACHE BOOL "" FORCE)
51+
set(JPEGXL_ENABLE_JNI OFF CACHE BOOL "" FORCE)
52+
set(JPEGXL_ENABLE_SJPEG OFF CACHE BOOL "" FORCE)
53+
set(JPEGXL_ENABLE_OPENEXR OFF CACHE BOOL "" FORCE)
54+
set(JPEGXL_ENABLE_TCMALLOC OFF CACHE BOOL "" FORCE)
55+
set(JPEGXL_ENABLE_PLUGINS OFF CACHE BOOL "" FORCE)
56+
set(JPEGXL_ENABLE_DEVTOOLS OFF CACHE BOOL "" FORCE)
57+
set(JPEGXL_ENABLE_COVERAGE OFF CACHE BOOL "" FORCE)
58+
set(JPEGXL_ENABLE_FUZZERS OFF CACHE BOOL "" FORCE)
59+
set(JPEGXL_BUNDLE_LIBPNG OFF CACHE BOOL "" FORCE)
60+
set(JPEGXL_ENABLE_SKCMS ON CACHE BOOL "" FORCE)
61+
set(JPEGXL_FORCE_SYSTEM_BROTLI OFF CACHE BOOL "" FORCE)
62+
set(JPEGXL_FORCE_SYSTEM_HWY OFF CACHE BOOL "" FORCE)
63+
# Needed for the JPEG XL JPEG Recompression transfer syntax
64+
# (1.2.840.10008.1.2.4.111), whose streams carry a jbrd box.
65+
set(JPEGXL_ENABLE_TRANSCODE_JPEG ON CACHE BOOL "" FORCE)
66+
67+
add_subdirectory("${LIBJXL_SOURCE_DIR}" "${CMAKE_CURRENT_BINARY_DIR}/libjxl"
68+
EXCLUDE_FROM_ALL)
69+
70+
# Decoder and encoder are separate modules. A viewer only ever decodes, and
71+
# every decode worker fetches and compiles whatever it loads: linking the
72+
# encoder in as well takes the module from 1.1 MB to 2.7 MB, which is paid per
73+
# worker, per session, for code that never runs there.
74+
#
75+
# Each module compiles frame_info.cpp for the shared FrameInfo binding - once
76+
# per module, since embind throws if a value_object name is registered twice.
77+
function(add_jxl_wasm_module target source export_name)
78+
add_executable(${target} src/frame_info.cpp "${source}")
79+
80+
# jxl_cms carries JxlGetDefaultCms(), which both halves need.
81+
target_link_libraries(${target} PRIVATE ${ARGN} jxl_cms)
82+
83+
target_include_directories(${target} PRIVATE
84+
"${CMAKE_CURRENT_SOURCE_DIR}/src"
85+
"${LIBJXL_SOURCE_DIR}/lib/include"
86+
"${CMAKE_CURRENT_BINARY_DIR}/libjxl/lib/include")
87+
88+
target_compile_options(${target} PRIVATE -fexceptions -msimd128 -O3)
89+
90+
set_target_properties(${target} PROPERTIES
91+
OUTPUT_NAME "${target}"
92+
SUFFIX ".js"
93+
LINK_FLAGS "\
94+
-O3 \
95+
-fexceptions \
96+
-lembind \
97+
-sWASM=1 \
98+
-sMODULARIZE=1 \
99+
-sEXPORT_ES6=1 \
100+
-sEXPORT_NAME=${export_name} \
101+
-sALLOW_MEMORY_GROWTH=1 \
102+
-sMALLOC=emmalloc \
103+
-sFILESYSTEM=0 \
104+
-sENVIRONMENT=${JXL_WASM_ENVIRONMENT} \
105+
-sEXPORTED_RUNTIME_METHODS=[getExceptionMessage,decrementExceptionRefcount] \
106+
-sDYNAMIC_EXECUTION=0 \
107+
-sTEXTDECODER=2 \
108+
-sINCOMING_MODULE_JS_API=[locateFile,instantiateWasm,wasmBinary,print,printErr] \
109+
")
110+
endfunction()
111+
112+
# jxl_dec is the decode-only public library, so the decode module carries no
113+
# encoder code at all; jxl is the full one (see lib/jxl.cmake).
114+
add_jxl_wasm_module(jpegxlwasm_decode src/jpegxl_decode.cpp
115+
createJpegXLDecoder jxl_dec)
116+
add_jxl_wasm_module(jpegxlwasm_encode src/jpegxl_encode.cpp
117+
createJpegXLEncoder jxl)

packages/libjxl/README.md

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
# @cornerstonejs/codec-libjxl
2+
3+
JavaScript/WebAssembly build of [libjxl](https://github.com/libjxl/libjxl)
4+
with separate decoder and encoder modules for JPEG XL DICOM images.
5+
6+
## Installing
7+
8+
Using npm:
9+
10+
```bash
11+
npm install @cornerstonejs/codec-libjxl
12+
```
13+
14+
Using yarn:
15+
16+
```bash
17+
yarn add @cornerstonejs/codec-libjxl
18+
```
19+
20+
## Usage
21+
22+
The package exports separate JavaScript loaders and WASM binaries for decoding
23+
and encoding:
24+
25+
- `@cornerstonejs/codec-libjxl/decodewasmjs`
26+
- `@cornerstonejs/codec-libjxl/decodewasm`
27+
- `@cornerstonejs/codec-libjxl/encodewasmjs`
28+
- `@cornerstonejs/codec-libjxl/encodewasm`
29+
30+
### Decoder
31+
32+
```js
33+
import createJpegXLDecoder from '@cornerstonejs/codec-libjxl/decodewasmjs';
34+
35+
const codec = await createJpegXLDecoder();
36+
const decoder = new codec.JpegXLDecoder();
37+
38+
decoder.getEncodedBuffer(bitstream.length).set(bitstream);
39+
decoder.decode();
40+
41+
const frameInfo = decoder.getFrameInfo();
42+
const decodedPixels = decoder.getDecodedBuffer();
43+
```
44+
45+
### Encoder
46+
47+
```js
48+
import createJpegXLEncoder from '@cornerstonejs/codec-libjxl/encodewasmjs';
49+
50+
const codec = await createJpegXLEncoder();
51+
const encoder = new codec.JpegXLEncoder();
52+
const frameInfo = {
53+
width,
54+
height,
55+
bitsPerSample,
56+
componentCount,
57+
isSigned: false,
58+
};
59+
60+
encoder.getDecodedBuffer(frameInfo).set(decodedPixels);
61+
encoder.setLossless(true);
62+
encoder.encode();
63+
64+
const bitstream = encoder.getEncodedBuffer();
65+
```
66+
67+
The encoder accepts greyscale samples with one channel or RGB samples with
68+
three channels at bit depths from 1 to 16. Copy returned buffer views before
69+
the next operation or before calling `releaseBuffers()`.
70+
71+
## Building
72+
73+
This project uses a Git submodule for libjxl. Initialize the submodules from
74+
the repository root:
75+
76+
```bash
77+
git submodule update --init --recursive
78+
```
79+
80+
An activated Emscripten SDK is required. Build both WASM modules with:
81+
82+
```bash
83+
cd packages/libjxl
84+
yarn build
85+
```
86+
87+
The generated decoder and encoder `.js` and `.wasm` files are written to
88+
`dist/`.

packages/libjxl/build.sh

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
#!/usr/bin/env bash
2+
#
3+
# Builds the libjxl decoder and encoder WASM modules into dist/.
4+
#
5+
# Requires an activated Emscripten SDK. Either have emcmake on PATH already, or
6+
# point EMSDK at an emsdk checkout and this script will source its env for you.
7+
#
8+
# EMSDK=/c/Apps/emsdk ./build.sh
9+
#
10+
# The build output (dist/*.js and dist/*.wasm) is committed, so only someone
11+
# changing the sources in src/ or updating the libjxl submodule needs to run it.
12+
13+
set -euo pipefail
14+
15+
cd "$(dirname "$0")"
16+
17+
LIBJXL_DIR="extern/libjxl"
18+
BUILD_DIR="build"
19+
20+
if ! command -v emcmake >/dev/null 2>&1; then
21+
if [ -n "${EMSDK:-}" ] && [ -f "${EMSDK}/emsdk_env.sh" ]; then
22+
# shellcheck disable=SC1091
23+
source "${EMSDK}/emsdk_env.sh"
24+
else
25+
echo "emcmake not found. Activate the Emscripten SDK, or set EMSDK to an" >&2
26+
echo "emsdk checkout (e.g. EMSDK=/c/Apps/emsdk $0)." >&2
27+
exit 1
28+
fi
29+
fi
30+
31+
if [ ! -f "${LIBJXL_DIR}/lib/include/jxl/decode.h" ]; then
32+
echo "libjxl submodule not found. Run:" >&2
33+
echo " git submodule update --init --recursive" >&2
34+
exit 1
35+
fi
36+
37+
rm -rf "${BUILD_DIR}" dist
38+
39+
echo "Configuring..."
40+
emcmake cmake -S . -B "${BUILD_DIR}" \
41+
-DCMAKE_BUILD_TYPE=Release \
42+
-DLIBJXL_SOURCE_DIR="$(pwd)/${LIBJXL_DIR}"
43+
44+
echo "Building..."
45+
cmake --build "${BUILD_DIR}" \
46+
--target jpegxlwasm_decode jpegxlwasm_encode --parallel
47+
48+
mkdir -p dist
49+
for module in jpegxlwasm_decode jpegxlwasm_encode; do
50+
cp "${BUILD_DIR}/${module}.js" "${BUILD_DIR}/${module}.wasm" dist/
51+
done
52+
53+
echo "Done:"
54+
ls -la dist/

packages/libjxl/package.json

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
{
2+
"name": "@cornerstonejs/codec-libjxl",
3+
"version": "1.0.0",
4+
"description": "WASM build of libjxl (JPEG XL) for DICOM, with the decoder and encoder as separate modules",
5+
"license": "BSD-3-Clause",
6+
"type": "module",
7+
"publishConfig": {
8+
"access": "public"
9+
},
10+
"files": [
11+
"package.json",
12+
"README.md",
13+
"dist"
14+
],
15+
"exports": {
16+
".": "./dist/jpegxlwasm_decode.js",
17+
"./decodewasm": "./dist/jpegxlwasm_decode.wasm",
18+
"./decodewasmjs": "./dist/jpegxlwasm_decode.js",
19+
"./encodewasm": "./dist/jpegxlwasm_encode.wasm",
20+
"./encodewasmjs": "./dist/jpegxlwasm_encode.js",
21+
"./dist/*": "./dist/*"
22+
},
23+
"scripts": {
24+
"build": "bash build.sh",
25+
"build:ci": "yarn run build",
26+
"clean": "shx rm -rf build dist"
27+
},
28+
"keywords": [
29+
"JPEG XL",
30+
"JPEGXL",
31+
"JXL",
32+
"libjxl",
33+
"DICOM",
34+
"WASM"
35+
],
36+
"repository": {
37+
"type": "git",
38+
"url": "git+https://github.com/cornerstonejs/cornerstone3D.git"
39+
}
40+
}

packages/libjxl/src/frame_info.cpp

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
#include "frame_info.h"
2+
3+
#include <emscripten/bind.h>
4+
5+
using namespace emscripten;
6+
7+
EMSCRIPTEN_BINDINGS(JpegXLFrameInfoBindings) {
8+
value_object<FrameInfo>("FrameInfo")
9+
.field("width", &FrameInfo::width)
10+
.field("height", &FrameInfo::height)
11+
.field("bitsPerSample", &FrameInfo::bitsPerSample)
12+
.field("componentCount", &FrameInfo::componentCount)
13+
.field("isSigned", &FrameInfo::isSigned);
14+
}

0 commit comments

Comments
 (0)