Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions py/ngff_zarr/to_ngff_zarr.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down
120 changes: 120 additions & 0 deletions py/test/test_ngff_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from_ngff_zarr,
from_ome_zarr,
to_multiscales,
to_ngff_image,
to_ngff_zarr,
to_ome_zarr,
validate,
Expand Down Expand Up @@ -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")
Loading