diff --git a/python/tests/zarr_test.py b/python/tests/zarr_test.py index d6fc9b1de..864105591 100644 --- a/python/tests/zarr_test.py +++ b/python/tests/zarr_test.py @@ -487,6 +487,66 @@ def test_ome_zarr_0_6_affine(static_file_server, webdriver): _check_sequence_result(model_space) +def test_ome_zarr_0_6_vs_0_5_affine(static_file_server, webdriver): + """affine: Affine matrix (JSON form) applied to single scale. Same data as 0.6 example, and equivalent metadata for 0.5 as 0.6. But 0.6 should expose transform while 0.5 does not. + Example dataset: simple/affine_copy_0.5.zarr + + Affine transform: Diagonal scale matrix with translation (equivalent to sequence of scale + translation). + Matrix: + [4, 0, 0, 32] - z axis: scale 4, translation 32 + [0, 3, 0, 21] - y axis: scale 3, translation 21 + [0, 0, 2, 10] - x axis: scale 2, translation 10 + """ + test_dir_0_5 = OME_ZARR_0_6_ROOT / "simple" / "affine_copy_0.5.zarr" + server_url_0_5 = static_file_server(test_dir_0_5) + test_dir_0_6 = OME_ZARR_0_6_ROOT / "simple" / "affine.zarr" + server_url_0_6 = static_file_server(test_dir_0_6) + with webdriver.viewer.txn() as s: + s.layers.append( + name="affine_0_5", + layer=neuroglancer.ImageLayer(source=f"zarr3://{server_url_0_5}"), + ) + s.layers.append( + name="affine_0_6", + layer=neuroglancer.ImageLayer(source=f"zarr3://{server_url_0_6}"), + ) + webdriver.sync() + + model_space_0_5 = _assert_renders(webdriver, "affine_0_5") + model_space_0_6 = _assert_renders(webdriver, "affine_0_6") + + # Both scales should be the same + assert model_space_0_5["scales"] == model_space_0_6["scales"] + + # Both of the volumes should be the same shape + assert model_space_0_5["volume"].shape == model_space_0_6["volume"].shape + + # Both should render the same in the viewer with one visible at a time + with webdriver.viewer.txn() as s: + s.layers["affine_0_5"].visible = True + s.layers["affine_0_6"].visible = False + webdriver.sync() + screenshot_0_5 = webdriver.viewer.screenshot(size=[200, 200]).screenshot + with webdriver.viewer.txn() as s: + s.layers["affine_0_5"].visible = False + s.layers["affine_0_6"].visible = True + webdriver.sync() + screenshot_0_6 = webdriver.viewer.screenshot(size=[200, 200]).screenshot + np.testing.assert_array_equal( + screenshot_0_5.image_pixels, screenshot_0_6.image_pixels + ) + + # Both should obey the single voxel test + origin_0_5 = model_space_0_5["volume"].domain.origin + origin_0_6 = model_space_0_6["volume"].domain.origin + _verify_data_at_point( + model_space_0_5["volume"], TEST_VOXEL + np.array(origin_0_5), EXPECTED_VALUE + ) + _verify_data_at_point( + model_space_0_6["volume"], TEST_VOXEL + np.array(origin_0_6), EXPECTED_VALUE + ) + + def test_ome_zarr_0_6_rotation(static_file_server, webdriver): """rotation: Rotation matrix (or axis permutation) example. Example dataset: simple/rotation.zarr diff --git a/src/datasource/zarr/ome.ts b/src/datasource/zarr/ome.ts index 7526029f9..31a2653a8 100644 --- a/src/datasource/zarr/ome.ts +++ b/src/datasource/zarr/ome.ts @@ -71,6 +71,14 @@ const SUPPORTED_OME_MULTISCALE_VERSIONS = new Set([ "0.6", ]); +// OME-Zarr versions (< 0.6) should not expose base transform +// to maintain backward compatibility with existing saved states +const OME_MULTISCALE_NO_EXPOSE_TRANSFORM_VERSIONS = new Set([ + "0.4", + "0.5-dev", + "0.5", +]); + const OME_UNITS = new Map([ ["angstrom", { unit: "m", scale: 1e-10 }], ["foot", { unit: "m", scale: 0.3048 }], @@ -560,6 +568,7 @@ function parseMultiscaleScale( function parseOmeMultiscale( url: string, multiscale: unknown, + version: string, ): OmeMultiscaleMetadata { verifyObject(multiscale); @@ -578,7 +587,7 @@ function parseOmeMultiscale( Array.isArray(coordinateSystemsRaw) && coordinateSystemsRaw.length > 0 ) { - // OME-ZARR 0.6+: Use the last (intrinsic) coordinate system + // If coordinate systems specified, use the last (intrinsic) coordinate system const coordinateSystems = parseArray( coordinateSystemsRaw, parseOmeCoordinateSystem, @@ -595,7 +604,7 @@ function parseOmeMultiscale( verifyString, ); } else { - // OME-ZARR 0.4/0.5: Use axes directly + // Use axes directly if no coordinate systems (usually OME-zarr < 0.6) coordinateSpace = verifyObjectProperty(multiscale, "axes", parseOmeAxes); } @@ -638,32 +647,48 @@ function parseOmeMultiscale( coordinateSpace.scales[i] *= baseScales[i]; } - // The unscaled inverse of the base transform is used in the per-scale - // calculation of the affine transform to apply on top of the base transform. - const inverseBaseTransformUnscaled = new Float64Array(baseTransform.length); - matrix.inverse( - inverseBaseTransformUnscaled, - rank + 1, - baseTransform, - rank + 1, - rank + 1, - ); + const shouldExposeBaseTransform = + !OME_MULTISCALE_NO_EXPOSE_TRANSFORM_VERSIONS.has(version); - // The base transform with scaling removed is used - // to provide a default transform in the layer source tab - // and for the bounding box transformation - const baseTransformScaled = new Float64Array(baseTransform.length); - matrix.copy( - baseTransformScaled, - rank + 1, - baseTransform, - rank + 1, - rank + 1, - rank + 1, - ); - for (let i = 0; i < rank; ++i) { - for (let j = 0; j <= rank; ++j) { - baseTransformScaled[j * (rank + 1) + i] /= baseScales[i]; + // Create the inverse base transform to make scale transforms relative. + // For OME-Zarr 0.6+: Use full inverse of base transform since transform is fully exposed + // For older versions: Use diagonal scale matrix inverse as only the scale is exposed + const inverseBaseTransform = new Float64Array(baseTransform.length); + if (shouldExposeBaseTransform) { + matrix.inverse( + inverseBaseTransform, + rank + 1, + baseTransform, + rank + 1, + rank + 1, + ); + } else { + // Create inverse scale matrix using 1/baseScales + inverseBaseTransform.set( + matrix.createHomogeneousScaleMatrix( + Float64Array, + Float64Array.from(baseScales, (scale) => 1 / scale), + ), + ); + } + + // For OME-Zarr 0.6+: Remove scaling from base transform since it will be + // exposed as the model transform and scales are in the coordinate space. + // For older versions: Set base transform to identity since it won't be exposed. + const baseTransformScaled = matrix.createIdentity(Float64Array, rank + 1); + if (shouldExposeBaseTransform) { + matrix.copy( + baseTransformScaled, + rank + 1, + baseTransform, + rank + 1, + rank + 1, + rank + 1, + ); + for (let i = 0; i < rank; ++i) { + for (let j = 0; j <= rank; ++j) { + baseTransformScaled[j * (rank + 1) + i] /= baseScales[i]; + } } } @@ -679,24 +704,19 @@ function parseOmeMultiscale( } t[rank * (rank + 1) + i] -= offset; } - - // At each scale, we provide an affine transform matrix - // to get applied on top of the base transformation matrix - // This matrix should apply the per path scaling for moving between - // LODs as well as the per-lod offset in translations (for voxel center) - // In theory, if the transform at that path describes a different rotation - // shear etc, to the base transform that would be captured here as well - // though the common case is just scaling + translation differences scale.transform = makeAffineRelativeToBaseTransform( scale.transform, - inverseBaseTransformUnscaled, + inverseBaseTransform, rank, ); } return { coordinateSpace, scales, - baseInfo: { baseScales, baseTransform: baseTransformScaled }, + baseInfo: { + baseScales, + baseTransform: baseTransformScaled, + }, }; } @@ -740,7 +760,7 @@ export function parseOmeMetadata( ); continue; } - const multiScaleInfo = parseOmeMultiscale(url, multiscale); + const multiScaleInfo = parseOmeMultiscale(url, multiscale, version); const channelMetadata = omero ? parseOmeroMetadata(omero) : undefined; return { multiscale: multiScaleInfo, channels: channelMetadata }; } diff --git a/testdata/datasource/zarr/ome_zarr/all_0.6/simple/affine_copy_0.5.zarr/array/c/0/0/0 b/testdata/datasource/zarr/ome_zarr/all_0.6/simple/affine_copy_0.5.zarr/array/c/0/0/0 new file mode 100644 index 000000000..894d1a7a1 Binary files /dev/null and b/testdata/datasource/zarr/ome_zarr/all_0.6/simple/affine_copy_0.5.zarr/array/c/0/0/0 differ diff --git a/testdata/datasource/zarr/ome_zarr/all_0.6/simple/affine_copy_0.5.zarr/array/zarr.json b/testdata/datasource/zarr/ome_zarr/all_0.6/simple/affine_copy_0.5.zarr/array/zarr.json new file mode 100644 index 000000000..dcb07683f --- /dev/null +++ b/testdata/datasource/zarr/ome_zarr/all_0.6/simple/affine_copy_0.5.zarr/array/zarr.json @@ -0,0 +1,41 @@ +{ + "node_type": "array", + "chunk_key_encoding": { + "name": "default", + "configuration": { + "separator": "/" + } + }, + "shape": [ + 27, + 226, + 186 + ], + "chunk_grid": { + "name": "regular", + "configuration": { + "chunk_shape": [ + 27, + 226, + 186 + ] + } + }, + "codecs": [ + { + "name": "bytes", + "configuration": { + "endian": "big" + } + }, + { + "name": "zstd", + "configuration": { + "level": 3 + } + } + ], + "data_type": "uint8", + "fill_value": 0, + "zarr_format": 3 +} diff --git a/testdata/datasource/zarr/ome_zarr/all_0.6/simple/affine_copy_0.5.zarr/zarr.json b/testdata/datasource/zarr/ome_zarr/all_0.6/simple/affine_copy_0.5.zarr/zarr.json new file mode 100644 index 000000000..a2976fb50 --- /dev/null +++ b/testdata/datasource/zarr/ome_zarr/all_0.6/simple/affine_copy_0.5.zarr/zarr.json @@ -0,0 +1,66 @@ +{ + "zarr_format": 3, + "node_type": "group", + "attributes": { + "ome": { + "version": "0.5", + "multiscales": [ + { + "axes": [ + { + "type": "space", + "name": "z", + "unit": "micrometer", + "discrete": false + }, + { + "type": "space", + "name": "y", + "unit": "micrometer", + "discrete": false + }, + { + "type": "space", + "name": "x", + "unit": "micrometer", + "discrete": false + } + ], + "datasets": [ + { + "path": "array", + "coordinateTransformations": [ + { + "type": "affine", + "input": "array", + "output": "physical", + "name": "array-to-physical", + "affine": [ + [ + 4, + 0, + 0, + 32 + ], + [ + 0, + 3, + 0, + 21 + ], + [ + 0, + 0, + 2, + 10 + ] + ] + } + ] + } + ] + } + ] + } + } +}