Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
163 changes: 163 additions & 0 deletions build_tools/generate_jxl_fixtures.ts
Original file line number Diff line number Diff line change
@@ -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();
4 changes: 2 additions & 2 deletions src/async_computation/decode_jxl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
1 change: 1 addition & 0 deletions src/datasource/zarr/async_computation.ts
Original file line number Diff line number Diff line change
@@ -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";
2 changes: 1 addition & 1 deletion src/datasource/zarr/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
94 changes: 94 additions & 0 deletions src/datasource/zarr/codec/jpegxl/decode.ts
Original file line number Diff line number Diff line change
@@ -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;
},
});
56 changes: 56 additions & 0 deletions src/datasource/zarr/codec/jpegxl/resolve.ts
Original file line number Diff line number Diff line change
@@ -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,
},
};
},
});
2 changes: 1 addition & 1 deletion src/datasource/zarr/frontend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
1 change: 1 addition & 0 deletions src/datasource/zarr/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ Supported compressors:
- null (raw)
- zlib
- zstd
- jpegxl

Filters are not supported.

Expand Down
25 changes: 25 additions & 0 deletions src/datasource/zarr/metadata/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading