diff --git a/src/datasource/zarr/ome.spec.ts b/src/datasource/zarr/ome.spec.ts index ce1004130a..88fff870a9 100644 --- a/src/datasource/zarr/ome.spec.ts +++ b/src/datasource/zarr/ome.spec.ts @@ -103,7 +103,6 @@ describe("OME-Zarr 0.6 coordinate transformations", () => { expect(metadata!.multiscale.baseInfo.baseTransform).toStrictEqual( new Float64Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]), ); - console.log(metadata); const scales = metadata!.multiscale.coordinateSpace.scales; expect(scales[0]).toBeCloseTo(1e-5); // 10 micrometer in meters expect(scales[1]).toBeCloseTo(3e-7); // 0.3 micrometer in meters @@ -279,7 +278,7 @@ describe("OME-Zarr 0.6 coordinate transformations", () => { ); }); - it("should use the last coordinate system if multiple provided", () => { + it("should require all multiscales.datasets coordinate transformations to have the same output which is treated as the intrinsic system", () => { const attrs = { ome: { version: "0.6", @@ -292,6 +291,10 @@ describe("OME-Zarr 0.6 coordinate transformations", () => { axes: [{ type: "space", name: "x", unit: "micrometer" }], }, intrinsicCoordinateSystem, + { + name: "last_system", + axes: [{ type: "space", name: "x", unit: "micrometer" }], + }, ], datasets: [ { @@ -299,7 +302,93 @@ describe("OME-Zarr 0.6 coordinate transformations", () => { coordinateTransformations: [ { type: "identity", - output: "physical", + input: { path: "array" }, + output: { name: "physical" }, + }, + ], + }, + { + path: "array1", + coordinateTransformations: [ + { + type: "identity", + input: { path: "array1" }, + output: { name: "last_system" }, + }, + ], + }, + ], + }, + ], + }, + }; + + expect(() => parseOmeMetadata("test://", attrs, 3)).toThrow( + /candidate name from first scale is physical, but found last_system at scale 1/, + ); + }); + + it("should use the coordinate system output by the first transformation whose input is the intrinsic system", () => { + const attrs = { + ome: { + version: "0.6", + multiscales: [ + { + name: "multiscales", + coordinateSystems: [ + { + name: "physical", + axes: [{ type: "space", name: "y", unit: "micrometer" }], + }, + { + name: "target_system", + axes: [{ type: "space", name: "z", unit: "millimeter" }], + }, + { + name: "last_system", + axes: [{ type: "space", name: "x", unit: "micrometer" }], + }, + ], + coordinateTransformations: [ + { + type: "identity", + input: { name: "last_system" }, + output: { name: "physical" }, + }, + { + type: "identity", + input: { name: "target_system" }, + output: { name: "physical" }, + }, + { + type: "identity", + input: { name: "physical" }, + output: { name: "target_system" }, + }, + { + type: "identity", + input: { name: "physical" }, + output: { name: "last_system" }, + }, + ], + datasets: [ + { + path: "array", + coordinateTransformations: [ + { + type: "identity", + input: { path: "array" }, + output: { name: "physical" }, + }, + ], + }, + { + path: "array1", + coordinateTransformations: [ + { + type: "identity", + input: { path: "array1" }, + output: { name: "physical" }, }, ], }, @@ -311,8 +400,126 @@ describe("OME-Zarr 0.6 coordinate transformations", () => { const metadata = parseOmeMetadata("test://", attrs, 3); const space = metadata!.multiscale.coordinateSpace; - expect(space.names).toStrictEqual(["z", "y", "x"]); - expect(space.units).toStrictEqual(["m", "m", "m"]); + expect(space.names).toStrictEqual(["z"]); + expect(space.scales[0]).toBeCloseTo(1e-3); + }); + + it("should ignore a transformation whose output is the intrinsic system", () => { + const attrs = { + ome: { + version: "0.6", + multiscales: [ + { + coordinateSystems: [ + { + name: "physical", + axes: [ + { name: "y", type: "space", unit: "micrometer" }, + { name: "x", type: "space", unit: "micrometer" }, + ], + }, + { + name: "world", + axes: [ + { name: "y", type: "space", unit: "micrometer" }, + { name: "x", type: "space", unit: "micrometer" }, + ], + }, + ], + datasets: [ + { + path: "s0", + coordinateTransformations: [ + { + type: "identity", + input: { path: "s0" }, + output: { name: "physical" }, + }, + ], + }, + ], + coordinateTransformations: [ + { + type: "translation", + input: { name: "world" }, + output: { name: "physical" }, + translation: [10, 20], + }, + ], + }, + ], + }, + }; + + const metadata = parseOmeMetadata("test://", attrs, 3); + // s0 already outputs to physical, so the world to physical translation does not apply. + expect(metadata!.multiscale.coordinateSpace.names).toStrictEqual([ + "y", + "x", + ]); + expect(metadata!.multiscale.baseInfo.baseTransform).toStrictEqual( + createIdentity(Float64Array, 3), + ); + }); + + it("should skip a transformation into the intrinsic system and use a later one", () => { + const attrs = { + ome: { + version: "0.6", + multiscales: [ + { + name: "multiscales", + coordinateSystems: [ + { + name: "physical", + axes: [{ type: "space", name: "y", unit: "micrometer" }], + }, + { + name: "world", + axes: [{ type: "space", name: "y", unit: "micrometer" }], + }, + { + name: "target_system", + axes: [{ type: "space", name: "z", unit: "millimeter" }], + }, + ], + coordinateTransformations: [ + { + type: "translation", + translation: [10], + input: { name: "world" }, + output: { name: "physical" }, + }, + { + type: "identity", + input: { name: "physical" }, + output: { name: "target_system" }, + }, + ], + datasets: [ + { + path: "array", + coordinateTransformations: [ + { + type: "identity", + input: { path: "array" }, + output: { name: "physical" }, + }, + ], + }, + ], + }, + ], + }, + }; + + const metadata = parseOmeMetadata("test://", attrs, 3); + const space = metadata!.multiscale.coordinateSpace; + expect(space.names).toStrictEqual(["z"]); + expect(space.scales[0]).toBeCloseTo(1e-3); + expect(metadata!.multiscale.baseInfo.baseTransform).toStrictEqual( + createIdentity(Float64Array, 2), + ); }); it("should throw an error for non-supported transformation types", () => { @@ -478,8 +685,8 @@ describe("OME-Zarr 0.6 sequence transformation validation", () => { coordinateTransformations: [ { type: "sequence", - output: "physical", - input: "array", + output: { name: "physical" }, + input: { path: "array" }, transformations: [ { type: "scale", scale: [4, 3, 2] }, { type: "translation", translation: [32, 21, 10] }, @@ -493,26 +700,43 @@ describe("OME-Zarr 0.6 sequence transformation validation", () => { }, }; - // This should not throw an error expect(() => parseOmeMetadata("test://", attrs, 3)).not.toThrow(); }); - it("should accept transforms with empty string input/output (optional fields)", () => { + it("should accept transforms with name/path in input/output", () => { const attrs = { ome: { version: "0.6", multiscales: [ { name: "multiscales", - coordinateSystems: [intrinsicCoordinateSystem], + coordinateSystems: [ + { + name: "scaled", + axes: [ + { name: "x", type: "space", unit: "millimeter" }, + { name: "y", type: "space", unit: "millimeter" }, + { name: "z", type: "space", unit: "millimeter" }, + ], + }, + intrinsicCoordinateSystem, + ], + coordinateTransformations: [ + { + type: "scale", + input: { name: "scaled" }, + output: { name: "physical" }, + scale: [1, 2, 3], + }, + ], datasets: [ { path: "s0", coordinateTransformations: [ { type: "scale", - output: "physical", - input: "", // Empty string means not specified + output: { name: "scaled" }, + input: { path: "s0" }, scale: [4, 3, 2], }, ], @@ -523,11 +747,10 @@ describe("OME-Zarr 0.6 sequence transformation validation", () => { }, }; - // This should not throw an error - empty strings are treated as "not specified" expect(() => parseOmeMetadata("test://", attrs, 3)).not.toThrow(); }); - it("should reject sequence transform with wrong output coordinate system", () => { + it("should reject transform with no intrinsic coordinate system", () => { const attrs = { ome: { version: "0.6", @@ -553,9 +776,8 @@ describe("OME-Zarr 0.6 sequence transformation validation", () => { }, }; - // This should throw an error expect(() => parseOmeMetadata("test://", attrs, 3)).toThrow( - /output is "wrong_system" but expected "physical"/, + /coordinate system for the intrinsic system wrong_system/, ); }); @@ -574,7 +796,7 @@ describe("OME-Zarr 0.6 sequence transformation validation", () => { { type: "sequence", output: "physical", - input: "wrong_path", + input: { path: "wrong_path" }, transformations: [{ type: "scale", scale: [4, 3, 2] }], }, ], @@ -585,7 +807,6 @@ describe("OME-Zarr 0.6 sequence transformation validation", () => { }, }; - // This should throw an error expect(() => parseOmeMetadata("test://", attrs, 3)).toThrow( /input is "wrong_path" but expected "array"/, ); @@ -622,7 +843,6 @@ describe("OME-Zarr 0.6 sequence transformation validation", () => { }, }; - // This should throw an error expect(() => parseOmeMetadata("test://", attrs, 3)).toThrow( /sequence transformation MUST NOT be part of another sequence transformation/, ); @@ -677,7 +897,6 @@ describe("OME-Zarr 0.6 sequence transformation validation", () => { }, }; - // This should not throw an error as the chain is valid expect(() => parseOmeMetadata("test://", attrs, 3)).not.toThrow(); }); @@ -730,15 +949,72 @@ describe("OME-Zarr 0.6 sequence transformation validation", () => { }, }; - // This should throw an error as the chain is broken expect(() => parseOmeMetadata("test://", attrs, 3)).toThrow( /transform 0 has output "intermediate" but transform 1 has input "wrong_system"/, ); }); + + it("should use path as first transform input to determine start of sequence chaining", () => { + const attrs = { + ome: { + version: "0.6", + type: "sequence", + multiscales: [ + { + name: "multiscales", + coordinateSystems: [ + { + name: "physical", + axes: [ + { name: "y", type: "space", unit: "micrometer" }, + { name: "x", type: "space", unit: "micrometer" }, + ], + }, + { + name: "scaled", + axes: [ + { name: "y", type: "space", unit: "micrometer" }, + { name: "x", type: "space", unit: "micrometer" }, + ], + }, + ], + datasets: [ + { + path: "s0", + coordinateTransformations: [ + { + type: "sequence", + input: { path: "s0" }, + output: { name: "physical" }, + transformations: [ + { + type: "scale", + scale: [2, 2], + input: { path: "s0" }, + output: { name: "scaled" }, + }, + { + type: "translation", + translation: [10, 20], + input: { name: "scaled" }, + output: { name: "physical" }, + }, + ], + }, + ], + }, + ], + }, + ], + }, + }; + const metadata = parseOmeMetadata("test://", attrs, 3); + expect(metadata).not.toBeUndefined(); + }); }); -describe("OME-Zarr version-gated transform behavior (issue #905)", () => { - it("should produce identity baseTransform for v0.4 with scale+translation", () => { +describe("OME-Zarr version-gated transform behavior (see also issue #905)", () => { + it("should produce identity baseTransform for < 0.6 with scale+translation", () => { // v0.4 uses the old behavior: identity base transform, translations baked into per-scale transforms const attrs = { multiscales: [ @@ -785,11 +1061,75 @@ describe("OME-Zarr version-gated transform behavior (issue #905)", () => { expect(t[14]).toBeCloseTo((300 - 1) / 2); }); - it("should handle v0.4 with multiple scales correctly", () => { + it("should combine every multiscale transformation for <0.6", () => { + // Before 0.6 there are no coordinate systems, so the multiscale-level + // transformations still compose into one transform. const attrs = { multiscales: [ { - version: "0.4", + version: "0.5", + axes: [ + { type: "space", name: "z", unit: "micrometer" }, + { type: "space", name: "y", unit: "micrometer" }, + { type: "space", name: "x", unit: "micrometer" }, + ], + coordinateTransformations: [ + { type: "scale", scale: [4, 4, 4] }, + { type: "translation", translation: [100, 200, 300] }, + ], + datasets: [ + { + path: "0", + coordinateTransformations: [{ type: "identity" }], + }, + { + path: "1", + coordinateTransformations: [{ type: "scale", scale: [2, 2, 1] }], + }, + ], + }, + ], + }; + const metadata = parseOmeMetadata("test://", attrs, 3); + + expect(metadata!.multiscale.baseInfo.baseTransform).toStrictEqual( + createIdentity(Float64Array, 4), + ); + const baseScales = metadata!.multiscale.baseInfo.baseScales; + expect(baseScales[0]).toBeCloseTo(4); + expect(baseScales[1]).toBeCloseTo(4); + expect(baseScales[2]).toBeCloseTo(4); + + // Both transformations should reach the per-scale transform. + // At each level apply the voxel offset and remove the base scale + { + // scale 0 + const t = metadata!.multiscale.scales[0].transform; + expect(t[0]).toBeCloseTo(1); + expect(t[5]).toBeCloseTo(1); + expect(t[10]).toBeCloseTo(1); + expect(t[12]).toBeCloseTo(-0.5 + 100 / 4); + expect(t[13]).toBeCloseTo(-0.5 + 200 / 4); + expect(t[14]).toBeCloseTo(-0.5 + 300 / 4); + } + + { + // scale 1 + const t = metadata!.multiscale.scales[1].transform; + expect(t[0]).toBeCloseTo(2); + expect(t[5]).toBeCloseTo(2); + expect(t[10]).toBeCloseTo(1); + expect(t[12]).toBeCloseTo(-0.5 * 2 + 100 / 4); + expect(t[13]).toBeCloseTo(-0.5 * 2 + 200 / 4); + expect(t[14]).toBeCloseTo(-0.5 * 1 + 300 / 4); + } + }); + + it("should combine base and per scale scale and translation for <0.6", () => { + const attrs = { + multiscales: [ + { + version: "0.5", axes: [ { type: "space", name: "y", unit: "micrometer" }, { type: "space", name: "x", unit: "micrometer" }, @@ -820,7 +1160,7 @@ describe("OME-Zarr version-gated transform behavior (issue #905)", () => { }, ], }; - const metadata = parseOmeMetadata("test://", attrs, 2); + const metadata = parseOmeMetadata("test://", attrs, 3); expect(metadata).not.toBeUndefined(); // baseTransform should be identity for v0.4 @@ -967,7 +1307,7 @@ describe("OME-Zarr version-gated transform behavior (issue #905)", () => { expect(t2[7]).toBeCloseTo(999.5); }); - it("should produce identity baseTransform for v0.5-dev", () => { + it("should not surface baseTransform for <0.6", () => { const attrs = { multiscales: [ { diff --git a/src/datasource/zarr/ome.ts b/src/datasource/zarr/ome.ts index a351c5da7a..4cc7261db6 100644 --- a/src/datasource/zarr/ome.ts +++ b/src/datasource/zarr/ome.ts @@ -428,30 +428,20 @@ function validateCoordinateTransformations( const input = verifyOptionalObjectProperty( transform, "input", - verifyString, - ); - const output = verifyOptionalObjectProperty( - transform, - "output", - verifyString, + parseOmeInputOutput, ); const type = verifyObjectProperty(transform, "type", verifyString); // Validate input matches expected (array path) // Empty string or undefined means the field is not specified - if (input !== undefined && input !== "" && input !== expectedInput) { - throw new Error( - `Invalid coordinate transformation for dataset at path "${path}": ` + - `input is "${input}" but expected "${expectedInput}"`, - ); - } - - // Validate output matches expected (intrinsic coordinate system) - // Empty string or undefined means the field is not specified - if (output !== undefined && output !== "" && output !== expectedOutput) { + if ( + input !== undefined && + input.path !== "" && + input.path !== expectedInput + ) { throw new Error( `Invalid coordinate transformation for dataset at path "${path}": ` + - `output is "${output}" but expected "${expectedOutput}"`, + `input is "${input.path}" but expected "${expectedInput}"`, ); } @@ -471,23 +461,23 @@ function validateCoordinateTransformations( const innerInput = verifyOptionalObjectProperty( innerTransform, "input", - verifyString, + parseOmeInputOutput, ); const innerOutput = verifyOptionalObjectProperty( innerTransform, "output", - verifyString, + parseOmeInputOutput, ); // First transform in sequence should have input matching the sequence's input if ( i === 0 && innerInput !== undefined && - innerInput !== expectedInput + innerInput.path !== expectedInput ) { throw new Error( `Invalid sequence transformation for dataset at path "${path}": ` + - `first inner transform has input "${innerInput}" but expected "${expectedInput}"`, + `first inner transform has input "${innerInput.path}" but expected "${expectedInput}"`, ); } @@ -495,11 +485,11 @@ function validateCoordinateTransformations( if ( i === innerTransforms.length - 1 && innerOutput !== undefined && - innerOutput !== expectedOutput + innerOutput.name !== expectedOutput ) { throw new Error( `Invalid sequence transformation for dataset at path "${path}": ` + - `last inner transform has output "${innerOutput}" but expected "${expectedOutput}"`, + `last inner transform has output "${innerOutput.name}" but expected "${expectedOutput}"`, ); } @@ -510,17 +500,17 @@ function validateCoordinateTransformations( const prevOutput = verifyOptionalObjectProperty( prevTransform, "output", - verifyString, + parseOmeInputOutput, ); if ( - prevOutput !== undefined && - innerInput !== undefined && - prevOutput !== innerInput + prevOutput?.name !== undefined && + innerInput?.name !== undefined && + prevOutput.name !== innerInput.name ) { throw new Error( `Invalid sequence transformation for dataset at path "${path}": ` + - `transform ${i - 1} has output "${prevOutput}" but transform ${i} has input "${innerInput}". ` + + `transform ${i - 1} has output "${prevOutput.name}" but transform ${i} has input "${innerInput.name}". ` + `Transforms in a sequence must have matching input/output for consecutive transforms.`, ); } @@ -531,6 +521,76 @@ function validateCoordinateTransformations( } } +function parseOmeInputOutput(obj: unknown) { + // OME 0.6 expects object but the RFC allowed strings where + // the string represented either path or name depending on level + // and we declare to support dev versions + if (typeof obj === "object") { + return { + name: verifyOptionalObjectProperty(obj, "name", verifyString), + path: verifyOptionalObjectProperty(obj, "path", verifyString), + }; + } else if (typeof obj === "string") { + return { + name: obj, + path: obj, + }; + } else { + throw new Error("Expected input/output to be string or object"); + } +} + +function parseMultiscaleOutput(obj: unknown) { + const transformationInputs = verifyObjectProperty( + obj, + "coordinateTransformations", + (y) => parseArray(y, (x) => x.output), + ); + return parseOmeInputOutput(transformationInputs[0]); +} + +// Find each coordinate transform with an input of the intrinsic system +// as those are the only ones which could be applied directly +function findTransformationsWithIntrinsicInput( + transformations: unknown, + intrinsicCoordinateSystemName: string, +): { transformation: unknown; outputCoordinateSystemName: string }[] { + if (transformations === undefined) return []; + const parsed = parseArray(transformations, (transformation) => { + verifyObject(transformation); + return { + transformation, + input: verifyOptionalObjectProperty( + transformation, + "input", + parseOmeInputOutput, + ), + output: verifyOptionalObjectProperty( + transformation, + "output", + parseOmeInputOutput, + ), + }; + }); + const transforms = []; + for (const { transformation, input, output } of parsed) { + if (input?.name !== intrinsicCoordinateSystemName) continue; + if (output?.name === undefined) continue; + // Avoid finding child labels groups + if ( + output.path !== undefined && + output.path.includes("labels") && + output.path !== output.name + ) + continue; + transforms.push({ + transformation, + outputCoordinateSystemName: output.name, + }); + } + return transforms; +} + function parseMultiscaleScale( rank: number, url: string, @@ -572,44 +632,85 @@ function parseOmeMultiscale( let coordinateSpace: CoordinateSpace; let intrinsicCoordinateSystemName: string | undefined; - const coordinateSystemsRaw = verifyOptionalObjectProperty( + const coordinateSystemsJson = verifyOptionalObjectProperty( multiscale, "coordinateSystems", (x) => x, ); + const coordinateTransformsJson = verifyOptionalObjectProperty( + multiscale, + "coordinateTransformations", + (x) => x, + ); + let coordinateTransformsToApply: unknown = coordinateTransformsJson; if ( - coordinateSystemsRaw !== undefined && - Array.isArray(coordinateSystemsRaw) && - coordinateSystemsRaw.length > 0 + coordinateSystemsJson !== undefined && + Array.isArray(coordinateSystemsJson) && + coordinateSystemsJson.length > 0 ) { - // OME-ZARR 0.6+: Use the last (intrinsic) coordinate system + // 0.6+ - Directly from the OME-Zarr spec: + // "In terms of metadata, the coordinate system referred to as the “intrinsic” coordinate system in this document, is the coordinate system that is referenced by all multiscale coordinate transformations under datasets as their output." + // As such, we iterate over all the scales once to find this output name + const outputNames = verifyObjectProperty(multiscale, "datasets", (obj) => + parseArray(obj, parseMultiscaleOutput), + ); + intrinsicCoordinateSystemName = outputNames[0]?.name; + const mismatch = outputNames.findIndex( + (x) => x?.name !== intrinsicCoordinateSystemName, + ); + if (mismatch !== -1) { + throw new Error( + `All output names of multiscale.datasets must be the same, candidate name from first scale is ${intrinsicCoordinateSystemName}, but found ${outputNames[mismatch].name} at scale ${mismatch}`, + ); + } + if (intrinsicCoordinateSystemName === undefined) { + throw new Error(`There must be an intrinsic coordinateSystem`); + } + const coordinateSystems = parseArray( - coordinateSystemsRaw, + coordinateSystemsJson, parseOmeCoordinateSystem, ); - coordinateSpace = coordinateSystems[coordinateSystems.length - 1]; - - // Extract the name of the intrinsic coordinate system from the raw object - const intrinsicCoordinateSystemRaw = - coordinateSystemsRaw[coordinateSystemsRaw.length - 1]; - verifyObject(intrinsicCoordinateSystemRaw); - intrinsicCoordinateSystemName = verifyObjectProperty( - intrinsicCoordinateSystemRaw, - "name", - verifyString, + + const applicableCoordinateTransforms = + findTransformationsWithIntrinsicInput( + coordinateTransformsJson, + intrinsicCoordinateSystemName, + ); + // As we have no coordinate space selector, we just apply + // the first found applicable transform + coordinateTransformsToApply = + applicableCoordinateTransforms.length === 0 + ? undefined + : [applicableCoordinateTransforms[0].transformation]; + + // The image ends in the space that the applied transformation outputs to, or in the + // intrinsic space when no transformation applies. + const nameToMatch = + applicableCoordinateTransforms[0]?.outputCoordinateSystemName ?? + intrinsicCoordinateSystemName; + + const coordinateSpaceMatch = coordinateSystemsJson.findIndex( + (x) => x.name === nameToMatch, ); + if (coordinateSpaceMatch === -1) { + const reason = + nameToMatch === intrinsicCoordinateSystemName ? "intrinsic" : "output"; + throw new Error( + `Could not find any coordinate system for the ${reason} system ${nameToMatch}`, + ); + } + coordinateSpace = coordinateSystems[coordinateSpaceMatch]; } else { // OME-ZARR 0.4/0.5: Use axes directly coordinateSpace = verifyObjectProperty(multiscale, "axes", parseOmeAxes); } const rank = coordinateSpace.rank; - const transform = verifyOptionalObjectProperty( - multiscale, - "coordinateTransformations", - (x) => parseOmeCoordinateTransforms(rank, x), - matrix.createIdentity(Float64Array, rank + 1), + const transform = parseOmeCoordinateTransforms( + rank, + coordinateTransformsToApply, ); const scales = verifyObjectProperty(multiscale, "datasets", (obj) => parseArray(obj, (x) => {