diff --git a/fixtures/13457537.zarr.yaml b/fixtures/13457537.zarr.yaml new file mode 100644 index 00000000..c20f0d72 --- /dev/null +++ b/fixtures/13457537.zarr.yaml @@ -0,0 +1,4 @@ +source: https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.4/idr0101A/13457537.zarr +name: 13457537.zarr +features: + multiscale: true diff --git a/fixtures/13457539.zarr.yaml b/fixtures/13457539.zarr.yaml new file mode 100644 index 00000000..0de3d5ab --- /dev/null +++ b/fixtures/13457539.zarr.yaml @@ -0,0 +1,4 @@ +source: https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.4/idr0101A/13457539.zarr +name: 13457539.zarr +features: + multiscale: true diff --git a/fixtures/resolution_transformationsv0.5.yaml b/fixtures/resolution_transformationsv0.5.yaml new file mode 100644 index 00000000..7dda7257 --- /dev/null +++ b/fixtures/resolution_transformationsv0.5.yaml @@ -0,0 +1,2 @@ +source: https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.4/idr0101A/13457537.zarr +name: 13457537.zarr diff --git a/fixtures/transformationsv0.5.yaml b/fixtures/transformationsv0.5.yaml new file mode 100644 index 00000000..4ca7f422 --- /dev/null +++ b/fixtures/transformationsv0.5.yaml @@ -0,0 +1,2 @@ +source: https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.4/idr0101A/13457539.zarr +name: 13457539.zarr diff --git a/viewer/src/coordinate-transformations.ts b/viewer/src/coordinate-transformations.ts new file mode 100644 index 00000000..409450a3 --- /dev/null +++ b/viewer/src/coordinate-transformations.ts @@ -0,0 +1,110 @@ +import { Matrix4 } from "math.gl"; +import { getNgffAxes, isMultiscales, assert } from "./utils"; +import * as zarr from "zarrita"; +import type * as viv from "@vivjs/types"; + + +/** + * Convert an array of coordinateTransformations objects to a 16-element + * plain JS array using Matrix4 linear algebra transformation functions. + * + * Adapted from Vitessce: https://github.com/vitessce/vitessce/blob/c267ebecab1824dae68d6f2640a6c5ce7250efbb/packages/utils/spatial-utils/src/spatial.js#L403-L524 + * + * @param coordinateTransformations List of objects matching the OME-NGFF v0.4 coordinateTransformations spec. + * @param axes - Axes in OME-NGFF v0.4 format + * + * @returns Array of 16 numbers representing the Matrix4. + */ +export function coordinateTransformationsToMatrix(coordinateTransformations: Ome.CoordinateTransformation[], axes: Ome.Axis[]) { + let mat = new Matrix4().identity(); + + + // Apply each transformation sequentially and in order according to the OME-NGFF v0.4 spec. + // Reference: https://ngff.openmicroscopy.org/0.4/#trafo-md + for (const transform of coordinateTransformations ?? []) { + if (transform.type === "translation") { + const { translation: axisOrderedTranslation } = transform; + if (axisOrderedTranslation.length !== axes.length) { + throw new Error("Length of translation array was expected to match length of axes."); + } + const cartesianTranslation = getCartesianTransformation(axes, axisOrderedTranslation, 0) + mat = coordinateTransformationToMatrix('translation', cartesianTranslation, mat) + + } + if (transform.type === "scale") { + const { scale: axisOrderedScale } = transform; + // Add in z dimension needed for Matrix4 scale API. + if (axisOrderedScale.length !== axes.length) { + throw new Error("Length of scale array was expected to match length of axes."); + } + const cartesianTranslation = getCartesianTransformation(axes, axisOrderedScale, 1) + mat = coordinateTransformationToMatrix('scale', cartesianTranslation, mat) + } + } + + return mat; +} + +function getCartesianTransformation(axes: Ome.Axis[], transformation: Array, defaultValue: number): Array { + const xyzIndices = ["x", "y", "z"].map((name) => + axes.findIndex((axisObj) => axisObj.type === "space" && axisObj.name === name), + ); + + // Get the translation values for [x, y, z]. + return xyzIndices.map((axisIndex) => + axisIndex >= 0 ? transformation[axisIndex] : defaultValue, + ); + +} + +function coordinateTransformationToMatrix(type: string, transformation: Array, modelMatrix: Matrix4 = new Matrix4().identity()): Matrix4 { + switch (type) { + case 'translation': + return applyCoordinateTranslationToMatrix(modelMatrix, transformation) + case 'scale': + return applyCoordinateScalingToMatrix(modelMatrix, transformation) + default: + assert(type === 'translation' || type === 'scale') + } + + +} + +function applyCoordinateScalingToMatrix(matrix: Matrix4, scale: Array): Matrix4 { + // Get the scale values for [x, y, z]. + const nextMat = new Matrix4().scale(scale); + return matrix.multiplyLeft(nextMat); +} + +function applyCoordinateTranslationToMatrix(matrix: Matrix4, translation: Array): Matrix4 { + + const defaultValue = 0; + // Get the translation values for [x, y, z]. + const nextMat = new Matrix4().translate(translation); + return matrix.multiplyLeft(nextMat); +} + + +/** + *Get physical size for specific resolution in multiscale image + */ +export function getPhysicalSizes(attrs: zarr.Attributes) { + if (isMultiscales(attrs)) { + const axes = getNgffAxes(attrs.multiscales); + const ct = coordinateTransformationsToMatrix(attrs.multiscales); + const matrixIndices = { + x: 0, + y: 5, + z: 10, + }; + const physicalSizes = axes + .filter((a) => a.type === "space") + .reduce((acc: { [key: string]: viv.PhysicalSize }, { name, unit }: Ome.Axis) => { + acc[name] = { size: ct[matrixIndices[name as keyof typeof matrixIndices]], unit: unit ?? "" }; + return acc; + }, {}); + // @TODO: get t size from multiscales.coordinateTransformations if axis is present + return physicalSizes; + } +} + diff --git a/viewer/src/ome.ts b/viewer/src/ome.ts index 3021104f..dfe6b31b 100644 --- a/viewer/src/ome.ts +++ b/viewer/src/ome.ts @@ -5,6 +5,8 @@ import type { ImageLabels, ImageLayerConfig, OnClickData, SourceData } from "./s import { ZarrPixelSource } from "./ZarrPixelSource"; import * as utils from "./utils"; +import { getPhysicalSizes, coordinateTransformationsToMatrix } from "./coordinate-transformations"; + export async function loadWell( config: ImageLayerConfig, grp: zarr.Group, @@ -256,6 +258,14 @@ function isDownsampledZ( return !data.every((element) => element.shape[zIndex] === originalSizeZ); } +function getOrderedTransformations(metadata: Ome.Multiscale[]): Ome.CoordinateTransformation[] { + const resolutionTransformations = metadata[0].datasets[0]?.coordinateTransformations ? metadata[0].datasets[0]?.coordinateTransformations : [] + const imageTransformations = metadata[0].coordinateTransformations ? metadata[0].coordinateTransformations : [] + + + return [...resolutionTransformations, ...imageTransformations] +} + /** * Load a multiscale OME-NGFF image */ @@ -280,7 +290,7 @@ export async function loadOmeMultiscales( } const originalSizeZ = data[0].shape[axis_labels.indexOf("z")]; const zDownsampled = isDownsampledZ(data, axis_labels.indexOf("z"), originalSizeZ); - const physicalSizes = utils.getPhysicalSizes(utils.resolveAttrs(attrs)); + const physicalSizes = getPhysicalSizes(utils.resolveAttrs(attrs)); const loader = data.map( (arr, i) => new ZarrPixelSource(arr, { @@ -296,7 +306,7 @@ export async function loadOmeMultiscales( axis_labels, model_matrix: config.model_matrix ? utils.parseMatrix(config.model_matrix) - : utils.coordinateTransformationsToMatrix(attrs.multiscales), + : coordinateTransformationsToMatrix(getOrderedTransformations(attrs.multiscales), utils.getNgffAxes(attrs.multiscales)), defaults: { selection: meta.defaultSelection, colormap, @@ -321,7 +331,7 @@ async function loadOmeImageLabel(root: zarr.Location, name: strin const colors = (attrs["image-label"].colors ?? []).map((d) => ({ labelValue: d["label-value"], rgba: d.rgba })); return { name, - modelMatrix: utils.coordinateTransformationsToMatrix(attrs.multiscales), + modelMatrix: coordinateTransformationsToMatrix(attrs.multiscales), loader: data.map((arr) => new ZarrPixelSource(arr, { labels, tileSize })), colors: colors.length > 0 ? colors : undefined, }; diff --git a/viewer/src/types.ts b/viewer/src/types.ts index 7b9a08ee..0659231f 100644 --- a/viewer/src/types.ts +++ b/viewer/src/types.ts @@ -36,13 +36,13 @@ declare namespace Ome { type CoordinateTransformation = | { - type: "scale"; - scale: Array; - } + type: "scale"; + scale: Array; + } | { - type: "translation"; - translation: Array; - }; + type: "translation"; + translation: Array; + }; interface Dataset { path: string; @@ -53,6 +53,7 @@ declare namespace Ome { datasets: Array; version?: string; axes?: string[] | Axis[]; + coordinateTransformations?: CoordinateTransformation[] } interface Bioformats2rawlayout { diff --git a/viewer/src/utils.ts b/viewer/src/utils.ts index bdc1255e..0cf78bde 100644 --- a/viewer/src/utils.ts +++ b/viewer/src/utils.ts @@ -1,4 +1,4 @@ -import { Matrix4 } from "math.gl"; + import * as zarr from "zarrita"; import type * as viv from "@vivjs/types"; @@ -552,57 +552,6 @@ export function resolveLoaderFromLayerProps( return isGridLayerProps(layerProps) ? layerProps.loaders[0].loader : layerProps.loader; } -/** - * Convert an array of coordinateTransformations objects to a 16-element - * plain JS array using Matrix4 linear algebra transformation functions. - * - * Adapted from Vitessce: https://github.com/vitessce/vitessce/blob/c267ebecab1824dae68d6f2640a6c5ce7250efbb/packages/utils/spatial-utils/src/spatial.js#L403-L524 - * - * @param coordinateTransformations List of objects matching the OME-NGFF v0.4 coordinateTransformations spec. - * @param axes - Axes in OME-NGFF v0.4 format - * - * @returns Array of 16 numbers representing the Matrix4. - */ -export function coordinateTransformationsToMatrix(multiscales: Array) { - let mat = new Matrix4().identity(); - const axes = getNgffAxes(multiscales); - const coordinateTransformations = multiscales[0].datasets[0]?.coordinateTransformations; - const xyzIndices = ["x", "y", "z"].map((name) => - axes.findIndex((axisObj) => axisObj.type === "space" && axisObj.name === name), - ); - - // Apply each transformation sequentially and in order according to the OME-NGFF v0.4 spec. - // Reference: https://ngff.openmicroscopy.org/0.4/#trafo-md - for (const transform of coordinateTransformations ?? []) { - if (transform.type === "translation") { - const { translation: axisOrderedTranslation } = transform; - if (axisOrderedTranslation.length !== axes.length) { - throw new Error("Length of translation array was expected to match length of axes."); - } - const defaultValue = 0; - // Get the translation values for [x, y, z]. - const xyzTranslation = xyzIndices.map((axisIndex) => - axisIndex >= 0 ? axisOrderedTranslation[axisIndex] : defaultValue, - ); - const nextMat = new Matrix4().translate(xyzTranslation); - mat = mat.multiplyLeft(nextMat); - } - if (transform.type === "scale") { - const { scale: axisOrderedScale } = transform; - // Add in z dimension needed for Matrix4 scale API. - if (axisOrderedScale.length !== axes.length) { - throw new Error("Length of scale array was expected to match length of axes."); - } - const defaultValue = 1; - // Get the scale values for [x, y, z]. - const xyzScale = xyzIndices.map((axisIndex) => (axisIndex >= 0 ? axisOrderedScale[axisIndex] : defaultValue)); - const nextMat = new Matrix4().scale(xyzScale); - mat = mat.multiplyLeft(nextMat); - } - } - - return mat; -} /** * Builds N-tuples of elements from the given N arrays with matching indices, @@ -633,6 +582,9 @@ export function getLayerSize({ props }: VizarrLayer) { return { height, width, maxZoom }; } +/** + *Get physical size for specific resolution in multiscale image + */ export function getPhysicalSizes(attrs: zarr.Attributes) { if (isMultiscales(attrs)) { const axes = getNgffAxes(attrs.multiscales); diff --git a/viewer/tests/transformations.test.js b/viewer/tests/transformations.test.js new file mode 100644 index 00000000..dd9e1d51 --- /dev/null +++ b/viewer/tests/transformations.test.js @@ -0,0 +1,20 @@ +import { test } from "vitest"; +import yaml from 'yaml'; +import fs from 'node:fs' +import path from 'node:path' +import { createSourceData } from '../src/io' + +test('Resolution-level transformations should be applied', async () => { + const sourcePath = path.resolve(path.join(__dirname, "..", "..", "fixtures", "resolution_transformationsv0.5.yaml")) + const source = yaml.parse(fs.readFileSync(sourcePath, 'utf-8')) + const sourceData = await createSourceData(source) + console.log(sourceData.model_matrix) +}) + +test('Image-level transformations should be applied', async () => { + const sourcePath = path.resolve(path.join(__dirname, "..", "..", "fixtures", "transformationsv0.5.yaml")) + const source = yaml.parse(fs.readFileSync(sourcePath, 'utf-8')) + const sourceData = await createSourceData(source) + console.log(sourceData.model_matrix) + console.log(sourceData) +}) diff --git a/viewer/tests/transformations.ts b/viewer/tests/transformations.ts new file mode 100644 index 00000000..76334369 --- /dev/null +++ b/viewer/tests/transformations.ts @@ -0,0 +1,12 @@ +import { test } from "vitest"; +import yaml from 'yaml'; +import fs from 'node:fs' +import path from 'node:path' +import { createSourceData } from '../src/io' + +test('Resolution-level transformations should be applied', async () => { + const sourcePath = path.resolve(path.join(__dirname, "..", "..", "fixtures", "resolution_transformationsv0.5.yaml")) + const source = yaml.parse(fs.readFileSync(sourcePath, 'utf-8')) + const sourceData = await createSourceData(source) + console.log(sourceData.model_matrix) +})