diff --git a/public/index.ts b/public/index.ts index f43afe93..84b3293c 100644 --- a/public/index.ts +++ b/public/index.ts @@ -31,6 +31,7 @@ import { OMEZarrSource } from "../src/data_manager/sources/index.js"; import { ChunkId, ChunkPriority, ChunkPriorityLevel } from "../src/data_manager/types.js"; import { ThreeInterface } from "../src/data_manager/device_interface.js"; import { TaskPool } from "../src/data_manager/task_pool/task_pool.js"; +import AdapterLoader from "../src/data_manager/adapter_loader.js"; const CACHE_MAX_SIZE = 1_000_000_000; const CONCURRENCY_LIMIT = 8; @@ -82,10 +83,11 @@ const TEST_DATA: Record = { }, zarrVariance: { type: VolumeFileFormat.ZARR, - url: [ - "https://animatedcell-test-data.s3.us-west-2.amazonaws.com/variance/1.zarr", - "https://animatedcell-test-data.s3.us-west-2.amazonaws.com/variance/2.zarr", - ], + // url: [ + // "https://animatedcell-test-data.s3.us-west-2.amazonaws.com/variance/1.zarr", + // "https://animatedcell-test-data.s3.us-west-2.amazonaws.com/variance/2.zarr", + // ], + url: "https://animatedcell-test-data.s3.us-west-2.amazonaws.com/variance/1.zarr", }, zarrNucmorph0: { type: VolumeFileFormat.ZARR, @@ -925,57 +927,6 @@ function shuffle(arr: T[]): T[] { return result; } -async function testDataManager() { - const pool = new TaskPool(new URL("./test_worker.js", import.meta.url)); - const dataManager = new DataManager(new ThreeInterface()); - const zarrSource = await OMEZarrSource.new( - // "https://animatedcell-test-data.s3.us-west-2.amazonaws.com/variance/1.zarr" - "https://vast-files.int.allencell.org/users/alex.khang/repos/bioio_conversion/H2B_640_CAAX_488_20250904_flat_colony_1.ome.zarr/", - { pool } - ); - const sourceId = dataManager.addSource(zarrSource); - const mockSubscriber: IDataSubscriber = { - onChunkLoaded: (id, chunk) => console.log("load", id, chunk), - onChunkOnGpu: (id, texture) => console.log("on device", id, texture), - }; - dataManager.subscribeToSource(mockSubscriber, sourceId); - - const dims = zarrSource.getDims(); - const multiscale = dims.length - 2; - const levelDims = dims[multiscale]; - const [_dt, _dc, dz, dy, dx] = levelDims.shape; - const [_ct, _cc, cz, cy, cx] = levelDims.chunkShape; - const chunksX = Math.ceil(dx / cx); - const chunksY = Math.ceil(dy / cy); - const chunksZ = Math.ceil(dz / cz); - console.log("dims", chunksX, chunksY, chunksZ); - - const requests: [ChunkId, ChunkPriority][] = []; - for (let z = 0; z < chunksZ; z++) { - for (let y = 0; y < chunksY; y++) { - for (let x = 0; x < chunksX; x++) { - const id: ChunkId = { - source: sourceId, - multiscale, - tczyx: [0, 0, z, y, x], - }; - const priority: ChunkPriority = { - level: ChunkPriorityLevel.VISIBLE, - score: (z + 1) * (y + 1) * (x + 1), - }; - requests.push([id, priority]); - } - } - } - - for (const [id, priority] of shuffle(requests)) { - console.log("request", id, priority); - dataManager.addChunkRequest(mockSubscriber, id, priority, true); - } - - dataManager.update(); -} - function loadImageData(jsonData: ImageInfo, volumeData: Uint8Array[]) { const vol = new Volume(jsonData); myState.volume = vol; @@ -1172,11 +1123,73 @@ function createTestVolume(dtype: NumberType): RawArrayLoaderOptions { }; } +const pool = new TaskPool(new URL("./test_worker.js", import.meta.url)); +const dataManager = new DataManager(new ThreeInterface()); + +async function testDataManager() { + const zarrSource = await OMEZarrSource.new( + // "https://animatedcell-test-data.s3.us-west-2.amazonaws.com/variance/1.zarr" + "https://vast-files.int.allencell.org/users/alex.khang/repos/bioio_conversion/H2B_640_CAAX_488_20250904_flat_colony_1.ome.zarr/", + { pool } + ); + const sourceId = dataManager.addSource(zarrSource); + const mockSubscriber: IDataSubscriber = { + onChunkLoaded: (id, chunk) => console.log("load", id, chunk), + onChunkOnGpu: (id, texture) => console.log("on device", id, texture), + }; + dataManager.subscribeToSource(mockSubscriber, sourceId); + + const dims = zarrSource.getDims(); + const multiscale = dims.length - 2; + const levelDims = dims[multiscale]; + const [_dt, _dc, dz, dy, dx] = levelDims.shape; + const [_ct, _cc, cz, cy, cx] = levelDims.chunkShape; + const chunksX = Math.ceil(dx / cx); + const chunksY = Math.ceil(dy / cy); + const chunksZ = Math.ceil(dz / cz); + console.log("dims", chunksX, chunksY, chunksZ); + + const requests: [ChunkId, ChunkPriority][] = []; + for (let z = 0; z < chunksZ; z++) { + for (let y = 0; y < chunksY; y++) { + for (let x = 0; x < chunksX; x++) { + const id: ChunkId = { + source: sourceId, + multiscale, + tczyx: [0, 0, z, y, x], + }; + const priority: ChunkPriority = { + level: ChunkPriorityLevel.VISIBLE, + score: (z + 1) * (y + 1) * (x + 1), + }; + requests.push([id, priority]); + } + } + } + + for (const [id, priority] of shuffle(requests)) { + console.log("request", id, priority); + dataManager.addChunkRequest(mockSubscriber, id, priority, true); + } + + dataManager.update(); +} + +async function createAdapterLoader(url: string): Promise { + const source = await OMEZarrSource.new(url, { pool }); + const sourceId = dataManager.addSource(source); + return new AdapterLoader(dataManager, sourceId, pool); +} + async function createLoader(data: TestDataSpec): Promise { if (data.type === "opencell") { return [new OpenCellLoader()]; } + if (data.type === VolumeFileFormat.ZARR && !Array.isArray(data.url)) { + return [await createAdapterLoader(data.url)]; + } + await loaderContext.onOpen(); const options: Partial = {}; diff --git a/src/data_manager/adapter_loader.ts b/src/data_manager/adapter_loader.ts new file mode 100644 index 00000000..05f4c03a --- /dev/null +++ b/src/data_manager/adapter_loader.ts @@ -0,0 +1,375 @@ +import type { ImageInfo } from "../ImageInfo.js"; +import type { LoadedVolumeInfo, LoadSpec, RawChannelDataCallback, Region } from "../loaders/IVolumeLoader.js"; +import { ThreadableVolumeLoader } from "../loaders/IVolumeLoader.js"; +import { VolumeDims } from "../VolumeDims.js"; +import DataManager, { IDataSubscriber } from "./data_manager.js"; +import { ExtVolumeDims, VolumeMetadata } from "./sources/chunk_source.js"; +import { ChunkPriorityLevel, LocalChunkId, type ChunkId } from "./types.js"; +import { computePackedAtlasDims, MAX_ATLAS_EDGE } from "../loaders/VolumeLoaderUtils.js"; +import { BorrowGuard } from "./task_pool/borrow_guard.js"; +import { ARRAY_CONSTRUCTORS, type TypedArray } from "../types.js"; +import { TaskPool } from "./task_pool/task_pool.js"; +import { getDataRange } from "../utils/num_utils.js"; +import { copyChunkTask } from "./index.js"; +import type { CopyChunkParams } from "./indexing.js"; +import { getChunkDims } from "./utils.js"; + +// MARK: Utils + +type XYZ = [T, T, T]; +type TCZYX = [T, T, T, T, T]; + +const mapXYZ = ([x, y, z]: XYZ, f: (a: T) => U): XYZ => [f(x), f(y), f(z)]; +const zipXYZ = ([x1, y1, z1]: XYZ, [x2, y2, z2]: XYZ, f: (a: T, b: U) => V): XYZ => [ + f(x1, x2), + f(y1, y2), + f(z1, z2), +]; +const zipTCZYX = ([x1, y1, z1]: XYZ, [, , z2, y2, x2]: TCZYX, f: (a: T, b: U) => V): XYZ => [ + f(x1, x2), + f(y1, y2), + f(z1, z2), +]; +const subtract = (a: XYZ, b: XYZ): XYZ => zipXYZ(a, b, (l, r) => l - r); +const equals = ([x1, y1, z1]: XYZ, [x2, y2, z2]: XYZ): boolean => x1 === x2 && y1 === y2 && z1 === z2; + +const normalizedPointToVoxel = (point: XYZ, { shape }: ExtVolumeDims): XYZ => + zipTCZYX(point, shape, (p, s) => Math.min(p * s, s - 1)); +const voxelToNormalizedPoint = (voxel: XYZ, { shape }: ExtVolumeDims): XYZ => zipTCZYX(voxel, shape, (v, s) => v / s); + +const chunkToVoxel = (chunk: XYZ, { chunkShape, shape }: ExtVolumeDims): XYZ => { + const rawVoxel = zipTCZYX(chunk, chunkShape, (c, s) => c * s); + return zipTCZYX(rawVoxel, shape, Math.min); +}; +const voxelToChunk = (voxel: XYZ, { chunkShape }: ExtVolumeDims): XYZ => + zipTCZYX(voxel, chunkShape, (v, s) => Math.floor(v / s)); + +const normalizedPointToChunk = (point: XYZ, dims: ExtVolumeDims): XYZ => + voxelToChunk(normalizedPointToVoxel(point, dims), dims); + +const subregionToChunks = ({ min, max }: Region, dims: ExtVolumeDims): Region => ({ + min: normalizedPointToChunk(min, dims), + max: mapXYZ(normalizedPointToChunk(max, dims), (n) => n + 1), +}); + +const contains = ({ min, max }: Region, [x, y, z]: XYZ): boolean => { + const [minX, minY, minZ] = min; + const [maxX, maxY, maxZ] = max; + return minX <= x && minY <= y && minZ <= z && x < maxX && y < maxY && z < maxZ; +}; + +function* iterRegion({ min, max }: Region): Generator { + const [minX, minY, minZ] = min; + const [maxX, maxY, maxZ] = max; + for (let z = minZ; z < maxZ; z++) { + for (let y = minY; y < maxY; y++) { + for (let x = minX; x < maxX; x++) { + yield [x, y, z]; + } + } + } +} + +const chunkReceivedIndex = ([x, y, z]: XYZ, { min, max }: Region): number => { + const [, sizeY, sizeZ] = subtract(max, min); + return sizeY * sizeZ * z + sizeY * y + x; +}; + +// MARK: Channel assembler + +class ChannelAssembler { + private chunksReceived: boolean[]; + private shape: XYZ; + + constructor( + public data: BorrowGuard, + private dims: ExtVolumeDims, + private chunks: Region, + private onComplete: (data: TypedArray) => void, + private pool: TaskPool + ) { + const [x, y, z] = subtract(chunks.max, chunks.min); + const chunkCount = x * y * z; + this.chunksReceived = new Array(chunkCount).fill(false); + const voxelMin = chunkToVoxel(chunks.min, dims); + const voxelMax = chunkToVoxel(chunks.max, dims); + this.shape = subtract(voxelMax, voxelMin); + } + + private getParams(id: LocalChunkId): CopyChunkParams { + const chunkDims = getChunkDims(id, this.dims); + const [, , z, y, x] = id.tczyx; + const offset = chunkToVoxel([x, y, z], this.dims); + return { + srcShape: [chunkDims.x, chunkDims.y, chunkDims.z], + destShape: this.shape, + offset, + }; + } + + async set(id: LocalChunkId, chunk: BorrowGuard): Promise { + const [, , z, y, x] = id.tczyx; + const xyz: XYZ = [x, y, z]; + if (!contains(this.chunks, xyz)) { + return; + } + + const receivedIndex = chunkReceivedIndex(xyz, this.chunks); + if (this.chunksReceived[receivedIndex]) { + return; + } + this.chunksReceived[receivedIndex] = true; + + const params = this.getParams(id); + await this.pool.runTask(copyChunkTask, chunk, this.data, params); + + if (this.chunksReceived.every((value) => value)) { + const data = await this.data.getAsync(); + this.onComplete(data); + } + } +} + +// MARK: Class definition + +export default class AdapterLoader extends ThreadableVolumeLoader implements IDataSubscriber { + private dims: ExtVolumeDims[]; + private meta: VolumeMetadata; + private channels: Map = new Map(); + private currentExtent: { + t: number; + c: number[]; + multiscale: number; + chunks: Region; + } = { + t: 0, + c: [], + multiscale: 0, + chunks: { min: [0, 0, 0], max: [0, 0, 0] }, + }; + + constructor( + private manager: DataManager, + private sourceId: number, + private pool: TaskPool + ) { + super(); + const dims = manager.getSourceDims(sourceId); + const meta = manager.getSourceMeta(sourceId); + + if (dims === undefined || meta === undefined) { + throw new Error(`AdapterLoader: source ${this.sourceId} does not exist`); + } + + this.dims = dims; + this.meta = meta; + + manager.subscribeToSource(this, sourceId); + } + + private pickMultiscaleIndex(loadSpec: LoadSpec): number { + if (loadSpec.useExplicitLevel && loadSpec.multiscaleLevel !== undefined) { + return Math.max(0, Math.min(this.dims.length - 1, loadSpec.multiscaleLevel)); + } + + const { subregion } = loadSpec; + const maxAtlasEdge = loadSpec.maxAtlasEdge ?? MAX_ATLAS_EDGE; + let i = 0; + for (; i < this.dims.length; i++) { + const dims = this.dims[i]; + const chunks = subregionToChunks(subregion, dims); + + const voxelMin = chunkToVoxel(chunks.min, dims); + const voxelMax = chunkToVoxel(chunks.max, dims); + + const [sizeX, sizeY, sizeZ] = subtract(voxelMax, voxelMin); + const tilesX = Math.floor(maxAtlasEdge / sizeX); + const tilesY = Math.floor(maxAtlasEdge / sizeY); + if (tilesX * tilesY >= sizeZ) { + break; + } + } + + if (i === this.dims.length) { + // TODO bring in better error message, like equivalent from `VolumeLoaderUtils` + console.error("Volume is too large!"); + return this.dims.length - 1; + } + + const adjustedLevel = Math.max(i + (loadSpec.scaleLevelBias ?? 0), loadSpec.multiscaleLevel ?? 0); + return Math.max(0, Math.min(this.dims.length - 1, adjustedLevel)); + } + + private *iterChunks(c: number): Generator { + const source = this.sourceId; + const { t, multiscale, chunks } = this.currentExtent; + for (const [x, y, z] of iterRegion(chunks)) { + const tczyx: TCZYX = [t, c, z, y, x]; + yield { source, multiscale, tczyx }; + } + } + + private removeChannelRequests(c: number): void { + for (const id of this.iterChunks(c)) { + this.manager.removeChunkRequest(this, id); + } + } + + private updateExtent(loadSpec: LoadSpec): void { + const levelToLoad = this.pickMultiscaleIndex(loadSpec); + const dims = this.dims[levelToLoad]; + const nextChunks = subregionToChunks(loadSpec.subregion, dims); + const { t, multiscale, chunks } = this.currentExtent; + + if ( + loadSpec.time === t && + levelToLoad === multiscale && + equals(nextChunks.min, chunks.min) && + equals(nextChunks.max, chunks.max) + ) { + // We can keep some channel arrays around. Let's see which ones. + const nextChannels = new Map(); + for (const index of loadSpec.channels ?? []) { + const channel = this.channels.get(index); + if (channel !== undefined) { + this.channels.delete(index); + nextChannels.set(index, channel); + } + } + + for (const index of this.channels.keys()) { + this.removeChannelRequests(index); + } + + this.channels = nextChannels; + } + + // We have to completely replace all channels. + for (const channel of this.channels.keys()) { + this.removeChannelRequests(channel); + } + this.currentExtent = { + t, + c: loadSpec.channels ?? [], + chunks: nextChunks, + multiscale: levelToLoad, + }; + this.channels = new Map(); + } + + // MARK: Loader interface + + loadDims(_loadSpec: LoadSpec): Promise { + return Promise.resolve(this.dims); + } + + createImageInfo(loadSpec: LoadSpec): Promise { + const multiscaleLevel = this.pickMultiscaleIndex(loadSpec); + const dims = this.dims[multiscaleLevel]; + const chunks = subregionToChunks(loadSpec.subregion, dims); + const voxelMin = chunkToVoxel(chunks.min, dims); + const voxelMax = chunkToVoxel(chunks.max, dims); + const subregionSize = subtract(voxelMax, voxelMin); + + const [x, y, z] = subregionSize; + const atlasTileDims = computePackedAtlasDims(z, x, y).toArray(); + + const imageInfo: ImageInfo = { + name: undefined, + ...this.meta, + multiscaleLevelDims: this.dims, + multiscaleLevel, + atlasTileDims, + subregionSize, + subregionOffset: voxelMin, + // no multi-source support, yet + numChannelsPerSource: [this.dims[multiscaleLevel].shape[1]], + }; + + const subregion = { + min: voxelToNormalizedPoint(voxelMin, dims), + max: voxelToNormalizedPoint(voxelMax, dims), + }; + return Promise.resolve({ imageInfo, loadSpec: { ...loadSpec, subregion } }); + } + + async loadRawChannelData( + imageInfo: ImageInfo, + loadSpec: LoadSpec, + onUpdateVolumeMetadata: (imageInfo?: ImageInfo, loadSpec?: LoadSpec) => void, + onData: RawChannelDataCallback + ): Promise { + // TODO update `ImageInfo`? See `OMEZarrLoader` + this.updateExtent(loadSpec); + const channels = loadSpec.channels ?? []; + const { multiscale, chunks } = this.currentExtent; + const dims = this.dims[multiscale]; + + const dtype = this.dims[multiscale].dataType; + const arrayConstructor = ARRAY_CONSTRUCTORS[dtype]; + + const voxelMin = chunkToVoxel(chunks.min, dims); + const voxelMax = chunkToVoxel(chunks.max, dims); + const [sizeX, sizeY, sizeZ] = subtract(voxelMax, voxelMin); + const bufferSize = sizeX * sizeY * sizeZ; + + const channelPromises: Promise[] = []; + + for (const channelIndex of channels) { + const preservedChannel = this.channels.get(channelIndex); + + // We've already got this channel. Just return it. + if (preservedChannel !== undefined) { + const promise = preservedChannel.data + .getAsync() + .then((data) => onData([channelIndex], [dtype], [data], [getDataRange(data)])); + channelPromises.push(promise); + continue; + } + + // Build a new channel + const data = new BorrowGuard(new arrayConstructor(bufferSize)); + let resolve: () => void; + channelPromises.push(new Promise((r) => (resolve = r))); + const onComplete = (data: TypedArray): void => { + onData([channelIndex], [dtype], [data], [getDataRange(data)]); + resolve(); + }; + const channel = new ChannelAssembler(data, dims, chunks, onComplete, this.pool); + this.channels.set(channelIndex, channel); + + // Request the chunks in this channel + for (const chunkId of this.iterChunks(channelIndex)) { + this.manager.addChunkRequest(this, chunkId, { level: ChunkPriorityLevel.VISIBLE, score: 1000 }, false); + const chunk = this.manager.getChunkGuard(chunkId); + if (chunk !== undefined) { + channel.set(chunkId, chunk); + } + } + } + + this.manager.update(); + await Promise.all(channelPromises); + } + + onChunkLoaded(id: ChunkId, chunk: BorrowGuard) { + const [t, c] = id.tczyx; + if (t !== this.currentExtent.t) { + return; + } + + const channel = this.channels.get(c); + if (channel === undefined) { + return; + } + + channel.set(id, chunk); + } + + destroy() { + for (const channelIndex of this.channels.keys()) { + this.removeChannelRequests(channelIndex); + } + this.manager.unsubscribeFromSource(this, this.sourceId); + } +} diff --git a/src/data_manager/data_manager.ts b/src/data_manager/data_manager.ts index bce1d8af..98ea4185 100644 --- a/src/data_manager/data_manager.ts +++ b/src/data_manager/data_manager.ts @@ -13,7 +13,7 @@ import { deviceSizeLimitForPriority, } from "./types.js"; import type { NumberType, TypedArray } from "../types.js"; -import { defineGlobalId, swapRemove } from "./utils.js"; +import { defineGlobalId, getChunkDims, swapRemove } from "./utils.js"; import PriorityQueue from "./priority_queue.js"; import type { DeviceInterface } from "./device_interface.js"; import { dataTypeToSize } from "./utils.js"; @@ -24,8 +24,9 @@ import { BorrowGuard } from "./task_pool/borrow_guard.js"; // eslint-disable-next-line @typescript-eslint/no-explicit-any const getIdForSubscriber = defineGlobalId>(Symbol("DataManager.subscriberId")); -export interface IDataSubscriber { - onChunkLoaded?: (id: ChunkId, chunk: Chunk) => void; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export interface IDataSubscriber { + onChunkLoaded?: (id: ChunkId, chunk: BorrowGuard) => void; onChunkOnGpu?: (id: ChunkId, texture: Tex) => void; // TODO events for when chunks are evicted? } @@ -53,7 +54,8 @@ class ChunkQueues { deviceEvict: ChunkQueue = new PriorityQueue(reverseComparePriority); } -export default class DataManager { +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export default class DataManager { /** Data and current state for every chunk of data tracked and managed by this class. */ private chunks = new Map>(); private queues = new ChunkQueues(); @@ -179,21 +181,8 @@ export default class DataManager { return undefined; } - const multiscale = sourceEntry.source.getDims()[chunkId.multiscale]; - if (multiscale === undefined) { - return undefined; - } - - const { shape, chunkShape, dataType } = multiscale; - const [, , iz, iy, ix] = chunkId.tczyx; - const [, , cz, cy, cx] = chunkShape; - const [, , sz, sy, sx] = shape; - // Chunks that abut the max edge of the volume in any dimension may be smaller in that dimension. - // e.g. for a volume with chunk size 10 & volume size 35 in x, chunks at x = 3 have size 5 in x. - const z = Math.min(cz, sz - cz * iz); - const y = Math.min(cy, sy - cy * iy); - const x = Math.min(cx, sx - cx * ix); - return { x, y, z, dataType }; + const dims = sourceEntry.source.getDims(); + return getChunkDims(chunkId, dims[chunkId.multiscale]); } private estimateChunkSize(chunkId: ChunkId): number { @@ -500,7 +489,7 @@ export default class DataManager { this.queues.evict.insert(key, chunkEntry.memoryPriority); this.memorySize += memory.byteLength; - sourceEntry.subscribers.forEach((s) => s.onChunkLoaded?.(globalId, chunk)); + sourceEntry.subscribers.forEach((s) => s.onChunkLoaded?.(globalId, memory)); this.update(); } diff --git a/src/data_manager/index.ts b/src/data_manager/index.ts new file mode 100644 index 00000000..bb348270 --- /dev/null +++ b/src/data_manager/index.ts @@ -0,0 +1,20 @@ +import * as indexingTasks from "./indexing_task.js"; +import * as zarrTasks from "./sources/ome_zarr/zarr_codec_worker.js"; +import { initWorker as _initWorker, BorrowGuard, taskHandle, type Task } from "./task_pool/index.js"; + +export const copyChunkTask = taskHandle("copyChunk", (src) => { + return BorrowGuard.isBorrowGuard(src) ? [] : [src.buffer]; +}); +export const reorderChunkTask = taskHandle("reorderChunk", (src) => { + return BorrowGuard.isBorrowGuard(src) ? [] : [src.buffer]; +}); + +export const initWorker = (tasks?: Task[]): void => { + _initWorker([ + zarrTasks.encodeTask, + zarrTasks.decodeTask, + indexingTasks.copyChunkTask, + indexingTasks.reorderChunkTask, + ...(tasks ?? []), + ]); +}; diff --git a/src/data_manager/utils.ts b/src/data_manager/utils.ts index 69a48670..5fecd227 100644 --- a/src/data_manager/utils.ts +++ b/src/data_manager/utils.ts @@ -1,4 +1,6 @@ import type { NumberType } from "../types.js"; +import type { ExtVolumeDims } from "./sources/chunk_source.js"; +import type { LocalChunkId } from "./types.js"; export const dataTypeToSize: { [T in NumberType]: number } = { int8: 1, @@ -35,3 +37,22 @@ export const swapRemove = (arr: T[], index: number) => { arr[index] = replace; } }; + +// TODO should this go in `types`, or should more `types` functions go in `util`? +// TODO could be implemented in terms of utils in `adapter_loader`? +export const getChunkDims = ( + chunkId: LocalChunkId, + dims: ExtVolumeDims +): { x: number; y: number; z: number; dataType: NumberType } => { + const { shape, chunkShape, dataType } = dims; + + const [, , iz, iy, ix] = chunkId.tczyx; + const [, , cz, cy, cx] = chunkShape; + const [, , sz, sy, sx] = shape; + // Chunks that abut the max edge of the volume in any dimension may be smaller in that dimension. + // e.g. for a volume with chunk size 10 & volume size 35 in x, chunks at x = 3 have size 5 in x. + const z = Math.min(cz, sz - cz * iz); + const y = Math.min(cy, sy - cy * iy); + const x = Math.min(cx, sx - cx * ix); + return { x, y, z, dataType }; +};