diff --git a/docs/rfc5.md b/docs/rfc5.md index 0ddf58e1..807cd682 100644 --- a/docs/rfc5.md +++ b/docs/rfc5.md @@ -53,7 +53,7 @@ position `i` is the input axis that becomes the `i`-th output axis, and every zero-based input axis index appears exactly once. `ByDimension` builds a high dimensional transform from lower dimensional ones; -each `ByDimensionItem` wraps a transformation with the `input_axes` and `output_axes` +each `ByDimensionItem` wraps a transformation with the `inputAxes` and `outputAxes` (zero-based indices into the parent's coordinate systems) it applies to, and every output axis is produced by exactly one item. diff --git a/py/examples/upgrade_ome_zarr_example.py b/py/examples/upgrade_ome_zarr_example.py index ef9fd88f..a0d5970a 100644 --- a/py/examples/upgrade_ome_zarr_example.py +++ b/py/examples/upgrade_ome_zarr_example.py @@ -111,7 +111,7 @@ def demo_write_to_new_store(tmp: Path) -> None: print(f" source version (after) : {src_after!r} (unchanged)") print(f" new store version : {dst_after!r}") assert src_after == "0.4", src_after - assert dst_after == "0.6.dev4", dst_after + assert dst_after == "0.6rc0", dst_after # Confirm the upgraded store reads back with pixel data intact. reloaded = from_ome_zarr(dst, version="0.6") @@ -142,7 +142,7 @@ def demo_in_place(tmp: Path) -> None: after_version = read_ome_version(store) after_chunks = chunk_digests(store_root) print(f" version (after) : {after_version!r}") - assert after_version == "0.6.dev4", after_version + assert after_version == "0.6rc0", after_version # Prove every array chunk file is byte-for-byte identical. assert after_chunks == before_chunks, "array chunk data changed on in-place upgrade" diff --git a/py/ngff_zarr/__init__.py b/py/ngff_zarr/__init__.py index e94da07f..e70adbe4 100644 --- a/py/ngff_zarr/__init__.py +++ b/py/ngff_zarr/__init__.py @@ -4,7 +4,7 @@ # SPDX-License-Identifier: MIT from .__about__ import __version__ -from ._supported_versions import SUPPORTED_VERSIONS +from ._supported_versions import SUPPORTED_VERSIONS, V06_ONDISK_VERSION from .cli_input_to_ngff_image import cli_input_to_ngff_image from .codecs import codec_from_name, get_available_codecs from .compute_omero import ( @@ -110,6 +110,7 @@ __all__ = [ "__version__", "SUPPORTED_VERSIONS", + "V06_ONDISK_VERSION", "config", # OMERO computation "compute_omero_from_ngff_image", diff --git a/py/ngff_zarr/_supported_versions.py b/py/ngff_zarr/_supported_versions.py index 7f4f6ee3..3912ea8a 100644 --- a/py/ngff_zarr/_supported_versions.py +++ b/py/ngff_zarr/_supported_versions.py @@ -12,8 +12,11 @@ class NgffVersion(StrEnum): V04 = "0.4" V05 = "0.5" V06 = "0.6" + #: Pre-release tags of the 0.6 spec. Both remain readable: stores written + #: while 0.6 was a draft carry the ``dev4`` tag on disk. V06dev4 = "0.6.dev4" - LATEST = "0.6.dev4" + V06rc0 = "0.6rc0" + LATEST = "0.6rc0" # Supported NGFF specification versions @@ -25,4 +28,19 @@ class NgffVersion(StrEnum): NgffVersion.V05, NgffVersion.V06, NgffVersion.V06dev4, + NgffVersion.V06rc0, ) + +#: The ``ome.version`` string written to disk for the API version ``"0.6"``. +#: The 0.6 spec is a release candidate, so a store is tagged with the +#: pre-release the bundled ``spec/0.6`` schemas carry rather than with the bare +#: ``"0.6"`` the public ``version`` option accepts. Mirrors the TypeScript +#: port's ``V06_ONDISK_VERSION``; this constant is the one place the tag lives. +V06_ONDISK_VERSION = NgffVersion.V06rc0 + +#: 0.6 pre-release tags that earlier ngff-zarr releases wrote and that the +#: bundled schemas no longer accept. A store carrying one differs from a valid +#: store in that string alone; the validating reader says so and checks the +#: rest, and ``upgrade_ome_zarr`` rewrites the tag. Any other tag is checked +#: as given, so a tag from a later spec release is not passed off as this one. +V06_SUPERSEDED_TAGS = frozenset({NgffVersion.V06dev4.value}) diff --git a/py/ngff_zarr/spec/0.6/schemas/_version.schema b/py/ngff_zarr/spec/0.6/schemas/_version.schema index 5e32f984..a5f079a6 100644 --- a/py/ngff_zarr/spec/0.6/schemas/_version.schema +++ b/py/ngff_zarr/spec/0.6/schemas/_version.schema @@ -1,10 +1,10 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://ngff.openmicroscopy.org/0.6.dev4/schemas/_version.schema", + "$id": "https://ngff.openmicroscopy.org/0.6rc0/schemas/_version.schema", "title": "OME-Zarr version", "description": "OME-Zarr version.", "type": "string", "enum": [ - "0.6.dev4" + "0.6rc0" ] } diff --git a/py/ngff_zarr/spec/0.6/schemas/axes.schema b/py/ngff_zarr/spec/0.6/schemas/axes.schema index 49f59683..14b007fb 100644 --- a/py/ngff_zarr/spec/0.6/schemas/axes.schema +++ b/py/ngff_zarr/spec/0.6/schemas/axes.schema @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://ngff.openmicroscopy.org/0.6.dev4/schemas/axes.schema", + "$id": "https://ngff.openmicroscopy.org/0.6rc0/schemas/axes.schema", "title": "Axes", "description": "OME-Zarr Axes.", "type": "array", diff --git a/py/ngff_zarr/spec/0.6/schemas/bf2raw.schema b/py/ngff_zarr/spec/0.6/schemas/bf2raw.schema index 60281e5c..80a4bc1b 100644 --- a/py/ngff_zarr/spec/0.6/schemas/bf2raw.schema +++ b/py/ngff_zarr/spec/0.6/schemas/bf2raw.schema @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://ngff.openmicroscopy.org/0.6.dev4/schemas/bf2raw.schema", + "$id": "https://ngff.openmicroscopy.org/0.6rc0/schemas/bf2raw.schema", "title": "bioformats2raw", "description": "OME-Zarr bioformats2raw metadata.", "type": "object", @@ -17,7 +17,7 @@ ] }, "version": { - "$ref": "https://ngff.openmicroscopy.org/0.6.dev4/schemas/_version.schema" + "$ref": "https://ngff.openmicroscopy.org/0.6rc0/schemas/_version.schema" } }, "required": [ diff --git a/py/ngff_zarr/spec/0.6/schemas/coordinate_systems.schema b/py/ngff_zarr/spec/0.6/schemas/coordinate_systems.schema index 686c14bf..424c2285 100644 --- a/py/ngff_zarr/spec/0.6/schemas/coordinate_systems.schema +++ b/py/ngff_zarr/spec/0.6/schemas/coordinate_systems.schema @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://ngff.openmicroscopy.org/0.6.dev4/schemas/coordinate_systems.schema", + "$id": "https://ngff.openmicroscopy.org/0.6rc0/schemas/coordinate_systems.schema", "title": "Coordinate systems", "description": "OME-Zarr coordinate system.", "type": "array", diff --git a/py/ngff_zarr/spec/0.6/schemas/coordinate_transformations.schema b/py/ngff_zarr/spec/0.6/schemas/coordinate_transformations.schema index 7500032f..5ddb6066 100644 --- a/py/ngff_zarr/spec/0.6/schemas/coordinate_transformations.schema +++ b/py/ngff_zarr/spec/0.6/schemas/coordinate_transformations.schema @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://ngff.openmicroscopy.org/0.6.dev4/schemas/coordinate_transformations.schema", + "$id": "https://ngff.openmicroscopy.org/0.6rc0/schemas/coordinate_transformations.schema", "title": "Coordinate Transformations", "description": "OME-Zarr Coordinate transforms.", "type": "array", @@ -67,6 +67,9 @@ { "$ref": "#/$defs/mapAxis" }, + { + "$ref": "#/$defs/projectAxis" + }, { "$ref": "#/$defs/scale" }, @@ -133,6 +136,51 @@ "mapAxis" ] }, + "projectAxis": { + "type": "object", + "title": "projectAxis transformation", + "description": "Add or drop axes from a coordinate vector.", + "allOf": [ + { + "properties": { + "type": { + "const": "projectAxis" + }, + "droppedInputs": { + "type": "array", + "minItems": 1, + "items": { + "type": "integer", + "minimum": 0, + "maximum": 4 + }, + "maxItems": 3, + "uniqueItems": true, + "description": "An array of integers representing the indices of the input axes to drop." + }, + "createdOutputs": { + "type": "array", + "minItems": 1, + "items": { + "type": "integer", + "minimum": 0, + "maximum": 4 + }, + "maxItems": 3, + "uniqueItems": true, + "description": "An array of integers representing the indices where zeros are inserted in the output coordinate vector." + } + } + }, + { + "anyOf": [ + {"required": ["droppedInputs"]}, + {"required": ["createdOutputs"]} + ] + } + ] + + }, "scale": { "type": "object", "title": "Scale Transformation", @@ -319,27 +367,25 @@ "transformation": { "$ref": "#/$defs/coordinateTransformation" }, - "input_axes": { + "inputAxes": { "type": "array", "items": { - "type": "integer", - "minimum": 0 + "type": "number" }, - "description": "Zero-based axis indices into the parent byDimension transformation's input coordinate system." + "description": "Names of the input axes for this transformation." }, - "output_axes": { + "outputAxes": { "type": "array", "items": { - "type": "integer", - "minimum": 0 + "type": "number" }, - "description": "Zero-based axis indices into the parent byDimension transformation's output coordinate system." + "description": "Names of the output axes for this transformation." } }, "required": [ "transformation", - "input_axes", - "output_axes" + "inputAxes", + "outputAxes" ] } } diff --git a/py/ngff_zarr/spec/0.6/schemas/image.schema b/py/ngff_zarr/spec/0.6/schemas/image.schema index 280159dc..379b00f4 100644 --- a/py/ngff_zarr/spec/0.6/schemas/image.schema +++ b/py/ngff_zarr/spec/0.6/schemas/image.schema @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://ngff.openmicroscopy.org/0.6.dev4/schemas/image.schema", + "$id": "https://ngff.openmicroscopy.org/0.6rc0/schemas/image.schema", "title": "Image", "description": "OME-Zarr image.", "type": "object", @@ -16,7 +16,7 @@ "$ref": "#/$defs/multiscales" }, "version": { - "$ref": "https://ngff.openmicroscopy.org/0.6.dev4/schemas/_version.schema" + "$ref": "https://ngff.openmicroscopy.org/0.6rc0/schemas/_version.schema" } }, "required": [ @@ -56,14 +56,14 @@ "description": "A single scale transformation", "allOf": [ { - "$ref": "https://ngff.openmicroscopy.org/0.6.dev4/schemas/coordinate_transformations.schema#/$defs/scale" + "$ref": "https://ngff.openmicroscopy.org/0.6rc0/schemas/coordinate_transformations.schema#/$defs/scale" }, { "type": "object", "properties": { "input": { "allOf": [ - {"$ref": "https://ngff.openmicroscopy.org/0.6.dev4/schemas/coordinate_transformations.schema#/$defs/inputOutput"}, + {"$ref": "https://ngff.openmicroscopy.org/0.6rc0/schemas/coordinate_transformations.schema#/$defs/inputOutput"}, { "required": ["path"] } @@ -71,7 +71,7 @@ }, "output": { "allOf": [ - {"$ref": "https://ngff.openmicroscopy.org/0.6.dev4/schemas/coordinate_transformations.schema#/$defs/inputOutput"}, + {"$ref": "https://ngff.openmicroscopy.org/0.6rc0/schemas/coordinate_transformations.schema#/$defs/inputOutput"}, { "required": ["name"] } @@ -86,13 +86,13 @@ { "description": "A single identity transformation", "allOf": [ - {"$ref": "https://ngff.openmicroscopy.org/0.6.dev4/schemas/coordinate_transformations.schema#/$defs/identity"}, + {"$ref": "https://ngff.openmicroscopy.org/0.6rc0/schemas/coordinate_transformations.schema#/$defs/identity"}, { "type": "object", "properties": { "input": { "allOf": [ - {"$ref": "https://ngff.openmicroscopy.org/0.6.dev4/schemas/coordinate_transformations.schema#/$defs/inputOutput"}, + {"$ref": "https://ngff.openmicroscopy.org/0.6rc0/schemas/coordinate_transformations.schema#/$defs/inputOutput"}, { "required": ["path"] } @@ -100,7 +100,7 @@ }, "output": { "allOf": [ - {"$ref": "https://ngff.openmicroscopy.org/0.6.dev4/schemas/coordinate_transformations.schema#/$defs/inputOutput"}, + {"$ref": "https://ngff.openmicroscopy.org/0.6rc0/schemas/coordinate_transformations.schema#/$defs/inputOutput"}, { "required": ["name"] } @@ -121,8 +121,8 @@ "type": "array", "items": { "oneOf": [ - {"$ref": "https://ngff.openmicroscopy.org/0.6.dev4/schemas/coordinate_transformations.schema#/$defs/scale"}, - {"$ref": "https://ngff.openmicroscopy.org/0.6.dev4/schemas/coordinate_transformations.schema#/$defs/translation"} + {"$ref": "https://ngff.openmicroscopy.org/0.6rc0/schemas/coordinate_transformations.schema#/$defs/scale"}, + {"$ref": "https://ngff.openmicroscopy.org/0.6rc0/schemas/coordinate_transformations.schema#/$defs/translation"} ] }, "minItems": 2, @@ -130,7 +130,7 @@ }, "input": { "allOf": [ - {"$ref": "https://ngff.openmicroscopy.org/0.6.dev4/schemas/coordinate_transformations.schema#/$defs/inputOutput"}, + {"$ref": "https://ngff.openmicroscopy.org/0.6rc0/schemas/coordinate_transformations.schema#/$defs/inputOutput"}, { "required": ["path"] } @@ -138,7 +138,7 @@ }, "output": { "allOf": [ - {"$ref": "https://ngff.openmicroscopy.org/0.6.dev4/schemas/coordinate_transformations.schema#/$defs/inputOutput"}, + {"$ref": "https://ngff.openmicroscopy.org/0.6rc0/schemas/coordinate_transformations.schema#/$defs/inputOutput"}, { "required": ["name"] } @@ -165,7 +165,7 @@ "type": "array", "minItems": 1, "items": { - "$ref": "https://ngff.openmicroscopy.org/0.6.dev4/schemas/coordinate_systems.schema#/$defs/coordinateSystem" + "$ref": "https://ngff.openmicroscopy.org/0.6rc0/schemas/coordinate_systems.schema#/$defs/coordinateSystem" } }, "coordinateTransformations": { @@ -174,67 +174,31 @@ "items": { "allOf": [ { - "$ref": "https://ngff.openmicroscopy.org/0.6.dev4/schemas/coordinate_transformations.schema#/$defs/coordinateTransformation", + "$ref": "https://ngff.openmicroscopy.org/0.6rc0/schemas/coordinate_transformations.schema#/$defs/coordinateTransformation", "description": "Parameters of any possible transform" }, { - "oneOf": [ - { + "type": "object", + "description": "Transformations between two named coordinate systems same metadata document or in a child labels group", + "properties": { + "input": { "type": "object", - "description": "Transformations between two coordinate systems referenced by name in the same metadata document", "properties": { - "input": { - "type": "object", - "properties": { - "name": {"type": "string"} - }, - "required": ["name"], - "additionalProperties": false, - "description": "Must reference a coordinate system defined in the same metadata document (path empty)" - }, - "output": { - "type": "object", - "properties": { - "name": {"type": "string"} - }, - "required": ["name"], - "additionalProperties": false, - "description": "Must reference a coordinate system defined in the same metadata document (path empty)" - } + "name": {"type": "string"} }, - "required": ["input", "output"] + "required": ["name"], + "description": "Reference to named coordinate system in the same metadata document (path empty) or in a child labels group (path to the labels group)" }, - { + "output": { "type": "object", - "description": "Transformations pointing from a coordinate system in the same metadata document to a coordinate system defined externally by a path to a multiscale dataset", "properties": { - "input": { - "type": "object", - "properties": { - "name": {"type": "string"} - }, - "required": ["name"], - "additionalProperties": false - }, - "output": { - "type": "object", - "properties": { - "name": {"type": "string"}, - "path": { - "type": "string", - "description": "Allows only relative, downward paths to prevent referencing external metadata documents", - "not": { - "pattern": "^(\\.\\./|/)" - } - } - }, - "required": ["name", "path"], - "additionalProperties": false - } + "name": {"type": "string"} }, - "required": ["input", "output"] + "required": ["name"], + "description": "Reference to named coordinate system in the same metadata document (path empty) or in a child labels group (path to the labels group)" } - ] + }, + "required": ["input", "output"] } ] } diff --git a/py/ngff_zarr/spec/0.6/schemas/label.schema b/py/ngff_zarr/spec/0.6/schemas/label.schema index ea579b4a..1c753ebf 100644 --- a/py/ngff_zarr/spec/0.6/schemas/label.schema +++ b/py/ngff_zarr/spec/0.6/schemas/label.schema @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://ngff.openmicroscopy.org/0.6.dev4/schemas/label.schema", + "$id": "https://ngff.openmicroscopy.org/0.6rc0/schemas/label.schema", "title": "Label", "description": "OME-Zarr label.", "type": "object", @@ -13,7 +13,7 @@ "$ref": "#/$defs/image-label" }, "version": { - "$ref": "https://ngff.openmicroscopy.org/0.6.dev4/schemas/_version.schema" + "$ref": "https://ngff.openmicroscopy.org/0.6rc0/schemas/_version.schema" } }, "required": [ diff --git a/py/ngff_zarr/spec/0.6/schemas/ome.schema b/py/ngff_zarr/spec/0.6/schemas/ome.schema index c332fbe8..32d0c546 100644 --- a/py/ngff_zarr/spec/0.6/schemas/ome.schema +++ b/py/ngff_zarr/spec/0.6/schemas/ome.schema @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://ngff.openmicroscopy.org/0.6.dev4/schemas/ome.schema", + "$id": "https://ngff.openmicroscopy.org/0.6rc0/schemas/ome.schema", "title": "OME", "description": "OME-Zarr OME metadata.", "type": "object", @@ -18,7 +18,7 @@ "minContains": 1 }, "version": { - "$ref": "https://ngff.openmicroscopy.org/0.6.dev4/schemas/_version.schema" + "$ref": "https://ngff.openmicroscopy.org/0.6rc0/schemas/_version.schema" } }, "required": [ diff --git a/py/ngff_zarr/spec/0.6/schemas/ome_zarr.schema b/py/ngff_zarr/spec/0.6/schemas/ome_zarr.schema index fa967e20..6ceb634a 100644 --- a/py/ngff_zarr/spec/0.6/schemas/ome_zarr.schema +++ b/py/ngff_zarr/spec/0.6/schemas/ome_zarr.schema @@ -1,29 +1,29 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://ngff.openmicroscopy.org/0.6.dev4/schemas/ome_zarr.schema", + "$id": "https://ngff.openmicroscopy.org/0.6rc0/schemas/ome_zarr.schema", "title": "OME-Zarr", "description": "Any OME-Zarr dataset.", "anyOf": [ { - "$ref": "https://ngff.openmicroscopy.org/0.6.dev4/schemas/bf2raw.schema" + "$ref": "https://ngff.openmicroscopy.org/0.6rc0/schemas/bf2raw.schema" }, { - "$ref": "https://ngff.openmicroscopy.org/0.6.dev4/schemas/image.schema" + "$ref": "https://ngff.openmicroscopy.org/0.6rc0/schemas/image.schema" }, { - "$ref": "https://ngff.openmicroscopy.org/0.6.dev4/schemas/label.schema" + "$ref": "https://ngff.openmicroscopy.org/0.6rc0/schemas/label.schema" }, { - "$ref": "https://ngff.openmicroscopy.org/0.6.dev4/schemas/ome.schema" + "$ref": "https://ngff.openmicroscopy.org/0.6rc0/schemas/ome.schema" }, { - "$ref": "https://ngff.openmicroscopy.org/0.6.dev4/schemas/plate.schema" + "$ref": "https://ngff.openmicroscopy.org/0.6rc0/schemas/plate.schema" }, { - "$ref": "https://ngff.openmicroscopy.org/0.6.dev4/schemas/well.schema" + "$ref": "https://ngff.openmicroscopy.org/0.6rc0/schemas/well.schema" }, { - "$ref": "https://ngff.openmicroscopy.org/0.6.dev4/schemas/scene.schema" + "$ref": "https://ngff.openmicroscopy.org/0.6rc0/schemas/scene.schema" } ] } diff --git a/py/ngff_zarr/spec/0.6/schemas/plate.schema b/py/ngff_zarr/spec/0.6/schemas/plate.schema index 361e7938..1c398913 100644 --- a/py/ngff_zarr/spec/0.6/schemas/plate.schema +++ b/py/ngff_zarr/spec/0.6/schemas/plate.schema @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://ngff.openmicroscopy.org/0.6.dev4/schemas/plate.schema", + "$id": "https://ngff.openmicroscopy.org/0.6rc0/schemas/plate.schema", "title": "Plate", "description": "OME-Zarr plate.", "type": "object", @@ -138,7 +138,7 @@ ] }, "version": { - "$ref": "https://ngff.openmicroscopy.org/0.6.dev4/schemas/_version.schema" + "$ref": "https://ngff.openmicroscopy.org/0.6rc0/schemas/_version.schema" } }, "required": [ diff --git a/py/ngff_zarr/spec/0.6/schemas/scene.schema b/py/ngff_zarr/spec/0.6/schemas/scene.schema index 8c07d074..96100026 100644 --- a/py/ngff_zarr/spec/0.6/schemas/scene.schema +++ b/py/ngff_zarr/spec/0.6/schemas/scene.schema @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://ngff.openmicroscopy.org/0.6.dev4/schemas/scene.schema", + "$id": "https://ngff.openmicroscopy.org/0.6rc0/schemas/scene.schema", "title": "Scene", "description": "Scene metadata combining coordinate systems and coordinate transformations to define spatial relationships", "type": "object", @@ -8,6 +8,9 @@ "ome": { "type": "object", "properties": { + "version": { + "$ref": "https://ngff.openmicroscopy.org/0.6rc0/schemas/_version.schema" + }, "scene":{ "properties": { "coordinateSystems": { @@ -16,74 +19,50 @@ }, "coordinateTransformations": { "$comment": "Merge general coordinate transformations with constraints for scene metadata", - "allOf": [ - { - "$ref": "coordinate_transformations.schema", - "description": "General coordinate transformations defining spatial relationships between coordinate systems" - }, - { - "type": "array", - "items": { - "allOf": [ - { - "properties": { - "input": { - "allOf": [ - {"$ref": "coordinate_transformations.schema#/$defs/inputOutput"}, - {"required": ["name"]} - ] - }, - "output": { - "allOf": [ - {"$ref": "coordinate_transformations.schema#/$defs/inputOutput"}, - {"required": ["name"]} - ] - } - } - } - ] + "type": "array", + "minItems": 1, + "items": { + "allOf": [ + { + "$ref": "coordinate_transformations.schema#/$defs/coordinateTransformation", + "description": "Parameters of any possible transform" }, - "description": "Constrained input/output for coordinate transformations in scene metadata" - } - ] - - }, - "arrayCoordinateSystem": { - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Name of coordinate space" - }, - "axes": { - "allOf": [ - { - "$ref": "axes.schema" - }, - { - "type": "array", - "items": { + { + "type": "object", + "description": "Transformations between two referenced coordinate systems", + "properties": { + "input": { "type": "object", "properties": { - "type": { - "const": "array" - } - } + "name": {"type": "string"}, + "path": {"type": "string"} + }, + "required": ["name"], + "additionalProperties": false, + "description": "Must reference a coordinate system defined in the same metadata document (path empty) or in a subgroup (path provided)" + }, + "output": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "path": {"type": "string"} + }, + "required": ["name"], + "additionalProperties": false, + "description": "Must reference a coordinate system defined in the same metadata document (path empty) or in a subgroup (path provided)" } - } - ] - } - }, - "required": [ - "axes" - ] + }, + "required": ["input", "output"] + } + ] + } } }, "type": "object", "required": ["coordinateTransformations"] } }, - "required": ["scene"] + "required": ["scene", "version"] } }, "required": ["ome"] diff --git a/py/ngff_zarr/spec/0.6/schemas/strict_axes.schema b/py/ngff_zarr/spec/0.6/schemas/strict_axes.schema index f3e34250..70288fbc 100644 --- a/py/ngff_zarr/spec/0.6/schemas/strict_axes.schema +++ b/py/ngff_zarr/spec/0.6/schemas/strict_axes.schema @@ -1,11 +1,11 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://ngff.openmicroscopy.org/0.6.dev4/schemas/strict_axes.schema", + "$id": "https://ngff.openmicroscopy.org/0.6rc0/schemas/strict_axes.schema", "title": "NGFF Strict Axes", "description": "JSON from OME-NGFF .zattrs", "allOf": [ { - "$ref": "https://ngff.openmicroscopy.org/0.6.dev4/schemas/axes.schema" + "$ref": "https://ngff.openmicroscopy.org/0.6rc0/schemas/axes.schema" }, { "items": { diff --git a/py/ngff_zarr/spec/0.6/schemas/strict_coordinate_systems.schema b/py/ngff_zarr/spec/0.6/schemas/strict_coordinate_systems.schema index 848aec46..2f9c6372 100644 --- a/py/ngff_zarr/spec/0.6/schemas/strict_coordinate_systems.schema +++ b/py/ngff_zarr/spec/0.6/schemas/strict_coordinate_systems.schema @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://ngff.openmicroscopy.org/0.6.dev4/schemas/strict_coordinate_systems.schema", + "$id": "https://ngff.openmicroscopy.org/0.6rc0/schemas/strict_coordinate_systems.schema", "allOf" : [ { "$ref": "coordinate_systems.schema" diff --git a/py/ngff_zarr/spec/0.6/schemas/strict_image.schema b/py/ngff_zarr/spec/0.6/schemas/strict_image.schema index b5f8e8f0..cb8196ed 100644 --- a/py/ngff_zarr/spec/0.6/schemas/strict_image.schema +++ b/py/ngff_zarr/spec/0.6/schemas/strict_image.schema @@ -1,9 +1,9 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://ngff.openmicroscopy.org/0.6.dev4/schemas/strict_image.schema", + "$id": "https://ngff.openmicroscopy.org/0.6rc0/schemas/strict_image.schema", "allOf": [ { - "$ref": "https://ngff.openmicroscopy.org/0.6.dev4/schemas/image.schema" + "$ref": "https://ngff.openmicroscopy.org/0.6rc0/schemas/image.schema" }, { "properties": { diff --git a/py/ngff_zarr/spec/0.6/schemas/strict_label.schema b/py/ngff_zarr/spec/0.6/schemas/strict_label.schema index ede5444f..50322e86 100644 --- a/py/ngff_zarr/spec/0.6/schemas/strict_label.schema +++ b/py/ngff_zarr/spec/0.6/schemas/strict_label.schema @@ -1,9 +1,9 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://ngff.openmicroscopy.org/0.6.dev4/schemas/strict_label.schema", + "$id": "https://ngff.openmicroscopy.org/0.6rc0/schemas/strict_label.schema", "allOf": [ { - "$ref": "https://ngff.openmicroscopy.org/0.6.dev4/schemas/label.schema" + "$ref": "https://ngff.openmicroscopy.org/0.6rc0/schemas/label.schema" }, { "properties": { diff --git a/py/ngff_zarr/spec/0.6/schemas/strict_ome_zarr.schema b/py/ngff_zarr/spec/0.6/schemas/strict_ome_zarr.schema index ab0fe932..25e75302 100644 --- a/py/ngff_zarr/spec/0.6/schemas/strict_ome_zarr.schema +++ b/py/ngff_zarr/spec/0.6/schemas/strict_ome_zarr.schema @@ -1,24 +1,24 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://ngff.openmicroscopy.org/0.6.dev4/schemas/strict_ome_zarr.schema", + "$id": "https://ngff.openmicroscopy.org/0.6rc0/schemas/strict_ome_zarr.schema", "anyOf": [ { - "$ref": "https://ngff.openmicroscopy.org/0.6.dev4/schemas/bf2raw.schema" + "$ref": "https://ngff.openmicroscopy.org/0.6rc0/schemas/bf2raw.schema" }, { - "$ref": "https://ngff.openmicroscopy.org/0.6.dev4/schemas/strict_image.schema" + "$ref": "https://ngff.openmicroscopy.org/0.6rc0/schemas/strict_image.schema" }, { - "$ref": "https://ngff.openmicroscopy.org/0.6.dev4/schemas/strict_label.schema" + "$ref": "https://ngff.openmicroscopy.org/0.6rc0/schemas/strict_label.schema" }, { - "$ref": "https://ngff.openmicroscopy.org/0.6.dev4/schemas/ome.schema" + "$ref": "https://ngff.openmicroscopy.org/0.6rc0/schemas/ome.schema" }, { - "$ref": "https://ngff.openmicroscopy.org/0.6.dev4/schemas/strict_plate.schema" + "$ref": "https://ngff.openmicroscopy.org/0.6rc0/schemas/strict_plate.schema" }, { - "$ref": "https://ngff.openmicroscopy.org/0.6.dev4/schemas/strict_well.schema" + "$ref": "https://ngff.openmicroscopy.org/0.6rc0/schemas/strict_well.schema" } ] } diff --git a/py/ngff_zarr/spec/0.6/schemas/strict_plate.schema b/py/ngff_zarr/spec/0.6/schemas/strict_plate.schema index 3eed3015..0a23bb36 100644 --- a/py/ngff_zarr/spec/0.6/schemas/strict_plate.schema +++ b/py/ngff_zarr/spec/0.6/schemas/strict_plate.schema @@ -1,9 +1,9 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://ngff.openmicroscopy.org/0.6.dev4/schemas/strict_plate.schema", + "$id": "https://ngff.openmicroscopy.org/0.6rc0/schemas/strict_plate.schema", "allOf": [ { - "$ref": "https://ngff.openmicroscopy.org/0.6.dev4/schemas/plate.schema" + "$ref": "https://ngff.openmicroscopy.org/0.6rc0/schemas/plate.schema" }, { "properties": { diff --git a/py/ngff_zarr/spec/0.6/schemas/strict_well.schema b/py/ngff_zarr/spec/0.6/schemas/strict_well.schema index 7f4f66b2..004860ea 100644 --- a/py/ngff_zarr/spec/0.6/schemas/strict_well.schema +++ b/py/ngff_zarr/spec/0.6/schemas/strict_well.schema @@ -1,5 +1,5 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://ngff.openmicroscopy.org/0.6.dev4/schemas/strict_well.schema", - "$ref": "https://ngff.openmicroscopy.org/0.6.dev4/schemas/well.schema" + "$id": "https://ngff.openmicroscopy.org/0.6rc0/schemas/strict_well.schema", + "$ref": "https://ngff.openmicroscopy.org/0.6rc0/schemas/well.schema" } diff --git a/py/ngff_zarr/spec/0.6/schemas/well.schema b/py/ngff_zarr/spec/0.6/schemas/well.schema index 5823f48b..e004b3d7 100644 --- a/py/ngff_zarr/spec/0.6/schemas/well.schema +++ b/py/ngff_zarr/spec/0.6/schemas/well.schema @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://ngff.openmicroscopy.org/0.6.dev4/schemas/well.schema", + "$id": "https://ngff.openmicroscopy.org/0.6rc0/schemas/well.schema", "title": "Well", "description": "OME-Zarr well.", "type": "object", @@ -48,7 +48,7 @@ ] }, "version": { - "$ref": "https://ngff.openmicroscopy.org/0.6.dev4/schemas/_version.schema" + "$ref": "https://ngff.openmicroscopy.org/0.6rc0/schemas/_version.schema" } }, "required": [ diff --git a/py/ngff_zarr/to_ngff_zarr.py b/py/ngff_zarr/to_ngff_zarr.py index 2a94636d..3ca07f72 100644 --- a/py/ngff_zarr/to_ngff_zarr.py +++ b/py/ngff_zarr/to_ngff_zarr.py @@ -13,7 +13,7 @@ from itkwasm import array_like_to_numpy_array from ._store_types import StoreLike -from ._supported_versions import NgffVersion +from ._supported_versions import V06_ONDISK_VERSION, NgffVersion from ._zarrista_utils import ( consolidate_metadata as _zarrista_consolidate_metadata, ) @@ -265,6 +265,28 @@ def _validate_ngff_parameters( ) +def _gate_top_level_transforms(metadata, version: str) -> None: + """Refuse a multiscale-level transform the 0.6 schema cannot express. + + From 0.6 a transform on the multiscales entry maps between two named + coordinate systems, and the schema requires both ``input`` and ``output`` + to name one. The writer serializes whatever the model holds, so a missing + reference would produce a store the validated reader rejects. + """ + if version != "0.6" or not metadata.coordinateTransformations: + return + for index, transform in enumerate(metadata.coordinateTransformations): + for side in ("input", "output"): + reference = getattr(transform, side, None) + if reference is None or getattr(reference, "name", None) is None: + raise ValueError( + f"multiscales coordinateTransformations[{index}] " + f"({transform.type}) names no {side} coordinate system; " + "OME-Zarr 0.6 requires every multiscale-level transformation " + "to name both its input and its output coordinate system" + ) + + def _prepare_metadata( multiscales: NgffMultiscales, version: str ) -> tuple[Metadata_v04 | Metadata_v05, tuple[str, ...], dict]: @@ -295,15 +317,15 @@ def _root_ome_attrs(metadata_dict: dict, version: str) -> dict: Returns the ``ome``/``multiscales`` attribute mapping (hoisting ``omero`` to its version-specific location) exactly as the writer persists it -- - including mapping the API version ``"0.6"`` to the ``"0.6.dev4"`` string - stored on disk. ``metadata_dict`` is mutated in place: its ``omero`` entry + including mapping the API version ``"0.6"`` to the pre-release string + stored on disk (:data:`~ngff_zarr._supported_versions.V06_ONDISK_VERSION`). ``metadata_dict`` is mutated in place: its ``omero`` entry is popped so it lives only in its hoisted location, matching historical behavior. """ if version != "0.4": # RFC 2, Zarr 3 - omero goes inside ome namespace if version == "0.6": - version = "0.6.dev4" + version = V06_ONDISK_VERSION.value ome_dict = {"version": version, "multiscales": [metadata_dict]} if "omero" in metadata_dict: ome_dict["omero"] = metadata_dict.pop("omero") @@ -1065,6 +1087,7 @@ def _to_ngff_zarr_impl( _validate_ngff_parameters(version, chunks_per_shard) metadata, dimension_names, _ = _prepare_metadata(multiscales, version) + _gate_top_level_transforms(metadata, version) metadata_dict = asdict(metadata) metadata_dict = _pop_metadata_optionals(metadata_dict) metadata_dict["@type"] = "ngff:Image" diff --git a/py/ngff_zarr/upgrade_ome_zarr.py b/py/ngff_zarr/upgrade_ome_zarr.py index 6de98c10..2a14f8bd 100644 --- a/py/ngff_zarr/upgrade_ome_zarr.py +++ b/py/ngff_zarr/upgrade_ome_zarr.py @@ -49,7 +49,7 @@ from ._remote_reader import RemoteZarrStore, remote_read_available from ._store_types import StoreLike -from ._supported_versions import NgffVersion +from ._supported_versions import V06_ONDISK_VERSION, NgffVersion from ._zarrista_utils import ( _is_local_path, create_zarrista_array, @@ -82,16 +82,28 @@ def _normalize_target_version(version: str | NgffVersion) -> str: """Return the API version string (``"0.4"``/``"0.5"``/``"0.6"``). - Both the API alias ``"0.6"`` and the on-disk development string - ``"0.6.dev4"`` normalize to ``"0.6"`` so the value can be handed to + Both the API alias ``"0.6"`` and the on-disk pre-release strings of the + 0.6 family normalize to ``"0.6"`` so the value can be handed to ``Metadata.to_version`` (which only knows the three released versions). """ nv = NgffVersion(version) - if nv in (NgffVersion.V06, NgffVersion.V06dev4): + if nv in (NgffVersion.V06, NgffVersion.V06dev4, NgffVersion.V06rc0): return "0.6" return nv.value +def _ondisk_version_for(target_version: str) -> str: + """The ``ome.version`` string a store written at ``target_version`` carries. + + ``"0.4"`` and ``"0.5"`` are written as themselves; ``"0.6"`` is written as + the pre-release tag the bundled schemas carry, see + :data:`~ngff_zarr._supported_versions.V06_ONDISK_VERSION`. + """ + if target_version == "0.6": + return V06_ONDISK_VERSION.value + return target_version + + def _zarr_format_for_version(version: str) -> int: """OME-Zarr < 0.5 lives in Zarr v2; 0.5 and later live in Zarr v3.""" if packaging.version.parse(version) < packaging.version.parse("0.5"): @@ -492,10 +504,11 @@ def upgrade_ome_zarr( source_zarr_format = _zarr_format_for_version(source_version) same_store = _stores_are_same(input, output) - # No-op: the source spec version already equals the requested target. - # Normalize both to the API version so an on-disk "0.6.dev4" matches a - # requested "0.6". - if _normalize_target_version(source_version) == target_version and same_store: + # No-op: the store already carries the exact tag the target would write. + # Compared on the on-disk string rather than the API version, so a 0.6 + # store tagged with an earlier pre-release is rewritten and its tag catches + # up with the vendored schemas, which is the only way to re-tag it. + if source_version == _ondisk_version_for(target_version) and same_store: # In-place no-op: leave the store bit-for-bit unchanged (no read, no # write). When an ``output`` is given for a same-version request we fall # through instead, so the user still gets their requested new store. diff --git a/py/ngff_zarr/v06/zarr_metadata.py b/py/ngff_zarr/v06/zarr_metadata.py index b68ada67..7a767622 100644 --- a/py/ngff_zarr/v06/zarr_metadata.py +++ b/py/ngff_zarr/v06/zarr_metadata.py @@ -1,11 +1,17 @@ # SPDX-FileCopyrightText: Copyright (c) Fideus Labs LLC # SPDX-License-Identifier: MIT +import copy +import warnings from abc import ABC from dataclasses import dataclass, field from typing import TYPE_CHECKING, Literal, Union from .._store_types import StoreLike -from .._supported_versions import NgffVersion +from .._supported_versions import ( + V06_ONDISK_VERSION, + V06_SUPERSEDED_TAGS, + NgffVersion, +) from ..rfc4 import AnatomicalOrientation from ..v04.zarr_metadata import ( AxesType as AxesTypeV04, @@ -241,57 +247,67 @@ def _item_dimensions(transformation: "Transform") -> int | None: return None +_BY_DIMENSION_LEGACY_KEYS = {"input_axes": "inputAxes", "output_axes": "outputAxes"} + + @dataclass class ByDimensionItem: """One lower-dimensional transformation of a byDimension transform. - ``input_axes`` and ``output_axes`` hold zero-based axis indices into the + ``inputAxes`` and ``outputAxes`` hold zero-based axis indices into the parent byDimension's input and output coordinate systems. """ transformation: Transform - input_axes: list[int] - output_axes: list[int] + inputAxes: list[int] + outputAxes: list[int] def __post_init__(self) -> None: self._check_intrinsic() def _check_intrinsic(self) -> None: - axes = list(self.input_axes) + list(self.output_axes) + axes = list(self.inputAxes) + list(self.outputAxes) _require_integer_axes(axes, "byDimension") if any(axis < 0 for axis in axes): raise ValueError( f"byDimension axis indices must be non-negative; got {axes}" ) - if len(set(self.output_axes)) != len(self.output_axes): + if len(set(self.outputAxes)) != len(self.outputAxes): raise ValueError( "byDimension output axes must each be produced by exactly " - f"one transformation; {self.output_axes} repeats an axis" + f"one transformation; {self.outputAxes} repeats an axis" ) dimensions = _item_dimensions(self.transformation) if dimensions is not None and ( - len(self.input_axes) != dimensions or len(self.output_axes) != dimensions + len(self.inputAxes) != dimensions or len(self.outputAxes) != dimensions ): raise ValueError( f"byDimension item of type '{self.transformation.type}' is " - f"{dimensions}-dimensional but maps {len(self.input_axes)} " - f"input axes to {len(self.output_axes)} output axes" + f"{dimensions}-dimensional but maps {len(self.inputAxes)} " + f"input axes to {len(self.outputAxes)} output axes" ) @classmethod def from_dict( cls, data: dict, coordinateSystems: list[CoordinateSystem] | None = None ) -> "ByDimensionItem": + # ngff-zarr 0.43.0 wrote these two keys in snake_case; the spec and the + # 0.6rc0 schema spell them inputAxes and outputAxes, which is what is + # written now. Both spellings are read, the spec one taking precedence + # when a document carries both. + for legacy, canonical in _BY_DIMENSION_LEGACY_KEYS.items(): + if legacy in data and canonical not in data: + data = {**data, canonical: data[legacy]} _require_keys( - data, ("transformation", "input_axes", "output_axes"), "byDimension item" + data, ("transformation", "inputAxes", "outputAxes"), "byDimension item" ) (transformation,) = Metadata._parse_transforms( [data["transformation"]], coordinateSystems or [] ) return cls( transformation=transformation, - input_axes=list(data["input_axes"]), - output_axes=list(data["output_axes"]), + inputAxes=list(data["inputAxes"]), + outputAxes=list(data["outputAxes"]), ) @@ -300,7 +316,7 @@ class ByDimension(BaseTransform): """A high dimensional transform built from lower dimensional ones. Every axis index of the output coordinate system appears in exactly one - item's ``output_axes``. + item's ``outputAxes``. """ transformations: list[ByDimensionItem] @@ -310,23 +326,23 @@ def __post_init__(self) -> None: self._check_intrinsic() def _check_intrinsic(self) -> None: - seen_output_axes: set[int] = set() + seen_outputAxes: set[int] = set() for item in self.transformations: - duplicated = seen_output_axes.intersection(item.output_axes) + duplicated = seen_outputAxes.intersection(item.outputAxes) if duplicated: raise ValueError( "byDimension output axes must each be produced by exactly " f"one transformation; axis {sorted(duplicated)} appears " "more than once" ) - seen_output_axes.update(item.output_axes) + seen_outputAxes.update(item.outputAxes) @property - def produced_output_axes(self) -> set[int]: + def produced_outputAxes(self) -> set[int]: """The output axis indices the items produce, taken together.""" axes: set[int] = set() for item in self.transformations: - axes.update(item.output_axes) + axes.update(item.outputAxes) return axes def validate(self, coordinateSystems: list[CoordinateSystem] | None = None) -> None: @@ -337,15 +353,15 @@ def validate(self, coordinateSystems: list[CoordinateSystem] | None = None) -> N input_count = _resolved_axis_count(self.input, coordinateSystems) if input_count is not None: for item in self.transformations: - if any(axis >= input_count for axis in item.input_axes): + if any(axis >= input_count for axis in item.inputAxes): raise ValueError( - f"byDimension input axes {item.input_axes} exceed the " + f"byDimension input axes {item.inputAxes} exceed the " f"{input_count} axes of coordinate system " f"'{self.input.name}'" ) output_count = _resolved_axis_count(self.output, coordinateSystems) if output_count is not None: - produced = self.produced_output_axes + produced = self.produced_outputAxes if produced != set(range(output_count)): raise ValueError( "byDimension items must cover every output axis exactly " @@ -686,15 +702,34 @@ def _from_zarr_attrs( if validate: # From 0.6 the version is recorded on the ``ome`` namespace rather - # than on each multiscales entry, and it is the pre-release string - # ("0.6.dev4") that the bundled 0.6 schemas are tagged with. The - # per-entry value is the fallback, as on the v0.4 read path. + # than on each multiscales entry, as the pre-release string the + # store was written with. The per-entry value is the fallback, as + # on the v0.4 read path. schema_version = str( root_attrs["ome"].get("version") or root_attrs["ome"]["multiscales"][0].get("version") or "0.6" ) - validate_ngff(root_attrs, version=schema_version) + schema_attrs = root_attrs + if schema_version in V06_SUPERSEDED_TAGS: + # The bundled 0.6 schemas accept one tag, the pre-release they + # were published with. A store tagged with one an earlier + # release wrote differs from a valid store in that string + # alone, so the rest of the document is validated with the tag + # substituted, and the substitution is reported: + # ``upgrade_ome_zarr`` rewrites the tag in place. + warnings.warn( + f"OME-Zarr store carries the superseded 0.6 pre-release tag " + f"{schema_version!r}; the bundled schemas are tagged " + f"{V06_ONDISK_VERSION.value!r}. Validating the rest of the " + "document against them. upgrade_ome_zarr(store, " + "version='0.6') rewrites the tag.", + stacklevel=2, + ) + schema_attrs = copy.deepcopy(root_attrs) + schema_attrs["ome"]["version"] = V06_ONDISK_VERSION.value + schema_version = V06_ONDISK_VERSION.value + validate_ngff(schema_attrs, version=schema_version) # RFC 4 validation for anatomical orientation. From v0.6 the axes # live in the intrinsic coordinate system, so they are read through diff --git a/py/ngff_zarr/validate.py b/py/ngff_zarr/validate.py index 94c95157..529678ef 100644 --- a/py/ngff_zarr/validate.py +++ b/py/ngff_zarr/validate.py @@ -29,8 +29,8 @@ def _schemas_dir(version: str) -> "Traversable": """Locate the bundled ``schemas`` directory that holds ``version``. A pre-release shares the tree of the release it leads to: the bundled 0.6 - schemas carry the upstream ``0.6.dev4`` tag, so the ``"0.6.dev4"`` string a - 0.6 store records on disk resolves to ``spec/0.6`` just as ``"0.6"`` does. + schemas carry the upstream ``0.6rc0`` tag, so the pre-release string a 0.6 + store records on disk resolves to ``spec/0.6`` just as ``"0.6"`` does. The version is matched against the bundled directory names rather than joined onto the path as given, because it reaches here straight from a diff --git a/py/test/rfc5_transform_cases.json b/py/test/rfc5_transform_cases.json index 08b556d5..0309352c 100644 --- a/py/test/rfc5_transform_cases.json +++ b/py/test/rfc5_transform_cases.json @@ -172,11 +172,11 @@ 3 ] }, - "input_axes": [ + "inputAxes": [ 0, 1 ], - "output_axes": [ + "outputAxes": [ 0, 1 ] @@ -188,10 +188,10 @@ 5 ] }, - "input_axes": [ + "inputAxes": [ 2 ], - "output_axes": [ + "outputAxes": [ 2 ] } @@ -215,11 +215,11 @@ 3 ] }, - "input_axes": [ + "inputAxes": [ 0, 1 ], - "output_axes": [ + "outputAxes": [ 0, 1 ] @@ -241,11 +241,11 @@ 3 ] }, - "input_axes": [ + "inputAxes": [ 0, 1 ], - "output_axes": [ + "outputAxes": [ 0, 1 ] @@ -257,10 +257,10 @@ 5 ] }, - "input_axes": [ + "inputAxes": [ 2 ], - "output_axes": [ + "outputAxes": [ 1 ] } @@ -281,11 +281,11 @@ 3 ] }, - "input_axes": [ + "inputAxes": [ 0, 1 ], - "output_axes": [ + "outputAxes": [ 1, 1 ] @@ -307,10 +307,10 @@ 3 ] }, - "input_axes": [ + "inputAxes": [ 0 ], - "output_axes": [ + "outputAxes": [ 0 ] } @@ -338,12 +338,12 @@ 4 ] }, - "input_axes": [ + "inputAxes": [ 0, 1, 3 ], - "output_axes": [ + "outputAxes": [ 0, 1, 2 @@ -365,10 +365,10 @@ 2 ] }, - "input_axes": [ + "inputAxes": [ -1 ], - "output_axes": [ + "outputAxes": [ 0 ] } @@ -388,10 +388,10 @@ 2 ] }, - "input_axes": [ + "inputAxes": [ 0.5 ], - "output_axes": [ + "outputAxes": [ 0 ] } @@ -412,11 +412,11 @@ 0 ] }, - "input_axes": [ + "inputAxes": [ 0, 1 ], - "output_axes": [ + "outputAxes": [ 0, 1 ] @@ -443,11 +443,11 @@ } ] }, - "input_axes": [ + "inputAxes": [ 0, 1 ], - "output_axes": [ + "outputAxes": [ 0, 1 ] @@ -558,7 +558,7 @@ 3 ] }, - "input_axes": [ + "inputAxes": [ 0, 1 ] diff --git a/py/test/test_cli_upgrade.py b/py/test/test_cli_upgrade.py index c0a04e59..729d3041 100644 --- a/py/test/test_cli_upgrade.py +++ b/py/test/test_cli_upgrade.py @@ -28,7 +28,7 @@ ) # The on-disk ``ome.version`` string each API version is written as. -DISK_VERSION = {"0.4": "0.4", "0.5": "0.5", "0.6": "0.6.dev4"} +DISK_VERSION = {"0.4": "0.4", "0.5": "0.5", "0.6": "0.6rc0"} # Metadata sidecars across Zarr v2 and v3, excluded when isolating chunk data. _METADATA_NAMES = {"zarr.json", ".zarray", ".zattrs", ".zgroup", ".zmetadata"} diff --git a/py/test/test_coordinate_transformations.py b/py/test/test_coordinate_transformations.py index 866f309b..bf9d39ed 100644 --- a/py/test/test_coordinate_transformations.py +++ b/py/test/test_coordinate_transformations.py @@ -88,13 +88,13 @@ def by_dimension_transform() -> ByDimension: transformations=[ ByDimensionItem( transformation=Scale(scale=[2.0, 3.0]), - input_axes=[0, 1], - output_axes=[0, 1], + inputAxes=[0, 1], + outputAxes=[0, 1], ), ByDimensionItem( transformation=Translation(translation=[5.0]), - input_axes=[2], - output_axes=[2], + inputAxes=[2], + outputAxes=[2], ), ] ) @@ -246,11 +246,11 @@ def test_new_transform_payloads_roundtrip(): assert len(imported_by_dim.transformations) == 2 first, second = imported_by_dim.transformations assert first.transformation.scale == [2.0, 3.0] - assert first.input_axes == [0, 1] - assert first.output_axes == [0, 1] + assert first.inputAxes == [0, 1] + assert first.outputAxes == [0, 1] assert second.transformation.translation == [5.0] - assert second.input_axes == [2] - assert second.output_axes == [2] + assert second.inputAxes == [2] + assert second.outputAxes == [2] assert imported_bijection.forward.path == "forward_field" assert imported_bijection.inverse.path == "inverse_field" @@ -288,8 +288,8 @@ def test_axis_indices_must_be_integers(): with pytest.raises(ValueError, match="integers"): ByDimensionItem( transformation=Scale(scale=[2.0, 3.0]), - input_axes=[0.0, 1.0], - output_axes=[0, 1], + inputAxes=[0.0, 1.0], + outputAxes=[0, 1], ) @@ -297,31 +297,31 @@ def test_by_dimension_item_rejects_dimension_mismatch(): with pytest.raises(ValueError, match="dimensional"): ByDimensionItem( transformation=Scale(scale=[2.0, 3.0]), - input_axes=[0], - output_axes=[0], + inputAxes=[0], + outputAxes=[0], ) -def test_by_dimension_rejects_duplicate_output_axes(): +def test_by_dimension_rejects_duplicate_outputAxes(): """Duplicates within an item and across items are both rejected.""" with pytest.raises(ValueError, match="exactly one"): ByDimensionItem( transformation=Scale(scale=[2.0, 3.0]), - input_axes=[0, 1], - output_axes=[1, 1], + inputAxes=[0, 1], + outputAxes=[1, 1], ) with pytest.raises(ValueError, match="exactly one"): ByDimension( transformations=[ ByDimensionItem( transformation=Scale(scale=[2.0, 3.0]), - input_axes=[0, 1], - output_axes=[0, 1], + inputAxes=[0, 1], + outputAxes=[0, 1], ), ByDimensionItem( transformation=Translation(translation=[5.0]), - input_axes=[2], - output_axes=[1], + inputAxes=[2], + outputAxes=[1], ), ] ) @@ -342,8 +342,8 @@ def test_by_dimension_must_cover_every_output_axis(): transformations=[ ByDimensionItem( transformation=Scale(scale=[2.0, 3.0]), - input_axes=[0, 1], - output_axes=[0, 1], + inputAxes=[0, 1], + outputAxes=[0, 1], ), ], output=CoordinateSystemIdentifier(name="system"), @@ -352,13 +352,13 @@ def test_by_dimension_must_cover_every_output_axis(): validate_transform(transform, [_three_axis_system()]) -def test_by_dimension_rejects_out_of_range_input_axes(): +def test_by_dimension_rejects_out_of_range_inputAxes(): transform = ByDimension( transformations=[ ByDimensionItem( transformation=Scale(scale=[2.0, 3.0, 4.0]), - input_axes=[0, 1, 3], - output_axes=[0, 1, 2], + inputAxes=[0, 1, 3], + outputAxes=[0, 1, 2], ), ], input=CoordinateSystemIdentifier(name="system"), @@ -463,3 +463,22 @@ def test_shared_rfc5_cases_match_expected_verdict(case): else: with pytest.raises(ValueError): Metadata._parse_transforms([case["transformation"]], _SHARED_SYSTEMS) + + +def test_by_dimension_item_reads_the_snake_case_spelling(): + # ngff-zarr 0.43.0 wrote `input_axes` and `output_axes`; the spec spells + # them `inputAxes` and `outputAxes`, which is what is written now. A store + # from that release must still read. + from ngff_zarr.v06.zarr_metadata import ByDimensionItem, Scale + + item = ByDimensionItem.from_dict( + { + "transformation": {"type": "scale", "scale": [2.0]}, + "input_axes": [0], + "output_axes": [0], + } + ) + + assert item.inputAxes == [0] + assert item.outputAxes == [0] + assert isinstance(item.transformation, Scale) diff --git a/py/test/test_ngff_validation.py b/py/test/test_ngff_validation.py index 7bb8a0c7..da5638af 100644 --- a/py/test/test_ngff_validation.py +++ b/py/test/test_ngff_validation.py @@ -8,8 +8,10 @@ from ngff_zarr import ( NgffMultiscales, from_ngff_zarr, + from_ome_zarr, to_multiscales, to_ngff_zarr, + to_ome_zarr, validate, ) from packaging import version @@ -207,6 +209,105 @@ def test_validate_v06_accepts_the_on_disk_version_string(tmp_path): validate(root_attrs, version=on_disk_version, model="image") +@requires_zarr_v3 +def test_validate_v06_rejects_an_earlier_prerelease_tag(tmp_path): + # The bundled 0.6 schemas pin ``ome.version`` to the pre-release they were + # published with, and the schema API checks a document as given. The + # reader is the lenient one: see the warning test below. + jsonschema = pytest.importorskip("jsonschema") + + store = _write_valid_3d_store_v06(tmp_path / "image.ome.zarr") + root_attrs = zarr.open_group(str(store), mode="r").attrs.asdict() + root_attrs["ome"]["version"] = "0.6.dev4" + + with pytest.raises(jsonschema.ValidationError, match="0.6rc0"): + validate(root_attrs, version="0.6", model="image") + + +@requires_zarr_v3 +def test_v06_write_refuses_a_top_level_transform_without_references(tmp_path): + # From 0.6 a multiscale-level transform maps between two named coordinate + # systems and the schema requires both references. The writer refuses the + # model up front rather than producing a store its own validated reader + # rejects; with the references present the store validates. + from ngff_zarr.v06.zarr_metadata import CoordinateSystemIdentifier, Scale + + array = np.random.random((4, 8, 8)).astype("float32") + multiscales = to_multiscales(array, [2]) + multiscales.metadata.coordinateTransformations = [Scale(scale=[2.0, 2.0, 2.0])] + + with pytest.raises(ValueError, match="names no input coordinate system"): + to_ome_zarr(tmp_path / "refused.ome.zarr", multiscales, version="0.6") + + intrinsic = multiscales.metadata.intrinsic_coordinate_system.name + multiscales.metadata.coordinateTransformations = [ + Scale( + scale=[2.0, 2.0, 2.0], + input=CoordinateSystemIdentifier(name=intrinsic), + output=CoordinateSystemIdentifier(name=intrinsic), + ) + ] + store = tmp_path / "written.ome.zarr" + to_ome_zarr(store, multiscales, version="0.6") + pytest.importorskip("jsonschema") + validate(zarr.open_group(str(store), mode="r").attrs.asdict(), version="0.6") + + +@requires_zarr_v3 +def test_read_warns_on_a_superseded_0_6_tag_and_validates_the_rest(tmp_path): + # A store tagged with an earlier 0.6 pre-release differs from a valid store + # in that string alone. The validating reader says so and checks the rest + # of the document with the tag substituted, rather than fail on the one + # thing ``upgrade_ome_zarr`` exists to rewrite. + pytest.importorskip("jsonschema") + + store = _write_valid_3d_store_v06(tmp_path / "image.ome.zarr") + root = zarr.open_group(str(store), mode="r+") + ome = dict(root.attrs["ome"]) + ome["version"] = "0.6.dev4" + root.attrs["ome"] = ome + + with pytest.warns(UserWarning, match="superseded 0.6 pre-release tag '0.6.dev4'"): + multiscales = from_ome_zarr(store, validate=True) + assert len(multiscales.images) == 2 + + +@requires_zarr_v3 +def test_read_does_not_substitute_a_tag_no_release_wrote(tmp_path): + # Only the tags earlier releases wrote are substituted. A plain ``0.6``, + # which a stricter writer might record, is checked as given, and the + # schema rejects it, so it is not passed off as the vendored pre-release. + jsonschema = pytest.importorskip("jsonschema") + + store = _write_valid_3d_store_v06(tmp_path / "image.ome.zarr") + root = zarr.open_group(str(store), mode="r+") + ome = dict(root.attrs["ome"]) + ome["version"] = "0.6" + root.attrs["ome"] = ome + + with pytest.raises(jsonschema.ValidationError, match="'0.6' is not one of"): + from_ome_zarr(store, validate=True) + + +@requires_zarr_v3 +def test_read_still_rejects_a_defect_behind_a_superseded_0_6_tag(tmp_path): + # The substitution covers the tag and nothing else. + jsonschema = pytest.importorskip("jsonschema") + + store = _write_valid_3d_store_v06(tmp_path / "image.ome.zarr") + root = zarr.open_group(str(store), mode="r+") + ome = dict(root.attrs["ome"]) + ome["version"] = "0.6.dev4" + del ome["multiscales"][0]["coordinateSystems"] + root.attrs["ome"] = ome + + with ( + pytest.warns(UserWarning, match="superseded"), + pytest.raises(jsonschema.ValidationError), + ): + from_ome_zarr(store, validate=True) + + @requires_zarr_v3 def test_validate_v06_rejects_invalid_metadata(tmp_path): # Resolving the references must not turn validation into a no-op: dropping diff --git a/py/test/test_upgrade_ome_zarr.py b/py/test/test_upgrade_ome_zarr.py index 6f5e1c6c..6a84dbd0 100644 --- a/py/test/test_upgrade_ome_zarr.py +++ b/py/test/test_upgrade_ome_zarr.py @@ -38,7 +38,7 @@ ) # The on-disk ``ome.version`` string a given API version is written as. -DISK_VERSION = {"0.4": "0.4", "0.5": "0.5", "0.6": "0.6.dev4"} +DISK_VERSION = {"0.4": "0.4", "0.5": "0.5", "0.6": "0.6rc0"} # Every metadata sidecar name across Zarr v2 and v3, so ``_chunk_files`` can # isolate the true chunk *data* whose immutability we assert across an upgrade. @@ -113,6 +113,34 @@ def _disk_ome_version(store_path, api_version: str) -> str: # --------------------------------------------------------------------------- # +@pytest.mark.parametrize("validate", [False, True]) +def test_in_place_retags_an_earlier_0_6_prerelease(tmp_path, validate): + # A store written while 0.6 was a draft carries that draft's tag. The + # bundled schemas pin ``ome.version`` to a later pre-release, whose enum + # rejects the old tag, so ``upgrade`` to the same API version rewrites the + # tag instead of treating the request as a no-op. Chunks stay untouched. + # With ``validate`` the source is checked with its tag substituted, since + # the tag is the one thing this upgrade changes. + multiscales, data = _synth_multiscales() + store_path = str(tmp_path / "image.ome.zarr") + to_ome_zarr(store_path, multiscales, version="0.6") + root = zarr.open_group(store_path, mode="r+") + ome = dict(root.attrs["ome"]) + ome["version"] = "0.6.dev4" + root.attrs["ome"] = ome + assert zarr.open_group(store_path, mode="r").attrs["ome"]["version"] == "0.6.dev4" + chunks_before = _chunk_files(tmp_path) + assert chunks_before + + upgrade_ome_zarr(store_path, version="0.6", validate=validate) + + root_after = zarr.open_group(store_path, mode="r") + assert root_after.attrs["ome"]["version"] == DISK_VERSION["0.6"] + assert _chunk_files(tmp_path) == chunks_before + reloaded = from_ome_zarr(store_path, validate=True) + np.testing.assert_array_equal(reloaded.images[0].data.compute(), data) + + def test_in_place_0_5_to_0_6(tmp_path): multiscales, data = _synth_multiscales() store_path = str(tmp_path / "image.ome.zarr") @@ -126,7 +154,7 @@ def test_in_place_0_5_to_0_6(tmp_path): upgrade_ome_zarr(store_path, version="0.6") root_after = zarr.open_group(store_path, mode="r") - assert root_after.attrs["ome"]["version"] == "0.6.dev4" + assert root_after.attrs["ome"]["version"] == "0.6rc0" # Array chunk files are byte-identical and unmodified (path, size, mtime, # and content) -- only the root group metadata was rewritten. @@ -438,7 +466,7 @@ def test_output_equal_to_input_routes_in_place(tmp_path): upgrade_ome_zarr(store_path, store_path, version="0.6") root_after = zarr.open_group(store_path, mode="r") - assert root_after.attrs["ome"]["version"] == "0.6.dev4" + assert root_after.attrs["ome"]["version"] == "0.6rc0" chunks_after = _chunk_files(tmp_path) assert set(chunks_after) == set(chunks_before) for rel, sig in chunks_before.items(): diff --git a/ts/src/io/from_ngff_zarr-browser.ts b/ts/src/io/from_ngff_zarr-browser.ts index af463d9c..3abf6055 100644 --- a/ts/src/io/from_ngff_zarr-browser.ts +++ b/ts/src/io/from_ngff_zarr-browser.ts @@ -104,8 +104,8 @@ export async function fromOmeZarr( ) { // Gate the requested-version mismatch behind `validate`, matching the node // reader and the v0.4/v0.5 path below; otherwise behavior diverges by - // version and environment. The v0.6 family (`0.6` and draft `0.6.dev4`) - // is treated as equivalent. + // version and environment. The v0.6 family (`0.6` and its pre-release + // tags) is treated as equivalent. if (validate && version && !isV06Version(version)) { throw new Error( `Expected OME-Zarr version ${version}, but found ${omeForVersion.version}`, diff --git a/ts/src/io/from_ngff_zarr.ts b/ts/src/io/from_ngff_zarr.ts index 96cec909..922ffa40 100644 --- a/ts/src/io/from_ngff_zarr.ts +++ b/ts/src/io/from_ngff_zarr.ts @@ -128,8 +128,8 @@ export async function fromOmeZarr( const detectedVersion = detectVersion(rootAttrs); // Validate version if requested. Treat the v0.6 family as equivalent so a - // store tagged `0.6.dev4` on disk satisfies a requested version of `"0.6"` - // (and vice versa). + // store tagged with a 0.6 pre-release on disk satisfies a requested version + // of `"0.6"` (and vice versa). if (validate && requestedVersion) { const versionsMatch = detectedVersion === requestedVersion || (isV06Version(detectedVersion) && isV06Version(requestedVersion)); @@ -141,7 +141,7 @@ export async function fromOmeZarr( } // Parse metadata using version-specific function. The v0.6 reader handles - // both `0.6` and the draft `0.6.dev4` on-disk version strings. + // both `0.6` and the pre-release on-disk version strings. let result; if (isV06Version(detectedVersion)) { result = await fromZarrAttrsV06(rootAttrs, resolvedStore, validate); diff --git a/ts/src/io/to_ngff_zarr_ozx_common.ts b/ts/src/io/to_ngff_zarr_ozx_common.ts index 92fa4150..7bd0aaa7 100644 --- a/ts/src/io/to_ngff_zarr_ozx_common.ts +++ b/ts/src/io/to_ngff_zarr_ozx_common.ts @@ -81,8 +81,8 @@ export function buildRootAttributes( const v06Entry = buildV06MultiscalesEntry(metadata, processedAxes); return { ome: { - // The v0.6 spec is still a draft; tag the store with the development - // version `0.6.dev4` even though the requested version is `"0.6"`. + // Tag the store with the pre-release the bundled schemas carry, not + // with the bare `"0.6"` that was requested; see V06_ONDISK_VERSION. version: V06_ONDISK_VERSION, multiscales: [v06Entry], ...(metadata.omero && { omero: metadata.omero }), diff --git a/ts/src/io/upgrade_ome_zarr_common.ts b/ts/src/io/upgrade_ome_zarr_common.ts index fdf20974..efb9b356 100644 --- a/ts/src/io/upgrade_ome_zarr_common.ts +++ b/ts/src/io/upgrade_ome_zarr_common.ts @@ -18,7 +18,10 @@ import * as zarr from "zarrita"; import type { NgffMultiscales } from "../types/multiscales.ts"; import type { MemoryStore } from "./rfc9_zip.ts"; import { detectVersion } from "../utils/parse_metadata.ts"; -import { isV06Version } from "../types/supported_versions.ts"; +import { + isV06Version, + V06_ONDISK_VERSION, +} from "../types/supported_versions.ts"; import { buildRootAttributes } from "./to_ngff_zarr_ozx_common.ts"; /** Stores/paths `upgradeOmeZarr` can read from. */ @@ -74,13 +77,29 @@ function zarrFormatForVersion(version: string): 2 | 3 { } /** - * Collapse the v0.6 family (including the on-disk draft `0.6.dev4`) to `"0.6"` - * so a store tagged `0.6.dev4` compares equal to a requested version of `"0.6"`. + * Collapse the v0.6 family (including its on-disk pre-release tags) to `"0.6"` + * so a store tagged with one compares equal to a requested version of `"0.6"`. */ function normalizeVersion(version: string): string { return isV06Version(version) ? "0.6" : version; } +/** The raw version string a store records, before any family collapsing. */ +function onDiskVersion(rootAttrs: Record): string | undefined { + const ome = rootAttrs.ome as Record | undefined; + if (ome && typeof ome.version === "string") return ome.version; + const multiscales = rootAttrs.multiscales as + | Array> + | undefined; + const version = multiscales?.[0]?.version; + return typeof version === "string" ? version : undefined; +} + +/** The `ome.version` string a store written at `version` carries. */ +function onDiskVersionFor(version: "0.4" | "0.5" | "0.6"): string { + return version === "0.6" ? V06_ONDISK_VERSION : version; +} + /** Whether `s` looks like a URL with a scheme (e.g. `http://`, `s3://`). */ function isUrl(s: string): boolean { return /^[a-z][a-z0-9+.-]*:\/\//i.test(s); @@ -170,15 +189,19 @@ export async function upgradeOmeZarrImpl( const store = await deps.resolveWritableStore(input); const location = zarr.root(store); const rootGroup = await zarr.open(location, { kind: "group" }); - const sourceVersion = detectVersion( - rootGroup.attrs as Record, - ); + const rootAttrs = rootGroup.attrs as Record; + const sourceVersion = detectVersion(rootAttrs); const sourceZarrFormat = zarrFormatForVersion(sourceVersion); const targetZarrFormat = zarrFormatForVersion(version); - // No-op: the store already records the requested spec version. Leave it - // byte-for-byte unchanged (no read of arrays, no write). - if (normalizeVersion(sourceVersion) === normalizeVersion(version)) { + // No-op: the store already carries the exact tag the target would write. + // Compared on the on-disk string rather than the detected family, so a 0.6 + // store tagged with an earlier pre-release is rewritten and its tag catches + // up with the vendored schemas, which is the only way to re-tag it. Leaves + // the store byte-for-byte unchanged (no read of arrays, no write). + if ( + (onDiskVersion(rootAttrs) ?? sourceVersion) === onDiskVersionFor(version) + ) { return; } diff --git a/ts/src/schemas/coordinate_systems.ts b/ts/src/schemas/coordinate_systems.ts index 52e580e6..e10daf22 100644 --- a/ts/src/schemas/coordinate_systems.ts +++ b/ts/src/schemas/coordinate_systems.ts @@ -248,8 +248,8 @@ export type CoordinateTransformation = type: "byDimension"; transformations: Array<{ transformation: CoordinateTransformation; - input_axes: number[]; - output_axes: number[]; + inputAxes: number[]; + outputAxes: number[]; }>; } & TransformationCommon @@ -302,12 +302,12 @@ export const BijectionTransformationSchema: z.ZodType< // zero-based indices into the parent's input and output coordinate systems. export const ByDimensionItemSchema: z.ZodType<{ transformation: CoordinateTransformation; - input_axes: number[]; - output_axes: number[]; + inputAxes: number[]; + outputAxes: number[]; }> = z.object({ transformation: z.lazy(() => CoordinateTransformationSchema), - input_axes: z.array(z.number().int().nonnegative()), - output_axes: z.array(z.number().int().nonnegative()), + inputAxes: z.array(z.number().int().nonnegative()), + outputAxes: z.array(z.number().int().nonnegative()), }); // By dimension transformation: a high dimensional transformation built from diff --git a/ts/src/types/supported_versions.ts b/ts/src/types/supported_versions.ts index 5543d86f..7e9f70d9 100644 --- a/ts/src/types/supported_versions.ts +++ b/ts/src/types/supported_versions.ts @@ -13,22 +13,22 @@ export enum NgffVersion { V05 = "0.5", V06 = "0.6", /** - * Draft development version of the OME-Zarr v0.6 (RFC-5) spec. Stores are - * tagged on disk with this string while the spec is still in development; - * see {@link V06_ONDISK_VERSION}. Temporary — tracks the upstream dev release. + * Pre-release tags of the OME-Zarr v0.6 (RFC-5) spec. Both stay readable: + * stores written while 0.6 was a draft carry the `dev4` tag on disk. */ V06dev4 = "0.6.dev4", - LATEST = "0.6.dev4", + V06rc0 = "0.6rc0", + LATEST = "0.6rc0", } /** - * The on-disk `ome.version` string written for OME-Zarr v0.6. The v0.6 / RFC-5 - * spec is still a draft, so stores are tagged with the development version - * `0.6.dev4` even though the library's public `version` option is `"0.6"`. - * This is a temporary shim that tracks the upstream spec's dev releases; - * mirrors the Python port writing `"0.6.dev4"` for a requested version `"0.6"`. + * The on-disk `ome.version` string written for OME-Zarr v0.6. The v0.6 spec + * is a release candidate, so a store is tagged with the pre-release the + * bundled `spec/0.6` schemas carry rather than with the bare `"0.6"` the + * public `version` option accepts. Mirrors the Python port's + * `V06_ONDISK_VERSION`; this constant is the one place the tag lives. */ -export const V06_ONDISK_VERSION: NgffVersion = NgffVersion.V06dev4; +export const V06_ONDISK_VERSION: NgffVersion = NgffVersion.V06rc0; /** * Supported NGFF specification versions @@ -41,6 +41,7 @@ export const SUPPORTED_VERSIONS: readonly NgffVersion[] = [ NgffVersion.V05, NgffVersion.V06, NgffVersion.V06dev4, + NgffVersion.V06rc0, ] as const; /** diff --git a/ts/src/types/zarr_metadata.ts b/ts/src/types/zarr_metadata.ts index 98fb33cb..a197ad50 100644 --- a/ts/src/types/zarr_metadata.ts +++ b/ts/src/types/zarr_metadata.ts @@ -129,19 +129,19 @@ export interface MapAxis { /** * One lower-dimensional transformation of a byDimension transform. The - * `input_axes` and `output_axes` arrays hold zero-based axis indices into the + * `inputAxes` and `outputAxes` arrays hold zero-based axis indices into the * parent byDimension's input and output coordinate systems. */ export interface ByDimensionItem { transformation: V06Transform; - input_axes: number[]; - output_axes: number[]; + inputAxes: number[]; + outputAxes: number[]; } /** * RFC 5 byDimension transformation (v0.6): a high dimensional transform built * from lower dimensional ones. Every axis index of the output coordinate - * system appears in exactly one item's `output_axes`. + * system appears in exactly one item's `outputAxes`. */ export interface ByDimension { transformations: ByDimensionItem[]; @@ -377,8 +377,8 @@ export function createByDimension( return { transformations: transformations.map((item) => ({ transformation: item.transformation, - input_axes: [...item.input_axes], - output_axes: [...item.output_axes], + inputAxes: [...item.inputAxes], + outputAxes: [...item.outputAxes], })), type: "byDimension", }; diff --git a/ts/src/utils/parse_metadata.ts b/ts/src/utils/parse_metadata.ts index 3f7e1268..7fbd3cb5 100644 --- a/ts/src/utils/parse_metadata.ts +++ b/ts/src/utils/parse_metadata.ts @@ -179,8 +179,8 @@ export function detectVersion( throw new Error("Could not detect NGFF version from root attributes."); } - // Any 0.6-family version, including draft development tags such as - // `0.6.dev4`, is read as v0.6. Mirrors the Python port's + // Any 0.6-family version, including pre-release tags such as `0.6.dev4` + // and `0.6rc0`, is read as v0.6. Mirrors the Python port's // `version.startswith("0.6")` read check so dev releases remain readable. if (isV06Version(versionStr)) { return NgffVersion.V06; diff --git a/ts/src/utils/v06_metadata.ts b/ts/src/utils/v06_metadata.ts index 18b6ac80..f31967e5 100644 --- a/ts/src/utils/v06_metadata.ts +++ b/ts/src/utils/v06_metadata.ts @@ -88,6 +88,22 @@ export function buildV06MultiscalesEntry( metadata.coordinateTransformations && metadata.coordinateTransformations.length > 0 ) { + // From 0.6 a transform on the multiscales entry maps between two named + // coordinate systems, and the schema requires both `input` and `output` + // to name one. Serializing whatever the model holds would produce a store + // the validated reader rejects. + metadata.coordinateTransformations.forEach((transform, index) => { + for (const side of ["input", "output"] as const) { + if (transform[side]?.name === undefined) { + throw new Error( + `multiscales coordinateTransformations[${index}] ` + + `(${transform.type}) names no ${side} coordinate system; ` + + "OME-Zarr 0.6 requires every multiscale-level transformation " + + "to name both its input and its output coordinate system", + ); + } + } + }); entry.coordinateTransformations = metadata.coordinateTransformations.map( serializeV06Transform, ); @@ -157,8 +173,8 @@ export function serializeV06Transform( case "byDimension": out.transformations = transform.transformations.map((item) => ({ transformation: serializeV06Transform(item.transformation), - input_axes: item.input_axes, - output_axes: item.output_axes, + inputAxes: item.inputAxes, + outputAxes: item.outputAxes, })); break; case "bijection": @@ -264,7 +280,7 @@ function parseV06Transform( if (item === null || typeof item !== "object") { throw new Error( "Invalid byDimension transform: each item must be an object " + - "holding 'transformation', 'input_axes' and 'output_axes'", + "holding 'transformation', 'inputAxes' and 'outputAxes'", ); } return { @@ -273,8 +289,17 @@ function parseV06Transform( coordinateSystemNames, coordinateSystems, ), - input_axes: asIntegerArray(item.input_axes, "byDimension"), - output_axes: asIntegerArray(item.output_axes, "byDimension"), + // ngff-zarr 0.29.0 wrote these two keys in snake_case; the spec + // and the 0.6rc0 schema spell them inputAxes and outputAxes, which + // is what is written now. Both spellings are read. + inputAxes: asIntegerArray( + item.inputAxes ?? item.input_axes, + "byDimension", + ), + outputAxes: asIntegerArray( + item.outputAxes ?? item.output_axes, + "byDimension", + ), }; }, ); @@ -402,7 +427,7 @@ export function validateV06Transform( const inputCount = axisCount(transform.input, coordinateSystems); const seenOutputAxes = new Set(); for (const item of transform.transformations) { - const axes = [...item.input_axes, ...item.output_axes]; + const axes = [...item.inputAxes, ...item.outputAxes]; if (!axes.every((axis) => Number.isInteger(axis))) { throw new Error( `byDimension axis indices must be integers; got [${axes}]`, @@ -415,14 +440,14 @@ export function validateV06Transform( } if ( inputCount !== undefined && - item.input_axes.some((axis) => axis >= inputCount) + item.inputAxes.some((axis) => axis >= inputCount) ) { throw new Error( - `byDimension input axes [${item.input_axes}] exceed the ` + + `byDimension input axes [${item.inputAxes}] exceed the ` + `${inputCount} axes of coordinate system '${transform.input?.name}'`, ); } - for (const axis of item.output_axes) { + for (const axis of item.outputAxes) { if (seenOutputAxes.has(axis)) { throw new Error( "byDimension output axes must each be produced by exactly one " + @@ -434,13 +459,13 @@ export function validateV06Transform( const dimensions = itemDimensions(item.transformation); if ( dimensions !== undefined && - (item.input_axes.length !== dimensions || - item.output_axes.length !== dimensions) + (item.inputAxes.length !== dimensions || + item.outputAxes.length !== dimensions) ) { throw new Error( `byDimension item of type '${item.transformation.type}' is ` + - `${dimensions}-dimensional but maps ${item.input_axes.length} ` + - `input axes to ${item.output_axes.length} output axes`, + `${dimensions}-dimensional but maps ${item.inputAxes.length} ` + + `input axes to ${item.outputAxes.length} output axes`, ); } } diff --git a/ts/test/upgrade_ome_zarr_test.ts b/ts/test/upgrade_ome_zarr_test.ts index 97ff8d9d..171fe780 100644 --- a/ts/test/upgrade_ome_zarr_test.ts +++ b/ts/test/upgrade_ome_zarr_test.ts @@ -150,7 +150,7 @@ function assertDataIntact(actual: Uint16Array): void { const DISK_VERSION: Record<"0.4" | "0.5" | "0.6", string> = { "0.4": "0.4", "0.5": "0.5", - "0.6": "0.6.dev4", + "0.6": "0.6rc0", }; Deno.test("in-place same-format upgrade 0.5 -> 0.6 preserves chunks", async () => { @@ -163,7 +163,7 @@ Deno.test("in-place same-format upgrade 0.5 -> 0.6 preserves chunks", async () = await upgradeOmeZarr(store, { version: "0.6" }); const ome = await readOme(store); - assertEquals(ome.version, "0.6.dev4"); + assertEquals(ome.version, "0.6rc0"); const ms0 = (ome.multiscales as Record[])[0]; assertExists(ms0.coordinateSystems); @@ -171,12 +171,35 @@ Deno.test("in-place same-format upgrade 0.5 -> 0.6 preserves chunks", async () = assertDataIntact(await readImageData(store, "0.6")); }); -Deno.test("in-place same-format downgrade 0.6 -> 0.5 preserves chunks", async () => { +Deno.test("in-place upgrade retags an earlier 0.6 pre-release", async () => { + // A store written while 0.6 was a draft carries that draft's tag. The + // bundled schemas pin `ome.version` to a later pre-release, so upgrading to + // the same API version rewrites the tag instead of treating the request as + // a no-op. Chunks stay untouched. const store = await makeSourceStore("0.6"); + const rootKey = [...store.keys()].find((key) => + key.replace(/^\//, "") === "zarr.json" + )!; + const rootJson = JSON.parse(new TextDecoder().decode(store.get(rootKey)!)); + rootJson.attributes.ome.version = "0.6.dev4"; + store.set(rootKey, new TextEncoder().encode(JSON.stringify(rootJson))); assertEquals((await readOme(store)).version, "0.6.dev4"); const chunksBefore = chunkKeys(store); const before = snapshot(store); + await upgradeOmeZarr(store, { version: "0.6" }); + + assertEquals((await readOme(store)).version, DISK_VERSION["0.6"]); + assertChunksUnchanged(store, before, chunksBefore); + assertDataIntact(await readImageData(store, "0.6")); +}); + +Deno.test("in-place same-format downgrade 0.6 -> 0.5 preserves chunks", async () => { + const store = await makeSourceStore("0.6"); + assertEquals((await readOme(store)).version, "0.6rc0"); + const chunksBefore = chunkKeys(store); + const before = snapshot(store); + await upgradeOmeZarr(store, { version: "0.5" }); const ome = await readOme(store); diff --git a/ts/test/v06_coordinate_transformations_test.ts b/ts/test/v06_coordinate_transformations_test.ts index 7a60e0d4..a638ff43 100644 --- a/ts/test/v06_coordinate_transformations_test.ts +++ b/ts/test/v06_coordinate_transformations_test.ts @@ -140,9 +140,9 @@ Deno.test("v0.6 write produces coordinate systems and sequence transforms", asyn version: string; multiscales: Array>; }; - // The v0.6 spec is still a draft, so the on-disk version is tagged - // `0.6.dev4` even though the requested version is `"0.6"`. - assertEquals(ome.version, "0.6.dev4"); + // The on-disk tag is the pre-release the bundled schemas carry, not the + // requested `"0.6"`. + assertEquals(ome.version, "0.6rc0"); const entry = ome.multiscales[0]; // v0.6 carries axes inside coordinate systems, not at the entry level. @@ -219,13 +219,13 @@ function transformsToRoundTrip(): V06Transform[] { createByDimension([ { transformation: createScale([2.0, 3.0]), - input_axes: [0, 1], - output_axes: [0, 1], + inputAxes: [0, 1], + outputAxes: [0, 1], }, { transformation: createTranslation([5.0]), - input_axes: [2], - output_axes: [2], + inputAxes: [2], + outputAxes: [2], }, ]), createBijection( @@ -458,15 +458,19 @@ Deno.test("v0.6 preserves the path field on rotation and affine", async () => { // Mirrors the Python _parse_transforms identifier resolution. Deno.test("v0.6 read drops unknown coordinate-system names, keeps paths", async () => { const multiscales = await buildMultiscales(); - multiscales.metadata.coordinateTransformations = [{ - type: "scale", - scale: [2.0, 2.0, 2.0], - input: { name: "does-not-exist" }, - output: { path: "scale0" }, - }]; - const store: MemoryStore = new Map(); await toOmeZarr(store, multiscales, { version: "0.6" }); + // Injected into the written document rather than passed to the writer: a + // multiscale-level transform naming no output system is exactly what the + // 0.6 writer refuses, and this test is about what the reader does with one. + mutateRootEntry(store, (entry) => { + entry.coordinateTransformations = [{ + type: "scale", + scale: [2.0, 2.0, 2.0], + input: { name: "does-not-exist" }, + output: { path: "scale0" }, + }]; + }); const imported = await fromOmeZarr(store); const transform = imported.metadata.coordinateTransformations![0]; @@ -475,6 +479,24 @@ Deno.test("v0.6 read drops unknown coordinate-system names, keeps paths", async assertEquals(transform.output, { path: "scale0" }); }); +// From 0.6 a multiscale-level transform maps between two named coordinate +// systems and the schema requires both references, so the writer refuses the +// model rather than produce a store its own validated reader rejects. +Deno.test("v0.6 write refuses a top-level transform without references", async () => { + const multiscales = await buildMultiscales(); + multiscales.metadata.coordinateTransformations = [{ + type: "scale", + scale: [2.0, 2.0, 2.0], + }]; + + const store: MemoryStore = new Map(); + await assertRejects( + () => toOmeZarr(store, multiscales, { version: "0.6" }), + Error, + "names no input coordinate system", + ); +}); + // A dataset whose transforms are a bare [scale, translation] (not wrapped in a // sequence, as another writer might emit) must still yield the per-axis scale // and translation. Exercises the non-sequence branch of extractScaleTranslation. @@ -690,25 +712,25 @@ Deno.test("RFC-9 metadata drops a v0.6-only rotation but keeps a scale", async ( assertEquals("output" in kept[0], false); }); -// --- OME-Zarr v0.6 draft "0.6.dev4" version handling (temporary) --- +// --- OME-Zarr v0.6 pre-release version tag handling --- -// Writing version "0.6" tags the store with the draft "0.6.dev4" on disk while -// keeping the in-memory metadata version at "0.6". -Deno.test("v0.6 write tags the store 0.6.dev4 but reads back as 0.6", async () => { +// Writing version "0.6" tags the store with the pre-release "0.6rc0" on disk +// while keeping the in-memory metadata version at "0.6". +Deno.test("v0.6 write tags the store 0.6rc0 but reads back as 0.6", async () => { const multiscales = await buildMultiscales(); const store: MemoryStore = new Map(); await toOmeZarr(store, multiscales, { version: "0.6" }); const ome = readOmeAttributes(store) as { version: string }; - assertEquals(ome.version, "0.6.dev4"); + assertEquals(ome.version, "0.6rc0"); const imported = await fromOmeZarr(store); assertEquals(imported.metadata.version, "0.6"); }); // Round-trip with an explicit requested version "0.6" and validation: the -// draft "0.6.dev4" on disk must satisfy the requested "0.6" (family match). -Deno.test("v0.6 round-trip with validate accepts the 0.6.dev4 on-disk tag", async () => { +// pre-release "0.6rc0" on disk must satisfy the requested "0.6" (family match). +Deno.test("v0.6 round-trip with validate accepts the 0.6rc0 on-disk tag", async () => { const multiscales = await buildMultiscales(); const store: MemoryStore = new Map(); await toOmeZarr(store, multiscales, { version: "0.6" }); @@ -749,21 +771,75 @@ Deno.test("browser reader accepts the 0.6.dev4 on-disk tag", async () => { await toOmeZarrBrowser(store, multiscales, { version: "0.6" }); const ome = readOmeAttributes(store) as { version: string }; - assertEquals(ome.version, "0.6.dev4"); + assertEquals(ome.version, "0.6rc0"); const imported = await fromOmeZarrBrowser(store, { version: "0.6" }); assertEquals(imported.metadata.version, "0.6"); assertEquals(imported.images.length, multiscales.images.length); }); -// "0.6.dev4" is a recognized, supported version string. -Deno.test("0.6.dev4 is a supported version", () => { +// Both 0.6 pre-release tags are recognized, supported version strings: the +// current one is written, the earlier one is still read. +Deno.test("the 0.6 pre-release tags are supported versions", () => { + assertEquals(isSupportedVersion("0.6rc0"), true); + assertEquals(isV06Version("0.6rc0"), true); assertEquals(isSupportedVersion("0.6.dev4"), true); assertEquals(isV06Version("0.6.dev4"), true); assertEquals(isV06Version("0.6"), true); assertEquals(isV06Version("0.5"), false); }); +// ngff-zarr 0.29.0 wrote the byDimension axis keys in snake_case; the spec +// spells them inputAxes and outputAxes, which is what is written now. A store +// from that release must still read. +Deno.test("v0.6 read accepts the snake_case byDimension axis keys", async () => { + const multiscales = await buildMultiscales(); + const intrinsic = multiscales.metadata.coordinateSystems![0]; + const byDimension = createByDimension([ + { + transformation: createScale([2.0, 3.0]), + inputAxes: [0, 1], + outputAxes: [0, 1], + }, + { + transformation: createTranslation([5.0]), + inputAxes: [2], + outputAxes: [2], + }, + ]); + byDimension.input = { name: intrinsic.name }; + byDimension.output = { name: intrinsic.name }; + multiscales.metadata.coordinateTransformations = [byDimension]; + + const store: MemoryStore = new Map(); + await toOmeZarr(store, multiscales, { version: "0.6" }); + mutateRootEntry(store, (entry) => { + const [written] = entry.coordinateTransformations as Array< + Record + >; + for ( + const item of written.transformations as Array> + ) { + item.input_axes = item.inputAxes; + item.output_axes = item.outputAxes; + delete item.inputAxes; + delete item.outputAxes; + } + }); + + const imported = await fromOmeZarr(store); + const transform = imported.metadata.coordinateTransformations![0]; + if (transform.type !== "byDimension") throw new Error("expected byDimension"); + assertEquals(transform.transformations.map((item) => item.inputAxes), [ + [0, 1], + [2], + ]); + assertEquals(transform.transformations.map((item) => item.outputAxes), [[ + 0, + 1, + ], [2]]); +}); + // Mirrors test_coordinate_transformations.py: the mapAxis, byDimension and // bijection payloads survive the round-trip by value, not just by type. Deno.test("mapAxis, byDimension and bijection payloads survive the round-trip", async () => { @@ -774,13 +850,13 @@ Deno.test("mapAxis, byDimension and bijection payloads survive the round-trip", const byDimension = createByDimension([ { transformation: createScale([2.0, 3.0]), - input_axes: [0, 1], - output_axes: [0, 1], + inputAxes: [0, 1], + outputAxes: [0, 1], }, { transformation: createTranslation([5.0]), - input_axes: [2], - output_axes: [2], + inputAxes: [2], + outputAxes: [2], }, ]); const bijection = createBijection( @@ -816,14 +892,14 @@ Deno.test("mapAxis, byDimension and bijection payloads survive the round-trip", assertEquals(importedMapAxis.mapAxis, [2, 0, 1]); assertEquals(importedByDimension.transformations.length, 2); const [first, second] = importedByDimension.transformations; - assertEquals(first.input_axes, [0, 1]); - assertEquals(first.output_axes, [0, 1]); + assertEquals(first.inputAxes, [0, 1]); + assertEquals(first.outputAxes, [0, 1]); if (first.transformation.type !== "scale") { throw new Error("expected a scale item transformation"); } assertEquals(first.transformation.scale, [2.0, 3.0]); - assertEquals(second.input_axes, [2]); - assertEquals(second.output_axes, [2]); + assertEquals(second.inputAxes, [2]); + assertEquals(second.outputAxes, [2]); if (importedBijection.forward.type !== "displacements") { throw new Error("expected a displacements forward transformation"); } @@ -845,13 +921,13 @@ Deno.test("invalid mapAxis, byDimension and bijection metadata are rejected", as transform: createByDimension([ { transformation: createScale([2.0, 3.0]), - input_axes: [0, 1], - output_axes: [0, 1], + inputAxes: [0, 1], + outputAxes: [0, 1], }, { transformation: createTranslation([5.0]), - input_axes: [2], - output_axes: [1], + inputAxes: [2], + outputAxes: [1], }, ]), }, @@ -861,8 +937,8 @@ Deno.test("invalid mapAxis, byDimension and bijection metadata are rejected", as transform: createByDimension([ { transformation: createScale([2.0, 3.0]), - input_axes: [0], - output_axes: [0], + inputAxes: [0], + outputAxes: [0], }, ]), }, @@ -894,8 +970,8 @@ Deno.test("byDimension incomplete output coverage is rejected", async () => { const byDimension = createByDimension([ { transformation: createScale([2.0, 3.0]), - input_axes: [0, 1], - output_axes: [0, 1], + inputAxes: [0, 1], + outputAxes: [0, 1], }, ]); byDimension.input = { name: intrinsic.name }; @@ -918,13 +994,13 @@ Deno.test("byDimension items nest wrapper transformations", async () => { createScale([2.0, 3.0]), createTranslation([1.0, 1.0]), ]), - input_axes: [0, 1], - output_axes: [0, 1], + inputAxes: [0, 1], + outputAxes: [0, 1], }, { transformation: createTranslation([5.0]), - input_axes: [2], - output_axes: [2], + inputAxes: [2], + outputAxes: [2], }, ]); byDimension.input = { name: intrinsic.name }; @@ -953,8 +1029,8 @@ Deno.test("byDimension items nest wrapper transformations", async () => { type: "sequence", transformations: [{ type: "scale", scale: [2.0, 3.0] }], }, - input_axes: [0, 1], - output_axes: [0, 1], + inputAxes: [0, 1], + outputAxes: [0, 1], }, ], }); @@ -978,8 +1054,8 @@ Deno.test("validateV06Transform rejects fractional and out-of-range axes", () => const outOfRange = createByDimension([ { transformation: createScale([2.0, 3.0, 4.0]), - input_axes: [0, 1, 3], - output_axes: [0, 1, 2], + inputAxes: [0, 1, 3], + outputAxes: [0, 1, 2], }, ]); outOfRange.input = { name: "system" };