From 98d38b882c6ad776f0d64689c5b32ad51880c21d Mon Sep 17 00:00:00 2001 From: Valentin Boussot Date: Tue, 25 Aug 2026 11:22:50 +0200 Subject: [PATCH 01/10] feat(py): validate 0.9.dev1 stores against the bundled schemas The 0.9.dev1 release publishes the JSON Schemas that 0.9 lacked, so the reader no longer refuses validate=True. The 20 schemas of the tag are vendored under spec/0.9, which is where _schemas_dir sends "0.9.dev1" already: it resolves a prerelease string to its base version, as it does for "0.6rc0". axis_orientation.schema is the one file with no counterpart at 0.6. The v0.6 delegate runs with validate=False afterwards, since it would otherwise measure a 0.9 document against the 0.6 schemas. --- docs/cli.md | 5 +- docs/spec_features.md | 6 +- py/ngff_zarr/spec/0.9/schemas/_version.schema | 10 + py/ngff_zarr/spec/0.9/schemas/axes.schema | 46 ++ .../spec/0.9/schemas/axis_orientation.schema | 41 ++ py/ngff_zarr/spec/0.9/schemas/bf2raw.schema | 32 ++ .../0.9/schemas/coordinate_systems.schema | 31 ++ .../schemas/coordinate_transformations.schema | 433 ++++++++++++++++++ py/ngff_zarr/spec/0.9/schemas/image.schema | 265 +++++++++++ py/ngff_zarr/spec/0.9/schemas/label.schema | 91 ++++ py/ngff_zarr/spec/0.9/schemas/ome.schema | 33 ++ py/ngff_zarr/spec/0.9/schemas/ome_zarr.schema | 29 ++ py/ngff_zarr/spec/0.9/schemas/plate.schema | 153 +++++++ py/ngff_zarr/spec/0.9/schemas/scene.schema | 69 +++ .../spec/0.9/schemas/strict_axes.schema | 30 ++ .../schemas/strict_coordinate_systems.schema | 19 + .../spec/0.9/schemas/strict_image.schema | 26 ++ .../spec/0.9/schemas/strict_label.schema | 22 + .../spec/0.9/schemas/strict_ome_zarr.schema | 24 + .../spec/0.9/schemas/strict_plate.schema | 32 ++ .../spec/0.9/schemas/strict_well.schema | 5 + py/ngff_zarr/spec/0.9/schemas/well.schema | 63 +++ py/ngff_zarr/v09/zarr_metadata.py | 20 +- py/test/test_v09_metadata.py | 65 ++- 24 files changed, 1532 insertions(+), 18 deletions(-) create mode 100644 py/ngff_zarr/spec/0.9/schemas/_version.schema create mode 100644 py/ngff_zarr/spec/0.9/schemas/axes.schema create mode 100644 py/ngff_zarr/spec/0.9/schemas/axis_orientation.schema create mode 100644 py/ngff_zarr/spec/0.9/schemas/bf2raw.schema create mode 100644 py/ngff_zarr/spec/0.9/schemas/coordinate_systems.schema create mode 100644 py/ngff_zarr/spec/0.9/schemas/coordinate_transformations.schema create mode 100644 py/ngff_zarr/spec/0.9/schemas/image.schema create mode 100644 py/ngff_zarr/spec/0.9/schemas/label.schema create mode 100644 py/ngff_zarr/spec/0.9/schemas/ome.schema create mode 100644 py/ngff_zarr/spec/0.9/schemas/ome_zarr.schema create mode 100644 py/ngff_zarr/spec/0.9/schemas/plate.schema create mode 100644 py/ngff_zarr/spec/0.9/schemas/scene.schema create mode 100644 py/ngff_zarr/spec/0.9/schemas/strict_axes.schema create mode 100644 py/ngff_zarr/spec/0.9/schemas/strict_coordinate_systems.schema create mode 100644 py/ngff_zarr/spec/0.9/schemas/strict_image.schema create mode 100644 py/ngff_zarr/spec/0.9/schemas/strict_label.schema create mode 100644 py/ngff_zarr/spec/0.9/schemas/strict_ome_zarr.schema create mode 100644 py/ngff_zarr/spec/0.9/schemas/strict_plate.schema create mode 100644 py/ngff_zarr/spec/0.9/schemas/strict_well.schema create mode 100644 py/ngff_zarr/spec/0.9/schemas/well.schema diff --git a/docs/cli.md b/docs/cli.md index b0134bfc..9f999a4d 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -197,9 +197,8 @@ ngff-zarr upgrade src.zarr -o dst.zarr --to 0.5 The target version is selected with `--to` (alias `--version`), one of `0.4`, `0.5`, `0.6`, or `0.9.dev1` (default `0.6`). `0.9.dev1` is the development -version that adopts RFC-3; OME publishes no JSON Schema for it yet, so -`--validate` cannot check a store at that version. Add `--validate` to validate -the source metadata against the NGFF schema while reading. For the write-to-new-store mode, +version that adopts RFC-3. Add `--validate` to validate the source metadata +against the NGFF schema while reading. For the write-to-new-store mode, `--overwrite` (the default) replaces any pre-existing data at the output store, while `--no-overwrite` refuses to; both flags are ignored for an in-place upgrade, which never overwrites array data. diff --git a/docs/spec_features.md b/docs/spec_features.md index a453f580..a65ff0ee 100644 --- a/docs/spec_features.md +++ b/docs/spec_features.md @@ -40,9 +40,9 @@ supported by `ngff-zarr`. and transformations. - **OME-Zarr 0.9.dev1**: Reads and writes the development version that adopts RFC-3, which extends support for the number, names, types and order - of axes. It is opt-in: pass `version="0.9.dev1"` explicitly. The default - target is unchanged, and OME publishes no JSON Schema for it yet, so schema - validation is unavailable at that version. + of axes. It is opt-in: pass `version="0.9.dev1"` explicitly, the default + target is unchanged. The schemas of the `0.9.dev1` release are bundled, so + `validate=True` checks a store at that version as it does at any other. ## High Content Screening (HCS) diff --git a/py/ngff_zarr/spec/0.9/schemas/_version.schema b/py/ngff_zarr/spec/0.9/schemas/_version.schema new file mode 100644 index 00000000..aefecf67 --- /dev/null +++ b/py/ngff_zarr/spec/0.9/schemas/_version.schema @@ -0,0 +1,10 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ngff.openmicroscopy.org/0.9.dev1/schemas/_version.schema", + "title": "OME-Zarr version", + "description": "OME-Zarr version.", + "type": "string", + "enum": [ + "0.9.dev1" + ] +} diff --git a/py/ngff_zarr/spec/0.9/schemas/axes.schema b/py/ngff_zarr/spec/0.9/schemas/axes.schema new file mode 100644 index 00000000..004e2f39 --- /dev/null +++ b/py/ngff_zarr/spec/0.9/schemas/axes.schema @@ -0,0 +1,46 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ngff.openmicroscopy.org/0.9.dev1/schemas/axes.schema", + "title": "Axes", + "description": "OME-Zarr Axes.", + "type": "array", + "uniqueItems": true, + "minItems": 1, + "items": { + "$ref": "#/$defs/axis" + }, + "$defs": { + "axis": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "description": "Name of the axis. Must be unique within the coordinate system." + }, + "longName": { + "type": "string", + "description": "Longer name or description of the axis." + }, + "type": { + "type": "string", + "description": "Type of the axis. Can be a predefined type or custom type." + }, + "discrete": { + "type": "boolean", + "description": "Whether the dimension is discrete" + }, + "unit": { + "type": "string", + "description": "Unit for the axis" + }, + "orientation": { + "$ref": "axis_orientation.schema" + } + }, + "required": [ + "name" + ] + } + } +} diff --git a/py/ngff_zarr/spec/0.9/schemas/axis_orientation.schema b/py/ngff_zarr/spec/0.9/schemas/axis_orientation.schema new file mode 100644 index 00000000..1a11028e --- /dev/null +++ b/py/ngff_zarr/spec/0.9/schemas/axis_orientation.schema @@ -0,0 +1,41 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ngff.openmicroscopy.org/0.9.dev1/schemas/axis_orientation.schema", + "title": "Axis Orientation", + "description": "Controlled vocabulary for orientation of each spatial axis according to subject or subject-global reference.", + "type": "object", + "properties": { + "type": { + "enum": ["anatomical"] + }, + "value": { + "enum": [ + "left-to-right", + "right-to-left", + "anterior-to-posterior", + "posterior-to-anterior", + "inferior-to-superior", + "superior-to-inferior", + "dorsal-to-ventral", + "ventral-to-dorsal", + "dorsal-to-palmar", + "palmar-to-dorsal", + "dorsal-to-plantar", + "plantar-to-dorsal", + "rostral-to-caudal", + "caudal-to-rostral", + "cranial-to-caudal", + "caudal-to-cranial", + "proximal-to-distal", + "distal-to-proximal", + "superficial-to-deep", + "deep-to-superficial", + "apical-to-basal", + "basal-to-apical", + "apex-to-base", + "base-to-apex" + ] + } + }, + "required": ["type", "value"] +} diff --git a/py/ngff_zarr/spec/0.9/schemas/bf2raw.schema b/py/ngff_zarr/spec/0.9/schemas/bf2raw.schema new file mode 100644 index 00000000..8a505362 --- /dev/null +++ b/py/ngff_zarr/spec/0.9/schemas/bf2raw.schema @@ -0,0 +1,32 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ngff.openmicroscopy.org/0.9.dev1/schemas/bf2raw.schema", + "title": "bioformats2raw", + "description": "OME-Zarr bioformats2raw metadata.", + "type": "object", + "properties": { + "ome": { + "description": "The versioned OME-Zarr Metadata namespace", + "type": "object", + "properties": { + "bioformats2raw.layout": { + "description": "The top-level identifier metadata added by bioformats2raw", + "type": "number", + "enum": [ + 3 + ] + }, + "version": { + "$ref": "https://ngff.openmicroscopy.org/0.9.dev1/schemas/_version.schema" + } + }, + "required": [ + "bioformats2raw.layout", + "version" + ] + } + }, + "required": [ + "ome" + ] +} diff --git a/py/ngff_zarr/spec/0.9/schemas/coordinate_systems.schema b/py/ngff_zarr/spec/0.9/schemas/coordinate_systems.schema new file mode 100644 index 00000000..6dcc3b65 --- /dev/null +++ b/py/ngff_zarr/spec/0.9/schemas/coordinate_systems.schema @@ -0,0 +1,31 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ngff.openmicroscopy.org/0.9.dev1/schemas/coordinate_systems.schema", + "title": "Coordinate systems", + "description": "OME-Zarr coordinate system.", + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "#/$defs/coordinateSystem" + }, + "$defs": { + "coordinateSystem": { + "description": "Coordinate Systems for OME-NGFF", + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "description": "Name of coordinate system. Must be unique among all coordinate systems." + }, + "axes": { + "$ref": "axes.schema" + } + }, + "required": [ + "name", + "axes" + ] + } + } +} diff --git a/py/ngff_zarr/spec/0.9/schemas/coordinate_transformations.schema b/py/ngff_zarr/spec/0.9/schemas/coordinate_transformations.schema new file mode 100644 index 00000000..8f0af7b9 --- /dev/null +++ b/py/ngff_zarr/spec/0.9/schemas/coordinate_transformations.schema @@ -0,0 +1,433 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ngff.openmicroscopy.org/0.9.dev1/schemas/coordinate_transformations.schema", + "title": "Coordinate Transformations", + "description": "OME-Zarr Coordinate transforms.", + "type": "array", + "uniqueItems": true, + "minItems": 1, + "items": { + "allOf": [ + { + "$ref": "#/$defs/coordinateTransformation" + }, + { + "type": "object", + "properties": { + "input": { + "allOf": [ + {"$ref": "#/$defs/inputOutput"}, + {"required": ["path"]} + ] + }, + "output": { + "allOf": [ + {"$ref": "#/$defs/inputOutput"}, + {"required": ["name"]} + ] + } + }, + "required": [ + "input", + "output" + ] + } + ] + }, + "$defs": { + "inputOutput": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "path": {"type": "string"} + } + }, + "coordinateTransformation": { + "description": "OME-NGFF coordinate transformation.", + "allOf": [ + { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "required": [ + "type" + ] + }, + { + "oneOf": [ + { + "$ref": "#/$defs/identity" + }, + { + "$ref": "#/$defs/mapAxis" + }, + { + "$ref": "#/$defs/projectAxis" + }, + { + "$ref": "#/$defs/scale" + }, + { + "$ref": "#/$defs/translation" + }, + { + "$ref": "#/$defs/affine" + }, + { + "$ref": "#/$defs/rotation" + }, + { + "$ref": "#/$defs/bijection" + }, + { + "$ref": "#/$defs/sequence" + }, + { + "$ref": "#/$defs/byDimension" + }, + { + "$ref": "#/$defs/displacements" + }, + { + "$ref": "#/$defs/coordinates" + } + ] + } + ] + }, + "identity": { + "type": "object", + "properties": { + "type": { + "const": "identity" + } + }, + "title": "Identity Transformation", + "description": "Identity transformation that maps input coordinates directly to output coordinates without modification." + }, + "mapAxis": { + "type": "object", + "title": "Map Axis Transformation", + "description": "Permute axes by mapping input axes to output axes.", + "properties": { + "type": { + "const": "mapAxis" + }, + "mapAxis": { + "type": "array", + "items": { + "type": "integer", + "minimum": 0 + }, + "uniqueItems": true, + "description": "An array of integers representing the new axis order as zero-based indices of the input axes." + } + }, + "required": [ + "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 + }, + "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 + }, + "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", + "description": "Scale transformation that scales coordinates by specified factors along each axis.", + "properties": { + "type": { + "const": "scale" + }, + "scale": { + "type": "array", + "items": { + "type": "number", + "exclusiveMinimum": 0 + } + } + }, + "required": [ + "scale" + ] + }, + "translation": { + "type": "object", + "title": "Translation Transformation", + "description": "Translation transformation that shifts coordinates by specified offsets along each axis.", + "properties": { + "type": { + "const": "translation" + }, + "translation": { + "type": "array", + "items": { + "type": "number" + } + } + }, + "required": [ + "translation" + ] + }, + "affine": { + "type": "object", + "title": "Affine Transformation", + "description": "Affine transformation represented by a transformation matrix.", + "properties": { + "type": { + "const": "affine" + } + }, + "oneOf": [ + { + "properties": { + "path": { + "type": "string", + "description": "Path to a zarr array containing the affine matrix." + } + }, + "required": [ + "path" + ] + }, + { + "properties": { + "affine": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "number" + } + } + } + }, + "required": [ + "affine" + ] + } + ] + }, + "rotation": { + "type": "object", + "title": "Rotation Transformation", + "description": "Rotation transformation represented by a rotation matrix.", + "properties": { + "type": { + "const": "rotation" + } + }, + "oneOf": [ + { + "properties": { + "path": { + "type": "string", + "description": "Path to a zarr array containing the rotation matrix." + } + }, + "required": [ + "path" + ] + }, + { + "properties": { + "rotation": { + "oneOf": [ + { + "type": "array", + "minItems": 2, + "maxItems": 2, + "items": { "type": "array", "minItems": 2, "maxItems": 2, "items": { "type": "number" } } + }, + { + "type": "array", + "minItems": 3, + "maxItems": 3, + "items": { "type": "array", "minItems": 3, "maxItems": 3, "items": { "type": "number" } } + }, + { + "type": "array", + "minItems": 4, + "maxItems": 4, + "items": { "type": "array", "minItems": 4, "maxItems": 4, "items": { "type": "number" } } + }, + { + "type": "array", + "minItems": 5, + "maxItems": 5, + "items": { "type": "array", "minItems": 5, "maxItems": 5, "items": { "type": "number" } } + } + ] + } + }, + "required": [ + "rotation" + ] + } + ] + }, + "bijection": { + "type": "object", + "title": "Bijection Transformation", + "description": "A pair of forward and inverse coordinate transformations.", + "properties": { + "type": { + "const": "bijection" + }, + "forward": { + "$ref": "#/$defs/coordinateTransformation" + }, + "inverse": { + "$ref": "#/$defs/coordinateTransformation" + } + }, + "required": [ + "forward", "inverse" + ] + }, + "sequence": { + "title": "Sequence Transformation", + "description": "A sequence of transformations applied in order.", + "type": "object", + "properties": { + "type": { "const": "sequence" }, + "transformations": { + "type": "array", + "items": { + "$ref": "#/$defs/coordinateTransformation" + } + } + }, + "required": [ + "transformations" + ] + }, + "byDimension": { + "type": "object", + "title": "By Dimension Transformation", + "description": "A set of transformations applied independently to each dimension.", + "properties": { + "type": { "const": "byDimension" }, + "transformations": { + "type": "array", + "items": { + "type": "object", + "properties": { + "transformation": { + "$ref": "#/$defs/coordinateTransformation" + }, + "inputAxes": { + "type": "array", + "items": { + "type": "number" + }, + "description": "Names of the input axes for this transformation." + }, + "outputAxes": { + "type": "array", + "items": { + "type": "number" + }, + "description": "Names of the output axes for this transformation." + } + }, + "required": [ + "transformation", + "inputAxes", + "outputAxes" + ] + } + } + }, + "required": [ + "transformations" + ] + }, + "displacements": { + "type": "object", + "title": "Displacement Field Transformation", + "description": "Transformation defined by a displacement field stored in a zarr array.", + "properties": { + "type": { "const": "displacements" }, + "path": { + "type": "string", + "description": "Path to the zarr array containing the displacement field." + }, + "interpolation": { + "type": "string", + "enum": ["nearest", "linear", "cubic"], + "default": "linear", + "description": "Interpolation method to use when applying the displacement field." + } + }, + "required": [ + "path" + ] + }, + "coordinates": { + "type": "object", + "title": "Coordinate Field Transformation", + "description": "Transformation defined by a coordinate field stored in a zarr array.", + "properties": { + "type": { "const": "coordinates" }, + "path": { + "type": "string", + "description": "Path to the zarr array containing the coordinate field." + }, + "interpolation": { + "type": "string", + "enum": ["nearest", "linear", "cubic"], + "default": "linear", + "description": "Interpolation method to use when applying the coordinate field." + } + }, + "required": [ + "path" + ] + } + } +} diff --git a/py/ngff_zarr/spec/0.9/schemas/image.schema b/py/ngff_zarr/spec/0.9/schemas/image.schema new file mode 100644 index 00000000..effb6e84 --- /dev/null +++ b/py/ngff_zarr/spec/0.9/schemas/image.schema @@ -0,0 +1,265 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ngff.openmicroscopy.org/0.9.dev1/schemas/image.schema", + "title": "Image", + "description": "OME-Zarr image.", + "type": "object", + "properties": { + "ome": { + "description": "The versioned OME-Zarr Metadata namespace", + "type": "object", + "properties": { + "omero": { + "$ref": "#/$defs/omero" + }, + "multiscales": { + "$ref": "#/$defs/multiscales" + }, + "version": { + "$ref": "https://ngff.openmicroscopy.org/0.9.dev1/schemas/_version.schema" + } + }, + "required": [ + "multiscales", + "version" + ] + } + }, + "required": [ + "ome" + ], + "$defs": { + "multiscales": { + "description": "The multiscale datasets for this image", + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "datasets": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "coordinateTransformations": { + "type": "array", + "uniqueItems": true, + "items": { + "oneOf": [ + { + "description": "A single scale transformation", + "allOf": [ + { + "$ref": "https://ngff.openmicroscopy.org/0.9.dev1/schemas/coordinate_transformations.schema#/$defs/scale" + }, + { + "type": "object", + "properties": { + "input": { + "allOf": [ + {"$ref": "https://ngff.openmicroscopy.org/0.9.dev1/schemas/coordinate_transformations.schema#/$defs/inputOutput"}, + { + "required": ["path"] + } + ] + }, + "output": { + "allOf": [ + {"$ref": "https://ngff.openmicroscopy.org/0.9.dev1/schemas/coordinate_transformations.schema#/$defs/inputOutput"}, + { + "required": ["name"] + } + ] + }, + "name": {"type": "string"} + }, + "required": ["input", "output"] + } + ] + }, + { + "description": "A single identity transformation", + "allOf": [ + {"$ref": "https://ngff.openmicroscopy.org/0.9.dev1/schemas/coordinate_transformations.schema#/$defs/identity"}, + { + "type": "object", + "properties": { + "input": { + "allOf": [ + {"$ref": "https://ngff.openmicroscopy.org/0.9.dev1/schemas/coordinate_transformations.schema#/$defs/inputOutput"}, + { + "required": ["path"] + } + ] + }, + "output": { + "allOf": [ + {"$ref": "https://ngff.openmicroscopy.org/0.9.dev1/schemas/coordinate_transformations.schema#/$defs/inputOutput"}, + { + "required": ["name"] + } + ] + }, + "name": {"type": "string"} + }, + "required": ["input", "output"] + } + ] + }, + { + "description": "A sequence of a ingle scale followed by a single translation", + "type": "object", + "properties": { + "type": {"const": "sequence"}, + "transformations": { + "type": "array", + "items": { + "oneOf": [ + {"$ref": "https://ngff.openmicroscopy.org/0.9.dev1/schemas/coordinate_transformations.schema#/$defs/scale"}, + {"$ref": "https://ngff.openmicroscopy.org/0.9.dev1/schemas/coordinate_transformations.schema#/$defs/translation"} + ] + }, + "minItems": 2, + "maxItems": 2 + }, + "input": { + "allOf": [ + {"$ref": "https://ngff.openmicroscopy.org/0.9.dev1/schemas/coordinate_transformations.schema#/$defs/inputOutput"}, + { + "required": ["path"] + } + ] + }, + "output": { + "allOf": [ + {"$ref": "https://ngff.openmicroscopy.org/0.9.dev1/schemas/coordinate_transformations.schema#/$defs/inputOutput"}, + { + "required": ["name"] + } + ] + }, + "name": {"type": "string"} + }, + "required": ["type", "transformations", "input", "output"] + } + ] + }, + "minItems": 1, + "maxItems": 1, + "description": "Multiscale transformations (identity/scale/sequence of scale and translation) from this dataset to the coordinate system specified by 'output'." + } + }, + "required": [ + "path", + "coordinateTransformations" + ] + } + }, + "coordinateSystems": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "https://ngff.openmicroscopy.org/0.9.dev1/schemas/coordinate_systems.schema#/$defs/coordinateSystem" + } + }, + "coordinateTransformations": { + "type": "array", + "minItems": 1, + "items": { + "allOf": [ + { + "$ref": "https://ngff.openmicroscopy.org/0.9.dev1/schemas/coordinate_transformations.schema#/$defs/coordinateTransformation", + "description": "Parameters of any possible transform" + }, + { + "type": "object", + "description": "Transformations between two named coordinate systems same metadata document or in a child labels group", + "properties": { + "input": { + "type": "object", + "properties": { + "name": {"type": "string"} + }, + "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", + "properties": { + "name": {"type": "string"} + }, + "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"] + } + ] + } + } + }, + "required": [ + "datasets", + "coordinateSystems" + ] + }, + "minItems": 1, + "uniqueItems": true + }, + "omero": { + "type": "object", + "properties": { + "channels": { + "type": "array", + "items": { + "type": "object", + "properties": { + "window": { + "type": "object", + "properties": { + "end": { + "type": "number" + }, + "max": { + "type": "number" + }, + "min": { + "type": "number" + }, + "start": { + "type": "number" + } + }, + "required": [ + "start", + "end" + ] + }, + "label": { + "type": "string" + }, + "color": { + "type": "string" + }, + "active": { + "type": "boolean" + }, + "inverted": { + "type": "boolean" + } + } + } + } + }, + "required": [ + "channels" + ] + } + } +} diff --git a/py/ngff_zarr/spec/0.9/schemas/label.schema b/py/ngff_zarr/spec/0.9/schemas/label.schema new file mode 100644 index 00000000..ae6d8b4e --- /dev/null +++ b/py/ngff_zarr/spec/0.9/schemas/label.schema @@ -0,0 +1,91 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ngff.openmicroscopy.org/0.9.dev1/schemas/label.schema", + "title": "Label", + "description": "OME-Zarr label.", + "type": "object", + "properties": { + "ome": { + "description": "The versioned OME-Zarr Metadata namespace", + "type": "object", + "properties": { + "image-label": { + "$ref": "#/$defs/image-label" + }, + "version": { + "$ref": "https://ngff.openmicroscopy.org/0.9.dev1/schemas/_version.schema" + } + }, + "required": [ + "image-label", + "version" + ] + } + }, + "required": [ + "ome" + ], + "$defs": { + "image-label": { + "type": "object", + "properties": { + "colors": { + "description": "The colors for this label image", + "type": "array", + "items": { + "type": "object", + "properties": { + "label-value": { + "description": "The value of the label", + "type": "number" + }, + "rgba": { + "description": "The RGBA color stored as an array of four integers between 0 and 255", + "type": "array", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "minItems": 4, + "maxItems": 4 + } + }, + "required": [ + "label-value" + ] + }, + "minItems": 1, + "uniqueItems": true + }, + "properties": { + "description": "The properties for this label image", + "type": "array", + "items": { + "type": "object", + "properties": { + "label-value": { + "description": "The pixel value for this label", + "type": "integer" + } + }, + "required": [ + "label-value" + ] + }, + "minItems": 1, + "uniqueItems": true + }, + "source": { + "description": "The source of this label image", + "type": "object", + "properties": { + "image": { + "type": "string" + } + } + } + } + } + } +} diff --git a/py/ngff_zarr/spec/0.9/schemas/ome.schema b/py/ngff_zarr/spec/0.9/schemas/ome.schema new file mode 100644 index 00000000..69efecdc --- /dev/null +++ b/py/ngff_zarr/spec/0.9/schemas/ome.schema @@ -0,0 +1,33 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ngff.openmicroscopy.org/0.9.dev1/schemas/ome.schema", + "title": "OME", + "description": "OME-Zarr OME metadata.", + "type": "object", + "properties": { + "ome": { + "description": "The versioned OME-Zarr Metadata namespace", + "type": "object", + "properties": { + "series": { + "description": "An array of the same length and the same order as the images defined in the OME-XML", + "type": "array", + "items": { + "type": "string" + }, + "minContains": 1 + }, + "version": { + "$ref": "https://ngff.openmicroscopy.org/0.9.dev1/schemas/_version.schema" + } + }, + "required": [ + "series", + "version" + ] + } + }, + "required": [ + "ome" + ] +} diff --git a/py/ngff_zarr/spec/0.9/schemas/ome_zarr.schema b/py/ngff_zarr/spec/0.9/schemas/ome_zarr.schema new file mode 100644 index 00000000..6f4fa1ff --- /dev/null +++ b/py/ngff_zarr/spec/0.9/schemas/ome_zarr.schema @@ -0,0 +1,29 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ngff.openmicroscopy.org/0.9.dev1/schemas/ome_zarr.schema", + "title": "OME-Zarr", + "description": "Any OME-Zarr dataset.", + "anyOf": [ + { + "$ref": "https://ngff.openmicroscopy.org/0.9.dev1/schemas/bf2raw.schema" + }, + { + "$ref": "https://ngff.openmicroscopy.org/0.9.dev1/schemas/image.schema" + }, + { + "$ref": "https://ngff.openmicroscopy.org/0.9.dev1/schemas/label.schema" + }, + { + "$ref": "https://ngff.openmicroscopy.org/0.9.dev1/schemas/ome.schema" + }, + { + "$ref": "https://ngff.openmicroscopy.org/0.9.dev1/schemas/plate.schema" + }, + { + "$ref": "https://ngff.openmicroscopy.org/0.9.dev1/schemas/well.schema" + }, + { + "$ref": "https://ngff.openmicroscopy.org/0.9.dev1/schemas/scene.schema" + } + ] +} diff --git a/py/ngff_zarr/spec/0.9/schemas/plate.schema b/py/ngff_zarr/spec/0.9/schemas/plate.schema new file mode 100644 index 00000000..978c00fe --- /dev/null +++ b/py/ngff_zarr/spec/0.9/schemas/plate.schema @@ -0,0 +1,153 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ngff.openmicroscopy.org/0.9.dev1/schemas/plate.schema", + "title": "Plate", + "description": "OME-Zarr plate.", + "type": "object", + "properties": { + "ome": { + "description": "The versioned OME-Zarr Metadata namespace", + "type": "object", + "properties": { + "plate": { + "type": "object", + "properties": { + "acquisitions": { + "description": "The acquisitions for this plate", + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "description": "A unique identifier within the context of the plate", + "type": "integer", + "minimum": 0 + }, + "maximumfieldcount": { + "description": "The maximum number of fields of view for the acquisition", + "type": "integer", + "exclusiveMinimum": 0 + }, + "name": { + "description": "The name of the acquisition", + "type": "string" + }, + "description": { + "description": "The description of the acquisition", + "type": "string" + }, + "starttime": { + "description": "The start timestamp of the acquisition, expressed as epoch time i.e. the number seconds since the Epoch", + "type": "integer", + "minimum": 0 + }, + "endtime": { + "description": "The end timestamp of the acquisition, expressed as epoch time i.e. the number seconds since the Epoch", + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "id" + ] + } + }, + "field_count": { + "description": "The maximum number of fields per view across all wells", + "type": "integer", + "exclusiveMinimum": 0 + }, + "name": { + "description": "The name of the plate", + "type": "string" + }, + "columns": { + "description": "The columns of the plate", + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "description": "The column name", + "type": "string", + "pattern": "^[A-Za-z0-9]+$" + } + }, + "required": [ + "name" + ] + }, + "minItems": 1, + "uniqueItems": true + }, + "rows": { + "description": "The rows of the plate", + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "description": "The row name", + "type": "string", + "pattern": "^[A-Za-z0-9]+$" + } + }, + "required": [ + "name" + ] + }, + "minItems": 1, + "uniqueItems": true + }, + "wells": { + "description": "The wells of the plate", + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "description": "The path to the well subgroup", + "type": "string", + "pattern": "^[A-Za-z0-9]+/[A-Za-z0-9]+$" + }, + "rowIndex": { + "description": "The index of the well in the rows list", + "type": "integer", + "minimum": 0 + }, + "columnIndex": { + "description": "The index of the well in the columns list", + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "path", + "rowIndex", + "columnIndex" + ] + }, + "minItems": 1, + "uniqueItems": true + } + }, + "required": [ + "columns", + "rows", + "wells" + ] + }, + "version": { + "$ref": "https://ngff.openmicroscopy.org/0.9.dev1/schemas/_version.schema" + } + }, + "required": [ + "plate", + "version" + ] + } + }, + "required": [ + "ome" + ] +} diff --git a/py/ngff_zarr/spec/0.9/schemas/scene.schema b/py/ngff_zarr/spec/0.9/schemas/scene.schema new file mode 100644 index 00000000..10f0d7c3 --- /dev/null +++ b/py/ngff_zarr/spec/0.9/schemas/scene.schema @@ -0,0 +1,69 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ngff.openmicroscopy.org/0.9.dev1/schemas/scene.schema", + "title": "Scene", + "description": "Scene metadata combining coordinate systems and coordinate transformations to define spatial relationships", + "type": "object", + "properties": { + "ome": { + "type": "object", + "properties": { + "version": { + "$ref": "https://ngff.openmicroscopy.org/0.9.dev1/schemas/_version.schema" + }, + "scene":{ + "properties": { + "coordinateSystems": { + "$ref": "coordinate_systems.schema", + "description": "Coordinate systems to combine with transforms to define spatial relationships" + }, + "coordinateTransformations": { + "$comment": "Merge general coordinate transformations with constraints for scene metadata", + "type": "array", + "minItems": 1, + "items": { + "allOf": [ + { + "$ref": "coordinate_transformations.schema#/$defs/coordinateTransformation", + "description": "Parameters of any possible transform" + }, + { + "type": "object", + "description": "Transformations between two referenced coordinate systems", + "properties": { + "input": { + "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)" + }, + "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": ["input", "output"] + } + ] + } + } + }, + "type": "object", + "required": ["coordinateTransformations"] + } + }, + "required": ["scene", "version"] + } + }, + "required": ["ome"] +} diff --git a/py/ngff_zarr/spec/0.9/schemas/strict_axes.schema b/py/ngff_zarr/spec/0.9/schemas/strict_axes.schema new file mode 100644 index 00000000..e05ebbaf --- /dev/null +++ b/py/ngff_zarr/spec/0.9/schemas/strict_axes.schema @@ -0,0 +1,30 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ngff.openmicroscopy.org/0.9.dev1/schemas/strict_axes.schema", + "title": "NGFF Strict Axes", + "description": "JSON from OME-NGFF .zattrs", + "allOf": [ + { + "$ref": "https://ngff.openmicroscopy.org/0.9.dev1/schemas/axes.schema" + }, + { + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "array", + "channel", + "time", + "space", + "displacement", + "coordinate", + "frequency" + ] + } + } + } + } + ] +} diff --git a/py/ngff_zarr/spec/0.9/schemas/strict_coordinate_systems.schema b/py/ngff_zarr/spec/0.9/schemas/strict_coordinate_systems.schema new file mode 100644 index 00000000..8cc318c3 --- /dev/null +++ b/py/ngff_zarr/spec/0.9/schemas/strict_coordinate_systems.schema @@ -0,0 +1,19 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ngff.openmicroscopy.org/0.9.dev1/schemas/strict_coordinate_systems.schema", + "allOf" : [ + { + "$ref": "coordinate_systems.schema" + }, + { + "items": { + "type": "object", + "properties": { + "axes": { + "$ref": "strict_axes.schema" + } + } + } + } + ] +} diff --git a/py/ngff_zarr/spec/0.9/schemas/strict_image.schema b/py/ngff_zarr/spec/0.9/schemas/strict_image.schema new file mode 100644 index 00000000..593d417e --- /dev/null +++ b/py/ngff_zarr/spec/0.9/schemas/strict_image.schema @@ -0,0 +1,26 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ngff.openmicroscopy.org/0.9.dev1/schemas/strict_image.schema", + "allOf": [ + { + "$ref": "https://ngff.openmicroscopy.org/0.9.dev1/schemas/image.schema" + }, + { + "properties": { + "ome": { + "properties": { + "multiscales": { + "items": { + "required": [ + "metadata", + "type", + "name" + ] + } + } + } + } + } + } + ] +} diff --git a/py/ngff_zarr/spec/0.9/schemas/strict_label.schema b/py/ngff_zarr/spec/0.9/schemas/strict_label.schema new file mode 100644 index 00000000..999eda87 --- /dev/null +++ b/py/ngff_zarr/spec/0.9/schemas/strict_label.schema @@ -0,0 +1,22 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ngff.openmicroscopy.org/0.9.dev1/schemas/strict_label.schema", + "allOf": [ + { + "$ref": "https://ngff.openmicroscopy.org/0.9.dev1/schemas/label.schema" + }, + { + "properties": { + "ome": { + "properties": { + "image-label": { + "required": [ + "colors" + ] + } + } + } + } + } + ] +} diff --git a/py/ngff_zarr/spec/0.9/schemas/strict_ome_zarr.schema b/py/ngff_zarr/spec/0.9/schemas/strict_ome_zarr.schema new file mode 100644 index 00000000..8b936afe --- /dev/null +++ b/py/ngff_zarr/spec/0.9/schemas/strict_ome_zarr.schema @@ -0,0 +1,24 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ngff.openmicroscopy.org/0.9.dev1/schemas/strict_ome_zarr.schema", + "anyOf": [ + { + "$ref": "https://ngff.openmicroscopy.org/0.9.dev1/schemas/bf2raw.schema" + }, + { + "$ref": "https://ngff.openmicroscopy.org/0.9.dev1/schemas/strict_image.schema" + }, + { + "$ref": "https://ngff.openmicroscopy.org/0.9.dev1/schemas/strict_label.schema" + }, + { + "$ref": "https://ngff.openmicroscopy.org/0.9.dev1/schemas/ome.schema" + }, + { + "$ref": "https://ngff.openmicroscopy.org/0.9.dev1/schemas/strict_plate.schema" + }, + { + "$ref": "https://ngff.openmicroscopy.org/0.9.dev1/schemas/strict_well.schema" + } + ] +} diff --git a/py/ngff_zarr/spec/0.9/schemas/strict_plate.schema b/py/ngff_zarr/spec/0.9/schemas/strict_plate.schema new file mode 100644 index 00000000..ed99a1f5 --- /dev/null +++ b/py/ngff_zarr/spec/0.9/schemas/strict_plate.schema @@ -0,0 +1,32 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ngff.openmicroscopy.org/0.9.dev1/schemas/strict_plate.schema", + "allOf": [ + { + "$ref": "https://ngff.openmicroscopy.org/0.9.dev1/schemas/plate.schema" + }, + { + "properties": { + "ome": { + "properties": { + "plate": { + "properties": { + "acquisitions": { + "items": { + "required": [ + "name", + "maximumfieldcount" + ] + } + } + }, + "required": [ + "name" + ] + } + } + } + } + } + ] +} diff --git a/py/ngff_zarr/spec/0.9/schemas/strict_well.schema b/py/ngff_zarr/spec/0.9/schemas/strict_well.schema new file mode 100644 index 00000000..b0d52908 --- /dev/null +++ b/py/ngff_zarr/spec/0.9/schemas/strict_well.schema @@ -0,0 +1,5 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ngff.openmicroscopy.org/0.9.dev1/schemas/strict_well.schema", + "$ref": "https://ngff.openmicroscopy.org/0.9.dev1/schemas/well.schema" +} diff --git a/py/ngff_zarr/spec/0.9/schemas/well.schema b/py/ngff_zarr/spec/0.9/schemas/well.schema new file mode 100644 index 00000000..2d05d0a6 --- /dev/null +++ b/py/ngff_zarr/spec/0.9/schemas/well.schema @@ -0,0 +1,63 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ngff.openmicroscopy.org/0.9.dev1/schemas/well.schema", + "title": "Well", + "description": "OME-Zarr well.", + "type": "object", + "properties": { + "ome": { + "description": "The versioned OME-Zarr Metadata namespace", + "type": "object", + "properties": { + "well": { + "type": "object", + "properties": { + "images": { + "description": "The fields of view for this well", + "type": "array", + "items": { + "type": "object", + "properties": { + "acquisition": { + "description": "A unique identifier within the context of the plate", + "type": "integer" + }, + "path": { + "description": "The path for this field of view subgroup", + "type": "string", + "minLength": 1, + "pattern": "^[A-Za-z0-9_.-]+$", + "not": { + "anyOf": [ + { "pattern": "^\\.+$" }, + { "pattern": "^__" } + ] + } + } + }, + "required": [ + "path" + ] + }, + "minItems": 1, + "uniqueItems": true + } + }, + "required": [ + "images" + ] + }, + "version": { + "$ref": "https://ngff.openmicroscopy.org/0.9.dev1/schemas/_version.schema" + } + }, + "required": [ + "well", + "version" + ] + } + }, + "required": [ + "ome" + ] +} diff --git a/py/ngff_zarr/v09/zarr_metadata.py b/py/ngff_zarr/v09/zarr_metadata.py index ec69d4c7..05b1497c 100644 --- a/py/ngff_zarr/v09/zarr_metadata.py +++ b/py/ngff_zarr/v09/zarr_metadata.py @@ -335,8 +335,11 @@ def _from_zarr_attrs( Dataset transform parsing and ``NgffImage`` construction are delegated to the v0.6 reader. Handled here first: - 1. ``validate=True`` is refused: no ``0.9.dev1`` JSON Schema is - published, so no ``spec/0.9.dev1/schemas`` tree is bundled. + 1. ``validate=True`` runs the schema pass against the bundled + ``spec/0.9`` tree, which holds the schemas of the ``0.9.dev1`` + release. The delegate then runs with ``validate=False``: it would + otherwise measure the document against the schemas of its own + version. 2. A 0.5-shaped entry (flat ``axes``, no ``coordinateSystems``) is normalized to a single ``intrinsic`` coordinate system, so either shape is readable. @@ -348,12 +351,13 @@ def _from_zarr_attrs( from ..v06.zarr_metadata import Metadata as Metadata_v06 if validate: - raise NotImplementedError( - "Schema validation is unavailable for OME-Zarr 0.9.dev1: OME has " - "published no JSON Schema for it, so ngff-zarr ships no " - "spec/0.9.dev1/schemas tree. Read with validate=False and use " - "ngff_zarr.validate_structural() for the structural rules." - ) + # The 0.9.dev series records its version on the ``ome`` namespace, + # as 0.6 does, and the bundled ``spec/0.9`` tree carries the + # ``0.9.dev1`` tag its ``_version.schema`` binds. + from ..validate import validate as validate_ngff + + schema_version = str(root_attrs.get("ome", {}).get("version") or "0.9.dev1") + validate_ngff(root_attrs, version=schema_version) if "ome" not in root_attrs or "multiscales" not in root_attrs.get("ome", {}): raise ValueError( diff --git a/py/test/test_v09_metadata.py b/py/test/test_v09_metadata.py index 6dad7198..9ede791a 100644 --- a/py/test/test_v09_metadata.py +++ b/py/test/test_v09_metadata.py @@ -206,12 +206,69 @@ def test_axes_property_is_not_a_field(): assert metadata.axes == metadata.coordinateSystems[0].axes -def test_no_bundled_schema_is_reported_explicitly(): - """``load_schema`` names the missing 0.9.dev1 schema instead of failing on I/O.""" +def test_the_0_9_dev1_schemas_are_bundled(): + """``spec/0.9`` holds the schemas of the 0.9.dev1 release.""" from ngff_zarr.validate import load_schema - with pytest.raises(ValueError, match="0.9.dev1"): - load_schema(version="0.9.dev1") + assert load_schema(version="0.9.dev1", model="_version")["enum"] == ["0.9.dev1"] + assert ( + load_schema(version="0.9.dev1", model="image")["$id"] + == "https://ngff.openmicroscopy.org/0.9.dev1/schemas/image.schema" + ) + + +def test_a_0_9_dev1_store_reads_with_validate(tmp_path): + """The schema pass runs at 0.9.dev1 as it does at every bundled version.""" + import dask.array as da + import ngff_zarr as nz + import numpy as np + + image = nz.NgffImage( + da.zeros((1, 8, 8), dtype=np.uint8, chunks=(1, 8, 8)), + ["c", "y", "x"], + {"c": 1.0, "y": 1.0, "x": 1.0}, + {"c": 0.0, "y": 0.0, "x": 0.0}, + ) + store = str(tmp_path / "s.ome.zarr") + nz.to_ome_zarr( + store, + nz.to_multiscales(image, scale_factors=[2], cache=False), + version="0.9.dev1", + ) + + assert len(nz.from_ome_zarr(store, validate=True).images) == 2 + + +def test_a_broken_0_9_dev1_store_is_refused(tmp_path): + import json + from pathlib import Path + + import dask.array as da + import ngff_zarr as nz + from jsonschema.exceptions import ValidationError + + image = nz.NgffImage( + da.zeros((1, 8, 8), dtype=np.uint8, chunks=(1, 8, 8)), + ["c", "y", "x"], + {"c": 1.0, "y": 1.0, "x": 1.0}, + {"c": 0.0, "y": 0.0, "x": 0.0}, + ) + store = str(tmp_path / "s.ome.zarr") + nz.to_ome_zarr( + store, + nz.to_multiscales(image, scale_factors=[], cache=False), + version="0.9.dev1", + ) + + doc_path = Path(store) / "zarr.json" + doc = json.loads(doc_path.read_text()) + doc["attributes"]["ome"]["multiscales"][0]["coordinateSystems"][0]["axes"][0].pop( + "name" + ) + doc_path.write_text(json.dumps(doc, indent=4)) + + with pytest.raises(ValidationError): + nz.from_ome_zarr(store, validate=True) def test_version_is_supported(): From 7205c2e85e84f8bb1f07fa5a188b7440634e9846 Mon Sep 17 00:00:00 2001 From: Valentin Boussot Date: Mon, 24 Aug 2026 19:15:48 +0200 Subject: [PATCH 02/10] fix(py,ts): gate the RFC-4 orientation rules on 0.9.dev1 RFC-4 anatomical orientation is normative from OME-Zarr 0.9.dev1 (ome/ngff-spec#190); the released 0.4, 0.5 and 0.6 specs give it no status. Decided in #667: the three axis-orientation rules now gate on the declared version, becoming a no-op below 0.9.dev1. An omitted version keeps the checks on, as a strictness choice, exactly like axis-names-unique below 0.9.dev1, so every existing no-version caller is unchanged. The gate mirrors the RFC-3 helper's form with the polarity inverted: RFC-3 lifts axis restrictions at 0.9.dev1 while RFC-4 adds requirements, so is_rfc4_orientation_enforced exits the rules early below 0.9.dev1 rather than at it. The Python v0.4 reader now passes the store's declared version to validate_structural, as the TypeScript reader already did. The readers' raw RFC-4 hooks are removed. They ran validate_rfc4 orientation on the raw axis dicts before parsing, which the structural pass repeats on the parsed axes with the same function and the same verdicts (the RFC-4 JSON Schema's root object declares no properties, so its final pass constrains nothing); they fired before the structural rules, out of the canonical evaluation order; and the v0.6 Python hook read a flat axes list the v0.6 layout does not have. The orientation rule is now the single enforcement point in each port. No rule is added, renamed or reordered. The parity suites pin the new version set as CANONICAL_RFC4_VERSIONS in both ports. Two tests that pinned the raw hooks' read-path rejection now pin the read-path acceptance below 0.9.dev1; their fixtures carried an (x, y, z) spatial order that the hooks' early raise had kept from the structural pass. Refs #667 --- py/ngff_zarr/structural_validation.py | 44 +++++-- py/ngff_zarr/v04/zarr_metadata.py | 34 ++--- py/ngff_zarr/v06/zarr_metadata.py | 18 +-- py/test/test_rfc4_validation.py | 64 ++++++---- .../test_structural_validation_orientation.py | 27 ++++ py/test/test_structural_validation_parity.py | 106 ++++++++++++++++ py/test/test_structural_validation_reader.py | 37 ++++++ ts/src/types/supported_versions.ts | 19 +++ ts/src/utils/from_zarr_attrs.ts | 53 ++------ ts/src/utils/structural_validation.ts | 24 +++- ts/test/rfc4_validation_test.ts | 71 ++++++----- .../structural_validation_orientation_test.ts | 23 ++++ ts/test/structural_validation_parity_test.ts | 118 ++++++++++++++++++ ts/test/structural_validation_reader_test.ts | 69 +++++++++- 14 files changed, 555 insertions(+), 152 deletions(-) diff --git a/py/ngff_zarr/structural_validation.py b/py/ngff_zarr/structural_validation.py index a15d1b3b..367e2ca0 100644 --- a/py/ngff_zarr/structural_validation.py +++ b/py/ngff_zarr/structural_validation.py @@ -61,13 +61,13 @@ # each OMERO channel color is exactly 6 hex digits # e.g. multiscales[0].omero.channels[0].color # axis-orientation-anatomical-type -# each RFC 4 orientation type is "anatomical" (the only value defined) +# each RFC 4 orientation type is "anatomical"; normative from 0.9.dev1 # e.g. multiscales[0].axes # axis-orientation-on-non-space -# orientation is declared only on spatial axes (never time/channel) +# orientation only on spatial axes (never time/channel); from 0.9.dev1 # e.g. multiscales[0].axes # axis-orientation-unique-axis -# at most one direction per anatomical axis (antonyms are exclusive) +# one direction per anatomical axis (antonyms exclusive); from 0.9.dev1 # e.g. multiscales[0].axes # zarr-format # a v0.5 entry implies a Zarr v3 store (a leaked zarr_format must be 3) @@ -367,6 +367,24 @@ def is_rfc3_axis_model_allowed(version: object | None = None) -> bool: return version is not None and version == NgffVersion.V09dev1 +def is_rfc4_orientation_enforced(version: object | None = None) -> bool: + """Whether ``version`` makes the RFC-4 orientation rules normative. + + Only OME-Zarr ``0.9.dev1`` does (``ome/ngff-spec#190`` folds RFC-4 into + it): the released 0.4, 0.5 and 0.6 specs give ``orientation`` no normative + status, so when the caller declares one of those versions the orientation + rules are inert. ``None`` keeps them on: with no declared version, + enforcement is a strictness choice, exactly like ``axis-names-unique`` + below 0.9.dev1 (see docs/validation/rule-reference.md). + + The gate points the opposite way from :func:`is_rfc3_axis_model_allowed`: + at 0.9.dev1 RFC-3 *lifts* the axis restrictions while RFC-4 *adds* the + orientation requirements, so the rules exit early below 0.9.dev1 rather + than at it. + """ + return version is None or version == NgffVersion.V09dev1 + + def validate_axis_count(metadata: Metadata, version: object | None = None) -> None: """Validate that the axis count is within the v0.4-permitted range. @@ -752,7 +770,9 @@ def _axis_to_validation_dict(axis: Axis) -> dict[str, Any]: return axis_dict -def validate_axis_orientation(metadata: Metadata) -> None: +def validate_axis_orientation( + metadata: Metadata, version: object | None = None +) -> None: """Validate RFC 4 anatomical-orientation metadata on the spatial axes. This rule does not reimplement RFC 4; it wraps the package's existing logic @@ -766,6 +786,10 @@ def validate_axis_orientation(metadata: Metadata) -> None: no-op and the comparatively heavy ``jsonschema`` import inside :func:`validate_rfc4_orientation` is never triggered. + Inert when ``version`` declares a release below 0.9.dev1, where RFC-4 has + no normative status; ``None`` keeps the checks on (see + :func:`is_rfc4_orientation_enforced`). + Raises ------ ValidationError @@ -779,6 +803,8 @@ def validate_axis_orientation(metadata: Metadata) -> None: Propagated unchanged when an orientation value is outside the RFC 4 vocabulary -- a schema-level concern with no dedicated structural rule. """ + if not is_rfc4_orientation_enforced(version): + return from .rfc4_validation import ( has_any_rfc4_orientation, validate_rfc4_orientation, @@ -1050,7 +1076,10 @@ def validate_structural( version: The OME-Zarr version the metadata declares. Rules 1-3 are inert for the versions that adopt the RFC-3 axis model, so omitting it holds every - store to the v0.4 axis caps. + store to the v0.4 axis caps. The RFC 4 orientation checks + (:func:`validate_axis_orientation`) gate the opposite way: they are + normative from 0.9.dev1, inert when an earlier version is declared, + and kept on when the version is omitted. Raises ------ @@ -1063,7 +1092,8 @@ def validate_structural( ----- This orchestrator covers the image/multiscales rules, including the RFC 4 anatomical-orientation checks (:func:`validate_axis_orientation`, a no-op - when no axis declares orientation). The HCS plate/well structural rules + when no axis declares orientation and inert when ``version`` declares a + release below 0.9.dev1). The HCS plate/well structural rules operate on separate metadata objects and are dispatched by the companion :func:`validate_plate` and :func:`validate_well` entry points. """ @@ -1082,7 +1112,7 @@ def validate_structural( validate_transform_order(metadata) validate_dataset_order(metadata) validate_omero_color_hex(metadata) - validate_axis_orientation(metadata) + validate_axis_orientation(metadata, version) validate_zarr_format_for_version(metadata) validate_ome_namespace(metadata) diff --git a/py/ngff_zarr/v04/zarr_metadata.py b/py/ngff_zarr/v04/zarr_metadata.py index 22393d38..02dc5f4d 100644 --- a/py/ngff_zarr/v04/zarr_metadata.py +++ b/py/ngff_zarr/v04/zarr_metadata.py @@ -441,11 +441,7 @@ def _from_zarr_attrs( from .._zarrista_utils import open_lazy_array from ..ngff_image import NgffImage - from ..parse_metadata import _parse_omero, _raw_axes - from ..rfc4_validation import ( - has_any_rfc4_orientation, - validate_rfc4_orientation, - ) + from ..parse_metadata import _parse_omero from ..validate import validate as validate_ngff # Validate structure before any processing to avoid cryptic KeyError @@ -478,17 +474,6 @@ def _from_zarr_attrs( else: validate_ngff(root_attrs, version=schema_version) - # RFC 4 validation for anatomical orientation. The axes are read - # through the shared helper, which knows where each version keeps - # them, and a non-dict axis entry is left to the schema check. - axes_dicts = [ - axis - for axis in _raw_axes(root_attrs["multiscales"][0]) - if isinstance(axis, dict) - ] - if axes_dicts and has_any_rfc4_orientation(axes_dicts): - validate_rfc4_orientation(axes_dicts) - omero = _parse_omero(root_attrs.get("omero")) # OME-Zarr v0.5 hoists the spec ``version`` to the group-level ``ome`` # namespace; v0.4 carries it on each multiscale entry. Capture the @@ -619,11 +604,14 @@ def _from_zarr_attrs( if validate: # Strict structural validation of the parsed Metadata, layered after - # the schema pass and RFC 4 dict check above. The structural rules - # encode the v0.4+ metadata model (typed axes and per-dataset - # coordinate transformations); the legacy v0.1-0.3 layouts predate - # it, so the rules are scoped to v0.4 and newer. Imported lazily so - # the default validate=False read path incurs no extra import cost. + # the schema pass above. The structural rules encode the v0.4+ + # metadata model (typed axes and per-dataset coordinate + # transformations); the legacy v0.1-0.3 layouts predate it, so the + # rules are scoped to v0.4 and newer. Imported lazily so the + # default validate=False read path incurs no extra import cost. + # The declared version is passed through so the version-gated rules + # (the RFC-3 axis rules and the RFC 4 orientation checks) apply + # exactly the requirements of the store's own version. from ..structural_validation import ( ValidateOptions, ValidationLevel, @@ -633,7 +621,9 @@ def _from_zarr_attrs( spec_version = packaging.version.parse(str(metadata.version)) if spec_version >= packaging.version.parse("0.4"): validate_structural( - metadata, ValidateOptions(level=ValidationLevel.STRICT) + metadata, + ValidateOptions(level=ValidationLevel.STRICT), + version=metadata.version, ) return metadata, images diff --git a/py/ngff_zarr/v06/zarr_metadata.py b/py/ngff_zarr/v06/zarr_metadata.py index f62cf4b3..6d89600c 100644 --- a/py/ngff_zarr/v06/zarr_metadata.py +++ b/py/ngff_zarr/v06/zarr_metadata.py @@ -731,11 +731,7 @@ def _from_zarr_attrs( from .._zarrista_utils import open_lazy_array from ..ngff_image import NgffImage - from ..parse_metadata import _parse_omero, _raw_axes - from ..rfc4_validation import ( - has_any_rfc4_orientation, - validate_rfc4_orientation, - ) + from ..parse_metadata import _parse_omero from ..validate import validate as validate_ngff # make sure root_attrs['ome]['multiscales'] exists @@ -778,18 +774,6 @@ def _from_zarr_attrs( 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 - # the shared helper rather than from a flat ``axes`` key, which a - # v0.6 entry does not carry. - axes_dicts = [ - axis - for axis in _raw_axes(root_attrs["ome"]["multiscales"][0]) - if isinstance(axis, dict) - ] - if axes_dicts and has_any_rfc4_orientation(axes_dicts): - validate_rfc4_orientation(axes_dicts) - omero = _parse_omero(root_attrs.get("ome", {}).get("omero")) root_attrs = root_attrs["ome"]["multiscales"][0] diff --git a/py/test/test_rfc4_validation.py b/py/test/test_rfc4_validation.py index 2862f515..87395ceb 100644 --- a/py/test/test_rfc4_validation.py +++ b/py/test/test_rfc4_validation.py @@ -461,8 +461,15 @@ def test_from_ngff_zarr_with_rfc4_validation(tmp_path): assert multiscales is not None -def test_from_ngff_zarr_with_rfc4_validation_invalid(tmp_path): - """Test from_ngff_zarr with RFC 4 validation fails on invalid orientation.""" +def test_from_ngff_zarr_invalid_orientation_reads_below_rfc4(tmp_path): + """An invalid orientation reads back at 0.4 under validate=True. + + RFC 4 orientation is normative from OME-Zarr 0.9.dev1 only + (fideus-labs/ngff-zarr#667); this store declares 0.4, so its + out-of-vocabulary orientation value is read back without complaint. The + version gate lives in the read path alone: the module-level check rejects + the value at every version. + """ pytest.importorskip("jsonschema", reason="jsonschema required for RFC 4 validation") # Create a store with invalid RFC 4 orientation metadata @@ -475,31 +482,32 @@ def test_from_ngff_zarr_with_rfc4_validation_invalid(tmp_path): else: root.create_dataset("0", shape=(10, 10, 10), dtype="uint8") - # Add OME-NGFF metadata with incomplete RFC 4 orientation (missing z orientation) + axes = [ + { + "name": "z", + "type": "space", + "unit": "micrometer", + # Out-of-vocabulary value: rejected by validate_rfc4_orientation, + # tolerated by every pre-RFC-4 read. + "orientation": {"type": "anatomical", "value": "not-a-direction"}, + }, + { + "name": "y", + "type": "space", + "unit": "micrometer", + "orientation": {"type": "anatomical", "value": "anterior-to-posterior"}, + }, + { + "name": "x", + "type": "space", + "unit": "micrometer", + "orientation": {"type": "anatomical", "value": "right-to-left"}, + }, + ] multiscales_metadata = { "version": "0.4", "name": "test", - "axes": [ - { - "name": "x", - "type": "space", - "unit": "micrometer", - "orientation": {"type": "anatomical", "value": "right-to-left"}, - }, - { - "name": "y", - "type": "space", - "unit": "micrometer", - "orientation": {"type": "anatomical", "value": "anterior-to-posterior"}, - }, - { - "name": "z", - "type": "space", - "unit": "micrometer", - # Out-of-vocabulary value - this should cause validation to fail - "orientation": {"type": "anatomical", "value": "not-a-direction"}, - }, - ], + "axes": axes, "datasets": [ { "path": "0", @@ -512,9 +520,13 @@ def test_from_ngff_zarr_with_rfc4_validation_invalid(tmp_path): root.attrs["multiscales"] = [multiscales_metadata] - # Should fail on the out-of-vocabulary orientation value + # The module-level check rejects the out-of-vocabulary value... with pytest.raises(ValidationError, match="Invalid orientation value"): - from_ngff_zarr(store, validate=True) + validate_rfc4_orientation(axes) + + # ...and the 0.4 read path does not apply it: RFC 4 gates on 0.9.dev1. + multiscales = from_ngff_zarr(store, validate=True) + assert multiscales is not None def test_from_ngff_zarr_without_rfc4_validation(tmp_path): diff --git a/py/test/test_structural_validation_orientation.py b/py/test/test_structural_validation_orientation.py index 2815b0ea..9e4319ab 100644 --- a/py/test/test_structural_validation_orientation.py +++ b/py/test/test_structural_validation_orientation.py @@ -193,6 +193,33 @@ def test_axis_orientation_duplicate_anatomical_axis(): assert exc_info.value.location == "multiscales[0].axes" +def test_axis_orientation_version_gate(): + """The rule is inert when the caller declares a pre-RFC-4 version. + + RFC 4 orientation is normative from OME-Zarr 0.9.dev1 + (``ome/ngff-spec#190``); when the caller declares 0.4, 0.5 or 0.6 the same + metadata passes. With no version the rule stays on as a strictness choice, + which every no-version test above relies on. + """ + metadata = _metadata_with_axes( + [ + Axis(name="z", type="space", orientation=_orientation_dict(_LPS_VALUES[0])), + Axis( + name="y", + type="space", + orientation={"type": "other", "value": _LPS_VALUES[1]}, + ), + Axis(name="x", type="space", orientation=_orientation_dict(_LPS_VALUES[2])), + ] + ) + for version in ("0.4", "0.5", "0.6", "0.6.dev4"): + validate_axis_orientation(metadata, version=version) + for version in ("0.9.dev1", None): + with pytest.raises(ValidationError) as exc_info: + validate_axis_orientation(metadata, version=version) + assert exc_info.value.rule == SpecRule.AXIS_ORIENTATION_ANATOMICAL_TYPE, version + + def test_rfc4_orientation_messages_carry_mapping_markers(): """Pin the substrings :func:`validate_axis_orientation` maps onto rules. diff --git a/py/test/test_structural_validation_parity.py b/py/test/test_structural_validation_parity.py index 6e6b1841..69df19f2 100644 --- a/py/test/test_structural_validation_parity.py +++ b/py/test/test_structural_validation_parity.py @@ -84,6 +84,26 @@ if version.value not in CANONICAL_RFC3_VERSIONS ] + [None] +# The locked RFC-4 orientation version manifest: the versions at which the +# three axis-orientation rules are normative (ome/ngff-spec#190 folds RFC-4 +# into 0.9.dev1). This identical literal list appears in the Deno mirror test. +# The gate points the opposite way from CANONICAL_RFC3_VERSIONS: RFC-3 *lifts* +# the axis restrictions at 0.9.dev1 while RFC-4 *adds* the orientation +# requirements, so the rules are inert below these versions. No version at all +# keeps them on, as a strictness choice (like axis-names-unique). +CANONICAL_RFC4_VERSIONS = [ + "0.9.dev1", +] + +# Every other supported version must leave the orientation rules inert. Read +# off SUPPORTED_VERSIONS so a newly supported version has to be classified +# here rather than silently defaulting to either side. +PRE_RFC4_VERSIONS = [ + version.value + for version in SUPPORTED_VERSIONS + if version.value not in CANONICAL_RFC4_VERSIONS +] + # The canonical fail-fast evaluation order of the image/multiscales # orchestrator (validate_structural). Each entry is the SpecRule the # orchestrator must raise when that rule -- and every rule after it -- is @@ -423,6 +443,92 @@ def test_axis_names_unique_is_never_inert(): assert exc_info.value.rule == SpecRule.AXIS_NAMES_UNIQUE, version +# --------------------------------------------------------------------------- +# Manifest: the locked RFC-4 orientation version set +# --------------------------------------------------------------------------- + + +def _orientation_metadata(axes: list[Axis]) -> Metadata: + """Wrap ``axes`` in metadata that satisfies every non-orientation rule. + + The axis lists below are legal under both the restricted axis model and + RFC-3, so at every version the orientation rules alone decide the verdict. + """ + return Metadata( + axes=axes, + datasets=[ + Dataset( + path="0", + coordinateTransformations=[ + Scale([1.0] * len(axes)), + Translation([0.0] * len(axes)), + ], + ) + ], + coordinateTransformations=None, + ) + + +def _orientation_on_non_space_axes() -> list[Axis]: + """Orientation on the non-spatial time axis, and nowhere else. + + Only a stray orientation violates RFC 4 here, and it sits on the one axis + that may not carry it -- exercising the non-space arm the orchestrator + reaches even when no spatial axis is oriented. + """ + return [ + Axis( + name="t", + type="time", + orientation=_orientation("anatomical", "inferior-to-superior"), + ), + Axis(name="y", type="space"), + Axis(name="x", type="space"), + ] + + +def _duplicate_anatomical_axis_axes() -> list[Axis]: + """Two spatial axes on the one left-right anatomical axis.""" + return [ + Axis( + name="y", + type="space", + orientation=_orientation("anatomical", "left-to-right"), + ), + Axis( + name="x", + type="space", + orientation=_orientation("anatomical", "right-to-left"), + ), + ] + + +# One violating document per orientation rule. Each satisfies every other rule +# at every version, so the orientation rule alone decides accept or reject. +_RFC4_ORIENTATION_CASES = [ + ( + _valid_axes_with_inconsistent_orientation, + SpecRule.AXIS_ORIENTATION_ANATOMICAL_TYPE, + ), + (_orientation_on_non_space_axes, SpecRule.AXIS_ORIENTATION_ON_NON_SPACE), + (_duplicate_anatomical_axis_axes, SpecRule.AXIS_ORIENTATION_UNIQUE_AXIS), +] + + +def test_rfc4_orientation_version_manifest_is_locked(): + for build_axes, rule in _RFC4_ORIENTATION_CASES: + # Enforced at the manifest versions, and when no version is given... + for version in [*CANONICAL_RFC4_VERSIONS, None]: + with pytest.raises(ValidationError) as exc_info: + validate_structural( + _orientation_metadata(build_axes()), version=version + ) + assert exc_info.value.rule == rule, (rule, version) + # ...and inert at every earlier supported version. + for version in PRE_RFC4_VERSIONS: + validate_structural(_orientation_metadata(build_axes()), version=version) + + def _commented_rule_ids() -> list[str]: """The rule ids listed in the canonical table at the top of the module. diff --git a/py/test/test_structural_validation_reader.py b/py/test/test_structural_validation_reader.py index b51139b2..e7e08fba 100644 --- a/py/test/test_structural_validation_reader.py +++ b/py/test/test_structural_validation_reader.py @@ -187,6 +187,43 @@ def test_v05_namespacing_validated_when_ome_version_missing(tmp_path): assert exc_info.value.rule == SpecRule.OME_NAMESPACE +def _add_out_of_vocabulary_orientation(entry: dict) -> None: + """Give the entry's first (spatial) axis an orientation RFC 4 does not define.""" + entry["axes"][0]["orientation"] = { + "type": "anatomical", + "value": "up-to-down", + } + + +def test_v04_pre_rfc4_orientation_reads_cleanly_when_validating(tmp_path): + # RFC 4 orientation is normative from OME-Zarr 0.9.dev1 only; a 0.4 store + # whose orientation value is outside the RFC 4 vocabulary must read cleanly + # under validate=True. + store = _write_valid_2d_store(tmp_path / "valid.zarr") + root = zarr.open_group(str(store), mode="r+") + attrs = root.attrs.asdict() + _add_out_of_vocabulary_orientation(attrs["multiscales"][0]) + root.attrs["multiscales"] = attrs["multiscales"] + + multiscales = from_ngff_zarr(store, validate=True) + assert multiscales is not None + + +@requires_zarr_v3 +def test_v05_pre_rfc4_orientation_reads_cleanly_when_validating(tmp_path): + # The v0.5 read path shares the v0.4 parser and its RFC 4 hook; the same + # out-of-vocabulary orientation must read cleanly at 0.5 too. + store = _write_valid_2d_store_v05(tmp_path / "valid_v05.zarr") + root = zarr.open_group(str(store), mode="r+") + attrs = root.attrs.asdict() + ome = attrs["ome"] + _add_out_of_vocabulary_orientation(ome["multiscales"][0]) + root.attrs["ome"] = ome + + multiscales = from_ngff_zarr(store, validate=True) + assert multiscales is not None + + def test_scale_length_violation_raises_only_when_validating(tmp_path): store = _write_valid_2d_store(tmp_path / "valid.zarr") # Corrupt one dataset's scale to length 3 against the 2-axis (y, x) image. diff --git a/ts/src/types/supported_versions.ts b/ts/src/types/supported_versions.ts index 56d6d067..cbd2cb27 100644 --- a/ts/src/types/supported_versions.ts +++ b/ts/src/types/supported_versions.ts @@ -79,3 +79,22 @@ export function isV06Version(version: string): boolean { export function isRfc3AxisModelAllowed(version?: string): boolean { return version !== undefined && version === NgffVersion.V09dev1; } + +/** + * Whether a version makes the RFC-4 orientation rules normative. + * + * Only `0.9.dev1` does (ome/ngff-spec#190 folds RFC-4 into it): the released + * 0.4, 0.5 and 0.6 specs give `orientation` no normative status, so when the + * caller declares one of those versions the orientation rules are inert. + * `undefined` keeps them on: with no declared version, enforcement is a + * strictness choice, exactly like `axis-names-unique` below 0.9.dev1 (see + * docs/validation/rule-reference.md). + * + * The gate points the opposite way from {@link isRfc3AxisModelAllowed}: at + * 0.9.dev1 RFC-3 *lifts* the axis restrictions while RFC-4 *adds* the + * orientation requirements, so the rules exit early below 0.9.dev1 rather + * than at it. + */ +export function isRfc4OrientationEnforced(version?: string): boolean { + return version === undefined || version === NgffVersion.V09dev1; +} diff --git a/ts/src/utils/from_zarr_attrs.ts b/ts/src/utils/from_zarr_attrs.ts index ed7f4574..ce1c1741 100644 --- a/ts/src/utils/from_zarr_attrs.ts +++ b/ts/src/utils/from_zarr_attrs.ts @@ -24,10 +24,6 @@ import { NgffImage } from "../types/ngff_image.ts"; import type { AxesType, AxisUnit, SupportedDims } from "../types/units.ts"; import { parseOmero } from "./parse_metadata.ts"; import type { MemoryStore } from "../io/from_ngff_zarr.ts"; -import { - hasRfc4OrientationMetadata, - validateRfc4Orientation, -} from "./rfc4_validation.ts"; import { validateStructural, ValidationLevel, @@ -165,6 +161,15 @@ export async function fromZarrAttrsV04( const multiscalesMetadata = multiscalesArray[0] as Record; + // OME-Zarr v0.5 hoists the spec `version` to the group-level `ome` + // namespace; v0.4 carries it on each multiscale entry. Prefer the + // group-level value (which fromZarrAttrsV05 forwards as a top-level + // `version`) so validation sees the true spec version -- which the v0.5 + // namespacing rules and the version-gated structural rules read. v0.4 has no + // top-level version, so this falls back to the entry's. + const declaredVersion = (rootAttrs.version as string | undefined) ?? + (multiscalesMetadata.version as string | undefined) ?? "0.4"; + // Validate the root attributes against the OME-Zarr v0.4 schema if (validate) { // Basic structural validation @@ -187,24 +192,6 @@ export async function fromZarrAttrsV04( "Invalid OME-Zarr metadata: 'datasets' must be a non-empty array", ); } - - // RFC 4 validation for anatomical orientation - if ( - "axes" in multiscalesMetadata && - Array.isArray(multiscalesMetadata.axes) - ) { - const axesData = multiscalesMetadata.axes as Array< - Record - >; - // Filter to only dict-style axes for RFC4 validation - const axesDicts = axesData.filter( - (axis): axis is Record => - typeof axis === "object" && axis !== null, - ); - if (axesDicts.length > 0 && hasRfc4OrientationMetadata(axesDicts)) { - validateRfc4Orientation(axesDicts); - } - } } // Parse OMERO metadata @@ -442,14 +429,9 @@ export async function fromZarrAttrsV04( axes, datasets, name: (multiscalesMetadata.name as string) ?? "image", - // OME-Zarr v0.5 hoists the spec `version` to the group-level `ome` - // namespace; v0.4 carries it on each multiscale entry. Prefer the - // group-level value (which fromZarrAttrsV05 forwards as a top-level - // `version`) so the structural pass sees the true spec version -- which the - // v0.5 namespacing rules gate on. v0.4 has no top-level version, so this - // falls back to the entry's. - version: (rootAttrs.version as string | undefined) ?? - (multiscalesMetadata.version as string) ?? "0.4", + // The declared spec version, resolved above so the schema pass and the + // structural pass read the same value. + version: declaredVersion, omero, extra, coordinateTransformations: @@ -648,17 +630,6 @@ export async function fromZarrAttrsV06( } const axesOrientations = extractOrientationsFromAxes(intrinsicRawAxes); - // RFC 4 anatomical-orientation validation, mirroring the v0.4 reader. - if (validate) { - const axesDicts = intrinsicRawAxes.filter( - (axis): axis is Record => - typeof axis === "object" && axis !== null, - ); - if (axesDicts.length > 0 && hasRfc4OrientationMetadata(axesDicts)) { - validateRfc4Orientation(axesDicts); - } - } - // Open root group for array access (reuse consolidated metadata if present). let optimizedStore: MemoryStore | zarr.FetchStore | zarr.Readable; try { diff --git a/ts/src/utils/structural_validation.ts b/ts/src/utils/structural_validation.ts index c605fd9b..426148eb 100644 --- a/ts/src/utils/structural_validation.ts +++ b/ts/src/utils/structural_validation.ts @@ -40,6 +40,7 @@ import { import { formatNameList, pyRepr } from "./py_format.ts"; import { isRfc3AxisModelAllowed, + isRfc4OrientationEnforced, isV06Version, } from "../types/supported_versions.ts"; @@ -704,7 +705,12 @@ function axisToValidationRecord(axis: Axis): Record { * Orientation is optional in RFC 4, so when no spatial axis carries it the rule * is a no-op. * + * Inert when `version` declares a release below 0.9.dev1, where RFC-4 has no + * normative status; `undefined` keeps the checks on (see + * {@link isRfc4OrientationEnforced}). + * * @param metadata - The parsed multiscales metadata to validate. + * @param version - The OME-Zarr version the metadata declares. * @throws {ValidationError} With {@link SpecRule.AxisOrientationAnatomicalType} * when an orientation `type` is not `"anatomical"`, * {@link SpecRule.AxisOrientationOnNonSpace} when @@ -716,7 +722,13 @@ function axisToValidationRecord(axis: Axis): Record { * RFC 4 vocabulary, a schema-level concern with no dedicated structural rule * -- propagates unchanged. */ -export function validateAxisOrientation(metadata: Metadata): void { +export function validateAxisOrientation( + metadata: Metadata, + version?: string, +): void { + if (!isRfc4OrientationEnforced(version)) { + return; + } const axesRecords = metadata.axes.map(axisToValidationRecord); // A stray orientation on a non-spatial axis carries no spatial-axis // orientation, so hasRfc4OrientationMetadata alone would skip it; check for any @@ -985,7 +997,8 @@ export function validateWellAcquisition( * * This orchestrator covers the image/multiscales rules, including the RFC 4 * anatomical-orientation checks ({@link validateAxisOrientation}, a no-op when - * no axis declares orientation). The HCS plate/well structural rules operate on + * no axis declares orientation and inert when `version` declares a release + * below 0.9.dev1). The HCS plate/well structural rules operate on * separate metadata objects and are dispatched by the companion * {@link validatePlate} and {@link validateWell} entry points. * @@ -994,7 +1007,10 @@ export function validateWellAcquisition( * `{ level: "strict", allowUnknownFields: true }`. * @param version - The OME-Zarr version the metadata declares. Rules 1-3 are * inert for the versions that adopt the RFC-3 axis model, so omitting it holds - * every store to the v0.4 axis caps. + * every store to the v0.4 axis caps. The RFC 4 orientation checks + * ({@link validateAxisOrientation}) gate the opposite way: they are normative + * from 0.9.dev1, inert when an earlier version is declared, and kept on when + * the version is omitted. * @throws {ValidationError} For the first structural rule violated, carrying * the offending {@link SpecRule} and `location`. Never thrown under * {@link ValidationLevel.SchemaOnly}, which runs no structural rule. @@ -1021,7 +1037,7 @@ export function validateStructural( validateTransformOrder(metadata); validateDatasetOrder(metadata); validateOmeroColorHex(metadata); - validateAxisOrientation(metadata); + validateAxisOrientation(metadata, version); validateZarrFormatForVersion(metadata); validateOmeNamespace(metadata); } diff --git a/ts/test/rfc4_validation_test.ts b/ts/test/rfc4_validation_test.ts index ccb955e2..34abde69 100644 --- a/ts/test/rfc4_validation_test.ts +++ b/ts/test/rfc4_validation_test.ts @@ -339,31 +339,39 @@ Deno.test("fromNgffZarr with valid RFC-4 orientation - validation passes", async assertEquals(multiscales !== undefined, true); }); -Deno.test("fromNgffZarr with invalid RFC-4 orientation - throws error", async () => { +Deno.test("fromNgffZarr - an invalid orientation reads below the RFC-4 versions", async () => { + // RFC 4 orientation is normative from OME-Zarr 0.9.dev1 only + // (fideus-labs/ngff-zarr#667); this store declares 0.4, so its + // out-of-vocabulary orientation value is read back without complaint. The + // version gate lives in the read path alone: the module-level check rejects + // the value at every version. Mirrors the Python + // test_from_ngff_zarr_invalid_orientation_reads_below_rfc4. + const axes = [ + { + name: "z", + type: "space", + unit: "micrometer", + // Out-of-vocabulary value: rejected by validateRfc4Orientation, + // tolerated by every pre-RFC-4 read. + orientation: { type: "anatomical", value: "not-a-direction" }, + }, + { + name: "y", + type: "space", + unit: "micrometer", + orientation: { type: "anatomical", value: "anterior-to-posterior" }, + }, + { + name: "x", + type: "space", + unit: "micrometer", + orientation: { type: "anatomical", value: "right-to-left" }, + }, + ]; const multiscalesMetadata = { version: "0.4", name: "test", - axes: [ - { - name: "x", - type: "space", - unit: "micrometer", - orientation: { type: "anatomical", value: "right-to-left" }, - }, - { - name: "y", - type: "space", - unit: "micrometer", - orientation: { type: "anatomical", value: "anterior-to-posterior" }, - }, - { - name: "z", - type: "space", - unit: "micrometer", - // Out-of-vocabulary value - this should cause validation to fail - orientation: { type: "anatomical", value: "not-a-direction" }, - }, - ], + axes, datasets: [ { path: "0", @@ -376,18 +384,13 @@ Deno.test("fromNgffZarr with invalid RFC-4 orientation - throws error", async () const store = await createTestStore(multiscalesMetadata); - // Should fail on the out-of-vocabulary orientation value - let errorThrown = false; - try { - await fromNgffZarr(store, { validate: true }); - } catch (error) { - errorThrown = true; - assertEquals( - (error as Error).message.includes("Invalid orientation value"), - true, - ); - } - assertEquals(errorThrown, true); + // The module-level check rejects the out-of-vocabulary value... + const error = assertThrows(() => validateRfc4Orientation(axes), Error); + assertEquals(error.message.includes("Invalid orientation value"), true); + + // ...and the 0.4 read path does not apply it: RFC 4 gates on 0.9.dev1. + const multiscales = await fromNgffZarr(store, { validate: true }); + assertEquals(multiscales !== undefined, true); }); Deno.test("fromNgffZarr without validation - loads invalid data", async () => { diff --git a/ts/test/structural_validation_orientation_test.ts b/ts/test/structural_validation_orientation_test.ts index e592c3f7..484d2823 100644 --- a/ts/test/structural_validation_orientation_test.ts +++ b/ts/test/structural_validation_orientation_test.ts @@ -168,6 +168,29 @@ Deno.test("validateAxisOrientation - accepts partial orientation", () => { validateAxisOrientation(metadata); }); +Deno.test("validateAxisOrientation - inert below the RFC-4 versions", () => { + // RFC 4 orientation is normative from OME-Zarr 0.9.dev1 (ome/ngff-spec#190); + // when the caller declares 0.4, 0.5 or 0.6 the same metadata passes. With no + // version the rule stays on as a strictness choice, which every no-version + // test above relies on. Mirrors the Python + // test_axis_orientation_version_gate. + const metadata = metadataWithOrientations([ + { type: "anatomical", value: LPS_VALUES[0] }, + { type: "other", value: LPS_VALUES[1] }, + { type: "anatomical", value: LPS_VALUES[2] }, + ]); + for (const version of ["0.4", "0.5", "0.6", "0.6.dev4"]) { + validateAxisOrientation(metadata, version); + } + for (const version of ["0.9.dev1", undefined]) { + assertRuleViolation( + () => validateAxisOrientation(metadata, version), + SpecRule.AxisOrientationAnatomicalType, + "multiscales[0].axes", + ); + } +}); + Deno.test( "validateRfc4Orientation - messages carry the SpecRule mapping markers", () => { diff --git a/ts/test/structural_validation_parity_test.ts b/ts/test/structural_validation_parity_test.ts index f7270b14..fa244502 100644 --- a/ts/test/structural_validation_parity_test.ts +++ b/ts/test/structural_validation_parity_test.ts @@ -93,6 +93,24 @@ const NON_RFC3_VERSIONS: (string | undefined)[] = [ undefined, ]; +// The locked RFC-4 orientation version manifest: the versions at which the +// three axis-orientation rules are normative (ome/ngff-spec#190 folds RFC-4 +// into 0.9.dev1). This identical literal list appears in the Python twin. +// The gate points the opposite way from CANONICAL_RFC3_VERSIONS: RFC-3 *lifts* +// the axis restrictions at 0.9.dev1 while RFC-4 *adds* the orientation +// requirements, so the rules are inert below these versions. No version at all +// keeps them on, as a strictness choice (like axis-names-unique). +const CANONICAL_RFC4_VERSIONS: string[] = [ + "0.9.dev1", +]; + +// Every other supported version must leave the orientation rules inert. Read +// off SUPPORTED_VERSIONS so a newly supported version has to be classified +// here rather than silently defaulting to either side. +const PRE_RFC4_VERSIONS: string[] = SUPPORTED_VERSIONS + .map((version) => version as string) + .filter((version) => !CANONICAL_RFC4_VERSIONS.includes(version)); + // The canonical fail-fast evaluation order of the image/multiscales // orchestrator (validateStructural). Each entry is the SpecRule the // orchestrator must raise when that rule -- and every rule after it -- is @@ -480,3 +498,103 @@ Deno.test("axis-names-unique is never inert", () => { assertEquals(error.rule, SpecRule.AxisNamesUnique, String(version)); } }); + +// --------------------------------------------------------------------------- +// Manifest: the locked RFC-4 orientation version set +// --------------------------------------------------------------------------- + +/** + * Wrap `axes` in metadata that satisfies every non-orientation rule. + * + * The axis lists below are legal under both the restricted axis model and + * RFC-3, so at every version the orientation rules alone decide the verdict. + */ +function orientationMetadata(axes: Axis[]): Metadata { + return { + axes, + datasets: [ + { + path: "0", + coordinateTransformations: [ + createScale(axes.map(() => 1.0)), + createTranslation(axes.map(() => 0.0)), + ], + }, + ], + coordinateTransformations: undefined, + omero: undefined, + name: "image", + version: "0.4", + }; +} + +/** + * Orientation on the non-spatial time axis, and nowhere else. + * + * Only a stray orientation violates RFC 4 here, and it sits on the one axis + * that may not carry it -- exercising the non-space arm the orchestrator + * reaches even when no spatial axis is oriented. + */ +function orientationOnNonSpaceAxes(): Axis[] { + return [ + { + name: "t", + type: "time", + unit: undefined, + orientation: orientation("anatomical", "inferior-to-superior"), + }, + { name: "y", type: "space", unit: undefined }, + { name: "x", type: "space", unit: undefined }, + ]; +} + +/** Two spatial axes on the one left-right anatomical axis. */ +function duplicateAnatomicalAxisAxes(): Axis[] { + return [ + { + name: "y", + type: "space", + unit: undefined, + orientation: orientation("anatomical", "left-to-right"), + }, + { + name: "x", + type: "space", + unit: undefined, + orientation: orientation("anatomical", "right-to-left"), + }, + ]; +} + +// One violating document per orientation rule. Each satisfies every other rule +// at every version, so the orientation rule alone decides accept or reject. +const RFC4_ORIENTATION_CASES: Array<[() => Axis[], SpecRule]> = [ + [ + validAxesWithInconsistentOrientation, + SpecRule.AxisOrientationAnatomicalType, + ], + [orientationOnNonSpaceAxes, SpecRule.AxisOrientationOnNonSpace], + [duplicateAnatomicalAxisAxes, SpecRule.AxisOrientationUniqueAxis], +]; + +Deno.test("RFC-4 orientation version manifest is locked", () => { + for (const [buildAxes, rule] of RFC4_ORIENTATION_CASES) { + // Enforced at the manifest versions, and when no version is given... + for (const version of [...CANONICAL_RFC4_VERSIONS, undefined]) { + const error = assertThrows( + () => + validateStructural( + orientationMetadata(buildAxes()), + undefined, + version, + ), + ValidationError, + ); + assertEquals(error.rule, rule, String(version)); + } + // ...and inert at every earlier supported version. + for (const version of PRE_RFC4_VERSIONS) { + validateStructural(orientationMetadata(buildAxes()), undefined, version); + } + } +}); diff --git a/ts/test/structural_validation_reader_test.ts b/ts/test/structural_validation_reader_test.ts index dc0c5dfd..af911dfa 100644 --- a/ts/test/structural_validation_reader_test.ts +++ b/ts/test/structural_validation_reader_test.ts @@ -341,7 +341,7 @@ async function createOmeNamespacedStore( /** A single-level `ome` block over `axes`, tagged with the given version. */ function omeBlock( version: string, - axes: Array<{ name: string; type: string }>, + axes: Array>, ): Record { const rank = axes.length; return { @@ -427,3 +427,70 @@ Deno.test( assertEquals(result.images[0].dims, ["i", "j"]); }, ); + +// --- The declared version drives the RFC 4 orientation checks on read --- + +/** `(t, y, x)` axes with an orientation on the non-spatial time axis. */ +const NON_SPACE_ORIENTED_AXES: Array> = [ + { + name: "t", + type: "time", + orientation: { type: "anatomical", value: "inferior-to-superior" }, + }, + { name: "y", type: "space" }, + { name: "x", type: "space" }, +]; + +Deno.test( + "reader - a 0.6 store with orientation on a non-space axis reads under validate", + async () => { + // RFC 4 orientation is normative from OME-Zarr 0.9.dev1 only; at 0.6 a + // declared orientation is read back but not validated. + const store = await createOmeNamespacedStore( + omeBlock("0.6.dev4", NON_SPACE_ORIENTED_AXES), + [2, 2, 2], + ); + + const result = await fromOmeZarr(store, { validate: true }); + assertEquals(result.images[0].dims, ["t", "y", "x"]); + }, +); + +Deno.test( + "reader - the same store tagged 0.9.dev1 is refused", + async () => { + // The negative control: at 0.9.dev1 the orientation rules are normative, + // so the same document is refused with the on-non-space rule. + const store = await createOmeNamespacedStore( + omeBlock("0.9.dev1", NON_SPACE_ORIENTED_AXES), + [2, 2, 2], + ); + + const error = await assertRejects( + () => fromOmeZarr(store, { validate: true }), + Error, + ); + assertStringIncludes(error.message, SpecRule.AxisOrientationOnNonSpace); + }, +); + +Deno.test( + "fromZarrAttrsV04 - an out-of-vocabulary orientation reads under validate", + async () => { + // The v0.4 reader hook takes the same gate: an orientation value outside + // the RFC 4 vocabulary reads cleanly at 0.4. + const metadata = buildValidImageMetadata(); + (metadata.axes as Array>)[0].orientation = { + type: "anatomical", + value: "up-to-down", + }; + const store = await createImageStore(metadata); + + const result = await fromZarrAttrsV04( + { multiscales: [metadata] }, + store, + true, + ); + assertExists(result.metadata); + }, +); From 63b6311f95c94ece8d05978bcca9ac9fb3fc334f Mon Sep 17 00:00:00 2001 From: Valentin Boussot Date: Mon, 24 Aug 2026 19:15:49 +0200 Subject: [PATCH 03/10] docs: describe the RFC-4 orientation version gate The three axis-orientation rules are normative from OME-Zarr 0.9.dev1 and inert when the caller declares an earlier version; with no version they stay on as a strictness choice, like axis-names-unique. Said in the rule-reference table rows and intro, the overview's scope paragraph, and the parity contract, which grows a sixth dimension and the CANONICAL_RFC4_VERSIONS manifest. Refs #667 --- docs/validation/overview.md | 3 +++ docs/validation/parity.md | 19 ++++++++++++++++++- docs/validation/rule-reference.md | 30 ++++++++++++++++++------------ 3 files changed, 39 insertions(+), 13 deletions(-) diff --git a/docs/validation/overview.md b/docs/validation/overview.md index 83a9363e..83c67633 100644 --- a/docs/validation/overview.md +++ b/docs/validation/overview.md @@ -26,6 +26,9 @@ finest-to-coarsest dataset ordering, OMERO channel color format, RFC 4 anatomical orientation, and HCS plate/well consistency. Most rules enforce v0.4 MUSTs; two additional rules (`zarr-format`, `ome-namespace`) enforce the v0.5 `ome`-namespace and Zarr v3 store conventions and fire only for v0.5 metadata. +The three RFC 4 orientation rules are normative from OME-Zarr 0.9.dev1: they +are inert when the caller declares an earlier version and stay on, as a +strictness choice, when no version is declared (see [[rule-reference]]). The rules operate on the already-parsed metadata object (the `Metadata`, `Plate`, and `Well` dataclasses in Python; their equivalents in TypeScript), so diff --git a/docs/validation/parity.md b/docs/validation/parity.md index a7bb1f27..f53dda30 100644 --- a/docs/validation/parity.md +++ b/docs/validation/parity.md @@ -26,7 +26,7 @@ for usage, see [[api]]. ## The contract -Both ports must agree on five observable dimensions: +Both ports must agree on six observable dimensions: 1. **Rule identifiers** — the same `SpecRule` string values, in the same canonical declaration/iteration order. @@ -42,6 +42,10 @@ Both ports must agree on five observable dimensions: inert for the versions that adopt the RFC-3 free-form axis model. Both ports must treat exactly the same version strings as RFC-3, or the same metadata validates in one language and not the other. +6. **The RFC-4 orientation version set** — the three orientation rules gate the + opposite way: they are normative at exactly the versions that adopt RFC-4 + (and when no version is given), and inert below. Both ports must treat the + same version strings as RFC-4 for the same reason. Because both test suites assert these facts against the **same literal identifier list**, adding, removing, renaming, or reordering a rule — or @@ -85,6 +89,15 @@ at every other. Rule 4, `axis-names-unique`, is never inert: RFC-3 *adds* it, and ngff-zarr applies it at all versions as a strictness choice (see [[rule-reference]]). +The versions at which the RFC-4 orientation rules are normative are pinned the +same way, as a `CANONICAL_RFC4_VERSIONS` literal: + +1. `0.9.dev1` + +Rules 9–11 gate the opposite way from the RFC-3 set: they are enforced at +those versions and when no version is given, and inert at every earlier +version, where RFC-4 has no normative status (see [[rule-reference]]). + ## The parity tests | Language | Test file | @@ -116,6 +129,10 @@ Each suite independently locks: `CANONICAL_RFC3_VERSIONS` and enforced at every other supported version and when no version is given, asserted through the public orchestrator rather than through the internal predicate. +- **RFC-4 orientation version set** — the orientation rules are enforced at + exactly the versions in `CANONICAL_RFC4_VERSIONS` (and when no version is + given) and inert at every other supported version, asserted the same way, + once per orientation rule. Four of the fifteen rules never appear in `EXPECTED_EVALUATION_ORDER`, each for its own reason. The two v0.5 namespacing rules (`zarr-format`, `ome-namespace`) diff --git a/docs/validation/rule-reference.md b/docs/validation/rule-reference.md index 2aea8ae1..41efc435 100644 --- a/docs/validation/rule-reference.md +++ b/docs/validation/rule-reference.md @@ -23,8 +23,12 @@ stable, lower-kebab-case identifier (the `SpecRule` value) that is identical across the Python and TypeScript ports — see [[parity]] for the guarantee. Most rules enforce OME-Zarr v0.4 MUSTs; the two v0.5 namespacing rules (`zarr-format`, `ome-namespace`) fire only for v0.5 metadata and are inert (a -no-op) for v0.4. For the conceptual background and the two validation levels, -see [[overview]]; for invocation, see [[api]]. +no-op) for v0.4. The three RFC 4 orientation rules (9–11) are normative from +OME-Zarr 0.9.dev1, the version `ome/ngff-spec#190` folds RFC-4 into: they are +inert when the caller declares 0.4, 0.5 or 0.6, where RFC 4 has no normative +status, and stay on when no version is declared — a strictness choice, like +`axis-names-unique` below 0.9.dev1. For the conceptual background and the two +validation levels, see [[overview]]; for invocation, see [[api]]. Location strings are dotted-segment, JSON-Pointer-style identifiers of the offending metadata node, emitted byte-for-byte identically in both languages. @@ -44,9 +48,9 @@ the `SpecRule` enum declares them and the orchestrators evaluate them. | 6 | `global-coord-transform-after-per-level` | images/multiscales | Exactly one `scale` per dataset, and a `translation` must follow — not precede — its `scale`. | v0.4: each dataset defines exactly one scale; a translation follows its scale. | `multiscales[0].datasets[1].coordinateTransformations` | | 7 | `dataset-order-highest-to-lowest` | images/multiscales | Datasets ordered finest → coarsest; the spatial scale must not decrease as the level index rises. | v0.4: multiscale datasets ordered from highest to lowest resolution. | `multiscales[0].datasets[2]` | | 8 | `omero-channel-color-format` | OMERO | Each OMERO channel `color` is exactly six hexadecimal digits (RGB). | v0.4: OMERO channel color is 6 hex digits. | `multiscales[0].omero.channels[0].color` | -| 9 | `axis-orientation-anatomical-type` | RFC 4 orientation | Every declared spatial-axis `orientation` has `type` `anatomical`. | RFC 4: an orientation's `type` is `anatomical`. | `multiscales[0].axes` | -| 10| `axis-orientation-on-non-space` | RFC 4 orientation | An `orientation` is declared only on `space` axes, never on a non-spatial axis. | RFC 4: orientation applies to spatial axes only. | `multiscales[0].axes[0]` | -| 11| `axis-orientation-unique-axis` | RFC 4 orientation | No two spatial axes declare orientations describing the same anatomical axis. | RFC 4: each spatial axis describes a distinct anatomical axis. | `multiscales[0].axes` | +| 9 | `axis-orientation-anatomical-type` | RFC 4 orientation | Every declared spatial-axis `orientation` has `type` `anatomical`. Inert below 0.9.dev1 when the caller declares a version; kept on when none is given. | RFC 4 (normative from 0.9.dev1): an orientation's `type` is `anatomical`. No released spec below 0.9.dev1 adopts RFC 4, so enforcement without a declared version is a strictness choice, not a spec MUST of those versions. | `multiscales[0].axes` | +| 10| `axis-orientation-on-non-space` | RFC 4 orientation | An `orientation` is declared only on `space` axes, never on a non-spatial axis. Inert below 0.9.dev1 when the caller declares a version; kept on when none is given. | RFC 4 (normative from 0.9.dev1): orientation applies to spatial axes only. Same below-0.9.dev1 status as rule 9. | `multiscales[0].axes[0]` | +| 11| `axis-orientation-unique-axis` | RFC 4 orientation | No two spatial axes declare orientations describing the same anatomical axis. Inert below 0.9.dev1 when the caller declares a version; kept on when none is given. | RFC 4 (normative from 0.9.dev1): each spatial axis describes a distinct anatomical axis. Same below-0.9.dev1 status as rule 9. | `multiscales[0].axes` | | 12| `zarr-format` | images/multiscales (v0.5) | A v0.5 entry implies a Zarr v3 store; a `zarr_format` value that leaked into the entry must be exactly `3`. Inert for v0.4. | v0.5: metadata is backed by a Zarr v3 store (`zarr_format == 3`). | `multiscales[0]` | | 13| `ome-namespace` | images/multiscales (v0.5) | A v0.5 entry must not retain a group-level `ome` or `multiscales` wrapper key — the `ome` namespace wraps the group attributes, not each entry. Inert for v0.4. | v0.5: multiscales live under the top-level `ome` namespace, with `version` hoisted to `ome.version`. | `multiscales[0]` | | 14| `plate-row-index-consistency` | HCS plate | Each well's `path` is `/`, naming declared row/column entries, with `rowIndex`/`columnIndex` equal to those entries' positions. | v0.4: well `rowIndex`/`columnIndex` match the named row/column positions in `plate.rows`/`plate.columns`. | `plate.wells[3]` | @@ -65,11 +69,12 @@ rules: class-ordering then spatial-name ordering for `axis-order`; per-dataset scale-count then transform-ordering for `global-coord-transform-after-per-level`. The v0.5 namespacing rules (12 and - 13) run last and are inert for v0.4 input. The orientation rules - `axis-orientation-on-non-space` (10) and `axis-orientation-unique-axis` (11) - fire only for specific axis shapes, so the linear fail-fast cascade for a v0.4 - metadata is an 11-step sequence ending at - `axis-orientation-anatomical-type`. + 13) run last and are inert for v0.4 input, and the orientation rules (9–11) + are inert when the caller declares a version below 0.9.dev1. The orientation + rules `axis-orientation-on-non-space` (10) and `axis-orientation-unique-axis` + (11) fire only for specific axis shapes, so the linear fail-fast cascade for + a v0.4-shaped metadata validated with no declared version is an 11-step + sequence ending at `axis-orientation-anatomical-type`. - **`validate_plate` / `validatePlate`** evaluates rule **14** (`plate-row-index-consistency`). - **`validate_well` / `validateWell`** evaluates rule **15** @@ -96,8 +101,9 @@ TypeScript by checking the target version in `axisViews`. `global-coord-transform-after-per-level`, `dataset-order-highest-to-lowest`. - **OMERO** — `omero-channel-color-format`. -- **RFC 4 orientation** — `axis-orientation-anatomical-type`, - `axis-orientation-on-non-space`, `axis-orientation-unique-axis`. +- **RFC 4 orientation** (normative from 0.9.dev1) — + `axis-orientation-anatomical-type`, `axis-orientation-on-non-space`, + `axis-orientation-unique-axis`. - **v0.5 namespacing** — `zarr-format`, `ome-namespace` (inert for v0.4). - **HCS plate / well** — `plate-row-index-consistency`, `well-acquisition-missing`. From 90edfcb1399a809a309d074f777a8ef841562905 Mon Sep 17 00:00:00 2001 From: Valentin Boussot Date: Tue, 25 Aug 2026 00:15:00 +0200 Subject: [PATCH 04/10] test(py): read a 0.4 store with a non-space orientation under the version gate The reader test main added with #661 expected the non-space orientation rule to fire on a 0.4 store. RFC 4 gates on 0.9.dev1 here, so the 0.4 read path does not apply it and the document reads cleanly, as the neighbouring tests already state. The rule itself stays covered at the rule level. --- py/test/test_rfc4_validation.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/py/test/test_rfc4_validation.py b/py/test/test_rfc4_validation.py index 87395ceb..c6945fd1 100644 --- a/py/test/test_rfc4_validation.py +++ b/py/test/test_rfc4_validation.py @@ -643,11 +643,12 @@ def test_a_falsey_orientation_still_reaches_the_validator(orientation): validate_rfc4_orientation(axes) -def test_read_rejects_orientation_on_a_non_space_axis(tmp_path): - """The reader reaches the non-space rule with no spatial axis oriented. +def test_read_accepts_orientation_on_a_non_space_axis_below_0_9(tmp_path): + """A 0.4 store with an orientation on its time axis reads cleanly. - Gating on a spatial orientation left this document accepted: nothing in it - orients a space axis, which is exactly what makes it invalid. + The non-space rule is reachable with no spatial axis oriented (see + ``test_orientation_on_a_non_space_axis_is_reachable``), but RFC 4 gates on + 0.9.dev1, so the 0.4 read path does not apply it to this document. """ pytest.importorskip("jsonschema", reason="jsonschema required for RFC 4 validation") @@ -685,5 +686,5 @@ def test_read_rejects_orientation_on_a_non_space_axis(tmp_path): } ] - with pytest.raises(ValueError, match="non-space axes"): - from_ngff_zarr(store, validate=True) + multiscales = from_ngff_zarr(store, validate=True) + assert [axis.name for axis in multiscales.metadata.axes] == ["t", "y", "x"] From fe990eb2ed1acea4f84d19e7ae6e11d5f42537dd Mon Sep 17 00:00:00 2001 From: Valentin Boussot Date: Tue, 25 Aug 2026 20:34:22 +0200 Subject: [PATCH 05/10] docs: correct the sentence citing the RFC-4 reference "OME-Zarr 0.9.dev1, the version `ome/ngff-spec#190` folds RFC-4 into" reads the issue number as a version and leaves the verb without an object. The version is 0.9.dev1; ome/ngff-spec#190 is what incorporates RFC-4 into it. --- docs/validation/rule-reference.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/validation/rule-reference.md b/docs/validation/rule-reference.md index 41efc435..ad2f808b 100644 --- a/docs/validation/rule-reference.md +++ b/docs/validation/rule-reference.md @@ -24,7 +24,7 @@ across the Python and TypeScript ports — see [[parity]] for the guarantee. Mos rules enforce OME-Zarr v0.4 MUSTs; the two v0.5 namespacing rules (`zarr-format`, `ome-namespace`) fire only for v0.5 metadata and are inert (a no-op) for v0.4. The three RFC 4 orientation rules (9–11) are normative from -OME-Zarr 0.9.dev1, the version `ome/ngff-spec#190` folds RFC-4 into: they are +OME-Zarr 0.9.dev1, which incorporates RFC-4 through `ome/ngff-spec#190`: they are inert when the caller declares 0.4, 0.5 or 0.6, where RFC 4 has no normative status, and stay on when no version is declared — a strictness choice, like `axis-names-unique` below 0.9.dev1. For the conceptual background and the two From 8dcd7804d24e5173981bc6fd229de94a67541052 Mon Sep 17 00:00:00 2001 From: Valentin Boussot Date: Wed, 26 Aug 2026 16:24:11 +0200 Subject: [PATCH 06/10] test(py): pin the schema refusal of a 0.5-shaped 0.9.dev1 entry The reader accepts a flat axis list at 0.9.dev1, so a store carrying a 0.9 version string over a 0.5-shaped entry stays readable. That tolerance is one-sided: the entry declares no coordinateSystems, which image.schema requires, and its dataset transforms name no input or output, so the schema pass refuses it. Only the lenient half was covered. The refusal is asserted without a message match, since jsonschema reports whichever of the two violations it reaches first. --- py/test/test_v09_metadata.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/py/test/test_v09_metadata.py b/py/test/test_v09_metadata.py index 9ede791a..7664db58 100644 --- a/py/test/test_v09_metadata.py +++ b/py/test/test_v09_metadata.py @@ -120,6 +120,26 @@ def test_read_flat_axes_shape(tmp_path): assert multiscales.metadata.dimension_names == ("z", "y", "x") +@zarr_v3 +def test_the_flat_axes_shape_fails_the_schema_pass(tmp_path): + """The flat shape is a read tolerance, not a second valid 0.9.dev1 shape. + + The entry the test above reads breaks the 0.9.dev1 schema twice over: it + declares no ``coordinateSystems``, which ``image.schema`` requires, and its + dataset transforms name no ``input`` or ``output``. Which of the two the + error reports is jsonschema's choice, so this pins the refusal rather than + a message. Reading the shape at all is a concession to stores that carry a + 0.9 version string over a 0.5-shaped entry. + """ + from jsonschema.exceptions import ValidationError + + axes = [{"name": n, "type": "space"} for n in "zyx"] + root = _write_v09(tmp_path / "flat-validate.ome.zarr", axes, (2, 3, 4), flat=True) + + with pytest.raises(ValidationError): + from_ome_zarr(root, validate=True) + + @zarr_v3 @pytest.mark.parametrize("flat", [False, True], ids=["coordinate-systems", "flat-axes"]) def test_read_axis_without_type(tmp_path, flat): From 6eb793679d2767236b151eb4695966676d90ec2e Mon Sep 17 00:00:00 2001 From: Valentin Boussot Date: Thu, 27 Aug 2026 11:20:56 +0200 Subject: [PATCH 07/10] fix(py): correct two schema descriptions the validator prints Both are description text, so no document changes validity. "A sequence of a ingle scale followed by a single translation" in image.schema, present since 0.6rc0. And byDimension describing inputAxes and outputAxes as names of axes, while both arrays are typed number: the spec text says positions in three places, and ngff-spec ships an invalid fixture using ["x"] so that names are rejected. Both are proposed upstream in ome/ngff-spec#195 against the 0.9dev branch; these copies carry them until a later tag brings them back. --- .../spec/0.6/schemas/coordinate_transformations.schema | 4 ++-- py/ngff_zarr/spec/0.6/schemas/image.schema | 2 +- .../spec/0.9/schemas/coordinate_transformations.schema | 4 ++-- py/ngff_zarr/spec/0.9/schemas/image.schema | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) 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 5ddb6066..6b753c15 100644 --- a/py/ngff_zarr/spec/0.6/schemas/coordinate_transformations.schema +++ b/py/ngff_zarr/spec/0.6/schemas/coordinate_transformations.schema @@ -372,14 +372,14 @@ "items": { "type": "number" }, - "description": "Names of the input axes for this transformation." + "description": "Positions of the input axes for this transformation." }, "outputAxes": { "type": "array", "items": { "type": "number" }, - "description": "Names of the output axes for this transformation." + "description": "Positions of the output axes for this transformation." } }, "required": [ diff --git a/py/ngff_zarr/spec/0.6/schemas/image.schema b/py/ngff_zarr/spec/0.6/schemas/image.schema index 379b00f4..c0417d62 100644 --- a/py/ngff_zarr/spec/0.6/schemas/image.schema +++ b/py/ngff_zarr/spec/0.6/schemas/image.schema @@ -113,7 +113,7 @@ ] }, { - "description": "A sequence of a ingle scale followed by a single translation", + "description": "A sequence of a single scale followed by a single translation", "type": "object", "properties": { "type": {"const": "sequence"}, diff --git a/py/ngff_zarr/spec/0.9/schemas/coordinate_transformations.schema b/py/ngff_zarr/spec/0.9/schemas/coordinate_transformations.schema index 8f0af7b9..d7011327 100644 --- a/py/ngff_zarr/spec/0.9/schemas/coordinate_transformations.schema +++ b/py/ngff_zarr/spec/0.9/schemas/coordinate_transformations.schema @@ -365,14 +365,14 @@ "items": { "type": "number" }, - "description": "Names of the input axes for this transformation." + "description": "Positions of the input axes for this transformation." }, "outputAxes": { "type": "array", "items": { "type": "number" }, - "description": "Names of the output axes for this transformation." + "description": "Positions of the output axes for this transformation." } }, "required": [ diff --git a/py/ngff_zarr/spec/0.9/schemas/image.schema b/py/ngff_zarr/spec/0.9/schemas/image.schema index effb6e84..d662a7cf 100644 --- a/py/ngff_zarr/spec/0.9/schemas/image.schema +++ b/py/ngff_zarr/spec/0.9/schemas/image.schema @@ -113,7 +113,7 @@ ] }, { - "description": "A sequence of a ingle scale followed by a single translation", + "description": "A sequence of a single scale followed by a single translation", "type": "object", "properties": { "type": {"const": "sequence"}, From 144b418d818e9afa13c57f4e149a711613236930 Mon Sep 17 00:00:00 2001 From: Valentin Boussot Date: Thu, 27 Aug 2026 11:25:29 +0200 Subject: [PATCH 08/10] fix(py): let a malformed 0.9 document reach the schema that describes it Reading the declared version off `ome` before validating assumed `ome` was an object. A document whose `ome` is null, a string or an array raised AttributeError about `.get`, which says nothing about the document; it now goes to the schema pass, which reports what is wrong with it. strict_ome_zarr.schema also lists one branch fewer than ome_zarr.schema: it omits scene, and the 0.9 cohort ships no strict_scene.schema, so a scene document passed the generic schema and failed the strict one. The scene branch is added, which stops a refusal rather than starting one. --- .../spec/0.9/schemas/strict_ome_zarr.schema | 3 +++ py/ngff_zarr/v09/zarr_metadata.py | 8 ++++++-- py/test/test_v09_metadata.py | 13 +++++++++++++ 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/py/ngff_zarr/spec/0.9/schemas/strict_ome_zarr.schema b/py/ngff_zarr/spec/0.9/schemas/strict_ome_zarr.schema index 8b936afe..1cb06c05 100644 --- a/py/ngff_zarr/spec/0.9/schemas/strict_ome_zarr.schema +++ b/py/ngff_zarr/spec/0.9/schemas/strict_ome_zarr.schema @@ -19,6 +19,9 @@ }, { "$ref": "https://ngff.openmicroscopy.org/0.9.dev1/schemas/strict_well.schema" + }, + { + "$ref": "https://ngff.openmicroscopy.org/0.9.dev1/schemas/scene.schema" } ] } diff --git a/py/ngff_zarr/v09/zarr_metadata.py b/py/ngff_zarr/v09/zarr_metadata.py index 05b1497c..58f9af30 100644 --- a/py/ngff_zarr/v09/zarr_metadata.py +++ b/py/ngff_zarr/v09/zarr_metadata.py @@ -356,8 +356,12 @@ def _from_zarr_attrs( # ``0.9.dev1`` tag its ``_version.schema`` binds. from ..validate import validate as validate_ngff - schema_version = str(root_attrs.get("ome", {}).get("version") or "0.9.dev1") - validate_ngff(root_attrs, version=schema_version) + # A document whose ``ome`` is not an object has no version to + # read; it goes to the schema pass, which says so, rather than + # failing here on the way to asking. + ome = root_attrs.get("ome") + declared = ome.get("version") if isinstance(ome, dict) else None + validate_ngff(root_attrs, version=str(declared or "0.9.dev1")) if "ome" not in root_attrs or "multiscales" not in root_attrs.get("ome", {}): raise ValueError( diff --git a/py/test/test_v09_metadata.py b/py/test/test_v09_metadata.py index 7664db58..90832112 100644 --- a/py/test/test_v09_metadata.py +++ b/py/test/test_v09_metadata.py @@ -329,3 +329,16 @@ def test_validate_structural_is_version_aware(version, accepted): else: with pytest.raises(ValidationError): validate_structural(metadata, version=version) + + +@pytest.mark.parametrize("ome", [None, "0.9.dev1", [{"version": "0.9.dev1"}]]) +def test_a_non_object_ome_fails_the_schema_rather_than_the_reader(ome): + """A document with no object to read a version from goes to the schema. + + Reaching for the version first turned a malformed document into an + AttributeError about ``.get``, which says nothing about the document. + """ + import jsonschema + + with pytest.raises(jsonschema.ValidationError): + Metadata._from_zarr_attrs({"ome": ome}, None, validate=True) From e95535fb9ce372fa1d607e8df44659acfe388364 Mon Sep 17 00:00:00 2001 From: Valentin Boussot Date: Thu, 27 Aug 2026 11:39:06 +0200 Subject: [PATCH 09/10] fix(py): word the byDimension axes as upstream words them "Indices", which is what ome/ngff-spec#195 settles on, rather than the "Positions" this copy carried: a later tag then brings the same sentence back instead of a second wording. --- .../spec/0.6/schemas/coordinate_transformations.schema | 4 ++-- .../spec/0.9/schemas/coordinate_transformations.schema | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) 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 6b753c15..a0354b29 100644 --- a/py/ngff_zarr/spec/0.6/schemas/coordinate_transformations.schema +++ b/py/ngff_zarr/spec/0.6/schemas/coordinate_transformations.schema @@ -372,14 +372,14 @@ "items": { "type": "number" }, - "description": "Positions of the input axes for this transformation." + "description": "Indices of the input axes for this transformation." }, "outputAxes": { "type": "array", "items": { "type": "number" }, - "description": "Positions of the output axes for this transformation." + "description": "Indices of the output axes for this transformation." } }, "required": [ diff --git a/py/ngff_zarr/spec/0.9/schemas/coordinate_transformations.schema b/py/ngff_zarr/spec/0.9/schemas/coordinate_transformations.schema index d7011327..309e5980 100644 --- a/py/ngff_zarr/spec/0.9/schemas/coordinate_transformations.schema +++ b/py/ngff_zarr/spec/0.9/schemas/coordinate_transformations.schema @@ -365,14 +365,14 @@ "items": { "type": "number" }, - "description": "Positions of the input axes for this transformation." + "description": "Indices of the input axes for this transformation." }, "outputAxes": { "type": "array", "items": { "type": "number" }, - "description": "Positions of the output axes for this transformation." + "description": "Indices of the output axes for this transformation." } }, "required": [ From 2309416908b48deae5bfd804f1b425802aeea4ac Mon Sep 17 00:00:00 2001 From: Valentin Boussot Date: Thu, 27 Aug 2026 12:07:50 +0200 Subject: [PATCH 10/10] fix(py): treat a version that is not a string as no version at all A truthy non-string, a number or a list, was stringified and handed on as a schema selector, so the document came back as a ValueError listing which schemas this package bundles. That describes the package, not the store. It now selects nothing and goes to the 0.9.dev1 pass, which reports what is actually wrong with the document. --- py/ngff_zarr/v09/zarr_metadata.py | 11 +++++++---- py/test/test_v09_metadata.py | 14 ++++++++++++++ 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/py/ngff_zarr/v09/zarr_metadata.py b/py/ngff_zarr/v09/zarr_metadata.py index 58f9af30..6417a552 100644 --- a/py/ngff_zarr/v09/zarr_metadata.py +++ b/py/ngff_zarr/v09/zarr_metadata.py @@ -356,12 +356,15 @@ def _from_zarr_attrs( # ``0.9.dev1`` tag its ``_version.schema`` binds. from ..validate import validate as validate_ngff - # A document whose ``ome`` is not an object has no version to - # read; it goes to the schema pass, which says so, rather than - # failing here on the way to asking. + # A document that carries no version string has none to select a + # schema with; it goes to the 0.9.dev1 pass, which describes what + # is wrong with it. Reaching for the version first turned such a + # document into an error about which schemas are bundled. ome = root_attrs.get("ome") declared = ome.get("version") if isinstance(ome, dict) else None - validate_ngff(root_attrs, version=str(declared or "0.9.dev1")) + if not isinstance(declared, str) or not declared: + declared = "0.9.dev1" + validate_ngff(root_attrs, version=declared) if "ome" not in root_attrs or "multiscales" not in root_attrs.get("ome", {}): raise ValueError( diff --git a/py/test/test_v09_metadata.py b/py/test/test_v09_metadata.py index 90832112..9e23af4f 100644 --- a/py/test/test_v09_metadata.py +++ b/py/test/test_v09_metadata.py @@ -342,3 +342,17 @@ def test_a_non_object_ome_fails_the_schema_rather_than_the_reader(ome): with pytest.raises(jsonschema.ValidationError): Metadata._from_zarr_attrs({"ome": ome}, None, validate=True) + + +@pytest.mark.parametrize("declared", [5, True, ["0.9.dev1"], {"a": 1}, ""]) +def test_a_non_string_version_does_not_select_a_schema(declared): + """A version that is not a string names no schema, so it selects none. + + Passing it on turned the document into a ValueError listing which + schemas are bundled, which describes this package rather than the store. + """ + import jsonschema + + document = {"ome": {"version": declared, "multiscales": []}} + with pytest.raises(jsonschema.ValidationError): + Metadata._from_zarr_attrs(document, None, validate=True)