diff --git a/index.html b/index.html index 76b15cb..3db6767 100644 --- a/index.html +++ b/index.html @@ -28,7 +28,7 @@ linear-gradient(45deg, transparent 75%, #ccc 75%), linear-gradient(-45deg, transparent 75%, #ccc 75%); background-size: 20px 20px; - background-position: 0 0, 0 10px, 10px -10px, -10px 0px; + background-position: 0 0, 0 10px, 10px -10px, -10px 0px; } .fadeInOut { animation: fadeInOut 3s ease-in-out infinite; @@ -83,6 +83,8 @@

render()

Look-up tables available to choose

+

Projections for 3D images

+
diff --git a/package-lock.json b/package-lock.json index c4ac38b..ced8a2e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ome-zarr.js", - "version": "0.0.19", + "version": "0.0.20", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "ome-zarr.js", - "version": "0.0.19", + "version": "0.0.20", "license": "BSD", "dependencies": { "zarrita": "^0.7.3" diff --git a/src/image.ts b/src/image.ts index 451807c..f1a8761 100644 --- a/src/image.ts +++ b/src/image.ts @@ -1,13 +1,12 @@ - import * as zarr from "zarrita"; import { ImageAttrs, ImageAttrsV5, OmeAttrs, Multiscale, Omero, Axis, Channel, Color } from "./types/ome"; import { createRgbDataUrl, openArray, openGroup, createOmero } from "./utils"; -import { renderArray } from "./render"; +import { renderArray, Projection } from "./render"; import { generateNeuroglancerStateForOmeZarr, LayerType } from "./helper"; export class NgffImage { /** - * This is the main Image class for the API. It handles both v0.4 and v0.5 formats, + * This is the main Image class for the API. It handles both v0.4 and v0.5 formats, * and provides a consistent interface for accessing the multiscale datasets, * omero metadata, and zarr version. * @@ -96,7 +95,7 @@ export class NgffImage { } attrs = group.attrs as OmeAttrs; } - + // create an instance of the static class... const img = new this(attrs, store); @@ -372,23 +371,68 @@ export class NgffImage { return path; } + /** + * Count the voxels a projection would fetch for a given shape: nz * ny * nx. + */ + private countVoxels(shape: number[]): number { + const names = this.getAxesNames(); + const zi = names.indexOf("z"); + const yi = names.indexOf("y"); + const xi = names.indexOf("x"); + // If any of the axes are missing, treat them as size 1 (e.g. a 2D image has no z axis). + const sz = zi === -1 ? 1 : shape[zi]; + const sy = yi === -1 ? 1 : shape[yi]; + const sx = xi === -1 ? 1 : shape[xi]; + return sz * sy * sx; + } + + + /** + * This is the projection counterpart of getPathForTargetSize() + * Default is limited to 64M voxels (~64MB if uint8) + */ + async getPathForTargetVolume(maxVoxels: number = 64_000_000): Promise { + const names = this.getAxesNames(); + if (!names.includes("z")) { + throw new Error("getPathForTargetVolume requires a 'z' axis; image has none"); + } + // shapes can be empty for v0.1-v0.3 (no coordinateTransformations) + const shapes = await this.calcShapes(); + if (!shapes.length) { + // no scales to reason about - use the smallest level + return this.paths[this.paths.length - 1]; + } + for (let i = 0; i < shapes.length; i++) { + if (this.countVoxels(shapes[i]) <= maxVoxels) { + return this.paths[i]; + } + } + return this.paths[this.paths.length - 1]; + } + async renderArray(options: { // Array can be provided directly, or we will load based on targetSize or arrayPathOrIndex arr?: zarr.Array | string, targetSize?: number, - arrayPathOrIndex?: string | number, + arrayPathOrIndex?: string | number, slices?: { [k: string]: number | [number, number] | undefined }, autoBoost?: boolean, channels?: Channel[], maxSize?: number, signal?: AbortSignal, calcMinMaxForRange?: boolean, + projection?: Projection, // "max" | "mean" | "sum" + projectionAxis?: "z" | "y" | "x", // default "z" + maxVoxels?: number, // budget for the nz*ny*nx fetch } = {} ): Promise<{ data: Uint8ClampedArray; width: number, height: number }> { + const projection = options.projection; + const maxVoxels = options.maxVoxels ?? 64_000_000; + let arr; if (options.arr) { if (typeof options.arr === "string") { @@ -403,13 +447,16 @@ export class NgffImage { path = options.arrayPathOrIndex; } else if (options.targetSize !== undefined) { path = await this.getPathForTargetSize(options.targetSize); + } else if (projection) { + // No level given: for a projection, pick by VOLUME rather than XY size. + path = await this.getPathForTargetVolume(maxVoxels); } else { - throw new Error("Need to specify arr OR targetSize OR arrayPathOrIndex ") + throw new Error("Need to specify arr OR targetSize OR arrayPathOrIndex OR projection"); } arr = await this.openArray(path); } - // TODO: decide when to ignore maxSize? + // TODO: decide when to ignore maxSize? // E.g. if targetSize is specified, use maxSize = 2 x targetSize? let maxSize = options.maxSize ?? 1000; let shape = arr.shape; @@ -424,10 +471,22 @@ export class NgffImage { ); } + // A projection fetches nz * ny * nx voxels, + if (projection) { + const voxels = this.countVoxels(shape); + if (voxels > maxVoxels) { + throw new Error( + `Projection would fetch ${voxels.toLocaleString()} voxels, over the ` + + `'maxVoxels' limit of ${maxVoxels.toLocaleString()}. Use a coarser ` + + `level (arrayPathOrIndex / getPathForTargetVolume), or raise maxVoxels.` + ); + } + } + // let omero = options.omero || this.omero; let slices = options.slices || {}; - // Get slices for each channel - if (slices["z"] == undefined) { + // Get slices for each channel. + if (slices["z"] == undefined && !projection) { slices["z"] = this.omero?.rdefs?.defaultZ; } @@ -458,7 +517,12 @@ export class NgffImage { channels, slices, Boolean(options.autoBoost), - { signal: options.signal, calcMinMaxForRange: Boolean(calcMinMaxForRange) } + { + signal: options.signal, + calcMinMaxForRange: Boolean(calcMinMaxForRange), + projection: options.projection, + projectionAxis: options.projectionAxis, + } ); return { data, width, height }; @@ -468,13 +532,17 @@ export class NgffImage { // Array can be provided directly, or we will load based on targetSize or arrayPathOrIndex arr?: zarr.Array | string, targetSize?: number, - arrayPathOrIndex?: string | number, + arrayPathOrIndex?: string | number, slices?: { [k: string]: number | [number, number] | undefined }, autoBoost?: boolean, channels?: Channel[], maxSize?: number, signal?: AbortSignal, - calcMinMaxForRange?: boolean + calcMinMaxForRange?: boolean, + // Projection: collapse an axis instead of picking a single plane. + projection?: Projection, // "max" | "mean" | "sum" + projectionAxis?: "z" | "y" | "x", // default "z" + maxVoxels?: number, } = {} ): Promise { let { data, width } = await this.renderArray(options); diff --git a/src/render.ts b/src/render.ts index aa3fa3b..ce0dd04 100644 --- a/src/render.ts +++ b/src/render.ts @@ -15,6 +15,98 @@ import { export type Blending = "additive" | "translucent"; +export type Projection = "max" | "mean" | "sum"; + +/** + * Collapse one axis of a chunk, returning a new chunk with that axis removed. + * + * The result is a plain object with `data` / `shape` / `stride`, which is all + * that renderChunk() and getMinMaxValues() need + * The rendering (LUTs, colormaps, inverted, blending, autoBoost) is unchanged. + * + */ +export function projectChunk( + chunk: zarr.Chunk, + axis: number, + how: Projection = "max" +): zarr.Chunk { + const shape = chunk.shape; + const src = chunk.data as any; + + if (axis < 0 || axis >= shape.length) { + throw new Error( + `projectChunk: axis ${axis} out of range for shape [${shape}]` + ); + } + + const n = shape[axis]; + + // C-order strides for the source + const strides: number[] = new Array(shape.length); + let s = 1; + for (let d = shape.length - 1; d >= 0; d--) { + strides[d] = s; + s *= shape[d]; + } + const axisStride = strides[axis]; + + const outShape = shape.filter((_, d) => d !== axis); + const outAxes = shape.map((_, d) => d).filter((d) => d !== axis); + const outLen = outShape.reduce((a, b) => a * b, 1); + + const outStrides: number[] = new Array(outShape.length); + let os = 1; + for (let d = outShape.length - 1; d >= 0; d--) { + outStrides[d] = os; + os *= outShape[d]; + } + + const isBig = typeof src[0] === "bigint"; + + + // "max" and "mean" stay within the source range, so keep the source dtype. + // "sum" can exceed it. Widen to Float32 so accumulated value survives + const OutCtor = how === "sum" ? Float32Array : (src.constructor as any); + const out = new OutCtor(outLen); + // ----------------------------------------------- + + for (let o = 0; o < outLen; o++) { + // decompose the flat output index into coords, map back to the source + // offset with the projected axis at 0 + let rem = o; + let base = 0; + for (let d = 0; d < outShape.length; d++) { + const c = (rem / outStrides[d]) | 0; + rem -= c * outStrides[d]; + base += c * strides[outAxes[d]]; + } + + if (how === "max") { + let m = src[base]; + for (let k = 1; k < n; k++) { + const v = src[base + k * axisStride]; + if (v > m) m = v; + } + out[o] = m; + } else { + let sum = 0; + for (let k = 0; k < n; k++) { + sum += Number(src[base + k * axisStride]); + } + // only mean rounds back into an integer dtype + if (how === "mean") { + const val = sum / n; + out[o] = isBig ? BigInt(Math.round(val)) : val; + } else { + // "sum" -> Float32 output, write the raw accumulated value + out[o] = sum; + } + // --------------------------------------------------------------- + } + } + + return { data: out, shape: outShape, stride: outStrides } as any; +} export function renderChunk( chunk: zarr.Chunk, transferFunc: (value: number) => Color, @@ -130,13 +222,23 @@ export async function renderArray( channels: Channel[] | null | undefined, sliceIndices: { [k: string]: number | [number, number] | undefined }, autoBoost: boolean, - options?: { signal?: AbortSignal, calcMinMaxForRange?: boolean } + options?: { + signal?: AbortSignal; + calcMinMaxForRange?: boolean; + projection?: Projection; + projectionAxis?: string; + } ): Promise<{ data: Uint8ClampedArray; width: number; height: number; }> { - const { signal, calcMinMaxForRange } = options ?? {}; + const { + signal, + calcMinMaxForRange, + projection, + projectionAxis = "z", + } = options ?? {}; signal?.throwIfAborted(); let shape = arr.shape; @@ -192,11 +294,36 @@ export async function renderArray( lutsOrColorMaps = lutsOrColorMaps.filter((_, index) => activeChannelIndices.includes(index)); } + // For a projection, the projected axis must be fetched as a FULL RANGE, not as + // a single index. Override whatever the caller (or omero rdefs) asked for. + let effectiveSlices = sliceIndices; + if (projection) { + if (!axesNames.includes(projectionAxis)) { + throw new Error( + `Cannot project along '${projectionAxis}': image has axes [${axesNames}]` + ); + } + effectiveSlices = { ...sliceIndices }; + // The projected axis must be a full range... + const pDim = axesNames.indexOf(projectionAxis); + effectiveSlices[projectionAxis] = [0, shape[pDim]]; + // ...and so must the two spatial axes we keep. getSlices() gives x/y a full + // range by default, but defaults z to a MIDDLE INDEX - which would collapse + // it to a scalar and leave us with a 2D chunk when projecting along y or x. + for (const name of ["z", "y", "x"]) { + if (name === projectionAxis) continue; + const d = axesNames.indexOf(name); + if (d === -1) continue; + if (effectiveSlices[name] === undefined || !Array.isArray(effectiveSlices[name])) { + effectiveSlices[name] = [0, shape[d]]; + } + } + } let chSlices = getSlices( activeChannelIndices, shape, axesNames, - sliceIndices + effectiveSlices ); // Wait for all chunks to be fetched... @@ -206,7 +333,26 @@ export async function renderArray( let ndChunks = await Promise.all(promises); signal?.throwIfAborted(); - // Use start/end values from 'omero' if available, otherwise calculate min/max + // Collapse the projection axis, turning each 3D chunk into a 2D one. + if (projection) { + const pDim = axesNames.indexOf(projectionAxis); + + // Which index is the projection axis WITHIN the fetched chunk? + let pInChunk = 0; + for (let d = 0; d < pDim; d++) { + const name = axesNames[d]; + const sel = effectiveSlices[name]; + const isScalar = name === "c" || (sel !== undefined && !Array.isArray(sel)); + if (!isScalar) pInChunk++; + } + ndChunks = ndChunks.map((chunk: any) => + projectChunk(chunk, pInChunk, projection) + ); + } + + // Use start/end values from 'omero' if available, otherwise calculate min/max. + // NB: for a projection this is computed on the PROJECTED values, which is what + // we want — the range should match what is actually displayed. let ranges = activeChannelIndices.map( (chIndex: number, i: number): [number, number] | undefined => { if (channels && channels[chIndex]) {