diff --git a/docs/rfc5.md b/docs/rfc5.md index 1a9e88db..a5ac7b8f 100644 --- a/docs/rfc5.md +++ b/docs/rfc5.md @@ -4,9 +4,10 @@ [RFC-5] extends OME-NGFF with named **coordinate systems** and a richer set of **coordinate transformations** — identity, scale, translation, rotation, -affine, transformation sequences, and array-backed *displacement* and -*coordinate* fields. This is the OME-Zarr v0.6 data model. `ngff-zarr` reads and -writes it in both the Python and TypeScript packages. +affine, axis permutations, transformation sequences, per-dimension and +invertible wrappers, and array-backed *displacement* and *coordinate* fields. +This is the OME-Zarr v0.6 data model. `ngff-zarr` reads and writes it in both +the Python and TypeScript packages. ## Overview @@ -76,6 +77,23 @@ The transformation data classes live in `ngff_zarr.v06.zarr_metadata`: | `Displacements` | `displacements` | `path: str`, `interpolation: str` | | `Coordinates` | `coordinates` | `path: str`, `interpolation: str` | | `TransformSequence` | `sequence` | `transformations: list[Transform]` | +| `MapAxis` | `mapAxis` | `mapAxis: list[int]` | +| `ByDimension` | `byDimension` | `transformations: list[ByDimensionItem]` | +| `Bijection` | `bijection` | `forward: Transform`, `inverse: Transform` | + +`MapAxis` stores an axis permutation as a transpose vector: the value at +position `i` is the input axis that becomes the `i`-th output axis, and every +zero-based input axis index appears exactly once. `ByDimension` builds a high +dimensional transform from lower dimensional ones; each `ByDimensionItem` +wraps a transformation with the `input_axes` and `output_axes` (zero-based +indices into the parent's coordinate systems) it applies to, and every output +axis is produced by exactly one item. `Bijection` pairs an explicit `forward` +transformation with its `inverse`. Constraints that follow from a transform's +parameters alone are enforced when it is constructed, so an invalid instance +cannot exist; the ones that depend on the resolved input and output +coordinate systems are checked on read, and by +`ngff_zarr.v06.zarr_metadata.validate_transform` (or the transform's own +`validate` method) for programmatically built transforms. Every transform has an `input` and `output`, each a `CoordinateSystemIdentifier` naming a coordinate system (`name=`) or referencing 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 c795311b..7500032f 100644 --- a/py/ngff_zarr/spec/0.6/schemas/coordinate_transformations.schema +++ b/py/ngff_zarr/spec/0.6/schemas/coordinate_transformations.schema @@ -322,16 +322,18 @@ "input_axes": { "type": "array", "items": { - "type": "number" + "type": "integer", + "minimum": 0 }, - "description": "Names of the input axes for this transformation." + "description": "Zero-based axis indices into the parent byDimension transformation's input coordinate system." }, "output_axes": { "type": "array", "items": { - "type": "number" + "type": "integer", + "minimum": 0 }, - "description": "Names of the output axes for this transformation." + "description": "Zero-based axis indices into the parent byDimension transformation's output coordinate system." } }, "required": [ diff --git a/py/ngff_zarr/v06/zarr_metadata.py b/py/ngff_zarr/v06/zarr_metadata.py index e76d4947..b067a7a3 100644 --- a/py/ngff_zarr/v06/zarr_metadata.py +++ b/py/ngff_zarr/v06/zarr_metadata.py @@ -56,6 +56,46 @@ class CoordinateSystemIdentifier: path: str | None = None name: str | None = None + def axis_count( + self, coordinateSystems: list[CoordinateSystem] | None + ) -> int | None: + """Number of axes of the referenced coordinate system, when resolvable. + + Returns ``None`` when this identifier names no system in + ``coordinateSystems``, as for the wrapped transforms that omit + ``input`` and ``output``. + """ + if self.name is None or not coordinateSystems: + return None + for coordinate_system in coordinateSystems: + if coordinate_system.name == self.name: + return len(coordinate_system.axes) + return None + + +def _resolved_axis_count( + identifier: CoordinateSystemIdentifier | None, + coordinateSystems: list[CoordinateSystem] | None, +) -> int | None: + return None if identifier is None else identifier.axis_count(coordinateSystems) + + +def _require_keys(data: dict, keys: tuple[str, ...], context: str) -> None: + """Reject a transformation payload that omits a required field.""" + missing = [key for key in keys if key not in data] + if missing: + raise ValueError( + f"{context} transformation is missing required field(s) " + f"{', '.join(missing)}" + ) + + +def _require_integer_axes(axes: list, context: str) -> None: + """Axis indices are zero-based integer positions; reject anything else.""" + for axis in axes: + if isinstance(axis, bool) or not isinstance(axis, int): + raise ValueError(f"{context} axis indices must be integers; got {axis!r}") + @dataclass(kw_only=True) class BaseTransform(ABC): # noqa: B024 @@ -73,6 +113,23 @@ def to_dict(self) -> dict: def from_dict(cls, data: dict) -> "BaseTransform": return cls(**data) + def validate( # noqa: B027 + self, coordinateSystems: list[CoordinateSystem] | None = None + ) -> None: + """Check this transform's RFC-5 constraints. + + Constraints that follow from the parameters alone are enforced by + ``__post_init__``, so an invalid instance cannot be constructed; + this method checks them again, which covers instances mutated after + construction. Constraints against the ``input`` and ``output`` + coordinate systems run when those resolve in ``coordinateSystems`` + and are skipped otherwise. Raises ``ValueError`` on the first + violation. + + Transform types without constraints beyond their field types, such + as ``Scale`` or ``Identity``, inherit this no-op deliberately. + """ + @dataclass(kw_only=True) class Identity(BaseTransform): @@ -119,6 +176,45 @@ class Displacements(BaseTransform): type: str = "displacements" +@dataclass(kw_only=True) +class MapAxis(BaseTransform): + """An axis permutation stored as a transpose vector of integer indices. + + The value at position ``i`` names which input axis becomes the ``i``-th + output axis. Every zero-based input axis index appears exactly once. + """ + + mapAxis: list[int] + type: str = "mapAxis" + + def __post_init__(self) -> None: + self._check_intrinsic() + + def _check_intrinsic(self) -> None: + indices = self.mapAxis + _require_integer_axes(indices, "mapAxis") + if not (2 <= len(indices) <= 5): + raise ValueError( + "mapAxis must hold between 2 and 5 indices, one per axis " + f"of the coordinate systems it permutes; got {indices}" + ) + if sorted(indices) != list(range(len(indices))): + raise ValueError( + "mapAxis must be a permutation holding every zero-based " + f"input axis index exactly once; got {indices}" + ) + + def validate(self, coordinateSystems: list[CoordinateSystem] | None = None) -> None: + self._check_intrinsic() + for identifier in (self.input, self.output): + count = _resolved_axis_count(identifier, coordinateSystems) + if count is not None and count != len(self.mapAxis): + raise ValueError( + f"mapAxis length {len(self.mapAxis)} does not match the " + f"{count} axes of coordinate system '{identifier.name}'" + ) + + Transform = Union[ Identity, Scale, @@ -127,16 +223,206 @@ class Displacements(BaseTransform): Affine, Coordinates, Displacements, + MapAxis, + "ByDimension", + "Bijection", "TransformSequence", ] +def _item_dimensions(transformation: "Transform") -> int | None: + """Dimensionality a byDimension item's axis lists must have, if knowable.""" + if isinstance(transformation, Scale): + return len(transformation.scale) + if isinstance(transformation, Translation): + return len(transformation.translation) + if isinstance(transformation, MapAxis): + return len(transformation.mapAxis) + return None + + +@dataclass +class ByDimensionItem: + """One lower-dimensional transformation of a byDimension transform. + + ``input_axes`` and ``output_axes`` hold zero-based axis indices into the + parent byDimension's input and output coordinate systems. + """ + + transformation: Transform + input_axes: list[int] + output_axes: list[int] + + def __post_init__(self) -> None: + self._check_intrinsic() + + def _check_intrinsic(self) -> None: + axes = list(self.input_axes) + list(self.output_axes) + _require_integer_axes(axes, "byDimension") + if any(axis < 0 for axis in axes): + raise ValueError( + f"byDimension axis indices must be non-negative; got {axes}" + ) + if len(set(self.output_axes)) != len(self.output_axes): + raise ValueError( + "byDimension output axes must each be produced by exactly " + f"one transformation; {self.output_axes} repeats an axis" + ) + dimensions = _item_dimensions(self.transformation) + if dimensions is not None and ( + len(self.input_axes) != dimensions or len(self.output_axes) != dimensions + ): + raise ValueError( + f"byDimension item of type '{self.transformation.type}' is " + f"{dimensions}-dimensional but maps {len(self.input_axes)} " + f"input axes to {len(self.output_axes)} output axes" + ) + + @classmethod + def from_dict( + cls, data: dict, coordinateSystems: list[CoordinateSystem] | None = None + ) -> "ByDimensionItem": + _require_keys( + data, ("transformation", "input_axes", "output_axes"), "byDimension item" + ) + (transformation,) = Metadata._parse_transforms( + [data["transformation"]], coordinateSystems or [] + ) + return cls( + transformation=transformation, + input_axes=list(data["input_axes"]), + output_axes=list(data["output_axes"]), + ) + + +@dataclass(kw_only=True) +class ByDimension(BaseTransform): + """A high dimensional transform built from lower dimensional ones. + + Every axis index of the output coordinate system appears in exactly one + item's ``output_axes``. + """ + + transformations: list[ByDimensionItem] + type: str = "byDimension" + + def __post_init__(self) -> None: + self._check_intrinsic() + + def _check_intrinsic(self) -> None: + seen_output_axes: set[int] = set() + for item in self.transformations: + duplicated = seen_output_axes.intersection(item.output_axes) + if duplicated: + raise ValueError( + "byDimension output axes must each be produced by exactly " + f"one transformation; axis {sorted(duplicated)} appears " + "more than once" + ) + seen_output_axes.update(item.output_axes) + + @property + def produced_output_axes(self) -> set[int]: + """The output axis indices the items produce, taken together.""" + axes: set[int] = set() + for item in self.transformations: + axes.update(item.output_axes) + return axes + + def validate(self, coordinateSystems: list[CoordinateSystem] | None = None) -> None: + for item in self.transformations: + item._check_intrinsic() + item.transformation.validate(coordinateSystems) + self._check_intrinsic() + input_count = _resolved_axis_count(self.input, coordinateSystems) + if input_count is not None: + for item in self.transformations: + if any(axis >= input_count for axis in item.input_axes): + raise ValueError( + f"byDimension input axes {item.input_axes} exceed the " + f"{input_count} axes of coordinate system " + f"'{self.input.name}'" + ) + output_count = _resolved_axis_count(self.output, coordinateSystems) + if output_count is not None: + produced = self.produced_output_axes + if produced != set(range(output_count)): + raise ValueError( + "byDimension items must cover every output axis exactly " + f"once; coordinate system '{self.output.name}' has " + f"{output_count} axes but the items produce {sorted(produced)}" + ) + + @classmethod + def from_dict( + cls, data: dict, coordinateSystems: list[CoordinateSystem] | None = None + ) -> "ByDimension": + _require_keys(data, ("transformations",), "byDimension") + return cls( + transformations=[ + ByDimensionItem.from_dict(item, coordinateSystems) + for item in data["transformations"] + ] + ) + + +@dataclass(kw_only=True) +class Bijection(BaseTransform): + """An invertible transform with explicit forward and inverse directions.""" + + forward: Transform + inverse: Transform + type: str = "bijection" + + def validate(self, coordinateSystems: list[CoordinateSystem] | None = None) -> None: + self.forward.validate(coordinateSystems) + self.inverse.validate(coordinateSystems) + input_count = _resolved_axis_count(self.input, coordinateSystems) + output_count = _resolved_axis_count(self.output, coordinateSystems) + if ( + input_count is not None + and output_count is not None + and input_count != output_count + ): + raise ValueError( + "bijection input and output coordinate systems must have the " + f"same dimensionality; got {input_count} and {output_count}" + ) + + @classmethod + def from_dict( + cls, data: dict, coordinateSystems: list[CoordinateSystem] | None = None + ) -> "Bijection": + _require_keys(data, ("forward", "inverse"), "bijection") + systems = coordinateSystems or [] + (forward,) = Metadata._parse_transforms([data["forward"]], systems) + (inverse,) = Metadata._parse_transforms([data["inverse"]], systems) + return cls(forward=forward, inverse=inverse) + + @dataclass(kw_only=True) class TransformSequence(BaseTransform): transformations: list[Transform] name: str | None = "transformSequence" type: str = "sequence" + def validate(self, coordinateSystems: list[CoordinateSystem] | None = None) -> None: + for transformation in self.transformations: + transformation.validate(coordinateSystems) + + +def validate_transform( + transformation: Transform, + coordinateSystems: list[CoordinateSystem] | None = None, +) -> None: + """Check a transform's RFC-5 constraints; see ``BaseTransform.validate``. + + Constraints that follow from the parameters alone hold by construction. + This also checks the ones that need the ``input`` and ``output`` + coordinate systems, when those resolve in ``coordinateSystems``. + """ + transformation.validate(coordinateSystems) + @dataclass class Dataset: @@ -536,6 +822,13 @@ def _parse_transforms( transformation = Coordinates.from_dict(transform) elif transform["type"] == "displacements": transformation = Displacements.from_dict(transform) + elif transform["type"] == "mapAxis": + _require_keys(transform, ("mapAxis",), "mapAxis") + transformation = MapAxis(mapAxis=list(transform["mapAxis"])) + elif transform["type"] == "byDimension": + transformation = ByDimension.from_dict(transform, coordinateSystems) + elif transform["type"] == "bijection": + transformation = Bijection.from_dict(transform, coordinateSystems) elif transform["type"] == "sequence": # TODO: Undo nested sequences on import? sub_transforms = cls._parse_transforms( @@ -570,6 +863,12 @@ def _parse_transforms( transformation.input = input transformation.output = output + # The wrapper branches above construct their transform from the + # payload fields alone, so the optional name is restored here for + # every type. + if transform.get("name") is not None: + transformation.name = transform["name"] + validate_transform(transformation, coordinateSystems) parsed_transforms.append(transformation) return parsed_transforms diff --git a/py/test/rfc5_transform_cases.json b/py/test/rfc5_transform_cases.json new file mode 100644 index 00000000..08b556d5 --- /dev/null +++ b/py/test/rfc5_transform_cases.json @@ -0,0 +1,581 @@ +{ + "$comment": "RFC-5 transformation cases shared by the Python and TypeScript test suites. Both ports must accept every case with ok=true and reject every case with ok=false when parsing it against the listed coordinate systems.", + "coordinateSystems": [ + { + "name": "s3", + "axes": [ + { + "name": "z", + "type": "space" + }, + { + "name": "y", + "type": "space" + }, + { + "name": "x", + "type": "space" + } + ] + }, + { + "name": "s2", + "axes": [ + { + "name": "y", + "type": "space" + }, + { + "name": "x", + "type": "space" + } + ] + } + ], + "cases": [ + { + "name": "mapAxis_valid", + "ok": true, + "transformation": { + "type": "mapAxis", + "mapAxis": [ + 2, + 0, + 1 + ], + "input": { + "name": "s3" + }, + "output": { + "name": "s3" + } + } + }, + { + "name": "mapAxis_dup", + "ok": false, + "transformation": { + "type": "mapAxis", + "mapAxis": [ + 0, + 0, + 1 + ] + } + }, + { + "name": "mapAxis_gap", + "ok": false, + "transformation": { + "type": "mapAxis", + "mapAxis": [ + 0, + 1, + 3 + ] + } + }, + { + "name": "mapAxis_too_short", + "ok": false, + "transformation": { + "type": "mapAxis", + "mapAxis": [ + 0 + ] + } + }, + { + "name": "mapAxis_too_long", + "ok": false, + "transformation": { + "type": "mapAxis", + "mapAxis": [ + 5, + 4, + 3, + 2, + 1, + 0 + ] + } + }, + { + "name": "mapAxis_negative", + "ok": false, + "transformation": { + "type": "mapAxis", + "mapAxis": [ + -1, + 0, + 1 + ] + } + }, + { + "name": "mapAxis_float", + "ok": false, + "transformation": { + "type": "mapAxis", + "mapAxis": [ + 0.5, + 1, + 2 + ] + } + }, + { + "name": "mapAxis_len_mismatch", + "ok": false, + "transformation": { + "type": "mapAxis", + "mapAxis": [ + 1, + 0 + ], + "input": { + "name": "s3" + } + } + }, + { + "name": "mapAxis_unknown_system", + "ok": true, + "transformation": { + "type": "mapAxis", + "mapAxis": [ + 1, + 0 + ], + "input": { + "name": "nope" + } + } + }, + { + "name": "byDim_valid", + "ok": true, + "transformation": { + "type": "byDimension", + "input": { + "name": "s3" + }, + "output": { + "name": "s3" + }, + "transformations": [ + { + "transformation": { + "type": "scale", + "scale": [ + 2, + 3 + ] + }, + "input_axes": [ + 0, + 1 + ], + "output_axes": [ + 0, + 1 + ] + }, + { + "transformation": { + "type": "translation", + "translation": [ + 5 + ] + }, + "input_axes": [ + 2 + ], + "output_axes": [ + 2 + ] + } + ] + } + }, + { + "name": "byDim_missing_output", + "ok": false, + "transformation": { + "type": "byDimension", + "output": { + "name": "s3" + }, + "transformations": [ + { + "transformation": { + "type": "scale", + "scale": [ + 2, + 3 + ] + }, + "input_axes": [ + 0, + 1 + ], + "output_axes": [ + 0, + 1 + ] + } + ] + } + }, + { + "name": "byDim_dup_across_items", + "ok": false, + "transformation": { + "type": "byDimension", + "transformations": [ + { + "transformation": { + "type": "scale", + "scale": [ + 2, + 3 + ] + }, + "input_axes": [ + 0, + 1 + ], + "output_axes": [ + 0, + 1 + ] + }, + { + "transformation": { + "type": "translation", + "translation": [ + 5 + ] + }, + "input_axes": [ + 2 + ], + "output_axes": [ + 1 + ] + } + ] + } + }, + { + "name": "byDim_dup_within_item", + "ok": false, + "transformation": { + "type": "byDimension", + "transformations": [ + { + "transformation": { + "type": "scale", + "scale": [ + 2, + 3 + ] + }, + "input_axes": [ + 0, + 1 + ], + "output_axes": [ + 1, + 1 + ] + } + ] + } + }, + { + "name": "byDim_dim_mismatch", + "ok": false, + "transformation": { + "type": "byDimension", + "transformations": [ + { + "transformation": { + "type": "scale", + "scale": [ + 2, + 3 + ] + }, + "input_axes": [ + 0 + ], + "output_axes": [ + 0 + ] + } + ] + } + }, + { + "name": "byDim_input_out_of_range", + "ok": false, + "transformation": { + "type": "byDimension", + "input": { + "name": "s3" + }, + "output": { + "name": "s3" + }, + "transformations": [ + { + "transformation": { + "type": "scale", + "scale": [ + 2, + 3, + 4 + ] + }, + "input_axes": [ + 0, + 1, + 3 + ], + "output_axes": [ + 0, + 1, + 2 + ] + } + ] + } + }, + { + "name": "byDim_negative", + "ok": false, + "transformation": { + "type": "byDimension", + "transformations": [ + { + "transformation": { + "type": "scale", + "scale": [ + 2 + ] + }, + "input_axes": [ + -1 + ], + "output_axes": [ + 0 + ] + } + ] + } + }, + { + "name": "byDim_float", + "ok": false, + "transformation": { + "type": "byDimension", + "transformations": [ + { + "transformation": { + "type": "scale", + "scale": [ + 2 + ] + }, + "input_axes": [ + 0.5 + ], + "output_axes": [ + 0 + ] + } + ] + } + }, + { + "name": "byDim_nested_invalid", + "ok": false, + "transformation": { + "type": "byDimension", + "transformations": [ + { + "transformation": { + "type": "mapAxis", + "mapAxis": [ + 0, + 0 + ] + }, + "input_axes": [ + 0, + 1 + ], + "output_axes": [ + 0, + 1 + ] + } + ] + } + }, + { + "name": "byDim_nested_sequence", + "ok": true, + "transformation": { + "type": "byDimension", + "transformations": [ + { + "transformation": { + "type": "sequence", + "transformations": [ + { + "type": "scale", + "scale": [ + 2, + 3 + ] + } + ] + }, + "input_axes": [ + 0, + 1 + ], + "output_axes": [ + 0, + 1 + ] + } + ] + } + }, + { + "name": "bijection_valid", + "ok": true, + "transformation": { + "type": "bijection", + "input": { + "name": "s3" + }, + "output": { + "name": "s3" + }, + "forward": { + "type": "displacements", + "path": "f" + }, + "inverse": { + "type": "displacements", + "path": "i" + } + } + }, + { + "name": "bijection_dim_mismatch", + "ok": false, + "transformation": { + "type": "bijection", + "input": { + "name": "s2" + }, + "output": { + "name": "s3" + }, + "forward": { + "type": "displacements", + "path": "f" + }, + "inverse": { + "type": "displacements", + "path": "i" + } + } + }, + { + "name": "bijection_nested_invalid", + "ok": false, + "transformation": { + "type": "bijection", + "forward": { + "type": "mapAxis", + "mapAxis": [ + 0, + 0 + ] + }, + "inverse": { + "type": "identity" + } + } + }, + { + "name": "sequence_nested_invalid", + "ok": false, + "transformation": { + "type": "sequence", + "transformations": [ + { + "type": "mapAxis", + "mapAxis": [ + 1, + 1 + ] + } + ] + } + }, + { + "name": "mapAxis_missing_payload", + "ok": false, + "transformation": { + "type": "mapAxis" + } + }, + { + "name": "byDim_missing_transformations", + "ok": false, + "transformation": { + "type": "byDimension" + } + }, + { + "name": "byDim_item_missing_axes", + "ok": false, + "transformation": { + "type": "byDimension", + "transformations": [ + { + "transformation": { + "type": "scale", + "scale": [ + 2, + 3 + ] + }, + "input_axes": [ + 0, + 1 + ] + } + ] + } + }, + { + "name": "bijection_missing_inverse", + "ok": false, + "transformation": { + "type": "bijection", + "forward": { + "type": "displacements", + "path": "f" + } + } + } + ] +} diff --git a/py/test/test_coordinate_transformations.py b/py/test/test_coordinate_transformations.py index 29874cb0..866f309b 100644 --- a/py/test/test_coordinate_transformations.py +++ b/py/test/test_coordinate_transformations.py @@ -9,13 +9,19 @@ from ngff_zarr.v06.zarr_metadata import ( Affine, Axis, + Bijection, + ByDimension, + ByDimensionItem, CoordinateSystem, CoordinateSystemIdentifier, + Displacements, Identity, + MapAxis, Rotation, Scale, TransformSequence, Translation, + validate_transform, ) from packaging import version @@ -73,6 +79,34 @@ def transform_sequence() -> TransformSequence: ) +def map_axis_transform() -> MapAxis: + return MapAxis(mapAxis=[2, 0, 1]) + + +def by_dimension_transform() -> ByDimension: + return ByDimension( + transformations=[ + ByDimensionItem( + transformation=Scale(scale=[2.0, 3.0]), + input_axes=[0, 1], + output_axes=[0, 1], + ), + ByDimensionItem( + transformation=Translation(translation=[5.0]), + input_axes=[2], + output_axes=[2], + ), + ] + ) + + +def bijection_transform() -> Bijection: + return Bijection( + forward=Displacements(path="forward_field"), + inverse=Displacements(path="inverse_field"), + ) + + @requires_zarr_v3 @pytest.mark.parametrize( "transform", @@ -83,6 +117,9 @@ def transform_sequence() -> TransformSequence: rotation_transform(), affine_transform(), transform_sequence(), + map_axis_transform(), + by_dimension_transform(), + bijection_transform(), ], ) def test_transform_serialization(transform): @@ -122,6 +159,7 @@ def test_transform_serialization(transform): assert len(imported.metadata.coordinateSystems) == 2 assert imported_transforms[0].type == transform.type + assert imported_transforms[0].name == transform.name assert imported_transforms[0].input == CoordinateSystemIdentifier( name=input_cs.name ) @@ -172,3 +210,256 @@ def test_affine_image_single_store_roundtrip(): assert len(imported_transforms) == 1 assert imported_transforms[0].type == "affine" assert imported_transforms[0].affine == affine_transform().affine + + +@requires_zarr_v3 +def test_new_transform_payloads_roundtrip(): + """mapAxis, byDimension and bijection survive the round-trip by value.""" + array = rng.random(size=(8, 8, 8), dtype=np.float32) + input_image = nz.to_ngff_image( + array, + dims=["z", "y", "x"], + scale={"z": 1.0, "y": 1.0, "x": 1.0}, + ) + multiscales = nz.to_multiscales(input_image, scale_factors=[]) + intrinsic = multiscales.metadata.intrinsic_coordinate_system + + transforms = [ + map_axis_transform(), + by_dimension_transform(), + bijection_transform(), + ] + for transform in transforms: + transform.input = CoordinateSystemIdentifier(name=intrinsic.name) + transform.output = CoordinateSystemIdentifier(name=intrinsic.name) + multiscales.metadata.coordinateTransformations = transforms + + with tempfile.TemporaryDirectory() as tmpdir: + nz.to_ngff_zarr(tmpdir, multiscales, version="0.6") + imported = nz.from_ngff_zarr(tmpdir) + + imported_map, imported_by_dim, imported_bijection = ( + imported.metadata.coordinateTransformations + ) + assert imported_map.mapAxis == [2, 0, 1] + + assert len(imported_by_dim.transformations) == 2 + first, second = imported_by_dim.transformations + assert first.transformation.scale == [2.0, 3.0] + assert first.input_axes == [0, 1] + assert first.output_axes == [0, 1] + assert second.transformation.translation == [5.0] + assert second.input_axes == [2] + assert second.output_axes == [2] + + assert imported_bijection.forward.path == "forward_field" + assert imported_bijection.inverse.path == "inverse_field" + + +def _three_axis_system(name: str = "system") -> CoordinateSystem: + return CoordinateSystem( + name=name, + axes=[ + Axis(name="z", type="space"), + Axis(name="y", type="space"), + Axis(name="x", type="space"), + ], + ) + + +def test_map_axis_must_be_a_permutation(): + """Intrinsic constraints hold by construction; the instance never exists.""" + with pytest.raises(ValueError, match="permutation"): + MapAxis(mapAxis=[2, 0, 2]) + with pytest.raises(ValueError, match="permutation"): + MapAxis(mapAxis=[0, 1, 3]) + + +def test_map_axis_arity_is_bounded(): + """OME-Zarr coordinate systems hold 2 to 5 axes; mapAxis matches.""" + for indices in ([0], [], [5, 4, 3, 2, 1, 0]): + with pytest.raises(ValueError, match="between 2 and 5"): + MapAxis(mapAxis=indices) + + +def test_axis_indices_must_be_integers(): + with pytest.raises(ValueError, match="integers"): + MapAxis(mapAxis=[0.0, 1.0, 2.0]) + with pytest.raises(ValueError, match="integers"): + ByDimensionItem( + transformation=Scale(scale=[2.0, 3.0]), + input_axes=[0.0, 1.0], + output_axes=[0, 1], + ) + + +def test_by_dimension_item_rejects_dimension_mismatch(): + with pytest.raises(ValueError, match="dimensional"): + ByDimensionItem( + transformation=Scale(scale=[2.0, 3.0]), + input_axes=[0], + output_axes=[0], + ) + + +def test_by_dimension_rejects_duplicate_output_axes(): + """Duplicates within an item and across items are both rejected.""" + with pytest.raises(ValueError, match="exactly one"): + ByDimensionItem( + transformation=Scale(scale=[2.0, 3.0]), + input_axes=[0, 1], + output_axes=[1, 1], + ) + with pytest.raises(ValueError, match="exactly one"): + ByDimension( + transformations=[ + ByDimensionItem( + transformation=Scale(scale=[2.0, 3.0]), + input_axes=[0, 1], + output_axes=[0, 1], + ), + ByDimensionItem( + transformation=Translation(translation=[5.0]), + input_axes=[2], + output_axes=[1], + ), + ] + ) + + +def test_map_axis_length_must_match_coordinate_system(): + """Contextual constraints need the coordinate systems: validate_transform.""" + transform = MapAxis( + mapAxis=[1, 0], + input=CoordinateSystemIdentifier(name="system"), + ) + with pytest.raises(ValueError, match="does not match"): + validate_transform(transform, [_three_axis_system()]) + + +def test_by_dimension_must_cover_every_output_axis(): + transform = ByDimension( + transformations=[ + ByDimensionItem( + transformation=Scale(scale=[2.0, 3.0]), + input_axes=[0, 1], + output_axes=[0, 1], + ), + ], + output=CoordinateSystemIdentifier(name="system"), + ) + with pytest.raises(ValueError, match="every output axis"): + validate_transform(transform, [_three_axis_system()]) + + +def test_by_dimension_rejects_out_of_range_input_axes(): + transform = ByDimension( + transformations=[ + ByDimensionItem( + transformation=Scale(scale=[2.0, 3.0, 4.0]), + input_axes=[0, 1, 3], + output_axes=[0, 1, 2], + ), + ], + input=CoordinateSystemIdentifier(name="system"), + output=CoordinateSystemIdentifier(name="system"), + ) + with pytest.raises(ValueError, match="exceed"): + validate_transform(transform, [_three_axis_system()]) + + +def test_bijection_dimensions_must_match(): + transform = Bijection( + forward=Displacements(path="forward_field"), + inverse=Displacements(path="inverse_field"), + input=CoordinateSystemIdentifier(name="two"), + output=CoordinateSystemIdentifier(name="three"), + ) + two_axis = CoordinateSystem( + name="two", + axes=[Axis(name="y", type="space"), Axis(name="x", type="space")], + ) + with pytest.raises(ValueError, match="dimensionality"): + validate_transform(transform, [two_axis, _three_axis_system("three")]) + + +def test_contextual_checks_are_skipped_without_coordinate_systems(): + """A valid transform whose systems do not resolve passes on its own.""" + transform = MapAxis( + mapAxis=[1, 0], + input=CoordinateSystemIdentifier(name="unknown"), + ) + validate_transform(transform) + validate_transform(transform, [_three_axis_system("other")]) + + +def test_axis_count_resolves_named_systems(): + systems = [_three_axis_system("system")] + assert CoordinateSystemIdentifier(name="system").axis_count(systems) == 3 + assert CoordinateSystemIdentifier(name="unknown").axis_count(systems) is None + assert CoordinateSystemIdentifier(path="scale0").axis_count(systems) is None + assert CoordinateSystemIdentifier(name="system").axis_count(None) is None + + +def test_invalid_map_axis_is_rejected_at_parse(): + """The reader enforces the constraints on untrusted on-disk metadata.""" + from ngff_zarr.v06.zarr_metadata import Metadata + + with pytest.raises(ValueError, match="permutation"): + Metadata._parse_transforms([{"type": "mapAxis", "mapAxis": [0, 0, 1]}], []) + + +def test_wrapper_validation_reaches_nested_transforms(): + """Mutating a nested transform after construction is caught by validate.""" + nested = MapAxis(mapAxis=[1, 0]) + wrappers = [ + Bijection(forward=nested, inverse=Identity()), + TransformSequence(transformations=[nested]), + ByDimension( + transformations=[ByDimensionItem(nested, [0, 1], [0, 1])], + ), + ] + for wrapper in wrappers: + validate_transform(wrapper) + nested.mapAxis = [0, 0] + for wrapper in wrappers: + with pytest.raises(ValueError, match="permutation"): + validate_transform(wrapper) + + +def _shared_cases(): + import json + from pathlib import Path + + spec = json.loads((Path(__file__).parent / "rfc5_transform_cases.json").read_text()) + systems = [ + CoordinateSystem( + name=system["name"], + axes=[ + Axis(name=axis["name"], type=axis["type"]) for axis in system["axes"] + ], + ) + for system in spec["coordinateSystems"] + ] + return systems, spec["cases"] + + +_SHARED_SYSTEMS, _SHARED_CASES = _shared_cases() + + +@pytest.mark.parametrize( + "case", _SHARED_CASES, ids=[case["name"] for case in _SHARED_CASES] +) +def test_shared_rfc5_cases_match_expected_verdict(case): + """The Python and TypeScript readers give the same verdict on each case. + + ``rfc5_transform_cases.json`` is also exercised by the TypeScript suite, + so a rule enforced in one port and not the other fails here. + """ + from ngff_zarr.v06.zarr_metadata import Metadata + + if case["ok"]: + Metadata._parse_transforms([case["transformation"]], _SHARED_SYSTEMS) + else: + with pytest.raises(ValueError): + Metadata._parse_transforms([case["transformation"]], _SHARED_SYSTEMS) diff --git a/ts/src/schemas/coordinate_systems.ts b/ts/src/schemas/coordinate_systems.ts index 08753bd4..52e580e6 100644 --- a/ts/src/schemas/coordinate_systems.ts +++ b/ts/src/schemas/coordinate_systems.ts @@ -24,16 +24,31 @@ export const IdentityTransformationSchema: z.ZodType<{ name: z.string().optional(), }); -// Map Axis transformation (axis permutation) +// Map Axis transformation: an axis permutation stored as a transpose vector +// of zero-based integer indices, each appearing exactly once. export const MapAxisTransformationSchema: z.ZodType<{ type: "mapAxis"; - mapAxis: Record; + mapAxis: number[]; input?: string | string[] | undefined; output?: string | string[] | undefined; name?: string | undefined; }> = z.object({ type: z.literal("mapAxis"), - mapAxis: z.record(z.string(), z.string()), // Dictionary mapping axis names + mapAxis: z + .array(z.number().int().nonnegative()) + .min(2) + .max(5) + .refine( + (indices) => + [...indices].sort((a, b) => a - b).every( + (value, position) => value === position, + ), + { + message: + "mapAxis must be a permutation holding every zero-based input " + + "axis index exactly once", + }, + ), input: z.union([z.string(), z.array(z.string())]).optional(), output: z.union([z.string(), z.array(z.string())]).optional(), name: z.string().optional(), @@ -123,188 +138,190 @@ export const RotationTransformationSchema: z.ZodType<{ message: "Either rotation array or path must be provided", }); -// Forward declaration for recursive types -type BaseCoordinateTransformation = { - type: - | "identity" - | "mapAxis" - | "translation" - | "scale" - | "affine" - | "rotation"; +// Coordinates transformation referencing a coordinate field array +export const CoordinatesTransformationSchema: z.ZodType<{ + type: "coordinates"; + path: string; + interpolation?: string | undefined; input?: string | string[] | undefined; output?: string | string[] | undefined; name?: string | undefined; - mapAxis?: Record | undefined; - translation?: number[] | undefined; - path?: string | undefined; - scale?: number[] | undefined; - affine?: number[][] | undefined; - rotation?: number[] | undefined; -}; - -const BaseCoordinateTransformationSchema: z.ZodType< - BaseCoordinateTransformation -> = z.union([ - IdentityTransformationSchema, - MapAxisTransformationSchema, - TranslationTransformationSchema, - ScaleTransformationSchema, - AffineTransformationSchema, - RotationTransformationSchema, -]); +}> = z.object({ + type: z.literal("coordinates"), + path: z.string(), + interpolation: z.string().optional(), + input: z.union([z.string(), z.array(z.string())]).optional(), + output: z.union([z.string(), z.array(z.string())]).optional(), + name: z.string().optional(), +}); -// Sequence transformation (for chaining transformations) -export const SequenceTransformationSchema: z.ZodType<{ - type: "sequence"; - transformations: BaseCoordinateTransformation[]; +// Displacements transformation referencing a displacement field array +export const DisplacementsTransformationSchema: z.ZodType<{ + type: "displacements"; + path: string; + interpolation?: string | undefined; input?: string | string[] | undefined; output?: string | string[] | undefined; name?: string | undefined; }> = z.object({ - type: z.literal("sequence"), - transformations: z.array(BaseCoordinateTransformationSchema), + type: z.literal("displacements"), + path: z.string(), + interpolation: z.string().optional(), input: z.union([z.string(), z.array(z.string())]).optional(), output: z.union([z.string(), z.array(z.string())]).optional(), name: z.string().optional(), }); -// Inverse transformation -export const InverseTransformationSchema: z.ZodType<{ - type: "inverseOf"; - transformation: BaseCoordinateTransformation; +type TransformationCommon = { input?: string | string[] | undefined; output?: string | string[] | undefined; name?: string | undefined; -}> = z.object({ - type: z.literal("inverseOf"), - transformation: BaseCoordinateTransformationSchema, +}; + +/** + * The full RFC-5 transformation union as a recursive type: wrapper + * transformations (sequence, bijection, byDimension) nest any + * transformation, matching the bundled JSON schema's recursive + * coordinateTransformation reference. + */ +export type CoordinateTransformation = + | ({ type: "identity" } & TransformationCommon) + | ({ type: "mapAxis"; mapAxis: number[] } & TransformationCommon) + | ( + & { + type: "translation"; + translation?: number[] | undefined; + path?: string | undefined; + } + & TransformationCommon + ) + | ( + & { + type: "scale"; + scale?: number[] | undefined; + path?: string | undefined; + } + & TransformationCommon + ) + | ( + & { + type: "affine"; + affine?: number[][] | undefined; + path?: string | undefined; + } + & TransformationCommon + ) + | ( + & { + type: "rotation"; + rotation?: number[] | undefined; + path?: string | undefined; + } + & TransformationCommon + ) + | ( + & { type: "coordinates"; path: string; interpolation?: string | undefined } + & TransformationCommon + ) + | ( + & { + type: "displacements"; + path: string; + interpolation?: string | undefined; + } + & TransformationCommon + ) + | ( + & { type: "sequence"; transformations: CoordinateTransformation[] } + & TransformationCommon + ) + | ( + & { + type: "bijection"; + forward: CoordinateTransformation; + inverse: CoordinateTransformation; + } + & TransformationCommon + ) + | ( + & { + type: "byDimension"; + transformations: Array<{ + transformation: CoordinateTransformation; + input_axes: number[]; + output_axes: number[]; + }>; + } + & TransformationCommon + ); + +// Complete coordinate transformation schema (union of all types). Lazy so the +// wrapper schemas below can nest it recursively. +export const CoordinateTransformationSchema: z.ZodType< + CoordinateTransformation +> = z.lazy(() => + z.union([ + IdentityTransformationSchema, + MapAxisTransformationSchema, + TranslationTransformationSchema, + ScaleTransformationSchema, + AffineTransformationSchema, + RotationTransformationSchema, + CoordinatesTransformationSchema, + DisplacementsTransformationSchema, + SequenceTransformationSchema, + BijectionTransformationSchema, + ByDimensionTransformationSchema, + ]) +); + +// Sequence transformation (for chaining transformations) +export const SequenceTransformationSchema: z.ZodType< + Extract +> = z.object({ + type: z.literal("sequence"), + transformations: z.array(z.lazy(() => CoordinateTransformationSchema)), input: z.union([z.string(), z.array(z.string())]).optional(), output: z.union([z.string(), z.array(z.string())]).optional(), name: z.string().optional(), }); // Bijection transformation (forward and inverse) -export const BijectionTransformationSchema: z.ZodType<{ - type: "bijection"; - forward: BaseCoordinateTransformation; - inverse: BaseCoordinateTransformation; - input?: string | string[] | undefined; - output?: string | string[] | undefined; - name?: string | undefined; -}> = z.object({ +export const BijectionTransformationSchema: z.ZodType< + Extract +> = z.object({ type: z.literal("bijection"), - forward: BaseCoordinateTransformationSchema, - inverse: BaseCoordinateTransformationSchema, + forward: z.lazy(() => CoordinateTransformationSchema), + inverse: z.lazy(() => CoordinateTransformationSchema), input: z.union([z.string(), z.array(z.string())]).optional(), output: z.union([z.string(), z.array(z.string())]).optional(), name: z.string().optional(), }); -// By dimension transformation -export const ByDimensionTransformationSchema: z.ZodType<{ - type: "byDimension"; - transformations: BaseCoordinateTransformation[]; - input?: string | string[] | undefined; - output?: string | string[] | undefined; - name?: string | undefined; +// One wrapped item of a byDimension transformation. The axis arrays hold +// zero-based indices into the parent's input and output coordinate systems. +export const ByDimensionItemSchema: z.ZodType<{ + transformation: CoordinateTransformation; + input_axes: number[]; + output_axes: number[]; }> = z.object({ + transformation: z.lazy(() => CoordinateTransformationSchema), + input_axes: z.array(z.number().int().nonnegative()), + output_axes: z.array(z.number().int().nonnegative()), +}); + +// By dimension transformation: a high dimensional transformation built from +// lower dimensional transformations on subsets of dimensions. +export const ByDimensionTransformationSchema: z.ZodType< + Extract +> = z.object({ type: z.literal("byDimension"), - transformations: z.array(BaseCoordinateTransformationSchema), + transformations: z.array(ByDimensionItemSchema), input: z.union([z.string(), z.array(z.string())]).optional(), output: z.union([z.string(), z.array(z.string())]).optional(), name: z.string().optional(), }); -// Complete coordinate transformation schema (union of all types) -export const CoordinateTransformationSchema: z.ZodType< - | { - type: "identity"; - input?: string | string[] | undefined; - output?: string | string[] | undefined; - name?: string | undefined; - } - | { - type: "mapAxis"; - mapAxis: Record; - input?: string | string[] | undefined; - output?: string | string[] | undefined; - name?: string | undefined; - } - | { - type: "translation"; - translation?: number[] | undefined; - path?: string | undefined; - input?: string | string[] | undefined; - output?: string | string[] | undefined; - name?: string | undefined; - } - | { - type: "scale"; - scale?: number[] | undefined; - path?: string | undefined; - input?: string | string[] | undefined; - output?: string | string[] | undefined; - name?: string | undefined; - } - | { - type: "affine"; - affine?: number[][] | undefined; - path?: string | undefined; - input?: string | string[] | undefined; - output?: string | string[] | undefined; - name?: string | undefined; - } - | { - type: "rotation"; - rotation?: number[] | undefined; - path?: string | undefined; - input?: string | string[] | undefined; - output?: string | string[] | undefined; - name?: string | undefined; - } - | { - type: "sequence"; - transformations: BaseCoordinateTransformation[]; - input?: string | string[] | undefined; - output?: string | string[] | undefined; - name?: string | undefined; - } - | { - type: "inverseOf"; - transformation: BaseCoordinateTransformation; - input?: string | string[] | undefined; - output?: string | string[] | undefined; - name?: string | undefined; - } - | { - type: "bijection"; - forward: BaseCoordinateTransformation; - inverse: BaseCoordinateTransformation; - input?: string | string[] | undefined; - output?: string | string[] | undefined; - name?: string | undefined; - } - | { - type: "byDimension"; - transformations: BaseCoordinateTransformation[]; - input?: string | string[] | undefined; - output?: string | string[] | undefined; - name?: string | undefined; - } -> = z.union([ - IdentityTransformationSchema, - MapAxisTransformationSchema, - TranslationTransformationSchema, - ScaleTransformationSchema, - AffineTransformationSchema, - RotationTransformationSchema, - SequenceTransformationSchema, - InverseTransformationSchema, - BijectionTransformationSchema, - ByDimensionTransformationSchema, -]); - // Array coordinate system schema export const ArrayCoordinateSystemSchema = z.object({ name: z.string().min(1), @@ -333,14 +350,13 @@ export type RotationTransformation = z.infer< export type SequenceTransformation = z.infer< typeof SequenceTransformationSchema >; -export type InverseTransformation = z.infer; export type BijectionTransformation = z.infer< typeof BijectionTransformationSchema >; +export type ByDimensionTransformationItem = z.infer< + typeof ByDimensionItemSchema +>; export type ByDimensionTransformation = z.infer< typeof ByDimensionTransformationSchema >; -export type CoordinateTransformation = z.infer< - typeof CoordinateTransformationSchema ->; export type ArrayCoordinateSystem = z.infer; diff --git a/ts/src/types/zarr_metadata.ts b/ts/src/types/zarr_metadata.ts index 81885421..98fb33cb 100644 --- a/ts/src/types/zarr_metadata.ts +++ b/ts/src/types/zarr_metadata.ts @@ -113,6 +113,57 @@ export interface Displacements { name?: string; } +/** + * RFC 5 mapAxis transformation (v0.6): an axis permutation stored as a + * transpose vector of integer indices. The value at position `i` names which + * input axis becomes the `i`-th output axis; every zero-based input axis + * index appears exactly once. + */ +export interface MapAxis { + mapAxis: number[]; + type: "mapAxis"; + input?: CoordinateSystemIdentifier; + output?: CoordinateSystemIdentifier; + name?: string; +} + +/** + * One lower-dimensional transformation of a byDimension transform. The + * `input_axes` and `output_axes` arrays hold zero-based axis indices into the + * parent byDimension's input and output coordinate systems. + */ +export interface ByDimensionItem { + transformation: V06Transform; + input_axes: number[]; + output_axes: number[]; +} + +/** + * RFC 5 byDimension transformation (v0.6): a high dimensional transform built + * from lower dimensional ones. Every axis index of the output coordinate + * system appears in exactly one item's `output_axes`. + */ +export interface ByDimension { + transformations: ByDimensionItem[]; + type: "byDimension"; + input?: CoordinateSystemIdentifier; + output?: CoordinateSystemIdentifier; + name?: string; +} + +/** + * RFC 5 bijection transformation (v0.6): an invertible transform with + * explicit forward and inverse directions. + */ +export interface Bijection { + forward: V06Transform; + inverse: V06Transform; + type: "bijection"; + input?: CoordinateSystemIdentifier; + output?: CoordinateSystemIdentifier; + name?: string; +} + /** RFC 5 sequence transformation, chaining sub-transformations (v0.6). */ export interface TransformSequence { transformations: V06Transform[]; @@ -140,6 +191,9 @@ export type V06Transform = | Affine | Coordinates | Displacements + | MapAxis + | ByDimension + | Bijection | TransformSequence; export interface Dataset { @@ -313,6 +367,30 @@ export function createTransformSequence( return { transformations: [...transformations], type: "sequence" }; } +export function createMapAxis(mapAxis: number[]): MapAxis { + return { mapAxis: [...mapAxis], type: "mapAxis" }; +} + +export function createByDimension( + transformations: ByDimensionItem[], +): ByDimension { + return { + transformations: transformations.map((item) => ({ + transformation: item.transformation, + input_axes: [...item.input_axes], + output_axes: [...item.output_axes], + })), + type: "byDimension", + }; +} + +export function createBijection( + forward: V06Transform, + inverse: V06Transform, +): Bijection { + return { forward, inverse, type: "bijection" }; +} + export function createCoordinateSystem( name: string, axes: Axis[], diff --git a/ts/src/utils/from_zarr_attrs.ts b/ts/src/utils/from_zarr_attrs.ts index ab46f164..7c347a61 100644 --- a/ts/src/utils/from_zarr_attrs.ts +++ b/ts/src/utils/from_zarr_attrs.ts @@ -690,6 +690,7 @@ export async function fromZarrAttrsV06( const parsed = parseV06Transforms( dataset.coordinateTransformations as Array>, coordinateSystemNames, + coordinateSystems, ); const extracted = extractScaleTranslation(parsed, dims); scaleValues = extracted.scale; @@ -738,6 +739,7 @@ export async function fromZarrAttrsV06( coordinateTransformations = parseV06Transforms( entry.coordinateTransformations as Array>, coordinateSystemNames, + coordinateSystems, ); } diff --git a/ts/src/utils/v06_metadata.ts b/ts/src/utils/v06_metadata.ts index 8afcb450..18b6ac80 100644 --- a/ts/src/utils/v06_metadata.ts +++ b/ts/src/utils/v06_metadata.ts @@ -12,6 +12,7 @@ */ import type { + ByDimensionItem, CoordinateSystem, CoordinateSystemIdentifier, MetadataInterface, @@ -150,6 +151,20 @@ export function serializeV06Transform( out.interpolation = transform.interpolation; } break; + case "mapAxis": + out.mapAxis = transform.mapAxis; + break; + case "byDimension": + out.transformations = transform.transformations.map((item) => ({ + transformation: serializeV06Transform(item.transformation), + input_axes: item.input_axes, + output_axes: item.output_axes, + })); + break; + case "bijection": + out.forward = serializeV06Transform(transform.forward); + out.inverse = serializeV06Transform(transform.inverse); + break; case "sequence": out.transformations = transform.transformations.map( serializeV06Transform, @@ -170,13 +185,17 @@ export function serializeV06Transform( export function parseV06Transforms( raw: Array>, coordinateSystemNames: string[], + coordinateSystems?: CoordinateSystem[], ): V06Transform[] { - return raw.map((entry) => parseV06Transform(entry, coordinateSystemNames)); + return raw.map((entry) => + parseV06Transform(entry, coordinateSystemNames, coordinateSystems) + ); } function parseV06Transform( entry: Record, coordinateSystemNames: string[], + coordinateSystems?: CoordinateSystem[], ): V06Transform { const type = String(entry.type); let transform: V06Transform; @@ -227,6 +246,56 @@ function parseV06Transform( } break; } + case "mapAxis": + transform = { + type: "mapAxis", + mapAxis: asIntegerArray(entry.mapAxis, "mapAxis"), + }; + break; + case "byDimension": { + if (!Array.isArray(entry.transformations)) { + throw new Error( + "Invalid byDimension transform: 'transformations' must be an array", + ); + } + const items: ByDimensionItem[] = entry.transformations.map( + (rawItem: unknown) => { + const item = rawItem as Record; + if (item === null || typeof item !== "object") { + throw new Error( + "Invalid byDimension transform: each item must be an object " + + "holding 'transformation', 'input_axes' and 'output_axes'", + ); + } + return { + transformation: parseV06Transform( + item.transformation as Record, + coordinateSystemNames, + coordinateSystems, + ), + input_axes: asIntegerArray(item.input_axes, "byDimension"), + output_axes: asIntegerArray(item.output_axes, "byDimension"), + }; + }, + ); + transform = { type: "byDimension", transformations: items }; + break; + } + case "bijection": + transform = { + type: "bijection", + forward: parseV06Transform( + entry.forward as Record, + coordinateSystemNames, + coordinateSystems, + ), + inverse: parseV06Transform( + entry.inverse as Record, + coordinateSystemNames, + coordinateSystems, + ), + }; + break; case "sequence": if (!Array.isArray(entry.transformations)) { throw new Error( @@ -238,6 +307,7 @@ function parseV06Transform( transformations: parseV06Transforms( entry.transformations as Array>, coordinateSystemNames, + coordinateSystems, ), }; break; @@ -257,9 +327,154 @@ function parseV06Transform( transform.name = entry.name; } + validateV06Transform(transform, coordinateSystems ?? []); return transform; } +/** Number of axes of the referenced coordinate system, when resolvable. */ +function axisCount( + identifier: CoordinateSystemIdentifier | undefined, + coordinateSystems: CoordinateSystem[], +): number | undefined { + if (identifier === undefined || identifier.name === undefined) { + return undefined; + } + return coordinateSystems.find((cs) => cs.name === identifier.name)?.axes + .length; +} + +/** Dimensionality a byDimension item's axis lists must have, if knowable. */ +function itemDimensions(transformation: V06Transform): number | undefined { + switch (transformation.type) { + case "scale": + return transformation.scale.length; + case "translation": + return transformation.translation.length; + case "mapAxis": + return transformation.mapAxis.length; + default: + return undefined; + } +} + +/** + * Check the RFC 5 constraints the schema cannot express, mirroring the Python + * `validate_transform`: a `mapAxis` must be a permutation, a `byDimension` + * must cover every output axis exactly once with dimensionally consistent + * items, and a `bijection` must join coordinate systems of equal + * dimensionality. Dimension checks against `input`/`output` run only when + * those resolve to a known coordinate system. + */ +export function validateV06Transform( + transform: V06Transform, + coordinateSystems: CoordinateSystem[], +): void { + if (transform.type === "mapAxis") { + const indices = transform.mapAxis; + if (!indices.every((axis) => Number.isInteger(axis))) { + throw new Error( + `mapAxis axis indices must be integers; got [${indices}]`, + ); + } + if (indices.length < 2 || indices.length > 5) { + throw new Error( + "mapAxis must hold between 2 and 5 indices, one per axis of the " + + `coordinate systems it permutes; got [${indices}]`, + ); + } + const sorted = [...indices].sort((a, b) => a - b); + if (!sorted.every((value, position) => value === position)) { + throw new Error( + "mapAxis must be a permutation holding every zero-based input axis " + + `index exactly once; got [${indices}]`, + ); + } + for (const identifier of [transform.input, transform.output]) { + const count = axisCount(identifier, coordinateSystems); + if (count !== undefined && count !== indices.length) { + throw new Error( + `mapAxis length ${indices.length} does not match the ${count} ` + + `axes of coordinate system '${identifier?.name}'`, + ); + } + } + } else if (transform.type === "byDimension") { + const inputCount = axisCount(transform.input, coordinateSystems); + const seenOutputAxes = new Set(); + for (const item of transform.transformations) { + const axes = [...item.input_axes, ...item.output_axes]; + if (!axes.every((axis) => Number.isInteger(axis))) { + throw new Error( + `byDimension axis indices must be integers; got [${axes}]`, + ); + } + if (axes.some((axis) => axis < 0)) { + throw new Error( + `byDimension axis indices must be non-negative; got [${axes}]`, + ); + } + if ( + inputCount !== undefined && + item.input_axes.some((axis) => axis >= inputCount) + ) { + throw new Error( + `byDimension input axes [${item.input_axes}] exceed the ` + + `${inputCount} axes of coordinate system '${transform.input?.name}'`, + ); + } + for (const axis of item.output_axes) { + if (seenOutputAxes.has(axis)) { + throw new Error( + "byDimension output axes must each be produced by exactly one " + + `transformation; axis ${axis} appears more than once`, + ); + } + seenOutputAxes.add(axis); + } + const dimensions = itemDimensions(item.transformation); + if ( + dimensions !== undefined && + (item.input_axes.length !== dimensions || + item.output_axes.length !== dimensions) + ) { + throw new Error( + `byDimension item of type '${item.transformation.type}' is ` + + `${dimensions}-dimensional but maps ${item.input_axes.length} ` + + `input axes to ${item.output_axes.length} output axes`, + ); + } + } + const count = axisCount(transform.output, coordinateSystems); + if (count !== undefined) { + const expected = Array.from({ length: count }, (_, axis) => axis); + if ( + seenOutputAxes.size !== count || + !expected.every((axis) => seenOutputAxes.has(axis)) + ) { + throw new Error( + "byDimension items must cover every output axis exactly once; " + + `coordinate system '${transform.output?.name}' has ${count} axes ` + + `but the items produce [${ + [...seenOutputAxes].sort((a, b) => a - b) + }]`, + ); + } + } + } else if (transform.type === "bijection") { + const inputCount = axisCount(transform.input, coordinateSystems); + const outputCount = axisCount(transform.output, coordinateSystems); + if ( + inputCount !== undefined && outputCount !== undefined && + inputCount !== outputCount + ) { + throw new Error( + "bijection input and output coordinate systems must have the same " + + `dimensionality; got ${inputCount} and ${outputCount}`, + ); + } + } +} + /** * Extract the effective scale and translation from a dataset's v0.6 * transformations (unwrapping a `sequence`), defaulting to identity. Mirrors @@ -342,6 +557,19 @@ function asNumberArray(value: unknown, field: string): number[] { return value as number[]; } +/** Validate that a parsed transform field is a flat array of integers. */ +function asIntegerArray(value: unknown, field: string): number[] { + if ( + !Array.isArray(value) || + !value.every((v) => typeof v === "number" && Number.isInteger(v)) + ) { + throw new Error( + `Invalid ${field} transform: expected an array of integers`, + ); + } + return value as number[]; +} + /** Validate that a parsed transform field is a 2D array of numbers. */ function asNumberMatrix(value: unknown, field: string): number[][] { if ( diff --git a/ts/test/v06_coordinate_transformations_test.ts b/ts/test/v06_coordinate_transformations_test.ts index 68109416..7a60e0d4 100644 --- a/ts/test/v06_coordinate_transformations_test.ts +++ b/ts/test/v06_coordinate_transformations_test.ts @@ -10,13 +10,21 @@ * relying on downloaded fixtures. */ -import { assertEquals, assertExists, assertRejects } from "@std/assert"; +import { + assertEquals, + assertExists, + assertRejects, + assertThrows, +} from "@std/assert"; import { AnatomicalOrientationValues, createAffine, createAxis, + createBijection, + createByDimension, createCoordinateSystem, createIdentity, + createMapAxis, createRotation, createScale, createTransformSequence, @@ -37,6 +45,13 @@ import { import { fromOmeZarr as fromOmeZarrBrowser } from "../src/io/from_ngff_zarr-browser.ts"; import { toOmeZarr as toOmeZarrBrowser } from "../src/io/to_ngff_zarr-browser.ts"; import { prepareRfc9Metadata } from "../src/io/to_ngff_zarr_ozx_common.ts"; +import { CoordinateTransformationSchema } from "../src/schemas/coordinate_systems.ts"; +import { + parseV06Transforms, + validateV06Transform, +} from "../src/utils/v06_metadata.ts"; +import { fromFileUrl } from "@std/path"; +import type { CoordinateSystem } from "../src/types/zarr_metadata.ts"; async function buildMultiscales(): Promise { const shape = [32, 32, 32]; @@ -200,6 +215,23 @@ function transformsToRoundTrip(): V06Transform[] { createScale([2.0, 2.0, 2.0]), createTranslation([10.0, 20.0, 30.0]), ]), + createMapAxis([2, 0, 1]), + createByDimension([ + { + transformation: createScale([2.0, 3.0]), + input_axes: [0, 1], + output_axes: [0, 1], + }, + { + transformation: createTranslation([5.0]), + input_axes: [2], + output_axes: [2], + }, + ]), + createBijection( + { type: "displacements", path: "forward_field" }, + { type: "displacements", path: "inverse_field" }, + ), ]; } @@ -731,3 +763,263 @@ Deno.test("0.6.dev4 is a supported version", () => { assertEquals(isV06Version("0.6"), true); assertEquals(isV06Version("0.5"), false); }); + +// Mirrors test_coordinate_transformations.py: the mapAxis, byDimension and +// bijection payloads survive the round-trip by value, not just by type. +Deno.test("mapAxis, byDimension and bijection payloads survive the round-trip", async () => { + const multiscales = await buildMultiscales(); + const intrinsic = multiscales.metadata.coordinateSystems![0]; + + const mapAxis = createMapAxis([2, 0, 1]); + const byDimension = createByDimension([ + { + transformation: createScale([2.0, 3.0]), + input_axes: [0, 1], + output_axes: [0, 1], + }, + { + transformation: createTranslation([5.0]), + input_axes: [2], + output_axes: [2], + }, + ]); + const bijection = createBijection( + { type: "displacements", path: "forward_field" }, + { type: "displacements", path: "inverse_field" }, + ); + for (const t of [mapAxis, byDimension, bijection]) { + t.input = { name: intrinsic.name }; + t.output = { name: intrinsic.name }; + } + multiscales.metadata.coordinateTransformations = [ + mapAxis, + byDimension, + bijection, + ]; + + const store: MemoryStore = new Map(); + await toOmeZarr(store, multiscales, { version: "0.6" }); + const imported = await fromOmeZarr(store); + + const transforms = imported.metadata.coordinateTransformations!; + assertEquals(transforms.length, 3); + const [importedMapAxis, importedByDimension, importedBijection] = transforms; + if (importedMapAxis.type !== "mapAxis") { + throw new Error("expected a mapAxis transform"); + } + if (importedByDimension.type !== "byDimension") { + throw new Error("expected a byDimension transform"); + } + if (importedBijection.type !== "bijection") { + throw new Error("expected a bijection transform"); + } + assertEquals(importedMapAxis.mapAxis, [2, 0, 1]); + assertEquals(importedByDimension.transformations.length, 2); + const [first, second] = importedByDimension.transformations; + assertEquals(first.input_axes, [0, 1]); + assertEquals(first.output_axes, [0, 1]); + if (first.transformation.type !== "scale") { + throw new Error("expected a scale item transformation"); + } + assertEquals(first.transformation.scale, [2.0, 3.0]); + assertEquals(second.input_axes, [2]); + assertEquals(second.output_axes, [2]); + if (importedBijection.forward.type !== "displacements") { + throw new Error("expected a displacements forward transformation"); + } + assertEquals(importedBijection.forward.path, "forward_field"); + if (importedBijection.inverse.type !== "displacements") { + throw new Error("expected a displacements inverse transformation"); + } + assertEquals(importedBijection.inverse.path, "inverse_field"); +}); + +// The RFC 5 constraints the schema cannot express are enforced at read time. +Deno.test("invalid mapAxis, byDimension and bijection metadata are rejected", async () => { + const cases: Array<{ name: string; transform: V06Transform }> = [ + // Not a permutation: index 1 missing, index 2 duplicated. + { name: "mapAxis gap", transform: createMapAxis([2, 0, 2]) }, + // Output axis 1 produced by two items. + { + name: "byDimension duplicate output", + transform: createByDimension([ + { + transformation: createScale([2.0, 3.0]), + input_axes: [0, 1], + output_axes: [0, 1], + }, + { + transformation: createTranslation([5.0]), + input_axes: [2], + output_axes: [1], + }, + ]), + }, + // A 2D scale mapping a single axis. + { + name: "byDimension dimension mismatch", + transform: createByDimension([ + { + transformation: createScale([2.0, 3.0]), + input_axes: [0], + output_axes: [0], + }, + ]), + }, + ]; + + for (const { name, transform } of cases) { + const multiscales = await buildMultiscales(); + const intrinsic = multiscales.metadata.coordinateSystems![0]; + transform.input = { name: intrinsic.name }; + transform.output = { name: intrinsic.name }; + multiscales.metadata.coordinateTransformations = [transform]; + + const store: MemoryStore = new Map(); + await toOmeZarr(store, multiscales, { version: "0.6" }); + await assertRejects( + () => fromOmeZarr(store), + Error, + undefined, + `case '${name}' should be rejected`, + ); + } +}); + +// byDimension items must cover every output axis of a resolved coordinate +// system exactly once; two items over three output axes leave one uncovered. +Deno.test("byDimension incomplete output coverage is rejected", async () => { + const multiscales = await buildMultiscales(); + const intrinsic = multiscales.metadata.coordinateSystems![0]; + const byDimension = createByDimension([ + { + transformation: createScale([2.0, 3.0]), + input_axes: [0, 1], + output_axes: [0, 1], + }, + ]); + byDimension.input = { name: intrinsic.name }; + byDimension.output = { name: intrinsic.name }; + multiscales.metadata.coordinateTransformations = [byDimension]; + + const store: MemoryStore = new Map(); + await toOmeZarr(store, multiscales, { version: "0.6" }); + await assertRejects(() => fromOmeZarr(store), Error, "exactly once"); +}); + +// A byDimension item may wrap any transformation, including a sequence; the +// round-trip and the zod schema both accept the nesting. +Deno.test("byDimension items nest wrapper transformations", async () => { + const multiscales = await buildMultiscales(); + const intrinsic = multiscales.metadata.coordinateSystems![0]; + const byDimension = createByDimension([ + { + transformation: createTransformSequence([ + createScale([2.0, 3.0]), + createTranslation([1.0, 1.0]), + ]), + input_axes: [0, 1], + output_axes: [0, 1], + }, + { + transformation: createTranslation([5.0]), + input_axes: [2], + output_axes: [2], + }, + ]); + byDimension.input = { name: intrinsic.name }; + byDimension.output = { name: intrinsic.name }; + multiscales.metadata.coordinateTransformations = [byDimension]; + + const store: MemoryStore = new Map(); + await toOmeZarr(store, multiscales, { version: "0.6" }); + const imported = await fromOmeZarr(store); + + const transform = imported.metadata.coordinateTransformations![0]; + if (transform.type !== "byDimension") { + throw new Error("expected a byDimension transform"); + } + const nested = transform.transformations[0].transformation; + if (nested.type !== "sequence") { + throw new Error("expected a nested sequence transformation"); + } + assertEquals(nested.transformations.length, 2); + + const parsed = CoordinateTransformationSchema.parse({ + type: "byDimension", + transformations: [ + { + transformation: { + type: "sequence", + transformations: [{ type: "scale", scale: [2.0, 3.0] }], + }, + input_axes: [0, 1], + output_axes: [0, 1], + }, + ], + }); + assertEquals(parsed.type, "byDimension"); +}); + +// Programmatically built transforms are validated with the same rules the +// reader applies to on-disk metadata. +Deno.test("validateV06Transform rejects fractional and out-of-range axes", () => { + assertThrows( + () => validateV06Transform(createMapAxis([0.5, 1, 2]), []), + Error, + "integers", + ); + + const threeAxisSystem = createCoordinateSystem("system", [ + createAxis("z", "space"), + createAxis("y", "space"), + createAxis("x", "space"), + ]); + const outOfRange = createByDimension([ + { + transformation: createScale([2.0, 3.0, 4.0]), + input_axes: [0, 1, 3], + output_axes: [0, 1, 2], + }, + ]); + outOfRange.input = { name: "system" }; + outOfRange.output = { name: "system" }; + assertThrows( + () => validateV06Transform(outOfRange, [threeAxisSystem]), + Error, + "exceed", + ); +}); + +// OME-Zarr coordinate systems hold 2 to 5 axes; a mapAxis of another length +// is rejected on read as well as by the zod schema. +Deno.test("validateV06Transform bounds the mapAxis arity", () => { + for (const indices of [[0], [], [5, 4, 3, 2, 1, 0]]) { + assertThrows( + () => validateV06Transform(createMapAxis(indices), []), + Error, + "between 2 and 5", + ); + } +}); + +// rfc5_transform_cases.json is shared with the Python suite: both readers must +// give the same verdict on every case, so a rule enforced in one port and not +// the other fails here. +Deno.test("shared RFC-5 cases match the expected verdict", async () => { + const path = fromFileUrl( + new URL("../../py/test/rfc5_transform_cases.json", import.meta.url), + ); + const spec = JSON.parse(await Deno.readTextFile(path)); + const systems = spec.coordinateSystems as CoordinateSystem[]; + const names = systems.map((system) => system.name); + for (const testCase of spec.cases) { + const parse = () => + parseV06Transforms([testCase.transformation], names, systems); + if (testCase.ok) { + parse(); + } else { + assertThrows(parse, Error, undefined, testCase.name); + } + } +});