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/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..ad2f808b 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, 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 +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`. 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..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": "Names of the input axes for this transformation." + "description": "Indices of the input axes for this transformation." }, "outputAxes": { "type": "array", "items": { "type": "number" }, - "description": "Names 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.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/_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..309e5980 --- /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": "Indices of the input axes for this transformation." + }, + "outputAxes": { + "type": "array", + "items": { + "type": "number" + }, + "description": "Indices 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..d662a7cf --- /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 single 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..1cb06c05 --- /dev/null +++ b/py/ngff_zarr/spec/0.9/schemas/strict_ome_zarr.schema @@ -0,0 +1,27 @@ +{ + "$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" + }, + { + "$ref": "https://ngff.openmicroscopy.org/0.9.dev1/schemas/scene.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/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 560d4696..cfbbafa2 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, non_default_axes_types - 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 @@ -620,11 +605,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, @@ -634,7 +622,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 29774e9d..bb49b32c 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, non_default_axes_types - 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/ngff_zarr/v09/zarr_metadata.py b/py/ngff_zarr/v09/zarr_metadata.py index ec69d4c7..6417a552 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,20 @@ 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 + + # 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 + 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_rfc4_validation.py b/py/test/test_rfc4_validation.py index 2862f515..c6945fd1 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): @@ -631,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") @@ -673,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"] 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/py/test/test_v09_metadata.py b/py/test/test_v09_metadata.py index 6dad7198..9e23af4f 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): @@ -206,12 +226,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(): @@ -252,3 +329,30 @@ 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) + + +@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) 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); + }, +);