diff --git a/build_tools/generate_jxl_fixtures.ts b/build_tools/generate_jxl_fixtures.ts new file mode 100644 index 0000000000..e67089c6dd --- /dev/null +++ b/build_tools/generate_jxl_fixtures.ts @@ -0,0 +1,163 @@ +/** + * @license + * Utility to generate tiny JPEG XL fixtures for tests. + * + * Strategy: + * 1. Create raw PPM (Portable Pixmap) files for 1x1 grayscale values. + * 2. If `cjxl` (libjxl CLI encoder) is available on PATH, invoke it + * to produce .jxl outputs under testdata/jxl/. + * 3. If unavailable, emit a notice; tests will skip if fixtures are absent. + * + * Usage: + * npx ts-node build_tools/generate_jxl_fixtures.ts + * or add a package.json script alias. + */ + +import { spawnSync } from "node:child_process"; +import { writeFileSync, mkdirSync, existsSync } from "node:fs"; +import { join } from "node:path"; + +const ROOT = new URL("..", import.meta.url).pathname; // neuroglancer/ +const OUT_DIR = join(ROOT, "testdata", "jxl"); + +interface FixtureSpec { + filename: string; // base name without extension + kind: "u8" | "u16" | "f32"; + width: number; + height: number; + value: number; // canonical value (0..255 for u8, 0..65535 for u16, 0..1 float) +} + +const FIXTURES: FixtureSpec[] = [ + { filename: "gray_u8_128", kind: "u8", width: 1, height: 1, value: 128 }, + { + filename: "gray_u16_40000", + kind: "u16", + width: 1, + height: 1, + value: 40000, + }, + { filename: "gray_f32_0_25", kind: "f32", width: 1, height: 1, value: 0.25 }, +]; + +function ensureDir(p: string) { + if (!existsSync(p)) mkdirSync(p, { recursive: true }); +} + +function writeRawPortableGraymap( + path: string, + width: number, + height: number, + data: Uint8Array, +) { + // P5 (PGM) header + const header = `P5\n${width} ${height}\n255\n`; + const headerBytes = new TextEncoder().encode(header); + const out = new Uint8Array(headerBytes.length + data.length); + out.set(headerBytes, 0); + out.set(data, headerBytes.length); + writeFileSync(path, out); +} + +// Removed unused writePpmFromGray to satisfy TS noUnusedLocals. + +function haveCjxl(): boolean { + const r = spawnSync("cjxl", ["--version"], { encoding: "utf8" }); + return r.status === 0; +} + +function generate() { + ensureDir(OUT_DIR); + const cjxl = haveCjxl(); + if (!cjxl) { + console.error( + "[generate_jxl_fixtures] 'cjxl' not found on PATH. Skipping generation.", + ); + console.error( + "Install libjxl (brew install jpeg-xl) then re-run to create fixtures.", + ); + return; + } + const metadata: any[] = []; + for (const f of FIXTURES) { + const base = join(OUT_DIR, f.filename); + const jxlPath = `${base}.jxl`; + let sourcePath: string; + if (f.kind === "u8") { + const gray = new Uint8Array([f.value & 0xff]); + sourcePath = `${base}.pgm`; + writeRawPortableGraymap(sourcePath, f.width, f.height, gray); + const res = spawnSync("cjxl", [sourcePath, jxlPath, "--quiet"], { + stdio: "inherit", + }); + if (res.status !== 0) console.error(`Failed encode ${sourcePath}`); + metadata.push({ + file: `${f.filename}.jxl`, + width: f.width, + height: f.height, + channels: 1, + bytesPerSample: 1, + kind: f.kind, + value: f.value, + }); + } else if (f.kind === "u16") { + // Produce a 16-bit gray via PGM maxval 65535 then cjxl. + const header = `P5\n${f.width} ${f.height}\n65535\n`; + const headerBytes = new TextEncoder().encode(header); + const pixel = f.value & 0xffff; + const gray16 = new Uint8Array([pixel >> 8, pixel & 0xff]); // big-endian per PGM spec + const out = new Uint8Array(headerBytes.length + gray16.length); + out.set(headerBytes, 0); + out.set(gray16, headerBytes.length); + sourcePath = `${base}_16.pgm`; + writeFileSync(sourcePath, out); + const res = spawnSync("cjxl", [sourcePath, jxlPath, "--quiet"], { + stdio: "inherit", + }); + if (res.status !== 0) console.error(`Failed encode ${sourcePath}`); + metadata.push({ + file: `${f.filename}.jxl`, + width: f.width, + height: f.height, + channels: 1, + bytesPerSample: 2, + kind: f.kind, + value: f.value, + }); + } else if (f.kind === "f32") { + // True float32 via PFM (Portable Float Map) grayscale: header 'Pf', negative scale => little-endian + // Spec: lines: 'Pf', 'width height', 'scale' then binary floats row-major. + const header = `Pf\n${f.width} ${f.height}\n-1.0\n`; + const headerBytes = new TextEncoder().encode(header); + const pixelF32 = new Float32Array([f.value]); + const pixelBytes = new Uint8Array(pixelF32.buffer); + sourcePath = `${base}.pfm`; + const out = new Uint8Array(headerBytes.length + pixelBytes.length); + out.set(headerBytes, 0); + out.set(pixelBytes, headerBytes.length); + writeFileSync(sourcePath, out); + const res = spawnSync("cjxl", [sourcePath, jxlPath, "--quiet"], { + stdio: "inherit", + }); + if (res.status !== 0) console.error(`Failed encode ${sourcePath}`); + metadata.push({ + file: `${f.filename}.jxl`, + width: f.width, + height: f.height, + channels: 1, + bytesPerSample: 4, + kind: f.kind, + value: f.value, + }); + } + } + writeFileSync( + join(OUT_DIR, "fixtures.json"), + JSON.stringify(metadata, null, 2), + ); + console.log( + "JPEG XL fixtures generated (if encoding succeeded). You may delete .ppm sources.", + ); +} + +generate(); diff --git a/src/async_computation/decode_jxl.ts b/src/async_computation/decode_jxl.ts index 916e9567fa..9a8ad402a9 100644 --- a/src/async_computation/decode_jxl.ts +++ b/src/async_computation/decode_jxl.ts @@ -22,8 +22,8 @@ registerAsyncComputation( decodeJxl, async ( data: Uint8Array, - area: number | undefined, - numComponents: number | undefined, + area: number, + numComponents: number, bytesPerPixel: number, ) => { const result = await decompressJxl( diff --git a/src/datasource/zarr/async_computation.ts b/src/datasource/zarr/async_computation.ts index db56b860ff..b43f718f10 100644 --- a/src/datasource/zarr/async_computation.ts +++ b/src/datasource/zarr/async_computation.ts @@ -1,2 +1,3 @@ import "#src/async_computation/decode_blosc.js"; import "#src/async_computation/decode_zstd.js"; +import "#src/async_computation/decode_jxl.js"; diff --git a/src/datasource/zarr/backend.ts b/src/datasource/zarr/backend.ts index 7370f7af0e..76d20d2d93 100644 --- a/src/datasource/zarr/backend.ts +++ b/src/datasource/zarr/backend.ts @@ -18,7 +18,7 @@ import "#src/datasource/zarr/codec/blosc/decode.js"; import "#src/datasource/zarr/codec/zstd/decode.js"; import "#src/datasource/zarr/codec/bytes/decode.js"; import "#src/datasource/zarr/codec/crc32c/decode.js"; - +import "#src/datasource/zarr/codec/jpegxl/decode.js"; import { WithParameters } from "#src/chunk_manager/backend.js"; import { VolumeChunkSourceParameters } from "#src/datasource/zarr/base.js"; import { diff --git a/src/datasource/zarr/codec/jpegxl/decode.ts b/src/datasource/zarr/codec/jpegxl/decode.ts new file mode 100644 index 0000000000..456c8072ce --- /dev/null +++ b/src/datasource/zarr/codec/jpegxl/decode.ts @@ -0,0 +1,94 @@ +/** + * @license + * Copyright 2025. + */ + +import { decodeJxl } from "#src/async_computation/decode_jxl_request.js"; +import { requestAsyncComputation } from "#src/async_computation/request.js"; +import { registerCodec } from "#src/datasource/zarr/codec/decode.js"; +import { CodecKind } from "#src/datasource/zarr/codec/index.js"; +import type { Configuration } from "#src/datasource/zarr/codec/jpegxl/resolve.js"; + +registerCodec({ + name: "jpegxl", + kind: CodecKind.bytesToBytes, + async decode( + configuration: Configuration, + encoded: Uint8Array, + signal: AbortSignal, + ) { + signal; + // Determine bytesPerPixel from bitspersample. + let bytesPerPixel: 1 | 2 | 4; + switch (configuration.bitspersample) { + case 16: + bytesPerPixel = 2; + break; + case 32: + bytesPerPixel = 4; + break; + default: + bytesPerPixel = 1; + break; // 8-bit or unknown -> assume 1 + } + + // Infer spatial area (x*y) and numComponents (channels) from chunkShape. + if (!configuration.chunkShape || configuration.chunkShape.length < 2) { + throw new Error( + "jpegxl: missing or invalid chunkShape for area inference", + ); + } + const shape = configuration.chunkShape; + // Identify trailing non-singleton dims for spatial (x,y[,z]). Take last two as x,y. + let x = 1, + y = 1, + z = 1; // width, height, depth + for (let i = shape.length - 1; i >= 0; --i) { + if (x === 1) { + x = shape[i]; + continue; + } + if (z === 1) { + z = shape[i]; + continue; + } + if (y === 1) { + y = shape[i]; + break; + } + } + const area = x * y * z; + + // Channel inference: first dimension with value 3 or 4 outside of the trailing two spatial dims. + let numComponents = 1; + for (let i = 0; i < shape.length - 2; ++i) { + const v = shape[i]; + if (v === 3 || v === 4) { + numComponents = v; + break; + } + } + + const decoded = await requestAsyncComputation( + decodeJxl, + signal, + [encoded.buffer], + encoded, + area, + numComponents, + bytesPerPixel, + ); + + // Validate total bytes against chunkElements if provided. + if (configuration.chunkElements && decoded.uint8Array) { + const bytesPerVoxel = bytesPerPixel * numComponents; + const expectedBytes = configuration.chunkElements * bytesPerVoxel; + if (decoded.uint8Array.byteLength !== expectedBytes) { + console.warn( + `jpegxl: decoded bytes ${decoded.uint8Array.byteLength} != expected ${expectedBytes} (chunkElements=${configuration.chunkElements}, bytesPerVoxel=${bytesPerVoxel}).`, + ); + } + } + return decoded.uint8Array; + }, +}); diff --git a/src/datasource/zarr/codec/jpegxl/resolve.ts b/src/datasource/zarr/codec/jpegxl/resolve.ts new file mode 100644 index 0000000000..7855bc1e8b --- /dev/null +++ b/src/datasource/zarr/codec/jpegxl/resolve.ts @@ -0,0 +1,56 @@ +/** + * @license + * Copyright 2025. + */ + +// bytesToBytes codecs do not receive full decoded array shape info at resolve time; they only +// get the decoded byte size from the previous stage via the resolve API in parseCodecChainSpec. +import { CodecKind } from "#src/datasource/zarr/codec/index.js"; +import { registerCodec } from "#src/datasource/zarr/codec/resolve.js"; +import { verifyObject, verifyOptionalObjectProperty } from "#src/util/json.js"; + +export interface Configuration { + bitspersample: number | null; + chunkElements?: number; // total elements in the chunk (product of chunkShape) + chunkShape?: number[]; // raw chunk shape (logical order) +} + +registerCodec({ + name: "jpegxl", + kind: CodecKind.bytesToBytes, + resolve(configuration: unknown, _decodedSize: number | undefined) { + // decodedSize is the size in bytes output by the preceding arrayToBytes codec (if known). + // For jpegxl we can't map that directly without bitspersample; we just carry through fields. + verifyObject(configuration); + const bitspersample = verifyOptionalObjectProperty( + configuration, + "bitspersample", + (x) => (typeof x === "number" ? x : null), + null, + ); + const chunkElements = verifyOptionalObjectProperty( + configuration, + "chunkElements", + (x) => (typeof x === "number" ? x : undefined), + undefined, + ); + const chunkShape = verifyOptionalObjectProperty( + configuration, + "chunkShape", + (x) => + Array.isArray(x) + ? x + .map((v) => (typeof v === "number" ? v : NaN)) + .filter((v) => Number.isFinite(v)) + : undefined, + undefined, + ); + return { + configuration: { + bitspersample, + chunkElements, + chunkShape, + }, + }; + }, +}); diff --git a/src/datasource/zarr/frontend.ts b/src/datasource/zarr/frontend.ts index b0d5ea6c79..ea6ee23ccf 100644 --- a/src/datasource/zarr/frontend.ts +++ b/src/datasource/zarr/frontend.ts @@ -16,7 +16,7 @@ import "#src/datasource/zarr/codec/blosc/resolve.js"; import "#src/datasource/zarr/codec/zstd/resolve.js"; - +import "#src/datasource/zarr/codec/jpegxl/resolve.js"; import { makeDataBoundsBoundingBoxAnnotationSet } from "#src/annotation/index.js"; import { WithParameters } from "#src/chunk_manager/frontend.js"; import type { CoordinateSpace } from "#src/coordinate_transform.js"; diff --git a/src/datasource/zarr/index.rst b/src/datasource/zarr/index.rst index cd25bd7c01..5a06868d65 100644 --- a/src/datasource/zarr/index.rst +++ b/src/datasource/zarr/index.rst @@ -78,6 +78,7 @@ Supported compressors: - null (raw) - zlib - zstd +- jpegxl Filters are not supported. diff --git a/src/datasource/zarr/metadata/parse.ts b/src/datasource/zarr/metadata/parse.ts index c77e710772..a4037cc27f 100644 --- a/src/datasource/zarr/metadata/parse.ts +++ b/src/datasource/zarr/metadata/parse.ts @@ -362,6 +362,31 @@ export function parseV2Metadata( verifyObject(compressor); const id = verifyObjectProperty(compressor, "id", verifyString); switch (id) { + case "imagecodecs_jpegxl": + { + // bitspersample may be omitted or explicitly null. In either case, fall back to dtype size. + const bitspersampleRaw = verifyOptionalObjectProperty( + compressor, + "bitspersample", + (v) => (v === null ? null : verifyInt(v)), + null, + ); + const bitspersample = + bitspersampleRaw == null + ? DATA_TYPE_BYTES[dataType] * 8 + : bitspersampleRaw; + const chunkElements = chunkShape.reduce((a, b) => a * b, 1); + // For JPEG XL we encode an entire chunk (all z-slices) as one codestream. + codecs.push({ + name: "jpegxl", + configuration: { + bitspersample, + chunkElements, + chunkShape: Array.from(chunkShape), + }, + }); + } + break; case "blosc": codecs.push({ name: "blosc", diff --git a/src/sliceview/jxl/index.ts b/src/sliceview/jxl/index.ts index b41f3fba84..7626149134 100644 --- a/src/sliceview/jxl/index.ts +++ b/src/sliceview/jxl/index.ts @@ -91,8 +91,8 @@ function checkHeader(buffer: Uint8Array) { export async function decompressJxl( buffer: Uint8Array, - area: number | undefined, - numComponents: number | undefined, + area: number, + numComponents: number, bytesPerPixel: number, ): Promise { const m = await getJxlModulePromise(); @@ -107,7 +107,9 @@ export async function decompressJxl( const heap = new Uint8Array((m.exports.memory as WebAssembly.Memory).buffer); heap.set(buffer, jxlImagePtr); - let imagePtr = null; + let imagePtr: number = 0; + // Will be set after we know width/height. + let frameCount = 1; try { const width = (m.exports.width as Function)( @@ -120,6 +122,12 @@ export async function decompressJxl( buffer.byteLength, nbytes, ); + frameCount = (m.exports.frames as Function)( + jxlImagePtr, + buffer.byteLength, + nbytes, + ); + if (frameCount <= 0) frameCount = 1; if (width <= 0 || height <= 0) { throw new Error( @@ -127,25 +135,30 @@ export async function decompressJxl( ); } - if (area !== undefined && width * height !== area) { + if (area !== undefined && width * height * frameCount !== area) { throw new Error( - `jxl: Expected width and height (${width} x ${height}, ${width * height}) to match area: ${area}.`, + `jxl: Expected width and height (${width} x ${height} x ${frameCount}, ${width * height * frameCount}) to match area: ${area}.`, + ); + } + if (bytesPerPixel === 1) { + imagePtr = (m.exports.decode as Function)( + jxlImagePtr, + buffer.byteLength, + nbytes, + ); + } else { + imagePtr = (m.exports.decode_with_bpp as Function)( + jxlImagePtr, + buffer.byteLength, + nbytes, + bytesPerPixel, ); } - - imagePtr = (m.exports.decode as Function)( - jxlImagePtr, - buffer.byteLength, - nbytes, - ); if (imagePtr === 0) { throw new Error("jxl: Decoding failed. Null pointer returned."); } - // Likewise, we reference memory.buffer instead of heap.buffer - // because memory growth during decompress could have detached - // the buffer. const image = new Uint8Array( (m.exports.memory as WebAssembly.Memory).buffer, imagePtr, diff --git a/src/sliceview/jxl/jxl_decoder.wasm b/src/sliceview/jxl/jxl_decoder.wasm index a33382bae1..a5b92da89d 100755 Binary files a/src/sliceview/jxl/jxl_decoder.wasm and b/src/sliceview/jxl/jxl_decoder.wasm differ diff --git a/src/sliceview/jxl/src/lib.rs b/src/sliceview/jxl/src/lib.rs index 5b9bbf63a6..130f2767e3 100644 --- a/src/sliceview/jxl/src/lib.rs +++ b/src/sliceview/jxl/src/lib.rs @@ -80,6 +80,20 @@ pub fn height(ptr: *mut u8, input_size: usize, output_size: usize) -> i32 { -4 as i32 } +/// Returns number of keyframes (frames) in the codestream, or negative on error. +#[no_mangle] +pub fn frames(ptr: *mut u8, input_size: usize, output_size: usize) -> i32 { + if ptr.is_null() || input_size == 0 || output_size == 0 { return -1; } + let data: &[u8] = unsafe { slice::from_raw_parts(ptr, input_size) }; + let image = match JxlImage::builder().read(data) { Ok(image) => image, Err(_image) => return -2 }; + let mut count = 0i32; + for keyframe_idx in 0..image.num_loaded_keyframes() { + let _ = match image.render_frame(keyframe_idx) { Ok(frame) => frame, Err(_frame) => return -3 }; + count += 1; + } + if count == 0 { -4 } else { count } +} + #[no_mangle] pub fn decode(ptr: *mut u8, input_size: usize, output_size: usize) -> *const u8 { if ptr.is_null() || input_size == 0 || output_size == 0 { @@ -125,10 +139,13 @@ pub fn decode(ptr: *mut u8, input_size: usize, output_size: usize) -> *const u8 } } PixelFormat::Rgba => { - for pixel in fb.buf() { - let value = (pixel * 255.0).clamp(0.0, 255.0) as u8; - output_buffer.push(value); - output_buffer.push(255); // Alpha channel set to fully opaque + // fb.buf() laid out as RGBA RGBA ...; write exactly 4 bytes per pixel + for px in fb.buf().chunks_exact(4) { + for c in 0..3 { // RGB + let v = (px[c] * 255.0).clamp(0.0, 255.0) as u8; + output_buffer.push(v); + } + output_buffer.push(255); // opaque alpha } } _ => return std::ptr::null_mut(), @@ -144,4 +161,120 @@ pub fn decode(ptr: *mut u8, input_size: usize, output_size: usize) -> *const u8 ptr } +/// Extended decode that supports 1-, 2-, or 4-byte per sample output. +/// 1 => uint8, 2 => uint16 little-endian, 4 => float32 little-endian (linear 0..1). +/// Returns a pointer to a heap-allocated buffer of length exactly `output_size` on success or null on failure. +#[no_mangle] +pub fn decode_with_bpp(ptr: *mut u8, input_size: usize, output_size: usize, bytes_per_sample: usize) -> *const u8 { + if ptr.is_null() || input_size == 0 || output_size == 0 { + return ptr::null(); + } + + if bytes_per_sample != 1 && bytes_per_sample != 2 && bytes_per_sample != 4 { + return ptr::null(); + } + + let data: &[u8] = unsafe { slice::from_raw_parts(ptr, input_size) }; + + let image = match JxlImage::builder().read(data) { + Ok(image) => image, + Err(_image) => return std::ptr::null_mut(), + }; + + let mut output_buffer: Vec = Vec::with_capacity(output_size); + + for keyframe_idx in 0..image.num_loaded_keyframes() { + let frame = match image.render_frame(keyframe_idx) { + Ok(frame) => frame, + Err(_frame) => return std::ptr::null_mut(), + }; + + let mut stream = frame.stream(); + let mut fb = FrameBuffer::new( + stream.width() as usize, + stream.height() as usize, + stream.channels() as usize, + ); + stream.write_to_buffer(fb.buf_mut()); + + match image.pixel_format() { + PixelFormat::Gray => { + for pixel in fb.buf() { // pixel in 0.0..1.0 + match bytes_per_sample { + 1 => { + let value = (pixel * 255.0).clamp(0.0, 255.0) as u8; + output_buffer.push(value); + } + 2 => { + let v = (pixel * 65535.0).clamp(0.0, 65535.0).round() as u16; + output_buffer.extend_from_slice(&v.to_le_bytes()); + } + 4 => { + let f = *pixel as f32; // already 0..1 linear + output_buffer.extend_from_slice(&f.to_le_bytes()); + } + _ => return ptr::null_mut(), + } + } + }, + PixelFormat::Rgb => { + for pixel in fb.buf() { + match bytes_per_sample { + 1 => { + let value = (pixel * 255.0).clamp(0.0, 255.0) as u8; + output_buffer.push(value); + } + 2 => { + let v = (pixel * 65535.0).clamp(0.0, 65535.0).round() as u16; + output_buffer.extend_from_slice(&v.to_le_bytes()); + } + 4 => { + let f = *pixel as f32; + output_buffer.extend_from_slice(&f.to_le_bytes()); + } + _ => return ptr::null_mut(), + } + } + } + PixelFormat::Rgba => { + // Iterate per pixel (4 floats) + for px in fb.buf().chunks_exact(4) { + match bytes_per_sample { + 1 => { + for c in 0..3 { // RGB + let v = (px[c] * 255.0).clamp(0.0, 255.0) as u8; output_buffer.push(v); + } + output_buffer.push(255); // alpha + } + 2 => { + for c in 0..3 { + let v = (px[c] * 65535.0).clamp(0.0, 65535.0).round() as u16; + output_buffer.extend_from_slice(&v.to_le_bytes()); + } + output_buffer.extend_from_slice(&0xFFFFu16.to_le_bytes()); + } + 4 => { + for c in 0..3 { + let f = px[c] as f32; output_buffer.extend_from_slice(&f.to_le_bytes()); + } + let alpha: f32 = 1.0; output_buffer.extend_from_slice(&alpha.to_le_bytes()); + } + _ => return ptr::null_mut(), + } + } + } + _ => return std::ptr::null_mut(), + } + } + + if output_buffer.len() != output_size { + // Size mismatch -> unsafe to expose. + return std::ptr::null_mut(); + } + + let ptr_out = output_buffer.as_ptr(); + std::mem::forget(output_buffer); + ptr_out +} + diff --git a/testdata/jxl/README.md b/testdata/jxl/README.md new file mode 100644 index 0000000000..bc53988ee4 --- /dev/null +++ b/testdata/jxl/README.md @@ -0,0 +1,23 @@ +JPEG XL Test Fixtures +====================== + +This directory holds tiny JPEG XL images used by automated tests. + +Generation +---------- +Install the JPEG XL reference encoder (macOS example): + + brew install jpeg-xl + +Then run: + + npx ts-node build_tools/generate_jxl_fixtures.ts + +This creates (by default): + sample_gray_128.jxl (1x1 grayscale pixel value 128) + sample_gray_200.jxl (1x1 grayscale pixel value 200) + +The tests will decode both at 8-bit and 16-bit depths and verify integrity +and scaling (16-bit value ≈ 8bit * 257). + +If fixtures are missing, the related test will skip gracefully. diff --git a/testdata/jxl/fixtures.json b/testdata/jxl/fixtures.json new file mode 100644 index 0000000000..c2a7269bd5 --- /dev/null +++ b/testdata/jxl/fixtures.json @@ -0,0 +1,29 @@ +[ + { + "file": "gray_u8_128.jxl", + "width": 1, + "height": 1, + "channels": 1, + "bytesPerSample": 1, + "kind": "u8", + "value": 128 + }, + { + "file": "gray_u16_40000.jxl", + "width": 1, + "height": 1, + "channels": 1, + "bytesPerSample": 2, + "kind": "u16", + "value": 40000 + }, + { + "file": "gray_f32_0_25.jxl", + "width": 1, + "height": 1, + "channels": 1, + "bytesPerSample": 4, + "kind": "f32", + "value": 0.25 + } +] \ No newline at end of file diff --git a/testdata/jxl/gray_f32_0_25.jxl b/testdata/jxl/gray_f32_0_25.jxl new file mode 100644 index 0000000000..25b1d33c56 Binary files /dev/null and b/testdata/jxl/gray_f32_0_25.jxl differ diff --git a/testdata/jxl/gray_f32_0_25.pfm b/testdata/jxl/gray_f32_0_25.pfm new file mode 100644 index 0000000000..b131eadfec Binary files /dev/null and b/testdata/jxl/gray_f32_0_25.pfm differ diff --git a/testdata/jxl/gray_u16_40000.jxl b/testdata/jxl/gray_u16_40000.jxl new file mode 100644 index 0000000000..a3490e83a5 Binary files /dev/null and b/testdata/jxl/gray_u16_40000.jxl differ diff --git a/testdata/jxl/gray_u16_40000_16.pgm b/testdata/jxl/gray_u16_40000_16.pgm new file mode 100644 index 0000000000..2264412cfe --- /dev/null +++ b/testdata/jxl/gray_u16_40000_16.pgm @@ -0,0 +1,4 @@ +P5 +1 1 +65535 +œ@ \ No newline at end of file diff --git a/testdata/jxl/gray_u8_128.jxl b/testdata/jxl/gray_u8_128.jxl new file mode 100644 index 0000000000..855d1cd576 Binary files /dev/null and b/testdata/jxl/gray_u8_128.jxl differ diff --git a/testdata/jxl/gray_u8_128.pgm b/testdata/jxl/gray_u8_128.pgm new file mode 100644 index 0000000000..bf92d300ba --- /dev/null +++ b/testdata/jxl/gray_u8_128.pgm @@ -0,0 +1,4 @@ +P5 +1 1 +255 +€ \ No newline at end of file diff --git a/tests/codec/jpegxl.browser_test.ts b/tests/codec/jpegxl.browser_test.ts new file mode 100644 index 0000000000..80e29343c9 --- /dev/null +++ b/tests/codec/jpegxl.browser_test.ts @@ -0,0 +1,84 @@ +/** + * @license + * Browser test: JPEG XL decoding 8-bit vs 16-bit using testdata server. + */ +import { expect, it, describe } from "vitest"; +import { decompressJxl } from "#src/sliceview/jxl/index.js"; + +declare const TEST_DATA_SERVER: string; + +interface FixtureMeta { + file: string; + width: number; + height: number; + channels: number; + bytesPerSample: number; // 1=u8,2=u16,4=float32 + kind: string; // u8|u16|f32 + value: number; // reference value (see generator notes) +} + +async function fetchMetadata(): Promise { + const url = `${TEST_DATA_SERVER.replace(/\/$/, "")}/jxl/fixtures.json`; + try { + const resp = await fetch(url); + if (!resp.ok) return null; + return (await resp.json()) as FixtureMeta[]; + } catch { + return null; + } +} + +async function fetchFixture(relPath: string): Promise { + const url = `${TEST_DATA_SERVER.replace(/\/$/, "")}/${relPath}`; + try { + const resp = await fetch(url); + if (!resp.ok) return null; + return new Uint8Array(await resp.arrayBuffer()); + } catch { + return null; + } +} + +describe("jpegxl decode (browser)", () => { + it("decodes metadata-described fixtures (u8/u16/f32 single-channel)", async () => { + const metas = await fetchMetadata(); + if (!metas) { + expect(true).toBe(true); // skip + return; + } + let ran = 0; + for (const meta of metas) { + const data = await fetchFixture(`jxl/${meta.file}`); + if (!data) continue; + const area = meta.width * meta.height; + const decoded = await decompressJxl( + data, + area, + meta.channels, + meta.bytesPerSample === 4 ? 4 : meta.bytesPerSample === 2 ? 2 : 1, + ); + const expectedLen = area * meta.channels * meta.bytesPerSample; + expect(decoded.uint8Array.length).toBe(expectedLen); + // Validate central pixel value approximation. + if (meta.channels === 1 && area === 1) { + if (meta.bytesPerSample === 1) { + const v = decoded.uint8Array[0]; + expect(Math.abs(v - meta.value)).toBeLessThanOrEqual(2); + } else if (meta.bytesPerSample === 2) { + const v16 = decoded.uint8Array[0] | (decoded.uint8Array[1] << 8); + expect(Math.abs(v16 - meta.value)).toBeLessThanOrEqual(512); + } else if (meta.bytesPerSample === 4) { + const view = new DataView( + decoded.uint8Array.buffer, + decoded.uint8Array.byteOffset, + decoded.uint8Array.byteLength, + ); + const f = view.getFloat32(0, true); + expect(Math.abs(f - meta.value)).toBeLessThanOrEqual(0.005); + } + } + ran++; + } + expect(ran).toBeGreaterThan(0); + }); +});