Skip to main content

Codec fixture guide

SpatialData codec fixtures live under test-fixtures/codecs/ and are generated separately from the versioned blobs() fixtures. The reference fixtures are small OME-Zarr/SpatialData images using:

  • imagecodecs_jpeg2k (registered JP2K codec id)
  • experimental.openjph_htj2k (OpenJPH WASM HTJ2K codec id)

Generated stores include:

  • jpeg2k.zarr — small multiscale Mandelbrot ([1,1,1,64,64])
  • mandelbulb.zarr — multi-dimensional Mandelbulb volume ([2,1,8,128,128], HTJ2K, one plane per t/z chunk)
  • htj2k.zarr / htj2k-demo.zarr — HTJ2K smoke and encode-demo stores (when the WASM encoder is available)

Older fixtures may use experimental.imagecodecs_htj2k; the frontend decodes both.

Try it in the browser​

From the repo root:

pnpm test:fixtures:generate:codecs
pnpm --filter @spatialdata/vis dev

Open http://127.0.0.1:5173/codec.

The route uses SpatialCanvas, which activates the bundled codec worker through @spatialdata/vis automatically in browser contexts. Under the hood, vis calls an idempotent ensureCodecWorkers() helper, which uses zarrextra/workers and @fideus-labs/fizarrita with a custom codec worker. That worker registers JP2K and experimental HTJ2K support inside the worker before decode. Use the codec selector on the page to switch between /test-fixtures/codecs/jpeg2k.zarr, /test-fixtures/codecs/mandelbulb.zarr, and /test-fixtures/codecs/htj2k-demo.zarr. When HTJ2K is selected, the demo loads a single htj2k-demo.zarr store containing mandelbrot_lossless, mandelbrot_balanced, and mandelbrot_small image layers (512×512 Mandelbrot, two pyramid levels). Use the SpatialCanvas layer controls to toggle between presets. Manifests are at /test-fixtures/codecs/jpeg2k.manifest.json, /test-fixtures/codecs/mandelbulb.manifest.json, and /test-fixtures/codecs/htj2k-encode-demo.manifest.json.

The vis dev script starts the fixture server on port 38473 and proxies /test-fixtures through Vite on 5173. Override the fixture server port with SPATIALDATA_FIXTURE_PORT if needed.

Python reference writer​

The publishable package (spatialdata-js-util) exposes recompression only. Codec test fixtures are generated by repo-local scripts under python/spatialdata-js-util/scripts/.

Useful commands:

pnpm test:fixtures:generate:codecs
uv run --directory python/spatialdata-js-util spatialdata-js-util images inspect ../../test-fixtures/codecs/jpeg2k.manifest.json

Fixture provenance​

Dev fixtures stamp root metadata with an experimental marker rather than a fake spatialdata library version:

"spatialdata_attrs": {
"experimental_codec_writer": "0.1.0",
"note": "codec test fixture; not a spatialdata.write() artifact"
}

The frontend image schema treats spatialdata_attrs as optional on rasters and does not use version for OME format detection. We intend to propose a standardized provenance field to the spatialdata community; feedback welcome.

The sidecar manifest records shape, dtype, chunk shape, codec id, package versions, and representative decoded/encoded checksums. The fixture scripts validate representative chunks after writing so JS tests can compare decoded samples against Python.

Whole-object recompression​

For larger or real examples, use the recompress command. It copies the source SpatialData store, rewrites configured image rasters with JP2K or experimental HTJ2K, and writes labels with Blosc/zstd by default.

uv run --directory python/spatialdata-js-util spatialdata-js-util images recompress input.sdata.zarr output-jp2k.zarr --image-key morphology_focus --preset balanced --chunks auto --overwrite

Custom HTJ2K quality (instead of a preset name):

uv run --directory python/spatialdata-js-util spatialdata-js-util images recompress \
input.sdata.zarr output-htj2k.zarr \
--image-key morphology_focus \
--codec experimental.openjph_htj2k \
--quality 0.001 \
--chunks auto \
--sibling \
--overwrite

--quality requires --image-key and --codec experimental.openjph_htj2k. Lower values preserve more detail. With --sibling, the output image is named like morphology_focus:htj2k_q0.001.

For repeatable per-raster settings, use JSON:

{
"default_image": { "codec": "imagecodecs_jpeg2k", "preset": "lossless", "chunks": "auto" },
"images": {
"morphology_focus": { "preset": "balanced" },
"morphology_focus_htj2k_tuned": {
"codec": "experimental.openjph_htj2k",
"quality": 0.001,
"chunks": "auto"
},
"he_image": { "preset": "small" },
"fast_preview": {
"codec": "experimental.openjph_htj2k",
"preset": "lossless",
"chunks": "auto"
}
},
"default_labels": { "codec": "blosc", "clevel": 5 }
}

JP2K and HTJ2K share preset names (lossless, balanced, small). HTJ2K encode uses OpenJPH WASM (experimental.openjph_htj2k) via HTJ2KEncoder.setQuality(reversible, quality). The quality argument is a float quantization factor (lower = higher fidelity, larger output) — not JP2K-style 0–100. The step is relative to the dtype's full range, so HTJ2K presets are expressed as a multiple of the input's LSB and resolve from the raster's dtype: balanced: 2 LSB, small: 5 LSB — 0.0078125/0.01953125 for uint8, 0.000030518/0.000076294 for uint16. A step below one LSB encodes larger than lossless for a bit-identical image, so the presets stay clear above that floor; see python/spatialdata-js-util/docs/htj2k-wasm-encode-design.md. Override with CLI --quality 0.001 or per-image JSON "quality": 0.001 (an absolute step, not an LSB multiple; implies lossy unless "reversible": true). A future codec-demo UI may add interactive q exploration on sample regions.

generate_codec_fixtures.py --experimental-htj2k also writes htj2k-quality-sweep.manifest.json, encoding the same Mandelbrot plane at several qualities so you can confirm compression responds on detail-rich imagery, and htj2k-encode-demo.manifest.json with three browser-viewable multiscale image layers in one store (htj2k-demo.zarr: mandelbrot_lossless, mandelbrot_balanced, mandelbrot_small) at 512×512 with 64×64 chunks. The main htj2k.zarr fixture remains a small 64×64 Mandelbrot raster for fast CI smoke tests. mandelbulb.zarr exercises multi t/z HTJ2K decode in JS (images/mandelbulb, shape [2, 1, 8, 128, 128], chunks (1, 1, 1, 128, 128)).

Browser-targeted image codec output is limited to uint8, int8, uint16, and int16. Labels are not image-codec-compressed in v1; they stay lossless through Blosc/zstd because integer IDs and wider dtypes such as uint32 are not considered supported by the current JavaScript decoder paths.

Useful Xenium morphology experiment:

uv run --directory python/spatialdata-js-util spatialdata-js-util images recompress /path/to/input.zarr /path/to/output.zarr --image-key morphology_focus --preset balanced --chunks auto --overwrite

When serving recompressed stores for browser experiments, disable HTTP caching or use a fresh output path after each rewrite. http-server defaults to cacheable responses, so overwriting a Zarr store in place can leave the browser with stale array metadata that points at the old codec while the chunk bytes have changed.

bunx http-server --cors -c-1 /private/tmp

Repo scripts may set UV_CACHE_DIR=.tmp/uv-cache for sandbox or CI cache isolation, but normal user-facing uv run commands do not need it.

JS codec flow​

zarrextra exposes:

  • registerJpeg2kCodec() for the registered imagecodecs_jpeg2k id (Node/CI).
  • enableWorkerChunkDecode() from zarrextra/workers for browser apps (fizarrita worker pool + custom codec worker with JP2K and experimental HTJ2K registration).
  • registerExperimentalHtj2kCodec() for OpenJPH HTJ2K decode (experimental.openjph_htj2k and legacy experimental.imagecodecs_htj2k) in Node/CI smoke tests.
  • loadOmeZarrMultiscalesFromStore() for loading multiscales from a zarr.Readable store without going back through Viv's URL loader.

@spatialdata/vis now prefers raster element stores (images/<key> and labels/<key>) and keeps Viv URL loading as a compatibility fallback. Browser apps using vis do not need to call enableWorkerChunkDecode() directly; the shared renderer path calls ensureCodecWorkers() once. Lower-level browser apps that use zarrextra without vis should still call enableWorkerChunkDecode() before loading JP2K or HTJ2K chunks.