diff --git a/py/ngff_zarr/to_ngff_zarr.py b/py/ngff_zarr/to_ngff_zarr.py index d054ec62..9cdb882c 100644 --- a/py/ngff_zarr/to_ngff_zarr.py +++ b/py/ngff_zarr/to_ngff_zarr.py @@ -306,6 +306,78 @@ def _validate_ngff_parameters( ) +def _gate_transform_arity(metadata) -> None: + """Refuse a scale or translation whose vector does not span the axes it applies to. + + A ``scale`` of two values over three axes is metadata no consumer can + apply, and nothing caught it: the 0.4 and 0.5 models carry no validation of + their own, from 0.6 ``Scale`` and ``Translation`` inherit a ``validate`` + that checks nothing, and the bundled schemas constrain what these vectors + hold rather than how many. Such a store was written and read back as valid + at every version, at the multiscales level and the dataset level alike. + + Which axes a vector spans is the transform's own question. From 0.6 a + transform maps between the coordinate systems it names, whose arity need + not be the intrinsic one: a spatial system beside a channel axis is what a + field transform declares. So the count comes from the systems a transform + references, when they resolve, and from the intrinsic axes otherwise -- + which is every 0.4 and 0.5 transform, none of which names a system. + """ + systems = { + system.name: len(system.axes) + for system in getattr(metadata, "coordinateSystems", None) or () + } + intrinsic = len(getattr(metadata, "axes", None) or ()) + if not intrinsic: + return + levels = [ + ("the multiscales", getattr(metadata, "coordinateTransformations", None) or ()) + ] + levels += [ + (f"dataset '{dataset.path}'", dataset.coordinateTransformations or ()) + for dataset in getattr(metadata, "datasets", None) or () + ] + for where, transforms in levels: + for index, transform in enumerate(transforms): + _gate_spans( + transform, + f"{where} coordinateTransformations[{index}]", + systems, + {intrinsic}, + ) + + +def _gate_spans( + transform, where: str, systems: dict[str, int], inherited: set[int] +) -> None: + """``transform``'s own vectors, then those of its sequence members. + + A member names no system of its own -- at 0.6 a dataset's scale and + translation sit inside one sequence -- so it spans what the sequence spans. + """ + named = { + systems[reference.name] + for reference in ( + getattr(transform, "input", None), + getattr(transform, "output", None), + ) + if reference is not None and reference.name in systems + } + spans = named or inherited + for kind in ("scale", "translation"): + vector = getattr(transform, kind, None) + if vector is not None and len(vector) not in spans: + axes = " or ".join(str(count) for count in sorted(spans)) + raise ValueError( + f"{where} ({transform.type}) gives {len(vector)} {kind} values " + f"for the {axes} axes it applies to; a transform that does not " + "span its axes cannot be applied by a reader." + ) + members = getattr(transform, "transformations", None) or () + for position, member in enumerate(members): + _gate_spans(member, f"{where}.transformations[{position}]", systems, spans) + + def _gate_top_level_transforms(metadata, version: str) -> None: """Refuse a multiscale-level transform the 0.6 schema cannot express. @@ -1582,6 +1654,7 @@ def _to_ngff_zarr_impl( root_attributes = _check_root_attributes(multiscales.root_attributes) metadata, dimension_names, _ = _prepare_metadata(multiscales, version) _gate_top_level_transforms(metadata, version) + _gate_transform_arity(metadata) if start_level: _guard_rewrite_of_read_levels( multiscales, store_path, metadata.datasets, start_level diff --git a/py/test/test_ngff_validation.py b/py/test/test_ngff_validation.py index da5638af..71387dba 100644 --- a/py/test/test_ngff_validation.py +++ b/py/test/test_ngff_validation.py @@ -10,6 +10,7 @@ from_ngff_zarr, from_ome_zarr, to_multiscales, + to_ngff_image, to_ngff_zarr, to_ome_zarr, validate, @@ -400,3 +401,122 @@ def test_read_path_rejects_a_forged_version_string(tmp_path): with pytest.raises(ValueError, match="No JSON Schema is bundled"): from_ngff_zarr(store, validate=True, version="0.6") + + +@pytest.mark.parametrize("values", [[2.0, 2.0], [2.0, 2.0, 2.0, 2.0]]) +def test_a_transform_that_does_not_span_its_axes_is_refused(tmp_path, values): + # The 0.4 and 0.5 models validate nothing of their own and the bundled + # schemas constrain what these vectors hold, not how many: a scale of two + # values over three axes was written, read back, and passed validate(), + # while no consumer could apply it. Short and long both. + import dataclasses + + from ngff_zarr.v04.zarr_metadata import Scale as ScaleV04 + + array = np.random.random((4, 8, 8)).astype("float32") + multiscales = to_multiscales(array, []) + metadata = multiscales.metadata.to_version("0.4") + metadata.coordinateTransformations = [ScaleV04(scale=list(values))] + + with pytest.raises( + ValueError, match="does not span its axes|values for the 3 axes" + ): + to_ome_zarr( + tmp_path / "refused.ome.zarr", + dataclasses.replace(multiscales, metadata=metadata), + version="0.4", + ) + + +def test_a_dataset_level_transform_is_checked_too(tmp_path): + # The mainstream path: every store carries dataset-level transforms, and + # they went unchecked at the same versions. + import dataclasses + + from ngff_zarr.v04.zarr_metadata import Scale as ScaleV04 + + array = np.random.random((4, 8, 8)).astype("float32") + multiscales = to_multiscales(array, []) + metadata = multiscales.metadata.to_version("0.4") + metadata.datasets[0].coordinateTransformations = [ScaleV04(scale=[2.0, 2.0])] + + with pytest.raises(ValueError, match="dataset"): + to_ome_zarr( + tmp_path / "refused.ome.zarr", + dataclasses.replace(multiscales, metadata=metadata), + version="0.4", + ) + + +@requires_zarr_v3 +def test_a_scale_nested_in_a_sequence_is_checked(tmp_path): + # The mainstream 0.6 representation: a dataset's scale and translation sit + # inside one TransformSequence, whose own attributes hold no vector. A gate + # that reads only the outer transform checks nothing at the version where + # every store is written this way. + import dataclasses + + array = np.random.random((4, 8, 8)).astype("float32") + multiscales = to_multiscales(array, []) + metadata = multiscales.metadata.to_version("0.6") + metadata.datasets[0].coordinateTransformations[0].transformations[0].scale = [ + 2.0, + 2.0, + ] + + with pytest.raises(ValueError, match="transformations\\[0\\]"): + to_ome_zarr( + tmp_path / "refused.ome.zarr", + dataclasses.replace(multiscales, metadata=metadata), + version="0.6", + ) + + +@requires_zarr_v3 +def test_a_transform_spans_the_systems_it_names(tmp_path): + # From 0.6 a transform maps between the coordinate systems it names, whose + # arity need not be the intrinsic one: here a spatial system beside a + # channel axis, which is what a field transform declares. Measured against + # the intrinsic axes the scale is short by one and the store is refused. + import dataclasses + + from ngff_zarr.v06.zarr_metadata import ( + Axis, + CoordinateSystem, + CoordinateSystemIdentifier, + Scale, + ) + + image = to_ngff_image( + np.zeros((2, 4, 5, 6), dtype=np.float32), + dims=["c", "z", "y", "x"], + scale={"c": 1.0, "z": 2.0, "y": 1.5, "x": 1.0}, + translation={"c": 0.0, "z": 5.0, "y": -3.0, "x": 7.0}, + ) + multiscales = to_multiscales(image, scale_factors=[], cache=False) + metadata = multiscales.metadata.to_version("0.6") + spatial = [Axis(name=name, type="space", unit=None) for name in ("z", "y", "x")] + metadata = dataclasses.replace( + metadata, + coordinateSystems=[ + *metadata.coordinateSystems, + CoordinateSystem(name="phys", axes=spatial), + ], + coordinateTransformations=[ + Scale( + input=CoordinateSystemIdentifier(name="phys"), + output=CoordinateSystemIdentifier(name="phys"), + scale=[2.0, 2.0, 2.0], + ) + ], + ) + + store = tmp_path / "named.ome.zarr" + to_ome_zarr( + store, dataclasses.replace(multiscales, metadata=metadata), version="0.6" + ) + + back = from_ome_zarr(store) + entry = back.metadata.coordinateTransformations[0] + assert entry.scale == [2.0, 2.0, 2.0] + assert (entry.input.name, entry.output.name) == ("phys", "phys")