diff --git a/main.ts b/main.ts index b35da4a..18b7f4d 100644 --- a/main.ts +++ b/main.ts @@ -20,14 +20,14 @@ async function main() { // Add event listener to sync viewState as query param. // Debounce to limit how quickly we are pushing to browser history - viewer.on( - "viewStateChange", - debounce((update: vizarr.ViewState) => { - const url = new URL(window.location.href); - url.searchParams.set("viewState", JSON.stringify(update)); - window.history.pushState({}, "", decodeURIComponent(url.href)); - }, 200), - ); + // viewer.on( + // "viewStateChange", + // debounce((update: vizarr.ViewState) => { + // const url = new URL(window.location.href); + // url.searchParams.set("viewState", JSON.stringify(update)); + // window.history.pushState({}, "", decodeURIComponent(url.href)); + // }, 200), + // ); // parse image config // @ts-expect-error - TODO: validate config diff --git a/src/components/LayerController/Content.tsx b/src/components/LayerController/Content.tsx index da2552c..787d6f0 100644 --- a/src/components/LayerController/Content.tsx +++ b/src/components/LayerController/Content.tsx @@ -6,6 +6,7 @@ import AcquisitionController from "./AcquisitionController"; import AddChannelButton from "./AddChannelButton"; import AxisSliders from "./AxisSliders"; import ChannelController from "./ChannelController"; +import Labels from "./Labels"; import OpacitySlider from "./OpacitySlider"; import { useLayerState } from "../../hooks"; @@ -51,6 +52,19 @@ function Content() { <ChannelController channelIndex={i} key={i} /> ))} </Grid> + {layer.labels?.length && ( + <> + <Grid container justifyContent="space-between"> + <Typography variant="caption">labels:</Typography> + </Grid> + <Divider /> + <Grid> + {layer.labels.map((label, i) => ( + <Labels labelIndex={i} key={label.layerProps.id} /> + ))} + </Grid> + </> + )} </Grid> </Details> ); diff --git a/src/components/LayerController/Labels.tsx b/src/components/LayerController/Labels.tsx new file mode 100644 index 0000000..81d6fe8 --- /dev/null +++ b/src/components/LayerController/Labels.tsx @@ -0,0 +1,73 @@ +import { Grid, IconButton, Slider, Typography } from "@material-ui/core"; +import { RadioButtonChecked, RadioButtonUnchecked } from "@material-ui/icons"; +import React from "react"; + +import { useLayerState, useSourceData } from "../../hooks"; +import { assert } from "../../utils"; + +export default function Labels({ labelIndex }: { labelIndex: number }) { + const [source] = useSourceData(); + const [layer, setLayer] = useLayerState(); + assert(source.labels && layer.kind === "multiscale" && layer.labels, "Missing image labels"); + + const handleOpacityChange = (_: unknown, value: number | number[]) => { + setLayer((prev) => { + assert(prev.kind === "multiscale" && prev.labels, "Missing image labels"); + return { + ...prev, + labels: prev.labels.with(labelIndex, { + ...prev.labels[labelIndex], + layerProps: { + ...prev.labels[labelIndex].layerProps, + opacity: value as number, + }, + }), + }; + }); + }; + + const { name } = source.labels[labelIndex]; + const label = layer.labels[labelIndex]; + return ( + <> + <Grid container justifyContent="space-between" wrap="nowrap"> + <div style={{ width: 165, overflow: "hidden", textOverflow: "ellipsis" }}> + <Typography variant="caption" noWrap> + {name} + </Typography> + </div> + </Grid> + <Grid container justifyContent="space-between"> + <Grid item xs={2}> + <IconButton + style={{ backgroundColor: "transparent", padding: 0, zIndex: 2 }} + onClick={() => { + setLayer((prev) => { + assert(prev.kind === "multiscale" && prev.labels, "Missing image labels"); + return { + ...prev, + labels: prev.labels.with(labelIndex, { + ...prev.labels[labelIndex], + on: !prev.labels[labelIndex].on, + }), + }; + }); + }} + > + {label.on ? <RadioButtonChecked /> : <RadioButtonUnchecked />} + </IconButton> + </Grid> + <Grid item xs={10}> + <Slider + value={label.layerProps.opacity} + onChange={handleOpacityChange} + min={0} + max={1} + step={0.01} + style={{ padding: "10px 0px 5px 0px" }} + /> + </Grid> + </Grid> + </> + ); +} diff --git a/src/components/LayerController/index.tsx b/src/components/LayerController/index.tsx index 287f1fd..8e13043 100644 --- a/src/components/LayerController/index.tsx +++ b/src/components/LayerController/index.tsx @@ -31,7 +31,6 @@ const Accordion = withStyles({ function LayerController() { const [sourceInfo] = useSourceData(); const layerAtom = layerFamilyAtom(sourceInfo); - const { name = "" } = sourceInfo; return ( <LayerStateContext.Provider value={layerAtom}> <Accordion defaultExpanded> diff --git a/src/io.ts b/src/io.ts index d0dc52d..fd97096 100644 --- a/src/io.ts +++ b/src/io.ts @@ -3,6 +3,7 @@ import { ZarrPixelSource } from "./ZarrPixelSource"; import { loadOmeMultiscales, loadPlate, loadWell } from "./ome"; import * as utils from "./utils"; +import { DEFAULT_LABEL_OPACITY } from "./layers/label-layer"; import type { BaseLayerProps } from "./layers/viv-layers"; import type { ImageLayerConfig, LayerState, MultichannelConfig, SingleChannelConfig, SourceData } from "./state"; @@ -218,6 +219,21 @@ export function initLayerStateFromSource(source: SourceData & { id: string }): L }; } + let labels = undefined; + if (source.labels && source.labels.length > 0) { + labels = source.labels.map((label, i) => ({ + on: false, + transformSourceSelection: getSourceSelectionTransform(label.loader[0], source.loader[0]), + layerProps: { + id: `${source.id}_${i}`, + loader: label.loader, + modelMatrix: label.modelMatrix, + opacity: DEFAULT_LABEL_OPACITY, + colors: label.colors, + }, + })); + } + return { kind: "multiscale", layerProps: { @@ -225,5 +241,36 @@ export function initLayerStateFromSource(source: SourceData & { id: string }): L loader: source.loader, }, on: true, + labels, + }; +} + +function getSourceSelectionTransform( + labels: { shape: Array<number>; labels: Array<string> }, + source: { shape: Array<number>; labels: Array<string> }, +) { + utils.assert( + source.shape.length === source.labels.length, + `Image source axes and shape are not same rank. Got ${JSON.stringify(source)}`, + ); + utils.assert( + labels.shape.length === labels.labels.length, + `Label axes and shape are not same rank. Got ${JSON.stringify(labels)}`, + ); + utils.assert( + labels.labels.every((label) => source.labels.includes(label)), + `Label axes MUST be a subset of source. Source: ${JSON.stringify(source.labels)} Labels: ${JSON.stringify(labels.labels)}`, + ); + // Identify labels that should always map to 0, regardless of the source selection. + const excludeFromTransformedSelection = new Set( + utils + .zip(labels.labels, labels.shape) + .filter(([_, size]) => size === 1) + .map(([name, _]) => name), + ); + return (sourceSelection: Array<number>): Array<number> => { + return labels.labels.map((name) => + excludeFromTransformedSelection.has(name) ? 0 : sourceSelection[source.labels.indexOf(name)], + ); }; } diff --git a/src/layers/label-layer.ts b/src/layers/label-layer.ts new file mode 100644 index 0000000..daccab8 --- /dev/null +++ b/src/layers/label-layer.ts @@ -0,0 +1,321 @@ +import { BitmapLayer, TileLayer } from "deck.gl"; +import * as utils from "../utils"; + +import type { Layer, UpdateParameters } from "deck.gl"; +import { type Matrix4, clamp } from "math.gl"; +import type * as zarr from "zarrita"; +import type { ZarrPixelSource } from "../ZarrPixelSource"; + +type Texture = ReturnType<BitmapLayer["context"]["device"]["createTexture"]>; + +export const DEFAULT_LABEL_OPACITY = 0.5; + +export type OmeColor = Readonly<{ + labelValue: number; + rgba: readonly [r: number, g: number, b: number, a: number]; +}>; + +export interface LabelLayerProps { + id: string; + loader: Array<ZarrPixelSource>; + selection: Array<number>; + opacity: number; + modelMatrix: Matrix4; + colors?: ReadonlyArray<OmeColor>; +} + +/** + * @see {@href https://ngff.openmicroscopy.org/0.5/index.html#labels-md} + * + * The pixels of the label images MUST be integer data types, i.e. one of [uint8, int8, uint16, int16, uint32, int32, uint64, int64]. + */ +type LabelDataType = zarr.Uint8 | zarr.Int8 | zarr.Uint16 | zarr.Int16 | zarr.Uint32 | zarr.Int32; +// TODO: bigint data types are supported by the spec but not by Viv's data loader. +// | zarr.Uint64 +// | zarr.Int64; + +/** The decoded tile data from a OME-NGFF label source */ +type LabelPixelData = { + data: zarr.TypedArray<LabelDataType>; + width: number; + height: number; +}; + +export class LabelLayer extends TileLayer<LabelPixelData, LabelLayerProps> { + static layerName = "VizarrLabelLayer"; + // @ts-expect-error - only way to extend the base state type + state!: { colorTexture: Texture } & TileLayer["state"]; + + constructor(props: LabelLayerProps) { + const resolutions = props.loader; + const dimensions = { + height: resolutions[0].shape.at(-2), + width: resolutions[0].shape.at(-1), + }; + utils.assert(dimensions.width && dimensions.height); + const tileSize = getTileSizeForResolutions(resolutions); + super({ + id: `labels-${props.id}`, + extent: [0, 0, dimensions.width, dimensions.height], + tileSize: tileSize, + minZoom: Math.round(-(resolutions.length - 1)), + opacity: props.opacity, + maxZoom: 0, + modelMatrix: props.modelMatrix, + colors: props.colors, + zoomOffset: Math.round(Math.log2(props.modelMatrix ? props.modelMatrix.getScale()[0] : 1)), + updateTriggers: { + getTileData: [props.loader, props.selection], + }, + async getTileData({ index, signal }) { + const { x, y, z } = index; + const resolution = resolutions[Math.round(-z)]; + const request = { x, y, signal, selection: props.selection }; + let { data, width, height } = await resolution.getTile(request); + utils.assert( + !(data instanceof Float32Array) && !(data instanceof Float64Array), + `The pixels of labels MUST be integer data types, got ${JSON.stringify(resolution.dtype)}`, + ); + return { data, width, height }; + }, + }); + } + + renderSubLayers( + params: TileLayer["props"] & { + data: LabelPixelData; + tile: { + index: { x: number; y: number; z: number }; + boundingBox: [min: Array<number>, max: Array<number>]; + }; + }, + ): Layer { + const { tile, data, ...props } = params; + const [[left, bottom], [right, top]] = tile.boundingBox; + utils.assert(props.extent, "missing extent"); + const [_x0, _y0, width, height] = props.extent; + return new GrayscaleBitmapLayer({ + id: `tile-${tile.index.x}.${tile.index.y}.${tile.index.z}-${props.id}`, + pixelData: data, + opacity: props.opacity, + modelMatrix: props.modelMatrix, + colorTexture: this.state.colorTexture, + bounds: [clamp(left, 0, width), clamp(top, 0, height), clamp(right, 0, width), clamp(bottom, 0, height)], + // For underlying class + image: new ImageData(data.width, data.height), + pickable: false, + }); + } + + updateState({ props, oldProps, changeFlags, ...rest }: UpdateParameters<this>): void { + super.updateState({ props, oldProps, changeFlags, ...rest }); + // we make the colorTexture on this layer so we can share it amoung all the sublayers + if (props.colors !== oldProps.colors || !this.state.colorTexture) { + this.state.colorTexture?.destroy(); + const colorTexture = createColorTexture({ + source: props.colors, + maxTextureDimension2D: this.context.device.limits.maxTextureDimension2D, + }); + this.setState({ + colorTexture: this.context.device.createTexture({ + width: colorTexture.width, + height: colorTexture.height, + data: colorTexture.data, + dimension: "2d", + mipmaps: false, + sampler: { + minFilter: "nearest", + magFilter: "nearest", + addressModeU: "clamp-to-edge", + addressModeV: "clamp-to-edge", + }, + format: "rgba8unorm", + }), + }); + } + } +} + +export class GrayscaleBitmapLayer extends BitmapLayer<{ pixelData: LabelPixelData; colorTexture: Texture }> { + static layerName = "VizarrGrayscaleBitmapLayer"; + // @ts-expect-error - only way to extend the base state type + state!: { texture: Texture } & BitmapLayer["state"]; + + getShaders() { + const sampler = ( + { + Uint8Array: "usampler2D", + Uint16Array: "usampler2D", + Uint32Array: "usampler2D", + Int8Array: "isampler2D", + Int16Array: "isampler2D", + Int32Array: "isampler2D", + } as const + )[typedArrayConstructorName(this.props.pixelData.data)]; + // replace the builtin fragment shader with our own + return { + ...super.getShaders(), + fs: `\ +#version 300 es +#define SHADER_NAME grayscale-bitmap-layer-fragment-shader + +precision highp float; +precision highp int; +precision highp ${sampler}; + +uniform ${sampler} grayscaleTexture; +uniform sampler2D colorTexture; +uniform float colorTextureWidth; +uniform float colorTextureHeight; +uniform float opacity; + +in vec2 vTexCoord; +out vec4 fragColor; + +void main() { + int index = int(texture(grayscaleTexture, vTexCoord).r); + float x = (mod(float(index), colorTextureWidth) + 0.5) / colorTextureWidth; + float y = (floor(float(index) / colorTextureWidth) + 0.5) / colorTextureHeight; + vec2 uv = vec2(x, y); + vec3 color = texture(colorTexture, uv).rgb; + fragColor = vec4(color, ((index > 0) ? 1.0 : 0.0) * opacity); +} +`, + }; + } + + updateState({ props, oldProps, changeFlags, ...rest }: UpdateParameters<this>): void { + super.updateState({ props, oldProps, changeFlags, ...rest }); + if (props.pixelData !== oldProps.pixelData) { + this.state.texture?.destroy(); + this.setState({ + texture: this.context.device.createTexture({ + width: props.pixelData.width, + height: props.pixelData.height, + data: props.pixelData.data, + dimension: "2d", + mipmaps: false, + sampler: { + minFilter: "nearest", + magFilter: "nearest", + addressModeU: "clamp-to-edge", + addressModeV: "clamp-to-edge", + }, + format: ( + { + Uint8Array: "r8uint", + Uint16Array: "r16uint", + Uint32Array: "r32uint", + Int8Array: "r8sint", + Int16Array: "r16sint", + Int32Array: "r32sint", + } as const + )[typedArrayConstructorName(props.pixelData.data)], + }), + }); + } + } + + draw(opts: unknown) { + const { model, texture } = this.state; + const { colorTexture } = this.props; + + if (model && texture && colorTexture) { + model.setUniforms({ + colorTextureWidth: colorTexture.width, + colorTextureHeight: colorTexture.height, + }); + model.setBindings({ + grayscaleTexture: texture, + colorTexture: colorTexture, + }); + } + super.draw(opts); + } +} + +function getTileSizeForResolutions(resolutions: Array<ZarrPixelSource>): number { + const tileSize = resolutions[0].tileSize; + utils.assert( + resolutions.every((resolution) => resolution.tileSize === tileSize), + "resolutions must all have the same tile size", + ); + return tileSize; +} + +const SEEN_LUTS = new WeakSet<ReadonlyArray<OmeColor>>(); + +/** + * Creates a color lookup table (LUT) as a 2D texture. + * + * @param options.source - The source lookup table. + * @param options.maxTextureDimension2D - The maximum texture dimension size. + */ +function createColorTexture(options: { + source?: ReadonlyArray<OmeColor>; + maxTextureDimension2D: number; +}) { + const { source, maxTextureDimension2D } = options; + const fallback = { + data: DEFAULT_COLOR_TEXTURE, + width: DEFAULT_COLOR_TEXTURE.length / 4, + height: 1, + }; + + if (!source) { + return fallback; + } + + // pack the colors into a 2D texture + const size = Math.max(...source.map((e) => e.labelValue)) + 1; + const width = Math.min(size, maxTextureDimension2D); + const height = Math.ceil(size / width); + + if (width > maxTextureDimension2D || height > maxTextureDimension2D) { + if (!SEEN_LUTS.has(source)) { + console.warn("[vizarr] Skipping color palette from OME-NGFF `image-label` source: max texture dimension limit."); + SEEN_LUTS.add(source); + } + return fallback; + } + + const data = new Uint8Array(width * height * 4); + for (const { labelValue, rgba } of source) { + const x = labelValue % width; + const y = Math.floor(labelValue / width); + const texIndex = (y * width + x) * 4; + data[texIndex] = rgba[0]; + data[texIndex + 1] = rgba[1]; + data[texIndex + 2] = rgba[2]; + data[texIndex + 3] = rgba[3]; + } + + return { data, width, height }; +} + +// From Vitessce https://github.com/vitessce/vitessce/blob/03c6d5d843640982e984a0e309f1ba1807085128/packages/utils/other-utils/src/components.ts#L50-L67 +const DEFAULT_COLOR_TEXTURE = Uint8Array.from( + [ + [0, 73, 73], + [0, 146, 146], + [255, 109, 182], + [255, 182, 219], + [73, 0, 146], + [0, 109, 219], + [182, 109, 255], + [109, 182, 255], + [182, 219, 255], + [146, 0, 0], + [146, 72, 0], + [219, 109, 0], + [36, 255, 36], + [255, 255, 109], + [255, 255, 255], + ].flatMap((color) => [...color, 255]), +); + +function typedArrayConstructorName(arr: zarr.TypedArray<LabelDataType>) { + const ArrayType = arr.constructor as zarr.TypedArrayConstructor<LabelDataType>; + const name = ArrayType.name as `${Capitalize<LabelDataType>}Array`; + return name; +} diff --git a/src/ome.ts b/src/ome.ts index 4d5396b..026e285 100644 --- a/src/ome.ts +++ b/src/ome.ts @@ -1,8 +1,9 @@ import pMap from "p-map"; import * as zarr from "zarrita"; -import type { ImageLayerConfig, OnClickData, SourceData } from "./state"; +import type { ImageLabels, ImageLayerConfig, OnClickData, SourceData } from "./state"; import { ZarrPixelSource } from "./ZarrPixelSource"; +import type { OmeColor } from "./layers/label-layer"; import * as utils from "./utils"; export async function loadWell( @@ -253,8 +254,8 @@ export async function loadOmeMultiscales( const axis_labels = utils.getNgffAxisLabels(axes); const meta = parseOmeroMeta(attrs.omero, axes); const tileSize = utils.guessTileSize(data[0]); - const loader = data.map((arr) => new ZarrPixelSource(arr, { labels: axis_labels, tileSize })); + const labels = await resolveOmeLabelsFromMultiscales(grp); return { loader: loader, axis_labels, @@ -268,9 +269,39 @@ export async function loadOmeMultiscales( }, ...meta, name: meta.name ?? name, + labels: await Promise.all(labels.map((name) => loadOmeImageLabel(grp.resolve("labels"), name))), }; } +async function loadOmeImageLabel(root: zarr.Location<zarr.Readable>, name: string): Promise<ImageLabels[number]> { + const grp = await zarr.open(root.resolve(name), { kind: "group" }); + const attrs = utils.resolveAttrs(grp.attrs); + utils.assert(utils.isOmeImageLabel(attrs), "No 'image-label' metadata."); + const data = await utils.loadMultiscales(grp, attrs.multiscales); + const baseResolution = data.at(0); + utils.assert(baseResolution, "No base resolution found for multiscale labels."); + const tileSize = utils.guessTileSize(baseResolution); + const axes = utils.getNgffAxes(attrs.multiscales); + const labels = utils.getNgffAxisLabels(axes); + const colors = (attrs["image-label"].colors ?? []).map((d) => ({ labelValue: d["label-value"], rgba: d.rgba })); + return { + name, + modelMatrix: utils.coordinateTransformationsToMatrix(attrs.multiscales), + loader: data.map((arr) => new ZarrPixelSource(arr, { labels, tileSize })), + colors: colors.length > 0 ? colors : undefined, + }; +} + +async function resolveOmeLabelsFromMultiscales(grp: zarr.Group<zarr.Readable>): Promise<Array<string>> { + return zarr + .open(grp.resolve("labels"), { kind: "group" }) + .then(({ attrs }) => (attrs.labels ?? []) as Array<string>) + .catch((e) => { + utils.rethrowUnless(e, zarr.NodeNotFoundError); + return []; + }); +} + type Meta = { name: string | undefined; names: Array<string>; diff --git a/src/state.ts b/src/state.ts index 6430d41..69c87e7 100644 --- a/src/state.ts +++ b/src/state.ts @@ -1,4 +1,4 @@ -import { atom } from "jotai"; +import { type Atom, atom } from "jotai"; import { atomFamily, splitAtom, waitForAll } from "jotai/utils"; import { RedirectError, rethrowUnless } from "./utils"; @@ -11,6 +11,7 @@ import type { ZarrPixelSource } from "./ZarrPixelSource"; import { initLayerStateFromSource } from "./io"; import { GridLayer, type GridLayerProps, type GridLoader } from "./layers/grid-layer"; +import { LabelLayer, type LabelLayerProps, type OmeColor } from "./layers/label-layer"; import { ImageLayer, type ImageLayerProps, @@ -54,6 +55,13 @@ export type OnClickData = Record<string, unknown> & { gridCoord?: { row: number; column: number }; }; +export type ImageLabels = Array<{ + name: string; + loader: ZarrPixelSource[]; + modelMatrix: Matrix4; + colors?: ReadonlyArray<OmeColor>; +}>; + export type SourceData = { loader: ZarrPixelSource[]; loaders?: GridLoader[]; // for OME plates @@ -75,23 +83,9 @@ export type SourceData = { model_matrix: Matrix4; axis_labels: string[]; onClick?: (e: OnClickData) => void; + labels?: ImageLabels; }; -export type VivProps = ConstructorParameters<typeof MultiscaleImageLayer>[0]; - -export interface BaseLayerProps { - id: string; - contrastLimits: VivProps["contrastLimits"]; - colors: [r: number, g: number, b: number][]; - channelsVisible: NonNullable<VivProps["channelsVisible"]>; - opacity: NonNullable<VivProps["opacity"]>; - colormap: string; // TODO: more precise - selections: number[][]; - modelMatrix: Matrix4; - contrastLimitsRange: [min: number, max: number][]; - onClick?: (e: OnClickData) => void; -} - type LayerType = "image" | "multiscale" | "grid"; type LayerPropsMap = { image: ImageLayerProps; @@ -103,6 +97,11 @@ export type LayerState<T extends LayerType = LayerType> = { kind: T; layerProps: LayerPropsMap[T]; on: boolean; + labels?: Array<{ + layerProps: Omit<LabelLayerProps, "selection">; + on: boolean; + transformSourceSelection: (sourceSelection: Array<number>) => Array<number>; + }>; }; type WithId<T> = T & { id: string }; @@ -146,7 +145,11 @@ export const layerFamilyAtom: AtomFamily<WithId<SourceData>, PrimitiveAtom<WithI (a, b) => a.id === b.id, ); -export type VizarrLayer = Layer<MultiscaleImageLayerProps> | Layer<ImageLayerProps> | Layer<GridLayerProps>; +export type VizarrLayer = + | Layer<MultiscaleImageLayerProps> + | Layer<ImageLayerProps> + | Layer<GridLayerProps> + | Layer<LabelLayerProps>; const LayerConstructors = { image: ImageLayer, @@ -154,15 +157,41 @@ const LayerConstructors = { grid: GridLayer, } as const; +const layerInstanceFamily = atomFamily((a: Atom<LayerState>) => + atom((get) => { + const { on, layerProps, kind } = get(a); + if (!on) { + return null; + } + const Layer = LayerConstructors[kind]; + // @ts-expect-error - TS can't resolve that Layer & layerProps bound together + return new Layer(layerProps) as VizarrLayer; + }), +); + +const imageLabelsIstanceFamily = atomFamily((a: Atom<LayerState>) => + atom((get) => { + const { on, labels, layerProps } = get(a); + if (!on || !labels) { + return []; + } + return labels.map((label) => + label.on + ? new LabelLayer({ + ...label.layerProps, + selection: label.transformSourceSelection(layerProps.selections[0]), + }) + : null, + ); + }), +); + export const layerAtoms = atom((get) => { - const atoms = get(sourceInfoAtomAtoms); - if (atoms.length === 0) { - return []; + const layerAtoms = []; + for (const sourceAtom of get(sourceInfoAtomAtoms)) { + const layerStateAtom = layerFamilyAtom(get(sourceAtom)); + layerAtoms.push(layerInstanceFamily(layerStateAtom)); + layerAtoms.push(imageLabelsIstanceFamily(layerStateAtom)); } - const layersState = get(waitForAll(atoms.map((a) => layerFamilyAtom(get(a))))); - return layersState.map((layer) => { - const Layer = LayerConstructors[layer.kind]; - // @ts-expect-error - TS can't resolve that Layer & layerProps bound together - return layer.on ? new Layer(layer.layerProps) : null; - }) as Array<VizarrLayer | null>; + return get(waitForAll(layerAtoms)).flat(); }); diff --git a/src/types.ts b/src/types.ts index 9c800b2..59e959a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -85,9 +85,27 @@ declare namespace Ome { version: Version; } + interface ImageLabel { + version: Version; + colors?: Array<{ + "label-value": number; + rgba: [r: number, g: number, b: number, a: number]; + }>; + properties?: Array<{ + "label-value": number; + "omero:roiId": number; + "omero:shapeId": number; + }>; + /** Location of source image */ + source: { + image: string; + }; + } + type Attrs = | { multiscales: Multiscale[] } | { omero: Omero; multiscales: Multiscale[] } | { plate: Plate } - | { well: Well }; + | { well: Well } + | { "image-label": ImageLabel; multiscales: Multiscale[] }; } diff --git a/src/utils.ts b/src/utils.ts index ed64afe..fb51719 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -3,6 +3,7 @@ import * as zarr from "zarrita"; import type { ZarrPixelSource } from "./ZarrPixelSource"; import type { GridLayerProps } from "./layers/grid-layer"; +import type { LabelLayerProps } from "./layers/label-layer"; import type { ImageLayerProps, MultiscaleImageLayerProps } from "./layers/viv-layers"; import { lru } from "./lru-store"; import type { ViewState } from "./state"; @@ -456,6 +457,12 @@ export function isOmeWell(attrs: zarr.Attributes): attrs is { well: Ome.Well } { return "well" in attrs; } +export function isOmeImageLabel( + attrs: zarr.Attributes, +): attrs is { "image-label": Ome.ImageLabel; multiscales: Ome.Multiscale[] } { + return "image-label" in attrs && isMultiscales(attrs); +} + export function isOmeMultiscales(attrs: zarr.Attributes): attrs is { omero: Ome.Omero; multiscales: Ome.Multiscale[] } { return "omero" in attrs && isMultiscales(attrs); } @@ -508,12 +515,14 @@ export function rethrowUnless<E extends ReadonlyArray<new (...args: any[]) => Er } export function isGridLayerProps( - props: GridLayerProps | ImageLayerProps | MultiscaleImageLayerProps, + props: GridLayerProps | ImageLayerProps | MultiscaleImageLayerProps | LabelLayerProps, ): props is GridLayerProps { return "loaders" in props && "rows" in props && "columns" in props; } -export function resolveLoaderFromLayerProps(layerProps: GridLayerProps | ImageLayerProps | MultiscaleImageLayerProps) { +export function resolveLoaderFromLayerProps( + layerProps: GridLayerProps | ImageLayerProps | MultiscaleImageLayerProps | LabelLayerProps, +) { return isGridLayerProps(layerProps) ? layerProps.loaders[0].loader : layerProps.loader; } @@ -568,3 +577,17 @@ export function coordinateTransformationsToMatrix(multiscales: Array<Ome.Multisc return mat; } + +/** + * Builds N-tuples of elements from the given N arrays with matching indices, + * stopping when the smallest array's end is reached. + */ +export function zip<T extends unknown[]>(...arrays: { [K in keyof T]: ReadonlyArray<T[K]> }): T[] { + const minLength = arrays.reduce((minLength, arr) => Math.min(arr.length, minLength), Number.POSITIVE_INFINITY); + const result: T[] = new Array(minLength); + for (let i = 0; i < minLength; i += 1) { + const arr = arrays.map((it) => it[i]); + result[i] = arr as T; + } + return result; +} diff --git a/vite.config.js b/vite.config.js index f2deb49..409e82e 100644 --- a/vite.config.js +++ b/vite.config.js @@ -3,8 +3,6 @@ import * as path from "node:path"; import react from "@vitejs/plugin-react"; import { defineConfig } from "vite"; -const source = process.env.VIZARR_DATA || "https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.1/6001253.zarr"; - /** * Writes a new entry point that exports contents of an existing chunk. * @param {string} entryPointName - Name of the new entry point @@ -44,6 +42,6 @@ export default defineConfig({ }, }, server: { - open: `?source=${source}`, + open: `?source=${process.env.VIZARR_DATA || "https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.1/6001253.zarr"}`, }, });