Skip to content
Merged
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),

## [Unreleased]

### Added
- OME-Zarr with x, y, z axis order and Zarr URLs without `.zarr`, so public [webKnossos](https://webknossos.org) datasets open directly.

## [0.5.0] - 2026-09-28

### Added
Expand Down
6 changes: 3 additions & 3 deletions docs/data/ome-zarr.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ Kiln is listed in the [OME-NGFF tools registry](https://ngff.openmicroscopy.org/

- **OME-NGFF v0.4 and v0.5** with `multiscales` metadata in group attributes
- **Single-channel or multichannel** — up to 4 channels (see [Multichannel](/rendering/multichannel))
- **3D arrays** with dimensions ordered `[z, y, x]` (standard C-order); multichannel datasets use a `c` axis
- **3D arrays** with x, y and z as the last three dimensions, usually `[z, y, x]`; `[x, y, z]` as written by [webKnossos](https://webknossos.org) works too. Multichannel datasets use a `c` axis
- **Supported dtypes:** `uint8`, `uint16`, `int16`, `float32` (`float64` is read as `float32`). Other types, such as `int8` or `uint32`, are rejected with an explanation
- Multiple resolution levels (datasets within `multiscales`) are used as LODs. Kiln reads each level's own size and per-axis downsampling (1× or 2×). If the stored levels don't fit that model, for example a coarse level a few slices short of half its parent, it falls back to a uniform 2:1 model and logs why. Force either model with `?pyramid=native` or `?pyramid=legacy`
- Voxel spacing is read from `coordinateTransformations` if present
Expand All @@ -33,7 +33,7 @@ import { KilnViewer } from 'kiln-render';
const viewer = await KilnViewer.create(canvas, 'https://example.com/data/scan.ome.zarr');
```

Kiln auto-detects the format from the URL. Brick assembly (fetching Zarr chunks, decompressing, and re-chunking into 66³ bricks with ghost borders) runs in a Web Worker pool off the main thread. For Zarr v2 stores (NGFF 0.4) the metadata is read in two round trips and shared with the workers, so they start without touching the network.
Any URL that doesn't serve Kiln's sharded format (a `volume.json`) is read as OME-Zarr, so URLs without `.zarr`, such as webKnossos layers, work as well. Brick assembly (fetching Zarr chunks, decompressing, and re-chunking into 66³ bricks with ghost borders) runs in a Web Worker pool off the main thread. For Zarr v2 stores (NGFF 0.4) the metadata is read in two round trips and shared with the workers, so they start without touching the network.

### Layout tips for fast streaming

Expand All @@ -55,4 +55,4 @@ const viewer = await KilnViewer.create(

## Axis convention

Zarr stores dimensions as `[z, y, x]` (C-order, x fastest-varying). Kiln uses `[x, y, z]` in its metadata. Only metadata tuples are swapped; no data transposition is needed since the memory layout is identical.
Most OME-Zarr stores dimensions as `[z, y, x]`; webKnossos writes `[x, y, z]`. Kiln reads the order from the `axes` metadata and uses `[x, y, z]` internally. Chunks are read through their strides, so no data is transposed.
53 changes: 22 additions & 31 deletions src/data/base-zarr-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,9 @@ import type {
import { valueTypeFor } from './value-types.js';
import { UnsupportedDatasetError } from './data-provider.js';
import { NetworkTracker } from './network-tracker.js';
import { extractMultiscales, normalizeAxes, validateZarrSupport } from './zarr-validator.js';
import { estimateBrickChunkFanout, computeBrickChunkFootprint } from './chunk-math.js';
import { extractMultiscales, normalizeAxes, spatialOrder, validateZarrSupport } from './zarr-validator.js';
import { estimateBrickChunkFanout, computeBrickChunkFootprint, pickXyz } from './chunk-math.js';
import type { SpatialOrder } from './chunk-math.js';
import { buildPyramid } from '../core/pyramid.js';
import type { Vec3, PyramidPolicy } from '../core/pyramid.js';

Expand Down Expand Up @@ -53,12 +54,8 @@ export interface LodParams {
channelAxisIdx: number;
/** Channels stored per chunk along the channel axis (1 if none). */
channelChunkSize: number;
}

/** Zarr vectors are [..., z, y, x]; Kiln works in [x, y, z]. */
function lastThreeAsXyz(v: number[] | undefined): Vec3 | undefined {
if (!v || v.length < 3) return undefined;
return [v[v.length - 1]!, v[v.length - 2]!, v[v.length - 3]!];
/** Positions of x, y, z within the last three dims; absent means the usual z, y, x. */
spatialOrder?: SpatialOrder;
}

/** OMERO colours are "RRGGBB" hex strings; returns 0–1 RGB or undefined if malformed. */
Expand Down Expand Up @@ -257,6 +254,10 @@ export abstract class BaseZarrProvider implements DataProvider {
const validationReasons = validateZarrSupport(ms, arrays[0]!.shape, String(dtype));
if (validationReasons.length > 0) throw new UnsupportedDatasetError(validationReasons);

// Kiln works in [x, y, z]; Zarr vectors end in z, y, x, or in x, y, z for webKnossos
const order = spatialOrder(axisNames, arrays[0]!.shape.length);
const xyz = (v: number[] | undefined): Vec3 | undefined => (v && v.length >= 3 ? pickXyz(v, order) : undefined);

// Storage mapping for the source dtype (bit depth, unorm vs raw-value path)
const dtypeStr = String(dtype);
const valueType = valueTypeFor(dtypeStr);
Expand All @@ -268,34 +269,24 @@ export abstract class BaseZarrProvider implements DataProvider {
let voxelSpacing: [number, number, number] | undefined;
const transforms = ms.datasets[0]?.coordinateTransformations ?? ms.coordinateTransformations;
if (transforms) {
const scaleTransform = transforms.find(t => t.type === 'scale');
if (scaleTransform?.scale) {
const s = scaleTransform.scale;
// Zarr stores as [z, y, x], convert to [x, y, z]
voxelSpacing = [s[s.length - 1]!, s[s.length - 2]!, s[s.length - 3]!];
}
voxelSpacing = xyz(transforms.find(t => t.type === 'scale')?.scale);
}

// Build LOD levels with virtual dimensions for uniform 2:1 downsampling
const lod0Shape = arrays[0]!.shape; // [z, y, x]
const lod0Dims: [number, number, number] = [
lod0Shape[lod0Shape.length - 1]!, // x
lod0Shape[lod0Shape.length - 2]!, // y
lod0Shape[lod0Shape.length - 3]!, // z
];
const lod0Dims = pickXyz(arrays[0]!.shape, order);

// Native pyramid from per-level transforms; validated but not yet driving geometry
const pyramidBuild = buildPyramid(
arrays.map((arr, i) => {
const t = ms.datasets[i]?.coordinateTransformations ?? [];
return {
dims: lastThreeAsXyz(arr.shape)!,
scale: lastThreeAsXyz(t.find(x => x.type === 'scale')?.scale),
translation: lastThreeAsXyz(t.find(x => x.type === 'translation')?.translation),
dims: pickXyz(arr.shape, order),
scale: xyz(t.find(x => x.type === 'scale')?.scale),
translation: xyz(t.find(x => x.type === 'translation')?.translation),
};
}),
lastThreeAsXyz(ms.coordinateTransformations?.find(x => x.type === 'scale')?.scale),
lastThreeAsXyz(ms.coordinateTransformations?.find(x => x.type === 'translation')?.translation),
xyz(ms.coordinateTransformations?.find(x => x.type === 'scale')?.scale),
xyz(ms.coordinateTransformations?.find(x => x.type === 'translation')?.translation),
);
const native = this.pyramidPolicy === 'native';
if (native && pyramidBuild.issues.length > 0) {
Expand All @@ -308,9 +299,7 @@ export abstract class BaseZarrProvider implements DataProvider {
const lodParams: LodParams[] = [];
const levels: LodLevel[] = arrays.map((arr, i) => {
const shape = arr.shape;
const actualDimX = shape[shape.length - 1]!;
const actualDimY = shape[shape.length - 2]!;
const actualDimZ = shape[shape.length - 3]!;
const [actualDimX, actualDimY, actualDimZ] = pickXyz(shape, order);

// Native: levels are what the file stores. Legacy: uniform 2:1 virtual dims, resampled in assembly.
const virtualDimX = native ? actualDimX : Math.ceil(lod0Dims[0] / (1 << i));
Expand All @@ -319,19 +308,21 @@ export abstract class BaseZarrProvider implements DataProvider {

const chunkShape = arr.chunks;
const shapePrefixLength = shape.length - 3;
const [csx, csy, csz] = pickXyz(chunkShape, order);
lodParams.push({
scaleX: actualDimX / virtualDimX,
scaleY: actualDimY / virtualDimY,
scaleZ: actualDimZ / virtualDimZ,
actualDimX,
actualDimY,
actualDimZ,
csx: chunkShape[chunkShape.length - 1]!,
csy: chunkShape[chunkShape.length - 2]!,
csz: chunkShape[chunkShape.length - 3]!,
csx,
csy,
csz,
shapePrefixLength,
channelAxisIdx,
channelChunkSize: channelAxisIdx >= 0 && channelAxisIdx < shapePrefixLength ? (chunkShape[channelAxisIdx] ?? 1) : 1,
spatialOrder: order,
});

const brickGrid: [number, number, number] = [
Expand Down
34 changes: 22 additions & 12 deletions src/data/chunk-math.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,16 +93,31 @@ export function estimateDatasetFanoutMultiplier(lodParamsList: LodChunkParams[],
return Math.max(1, ...lodParamsList.map(p => estimateBrickChunkFanout(p, physSize)));
}

/** Positions of x, y and z within the last three array dimensions. */
export type SpatialOrder = [number, number, number];

/** The usual OME-Zarr layout, `…, z, y, x`. webKnossos writes `…, x, y, z`, which is [0, 1, 2]. */
export const ZYX: SpatialOrder = [2, 1, 0];

/** The x, y and z entries of a per-dimension vector (shape, chunks, scale). */
export function pickXyz(v: readonly number[], order: SpatialOrder = ZYX): [number, number, number] {
const b = v.length - 3;
return [v[b + order[0]]!, v[b + order[1]]!, v[b + order[2]]!];
}

/** Zarr chunk coordinates for `channel`; non-spatial prefix axes other than channel are pinned to 0. */
export function chunkCoords(
prefixLength: number, channelAxisIdx: number, channelChunkSize: number,
channel: number, cz: number, cy: number, cx: number,
channel: number, cz: number, cy: number, cx: number, order: SpatialOrder = ZYX,
): number[] {
const prefix = new Array<number>(prefixLength).fill(0);
const coords = new Array<number>(prefixLength + 3).fill(0);
if (channelAxisIdx >= 0 && channelAxisIdx < prefixLength) {
prefix[channelAxisIdx] = Math.floor(channel / channelChunkSize);
coords[channelAxisIdx] = Math.floor(channel / channelChunkSize);
}
return [...prefix, cz, cy, cx];
coords[prefixLength + order[0]] = cx;
coords[prefixLength + order[1]] = cy;
coords[prefixLength + order[2]] = cz;
return coords;
}

/** Flat-index layout of a decoded chunk, valid for C, F and transposed storage orders. */
Expand All @@ -116,17 +131,12 @@ export interface ChunkLayout {

export function chunkLayout(
chunk: { shape: number[]; stride?: number[] },
channelAxisIdx: number, channelChunkSize: number, channel: number,
channelAxisIdx: number, channelChunkSize: number, channel: number, order: SpatialOrder = ZYX,
): ChunkLayout {
const n = chunk.shape.length;
const stride = chunk.stride ?? cOrderStrides(chunk.shape);
const packedIdx = channelAxisIdx >= 0 && channelChunkSize > 1 ? channel % channelChunkSize : 0;
return {
base: packedIdx * (stride[channelAxisIdx] ?? 0),
strideX: stride[n - 1]!,
strideY: stride[n - 2]!,
strideZ: stride[n - 3]!,
};
const [strideX, strideY, strideZ] = pickXyz(stride, order);
return { base: packedIdx * (stride[channelAxisIdx] ?? 0), strideX, strideY, strideZ };
}

function cOrderStrides(shape: number[]): number[] {
Expand Down
6 changes: 3 additions & 3 deletions src/data/local-zarr-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ export class LocalZarrDataProvider extends BaseZarrProvider {
private async assembleBrick(lod: number, bx: number, by: number, bz: number, channelIndex = 0): Promise<{ data: BrickData; stats: BrickStats; rawMin?: number; rawMax?: number }> {
const arr = this.arrays[lod]!;
const params = this.lodParams[lod]!;
const { scaleX, scaleY, scaleZ, actualDimX, actualDimY, actualDimZ, csx, csy, csz, shapePrefixLength, channelAxisIdx, channelChunkSize } = params;
const { scaleX, scaleY, scaleZ, actualDimX, actualDimY, actualDimZ, csx, csy, csz, shapePrefixLength, channelAxisIdx, channelChunkSize, spatialOrder } = params;
const physSize = this.metadata!.physicalBrickSize;
const logicalSize = this.metadata!.brickSize;

Expand Down Expand Up @@ -170,11 +170,11 @@ export class LocalZarrDataProvider extends BaseZarrProvider {
for (let cy = minCy; cy <= maxCy; cy++) {
for (let cx = minCx; cx <= maxCx; cx++) {
const fi = (cz - minCz) * ncy * ncx + (cy - minCy) * ncx + (cx - minCx);
const coords = chunkCoords(shapePrefixLength, channelAxisIdx, channelChunkSize, channelIndex, cz, cy, cx);
const coords = chunkCoords(shapePrefixLength, channelAxisIdx, channelChunkSize, channelIndex, cz, cy, cx, spatialOrder);
chunkFetches.push(
arr.getChunk(coords).then(chunk => {
chunkDataArr[fi] = chunk.data as unknown as ArrayLike<number>;
const layout = chunkLayout(chunk, channelAxisIdx, channelChunkSize, channelIndex);
const layout = chunkLayout(chunk, channelAxisIdx, channelChunkSize, channelIndex, spatialOrder);
chunkBase[fi] = layout.base;
chunkStX[fi] = layout.strideX;
chunkStY[fi] = layout.strideY;
Expand Down
8 changes: 8 additions & 0 deletions src/data/sharded-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,14 @@ interface ShardedBrickEntry {
avg: number;
}

/** A URL holds Kiln's sharded format when it serves a volume.json. Anything else is read
* as OME-Zarr, with or without ".zarr" in the URL (webKnossos layers have none). */
export async function isShardedUrl(url: string): Promise<boolean> {
if (url.includes('.zarr')) return false;
const response = await fetch(`${url.replace(/\/$/, '')}/volume.json`).catch(() => null);
return response?.ok === true;
}

/**
* DataProvider implementation for Kiln's sharded binary format
*/
Expand Down
7 changes: 4 additions & 3 deletions src/data/zarr-chunk-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export interface ZarrWorkerRequest {
shapePrefixLength: number;
channelAxisIdx: number;
channelChunkSize: number;
spatialOrder?: [number, number, number];
}[];
/** Channel index to load (for datasets with a channel axis) */
channelIndex?: number;
Expand Down Expand Up @@ -342,7 +343,7 @@ async function assembleBrick(
): Promise<{ buffer: ArrayBuffer; min: number; max: number; avg: number; rawMin?: number; rawMax?: number; fetchMs: number; assemblyMs: number; chunkHits: number; chunkTotal: number }> {
const arr = arrays[lod]!;
const params = lodParams![lod]!;
const { scaleX, scaleY, scaleZ, actualDimX, actualDimY, actualDimZ, csx, csy, csz, shapePrefixLength, channelAxisIdx, channelChunkSize } = params;
const { scaleX, scaleY, scaleZ, actualDimX, actualDimY, actualDimZ, csx, csy, csz, shapePrefixLength, channelAxisIdx, channelChunkSize, spatialOrder } = params;
const physSize = PHYSICAL_SIZE;
const channelChunk = Math.floor(channelIndex / channelChunkSize);

Expand Down Expand Up @@ -371,7 +372,7 @@ async function assembleBrick(

const setChunkEntry = (fi: number, entry: DecodedChunk) => {
chunkDataArr[fi] = entry.data;
const layout = chunkLayout(entry, channelAxisIdx, channelChunkSize, channelIndex);
const layout = chunkLayout(entry, channelAxisIdx, channelChunkSize, channelIndex, spatialOrder);
chunkBase[fi] = layout.base;
chunkStX[fi] = layout.strideX;
chunkStY[fi] = layout.strideY;
Expand All @@ -393,7 +394,7 @@ async function assembleBrick(
setChunkEntry(fi, cached);
chunkHits++;
} else {
const coords = chunkCoords(shapePrefixLength, channelAxisIdx, channelChunkSize, channelIndex, cz, cy, cx);
const coords = chunkCoords(shapePrefixLength, channelAxisIdx, channelChunkSize, channelIndex, cz, cy, cx, spatialOrder);

if (refcountedAborts) {
// ?p4=1 — see fetchChunkShared.
Expand Down
17 changes: 11 additions & 6 deletions src/data/zarr-validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import { open, root } from 'zarrita';
import { TolerantFetchStore } from './tolerant-fetch-store.js';
import { FileSystemStore } from './filesystem-store.js';
import { SUPPORTED_DTYPES } from './value-types.js';
import { ZYX } from './chunk-math.js';
import type { SpatialOrder } from './chunk-math.js';

interface MultiscalesEntry {
axes?: unknown; // may be string[] (v0.4) or {name,type}[] (v0.5) or absent
Expand Down Expand Up @@ -55,7 +57,7 @@ export function extractMultiscales(attrs: Record<string, unknown>): MultiscalesE
);
}

/** Assembly indexes the last three array dims as [z, y, x]; reject layouts that do not match. */
/** Assembly reads the volume from the last three array dims; reject layouts that do not match. */
function validateSpatialLayout(axes: NormalizedAxis[], axesProvided: boolean, rank: number): string[] {
if (rank < 3) return [`Array has ${rank} dimensions — a volume needs at least 3`];
if (!axesProvided) return [];
Expand All @@ -64,14 +66,17 @@ function validateSpatialLayout(axes: NormalizedAxis[], axesProvided: boolean, ra
const spatial = axes.map((a, i) => ({ ...a, i })).filter(a => a.type === 'space');
if (spatial.length !== 3) return [`Expected 3 spatial axes, found ${spatial.length}`];
if (spatial.some((a, k) => a.i !== rank - 3 + k)) return ['Spatial axes must be the last three array dimensions (…, z, y, x)'];

const names = spatial.map(a => a.name.toLowerCase());
if (names.every(n => 'xyz'.includes(n)) && names.join('') !== 'zyx') {
return [`Spatial axis order "${names.join(', ')}" is not supported (expected z, y, x)`];
}
return [];
}

/** Where x, y and z sit within the last three array dimensions, from the axis names.
* Unnamed or unrecognised spatial axes are taken as the usual z, y, x. */
export function spatialOrder(axes: NormalizedAxis[], rank: number): SpatialOrder {
const names = axes.length === rank ? axes.slice(-3).map(a => a.name.toLowerCase()) : [];
const order = ['x', 'y', 'z'].map(n => names.indexOf(n));
return order.every(i => i >= 0) ? order as SpatialOrder : ZYX;
}

/**
* Validate whether a dataset is supported.
* Returns a list of human-readable reasons; empty array means fully supported.
Expand Down
15 changes: 7 additions & 8 deletions src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import type { ViewParams } from './core/view.js';
import type { DataProvider, VolumeMetadata } from './data/data-provider.js';
import { UnsupportedDatasetError } from './data/data-provider.js';
import { MAX_LOD_LEVELS } from './shaders/uniform-layout.js';
import { ShardedDataProvider } from './data/sharded-provider.js';
import { ShardedDataProvider, isShardedUrl } from './data/sharded-provider.js';
import { ZarrDataProvider } from './data/zarr-provider.js';
import { initializeWithPyramidFallback } from './data/pyramid-fallback.js';

Expand Down Expand Up @@ -195,10 +195,10 @@ export class KilnEngine {
if (isExternalProvider) {
dataProvider = dataset as DataProvider;
} else {
const isZarr = (dataset as string).includes('.zarr');
dataProvider = isZarr
? new ZarrDataProvider(dataset as string)
: new ShardedDataProvider(dataset as string);
const url = dataset as string;
dataProvider = (await isShardedUrl(url))
? new ShardedDataProvider(url)
: new ZarrDataProvider(url);
}

// The level model must reach the provider before workers and brick loads exist.
Expand Down Expand Up @@ -231,9 +231,8 @@ export class KilnEngine {

// Configure worker target format (string-URL providers only)
if (!isExternalProvider) {
const isHttpZarr = (dataset as string).includes('.zarr');
if (isHttpZarr) {
await (dataProvider as ZarrDataProvider).setTargetFormat(
if (dataProvider instanceof ZarrDataProvider) {
await dataProvider.setTargetFormat(
textureFormat as 'r8unorm' | 'r16float',
);
} else if (textureFormat !== 'r16unorm' || sourceBitDepth !== 16) {
Expand Down
6 changes: 6 additions & 0 deletions test/chunk-math.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,4 +279,10 @@ describe('chunkCoords / chunkLayout (storage order and packed channels)', () =>
it('unpacked channel chunks never add a base offset', () => {
expect(chunkLayout({ shape: [1, 4, 8, 16] }, 0, 1, 3).base).toBe(0);
});

it('x, y, z order (webKnossos): coordinates and strides follow the axes', () => {
expect(chunkCoords(1, 0, 1, 0, 4, 5, 6, [0, 1, 2])).toEqual([0, 6, 5, 4]);
const l = chunkLayout({ shape: [1, 16, 8, 4], stride: [1, 1, 16, 128] }, 0, 1, 0, [0, 1, 2]);
expect(l).toEqual({ base: 0, strideX: 1, strideY: 16, strideZ: 128 });
});
});
Loading
Loading