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
9 changes: 9 additions & 0 deletions docs/rfc5.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,22 @@ The transformation data classes live in `ngff_zarr.v06.zarr_metadata`:
| `Coordinates` | `coordinates` | `path: str`, `interpolation: str` |
| `TransformSequence` | `sequence` | `transformations: list[Transform]` |
| `MapAxis` | `mapAxis` | `mapAxis: list[int]` |
| `ProjectAxis` | `projectAxis` | `droppedInputs: list[int]`, `createdOutputs: 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.

`ProjectAxis` changes the dimensionality of a coordinate vector:
`droppedInputs` names the indices of the input vector to remove and
`createdOutputs` the indices of the output vector where a zero is inserted. At
least one of the two is given, the indices in each are unique, and the output
dimensionality is the input dimensionality less the dropped axes plus the
created ones. Dropping an axis loses information, so a projection is not
invertible in general.

`ByDimension` builds a high dimensional transform from lower dimensional ones;
each `ByDimensionItem` wraps a transformation with the `inputAxes` and `outputAxes`
(zero-based indices into the parent's coordinate systems) it applies to, and
Expand Down
14 changes: 14 additions & 0 deletions py/ngff_zarr/to_ngff_zarr.py
Original file line number Diff line number Diff line change
Expand Up @@ -280,11 +280,17 @@ def _gate_top_level_transforms(metadata, version: str) -> None:

0.9.dev1 is the 0.6 model with the axis restrictions relaxed, so its
inter-system transforms carry the same requirement and the gate covers it.

Each transform then runs the reader's ``validate_transform`` against the
systems it names, so a store this writes is one it can read back.
"""
if version not in ("0.6", NgffVersion.V09dev1.value):
return
if not metadata.coordinateTransformations:
return
from .v06.zarr_metadata import validate_transform

systems = getattr(metadata, "coordinateSystems", None)
for index, transform in enumerate(metadata.coordinateTransformations):
for side in ("input", "output"):
reference = getattr(transform, side, None)
Expand All @@ -295,6 +301,14 @@ def _gate_top_level_transforms(metadata, version: str) -> None:
"OME-Zarr 0.6 requires every multiscale-level transformation "
"to name both its input and its output coordinate system"
)
try:
validate_transform(transform, systems)
except ValueError as invalid:
raise ValueError(
f"multiscales coordinateTransformations[{index}] "
f"({transform.type}) would be written as a transform this "
f"package cannot read back: {invalid}"
) from invalid


@dataclass(frozen=True)
Expand Down
109 changes: 109 additions & 0 deletions py/ngff_zarr/v06/zarr_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,101 @@ def validate(
)


#: The ``maxItems`` the schema sets on ``droppedInputs`` and ``createdOutputs``.
#: Its companion ``maximum: 4`` is not mirrored: an index is bounded by the
#: coordinate system it points into, which ``validate`` checks.
_PROJECT_AXIS_MAX_OPERATIONS = 3


@dataclass(kw_only=True)
class ProjectAxis(BaseTransform):
"""A projection that drops input axes and inserts zero-valued output axes.

``droppedInputs`` are indices of the input vector to remove,
``createdOutputs`` indices of the output vector to zero-fill; at least one
is given. Output dimensionality is the input less the dropped plus the
created. Dropping loses information, so it is not invertible in general.
"""

droppedInputs: list[int] | None = None
createdOutputs: list[int] | None = None
type: str = "projectAxis"

def __post_init__(self) -> None:
self._check_intrinsic()

def _check_intrinsic(self) -> None:
if self.droppedInputs is None and self.createdOutputs is None:
raise ValueError(
"projectAxis must declare droppedInputs, createdOutputs, or both"
)
for field_name in ("droppedInputs", "createdOutputs"):
indices = getattr(self, field_name)
if indices is None:
continue
_require_integer_axes(indices, field_name)
Comment thread
vboussot marked this conversation as resolved.
if not indices:
raise ValueError(f"{field_name} must hold at least one index")
if any(index < 0 for index in indices):
raise ValueError(
f"{field_name} indices are zero-based positions and must "
f"not be negative; got {indices}"
)
if len(set(indices)) != len(indices):
raise ValueError(f"{field_name} indices must be unique; got {indices}")
if len(indices) > _PROJECT_AXIS_MAX_OPERATIONS:
raise ValueError(
f"{field_name} may name at most "
f"{_PROJECT_AXIS_MAX_OPERATIONS} axes; got {indices}"
)

def validate(self, coordinateSystems: list[CoordinateSystem] | None = None) -> None:
Comment thread
vboussot marked this conversation as resolved.
self._check_intrinsic()
dropped = self.droppedInputs or []
created = self.createdOutputs or []
input_count = _resolved_axis_count(self.input, coordinateSystems)
output_count = _resolved_axis_count(self.output, coordinateSystems)

if input_count is not None:
if len(dropped) > input_count:
raise ValueError(
f"projectAxis drops {len(dropped)} axes, more than the "
f"{input_count} axes of coordinate system "
f"'{self.input.name}'"
)
beyond = [index for index in dropped if index >= input_count]
if beyond:
raise ValueError(
f"droppedInputs indices {beyond} are beyond the "
f"{input_count} axes of coordinate system "
f"'{self.input.name}'"
)

if output_count is not None:
if len(created) > output_count:
raise ValueError(
f"projectAxis creates {len(created)} axes, more than the "
f"{output_count} axes of coordinate system "
f"'{self.output.name}'"
)
beyond = [index for index in created if index >= output_count]
if beyond:
raise ValueError(
f"createdOutputs indices {beyond} are beyond the "
f"{output_count} axes of coordinate system "
f"'{self.output.name}'"
)

if input_count is not None and output_count is not None:
expected = input_count - len(dropped) + len(created)
if expected != output_count:
raise ValueError(
f"projectAxis maps {input_count} input axes to {expected} "
f"output axes, but coordinate system '{self.output.name}' "
f"declares {output_count}"
)


Transform = Union[
Identity,
Scale,
Expand All @@ -250,6 +345,7 @@ def validate(
Coordinates,
Displacements,
MapAxis,
ProjectAxis,
"ByDimension",
"Bijection",
"TransformSequence",
Expand Down Expand Up @@ -930,6 +1026,19 @@ def _parse_transforms(
elif transform["type"] == "mapAxis":
_require_keys(transform, ("mapAxis",), "mapAxis")
transformation = MapAxis(mapAxis=list(transform["mapAxis"]))
elif transform["type"] == "projectAxis":
transformation = ProjectAxis(
droppedInputs=(
list(transform["droppedInputs"])
if "droppedInputs" in transform
else None
),
createdOutputs=(
list(transform["createdOutputs"])
if "createdOutputs" in transform
else None
),
)
elif transform["type"] == "byDimension":
transformation = ByDimension.from_dict(transform, coordinateSystems)
elif transform["type"] == "bijection":
Expand Down
184 changes: 184 additions & 0 deletions py/test/rfc5_transform_cases.json
Original file line number Diff line number Diff line change
Expand Up @@ -576,6 +576,190 @@
"path": "f"
}
}
},
{
"name": "projectAxis_remove_valid",
"ok": true,
"transformation": {
"type": "projectAxis",
"droppedInputs": [
0
],
"input": {
"name": "s3"
},
"output": {
"name": "s2"
}
}
},
{
"name": "projectAxis_insert_valid",
"ok": true,
"transformation": {
"type": "projectAxis",
"createdOutputs": [
0
],
"input": {
"name": "s2"
},
"output": {
"name": "s3"
}
}
},
{
"name": "projectAxis_remove_and_insert_valid",
"ok": true,
"transformation": {
"type": "projectAxis",
"droppedInputs": [
0
],
"createdOutputs": [
1
],
"input": {
"name": "s3"
},
"output": {
"name": "s3"
}
}
},
{
"name": "projectAxis_unresolved_systems_valid",
"ok": true,
"transformation": {
"type": "projectAxis",
"droppedInputs": [
2
]
}
},
{
"name": "projectAxis_missing_op",
"ok": false,
"transformation": {
"type": "projectAxis"
}
},
{
"name": "projectAxis_remove_non_unique",
"ok": false,
"transformation": {
"type": "projectAxis",
"droppedInputs": [
0,
0
]
}
},
{
"name": "projectAxis_insert_non_unique",
"ok": false,
"transformation": {
"type": "projectAxis",
"createdOutputs": [
1,
1
]
}
},
{
"name": "projectAxis_remove_too_high_dim",
"ok": false,
"transformation": {
"type": "projectAxis",
"droppedInputs": [
3
],
"input": {
"name": "s3"
},
"output": {
"name": "s2"
}
}
},
{
"name": "projectAxis_insert_too_high_dim",
"ok": false,
"transformation": {
"type": "projectAxis",
"createdOutputs": [
3
],
"input": {
"name": "s2"
},
"output": {
"name": "s3"
}
}
},
{
"name": "projectAxis_remove_too_many",
"ok": false,
"transformation": {
"type": "projectAxis",
"droppedInputs": [
0,
1,
2
],
"input": {
"name": "s2"
},
"output": {
"name": "s2"
}
}
},
{
"name": "projectAxis_insert_too_many",
"ok": false,
"transformation": {
"type": "projectAxis",
"createdOutputs": [
0,
1,
2
],
"input": {
"name": "s2"
},
"output": {
"name": "s2"
}
}
},
{
"name": "projectAxis_dimension_mismatch",
"ok": false,
"transformation": {
"type": "projectAxis",
"droppedInputs": [
0
],
"input": {
"name": "s3"
},
"output": {
"name": "s3"
}
}
},
{
"name": "projectAxis_negative_index",
"ok": false,
"transformation": {
"type": "projectAxis",
"droppedInputs": [
-1
]
}
}
]
}
Loading
Loading