diff --git a/packages/omezarr/src/index.ts b/packages/omezarr/src/index.ts index e6d5535e..26c0fef6 100644 --- a/packages/omezarr/src/index.ts +++ b/packages/omezarr/src/index.ts @@ -1,6 +1,10 @@ export { buildOmeZarrSliceRenderer, buildAsyncOmezarrRenderer, + makeRGBColorChannels, + makeRenderSettings, + toZarrDataSpecifier, + type Decoder, type VoxelTileImage, type RenderSettings, type RenderSettingsChannel, @@ -25,37 +29,33 @@ export { type OmeZarrOmeroChannelWindow, type OmeZarrOmeroChannel, type OmeZarrOmero, - type OmeZarrAttrs, - type OmeZarrArrayMetadata, + type OmeZarrData, + OmeZarrArrayTransform, OmeZarrAxisSchema, OmeZarrCoordinateTranslationSchema, OmeZarrCoordinateScaleSchema, OmeZarrCoordinateTransformSchema, OmeZarrDatasetSchema, + OmeZarrGroupTransform, OmeZarrMultiscaleSchema, OmeZarrOmeroChannelWindowSchema, OmeZarrOmeroChannelSchema, OmeZarrOmeroSchema, - OmeZarrAttrsSchema, - OmeZarrMetadata, - type DehydratedOmeZarrArray, - type DehydratedOmeZarrMetadata, } from './zarr/types'; export { - loadMetadata, - loadZarrArrayFile, - loadZarrAttrsFile, - pickBestScale, - loadSlice, - sizeInUnits, - sizeInVoxels, - nextSliceStep, - planeSizeInVoxels, - type ZarrRequest, -} from './zarr/loading'; + CachedOmeZarrConnection, + type OmeZarrConnection, + type ZarrDataSpecifier, +} from './zarr/connection'; -export { type CancelRequest, type ZarrSliceRequest, makeOmeZarrSliceLoaderWorker } from './sliceview/worker-loader'; -export { decoderFactory } from './zarr/cache-lower'; +export { + type OmeZarrLevelSpecifier, + type OmeZarrMultiscaleSpecifier, + OmeZarrMetadata, +} from './zarr/metadata'; +export { OmeZarrLevel } from './zarr/level'; +export {} from './zarr/omezarr-transforms'; +// export { decoderFactory } from './zarr/cache-lower'; export { setupFetchDataWorker } from './zarr/cached-loading/fetch-data.worker-loader'; export { type TransferrableRequestInit, diff --git a/packages/omezarr/src/sliceview/loader.test-data.ts b/packages/omezarr/src/sliceview/loader.test-data.ts new file mode 100644 index 00000000..5ef2ac18 --- /dev/null +++ b/packages/omezarr/src/sliceview/loader.test-data.ts @@ -0,0 +1,253 @@ +import { OmeZarrMetadata } from '../zarr/metadata'; + +export const exampleOmeZarr: OmeZarrMetadata = new OmeZarrMetadata( + new URL('https://allen-genetic-tools.s3.us-west-2.amazonaws.com/tissuecyte/1263343692/ome-zarr/'), + { + nodeType: 'group', + zarrFormat: 3, + attributes: { + multiscales: [ + { + name: 'test', + version: '2', + axes: [ + { + name: 'c', + type: 'channel', + unit: 'millimeter', + }, + { + name: 'z', + type: 'space', + unit: 'millimeter', + }, + { + name: 'y', + type: 'space', + unit: 'millimeter', + }, + { + name: 'x', + type: 'space', + unit: 'millimeter', + }, + ], + datasets: [ + { + coordinateTransformations: [ + { + scale: [1, 0.1, 0.00035, 0.00035], + type: 'scale', + }, + { + translation: [0, 0, 0, 0], + type: 'translation', + }, + ], + path: '0', + }, + { + coordinateTransformations: [ + { + scale: [1, 0.1, 0.0007, 0.0007], + type: 'scale', + }, + { + translation: [0, 0, 0.00035, 0.00035], + type: 'translation', + }, + ], + path: '1', + }, + { + coordinateTransformations: [ + { + scale: [1, 0.1, 0.0014, 0.0014], + type: 'scale', + }, + { + translation: [0, 0, 0.00105, 0.00105], + type: 'translation', + }, + ], + path: '2', + }, + { + coordinateTransformations: [ + { + scale: [1, 0.1, 0.0028, 0.0028], + type: 'scale', + }, + { + translation: [0, 0, 0.00245, 0.00245], + type: 'translation', + }, + ], + path: '3', + }, + { + coordinateTransformations: [ + { + scale: [1, 0.1, 0.0056, 0.0056], + type: 'scale', + }, + { + translation: [0, 0, 0.00525, 0.00525], + type: 'translation', + }, + ], + path: '4', + }, + { + coordinateTransformations: [ + { + scale: [1, 0.1, 0.0112, 0.0112], + type: 'scale', + }, + { + translation: [0, 0, 0.01085, 0.01085], + type: 'translation', + }, + ], + path: '5', + }, + { + coordinateTransformations: [ + { + scale: [1, 0.1, 0.0224, 0.0224], + type: 'scale', + }, + { + translation: [0, 0, 0.02205, 0.02205], + type: 'translation', + }, + ], + path: '6', + }, + { + coordinateTransformations: [ + { + scale: [1, 0.1, 0.0448, 0.0448], + type: 'scale', + }, + { + translation: [0, 0, 0.044449999999999996, 0.044449999999999996], + type: 'translation', + }, + ], + path: '7', + }, + { + coordinateTransformations: [ + { + scale: [1, 0.1, 0.0896, 0.0896], + type: 'scale', + }, + { + translation: [0, 0, 0.08925, 0.08925], + type: 'translation', + }, + ], + path: '8', + }, + { + coordinateTransformations: [ + { + scale: [1, 0.1, 0.1792, 0.1792], + type: 'scale', + }, + { + translation: [0, 0, 0.17885, 0.17885], + type: 'translation', + }, + ], + path: '9', + }, + ], + }, + ], + }, + }, + { + '/0': { + nodeType: 'array', + path: '0', + chunkShape: [], + dataType: 'float32', + shape: [3, 142, 29998, 39998], + attributes: {}, + }, + '/1': { + nodeType: 'array', + path: '1', + chunkShape: [], + dataType: 'float32', + shape: [3, 142, 14999, 19999], + attributes: {}, + }, + '/2': { + nodeType: 'array', + path: '2', + chunkShape: [], + dataType: 'float32', + shape: [3, 142, 7499, 9999], + attributes: {}, + }, + '/3': { + nodeType: 'array', + path: '2', + chunkShape: [], + dataType: 'float32', + shape: [3, 142, 3749, 4999], + attributes: {}, + }, + '/4': { + nodeType: 'array', + path: '2', + chunkShape: [], + dataType: 'float32', + shape: [3, 142, 1874, 2499], + attributes: {}, + }, + '/5': { + nodeType: 'array', + path: '2', + chunkShape: [], + dataType: 'float32', + shape: [3, 142, 937, 1249], + attributes: {}, + }, + '/6': { + nodeType: 'array', + path: '2', + chunkShape: [], + dataType: 'float32', + shape: [3, 142, 468, 624], + attributes: {}, + }, + '/7': { + nodeType: 'array', + path: '2', + chunkShape: [], + dataType: 'float32', + shape: [3, 142, 234, 312], + attributes: {}, + }, + '/8': { + nodeType: 'array', + path: '2', + chunkShape: [], + dataType: 'float32', + shape: [3, 142, 117, 156], + attributes: {}, + }, + '/9': { + nodeType: 'array', + path: '2', + chunkShape: [], + dataType: 'float32', + shape: [3, 142, 58, 78], + attributes: {}, + }, + }, +); diff --git a/packages/omezarr/src/sliceview/loader.test.ts b/packages/omezarr/src/sliceview/loader.test.ts index 8c712b40..435d3065 100644 --- a/packages/omezarr/src/sliceview/loader.test.ts +++ b/packages/omezarr/src/sliceview/loader.test.ts @@ -3,217 +3,8 @@ import { Box2D, PLANE_XY, PLANE_YZ, type box2D } from '@alleninstitute/vis-geometry'; import { describe, expect, it } from 'vitest'; -import { OmeZarrMetadata } from '../zarr/types'; -import { sizeInUnits } from '../zarr/loading'; import { getVisibleTiles } from './loader'; -const exampleOmeZarr: OmeZarrMetadata = new OmeZarrMetadata( - 'https://allen-genetic-tools.s3.us-west-2.amazonaws.com/tissuecyte/1263343692/ome-zarr/', - { - multiscales: [ - { - name: 'test', - version: '2', - axes: [ - { - name: 'c', - type: 'channel', - unit: 'millimeter', - }, - { - name: 'z', - type: 'space', - unit: 'millimeter', - }, - { - name: 'y', - type: 'space', - unit: 'millimeter', - }, - { - name: 'x', - type: 'space', - unit: 'millimeter', - }, - ], - datasets: [ - { - coordinateTransformations: [ - { - scale: [1, 0.1, 0.00035, 0.00035], - type: 'scale', - }, - { - translation: [0, 0, 0, 0], - type: 'translation', - }, - ], - path: '0', - }, - { - coordinateTransformations: [ - { - scale: [1, 0.1, 0.0007, 0.0007], - type: 'scale', - }, - { - translation: [0, 0, 0.00035, 0.00035], - type: 'translation', - }, - ], - path: '1', - }, - { - coordinateTransformations: [ - { - scale: [1, 0.1, 0.0014, 0.0014], - type: 'scale', - }, - { - translation: [0, 0, 0.00105, 0.00105], - type: 'translation', - }, - ], - path: '2', - }, - { - coordinateTransformations: [ - { - scale: [1, 0.1, 0.0028, 0.0028], - type: 'scale', - }, - { - translation: [0, 0, 0.00245, 0.00245], - type: 'translation', - }, - ], - path: '3', - }, - { - coordinateTransformations: [ - { - scale: [1, 0.1, 0.0056, 0.0056], - type: 'scale', - }, - { - translation: [0, 0, 0.00525, 0.00525], - type: 'translation', - }, - ], - path: '4', - }, - { - coordinateTransformations: [ - { - scale: [1, 0.1, 0.0112, 0.0112], - type: 'scale', - }, - { - translation: [0, 0, 0.01085, 0.01085], - type: 'translation', - }, - ], - path: '5', - }, - { - coordinateTransformations: [ - { - scale: [1, 0.1, 0.0224, 0.0224], - type: 'scale', - }, - { - translation: [0, 0, 0.02205, 0.02205], - type: 'translation', - }, - ], - path: '6', - }, - { - coordinateTransformations: [ - { - scale: [1, 0.1, 0.0448, 0.0448], - type: 'scale', - }, - { - translation: [0, 0, 0.044449999999999996, 0.044449999999999996], - type: 'translation', - }, - ], - path: '7', - }, - { - coordinateTransformations: [ - { - scale: [1, 0.1, 0.0896, 0.0896], - type: 'scale', - }, - { - translation: [0, 0, 0.08925, 0.08925], - type: 'translation', - }, - ], - path: '8', - }, - { - coordinateTransformations: [ - { - scale: [1, 0.1, 0.1792, 0.1792], - type: 'scale', - }, - { - translation: [0, 0, 0.17885, 0.17885], - type: 'translation', - }, - ], - path: '9', - }, - ], - }, - ], - }, - [ - { - path: '0', - shape: [3, 142, 29998, 39998], - }, - { - path: '1', - shape: [3, 142, 14999, 19999], - }, - { - path: '2', - shape: [3, 142, 7499, 9999], - }, - { - path: '3', - shape: [3, 142, 3749, 4999], - }, - { - path: '4', - shape: [3, 142, 1874, 2499], - }, - { - path: '5', - shape: [3, 142, 937, 1249], - }, - { - path: '6', - shape: [3, 142, 468, 624], - }, - { - path: '7', - shape: [3, 142, 234, 312], - }, - { - path: '8', - shape: [3, 142, 117, 156], - }, - { - path: '9', - shape: [3, 142, 58, 78], - }, - ], - 2, -); +import { exampleOmeZarr } from './loader.test-data'; describe('omezarr basic tiled loading', () => { describe('getVisibleTiles', () => { @@ -227,7 +18,7 @@ describe('omezarr basic tiled loading', () => { // this is a basic regression test: we had a bug which would result in // tiles from the image being larger than the image itself (they would be the given tile size) expect(visible.length).toBe(1); - const expectedLayer = exampleOmeZarr.getShapedDataset(9, 0); + const expectedLayer = exampleOmeZarr.getLevel({ multiscale: { index: 0 }, index: 9 }); expect(expectedLayer).toBeDefined(); if (expectedLayer === undefined) { throw new Error('invalid test condition: passed expect.toBeDefined while still undefined'); @@ -239,9 +30,9 @@ describe('omezarr basic tiled loading', () => { }); describe('sizeInUnits', () => { it('respects scale transformations', () => { - const axes = exampleOmeZarr.attrs.multiscales[0].axes; - const firstDataset = exampleOmeZarr.getFirstShapedDataset(0); - const lastDataset = exampleOmeZarr.getLastShapedDataset(0); + // const axes = exampleOmeZarr.attrs.multiscales[0].axes; + const firstDataset = exampleOmeZarr.getLevel({ multiscale: { index: 0 }, index: 0 }); + const lastDataset = exampleOmeZarr.getLevel({ multiscale: { index: 0 }, index: 9 }); expect(firstDataset).toBeDefined(); expect(lastDataset).toBeDefined(); @@ -249,11 +40,11 @@ describe('omezarr basic tiled loading', () => { throw new Error('invalid test condition: passed expect.toBeDefined while still undefined'); } - const layer9xy = sizeInUnits(PLANE_XY, axes, lastDataset); - const layer0xy = sizeInUnits(PLANE_XY, axes, firstDataset); + const layer9xy = lastDataset.sizeInUnits(PLANE_XY); + const layer0xy = firstDataset.sizeInUnits(PLANE_XY); - const layer9yz = sizeInUnits(PLANE_YZ, axes, lastDataset); - const layer0yz = sizeInUnits(PLANE_YZ, axes, firstDataset); + const layer9yz = lastDataset.sizeInUnits(PLANE_YZ); + const layer0yz = firstDataset.sizeInUnits(PLANE_YZ); // we're looking at the highest resolution and lowest resolution layers. // I think in an ideal world, we'd expect each layer to end up having an exactly equal size, // however I think that isnt happening here for floating-point reasons - so the small differences are acceptable. diff --git a/packages/omezarr/src/sliceview/loader.ts b/packages/omezarr/src/sliceview/loader.ts index 30f40179..6a88b823 100644 --- a/packages/omezarr/src/sliceview/loader.ts +++ b/packages/omezarr/src/sliceview/loader.ts @@ -1,23 +1,22 @@ import { Box2D, - type CartesianPlane, - Vec2, type box2D, + type CartesianPlane, type OrthogonalCartesianAxes, + Vec2, type vec2, } from '@alleninstitute/vis-geometry'; -import type { Chunk } from 'zarrita'; -import type { ZarrRequest } from '../zarr/loading'; -import { indexOfRelativeSlice, loadSlice, pickBestScale, planeSizeInVoxels, sizeInUnits } from '../zarr/loading'; -import type { VoxelTileImage } from './slice-renderer'; -import type { OmeZarrMetadata, OmeZarrShapedDataset } from '../zarr/types'; +import type { Decoder, VoxelTileImage } from './slice-renderer'; +import type { OmeZarrMetadata } from '../zarr/metadata'; +import type { OmeZarrLevel } from '../zarr/level'; +import type { OmeZarrConnection, ZarritaOmeZarrData, ZarrDataSpecifier } from '../zarr/connection'; export type VoxelTile = { plane: OrthogonalCartesianAxes; // the plane in which the tile sits realBounds: box2D; // in the space given by the axis descriptions of the omezarr dataset bounds: box2D; // in voxels, in the plane orthoVal: number; // the value along the orthogonal axis to the plane (e.g. the slice index along Z relative to an XY plane) - level: OmeZarrShapedDataset; // the index in the resolution pyramid of the omezarr dataset + level: OmeZarrLevel; // the index in the resolution pyramid of the omezarr dataset }; /** @@ -51,12 +50,11 @@ function getVisibleTilesInLayer( }, plane: CartesianPlane, orthoVal: number, - dataset: OmeZarrMetadata, tileSize: number, - level: OmeZarrShapedDataset, + level: OmeZarrLevel, ) { - const size = planeSizeInVoxels(plane, dataset.attrs.multiscales[0].axes, level); - const realSize = sizeInUnits(plane, dataset.attrs.multiscales[0].axes, level); + const size = level.planeSizeInVoxels(plane); + const realSize = level.sizeInUnits(plane); if (!size || !realSize) return []; const scale = Vec2.div(realSize, size); const vxlToReal = (vxl: box2D) => Box2D.scale(vxl, scale); @@ -95,16 +93,16 @@ export function getVisibleTiles( }, plane: CartesianPlane, planeLocation: number, - metadata: OmeZarrMetadata, + fileset: OmeZarrMetadata, tileSize: number, ): VoxelTile[] { // TODO (someday) open the array, look at its chunks, use that size for the size of the tiles I request! - const layer = pickBestScale(metadata, plane, camera.view, camera.screenSize); + const level = fileset.pickBestScale(plane, camera.view, camera.screenSize); // figure out the index of the slice - const sliceIndex = indexOfRelativeSlice(layer, metadata.attrs.multiscales[0].axes, planeLocation, plane.ortho); - return getVisibleTilesInLayer(camera, plane, sliceIndex, metadata, tileSize, layer); + const sliceIndex = level.indexOfRelativeSlice(planeLocation, plane.ortho); + return getVisibleTilesInLayer(camera, plane, sliceIndex, tileSize, level); } /** @@ -112,17 +110,16 @@ export function getVisibleTiles( * Note that omezarr decoding can be slow - consider wrapping this function in a web-worker (or a pool of them) * to improve performance (note also that the webworker message passing will need to itself be wrapped in promises) * @param metadata an omezarr object - * @param r a slice request @see getSlice + * @param req a request for a specific slice of data @see getSlice * @param layerIndex an index into the LOD pyramid of the given ZarrDataset. * @returns the requested voxel information from the given layer of the given dataset. */ -export const defaultDecoder = ( - metadata: OmeZarrMetadata, - r: ZarrRequest, - level: OmeZarrShapedDataset, +export const defaultDecoder: Decoder = async ( + connection: OmeZarrConnection, + req: ZarrDataSpecifier, signal?: AbortSignal, ): Promise => { - return loadSlice(metadata, r, level, signal).then((result: { shape: number[]; buffer: Chunk<'float32'> }) => { + return connection.loadData(req, signal).then((result: ZarritaOmeZarrData<'float32'>) => { const { shape, buffer } = result; return { shape, data: new Float32Array(buffer.data) }; }); diff --git a/packages/omezarr/src/sliceview/slice-renderer.ts b/packages/omezarr/src/sliceview/slice-renderer.ts index c6846ac3..595d18c9 100644 --- a/packages/omezarr/src/sliceview/slice-renderer.ts +++ b/packages/omezarr/src/sliceview/slice-renderer.ts @@ -1,25 +1,26 @@ +import { + buildAsyncRenderer, + type CachedTexture, + logger, + type QueueOptions, + type ReglCacheEntry, + type Renderer, +} from '@alleninstitute/vis-core'; import { Box2D, + type box2D, type CartesianPlane, type Interval, - type box2D, + intervalToVec2, + PLANE_XY, type vec2, type vec3, - intervalToVec2, } from '@alleninstitute/vis-geometry'; -import { - type CachedTexture, - type QueueOptions, - type ReglCacheEntry, - type Renderer, - buildAsyncRenderer, - logger, -} from '@alleninstitute/vis-core'; import type REGL from 'regl'; -import type { ZarrRequest } from '../zarr/loading'; +import type { OmeZarrMetadata } from '../zarr/metadata'; +import type { OmeZarrConnection, ZarrDataSpecifier } from '../zarr/connection'; import { type VoxelTile, getVisibleTiles } from './loader'; import { buildTileRenderCommand } from './tile-renderer'; -import type { OmeZarrMetadata, OmeZarrShapedDataset } from '../zarr/types'; export type RenderSettingsChannel = { index: number; @@ -46,14 +47,49 @@ export type RenderSettings = { // a slice of a volume (as voxels suitable for display) export type VoxelTileImage = { data: Float32Array; - shape: number[]; + shape: readonly number[]; }; type ImageChannels = { [channelKey: string]: CachedTexture; }; -function toZarrRequest(tile: VoxelTile, channel: number): ZarrRequest { +export const makeRGBColorChannels = (gamut: Interval): RenderSettingsChannels => ({ + R: { rgb: [1.0, 0, 0], gamut, index: 0 }, + G: { rgb: [0, 1.0, 0], gamut, index: 1 }, + B: { rgb: [0, 0, 1.0], gamut, index: 2 }, +}); + +export function makeRenderSettings( + fileset: OmeZarrMetadata, + screenSize: vec2, + view: box2D, + param: number, + defaultGamut: Interval, + tileSize = 256, + plane = PLANE_XY, +) { + const omezarrChannels = fileset.getColorChannels().reduce((acc, val, index) => { + acc[val.label ?? `${index}`] = { + rgb: val.rgb, + gamut: val.range, + index, + }; + return acc; + }, {} as RenderSettingsChannels); + + const fallbackChannels = makeRGBColorChannels(defaultGamut); + + return { + camera: { screenSize, view }, + planeLocation: param, + plane, + tileSize, + channels: Object.keys(omezarrChannels).length > 0 ? omezarrChannels : fallbackChannels, + }; +} + +export function toZarrDataSpecifier(tile: VoxelTile, channel: number): ZarrDataSpecifier { const { plane, orthoVal, bounds } = tile; const { minCorner: min, maxCorner: max } = bounds; const u = { min: min[0], max: max[0] }; @@ -61,27 +97,36 @@ function toZarrRequest(tile: VoxelTile, channel: number): ZarrRequest { switch (plane) { case 'xy': return { - x: u, - y: v, - t: 0, - c: channel, - z: orthoVal, + level: tile.level, + slice: { + x: u, + y: v, + t: 0, + c: channel, + z: orthoVal, + }, }; case 'xz': return { - x: u, - z: v, - t: 0, - c: channel, - y: orthoVal, + level: tile.level, + slice: { + x: u, + z: v, + t: 0, + c: channel, + y: orthoVal, + }, }; case 'yz': return { - y: u, - z: v, - t: 0, - c: channel, - x: orthoVal, + level: tile.level, + slice: { + y: u, + z: v, + t: 0, + c: channel, + x: orthoVal, + }, }; } } @@ -97,10 +142,9 @@ function isPrepared(cacheData: Record): cach return keys.every((key) => cacheData[key]?.type === 'texture'); } -type Decoder = ( - dataset: OmeZarrMetadata, - req: ZarrRequest, - level: OmeZarrShapedDataset, +export type Decoder = ( + connection: OmeZarrConnection, + req: ZarrDataSpecifier, signal?: AbortSignal, ) => Promise; @@ -113,6 +157,7 @@ const DEFAULT_NUM_CHANNELS = 3; export function buildOmeZarrSliceRenderer( regl: REGL.Regl, + connection: OmeZarrConnection, decoder: Decoder, options?: OmeZarrSliceRendererOptions | undefined, ): Renderer { @@ -146,11 +191,11 @@ export function buildOmeZarrSliceRenderer( const { camera, plane, planeLocation, tileSize } = settings; return getVisibleTiles(camera, plane, planeLocation, dataset, tileSize); }, - fetchItemContent: (item, dataset, settings): Record Promise> => { + fetchItemContent: (item, _dataset, settings): Record Promise> => { const contents: Record Promise> = {}; for (const key in settings.channels) { contents[key] = (signal) => - decoder(dataset, toZarrRequest(item, settings.channels[key].index), item.level, signal).then( + decoder(connection, toZarrDataSpecifier(item, settings.channels[key].index), signal).then( sliceAsTexture, ); } @@ -163,10 +208,10 @@ export function buildOmeZarrSliceRenderer( gamut: intervalToVec2(settings.channels[key].gamut), rgb: settings.channels[key].rgb, })); - const layers = dataset.getNumLayers(); + const levels = dataset.getNumLevels(); // per the spec, the highest resolution layer should be first // we want that layer most in front, so: - const depth = item.level.datasetIndex / layers; + const depth = item.level.datasetIndex / levels; const { camera } = settings; cmd({ channels, @@ -179,6 +224,11 @@ export function buildOmeZarrSliceRenderer( }; } -export function buildAsyncOmezarrRenderer(regl: REGL.Regl, decoder: Decoder, options?: OmeZarrSliceRendererOptions) { - return buildAsyncRenderer(buildOmeZarrSliceRenderer(regl, decoder, options), options?.queueOptions); +export function buildAsyncOmezarrRenderer( + regl: REGL.Regl, + connection: OmeZarrConnection, + decoder: Decoder, + options?: OmeZarrSliceRendererOptions, +) { + return buildAsyncRenderer(buildOmeZarrSliceRenderer(regl, connection, decoder, options), options?.queueOptions); } diff --git a/packages/omezarr/src/sliceview/worker-loader.ts b/packages/omezarr/src/sliceview/worker-loader.ts deleted file mode 100644 index 3aad6db4..00000000 --- a/packages/omezarr/src/sliceview/worker-loader.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { logger } from '@alleninstitute/vis-core'; -import type { Chunk, Float32 } from 'zarrita'; -import { type DehydratedOmeZarrMetadata, OmeZarrMetadata, type OmeZarrShapedDataset } from '../zarr/types'; -import { loadSlice, type ZarrRequest } from '../zarr/loading'; -// a helper for making a web-worker loader -export type ZarrSliceRequest = { - id: string; - type: 'ZarrSliceRequest'; - metadata: DehydratedOmeZarrMetadata; - req: ZarrRequest; - level: OmeZarrShapedDataset; -}; - -export type CancelRequest = { - type: 'cancel'; - id: string; -}; - -function isSliceRequest(payload: unknown): payload is ZarrSliceRequest { - return typeof payload === 'object' && payload !== null && 'type' in payload && payload.type === 'ZarrSliceRequest'; -} -function isCancellationRequest(payload: unknown): payload is CancelRequest { - return typeof payload === 'object' && payload !== null && 'type' in payload && payload.type === 'cancel'; -} -/** - * a helper function to initialize a message handler on a webworker, - * which responds to requests for omezarr slices: - * messages must be of type MessageEvent - * @see ZarrSliceRequest - * @see CancelRequest - * @param ctx the "global this" aka self object on a webworker context. - */ -export function makeOmeZarrSliceLoaderWorker(ctx: typeof self) { - const cancelers: Record = {}; - - ctx.onmessage = (msg: MessageEvent) => { - const { data } = msg; - try { - if (isSliceRequest(data)) { - const { metadata: dehydratedMetadata, req, level, id } = data; - const abort = new AbortController(); - cancelers[id] = abort; - OmeZarrMetadata.rehydrate(dehydratedMetadata).then((metadata) => { - loadSlice(metadata, req, level, abort.signal) - .then((result: { shape: number[]; buffer: Chunk }) => { - const { shape, buffer } = result; - const data = new Float32Array(buffer.data); - ctx.postMessage({ type: 'slice', id, shape, data }, { transfer: [data.buffer] }); - }) - .catch((err) => { - if ( - !( - err === 'cancelled' || - (typeof err === 'object' && - (('name' in err && err.name === 'AbortError') || - ('code' in err && err.code === 20))) - ) - ) { - logger.error('error in slice fetch worker: ', err); - } // else ignore it - }); - }); - } else if (isCancellationRequest(data)) { - const { id } = data; - cancelers[id]?.abort('cancelled'); - } else { - logger.error('web-worker slice-fetcher recieved incomprehensible message: ', msg); - } - } catch (err) { - logger.error('OME-Zarr fetch onmessage error', err); - } - }; -} diff --git a/packages/omezarr/src/zarr/cache-lower.ts b/packages/omezarr/src/zarr/cache-lower.ts index 431b59a3..c518babb 100644 --- a/packages/omezarr/src/zarr/cache-lower.ts +++ b/packages/omezarr/src/zarr/cache-lower.ts @@ -1,52 +1,55 @@ -import type { OmeZarrShapedDataset, OmeZarrMetadata } from './types'; -import { type ZarrRequest, buildQuery, loadZarrArrayFileFromStore } from './loading'; -import { VisZarrDataError } from '../errors'; -import * as zarr from 'zarrita'; -import { logger, type WorkerInit } from '@alleninstitute/vis-core'; -import { ZarrFetchStore, type CachingMultithreadedFetchStoreOptions } from './cached-loading/store'; +// NOTE: this approach won't be necessary -export function decoderFactory(url: string, workerModule: WorkerInit, options?: CachingMultithreadedFetchStoreOptions) { - const store = new ZarrFetchStore(url, workerModule, options); - const getSlice = async ( - metadata: OmeZarrMetadata, - req: ZarrRequest, - level: OmeZarrShapedDataset, - signal?: AbortSignal, - ) => { - if (metadata.url !== url) { - throw new Error( - 'trying to use a decoder from a different store - we cant do that yet, although we could build a map of url->stores here if we wanted later - TODO', - ); - } - const scene = metadata.attrs.multiscales[0]; - const { axes } = scene; - if (!level) { - const message = 'invalid Zarr data: no datasets found'; - logger.error(message); - throw new VisZarrDataError(message); - } - const arr = metadata.arrays.find((a) => a.path === level.path); - if (!arr) { - const message = `cannot load slice: no array found for path [${level.path}]`; - logger.error(message); - throw new VisZarrDataError(message); - } - const { raw } = await loadZarrArrayFileFromStore(store, arr.path, metadata.zarrVersion, false); - const result = await zarr.get(raw, buildQuery(req, axes, level.shape), { opts: { signal: signal ?? null } }); - if (typeof result === 'number') { - throw new Error('oh noes, slice came back all weird'); - } - const { shape, data } = result; - if (typeof data !== 'object' || !('buffer' in data)) { - throw new Error('slice was malformed, array-buffer response required'); - } - // biome-ignore lint/suspicious/noExplicitAny: - return { shape, data: new Float32Array(data as any) }; - }; - return { - decoder: getSlice, - destroy: () => { - store.destroy(); - }, - }; -} +// import type { OmeZarrShapedDataset, OmeZarrMetadata } from './types'; +// import { type ZarrRequest, buildQuery, loadZarrArrayFileFromStore } from './loading'; +// import { VisZarrDataError } from '../errors'; +// import * as zarr from 'zarrita'; +// import { logger, type WorkerInit } from '@alleninstitute/vis-core'; +// import { ZarrFetchStore, type CachingMultithreadedFetchStoreOptions } from './cached-loading/store'; + +// export function decoderFactory(url: string, workerModule: WorkerInit, options?: CachingMultithreadedFetchStoreOptions) { +// const store = new ZarrFetchStore(url, workerModule, options); +// const getSlice = async ( +// metadata: OmeZarrMetadata, +// req: ZarrRequest, +// level: OmeZarrShapedDataset, +// signal?: AbortSignal, +// ) => { + +// if (metadata.url !== url) { +// throw new Error( +// 'trying to use a decoder from a different store - we cant do that yet, although we could build a map of url->stores here if we wanted later - TODO', +// ); +// } +// const scene = metadata.attrs.multiscales[0]; +// const { axes } = scene; +// if (!level) { +// const message = 'invalid Zarr data: no datasets found'; +// logger.error(message); +// throw new VisZarrDataError(message); +// } +// const arr = metadata.arrays.find((a) => a.path === level.path); +// if (!arr) { +// const message = `cannot load slice: no array found for path [${level.path}]`; +// logger.error(message); +// throw new VisZarrDataError(message); +// } +// const { raw } = await loadZarrArrayFileFromStore(store, arr.path, metadata.zarrVersion, false); +// const result = await zarr.get(raw, buildQuery(req, axes, level.shape), { opts: { signal: signal ?? null } }); +// if (typeof result === 'number') { +// throw new Error('oh noes, slice came back all weird'); +// } +// const { shape, data } = result; +// if (typeof data !== 'object' || !('buffer' in data)) { +// throw new Error('slice was malformed, array-buffer response required'); +// } +// // biome-ignore lint/suspicious/noExplicitAny: +// return { shape, data: new Float32Array(data as any) }; +// }; +// return { +// decoder: getSlice, +// destroy: () => { +// store.destroy(); +// }, +// }; +// } diff --git a/packages/omezarr/src/zarr/cached-loading/fetch-slice.interface.ts b/packages/omezarr/src/zarr/cached-loading/fetch-slice.interface.ts deleted file mode 100644 index 8774a551..00000000 --- a/packages/omezarr/src/zarr/cached-loading/fetch-slice.interface.ts +++ /dev/null @@ -1,88 +0,0 @@ -import type { AbsolutePath, RangeQuery } from 'zarrita'; -import z from 'zod'; - -export type TransferrableRequestInit = Omit & { - body?: string; - headers?: [string, string][] | Record; -}; - -export type FetchSliceMessagePayload = { - rootUrl: string; - path: AbsolutePath; - range: RangeQuery; - options?: TransferrableRequestInit | undefined; -}; - -export const FETCH_SLICE_MESSAGE_TYPE = 'fetch-slice' as const; -export const FETCH_SLICE_RESPONSE_MESSAGE_TYPE = 'fetch-slice-response' as const; -export const CANCEL_MESSAGE_TYPE = 'cancel' as const; - -export type FetchSliceMessage = { - type: typeof FETCH_SLICE_MESSAGE_TYPE; - id: string; - payload: FetchSliceMessagePayload; -}; - -export type FetchSliceResponseMessage = { - type: typeof FETCH_SLICE_RESPONSE_MESSAGE_TYPE; - id: string; - payload: ArrayBufferLike | undefined; -}; - -export type CancelMessage = { - type: typeof CANCEL_MESSAGE_TYPE; - id: string; -}; - -const FetchSliceMessagePayloadSchema = z.object({ - rootUrl: z.string().nonempty(), - path: z.string().nonempty().startsWith('/'), - range: z.union([ - z.object({ - offset: z.number(), - length: z.number(), - }), - z.object({ - suffixLength: z.number(), - }), - ]), - options: z.unknown().optional(), // being "lazy" for now; doing a full schema for this could be complex and fragile -}); - -const FetchSliceMessageSchema = z.object({ - type: z.literal(FETCH_SLICE_MESSAGE_TYPE), - id: z.string().nonempty(), - payload: FetchSliceMessagePayloadSchema, -}); - -const FetchSliceResponseMessageSchema = z.object({ - type: z.literal(FETCH_SLICE_RESPONSE_MESSAGE_TYPE), - id: z.string().nonempty(), - payload: z.unknown().optional(), // unclear if it's feasible/wise to define a schema for this one -}); - -const CancelMessageSchema = z.object({ - type: z.literal(CANCEL_MESSAGE_TYPE), - id: z.string().nonempty(), -}); - -export function isFetchSliceMessage(val: unknown): val is FetchSliceMessage { - return FetchSliceMessageSchema.safeParse(val).success; -} - -export function isFetchSliceResponseMessage(val: unknown): val is FetchSliceResponseMessage { - return FetchSliceResponseMessageSchema.safeParse(val).success; -} - -export function isCancelMessage(val: unknown): val is CancelMessage { - return CancelMessageSchema.safeParse(val).success; -} - -export function isCancellationError(err: unknown): boolean { - return ( - err === 'cancelled' || - (typeof err === 'object' && - err !== null && - (('name' in err && err.name === 'AbortError') || ('code' in err && err.code === 20))) - ); -} diff --git a/packages/omezarr/src/zarr/cached-loading/fetch-slice.worker.ts b/packages/omezarr/src/zarr/cached-loading/fetch-slice.worker.ts deleted file mode 100644 index bd845a25..00000000 --- a/packages/omezarr/src/zarr/cached-loading/fetch-slice.worker.ts +++ /dev/null @@ -1,76 +0,0 @@ -// a web-worker which fetches slices of data, decodes them, and returns the result as a flat float32 array, using transferables - -import { HEARTBEAT_RATE_MS, logger } from '@alleninstitute/vis-core'; -import { type AbsolutePath, FetchStore, type RangeQuery } from 'zarrita'; -import type { CancelMessage, FetchSliceMessage, TransferrableRequestInit } from './fetch-slice.interface'; -import { isCancellationError, isCancelMessage, isFetchSliceMessage } from './fetch-slice.interface'; - -async function fetchSlice( - rootUrl: string, - path: AbsolutePath, - range: RangeQuery, - options?: TransferrableRequestInit | undefined, - abortController?: AbortController | undefined, -): Promise { - const store = new FetchStore(rootUrl); - return store.getRange(path, range, { ...(options || {}), signal: abortController?.signal }); -} - -const abortControllers: Record = {}; - -const handleFetchSlice = (message: FetchSliceMessage) => { - const { id, payload } = message; - const { rootUrl, path, range, options } = payload; - - if (id in abortControllers) { - logger.error('cannot send message: request ID already in use'); - return; - } - - const abort = new AbortController(); - abortControllers[id] = abort; - - fetchSlice(rootUrl, path, range, options, abort) - .then((result: Uint8Array | undefined) => { - const buffer = result?.buffer; - const options = buffer !== undefined ? { transfer: [buffer] } : {}; - self.postMessage( - { - type: 'fetch-slice-response', - id, - payload: result?.buffer, - }, - { ...options }, - ); - }) - .catch((e) => { - if (!isCancellationError(e)) { - logger.error('error in slice fetch worker: ', e); - } - // can ignore if it is a cancellation error - }); -}; - -const handleCancel = (message: CancelMessage) => { - const { id } = message; - const abortController = abortControllers[id]; - if (!abortController) { - logger.warn('attempted to cancel a non-existent request'); - } else { - abortController.abort('cancelled'); - } -}; - -self.setInterval(() => { - self.postMessage({ type: 'heartbeat' }); -}, HEARTBEAT_RATE_MS); - -self.onmessage = async (e: MessageEvent) => { - const { data: message } = e; - - if (isFetchSliceMessage(message)) { - handleFetchSlice(message); - } else if (isCancelMessage(message)) { - handleCancel(message); - } -}; diff --git a/packages/omezarr/src/zarr/connection.ts b/packages/omezarr/src/zarr/connection.ts new file mode 100644 index 00000000..00e36247 --- /dev/null +++ b/packages/omezarr/src/zarr/connection.ts @@ -0,0 +1,230 @@ +import { getResourceUrl, logger, type WebResource, type WorkerInit } from '@alleninstitute/vis-core'; +import { limit, type Interval } from '@alleninstitute/vis-geometry'; +import * as zarr from 'zarrita'; +import { z } from 'zod'; +import { ZarrFetchStore } from './cached-loading/store'; +import { OmeZarrMetadata, type OmeZarrLevelSpecifier } from './metadata'; +import { + type OmeZarrArray, + OmeZarrArrayTransform, + type OmeZarrAxis, + type OmeZarrData, + type OmeZarrGroup, + OmeZarrGroupTransform, + type ZarrDimension, +} from './types'; +import { VisZarrDataError } from '../errors'; + +// Documentation for OME-Zarr datasets (from which these types are built) +// can be found here: +// - top-level metadata: https://ngff.openmicroscopy.org/latest/#multiscale-md +// - array metadata: v2: https://zarr-specs.readthedocs.io/en/latest/v2/v2.0.html#arrays +// v3: https://zarr-specs.readthedocs.io/en/latest/v3/core/v3.0.html#array-metadata + +type OmeZarrGroupLoadSet = { + raw: zarr.Group; + transformed: OmeZarrGroup; +}; + +type OmeZarrArrayLoadSet = { + raw: zarr.Array; + transformed: OmeZarrArray; +}; + +const loadGroup = async (location: zarr.Location): Promise> => { + const group = await zarr.open(location, { kind: 'group' }); + try { + return { raw: group, transformed: OmeZarrGroupTransform.parse(group.attrs) }; + } catch (e) { + if (e instanceof z.ZodError) { + logger.error('could not load Zarr group metadata: parsing failed'); + } + throw e; + } +}; + +const loadArray = async (location: zarr.Location): Promise> => { + const array = await zarr.open(location, { kind: 'array' }); + try { + return { raw: array, transformed: OmeZarrArrayTransform.parse(array) }; + } catch (e) { + if (e instanceof z.ZodError) { + logger.error('could not load Zarr array metadata: parsing failed'); + } + throw e; + } +}; + +export type ZarrDimensionSelection = number | Interval | null; + +export type ZarrSlice = Record; + +type ZarritaSelection = (number | zarr.Slice | null)[]; + +export interface ZarritaOmeZarrData extends OmeZarrData> { + buffer: zarr.Chunk; +} + +export type ZarrDataSpecifier = { + level: OmeZarrLevelSpecifier; + slice: ZarrSlice; +}; + +const buildSliceQuery = ( + r: Readonly, + axes: readonly OmeZarrAxis[], + shape: readonly number[], +): ZarritaSelection => { + const ordered = axes.map((a) => r[a.name as ZarrDimension]); + + if (ordered.some((a) => a === undefined)) { + throw new VisZarrDataError('requested slice does not match specified dimensions of OME-Zarr dataset'); + } + + return ordered.map((d, i) => { + const bounds = { min: 0, max: shape[i] }; + if (d === null) { + return d; + } + if (typeof d === 'number') { + return limit(bounds, d); + } + return zarr.slice(limit(bounds, d.min), limit(bounds, d.max)); + }); +}; + +export type LoadOmeZarrMetadataOptions = { + numWorkers?: number | undefined; +}; + +export interface OmeZarrConnection { + url: URL; + metadata: OmeZarrMetadata | null; + loadMetadata: () => Promise; + loadData: ( + spec: ZarrDataSpecifier, + signal?: AbortSignal | undefined, + ) => Promise>; + close: () => void; +} + +export class CachedOmeZarrConnection implements OmeZarrConnection { + #res: WebResource; + #store: ZarrFetchStore; + #root: zarr.Location; + #zarritaGroups: Map>; + #zarritaArrays: Map>; + #metadata: OmeZarrMetadata | null; + #loadingMetadataPromise: Promise | null; + + constructor(res: WebResource, workerInit: WorkerInit, options?: LoadOmeZarrMetadataOptions | undefined) { + this.#res = res; + const url = getResourceUrl(res); + this.#store = new ZarrFetchStore(url, workerInit, { numWorkers: options?.numWorkers }); + this.#root = zarr.root(this.#store); + this.#zarritaGroups = new Map>(); + this.#zarritaArrays = new Map>(); + this.#metadata = null; + this.#loadingMetadataPromise = null; + } + + get url(): URL { + return new URL(getResourceUrl(this.#res)); + } + + get metadata(): OmeZarrMetadata | null { + return this.#metadata; + } + + async #loadOmeZarrFileset(): Promise { + const { raw: rawRootGroup, transformed: rootGroup } = await loadGroup(this.#root); + this.#zarritaGroups.set('/', rawRootGroup); + const arrayResults = await Promise.all( + rootGroup.attributes.multiscales + .map((multiscale) => + multiscale.datasets?.map(async (dataset) => { + return await loadArray(this.#root.resolve(dataset.path)); + }), + ) + .reduce((prev, curr) => prev.concat(curr)) + .filter((arr) => arr !== undefined), + ); + + const arrays: Record = {}; + arrayResults.forEach(({ raw, transformed }) => { + arrays[transformed.path] = transformed; + this.#zarritaArrays.set(raw.path, raw); + }); + + this.#metadata = new OmeZarrMetadata(this.url, rootGroup, arrays); + return this.#metadata; + } + + loadMetadata(): Promise { + if (this.#loadingMetadataPromise !== null) { + return this.#loadingMetadataPromise; + } + + this.#loadingMetadataPromise = this.#loadOmeZarrFileset(); + return this.#loadingMetadataPromise; + } + + /** + * Loads and returns any voxel data from this OME-Zarr that matches the requested segment of the overall fileset, + * as defined by a multiscale, a dataset, and a chunk slice. + * @see https://zarrita.dev/slicing.html for more details on how slicing is handled. + * @param spec The data request, specifying the coordinates within the OME-Zarr's data from which to source voxel data + * @param signal An optional abort signal with which to cancel this request if necessary + * @returns the loaded slice data + */ + async loadData( + spec: ZarrDataSpecifier, + signal?: AbortSignal | undefined, + ): Promise> { + if (this.#metadata === null) { + throw new VisZarrDataError( + 'cannot load array data until metadata has been loaded; please ensure loadMetadata() has completed first', + ); + } + const axes = this.#metadata.getMultiscale(spec.level.multiscale)?.axes; + if (axes === undefined) { + const message = 'invalid Zarr data: no axes found for specified multiscale'; + logger.error(message); + throw new VisZarrDataError(message); + } + const path = this.#metadata.getLevel(spec.level)?.path; + if (path === undefined) { + const message = 'invalid Zarr data: no path found for specified dataset'; + logger.error(message); + throw new VisZarrDataError(message); + } + const arr = this.#zarritaArrays.get(`/${path}`); + if (arr === undefined) { + const message = 'invalid Zarr data: no array found for specified dataset'; + logger.error(message); + throw new VisZarrDataError(message); + } + const shape = arr.shape; + const query = buildSliceQuery(spec.slice, axes, shape); + const result = await zarr.get(arr, query, { opts: { signal: signal ?? null } }); + if (typeof result === 'number') { + const message = "could not fetch Zarr slice: parsed slice data's shape was undefined"; + logger.error(message); + throw new VisZarrDataError(message); + } + return { + shape: result.shape, + buffer: result, + }; + } + + /** + * Closes the connection and cleans up any volatile resources (such as web workers). + * Note that this DOES NOT remove the already-loaded data or metadata. That is expected + * to be removed from the system via other means, as needed by the application, and is + * left up to the application to oversee. + */ + close() { + this.#store.destroy(); + } +} diff --git a/packages/omezarr/src/zarr/level.ts b/packages/omezarr/src/zarr/level.ts new file mode 100644 index 00000000..ed06258b --- /dev/null +++ b/packages/omezarr/src/zarr/level.ts @@ -0,0 +1,135 @@ +import { type CartesianPlane, Vec2, type vec2 } from '@alleninstitute/vis-geometry'; +import type { OmeZarrArray, OmeZarrAxis, OmeZarrDataset, OmeZarrMultiscale, ZarrDimension } from './types'; + +export class OmeZarrLevel { + readonly path: string; + readonly multiscale: OmeZarrMultiscale; + readonly dataset: OmeZarrDataset; + readonly datasetIndex: number; + readonly array: OmeZarrArray; + + constructor( + path: string, + multiscale: OmeZarrMultiscale, + dataset: OmeZarrDataset, + datasetIndex: number, + array: OmeZarrArray, + ) { + this.path = path; + this.multiscale = multiscale; + this.dataset = dataset; + this.datasetIndex = datasetIndex; + this.array = array; + } + + get shape(): readonly number[] { + return this.array.shape; + } + + get axes(): readonly OmeZarrAxis[] { + return this.multiscale.axes; + } + + /** + * For a given dimension (e.g. 'x', 'y', 'z'), retrieves the index of that dimension within + * this level's multiscale axes. + * @param dim The dimension to find the index for + * @returns the 0-based index of the given dimension, or -1 if the dimension was not found + */ + indexFor(dim: ZarrDimension) { + const axes = this.multiscale.axes; + return axes.findIndex((axis) => axis.name === dim); + } + + /** + * Determine the size of a slice of the volume, in the units specified by the axes metadata + * as described in the ome-zarr spec (https://ngff.openmicroscopy.org/latest/#axes-md). + * NOTE that only scale transformations (https://ngff.openmicroscopy.org/latest/#trafo-md) + * are supported at present - other types will be ignored. + * @param plane the plane to measure (eg. CartesianPlane('xy')) + * @returns the size, with respect to the coordinateTransformations present on the given dataset, of the requested plane. + * @example imagine a layer that is 29998 voxels wide in the X dimension, and a scale transformation of 0.00035 for that dimension. + * this function would return (29998*0.00035 = 10.4993) for the size of that dimension, which you would interpret to be in whatever unit + * is given by the axes metadata for that dimension (eg. millimeters) + */ + sizeInUnits(plane: CartesianPlane): vec2 | undefined { + const vxls = this.planeSizeInVoxels(plane); + if (vxls === undefined) { + return undefined; + } + + let size: vec2 = vxls; + const transforms = this.dataset.coordinateTransformations; + + // now, just apply the correct transforms, if they exist... + for (const trn of transforms) { + if (trn.type === 'scale') { + // try to apply it! + const uIndex = this.indexFor(plane.u); + const vIndex = this.indexFor(plane.v); + size = Vec2.mul(size, [trn.scale[uIndex], trn.scale[vIndex]]); + } + } + return size; + } + + /** + * Get the size in voxels of the given dimension within this level. + * @see `planeSizeInVoxels` for sizing of a plane rather than a dimension + * @param dim the dimension to measure + * @param axes the axes metadata for the zarr dataset + * @param shape the dimensional extents of the target dataset + * @returns the size, in voxels, of the given dimension of the given layer + * @example (pseudocode of course) return omezarr.multiscales[0].datasets[LAYER].shape[DIMENSION] + */ + sizeInVoxels(dim: ZarrDimension) { + const uI = this.indexFor(dim); + if (uI === -1) return undefined; + + return this.array.shape[uI]; + } + + /** + * Get the size in voxels of a plane within the volume of this level. + * @see `sizeInVoxels` for sizing of a dimension rather than a plane + * @param plane the plane to measure (eg. 'xy') + * @returns a vec2 containing the requested sizes, or undefined if the requested plane is malformed, or not present in the dataset + */ + planeSizeInVoxels(plane: CartesianPlane): vec2 | undefined { + // first - u&v must not refer to the same dimension, + // and both should exist in the axes... + if (!plane.isValid()) { + return undefined; + } + const uI = this.indexFor(plane.u); + const vI = this.indexFor(plane.v); + if (uI === -1 || vI === -1) { + return undefined; + } + + return [this.shape[uI], this.shape[vI]] as const; + } + + /** + * Determines the integer index within the given dimensional for the specified parametric value. + * + * For example, if the Z dimension has a depth of 100 and the parameter is given as 0.5, this will return + * an index of 49 (halfway from 0 to 99). + * @param parameter a value from [0:1] indicating a parameter of the volume, along the given dimension @param dim, + * @param dim the dimension (axis) along which @param parameter refers + * @returns a valid index (between [0, level.shape[axis]]) from the volume, suitable for + */ + indexOfRelativeSlice(parameter: number, dim: ZarrDimension): number { + const dimIndex = this.indexFor(dim); + return Math.floor(this.shape[dimIndex] * Math.max(0, Math.min(1, parameter))); + } + + toJSON() { + const path = this.path; + const multiscale = this.multiscale; + const dataset = this.dataset; + const datasetIndex = this.datasetIndex; + const array = this.array; + return { path, multiscale, dataset, datasetIndex, array }; + } +} diff --git a/packages/omezarr/src/zarr/loading.ts b/packages/omezarr/src/zarr/loading.ts deleted file mode 100644 index c5f7f04a..00000000 --- a/packages/omezarr/src/zarr/loading.ts +++ /dev/null @@ -1,364 +0,0 @@ -import { - Box2D, - type CartesianPlane, - type Interval, - Vec2, - type box2D, - limit, - type vec2, -} from '@alleninstitute/vis-geometry'; -import { getResourceUrl, logger, type WebResource } from '@alleninstitute/vis-core'; -import { VisZarrDataError } from '../errors'; -import { - OmeZarrAttrsSchema, - OmeZarrMetadata, - type OmeZarrAttrs, - type OmeZarrAxis, - type ZarrDimension, - type OmeZarrShapedDataset, - type OmeZarrArrayMetadata, -} from './types'; -import * as zarr from 'zarrita'; -import { ZodError } from 'zod'; - -// Documentation for OME-Zarr datasets (from which these types are built) -// can be found here: -// - top-level metadata: https://ngff.openmicroscopy.org/latest/#multiscale-md -// - array metadata: v2: https://zarr-specs.readthedocs.io/en/latest/v2/v2.0.html#arrays -// v3: https://zarr-specs.readthedocs.io/en/latest/v3/core/v3.0.html#array-metadata - -export async function loadZarrAttrsFile(res: WebResource): Promise { - const url = getResourceUrl(res); - const store = new zarr.FetchStore(url); - return loadZarrAttrsFileFromStore(store); -} - -async function loadZarrAttrsFileFromStore(store: zarr.FetchStore): Promise { - const group = await zarr.open(store, { kind: 'group' }); - try { - return OmeZarrAttrsSchema.parse(group.attrs); - } catch (e) { - if (e instanceof ZodError) { - logger.error('could not load Zarr file: parsing failed'); - } - throw e; - } -} - -type OmeZarrArrayMetadataLoad = { - metadata: OmeZarrArrayMetadata; - raw: zarr.Array; -}; - -export async function loadZarrArrayFile( - res: WebResource, - path: string, - version = 2, - loadV2Attrs = true, -): Promise { - const url = getResourceUrl(res); - const store = new zarr.FetchStore(url); - const result = await loadZarrArrayFileFromStore(store, path, version, loadV2Attrs); - return result.metadata; -} - -export async function loadZarrArrayFileFromStore( - store: zarr.FetchStore, - path: string, - version = 2, - loadV2Attrs = true, -): Promise { - const root = zarr.root(store); - let array: zarr.Array; - if (version === 3) { - array = await zarr.open.v3(root.resolve(path), { kind: 'array' }); - } else if (version === 2) { - array = await zarr.open.v2(root.resolve(path), { kind: 'array', attrs: loadV2Attrs }); - } else { - const message = `unsupported Zarr format version specified: ${version}`; - logger.error(message); - throw new VisZarrDataError(message); - } - const { shape, attrs } = array; - try { - return { metadata: { path, shape, attrs }, raw: array }; - } catch (e) { - if (e instanceof ZodError) { - logger.error('could not load Zarr file: parsing failed'); - } - throw e; - } -} - -/** - * - * @param url a url which resolves to an omezarr dataset - * @returns a structure describing the omezarr dataset. See - * https://ngff.openmicroscopy.org/latest/#multiscale-md for the specification. - * The object returned from this function can be passed to most of the other utilities for ome-zarr data - * manipulation. - */ -export async function loadMetadata(res: WebResource, loadV2ArrayAttrs = true): Promise { - const url = getResourceUrl(res); - const store = new zarr.FetchStore(url); - const attrs: OmeZarrAttrs = await loadZarrAttrsFileFromStore(store); - const version = attrs.zarrVersion; - const arrays = await Promise.all( - attrs.multiscales - .map((multiscale) => { - return ( - multiscale.datasets?.map(async (dataset) => { - return (await loadZarrArrayFileFromStore(store, dataset.path, version, loadV2ArrayAttrs)) - .metadata; - }) ?? [] - ); - }) - .reduce((prev, curr) => prev.concat(curr)) - .filter((v) => v !== undefined), - ); - return new OmeZarrMetadata(url, attrs, arrays, version); -} - -export type ZarrRequest = Record; - -/** - * given a region of a volume to view at a certain output resolution, find the layer in the ome-zarr dataset which - * is most appropriate - that is to say, as close to 1:1 relation between voxels and display pixels as possible. - * @param zarr an object representing an omezarr file - see @function loadMetadata - * @param plane a plane in the volume - the dimensions of this plane will be matched to the displayResolution - * when choosing an appropriate LOD layer - * @param relativeView a region of the selected plane which is the "screen" - the screen has resolution @param displayResolution. - * an example relative view of [0,0],[1,1] would suggest we're trying to view the entire slice at the given resolution. - * @param displayResolution - * @returns an LOD (level-of-detail) layer from the given dataset, that is appropriate for viewing at the given - * displayResolution. - */ -export function pickBestScale( - zarr: OmeZarrMetadata, - plane: CartesianPlane, - relativeView: box2D, // a box in data-unit-space - displayResolution: vec2, // in the plane given above -): OmeZarrShapedDataset { - const datasets = zarr.getAllShapedDatasets(0); - const axes = zarr.attrs.multiscales[0].axes; - const firstDataset = datasets[0]; - if (!firstDataset) { - const message = 'invalid Zarr data: no datasets found'; - logger.error(message); - throw new VisZarrDataError(message); - } - const realSize = sizeInUnits(plane, axes, firstDataset); - if (!realSize) { - const message = 'invalid Zarr data: could not determine the size of the plane in the given units'; - logger.error(message); - throw new VisZarrDataError(message); - } - - const vxlPitch = (size: vec2) => Vec2.div(realSize, size); - // size, in dataspace, of a pixel 1/res - const pxPitch = Vec2.div(Box2D.size(relativeView), displayResolution); - const dstToDesired = (a: vec2, goal: vec2) => { - const diff = Vec2.sub(a, goal); - if (diff[0] * diff[1] > 0) { - // the res (a) is higher than our goal - - // weight this heavily to prefer smaller than the goal - return 1000 * Vec2.length(Vec2.sub(a, goal)); - } - return Vec2.length(Vec2.sub(a, goal)); - }; - // we assume the datasets are ordered... hmmm TODO - const choice = datasets.reduce((bestSoFar, cur) => { - const planeSizeBest = planeSizeInVoxels(plane, axes, bestSoFar); - const planeSizeCur = planeSizeInVoxels(plane, axes, cur); - if (!planeSizeBest || !planeSizeCur) { - return bestSoFar; - } - return dstToDesired(vxlPitch(planeSizeBest), pxPitch) > dstToDesired(vxlPitch(planeSizeCur), pxPitch) - ? cur - : bestSoFar; - }, datasets[0]); - return choice ?? datasets[datasets.length - 1]; -} -// TODO this is a duplicate of indexOfDimension... delete one of them! -function indexFor(dim: ZarrDimension, axes: readonly OmeZarrAxis[]) { - return axes.findIndex((axis) => axis.name === dim); -} -/** - * - * @param layer a shaped layer from within the omezarr dataset - * @param axes the axes describing this omezarr dataset - * @param parameter a value from [0:1] indicating a parameter of the volume, along the given dimension @param dim, - * @param dim the dimension (axis) along which @param parameter refers - * @returns a valid index (between [0,layer.shape[axis] ]) from the volume, suitable for - */ -export function indexOfRelativeSlice( - layer: OmeZarrShapedDataset, - axes: readonly OmeZarrAxis[], - parameter: number, - dim: ZarrDimension, -): number { - const dimIndex = indexFor(dim, axes); - return Math.floor(layer.shape[dimIndex] * Math.max(0, Math.min(1, parameter))); -} -/** - * @param zarr - * @param plane - * @param relativeView - * @param displayResolution - * @returns - */ -export function nextSliceStep( - zarr: OmeZarrMetadata, - plane: CartesianPlane, - relativeView: box2D, // a box in data-unit-space - displayResolution: vec2, // in the plane given above -) { - // figure out what layer we'd be viewing - const layer = pickBestScale(zarr, plane, relativeView, displayResolution); - const axes = zarr.attrs.multiscales[0].axes; - const slices = sizeInVoxels(plane.ortho, axes, layer); - return slices === undefined ? undefined : 1 / slices; -} - -/** - * determine the size of a slice of the volume, in the units specified by the axes metadata - * as described in the ome-zarr spec (https://ngff.openmicroscopy.org/latest/#axes-md) - * NOTE that only scale transformations (https://ngff.openmicroscopy.org/latest/#trafo-md) are supported at present - other types will be ignored. - * @param plane the plane to measure (eg. CartesianPlane('xy')) - * @param axes the axes metadata from the omezarr file in question - * @param dataset one of the "datasets" in the omezarr layer pyramid (https://ngff.openmicroscopy.org/latest/#multiscale-md) - * @returns the size, with respect to the coordinateTransformations present on the given dataset, of the requested plane. - * @example imagine a layer that is 29998 voxels wide in the X dimension, and a scale transformation of 0.00035 for that dimension. - * this function would return (29998*0.00035 = 10.4993) for the size of that dimension, which you would interpret to be in whatever unit - * is given by the axes metadata for that dimension (eg. millimeters) - */ -export function sizeInUnits( - plane: CartesianPlane, - axes: readonly OmeZarrAxis[], - dataset: OmeZarrShapedDataset, -): vec2 | undefined { - const vxls = planeSizeInVoxels(plane, axes, dataset); - - if (vxls === undefined) return undefined; - - let size: vec2 = vxls; - - // now, just apply the correct transforms, if they exist... - for (const trn of dataset.coordinateTransformations) { - if (trn.type === 'scale') { - // try to apply it! - const uIndex = indexFor(plane.u, axes); - const vIndex = indexFor(plane.v, axes); - size = Vec2.mul(size, [trn.scale[uIndex], trn.scale[vIndex]]); - } - } - return size; -} -/** - * get the size in voxels of a layer of an omezarr on a given dimension - * @param dim the dimension to measure - * @param axes the axes metadata for the zarr dataset - * @param dataset an entry in the datasets list in the multiscales list in a ZarrDataset object - * @returns the size, in voxels, of the given dimension of the given layer - * @example (pseudocode of course) return omezarr.multiscales[0].datasets[LAYER].shape[DIMENSION] - */ -export function sizeInVoxels(dim: ZarrDimension, axes: readonly OmeZarrAxis[], dataset: OmeZarrShapedDataset) { - const uI = indexFor(dim, axes); - if (uI === -1) return undefined; - - return dataset.shape[uI]; -} - -// TODO move into ZarrMetadata object -/** - * get the size of a plane of a volume (given a specific layer) in voxels - * see @function sizeInVoxels - * @param plane the plane to measure (eg. 'xy') - * @param axes the axes metadata of an omezarr object - * @param dataset a layer of the ome-zarr resolution pyramid - * @returns a vec2 containing the requested sizes, or undefined if the requested plane is malformed, or not present in the dataset - */ -export function planeSizeInVoxels( - plane: CartesianPlane, - axes: readonly OmeZarrAxis[], - dataset: OmeZarrShapedDataset, -): vec2 | undefined { - // first - u&v must not refer to the same dimension, - // and both should exist in the axes... - if (!plane.isValid()) { - return undefined; - } - const uI = indexFor(plane.u, axes); - const vI = indexFor(plane.v, axes); - if (uI === -1 || vI === -1) { - return undefined; - } - - return [dataset.shape[uI], dataset.shape[vI]] as const; -} - -// feel free to freak out if the request is over or under determined or whatever -export function buildQuery(r: Readonly, axes: readonly OmeZarrAxis[], shape: readonly number[]) { - const ordered = axes.map((a) => r[a.name as ZarrDimension]); - // if any are undefined, throw up - if (ordered.some((a) => a === undefined)) { - throw new VisZarrDataError('request does not match expected dimensions of OME-Zarr dataset'); - } - - return ordered.map((d, i) => { - const bounds = { min: 0, max: shape[i] }; - if (d === null) { - return d; - } - if (typeof d === 'number') { - return limit(bounds, d); - } - return zarr.slice(limit(bounds, d.min), limit(bounds, d.max)); - }); -} - -export async function explain(z: OmeZarrMetadata) { - logger.dir(z); -} - -/** - * get voxels / pixels from a region of a layer of an omezarr dataset - * @param metadata a ZarrMetadata from which to request a slice of voxels - * @param r a slice object, describing the requested region of data - note that it is quite possible to request - * data that is not "just" a slice. The semantics of this slice object should match up with conventions in numpy or other multidimensional array tools: - * @see https://zarrita.dev/slicing.html - * @param level the layer within the LOD pyramid of the OME-Zarr dataset. - * @returns the requested chunk of image data from the given layer of the omezarr LOD pyramid. Note that if the given layerIndex is invalid, it will be treated as though it is the highest index possible. - * @throws an error if the request results in anything of lower-or-equal dimensionality than a single value - */ -export async function loadSlice( - metadata: OmeZarrMetadata, - r: ZarrRequest, - level: OmeZarrShapedDataset, - signal?: AbortSignal, -) { - // put the request in native order - const store = new zarr.FetchStore(metadata.url); - const scene = metadata.attrs.multiscales[0]; - const { axes } = scene; - if (!level) { - const message = 'invalid Zarr data: no datasets found'; - logger.error(message); - throw new VisZarrDataError(message); - } - const arr = metadata.arrays.find((a) => a.path === level.path); - if (!arr) { - const message = `cannot load slice: no array found for path [${level.path}]`; - logger.error(message); - throw new VisZarrDataError(message); - } - const { raw } = await loadZarrArrayFileFromStore(store, arr.path, metadata.zarrVersion, false); - const result = await zarr.get(raw, buildQuery(r, axes, level.shape), { opts: { signal: signal ?? null } }); - if (typeof result === 'number') { - throw new Error('oh noes, slice came back all weird'); - } - return { - shape: result.shape, - buffer: result, - }; -} diff --git a/packages/omezarr/src/zarr/metadata.ts b/packages/omezarr/src/zarr/metadata.ts new file mode 100644 index 00000000..7a002026 --- /dev/null +++ b/packages/omezarr/src/zarr/metadata.ts @@ -0,0 +1,314 @@ +import { logger } from '@alleninstitute/vis-core'; +import { Box2D, type box2D, type CartesianPlane, Vec2, type vec2 } from '@alleninstitute/vis-geometry'; +import { VisZarrDataError } from '../errors'; +import { OmeZarrLevel } from './level'; +import { + convertFromOmeroToColorChannels, + type OmeZarrArray, + type OmeZarrColorChannel, + type OmeZarrDataset, + type OmeZarrGroup, + type OmeZarrGroupAttributes, + type OmeZarrMultiscale, + type ZarrDimension, +} from './types'; + +export type OmeZarrMultiscaleSpecifier = + | { + index: number; + } + | { + name: string; + }; + +export type OmeZarrLevelSpecifier = { + multiscale?: OmeZarrMultiscaleSpecifier | undefined; +} & ( + | { + index: number; + } + | { + path: string; + } +); + +/** + * An `OmeZarrFileset` represents the metadata describing a full fileset for a given OME-Zarr. + * It provides access to all of the metadata information available about that fileset, and + * makes it easy to access a particular level-of-detail's full contextual information + * (provided as instances of the `OmeZarrLevel` class). + * + * `OmeZarrFileset`s are possible to construct given raw data, but they are generally produced + * via the `loadMetadata` function of the `OmeZarrConnection` type. Connections have a close + * relationship with the Fileset objects, and are able to load both metadata and the associated + * data for a given OME-Zarr. + * + * @see OmeZarrLevel + * @see OmeZarrConnection + */ +export class OmeZarrMetadata { + #url: URL; + #rootGroup: OmeZarrGroup; + #arrays: Record; + + constructor(url: URL, rootGroup: OmeZarrGroup, arrays: Record) { + this.#url = url; + this.#rootGroup = rootGroup; + this.#arrays = arrays; + } + + get url(): URL { + return this.#url; + } + + get attrs(): OmeZarrGroupAttributes { + return this.#rootGroup.attributes; + } + + getMultiscale(specifier: OmeZarrMultiscaleSpecifier | undefined): OmeZarrMultiscale | undefined { + const multiscales = this.#rootGroup.attributes.multiscales; + if (specifier === undefined) { + return multiscales[0]; + } + return 'index' in specifier ? multiscales[specifier.index] : multiscales.find((m) => m.name === specifier.name); + } + + getLevel(specifier: OmeZarrLevelSpecifier): OmeZarrLevel | undefined { + const targetDesc = 'index' in specifier ? `index [${specifier.index}]` : `path [${specifier.path}]`; + + const multiscale = this.getMultiscale(specifier.multiscale ?? { index: 0 }); + if (multiscale === undefined) { + const message = `cannot get matching dataset and array for ${targetDesc}: multiple multiscales specified`; + logger.error(message); + throw new VisZarrDataError(message); + } + + let matching: { path: string; dataset: OmeZarrDataset; datasetIndex: number }; + + if ('index' in specifier) { + const i = specifier.index; + if (i < 0 || i >= multiscale.datasets.length) { + const message = `cannot get matching dataset and array for ${targetDesc}: index out of bounds`; + logger.error(message); + throw new VisZarrDataError(message); + } + const dataset = multiscale.datasets[specifier.index]; + if (dataset === undefined) { + const message = `cannot get matching dataset and array for index ${targetDesc}: no dataset found at that index`; + logger.error(message); + throw new VisZarrDataError(message); + } + matching = { path: dataset.path, datasetIndex: i, dataset }; + } else { + const path = specifier.path; + const datasetIndex = multiscale.datasets.findIndex((d) => d.path === path); + const dataset = multiscale.datasets[datasetIndex]; + if (datasetIndex === -1 || dataset === undefined) { + const message = `cannot get matching dataset and array for ${targetDesc}: no matching path found`; + logger.error(message); + throw new VisZarrDataError(message); + } + matching = { path, dataset, datasetIndex }; + } + + const { path, dataset, datasetIndex } = matching; + const array = this.#arrays[`/${path}`]; // TODO this is a short-term fix, a more ideal fix would calculate the path from the array's group + if (array === undefined) { + const message = `cannot get matching dataset and array for ${targetDesc}: no matching array found`; + logger.error(message); + throw new VisZarrDataError(message); + } + return new OmeZarrLevel(path, multiscale, dataset, datasetIndex, array); + } + + getLevels(): OmeZarrLevel[] { + const multiscales = this.#rootGroup?.attributes.multiscales ?? []; + const arrays = this.#arrays; + + const levels = []; + for (const multiscale of multiscales) { + let i = 0; + for (const dataset of multiscale.datasets) { + const path = dataset.path; + const array = arrays[`/${path}`]; + if (array === undefined) { + const message = 'cannot get list of levels: mismatched array and dataset'; + logger.error(message); + throw new VisZarrDataError(message); + } + levels.push(new OmeZarrLevel(path, multiscale, dataset, i, array)); + i += 1; + } + } + return levels; + } + + getNumLevels(multiscaleSpec?: OmeZarrMultiscaleSpecifier | undefined): number { + const multiscale = this.getMultiscale(multiscaleSpec); + return multiscale?.datasets.length ?? 0; + } + + getColorChannels(): OmeZarrColorChannel[] { + const omero = this.#rootGroup?.attributes.omero; + return omero ? convertFromOmeroToColorChannels(omero) : []; + } + + toJSON() { + return { + url: this.#url, + root: this.#rootGroup, + arrays: this.#arrays, + }; + } + + /** + * Given a region of a volume to view at a certain output resolution, find the level-of-detail (LOD) in the ome-zarr + * fileset which is most appropriate - that is to say, as close to 1:1 relation between voxels and display pixels as possible. + * @param plane a plane in the volume - the dimensions of this plane will be matched to the displayResolution + * when choosing an appropriate LOD layer + * @param relativeView a region of the selected plane which is the "screen" - the screen has resolution `displayResolution`. + * An example relative view of `[0, 0], [1, 1]` would suggest we're trying to view the entire slice at the given resolution. + * @param displayResolution + * @param multiscaleSpec an optional specification of which multiscale to pick within (will default to the first defined) + * @returns an LOD level from the given dataset, that is appropriate for viewing at the given displayResolution. + */ + pickBestScale( + plane: CartesianPlane, + relativeView: box2D, // a box in data-unit-space + displayResolution: vec2, // in the plane given above + multiscaleSpec?: OmeZarrMultiscaleSpecifier | undefined, + ): OmeZarrLevel { + const level = this.getLevel({ index: 0, multiscale: multiscaleSpec }); + if (!level) { + const message = 'cannot pick best-fitting scale: no initial dataset context found'; + logger.error(message); + throw new VisZarrDataError(message); + } + + const realSize = level.sizeInUnits(plane); + if (!realSize) { + const message = 'invalid Zarr data: could not determine the size of the plane in the given units'; + logger.error(message); + throw new VisZarrDataError(message); + } + + const vxlPitch = (size: vec2) => Vec2.div(realSize, size); + + // size, in dataspace, of a pixel 1/res + const pxPitch = Vec2.div(Box2D.size(relativeView), displayResolution); + const dstToDesired = (a: vec2, goal: vec2) => { + const diff = Vec2.sub(a, goal); + if (diff[0] * diff[1] > 0) { + // the res (a) is higher than our goal - + // weight this heavily to prefer smaller than the goal + return 1000 * Vec2.length(Vec2.sub(a, goal)); + } + return Vec2.length(Vec2.sub(a, goal)); + }; + + const levels = Array.from(this.getLevels()); + + // per the OME-Zarr spec, datasets/levels are ordered by scale + const choice = levels.reduce((bestSoFar, cur) => { + const planeSizeBest = bestSoFar.planeSizeInVoxels(plane); + const planeSizeCur = cur.planeSizeInVoxels(plane); + if (!planeSizeBest || !planeSizeCur) { + return bestSoFar; + } + return dstToDesired(vxlPitch(planeSizeBest), pxPitch) > dstToDesired(vxlPitch(planeSizeCur), pxPitch) + ? cur + : bestSoFar; + }, levels[0]); + return choice ?? levels[levels.length - 1]; + } + + nextSliceStep( + plane: CartesianPlane, + relativeView: box2D, // a box in data-unit-space + displayResolution: vec2, // in the plane given above + multiscaleSpec?: OmeZarrMultiscaleSpecifier | undefined, + ) { + // figure out what layer we'd be viewing + const level = this.pickBestScale(plane, relativeView, displayResolution, multiscaleSpec); + const slices = level.sizeInVoxels(plane.ortho); + return slices === undefined ? undefined : 1 / slices; + } + + #getDimensionIndex(dim: ZarrDimension, multiscaleSpec: OmeZarrMultiscaleSpecifier | undefined): number | undefined { + const multiscale = this.getMultiscale(multiscaleSpec); + if (multiscale === undefined) { + return undefined; + } + const index = multiscale.axes.findIndex((a) => a.name === dim); + return index > -1 ? index : undefined; + } + + #getMaximumForDimension(dim: ZarrDimension, multiscaleSpec: OmeZarrMultiscaleSpecifier | undefined): number { + const multiscale = this.getMultiscale(multiscaleSpec); + if (multiscale === undefined) { + const message = `cannot get maximum ${dim}: no matching multiscale found`; + logger.error(message); + throw new VisZarrDataError(message); + } + + const arrays = multiscale.datasets.map((d) => this.#arrays[d.path]); + const dimIdx = this.#getDimensionIndex(dim, multiscaleSpec); + if (dimIdx === undefined) { + const message = `cannot get maximum ${dim}: '${dim}' is not a valid dimension for this multiscale`; + logger.error(message); + throw new VisZarrDataError(message); + } + const sortedValues = arrays.map((arr) => arr?.shape[dimIdx] ?? 0).sort(); + return sortedValues.at(sortedValues.length - 1) ?? 0; + } + + /** + * Given a specific multiscale representation of the Zarr data, finds the largest X shape component + * among the shapes of the different dataset arrays. + * @param multiscaleSpec the index or path of a specific multiscale representation (defaults to 0) + * @returns the largest Z scale for the specified multiscale representation + */ + maxX(multiscaleSpec?: OmeZarrMultiscaleSpecifier | undefined): number { + return this.#getMaximumForDimension('x', multiscaleSpec); + } + + /** + * Given a specific multiscale representation of the Zarr data, finds the largest Y shape component + * among the shapes of the different dataset arrays. + * @param multiscaleSpec the index or path of a specific multiscale representation (defaults to 0) + * @returns the largest Z scale for the specified multiscale representation + */ + maxY(multiscaleSpec?: OmeZarrMultiscaleSpecifier | undefined): number { + return this.#getMaximumForDimension('y', multiscaleSpec); + } + + /** + * Given a specific multiscale representation of the Zarr data, finds the largest Z shape component + * among the shapes of the different dataset arrays. + * @param multiscaleSpec the index or path of a specific multiscale representation (defaults to 0) + * @returns the largest Z scale for the specified multiscale representation + */ + maxZ(multiscaleSpec?: OmeZarrMultiscaleSpecifier | undefined): number { + return this.#getMaximumForDimension('z', multiscaleSpec); + } + + /** + * Given a specific plane and multiscale, determines the maximum value of the orthogonal axis to + * that plane within that multiscale. + * @param plane a cartesian plane + * @param multiscaleSpec identifies the multiscale to operate within + * @returns the maximum value of the axis orthogonal to `plane` + */ + maxOrthogonal(plane: CartesianPlane, multiscaleSpec?: OmeZarrMultiscaleSpecifier | undefined): number { + if (plane.ortho === 'x') { + return this.maxX(multiscaleSpec); + } + if (plane.ortho === 'y') { + return this.maxY(multiscaleSpec); + } + if (plane.ortho === 'z') { + return this.maxZ(multiscaleSpec); + } + throw new VisZarrDataError(`invalid plane: ortho set to '${plane.ortho}'`); + } +} diff --git a/packages/omezarr/src/zarr/types.ts b/packages/omezarr/src/zarr/types.ts index 9fe58875..3c555762 100644 --- a/packages/omezarr/src/zarr/types.ts +++ b/packages/omezarr/src/zarr/types.ts @@ -1,14 +1,10 @@ -import type { CartesianPlane, Interval, vec3, vec4 } from '@alleninstitute/vis-geometry'; -import { VisZarrDataError, VisZarrIndexError } from '../errors'; -import { logger, makeRGBAColorVector } from '@alleninstitute/vis-core'; +import type { Interval, vec3, vec4 } from '@alleninstitute/vis-geometry'; +import { makeRGBAColorVector } from '@alleninstitute/vis-core'; +import type * as zarr from 'zarrita'; import { z } from 'zod'; export type ZarrDimension = 't' | 'c' | 'z' | 'y' | 'x'; - -// these dimension indices are given for a 4-element shape array -const SHAPE_Z_DIM_INDEX = 1; -const SHAPE_Y_DIM_INDEX = 2; -const SHAPE_X_DIM_INDEX = 3; +export type OmeZarrDimension = ZarrDimension; export type OmeZarrAxis = { name: string; @@ -147,9 +143,32 @@ export type OmeZarrAttrsV3 = { ome: BaseOmeZarrAttrs; }; -export type OmeZarrAttrs = { - zarrVersion: number; -} & BaseOmeZarrAttrs; +// newer types that align a little more closely with how Zarr/OME-Zarr data +// is actually represented +export type OmeZarrNode = { + nodeType: 'group' | 'array'; +}; + +export type OmeZarrGroup = OmeZarrNode & { + nodeType: 'group'; + zarrFormat: 2 | 3; + attributes: OmeZarrGroupAttributes; +}; + +export type OmeZarrGroupAttributes = { + multiscales: OmeZarrMultiscale[]; + omero?: OmeZarrOmero | undefined; // omero is a transitional field, meaning it is expected to go away in a later version + version?: string | undefined; +}; + +export type OmeZarrArray = OmeZarrNode & { + nodeType: 'array'; + path: string; + chunkShape: number[]; + dataType: string; + shape: number[]; + attributes: Record; +}; export const OmeZarrAttrsBaseSchema: z.ZodType = z.object({ multiscales: OmeZarrMultiscaleSchema.array().nonempty(), @@ -162,38 +181,35 @@ export const OmeZarrAttrsV3Schema: z.ZodType = z.object({ ome: OmeZarrAttrsBaseSchema, }); -export const OmeZarrAttrsSchema = z +export const OmeZarrGroupTransform = z .union([OmeZarrAttrsV2Schema, OmeZarrAttrsV3Schema]) - .transform((v: OmeZarrAttrsV2 | OmeZarrAttrsV3) => { + .transform((v: OmeZarrAttrsV2 | OmeZarrAttrsV3) => { if ('ome' in v) { return { - zarrVersion: 3, - ...v.ome, + nodeType: 'group', + zarrFormat: 3, + attributes: v.ome, }; } return { - zarrVersion: 2, - ...v, + nodeType: 'group', + zarrFormat: 2, + attributes: v, }; }); -export type DehydratedOmeZarrArray = { - path: string; -}; - -// For details on Zarr Array Metadata format, see: https://zarr-specs.readthedocs.io/en/latest/v2/v2.0.html -export type OmeZarrArrayMetadata = { - path: string; - shape: number[]; - attrs?: Record | undefined; -}; - -export type DehydratedOmeZarrMetadata = { - url: string; - attrs: OmeZarrAttrs; - arrays: OmeZarrArrayMetadata[]; - zarrVersion: number; -}; +type ZarritaArray = zarr.Array; + +export const OmeZarrArrayTransform = z.transform((v: ZarritaArray) => { + return { + nodeType: 'array', + path: v.path, + chunkShape: v.chunks, + dataType: v.dtype, + shape: v.shape, + attributes: v.attrs, + } as OmeZarrArray; +}); export function convertFromOmeroToColorChannels(omero: OmeZarrOmero): OmeZarrColorChannel[] { return omero.channels.map(convertFromOmeroChannelToColorChannel); @@ -212,329 +228,7 @@ export function convertFromOmeroChannelToColorChannel(omeroChannel: OmeZarrOmero return { rgb, rgba, window, range, active, label }; } -export type OmeZarrMetadataFlattened = { - url: string; - attrs: OmeZarrAttrs; - arrays: ReadonlyArray; - zarrVersion: number; - colorChannels: OmeZarrColorChannel[]; - redChannel: OmeZarrColorChannel | undefined; - blueChannel: OmeZarrColorChannel | undefined; - greenChannel: OmeZarrColorChannel | undefined; -}; - -export class OmeZarrMetadata { - #url: string; - #attrs: OmeZarrAttrs; - #arrays: ReadonlyArray; - #zarrVersion: number; - - constructor(url: string, attrs: OmeZarrAttrs, arrays: ReadonlyArray, zarrVersion: number) { - this.#url = url; - this.#attrs = attrs; - this.#arrays = arrays; - this.#zarrVersion = zarrVersion; - } - - get url(): string { - return this.#url; - } - - get attrs(): OmeZarrAttrs { - return this.#attrs; - } - - get arrays(): ReadonlyArray { - return this.#arrays; - } - - get zarrVersion(): number { - return this.#zarrVersion; - } - - toJSON(): OmeZarrMetadataFlattened { - return { - url: this.url, - attrs: this.attrs, - arrays: this.arrays, - zarrVersion: this.zarrVersion, - colorChannels: this.colorChannels, - redChannel: this.redChannel, - blueChannel: this.blueChannel, - greenChannel: this.greenChannel, - }; - } - - #getMultiscaleIndex(multiscale?: number | string): number { - if (multiscale !== undefined) { - if (typeof multiscale === 'number') { - if (multiscale < 0) { - return -1; - } - return multiscale; - } - return this.#attrs.multiscales.findIndex((m) => m.name === multiscale); - } - return 0; - } - - #getValidMultiscaleIndex(multiscale?: number | string): number { - const multiscaleIndex = this.#getMultiscaleIndex(multiscale); - if (multiscaleIndex < 0) { - const message = `invalid multiscale requested: identifier [${multiscale}]`; - logger.error(message); - throw new VisZarrIndexError(message); - } - return multiscaleIndex; - } - - #getDatasetIndex(dataset: number | string, multiscaleIndex: number): number { - const datasets = this.#attrs.multiscales[multiscaleIndex]?.datasets ?? null; - if (!datasets) { - return -1; - } - if (typeof dataset === 'number') { - if (dataset < 0 || dataset >= datasets.length) { - return -1; - } - return dataset; - } - return datasets.findIndex((d) => d.path === dataset); - } - - #getValidDatasetIndex(dataset: number | string, multiscaleIndex: number): number { - const datasetIndex = this.#getDatasetIndex(dataset, multiscaleIndex); - if (datasetIndex < 0) { - const message = `invalid dataset requested: identifier [${dataset}]`; - logger.error(message); - throw new VisZarrIndexError(message); - } - return datasetIndex; - } - - /** Private function that retrieves the X value from the `shape` of a given array, within a - * specific multiscale representation of the data. - */ - #getShapeX(array: OmeZarrArrayMetadata, multiscaleIndex: number): number { - const shape = array.shape; - if (!shape || shape.length < 4) { - const message = `invalid dataset: .zarray formatting invalid, found array without valid shape; path [${multiscaleIndex}/${array.path}]`; - logger.error(message); - throw new VisZarrDataError(message); - } - const shapeIndex = shape.length === 5 ? SHAPE_X_DIM_INDEX + 1 : SHAPE_X_DIM_INDEX; - return shape[shapeIndex]; - } - - /** Private function that retrieves the Y value from the `shape` of a given array, within a - * specific multiscale representation of the data. - */ - #getShapeY(array: OmeZarrArrayMetadata, multiscaleIndex: number): number { - const shape = array.shape; - if (!shape || shape.length < 4) { - const message = `invalid dataset: .zarray formatting invalid, found array without valid shape; path [${multiscaleIndex}/${array.path}]`; - logger.error(message); - throw new VisZarrDataError(message); - } - const shapeIndex = shape.length === 5 ? SHAPE_Y_DIM_INDEX + 1 : SHAPE_Y_DIM_INDEX; - return shape[shapeIndex]; - } - - /** Private function that retrieves the Z value from the `shape` of a given array, within a - * specific multiscale representation of the data. - */ - #getShapeZ(array: OmeZarrArrayMetadata, multiscaleIndex: number): number { - const shape = array.shape; - if (!shape || shape.length < 4) { - const message = `invalid dataset: .zarray formatting invalid, found array without valid shape; path [${multiscaleIndex}/${array.path}]`; - logger.error(message); - throw new VisZarrDataError(message); - } - - // This checks to see if the shape provided has all 5 official OME-Zarr dimensions (t, c, z, y, x), - // or just the 4 that we typically have in our data files (c, z, y, x) - const shapeIndex = shape.length === 5 ? SHAPE_Z_DIM_INDEX + 1 : SHAPE_Z_DIM_INDEX; - return shape[shapeIndex]; - } - - /** Private function to retrieve the maximum value for a given shape element, e.g. - * the maximum value of one of the dimensions (t, c, z, y, x). It compares across all - * the values of that dimension for all zarrays/datasets within a given multiscale - * representation of the data. - * - * Note: Typically, we only receive the last 4 elements in the `shape` of a zarray. - * - * @param getShapeElement a function that retrieves one element from the `shape` of - * a zarray - * @returns the maxium value of that element across all arrays within the given - * multiscale representation - */ - #getShapeElementMax( - getShapeElement: (a: OmeZarrArrayMetadata, multiscaleIndex: number) => number, - multiscale?: number | string, - ): number { - const multiscaleIndex = this.#getValidMultiscaleIndex(multiscale); - return this.#attrs.multiscales[multiscaleIndex].datasets - .map((dataset) => { - const array = this.#arrays.find((a) => a.path === dataset.path); - if (!array) { - const message = `invalid dataset: .zarray missing for dataset [${multiscaleIndex}/${dataset.path}]`; - logger.error(message); - throw new VisZarrDataError(message); - } - return getShapeElement(array, multiscaleIndex); - }) - .reduce((prev, curr) => Math.max(prev, curr)); - } - - /** - * Given a specific @param multiscale representation of the Zarr data, finds the - * largest X shape component among the shapes of the different dataset arrays. - * @param multiscale the index or path of a specific multiscale representation (defaults to 0) - * @returns the largest Z scale for the specified multiscale representation - */ - maxX(multiscale: number | string = 0): number { - return this.#getShapeElementMax(this.#getShapeX, multiscale); - } - - /** - * Given a specific @param multiscale representation of the Zarr data, finds the - * largest Y shape component among the shapes of the different dataset arrays. - * @param multiscale the index or path of a specific multiscale representation (defaults to 0) - * @returns the largest Z scale for the specified multiscale representation - */ - maxY(multiscale: number | string = 0): number { - return this.#getShapeElementMax(this.#getShapeY, multiscale); - } - - /** - * Given a specific @param multiscale representation of the Zarr data, finds the - * largest Z shape component among the shapes of the different dataset arrays. - * @param multiscale the index or path of a specific multiscale representation (defaults to 0) - * @returns the largest Z scale for the specified multiscale representation - */ - maxZ(multiscale: number | string = 0): number { - return this.#getShapeElementMax(this.#getShapeZ, multiscale); - } - - maxOrthogonal(plane: CartesianPlane, multiscale: number | string = 0): number { - if (plane.ortho === 'x') { - return this.maxX(multiscale); - } - if (plane.ortho === 'y') { - return this.maxY(multiscale); - } - if (plane.ortho === 'z') { - return this.maxZ(multiscale); - } - throw new VisZarrDataError(`invalid plane: ortho set to '${plane.ortho}'`); - } - - #makeShapedDataset(dataset: OmeZarrDataset, multiscaleIndex: number, datasetIndex: number) { - const array = this.#arrays.find((a) => a.path === dataset.path); - if (!array) { - const message = `invalid dataset: .zarray missing for dataset [${multiscaleIndex}][${datasetIndex}]`; - logger.error(message); - throw new VisZarrDataError(message); - } - return { - ...dataset, - shape: array.shape, - multiscaleIndex, - datasetIndex, - }; - } - - getShapedDataset(indexOrPath: number | string, multiscale: number | string = 0): OmeZarrShapedDataset | undefined { - try { - const multiscaleIndex = this.#getValidMultiscaleIndex(multiscale); - const datasetIndex = this.#getValidDatasetIndex(indexOrPath, multiscaleIndex); - const dataset = this.#attrs.multiscales[multiscaleIndex].datasets[datasetIndex]; - return this.#makeShapedDataset(dataset, multiscaleIndex, datasetIndex); - } catch (e) { - if (e instanceof VisZarrIndexError) { - logger.debug('encountered index error when retrieving shaped dataset; returning undefined'); - return undefined; - } - throw e; - } - } - - getFirstShapedDataset(multiscale: number | string = 0): OmeZarrShapedDataset | undefined { - let multiscaleIndex: number; - try { - multiscaleIndex = this.#getValidMultiscaleIndex(multiscale); - const dataset = this.#attrs.multiscales[multiscaleIndex].datasets[0]; - return this.#makeShapedDataset(dataset, multiscaleIndex, 0); - } catch (e) { - if (e instanceof VisZarrIndexError) { - logger.debug('encountered index error when retrieving shaped dataset; returning undefined'); - return undefined; - } - throw e; - } - } - - getLastShapedDataset(multiscale: number | string = 0): OmeZarrShapedDataset | undefined { - let multiscaleIndex: number; - try { - multiscaleIndex = this.#getValidMultiscaleIndex(multiscale); - const datasets = this.#attrs.multiscales[multiscaleIndex].datasets; - const dataset = datasets[datasets.length - 1]; - return this.#makeShapedDataset(dataset, multiscaleIndex, 0); - } catch (e) { - if (e instanceof VisZarrIndexError) { - logger.debug('encountered index error when retrieving shaped dataset; returning undefined'); - return undefined; - } - throw e; - } - } - getNumLayers(multiscale: number | string = 0) { - const multiscaleIndex = this.#getValidMultiscaleIndex(multiscale); - return this.#attrs.multiscales[multiscaleIndex].datasets.length; - } - getAllShapedDatasets(multiscale: number | string = 0): OmeZarrShapedDataset[] { - const multiscaleIndex = this.#getValidMultiscaleIndex(multiscale); - const datasets = this.#attrs.multiscales[multiscaleIndex].datasets; - return datasets.map((dataset, i) => this.#makeShapedDataset(dataset, multiscaleIndex, i)); - } - - dehydrate(): DehydratedOmeZarrMetadata { - return { url: this.#url, attrs: this.#attrs, arrays: [...this.#arrays], zarrVersion: this.#zarrVersion }; - } - - static async rehydrate(dehydrated: DehydratedOmeZarrMetadata): Promise { - const { url, attrs, arrays, zarrVersion } = dehydrated; - return new OmeZarrMetadata(url, attrs, arrays, zarrVersion); - } - - #getChannelByMask(colorMask: string): OmeZarrColorChannel | undefined { - if (!this.#attrs.omero || !this.#attrs.omero.channels) { - logger.debug(`no omero data found for color mask ${colorMask}, returning undefined`); - return undefined; - } - const omeroChannel = this.#attrs.omero.channels.find((ch) => ch.color === colorMask); - if (!omeroChannel) { - logger.debug(`no matching omero channel found for color mask ${colorMask}, returning undefined`); - return undefined; - } - return convertFromOmeroChannelToColorChannel(omeroChannel); - } - - get colorChannels(): OmeZarrColorChannel[] { - return this.#attrs.omero ? convertFromOmeroToColorChannels(this.#attrs.omero) : []; - } - - get redChannel(): OmeZarrColorChannel | undefined { - return this.#getChannelByMask('#FF0000'); - } - - get greenChannel(): OmeZarrColorChannel | undefined { - return this.#getChannelByMask('#00FF00'); - } - - get blueChannel(): OmeZarrColorChannel | undefined { - return this.#getChannelByMask('#0000FF'); - } +export interface OmeZarrData { + shape: readonly number[]; + buffer: T; } diff --git a/packages/omezarr/tsconfig.json b/packages/omezarr/tsconfig.json index d8a6412f..5946286b 100644 --- a/packages/omezarr/tsconfig.json +++ b/packages/omezarr/tsconfig.json @@ -5,7 +5,7 @@ "~/*": ["./*"] }, "moduleResolution": "Bundler", - "module": "es6", + "module": "esnext", "target": "es2024", "lib": ["es2024", "DOM"] }, diff --git a/site/src/examples/common/filesets/omezarr.ts b/site/src/examples/common/filesets/omezarr.ts index 8cb07d39..d2403a64 100644 --- a/site/src/examples/common/filesets/omezarr.ts +++ b/site/src/examples/common/filesets/omezarr.ts @@ -1,16 +1,18 @@ import type { WebResource } from '@alleninstitute/vis-core'; -export type OmeZarrDemoFileset = { value: string; label: string; res: WebResource }; +export type OmeZarrDemoFileset = { value: string; label: string; zarrVersion: number; res: WebResource }; export const OMEZARR_DEMO_FILESETS: OmeZarrDemoFileset[] = [ { value: 'opt1', label: 'VERSA OME-Zarr Example (HTTPS) (color channels: [R, G, B])', + zarrVersion: 2, res: { type: 'https', url: 'https://neuroglancer-vis-prototype.s3.amazonaws.com/VERSA/scratch/0500408166/' }, }, { value: 'opt2', label: 'VS200 Example Image (S3) (color channels: [CFP, YFP])', + zarrVersion: 2, res: { type: 's3', region: 'us-west-2', @@ -20,6 +22,7 @@ export const OMEZARR_DEMO_FILESETS: OmeZarrDemoFileset[] = [ { value: 'opt3', label: 'EPI Example Image (S3) (color channels: [R, G, B])', + zarrVersion: 2, res: { type: 's3', region: 'us-west-2', @@ -29,6 +32,7 @@ export const OMEZARR_DEMO_FILESETS: OmeZarrDemoFileset[] = [ { value: 'opt4', label: 'STPT Example Image (S3) (color channels: [R, G, B])', + zarrVersion: 2, res: { type: 's3', region: 'us-west-2', @@ -38,6 +42,7 @@ export const OMEZARR_DEMO_FILESETS: OmeZarrDemoFileset[] = [ { value: 'opt5', label: 'Smart-SPIM (experimental)', + zarrVersion: 2, res: { type: 's3', region: 'us-west-2', @@ -46,7 +51,8 @@ export const OMEZARR_DEMO_FILESETS: OmeZarrDemoFileset[] = [ }, { value: 'opt6', - label: 'SmartSpim Lightsheet', + label: 'SmartSPIM Lightsheet', + zarrVersion: 2, res: { type: 's3', region: 'us-west-2', @@ -55,7 +61,8 @@ export const OMEZARR_DEMO_FILESETS: OmeZarrDemoFileset[] = [ }, { value: 'opt7', - label: 'V3 Zarr Example Image (S3) (color channels: [R, G, B])', + label: 'VS200 Brightfield #1458501514 (Zarr v3)', + zarrVersion: 3, res: { type: 's3', region: 'us-west-2', @@ -64,7 +71,8 @@ export const OMEZARR_DEMO_FILESETS: OmeZarrDemoFileset[] = [ }, { value: 'opt8', - label: 'STPT V3 example', + label: 'STPT #802451596237 (Zarr v3)', + zarrVersion: 3, res: { type: 's3', region: 'us-west-2', @@ -74,10 +82,31 @@ export const OMEZARR_DEMO_FILESETS: OmeZarrDemoFileset[] = [ { value: 'opt9', label: 'Tissuecyte #1196424284', + zarrVersion: 2, res: { type: 's3', region: 'us-west-2', url: 's3://allen-genetic-tools/tissuecyte/1196424284/ome_zarr_conversion/1196424284.zarr/', }, }, + { + value: 'opt10', + label: 'VS200 Epifluorescence #1161134570 (Zarr v3)', + zarrVersion: 3, + res: { + type: 's3', + region: 'us-west-2', + url: 's3://cortex-aav-toolbox-802451596237-us-west-2/epifluorescence/1161134570/ome_zarr_conversion/1161134570.zarr', + }, + }, + { + value: 'opt11', + label: 'VERSA Epifluorescence #1161864579 (Zarr v3)', + zarrVersion: 3, + res: { + type: 's3', + region: 'us-west-2', + url: 's3://cortex-aav-toolbox-802451596237-us-west-2/epifluorescence/1161864579/ome_zarr_conversion/1161864579.zarr', + }, + }, ]; diff --git a/site/src/examples/common/loaders/ome-zarr/fetchSlice.worker.ts b/site/src/examples/common/loaders/ome-zarr/fetchSlice.worker.ts deleted file mode 100644 index 41ff1a69..00000000 --- a/site/src/examples/common/loaders/ome-zarr/fetchSlice.worker.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { makeOmeZarrSliceLoaderWorker } from '@alleninstitute/vis-omezarr'; -// a web-worker which fetches slices of data, decodes them, and returns the result as a flat float32 array, using transferables - -const ctx = self; - -makeOmeZarrSliceLoaderWorker(ctx); diff --git a/site/src/examples/common/loaders/ome-zarr/sliceWorkerPool.ts b/site/src/examples/common/loaders/ome-zarr/sliceWorkerPool.ts deleted file mode 100644 index 1c167e9c..00000000 --- a/site/src/examples/common/loaders/ome-zarr/sliceWorkerPool.ts +++ /dev/null @@ -1,95 +0,0 @@ -import type { Decoder, OmeZarrMetadata, OmeZarrShapedDataset, ZarrRequest } from '@alleninstitute/vis-omezarr'; -import uniqueId from 'lodash/uniqueId'; -import type { ZarrSliceRequest } from './types'; - -type PromisifiedMessage = { - requestCacheKey: string; - resolve: (t: Slice) => void; - reject: (reason: unknown) => void; - promise?: Promise | undefined; -}; -type ExpectedResultSlice = { - type: 'slice'; - id: string; -} & Slice; -type Slice = { - data: Float32Array; - shape: number[]; -}; -function isExpectedResult(obj: unknown): obj is ExpectedResultSlice { - return typeof obj === 'object' && obj !== null && 'type' in obj && obj.type === 'slice'; -} -export class SliceWorkerPool { - private workers: Worker[]; - private promises: Record; - private which: number; - constructor(size: number) { - this.workers = new Array(size); - for (let i = 0; i < size; i++) { - this.workers[i] = new Worker(new URL('./fetchSlice.worker.ts', import.meta.url), { type: 'module' }); - this.workers[i].onmessage = (msg) => this.handleResponse(msg); - } - this.promises = {}; - this.which = 0; - } - - handleResponse(msg: MessageEvent) { - const { data: payload } = msg; - if (isExpectedResult(payload)) { - const prom = this.promises[payload.id]; - if (prom) { - const { data, shape } = payload; - prom.resolve({ data, shape }); - delete this.promises[payload.id]; - } - } - } - private roundRobin() { - this.which = (this.which + 1) % this.workers.length; - } - requestSlice(metadata: OmeZarrMetadata, req: ZarrRequest, level: OmeZarrShapedDataset, signal?: AbortSignal) { - const reqId = uniqueId('rq'); - const cacheKey = JSON.stringify({ url: metadata.url, req, level }); - const myWorker = this.which; - - // TODO caching I guess... - const eventually = new Promise((resolve, reject) => { - this.promises[reqId] = { - requestCacheKey: cacheKey, - resolve, - reject, - promise: undefined, // ill get added to the map once I am fully defined! - }; - const message: ZarrSliceRequest = { - id: reqId, - type: 'ZarrSliceRequest', - metadata: metadata.dehydrate(), - req, - level, - }; - if (signal) { - signal.onabort = () => { - this.workers[myWorker].postMessage({ type: 'cancel', id: reqId }); - this.promises[reqId]?.reject('cancelled'); - }; - } - this.workers[myWorker].postMessage(message); - this.roundRobin(); - }); - this.promises[reqId].promise = eventually; - return eventually; - } -} - -// a singleton... -let slicePool: SliceWorkerPool; -export function getSlicePool() { - if (!slicePool) { - slicePool = new SliceWorkerPool(6); - } - return slicePool; -} - -export const multithreadedDecoder: Decoder = (metadata, req, level: OmeZarrShapedDataset, signal?: AbortSignal) => { - return getSlicePool().requestSlice(metadata, req, level, signal); -}; diff --git a/site/src/examples/common/loaders/ome-zarr/types.ts b/site/src/examples/common/loaders/ome-zarr/types.ts deleted file mode 100644 index 9f4d2d48..00000000 --- a/site/src/examples/common/loaders/ome-zarr/types.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { DehydratedOmeZarrMetadata, OmeZarrShapedDataset, ZarrRequest } from '@alleninstitute/vis-omezarr'; - -export type ZarrSliceRequest = { - id: string; - type: 'ZarrSliceRequest'; - metadata: DehydratedOmeZarrMetadata; - req: ZarrRequest; - level: OmeZarrShapedDataset; -}; - -export type CancelRequest = { - type: 'cancel'; - id: string; -}; - -export function isSliceRequest(payload: unknown): payload is ZarrSliceRequest { - return typeof payload === 'object' && payload !== null && 'type' in payload && payload.type === 'ZarrSliceRequest'; -} -export function isCancellationRequest(payload: unknown): payload is CancelRequest { - return typeof payload === 'object' && payload !== null && 'type' in payload && payload.type === 'cancel'; -} diff --git a/site/src/examples/common/loaders/ome-zarr/fetch.worker.ts b/site/src/examples/common/loaders/omezarr/fetch.worker.ts similarity index 100% rename from site/src/examples/common/loaders/ome-zarr/fetch.worker.ts rename to site/src/examples/common/loaders/omezarr/fetch.worker.ts diff --git a/site/src/examples/data-renderers/versa-renderer.ts b/site/src/examples/data-renderers/versa-renderer.ts index 79bec6ef..774d309a 100644 --- a/site/src/examples/data-renderers/versa-renderer.ts +++ b/site/src/examples/data-renderers/versa-renderer.ts @@ -8,18 +8,17 @@ import { type vec4, } from '@alleninstitute/vis-geometry'; import { + type OmeZarrConnection, type OmeZarrMetadata, - type ZarrRequest, - pickBestScale, - planeSizeInVoxels, - sizeInUnits, + type ZarrDataSpecifier, + toZarrDataSpecifier, + type VoxelTile, } from '@alleninstitute/vis-omezarr'; import { logger } from '@alleninstitute/vis-core'; import omit from 'lodash/omit'; import type REGL from 'regl'; import type { Framebuffer2D } from 'regl'; import type { Camera } from '../common/camera'; -import { getSlicePool } from '../common/loaders/ome-zarr/sliceWorkerPool'; const TILE_SIZE = 256; @@ -163,75 +162,44 @@ type Bfr = { type: 'texture2D'; data: REGL.Texture2D }; export type VoxelSliceRenderSettings = { regl: REGL.Regl; - metadata: OmeZarrMetadata; + connection: OmeZarrConnection; view: box2D; rotation: number; gamut: Record<'R' | 'G' | 'B', { gamut: Interval; index: number }>; viewport: REGL.BoundingBox; target: REGL.Framebuffer2D | null; }; -export type AxisAlignedPlane = 'xy' | 'yz' | 'xz'; -export type VoxelTile = { - plane: CartesianPlane; - realBounds: box2D; - bounds: box2D; // in voxels, in the plane - planeIndex: number; - layerIndex: number; - // time and channel are always = 0, for now -}; -function toZarrRequest(tile: VoxelTile, channel: number): ZarrRequest { - const { plane, planeIndex, bounds } = tile; - const { minCorner: min, maxCorner: max } = bounds; - const u = { min: min[0], max: max[0] }; - const v = { min: min[1], max: max[1] }; - switch (plane.axes) { - case 'xy': - return { - x: u, - y: v, - t: 0, - c: channel, - z: planeIndex, - }; - case 'xz': - return { - x: u, - z: v, - t: 0, - c: channel, - y: planeIndex, - }; - case 'yz': - return { - y: u, - z: v, - t: 0, - c: channel, - x: planeIndex, - }; - } -} +// export type VoxelTile = { +// plane: CartesianPlane; +// realBounds: box2D; +// bounds: box2D; // in voxels, in the plane +// planeIndex: number; +// layerIndex: number; +// // time and channel are always = 0, for now +// }; + export function cacheKeyFactory(col: string, item: VoxelTile, settings: VoxelSliceRenderSettings) { - return `${settings.metadata.url}_${JSON.stringify(omit(item, 'desiredResolution'))}_ch=${ + return `${settings.connection.url}_${JSON.stringify(omit(item, 'desiredResolution'))}_ch=${ settings.gamut[col as 'R' | 'G' | 'B'].index }`; } -function reqSlice(dataset: OmeZarrMetadata, req: ZarrRequest, layerIndex: number) { - const layer = dataset.getShapedDataset(layerIndex, 0); +function reqSlice(connection: OmeZarrConnection, spec: ZarrDataSpecifier, layerIndex: number) { + const layer = connection.metadata?.getLevel({ index: layerIndex, multiscale: { index: 0 } }); if (!layer) { return Promise.reject('no such layer'); } - return getSlicePool().requestSlice(dataset, req, layer); + return connection.loadData<'float32'>(spec); } + const LUMINANCE = 'luminance'; export function requestsForTile(tile: VoxelTile, settings: VoxelSliceRenderSettings, _signal?: AbortSignal) { - const { metadata, regl } = settings; + const { connection, regl } = settings; const handleResponse = (vxl: Awaited>) => { - const { shape, data } = vxl; + const { shape, buffer } = vxl; const r = regl.texture({ - data, + data: new Float32Array(buffer.data), width: shape[1], height: shape[0], // TODO this swap is sus format: LUMINANCE, @@ -241,15 +209,27 @@ export function requestsForTile(tile: VoxelTile, settings: VoxelSliceRenderSetti // lets hope the browser caches our 3x repeat calls to teh same data... return { R: async () => { - const vxl = await reqSlice(metadata, toZarrRequest(tile, settings.gamut.R.index), tile.layerIndex); + const vxl = await reqSlice( + connection, + toZarrDataSpecifier(tile, settings.gamut.R.index), + tile.level.datasetIndex, + ); return { type: 'texture2D', data: handleResponse(vxl) }; }, G: async () => { - const vxl = await reqSlice(metadata, toZarrRequest(tile, settings.gamut.G.index), tile.layerIndex); + const vxl = await reqSlice( + connection, + toZarrDataSpecifier(tile, settings.gamut.G.index), + tile.level.datasetIndex, + ); return { type: 'texture2D', data: handleResponse(vxl) }; }, B: async () => { - const vxl = await reqSlice(metadata, toZarrRequest(tile, settings.gamut.B.index), tile.layerIndex); + const vxl = await reqSlice( + connection, + toZarrDataSpecifier(tile, settings.gamut.B.index), + tile.level.datasetIndex, + ); return { type: 'texture2D', data: handleResponse(vxl) }; }, }; @@ -274,12 +254,12 @@ export function getVisibleTiles( metadata: OmeZarrMetadata, offset?: vec2, ): { layer: number; view: box2D; tiles: VoxelTile[] } { - const layer = pickBestScale(metadata, plane, camera.view, camera.screen); + const level = metadata.pickBestScale(plane, camera.view, camera.screen); // TODO: open the array, look at its chunks, use that size for the size of the tiles I request! - const layerIndex = metadata.attrs.multiscales[0].datasets.findIndex((ds) => ds.path === layer.path); + const layerIndex = metadata.attrs.multiscales[0].datasets.findIndex((ds) => ds.path === level.path); - const size = planeSizeInVoxels(plane, metadata.attrs.multiscales[0].axes, layer); - const realSize = sizeInUnits(plane, metadata.attrs.multiscales[0].axes, layer); + const size = level.planeSizeInVoxels(plane); + const realSize = level.sizeInUnits(plane); if (!size || !realSize) return { layer: layerIndex, view: Box2D.create([0, 0], [1, 1]), tiles: [] }; const scale = Vec2.div(realSize, size); // to go from a voxel-box to a real-box (easier than you think, as both have an origin at 0,0, because we only support scale...) @@ -294,10 +274,11 @@ export function getVisibleTiles( layer: layerIndex, view: camera.view, tiles: inView.map((uv) => ({ - plane, + plane: plane.axes, realBounds: vxlToReal(uv), bounds: uv, - planeIndex, + orthoVal: planeIndex, + level, layerIndex, })), }; diff --git a/site/src/examples/data-renderers/volumeSliceRenderer.ts b/site/src/examples/data-renderers/volumeSliceRenderer.ts index 7c1cd2aa..707b4a3d 100644 --- a/site/src/examples/data-renderers/volumeSliceRenderer.ts +++ b/site/src/examples/data-renderers/volumeSliceRenderer.ts @@ -3,19 +3,18 @@ import type REGL from 'regl'; import type { RenderCallback } from './types'; import { Box2D, CartesianPlane, Vec2, type vec2 } from '@alleninstitute/vis-geometry'; -import { pickBestScale, sizeInUnits, sizeInVoxels } from '@alleninstitute/vis-omezarr'; import type { Camera } from '../common/camera'; import type { AxisAlignedZarrSlice } from '../data-sources/ome-zarr/planar-slice'; import type { AxisAlignedZarrSliceGrid } from '../data-sources/ome-zarr/slice-grid'; import { applyOptionalTrn } from './utils'; import { type VoxelSliceRenderSettings, - type VoxelTile, type buildVersaRenderer, cacheKeyFactory, getVisibleTiles, requestsForTile, } from './versa-renderer'; +import type { VoxelTile } from '@alleninstitute/vis-omezarr'; type Renderer = ReturnType; type CacheContentType = { type: 'texture2D'; data: REGL.Texture2D }; @@ -87,10 +86,11 @@ export function renderGrid( const rowSize = Math.floor(Math.sqrt(slices)); const allItems: VoxelTile[] = []; const smokeAndMirrors: VoxelTile[] = []; - const best = pickBestScale(metadata, plane, camera.view, camera.screen); + const bestLevel = metadata.pickBestScale(plane, camera.view, camera.screen); const renderSettings = { metadata, + connection: grid.connection, gamut, regl, rotation: grid.rotation, @@ -118,11 +118,11 @@ export function renderGrid( ...camera, view: applyOptionalTrn(camera.view, slice.toModelSpace, true), }; - const dim = sizeInVoxels(plane.ortho, axes, best); - const realSize = sizeInUnits(plane, axes, best); + const dim = bestLevel.sizeInVoxels(plane.ortho); + const realSize = bestLevel.sizeInUnits(plane); if (!realSize) { - logger.warn('no size for plane', plane, axes, best); + logger.warn('no size for plane', plane, axes, bestLevel); continue; } @@ -172,9 +172,8 @@ export function renderSlice( ...camera, view: applyOptionalTrn(camera.view, slice.toModelSpace, true), }; - const best = pickBestScale(metadata, plane, camera.view, desiredResolution); - const axes = metadata.attrs.multiscales[0].axes; - const dim = sizeInVoxels(plane.ortho, axes, best); + const best = metadata.pickBestScale(plane, camera.view, desiredResolution); + const dim = best.sizeInVoxels(plane.ortho); const orthoVal = Math.round(planeParameter * (dim ?? 0)); const items = getVisibleTiles({ ...camera, screen: desiredResolution }, plane, orthoVal, metadata); @@ -184,7 +183,7 @@ export function renderSlice( items.tiles, cache, { - metadata, + connection: slice.connection, gamut, regl, rotation: slice.rotation, diff --git a/site/src/examples/data-sources/ome-zarr/planar-slice.ts b/site/src/examples/data-sources/ome-zarr/planar-slice.ts index 775aa0ef..8fdfcb89 100644 --- a/site/src/examples/data-sources/ome-zarr/planar-slice.ts +++ b/site/src/examples/data-sources/ome-zarr/planar-slice.ts @@ -1,13 +1,15 @@ -import { type OmeZarrMetadata, loadMetadata } from '@alleninstitute/vis-omezarr'; -import type { AxisAlignedPlane } from '../../data-renderers/versa-renderer'; +import { CachedOmeZarrConnection, type OmeZarrMetadata, type OmeZarrConnection } from '@alleninstitute/vis-omezarr'; import type { ColorMapping } from '../../data-renderers/types'; import type { OptionalTransform, Simple2DTransform } from '../types'; -import { CartesianPlane } from '@alleninstitute/vis-geometry'; +import { CartesianPlane, type OrthogonalCartesianAxes } from '@alleninstitute/vis-geometry'; import type { WebResource } from '@alleninstitute/vis-core'; + +const workerFactory = () => new Worker(new URL('../../common/loaders/omezarr/fetch.worker.ts', import.meta.url)); + export type ZarrSliceConfig = { type: 'zarrSliceConfig'; resource: WebResource; - plane: AxisAlignedPlane; + plane: OrthogonalCartesianAxes; planeParameter: number; // [0:1] eg. if if plane is 'xy' and parameter is 0.5, then we want the slice from the middle of the z-axis gamut: ColorMapping; rotation?: number; @@ -16,18 +18,25 @@ export type ZarrSliceConfig = { export type AxisAlignedZarrSlice = { type: 'AxisAlignedZarrSlice'; + connection: OmeZarrConnection; metadata: OmeZarrMetadata; plane: CartesianPlane; planeParameter: number; gamut: ColorMapping; rotation: number; } & OptionalTransform; -function assembleZarrSlice(config: ZarrSliceConfig, metadata: OmeZarrMetadata): AxisAlignedZarrSlice { + +function assembleZarrSlice( + config: ZarrSliceConfig, + connection: OmeZarrConnection, + metadata: OmeZarrMetadata, +): AxisAlignedZarrSlice { const { rotation, trn } = config; return { ...config, plane: new CartesianPlane(config.plane), type: 'AxisAlignedZarrSlice', + connection, metadata, toModelSpace: trn, rotation: rotation ?? 0, @@ -35,7 +44,8 @@ function assembleZarrSlice(config: ZarrSliceConfig, metadata: OmeZarrMetadata): } export function createZarrSlice(config: ZarrSliceConfig): Promise { const { resource } = config; - return loadMetadata(resource).then((metadata) => { - return assembleZarrSlice(config, metadata); + const connection = new CachedOmeZarrConnection(resource, workerFactory); + return connection.loadMetadata().then((metadata) => { + return assembleZarrSlice(config, connection, metadata); }); } diff --git a/site/src/examples/data-sources/ome-zarr/slice-grid.ts b/site/src/examples/data-sources/ome-zarr/slice-grid.ts index c11c5aec..d80bfb96 100644 --- a/site/src/examples/data-sources/ome-zarr/slice-grid.ts +++ b/site/src/examples/data-sources/ome-zarr/slice-grid.ts @@ -1,13 +1,15 @@ -import { type OmeZarrMetadata, loadMetadata } from '@alleninstitute/vis-omezarr'; -import type { AxisAlignedPlane } from '../../data-renderers/versa-renderer'; +import { type OmeZarrConnection, CachedOmeZarrConnection, type OmeZarrMetadata } from '@alleninstitute/vis-omezarr'; import type { ColorMapping } from '../../data-renderers/types'; import type { OptionalTransform, Simple2DTransform } from '../types'; import type { WebResource } from '@alleninstitute/vis-core'; +import type { OrthogonalCartesianAxes } from '@alleninstitute/vis-geometry'; + +const workerFactory = () => new Worker(new URL('../../common/loaders/omezarr/fetch.worker.ts', import.meta.url)); export type ZarrSliceGridConfig = { type: 'ZarrSliceGridConfig'; resource: WebResource; - plane: AxisAlignedPlane; + plane: OrthogonalCartesianAxes; slices: number; // divide this volume into this many slices, and arrange them in a grid. gamut: ColorMapping; rotation?: number; @@ -15,18 +17,24 @@ export type ZarrSliceGridConfig = { }; export type AxisAlignedZarrSliceGrid = { type: 'AxisAlignedZarrSliceGrid'; + connection: OmeZarrConnection; metadata: OmeZarrMetadata; - plane: AxisAlignedPlane; + plane: OrthogonalCartesianAxes; slices: number; gamut: ColorMapping; rotation: number; } & OptionalTransform; -function assembleZarrSliceGrid(config: ZarrSliceGridConfig, metadata: OmeZarrMetadata): AxisAlignedZarrSliceGrid { +function assembleZarrSliceGrid( + config: ZarrSliceGridConfig, + connection: OmeZarrConnection, + metadata: OmeZarrMetadata, +): AxisAlignedZarrSliceGrid { const { rotation, trn } = config; return { ...config, type: 'AxisAlignedZarrSliceGrid', + connection, metadata, toModelSpace: trn, rotation: rotation ?? 0, @@ -34,7 +42,8 @@ function assembleZarrSliceGrid(config: ZarrSliceGridConfig, metadata: OmeZarrMet } export function createZarrSliceGrid(config: ZarrSliceGridConfig): Promise { const { resource } = config; - return loadMetadata(resource).then((metadata) => { - return assembleZarrSliceGrid(config, metadata); + const connection = new CachedOmeZarrConnection(resource, workerFactory); + return connection.loadMetadata().then((metadata) => { + return assembleZarrSliceGrid(config, connection, metadata); }); } diff --git a/site/src/examples/layers.ts b/site/src/examples/layers.ts index f89b681b..8917f11f 100644 --- a/site/src/examples/layers.ts +++ b/site/src/examples/layers.ts @@ -1,5 +1,11 @@ -import { Box2D, CartesianPlane, Vec2, type box2D, type vec2 } from '@alleninstitute/vis-geometry'; -import { sizeInUnits } from '@alleninstitute/vis-omezarr'; +import { + Box2D, + CartesianPlane, + Vec2, + type box2D, + type OrthogonalCartesianAxes, + type vec2, +} from '@alleninstitute/vis-geometry'; import { AsyncDataCache, type FrameLifecycle, logger, type NormalStatus, ReglLayer2D } from '@alleninstitute/vis-core'; import pkg from 'file-saver'; const { saveAs } = pkg; @@ -28,7 +34,7 @@ import { renderAnnotationLayer, } from './data-renderers/simpleAnnotationRenderer'; import type { ColorMapping, RenderCallback } from './data-renderers/types'; -import { type AxisAlignedPlane, buildVersaRenderer } from './data-renderers/versa-renderer'; +import { buildVersaRenderer } from './data-renderers/versa-renderer'; import { type RenderSettings as SliceRenderSettings, renderGrid, @@ -175,7 +181,7 @@ export class Demo { this.uiChange(); } } - setPlane(param: AxisAlignedPlane) { + setPlane(param: OrthogonalCartesianAxes) { const layer = this.layers[this.selectedLayer]; if (layer && (layer.type === 'volumeSlice' || layer.type === 'volumeGrid')) { layer.data.plane = new CartesianPlane(param); @@ -307,7 +313,7 @@ export class Demo { } private addVolumeSlice(config: ZarrSliceConfig) { const [w, h] = this.camera.screen; - return createZarrSlice(config).then((data) => { + return createZarrSlice(config).then((zarrSlice) => { const layer = new ReglLayer2D< AxisAlignedZarrSlice & OptionalTransform, Omit, 'target'> @@ -320,18 +326,17 @@ export class Demo { ); this.layers.push({ type: 'volumeSlice', - data, + data: zarrSlice, render: layer, }); - const axes = data.metadata.attrs.multiscales[0].axes; - const dataset = data.metadata.getFirstShapedDataset(0); - if (!dataset) { + const level = zarrSlice.metadata.getLevel({ index: 0 }); + if (!level) { throw new Error('invalid Zarr data: dataset 0 not found!'); } - const s = sizeInUnits(data.plane, axes, dataset); + const s = level.sizeInUnits(zarrSlice.plane); if (!s) { - logger.warn('no size for plane', data.plane, axes); + logger.warn('no size for plane', zarrSlice.plane, level.axes); return; } diff --git a/site/src/examples/omezarr/minimal-example/example.tsx b/site/src/examples/omezarr/minimal-example/example.tsx index 070501d9..675e6232 100644 --- a/site/src/examples/omezarr/minimal-example/example.tsx +++ b/site/src/examples/omezarr/minimal-example/example.tsx @@ -1,31 +1,31 @@ -import { RenderServerProvider } from 'src/examples/common/react/render-server-provider'; -import { SliceView } from './sliceview'; +import { CachedOmeZarrConnection, type OmeZarrConnection } from '@alleninstitute/vis-omezarr'; import { useEffect, useState } from 'react'; -import { loadMetadata, type OmeZarrMetadata } from '@alleninstitute/vis-omezarr'; -import { logger } from '@alleninstitute/vis-core'; -import { OMEZARR_DEMO_FILESETS } from 'src/examples/common/filesets/omezarr'; +import { SliceView } from './sliceview'; +import { OMEZARR_DEMO_FILESETS } from '../../common/filesets/omezarr'; +import { RenderServerProvider } from '../../common/react/render-server-provider'; /** * HEY!!! * this is an example React Component for rendering A single slice of an OMEZARR image in a react component * This example is as bare-bones as possible! It is NOT the recommended way to do anything, its just trying to show - * one way of: - * 1. using our rendering utilities for OmeZarr data, specifically in a react component. Your needs for state-management, - * slicing logic, etc might all be different! - * + * one way of using our rendering utilities for OmeZarr data, specifically in a react component. + * Your needs for state-management, slicing logic, etc might all be different! */ -export function DataPlease() { +export function BasicOmeZarrDemo() { // load our canned data for now: - const [omezarr, setfile] = useState(undefined); + const [connection, setConnection] = useState(undefined); + useEffect(() => { - loadMetadata(OMEZARR_DEMO_FILESETS[0].res).then((dataset) => { - setfile(dataset); - logger.info('loaded!'); - }); + const connection = new CachedOmeZarrConnection( + OMEZARR_DEMO_FILESETS[0].res, + () => new Worker(new URL('../../common/loaders/fetch.worker.ts', import.meta.url)), + ); + setConnection(connection); + connection.loadMetadata(); }, []); return ( - + ); } diff --git a/site/src/examples/omezarr/minimal-example/sliceview.tsx b/site/src/examples/omezarr/minimal-example/sliceview.tsx index 364d9545..e52bec14 100644 --- a/site/src/examples/omezarr/minimal-example/sliceview.tsx +++ b/site/src/examples/omezarr/minimal-example/sliceview.tsx @@ -2,6 +2,7 @@ import { Box2D, PLANE_XY, Vec2, type box2D } from '@alleninstitute/vis-geometry' import { type RenderSettings, type VoxelTile, + type OmeZarrConnection, type OmeZarrMetadata, buildAsyncOmezarrRenderer, defaultDecoder, @@ -12,7 +13,7 @@ import { useContext, useEffect, useRef } from 'react'; import { renderServerContext } from '../../common/react/render-server-provider'; type Props = { - omezarr: OmeZarrMetadata | undefined; + omezarr: OmeZarrConnection | undefined; }; const settings: RenderSettings = { tileSize: 256, @@ -60,22 +61,23 @@ export function SliceView(props: Props) { const renderer = useRef>(undefined); const [view, setView] = useState(Box2D.create([0, 0], [250, 120])); useEffect(() => { - if (server?.regl) { - renderer.current = buildAsyncOmezarrRenderer(server.regl, defaultDecoder); + if (server?.regl && omezarr !== undefined) { + renderer.current = buildAsyncOmezarrRenderer(server.regl, omezarr, defaultDecoder); } return () => { if (cnvs.current) { server?.destroyClient(cnvs.current); } }; - }, [server]); + }, [server, omezarr]); useEffect(() => { - if (server && renderer.current && cnvs.current && omezarr) { + if (server && renderer.current && cnvs.current && omezarr?.metadata && omezarr.metadata !== null) { + const metadata = omezarr.metadata; const renderFn: RenderFrameFn = (target, cache, callback) => { if (renderer.current) { return renderer.current( - omezarr, + metadata, { ...settings, camera: { ...settings.camera, view } }, callback, target, diff --git a/site/src/examples/omezarr/priority-cache-demo/omezarr-client.tsx b/site/src/examples/omezarr/priority-cache-demo/omezarr-client.tsx index ab68833d..a7bf437a 100644 --- a/site/src/examples/omezarr/priority-cache-demo/omezarr-client.tsx +++ b/site/src/examples/omezarr/priority-cache-demo/omezarr-client.tsx @@ -1,74 +1,46 @@ -/** biome-ignore-all lint/correctness/useExhaustiveDependencies: */ -import { getResourceUrl, logger, type WebResource } from '@alleninstitute/vis-core'; -import { Box2D, PLANE_XY, type box2D, type Interval, type vec2 } from '@alleninstitute/vis-geometry'; +/** biome-ignore-all lint/correctness/useExhaustiveDependencies: this is a demo, but not a demo of correct react-hook useage! */ +import { logger, type WebResource } from '@alleninstitute/vis-core'; +import { Box2D, PLANE_XY, type Interval, type vec2 } from '@alleninstitute/vis-geometry'; import { - type OmeZarrMetadata, - loadMetadata, - sizeInUnits, - type RenderSettings, - type RenderSettingsChannels, - nextSliceStep, + CachedOmeZarrConnection, + defaultDecoder, + makeRenderSettings, + type OmeZarrConnection, } from '@alleninstitute/vis-omezarr'; import { useContext, useState, useRef, useCallback, useEffect } from 'react'; import { zoom, pan } from '../../common/camera'; -import { decoderFactory } from '@alleninstitute/vis-omezarr'; import { SharedCacheContext } from '../../common/react/priority-cache-provider'; import { buildConnectedRenderer } from './render-utils'; - -const defaultInterval: Interval = { min: 0, max: 80 }; - -function makeZarrSettings(screenSize: vec2, view: box2D, param: number, omezarr: OmeZarrMetadata): RenderSettings { - const omezarrChannels = omezarr.colorChannels.reduce((acc, val, index) => { - acc[val.label ?? `${index}`] = { - rgb: val.rgb, - gamut: val.range, - index, - }; - return acc; - }, {} as RenderSettingsChannels); - - const fallbackChannels: RenderSettingsChannels = { - R: { rgb: [1.0, 0, 0], gamut: defaultInterval, index: 0 }, - G: { rgb: [0, 1.0, 0], gamut: defaultInterval, index: 1 }, - B: { rgb: [0, 0, 1.0], gamut: defaultInterval, index: 2 }, - }; - - return { - camera: { screenSize, view }, - planeLocation: param, - plane: PLANE_XY, - tileSize: 256, - channels: Object.keys(omezarrChannels).length > 0 ? omezarrChannels : fallbackChannels, - }; -} +const defaultGamut: Interval = { min: 0, max: 80 }; type Props = { res: WebResource; screenSize: vec2; }; -const WORKERS = new URL('../../common/loaders/ome-zarr/fetch.worker.ts', import.meta.url); +const workerFactory = () => new Worker(new URL('../../common/loaders/omezarr/fetch.worker.ts', import.meta.url)); -// const WORKERS = new URL('../common/loaders/ome-zarr/fetch-slice.worker', import.meta.url); export function OmeZarrView(props: Props) { const { screenSize } = props; const server = useContext(SharedCacheContext); - const [omezarr, setOmezarr] = useState(null); + const [omezarr, setOmezarr] = useState(null); const [view, setView] = useState(Box2D.create([0, 0], [1, 1])); const [planeParam, setPlaneParam] = useState(0.5); const [dragging, setDragging] = useState(false); const [renderer, setRenderer] = useState>(); const [tick, setTick] = useState(0); const cnvs = useRef(null); + const load = (res: WebResource) => { - loadMetadata(res).then((v) => { - setOmezarr(v); + const newOmeZarr = new CachedOmeZarrConnection(res, workerFactory); + newOmeZarr.loadMetadata().then((v) => { + setOmezarr(newOmeZarr); setPlaneParam(0.5); - const dataset = v.getFirstShapedDataset(0); - if (!dataset) { + const level = v.getLevel({ index: 0 }); + if (!level) { throw new Error('dataset 0 does not exist!'); } - const size = sizeInUnits(PLANE_XY, v.attrs.multiscales[0].axes, dataset); + const size = level.sizeInUnits(PLANE_XY); if (size) { logger.info('size', size); setView(Box2D.create([0, 0], size)); @@ -78,8 +50,8 @@ export function OmeZarrView(props: Props) { // you could put this on the mouse wheel, but for this demo we'll have buttons const handleScrollSlice = (next: 1 | -1) => { - if (omezarr) { - const step = nextSliceStep(omezarr, PLANE_XY, view, screenSize); + if (omezarr?.metadata) { + const step = omezarr.metadata.nextSliceStep(PLANE_XY, view, screenSize); setPlaneParam((prev) => Math.max(0, Math.min(prev + next * (step ?? 1), 1))); } }; @@ -109,27 +81,29 @@ export function OmeZarrView(props: Props) { const handleMouseUp = () => { setDragging(false); }; + useEffect(() => { if (cnvs.current && server && !renderer) { - const { decoder } = decoderFactory(getResourceUrl(props.res), WORKERS); - const { regl, cache } = server; - const renderer = buildConnectedRenderer(regl, screenSize, cache, decoder, () => { + const renderer = buildConnectedRenderer(regl, screenSize, cache, defaultDecoder, () => { requestAnimationFrame(() => { setTick(performance.now()); }); }); setRenderer(renderer); + if (omezarr) { + omezarr.close(); // VERY IMPORTANT! Cleans up soon-to-be-unused workers + } load(props.res); } }, [cnvs.current, props.res]); useEffect(() => { - if (omezarr && cnvs.current && renderer) { - const settings = makeZarrSettings(screenSize, view, planeParam, omezarr); + if (omezarr?.metadata && omezarr.metadata !== null && cnvs.current && renderer) { + const settings = makeRenderSettings(omezarr?.metadata, screenSize, view, planeParam, defaultGamut); const ctx = cnvs.current.getContext('2d'); if (ctx) { - renderer?.render(omezarr, settings); + renderer?.render(omezarr.metadata, settings); requestAnimationFrame(() => { renderer?.copyPixels(ctx); }); diff --git a/site/src/examples/omezarr/selectable-image-demo/omezarr-demo.tsx b/site/src/examples/omezarr/selectable-image-demo/omezarr-demo.tsx index 3d33485e..84b18be3 100644 --- a/site/src/examples/omezarr/selectable-image-demo/omezarr-demo.tsx +++ b/site/src/examples/omezarr/selectable-image-demo/omezarr-demo.tsx @@ -1,47 +1,28 @@ -import { Box2D, type Interval, PLANE_XY, type box2D, type vec2 } from '@alleninstitute/vis-geometry'; -import { type OmeZarrMetadata, loadMetadata, nextSliceStep, sizeInUnits } from '@alleninstitute/vis-omezarr'; -import type { RenderSettings, RenderSettingsChannels } from '@alleninstitute/vis-omezarr'; +import { Box2D, type Interval, PLANE_XY, type vec2 } from '@alleninstitute/vis-geometry'; +import { + CachedOmeZarrConnection, + type OmeZarrConnection, + type RenderSettings, + makeRenderSettings, +} from '@alleninstitute/vis-omezarr'; import { logger, type WebResource } from '@alleninstitute/vis-core'; import type React from 'react'; import { useId, useMemo, useState } from 'react'; import { pan, zoom } from '../../common/camera'; import { RenderServerProvider } from '../../common/react/render-server-provider'; -import { OmezarrViewer } from './omezarr-viewer'; +import { OmeZarrViewer } from './omezarr-viewer'; import { OMEZARR_DEMO_FILESETS } from 'src/examples/common/filesets/omezarr'; const screenSize: vec2 = [800, 800]; -const defaultInterval: Interval = { min: 0, max: 80 }; +const defaultGamut: Interval = { min: 0, max: 80 }; -function makeZarrSettings(screenSize: vec2, view: box2D, param: number, omezarr: OmeZarrMetadata): RenderSettings { - const omezarrChannels = omezarr.colorChannels.reduce((acc, val, index) => { - acc[val.label ?? `${index}`] = { - rgb: val.rgb, - gamut: val.range, - index, - }; - return acc; - }, {} as RenderSettingsChannels); - - const fallbackChannels: RenderSettingsChannels = { - R: { rgb: [1.0, 0, 0], gamut: defaultInterval, index: 0 }, - G: { rgb: [0, 1.0, 0], gamut: defaultInterval, index: 1 }, - B: { rgb: [0, 0, 1.0], gamut: defaultInterval, index: 2 }, - }; - - return { - camera: { screenSize, view }, - planeLocation: param, - plane: PLANE_XY, - tileSize: 256, - channels: Object.keys(omezarrChannels).length > 0 ? omezarrChannels : fallbackChannels, - }; -} +const workerFactory = () => new Worker(new URL('../../common/loaders/omezarr/fetch.worker.ts', import.meta.url)); export function OmezarrDemo() { const [customUrl, setCustomUrl] = useState(''); const [selectedDemoOptionValue, setSelectedDemoOptionValue] = useState(''); - const [omezarr, setOmezarr] = useState(null); + const [omezarr, setOmezarr] = useState(null); const [omezarrJson, setOmezarrJson] = useState(''); const [view, setView] = useState(Box2D.create([0, 0], [1, 1])); const [planeIndex, setPlaneParam] = useState(0); @@ -52,19 +33,23 @@ export function OmezarrDemo() { const omezarrId = useId(); const settings: RenderSettings | undefined = useMemo( - () => (omezarr ? makeZarrSettings(screenSize, view, planeIndex, omezarr) : undefined), + () => + omezarr?.metadata + ? makeRenderSettings(omezarr.metadata, screenSize, view, planeIndex, defaultGamut) + : undefined, [omezarr, view, planeIndex], ); - const load = (res: WebResource) => { - loadMetadata(res).then((v) => { - setOmezarr(v); - setOmezarrJson(JSON.stringify(v, undefined, 4)); - const dataset = v.getFirstShapedDataset(0); - if (!dataset) { + const load = async (res: WebResource) => { + const newOmezarr = new CachedOmeZarrConnection(res, workerFactory); + newOmezarr.loadMetadata().then((metadata) => { + setOmezarr(newOmezarr); + setOmezarrJson(JSON.stringify(metadata, undefined, 4)); + const level = metadata.getLevel({ index: 0 }); + if (!level) { throw new Error('dataset 0 does not exist!'); } - const size = sizeInUnits(PLANE_XY, v.attrs.multiscales[0].axes, dataset); + const size = level.sizeInUnits(PLANE_XY); if (size) { logger.info('size', size); setView(Box2D.create([0, 0], size)); @@ -74,6 +59,9 @@ export function OmezarrDemo() { const handleOptionSelected = (e: React.ChangeEvent) => { const selectedValue = e.target.value; + if (omezarr !== null) { + omezarr.close(); // VERY IMPORTANT! Cleans up web workers that won't be used anymore + } setOmezarr(null); setSelectedDemoOptionValue(selectedValue); if (selectedValue && selectedValue !== 'custom') { @@ -99,8 +87,8 @@ export function OmezarrDemo() { // you could put this on the mouse wheel, but for this demo we'll have buttons const handlePlaneIndex = (next: 1 | -1) => { - if (omezarr) { - const step = nextSliceStep(omezarr, PLANE_XY, view, screenSize); + if (omezarr?.metadata) { + const step = omezarr.metadata.nextSliceStep(PLANE_XY, view, screenSize); setPlaneParam((prev) => Math.max(0, Math.min(prev + next * (step ?? 1), 1))); } }; @@ -180,7 +168,7 @@ export function OmezarrDemo() { }} > {omezarr && settings && ( - {(omezarr && ( - Slide {Math.floor(planeIndex * (omezarr?.maxOrthogonal(PLANE_XY) ?? 1))} of{' '} - {omezarr?.maxOrthogonal(PLANE_XY) ?? 0} + Slide{' '} + {Math.floor(planeIndex * (omezarr.metadata?.maxOrthogonal(PLANE_XY) ?? 1))} of{' '} + {omezarr.metadata?.maxOrthogonal(PLANE_XY) ?? 0} )) || No image loaded}
diff --git a/site/src/examples/omezarr/selectable-image-demo/omezarr-viewer.tsx b/site/src/examples/omezarr/selectable-image-demo/omezarr-viewer.tsx index ab5bb442..ed617d88 100644 --- a/site/src/examples/omezarr/selectable-image-demo/omezarr-viewer.tsx +++ b/site/src/examples/omezarr/selectable-image-demo/omezarr-viewer.tsx @@ -4,16 +4,17 @@ import { type VoxelTile, type OmeZarrMetadata, buildAsyncOmezarrRenderer, + type OmeZarrConnection, + defaultDecoder, } from '@alleninstitute/vis-omezarr'; import type { RenderFrameFn, RenderServer } from '@alleninstitute/vis-core'; import { useContext, useEffect, useRef } from 'react'; import type REGL from 'regl'; import { renderServerContext } from '../../common/react/render-server-provider'; -import { multithreadedDecoder } from '../../common/loaders/ome-zarr/sliceWorkerPool'; import { buildImageRenderer } from '../../common/image-renderer'; interface OmezarrViewerProps { - omezarr: OmeZarrMetadata; + omezarr: OmeZarrConnection | null; id: string; screenSize: vec2; settings: RenderSettings; @@ -32,7 +33,7 @@ type StashedView = { image: REGL.Framebuffer2D; }; -export function OmezarrViewer({ +export function OmeZarrViewer({ omezarr, id, settings, @@ -52,9 +53,9 @@ export function OmezarrViewer({ useEffect(() => { const c = canvas?.current; - if (server?.regl && omezarr) { - const numChannels = omezarr.colorChannels.length || 3; - renderer.current = buildAsyncOmezarrRenderer(server.regl, multithreadedDecoder, { + if (server?.regl && omezarr?.metadata) { + const numChannels = omezarr.metadata.getColorChannels().length || 3; + renderer.current = buildAsyncOmezarrRenderer(server.regl, omezarr, defaultDecoder, { numChannels, queueOptions: { maximumInflightAsyncTasks: 2 }, }); @@ -128,12 +129,19 @@ export function OmezarrViewer({ stash.current.camera = { ...settings.camera }; } }; - if (server && renderer.current && canvas.current && omezarr) { + if ( + server && + renderer.current && + canvas.current && + omezarr?.metadata !== undefined && + omezarr.metadata !== null + ) { + const metadata = omezarr.metadata; const renderFrame: RenderFrameFn = (target, cache, callback) => { if (renderer.current) { // if we had a stashed buffer of the previous frame... // we could pre-load it into target, right here! - return renderer.current(omezarr, settings, callback, target, cache); + return renderer.current(metadata, settings, callback, target, cache); } return null; }; @@ -142,7 +150,7 @@ export function OmezarrViewer({ // if we had a stashed buffer of the previous frame... // we could pre-load it into target, right here! return renderer.current( - omezarr, + metadata, { ...settings, camera: { view: settings.camera.view, screenSize: [1, 1] } }, callback, target,