From 16ec87bd0348ba8eec08df0c736ab6d7833e21db Mon Sep 17 00:00:00 2001 From: Valentin Boussot Date: Thu, 6 Aug 2026 15:21:34 +0200 Subject: [PATCH 01/11] feat: convert coordinate transformations between RFC-5 and ITK Adds the conversion in both directions, mirroring itk_image_to_ngff_image and ngff_image_to_itk_image, and lets itk_transform_resample_bounding_box take an RFC-5 transformation directly rather than only an ITK one. ngff_transform_to_itk_transform collapses a linear RFC-5 chain into a single ITK affine. itk_transform_to_ngff_transform goes the other way, which is what lets a registration result be written into the store: it accepts any linear itk.Transform, including the CompositeTransform Elastix returns, and recovers the mapping by evaluating the transform rather than decoding its parameters, so parameterizations that store angles or a quaternion convert as well as an affine. Three conventions differ between the specifications and are reconciled here: RFC-5 orders parameters in Zarr axis order while ITK orders them fastest-axis-first; an RFC-5 sequence applies its first entry first while an ITK transform list applies its last entry first; and ITK's center of rotation is folded into the offset, since an RFC-5 affine has none. By default the result is the least expressive transformation that represents the mapping exactly, which RFC-5 recommends and which multiscales datasets require. --- docs/itk.md | 184 +++++++-- docs/rfc5.md | 19 + py/ngff_zarr/__init__.py | 13 + .../itk_transform_resample_bounding_box.py | 61 ++- .../itk_transform_to_ngff_transform.py | 258 ++++++++++++ .../ngff_transform_to_itk_transform.py | 253 ++++++++++++ ...est_itk_transform_resample_bounding_box.py | 180 +++++++++ .../test_itk_transform_to_ngff_transform.py | 373 ++++++++++++++++++ ts/src/browser-mod.ts | 10 + ...transform_resample_bounding_box-browser.ts | 3 +- ...tk_transform_resample_bounding_box-node.ts | 21 +- ..._transform_resample_bounding_box-shared.ts | 36 +- ts/src/mod.ts | 10 + .../utils/itk_transform_to_ngff_transform.ts | 260 ++++++++++++ .../utils/ngff_transform_to_itk_transform.ts | 289 ++++++++++++++ ...tk_transform_resample_bounding_box_test.ts | 216 +++++++++- .../itk_transform_to_ngff_transform_test.ts | 210 ++++++++++ 17 files changed, 2340 insertions(+), 56 deletions(-) create mode 100644 py/ngff_zarr/itk_transform_to_ngff_transform.py create mode 100644 py/ngff_zarr/ngff_transform_to_itk_transform.py create mode 100644 py/test/test_itk_transform_to_ngff_transform.py create mode 100644 ts/src/utils/itk_transform_to_ngff_transform.ts create mode 100644 ts/src/utils/ngff_transform_to_itk_transform.ts create mode 100644 ts/test/itk_transform_to_ngff_transform_test.ts diff --git a/docs/itk.md b/docs/itk.md index ecaabfa4..5aedd7e5 100644 --- a/docs/itk.md +++ b/docs/itk.md @@ -55,43 +55,59 @@ overlapping region is wasteful. `itk_transform_resample_bounding_box` answers *which moving-image indices will the resample actually read?* from image geometry alone. The pixel buffers are -never touched and the Dask graphs are never computed: +never touched and the Dask graphs are never computed, so both images can be +described by a handful of numbers: ```python ->>> import itk +>>> import dask.array as da +>>> import numpy as np >>> import ngff_zarr as nz +>>> from ngff_zarr.v06.zarr_metadata import Affine +>>> +>>> fixed = nz.NgffImage( +... data=da.zeros((64, 64), dtype=np.uint8), +... dims=['y', 'x'], +... scale={'y': 1.0, 'x': 1.0}, +... translation={'y': 0.0, 'x': 0.0}) +>>> moving = nz.NgffImage( +... data=da.zeros((256, 256), dtype=np.uint8), +... dims=['y', 'x'], +... scale={'y': 1.0, 'x': 1.0}, +... translation={'y': 0.0, 'x': 0.0}) +>>> +>>> # An RFC-5 affine mapping fixed points into moving space. Its parameters +>>> # are in Zarr axis order, and the translation is the last column, so this +>>> # shifts y by +12 and x by -4. +>>> transform = Affine(affine=[[1.0, 0.0, 12.0], +... [0.0, 1.0, -4.0]]) >>> ->>> # Any linear or deformable ITK transform, including the CompositeTransform ->>> # an Elastix registration returns. It maps fixed points into moving space. ->>> transform = registration_method.GetCombinedTransform() # doctest: +SKIP ->>> region = nz.itk_transform_resample_bounding_box( # doctest: +SKIP -... transform, fixed_block, moving) ->>> region.start_index # doctest: +SKIP +>>> region = nz.itk_transform_resample_bounding_box(transform, fixed, moving) +>>> region.start_index {'y': 11, 'x': -5} +>>> region.size +{'y': 66, 'x': 66} ``` The result is keyed by dimension name, so there is no ambiguity about axis -order -- the underlying pipeline reports arrays fastest-axis-first, the reverse -of the Zarr order. - -`region.crop(moving)` returns a lazily sliced `NgffImage` whose `translation` -has been shifted to match, ready to hand to `ngff_image_to_itk_image`: +order. `region.crop(moving)` returns a lazily sliced `NgffImage` whose +`translation` has been shifted to match, ready to hand to +`ngff_image_to_itk_image`: ```python ->>> block = region.crop(moving) # doctest: +SKIP +>>> block = region.crop(moving) # still lazy; nothing read yet +>>> block.data.shape # clamped into the moving image bounds +(66, 61) +>>> block.translation +{'y': 11.0, 'x': 0.0} >>> moving_itk = nz.ngff_image_to_itk_image(block, wasm=False) # doctest: +SKIP ``` -Only that block's chunks are read. `crop` returns `None` when the transformed -grid does not overlap the moving image at all, so a tiling loop can skip it -instead of resampling nothing. Start indices may be negative when the grid -extends past the moving origin; `crop`, `slices` and `clamped` clamp into -bounds rather than letting a negative index wrap around. - -The image geometry is built the way `ngff_image_to_itk_image` builds it, -including the direction matrix derived from [RFC-4](./rfc4.md) anatomical -orientation, so the transform is applied in the space a registration produced -it in. +Only this block's chunks are read when `moving_itk` is finally built. `crop` +returns `None` when the transformed grid does not overlap the moving image at +all, so a tiling loop can skip that block instead of resampling nothing. Start +indices may be negative when the grid extends past the moving origin; `crop`, +`slices` and `clamped` all clamp into bounds rather than letting a negative +index wrap around. Use `padding` to cover the interpolator's support. The default of `1` covers linear interpolation, which reads one neighbor beyond the continuous index @@ -195,18 +211,124 @@ the transformed corners -- so the whole grid boundary is walked instead. Cost is proportional to the boundary, not the pixel count, and per block that boundary is small. +Displacement-field transforms work directly: + +```python +>>> region = nz.itk_transform_resample_bounding_box( # doctest: +SKIP +... displacement_field_transform, fixed, moving) +``` + +### Registration transforms from Elastix + +Transforms produced by a registration library can be passed directly, including +the `itk.CompositeTransform` that Elastix returns: + +```python +>>> import itk +>>> composite = registration_method.GetCombinedTransform() # doctest: +SKIP +>>> region = nz.itk_transform_resample_bounding_box( # doctest: +SKIP +... composite, fixed_block, moving) +``` + +The two kinds of transform are interpreted in **different coordinate spaces**, +which matters when anatomical orientation is present: + +- An **RFC-5 coordinate transformation** acts on the intrinsic coordinate + system, where a point is `translation + scale * index`. Its parameters are in + Zarr axis order, and no direction matrix applies. +- An **ITK transform** acts on ITK physical space, so the image geometry is + built exactly the way `ngff_image_to_itk_image` builds it, including the + direction matrix derived from [RFC-4](./rfc4.md) anatomical orientation. + +In both cases the transform maps *fixed* points into *moving* space, matching +the direction registration libraries return. + +## Converting transforms + +Transforms convert in both directions, mirroring `itk_image_to_ngff_image` and +`ngff_image_to_itk_image`: + +| Function | Direction | +| --- | --- | +| `ngff_transform_to_itk_transform` | RFC-5 to ITK | +| `itk_transform_to_ngff_transform` | ITK to RFC-5 | + +Both reconcile the two conventions that differ between the specifications: + +- **Axis order.** RFC-5 orders parameters like the Zarr array, so a `zyx` image + has `z` first; ITK orders points fastest-axis-first. The spatial block is + reversed in both rows and columns. +- **Composition order.** An RFC-5 `sequence` applies its *first* entry first, + while an ITK transform list applies its *last* entry first. The chain is + collapsed into a single affine so the result does not depend on that + inversion. + +There is also a **center of rotation**: ITK computes `y = A(x - c) + t + c`, +while an RFC-5 affine has no center. Converting to RFC-5 folds it into the +offset as `b = t + c - A c`, so the mapping is preserved exactly. + +### Storing a registration result + +`itk_transform_to_ngff_transform` is what lets a registration be written into +the OME-Zarr store. It accepts any linear ITK transform, including the +`CompositeTransform` an Elastix registration returns, and it recovers the +mapping by evaluating the transform rather than by decoding its parameters -- +so parameterizations that store angles or a quaternion (`Euler2DTransform`, +`VersorRigid3DTransform`, ...) convert just as well as an `AffineTransform`: + +```python +>>> import itk +>>> import ngff_zarr as nz +>>> +>>> transform = registration_method.GetCombinedTransform() # doctest: +SKIP +>>> rfc5 = nz.itk_transform_to_ngff_transform( # doctest: +SKIP +... transform, multiscales.metadata.dimension_names) +>>> multiscales.metadata.coordinateTransformations = [rfc5] # doctest: +SKIP +>>> nz.to_ome_zarr( # doctest: +SKIP +... 'registered.ome.zarr', multiscales, version='0.6') +``` + +By default the result is the least expressive transformation that represents +the mapping exactly -- `identity`, `translation`, `scale`, or a `sequence` of +scale and translation -- falling back to `affine`. RFC-5 recommends this, and +only those simpler forms are legal inside `multiscales > datasets`. Pass +`simplify=False` to always get an `affine`. + +Only **linear** transforms convert between the two representations, in either +direction: a deformation has no affine equivalent, so a non-linear ITK +transform raises `NotImplementedError`, as do array-backed `displacements` and +`coordinates` going the other way. RFC-5 represents deformations with those +field types instead, described in the [RFC-5 documentation](./rfc5.md). + +This restriction applies only to *converting* a transform. Computing a bounding +box does **not** require linearity -- that is the section above. + +In the TypeScript package the equivalents are `ngffTransformToItkTransform` +and `itkTransformToNgffTransform`. TypeScript has no `itk` package to fall back +on, so only parameterizations that carry a matrix (`Identity`, `Translation`, +`Scale`, `Affine`) convert there; angle- and quaternion-based ones must be +converted to an affine first. + ## TypeScript -The TypeScript package provides `itkTransformResampleBoundingBox`. It is async, -takes options as an object, and returns a `ResampleBoundingBox` whose +The same functions are available in the TypeScript package as +`itkTransformResampleBoundingBox` and `ngffTransformToItkTransform`. They are +async, take options as an object, and return a `ResampleBoundingBox` whose `selection()` yields a zarrita selection instead of Python slices: ```typescript -import { itkTransformResampleBoundingBox, zarrGet } from "@fideus-labs/ngff-zarr"; - -const region = await itkTransformResampleBoundingBox(transform, fixed, moving, { - padding: 1, -}); +import { + createAffine, + itkTransformResampleBoundingBox, + zarrGet, +} from "@fideus-labs/ngff-zarr"; + +const region = await itkTransformResampleBoundingBox( + createAffine([[1, 0, 12], [0, 1, -4]]), + fixed, + moving, + { padding: 1 }, +); if (!region.isEmpty) { const block = await zarrGet(moving.data, region.selection(moving.dims)); diff --git a/docs/rfc5.md b/docs/rfc5.md index 807cd682..e293193e 100644 --- a/docs/rfc5.md +++ b/docs/rfc5.md @@ -222,6 +222,25 @@ Use `Coordinates` instead of `Displacements` (and `axes_types={"c": "coordinate"}` on the field) for an absolute coordinate field; the store layout is identical. +### Interoperating with ITK + +Linear transformations convert to and from ITK in both directions, with +`ngff_transform_to_itk_transform` and `itk_transform_to_ngff_transform`. The +second is how a registration result gets into the store: convert the +`CompositeTransform` an Elastix registration returns and attach it to the +multiscales metadata as shown above. + +Both reconcile the places where the conventions differ: RFC-5 orders +parameters in Zarr axis order while ITK orders them fastest-axis-first, an +RFC-5 `sequence` applies its first entry first while an ITK transform list +applies its last entry first, and ITK's center of rotation is folded into the +offset since an RFC-5 affine has none. + +Building on that, `itk_transform_resample_bounding_box` computes which region +of a moving image a resample through the transformation would read, from +geometry alone. See [Out-of-core resampling](./itk.md#out-of-core-resampling) +and [Converting transforms](./itk.md#converting-transforms). + ## TypeScript The TypeScript package (`@fideus-labs/ngff-zarr`) mirrors the Python API. Field diff --git a/py/ngff_zarr/__init__.py b/py/ngff_zarr/__init__.py index e70adbe4..7351e72b 100644 --- a/py/ngff_zarr/__init__.py +++ b/py/ngff_zarr/__init__.py @@ -29,6 +29,10 @@ ResampleBoundingBox, itk_transform_resample_bounding_box, ) +from .itk_transform_to_ngff_transform import ( + itk_transform_to_ngff_matrix, + itk_transform_to_ngff_transform, +) from .lif_to_ngff_image import ( has_mosaic_dimension, lif_file_to_ngff_images, @@ -40,6 +44,10 @@ from .multiscales import Multiscales, NgffMultiscales from .ngff_image import NgffImage from .ngff_image_to_itk_image import ngff_image_to_itk_image +from .ngff_transform_to_itk_transform import ( + ngff_transform_to_itk_matrix, + ngff_transform_to_itk_transform, +) from .nibabel_image_to_ngff_image import ( extract_omero_metadata_from_nibabel, nibabel_image_to_ngff_image, @@ -124,6 +132,11 @@ "nibabel_image_to_ngff_image", "extract_omero_metadata_from_nibabel", "ngff_image_to_itk_image", + # RFC 5 - Coordinate transformations and ITK + "ngff_transform_to_itk_matrix", + "ngff_transform_to_itk_transform", + "itk_transform_to_ngff_matrix", + "itk_transform_to_ngff_transform", # Out-of-core resampling "itk_transform_resample", "itk_transform_resample_bounding_box", diff --git a/py/ngff_zarr/itk_transform_resample_bounding_box.py b/py/ngff_zarr/itk_transform_resample_bounding_box.py index 4c347bef..a6e56787 100644 --- a/py/ngff_zarr/itk_transform_resample_bounding_box.py +++ b/py/ngff_zarr/itk_transform_resample_bounding_box.py @@ -9,7 +9,9 @@ import numpy as np from .ngff_image import NgffImage +from .ngff_transform_to_itk_transform import ngff_transform_to_itk_transform from .rfc4 import anatomical_orientation_to_itk_direction +from .v06.zarr_metadata import BaseTransform _SPATIAL_DIMS = ("x", "y", "z") @@ -292,6 +294,29 @@ def _metadata_only_itk_image( ) +#: RFC-5 transformation types, plus the v0.4 spellings of the three that +#: predate it. ``ngff_zarr.Scale`` and friends are the v0.4 dataclasses, which +#: do not share the v0.6 base class. +_NGFF_TRANSFORM_TYPES = frozenset( + { + "identity", + "scale", + "translation", + "rotation", + "affine", + "sequence", + "coordinates", + "displacements", + } +) + + +def _is_ngff_transform(transform) -> bool: + if isinstance(transform, BaseTransform): + return True + return getattr(transform, "type", None) in _NGFF_TRANSFORM_TYPES + + def _as_itk_transform_list(transform) -> list: """Normalize a supported transform input into an ITK-Wasm transform list. @@ -366,15 +391,21 @@ def itk_transform_resample_bounding_box( numbers, learn exactly which block of the moving image a resample will touch, and only then move pixels. - The transform acts on ITK physical space, so the image geometry is built - the way :func:`ngff_zarr.ngff_image_to_itk_image` builds it, including the - direction matrix derived from RFC-4 anatomical orientation. It maps *fixed* - points into *moving* space, matching the direction registration libraries - return. + Two kinds of transform are accepted, and they are interpreted in different + coordinate spaces: + + * An **RFC-5 coordinate transformation** acts on the intrinsic coordinate + system, where a point is ``translation + scale * index``. Its parameters + are in Zarr axis order and no direction matrix applies. + * An **ITK transform** (for example a ``CompositeTransform`` returned by + Elastix) acts on ITK physical space, so the geometry is built the way + :func:`ngff_zarr.ngff_image_to_itk_image` builds it, including the + direction matrix derived from RFC-4 anatomical orientation. + + In both cases the transform maps *fixed* points into *moving* space. - :param transform: An ``itk.Transform`` (including the ``CompositeTransform`` - an Elastix registration returns), or an ITK-Wasm ``Transform`` / - ``TransformList``. + :param transform: An RFC-5 coordinate transformation, an ``itk.Transform``, + or an ITK-Wasm ``Transform`` / ``TransformList``. :param fixed: The image whose grid is resampled. Geometry only. :type fixed: NgffImage @@ -395,6 +426,7 @@ def itk_transform_resample_bounding_box( translation entry is missing, zero or non-finite, if ``padding`` is negative, if the transform couples spatial and non-spatial axes, or if the region spans more than the index range the pipeline can represent. + :raises NotImplementedError: If the RFC-5 transformation is not linear. """ from itkwasm_downsample import resample_bounding_box @@ -448,9 +480,16 @@ def itk_transform_resample_bounding_box( moving_shape=moving_shape, ) - transform_list = _as_itk_transform_list(transform) - fixed_direction = _itk_direction(fixed, itk_dims) - moving_direction = _itk_direction(moving, itk_dims) + if _is_ngff_transform(transform): + transform_list = ngff_transform_to_itk_transform(transform, fixed.dims) + # An RFC-5 transformation is defined on the intrinsic coordinate + # system, which carries no direction matrix. + fixed_direction = np.eye(len(itk_dims)) + moving_direction = np.eye(len(itk_dims)) + else: + transform_list = _as_itk_transform_list(transform) + fixed_direction = _itk_direction(fixed, itk_dims) + moving_direction = _itk_direction(moving, itk_dims) result = resample_bounding_box( transform_list, diff --git a/py/ngff_zarr/itk_transform_to_ngff_transform.py b/py/ngff_zarr/itk_transform_to_ngff_transform.py new file mode 100644 index 00000000..79819697 --- /dev/null +++ b/py/ngff_zarr/itk_transform_to_ngff_transform.py @@ -0,0 +1,258 @@ +# SPDX-FileCopyrightText: Copyright (c) Fideus Labs LLC +# SPDX-License-Identifier: MIT +"""Convert ITK transforms to RFC-5 coordinate transformations. + +The inverse of :mod:`ngff_zarr.ngff_transform_to_itk_transform`. Its purpose +is persistence: a registration produces an ITK transform, and RFC-5 is where +that result belongs once it is written next to the image. + +The same two conventions apply in reverse -- the spatial block and the offset +are reversed from ITK's fastest-axis-first order back to Zarr order, and ITK's +center of rotation is folded into the offset, since an RFC-5 affine has no +center: + + ITK: y = A (x - c) + t + c + RFC-5: y = A x + b with b = t + c - A c +""" + +from collections.abc import Sequence + +import numpy as np + +from .v06.zarr_metadata import ( + Affine, + Identity, + Scale, + Transform, + TransformSequence, + Translation, +) + +_SPATIAL_DIMS = ("x", "y", "z") + +#: itkwasm parameterizations this module decodes without help from ``itk``. +#: Everything else (Euler, Versor, Similarity, ...) packs angles or quaternions +#: rather than a matrix, so it is rebuilt through ``itk`` and probed instead. +_DIRECTLY_DECODED = frozenset({"Identity", "Translation", "Scale", "Affine"}) + + +def _matrix_offset_by_probing(itk_transform, dimension: int): + """Recover ``(matrix, offset)`` by evaluating the transform. + + ``offset = T(0)`` and ``matrix[:, j] = T(e_j) - T(0)``. This is exact for + any linear transform and, unlike decoding ``GetParameters()``, does not + depend on how the particular transform type packs its parameters -- an + ``Euler3DTransform`` stores angles and a ``VersorRigid3DTransform`` a + quaternion, but both answer ``TransformPoint`` the same way. + """ + origin = [0.0] * dimension + offset = np.asarray(itk_transform.TransformPoint(origin), dtype=float) + matrix = np.zeros((dimension, dimension)) + for axis in range(dimension): + basis = [0.0] * dimension + basis[axis] = 1.0 + column = np.asarray(itk_transform.TransformPoint(basis), dtype=float) + matrix[:, axis] = column - offset + return matrix, offset + + +def _parameterization_name(transform_type) -> str: + """The parameterization as a plain name, e.g. ``"Affine"``. + + ``itkwasm.TransformParameterizations`` mixes in ``str``, and since Python + 3.11 ``str()`` on such a member returns ``"TransformParameterizations.Affine"`` + rather than its value. Reading ``.value`` keeps a member and a plain string + on the same footing. + """ + parameterization = transform_type.transformParameterization + return str(getattr(parameterization, "value", parameterization)) + + +def _matrix_offset_from_itkwasm(entry, dimension: int): + """Decode an ITK-Wasm transform, falling back to ``itk`` when needed.""" + transform_type = entry.transformType + parameterization = _parameterization_name(transform_type) + parameters = np.asarray( + [] if entry.parameters is None else entry.parameters, dtype=float + ) + fixed = np.asarray( + [] if entry.fixedParameters is None else entry.fixedParameters, dtype=float + ) + + if parameterization == "Identity": + return np.eye(dimension), np.zeros(dimension) + if parameterization == "Translation": + return np.eye(dimension), parameters[:dimension].copy() + if parameterization == "Scale": + return np.diag(parameters[:dimension]), np.zeros(dimension) + if parameterization == "Affine": + matrix = parameters[: dimension * dimension].reshape(dimension, dimension) + translation = parameters[dimension * dimension :][:dimension] + center = fixed[:dimension] if fixed.size >= dimension else np.zeros(dimension) + # ITK applies the matrix about the center, so fold it into the offset. + return matrix, translation + center - matrix @ center + + try: + import itk + except ImportError as error: + msg = ( + f"cannot decode an ITK-Wasm '{parameterization}' transform without " + "itk, because that parameterization stores angles or a quaternion " + "rather than a matrix. Install the ngff-zarr[itk] extra, or pass an " + "Affine transform." + ) + raise ImportError(msg) from error + + from dataclasses import asdict + + rebuilt = itk.transform_from_dict(asdict(entry)) + if hasattr(rebuilt, "GetNthTransform") and rebuilt.GetNumberOfTransforms() == 1: + rebuilt = rebuilt.GetNthTransform(0) + return _matrix_offset_by_probing(rebuilt, dimension) + + +def _itk_matrix_offset(transform, dimension: int): + """Reduce any supported ITK transform input to a single matrix and offset.""" + from itkwasm import Transform as ItkWasmTransform + + # A native itk.Transform, including the CompositeTransform Elastix returns. + if hasattr(transform, "TransformPoint"): + if hasattr(transform, "IsLinear") and not transform.IsLinear(): + msg = ( + "only linear ITK transforms can be expressed as an RFC-5 affine; " + f"{type(transform).__name__} is not linear" + ) + raise NotImplementedError(msg) + return _matrix_offset_by_probing(transform, dimension) + + entries = ( + [transform] if isinstance(transform, ItkWasmTransform) else list(transform) + ) + if not entries: + msg = "transform list is empty" + raise ValueError(msg) + + # An ITK transform list applies its last entry first, so the homogeneous + # matrices multiply left to right in list order. + total = np.eye(dimension + 1) + for entry in entries: + matrix, offset = _matrix_offset_from_itkwasm(entry, dimension) + homogeneous = np.eye(dimension + 1) + homogeneous[:dimension, :dimension] = matrix + homogeneous[:dimension, dimension] = offset + total = total @ homogeneous + return total[:dimension, :dimension], total[:dimension, dimension] + + +def itk_transform_to_ngff_matrix( + transform, + dims: Sequence[str], +) -> tuple[np.ndarray, np.ndarray]: + """Convert an ITK transform to a matrix and offset in RFC-5 axis order. + + :param transform: An ``itk.Transform`` (including a ``CompositeTransform``), + an ITK-Wasm ``Transform``, or an ITK-Wasm ``TransformList``. + + :param dims: The axis names of the coordinate system the result should be + expressed on, in RFC-5 (Zarr) order, e.g. ``("z", "y", "x")``. Only the + spatial axes take part in the transform. + :type dims: Sequence[str] + + :return: ``(matrix, offset)`` over the spatial axes, in Zarr order. + :rtype: tuple[numpy.ndarray, numpy.ndarray] + + :raises NotImplementedError: If the transform is not linear. + """ + dims = tuple(dims) + spatial = [dim for dim in dims if dim in _SPATIAL_DIMS] + if not spatial: + msg = f"no spatial axes among dims {dims}" + raise ValueError(msg) + + matrix, offset = _itk_matrix_offset(transform, len(spatial)) + + # ITK (fastest-axis-first) order -> RFC-5 (Zarr) order. + reversal = np.eye(len(spatial))[::-1] + return reversal @ matrix @ reversal, reversal @ offset + + +def itk_transform_to_ngff_transform( + transform, + dims: Sequence[str], + simplify: bool = True, +) -> Transform: + """Convert an ITK transform to an RFC-5 coordinate transformation. + + This is what lets a registration result be written into an OME-Zarr store: + run the registration, convert the transform, and attach it to the + multiscales metadata. + + :param transform: An ``itk.Transform`` (including the ``CompositeTransform`` + an Elastix registration returns), an ITK-Wasm ``Transform``, or an + ITK-Wasm ``TransformList``. + + :param dims: The axis names of the coordinate system the transformation is + expressed on, in RFC-5 (Zarr) order. Non-spatial axes (``t``, ``c``) are + left untransformed. + :type dims: Sequence[str] + + :param simplify: Return the least expressive transformation that represents + the mapping exactly -- ``identity``, ``translation``, ``scale``, or a + ``sequence`` of scale and translation -- falling back to ``affine``. + RFC-5 recommends this, and only these simpler forms are legal inside + ``multiscales > datasets``. Set to ``False`` to always get an ``affine``. + :type simplify: bool + + :return: An RFC-5 coordinate transformation over ``dims``. + :rtype: Transform + + :raises NotImplementedError: If the transform is not linear. A non-linear + registration has no affine equivalent. + """ + dims = tuple(dims) + matrix, offset = itk_transform_to_ngff_matrix(transform, dims) + + spatial_indices = [index for index, dim in enumerate(dims) if dim in _SPATIAL_DIMS] + ndim = len(dims) + + # Embed the spatial block into the full coordinate system, leaving any + # non-spatial axis untouched. + full = np.eye(ndim) + full_offset = np.zeros(ndim) + for row, row_index in enumerate(spatial_indices): + for col, col_index in enumerate(spatial_indices): + full[row_index, col_index] = matrix[row, col] + full_offset[row_index] = offset[row] + + if simplify: + simplified = _simplify(full, full_offset, ndim) + if simplified is not None: + return simplified + + # RFC-5 stores the upper M x (N+1) block: the matrix followed by the + # translation as the last column. + return Affine(affine=np.hstack([full, full_offset.reshape(-1, 1)]).tolist()) + + +def _simplify(matrix: np.ndarray, offset: np.ndarray, ndim: int) -> Transform | None: + """Return a less expressive equivalent transformation, or ``None``.""" + is_identity = np.array_equal(matrix, np.eye(ndim)) + is_diagonal = np.array_equal(matrix, np.diag(np.diag(matrix))) + no_offset = not offset.any() + + if is_identity and no_offset: + return Identity() + if is_identity: + return Translation(translation=offset.tolist()) + if is_diagonal and no_offset: + return Scale(scale=np.diag(matrix).tolist()) + if is_diagonal: + # Scale first, then translate: y = scale * x + translation, which is + # the form `multiscales > datasets` accepts. + return TransformSequence( + transformations=[ + Scale(scale=np.diag(matrix).tolist()), + Translation(translation=offset.tolist()), + ] + ) + return None diff --git a/py/ngff_zarr/ngff_transform_to_itk_transform.py b/py/ngff_zarr/ngff_transform_to_itk_transform.py new file mode 100644 index 00000000..0a49b60c --- /dev/null +++ b/py/ngff_zarr/ngff_transform_to_itk_transform.py @@ -0,0 +1,253 @@ +# SPDX-FileCopyrightText: Copyright (c) Fideus Labs LLC +# SPDX-License-Identifier: MIT +"""Bridge RFC-5 coordinate transformations to ITK transforms. + +RFC-5 and ITK describe the same affine geometry with two different +conventions, and the differences are silent rather than loud: getting one +wrong yields a plausible transform that is simply in the wrong place. + +Axis order + RFC-5 orders transformation parameters the same way the Zarr array is + ordered -- parameter ``i`` belongs to coordinate-system axis ``i``, so a + ``zyx`` image has ``z`` first. ITK orders points fastest-axis-first, so + the same point is ``xyz``. Writing ``R`` for the axis-reversal + permutation, an RFC-5 affine ``q = M p + b`` becomes ``A = R M R`` and + ``t = R b`` in ITK. + +Composition order + An RFC-5 ``sequence`` applies its first entry first. An ITK transform + list applies its *last* entry first. Rather than emit a list and rely on + that inversion, this module composes the chain into a single matrix here, + where the order is explicit and testable. + +Both conventions place the pixel center at the integer index, so no +half-pixel correction is involved. +""" + +from collections.abc import Sequence + +import numpy as np + +from .v06.zarr_metadata import ( + Affine, + Identity, + Rotation, + Scale, + Transform, + TransformSequence, + Translation, +) + +_SPATIAL_DIMS = ("x", "y", "z") + + +def _homogeneous_from_transform(transform: Transform, ndim: int) -> np.ndarray: + """Collapse one RFC-5 transformation into an ``(ndim+1, ndim+1)`` matrix. + + The matrix is in RFC-5 (Zarr) axis order and acts on column vectors in + homogeneous coordinates. + """ + if isinstance(transform, Identity): + return np.eye(ndim + 1) + + if isinstance(transform, Scale): + if len(transform.scale) != ndim: + msg = ( + f"scale transformation has {len(transform.scale)} parameters " + f"but the coordinate system has {ndim} axes" + ) + raise ValueError(msg) + matrix = np.eye(ndim + 1) + matrix[:ndim, :ndim] = np.diag(np.asarray(transform.scale, dtype=float)) + return matrix + + if isinstance(transform, Translation): + if len(transform.translation) != ndim: + msg = ( + f"translation transformation has {len(transform.translation)} " + f"parameters but the coordinate system has {ndim} axes" + ) + raise ValueError(msg) + matrix = np.eye(ndim + 1) + matrix[:ndim, ndim] = np.asarray(transform.translation, dtype=float) + return matrix + + if isinstance(transform, Rotation): + rotation = _as_matrix(transform.rotation, transform.path, "rotation") + if rotation.shape != (ndim, ndim): + msg = ( + f"rotation transformation is {rotation.shape[0]}x{rotation.shape[1]} " + f"but the coordinate system has {ndim} axes" + ) + raise ValueError(msg) + matrix = np.eye(ndim + 1) + matrix[:ndim, :ndim] = rotation + return matrix + + if isinstance(transform, Affine): + affine = _as_matrix(transform.affine, transform.path, "affine") + # RFC-5 stores the upper M x (N+1) block of a homogeneous matrix: the + # rotation/scale/shear part followed by the translation as the last + # column, with the trailing [0 ... 0 1] row omitted. + if affine.shape != (ndim, ndim + 1): + msg = ( + f"affine transformation is {affine.shape[0]}x{affine.shape[1]} but " + f"a coordinate system with {ndim} axes requires " + f"{ndim}x{ndim + 1} (the translation is the last column)" + ) + raise ValueError(msg) + matrix = np.eye(ndim + 1) + matrix[:ndim, :] = affine + return matrix + + if isinstance(transform, TransformSequence): + if not transform.transformations: + msg = "sequence transformation has no transformations" + raise ValueError(msg) + # RFC-5 applies the first entry first, so the matrix product runs + # right-to-left over the list. + matrix = np.eye(ndim + 1) + for sub_transform in transform.transformations: + matrix = _homogeneous_from_transform(sub_transform, ndim) @ matrix + return matrix + + # ``ngff_zarr.Scale`` and friends are the v0.4 dataclasses, which carry the + # same parameters under the same names but do not share the v0.6 base + # class. Accept them rather than making callers hunt for the v0.6 twin. + transform_type = getattr(transform, "type", None) + if transform_type == "identity": + return np.eye(ndim + 1) + if transform_type == "scale" and hasattr(transform, "scale"): + return _homogeneous_from_transform(Scale(scale=transform.scale), ndim) + if transform_type == "translation" and hasattr(transform, "translation"): + return _homogeneous_from_transform( + Translation(translation=transform.translation), ndim + ) + + msg = ( + f"transformation type '{transform_type or type(transform).__name__}'" + " cannot be converted to an ITK transform. Only identity, scale, " + "translation, rotation, affine and sequences of them describe a linear " + "mapping that ITK can represent as a single affine transform." + ) + raise NotImplementedError(msg) + + +def _as_matrix(values, path: str | None, field: str) -> np.ndarray: + if values is None or len(values) == 0: + if path is not None: + msg = ( + f"{field} transformation stores its parameters at '{path}'. " + "Reading matrix parameters from a Zarr array is not supported; " + f"supply the '{field}' values inline." + ) + raise NotImplementedError(msg) + msg = f"{field} transformation has no parameters" + raise ValueError(msg) + return np.asarray(values, dtype=float) + + +def ngff_transform_to_itk_matrix( + transform: Transform, + dims: Sequence[str], +) -> tuple[np.ndarray, np.ndarray]: + """Convert an RFC-5 transformation to an ITK matrix and offset. + + :param transform: An RFC-5 (OME-Zarr v0.6) coordinate transformation. It + must describe a linear mapping -- ``identity``, ``scale``, + ``translation``, ``rotation``, ``affine``, or a ``sequence`` of those. + :type transform: Transform + + :param dims: The axis names of the coordinate system the transformation is + defined on, in RFC-5 (Zarr) order, e.g. ``("z", "y", "x")``. + :type dims: Sequence[str] + + :return: ``(matrix, offset)`` for the spatial axes only, in ITK + (fastest-axis-first) order. + :rtype: tuple[numpy.ndarray, numpy.ndarray] + + :raises NotImplementedError: If the transformation is not linear. + :raises ValueError: If the transformation couples spatial and non-spatial + axes, or its parameters do not match ``dims``. + """ + dims = tuple(dims) + homogeneous = _homogeneous_from_transform(transform, len(dims)) + + spatial_indices = [i for i, dim in enumerate(dims) if dim in _SPATIAL_DIMS] + if not spatial_indices: + msg = f"no spatial axes among dims {dims}" + raise ValueError(msg) + other_indices = [i for i in range(len(dims)) if i not in spatial_indices] + + # A transform that mixes spatial and non-spatial axes has no ITK + # equivalent, and silently dropping the coupling would move the image. + if other_indices: + cross = homogeneous[np.ix_(spatial_indices, other_indices)] + if np.any(cross != 0.0): + msg = ( + "transformation couples spatial and non-spatial axes " + f"{[dims[i] for i in other_indices]}; it cannot be expressed as " + "an ITK spatial transform" + ) + raise ValueError(msg) + + matrix = homogeneous[np.ix_(spatial_indices, spatial_indices)] + offset = homogeneous[spatial_indices, len(dims)] + + # RFC-5 (Zarr) order -> ITK (fastest-axis-first) order. + reversal = np.eye(len(spatial_indices))[::-1] + return reversal @ matrix @ reversal, reversal @ offset + + +def ngff_transform_to_itk_transform( + transform: Transform, + dims: Sequence[str], +) -> list: + """Convert an RFC-5 transformation to an ITK-Wasm transform list. + + The transformation is collapsed into a single ``Affine`` entry, so the + result is independent of ITK's own list-composition order. + + :param transform: An RFC-5 (OME-Zarr v0.6) coordinate transformation + describing a linear mapping. + :type transform: Transform + + :param dims: The axis names of the coordinate system the transformation is + defined on, in RFC-5 (Zarr) order. + :type dims: Sequence[str] + + :return: A single-entry ITK-Wasm ``TransformList``. + :rtype: list[itkwasm.Transform] + """ + from itkwasm import ( + FloatTypes, + TransformParameterizations, + TransformType, + ) + from itkwasm import ( + Transform as ItkTransform, + ) + + matrix, offset = ngff_transform_to_itk_matrix(transform, dims) + dimension = offset.shape[0] + + # ITK's MatrixOffsetTransformBase packs the row-major matrix followed by + # the translation, with the center of rotation as the fixed parameters. + # An RFC-5 affine has no center, so it stays at the origin. + parameters = np.concatenate([matrix.ravel(order="C"), offset]) + transform_type = TransformType( + transformParameterization=TransformParameterizations.Affine, + parametersValueType=FloatTypes.Float64, + inputDimension=dimension, + outputDimension=dimension, + ) + return [ + ItkTransform( + transformType=transform_type, + numberOfFixedParameters=dimension, + numberOfParameters=len(parameters), + fixedParameters=np.zeros(dimension, dtype=np.float64), + parameters=parameters, + name="AffineTransform", + ) + ] diff --git a/py/test/test_itk_transform_resample_bounding_box.py b/py/test/test_itk_transform_resample_bounding_box.py index 1f168176..93775711 100644 --- a/py/test/test_itk_transform_resample_bounding_box.py +++ b/py/test/test_itk_transform_resample_bounding_box.py @@ -20,11 +20,21 @@ NgffImage, itk_transform_resample_bounding_box, ngff_image_to_itk_image, + ngff_transform_to_itk_matrix, ) from ngff_zarr.itk_transform_resample_bounding_box import ( _itk_direction, _metadata_only_itk_image, ) +from ngff_zarr.v06.zarr_metadata import ( + Affine, + Displacements, + Identity, + Rotation, + Scale, + TransformSequence, + Translation, +) def _translation(offset): @@ -121,6 +131,176 @@ def _oracle_region(matrix, offset, fixed, moving, spatial, padding): return start, np.maximum(end - start + 1, 0) +def test_ngff_translation_matches_the_documented_worked_example(): + """Reproduces the 2D translation example from the ITK-Wasm documentation. + + That example is stated in ITK order: fixed 16x16 spacing (2,2) origin + (10,20), moving 64x64 spacing (1,1) origin 0, translation (10,5), + padding 1. Written as RFC-5 the axes reverse, so the translation is + ``[5, 10]`` over ``("y", "x")``. + """ + fixed = _image("yx", {"y": 16, "x": 16}, {"y": 2, "x": 2}, {"y": 20, "x": 10}) + moving = _image("yx", {"y": 64, "x": 64}, {"y": 1, "x": 1}, {"y": 0, "x": 0}) + + bounding_box = itk_transform_resample_bounding_box( + Translation(translation=[5.0, 10.0]), fixed, moving, padding=1 + ) + + assert bounding_box.start_index == {"y": 24, "x": 19} + assert bounding_box.size == {"y": 33, "x": 33} + assert bounding_box.corners_min == {"y": 25.0, "x": 20.0} + assert bounding_box.corners_max == {"y": 55.0, "x": 50.0} + assert bounding_box.padded_corners_min == {"y": 24.0, "x": 19.0} + assert bounding_box.padded_corners_max == {"y": 56.0, "x": 51.0} + + +def test_padding_is_symmetric(): + fixed = _image("yx", {"y": 16, "x": 16}, {"y": 2, "x": 2}, {"y": 20, "x": 10}) + moving = _image("yx", {"y": 64, "x": 64}, {"y": 1, "x": 1}, {"y": 0, "x": 0}) + transform = Translation(translation=[5.0, 10.0]) + + padded = itk_transform_resample_bounding_box(transform, fixed, moving, padding=1) + tight = itk_transform_resample_bounding_box(transform, fixed, moving, padding=0) + + for dim in ("y", "x"): + assert tight.start_index[dim] == padded.start_index[dim] + 1 + assert tight.size[dim] == padded.size[dim] - 2 + # The tight corners are padding independent. + assert tight.corners_min == padded.corners_min + assert tight.corners_max == padded.corners_max + + +def test_asymmetric_three_dimensional_ngff_affine_matches_oracle(): + """An asymmetric sheared affine makes any axis-order slip visible.""" + spatial = ("z", "y", "x") + fixed = _image( + spatial, + {"z": 4, "y": 8, "x": 16}, + {"z": 3.0, "y": 2.0, "x": 1.0}, + {"z": 30.0, "y": 20.0, "x": 10.0}, + ) + moving = _image( + spatial, + {"z": 64, "y": 128, "x": 256}, + {"z": 1.5, "y": 0.5, "x": 0.25}, + {"z": -5.0, "y": 7.0, "x": 3.0}, + ) + matrix = np.array([[1.0, 0.2, 0.0], [0.0, 2.0, 0.3], [0.5, 0.0, 1.0]]) + offset = np.array([4.0, -6.0, 11.0]) + affine = np.hstack([matrix, offset.reshape(-1, 1)]).tolist() + + bounding_box = itk_transform_resample_bounding_box( + Affine(affine=affine), fixed, moving, padding=2 + ) + + expected_start, expected_size = _oracle_region( + matrix, offset, fixed, moving, spatial, 2 + ) + assert [bounding_box.start_index[d] for d in spatial] == expected_start.tolist() + assert [bounding_box.size[d] for d in spatial] == expected_size.tolist() + + +def test_affine_translation_is_the_last_column(): + """RFC-5 stores the translation as the last column of the affine matrix.""" + matrix, offset = ngff_transform_to_itk_matrix( + Affine(affine=[[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]), ("y", "x") + ) + # In NGFF terms: y = 1*y + 2*x + 3 and x = 4*y + 5*x + 6. ITK reverses the + # axes, so both the matrix and the offset come back reversed. + assert np.allclose(offset, [6.0, 3.0]) + assert np.allclose(matrix, [[5.0, 4.0], [2.0, 1.0]]) + + +def test_affine_with_wrong_shape_is_rejected(): + with pytest.raises(ValueError, match="translation is the last column"): + ngff_transform_to_itk_matrix( + Affine(affine=[[1.0, 0.0], [0.0, 1.0]]), ("y", "x") + ) + + +def test_sequence_applies_its_first_entry_first(): + """RFC-5 composes ``[f0, f1]`` as ``f1(f0(x))``. + + ITK transform lists compose the other way round, so a sequence that is + passed through unreversed would silently give ``2x + 10`` here instead of + ``2(x + 10)``. + """ + sequence = TransformSequence( + transformations=[ + Translation(translation=[0.0, 10.0]), + Scale(scale=[1.0, 2.0]), + ] + ) + matrix, offset = ngff_transform_to_itk_matrix(sequence, ("y", "x")) + + # ITK order is (x, y): translate by 10 then scale by 2 gives an offset of 20. + assert np.isclose(offset[0], 20.0) + assert np.isclose(matrix[0, 0], 2.0) + + reversed_sequence = TransformSequence( + transformations=[ + Scale(scale=[1.0, 2.0]), + Translation(translation=[0.0, 10.0]), + ] + ) + _, reversed_offset = ngff_transform_to_itk_matrix(reversed_sequence, ("y", "x")) + assert np.isclose(reversed_offset[0], 10.0) + + +def test_nested_sequences_compose(): + inner = TransformSequence( + transformations=[Scale(scale=[1.0, 2.0]), Translation(translation=[0.0, 1.0])] + ) + outer = TransformSequence( + transformations=[inner, Translation(translation=[0.0, 100.0])] + ) + _, offset = ngff_transform_to_itk_matrix(outer, ("y", "x")) + assert np.isclose(offset[0], 101.0) + + +def test_identity_scale_rotation_round_trip(): + matrix, offset = ngff_transform_to_itk_matrix(Identity(), ("y", "x")) + assert np.allclose(matrix, np.eye(2)) + assert np.allclose(offset, np.zeros(2)) + + matrix, _ = ngff_transform_to_itk_matrix(Scale(scale=[2.0, 3.0]), ("y", "x")) + # Reversed to ITK order (x, y). + assert np.allclose(matrix, np.diag([3.0, 2.0])) + + rotation = [[0.0, -1.0], [1.0, 0.0]] + matrix, _ = ngff_transform_to_itk_matrix(Rotation(rotation=rotation), ("y", "x")) + reversal = np.eye(2)[::-1] + assert np.allclose(matrix, reversal @ np.array(rotation) @ reversal) + + +def test_non_linear_transform_is_rejected(): + with pytest.raises(NotImplementedError, match="cannot be converted"): + ngff_transform_to_itk_matrix(Displacements(path="field"), ("y", "x")) + + +def test_transform_coupling_spatial_and_non_spatial_axes_is_rejected(): + affine = np.eye(3, 4) + affine[1, 0] = 0.5 # y would depend on c + with pytest.raises(ValueError, match="couples spatial and non-spatial"): + ngff_transform_to_itk_matrix(Affine(affine=affine.tolist()), ("c", "y", "x")) + + +def test_v04_transform_dataclasses_are_accepted(): + """``ngff_zarr.Scale`` and friends are the v0.4 spellings, not the v0.6 ones.""" + import ngff_zarr as nz + + assert not isinstance(nz.Translation(translation=[0.0, 0.0]), Translation) + + fixed = _image("yx", {"y": 16, "x": 16}, {"y": 2, "x": 2}, {"y": 20, "x": 10}) + moving = _image("yx", {"y": 64, "x": 64}, {"y": 1, "x": 1}, {"y": 0, "x": 0}) + + bounding_box = itk_transform_resample_bounding_box( + nz.Translation(translation=[5.0, 10.0]), fixed, moving, padding=1 + ) + + assert bounding_box.start_index == {"y": 24, "x": 19} + + def test_mismatched_spatial_dims_are_rejected(): fixed = _image( "zyx", diff --git a/py/test/test_itk_transform_to_ngff_transform.py b/py/test/test_itk_transform_to_ngff_transform.py new file mode 100644 index 00000000..8d134ffb --- /dev/null +++ b/py/test/test_itk_transform_to_ngff_transform.py @@ -0,0 +1,373 @@ +# SPDX-FileCopyrightText: Copyright (c) Fideus Labs LLC +# SPDX-License-Identifier: MIT +"""Tests for converting ITK transforms back to RFC-5 transformations. + +The strongest check available here is the round trip: converting an RFC-5 +transformation to ITK and back must preserve the mapping exactly. Anything +wrong with the axis reversal or the center-of-rotation algebra breaks it. +""" + +import numpy as np +import pytest +from ngff_zarr import ( + itk_transform_to_ngff_matrix, + itk_transform_to_ngff_transform, + ngff_transform_to_itk_matrix, + ngff_transform_to_itk_transform, +) +from ngff_zarr.v06.zarr_metadata import ( + Affine, + Identity, + Scale, + TransformSequence, + Translation, +) + +ROUND_TRIP_CASES = [ + ("identity", Identity(), ("y", "x")), + ("translation", Translation(translation=[5.0, 10.0]), ("y", "x")), + ("scale", Scale(scale=[2.0, 3.0, 4.0]), ("z", "y", "x")), + ("affine_2d", Affine(affine=[[0.8, -0.6, 10.0], [0.6, 0.8, -4.0]]), ("y", "x")), + ( + "sheared_affine_3d", + Affine( + affine=[ + [1.0, 0.2, 0.0, 4.0], + [0.0, 2.0, 0.3, -6.0], + [0.5, 0.0, 1.0, 11.0], + ] + ), + ("z", "y", "x"), + ), + ( + "sequence", + TransformSequence( + transformations=[ + Translation(translation=[0.0, 10.0]), + Scale(scale=[1.0, 2.0]), + ] + ), + ("y", "x"), + ), + ( + "affine_with_non_spatial_axes", + Affine( + affine=[ + [1.0, 0.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.9, 0.1, 3.0], + [0.0, 0.0, 0.2, 1.1, -2.0], + ] + ), + ("t", "c", "y", "x"), + ), +] + + +@pytest.mark.parametrize( + "transform, dims", + [(case[1], case[2]) for case in ROUND_TRIP_CASES], + ids=[case[0] for case in ROUND_TRIP_CASES], +) +def test_round_trip_preserves_the_mapping(transform, dims): + """RFC-5 -> ITK -> RFC-5 must describe the same function.""" + expected_matrix, expected_offset = ngff_transform_to_itk_matrix(transform, dims) + + converted = itk_transform_to_ngff_transform( + ngff_transform_to_itk_transform(transform, dims), dims + ) + + matrix, offset = ngff_transform_to_itk_matrix(converted, dims) + assert np.allclose(matrix, expected_matrix) + assert np.allclose(offset, expected_offset) + + +@pytest.mark.parametrize( + "transform, expected_type", + [ + (Identity(), Identity), + (Translation(translation=[5.0, 10.0]), Translation), + (Scale(scale=[2.0, 3.0]), Scale), + ( + TransformSequence( + transformations=[ + Scale(scale=[2.0, 3.0]), + Translation(translation=[1.0, 2.0]), + ] + ), + TransformSequence, + ), + (Affine(affine=[[0.8, -0.6, 0.0], [0.6, 0.8, 0.0]]), Affine), + ], +) +def test_simplify_returns_the_least_expressive_form(transform, expected_type): + """RFC-5 recommends the simplest form, and datasets only accept those.""" + dims = ("y", "x") + converted = itk_transform_to_ngff_transform( + ngff_transform_to_itk_transform(transform, dims), dims + ) + assert isinstance(converted, expected_type) + + +def test_simplify_can_be_disabled(): + dims = ("y", "x") + converted = itk_transform_to_ngff_transform( + ngff_transform_to_itk_transform(Identity(), dims), dims, simplify=False + ) + assert isinstance(converted, Affine) + assert converted.affine == [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]] + + +def test_itk_transform_with_angle_parameters_is_converted(): + """Euler stores an angle, not a matrix, so decoding parameters would fail. + + The conversion probes the mapping instead, which is independent of how the + transform packs its parameters. + """ + itk = pytest.importorskip("itk") + + euler = itk.Euler2DTransform[itk.D].New() + euler.SetAngle(0.3) + euler.SetTranslation([5.0, -2.0]) + + matrix, offset = itk_transform_to_ngff_matrix(euler, ("y", "x")) + + cosine, sine = np.cos(0.3), np.sin(0.3) + reversal = np.eye(2)[::-1] + expected = reversal @ np.array([[cosine, -sine], [sine, cosine]]) @ reversal + assert np.allclose(matrix, expected) + # ITK order (x, y) reverses to NGFF order (y, x). + assert np.allclose(offset, [-2.0, 5.0]) + + +def test_itk_composite_transform_agrees_with_transform_point(): + """The Elastix path: a composite must convert to the same mapping.""" + itk = pytest.importorskip("itk") + + translation = itk.TranslationTransform[itk.D, 2].New() + translation.SetOffset([10.0, 0.0]) + scaling = itk.AffineTransform[itk.D, 2].New() + scaling.SetMatrix(itk.matrix_from_array(np.diag([2.0, 2.0]))) + scaling.SetTranslation([0.0, 0.0]) + scaling.SetCenter([0.0, 0.0]) + composite = itk.CompositeTransform[itk.D, 2].New() + composite.AddTransform(translation) + composite.AddTransform(scaling) + + matrix, offset = itk_transform_to_ngff_matrix(composite, ("y", "x")) + + rng = np.random.default_rng(12345) + for _ in range(5): + point = rng.normal(size=2) # (y, x) + expected = np.asarray(composite.TransformPoint(point[::-1].tolist()))[::-1] + assert np.allclose(matrix @ point + offset, expected) + + +def test_itk_center_of_rotation_is_folded_into_the_offset(): + """An RFC-5 affine has no center, so ITK's must be absorbed.""" + itk = pytest.importorskip("itk") + + rotation = np.array([[0.8, -0.6], [0.6, 0.8]]) + center = np.array([9.5, 4.5]) + translation = np.array([10.0, 10.0]) + affine = itk.AffineTransform[itk.D, 2].New() + affine.SetMatrix(itk.matrix_from_array(rotation)) + affine.SetTranslation(translation.tolist()) + affine.SetCenter(center.tolist()) + + _, offset = itk_transform_to_ngff_matrix(affine, ("y", "x")) + + expected = translation + center - rotation @ center + assert np.allclose(offset, expected[::-1]) + + +def test_non_linear_itk_transform_is_rejected(): + itk = pytest.importorskip("itk") + + bspline = itk.BSplineTransform[itk.D, 2, 3].New() + with pytest.raises(NotImplementedError, match="not linear"): + itk_transform_to_ngff_transform(bspline, ("y", "x")) + + +def _itkwasm_affine(matrix_row_major, translation, center, parameterization=None): + """An ITK-Wasm affine, by default built with the enum member.""" + from itkwasm import ( + FloatTypes, + Transform, + TransformParameterizations, + TransformType, + ) + + dimension = len(translation) + transform_type = TransformType( + transformParameterization=( + TransformParameterizations.Affine + if parameterization is None + else parameterization + ), + parametersValueType=FloatTypes.Float64, + inputDimension=dimension, + outputDimension=dimension, + ) + parameters = np.asarray( + list(matrix_row_major) + list(translation), dtype=np.float64 + ) + return Transform( + transformType=transform_type, + numberOfFixedParameters=len(center), + numberOfParameters=len(parameters), + fixedParameters=np.asarray(center, dtype=np.float64), + parameters=parameters, + ) + + +def test_parameterization_is_read_by_value_not_by_str(): + """``str()`` on an itkwasm parameterization is not its name. + + ``TransformParameterizations`` mixes in ``str``, so since Python 3.11 + ``str(member)`` gives ``"TransformParameterizations.Affine"``. Matching on + that would silently miss every enum-built transform. + """ + from itkwasm import TransformParameterizations + from ngff_zarr.itk_transform_to_ngff_transform import _parameterization_name + + member = TransformParameterizations.Affine + assert str(member) != "Affine" + assert ( + _parameterization_name(type("T", (), {"transformParameterization": member})) + == "Affine" + ) + # A plain string must work identically. + assert ( + _parameterization_name(type("T", (), {"transformParameterization": "Affine"})) + == "Affine" + ) + + +def test_affine_decoding_is_exact_for_an_enum_built_transform(): + """Decoding must read the parameters, not re-derive them. + + Falling back to probing the mapping would land within a few ULP rather than + reproducing the input exactly, so this asserts bit equality. + """ + matrix = [0.90, 0.12, -0.30, -0.22, 1.05, 0.17, 0.08, -0.41, 0.95] + translation = [3.5, -1.25, 2.0] + transform = _itkwasm_affine(matrix, translation, [0.0, 0.0, 0.0]) + + got_matrix, got_offset = itk_transform_to_ngff_matrix(transform, ("z", "y", "x")) + + reversal = np.eye(3)[::-1] + expected_matrix = reversal @ np.asarray(matrix).reshape(3, 3) @ reversal + expected_offset = reversal @ np.asarray(translation) + assert np.array_equal(got_matrix, expected_matrix) + assert np.array_equal(got_offset, expected_offset) + + +def test_matrix_parameterizations_decode_without_itk(monkeypatch): + """The matrix-carrying types must not need ``itk`` to decode. + + They were reaching the ``itk`` fallback, which both required an optional + dependency and reported a misleading reason for an affine. + """ + import builtins + + real_import = builtins.__import__ + + def no_itk(name, *args, **kwargs): + if name == "itk": + raise ImportError("itk is unavailable for this test") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", no_itk) + + transform = _itkwasm_affine( + [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0], [1.0, 2.0, 3.0], [0.0, 0.0, 0.0] + ) + _, offset = itk_transform_to_ngff_matrix(transform, ("z", "y", "x")) + assert np.array_equal(offset, [3.0, 2.0, 1.0]) + + +def test_this_ports_own_output_decodes_without_itk(monkeypatch): + """``ngff_transform_to_itk_transform`` emits the enum, so the pair must agree.""" + import builtins + + real_import = builtins.__import__ + + def no_itk(name, *args, **kwargs): + if name == "itk": + raise ImportError("itk is unavailable for this test") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", no_itk) + + dims = ("y", "x") + emitted = ngff_transform_to_itk_transform( + Affine(affine=[[0.8, -0.6, 10.0], [0.6, 0.8, -4.0]]), dims + ) + converted = itk_transform_to_ngff_transform(emitted, dims) + assert isinstance(converted, Affine) + assert np.allclose(converted.affine, [[0.8, -0.6, 10.0], [0.6, 0.8, -4.0]]) + + +def test_itkwasm_transform_list_composes_last_entry_first(): + """An ITK transform list applies its last entry first.""" + from itkwasm import ( + FloatTypes, + Transform, + TransformParameterizations, + TransformType, + ) + + def entry(parameterization, parameters, fixed=()): + transform_type = TransformType( + transformParameterization=parameterization, + parametersValueType=FloatTypes.Float64, + inputDimension=2, + outputDimension=2, + ) + return Transform( + transformType=transform_type, + numberOfFixedParameters=len(fixed), + numberOfParameters=len(parameters), + fixedParameters=np.asarray(fixed, dtype=np.float64), + parameters=np.asarray(parameters, dtype=np.float64), + ) + + # ITK order (x, y): translate x by 10, and scale by 2. + shift = entry(TransformParameterizations.Translation, [10.0, 0.0]) + scaling = entry( + TransformParameterizations.Affine, [2.0, 0.0, 0.0, 2.0, 0.0, 0.0], [0.0, 0.0] + ) + + # [shift, scaling] means shift(scaling(p)) = 2p + 10. + matrix, offset = itk_transform_to_ngff_matrix([shift, scaling], ("y", "x")) + assert np.allclose(matrix, np.diag([2.0, 2.0])) + assert np.allclose(offset, [0.0, 10.0]) # reversed to (y, x) + + # The other order means scaling(shift(p)) = 2(p + 10) = 2p + 20. + _, reversed_offset = itk_transform_to_ngff_matrix([scaling, shift], ("y", "x")) + assert np.allclose(reversed_offset, [0.0, 20.0]) + + +def test_registration_result_can_be_attached_to_multiscales(): + """The point of this direction: persist a registration into the store.""" + itk = pytest.importorskip("itk") + import dask.array as da + import ngff_zarr as nz + + image = nz.to_ngff_image(da.zeros((32, 32), dtype=np.uint8), dims=["y", "x"]) + multiscales = nz.to_multiscales(image, scale_factors=[2], chunks=16) + + euler = itk.Euler2DTransform[itk.D].New() + euler.SetAngle(0.2) + euler.SetTranslation([3.0, -1.0]) + + transform = itk_transform_to_ngff_transform(euler, ["y", "x"]) + + assert isinstance(transform, Affine) + # An RFC-5 affine is M rows of N+1 columns, translation last. + assert len(transform.affine) == 2 + assert all(len(row) == 3 for row in transform.affine) + assert transform.type == "affine" + # It is shaped to go straight onto the multiscales metadata. + assert multiscales.metadata.coordinateTransformations is None diff --git a/ts/src/browser-mod.ts b/ts/src/browser-mod.ts index f5fb49c0..c98c4f6f 100644 --- a/ts/src/browser-mod.ts +++ b/ts/src/browser-mod.ts @@ -28,6 +28,16 @@ export { type ItkTransformResampleBoundingBoxOptions, ResampleBoundingBox, } from "./io/itk_transform_resample_bounding_box-shared.ts"; +export { + itkTransformToNgffMatrix, + itkTransformToNgffTransform, + type NgffMatrixAndOffset, +} from "./utils/itk_transform_to_ngff_transform.ts"; +export { + type ItkMatrixAndOffset, + ngffTransformToItkMatrix, + ngffTransformToItkTransform, +} from "./utils/ngff_transform_to_itk_transform.ts"; export { dataTypeToComponentType, ngffImageToItkImage, diff --git a/ts/src/io/itk_transform_resample_bounding_box-browser.ts b/ts/src/io/itk_transform_resample_bounding_box-browser.ts index e4744db7..4bfa94a9 100644 --- a/ts/src/io/itk_transform_resample_bounding_box-browser.ts +++ b/ts/src/io/itk_transform_resample_bounding_box-browser.ts @@ -6,6 +6,7 @@ import { resampleBoundingBox } from "@itk-wasm/downsample"; import type { TransformList } from "itk-wasm"; import type { NgffImage } from "../types/ngff_image.ts"; +import type { V06Transform } from "../types/zarr_metadata.ts"; import { type ItkTransformResampleBoundingBoxOptions, type ResampleBoundingBox, @@ -19,7 +20,7 @@ import { * `itk_transform_resample_bounding_box-node.ts` for the full description. */ export function itkTransformResampleBoundingBox( - transform: TransformList, + transform: V06Transform | TransformList, fixed: NgffImage, moving: NgffImage, options: ItkTransformResampleBoundingBoxOptions = {}, diff --git a/ts/src/io/itk_transform_resample_bounding_box-node.ts b/ts/src/io/itk_transform_resample_bounding_box-node.ts index 6065fac0..4ae7e1f6 100644 --- a/ts/src/io/itk_transform_resample_bounding_box-node.ts +++ b/ts/src/io/itk_transform_resample_bounding_box-node.ts @@ -6,6 +6,7 @@ import { resampleBoundingBoxNode } from "@itk-wasm/downsample"; import type { TransformList } from "itk-wasm"; import type { NgffImage } from "../types/ngff_image.ts"; +import type { V06Transform } from "../types/zarr_metadata.ts"; import { type ItkTransformResampleBoundingBoxOptions, type ResampleBoundingBox, @@ -20,19 +21,27 @@ import { * and a transform with a few numbers, learn exactly which block of the moving * image a resample will touch, and only then move pixels. * - * The transform acts on ITK physical space, so the geometry is built the way - * {@link ngffImageToItkImage} builds it, including the direction matrix derived - * from RFC-4 anatomical orientation. It maps *fixed* points into *moving* - * space. + * Two kinds of transform are accepted, and they are interpreted in different + * coordinate spaces: * - * @param transform An ITK-Wasm `TransformList`. + * - An **RFC-5 coordinate transformation** acts on the intrinsic coordinate + * system, where a point is `translation + scale * index`. Its parameters are + * in Zarr axis order and no direction matrix applies. + * - An **ITK-Wasm `TransformList`** acts on ITK physical space, so the + * geometry is built the way {@link ngffImageToItkImage} builds it, including + * the direction matrix derived from RFC-4 anatomical orientation. + * + * In both cases the transform maps *fixed* points into *moving* space. + * + * @param transform An RFC-5 coordinate transformation or an ITK-Wasm + * `TransformList`. * @param fixed The image whose grid is resampled. Geometry only. * @param moving The image to be sampled. Geometry only. * @param options Padding options. * @returns The region, keyed by dimension name in Zarr order. */ export function itkTransformResampleBoundingBox( - transform: TransformList, + transform: V06Transform | TransformList, fixed: NgffImage, moving: NgffImage, options: ItkTransformResampleBoundingBoxOptions = {}, diff --git a/ts/src/io/itk_transform_resample_bounding_box-shared.ts b/ts/src/io/itk_transform_resample_bounding_box-shared.ts index eaf73a71..003e95a9 100644 --- a/ts/src/io/itk_transform_resample_bounding_box-shared.ts +++ b/ts/src/io/itk_transform_resample_bounding_box-shared.ts @@ -11,7 +11,9 @@ import * as zarr from "zarrita"; import type { Image, TransformList } from "itk-wasm"; import { NgffImage } from "../types/ngff_image.ts"; +import type { V06Transform } from "../types/zarr_metadata.ts"; import { anatomicalOrientationToItkDirection } from "../types/rfc4.ts"; +import { ngffTransformToItkTransform } from "../utils/ngff_transform_to_itk_transform.ts"; const SPATIAL_DIMS = ["x", "y", "z"]; @@ -331,6 +333,11 @@ export function metadataOnlyItkImage( return itkImage; } +function isV06Transform(value: unknown): value is V06Transform { + return typeof value === "object" && value !== null && "type" in value && + typeof (value as { type: unknown }).type === "string"; +} + /** * Compute the moving-image region needed to resample a fixed image grid. * @@ -343,7 +350,7 @@ export async function resampleBoundingBoxShared( moving: Image, options: { padding?: number }, ) => Promise<{ boundingBox: unknown }>, - transform: TransformList, + transform: V06Transform | TransformList, fixed: NgffImage, moving: NgffImage, options: ItkTransformResampleBoundingBoxOptions = {}, @@ -398,11 +405,28 @@ export async function resampleBoundingBoxShared( }); } - // An ITK transform list acts on ITK physical space, so the geometry is built - // the way ngffImageToItkImage builds it, direction included. - const transformList = transform; - const fixedDirection = itkDirection(fixed, itkDims); - const movingDirection = itkDirection(moving, itkDims); + let transformList: TransformList; + let fixedDirection: Float64Array; + let movingDirection: Float64Array; + + if (Array.isArray(transform)) { + // An ITK transform list acts on ITK physical space, so the geometry is + // built the way ngffImageToItkImage builds it, direction included. + transformList = transform; + fixedDirection = itkDirection(fixed, itkDims); + movingDirection = itkDirection(moving, itkDims); + } else if (isV06Transform(transform)) { + transformList = ngffTransformToItkTransform(transform, fixed.dims); + // An RFC-5 transformation is defined on the intrinsic coordinate system, + // which carries no direction matrix. + fixedDirection = identityDirection(itkDims.length); + movingDirection = identityDirection(itkDims.length); + } else { + throw new Error( + `unsupported transform type. Expected an RFC-5 coordinate ` + + `transformation or an ITK-Wasm TransformList.`, + ); + } const { boundingBox } = await pipeline( transformList, diff --git a/ts/src/mod.ts b/ts/src/mod.ts index c9310d26..4e7298d4 100644 --- a/ts/src/mod.ts +++ b/ts/src/mod.ts @@ -69,6 +69,16 @@ export { createNgffImage, createNgffMultiscales, } from "./utils/factory.ts"; +export { + itkTransformToNgffMatrix, + itkTransformToNgffTransform, + type NgffMatrixAndOffset, +} from "./utils/itk_transform_to_ngff_transform.ts"; +export { + type ItkMatrixAndOffset, + ngffTransformToItkMatrix, + ngffTransformToItkTransform, +} from "./utils/ngff_transform_to_itk_transform.ts"; export { fromZarrAttrsV04, fromZarrAttrsV05, diff --git a/ts/src/utils/itk_transform_to_ngff_transform.ts b/ts/src/utils/itk_transform_to_ngff_transform.ts new file mode 100644 index 00000000..de8fb03b --- /dev/null +++ b/ts/src/utils/itk_transform_to_ngff_transform.ts @@ -0,0 +1,260 @@ +// SPDX-FileCopyrightText: Copyright (c) Fideus Labs LLC +// SPDX-License-Identifier: MIT + +/** + * Convert ITK transforms to RFC-5 coordinate transformations. + * + * The inverse of `ngff_transform_to_itk_transform.ts`. Its purpose is + * persistence: a registration produces an ITK transform, and RFC-5 is where + * that result belongs once it is written next to the image. + * + * The same two conventions apply in reverse -- the spatial block and the + * offset are reversed from ITK's fastest-axis-first order back to Zarr order, + * and ITK's center of rotation is folded into the offset, since an RFC-5 + * affine has no center: + * + * ``` + * ITK: y = A (x - c) + t + c + * RFC-5: y = A x + b with b = t + c - A c + * ``` + * + * Unlike the Python port there is no `itk` package to fall back on here, so + * only the parameterizations that carry a matrix are supported. Angle- and + * quaternion-based ones (Euler, Versor, Similarity, ...) must be converted to + * an affine first. + */ + +import type { Transform, TransformList } from "itk-wasm"; +import { + type Affine, + createAffine, + createIdentity, + createScale, + createTransformSequence, + createTranslation, + type V06Transform, +} from "../types/zarr_metadata.ts"; + +const SPATIAL_DIMS = ["x", "y", "z"]; + +/** An RFC-5 matrix and offset for the spatial axes, in Zarr order. */ +export interface NgffMatrixAndOffset { + /** Row-major square matrix, in RFC-5 (Zarr) axis order. */ + matrix: number[][]; + /** Translation, in RFC-5 (Zarr) axis order. */ + offset: number[]; +} + +function identityMatrix(size: number): number[][] { + return Array.from( + { length: size }, + (_, row) => Array.from({ length: size }, (_, col) => (row === col ? 1 : 0)), + ); +} + +function multiply(left: number[][], right: number[][]): number[][] { + const size = left.length; + return Array.from( + { length: size }, + (_, row) => + Array.from({ length: size }, (_, col) => { + let total = 0; + for (let k = 0; k < size; k++) total += left[row][k] * right[k][col]; + return total; + }), + ); +} + +/** + * Read a parameter array as plain numbers. + * + * ITK-Wasm types these as `TypedArray`, which admits the BigInt variants even + * though transform parameters are always floating point. + */ +function asNumbers(values: unknown): number[] { + if (values === undefined || values === null) return []; + return Array.from(values as ArrayLike, Number); +} + +function decode( + entry: Transform, + dimension: number, +): { matrix: number[][]; offset: number[] } { + const parameterization = String( + entry.transformType.transformParameterization, + ); + const parameters = asNumbers(entry.parameters); + const fixed = asNumbers(entry.fixedParameters); + + if (parameterization === "Identity") { + return { + matrix: identityMatrix(dimension), + offset: new Array(dimension).fill(0), + }; + } + if (parameterization === "Translation") { + return { + matrix: identityMatrix(dimension), + offset: parameters.slice(0, dimension), + }; + } + if (parameterization === "Scale") { + const matrix = identityMatrix(dimension); + for (let i = 0; i < dimension; i++) matrix[i][i] = parameters[i]; + return { matrix, offset: new Array(dimension).fill(0) }; + } + if (parameterization === "Affine") { + const matrix = Array.from( + { length: dimension }, + (_, row) => parameters.slice(row * dimension, (row + 1) * dimension), + ); + const translation = parameters.slice( + dimension * dimension, + dimension * dimension + dimension, + ); + const center = fixed.length >= dimension + ? fixed.slice(0, dimension) + : new Array(dimension).fill(0); + // ITK applies the matrix about the center, so fold it into the offset. + const offset = translation.map((value, row) => { + let rotated = 0; + for (let col = 0; col < dimension; col++) { + rotated += matrix[row][col] * center[col]; + } + return value + center[row] - rotated; + }); + return { matrix, offset }; + } + + throw new Error( + `cannot decode an ITK-Wasm '${parameterization}' transform, because that ` + + `parameterization stores angles or a quaternion rather than a matrix. ` + + `Convert it to an Affine transform first.`, + ); +} + +/** + * Convert an ITK transform to a matrix and offset in RFC-5 axis order. + * + * @param transform An ITK-Wasm `Transform` or `TransformList`. + * @param dims The axis names of the coordinate system the result should be + * expressed on, in RFC-5 (Zarr) order. Only the spatial axes take part. + * @returns The matrix and offset over the spatial axes, in Zarr order. + */ +export function itkTransformToNgffMatrix( + transform: Transform | TransformList, + dims: string[], +): NgffMatrixAndOffset { + const spatial = dims.filter((dim) => SPATIAL_DIMS.includes(dim)); + if (spatial.length === 0) { + throw new Error(`no spatial axes among dims [${dims.join(", ")}]`); + } + const dimension = spatial.length; + + const entries = Array.isArray(transform) ? transform : [transform]; + if (entries.length === 0) throw new Error("transform list is empty"); + + // An ITK transform list applies its last entry first, so the homogeneous + // matrices multiply left to right in list order. + let total = identityMatrix(dimension + 1); + for (const entry of entries) { + const { matrix, offset } = decode(entry, dimension); + const homogeneous = identityMatrix(dimension + 1); + for (let row = 0; row < dimension; row++) { + for (let col = 0; col < dimension; col++) { + homogeneous[row][col] = matrix[row][col]; + } + homogeneous[row][dimension] = offset[row]; + } + total = multiply(total, homogeneous); + } + + // ITK (fastest-axis-first) order -> RFC-5 (Zarr) order. + const order = Array.from({ length: dimension }, (_, i) => dimension - 1 - i); + return { + matrix: order.map((row) => order.map((col) => total[row][col])), + offset: order.map((row) => total[row][dimension]), + }; +} + +/** + * Convert an ITK transform to an RFC-5 coordinate transformation. + * + * This is what lets a registration result be written into an OME-Zarr store: + * run the registration, convert the transform, and attach it to the + * multiscales metadata. + * + * @param transform An ITK-Wasm `Transform` or `TransformList`. + * @param dims The coordinate system's axis names, in RFC-5 (Zarr) order. + * Non-spatial axes (`t`, `c`) are left untransformed. + * @param simplify Return the least expressive transformation that represents + * the mapping exactly -- `identity`, `translation`, `scale`, or a `sequence` + * of scale and translation -- falling back to `affine`. RFC-5 recommends + * this, and only these simpler forms are legal inside + * `multiscales > datasets`. Pass `false` to always get an `affine`. + * @returns An RFC-5 coordinate transformation over `dims`. + */ +export function itkTransformToNgffTransform( + transform: Transform | TransformList, + dims: string[], + simplify = true, +): V06Transform { + const { matrix, offset } = itkTransformToNgffMatrix(transform, dims); + + const spatialIndices: number[] = []; + dims.forEach((dim, index) => { + if (SPATIAL_DIMS.includes(dim)) spatialIndices.push(index); + }); + const ndim = dims.length; + + // Embed the spatial block into the full coordinate system, leaving any + // non-spatial axis untouched. + const full = identityMatrix(ndim); + const fullOffset = new Array(ndim).fill(0); + spatialIndices.forEach((rowIndex, row) => { + spatialIndices.forEach((colIndex, col) => { + full[rowIndex][colIndex] = matrix[row][col]; + }); + fullOffset[rowIndex] = offset[row]; + }); + + if (simplify) { + const simplified = simplifyTransform(full, fullOffset, ndim); + if (simplified !== undefined) return simplified; + } + + // RFC-5 stores the upper M x (N+1) block: the matrix followed by the + // translation as the last column. + return createAffine(full.map((row, i) => [...row, fullOffset[i]])) as Affine; +} + +function simplifyTransform( + matrix: number[][], + offset: number[], + ndim: number, +): V06Transform | undefined { + let isIdentity = true; + let isDiagonal = true; + for (let row = 0; row < ndim; row++) { + for (let col = 0; col < ndim; col++) { + const expected = row === col ? 1 : 0; + if (matrix[row][col] !== expected) isIdentity = false; + if (row !== col && matrix[row][col] !== 0) isDiagonal = false; + } + } + const noOffset = offset.every((value) => value === 0); + const diagonal = matrix.map((row, i) => row[i]); + + if (isIdentity && noOffset) return createIdentity(); + if (isIdentity) return createTranslation(offset); + if (isDiagonal && noOffset) return createScale(diagonal); + if (isDiagonal) { + // Scale first, then translate: y = scale * x + translation, which is the + // form `multiscales > datasets` accepts. + return createTransformSequence([ + createScale(diagonal), + createTranslation(offset), + ]); + } + return undefined; +} diff --git a/ts/src/utils/ngff_transform_to_itk_transform.ts b/ts/src/utils/ngff_transform_to_itk_transform.ts new file mode 100644 index 00000000..3043b304 --- /dev/null +++ b/ts/src/utils/ngff_transform_to_itk_transform.ts @@ -0,0 +1,289 @@ +// SPDX-FileCopyrightText: Copyright (c) Fideus Labs LLC +// SPDX-License-Identifier: MIT + +/** + * Bridge RFC-5 coordinate transformations to ITK transforms. + * + * RFC-5 and ITK describe the same affine geometry with two different + * conventions, and the differences are silent rather than loud: getting one + * wrong yields a plausible transform that is simply in the wrong place. + * + * Axis order + * RFC-5 orders transformation parameters the same way the Zarr array is + * ordered, so a `zyx` image has `z` first. ITK orders points + * fastest-axis-first, so the same point is `xyz`. Writing `R` for the + * axis-reversal permutation, an RFC-5 affine `q = M p + b` becomes + * `A = R M R` and `t = R b` in ITK. + * + * Composition order + * An RFC-5 `sequence` applies its first entry first. An ITK transform list + * applies its *last* entry first. Rather than emit a list and rely on that + * inversion, this module composes the chain into a single matrix here, + * where the order is explicit and testable. + * + * Both conventions place the pixel center at the integer index, so no + * half-pixel correction is involved. + */ + +import type { Transform, TransformList } from "itk-wasm"; +import type { V06Transform } from "../types/zarr_metadata.ts"; + +const SPATIAL_DIMS = ["x", "y", "z"]; + +/** A square matrix stored as an array of rows. */ +type Matrix = number[][]; + +function identityMatrix(size: number): Matrix { + return Array.from( + { length: size }, + (_, row) => Array.from({ length: size }, (_, col) => (row === col ? 1 : 0)), + ); +} + +function multiply(left: Matrix, right: Matrix): Matrix { + const size = left.length; + return Array.from( + { length: size }, + (_, row) => + Array.from({ length: size }, (_, col) => { + let total = 0; + for (let k = 0; k < size; k++) { + total += left[row][k] * right[k][col]; + } + return total; + }), + ); +} + +function assertMatrix( + values: number[][] | undefined, + path: string | undefined, + field: string, +): Matrix { + if (values === undefined || values.length === 0) { + if (path !== undefined) { + throw new Error( + `${field} transformation stores its parameters at '${path}'. Reading ` + + `matrix parameters from a Zarr array is not supported; supply the ` + + `'${field}' values inline.`, + ); + } + throw new Error(`${field} transformation has no parameters`); + } + return values; +} + +/** + * Collapse one RFC-5 transformation into an `(ndim+1) x (ndim+1)` homogeneous + * matrix, in RFC-5 (Zarr) axis order. + */ +function homogeneousFromTransform( + transform: V06Transform, + ndim: number, +): Matrix { + switch (transform.type) { + case "identity": + return identityMatrix(ndim + 1); + + case "scale": { + if (transform.scale.length !== ndim) { + throw new Error( + `scale transformation has ${transform.scale.length} parameters but ` + + `the coordinate system has ${ndim} axes`, + ); + } + const matrix = identityMatrix(ndim + 1); + for (let i = 0; i < ndim; i++) matrix[i][i] = transform.scale[i]; + return matrix; + } + + case "translation": { + if (transform.translation.length !== ndim) { + throw new Error( + `translation transformation has ${transform.translation.length} ` + + `parameters but the coordinate system has ${ndim} axes`, + ); + } + const matrix = identityMatrix(ndim + 1); + for (let i = 0; i < ndim; i++) matrix[i][ndim] = transform.translation[i]; + return matrix; + } + + case "rotation": { + const rotation = assertMatrix( + transform.rotation, + transform.path, + "rotation", + ); + if (rotation.length !== ndim || rotation.some((r) => r.length !== ndim)) { + throw new Error( + `rotation transformation is ${rotation.length}x` + + `${rotation[0]?.length} but the coordinate system has ${ndim} axes`, + ); + } + const matrix = identityMatrix(ndim + 1); + for (let row = 0; row < ndim; row++) { + for (let col = 0; col < ndim; col++) { + matrix[row][col] = rotation[row][col]; + } + } + return matrix; + } + + case "affine": { + const affine = assertMatrix(transform.affine, transform.path, "affine"); + // RFC-5 stores the upper M x (N+1) block of a homogeneous matrix: the + // rotation/scale/shear part followed by the translation as the last + // column, with the trailing [0 ... 0 1] row omitted. + if ( + affine.length !== ndim || affine.some((r) => r.length !== ndim + 1) + ) { + throw new Error( + `affine transformation is ${affine.length}x${ + affine[0]?.length + } but ` + + `a coordinate system with ${ndim} axes requires ` + + `${ndim}x${ndim + 1} (the translation is the last column)`, + ); + } + const matrix = identityMatrix(ndim + 1); + for (let row = 0; row < ndim; row++) { + for (let col = 0; col <= ndim; col++) { + matrix[row][col] = affine[row][col]; + } + } + return matrix; + } + + case "sequence": { + if (transform.transformations.length === 0) { + throw new Error("sequence transformation has no transformations"); + } + // RFC-5 applies the first entry first, so the matrix product runs + // right-to-left over the list. + let matrix = identityMatrix(ndim + 1); + for (const sub of transform.transformations) { + matrix = multiply(homogeneousFromTransform(sub, ndim), matrix); + } + return matrix; + } + + default: + throw new Error( + `transformation type '${ + (transform as { type: string }).type + }' cannot be converted to an ITK transform. Only identity, scale, ` + + `translation, rotation, affine and sequences of them describe a ` + + `linear mapping that ITK can represent as a single affine transform.`, + ); + } +} + +/** An ITK matrix and offset for the spatial axes, fastest-axis-first. */ +export interface ItkMatrixAndOffset { + /** Row-major square matrix, in ITK axis order. */ + matrix: number[][]; + /** Translation, in ITK axis order. */ + offset: number[]; +} + +/** + * Convert an RFC-5 transformation to an ITK matrix and offset. + * + * @param transform An RFC-5 (OME-Zarr v0.6) coordinate transformation. It must + * describe a linear mapping: `identity`, `scale`, `translation`, `rotation`, + * `affine`, or a `sequence` of those. + * @param dims The axis names of the coordinate system the transformation is + * defined on, in RFC-5 (Zarr) order, e.g. `["z", "y", "x"]`. + * @returns The matrix and offset for the spatial axes, in ITK order. + */ +export function ngffTransformToItkMatrix( + transform: V06Transform, + dims: string[], +): ItkMatrixAndOffset { + const ndim = dims.length; + const homogeneous = homogeneousFromTransform(transform, ndim); + + const spatialIndices: number[] = []; + const otherIndices: number[] = []; + dims.forEach((dim, index) => { + if (SPATIAL_DIMS.includes(dim)) spatialIndices.push(index); + else otherIndices.push(index); + }); + + if (spatialIndices.length === 0) { + throw new Error(`no spatial axes among dims [${dims.join(", ")}]`); + } + + // A transform that mixes spatial and non-spatial axes has no ITK + // equivalent, and silently dropping the coupling would move the image. + for (const row of spatialIndices) { + for (const col of otherIndices) { + if (homogeneous[row][col] !== 0) { + const names = otherIndices.map((i) => dims[i]).join(", "); + throw new Error( + `transformation couples spatial and non-spatial axes [${names}]; ` + + `it cannot be expressed as an ITK spatial transform`, + ); + } + } + } + + // RFC-5 (Zarr) order -> ITK (fastest-axis-first) order: reverse both the + // row and the column ordering of the spatial block. + const reversed = [...spatialIndices].reverse(); + const matrix = reversed.map((row) => + reversed.map((col) => homogeneous[row][col]) + ); + const offset = reversed.map((row) => homogeneous[row][ndim]); + + return { matrix, offset }; +} + +/** + * Convert an RFC-5 transformation to an ITK-Wasm transform list. + * + * The transformation is collapsed into a single `Affine` entry, so the result + * is independent of ITK's own list-composition order. + * + * @param transform An RFC-5 coordinate transformation describing a linear map. + * @param dims The coordinate system's axis names, in RFC-5 (Zarr) order. + * @returns A single-entry ITK-Wasm `TransformList`. + */ +export function ngffTransformToItkTransform( + transform: V06Transform, + dims: string[], +): TransformList { + const { matrix, offset } = ngffTransformToItkMatrix(transform, dims); + const dimension = offset.length; + + // ITK's MatrixOffsetTransformBase packs the row-major matrix followed by the + // translation, with the center of rotation as the fixed parameters. An + // RFC-5 affine has no center, so it stays at the origin. + const parameters = new Float64Array(dimension * dimension + dimension); + for (let row = 0; row < dimension; row++) { + for (let col = 0; col < dimension; col++) { + parameters[row * dimension + col] = matrix[row][col]; + } + } + parameters.set(offset, dimension * dimension); + + const itkTransform: Transform = { + transformType: { + transformParameterization: "Affine", + parametersValueType: "float64", + inputDimension: dimension, + outputDimension: dimension, + }, + name: "AffineTransform", + inputSpaceName: "", + outputSpaceName: "", + numberOfFixedParameters: dimension, + numberOfParameters: parameters.length, + fixedParameters: new Float64Array(dimension), + parameters, + metadata: new Map(), + } as unknown as Transform; + + return [itkTransform]; +} diff --git a/ts/test/itk_transform_resample_bounding_box_test.ts b/ts/test/itk_transform_resample_bounding_box_test.ts index e3ef2229..101ed325 100644 --- a/ts/test/itk_transform_resample_bounding_box_test.ts +++ b/ts/test/itk_transform_resample_bounding_box_test.ts @@ -15,7 +15,17 @@ import { assertAlmostEquals, assertEquals, assertRejects } from "@std/assert"; import * as zarr from "zarrita"; -import { itkTransformResampleBoundingBox, NgffImage } from "../src/mod.ts"; +import { + createAffine, + createIdentity, + createRotation, + createScale, + createTransformSequence, + createTranslation, + itkTransformResampleBoundingBox, + NgffImage, + ngffTransformToItkMatrix, +} from "../src/mod.ts"; import { resampleBoundingBoxShared } from "../src/io/itk_transform_resample_bounding_box-shared.ts"; import { RAS } from "../src/types/rfc4.ts"; import type { AnatomicalOrientation } from "../src/types/rfc4.ts"; @@ -142,6 +152,210 @@ function oracleRegion( return { start, size: end.map((e, i) => Math.max(e - start[i] + 1, 0)) }; } +Deno.test("an NGFF translation reproduces the documented 2D example", async () => { + // The documentation states this in ITK order: fixed 16x16 spacing (2,2) + // origin (10,20), moving 64x64 spacing (1,1) origin 0, translation (10,5), + // padding 1. Written as RFC-5 the axes reverse, so the translation is + // [5, 10] over ["y", "x"]. + const fixed = await geometryImage(["y", "x"], { y: 16, x: 16 }, { + y: 2, + x: 2, + }, { y: 20, x: 10 }); + const moving = await geometryImage(["y", "x"], { y: 64, x: 64 }, { + y: 1, + x: 1, + }, { y: 0, x: 0 }); + + const boundingBox = await itkTransformResampleBoundingBox( + createTranslation([5, 10]), + fixed, + moving, + { padding: 1 }, + ); + + assertEquals(boundingBox.startIndex, { y: 24, x: 19 }); + assertEquals(boundingBox.size, { y: 33, x: 33 }); + assertEquals(boundingBox.cornersMin, { y: 25, x: 20 }); + assertEquals(boundingBox.cornersMax, { y: 55, x: 50 }); + assertEquals(boundingBox.paddedCornersMin, { y: 24, x: 19 }); + assertEquals(boundingBox.paddedCornersMax, { y: 56, x: 51 }); +}); + +Deno.test("padding is applied symmetrically", async () => { + const fixed = await geometryImage(["y", "x"], { y: 16, x: 16 }, { + y: 2, + x: 2, + }, { y: 20, x: 10 }); + const moving = await geometryImage(["y", "x"], { y: 64, x: 64 }, { + y: 1, + x: 1, + }, { y: 0, x: 0 }); + const transform = createTranslation([5, 10]); + + const padded = await itkTransformResampleBoundingBox( + transform, + fixed, + moving, + { + padding: 1, + }, + ); + const tight = await itkTransformResampleBoundingBox( + transform, + fixed, + moving, + { + padding: 0, + }, + ); + + for (const dim of ["y", "x"]) { + assertEquals(tight.startIndex[dim], padded.startIndex[dim] + 1); + assertEquals(tight.size[dim], padded.size[dim] - 2); + } + // The tight corners are padding independent. + assertEquals(tight.cornersMin, padded.cornersMin); + assertEquals(tight.cornersMax, padded.cornersMax); +}); + +Deno.test("an asymmetric 3D NGFF affine matches the oracle", async () => { + const dims = ["z", "y", "x"]; + const fixed = await geometryImage(dims, { z: 4, y: 8, x: 16 }, { + z: 3, + y: 2, + x: 1, + }, { z: 30, y: 20, x: 10 }); + const moving = await geometryImage(dims, { z: 64, y: 128, x: 256 }, { + z: 1.5, + y: 0.5, + x: 0.25, + }, { z: -5, y: 7, x: 3 }); + + // Deliberately asymmetric, with shear, so any axis-order slip shows. + const matrix = [[1, 0.2, 0], [0, 2, 0.3], [0.5, 0, 1]]; + const offset = [4, -6, 11]; + const affine = matrix.map((row, i) => [...row, offset[i]]); + + const boundingBox = await itkTransformResampleBoundingBox( + createAffine(affine), + fixed, + moving, + { padding: 2 }, + ); + + const expected = oracleRegion( + matrix, + offset, + [4, 8, 16], + [3, 2, 1], + [30, 20, 10], + [1.5, 0.5, 0.25], + [-5, 7, 3], + 2, + ); + assertEquals(dims.map((d) => boundingBox.startIndex[d]), expected.start); + assertEquals(dims.map((d) => boundingBox.size[d]), expected.size); +}); + +Deno.test("affine translation is the last column", () => { + const { matrix, offset } = ngffTransformToItkMatrix( + createAffine([[1, 2, 3], [4, 5, 6]]), + ["y", "x"], + ); + // In NGFF terms: y = 1*y + 2*x + 3 and x = 4*y + 5*x + 6. ITK reverses the + // axes, so both the matrix and the offset come back reversed. + assertEquals(offset, [6, 3]); + assertEquals(matrix, [[5, 4], [2, 1]]); +}); + +Deno.test("an affine of the wrong shape is rejected", () => { + let message = ""; + try { + ngffTransformToItkMatrix(createAffine([[1, 0], [0, 1]]), ["y", "x"]); + } catch (error) { + message = (error as Error).message; + } + assertEquals(message.includes("translation is the last column"), true); +}); + +Deno.test("a sequence applies its first entry first", () => { + // RFC-5 composes [f0, f1] as f1(f0(x)). ITK transform lists compose the + // other way round, so a sequence passed through unreversed would silently + // give 2x + 10 here instead of 2(x + 10). + const sequence = createTransformSequence([ + createTranslation([0, 10]), + createScale([1, 2]), + ]); + const { matrix, offset } = ngffTransformToItkMatrix(sequence, ["y", "x"]); + + // ITK order is (x, y): translate by 10 then scale by 2 gives an offset of 20. + assertAlmostEquals(offset[0], 20); + assertAlmostEquals(matrix[0][0], 2); + + const reversed = createTransformSequence([ + createScale([1, 2]), + createTranslation([0, 10]), + ]); + assertAlmostEquals( + ngffTransformToItkMatrix(reversed, ["y", "x"]).offset[0], + 10, + ); +}); + +Deno.test("nested sequences compose", () => { + const inner = createTransformSequence([ + createScale([1, 2]), + createTranslation([0, 1]), + ]); + const outer = createTransformSequence([inner, createTranslation([0, 100])]); + assertAlmostEquals( + ngffTransformToItkMatrix(outer, ["y", "x"]).offset[0], + 101, + ); +}); + +Deno.test("identity, scale and rotation convert", () => { + const identity = ngffTransformToItkMatrix(createIdentity(), ["y", "x"]); + assertEquals(identity.matrix, [[1, 0], [0, 1]]); + assertEquals(identity.offset, [0, 0]); + + // Reversed to ITK order (x, y). + const scale = ngffTransformToItkMatrix(createScale([2, 3]), ["y", "x"]); + assertEquals(scale.matrix, [[3, 0], [0, 2]]); + + const rotation = ngffTransformToItkMatrix( + createRotation([[0, -1], [1, 0]]), + ["y", "x"], + ); + // Reversing both rows and columns of [[0,-1],[1,0]] gives [[0,1],[-1,0]]. + assertEquals(rotation.matrix, [[0, 1], [-1, 0]]); +}); + +Deno.test("a non-linear transformation is rejected", () => { + let message = ""; + try { + ngffTransformToItkMatrix( + { type: "displacements", path: "field" }, + ["y", "x"], + ); + } catch (error) { + message = (error as Error).message; + } + assertEquals(message.includes("cannot be converted"), true); +}); + +Deno.test("a transform coupling spatial and non-spatial axes is rejected", () => { + // y would depend on c. + const affine = [[1, 0, 0, 0], [0.5, 1, 0, 0], [0, 0, 1, 0]]; + let message = ""; + try { + ngffTransformToItkMatrix(createAffine(affine), ["c", "y", "x"]); + } catch (error) { + message = (error as Error).message; + } + assertEquals(message.includes("couples spatial and non-spatial"), true); +}); + Deno.test("mismatched spatial dims are rejected", async () => { const fixed = await geometryImage(["z", "y", "x"], { z: 4, y: 4, x: 4 }, { z: 1, diff --git a/ts/test/itk_transform_to_ngff_transform_test.ts b/ts/test/itk_transform_to_ngff_transform_test.ts new file mode 100644 index 00000000..14b6c5c0 --- /dev/null +++ b/ts/test/itk_transform_to_ngff_transform_test.ts @@ -0,0 +1,210 @@ +// SPDX-FileCopyrightText: Copyright (c) Fideus Labs LLC +// SPDX-License-Identifier: MIT + +/** + * Converting ITK transforms back to RFC-5 transformations. + * + * Mirrors `py/test/test_itk_transform_to_ngff_transform.py`. The strongest + * check available here is the round trip: converting an RFC-5 transformation + * to ITK and back must preserve the mapping exactly. Anything wrong with the + * axis reversal or the center-of-rotation algebra breaks it. + */ + +import { assertAlmostEquals, assertEquals, assertThrows } from "@std/assert"; +import type { Transform } from "itk-wasm"; +import { + createAffine, + createIdentity, + createScale, + createTransformSequence, + createTranslation, + itkTransformToNgffMatrix, + itkTransformToNgffTransform, + ngffTransformToItkMatrix, + ngffTransformToItkTransform, + type V06Transform, +} from "../src/mod.ts"; + +const ROUND_TRIP_CASES: Array<[string, V06Transform, string[]]> = [ + ["identity", createIdentity(), ["y", "x"]], + ["translation", createTranslation([5, 10]), ["y", "x"]], + ["scale", createScale([2, 3, 4]), ["z", "y", "x"]], + ["affine 2d", createAffine([[0.8, -0.6, 10], [0.6, 0.8, -4]]), ["y", "x"]], + [ + "sheared affine 3d", + createAffine([ + [1, 0.2, 0, 4], + [0, 2, 0.3, -6], + [0.5, 0, 1, 11], + ]), + ["z", "y", "x"], + ], + [ + "sequence", + createTransformSequence([ + createTranslation([0, 10]), + createScale([1, 2]), + ]), + ["y", "x"], + ], + [ + "affine with non-spatial axes", + createAffine([ + [1, 0, 0, 0, 0], + [0, 1, 0, 0, 0], + [0, 0, 0.9, 0.1, 3], + [0, 0, 0.2, 1.1, -2], + ]), + ["t", "c", "y", "x"], + ], +]; + +for (const [name, transform, dims] of ROUND_TRIP_CASES) { + Deno.test(`round trip preserves the mapping: ${name}`, () => { + const expected = ngffTransformToItkMatrix(transform, dims); + + const converted = itkTransformToNgffTransform( + ngffTransformToItkTransform(transform, dims), + dims, + ); + + const actual = ngffTransformToItkMatrix(converted, dims); + actual.matrix.forEach((row, i) => + row.forEach((value, j) => + assertAlmostEquals(value, expected.matrix[i][j], 1e-9) + ) + ); + actual.offset.forEach((value, i) => + assertAlmostEquals(value, expected.offset[i], 1e-9) + ); + }); +} + +const SIMPLIFY_CASES: Array<[V06Transform, string]> = [ + [createIdentity(), "identity"], + [createTranslation([5, 10]), "translation"], + [createScale([2, 3]), "scale"], + [ + createTransformSequence([createScale([2, 3]), createTranslation([1, 2])]), + "sequence", + ], + [createAffine([[0.8, -0.6, 0], [0.6, 0.8, 0]]), "affine"], +]; + +for (const [transform, expectedType] of SIMPLIFY_CASES) { + Deno.test(`simplify returns the least expressive form: ${expectedType}`, () => { + const dims = ["y", "x"]; + const converted = itkTransformToNgffTransform( + ngffTransformToItkTransform(transform, dims), + dims, + ); + assertEquals(converted.type, expectedType); + }); +} + +Deno.test("simplify can be disabled", () => { + const dims = ["y", "x"]; + const converted = itkTransformToNgffTransform( + ngffTransformToItkTransform(createIdentity(), dims), + dims, + false, + ); + assertEquals(converted.type, "affine"); + if (converted.type !== "affine") throw new Error("expected an affine"); + assertEquals(converted.affine, [[1, 0, 0], [0, 1, 0]]); +}); + +/** Build an ITK-Wasm transform entry by hand. */ +function entry( + parameterization: string, + parameters: number[], + fixed: number[] = [], +): Transform { + return { + transformType: { + transformParameterization: parameterization, + parametersValueType: "float64", + inputDimension: 2, + outputDimension: 2, + }, + name: parameterization, + inputSpaceName: "", + outputSpaceName: "", + numberOfFixedParameters: fixed.length, + numberOfParameters: parameters.length, + fixedParameters: new Float64Array(fixed), + parameters: new Float64Array(parameters), + metadata: new Map(), + } as unknown as Transform; +} + +Deno.test("a transform list composes its last entry first", () => { + // ITK order (x, y): shift x by 10, and scale by 2. + const shift = entry("Translation", [10, 0]); + const scaling = entry("Affine", [2, 0, 0, 2, 0, 0], [0, 0]); + + // [shift, scaling] means shift(scaling(p)) = 2p + 10. + const forward = itkTransformToNgffMatrix([shift, scaling], ["y", "x"]); + assertEquals(forward.matrix, [[2, 0], [0, 2]]); + assertEquals(forward.offset, [0, 10]); // reversed to (y, x) + + // The other order means scaling(shift(p)) = 2(p + 10) = 2p + 20. + const reversed = itkTransformToNgffMatrix([scaling, shift], ["y", "x"]); + assertEquals(reversed.offset, [0, 20]); +}); + +Deno.test("the ITK center of rotation is folded into the offset", () => { + // An RFC-5 affine has no center, so ITK's must be absorbed: + // b = t + c - A c. + const matrix = [[0.8, -0.6], [0.6, 0.8]]; + const center = [9.5, 4.5]; + const translation = [10, 10]; + const affine = entry( + "Affine", + [...matrix[0], ...matrix[1], ...translation], + center, + ); + + const { offset } = itkTransformToNgffMatrix(affine, ["y", "x"]); + + const expectedItk = translation.map((value, row) => + value + center[row] - + (matrix[row][0] * center[0] + matrix[row][1] * center[1]) + ); + // ITK order (x, y) reverses to NGFF order (y, x). + assertAlmostEquals(offset[0], expectedItk[1], 1e-9); + assertAlmostEquals(offset[1], expectedItk[0], 1e-9); +}); + +Deno.test("an angle-based parameterization is refused with guidance", () => { + const euler = entry("Euler2D", [0.3, 5, -2]); + assertThrows( + () => itkTransformToNgffMatrix(euler, ["y", "x"]), + Error, + "Convert it to an Affine transform first", + ); +}); + +Deno.test("a scale transform decodes", () => { + const scaling = entry("Scale", [2, 3]); + const { matrix, offset } = itkTransformToNgffMatrix(scaling, ["y", "x"]); + // Reversed from ITK (x, y) to NGFF (y, x). + assertEquals(matrix, [[3, 0], [0, 2]]); + assertEquals(offset, [0, 0]); +}); + +Deno.test("an empty transform list is rejected", () => { + assertThrows( + () => itkTransformToNgffMatrix([], ["y", "x"]), + Error, + "transform list is empty", + ); +}); + +Deno.test("a coordinate system without spatial axes is rejected", () => { + assertThrows( + () => itkTransformToNgffMatrix(entry("Translation", [1, 2]), ["t", "c"]), + Error, + "no spatial axes", + ); +}); From 444c879c881b2c8f248838125100dff12b00ecbe Mon Sep 17 00:00:00 2001 From: Valentin Boussot Date: Mon, 24 Aug 2026 19:18:59 +0200 Subject: [PATCH 02/11] feat(py,ts): exact frame conversion and hardened ITK-Wasm decoding itk_transform_to_ngff_transform and ngff_transform_to_itk_transform accept optional fixed/moving NgffImages. An ITK transform acts on physical space, direction matrix included; an RFC-5 transformation acts on the intrinsic coordinate systems. Given both images the conversion changes frames exactly (M = D_m^-1 A D_f, translations folded into the offset); without them it is exact only for unoriented images, as before. Probing recovers the matrix with a step that follows the offset's magnitude and checks linearity against the working scale at a matching precision, so a transform whose evaluation passes through large frames converts exactly instead of collapsing toward zero or being refused as non-linear, and single-precision transforms convert at any magnitude. The ITK-Wasm decode path validates the parameter count against the spatial dimensionality (and the declared dimension on the angle path, before the itk import), folds the Scale center like the Affine one, and refuses 'Composite' entries at any position: itk.dict_from_transform drops a nested composite's children, leaving the entry indistinguishable from a pipeline grouping header, so decoding past one composes the wrong mapping. Non-linear parameterizations are refused by name, by IsLinear(), and by the affine consistency check. A mirror no longer simplifies to a scale, since RFC-5 requires strictly positive factors. ITK axes bind to spatial dims by name (x first) rather than by reversing dims, which is only right for the canonical (z, y, x). Shape errors report the actual shape instead of raising IndexError while formatting the message. ngff_transform_to_itk_matrix becomes private in both ports; itk_transform_to_ngff_matrix stays public for numeric inspection of a registration. --- py/ngff_zarr/__init__.py | 6 +- .../itk_transform_resample_bounding_box.py | 22 +- .../itk_transform_to_ngff_transform.py | 384 ++++++++- .../ngff_transform_to_itk_transform.py | 89 +- ...est_itk_transform_resample_bounding_box.py | 144 +++- .../test_itk_transform_to_ngff_transform.py | 813 +++++++++++++++++- ts/src/browser-mod.ts | 6 +- ..._transform_resample_bounding_box-shared.ts | 52 +- ts/src/mod.ts | 6 +- ts/src/utils/itk_direction.ts | 178 ++++ .../utils/itk_transform_to_ngff_transform.ts | 201 ++++- .../utils/ngff_transform_to_itk_transform.ts | 84 +- ...tk_transform_resample_bounding_box_test.ts | 240 +++++- .../itk_transform_to_ngff_transform_test.ts | 154 +++- 14 files changed, 2206 insertions(+), 173 deletions(-) create mode 100644 ts/src/utils/itk_direction.ts diff --git a/py/ngff_zarr/__init__.py b/py/ngff_zarr/__init__.py index 7351e72b..7a0cda2d 100644 --- a/py/ngff_zarr/__init__.py +++ b/py/ngff_zarr/__init__.py @@ -44,10 +44,7 @@ from .multiscales import Multiscales, NgffMultiscales from .ngff_image import NgffImage from .ngff_image_to_itk_image import ngff_image_to_itk_image -from .ngff_transform_to_itk_transform import ( - ngff_transform_to_itk_matrix, - ngff_transform_to_itk_transform, -) +from .ngff_transform_to_itk_transform import ngff_transform_to_itk_transform from .nibabel_image_to_ngff_image import ( extract_omero_metadata_from_nibabel, nibabel_image_to_ngff_image, @@ -133,7 +130,6 @@ "extract_omero_metadata_from_nibabel", "ngff_image_to_itk_image", # RFC 5 - Coordinate transformations and ITK - "ngff_transform_to_itk_matrix", "ngff_transform_to_itk_transform", "itk_transform_to_ngff_matrix", "itk_transform_to_ngff_transform", diff --git a/py/ngff_zarr/itk_transform_resample_bounding_box.py b/py/ngff_zarr/itk_transform_resample_bounding_box.py index a6e56787..49aad8e0 100644 --- a/py/ngff_zarr/itk_transform_resample_bounding_box.py +++ b/py/ngff_zarr/itk_transform_resample_bounding_box.py @@ -247,6 +247,7 @@ def _itk_direction(ngff_image: NgffImage, itk_dims: Sequence[str]) -> np.ndarray return direction columns = [] + seen_axes = set() for dim in itk_dims: orientation = orientations.get(dim) if orientation is None: @@ -254,6 +255,16 @@ def _itk_direction(ngff_image: NgffImage, itk_dims: Sequence[str]) -> np.ndarray column = anatomical_orientation_to_itk_direction(orientation.value) if column is None: return direction + # A column pointing outside the matrix's dimension (e.g. a + # superior/inferior orientation on a 2D image), or two dims mapping + # onto the same LPS axis, would truncate to a singular matrix; keep + # the identity fallback instead. + if any(component != 0 for component in column[len(itk_dims) :]): + return direction + axis = max(range(len(column)), key=lambda i: abs(column[i])) + if axis in seen_axes: + return direction + seen_axes.add(axis) columns.append(column) for col_index, column in enumerate(columns): @@ -396,7 +407,10 @@ def itk_transform_resample_bounding_box( * An **RFC-5 coordinate transformation** acts on the intrinsic coordinate system, where a point is ``translation + scale * index``. Its parameters - are in Zarr axis order and no direction matrix applies. + are in Zarr axis order and no direction matrix applies. Its ``input`` + and ``output`` identifiers are **not resolved**: the transformation is + applied from the fixed image's intrinsic system to the moving image's, + whatever the identifiers name. * An **ITK transform** (for example a ``CompositeTransform`` returned by Elastix) acts on ITK physical space, so the geometry is built the way :func:`ngff_zarr.ngff_image_to_itk_image` builds it, including the @@ -452,8 +466,10 @@ def itk_transform_resample_bounding_box( for label, image in (("fixed", fixed), ("moving", moving)): _check_geometry(label, image, fixed_spatial) - # ITK orders points fastest-axis-first, the reverse of the Zarr order. - itk_dims = list(reversed(fixed_spatial)) + # ITK orders points fastest-axis-first: x, then y, then z, whatever order + # the image spells its spatial dims in. Reversing the dims is only right + # for the canonical (z, y, x); binding by name is right for every order. + itk_dims = [dim for dim in _SPATIAL_DIMS if dim in fixed_spatial] fixed_dims = tuple(fixed.dims) fixed_extent = { diff --git a/py/ngff_zarr/itk_transform_to_ngff_transform.py b/py/ngff_zarr/itk_transform_to_ngff_transform.py index 79819697..ef007836 100644 --- a/py/ngff_zarr/itk_transform_to_ngff_transform.py +++ b/py/ngff_zarr/itk_transform_to_ngff_transform.py @@ -7,7 +7,7 @@ that result belongs once it is written next to the image. The same two conventions apply in reverse -- the spatial block and the offset -are reversed from ITK's fastest-axis-first order back to Zarr order, and ITK's +are permuted from ITK's fastest-axis-first order back to ``dims`` order, and ITK's center of rotation is folded into the offset, since an RFC-5 affine has no center: @@ -30,29 +30,161 @@ _SPATIAL_DIMS = ("x", "y", "z") -#: itkwasm parameterizations this module decodes without help from ``itk``. -#: Everything else (Euler, Versor, Similarity, ...) packs angles or quaternions -#: rather than a matrix, so it is rebuilt through ``itk`` and probed instead. -_DIRECTLY_DECODED = frozenset({"Identity", "Translation", "Scale", "Affine"}) +#: ITK-Wasm parameterizations that describe a deformation rather than an affine +#: mapping. Probing one recovers a matrix from three points that says nothing +#: about the rest of the field, so they are refused by name before any decoding +#: is attempted. RFC-5 represents deformations with ``displacements`` or +#: ``coordinates`` field arrays instead. +_NON_LINEAR_PARAMETERIZATIONS = frozenset( + { + "AzimuthElevationToCartesian", + "BSpline", + "BSplineSmoothingOnUpdateDisplacementField", + "ConstantVelocityField", + "DisplacementField", + "GaussianExponentialDiffeomorphic", + "GaussianSmoothingOnUpdateDisplacementField", + "GaussianSmoothingOnUpdateTimeVaryingVelocityField", + "Rigid3DPerspective", + "TimeVaryingVelocityField", + "VelocityField", + } +) + + +def _direct_parameter_count(parameterization: str, dimension: int) -> int | None: + """Exact parameter count a directly decoded parameterization must carry. + + What ITK's ``MatrixOffsetTransformBase`` and friends pack, and therefore + the ground truth for the transform's dimensionality. ``None`` for the + parameterizations this module does not decode arithmetically. + """ + if parameterization == "Identity": + return 0 + if parameterization in ("Translation", "Scale"): + return dimension + if parameterization == "Affine": + return dimension * dimension + dimension + return None + + +def _reject_non_linear(itk_transform) -> None: + """Refuse a transform that ITK itself reports as non-linear.""" + if hasattr(itk_transform, "IsLinear") and not itk_transform.IsLinear(): + msg = ( + "only linear ITK transforms can be expressed as an RFC-5 affine; " + f"{type(itk_transform).__name__} is not linear" + ) + raise NotImplementedError(msg) + + +#: Relative agreement required at the check point, by working precision. +#: float64 is 1e-7 rather than machine-level: a composite transform may pass +#: through internal frames (e.g. a global stage position) that dwarf both its +#: probes and its outputs, and the rounding they leave behind is invisible to +#: any output-side bound. 1e-7 absorbs hidden intermediates up to ~1e9 while +#: a deformation still disagrees at the check point by orders of magnitude. +_AFFINE_CHECK_RTOL = {"float32": 1e-5, "float64": 1e-7} + + +def _working_precision(*samples: np.ndarray) -> str: + """Guess whether the transform computes in single or double precision. + + ``itk.AffineTransform[itk.F, 3]`` answers ``TransformPoint`` with about + seven digits, so holding it to a double-precision tolerance rejects every + valid single-precision transform. Rather than read the ITK type name, + which is a private spelling, look at whether the values coming back are + exactly representable in ``float32``: they are when the transform computes + in ``float32``, and a double-precision result generally is not. Guessing + ``float32`` for a double-precision transform that happens to return exact + values only loosens the check on values that carry no round-off anyway. + """ + values = np.concatenate([np.ravel(np.asarray(s, dtype=float)) for s in samples]) + if values.size and np.all(values.astype(np.float32) == values): + return "float32" + return "float64" + + +def _probe_step(offset: np.ndarray) -> float: + """The displacement to probe the transform with. + + An affine map is exact for *any* step, so there is no truncation error to + trade off and the usual "small step" rule is exactly backwards here. The + only error is the cancellation in ``T(h e_j) - T(0)``, which is worst when + the offset dwarfs the step: with a step of 1 and an offset of 1e15 the + subtraction keeps no significant digit at all, and probing silently + reports a zero matrix. Following the offset's magnitude keeps the two + terms comparable, so the difference stays good to a few ulps. + + The limit this leaves: a matrix coefficient whose contribution at the + probe scale falls below the rounding of the offset itself (roughly + ``|m| * h < ulp(|T(0)|)``) is unrecoverable by evaluation and folds into + the offset. Such a term is equally invisible to any consumer evaluating + the transform at that scale, so nothing representable is lost. + """ + return max(1.0, float(np.abs(offset).max())) def _matrix_offset_by_probing(itk_transform, dimension: int): """Recover ``(matrix, offset)`` by evaluating the transform. - ``offset = T(0)`` and ``matrix[:, j] = T(e_j) - T(0)``. This is exact for - any linear transform and, unlike decoding ``GetParameters()``, does not - depend on how the particular transform type packs its parameters -- an - ``Euler3DTransform`` stores angles and a ``VersorRigid3DTransform`` a + ``offset = T(0)`` and ``matrix[:, j] = (T(h e_j) - T(0)) / h``. This is + exact for any linear transform and, unlike decoding ``GetParameters()``, + does not depend on how the particular transform type packs its parameters + -- an ``Euler3DTransform`` stores angles and a ``VersorRigid3DTransform`` a quaternion, but both answer ``TransformPoint`` the same way. + + Probing a *non*-linear transform would succeed and return a plausible + affine that is simply wrong away from the probed points, so the recovered + model is checked against a point none of the probes reached. That point + scales with the probe step: a check fixed near the origin cannot tell a + zeroed matrix from a correct one once the offset dominates, which is the + very cancellation the step exists to avoid. """ origin = [0.0] * dimension offset = np.asarray(itk_transform.TransformPoint(origin), dtype=float) + step = _probe_step(offset) + matrix = np.zeros((dimension, dimension)) + probes = [] for axis in range(dimension): basis = [0.0] * dimension - basis[axis] = 1.0 + basis[axis] = step column = np.asarray(itk_transform.TransformPoint(basis), dtype=float) - matrix[:, axis] = column - offset + probes.append(column) + matrix[:, axis] = (column - offset) / step + + if not np.all(np.isfinite(matrix)) or not np.all(np.isfinite(offset)): + msg = ( + f"{type(itk_transform).__name__} maps finite points to non-finite " + "values; its parameters are not usable numbers" + ) + raise ValueError(msg) + + # Distinct from every probe and from the origin, whatever the dimension. + check = step * (np.arange(dimension) + 2.0) / (dimension + 2.0) + predicted = matrix @ check + offset + actual = np.asarray(itk_transform.TransformPoint(check.tolist()), dtype=float) + rtol = _AFFINE_CHECK_RTOL[_working_precision(offset, *probes, actual)] + # Rounding in TransformPoint scales with the magnitudes the evaluation + # passes through (|A| |x| and |t|), not with the result: a component of + # T(check) can legitimately be tiny while the terms producing it are huge. + # Comparing against the output alone would reject such transforms as + # non-linear, so the comparison floor is the working scale. + working_scale = max( + float(np.abs(matrix).max() * np.abs(check).max()), + float(np.abs(offset).max()), + 1.0, + ) + magnitude = np.maximum(np.maximum(np.abs(predicted), np.abs(actual)), working_scale) + if not np.all(np.abs(predicted - actual) <= rtol * magnitude): + msg = ( + "only linear ITK transforms can be expressed as an RFC-5 affine; " + f"{type(itk_transform).__name__} does not map " + f"{np.round(check, 6).tolist()} the way an affine recovered from " + "its behaviour at the origin would" + ) + raise NotImplementedError(msg) return matrix, offset @@ -79,19 +211,74 @@ def _matrix_offset_from_itkwasm(entry, dimension: int): [] if entry.fixedParameters is None else entry.fixedParameters, dtype=float ) + # A transform of the wrong dimensionality must not be decoded. Slicing a + # 3D parameter vector down to 2D silently projects the transform (or, for + # an affine, scrambles rows into a singular matrix); reading a 2D one as + # 3D pads it with garbage. The parameter count is the ground truth here, + # not ``transformType.inputDimension``: ``itkwasm.TransformType`` defaults + # that field to 3, so a hand-built 2D entry that omitted it would be + # wrongly refused on the declared number alone. + expected = _direct_parameter_count(parameterization, dimension) + if expected is not None: + if parameters.size != expected: + msg = ( + f"ITK-Wasm '{parameterization}' transform carries " + f"{parameters.size} parameters, but a coordinate system with " + f"{dimension} spatial axes requires exactly {expected}. The " + "transform's dimensionality does not match `dims`." + ) + raise ValueError(msg) + # Only the directly decoded parameterizations read fixedParameters as + # a center; elsewhere (a displacement field's grid, say) it holds + # other metadata and is none of this branch's business. + if fixed.size not in (0, dimension): + msg = ( + f"ITK-Wasm '{parameterization}' transform carries " + f"{fixed.size} fixed parameters (the center), but a " + f"coordinate system with {dimension} spatial axes requires " + f"0 or {dimension}" + ) + raise ValueError(msg) + center = fixed if fixed.size == dimension else np.zeros(dimension) + if parameterization == "Identity": return np.eye(dimension), np.zeros(dimension) if parameterization == "Translation": - return np.eye(dimension), parameters[:dimension].copy() + return np.eye(dimension), parameters.copy() if parameterization == "Scale": - return np.diag(parameters[:dimension]), np.zeros(dimension) + # itk.ScaleTransform scales about its center, like the affine below. + matrix = np.diag(parameters) + return matrix, center - matrix @ center if parameterization == "Affine": matrix = parameters[: dimension * dimension].reshape(dimension, dimension) - translation = parameters[dimension * dimension :][:dimension] - center = fixed[:dimension] if fixed.size >= dimension else np.zeros(dimension) + translation = parameters[dimension * dimension :] # ITK applies the matrix about the center, so fold it into the offset. return matrix, translation + center - matrix @ center + if parameterization in _NON_LINEAR_PARAMETERIZATIONS: + msg = ( + "only linear ITK transforms can be expressed as an RFC-5 affine; " + f"'{parameterization}' describes a deformation. RFC-5 represents " + "those with a 'displacements' or 'coordinates' field instead." + ) + raise NotImplementedError(msg) + + # An angle or quaternion parameterization packs a type-specific number of + # parameters, so the count says nothing about dimensionality here. These + # entries come from serializers (itk.dict_from_transform, the ITK-Wasm + # pipeline), which do fill in the declared dimension, so it is trustworthy + # on this path in a way it is not for the hand-built matrix entries above. + try: + declared = int(transform_type.inputDimension) + except (TypeError, ValueError): + declared = dimension # unusable declaration: let the itk rebuild judge + if declared != dimension: + msg = ( + f"ITK-Wasm '{parameterization}' transform is {declared}D, but the " + f"coordinate system has {dimension} spatial axes" + ) + raise ValueError(msg) + try: import itk except ImportError as error: @@ -108,6 +295,9 @@ def _matrix_offset_from_itkwasm(entry, dimension: int): rebuilt = itk.transform_from_dict(asdict(entry)) if hasattr(rebuilt, "GetNthTransform") and rebuilt.GetNumberOfTransforms() == 1: rebuilt = rebuilt.GetNthTransform(0) + # The name check above only covers the parameterizations known at the time + # of writing; ITK's own answer covers the rest. + _reject_non_linear(rebuilt) return _matrix_offset_by_probing(rebuilt, dimension) @@ -117,12 +307,7 @@ def _itk_matrix_offset(transform, dimension: int): # A native itk.Transform, including the CompositeTransform Elastix returns. if hasattr(transform, "TransformPoint"): - if hasattr(transform, "IsLinear") and not transform.IsLinear(): - msg = ( - "only linear ITK transforms can be expressed as an RFC-5 affine; " - f"{type(transform).__name__} is not linear" - ) - raise NotImplementedError(msg) + _reject_non_linear(transform) return _matrix_offset_by_probing(transform, dimension) entries = ( @@ -136,6 +321,23 @@ def _itk_matrix_offset(transform, dimension: int): # matrices multiply left to right in list order. total = np.eye(dimension + 1) for entry in entries: + if _parameterization_name(entry.transformType) == "Composite": + # A parameterless 'Composite' entry is ambiguous. The ITK-Wasm + # pipeline writes one as a grouping header before the children, + # but itk.dict_from_transform never writes a header at all: there + # it is a *nested* composite whose children the serialization + # dropped, at any position including the first. Decoding past one + # would silently compose the wrong mapping, so refuse the entry + # wherever it appears and name the ways out. + msg = ( + "a 'Composite' entry in an ITK-Wasm transform list cannot be " + "decoded: itk.dict_from_transform drops a nested composite's " + "children, leaving this entry indistinguishable from a " + "pipeline grouping header. Pass the native itk.Transform " + "instead, or, for a pipeline-serialized list, drop the " + "leading header entry and pass the children." + ) + raise NotImplementedError(msg) matrix, offset = _matrix_offset_from_itkwasm(entry, dimension) homogeneous = np.eye(dimension + 1) homogeneous[:dimension, :dimension] = matrix @@ -144,9 +346,94 @@ def _itk_matrix_offset(transform, dimension: int): return total[:dimension, :dimension], total[:dimension, dimension] +def _itk_axis_order(spatial): + """The spatial dims in ITK component order: x first, then y, then z. + + ITK orders points fastest-axis-first by *name*, not by reversing however + the caller spelled ``dims``: reversing is only right for the canonical + (z, y, x). + """ + return [dim for dim in _SPATIAL_DIMS if dim in spatial] + + +def _permutation_from_itk(spatial) -> np.ndarray: + """The matrix sending an ITK-order vector to the ``dims``-order vector.""" + itk_dims = _itk_axis_order(spatial) + permutation = np.zeros((len(spatial), len(spatial))) + for row, dim in enumerate(spatial): + permutation[row, itk_dims.index(dim)] = 1.0 + return permutation + + +def _frame_geometry(fixed, moving, itk_dims): + """The direction matrices and origins ``ngff_image_to_itk_image`` gives + the two images, in ITK component order.""" + from .itk_transform_resample_bounding_box import _itk_direction + + return ( + _itk_direction(fixed, itk_dims), + _itk_direction(moving, itk_dims), + np.array([float(fixed.translation[d]) for d in itk_dims]), + np.array([float(moving.translation[d]) for d in itk_dims]), + ) + + +def _change_of_frame( + matrix, offset, direction_in, direction_out, origin_in, origin_out +): + """Re-express ``y = A x + t`` through a change of frame on each side. + + ``ngff_image_to_itk_image`` builds each image with ``origin = translation`` + and the RFC-4 direction matrix ``D``, so an intrinsic point ``p`` sits at + the physical point ``phi(p) = D (p - o) + o``. Given a mapping ``T`` + between two frames, this returns ``phi_out^-1 . T . phi_in`` for the + directions and origins supplied: + + M = D_out^-1 A D_in + b = D_out^-1 (A (I - D_in) o_in + t - o_out) + o_out + + ``phi^-1`` has the same shape as ``phi`` with ``D^-1`` in place of ``D``, + so the same formula serves both conversions: ITK to RFC-5 passes the + images' directions, RFC-5 to ITK passes their inverses. The scales cancel + on both sides; the translations do not, which is why orientations alone + would not be enough. With no anatomical orientation every direction is + the identity and the mapping comes back unchanged. + """ + inverse_out = np.linalg.inv(direction_out) + identity = np.eye(len(origin_in)) + conjugated = inverse_out @ matrix @ direction_in + shifted = ( + inverse_out + @ (matrix @ (identity - direction_in) @ origin_in + offset - origin_out) + + origin_out + ) + return conjugated, shifted + + +def _check_frame_images(fixed, moving, spatial): + """Validate the fixed/moving pair handed to a converter.""" + if (fixed is None) != (moving is None): + msg = "pass both fixed and moving, or neither" + raise ValueError(msg) + if fixed is None: + return False + for label, image in (("fixed", fixed), ("moving", moving)): + missing = [d for d in spatial if d not in image.translation] + if missing: + msg = ( + f"the {label} image has no translation entry for spatial " + f"dimension(s) {missing}; its dims do not cover `dims`" + ) + raise ValueError(msg) + return True + + def itk_transform_to_ngff_matrix( transform, dims: Sequence[str], + *, + fixed=None, + moving=None, ) -> tuple[np.ndarray, np.ndarray]: """Convert an ITK transform to a matrix and offset in RFC-5 axis order. @@ -171,15 +458,23 @@ def itk_transform_to_ngff_matrix( matrix, offset = _itk_matrix_offset(transform, len(spatial)) - # ITK (fastest-axis-first) order -> RFC-5 (Zarr) order. - reversal = np.eye(len(spatial))[::-1] - return reversal @ matrix @ reversal, reversal @ offset + if _check_frame_images(fixed, moving, spatial): + # ITK physical space -> the intrinsic systems: phi_m^-1 . T . phi_f. + geometry = _frame_geometry(fixed, moving, _itk_axis_order(spatial)) + matrix, offset = _change_of_frame(matrix, offset, *geometry) + + # ITK (fastest-axis-first) order -> the order the axes appear in `dims`. + permutation = _permutation_from_itk(spatial) + return permutation @ matrix @ permutation.T, permutation @ offset def itk_transform_to_ngff_transform( transform, dims: Sequence[str], simplify: bool = True, + *, + fixed=None, + moving=None, ) -> Transform: """Convert an ITK transform to an RFC-5 coordinate transformation. @@ -199,18 +494,36 @@ def itk_transform_to_ngff_transform( :param simplify: Return the least expressive transformation that represents the mapping exactly -- ``identity``, ``translation``, ``scale``, or a ``sequence`` of scale and translation -- falling back to ``affine``. - RFC-5 recommends this, and only these simpler forms are legal inside - ``multiscales > datasets``. Set to ``False`` to always get an ``affine``. + RFC-5 recommends this. A mirror never simplifies to a ``scale``, since + RFC-5 requires strictly positive scale factors. Note that + ``multiscales > datasets`` accepts only a single ``scale``, a single + ``identity``, or a two-element ``sequence`` of scale and translation, + so a bare ``translation`` or an ``affine`` belongs in the + multiscales-level ``coordinateTransformations`` instead. Set to + ``False`` to always get an ``affine``. :type simplify: bool :return: An RFC-5 coordinate transformation over ``dims``. :rtype: Transform + :param fixed: The fixed and moving images the ITK transform was produced + on. An ITK transform acts on ITK physical space, which includes the + direction matrix derived from RFC-4 anatomical orientation; an RFC-5 + transformation acts on the intrinsic coordinate systems. Passing both + images lets the conversion change frames exactly. Omitting them is + exact only when neither image carries an anatomical orientation. + :type fixed: NgffImage, optional + + :param moving: See ``fixed``. Pass both or neither. + :type moving: NgffImage, optional + :raises NotImplementedError: If the transform is not linear. A non-linear registration has no affine equivalent. """ dims = tuple(dims) - matrix, offset = itk_transform_to_ngff_matrix(transform, dims) + matrix, offset = itk_transform_to_ngff_matrix( + transform, dims, fixed=fixed, moving=moving + ) spatial_indices = [index for index, dim in enumerate(dims) if dim in _SPATIAL_DIMS] ndim = len(dims) @@ -236,22 +549,29 @@ def itk_transform_to_ngff_transform( def _simplify(matrix: np.ndarray, offset: np.ndarray, ndim: int) -> Transform | None: """Return a less expressive equivalent transformation, or ``None``.""" + diagonal = np.diag(matrix) is_identity = np.array_equal(matrix, np.eye(ndim)) - is_diagonal = np.array_equal(matrix, np.diag(np.diag(matrix))) + is_diagonal = np.array_equal(matrix, np.diag(diagonal)) no_offset = not offset.any() + # RFC-5 requires every scale factor to be strictly positive, so a mirror + # or a flip is not a `scale` however diagonal its matrix looks. Emitting + # one anyway writes metadata the schema rejects, and an LPS to RAS flip + # (diag(-1, -1, 1)) is an ordinary registration result. Fall through to + # `affine`, which carries the sign. + is_scale = is_diagonal and bool((diagonal > 0).all()) if is_identity and no_offset: return Identity() if is_identity: return Translation(translation=offset.tolist()) - if is_diagonal and no_offset: - return Scale(scale=np.diag(matrix).tolist()) - if is_diagonal: + if is_scale and no_offset: + return Scale(scale=diagonal.tolist()) + if is_scale: # Scale first, then translate: y = scale * x + translation, which is # the form `multiscales > datasets` accepts. return TransformSequence( transformations=[ - Scale(scale=np.diag(matrix).tolist()), + Scale(scale=diagonal.tolist()), Translation(translation=offset.tolist()), ] ) diff --git a/py/ngff_zarr/ngff_transform_to_itk_transform.py b/py/ngff_zarr/ngff_transform_to_itk_transform.py index 0a49b60c..a69cf7f8 100644 --- a/py/ngff_zarr/ngff_transform_to_itk_transform.py +++ b/py/ngff_zarr/ngff_transform_to_itk_transform.py @@ -9,10 +9,11 @@ Axis order RFC-5 orders transformation parameters the same way the Zarr array is ordered -- parameter ``i`` belongs to coordinate-system axis ``i``, so a - ``zyx`` image has ``z`` first. ITK orders points fastest-axis-first, so - the same point is ``xyz``. Writing ``R`` for the axis-reversal - permutation, an RFC-5 affine ``q = M p + b`` becomes ``A = R M R`` and - ``t = R b`` in ITK. + ``zyx`` image has ``z`` first. ITK orders points fastest-axis-first by + name: x, then y, then z. Writing ``P`` for the permutation sending an + ITK-order vector to the ``dims``-order vector, an RFC-5 affine + ``q = M p + b`` becomes ``A = P^T M P`` and ``t = P^T b`` in ITK. For + the canonical ``zyx`` order ``P`` is the axis reversal. Composition order An RFC-5 ``sequence`` applies its first entry first. An ITK transform @@ -75,9 +76,13 @@ def _homogeneous_from_transform(transform: Transform, ndim: int) -> np.ndarray: if isinstance(transform, Rotation): rotation = _as_matrix(transform.rotation, transform.path, "rotation") if rotation.shape != (ndim, ndim): + # Formatted from the whole shape tuple: a flat parameter array is + # 1-D, and indexing a fixed axis would raise IndexError from + # inside the message meant to explain the problem. + shape = "x".join(str(extent) for extent in rotation.shape) msg = ( - f"rotation transformation is {rotation.shape[0]}x{rotation.shape[1]} " - f"but the coordinate system has {ndim} axes" + f"rotation transformation is {shape} but the coordinate " + f"system has {ndim} axes" ) raise ValueError(msg) matrix = np.eye(ndim + 1) @@ -90,10 +95,11 @@ def _homogeneous_from_transform(transform: Transform, ndim: int) -> np.ndarray: # rotation/scale/shear part followed by the translation as the last # column, with the trailing [0 ... 0 1] row omitted. if affine.shape != (ndim, ndim + 1): + shape = "x".join(str(extent) for extent in affine.shape) msg = ( - f"affine transformation is {affine.shape[0]}x{affine.shape[1]} but " - f"a coordinate system with {ndim} axes requires " - f"{ndim}x{ndim + 1} (the translation is the last column)" + f"affine transformation is {shape} but a coordinate system " + f"with {ndim} axes requires {ndim}x{ndim + 1} (the " + "translation is the last column)" ) raise ValueError(msg) matrix = np.eye(ndim + 1) @@ -147,12 +153,17 @@ def _as_matrix(values, path: str | None, field: str) -> np.ndarray: return np.asarray(values, dtype=float) -def ngff_transform_to_itk_matrix( +def _ngff_transform_to_itk_matrix( transform: Transform, dims: Sequence[str], ) -> tuple[np.ndarray, np.ndarray]: """Convert an RFC-5 transformation to an ITK matrix and offset. + Internal. :func:`ngff_transform_to_itk_transform` is the public entry + point; nothing outside this package needs the raw numbers, since ITK is + what the caller wants to hand them to. The tests use it as a fine probe on + the axis and composition conventions. + :param transform: An RFC-5 (OME-Zarr v0.6) coordinate transformation. It must describe a linear mapping -- ``identity``, ``scale``, ``translation``, ``rotation``, ``affine``, or a ``sequence`` of those. @@ -163,7 +174,12 @@ def ngff_transform_to_itk_matrix( :type dims: Sequence[str] :return: ``(matrix, offset)`` for the spatial axes only, in ITK - (fastest-axis-first) order. + (fastest-axis-first) order. ITK has no notion of a non-spatial axis, so + a component acting purely on ``t`` or ``c`` -- a frame interval, say -- + is *projected away*. That is lossless for the spatial mapping, which is + all an ITK transform describes, but the returned transform is not a + faithful copy of the input. A component that *couples* the two kinds of + axis is refused instead, because dropping it would move the image. :rtype: tuple[numpy.ndarray, numpy.ndarray] :raises NotImplementedError: If the transformation is not linear. @@ -194,14 +210,24 @@ def ngff_transform_to_itk_matrix( matrix = homogeneous[np.ix_(spatial_indices, spatial_indices)] offset = homogeneous[spatial_indices, len(dims)] - # RFC-5 (Zarr) order -> ITK (fastest-axis-first) order. - reversal = np.eye(len(spatial_indices))[::-1] - return reversal @ matrix @ reversal, reversal @ offset + # RFC-5 (`dims`) order -> ITK (fastest-axis-first) order. ITK orders + # components by *name* (x, then y, then z), so the permutation is built + # by name rather than by reversing `dims`, which is only equivalent for + # the canonical (z, y, x). + spatial = [dims[index] for index in spatial_indices] + itk_dims = [dim for dim in _SPATIAL_DIMS if dim in spatial] + permutation = np.zeros((len(spatial), len(spatial))) + for row, dim in enumerate(spatial): + permutation[row, itk_dims.index(dim)] = 1.0 + return permutation.T @ matrix @ permutation, permutation.T @ offset def ngff_transform_to_itk_transform( transform: Transform, dims: Sequence[str], + *, + fixed=None, + moving=None, ) -> list: """Convert an RFC-5 transformation to an ITK-Wasm transform list. @@ -216,6 +242,16 @@ def ngff_transform_to_itk_transform( defined on, in RFC-5 (Zarr) order. :type dims: Sequence[str] + :param fixed: The fixed and moving images the transform relates. Passing + both lets the conversion re-express the intrinsic-space mapping on ITK + physical space, including the direction matrix derived from RFC-4 + anatomical orientation. Omitting them is exact only when neither image + carries an anatomical orientation. + :type fixed: NgffImage, optional + + :param moving: See ``fixed``. Pass both or neither. + :type moving: NgffImage, optional + :return: A single-entry ITK-Wasm ``TransformList``. :rtype: list[itkwasm.Transform] """ @@ -228,9 +264,32 @@ def ngff_transform_to_itk_transform( Transform as ItkTransform, ) - matrix, offset = ngff_transform_to_itk_matrix(transform, dims) + matrix, offset = _ngff_transform_to_itk_matrix(transform, dims) dimension = offset.shape[0] + from .itk_transform_to_ngff_transform import ( + _change_of_frame, + _check_frame_images, + _frame_geometry, + ) + + spatial = [dim for dim in dims if dim in _SPATIAL_DIMS] + if _check_frame_images(fixed, moving, spatial): + # The intrinsic systems -> ITK physical space: phi_m . T . phi_f^-1, + # which is the same change of frame with the directions inverted. + itk_dims = [dim for dim in _SPATIAL_DIMS if dim in spatial] + direction_fixed, direction_moving, origin_fixed, origin_moving = ( + _frame_geometry(fixed, moving, itk_dims) + ) + matrix, offset = _change_of_frame( + matrix, + offset, + np.linalg.inv(direction_fixed), + np.linalg.inv(direction_moving), + origin_fixed, + origin_moving, + ) + # ITK's MatrixOffsetTransformBase packs the row-major matrix followed by # the translation, with the center of rotation as the fixed parameters. # An RFC-5 affine has no center, so it stays at the origin. diff --git a/py/test/test_itk_transform_resample_bounding_box.py b/py/test/test_itk_transform_resample_bounding_box.py index 93775711..40b866ab 100644 --- a/py/test/test_itk_transform_resample_bounding_box.py +++ b/py/test/test_itk_transform_resample_bounding_box.py @@ -20,12 +20,12 @@ NgffImage, itk_transform_resample_bounding_box, ngff_image_to_itk_image, - ngff_transform_to_itk_matrix, ) from ngff_zarr.itk_transform_resample_bounding_box import ( _itk_direction, _metadata_only_itk_image, ) +from ngff_zarr.ngff_transform_to_itk_transform import _ngff_transform_to_itk_matrix from ngff_zarr.v06.zarr_metadata import ( Affine, Displacements, @@ -202,7 +202,7 @@ def test_asymmetric_three_dimensional_ngff_affine_matches_oracle(): def test_affine_translation_is_the_last_column(): """RFC-5 stores the translation as the last column of the affine matrix.""" - matrix, offset = ngff_transform_to_itk_matrix( + matrix, offset = _ngff_transform_to_itk_matrix( Affine(affine=[[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]), ("y", "x") ) # In NGFF terms: y = 1*y + 2*x + 3 and x = 4*y + 5*x + 6. ITK reverses the @@ -213,7 +213,7 @@ def test_affine_translation_is_the_last_column(): def test_affine_with_wrong_shape_is_rejected(): with pytest.raises(ValueError, match="translation is the last column"): - ngff_transform_to_itk_matrix( + _ngff_transform_to_itk_matrix( Affine(affine=[[1.0, 0.0], [0.0, 1.0]]), ("y", "x") ) @@ -231,7 +231,7 @@ def test_sequence_applies_its_first_entry_first(): Scale(scale=[1.0, 2.0]), ] ) - matrix, offset = ngff_transform_to_itk_matrix(sequence, ("y", "x")) + matrix, offset = _ngff_transform_to_itk_matrix(sequence, ("y", "x")) # ITK order is (x, y): translate by 10 then scale by 2 gives an offset of 20. assert np.isclose(offset[0], 20.0) @@ -243,10 +243,42 @@ def test_sequence_applies_its_first_entry_first(): Translation(translation=[0.0, 10.0]), ] ) - _, reversed_offset = ngff_transform_to_itk_matrix(reversed_sequence, ("y", "x")) + _, reversed_offset = _ngff_transform_to_itk_matrix(reversed_sequence, ("y", "x")) assert np.isclose(reversed_offset[0], 10.0) +def test_sequence_order_survives_the_whole_pipeline(): + """The composition order has to hold at the public entry point too. + + Every other sequence test here reads the matrix out of + ``_ngff_transform_to_itk_matrix``. If the order inverted, the region the + caller actually gets would move, and nothing below that helper would + notice. Applying ``[translate 10, scale 2]`` in the wrong order gives + ``2x + 10`` instead of ``2(x + 10)``, so the x start moves by 10. + """ + spatial = ("y", "x") + fixed = _image(spatial, {"y": 16, "x": 16}, {"y": 2, "x": 2}, {"y": 20, "x": 10}) + moving = _image(spatial, {"y": 512, "x": 512}, {"y": 1, "x": 1}, {"y": 0, "x": 0}) + sequence = TransformSequence( + transformations=[ + Translation(translation=[0.0, 10.0]), + Scale(scale=[1.0, 2.0]), + ] + ) + + bounding_box = itk_transform_resample_bounding_box( + sequence, fixed, moving, padding=0 + ) + + # First entry first: y = x, x = 2(x + 10) = 2x + 20. + expected_start, expected_size = _oracle_region( + np.diag([1.0, 2.0]), np.array([0.0, 20.0]), fixed, moving, spatial, 0 + ) + assert [bounding_box.start_index[d] for d in spatial] == expected_start.tolist() + assert [bounding_box.size[d] for d in spatial] == expected_size.tolist() + assert bounding_box.start_index["x"] == 40 # 30 if the order inverted + + def test_nested_sequences_compose(): inner = TransformSequence( transformations=[Scale(scale=[1.0, 2.0]), Translation(translation=[0.0, 1.0])] @@ -254,35 +286,125 @@ def test_nested_sequences_compose(): outer = TransformSequence( transformations=[inner, Translation(translation=[0.0, 100.0])] ) - _, offset = ngff_transform_to_itk_matrix(outer, ("y", "x")) + _, offset = _ngff_transform_to_itk_matrix(outer, ("y", "x")) assert np.isclose(offset[0], 101.0) def test_identity_scale_rotation_round_trip(): - matrix, offset = ngff_transform_to_itk_matrix(Identity(), ("y", "x")) + matrix, offset = _ngff_transform_to_itk_matrix(Identity(), ("y", "x")) assert np.allclose(matrix, np.eye(2)) assert np.allclose(offset, np.zeros(2)) - matrix, _ = ngff_transform_to_itk_matrix(Scale(scale=[2.0, 3.0]), ("y", "x")) + matrix, _ = _ngff_transform_to_itk_matrix(Scale(scale=[2.0, 3.0]), ("y", "x")) # Reversed to ITK order (x, y). assert np.allclose(matrix, np.diag([3.0, 2.0])) rotation = [[0.0, -1.0], [1.0, 0.0]] - matrix, _ = ngff_transform_to_itk_matrix(Rotation(rotation=rotation), ("y", "x")) + matrix, _ = _ngff_transform_to_itk_matrix(Rotation(rotation=rotation), ("y", "x")) reversal = np.eye(2)[::-1] assert np.allclose(matrix, reversal @ np.array(rotation) @ reversal) def test_non_linear_transform_is_rejected(): with pytest.raises(NotImplementedError, match="cannot be converted"): - ngff_transform_to_itk_matrix(Displacements(path="field"), ("y", "x")) + _ngff_transform_to_itk_matrix(Displacements(path="field"), ("y", "x")) def test_transform_coupling_spatial_and_non_spatial_axes_is_rejected(): affine = np.eye(3, 4) affine[1, 0] = 0.5 # y would depend on c with pytest.raises(ValueError, match="couples spatial and non-spatial"): - ngff_transform_to_itk_matrix(Affine(affine=affine.tolist()), ("c", "y", "x")) + _ngff_transform_to_itk_matrix(Affine(affine=affine.tolist()), ("c", "y", "x")) + + +def test_rfc5_branch_ignores_anatomical_orientation(): + """An RFC-5 transformation acts on the intrinsic coordinate system, + where a point is ``translation + scale * index`` and no direction matrix + applies, so the region it selects cannot depend on RFC-4 anatomical + orientation: the oriented region must equal the unoriented one. + """ + from ngff_zarr.rfc4 import RAS + + spatial = ("z", "y", "x") + shape = {"z": 8, "y": 16, "x": 24} + scale = dict.fromkeys(spatial, 1.0) + translation = dict.fromkeys(spatial, 0.0) + transform = Translation(translation=[2.0, 3.0, 4.0]) + + plain = itk_transform_resample_bounding_box( + transform, + _image(spatial, shape, scale, translation), + _image(spatial, {"z": 32, "y": 64, "x": 96}, scale, translation), + padding=0, + ) + oriented = itk_transform_resample_bounding_box( + transform, + _image(spatial, shape, scale, translation, RAS), + _image(spatial, {"z": 32, "y": 64, "x": 96}, scale, translation, RAS), + padding=0, + ) + + assert oriented.start_index == plain.start_index == {"z": 2, "y": 3, "x": 4} + assert oriented.size == plain.size + + +def test_non_canonical_spatial_order_binds_itk_axes_by_name(): + """ITK's first component is x by *name*, not whichever axis comes last. + + Reversing the dims is only equivalent for the canonical ("z", "y", "x"): + with dims ("z", "x", "y") it would bind ITK x to y and ITK y to x, + silently swapping the two axes' regions. Both paths must agree, and + agree with the axis names. + """ + dims = ("z", "x", "y") + fixed = _image( + dims, + {"z": 4, "x": 8, "y": 16}, + dict.fromkeys(dims, 1.0), + dict.fromkeys(dims, 0.0), + ) + moving = _image( + dims, + {"z": 32, "x": 64, "y": 64}, + dict.fromkeys(dims, 1.0), + dict.fromkeys(dims, 0.0), + ) + expected = {"z": 1, "x": 7, "y": 5} + + # RFC-5 parameters are in dims order: (z, x, y). + via_rfc5 = itk_transform_resample_bounding_box( + Translation(translation=[1.0, 7.0, 5.0]), fixed, moving, padding=0 + ) + # ITK parameters are fastest-axis-first by name: (x, y, z). + via_itk = itk_transform_resample_bounding_box( + _translation([7.0, 5.0, 1.0]), fixed, moving, padding=0 + ) + + assert via_rfc5.start_index == expected + assert via_itk.start_index == expected + + +def test_a_component_on_a_non_spatial_axis_alone_is_projected_away(): + """ITK has no non-spatial axis, so a frame interval on ``t`` has nowhere + to go. + + Dropping it leaves the spatial mapping exact, which is all an ITK transform + describes. This pins that as a decision rather than an accident; the + coupled case above is refused instead, because dropping *that* would move + the image. + """ + sequence = TransformSequence( + transformations=[ + Scale(scale=[0.5, 1.0, 2.0, 2.0]), + Translation(translation=[7.0, 0.0, 3.0, -4.0]), + ] + ) + + matrix, offset = _ngff_transform_to_itk_matrix(sequence, ("t", "c", "y", "x")) + + # ITK order (x, y): the t and c entries are gone, the spatial ones exact. + assert np.allclose(matrix, np.diag([2.0, 2.0])) + assert np.allclose(offset, [-4.0, 3.0]) def test_v04_transform_dataclasses_are_accepted(): diff --git a/py/test/test_itk_transform_to_ngff_transform.py b/py/test/test_itk_transform_to_ngff_transform.py index 8d134ffb..f1681f8c 100644 --- a/py/test/test_itk_transform_to_ngff_transform.py +++ b/py/test/test_itk_transform_to_ngff_transform.py @@ -9,19 +9,21 @@ import numpy as np import pytest +import zarr from ngff_zarr import ( itk_transform_to_ngff_matrix, itk_transform_to_ngff_transform, - ngff_transform_to_itk_matrix, ngff_transform_to_itk_transform, ) from ngff_zarr.v06.zarr_metadata import ( Affine, Identity, + Rotation, Scale, TransformSequence, Translation, ) +from packaging import version ROUND_TRIP_CASES = [ ("identity", Identity(), ("y", "x")), @@ -64,6 +66,44 @@ ] +def _apply(transform, point): + """Evaluate an RFC-5 transformation, independently of the module. + + The round trip below compares mappings point by point rather than reading + both sides through ``_ngff_transform_to_itk_matrix``, so it does not lean on + the helper whose conventions it is meant to exercise. + + Note what a round trip can and cannot show. It establishes that the two + directions are mutual inverses; by construction it cannot see a convention + the two of them get wrong *together*, since that error cancels. Reverse the + axes in neither direction and every case here still passes. The absolute + anchors live elsewhere: the documented worked example, ``_oracle_region``, + and the comparisons against ``itk``'s own ``TransformPoint``. + """ + point = np.asarray(point, dtype=float) + if isinstance(transform, Identity): + return point + if isinstance(transform, Scale): + return point * np.asarray(transform.scale, dtype=float) + if isinstance(transform, Translation): + return point + np.asarray(transform.translation, dtype=float) + if isinstance(transform, Affine): + affine = np.asarray(transform.affine, dtype=float) + return affine[:, :-1] @ point + affine[:, -1] + if isinstance(transform, TransformSequence): + # RFC-5 applies the first entry first. + for sub_transform in transform.transformations: + point = _apply(sub_transform, point) + return point + raise AssertionError(f"the oracle does not handle {type(transform).__name__}") + + +def _sample_points(ndim): + """Points that pin every column of the matrix and the offset.""" + rng = np.random.default_rng(20260806) + return [np.zeros(ndim), *rng.normal(scale=10.0, size=(4, ndim))] + + @pytest.mark.parametrize( "transform, dims", [(case[1], case[2]) for case in ROUND_TRIP_CASES], @@ -71,15 +111,12 @@ ) def test_round_trip_preserves_the_mapping(transform, dims): """RFC-5 -> ITK -> RFC-5 must describe the same function.""" - expected_matrix, expected_offset = ngff_transform_to_itk_matrix(transform, dims) - converted = itk_transform_to_ngff_transform( ngff_transform_to_itk_transform(transform, dims), dims ) - matrix, offset = ngff_transform_to_itk_matrix(converted, dims) - assert np.allclose(matrix, expected_matrix) - assert np.allclose(offset, expected_offset) + for point in _sample_points(len(dims)): + assert np.allclose(_apply(converted, point), _apply(transform, point)) @pytest.mark.parametrize( @@ -109,6 +146,75 @@ def test_simplify_returns_the_least_expressive_form(transform, expected_type): assert isinstance(converted, expected_type) +def test_a_mirror_does_not_simplify_to_a_scale(): + """RFC-5 requires strictly positive scale factors. + + A flip is diagonal, so a sign-blind simplifier calls it a ``scale`` and + writes metadata the 0.6 schema rejects. An LPS to RAS flip is an ordinary + registration result, so this is not an exotic input. + """ + itk = pytest.importorskip("itk") + + flip = itk.ScaleTransform[itk.D, 2].New() + flip.SetScale([-1.0, 1.0]) + + converted = itk_transform_to_ngff_transform(flip, ("y", "x")) + + assert isinstance(converted, Affine) + assert np.allclose(converted.affine, [[1.0, 0.0, 0.0], [0.0, -1.0, 0.0]]) + + # A mirror with a translation must not become a scale/translation sequence + # either, for the same reason. + mirror = itk.AffineTransform[itk.D, 2].New() + mirror.SetMatrix(itk.matrix_from_array(np.diag([-2.0, 3.0]))) + mirror.SetTranslation([5.0, 7.0]) + mirror.SetCenter([0.0, 0.0]) + assert isinstance(itk_transform_to_ngff_transform(mirror, ("y", "x")), Affine) + + # ... while a strictly positive diagonal still simplifies. + positive = itk.ScaleTransform[itk.D, 2].New() + positive.SetScale([2.0, 3.0]) + assert isinstance(itk_transform_to_ngff_transform(positive, ("y", "x")), Scale) + + +@pytest.mark.skipif( + version.parse(zarr.__version__) < version.parse("3.0.0b1"), + reason="writing OME-Zarr 0.6 requires zarr-python 3", +) +def test_a_mirror_writes_a_store_that_validates(tmp_path): + """The point of the sign rule: the metadata has to survive the validator.""" + itk = pytest.importorskip("itk") + + import dask.array as da + from ngff_zarr import from_ome_zarr, to_multiscales, to_ngff_image, to_ome_zarr + from ngff_zarr.v06.zarr_metadata import ( + CoordinateSystem, + CoordinateSystemIdentifier, + ) + + flip = itk.ScaleTransform[itk.D, 2].New() + flip.SetScale([-1.0, 1.0]) + converted = itk_transform_to_ngff_transform(flip, ("y", "x")) + + image = to_ngff_image( + da.zeros((32, 32), dtype=np.uint8), + dims=["y", "x"], + scale={"y": 1.0, "x": 1.0}, + translation={"y": 0.0, "x": 0.0}, + ) + multiscales = to_multiscales(image, scale_factors=[]) + intrinsic = multiscales.metadata.coordinateSystems[0] + registered = CoordinateSystem(name="registered", axes=list(intrinsic.axes)) + multiscales.metadata.coordinateSystems.append(registered) + converted.input = CoordinateSystemIdentifier(name=intrinsic.name) + converted.output = CoordinateSystemIdentifier(name=registered.name) + multiscales.metadata.coordinateTransformations = [converted] + + store = tmp_path / "mirror.ome.zarr" + to_ome_zarr(str(store), multiscales, version="0.6") + from_ome_zarr(str(store), validate=True) + + def test_simplify_can_be_disabled(): dims = ("y", "x") converted = itk_transform_to_ngff_transform( @@ -189,6 +295,31 @@ def test_non_linear_itk_transform_is_rejected(): itk_transform_to_ngff_transform(bspline, ("y", "x")) +def _itkwasm_entry(parameterization, parameters, fixed, dimension=2): + """A bare ITK-Wasm transform entry of any parameterization.""" + from itkwasm import ( + FloatTypes, + Transform, + TransformParameterizations, + TransformType, + ) + + return Transform( + transformType=TransformType( + transformParameterization=getattr( + TransformParameterizations, parameterization + ), + parametersValueType=FloatTypes.Float64, + inputDimension=dimension, + outputDimension=dimension, + ), + numberOfFixedParameters=len(fixed), + numberOfParameters=len(parameters), + fixedParameters=np.asarray(fixed, dtype=np.float64), + parameters=np.asarray(parameters, dtype=np.float64), + ) + + def _itkwasm_affine(matrix_row_major, translation, center, parameterization=None): """An ITK-Wasm affine, by default built with the enum member.""" from itkwasm import ( @@ -221,6 +352,281 @@ def _itkwasm_affine(matrix_row_major, translation, center, parameterization=None ) +def _displacement_field_transform(itk, size=8, spacing=8.0, seed=0): + """A random deformation: nothing about it is affine.""" + field = itk.Image[itk.Vector[itk.D, 2], 2].New() + region = itk.ImageRegion[2]() + extent = itk.Size[2]() + extent[0], extent[1] = size, size + region.SetSize(extent) + field.SetRegions(region) + field.SetSpacing([spacing, spacing]) + field.Allocate() + rng = np.random.default_rng(seed) + itk.array_view_from_image(field)[:] = rng.normal(scale=5.0, size=(size, size, 2)) + + transform = itk.DisplacementFieldTransform[itk.D, 2].New() + transform.SetDisplacementField(field) + return transform + + +def test_non_linear_transform_is_rejected_as_an_itkwasm_entry(): + """The ITK-Wasm path must refuse what the native path refuses. + + Probing a deformation succeeds: three points always determine an affine. + The result is a plausible transform that is simply wrong everywhere else, + and it would be written into the store without a word. + """ + itk = pytest.importorskip("itk") + from ngff_zarr.itk_transform_resample_bounding_box import _as_itk_transform_list + + displacement = _displacement_field_transform(itk) + with pytest.raises(NotImplementedError, match="only linear"): + itk_transform_to_ngff_transform(displacement, ("y", "x")) + + entries = _as_itk_transform_list(displacement) + with pytest.raises(NotImplementedError, match="only linear"): + itk_transform_to_ngff_transform(entries, ("y", "x")) + + +def test_non_linear_transform_is_rejected_without_itk(monkeypatch): + """Refusing a deformation must not depend on the optional ``itk`` extra.""" + itk = pytest.importorskip("itk") + from ngff_zarr.itk_transform_resample_bounding_box import _as_itk_transform_list + + entries = _as_itk_transform_list(_displacement_field_transform(itk)) + + import builtins + + real_import = builtins.__import__ + + def no_itk(name, *args, **kwargs): + if name == "itk": + raise ImportError("itk is unavailable for this test") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", no_itk) + + with pytest.raises(NotImplementedError, match="describes a deformation"): + itk_transform_to_ngff_matrix(entries, ("y", "x")) + + +def test_probing_refuses_a_transform_that_is_not_affine(): + """The last line of defence, for a type ``IsLinear()`` does not catch.""" + from ngff_zarr.itk_transform_to_ngff_transform import _matrix_offset_by_probing + + class Quadratic: + """Linear at the origin and along each axis, quadratic in between.""" + + def TransformPoint(self, point): + x, y = point + return [x + 3.0 * x * y, y] + + with pytest.raises(NotImplementedError, match="only linear"): + _matrix_offset_by_probing(Quadratic(), 2) + + +def _float32_affine(matrix, translation): + """An ``itk.AffineTransform[itk.F, N]``, built through its parameters. + + ``SetMatrix`` wants an ``itk.Matrix`` of the same value type, so the + parameter vector is the least fiddly way in. + """ + itk = pytest.importorskip("itk") + + matrix = np.asarray(matrix, dtype=float) + dimension = matrix.shape[0] + transform = itk.AffineTransform[itk.F, dimension].New() + parameters = itk.OptimizerParameters[itk.F](dimension * dimension + dimension) + for index, value in enumerate([*matrix.ravel(), *translation]): + parameters.SetElement(index, float(value)) + transform.SetParameters(parameters) + fixed = itk.OptimizerParameters[itk.D](dimension) + for index in range(dimension): + fixed.SetElement(index, 0.0) + transform.SetFixedParameters(fixed) + return transform + + +# Stored as the nearest doubles; recovery is compared against those doubles, +# so the tolerance below covers only the probing arithmetic itself. +_ROTATION_3D = [[1.0, 0.0, 0.0], [0.0, 0.8, 0.6], [0.0, -0.6, 0.8]] + + +@pytest.mark.parametrize("offset", [0.0, 1e3, 1e8, 1e12, 1e15]) +def test_probing_survives_an_offset_that_dwarfs_the_matrix(offset): + """``T(e_j) - T(0)`` cancels catastrophically when the offset is large. + + A step fixed at 1 keeps no significant digit of the matrix once the offset + reaches 1e15, and the recovered transform silently collapses towards a + zero matrix. Worse, the check point has to grow with it: one fixed near + the origin cannot tell a zeroed matrix from a correct one, because the + offset dominates the prediction either way. + + Nanometre coordinates put real electron-microscopy data in this range. + """ + itk = pytest.importorskip("itk") + + transform = itk.AffineTransform[itk.D, 3].New() + transform.SetMatrix(itk.matrix_from_array(np.asarray(_ROTATION_3D))) + transform.SetTranslation([offset] * 3) + transform.SetCenter([0.0] * 3) + + matrix, _ = itk_transform_to_ngff_matrix(transform, ("z", "y", "x")) + + reversal = np.eye(3)[::-1] + expected = reversal @ np.asarray(_ROTATION_3D) @ reversal + assert np.allclose(matrix, expected, rtol=0, atol=1e-12) + + +@pytest.mark.parametrize("offset", [0.0, 1e3, 1e8]) +def test_single_precision_transforms_are_not_rejected_as_non_linear(offset): + """A float32 transform carries about seven digits, not sixteen. + + Holding it to a double-precision tolerance refuses every single-precision + rotation, at any magnitude including none, even though ``IsLinear()`` says + it is linear and it is. + """ + transform = _float32_affine(_ROTATION_3D, [offset] * 3) + assert transform.IsLinear() + + matrix, _ = itk_transform_to_ngff_matrix(transform, ("z", "y", "x")) + + reversal = np.eye(3)[::-1] + expected = reversal @ np.asarray(_ROTATION_3D) @ reversal + assert np.allclose(matrix, expected, rtol=0, atol=1e-6) + + +def test_probing_survives_hidden_large_intermediates(): + """A composite may pass through internal frames that dwarf its outputs. + + Two translations out to a global frame at 1e9 and back leave T(x) - x + exactly constant, but the rounding of those hidden intermediates lands in + the probed values. A tolerance measured against the *output* rejects such + transforms as non-linear; measured against the working scale it must not. + """ + itk = pytest.importorskip("itk") + + big = 1e9 + composite = itk.CompositeTransform[itk.D, 3].New() + outward = itk.TranslationTransform[itk.D, 3].New() + outward.SetOffset([big, -big, big]) + backward = itk.TranslationTransform[itk.D, 3].New() + backward.SetOffset([-big + 5.25, big - 3.125, -big + 1.5]) + composite.AddTransform(outward) + composite.AddTransform(backward) + + matrix, offset = itk_transform_to_ngff_matrix(composite, ("z", "y", "x")) + + assert np.allclose(matrix, np.eye(3), rtol=0, atol=1e-12) + assert np.allclose(offset, [1.5, -3.125, 5.25]) + + +def test_the_affine_check_still_refuses_a_near_affine_transform(): + """Loosening the tolerance for float32 must not blunt the check. + + This is linear at the origin and along every axis and quadratic in + between, so only the check point can tell, and only if it is far enough + from the probes. + """ + from ngff_zarr.itk_transform_to_ngff_transform import _matrix_offset_by_probing + + class NearlyAffine: + def __init__(self, coefficient, offset): + self.coefficient = coefficient + self.offset = offset + + def TransformPoint(self, point): + x, y, z = point + return [x + self.coefficient * x * y + self.offset, y + self.offset, z] + + for coefficient in (1e-1, 1e-3, 1e-6): + for offset in (0.0, 1e8): + with pytest.raises(NotImplementedError, match="only linear"): + _matrix_offset_by_probing(NearlyAffine(coefficient, offset), 3) + + +@pytest.mark.parametrize("magnitude", [1e-6, 1.0, 1e6]) +def test_probing_accepts_a_genuinely_linear_transform(magnitude): + """The affine check must not fire on ordinary float error. + + Probing recomposes the mapping from three evaluations, so the check + compares two arithmetic paths rather than a value against itself. It has to + stay quiet across the range of magnitudes a registration produces. + """ + itk = pytest.importorskip("itk") + + euler = itk.Euler3DTransform[itk.D].New() + euler.SetRotation(0.3, -0.7, 1.1) + euler.SetTranslation([magnitude, -2 * magnitude, 3 * magnitude]) + euler.SetCenter([0.5 * magnitude, magnitude, -1.5 * magnitude]) + + matrix, offset = itk_transform_to_ngff_matrix(euler, ("z", "y", "x")) + + point = np.array([12.0, -34.0, 56.0]) * magnitude # (z, y, x) + expected = np.asarray(euler.TransformPoint(point[::-1].tolist()))[::-1] + assert np.allclose(matrix @ point + offset, expected) + + +def _itkwasm_scale(scale, center): + """An ITK-Wasm scale, with the center of scaling as its fixed parameters.""" + from itkwasm import ( + FloatTypes, + Transform, + TransformParameterizations, + TransformType, + ) + + dimension = len(scale) + return Transform( + transformType=TransformType( + transformParameterization=TransformParameterizations.Scale, + parametersValueType=FloatTypes.Float64, + inputDimension=dimension, + outputDimension=dimension, + ), + numberOfFixedParameters=len(center), + numberOfParameters=dimension, + fixedParameters=np.asarray(center, dtype=np.float64), + parameters=np.asarray(scale, dtype=np.float64), + ) + + +def test_itkwasm_scale_center_is_folded_into_the_offset(): + """``itk.ScaleTransform`` scales about its center, like an affine does. + + Reading its parameters and ignoring the fixed ones yields a plausible + transform that puts the image in the wrong place, with no error. + """ + transform = _itkwasm_scale([2.0, 3.0], [10.0, 20.0]) + + matrix, offset = itk_transform_to_ngff_matrix(transform, ("y", "x")) + + # ITK order (x, y): c - S c = (10 - 2*10, 20 - 3*20) = (-10, -40). + assert np.array_equal(matrix, [[3.0, 0.0], [0.0, 2.0]]) + assert np.array_equal(offset, [-40.0, -10.0]) + + +def test_itkwasm_scale_agrees_with_the_native_itk_transform(): + """The two input paths must not disagree about the same transform. + + A native ``itk.Transform`` is probed; the same transform handed over as an + ITK-Wasm entry is decoded from its parameters. + """ + itk = pytest.importorskip("itk") + from ngff_zarr.itk_transform_resample_bounding_box import _as_itk_transform_list + + scaling = itk.ScaleTransform[itk.D, 2].New() + scaling.SetScale([2.0, 3.0]) + scaling.SetCenter([10.0, 20.0]) + + probed = itk_transform_to_ngff_matrix(scaling, ("y", "x")) + decoded = itk_transform_to_ngff_matrix(_as_itk_transform_list(scaling), ("y", "x")) + + assert np.allclose(probed[0], decoded[0]) + assert np.allclose(probed[1], decoded[1]) + + def test_parameterization_is_read_by_value_not_by_str(): """``str()`` on an itkwasm parameterization is not its name. @@ -309,6 +715,176 @@ def no_itk(name, *args, **kwargs): assert np.allclose(converted.affine, [[0.8, -0.6, 10.0], [0.6, 0.8, -4.0]]) +@pytest.mark.parametrize( + ("parameterization", "parameters", "fixed", "dims"), + [ + # 3D entries against a 2D coordinate system: slicing would silently + # project, or scramble an affine's rows into a singular matrix. + ("Affine", [2, 0, 0, 0, 3, 0, 0, 0, 4, 10, 20, 30], [0, 0, 0], ("y", "x")), + ("Translation", [10, 20, 30], [], ("y", "x")), + # 2D entries against a 3D coordinate system: reading past the end. + ("Affine", [2, 0, 0, 3, 10, 20], [0, 0], ("z", "y", "x")), + ("Scale", [2, 3], [], ("z", "y", "x")), + # A single parameter would broadcast across every axis. + ("Translation", [5], [], ("y", "x")), + # No parameters at all (a default-constructed entry). + ("Affine", [], [], ("y", "x")), + ], +) +def test_mismatched_itkwasm_dimensionality_is_rejected( + parameterization, parameters, fixed, dims +): + """The parameter count is the ground truth for an entry's dimensionality. + + Without this check a 3D affine decoded against 2D dims yields a singular + matrix that ``_simplify`` dresses up as a zero ``scale``, and a 2D affine + against 3D dims pads itself with garbage -- both written into store + metadata without a word. + """ + entry = _itkwasm_entry(parameterization, parameters, fixed) + with pytest.raises(ValueError, match="does not match"): + itk_transform_to_ngff_matrix(entry, dims) + + +def test_mismatched_angle_parameterization_dimensionality_is_rejected(): + """Angle entries carry a type-specific count, so the declared dimension + is checked instead -- and before the ``itk`` import, so the error does not + depend on the optional extra.""" + entry = _itkwasm_entry( + "Euler3D", [0.1, 0.2, 0.3, 1.0, 2.0, 3.0], [0.0, 0.0, 0.0], dimension=3 + ) + with pytest.raises(ValueError, match="3D, but the coordinate system has 2"): + itk_transform_to_ngff_matrix(entry, ("y", "x")) + + +@pytest.mark.parametrize("position", [0, 1]) +def test_a_composite_entry_is_refused_at_any_position(position): + """A parameterless 'Composite' entry is ambiguous, so it is refused. + + The ITK-Wasm pipeline writes one as a grouping header before the + children, but ``itk.dict_from_transform`` never writes a header at all: + there it is a *nested* composite whose children the serialization + dropped, at any position including the first. Skipping a leading one -- + the obvious 'header' reading -- silently returns the mapping minus the + lost children, which is exactly the failure mode this module exists to + avoid. + """ + entries = [ + _itkwasm_entry("Translation", [10.0, 0.0], []), + _itkwasm_entry("Affine", [2.0, 0.0, 0.0, 2.0, 0.0, 0.0], [0.0, 0.0]), + ] + entries.insert(position, _itkwasm_entry("Composite", [], [])) + + with pytest.raises(NotImplementedError, match="'Composite' entry"): + itk_transform_to_ngff_matrix(entries, ("y", "x")) + + +def test_a_nested_native_composite_converts_by_probing(): + """The safe route for any composite, nested included: probing evaluates + the native transform, so no serialization can drop its children.""" + itk = pytest.importorskip("itk") + + inner = itk.CompositeTransform[itk.D, 2].New() + shift = itk.TranslationTransform[itk.D, 2].New() + shift.SetOffset([1.0, 2.0]) + inner.AddTransform(shift) + outer = itk.CompositeTransform[itk.D, 2].New() + outer.AddTransform(inner) + second = itk.TranslationTransform[itk.D, 2].New() + second.SetOffset([100.0, 0.0]) + outer.AddTransform(second) + + matrix, offset = itk_transform_to_ngff_matrix(outer, ("y", "x")) + + assert np.array_equal(matrix, np.eye(2)) + assert np.allclose(offset, [2.0, 101.0]) + + +@pytest.mark.parametrize( + ("transform", "match"), + [ + (Affine(affine=[1.0, 0.0, 0.0, 0.0, 1.0, 0.0]), "affine transformation is 6"), + (Rotation(rotation=[0.0, -1.0, 1.0, 0.0]), "rotation transformation is 4"), + ], +) +def test_a_flat_parameter_array_gets_the_intended_error(transform, match): + """A flat array is an easy hand-construction slip, since the docs + describe the affine as 'the upper M x (N+1) block'. The shape in the + message is formatted from the whole shape tuple, so a 1-D array gets the + intended ValueError rather than an IndexError raised while formatting + it.""" + from ngff_zarr.ngff_transform_to_itk_transform import ( + _ngff_transform_to_itk_matrix, + ) + + with pytest.raises(ValueError, match=match): + _ngff_transform_to_itk_matrix(transform, ("y", "x")) + + +def test_an_itkwasm_identity_entry_decodes_to_identity(): + """The Identity decode branch, exercised directly in each port.""" + matrix, offset = itk_transform_to_ngff_matrix( + _itkwasm_entry("Identity", [], []), ("y", "x") + ) + assert np.array_equal(matrix, np.eye(2)) + assert np.array_equal(offset, np.zeros(2)) + + +def test_an_empty_transform_list_is_rejected(): + with pytest.raises(ValueError, match="transform list is empty"): + itk_transform_to_ngff_matrix([], ("y", "x")) + + +def test_dims_without_spatial_axes_are_rejected(): + with pytest.raises(ValueError, match="no spatial axes"): + itk_transform_to_ngff_matrix(_itkwasm_entry("Identity", [], []), ("t", "c")) + + +def test_an_itkwasm_affine_center_is_folded_into_the_offset(): + """The decode branch's own center algebra, not the probing path's. + + Every other Affine entry in this file carries a zero center, so only + this test observes the fold ``b = t + c - A c`` in the decode branch. + """ + matrix = [[0.8, -0.6], [0.6, 0.8]] + center = [9.5, 4.5] + translation = [10.0, 10.0] + entry = _itkwasm_entry("Affine", [*matrix[0], *matrix[1], *translation], center) + + _, offset = itk_transform_to_ngff_matrix(entry, ("y", "x")) + + expected_itk = ( + np.asarray(translation) + + np.asarray(center) + - np.asarray(matrix) @ np.asarray(center) + ) + assert np.allclose(offset, expected_itk[::-1]) + + +def test_non_canonical_spatial_order_converts_by_name(): + """The converters bind ITK components to axes by name, like the bounding + box: an ITK translation (x=7, y=5, z=1) lands on the axes so named + whatever order ``dims`` spells them in.""" + entry = _itkwasm_entry("Translation", [7.0, 5.0, 1.0], [], dimension=3) + + for dims in [("z", "y", "x"), ("z", "x", "y"), ("x", "y", "z")]: + _, offset = itk_transform_to_ngff_matrix(entry, dims) + named = dict(zip(dims, offset)) + assert named == {"x": 7.0, "y": 5.0, "z": 1.0}, dims + + # And the reverse direction inverts it exactly, in every spelling. + from ngff_zarr.ngff_transform_to_itk_transform import ( + _ngff_transform_to_itk_matrix, + ) + + for dims in [("z", "y", "x"), ("z", "x", "y"), ("x", "y", "z")]: + translation = Translation( + translation=[{"x": 7.0, "y": 5.0, "z": 1.0}[d] for d in dims] + ) + _, itk_offset = _ngff_transform_to_itk_matrix(translation, dims) + assert np.allclose(itk_offset, [7.0, 5.0, 1.0]), dims + + def test_itkwasm_transform_list_composes_last_entry_first(): """An ITK transform list applies its last entry first.""" from itkwasm import ( @@ -349,6 +925,231 @@ def entry(parameterization, parameters, fixed=()): assert np.allclose(reversed_offset, [0.0, 20.0]) +def _oriented_image(shape, scale, translation, orientations="RAS"): + """A geometry-only 3D NgffImage. + + ``"RAS"`` gives the diagonal direction ``diag(-1, -1, 1)``, which is its + own inverse. ``"cycle"`` maps z, y, x onto LPS x, z, y: a 3-cycle whose + direction matrix is not symmetric, so it tells ``D`` from ``D^-1``. + """ + import dask.array as da + from ngff_zarr import NgffImage + from ngff_zarr.rfc4 import RAS, AnatomicalOrientation, AnatomicalOrientationValues + + if orientations == "RAS": + axes_orientations = RAS + elif orientations == "cycle": + axes_orientations = { + "z": AnatomicalOrientation(value=AnatomicalOrientationValues.left_to_right), + "y": AnatomicalOrientation( + value=AnatomicalOrientationValues.inferior_to_superior + ), + "x": AnatomicalOrientation( + value=AnatomicalOrientationValues.posterior_to_anterior + ), + } + else: + axes_orientations = None + return NgffImage( + data=da.zeros(shape, dtype=np.uint8), + dims=["z", "y", "x"], + scale=scale, + translation=translation, + axes_orientations=axes_orientations, + ) + + +def _sheared_itk_affine(itk): + """An affine with rotation, shear and translation: every frame error shows.""" + transform = itk.AffineTransform[itk.D, 3].New() + transform.SetMatrix( + itk.matrix_from_array( + np.array([[0.9, 0.1, 0.0], [-0.1, 1.1, 0.0], [0.0, 0.0, 1.0]]) + ) + ) + transform.SetTranslation([2.0, -3.0, 4.0]) + transform.SetCenter([0.0, 0.0, 0.0]) + return transform + + +def test_conversion_with_frames_matches_the_itk_path_on_oriented_images(): + """The acceptance test for the change of frame. + + An ITK transform acts on physical space, direction matrix included; the + RFC-5 branch of the bounding box acts on the intrinsic systems. Convert + with ``fixed=``/``moving=`` and the two paths must select the same region + of the same RAS-oriented images. Without the frames they are 25 voxels + apart in y on this geometry. + """ + itk = pytest.importorskip("itk") + + from ngff_zarr import itk_transform_resample_bounding_box + + # Fractional translations keep every corner away from an integer, so the + # comparison cannot ride a floor/ceil knife edge. + fixed = _oriented_image( + (8, 8, 8), + {"z": 1.0, "y": 2.0, "x": 3.0}, + {"z": 1.3, "y": -2.7, "x": 5.1}, + ) + moving = _oriented_image( + (256, 256, 256), + {"z": 1.0, "y": 1.0, "x": 1.0}, + {"z": -4.2, "y": 7.6, "x": 3.4}, + ) + transform = _sheared_itk_affine(itk) + + via_itk = itk_transform_resample_bounding_box(transform, fixed, moving, padding=0) + converted = itk_transform_to_ngff_transform( + transform, ("z", "y", "x"), fixed=fixed, moving=moving + ) + via_rfc5 = itk_transform_resample_bounding_box(converted, fixed, moving, padding=0) + + assert via_rfc5.start_index == via_itk.start_index + assert via_rfc5.size == via_itk.size + + +def test_conversion_with_frames_preserves_the_rotation_sign(): + """Conjugation by diag(-1, -1, 1) transposes a rotation that mixes a + flipped axis with an unflipped one: a +30 degree registration about the + ITK x axis is persisted as -30 degrees unless the frames are given. + + (A rotation about z would not show it: both flipped axes lie in its + plane, so the conjugation is the identity there.) + """ + itk = pytest.importorskip("itk") + + angle = np.pi / 6 + # About the ITK x axis: mixes y (flipped by RAS) with z (not flipped). + rotation_itk = np.array( + [ + [1.0, 0.0, 0.0], + [0.0, np.cos(angle), -np.sin(angle)], + [0.0, np.sin(angle), np.cos(angle)], + ] + ) + transform = itk.AffineTransform[itk.D, 3].New() + transform.SetMatrix(itk.matrix_from_array(rotation_itk)) + transform.SetTranslation([0.0, 0.0, 0.0]) + transform.SetCenter([0.0, 0.0, 0.0]) + + fixed = _oriented_image( + (8, 8, 8), dict.fromkeys("zyx", 1.0), dict.fromkeys("zyx", 0.0) + ) + moving = _oriented_image( + (8, 8, 8), dict.fromkeys("zyx", 1.0), dict.fromkeys("zyx", 0.0) + ) + + with_frames, _ = itk_transform_to_ngff_matrix( + transform, ("z", "y", "x"), fixed=fixed, moving=moving + ) + frameless, _ = itk_transform_to_ngff_matrix(transform, ("z", "y", "x")) + + reversal = np.eye(3)[::-1] + flip = np.diag([-1.0, -1.0, 1.0]) + conjugated = reversal @ (flip @ rotation_itk @ flip) @ reversal + assert np.allclose(with_frames, conjugated) + # Without the frames the numbers are copied unconjugated, which for this + # rotation is exactly the transpose: the opposite angle. + assert np.allclose(frameless, reversal @ rotation_itk @ reversal) + assert np.allclose(with_frames, frameless.T) + assert not np.allclose(with_frames, frameless) + + +def test_frames_round_trip_through_both_converters(): + """RFC-5 -> ITK -> RFC-5 with frames must return the identical mapping. + + On a 3-cycle orientation: a diagonal direction such as RAS is its own + inverse, so it cannot tell the reverse conversion's ``D^-1`` from ``D``. + """ + pytest.importorskip("itk") + + from ngff_zarr import ngff_transform_to_itk_transform + + fixed = _oriented_image( + (8, 8, 8), + {"z": 1.0, "y": 2.0, "x": 3.0}, + {"z": 1.3, "y": -2.7, "x": 5.1}, + "cycle", + ) + moving = _oriented_image( + (16, 16, 16), + {"z": 1.0, "y": 1.0, "x": 1.0}, + {"z": -4.2, "y": 7.6, "x": 3.4}, + "cycle", + ) + original = Affine( + affine=[ + [1.0, 0.2, 0.0, 4.1], + [0.0, 2.0, 0.3, -6.2], + [0.5, 0.0, 1.0, 11.3], + ] + ) + + itk_list = ngff_transform_to_itk_transform( + original, ("z", "y", "x"), fixed=fixed, moving=moving + ) + back = itk_transform_to_ngff_transform( + itk_list, ("z", "y", "x"), simplify=False, fixed=fixed, moving=moving + ) + + assert np.allclose(back.affine, original.affine) + + +def test_an_out_of_plane_orientation_falls_back_to_identity_direction(): + """A 2D image whose axis points along LPS z would truncate to a singular + direction matrix; the all-or-nothing rule keeps the identity instead, so + frame conversion behaves as for an unoriented image rather than raising + LinAlgError from the inverse.""" + itk = pytest.importorskip("itk") + + import dask.array as da + from ngff_zarr import NgffImage + from ngff_zarr.rfc4 import AnatomicalOrientation, AnatomicalOrientationValues + + oriented = NgffImage( + data=da.zeros((8, 8), dtype=np.uint8), + dims=["y", "x"], + scale={"y": 1.0, "x": 1.0}, + translation={"y": 0.0, "x": 0.0}, + axes_orientations={ + "y": AnatomicalOrientation( + value=AnatomicalOrientationValues.superior_to_inferior + ), + "x": AnatomicalOrientation(value=AnatomicalOrientationValues.left_to_right), + }, + ) + transform = itk.TranslationTransform[itk.D, 2].New() + transform.SetOffset([3.0, 5.0]) + + with_frames = itk_transform_to_ngff_matrix( + transform, ("y", "x"), fixed=oriented, moving=oriented + ) + frameless = itk_transform_to_ngff_matrix(transform, ("y", "x")) + + assert np.allclose(with_frames[0], frameless[0]) + assert np.allclose(with_frames[1], frameless[1]) + + +def test_frames_are_all_or_nothing_and_a_noop_when_unoriented(): + itk = pytest.importorskip("itk") + + transform = _sheared_itk_affine(itk) + plain = _oriented_image( + (8, 8, 8), dict.fromkeys("zyx", 1.0), dict.fromkeys("zyx", 0.5), None + ) + + with pytest.raises(ValueError, match="both fixed and moving"): + itk_transform_to_ngff_matrix(transform, ("z", "y", "x"), fixed=plain) + + with_frames = itk_transform_to_ngff_matrix( + transform, ("z", "y", "x"), fixed=plain, moving=plain + ) + without = itk_transform_to_ngff_matrix(transform, ("z", "y", "x")) + assert np.allclose(with_frames[0], without[0]) + assert np.allclose(with_frames[1], without[1]) + + def test_registration_result_can_be_attached_to_multiscales(): """The point of this direction: persist a registration into the store.""" itk = pytest.importorskip("itk") diff --git a/ts/src/browser-mod.ts b/ts/src/browser-mod.ts index c98c4f6f..111de642 100644 --- a/ts/src/browser-mod.ts +++ b/ts/src/browser-mod.ts @@ -33,11 +33,7 @@ export { itkTransformToNgffTransform, type NgffMatrixAndOffset, } from "./utils/itk_transform_to_ngff_transform.ts"; -export { - type ItkMatrixAndOffset, - ngffTransformToItkMatrix, - ngffTransformToItkTransform, -} from "./utils/ngff_transform_to_itk_transform.ts"; +export { ngffTransformToItkTransform } from "./utils/ngff_transform_to_itk_transform.ts"; export { dataTypeToComponentType, ngffImageToItkImage, diff --git a/ts/src/io/itk_transform_resample_bounding_box-shared.ts b/ts/src/io/itk_transform_resample_bounding_box-shared.ts index 003e95a9..7533d8ae 100644 --- a/ts/src/io/itk_transform_resample_bounding_box-shared.ts +++ b/ts/src/io/itk_transform_resample_bounding_box-shared.ts @@ -12,7 +12,8 @@ import * as zarr from "zarrita"; import type { Image, TransformList } from "itk-wasm"; import { NgffImage } from "../types/ngff_image.ts"; import type { V06Transform } from "../types/zarr_metadata.ts"; -import { anatomicalOrientationToItkDirection } from "../types/rfc4.ts"; +import { identityDirection, itkDirection } from "../utils/itk_direction.ts"; +export { itkDirection }; import { ngffTransformToItkTransform } from "../utils/ngff_transform_to_itk_transform.ts"; const SPATIAL_DIMS = ["x", "y", "z"]; @@ -258,51 +259,6 @@ function checkRegionContainsCorners( }); } -function identityDirection(dimension: number): Float64Array { - const direction = new Float64Array(dimension * dimension); - for (let i = 0; i < dimension; i++) direction[i * dimension + i] = 1.0; - return direction; -} - -/** - * Direction matrix from RFC-4 orientation, matching `ngffImageToItkImage`. - * - * All-or-nothing: unless every spatial axis carries an orientation that maps - * onto an LPS axis, the direction falls back to identity. - */ -export function itkDirection( - image: NgffImage, - itkDims: string[], -): Float64Array { - const dimension = itkDims.length; - const direction = identityDirection(dimension); - - const orientations = image.axesOrientations; - if (!orientations) return direction; - - const columns: number[][] = []; - for (const dim of itkDims) { - const orientation = orientations[dim]; - if (orientation === undefined) return direction; - const column = anatomicalOrientationToItkDirection(orientation.value); - if (column === undefined) return direction; - // A column pointing outside the matrix's dimension (e.g. a - // superior/inferior orientation on a 2D image) would truncate to a - // singular matrix; keep the identity fallback instead. - if (column.slice(dimension).some((component) => component !== 0)) { - return direction; - } - columns.push(column); - } - - for (let col = 0; col < dimension; col++) { - for (let row = 0; row < dimension; row++) { - direction[row * dimension + col] = columns[col][row]; - } - } - return direction; -} - /** * Build an ITK-Wasm image carrying geometry only, with an empty buffer. * @@ -379,7 +335,9 @@ export async function resampleBoundingBoxShared( checkGeometry("moving", moving, fixedSpatial); // ITK orders points fastest-axis-first, the reverse of the Zarr order. - const itkDims = [...fixedSpatial].reverse(); + // ITK orders points fastest-axis-first by name: x, then y, then z. + // Reversing the dims is only right for the canonical (z, y, x). + const itkDims = SPATIAL_DIMS.filter((dim) => fixedSpatial.includes(dim)); const movingShape: Record = {}; for (const dim of movingSpatial) { diff --git a/ts/src/mod.ts b/ts/src/mod.ts index 4e7298d4..b5a7ebba 100644 --- a/ts/src/mod.ts +++ b/ts/src/mod.ts @@ -74,11 +74,7 @@ export { itkTransformToNgffTransform, type NgffMatrixAndOffset, } from "./utils/itk_transform_to_ngff_transform.ts"; -export { - type ItkMatrixAndOffset, - ngffTransformToItkMatrix, - ngffTransformToItkTransform, -} from "./utils/ngff_transform_to_itk_transform.ts"; +export { ngffTransformToItkTransform } from "./utils/ngff_transform_to_itk_transform.ts"; export { fromZarrAttrsV04, fromZarrAttrsV05, diff --git a/ts/src/utils/itk_direction.ts b/ts/src/utils/itk_direction.ts new file mode 100644 index 00000000..d1a7d0a5 --- /dev/null +++ b/ts/src/utils/itk_direction.ts @@ -0,0 +1,178 @@ +// SPDX-FileCopyrightText: Copyright (c) Fideus Labs LLC +// SPDX-License-Identifier: MIT + +/** + * The ITK direction matrix an `NgffImage`'s RFC-4 orientation induces. + * + * Shared between the resample bounding box and the transform converters, + * which both need the exact matrix `ngffImageToItkImage` would build. + */ + +import type { NgffImage } from "../types/ngff_image.ts"; +import { anatomicalOrientationToItkDirection } from "../types/rfc4.ts"; + +export function identityDirection(dimension: number): Float64Array { + const direction = new Float64Array(dimension * dimension); + for (let i = 0; i < dimension; i++) direction[i * dimension + i] = 1.0; + return direction; +} + +/** + * Direction matrix from RFC-4 orientation, matching `ngffImageToItkImage`. + * + * All-or-nothing: unless every spatial axis carries an orientation that maps + * onto an LPS axis, the direction falls back to identity. + */ +export function itkDirection( + image: NgffImage, + itkDims: string[], +): Float64Array { + const dimension = itkDims.length; + const direction = identityDirection(dimension); + + const orientations = image.axesOrientations; + if (!orientations) return direction; + + const columns: number[][] = []; + const seenAxes = new Set(); + for (const dim of itkDims) { + const orientation = orientations[dim]; + if (orientation === undefined) return direction; + const column = anatomicalOrientationToItkDirection(orientation.value); + if (column === undefined) return direction; + // A column pointing outside the matrix's dimension (e.g. a + // superior/inferior orientation on a 2D image), or two dims mapping onto + // the same LPS axis, would truncate to a singular matrix; keep the + // identity fallback instead. + if (column.slice(dimension).some((component) => component !== 0)) { + return direction; + } + let axis = 0; + for (let i = 1; i < column.length; i++) { + if (Math.abs(column[i]) > Math.abs(column[axis])) axis = i; + } + if (seenAxes.has(axis)) return direction; + seenAxes.add(axis); + columns.push(column); + } + + for (let col = 0; col < dimension; col++) { + for (let row = 0; row < dimension; row++) { + direction[row * dimension + col] = columns[col][row]; + } + } + return direction; +} + +/** Row-major `Float64Array` direction as an array of rows. */ +export function directionRows( + direction: Float64Array, + dimension: number, +): number[][] { + return Array.from( + { length: dimension }, + (_, row) => + Array.from( + { length: dimension }, + (_, col) => direction[row * dimension + col], + ), + ); +} + +/** RFC-4 directions are orthogonal, so the inverse is the transpose. */ +export function transposed(matrix: number[][]): number[][] { + return matrix.map((row, i) => row.map((_, j) => matrix[j][i])); +} + +function matmul(left: number[][], right: number[][]): number[][] { + return left.map((row) => + right[0].map((_, col) => + row.reduce((sum, value, k) => sum + value * right[k][col], 0) + ) + ); +} + +function matvec(matrix: number[][], vector: number[]): number[] { + return matrix.map((row) => + row.reduce((sum, value, col) => sum + value * vector[col], 0) + ); +} + +/** The direction matrices and origins of an image pair, in ITK order. */ +export interface FrameGeometry { + directionFixed: number[][]; + directionMoving: number[][]; + originFixed: number[]; + originMoving: number[]; +} + +/** + * The directions and origins `ngffImageToItkImage` gives the two images. + * + * Throws when an image lacks a translation entry for one of `itkDims`. + */ +export function frameGeometry( + fixed: NgffImage, + moving: NgffImage, + itkDims: string[], +): FrameGeometry { + for ( + const [label, image] of [["fixed", fixed], ["moving", moving]] as const + ) { + const missing = itkDims.filter((dim) => !(dim in image.translation)); + if (missing.length > 0) { + throw new Error( + `the ${label} image has no translation entry for spatial ` + + `dimension(s) [${missing.join(", ")}]; its dims do not cover dims`, + ); + } + } + const dimension = itkDims.length; + return { + directionFixed: directionRows(itkDirection(fixed, itkDims), dimension), + directionMoving: directionRows(itkDirection(moving, itkDims), dimension), + originFixed: itkDims.map((dim) => fixed.translation[dim]), + originMoving: itkDims.map((dim) => moving.translation[dim]), + }; +} + +/** + * Re-express `y = A x + t` through a change of frame on each side. + * + * `ngffImageToItkImage` builds each image with `origin = translation` and the + * RFC-4 direction matrix `D`, so an intrinsic point `p` sits at the physical + * point `phi(p) = D (p - o) + o`. Given a mapping `T` between two frames, + * this returns `phi_out^-1 . T . phi_in` for the directions and origins + * supplied: + * + * M = D_out^-1 A D_in + * b = D_out^-1 (A (I - D_in) o_in + t - o_out) + o_out + * + * `phi^-1` has the same shape as `phi` with `D^-1` in place of `D`, so the + * same formula serves both conversions: ITK to RFC-5 passes the images' + * directions, RFC-5 to ITK passes their inverses. The scales cancel on both + * sides; the translations do not, which is why orientations alone would not + * be enough. With no anatomical orientation every direction is the identity + * and the mapping comes back unchanged. + */ +export function changeOfFrame( + matrix: number[][], + offset: number[], + directionIn: number[][], + directionOut: number[][], + originIn: number[], + originOut: number[], +): { matrix: number[][]; offset: number[] } { + const inverseOut = transposed(directionOut); + const rotatedIn = matvec(directionIn, originIn); + const shifted = originIn.map((value, row) => value - rotatedIn[row]); + const inner = matvec(matrix, shifted).map( + (value, row) => value + offset[row] - originOut[row], + ); + return { + matrix: matmul(matmul(inverseOut, matrix), directionIn), + offset: matvec(inverseOut, inner).map( + (value, row) => value + originOut[row], + ), + }; +} diff --git a/ts/src/utils/itk_transform_to_ngff_transform.ts b/ts/src/utils/itk_transform_to_ngff_transform.ts index de8fb03b..9bdf0414 100644 --- a/ts/src/utils/itk_transform_to_ngff_transform.ts +++ b/ts/src/utils/itk_transform_to_ngff_transform.ts @@ -9,7 +9,7 @@ * that result belongs once it is written next to the image. * * The same two conventions apply in reverse -- the spatial block and the - * offset are reversed from ITK's fastest-axis-first order back to Zarr order, + * offset are permuted from ITK's fastest-axis-first order back to `dims` order, * and ITK's center of rotation is folded into the offset, since an RFC-5 * affine has no center: * @@ -25,6 +25,8 @@ */ import type { Transform, TransformList } from "itk-wasm"; +import type { NgffImage } from "../types/ngff_image.ts"; +import { changeOfFrame, frameGeometry } from "./itk_direction.ts"; import { type Affine, createAffine, @@ -37,6 +39,27 @@ import { const SPATIAL_DIMS = ["x", "y", "z"]; +/** + * ITK-Wasm parameterizations that describe a deformation rather than an affine + * mapping. They are refused with their own message, because "convert it to an + * affine first" is not advice that applies: a deformation has no affine + * equivalent. RFC-5 represents those with `displacements` or `coordinates` + * field arrays instead. + */ +const NON_LINEAR_PARAMETERIZATIONS = new Set([ + "AzimuthElevationToCartesian", + "BSpline", + "BSplineSmoothingOnUpdateDisplacementField", + "ConstantVelocityField", + "DisplacementField", + "GaussianExponentialDiffeomorphic", + "GaussianSmoothingOnUpdateDisplacementField", + "GaussianSmoothingOnUpdateTimeVaryingVelocityField", + "Rigid3DPerspective", + "TimeVaryingVelocityField", + "VelocityField", +]); + /** An RFC-5 matrix and offset for the spatial axes, in Zarr order. */ export interface NgffMatrixAndOffset { /** Row-major square matrix, in RFC-5 (Zarr) axis order. */ @@ -76,6 +99,18 @@ function asNumbers(values: unknown): number[] { return Array.from(values as ArrayLike, Number); } +/** + * Exact parameter count each directly decoded parameterization must carry for + * a given spatial dimension. What ITK's MatrixOffsetTransformBase and friends + * pack, and therefore the ground truth for the transform's dimensionality. + */ +const DIRECT_PARAMETER_COUNTS: Record number> = { + Identity: () => 0, + Translation: (dimension) => dimension, + Scale: (dimension) => dimension, + Affine: (dimension) => dimension * dimension + dimension, +}; + function decode( entry: Transform, dimension: number, @@ -86,6 +121,52 @@ function decode( const parameters = asNumbers(entry.parameters); const fixed = asNumbers(entry.fixedParameters); + // A transform of the wrong dimensionality must not be decoded. Slicing a 3D + // parameter vector down to 2D silently projects the transform (or, for an + // affine, scrambles rows into a singular matrix); reading a 2D one as 3D + // runs the slices off the end and fills the result with NaN, which + // JSON.stringify then writes into the store as null. The parameter count is + // the ground truth here, not `transformType.inputDimension`, which a + // hand-built entry may leave at a default. + const expected = Object.hasOwn(DIRECT_PARAMETER_COUNTS, parameterization) + ? DIRECT_PARAMETER_COUNTS[parameterization] + : undefined; + if (expected !== undefined) { + if (parameters.length !== expected(dimension)) { + throw new Error( + `ITK-Wasm '${parameterization}' transform carries ` + + `${parameters.length} parameters, but a coordinate system with ` + + `${dimension} spatial axes requires exactly ${ + expected(dimension) + }. ` + + `The transform's dimensionality does not match dims.`, + ); + } + // Only the directly decoded parameterizations read fixedParameters as a + // center; elsewhere (a displacement field's grid, say) it holds other + // metadata and is none of this branch's business. + if (fixed.length !== 0 && fixed.length !== dimension) { + throw new Error( + `ITK-Wasm '${parameterization}' transform carries ${fixed.length} ` + + `fixed parameters (the center), but a coordinate system with ` + + `${dimension} spatial axes requires 0 or ${dimension}`, + ); + } + } + const center = fixed.length === dimension + ? fixed + : new Array(dimension).fill(0); + + /** Fold ITK's center of rotation into the offset: `b = t + c - A c`. */ + const foldCenter = (matrix: number[][], translation: number[]): number[] => + translation.map((value, row) => { + let rotated = 0; + for (let col = 0; col < dimension; col++) { + rotated += matrix[row][col] * center[col]; + } + return value + center[row] - rotated; + }); + if (parameterization === "Identity") { return { matrix: identityMatrix(dimension), @@ -99,9 +180,10 @@ function decode( }; } if (parameterization === "Scale") { + // itk.ScaleTransform scales about its center, like the affine below. const matrix = identityMatrix(dimension); for (let i = 0; i < dimension; i++) matrix[i][i] = parameters[i]; - return { matrix, offset: new Array(dimension).fill(0) }; + return { matrix, offset: foldCenter(matrix, new Array(dimension).fill(0)) }; } if (parameterization === "Affine") { const matrix = Array.from( @@ -112,18 +194,23 @@ function decode( dimension * dimension, dimension * dimension + dimension, ); - const center = fixed.length >= dimension - ? fixed.slice(0, dimension) - : new Array(dimension).fill(0); - // ITK applies the matrix about the center, so fold it into the offset. - const offset = translation.map((value, row) => { - let rotated = 0; - for (let col = 0; col < dimension; col++) { - rotated += matrix[row][col] * center[col]; - } - return value + center[row] - rotated; - }); - return { matrix, offset }; + return { matrix, offset: foldCenter(matrix, translation) }; + } + + if (NON_LINEAR_PARAMETERIZATIONS.has(parameterization)) { + throw new Error( + `only linear ITK transforms can be expressed as an RFC-5 affine; ` + + `'${parameterization}' describes a deformation. RFC-5 represents ` + + `those with a 'displacements' or 'coordinates' field instead.`, + ); + } + + const declared = Number(entry.transformType.inputDimension); + if (Number.isFinite(declared) && declared !== dimension) { + throw new Error( + `ITK-Wasm '${parameterization}' transform is ${declared}D, but the ` + + `coordinate system has ${dimension} spatial axes`, + ); } throw new Error( @@ -133,6 +220,14 @@ function decode( ); } +/** The fixed and moving images a converted transform relates. */ +export interface FrameImages { + /** The image whose grid the transform maps from. */ + fixed?: NgffImage; + /** The image the transform maps into. */ + moving?: NgffImage; +} + /** * Convert an ITK transform to a matrix and offset in RFC-5 axis order. * @@ -144,6 +239,7 @@ function decode( export function itkTransformToNgffMatrix( transform: Transform | TransformList, dims: string[], + frames: FrameImages = {}, ): NgffMatrixAndOffset { const spatial = dims.filter((dim) => SPATIAL_DIMS.includes(dim)); if (spatial.length === 0) { @@ -158,6 +254,24 @@ export function itkTransformToNgffMatrix( // matrices multiply left to right in list order. let total = identityMatrix(dimension + 1); for (const entry of entries) { + const parameterization = String( + entry.transformType.transformParameterization, + ); + if (parameterization === "Composite") { + // A parameterless 'Composite' entry is ambiguous. The ITK-Wasm + // pipeline writes one as a grouping header before the children, but + // itk.dict_from_transform never writes a header at all: there it is a + // *nested* composite whose children the serialization dropped, at any + // position including the first. Decoding past one would silently + // compose the wrong mapping, so refuse it wherever it appears. + throw new Error( + `a 'Composite' entry in an ITK-Wasm transform list cannot be ` + + `decoded: the serialization drops a nested composite's children, ` + + `leaving this entry indistinguishable from a pipeline grouping ` + + `header. For a pipeline-serialized list, drop the leading header ` + + `entry and pass the children.`, + ); + } const { matrix, offset } = decode(entry, dimension); const homogeneous = identityMatrix(dimension + 1); for (let row = 0; row < dimension; row++) { @@ -169,11 +283,41 @@ export function itkTransformToNgffMatrix( total = multiply(total, homogeneous); } - // ITK (fastest-axis-first) order -> RFC-5 (Zarr) order. - const order = Array.from({ length: dimension }, (_, i) => dimension - 1 - i); + let matrix = Array.from( + { length: dimension }, + (_, row) => total[row].slice(0, dimension), + ); + let offset = Array.from( + { length: dimension }, + (_, row) => total[row][dimension], + ); + + if ((frames.fixed === undefined) !== (frames.moving === undefined)) { + throw new Error("pass both fixed and moving, or neither"); + } + if (frames.fixed !== undefined && frames.moving !== undefined) { + // ITK physical space -> the intrinsic systems: phi_m^-1 . T . phi_f. + const itkDims = SPATIAL_DIMS.filter((dim) => spatial.includes(dim)); + const geometry = frameGeometry(frames.fixed, frames.moving, itkDims); + ({ matrix, offset } = changeOfFrame( + matrix, + offset, + geometry.directionFixed, + geometry.directionMoving, + geometry.originFixed, + geometry.originMoving, + )); + } + + // ITK (fastest-axis-first) order -> the order the axes appear in `dims`. + // ITK orders components by name (x, then y, then z), so the mapping is + // built by name rather than by reversing, which is only equivalent for + // the canonical (z, y, x). + const itkOrder = SPATIAL_DIMS.filter((dim) => spatial.includes(dim)); + const order = spatial.map((dim) => itkOrder.indexOf(dim)); return { - matrix: order.map((row) => order.map((col) => total[row][col])), - offset: order.map((row) => total[row][dimension]), + matrix: order.map((row) => order.map((col) => matrix[row][col])), + offset: order.map((row) => offset[row]), }; } @@ -190,16 +334,21 @@ export function itkTransformToNgffMatrix( * @param simplify Return the least expressive transformation that represents * the mapping exactly -- `identity`, `translation`, `scale`, or a `sequence` * of scale and translation -- falling back to `affine`. RFC-5 recommends - * this, and only these simpler forms are legal inside - * `multiscales > datasets`. Pass `false` to always get an `affine`. + * this. A mirror never simplifies to a `scale`, since RFC-5 requires strictly + * positive scale factors. Note that `multiscales > datasets` accepts only a + * single `scale`, a single `identity`, or a two-element `sequence` of scale + * and translation, so a bare `translation` or an `affine` belongs in the + * multiscales-level `coordinateTransformations` instead. Pass `false` to + * always get an `affine`. * @returns An RFC-5 coordinate transformation over `dims`. */ export function itkTransformToNgffTransform( transform: Transform | TransformList, dims: string[], simplify = true, + frames: FrameImages = {}, ): V06Transform { - const { matrix, offset } = itkTransformToNgffMatrix(transform, dims); + const { matrix, offset } = itkTransformToNgffMatrix(transform, dims, frames); const spatialIndices: number[] = []; dims.forEach((dim, index) => { @@ -244,11 +393,17 @@ function simplifyTransform( } const noOffset = offset.every((value) => value === 0); const diagonal = matrix.map((row, i) => row[i]); + // RFC-5 requires every scale factor to be strictly positive, so a mirror or + // a flip is not a `scale` however diagonal its matrix looks. Emitting one + // anyway writes metadata the schema rejects, and an LPS to RAS flip + // (diag(-1, -1, 1)) is an ordinary registration result. Fall through to + // `affine`, which carries the sign. + const isScale = isDiagonal && diagonal.every((value) => value > 0); if (isIdentity && noOffset) return createIdentity(); if (isIdentity) return createTranslation(offset); - if (isDiagonal && noOffset) return createScale(diagonal); - if (isDiagonal) { + if (isScale && noOffset) return createScale(diagonal); + if (isScale) { // Scale first, then translate: y = scale * x + translation, which is the // form `multiscales > datasets` accepts. return createTransformSequence([ diff --git a/ts/src/utils/ngff_transform_to_itk_transform.ts b/ts/src/utils/ngff_transform_to_itk_transform.ts index 3043b304..3d45ce18 100644 --- a/ts/src/utils/ngff_transform_to_itk_transform.ts +++ b/ts/src/utils/ngff_transform_to_itk_transform.ts @@ -11,9 +11,10 @@ * Axis order * RFC-5 orders transformation parameters the same way the Zarr array is * ordered, so a `zyx` image has `z` first. ITK orders points - * fastest-axis-first, so the same point is `xyz`. Writing `R` for the - * axis-reversal permutation, an RFC-5 affine `q = M p + b` becomes - * `A = R M R` and `t = R b` in ITK. + * fastest-axis-first by name: x, then y, then z. Writing `P` for the + * permutation sending an ITK-order vector to the `dims`-order vector, an + * RFC-5 affine `q = M p + b` becomes `A = P^T M P` and `t = P^T b` in + * ITK. For the canonical `zyx` order `P` is the axis reversal. * * Composition order * An RFC-5 `sequence` applies its first entry first. An ITK transform list @@ -26,6 +27,8 @@ */ import type { Transform, TransformList } from "itk-wasm"; +import type { NgffImage } from "../types/ngff_image.ts"; +import { changeOfFrame, frameGeometry, transposed } from "./itk_direction.ts"; import type { V06Transform } from "../types/zarr_metadata.ts"; const SPATIAL_DIMS = ["x", "y", "z"]; @@ -55,6 +58,21 @@ function multiply(left: Matrix, right: Matrix): Matrix { ); } +/** + * A matrix's shape for an error message. Naming row 0's length alone would + * describe a ragged matrix as its own requirement ("is 2x3 but requires + * 2x3"), hiding the short row the message exists to point at. + */ +function describeShape(rows: number[][]): string { + const lengths = new Set(rows.map((row) => row.length)); + if (lengths.size <= 1) { + return `${rows.length}x${rows[0]?.length ?? 0}`; + } + return `${rows.length} rows of lengths ${ + rows.map((row) => row.length).join(", ") + }`; +} + function assertMatrix( values: number[][] | undefined, path: string | undefined, @@ -117,8 +135,8 @@ function homogeneousFromTransform( ); if (rotation.length !== ndim || rotation.some((r) => r.length !== ndim)) { throw new Error( - `rotation transformation is ${rotation.length}x` + - `${rotation[0]?.length} but the coordinate system has ${ndim} axes`, + `rotation transformation is ${describeShape(rotation)} but the ` + + `coordinate system has ${ndim} axes`, ); } const matrix = identityMatrix(ndim + 1); @@ -139,9 +157,7 @@ function homogeneousFromTransform( affine.length !== ndim || affine.some((r) => r.length !== ndim + 1) ) { throw new Error( - `affine transformation is ${affine.length}x${ - affine[0]?.length - } but ` + + `affine transformation is ${describeShape(affine)} but ` + `a coordinate system with ${ndim} axes requires ` + `${ndim}x${ndim + 1} (the translation is the last column)`, ); @@ -190,12 +206,23 @@ export interface ItkMatrixAndOffset { /** * Convert an RFC-5 transformation to an ITK matrix and offset. * + * Internal: {@link ngffTransformToItkTransform} is the public entry point, and + * nothing outside this package needs the raw numbers, since ITK is what the + * caller wants to hand them to. Exported for the tests, which use it as a fine + * probe on the axis and composition conventions. + * + * @internal * @param transform An RFC-5 (OME-Zarr v0.6) coordinate transformation. It must * describe a linear mapping: `identity`, `scale`, `translation`, `rotation`, * `affine`, or a `sequence` of those. * @param dims The axis names of the coordinate system the transformation is * defined on, in RFC-5 (Zarr) order, e.g. `["z", "y", "x"]`. - * @returns The matrix and offset for the spatial axes, in ITK order. + * @returns The matrix and offset for the spatial axes, in ITK order. ITK has no + * notion of a non-spatial axis, so a component acting purely on `t` or `c` (a + * frame interval, say) is *projected away*. That is lossless for the spatial + * mapping, which is all an ITK transform describes, but the result is not a + * faithful copy of the input. A component that *couples* the two kinds of + * axis is refused instead, because dropping it would move the image. */ export function ngffTransformToItkMatrix( transform: V06Transform, @@ -229,13 +256,18 @@ export function ngffTransformToItkMatrix( } } - // RFC-5 (Zarr) order -> ITK (fastest-axis-first) order: reverse both the - // row and the column ordering of the spatial block. - const reversed = [...spatialIndices].reverse(); - const matrix = reversed.map((row) => - reversed.map((col) => homogeneous[row][col]) + // RFC-5 (`dims`) order -> ITK (fastest-axis-first) order. ITK orders + // components by name (x, then y, then z), so the mapping is built by name + // rather than by reversing, which is only equivalent for the canonical + // (z, y, x) order. + const spatialNames = spatialIndices.map((index) => dims[index]); + const itkIndices = SPATIAL_DIMS + .filter((dim) => spatialNames.includes(dim)) + .map((dim) => spatialIndices[spatialNames.indexOf(dim)]); + const matrix = itkIndices.map((row) => + itkIndices.map((col) => homogeneous[row][col]) ); - const offset = reversed.map((row) => homogeneous[row][ndim]); + const offset = itkIndices.map((row) => homogeneous[row][ndim]); return { matrix, offset }; } @@ -253,10 +285,30 @@ export function ngffTransformToItkMatrix( export function ngffTransformToItkTransform( transform: V06Transform, dims: string[], + frames: { fixed?: NgffImage; moving?: NgffImage } = {}, ): TransformList { - const { matrix, offset } = ngffTransformToItkMatrix(transform, dims); + let { matrix, offset } = ngffTransformToItkMatrix(transform, dims); const dimension = offset.length; + if ((frames.fixed === undefined) !== (frames.moving === undefined)) { + throw new Error("pass both fixed and moving, or neither"); + } + if (frames.fixed !== undefined && frames.moving !== undefined) { + // The intrinsic systems -> ITK physical space: phi_m . T . phi_f^-1, + // which is the same change of frame with the directions inverted. + const spatial = dims.filter((dim) => SPATIAL_DIMS.includes(dim)); + const itkDims = SPATIAL_DIMS.filter((dim) => spatial.includes(dim)); + const geometry = frameGeometry(frames.fixed, frames.moving, itkDims); + ({ matrix, offset } = changeOfFrame( + matrix, + offset, + transposed(geometry.directionFixed), + transposed(geometry.directionMoving), + geometry.originFixed, + geometry.originMoving, + )); + } + // ITK's MatrixOffsetTransformBase packs the row-major matrix followed by the // translation, with the center of rotation as the fixed parameters. An // RFC-5 affine has no center, so it stays at the origin. diff --git a/ts/test/itk_transform_resample_bounding_box_test.ts b/ts/test/itk_transform_resample_bounding_box_test.ts index 101ed325..d55443d1 100644 --- a/ts/test/itk_transform_resample_bounding_box_test.ts +++ b/ts/test/itk_transform_resample_bounding_box_test.ts @@ -13,7 +13,12 @@ * order. */ -import { assertAlmostEquals, assertEquals, assertRejects } from "@std/assert"; +import { + assertAlmostEquals, + assertEquals, + assertRejects, + assertThrows, +} from "@std/assert"; import * as zarr from "zarrita"; import { createAffine, @@ -23,9 +28,11 @@ import { createTransformSequence, createTranslation, itkTransformResampleBoundingBox, + itkTransformToNgffTransform, NgffImage, - ngffTransformToItkMatrix, + ngffTransformToItkTransform, } from "../src/mod.ts"; +import { ngffTransformToItkMatrix } from "../src/utils/ngff_transform_to_itk_transform.ts"; import { resampleBoundingBoxShared } from "../src/io/itk_transform_resample_bounding_box-shared.ts"; import { RAS } from "../src/types/rfc4.ts"; import type { AnatomicalOrientation } from "../src/types/rfc4.ts"; @@ -302,6 +309,47 @@ Deno.test("a sequence applies its first entry first", () => { ); }); +Deno.test("sequence order survives the whole pipeline", async () => { + // Every other sequence test here reads the matrix out of + // ngffTransformToItkMatrix. If the order inverted, the region the caller + // actually gets would move, and nothing below that helper would notice. + // Applying [translate 10, scale 2] the wrong way round gives 2x + 10 + // instead of 2(x + 10), so the x start moves by 10. + const fixed = await geometryImage(["y", "x"], { y: 16, x: 16 }, { + y: 2, + x: 2, + }, { y: 20, x: 10 }); + const moving = await geometryImage(["y", "x"], { y: 512, x: 512 }, { + y: 1, + x: 1, + }, { y: 0, x: 0 }); + + const region = await itkTransformResampleBoundingBox( + createTransformSequence([ + createTranslation([0, 10]), + createScale([1, 2]), + ]), + fixed, + moving, + { padding: 0 }, + ); + + // First entry first: y = x, x = 2(x + 10) = 2x + 20. + const { start, size } = oracleRegion( + [[1, 0], [0, 2]], + [0, 20], + [16, 16], + [2, 2], + [20, 10], + [1, 1], + [0, 0], + 0, + ); + assertEquals([region.startIndex.y, region.startIndex.x], start); + assertEquals([region.size.y, region.size.x], size); + assertEquals(region.startIndex.x, 40); // 30 if the order inverted +}); + Deno.test("nested sequences compose", () => { const inner = createTransformSequence([ createScale([1, 2]), @@ -686,6 +734,194 @@ Deno.test("a degenerate fixed grid yields an empty region", async () => { assertEquals(boundingBox.isEmpty, true); }); +Deno.test("the RFC-5 branch ignores anatomical orientation", async () => { + // An RFC-5 transformation acts on the intrinsic coordinate system, where + // a point is translation + scale * index and no direction matrix applies, + // so the region it selects cannot depend on RFC-4 anatomical orientation: + // the oriented region must equal the unoriented one. + const ras: Record = RAS; + const geometry = async (oriented: boolean) => ({ + fixed: await geometryImage( + ["z", "y", "x"], + { z: 8, y: 16, x: 24 }, + { z: 1, y: 1, x: 1 }, + { z: 0, y: 0, x: 0 }, + oriented ? ras : undefined, + ), + moving: await geometryImage( + ["z", "y", "x"], + { z: 32, y: 64, x: 96 }, + { z: 1, y: 1, x: 1 }, + { z: 0, y: 0, x: 0 }, + oriented ? ras : undefined, + ), + }); + const transform = createTranslation([2, 3, 4]); + + const plainImages = await geometry(false); + const orientedImages = await geometry(true); + const plain = await itkTransformResampleBoundingBox( + transform, + plainImages.fixed, + plainImages.moving, + { padding: 0 }, + ); + const oriented = await itkTransformResampleBoundingBox( + transform, + orientedImages.fixed, + orientedImages.moving, + { padding: 0 }, + ); + + assertEquals(plain.startIndex, { z: 2, y: 3, x: 4 }); + assertEquals(oriented.startIndex, plain.startIndex); + assertEquals(oriented.size, plain.size); +}); + +Deno.test("converting with frames matches the ITK path on oriented images", async () => { + // The acceptance test for the change of frame: an ITK transform acts on + // physical space (direction matrix included), the RFC-5 branch on the + // intrinsic systems. Converted with { fixed, moving }, the two paths must + // select the same region of the same RAS-oriented images. Fractional + // translations keep every corner off an integer, away from floor/ceil + // knife edges. + const ras: Record = RAS; + const fixed = await geometryImage( + ["z", "y", "x"], + { z: 8, y: 8, x: 8 }, + { z: 1, y: 2, x: 3 }, + { z: 1.3, y: -2.7, x: 5.1 }, + ras, + ); + const moving = await geometryImage( + ["z", "y", "x"], + { z: 256, y: 256, x: 256 }, + { z: 1, y: 1, x: 1 }, + { z: -4.2, y: 7.6, x: 3.4 }, + ras, + ); + // A sheared affine with translation, in ITK order: every frame error shows. + const transform = [{ + transformType: { + transformParameterization: "Affine", + parametersValueType: "float64", + inputDimension: 3, + outputDimension: 3, + }, + name: "AffineTransform", + inputSpaceName: "", + outputSpaceName: "", + numberOfFixedParameters: 3, + numberOfParameters: 12, + fixedParameters: new Float64Array(3), + parameters: new Float64Array([ + 0.9, + 0.1, + 0, + -0.1, + 1.1, + 0, + 0, + 0, + 1, + 2, + -3, + 4, + ]), + metadata: new Map(), + // deno-lint-ignore no-explicit-any + } as any]; + + const viaItk = await itkTransformResampleBoundingBox( + transform, + fixed, + moving, + { + padding: 0, + }, + ); + const converted = itkTransformToNgffTransform( + transform, + ["z", "y", "x"], + true, + { + fixed, + moving, + }, + ); + const viaRfc5 = await itkTransformResampleBoundingBox( + converted, + fixed, + moving, + { padding: 0 }, + ); + + assertEquals(viaRfc5.startIndex, viaItk.startIndex); + assertEquals(viaRfc5.size, viaItk.size); + + // Passing only one image is refused; unoriented frames are a no-op. + assertThrows( + () => + itkTransformToNgffTransform(transform, ["z", "y", "x"], true, { fixed }), + Error, + "both fixed and moving", + ); +}); + +Deno.test("frames round trip through both converters", async () => { + // RFC-5 -> ITK -> RFC-5 with frames must return the identical mapping. + // On a 3-cycle orientation (z, y, x onto LPS x, z, y): a diagonal + // direction such as RAS is its own inverse, so it cannot tell the reverse + // conversion's D^-1 from D. + const { AnatomicalOrientationValues, createAnatomicalOrientation } = + await import("../src/types/rfc4.ts"); + const cycle = { + z: createAnatomicalOrientation(AnatomicalOrientationValues.LeftToRight), + y: createAnatomicalOrientation( + AnatomicalOrientationValues.InferiorToSuperior, + ), + x: createAnatomicalOrientation( + AnatomicalOrientationValues.PosteriorToAnterior, + ), + }; + const fixed = await geometryImage( + ["z", "y", "x"], + { z: 8, y: 8, x: 8 }, + { z: 1, y: 2, x: 3 }, + { z: 1.3, y: -2.7, x: 5.1 }, + cycle, + ); + const moving = await geometryImage( + ["z", "y", "x"], + { z: 16, y: 16, x: 16 }, + { z: 1, y: 1, x: 1 }, + { z: -4.2, y: 7.6, x: 3.4 }, + cycle, + ); + const original = createAffine([ + [1, 0.2, 0, 4.1], + [0, 2, 0.3, -6.2], + [0.5, 0, 1, 11.3], + ]); + + const itkList = ngffTransformToItkTransform(original, ["z", "y", "x"], { + fixed, + moving, + }); + const back = itkTransformToNgffTransform(itkList, ["z", "y", "x"], false, { + fixed, + moving, + }); + + assertEquals(back.type, "affine"); + const rows = (back as { affine: number[][] }).affine; + for (let row = 0; row < 3; row++) { + for (let col = 0; col < 4; col++) { + assertAlmostEquals(rows[row][col], original.affine![row][col], 1e-9); + } + } +}); + Deno.test("an ITK-Wasm transform list is accepted", async () => { const fixed = await geometryImage(["y", "x"], { y: 16, x: 16 }, { y: 2, diff --git a/ts/test/itk_transform_to_ngff_transform_test.ts b/ts/test/itk_transform_to_ngff_transform_test.ts index 14b6c5c0..5298b777 100644 --- a/ts/test/itk_transform_to_ngff_transform_test.ts +++ b/ts/test/itk_transform_to_ngff_transform_test.ts @@ -20,10 +20,10 @@ import { createTranslation, itkTransformToNgffMatrix, itkTransformToNgffTransform, - ngffTransformToItkMatrix, ngffTransformToItkTransform, type V06Transform, } from "../src/mod.ts"; +import { ngffTransformToItkMatrix } from "../src/utils/ngff_transform_to_itk_transform.ts"; const ROUND_TRIP_CASES: Array<[string, V06Transform, string[]]> = [ ["identity", createIdentity(), ["y", "x"]], @@ -102,6 +102,28 @@ for (const [transform, expectedType] of SIMPLIFY_CASES) { }); } +Deno.test("a mirror does not simplify to a scale", () => { + // RFC-5 requires strictly positive scale factors. A flip is diagonal, so a + // sign-blind simplifier calls it a `scale` and writes metadata the 0.6 + // schema rejects. An LPS to RAS flip is an ordinary registration result. + const flip = entry("Scale", [-1, 1]); + const converted = itkTransformToNgffTransform(flip, ["y", "x"]); + assertEquals(converted.type, "affine"); + + // A mirror with a translation must not become a sequence either. + const withShift = entry("Affine", [-2, 0, 0, 3, 5, 7], [0, 0]); + assertEquals( + itkTransformToNgffTransform(withShift, ["y", "x"]).type, + "affine", + ); + + // ... while a strictly positive diagonal still simplifies. + assertEquals( + itkTransformToNgffTransform(entry("Scale", [2, 3]), ["y", "x"]).type, + "scale", + ); +}); + Deno.test("simplify can be disabled", () => { const dims = ["y", "x"]; const converted = itkTransformToNgffTransform( @@ -119,13 +141,14 @@ function entry( parameterization: string, parameters: number[], fixed: number[] = [], + dimension = 2, ): Transform { return { transformType: { transformParameterization: parameterization, parametersValueType: "float64", - inputDimension: 2, - outputDimension: 2, + inputDimension: dimension, + outputDimension: dimension, }, name: parameterization, inputSpaceName: "", @@ -138,6 +161,69 @@ function entry( } as unknown as Transform; } +Deno.test("mismatched ITK-Wasm dimensionality is rejected", () => { + // Without the parameter-count check, a 3D affine decoded against 2D dims + // silently projects, and a 2D affine against 3D dims fills the result with + // NaN, which JSON.stringify writes into the store as null. + const cases: Array<[string, number[], number[], string[]]> = [ + ["Affine", [2, 0, 0, 0, 3, 0, 0, 0, 4, 10, 20, 30], [0, 0, 0], ["y", "x"]], + ["Translation", [10, 20, 30], [], ["y", "x"]], + ["Affine", [2, 0, 0, 3, 10, 20], [0, 0], ["z", "y", "x"]], + ["Scale", [2, 3], [], ["z", "y", "x"]], + ["Translation", [5], [], ["y", "x"]], + ["Affine", [], [], ["y", "x"]], + ]; + for (const [parameterization, parameters, fixed, dims] of cases) { + assertThrows( + () => + itkTransformToNgffMatrix( + entry(parameterization, parameters, fixed), + dims, + ), + Error, + "does not match", + ); + } +}); + +Deno.test("a composite entry is refused at any position", () => { + // A parameterless 'Composite' entry is ambiguous: the pipeline writes one + // as a grouping header, but itk.dict_from_transform emits one only for a + // NESTED composite whose children it dropped. Skipping a leading one + // silently returns the mapping minus the lost children. + for (const position of [0, 1]) { + const entries = [ + entry("Translation", [10, 0], []), + entry("Affine", [2, 0, 0, 2, 0, 0], [0, 0]), + ]; + entries.splice(position, 0, entry("Composite", [], [])); + assertThrows( + () => itkTransformToNgffMatrix(entries, ["y", "x"]), + Error, + "'Composite' entry", + ); + } +}); + +Deno.test("non-canonical spatial order converts by name", () => { + // ITK's first component is x by name, not whichever axis dims lists last. + const wasm = entry("Translation", [7, 5, 1], [], 3); + + for (const dims of [["z", "y", "x"], ["z", "x", "y"], ["x", "y", "z"]]) { + const { offset } = itkTransformToNgffMatrix(wasm, dims); + const named = Object.fromEntries(dims.map((dim, i) => [dim, offset[i]])); + assertEquals(named, { x: 7, y: 5, z: 1 }); + } + + // The reverse direction inverts it exactly, in every spelling. + const byName: Record = { x: 7, y: 5, z: 1 }; + for (const dims of [["z", "y", "x"], ["z", "x", "y"], ["x", "y", "z"]]) { + const translation = createTranslation(dims.map((dim) => byName[dim])); + const { offset } = ngffTransformToItkMatrix(translation, dims); + assertEquals(offset, [7, 5, 1]); + } +}); + Deno.test("a transform list composes its last entry first", () => { // ITK order (x, y): shift x by 10, and scale by 2. const shift = entry("Translation", [10, 0]); @@ -185,6 +271,20 @@ Deno.test("an angle-based parameterization is refused with guidance", () => { ); }); +Deno.test("a deformation is refused as a deformation, not as a matrix gap", () => { + // "Convert it to an Affine transform first" is not advice that applies to a + // displacement field: it has no affine equivalent at all. Saying so sends + // the caller to the RFC-5 field types instead of on a wild goose chase. + for (const parameterization of ["DisplacementField", "BSpline"]) { + const deformation = entry(parameterization, [0, 0, 0, 0]); + assertThrows( + () => itkTransformToNgffMatrix(deformation, ["y", "x"]), + Error, + "describes a deformation", + ); + } +}); + Deno.test("a scale transform decodes", () => { const scaling = entry("Scale", [2, 3]); const { matrix, offset } = itkTransformToNgffMatrix(scaling, ["y", "x"]); @@ -193,6 +293,54 @@ Deno.test("a scale transform decodes", () => { assertEquals(offset, [0, 0]); }); +Deno.test("a scale transform's center is folded into the offset", () => { + // itk.ScaleTransform scales about its center like an affine does, so its + // fixed parameters cannot be ignored: y = S (x - c) + c, which is + // y = S x + (c - S c). Reading the parameters alone would put the image at + // the wrong place with no error. + const scale = [2, 3]; + const center = [10, 20]; + const scaling = entry("Scale", scale, center); + + const { matrix, offset } = itkTransformToNgffMatrix(scaling, ["y", "x"]); + + // ITK order (x, y): (10 - 2*10, 20 - 3*20) = (-10, -40). + assertEquals(matrix, [[3, 0], [0, 2]]); + assertEquals(offset, [-40, -10]); // reversed to NGFF order (y, x) +}); + +Deno.test("a component on a non-spatial axis alone is projected away", () => { + // ITK has no non-spatial axis, so a frame interval on t has nowhere to go. + // Dropping it leaves the spatial mapping exact, which is all an ITK + // transform describes. This pins that as a decision rather than an accident; + // a component that couples t to a spatial axis is refused instead. + const { matrix, offset } = ngffTransformToItkMatrix( + createTransformSequence([ + createScale([0.5, 1, 2, 2]), + createTranslation([7, 0, 3, -4]), + ]), + ["t", "c", "y", "x"], + ); + + assertEquals(matrix, [[2, 0], [0, 2]]); // ITK order (x, y) + assertEquals(offset, [-4, 3]); + + assertThrows( + () => + ngffTransformToItkMatrix( + createAffine([ + [1, 0, 0, 0, 0], + [0, 1, 0, 0, 0], + [0.5, 0, 1, 0, 0], // y would depend on t + [0, 0, 0, 1, 0], + ]), + ["t", "c", "y", "x"], + ), + Error, + "couples spatial and non-spatial axes", + ); +}); + Deno.test("an empty transform list is rejected", () => { assertThrows( () => itkTransformToNgffMatrix([], ["y", "x"]), From 184ecc3419e6cfe8e0971bc5dafce3a05ae85b63 Mon Sep 17 00:00:00 2001 From: Valentin Boussot Date: Mon, 24 Aug 2026 19:18:59 +0200 Subject: [PATCH 03/11] docs: correct and complete the transform conversion guide The registration recipe declares the target coordinate system and points the transformation at it through CoordinateSystemIdentifiers; without them the write succeeds and only from_ngff_zarr(validate=True) complains. A note covers passing fixed/moving when the images carry RFC-4 orientation. Corrected claims: multiscales > datasets accepts only a single scale, a single identity, or a two-element sequence of scale and translation, so a bare translation belongs at the multiscales level; the linearity exemption for the bounding box holds for ITK input only, since an RFC-5 deformation is converted first; a transformation's input/output identifiers are not resolved by the bounding box. --- docs/itk.md | 86 ++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 72 insertions(+), 14 deletions(-) diff --git a/docs/itk.md b/docs/itk.md index 5aedd7e5..a781f77c 100644 --- a/docs/itk.md +++ b/docs/itk.md @@ -241,7 +241,11 @@ which matters when anatomical orientation is present: direction matrix derived from [RFC-4](./rfc4.md) anatomical orientation. In both cases the transform maps *fixed* points into *moving* space, matching -the direction registration libraries return. +the direction registration libraries return. A transformation's `input` and +`output` identifiers are **not resolved** here: the bounding box always maps +from the fixed image's intrinsic system to the moving image's, whatever the +identifiers name. They matter when the transformation is written into a store, +not when a region is computed. ## Converting transforms @@ -252,6 +256,13 @@ Transforms convert in both directions, mirroring `itk_image_to_ngff_image` and | --- | --- | | `ngff_transform_to_itk_transform` | RFC-5 to ITK | | `itk_transform_to_ngff_transform` | ITK to RFC-5 | +| `itk_transform_to_ngff_matrix` | ITK to RFC-5, as raw numbers | + +`itk_transform_to_ngff_matrix` returns the `(matrix, offset)` pair in Zarr axis +order instead of an RFC-5 dataclass. Reach for it to *inspect* a registration +numerically, a rotation angle or a determinant say: because the conversion +simplifies by default, `itk_transform_to_ngff_transform` may hand back a +`translation` or a `scale`, so there is no `affine` field to read. Both reconcile the two conventions that differ between the specifications: @@ -279,21 +290,58 @@ so parameterizations that store angles or a quaternion (`Euler2DTransform`, ```python >>> import itk >>> import ngff_zarr as nz +>>> from ngff_zarr.v06.zarr_metadata import ( +... CoordinateSystem, CoordinateSystemIdentifier) >>> >>> transform = registration_method.GetCombinedTransform() # doctest: +SKIP >>> rfc5 = nz.itk_transform_to_ngff_transform( # doctest: +SKIP ... transform, multiscales.metadata.dimension_names) +>>> +>>> # A transformation written into multiscales metadata must name the +>>> # coordinate systems it maps between, so declare the target and point the +>>> # transformation at it. Omit this and the store fails validation, quietly: +>>> # the write succeeds and only `from_ome_zarr(..., validate=True)` complains. +>>> intrinsic = multiscales.metadata.intrinsic_coordinate_system # doctest: +SKIP +>>> registered = CoordinateSystem( # doctest: +SKIP +... name='registered', axes=list(intrinsic.axes)) +>>> multiscales.metadata.coordinateSystems.append(registered) # doctest: +SKIP +>>> rfc5.input = CoordinateSystemIdentifier(name=intrinsic.name) # doctest: +SKIP +>>> rfc5.output = CoordinateSystemIdentifier(name=registered.name) # doctest: +SKIP +>>> >>> multiscales.metadata.coordinateTransformations = [rfc5] # doctest: +SKIP >>> nz.to_ome_zarr( # doctest: +SKIP ... 'registered.ome.zarr', multiscales, version='0.6') ``` +When the images carry [RFC-4](./rfc4.md) anatomical orientation, pass them: + +```python +>>> rfc5 = nz.itk_transform_to_ngff_transform( # doctest: +SKIP +... transform, fixed.dims, fixed=fixed, moving=moving) +``` + +An ITK transform acts on physical space, direction matrix included, while an +RFC-5 transformation acts on the intrinsic coordinate systems. Given both +`NgffImage`s the conversion changes frames exactly; without them it copies the +numbers unchanged, which is exact only when neither image is oriented. +`ngff_transform_to_itk_transform` accepts the same pair for the reverse +direction. + By default the result is the least expressive transformation that represents the mapping exactly -- `identity`, `translation`, `scale`, or a `sequence` of -scale and translation -- falling back to `affine`. RFC-5 recommends this, and -only those simpler forms are legal inside `multiscales > datasets`. Pass +scale and translation -- falling back to `affine`. RFC-5 recommends this. Pass `simplify=False` to always get an `affine`. +A mirror never simplifies to a `scale`, however diagonal its matrix looks, +because RFC-5 requires every scale factor to be strictly positive; it falls +through to `affine`, which carries the sign. + +The two slots that hold a transformation accept different things, and the +example above uses the permissive one. `multiscales > coordinateTransformations` +takes any RFC-5 type. `multiscales > datasets` takes exactly one entry, and only +a single `scale`, a single `identity`, or a two-element `sequence` of scale and +translation: a bare `translation` and an `affine` are both rejected there. + Only **linear** transforms convert between the two representations, in either direction: a deformation has no affine equivalent, so a non-linear ITK transform raises `NotImplementedError`, as do array-backed `displacements` and @@ -301,20 +349,30 @@ transform raises `NotImplementedError`, as do array-backed `displacements` and field types instead, described in the [RFC-5 documentation](./rfc5.md). This restriction applies only to *converting* a transform. Computing a bounding -box does **not** require linearity -- that is the section above. - -In the TypeScript package the equivalents are `ngffTransformToItkTransform` -and `itkTransformToNgffTransform`. TypeScript has no `itk` package to fall back -on, so only parameterizations that carry a matrix (`Identity`, `Translation`, -`Scale`, `Affine`) convert there; angle- and quaternion-based ones must be -converted to an affine first. +box from an **ITK** transform does not require linearity -- that is the section +above. An RFC-5 `displacements` or `coordinates` transformation is converted +first, so it is refused there too. + +ITK has no notion of a non-spatial axis. Going RFC-5 to ITK, a component acting +purely on `t` or `c` -- a frame interval, say -- is therefore **projected +away**: the spatial mapping stays exact, but the ITK transform is not a +faithful copy of the input. A component that *couples* the two kinds of axis, +where `y` would depend on `c`, is refused instead, because dropping that one +would move the image. Coming back the other way, non-spatial axes are left +untransformed. + +In the TypeScript package the equivalents are `ngffTransformToItkTransform`, +`itkTransformToNgffTransform` and `itkTransformToNgffMatrix`. TypeScript has no +`itk` package to fall back on, so only parameterizations that carry a matrix +(`Identity`, `Translation`, `Scale`, `Affine`) convert there; angle- and +quaternion-based ones must be converted to an affine first. ## TypeScript -The same functions are available in the TypeScript package as -`itkTransformResampleBoundingBox` and `ngffTransformToItkTransform`. They are -async, take options as an object, and return a `ResampleBoundingBox` whose -`selection()` yields a zarrita selection instead of Python slices: +The TypeScript package provides `itkTransformResampleBoundingBox`. It is async, +takes options as an object, and returns a `ResampleBoundingBox` whose +`selection()` yields a zarrita selection instead of Python slices. It accepts an +RFC-5 transformation just as the Python function does: ```typescript import { From a5ca4a3ac72bafc14cd8efe152625265e008b783 Mon Sep 17 00:00:00 2001 From: Valentin Boussot Date: Tue, 25 Aug 2026 00:31:31 +0200 Subject: [PATCH 04/11] docs(py): convert the RFC-5 affine before resampling the whole grid The out-of-core example handed the RFC-5 `Affine` built for the bounding box straight to `itk_transform_resample`, which takes an ITK or ITK-Wasm transform. The example now converts it with `ngff_transform_to_itk_transform` first, and writes with `to_ome_zarr`. --- docs/itk.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/itk.md b/docs/itk.md index a781f77c..78ce94be 100644 --- a/docs/itk.md +++ b/docs/itk.md @@ -123,10 +123,16 @@ the moving chunks directly, so a chunk that several blocks need is read and decoded once. The full moving image is never loaded, and nothing runs until the result is computed. +`itk_transform_resample` takes an ITK or ITK-Wasm transform, so the RFC-5 +`Affine` above is converted first; `ngff_transform_to_itk_transform` keeps the +axis order straight (see [Converting transforms](#converting-transforms)). + ```python +>>> itk_transform = nz.ngff_transform_to_itk_transform( # doctest: +SKIP +... transform, dims=['y', 'x']) >>> resampled = nz.itk_transform_resample( # doctest: +SKIP -... transform, fixed, moving) ->>> nz.to_ngff_zarr("resampled.zarr", # doctest: +SKIP +... itk_transform, fixed, moving) +>>> nz.to_ome_zarr("resampled.zarr", # doctest: +SKIP ... nz.to_multiscales(resampled)) ``` From 297c201f476073ae09e44ebc66480d468adae871 Mon Sep 17 00:00:00 2001 From: Valentin Boussot Date: Tue, 25 Aug 2026 01:21:22 +0200 Subject: [PATCH 05/11] feat(py): convert ITK displacement fields to and from RFC-5 displacements A displacement field is a transformation and an array at once. ITK keeps the array inside the transform; RFC-5 keeps it in the store, as a multiscale image the `displacements` entry points at by `path`. So the conversion has two outputs, and both functions stay free of I/O: the caller writes the field next to the image and loads it back with `to_ome_zarr` and `from_ome_zarr`. `itk_displacement_field_to_ngff_transform(transform, dims, path=...)` returns the `Displacements` transform and the field as an `NgffImage`, component axis first with `type: "displacement"`, components in `dims` order as RFC-5 requires. It accepts an `itk.DisplacementFieldTransform`, the vector `itk.Image` or `itkwasm.Image` a registration tool writes the field as, or an ITK-Wasm `DisplacementField` transform. `ngff_transform_to_itk_transform` gains `fields`, the field images keyed by the `path` their transform names, and returns a one-entry `DisplacementField` list that `itk.transform_from_dict` rebuilds. A read multiscales keeps the axis types in its metadata rather than on the image, so the component axis is taken from there. The fixed and moving images change frames as for an affine, per grid point, with one more rule: the field's grid must be oriented like the fixed image (the identity without frames), because the field's scale and translation, which map its array to the input system, cannot express another orientation. A field sampled elsewhere is refused with a message to resample it onto the fixed grid. The generic converters point a displacement field at the new function instead of refusing it as non-linear. Every case is anchored on `itk`'s own `TransformPoint`, on and off the grid, including through a store and under RFC-4 orientation. --- docs/itk.md | 77 ++- py/ngff_zarr/__init__.py | 6 + py/ngff_zarr/displacement_field_transform.py | 551 ++++++++++++++++++ .../itk_transform_to_ngff_transform.py | 15 + .../ngff_transform_to_itk_transform.py | 33 +- py/test/test_displacement_field_transform.py | 367 ++++++++++++ .../test_itk_transform_to_ngff_transform.py | 22 +- 7 files changed, 1053 insertions(+), 18 deletions(-) create mode 100644 py/ngff_zarr/displacement_field_transform.py create mode 100644 py/test/test_displacement_field_transform.py diff --git a/docs/itk.md b/docs/itk.md index 78ce94be..c835a20f 100644 --- a/docs/itk.md +++ b/docs/itk.md @@ -348,16 +348,73 @@ takes any RFC-5 type. `multiscales > datasets` takes exactly one entry, and only a single `scale`, a single `identity`, or a two-element `sequence` of scale and translation: a bare `translation` and an `affine` are both rejected there. -Only **linear** transforms convert between the two representations, in either -direction: a deformation has no affine equivalent, so a non-linear ITK -transform raises `NotImplementedError`, as do array-backed `displacements` and -`coordinates` going the other way. RFC-5 represents deformations with those -field types instead, described in the [RFC-5 documentation](./rfc5.md). - -This restriction applies only to *converting* a transform. Computing a bounding -box from an **ITK** transform does not require linearity -- that is the section -above. An RFC-5 `displacements` or `coordinates` transformation is converted -first, so it is refused there too. +Linear transforms and displacement fields convert; other deformations do not. +A B-spline or a velocity field has no RFC-5 equivalent, so such an ITK +transform raises `NotImplementedError`, as does an RFC-5 `coordinates` +transformation going the other way. RFC-5 represents deformations with its +`displacements` and `coordinates` field types, described in the +[RFC-5 documentation](./rfc5.md); the first of those is covered next. + +Computing a bounding box from an **ITK** transform does not require linearity +-- that is the section above. An RFC-5 `displacements` transformation is +converted first, so it needs its field there too. + +### Displacement fields + +A displacement field is a transformation and an array at once. ITK keeps the +array inside the transform; RFC-5 keeps it in the store, as a multiscale image +the `displacements` entry points at by `path`. The conversion therefore has two +outputs, the transform and the field to write next to the image, and the +functions stay free of any I/O: + +```python +>>> transform, field = nz.itk_displacement_field_to_ngff_transform( # doctest: +SKIP +... warp, multiscales.metadata.dimension_names, path='displacement_field') +>>> nz.to_ome_zarr( # doctest: +SKIP +... 'registered.ome.zarr/displacement_field', +... nz.to_multiscales(field, scale_factors=[]), version='0.6') +>>> transform.input = CoordinateSystemIdentifier(name=intrinsic.name) # doctest: +SKIP +>>> transform.output = CoordinateSystemIdentifier(name=registered.name) # doctest: +SKIP +>>> multiscales.metadata.coordinateTransformations = [transform] # doctest: +SKIP +>>> nz.to_ome_zarr( # doctest: +SKIP +... 'registered.ome.zarr', multiscales, version='0.6', overwrite=False) +``` + +`warp` may be an `itk.DisplacementFieldTransform`, the vector `itk.Image` or +`itkwasm.Image` a registration tool writes the field as, or an ITK-Wasm +`DisplacementField` transform. The field comes back as an `NgffImage` whose +first axis holds the components (`type: "displacement"`) followed by the +spatial axes, with the grid's spacing and origin as its scale and translation. +Its components follow the axes of `dims`, as RFC-5 requires, so an ITK `(dx, +dy, dz)` vector is stored as `(dz, dy, dx)` on a `zyx` image. + +Going back, pass the field the transform points at, loaded from the same +store, keyed by its `path`: + +```python +>>> imported = nz.from_ome_zarr('registered.ome.zarr') # doctest: +SKIP +>>> transform = imported.metadata.coordinateTransformations[0] # doctest: +SKIP +>>> field = nz.from_ome_zarr(f'registered.ome.zarr/{transform.path}') # doctest: +SKIP +>>> itk_transforms = nz.ngff_transform_to_itk_transform( # doctest: +SKIP +... transform, imported.metadata.dimension_names, +... fields={transform.path: field}) +``` + +The result is an ITK-Wasm `TransformList` with one `DisplacementField` entry; +`itk.transform_from_dict` turns it into a native +`itk.DisplacementFieldTransform`. ITK interpolates a field linearly, so a +transform asking for another `interpolation` converts with a warning, which +RFC-5 allows: the field's interpolation is a recommendation to consumers, not +a requirement. + +The `fixed` and `moving` images change frames here exactly as for an affine, +with one more rule. The field's own grid must be oriented like the fixed image +(the identity when no images are passed): RFC-5 maps the field's array to the +input coordinate system through the field's scale and translation, which +cannot express a differently oriented grid. Registrations sample the field on +the fixed grid, so this holds for their output; a field sampled elsewhere is +refused rather than written with a mapping a reader would misread, and should +be resampled onto the fixed grid first. ITK has no notion of a non-spatial axis. Going RFC-5 to ITK, a component acting purely on `t` or `c` -- a frame interval, say -- is therefore **projected diff --git a/py/ngff_zarr/__init__.py b/py/ngff_zarr/__init__.py index 7a0cda2d..6fa6b105 100644 --- a/py/ngff_zarr/__init__.py +++ b/py/ngff_zarr/__init__.py @@ -14,6 +14,10 @@ ) from .config import config from .detect_cli_io_backend import ConversionBackend, detect_cli_io_backend +from .displacement_field_transform import ( + itk_displacement_field_to_ngff_transform, + ngff_displacement_field_to_itk_transform, +) from .from_ngff_zarr import from_ngff_zarr, from_ome_zarr from .hcs import ( HCSPlate, @@ -133,6 +137,8 @@ "ngff_transform_to_itk_transform", "itk_transform_to_ngff_matrix", "itk_transform_to_ngff_transform", + "itk_displacement_field_to_ngff_transform", + "ngff_displacement_field_to_itk_transform", # Out-of-core resampling "itk_transform_resample", "itk_transform_resample_bounding_box", diff --git a/py/ngff_zarr/displacement_field_transform.py b/py/ngff_zarr/displacement_field_transform.py new file mode 100644 index 00000000..87315bc5 --- /dev/null +++ b/py/ngff_zarr/displacement_field_transform.py @@ -0,0 +1,551 @@ +# SPDX-FileCopyrightText: Copyright (c) Fideus Labs LLC +# SPDX-License-Identifier: MIT +"""Bridge ITK displacement fields and RFC-5 ``displacements`` transformations. + +A displacement field is a transformation and an array at once. ITK keeps the +array inside the transform, as a vector image with its own grid; RFC-5 keeps it +in the store, as a multiscale image the ``displacements`` entry points at by +``path``. So the two conversions here are the only ones in this package whose +result has two parts, and they stay pure: nothing is read from or written to a +store. The caller writes the field next to its image, and loads it back, with +:func:`~ngff_zarr.to_ome_zarr` and :func:`~ngff_zarr.from_ome_zarr`. + +The same two conventions as the affine conversion apply, plus one. + +Axis order + ITK orders a vector's components fastest-axis-first, x then y then z, and + lays the field out as ``[z][y][x][component]``. RFC-5 says the ``i``-th + component of the field refers to the ``i``-th axis of the output + coordinate system, so components follow ``dims``; the same permutation as + for an affine's matrix is applied to the component axis. + +Frames + An ITK vector is a difference of two physical points. An RFC-5 + displacement is a difference of two intrinsic points, ``d = q' - q``. With + ``phi(q) = D (q - o) + o`` relating each image's intrinsic system to ITK + physical space (``D`` from RFC-4 orientation, ``o`` the translation), a + vector ``v`` sampled at the input grid point ``q`` becomes:: + + d(q) = D_out^-1 (v + D_in (q - o_in) + o_in - o_out) + o_out - q + + which collapses to ``d = D^-1 v`` when both images share a frame. The + field's own grid follows ``phi_in^-1``, so ``translation = D_in^-1 (o_f - + o_in) + o_in`` and the spacing is unchanged. + +Grid direction + RFC-5 maps the field's array coordinates to the input system with the + field's own ``coordinateTransformations``, which this package writes as a + scale and a translation. That can only express a grid oriented like the + input image, so a field whose direction matrix differs from the fixed + image's (the identity when no frames are given) is refused rather than + written with a mapping a reader would misread. Registrations sample the + field on the fixed grid, so the common case is exact. +""" + +from __future__ import annotations + +import warnings +from collections.abc import Mapping, Sequence + +import numpy as np + +from .ngff_image import NgffImage +from .v06.zarr_metadata import Displacements + +#: The name given to the component axis of a converted field. RFC-5 puts that +#: axis after a time axis and before the spatial ones, with +#: ``type: "displacement"``. +_COMPONENT_DIM = "c" + + +def _check_dims(dims: Sequence[str]) -> tuple[str, ...]: + """Validate the spatial-only ``dims`` a displacement field is defined on.""" + from .itk_transform_to_ngff_transform import _SPATIAL_DIMS + + dims = tuple(dims) + if not dims or any(dim not in _SPATIAL_DIMS for dim in dims): + msg = ( + f"a displacement field is defined on spatial axes only; got dims " + f"{dims}. ITK has no notion of a time or channel axis, and RFC-5 " + "requires one field dimension per input axis." + ) + raise ValueError(msg) + if len(set(dims)) != len(dims): + msg = f"dims {dims} name an axis twice" + raise ValueError(msg) + return dims + + +def _frames(fixed, moving, dims): + """The two images' directions and origins in ITK order, or ``None``.""" + from .itk_transform_to_ngff_transform import ( + _check_frame_images, + _frame_geometry, + _itk_axis_order, + ) + + if not _check_frame_images(fixed, moving, dims): + return None + return _frame_geometry(fixed, moving, _itk_axis_order(dims)) + + +def _grid_points(shape, origin, spacing): + """Every grid point's coordinates, ``(*shape, N)`` in ITK component order. + + ``shape`` is the field's ``[z][y][x]`` layout, so the index arrays come + out slowest-axis-first and are reversed into ITK order. + """ + indices = np.stack(np.indices(shape)[::-1], axis=-1).astype(np.float64) + return origin + spacing * indices + + +def _vectors_to_intrinsic( + vectors, origin, spacing, direction_in, direction_out, origin_in, origin_out +): + """ITK physical vectors to RFC-5 displacements, per grid point.""" + inverse_out = np.linalg.inv(direction_out) + if np.allclose(direction_in, direction_out) and np.allclose(origin_in, origin_out): + return vectors @ inverse_out.T + points = _grid_points(vectors.shape[:-1], origin, spacing) + shifted = vectors + (points - origin_in) @ direction_in.T + origin_in - origin_out + return shifted @ inverse_out.T + origin_out - points + + +def _vectors_to_physical( + displacements, origin, spacing, direction_in, direction_out, origin_in, origin_out +): + """RFC-5 displacements to ITK physical vectors, per grid point.""" + if np.allclose(direction_in, direction_out) and np.allclose(origin_in, origin_out): + return displacements @ direction_out.T + points = _grid_points(displacements.shape[:-1], origin, spacing) + moved = (displacements + points - origin_out) @ direction_out.T + origin_out + return moved - (points - origin_in) @ direction_in.T - origin_in + + +def _decode_field(transform): + """Read any supported field input into ITK conventions. + + Returns ``(vectors, origin, spacing, direction)``: the field as a + ``[z][y][x][component]`` array with components in ITK order, and its grid + in ITK component order. + """ + if isinstance(transform, (list, tuple)): + if len(transform) != 1: + msg = ( + f"expected a single displacement field, got a transform list of " + f"{len(transform)} entries. A registration that chains an affine " + "and a field is not converted as one transform." + ) + raise ValueError(msg) + return _decode_field(transform[0]) + + try: + from itkwasm import Image as ItkWasmImage + from itkwasm import Transform as ItkWasmTransform + except ImportError: # pragma: no cover - itkwasm is a hard dependency + ItkWasmImage = ItkWasmTransform = () + + if isinstance(transform, ItkWasmTransform): + return _decode_itkwasm_transform(transform) + if isinstance(transform, ItkWasmImage): + from dataclasses import asdict + + return _decode_image_dict(asdict(transform)) + + if hasattr(transform, "GetDisplacementField"): + import itk + + return _decode_image_dict(itk.dict_from_image(transform.GetDisplacementField())) + + try: + import itk + except ImportError: + itk = None + if itk is not None and isinstance(transform, (itk.Image, itk.VectorImage)): + return _decode_image_dict(itk.dict_from_image(transform)) + + msg = ( + f"unsupported displacement field input {type(transform).__name__}. " + "Expected an itk.DisplacementFieldTransform, a vector itk.Image, an " + "itkwasm.Image, or an ITK-Wasm 'DisplacementField' transform." + ) + raise TypeError(msg) + + +def _decode_itkwasm_transform(transform): + """The field an ITK-Wasm ``DisplacementField`` entry packs.""" + transform_type = transform.transformType + parameterization = getattr( + transform_type.transformParameterization, + "value", + transform_type.transformParameterization, + ) + if parameterization != "DisplacementField": + msg = ( + f"expected an ITK-Wasm 'DisplacementField' transform, got " + f"'{parameterization}'. Linear transforms are converted by " + "itk_transform_to_ngff_transform." + ) + raise ValueError(msg) + dimension = int(transform_type.inputDimension) + fixed = np.asarray(transform.fixedParameters, dtype=np.float64) + expected = 3 * dimension + dimension * dimension + if fixed.size != expected: + msg = ( + f"an ITK-Wasm 'DisplacementField' transform of dimension {dimension} " + f"packs size, origin, spacing and direction into {expected} fixed " + f"parameters; got {fixed.size}" + ) + raise ValueError(msg) + size = fixed[:dimension].astype(int) + origin = fixed[dimension : 2 * dimension] + spacing = fixed[2 * dimension : 3 * dimension] + direction = fixed[3 * dimension :].reshape(dimension, dimension) + parameters = np.asarray(transform.parameters) + if parameters.size != int(np.prod(size)) * dimension: + msg = ( + f"an ITK-Wasm 'DisplacementField' transform over a grid of size " + f"{size.tolist()} holds {int(np.prod(size)) * dimension} parameters; " + f"got {parameters.size}" + ) + raise ValueError(msg) + # ITK packs the field slowest-axis-first with the component innermost. + vectors = parameters.reshape(*size[::-1], dimension) + return vectors, origin, spacing, direction + + +def _decode_image_dict(image): + """The field a vector image dictionary holds.""" + data = np.asarray(image["data"]) + components = int(image["imageType"]["components"]) + dimension = data.ndim - 1 if components > 1 else data.ndim + if components != dimension or data.ndim != dimension + 1: + msg = ( + f"a displacement field over {dimension} dimensions needs {dimension} " + f"components per pixel; got {components}" + ) + raise ValueError(msg) + origin = np.asarray(image["origin"], dtype=np.float64) + spacing = np.asarray(image["spacing"], dtype=np.float64) + direction = np.asarray(image["direction"], dtype=np.float64).reshape( + dimension, dimension + ) + return data, origin, spacing, direction + + +def itk_displacement_field_to_ngff_transform( + transform, + dims: Sequence[str], + *, + path: str, + fixed: NgffImage | None = None, + moving: NgffImage | None = None, +) -> tuple[Displacements, NgffImage]: + """Convert an ITK displacement field to an RFC-5 ``displacements`` transform. + + The result has two parts, because the field is an array: the transform + metadata, which names ``path``, and the field itself as an image to be + written at that path. Write the field first, into a subgroup of the store + the image goes to, then the image:: + + transform, field = itk_displacement_field_to_ngff_transform( + itk_transform, ["z", "y", "x"], path="displacement_field" + ) + to_ome_zarr(f"{store}/displacement_field", to_multiscales(field)) + multiscales.metadata.coordinateTransformations = [transform] + to_ome_zarr(store, multiscales, overwrite=False) + + :param transform: An ``itk.DisplacementFieldTransform``, a vector + ``itk.Image`` or ``itkwasm.Image`` holding the field directly (the form + a warp comes in from most registration tools), an ITK-Wasm + ``DisplacementField`` transform, or a one-entry list of it. The field + maps *fixed* points into *moving* space. + :param dims: The spatial axis names of the input coordinate system, in + RFC-5 (Zarr) order. The field must be defined on these axes and no + others. + :type dims: Sequence[str] + :param path: Where the field will be written, relative to the image's + group. Recorded on the returned transform. + :type path: str + :param fixed: The fixed and moving images the field relates. Passing both + re-expresses the vectors on the images' intrinsic coordinate systems, + including the direction matrix derived from RFC-4 anatomical + orientation, and gives the field the fixed image's orientation and + units. Omitting them is exact only when neither image carries an + anatomical orientation. + :type fixed: NgffImage, optional + :param moving: See ``fixed``. Pass both or neither. + :type moving: NgffImage, optional + :return: The ``displacements`` transform, with ``interpolation`` set to + ``linear`` (ITK's interpolator for a field), and the field as an + ``NgffImage`` whose first axis carries the components, ``type: + "displacement"``, followed by ``dims``. + :rtype: tuple[Displacements, NgffImage] + :raises ValueError: If the field's grid is not oriented like the fixed + image (the identity without frames), which the field's scale and + translation could not express; resample the field onto the fixed grid + first. Also for a field whose dimensionality does not match ``dims``. + """ + import dask.array + + from .itk_transform_to_ngff_transform import _itk_axis_order + + dims = _check_dims(dims) + vectors, origin, spacing, direction = _decode_field(transform) + dimension = vectors.shape[-1] + if dimension != len(dims): + msg = ( + f"the field has {dimension} components over {vectors.ndim - 1} " + f"dimensions, but dims {dims} name {len(dims)} axes" + ) + raise ValueError(msg) + itk_dims = _itk_axis_order(dims) + canonical = list(reversed(itk_dims)) + + frames = _frames(fixed, moving, dims) + if frames is None: + if not np.allclose(direction, np.eye(dimension)): + msg = ( + "the field's grid has a non-identity direction matrix, which its " + "scale and translation cannot express. Pass the fixed and " + "moving images so it is read against the fixed image's " + "orientation, or resample the field onto the fixed grid." + ) + raise ValueError(msg) + grid_origin = origin + displacements = vectors + else: + direction_in, direction_out, origin_in, origin_out = frames + if not np.allclose(direction, direction_in): + msg = ( + "the field's grid is not oriented like the fixed image: its " + f"direction is {direction.tolist()} where the fixed image gives " + f"{direction_in.tolist()}. Resample the field onto the fixed " + "grid first." + ) + raise ValueError(msg) + grid_origin = np.linalg.inv(direction_in) @ (origin - origin_in) + origin_in + displacements = _vectors_to_intrinsic( + vectors, + grid_origin, + spacing, + direction_in, + direction_out, + origin_in, + origin_out, + ) + + # [z][y][x][c] with ITK components -> (c, *dims) with components in dims order. + field = np.moveaxis(displacements, -1, 0) + field = np.transpose(field, [0] + [1 + canonical.index(dim) for dim in dims]) + field = field[[itk_dims.index(dim) for dim in dims]] + field = np.ascontiguousarray(field, dtype=vectors.dtype) + + scale = {_COMPONENT_DIM: 1.0} + translation = {_COMPONENT_DIM: 0.0} + for dim in dims: + scale[dim] = float(spacing[itk_dims.index(dim)]) + translation[dim] = float(grid_origin[itk_dims.index(dim)]) + + orientations = None + units = None + if fixed is not None: + if fixed.axes_orientations: + orientations = { + dim: fixed.axes_orientations[dim] + for dim in dims + if dim in fixed.axes_orientations + } or None + if fixed.axes_units: + units = { + dim: fixed.axes_units[dim] for dim in dims if dim in fixed.axes_units + } or None + + image = NgffImage( + data=dask.array.from_array(field), + dims=(_COMPONENT_DIM, *dims), + scale=scale, + translation=translation, + name=path.rstrip("/").rsplit("/", 1)[-1] or "displacement_field", + axes_units=units, + axes_orientations=orientations, + axes_types={_COMPONENT_DIM: "displacement"}, + ) + return Displacements(path=path, interpolation="linear"), image + + +def ngff_displacement_field_to_itk_transform( + transform: Displacements, + field, + dims: Sequence[str], + *, + fixed: NgffImage | None = None, + moving: NgffImage | None = None, +) -> list: + """Convert an RFC-5 ``displacements`` transform and its field to ITK. + + The counterpart of :func:`itk_displacement_field_to_ngff_transform`, and + what :func:`~ngff_zarr.ngff_transform_to_itk_transform` calls when handed + a ``displacements`` transform with its field. The field is the image + stored at ``transform.path``; load it with + ``from_ome_zarr(f"{store}/{transform.path}")``. + + :param transform: The ``displacements`` transform. + :type transform: Displacements + :param field: The field image: an ``NgffImage`` whose component axis is + the one with ``axes_types`` ``displacement``, followed by ``dims`` in + order; or an ``NgffMultiscales``, whose finest level is used. + :param dims: The spatial axis names of the input coordinate system, in + RFC-5 (Zarr) order. + :type dims: Sequence[str] + :param fixed: The fixed and moving images the field relates; see + :func:`itk_displacement_field_to_ngff_transform`. The field's own + orientation, if any, must be the fixed image's. + :type fixed: NgffImage, optional + :param moving: See ``fixed``. Pass both or neither. + :type moving: NgffImage, optional + :return: A single-entry ITK-Wasm ``TransformList`` of parameterization + ``DisplacementField``. ``itk.transform_from_dict`` turns it into a + native ``itk.DisplacementFieldTransform``. + :rtype: list[itkwasm.Transform] + :raises ValueError: If the field's axes are not the component axis followed + by ``dims``, or if its orientation is not the fixed image's. + """ + from itkwasm import FloatTypes, TransformParameterizations, TransformType + from itkwasm import Transform as ItkWasmTransform + + from .itk_transform_resample_bounding_box import _itk_direction + from .itk_transform_to_ngff_transform import _itk_axis_order + + dims = _check_dims(dims) + if hasattr(field, "images") and hasattr(field, "metadata"): + # A read multiscales keeps the axis types in its metadata, not on the + # image: the component axis is the one typed "displacement" there. + axes = field.metadata.intrinsic_coordinate_system.axes + component_dims = [axis.name for axis in axes if axis.type == "displacement"] + field = field.images[0] + else: + component_dims = [ + dim + for dim, axis_type in (field.axes_types or {}).items() + if axis_type == "displacement" + ] + if len(component_dims) != 1: + msg = ( + "the field image must have exactly one axis of type 'displacement' " + f"(axes_types on an NgffImage, the axes metadata of a multiscales); " + f"got {component_dims or 'none'} on dims {tuple(field.dims)}" + ) + raise ValueError(msg) + expected_dims = (component_dims[0], *dims) + if tuple(field.dims) != expected_dims: + msg = ( + f"the field's dims are {tuple(field.dims)}; a displacements transform " + f"over dims {dims} needs {expected_dims}: the component axis first, " + "then the input axes in order" + ) + raise ValueError(msg) + + data = field.data + data = np.asarray(data.compute() if hasattr(data, "compute") else data) + dimension = len(dims) + if data.shape[0] != dimension: + msg = ( + f"the field holds {data.shape[0]} components per point, but dims " + f"{dims} name {dimension} axes" + ) + raise ValueError(msg) + + itk_dims = _itk_axis_order(dims) + canonical = list(reversed(itk_dims)) + # (c, *dims) with components in dims order -> [z][y][x][c] with ITK components. + arranged = data[[dims.index(dim) for dim in itk_dims]] + arranged = np.transpose(arranged, [0] + [1 + dims.index(dim) for dim in canonical]) + displacements = np.moveaxis(arranged, 0, -1) + + spacing = np.array([float(field.scale[dim]) for dim in itk_dims]) + translation = np.array([float(field.translation[dim]) for dim in itk_dims]) + own_direction = _itk_direction(field, itk_dims) + + frames = _frames(fixed, moving, dims) + if frames is None: + if not np.allclose(own_direction, np.eye(dimension)): + msg = ( + "the field carries an anatomical orientation; pass the fixed and " + "moving images so its grid is placed in their frame" + ) + raise ValueError(msg) + origin = translation + direction = np.eye(dimension) + vectors = displacements + else: + direction_in, direction_out, origin_in, origin_out = frames + if not np.allclose(own_direction, np.eye(dimension)) and not np.allclose( + own_direction, direction_in + ): + msg = ( + "the field's orientation is not the fixed image's: it gives " + f"{own_direction.tolist()} where the fixed image gives " + f"{direction_in.tolist()}" + ) + raise ValueError(msg) + origin = direction_in @ (translation - origin_in) + origin_in + direction = direction_in + vectors = _vectors_to_physical( + displacements, + translation, + spacing, + direction_in, + direction_out, + origin_in, + origin_out, + ) + + if transform.interpolation not in (None, "linear"): + warnings.warn( + f"the displacements transform asks for '{transform.interpolation}' " + "interpolation; ITK interpolates a displacement field linearly. RFC-5 " + "leaves the choice to the consumer.", + stacklevel=2, + ) + + if vectors.dtype == np.float32: + value_type = FloatTypes.Float32 + else: + value_type = FloatTypes.Float64 + vectors = vectors.astype(np.float64) + size = np.array(displacements.shape[:-1][::-1], dtype=np.float64) + fixed_parameters = np.concatenate( + [size, origin, spacing, direction.ravel(order="C")] + ).astype(np.float64) + parameters = np.ascontiguousarray(vectors).ravel(order="C") + transform_type = TransformType( + transformParameterization=TransformParameterizations.DisplacementField, + parametersValueType=value_type, + inputDimension=dimension, + outputDimension=dimension, + ) + return [ + ItkWasmTransform( + transformType=transform_type, + numberOfFixedParameters=len(fixed_parameters), + numberOfParameters=len(parameters), + fixedParameters=fixed_parameters, + parameters=parameters, + name="DisplacementFieldTransform", + ) + ] + + +def _fields_entry(transform: Displacements, fields: Mapping[str, object] | None): + """The field ``fields`` holds for ``transform``, with a message otherwise.""" + if not fields or transform.path not in fields: + available = sorted(fields) if fields else [] + msg = ( + f"the displacements transform points at '{transform.path}', but no " + f"field was passed for it (fields given: {available}). Load it with " + f'from_ome_zarr(f"{{store}}/{transform.path}") and pass ' + f"fields={{'{transform.path}': field}}." + ) + raise ValueError(msg) + return fields[transform.path] diff --git a/py/ngff_zarr/itk_transform_to_ngff_transform.py b/py/ngff_zarr/itk_transform_to_ngff_transform.py index ef007836..5ec90b32 100644 --- a/py/ngff_zarr/itk_transform_to_ngff_transform.py +++ b/py/ngff_zarr/itk_transform_to_ngff_transform.py @@ -255,6 +255,13 @@ def _matrix_offset_from_itkwasm(entry, dimension: int): # ITK applies the matrix about the center, so fold it into the offset. return matrix, translation + center - matrix @ center + if parameterization == "DisplacementField": + msg = ( + "a displacement field has no affine equivalent; convert it with " + "itk_displacement_field_to_ngff_transform, which returns the " + "'displacements' transform and the field to write next to the image" + ) + raise NotImplementedError(msg) if parameterization in _NON_LINEAR_PARAMETERIZATIONS: msg = ( "only linear ITK transforms can be expressed as an RFC-5 affine; " @@ -307,6 +314,14 @@ def _itk_matrix_offset(transform, dimension: int): # A native itk.Transform, including the CompositeTransform Elastix returns. if hasattr(transform, "TransformPoint"): + if hasattr(transform, "GetDisplacementField"): + msg = ( + "a displacement field has no affine equivalent; convert it with " + "itk_displacement_field_to_ngff_transform, which returns the " + "'displacements' transform and the field to write next to the " + "image" + ) + raise NotImplementedError(msg) _reject_non_linear(transform) return _matrix_offset_by_probing(transform, dimension) diff --git a/py/ngff_zarr/ngff_transform_to_itk_transform.py b/py/ngff_zarr/ngff_transform_to_itk_transform.py index a69cf7f8..f3e9f563 100644 --- a/py/ngff_zarr/ngff_transform_to_itk_transform.py +++ b/py/ngff_zarr/ngff_transform_to_itk_transform.py @@ -25,12 +25,13 @@ half-pixel correction is involved. """ -from collections.abc import Sequence +from collections.abc import Mapping, Sequence import numpy as np from .v06.zarr_metadata import ( Affine, + Displacements, Identity, Rotation, Scale, @@ -226,17 +227,25 @@ def ngff_transform_to_itk_transform( transform: Transform, dims: Sequence[str], *, + fields: Mapping[str, object] | None = None, fixed=None, moving=None, ) -> list: """Convert an RFC-5 transformation to an ITK-Wasm transform list. - The transformation is collapsed into a single ``Affine`` entry, so the - result is independent of ITK's own list-composition order. + A linear transformation is collapsed into a single ``Affine`` entry, so + the result is independent of ITK's own list-composition order. A + ``displacements`` transformation becomes a single ``DisplacementField`` + entry built from the field passed in ``fields``. - :param transform: An RFC-5 (OME-Zarr v0.6) coordinate transformation - describing a linear mapping. + :param transform: An RFC-5 (OME-Zarr v0.6) coordinate transformation: + a linear mapping, or a ``displacements`` transformation. :type transform: Transform + :param fields: The field images a ``displacements`` transformation points + at, keyed by its ``path``: an ``NgffImage``, or the ``NgffMultiscales`` + read from ``f"{store}/{transform.path}"``. Required for a + ``displacements`` transformation, ignored otherwise. + :type fields: Mapping[str, NgffImage | NgffMultiscales], optional :param dims: The axis names of the coordinate system the transformation is defined on, in RFC-5 (Zarr) order. @@ -255,6 +264,20 @@ def ngff_transform_to_itk_transform( :return: A single-entry ITK-Wasm ``TransformList``. :rtype: list[itkwasm.Transform] """ + if isinstance(transform, Displacements): + from .displacement_field_transform import ( + _fields_entry, + ngff_displacement_field_to_itk_transform, + ) + + return ngff_displacement_field_to_itk_transform( + transform, + _fields_entry(transform, fields), + dims, + fixed=fixed, + moving=moving, + ) + from itkwasm import ( FloatTypes, TransformParameterizations, diff --git a/py/test/test_displacement_field_transform.py b/py/test/test_displacement_field_transform.py new file mode 100644 index 00000000..afc89b02 --- /dev/null +++ b/py/test/test_displacement_field_transform.py @@ -0,0 +1,367 @@ +# SPDX-FileCopyrightText: Copyright (c) Fideus Labs LLC +# SPDX-License-Identifier: MIT +"""ITK displacement fields to and from RFC-5 ``displacements`` transformations. + +The anchor for every case is ``itk``'s own ``TransformPoint``: a converted +field is right when the ITK transform rebuilt from it maps the same points to +the same places as the original, on and off the grid. Layout, component order +and grid geometry are pinned separately with a field holding one known vector. +""" + +from dataclasses import asdict + +import dask.array as da +import numpy as np +import pytest +from ngff_zarr import ( + NgffImage, + from_ome_zarr, + itk_displacement_field_to_ngff_transform, + itk_transform_to_ngff_transform, + ngff_transform_to_itk_transform, + to_multiscales, + to_ngff_image, + to_ome_zarr, +) +from ngff_zarr.rfc4 import RAS +from ngff_zarr.v06.zarr_metadata import CoordinateSystemIdentifier, Displacements + +itk = pytest.importorskip("itk") + +CANONICAL = {2: ("y", "x"), 3: ("z", "y", "x")} + + +def _field_transform(size, spacing, origin, direction=None, seed=0, value_type=None): + """A random deformation on a grid of ``size`` (ITK order), as a transform.""" + value_type = itk.D if value_type is None else value_type + ndim = len(size) + field = itk.Image[itk.Vector[value_type, ndim], ndim].New() + field.SetRegions(list(size)) + field.Allocate() + field.SetSpacing(list(spacing)) + field.SetOrigin(list(origin)) + if direction is not None: + field.SetDirection(itk.matrix_from_array(np.asarray(direction, dtype=float))) + view = itk.array_view_from_image(field) + view[:] = np.random.default_rng(seed).normal(scale=3.0, size=view.shape) + transform = itk.DisplacementFieldTransform[value_type, ndim].New() + transform.SetDisplacementField(field) + return transform + + +def _native(itkwasm_list): + """The native ITK transform an ITK-Wasm list rebuilds to.""" + assert len(itkwasm_list) == 1 + rebuilt = itk.transform_from_dict(asdict(itkwasm_list[0])) + if hasattr(rebuilt, "GetNthTransform"): + rebuilt = rebuilt.GetNthTransform(0) + return rebuilt + + +def _points_inside(transform, count=6, seed=1): + """Physical points inside the field's extent, mostly off the grid.""" + field = transform.GetDisplacementField() + size = np.array(itk.size(field)) + origin = np.array(field.GetOrigin()) + spacing = np.array(field.GetSpacing()) + rng = np.random.default_rng(seed) + fractions = rng.uniform(0.05, 0.95, size=(count, len(size))) + points = origin + fractions * spacing * (size - 1) + return [origin, *points] + + +def _transform_point(transform, point): + return np.array(transform.TransformPoint([float(value) for value in point])) + + +@pytest.mark.parametrize("ndim", [2, 3]) +def test_round_trip_matches_transform_point(ndim): + size = (5, 4, 3)[:ndim] + spacing = (0.5, 2.0, 1.5)[:ndim] + origin = (10.0, 20.0, -3.0)[:ndim] + dims = CANONICAL[ndim] + original = _field_transform(size, spacing, origin) + + transform, field = itk_displacement_field_to_ngff_transform( + original, dims, path="warp" + ) + + assert isinstance(transform, Displacements) + assert transform.path == "warp" + assert transform.interpolation == "linear" + assert tuple(field.dims) == ("c", *dims) + assert field.axes_types == {"c": "displacement"} + assert field.data.shape == (ndim, *size[::-1]) + itk_order = [dim for dim in ("x", "y", "z") if dim in dims] + for dim in dims: + assert field.scale[dim] == spacing[itk_order.index(dim)] + assert field.translation[dim] == origin[itk_order.index(dim)] + + rebuilt = _native( + ngff_transform_to_itk_transform(transform, dims, fields={"warp": field}) + ) + for point in _points_inside(original): + np.testing.assert_allclose( + _transform_point(rebuilt, point), _transform_point(original, point) + ) + + +def test_components_follow_dims_order(): + # One vector (dx=7, dy=9) at ITK index x=2, y=1. RFC-5 components follow + # the axes of dims, so a yx field reads (9, 7) at [y=1, x=2] and an xy + # field reads (7, 9) at [x=2, y=1]. + original = _field_transform((3, 2), (0.5, 2.0), (10.0, 20.0)) + view = itk.array_view_from_image(original.GetDisplacementField()) + view[:] = 0.0 + view[1, 2, :] = [7.0, 9.0] + + _, yx = itk_displacement_field_to_ngff_transform(original, ("y", "x"), path="w") + _, xy = itk_displacement_field_to_ngff_transform(original, ("x", "y"), path="w") + + assert yx.data.shape == (2, 2, 3) + np.testing.assert_array_equal(np.asarray(yx.data)[:, 1, 2], [9.0, 7.0]) + assert yx.scale == {"c": 1.0, "y": 2.0, "x": 0.5} + assert yx.translation == {"c": 0.0, "y": 20.0, "x": 10.0} + assert xy.data.shape == (2, 3, 2) + np.testing.assert_array_equal(np.asarray(xy.data)[:, 2, 1], [7.0, 9.0]) + assert xy.scale == {"c": 1.0, "x": 0.5, "y": 2.0} + assert np.count_nonzero(np.asarray(yx.data)) == 2 + + +def test_every_input_form_gives_the_same_field(): + import itkwasm + + original = _field_transform((4, 3), (1.0, 1.5), (2.0, -1.0), seed=3) + image = original.GetDisplacementField() + image_dict = itk.dict_from_image(image) + wasm_image = itkwasm.Image( + **{ + key: image_dict[key] + for key in ( + "imageType", + "name", + "origin", + "spacing", + "direction", + "size", + "data", + ) + } + ) + transform_dict = itk.dict_from_transform(original) + wasm_transform = itkwasm.Transform( + transformType=itkwasm.TransformType( + transformParameterization=itkwasm.TransformParameterizations.DisplacementField, + parametersValueType=itkwasm.FloatTypes.Float64, + inputDimension=2, + outputDimension=2, + ), + numberOfFixedParameters=len(transform_dict["fixedParameters"]), + numberOfParameters=len(transform_dict["parameters"]), + fixedParameters=np.asarray(transform_dict["fixedParameters"]), + parameters=np.asarray(transform_dict["parameters"]), + ) + + reference = itk_displacement_field_to_ngff_transform(original, ("y", "x"), path="w") + for candidate in (image, wasm_image, wasm_transform, [wasm_transform]): + transform, field = itk_displacement_field_to_ngff_transform( + candidate, ("y", "x"), path="w" + ) + assert transform == reference[0] + assert tuple(field.dims) == tuple(reference[1].dims) + assert field.scale == reference[1].scale + assert field.translation == reference[1].translation + np.testing.assert_array_equal( + np.asarray(field.data), np.asarray(reference[1].data) + ) + + +def test_store_round_trip(tmp_path): + # The documented layout: the field in a subgroup, then the image whose + # transform points at it. Reading both back and converting reproduces the + # ITK transform, with the component axis read from the field's metadata. + size, spacing, origin = (5, 4, 3), (0.5, 2.0, 1.5), (10.0, 20.0, -3.0) + dims = CANONICAL[3] + original = _field_transform(size, spacing, origin, seed=5) + transform, field = itk_displacement_field_to_ngff_transform( + original, dims, path="displacement_field" + ) + image = to_ngff_image(np.zeros(size[::-1], dtype=np.float32), dims=list(dims)) + multiscales = to_multiscales(image, scale_factors=[]) + intrinsic = multiscales.metadata.intrinsic_coordinate_system.name + transform.input = CoordinateSystemIdentifier(name=intrinsic) + transform.output = CoordinateSystemIdentifier(name=intrinsic) + multiscales.metadata.coordinateTransformations = [transform] + + store = tmp_path / "warped.ome.zarr" + to_ome_zarr( + str(store / transform.path), + to_multiscales(field, scale_factors=[]), + version="0.6", + ) + to_ome_zarr(str(store), multiscales, version="0.6", overwrite=False) + + imported = from_ome_zarr(str(store)) + read_transform = imported.metadata.coordinateTransformations[0] + assert isinstance(read_transform, Displacements) + read_field = from_ome_zarr(str(store / read_transform.path)) + axes = read_field.metadata.intrinsic_coordinate_system.axes + assert [axis.type for axis in axes] == ["displacement", "space", "space", "space"] + + rebuilt = _native( + ngff_transform_to_itk_transform( + read_transform, dims, fields={read_transform.path: read_field} + ) + ) + for point in _points_inside(original): + np.testing.assert_allclose( + _transform_point(rebuilt, point), _transform_point(original, point) + ) + + +def _frame_image(size, spacing, origin, orientations): + """A geometry-only image on the grid the field is sampled on.""" + dims = CANONICAL[len(size)] + itk_order = [dim for dim in ("x", "y", "z") if dim in dims] + return NgffImage( + data=da.zeros(size[::-1], dtype=np.uint8), + dims=list(dims), + scale={dim: spacing[itk_order.index(dim)] for dim in dims}, + translation={dim: origin[itk_order.index(dim)] for dim in dims}, + axes_orientations=orientations, + ) + + +def _phi(image, point_itk, itk_dims): + """An image's intrinsic point to ITK physical space, ``D (q - o) + o``.""" + from ngff_zarr.itk_transform_resample_bounding_box import _itk_direction + + direction = _itk_direction(image, itk_dims) + origin = np.array([image.translation[dim] for dim in itk_dims]) + return direction @ (point_itk - origin) + origin + + +@pytest.mark.parametrize("moving_orientation", ["same", "none"]) +def test_frames_are_applied_point_by_point(moving_orientation): + # phi_out(q + d(q)) must equal T(phi_in(q)) at every grid point q, whether + # the two images share a frame (d = D^-1 v) or not (the general formula). + size, spacing, origin = (4, 3, 5), (1.0, 2.0, 0.5), (5.0, -2.0, 8.0) + dims = CANONICAL[3] + itk_dims = ["x", "y", "z"] + fixed = _frame_image(size, spacing, origin, RAS) + moving = _frame_image( + size, spacing, (1.0, 1.0, 1.0), RAS if moving_orientation == "same" else None + ) + from ngff_zarr.itk_transform_resample_bounding_box import _itk_direction + + direction_in = _itk_direction(fixed, itk_dims) + assert not np.allclose(direction_in, np.eye(3)) + original = _field_transform(size, spacing, origin, direction=direction_in, seed=7) + + transform, field = itk_displacement_field_to_ngff_transform( + original, dims, path="warp", fixed=fixed, moving=moving + ) + assert field.axes_orientations == RAS + + data = np.asarray(field.data) + for index in [(0, 0, 0), (1, 2, 3), (4, 1, 0), (2, 2, 2)]: + q = np.array( + [ + field.translation[dim] + field.scale[dim] * i + for dim, i in zip(dims, index) + ] + ) + d = data[(slice(None), *index)] + q_itk, d_itk = q[::-1], d[::-1] + expected = _transform_point(original, _phi(fixed, q_itk, itk_dims)) + np.testing.assert_allclose(_phi(moving, q_itk + d_itk, itk_dims), expected) + + rebuilt = _native( + ngff_transform_to_itk_transform( + transform, dims, fields={"warp": field}, fixed=fixed, moving=moving + ) + ) + for point in _points_inside(original): + np.testing.assert_allclose( + _transform_point(rebuilt, point), _transform_point(original, point) + ) + + +def test_grid_direction_must_match_the_fixed_image(): + size, spacing, origin = (4, 3, 5), (1.0, 1.0, 1.0), (0.0, 0.0, 0.0) + flipped = _field_transform( + size, spacing, origin, direction=np.diag([-1.0, -1.0, 1.0]) + ) + + with pytest.raises(ValueError, match="non-identity direction"): + itk_displacement_field_to_ngff_transform(flipped, CANONICAL[3], path="w") + + fixed = _frame_image(size, spacing, origin, None) + with pytest.raises(ValueError, match="not oriented like the fixed image"): + itk_displacement_field_to_ngff_transform( + flipped, CANONICAL[3], path="w", fixed=fixed, moving=fixed + ) + + +def test_refusals(): + original = _field_transform((3, 2), (1.0, 1.0), (0.0, 0.0)) + transform, field = itk_displacement_field_to_ngff_transform( + original, ("y", "x"), path="w" + ) + + with pytest.raises(ValueError, match="spatial axes only"): + itk_displacement_field_to_ngff_transform(original, ("c", "y", "x"), path="w") + with pytest.raises(ValueError, match="name 3 axes"): + itk_displacement_field_to_ngff_transform(original, ("z", "y", "x"), path="w") + with pytest.raises(ValueError, match="pass both fixed and moving"): + itk_displacement_field_to_ngff_transform( + original, ("y", "x"), path="w", fixed=field + ) + with pytest.raises(ValueError, match="transform list of 2 entries"): + itk_displacement_field_to_ngff_transform( + [original, original], ("y", "x"), path="w" + ) + with pytest.raises( + NotImplementedError, match="itk_displacement_field_to_ngff_transform" + ): + itk_transform_to_ngff_transform(original, ("y", "x")) + with pytest.raises(ValueError, match="fields="): + ngff_transform_to_itk_transform(transform, ("y", "x")) + with pytest.raises(ValueError, match="component axis first"): + ngff_transform_to_itk_transform(transform, ("x", "y"), fields={"w": field}) + untyped = NgffImage( + data=field.data, + dims=field.dims, + scale=field.scale, + translation=field.translation, + ) + with pytest.raises(ValueError, match="exactly one axis of type 'displacement'"): + ngff_transform_to_itk_transform(transform, ("y", "x"), fields={"w": untyped}) + + +def test_interpolation_other_than_linear_warns(): + original = _field_transform((3, 2), (1.0, 1.0), (0.0, 0.0)) + _, field = itk_displacement_field_to_ngff_transform(original, ("y", "x"), path="w") + nearest = Displacements(path="w", interpolation="nearest") + + with pytest.warns(UserWarning, match="'nearest' interpolation"): + rebuilt = _native( + ngff_transform_to_itk_transform(nearest, ("y", "x"), fields={"w": field}) + ) + np.testing.assert_allclose( + _transform_point(rebuilt, (1.0, 1.0)), _transform_point(original, (1.0, 1.0)) + ) + + +def test_single_precision_is_preserved(): + from itkwasm import FloatTypes + + original = _field_transform((3, 2), (1.0, 1.0), (0.0, 0.0), value_type=itk.F) + _, field = itk_displacement_field_to_ngff_transform(original, ("y", "x"), path="w") + assert field.data.dtype == np.float32 + + (entry,) = ngff_transform_to_itk_transform( + Displacements(path="w", interpolation="linear"), ("y", "x"), fields={"w": field} + ) + assert entry.transformType.parametersValueType == FloatTypes.Float32 + assert entry.parameters.dtype == np.float32 diff --git a/py/test/test_itk_transform_to_ngff_transform.py b/py/test/test_itk_transform_to_ngff_transform.py index f1681f8c..89220dd5 100644 --- a/py/test/test_itk_transform_to_ngff_transform.py +++ b/py/test/test_itk_transform_to_ngff_transform.py @@ -381,13 +381,24 @@ def test_non_linear_transform_is_rejected_as_an_itkwasm_entry(): from ngff_zarr.itk_transform_resample_bounding_box import _as_itk_transform_list displacement = _displacement_field_transform(itk) - with pytest.raises(NotImplementedError, match="only linear"): + # A displacement field is refused with a pointer to its own conversion, + # on both paths alike. + with pytest.raises( + NotImplementedError, match="itk_displacement_field_to_ngff_transform" + ): itk_transform_to_ngff_transform(displacement, ("y", "x")) entries = _as_itk_transform_list(displacement) - with pytest.raises(NotImplementedError, match="only linear"): + with pytest.raises( + NotImplementedError, match="itk_displacement_field_to_ngff_transform" + ): itk_transform_to_ngff_transform(entries, ("y", "x")) + # Any other deformation gets the generic refusal on both paths. + bspline = _itkwasm_entry("BSpline", np.zeros(32), np.zeros(8), dimension=2) + with pytest.raises(NotImplementedError, match="only linear"): + itk_transform_to_ngff_transform([bspline], ("y", "x")) + def test_non_linear_transform_is_rejected_without_itk(monkeypatch): """Refusing a deformation must not depend on the optional ``itk`` extra.""" @@ -407,8 +418,13 @@ def no_itk(name, *args, **kwargs): monkeypatch.setattr(builtins, "__import__", no_itk) - with pytest.raises(NotImplementedError, match="describes a deformation"): + with pytest.raises( + NotImplementedError, match="itk_displacement_field_to_ngff_transform" + ): itk_transform_to_ngff_matrix(entries, ("y", "x")) + bspline = _itkwasm_entry("BSpline", np.zeros(32), np.zeros(8), dimension=2) + with pytest.raises(NotImplementedError, match="describes a deformation"): + itk_transform_to_ngff_matrix([bspline], ("y", "x")) def test_probing_refuses_a_transform_that_is_not_affine(): From 954d094dfaec9a86d2b37cd230f431de24041053 Mon Sep 17 00:00:00 2001 From: Valentin Boussot Date: Tue, 25 Aug 2026 01:29:36 +0200 Subject: [PATCH 06/11] feat(ts): convert ITK displacement fields to and from RFC-5 displacements The TypeScript counterpart of the Python conversion, with the same two outputs and the same rules: `itkDisplacementFieldToNgffTransform` takes an ITK-Wasm `DisplacementField` transform or a vector `Image` and returns the `displacements` transform with the field as an `NgffImage`, components in `dims` order, component axis first; `ngffDisplacementFieldToItkTransform` takes the transform and its field, an `NgffImage` or the `NgffMultiscales` read from the transform's path, and returns a one-entry `DisplacementField` list. Both are async, since the field is written to and read from a Zarr array. Frames change per grid point with the formula the Python module documents, and the field's grid must be oriented like the fixed image. The generic converters point a displacement field at these functions. TypeScript has no `itk` to evaluate a field against, so the tests anchor on arithmetic: at every grid point, phi_out(q + d(q)) equals phi_in(q) + v(q), and the reverse conversion gives back the entry it was built from. --- docs/itk.md | 11 +- ts/src/browser-mod.ts | 7 + ts/src/mod.ts | 7 + ts/src/utils/displacement_field_transform.ts | 643 ++++++++++++++++++ .../utils/itk_transform_to_ngff_transform.ts | 7 + .../utils/ngff_transform_to_itk_transform.ts | 7 + ts/test/displacement_field_transform_test.ts | 514 ++++++++++++++ .../itk_transform_to_ngff_transform_test.ts | 27 +- 8 files changed, 1209 insertions(+), 14 deletions(-) create mode 100644 ts/src/utils/displacement_field_transform.ts create mode 100644 ts/test/displacement_field_transform_test.ts diff --git a/docs/itk.md b/docs/itk.md index c835a20f..8e077b70 100644 --- a/docs/itk.md +++ b/docs/itk.md @@ -425,10 +425,13 @@ would move the image. Coming back the other way, non-spatial axes are left untransformed. In the TypeScript package the equivalents are `ngffTransformToItkTransform`, -`itkTransformToNgffTransform` and `itkTransformToNgffMatrix`. TypeScript has no -`itk` package to fall back on, so only parameterizations that carry a matrix -(`Identity`, `Translation`, `Scale`, `Affine`) convert there; angle- and -quaternion-based ones must be converted to an affine first. +`itkTransformToNgffTransform` and `itkTransformToNgffMatrix`, and for fields +`itkDisplacementFieldToNgffTransform` and `ngffDisplacementFieldToItkTransform`, +both async since the field is read from and written to a Zarr array. TypeScript +has no `itk` package to fall back on, so only parameterizations that carry a +matrix (`Identity`, `Translation`, `Scale`, `Affine`) or a field +(`DisplacementField`) convert there; angle- and quaternion-based ones must be +converted to an affine first. ## TypeScript diff --git a/ts/src/browser-mod.ts b/ts/src/browser-mod.ts index 111de642..29599300 100644 --- a/ts/src/browser-mod.ts +++ b/ts/src/browser-mod.ts @@ -34,6 +34,13 @@ export { type NgffMatrixAndOffset, } from "./utils/itk_transform_to_ngff_transform.ts"; export { ngffTransformToItkTransform } from "./utils/ngff_transform_to_itk_transform.ts"; +export { + type FieldFrames, + type ItkDisplacementFieldOptions, + itkDisplacementFieldToNgffTransform, + type NgffDisplacementField, + ngffDisplacementFieldToItkTransform, +} from "./utils/displacement_field_transform.ts"; export { dataTypeToComponentType, ngffImageToItkImage, diff --git a/ts/src/mod.ts b/ts/src/mod.ts index b5a7ebba..bb88c0ff 100644 --- a/ts/src/mod.ts +++ b/ts/src/mod.ts @@ -75,6 +75,13 @@ export { type NgffMatrixAndOffset, } from "./utils/itk_transform_to_ngff_transform.ts"; export { ngffTransformToItkTransform } from "./utils/ngff_transform_to_itk_transform.ts"; +export { + type FieldFrames, + type ItkDisplacementFieldOptions, + itkDisplacementFieldToNgffTransform, + type NgffDisplacementField, + ngffDisplacementFieldToItkTransform, +} from "./utils/displacement_field_transform.ts"; export { fromZarrAttrsV04, fromZarrAttrsV05, diff --git a/ts/src/utils/displacement_field_transform.ts b/ts/src/utils/displacement_field_transform.ts new file mode 100644 index 00000000..d95dac84 --- /dev/null +++ b/ts/src/utils/displacement_field_transform.ts @@ -0,0 +1,643 @@ +// SPDX-FileCopyrightText: Copyright (c) Fideus Labs LLC +// SPDX-License-Identifier: MIT +/** + * Bridge ITK displacement fields and RFC-5 `displacements` transformations. + * + * A displacement field is a transformation and an array at once. ITK keeps the + * array inside the transform, as a vector image with its own grid; RFC-5 keeps + * it in the store, as a multiscale image the `displacements` entry points at + * by `path`. So the two conversions here are the only ones in this package + * whose result has two parts, and they stay pure: nothing is read from or + * written to a store. The caller writes the field next to its image, and + * loads it back, with `toOmeZarr` and `fromOmeZarr`. + * + * The same two conventions as the affine conversion apply, plus one. + * + * Axis order. ITK orders a vector's components fastest-axis-first, x then y + * then z, and lays the field out as `[z][y][x][component]`. RFC-5 says the + * `i`-th component of the field refers to the `i`-th axis of the output + * coordinate system, so components follow `dims`; the same permutation as for + * an affine's matrix is applied to the component axis. + * + * Frames. An ITK vector is a difference of two physical points. An RFC-5 + * displacement is a difference of two intrinsic points, `d = q' - q`. With + * `phi(q) = D (q - o) + o` relating each image's intrinsic system to ITK + * physical space (`D` from RFC-4 orientation, `o` the translation), a vector + * `v` sampled at the input grid point `q` becomes + * + * d(q) = D_out^-1 (v + D_in (q - o_in) + o_in - o_out) + o_out - q + * + * which collapses to `d = D^-1 v` when both images share a frame. The field's + * own grid follows `phi_in^-1`, so `translation = D_in^-1 (o_f - o_in) + o_in` + * and the spacing is unchanged. + * + * Grid direction. RFC-5 maps the field's array coordinates to the input system + * with the field's own `coordinateTransformations`, which this package writes + * as a scale and a translation. That can only express a grid oriented like the + * input image, so a field whose direction matrix differs from the fixed + * image's (the identity when no frames are given) is refused rather than + * written with a mapping a reader would misread. Registrations sample the + * field on the fixed grid, so the common case is exact. + */ + +import * as zarr from "zarrita"; +import type { Image, Transform, TransformList } from "itk-wasm"; +import { NgffImage } from "../types/ngff_image.ts"; +import type { NgffMultiscales } from "../types/multiscales.ts"; +import type { Displacements } from "../types/zarr_metadata.ts"; +import { toNgffImage } from "../io/to_ngff_image.ts"; +import { directionRows, frameGeometry, itkDirection } from "./itk_direction.ts"; + +const SPATIAL_DIMS = ["x", "y", "z"]; + +/** The name given to the component axis of a converted field. */ +const COMPONENT_DIM = "c"; + +type Vectors = Float32Array | Float64Array; + +/** A field in ITK conventions: `[z][y][x][component]` on an ITK-order grid. */ +interface ItkField { + vectors: Vectors; + size: number[]; + origin: number[]; + spacing: number[]; + direction: number[][]; +} + +interface Frames { + directionIn: number[][]; + directionOut: number[][]; + originIn: number[]; + originOut: number[]; +} + +/** The fixed and moving images a converted field relates. */ +export interface FieldFrames { + /** The image whose grid the field is sampled on and maps from. */ + fixed?: NgffImage; + /** The image the field maps into. */ + moving?: NgffImage; +} + +export interface ItkDisplacementFieldOptions extends FieldFrames { + /** Where the field will be written, relative to the image's group. */ + path: string; +} + +/** The two parts a converted displacement field is made of. */ +export interface NgffDisplacementField { + transform: Displacements; + field: NgffImage; +} + +function checkDims(dims: string[]): void { + if (dims.length === 0 || dims.some((dim) => !SPATIAL_DIMS.includes(dim))) { + throw new Error( + `a displacement field is defined on spatial axes only; got dims ` + + `[${dims.join(", ")}]. ITK has no notion of a time or channel axis, ` + + "and RFC-5 requires one field dimension per input axis.", + ); + } + if (new Set(dims).size !== dims.length) { + throw new Error(`dims [${dims.join(", ")}] name an axis twice`); + } +} + +function itkAxisOrder(dims: string[]): string[] { + return SPATIAL_DIMS.filter((dim) => dims.includes(dim)); +} + +function identity(dimension: number): number[][] { + return Array.from( + { length: dimension }, + (_, row) => Array.from({ length: dimension }, (_, col) => +(row === col)), + ); +} + +function allClose(left: number[][], right: number[][]): boolean { + return left.every((row, i) => + row.every((value, j) => Math.abs(value - right[i][j]) <= 1e-9) + ); +} + +function transposed(matrix: number[][]): number[][] { + return matrix[0].map((_, col) => matrix.map((row) => row[col])); +} + +function matvec(matrix: number[][], vector: number[]): number[] { + return matrix.map((row) => + row.reduce((sum, value, col) => sum + value * vector[col], 0) + ); +} + +function frames( + fixed: NgffImage | undefined, + moving: NgffImage | undefined, + dims: string[], +): Frames | undefined { + if ((fixed === undefined) !== (moving === undefined)) { + throw new Error("pass both fixed and moving, or neither"); + } + if (fixed === undefined || moving === undefined) return undefined; + const geometry = frameGeometry(fixed, moving, itkAxisOrder(dims)); + return { + directionIn: geometry.directionFixed, + directionOut: geometry.directionMoving, + originIn: geometry.originFixed, + originOut: geometry.originMoving, + }; +} + +/** The field a vector `Image` or a `DisplacementField` transform holds. */ +function decodeField(input: Transform | TransformList | Image): ItkField { + if (Array.isArray(input)) { + if (input.length !== 1) { + throw new Error( + `expected a single displacement field, got a transform list of ` + + `${input.length} entries. A registration that chains an affine ` + + "and a field is not converted as one transform.", + ); + } + return decodeField(input[0]); + } + if ("imageType" in input) return decodeImage(input); + if ("transformType" in input) return decodeTransform(input); + throw new Error( + "unsupported displacement field input. Expected an ITK-Wasm vector " + + "Image or an ITK-Wasm 'DisplacementField' transform.", + ); +} + +function decodeImage(image: Image): ItkField { + const dimension = image.imageType.dimension; + const components = image.imageType.components; + if (components !== dimension) { + throw new Error( + `a displacement field over ${dimension} dimensions needs ` + + `${dimension} components per pixel; got ${components}`, + ); + } + if (image.data === null) throw new Error("the field image holds no data"); + const size = [...image.size]; + const expected = size.reduce((a, b) => a * b, 1) * dimension; + if (image.data.length !== expected) { + throw new Error( + `a field of size [${size.join(", ")}] with ${dimension} components ` + + `holds ${expected} values; got ${image.data.length}`, + ); + } + return { + vectors: asVectors(image.data as ArrayLike), + size, + origin: [...image.origin], + spacing: [...image.spacing], + direction: directionRows( + Float64Array.from(image.direction as ArrayLike), + dimension, + ), + }; +} + +function decodeTransform(transform: Transform): ItkField { + const parameterization = String( + transform.transformType.transformParameterization, + ); + if (parameterization !== "DisplacementField") { + throw new Error( + `expected an ITK-Wasm 'DisplacementField' transform, got ` + + `'${parameterization}'. Linear transforms are converted by ` + + "itkTransformToNgffTransform.", + ); + } + const dimension = transform.transformType.inputDimension; + const fixed = Array.from(transform.fixedParameters as ArrayLike); + const expected = 3 * dimension + dimension * dimension; + if (fixed.length !== expected) { + throw new Error( + `an ITK-Wasm 'DisplacementField' transform of dimension ${dimension} ` + + `packs size, origin, spacing and direction into ${expected} fixed ` + + `parameters; got ${fixed.length}`, + ); + } + const size = fixed.slice(0, dimension).map((value) => Math.round(value)); + const count = size.reduce((a, b) => a * b, 1) * dimension; + const parameters = transform.parameters as ArrayLike; + if (parameters.length !== count) { + throw new Error( + `an ITK-Wasm 'DisplacementField' transform over a grid of size ` + + `[${size.join(", ")}] holds ${count} parameters; got ` + + `${parameters.length}`, + ); + } + return { + vectors: asVectors(parameters), + size, + origin: fixed.slice(dimension, 2 * dimension), + spacing: fixed.slice(2 * dimension, 3 * dimension), + direction: directionRows( + Float64Array.from(fixed.slice(3 * dimension)), + dimension, + ), + }; +} + +function asVectors(values: ArrayLike): Vectors { + return values instanceof Float32Array ? values : Float64Array.from(values); +} + +/** Multipliers turning an ITK-order index into a flat `[z][y][x]` offset. */ +function strides(size: number[]): number[] { + const result = new Array(size.length); + let step = 1; + for (let axis = 0; axis < size.length; axis++) { + result[axis] = step; + step *= size[axis]; + } + return result; +} + +/** + * Convert an ITK displacement field to an RFC-5 `displacements` transform. + * + * The result has two parts, because the field is an array: the transform + * metadata, which names `path`, and the field itself as an image to be written + * at that path. Write the field first, into a subgroup of the store the image + * goes to, then the image. + * + * @param input An ITK-Wasm `DisplacementField` transform, a one-entry list of + * it, or a vector ITK-Wasm `Image` holding the field directly, the form a + * warp comes in from most registration tools. The field maps *fixed* points + * into *moving* space. + * @param dims The spatial axis names of the input coordinate system, in RFC-5 + * (Zarr) order. The field must be defined on these axes and no others. + * @param options `path`, where the field will be written relative to the + * image's group, and the optional `fixed` and `moving` images. Passing both + * re-expresses the vectors on the images' intrinsic coordinate systems, + * including the direction matrix derived from RFC-4 anatomical orientation, + * and gives the field the fixed image's orientation and units. Omitting + * them is exact only when neither image carries an anatomical orientation. + * @returns The `displacements` transform, with `interpolation` set to + * `linear` (ITK's interpolator for a field), and the field as an + * `NgffImage` whose first axis carries the components, `type: + * "displacement"`, followed by `dims`. + * @throws If the field's grid is not oriented like the fixed image (the + * identity without frames), which the field's scale and translation could + * not express; resample the field onto the fixed grid first. Also for a + * field whose dimensionality does not match `dims`. + */ +export async function itkDisplacementFieldToNgffTransform( + input: Transform | TransformList | Image, + dims: string[], + options: ItkDisplacementFieldOptions, +): Promise { + checkDims(dims); + const { vectors, size, origin, spacing, direction } = decodeField(input); + const dimension = size.length; + if (dimension !== dims.length) { + throw new Error( + `the field has ${dimension} components over ${dimension} dimensions, ` + + `but dims [${dims.join(", ")}] name ${dims.length} axes`, + ); + } + const itkDims = itkAxisOrder(dims); + const frame = frames(options.fixed, options.moving, dims); + + let gridOrigin: number[]; + let toIntrinsic: (vector: number[], point: number[]) => number[]; + if (frame === undefined) { + if (!allClose(direction, identity(dimension))) { + throw new Error( + "the field's grid has a non-identity direction matrix, which its " + + "scale and translation cannot express. Pass the fixed and moving " + + "images so it is read against the fixed image's orientation, or " + + "resample the field onto the fixed grid.", + ); + } + gridOrigin = origin; + toIntrinsic = (vector) => vector; + } else { + if (!allClose(direction, frame.directionIn)) { + throw new Error( + "the field's grid is not oriented like the fixed image: its " + + `direction is ${JSON.stringify(direction)} where the fixed image ` + + `gives ${JSON.stringify(frame.directionIn)}. Resample the field ` + + "onto the fixed grid first.", + ); + } + const inverseIn = transposed(frame.directionIn); + const inverseOut = transposed(frame.directionOut); + gridOrigin = matvec( + inverseIn, + origin.map((value, i) => value - frame.originIn[i]), + ).map((value, i) => value + frame.originIn[i]); + const shared = allClose(frame.directionIn, frame.directionOut) && + frame.originIn.every((value, i) => + Math.abs(value - frame.originOut[i]) <= 1e-9 + ); + toIntrinsic = shared + ? (vector) => matvec(inverseOut, vector) + : (vector, point) => { + const rotated = matvec( + frame.directionIn, + point.map((value, i) => value - frame.originIn[i]), + ); + const shifted = vector.map( + (value, i) => + value + rotated[i] + frame.originIn[i] - frame.originOut[i], + ); + return matvec(inverseOut, shifted).map( + (value, i) => value + frame.originOut[i] - point[i], + ); + }; + } + + // (c, *dims) with components in dims order, C-contiguous. + const shape = dims.map((dim) => size[itkDims.indexOf(dim)]); + const voxels = size.reduce((a, b) => a * b, 1); + const out = vectors instanceof Float32Array + ? new Float32Array(voxels * dimension) + : new Float64Array(voxels * dimension); + const itkStrides = strides(size); + const dimsStrides = new Array(dimension); + let step = 1; + for (let axis = dimension - 1; axis >= 0; axis--) { + dimsStrides[axis] = step; + step *= shape[axis]; + } + const componentOf = dims.map((dim) => itkDims.indexOf(dim)); + const index = new Array(dimension); + const vector = new Array(dimension); + const point = new Array(dimension); + for (let voxel = 0; voxel < voxels; voxel++) { + let offset = 0; + for (let axis = 0; axis < dimension; axis++) { + index[axis] = Math.floor(voxel / itkStrides[axis]) % size[axis]; + point[axis] = gridOrigin[axis] + spacing[axis] * index[axis]; + vector[axis] = vectors[voxel * dimension + axis]; + } + const displacement = toIntrinsic(vector, point); + for (let axis = 0; axis < dimension; axis++) { + offset += index[componentOf[axis]] * dimsStrides[axis]; + } + for (let component = 0; component < dimension; component++) { + out[component * voxels + offset] = displacement[componentOf[component]]; + } + } + + const scale: Record = { [COMPONENT_DIM]: 1.0 }; + const translation: Record = { [COMPONENT_DIM]: 0.0 }; + for (const dim of dims) { + scale[dim] = spacing[itkDims.indexOf(dim)]; + translation[dim] = gridOrigin[itkDims.indexOf(dim)]; + } + const name = options.path.replace(/\/+$/, "").split("/").pop() || + "displacement_field"; + const image = await toNgffImage(out, { + dims: [COMPONENT_DIM, ...dims], + shape: [dimension, ...shape], + scale, + translation, + name, + axesTypes: { [COMPONENT_DIM]: "displacement" }, + }); + + const fixed = options.fixed; + const orientations = fixed?.axesOrientations + ? Object.fromEntries( + dims + .filter((dim) => fixed.axesOrientations![dim] !== undefined) + .map((dim) => [dim, fixed.axesOrientations![dim]]), + ) + : undefined; + const units = fixed?.axesUnits + ? Object.fromEntries( + dims + .filter((dim) => fixed.axesUnits![dim] !== undefined) + .map((dim) => [dim, fixed.axesUnits![dim]]), + ) + : undefined; + const field = new NgffImage({ + data: image.data, + dims: image.dims, + scale: image.scale, + translation: image.translation, + name: image.name, + axesUnits: units && Object.keys(units).length > 0 ? units : undefined, + axesOrientations: orientations && Object.keys(orientations).length > 0 + ? orientations + : undefined, + axesTypes: image.axesTypes, + computedCallbacks: undefined, + }); + return { + transform: { + type: "displacements", + path: options.path, + interpolation: "linear", + }, + field, + }; +} + +/** + * Convert an RFC-5 `displacements` transform and its field to ITK. + * + * The counterpart of {@link itkDisplacementFieldToNgffTransform}. The field + * is the image stored at `transform.path`; load it with + * `fromOmeZarr(`${store}/${transform.path}`)`. + * + * @param transform The `displacements` transform. + * @param field The field image: an `NgffImage` whose component axis is the one + * with `axesTypes` `displacement`, followed by `dims` in order; or an + * `NgffMultiscales`, whose finest level is used and whose metadata names the + * component axis. + * @param dims The spatial axis names of the input coordinate system, in RFC-5 + * (Zarr) order. + * @param frames The fixed and moving images the field relates; see + * {@link itkDisplacementFieldToNgffTransform}. The field's own orientation, + * if any, must be the fixed image's. + * @returns A single-entry ITK-Wasm `TransformList` of parameterization + * `DisplacementField`. + * @throws If the field's axes are not the component axis followed by `dims`, + * or if its orientation is not the fixed image's. + */ +export async function ngffDisplacementFieldToItkTransform( + transform: Displacements, + field: NgffImage | NgffMultiscales, + dims: string[], + frames_: FieldFrames = {}, +): Promise { + checkDims(dims); + let componentDims: string[]; + let image: NgffImage; + if ("images" in field && "metadata" in field) { + // A read multiscales keeps the axis types in its metadata, not on the + // image: the component axis is the one typed "displacement" there. + componentDims = field.metadata.axes + .filter((axis) => axis.type === "displacement") + .map((axis) => axis.name); + image = field.images[0]; + } else { + image = field; + componentDims = Object.entries(image.axesTypes ?? {}) + .filter(([, type]) => type === "displacement") + .map(([dim]) => dim); + } + if (componentDims.length !== 1) { + throw new Error( + "the field image must have exactly one axis of type 'displacement' " + + "(axesTypes on an NgffImage, the axes metadata of a multiscales); " + + `got [${componentDims.join(", ")}] on dims [${image.dims.join(", ")}]`, + ); + } + const expectedDims = [componentDims[0], ...dims]; + if ( + image.dims.length !== expectedDims.length || + image.dims.some((dim, i) => dim !== expectedDims[i]) + ) { + throw new Error( + `the field's dims are [${image.dims.join(", ")}]; a displacements ` + + `transform over dims [${dims.join(", ")}] needs ` + + `[${expectedDims.join(", ")}]: the component axis first, then the ` + + "input axes in order", + ); + } + + const chunk = await zarr.get(image.data, null); + const data = chunk.data as ArrayLike; + const shape = chunk.shape; + const dimension = dims.length; + if (shape[0] !== dimension) { + throw new Error( + `the field holds ${shape[0]} components per point, but dims ` + + `[${dims.join(", ")}] name ${dimension} axes`, + ); + } + + const itkDims = itkAxisOrder(dims); + const size = itkDims.map((dim) => shape[1 + dims.indexOf(dim)]); + const spacing = itkDims.map((dim) => image.scale[dim]); + const translation = itkDims.map((dim) => image.translation[dim]); + const ownDirection = directionRows(itkDirection(image, itkDims), dimension); + + const frame = frames(frames_.fixed, frames_.moving, dims); + let origin: number[]; + let direction: number[][]; + let toPhysical: (displacement: number[], point: number[]) => number[]; + if (frame === undefined) { + if (!allClose(ownDirection, identity(dimension))) { + throw new Error( + "the field carries an anatomical orientation; pass the fixed and " + + "moving images so its grid is placed in their frame", + ); + } + origin = translation; + direction = identity(dimension); + toPhysical = (displacement) => displacement; + } else { + if ( + !allClose(ownDirection, identity(dimension)) && + !allClose(ownDirection, frame.directionIn) + ) { + throw new Error( + "the field's orientation is not the fixed image's: it gives " + + `${JSON.stringify(ownDirection)} where the fixed image gives ` + + `${JSON.stringify(frame.directionIn)}`, + ); + } + origin = matvec( + frame.directionIn, + translation.map((value, i) => value - frame.originIn[i]), + ).map((value, i) => value + frame.originIn[i]); + direction = frame.directionIn; + const shared = allClose(frame.directionIn, frame.directionOut) && + frame.originIn.every((value, i) => + Math.abs(value - frame.originOut[i]) <= 1e-9 + ); + toPhysical = shared + ? (displacement) => matvec(frame.directionOut, displacement) + : (displacement, point) => { + const moved = matvec( + frame.directionOut, + displacement.map((value, i) => value + point[i] - frame.originOut[i]), + ).map((value, i) => value + frame.originOut[i]); + const rotated = matvec( + frame.directionIn, + point.map((value, i) => value - frame.originIn[i]), + ); + return moved.map((value, i) => value - rotated[i] - frame.originIn[i]); + }; + } + + if ( + transform.interpolation !== undefined && + transform.interpolation !== "linear" + ) { + console.warn( + `the displacements transform asks for '${transform.interpolation}' ` + + "interpolation; ITK interpolates a displacement field linearly. " + + "RFC-5 leaves the choice to the consumer.", + ); + } + + const voxels = size.reduce((a, b) => a * b, 1); + const float32 = data instanceof Float32Array; + const parameters = float32 + ? new Float32Array(voxels * dimension) + : new Float64Array(voxels * dimension); + const itkStrides = strides(size); + const dimsShape = dims.map((dim) => size[itkDims.indexOf(dim)]); + const dimsStrides = new Array(dimension); + let step = 1; + for (let axis = dimension - 1; axis >= 0; axis--) { + dimsStrides[axis] = step; + step *= dimsShape[axis]; + } + const componentOf = dims.map((dim) => itkDims.indexOf(dim)); + const index = new Array(dimension); + const point = new Array(dimension); + const displacement = new Array(dimension); + for (let voxel = 0; voxel < voxels; voxel++) { + let offset = 0; + for (let axis = 0; axis < dimension; axis++) { + index[axis] = Math.floor(voxel / itkStrides[axis]) % size[axis]; + point[axis] = translation[axis] + spacing[axis] * index[axis]; + } + for (let axis = 0; axis < dimension; axis++) { + offset += index[componentOf[axis]] * dimsStrides[axis]; + } + // displacement[j] is the component along ITK axis j: dims order -> ITK. + for (let axis = 0; axis < dimension; axis++) { + displacement[componentOf[axis]] = data[axis * voxels + offset]; + } + const vector = toPhysical(displacement, point); + for (let axis = 0; axis < dimension; axis++) { + parameters[voxel * dimension + axis] = vector[axis]; + } + } + + const fixedParameters = new Float64Array( + 3 * dimension + dimension * dimension, + ); + fixedParameters.set(size, 0); + fixedParameters.set(origin, dimension); + fixedParameters.set(spacing, 2 * dimension); + fixedParameters.set(direction.flat(), 3 * dimension); + const itkTransform: Transform = { + transformType: { + transformParameterization: "DisplacementField", + parametersValueType: float32 ? "float32" : "float64", + inputDimension: dimension, + outputDimension: dimension, + }, + name: "DisplacementFieldTransform", + inputSpaceName: "", + outputSpaceName: "", + numberOfFixedParameters: fixedParameters.length, + numberOfParameters: parameters.length, + fixedParameters, + parameters, + metadata: new Map(), + } as unknown as Transform; + return [itkTransform]; +} diff --git a/ts/src/utils/itk_transform_to_ngff_transform.ts b/ts/src/utils/itk_transform_to_ngff_transform.ts index 9bdf0414..c9158740 100644 --- a/ts/src/utils/itk_transform_to_ngff_transform.ts +++ b/ts/src/utils/itk_transform_to_ngff_transform.ts @@ -257,6 +257,13 @@ export function itkTransformToNgffMatrix( const parameterization = String( entry.transformType.transformParameterization, ); + if (parameterization === "DisplacementField") { + throw new Error( + "a displacement field has no affine equivalent; convert it with " + + "itkDisplacementFieldToNgffTransform, which returns the " + + "'displacements' transform and the field to write next to the image", + ); + } if (parameterization === "Composite") { // A parameterless 'Composite' entry is ambiguous. The ITK-Wasm // pipeline writes one as a grouping header before the children, but diff --git a/ts/src/utils/ngff_transform_to_itk_transform.ts b/ts/src/utils/ngff_transform_to_itk_transform.ts index 3d45ce18..ce86a38f 100644 --- a/ts/src/utils/ngff_transform_to_itk_transform.ts +++ b/ts/src/utils/ngff_transform_to_itk_transform.ts @@ -287,6 +287,13 @@ export function ngffTransformToItkTransform( dims: string[], frames: { fixed?: NgffImage; moving?: NgffImage } = {}, ): TransformList { + if (transform.type === "displacements") { + throw new Error( + `the displacements transform points at '${transform.path}'; convert ` + + "it with ngffDisplacementFieldToItkTransform, passing the field " + + "loaded from that path", + ); + } let { matrix, offset } = ngffTransformToItkMatrix(transform, dims); const dimension = offset.length; diff --git a/ts/test/displacement_field_transform_test.ts b/ts/test/displacement_field_transform_test.ts new file mode 100644 index 00000000..7bc1800e --- /dev/null +++ b/ts/test/displacement_field_transform_test.ts @@ -0,0 +1,514 @@ +// SPDX-FileCopyrightText: Copyright (c) Fideus Labs LLC +// SPDX-License-Identifier: MIT +// ITK displacement fields to and from RFC-5 `displacements` transformations. +// +// TypeScript has no `itk` to evaluate a field against, so the anchors are +// arithmetic: a field defines T(p) = p + v at its grid points, and every +// converted field must satisfy phi_out(q + d(q)) = phi_in(q) + v(q) point by +// point. Layout and component order are pinned with one known vector, and +// the reverse conversion must give back the entry it was built from. + +import { assertAlmostEquals, assertEquals, assertRejects } from "@std/assert"; +import type { Image, Transform } from "itk-wasm"; +import * as zarr from "zarrita"; +import { + itkDisplacementFieldToNgffTransform, + itkTransformToNgffTransform, + ngffDisplacementFieldToItkTransform, + NgffImage, + ngffTransformToItkTransform, +} from "../src/mod.ts"; +import { type AnatomicalOrientation, RAS } from "../src/types/rfc4.ts"; +import type { Displacements } from "../src/types/zarr_metadata.ts"; +import { directionRows, itkDirection } from "../src/utils/itk_direction.ts"; + +const CANONICAL: Record = { + 2: ["y", "x"], + 3: ["z", "y", "x"], +}; + +/** A deterministic pseudo-random sequence, so a field is reproducible. */ +function noise(seed: number): () => number { + let state = seed >>> 0 || 1; + return () => { + state = (Math.imul(state, 1664525) + 1013904223) >>> 0; + return state / 0x100000000 * 6 - 3; + }; +} + +/** A field on a grid of `size` (ITK order), as a `DisplacementField` entry. */ +function fieldTransform( + size: number[], + spacing: number[], + origin: number[], + direction?: number[][], + seed = 0, + Values: typeof Float64Array | typeof Float32Array = Float64Array, +): Transform { + const dimension = size.length; + const voxels = size.reduce((a, b) => a * b, 1); + const next = noise(seed + 1); + const parameters = new Values(voxels * dimension); + for (let i = 0; i < parameters.length; i++) parameters[i] = next(); + const rows = direction ?? + Array.from( + { length: dimension }, + (_, r) => Array.from({ length: dimension }, (_, c) => +(r === c)), + ); + const fixed = new Float64Array([ + ...size, + ...origin, + ...spacing, + ...rows.flat(), + ]); + return { + transformType: { + transformParameterization: "DisplacementField", + parametersValueType: Values === Float32Array ? "float32" : "float64", + inputDimension: dimension, + outputDimension: dimension, + }, + name: "DisplacementFieldTransform", + inputSpaceName: "", + outputSpaceName: "", + numberOfFixedParameters: fixed.length, + numberOfParameters: parameters.length, + fixedParameters: fixed, + parameters, + metadata: new Map(), + } as unknown as Transform; +} + +/** The vector the entry holds at ITK index `index`, in ITK component order. */ +function vectorAt(entry: Transform, size: number[], index: number[]): number[] { + const dimension = size.length; + let flat = 0; + let stride = 1; + for (let axis = 0; axis < dimension; axis++) { + flat += index[axis] * stride; + stride *= size[axis]; + } + const parameters = entry.parameters as unknown as ArrayLike; + return Array.from( + { length: dimension }, + (_, c) => parameters[flat * dimension + c], + ); +} + +async function fieldData(field: NgffImage): Promise> { + return (await zarr.get(field.data, null)).data as ArrayLike; +} + +/** A geometry-only image on the grid a field is sampled on. */ +async function frameImage( + size: number[], + spacing: number[], + origin: number[], + orientations?: Record, +): Promise { + const dims = CANONICAL[size.length]; + const itkOrder = ["x", "y", "z"].filter((dim) => dims.includes(dim)); + const shape = dims.map((dim) => size[itkOrder.indexOf(dim)]); + const data = await zarr.create(zarr.root(new Map()).resolve("data"), { + shape, + chunk_shape: shape, + data_type: "uint8", + fill_value: 0, + }); + return new NgffImage({ + data, + dims, + scale: Object.fromEntries( + dims.map((dim) => [dim, spacing[itkOrder.indexOf(dim)]]), + ), + translation: Object.fromEntries( + dims.map((dim) => [dim, origin[itkOrder.indexOf(dim)]]), + ), + name: "image", + axesUnits: undefined, + axesOrientations: orientations, + computedCallbacks: undefined, + }); +} + +/** An image's intrinsic point to ITK physical space, `D (q - o) + o`. */ +function phi( + image: NgffImage, + pointItk: number[], + itkDims: string[], +): number[] { + const direction = directionRows(itkDirection(image, itkDims), itkDims.length); + const origin = itkDims.map((dim) => image.translation[dim]); + return direction.map((row, i) => + row.reduce((sum, value, j) => sum + value * (pointItk[j] - origin[j]), 0) + + origin[i] + ); +} + +function assertAllClose( + actual: ArrayLike, + expected: ArrayLike, +) { + assertEquals(actual.length, expected.length); + for (let i = 0; i < actual.length; i++) { + assertAlmostEquals(actual[i], expected[i], 1e-9); + } +} + +Deno.test("components follow dims order", async () => { + // One vector (dx=7, dy=9) at ITK index x=2, y=1. RFC-5 components follow + // the axes of dims, so a yx field reads (9, 7) at [y=1, x=2] and an xy + // field reads (7, 9) at [x=2, y=1]. + const entry = fieldTransform([3, 2], [0.5, 2.0], [10.0, 20.0]); + (entry.parameters as unknown as Float64Array).fill(0); + (entry.parameters as unknown as Float64Array).set([7, 9], (1 * 3 + 2) * 2); + + const yx = await itkDisplacementFieldToNgffTransform(entry, ["y", "x"], { + path: "w", + }); + const xy = await itkDisplacementFieldToNgffTransform(entry, ["x", "y"], { + path: "w", + }); + + assertEquals(yx.transform, { + type: "displacements", + path: "w", + interpolation: "linear", + }); + assertEquals(yx.field.dims, ["c", "y", "x"]); + assertEquals(yx.field.axesTypes, { c: "displacement" }); + assertEquals(yx.field.data.shape, [2, 2, 3]); + const yxData = await fieldData(yx.field); + // (c, y, x): component c at [1, 2] sits at c*6 + 1*3 + 2. + assertEquals([yxData[0 * 6 + 5], yxData[1 * 6 + 5]], [9, 7]); + assertEquals( + Array.from(yxData as ArrayLike).filter((v) => v !== 0).length, + 2, + ); + assertEquals(yx.field.scale, { c: 1, y: 2.0, x: 0.5 }); + assertEquals(yx.field.translation, { c: 0, y: 20.0, x: 10.0 }); + + assertEquals(xy.field.data.shape, [2, 3, 2]); + const xyData = await fieldData(xy.field); + // (c, x, y): component c at [2, 1] sits at c*6 + 2*2 + 1. + assertEquals([xyData[0 * 6 + 5], xyData[1 * 6 + 5]], [7, 9]); + assertEquals(xy.field.scale, { c: 1, x: 0.5, y: 2.0 }); +}); + +for (const ndim of [2, 3]) { + Deno.test(`round trip gives back the entry (${ndim}D)`, async () => { + const size = [5, 4, 3].slice(0, ndim); + const spacing = [0.5, 2.0, 1.5].slice(0, ndim); + const origin = [10.0, 20.0, -3.0].slice(0, ndim); + const dims = CANONICAL[ndim]; + const entry = fieldTransform(size, spacing, origin); + + const { transform, field } = await itkDisplacementFieldToNgffTransform( + entry, + dims, + { path: "warp" }, + ); + assertEquals(field.data.shape, [ndim, ...size.slice().reverse()]); + const itkOrder = ["x", "y", "z"].slice(0, ndim); + for (const dim of dims) { + assertEquals(field.scale[dim], spacing[itkOrder.indexOf(dim)]); + assertEquals(field.translation[dim], origin[itkOrder.indexOf(dim)]); + } + + const [back] = await ngffDisplacementFieldToItkTransform( + transform, + field, + dims, + ); + assertEquals( + String(back.transformType.transformParameterization), + "DisplacementField", + ); + assertAllClose( + back.fixedParameters as unknown as ArrayLike, + entry.fixedParameters as unknown as ArrayLike, + ); + assertAllClose( + back.parameters as unknown as ArrayLike, + entry.parameters as unknown as ArrayLike, + ); + }); +} + +Deno.test("a vector image converts like the transform holding it", async () => { + const size = [4, 3]; + const entry = fieldTransform(size, [1.0, 1.5], [2.0, -1.0], undefined, 3); + const image = { + imageType: { + dimension: 2, + componentType: "float64", + pixelType: "Vector", + components: 2, + }, + name: "warp", + origin: [2.0, -1.0], + spacing: [1.0, 1.5], + direction: new Float64Array([1, 0, 0, 1]), + size, + metadata: new Map(), + data: entry.parameters, + } as unknown as Image; + + const fromEntry = await itkDisplacementFieldToNgffTransform( + entry, + ["y", "x"], + { + path: "w", + }, + ); + const fromImage = await itkDisplacementFieldToNgffTransform( + image, + ["y", "x"], + { + path: "w", + }, + ); + const fromList = await itkDisplacementFieldToNgffTransform([entry], [ + "y", + "x", + ], { + path: "w", + }); + for (const candidate of [fromImage, fromList]) { + assertEquals(candidate.transform, fromEntry.transform); + assertEquals(candidate.field.dims, fromEntry.field.dims); + assertEquals(candidate.field.scale, fromEntry.field.scale); + assertEquals(candidate.field.translation, fromEntry.field.translation); + assertAllClose( + await fieldData(candidate.field), + await fieldData(fromEntry.field), + ); + } +}); + +for (const moving of ["same", "none"] as const) { + Deno.test(`frames are applied point by point (moving: ${moving})`, async () => { + // phi_out(q + d(q)) must equal phi_in(q) + v(q) at every grid point q, + // whether the two images share a frame (d = D^-1 v) or not. + const size = [4, 3, 5]; + const spacing = [1.0, 2.0, 0.5]; + const origin = [5.0, -2.0, 8.0]; + const dims = CANONICAL[3]; + const itkDims = ["x", "y", "z"]; + const fixed = await frameImage(size, spacing, origin, RAS); + const movingImage = await frameImage( + size, + spacing, + [1.0, 1.0, 1.0], + moving === "same" ? RAS : undefined, + ); + const directionIn = directionRows(itkDirection(fixed, itkDims), 3); + assertEquals(directionIn, [[-1, 0, 0], [0, -1, 0], [0, 0, 1]]); + const entry = fieldTransform(size, spacing, origin, directionIn, 7); + + const { transform, field } = await itkDisplacementFieldToNgffTransform( + entry, + dims, + { path: "warp", fixed, moving: movingImage }, + ); + assertEquals(field.axesOrientations, RAS); + + const data = await fieldData(field); + const shape = field.data.shape; + const voxels = shape[1] * shape[2] * shape[3]; + for (const index of [[0, 0, 0], [1, 2, 3], [4, 1, 0], [2, 2, 2]]) { + // index is in dims order (z, y, x). + const q = dims.map((dim, i) => + field.translation[dim] + field.scale[dim] * index[i] + ); + const offset = (index[0] * shape[2] + index[1]) * shape[3] + index[2]; + const d = [0, 1, 2].map((c) => data[c * voxels + offset]); + const qItk = [...q].reverse(); + const dItk = [...d].reverse(); + const v = vectorAt(entry, size, [...index].reverse()); + const expected = phi(fixed, qItk, itkDims).map((value, i) => + value + v[i] + ); + assertAllClose( + phi(movingImage, qItk.map((value, i) => value + dItk[i]), itkDims), + expected, + ); + } + + const [back] = await ngffDisplacementFieldToItkTransform( + transform, + field, + dims, + { + fixed, + moving: movingImage, + }, + ); + assertAllClose( + back.fixedParameters as unknown as ArrayLike, + entry.fixedParameters as unknown as ArrayLike, + ); + assertAllClose( + back.parameters as unknown as ArrayLike, + entry.parameters as unknown as ArrayLike, + ); + }); +} + +Deno.test("the grid direction must match the fixed image", async () => { + const size = [4, 3, 5]; + const flipped = fieldTransform(size, [1, 1, 1], [0, 0, 0], [ + [-1, 0, 0], + [0, -1, 0], + [0, 0, 1], + ]); + await assertRejects( + () => + itkDisplacementFieldToNgffTransform(flipped, CANONICAL[3], { path: "w" }), + Error, + "non-identity direction", + ); + const fixed = await frameImage(size, [1, 1, 1], [0, 0, 0]); + await assertRejects( + () => + itkDisplacementFieldToNgffTransform(flipped, CANONICAL[3], { + path: "w", + fixed, + moving: fixed, + }), + Error, + "not oriented like the fixed image", + ); +}); + +Deno.test("refusals", async () => { + const entry = fieldTransform([3, 2], [1, 1], [0, 0]); + const { transform, field } = await itkDisplacementFieldToNgffTransform( + entry, + ["y", "x"], + { path: "w" }, + ); + + await assertRejects( + () => + itkDisplacementFieldToNgffTransform(entry, ["c", "y", "x"], { + path: "w", + }), + Error, + "spatial axes only", + ); + await assertRejects( + () => + itkDisplacementFieldToNgffTransform(entry, ["z", "y", "x"], { + path: "w", + }), + Error, + "name 3 axes", + ); + await assertRejects( + () => + itkDisplacementFieldToNgffTransform(entry, ["y", "x"], { + path: "w", + fixed: field, + }), + Error, + "pass both fixed and moving", + ); + await assertRejects( + () => + itkDisplacementFieldToNgffTransform([entry, entry], ["y", "x"], { + path: "w", + }), + Error, + "transform list of 2 entries", + ); + let message = ""; + try { + itkTransformToNgffTransform(entry, ["y", "x"]); + } catch (error) { + message = (error as Error).message; + } + assertEquals(message.includes("itkDisplacementFieldToNgffTransform"), true); + message = ""; + try { + ngffTransformToItkTransform(transform, ["y", "x"]); + } catch (error) { + message = (error as Error).message; + } + assertEquals(message.includes("ngffDisplacementFieldToItkTransform"), true); + await assertRejects( + () => ngffDisplacementFieldToItkTransform(transform, field, ["x", "y"]), + Error, + "component axis first", + ); + const untyped = new NgffImage({ + data: field.data, + dims: field.dims, + scale: field.scale, + translation: field.translation, + name: "untyped", + axesUnits: undefined, + computedCallbacks: undefined, + }); + await assertRejects( + () => ngffDisplacementFieldToItkTransform(transform, untyped, ["y", "x"]), + Error, + "exactly one axis of type 'displacement'", + ); +}); + +Deno.test("interpolation other than linear still converts", async () => { + const entry = fieldTransform([3, 2], [1, 1], [0, 0]); + const { field } = await itkDisplacementFieldToNgffTransform( + entry, + ["y", "x"], + { + path: "w", + }, + ); + const nearest: Displacements = { + type: "displacements", + path: "w", + interpolation: "nearest", + }; + const warn = console.warn; + const warnings: string[] = []; + console.warn = (message: string) => warnings.push(message); + try { + const [back] = await ngffDisplacementFieldToItkTransform(nearest, field, [ + "y", + "x", + ]); + assertAllClose( + back.parameters as unknown as ArrayLike, + entry.parameters as unknown as ArrayLike, + ); + } finally { + console.warn = warn; + } + assertEquals(warnings.length, 1); + assertEquals(warnings[0].includes("'nearest' interpolation"), true); +}); + +Deno.test("single precision is preserved", async () => { + const entry = fieldTransform( + [3, 2], + [1, 1], + [0, 0], + undefined, + 0, + Float32Array, + ); + const { transform, field } = await itkDisplacementFieldToNgffTransform( + entry, + ["y", "x"], + { path: "w" }, + ); + assertEquals(field.data.dtype, "float32"); + const [back] = await ngffDisplacementFieldToItkTransform(transform, field, [ + "y", + "x", + ]); + assertEquals(String(back.transformType.parametersValueType), "float32"); + assertEquals(back.parameters instanceof Float32Array, true); +}); diff --git a/ts/test/itk_transform_to_ngff_transform_test.ts b/ts/test/itk_transform_to_ngff_transform_test.ts index 5298b777..394d04af 100644 --- a/ts/test/itk_transform_to_ngff_transform_test.ts +++ b/ts/test/itk_transform_to_ngff_transform_test.ts @@ -273,16 +273,23 @@ Deno.test("an angle-based parameterization is refused with guidance", () => { Deno.test("a deformation is refused as a deformation, not as a matrix gap", () => { // "Convert it to an Affine transform first" is not advice that applies to a - // displacement field: it has no affine equivalent at all. Saying so sends - // the caller to the RFC-5 field types instead of on a wild goose chase. - for (const parameterization of ["DisplacementField", "BSpline"]) { - const deformation = entry(parameterization, [0, 0, 0, 0]); - assertThrows( - () => itkTransformToNgffMatrix(deformation, ["y", "x"]), - Error, - "describes a deformation", - ); - } + // deformation: it has no affine equivalent at all. A displacement field is + // sent to its own conversion; any other deformation to the RFC-5 field + // types, instead of on a wild goose chase. + assertThrows( + () => + itkTransformToNgffMatrix(entry("DisplacementField", [0, 0, 0, 0]), [ + "y", + "x", + ]), + Error, + "itkDisplacementFieldToNgffTransform", + ); + assertThrows( + () => itkTransformToNgffMatrix(entry("BSpline", [0, 0, 0, 0]), ["y", "x"]), + Error, + "describes a deformation", + ); }); Deno.test("a scale transform decodes", () => { From f756056f6bc3e1e6157ce61981484ef86b787034 Mon Sep 17 00:00:00 2001 From: Valentin Boussot Date: Tue, 25 Aug 2026 02:50:32 +0200 Subject: [PATCH 07/11] fix(py,ts): recover an ITK affine exactly and bind sub-grid axes by name Probing recovers a transform's matrix from `T(h e_j) - T(0)`, a difference of two nearly equal points. An affine combining a large offset with a small matrix coefficient loses the coefficient to cancellation: `1e-20 x + 1e15` came back as a zero matrix, so an invertible mapping was persisted as a singular one, and the check point could not tell the two apart. Every ITK transform built on `MatrixOffsetTransformBase` answers `GetMatrix()` and `GetOffset()` with the center of rotation already folded in, whatever parameterization it stores, and a `CompositeTransform` holds its children, so the affine is now read rather than reconstructed. Probing stays as the fallback for a transform that carries no such pair. The recovered model is still confronted with one evaluation, which is what catches a transform whose matrix contradicts its own `TransformPoint` (InsightSoftwareConsortium/ITK#6791); that case now has a test. `_shifted_translation` still reversed the spatial dims to reach ITK order, which names the wrong axis for any order other than zyx or yx, so a cropped block of an oriented image whose dims are xyz got its origin moved along the wrong axes. Also in this pass: - RFC-4 directions are signed permutations, so the inverse is the transpose. Both ports now transpose instead of calling `np.linalg.inv`, which removes the `LinAlgError` path and matches TypeScript's arithmetic exactly. - Both displacement-field conversions derive their per-point terms from the affine change of frame, `d(q) = D_out^-1 v + (M - I) q + b`, in Python and in TypeScript. The shared-frame fast path falls out of the terms being zero rather than being a separate branch, and the frame formula is written once. - `itk_transform_resample_bounding_box` takes `fields=`, so a `displacements` transformation can be used there. Its error previously told the caller to pass a mapping the signature had no room for. - `ngff_transform_to_itk_transform` takes the spatial subset of `dims` on the displacements branch too, so the image's own dimension names work on either branch. - Errors: a transform of the wrong dimensionality, an input that is no transform at all, and a list of native `itk.Transform` objects are each named instead of surfacing as a SWIG `TypeError`, `'int' object is not iterable`, or an `AttributeError`. - Fewer array copies in both field directions, and the grid term is only built when the frames differ. - Docs: the axis permutation is by name, not a reversal; the recovery description matches the code; `dims` for a field is the spatial axes. --- docs/itk.md | 63 ++-- docs/rfc5.md | 5 + py/ngff_zarr/displacement_field_transform.py | 220 ++++++++------ .../itk_transform_resample_bounding_box.py | 24 +- .../itk_transform_to_ngff_transform.py | 276 +++++++++++++----- .../ngff_transform_to_itk_transform.py | 19 +- py/test/test_displacement_field_transform.py | 56 +++- ...est_itk_transform_resample_bounding_box.py | 50 ++++ .../test_itk_transform_to_ngff_transform.py | 138 ++++++--- ...tk_transform_resample_bounding_box-node.ts | 7 +- ..._transform_resample_bounding_box-shared.ts | 40 ++- ts/src/utils/displacement_field_transform.ts | 230 ++++++++------- ts/src/utils/itk_direction.ts | 19 ++ .../utils/itk_transform_to_ngff_transform.ts | 27 +- .../utils/ngff_transform_to_itk_transform.ts | 17 +- ts/test/displacement_field_transform_test.ts | 15 +- ...tk_transform_resample_bounding_box_test.ts | 95 +++++- 17 files changed, 932 insertions(+), 369 deletions(-) diff --git a/docs/itk.md b/docs/itk.md index 8e077b70..ff78b859 100644 --- a/docs/itk.md +++ b/docs/itk.md @@ -9,7 +9,7 @@ Bidirectional type conversion that preserves spatial metadata is available with Once represented as an `NgffImage`, a multiscale representation can be generated with `to_multiscales`. And an OME-Zarr can be generated from the multiscales -with `to_ngff_zarr`. For more information, see the +with `to_ome_zarr`. For more information, see the [Python interface documentation](./python.md). ## ITK Python @@ -148,7 +148,7 @@ so that is the knob to reach for when the peak is still too high: ```python >>> import dask # doctest: +SKIP >>> with dask.config.set(num_workers=4): # doctest: +SKIP -... nz.to_ngff_zarr("resampled.zarr", +... nz.to_ome_zarr("resampled.zarr", ... nz.to_multiscales(resampled, scale_factors=[])) ``` @@ -263,6 +263,8 @@ Transforms convert in both directions, mirroring `itk_image_to_ngff_image` and | `ngff_transform_to_itk_transform` | RFC-5 to ITK | | `itk_transform_to_ngff_transform` | ITK to RFC-5 | | `itk_transform_to_ngff_matrix` | ITK to RFC-5, as raw numbers | +| `ngff_displacement_field_to_itk_transform` | RFC-5 `displacements` to an ITK field | +| `itk_displacement_field_to_ngff_transform` | ITK field to RFC-5 `displacements` | `itk_transform_to_ngff_matrix` returns the `(matrix, offset)` pair in Zarr axis order instead of an RFC-5 dataclass. Reach for it to *inspect* a registration @@ -273,8 +275,10 @@ simplifies by default, `itk_transform_to_ngff_transform` may hand back a Both reconcile the two conventions that differ between the specifications: - **Axis order.** RFC-5 orders parameters like the Zarr array, so a `zyx` image - has `z` first; ITK orders points fastest-axis-first. The spatial block is - reversed in both rows and columns. + has `z` first; ITK orders points fastest-axis-first *by name*: x, then y, + then z. The spatial block is permuted in both rows and columns by that + naming, which for the canonical `zyx` is the axis reversal and for any other + order is not. - **Composition order.** An RFC-5 `sequence` applies its *first* entry first, while an ITK transform list applies its *last* entry first. The chain is collapsed into a single affine so the result does not depend on that @@ -288,10 +292,16 @@ offset as `b = t + c - A c`, so the mapping is preserved exactly. `itk_transform_to_ngff_transform` is what lets a registration be written into the OME-Zarr store. It accepts any linear ITK transform, including the -`CompositeTransform` an Elastix registration returns, and it recovers the -mapping by evaluating the transform rather than by decoding its parameters -- -so parameterizations that store angles or a quaternion (`Euler2DTransform`, -`VersorRigid3DTransform`, ...) convert just as well as an `AffineTransform`: +`CompositeTransform` an Elastix registration returns. Every ITK transform built +on `MatrixOffsetTransformBase` answers `GetMatrix()` and `GetOffset()`, center +of rotation already folded in, whatever it stores underneath, so +parameterizations that hold angles or a quaternion (`Euler2DTransform`, +`VersorRigid3DTransform`, ...) convert exactly and just as well as an +`AffineTransform`; a composite composes its children the same way. A transform +carrying no such pair is recovered by evaluating it at the origin and along +each axis instead. Either way the result is confronted with one more +evaluation, so a transform whose matrix does not describe what it actually +does is refused rather than written: ```python >>> import itk @@ -357,7 +367,11 @@ transformation going the other way. RFC-5 represents deformations with its Computing a bounding box from an **ITK** transform does not require linearity -- that is the section above. An RFC-5 `displacements` transformation is -converted first, so it needs its field there too. +converted first, so `itk_transform_resample_bounding_box` takes the same +`fields=` mapping to find its field. Because that branch works on the intrinsic +systems, where no direction matrix applies, a field carrying an anatomical +orientation is refused there: convert it with `ngff_transform_to_itk_transform` +and its `fixed=`/`moving=` pair, and pass the ITK transform that returns. ### Displacement fields @@ -369,7 +383,7 @@ functions stay free of any I/O: ```python >>> transform, field = nz.itk_displacement_field_to_ngff_transform( # doctest: +SKIP -... warp, multiscales.metadata.dimension_names, path='displacement_field') +... warp, ['z', 'y', 'x'], path='displacement_field') >>> nz.to_ome_zarr( # doctest: +SKIP ... 'registered.ome.zarr/displacement_field', ... nz.to_multiscales(field, scale_factors=[]), version='0.6') @@ -382,11 +396,15 @@ functions stay free of any I/O: `warp` may be an `itk.DisplacementFieldTransform`, the vector `itk.Image` or `itkwasm.Image` a registration tool writes the field as, or an ITK-Wasm -`DisplacementField` transform. The field comes back as an `NgffImage` whose -first axis holds the components (`type: "displacement"`) followed by the -spatial axes, with the grid's spacing and origin as its scale and translation. -Its components follow the axes of `dims`, as RFC-5 requires, so an ITK `(dx, -dy, dz)` vector is stored as `(dz, dy, dx)` on a `zyx` image. +`DisplacementField` transform. Here `dims` names the spatial axes and nothing +else: ITK has no notion of a time or channel axis, and RFC-5 wants one field +component per input axis. + +The field comes back as an `NgffImage` whose first axis holds the components +(`type: "displacement"`) followed by the spatial axes, with the grid's spacing +and origin as its scale and translation. Its components follow the axes of +`dims`, as RFC-5 requires, so an ITK `(dx, dy, dz)` vector is stored as +`(dz, dy, dx)` on a `zyx` image. Going back, pass the field the transform points at, loaded from the same store, keyed by its `path`: @@ -398,6 +416,8 @@ store, keyed by its `path`: >>> itk_transforms = nz.ngff_transform_to_itk_transform( # doctest: +SKIP ... transform, imported.metadata.dimension_names, ... fields={transform.path: field}) +>>> region = nz.itk_transform_resample_bounding_box( # doctest: +SKIP +... transform, fixed, moving, fields={transform.path: field}) ``` The result is an ITK-Wasm `TransformList` with one `DisplacementField` entry; @@ -427,11 +447,14 @@ untransformed. In the TypeScript package the equivalents are `ngffTransformToItkTransform`, `itkTransformToNgffTransform` and `itkTransformToNgffMatrix`, and for fields `itkDisplacementFieldToNgffTransform` and `ngffDisplacementFieldToItkTransform`, -both async since the field is read from and written to a Zarr array. TypeScript -has no `itk` package to fall back on, so only parameterizations that carry a -matrix (`Identity`, `Translation`, `Scale`, `Affine`) or a field -(`DisplacementField`) convert there; angle- and quaternion-based ones must be -converted to an affine first. +both async since the field is read from and written to a Zarr array. +`itkTransformResampleBoundingBox` takes the fields as an option there rather +than an argument, `{ fields: { [path]: field } }`, and +`ngffTransformToItkTransform` stays synchronous by leaving fields to the pair +above. TypeScript has no `itk` package to fall back on, so only +parameterizations that carry a matrix (`Identity`, `Translation`, `Scale`, +`Affine`) or a field (`DisplacementField`) convert there; angle- and +quaternion-based ones must be converted to an affine first. ## TypeScript diff --git a/docs/rfc5.md b/docs/rfc5.md index e293193e..7789b01d 100644 --- a/docs/rfc5.md +++ b/docs/rfc5.md @@ -236,6 +236,11 @@ RFC-5 `sequence` applies its first entry first while an ITK transform list applies its last entry first, and ITK's center of rotation is folded into the offset since an RFC-5 affine has none. +A `displacements` transformation converts too, with +`itk_displacement_field_to_ngff_transform` and its inverse: the field is an +array rather than a handful of numbers, so those return, and take, the field +image alongside the transformation. + Building on that, `itk_transform_resample_bounding_box` computes which region of a moving image a resample through the transformation would read, from geometry alone. See [Out-of-core resampling](./itk.md#out-of-core-resampling) diff --git a/py/ngff_zarr/displacement_field_transform.py b/py/ngff_zarr/displacement_field_transform.py index 87315bc5..d2a6fcd9 100644 --- a/py/ngff_zarr/displacement_field_transform.py +++ b/py/ngff_zarr/displacement_field_transform.py @@ -21,16 +21,13 @@ Frames An ITK vector is a difference of two physical points. An RFC-5 - displacement is a difference of two intrinsic points, ``d = q' - q``. With - ``phi(q) = D (q - o) + o`` relating each image's intrinsic system to ITK - physical space (``D`` from RFC-4 orientation, ``o`` the translation), a - vector ``v`` sampled at the input grid point ``q`` becomes:: - - d(q) = D_out^-1 (v + D_in (q - o_in) + o_in - o_out) + o_out - q - - which collapses to ``d = D^-1 v`` when both images share a frame. The - field's own grid follows ``phi_in^-1``, so ``translation = D_in^-1 (o_f - - o_in) + o_in`` and the spacing is unchanged. + displacement is a difference of two intrinsic points, ``d = q' - q``, so + the two differ by more than a rotation as soon as the images sit in + different frames. Both directions follow from one identity, + ``phi_out(q + d(q)) = phi_in(q) + v(q)``, worked out in + :func:`_frame_terms` on top of the affine conversion's change of frame. + The field's own grid follows ``phi_in^-1``, so its origin moves and its + spacing does not. Grid direction RFC-5 maps the field's array coordinates to the input system with the @@ -49,6 +46,7 @@ import numpy as np +from .itk_transform_to_ngff_transform import _FrameGeometry from .ngff_image import NgffImage from .v06.zarr_metadata import Displacements @@ -63,11 +61,12 @@ def _check_dims(dims: Sequence[str]) -> tuple[str, ...]: from .itk_transform_to_ngff_transform import _SPATIAL_DIMS dims = tuple(dims) - if not dims or any(dim not in _SPATIAL_DIMS for dim in dims): + other = [dim for dim in dims if dim not in _SPATIAL_DIMS] + if not dims or other: msg = ( - f"a displacement field is defined on spatial axes only; got dims " - f"{dims}. ITK has no notion of a time or channel axis, and RFC-5 " - "requires one field dimension per input axis." + f"a displacement field is defined on spatial axes only; dims {dims} " + f"name {other or 'none'}. ITK has no notion of a time or channel " + "axis, and RFC-5 requires one field dimension per input axis." ) raise ValueError(msg) if len(set(dims)) != len(dims): @@ -89,37 +88,56 @@ def _frames(fixed, moving, dims): return _frame_geometry(fixed, moving, _itk_axis_order(dims)) -def _grid_points(shape, origin, spacing): - """Every grid point's coordinates, ``(*shape, N)`` in ITK component order. +def _unoriented_frames(dimension: int) -> _FrameGeometry: + """The frame pair of two images with no orientation and no translation. - ``shape`` is the field's ``[z][y][x]`` layout, so the index arrays come - out slowest-axis-first and are reversed into ITK order. + With it the terms below all vanish, so the conversion that changes frames + and the one that does not are the same arithmetic rather than two branches. """ - indices = np.stack(np.indices(shape)[::-1], axis=-1).astype(np.float64) - return origin + spacing * indices + identity = np.eye(dimension) + zero = np.zeros(dimension) + return _FrameGeometry(identity, identity, zero, zero) + +def _frame_terms(frames: _FrameGeometry): + """The per-point terms relating ITK vectors and RFC-5 displacements. -def _vectors_to_intrinsic( - vectors, origin, spacing, direction_in, direction_out, origin_in, origin_out -): - """ITK physical vectors to RFC-5 displacements, per grid point.""" - inverse_out = np.linalg.inv(direction_out) - if np.allclose(direction_in, direction_out) and np.allclose(origin_in, origin_out): - return vectors @ inverse_out.T - points = _grid_points(vectors.shape[:-1], origin, spacing) - shifted = vectors + (points - origin_in) @ direction_in.T + origin_in - origin_out - return shifted @ inverse_out.T + origin_out - points + An ITK vector is a difference of two *physical* points; an RFC-5 + displacement is a difference of two *intrinsic* points. Writing + ``phi_out^-1 . phi_in`` as ``q -> M q + b`` -- the change of frame the + affine conversion applies, given the identity as its mapping -- the + defining identity ``phi_out(q + d(q)) = phi_in(q) + v(q)`` rearranges to + d(q) = D_out^-1 v + (M - I) q + b + v(q) = D_out (d - (M - I) q - b) -def _vectors_to_physical( - displacements, origin, spacing, direction_in, direction_out, origin_in, origin_out -): - """RFC-5 displacements to ITK physical vectors, per grid point.""" - if np.allclose(direction_in, direction_out) and np.allclose(origin_in, origin_out): - return displacements @ direction_out.T - points = _grid_points(displacements.shape[:-1], origin, spacing) - moved = (displacements + points - origin_out) @ direction_out.T + origin_out - return moved - (points - origin_in) @ direction_in.T - origin_in + so one derivation serves both directions. The two ``q`` terms vanish when + the images share a frame, leaving ``d = D_out^-1 v``. + + :return: ``(D_out, M - I, b)``. + """ + from .itk_transform_to_ngff_transform import _change_of_frame + + dimension = len(frames.origin_in) + matrix, offset = _change_of_frame(np.eye(dimension), np.zeros(dimension), *frames) + return frames.direction_out, matrix - np.eye(dimension), offset + + +def _grid_shift(shape, origin, spacing, matrix, vector): + """``matrix q + vector`` at every grid point, or ``None`` when it is zero. + + ``shape`` is the field's ``[z][y][x]`` layout, so the index arrays come out + slowest-axis-first and are reversed into ITK component order. The result is + ``(*shape, N)``, ready to add to or subtract from the field. + """ + if not matrix.any() and not vector.any(): + return None + indices = np.stack(np.indices(shape, dtype=np.float64)[::-1], axis=-1) + return (origin + spacing * indices) @ matrix.T + vector + + +def _is_identity(matrix: np.ndarray) -> bool: + return bool(np.array_equal(matrix, np.eye(len(matrix)))) def _decode_field(transform): @@ -288,7 +306,7 @@ def itk_displacement_field_to_ngff_transform( """ import dask.array - from .itk_transform_to_ngff_transform import _itk_axis_order + from .itk_transform_to_ngff_transform import _inverse_direction, _itk_axis_order dims = _check_dims(dims) vectors, origin, spacing, direction = _decode_field(transform) @@ -312,34 +330,40 @@ def itk_displacement_field_to_ngff_transform( "orientation, or resample the field onto the fixed grid." ) raise ValueError(msg) - grid_origin = origin - displacements = vectors - else: - direction_in, direction_out, origin_in, origin_out = frames - if not np.allclose(direction, direction_in): - msg = ( - "the field's grid is not oriented like the fixed image: its " - f"direction is {direction.tolist()} where the fixed image gives " - f"{direction_in.tolist()}. Resample the field onto the fixed " - "grid first." - ) - raise ValueError(msg) - grid_origin = np.linalg.inv(direction_in) @ (origin - origin_in) + origin_in - displacements = _vectors_to_intrinsic( - vectors, - grid_origin, - spacing, - direction_in, - direction_out, - origin_in, - origin_out, + frames = _unoriented_frames(dimension) + elif not np.allclose(direction, frames.direction_in): + msg = ( + "the field's grid is not oriented like the fixed image: its " + f"direction is {direction.tolist()} where the fixed image gives " + f"{frames.direction_in.tolist()}. Resample the field onto the fixed " + "grid first." ) + raise ValueError(msg) + + # The field's grid follows phi_in^-1, so its origin moves and its spacing + # does not. + grid_origin = ( + _inverse_direction(frames.direction_in) @ (origin - frames.origin_in) + + frames.origin_in + ) - # [z][y][x][c] with ITK components -> (c, *dims) with components in dims order. + direction_out, shift_matrix, shift_vector = _frame_terms(frames) + inverse_out = _inverse_direction(direction_out) + displacements = vectors if _is_identity(inverse_out) else vectors @ inverse_out.T + shift = _grid_shift( + vectors.shape[:-1], grid_origin, spacing, shift_matrix, shift_vector + ) + if shift is not None: + displacements = displacements + shift + + # [z][y][x][c] with ITK components -> (c, *dims) with components in dims + # order. The transposes are views; indexing the component axis is the one + # copy, and it lands contiguous. field = np.moveaxis(displacements, -1, 0) field = np.transpose(field, [0] + [1 + canonical.index(dim) for dim in dims]) - field = field[[itk_dims.index(dim) for dim in dims]] - field = np.ascontiguousarray(field, dtype=vectors.dtype) + field = field[[itk_dims.index(dim) for dim in dims]].astype( + vectors.dtype, copy=False + ) scale = {_COMPONENT_DIM: 1.0} translation = {_COMPONENT_DIM: 0.0} @@ -458,10 +482,11 @@ def ngff_displacement_field_to_itk_transform( itk_dims = _itk_axis_order(dims) canonical = list(reversed(itk_dims)) - # (c, *dims) with components in dims order -> [z][y][x][c] with ITK components. - arranged = data[[dims.index(dim) for dim in itk_dims]] - arranged = np.transpose(arranged, [0] + [1 + dims.index(dim) for dim in canonical]) - displacements = np.moveaxis(arranged, 0, -1) + # (c, *dims) with components in dims order -> [z][y][x][c] with ITK + # components. The transpose is a view; indexing the component axis, which + # the transpose has moved last, is the one copy. + arranged = np.transpose(data, [1 + dims.index(dim) for dim in canonical] + [0]) + displacements = arranged[..., [dims.index(dim) for dim in itk_dims]] spacing = np.array([float(field.scale[dim]) for dim in itk_dims]) translation = np.array([float(field.translation[dim]) for dim in itk_dims]) @@ -469,37 +494,40 @@ def ngff_displacement_field_to_itk_transform( frames = _frames(fixed, moving, dims) if frames is None: - if not np.allclose(own_direction, np.eye(dimension)): + if not _is_identity(own_direction): + # An ITK transform lives in physical space, and without the images + # there is nothing to say where the oriented grid sits in it. msg = ( - "the field carries an anatomical orientation; pass the fixed and " - "moving images so its grid is placed in their frame" + "the field carries an anatomical orientation, so its grid " + "cannot be placed in ITK physical space on its own; pass the " + "fixed and moving images. itk_transform_resample_bounding_box " + "has no place for them, because its RFC-5 branch works on the " + "intrinsic systems where no orientation applies: call " + "ngff_transform_to_itk_transform with both images yourself and " + "hand the bounding box the ITK transform it returns." ) raise ValueError(msg) - origin = translation - direction = np.eye(dimension) - vectors = displacements - else: - direction_in, direction_out, origin_in, origin_out = frames - if not np.allclose(own_direction, np.eye(dimension)) and not np.allclose( - own_direction, direction_in - ): - msg = ( - "the field's orientation is not the fixed image's: it gives " - f"{own_direction.tolist()} where the fixed image gives " - f"{direction_in.tolist()}" - ) - raise ValueError(msg) - origin = direction_in @ (translation - origin_in) + origin_in - direction = direction_in - vectors = _vectors_to_physical( - displacements, - translation, - spacing, - direction_in, - direction_out, - origin_in, - origin_out, + frames = _unoriented_frames(dimension) + elif not _is_identity(own_direction) and not np.allclose( + own_direction, frames.direction_in + ): + msg = ( + "the field's orientation is not the fixed image's: it gives " + f"{own_direction.tolist()} where the fixed image gives " + f"{frames.direction_in.tolist()}" ) + raise ValueError(msg) + + direction = frames.direction_in + origin = direction @ (translation - frames.origin_in) + frames.origin_in + + direction_out, shift_matrix, shift_vector = _frame_terms(frames) + shift = _grid_shift( + displacements.shape[:-1], translation, spacing, shift_matrix, shift_vector + ) + vectors = displacements if shift is None else displacements - shift + if not _is_identity(direction_out): + vectors = vectors @ direction_out.T if transform.interpolation not in (None, "linear"): warnings.warn( @@ -513,7 +541,7 @@ def ngff_displacement_field_to_itk_transform( value_type = FloatTypes.Float32 else: value_type = FloatTypes.Float64 - vectors = vectors.astype(np.float64) + vectors = vectors.astype(np.float64, copy=False) size = np.array(displacements.shape[:-1][::-1], dtype=np.float64) fixed_parameters = np.concatenate( [size, origin, spacing, direction.ravel(order="C")] diff --git a/py/ngff_zarr/itk_transform_resample_bounding_box.py b/py/ngff_zarr/itk_transform_resample_bounding_box.py index 49aad8e0..18c5bfdf 100644 --- a/py/ngff_zarr/itk_transform_resample_bounding_box.py +++ b/py/ngff_zarr/itk_transform_resample_bounding_box.py @@ -3,7 +3,7 @@ """Find the region of a moving image needed to resample a fixed image grid.""" import math -from collections.abc import Sequence +from collections.abc import Mapping, Sequence from dataclasses import dataclass, field import numpy as np @@ -220,7 +220,7 @@ def _shifted_translation(ngff_image: NgffImage, starts: dict) -> dict: """ translation = dict(ngff_image.translation) spatial = _spatial_dims(ngff_image) - itk_dims = list(reversed(spatial)) + itk_dims = [dim for dim in _SPATIAL_DIMS if dim in spatial] direction = _itk_direction(ngff_image, itk_dims) offset = direction @ np.array( [starts.get(dim, 0) * ngff_image.scale[dim] for dim in itk_dims], dtype=float @@ -393,6 +393,8 @@ def itk_transform_resample_bounding_box( fixed: NgffImage, moving: NgffImage, padding: int = 1, + *, + fields: Mapping[str, object] | None = None, ) -> ResampleBoundingBox: """Compute the moving-image region needed to resample a fixed image grid. @@ -416,6 +418,10 @@ def itk_transform_resample_bounding_box( :func:`ngff_zarr.ngff_image_to_itk_image` builds it, including the direction matrix derived from RFC-4 anatomical orientation. + An ITK transform need not be linear. An RFC-5 transformation is converted + first, so it must be one this package can convert: a linear mapping, or a + ``displacements`` transformation whose field is passed in ``fields``. + In both cases the transform maps *fixed* points into *moving* space. :param transform: An RFC-5 coordinate transformation, an ``itk.Transform``, @@ -432,6 +438,16 @@ def itk_transform_resample_bounding_box( index bound. Use 0 for the tight region, or more for wider kernels. :type padding: int + :param fields: The field images an RFC-5 ``displacements`` transformation + points at, keyed by its ``path``, as + :func:`ngff_zarr.ngff_transform_to_itk_transform` takes them. Required + for a ``displacements`` transformation, ignored otherwise. A field + carrying an anatomical orientation is refused here, since this branch + works on the intrinsic systems where none applies: convert it with + :func:`ngff_zarr.ngff_transform_to_itk_transform`, passing ``fixed`` + and ``moving``, and pass the ITK transform it returns. + :type fields: Mapping[str, NgffImage | NgffMultiscales], optional + :return: The region, keyed by dimension name in Zarr order. :rtype: ResampleBoundingBox @@ -497,7 +513,9 @@ def itk_transform_resample_bounding_box( ) if _is_ngff_transform(transform): - transform_list = ngff_transform_to_itk_transform(transform, fixed.dims) + transform_list = ngff_transform_to_itk_transform( + transform, fixed.dims, fields=fields + ) # An RFC-5 transformation is defined on the intrinsic coordinate # system, which carries no direction matrix. fixed_direction = np.eye(len(itk_dims)) diff --git a/py/ngff_zarr/itk_transform_to_ngff_transform.py b/py/ngff_zarr/itk_transform_to_ngff_transform.py index 5ec90b32..3c13502c 100644 --- a/py/ngff_zarr/itk_transform_to_ngff_transform.py +++ b/py/ngff_zarr/itk_transform_to_ngff_transform.py @@ -16,6 +16,7 @@ """ from collections.abc import Sequence +from typing import NamedTuple import numpy as np @@ -105,67 +106,48 @@ def _working_precision(*samples: np.ndarray) -> str: return "float64" -def _probe_step(offset: np.ndarray) -> float: - """The displacement to probe the transform with. +def _working_scale(offset: np.ndarray) -> float: + """The magnitude the transform's own numbers work at. - An affine map is exact for *any* step, so there is no truncation error to - trade off and the usual "small step" rule is exactly backwards here. The - only error is the cancellation in ``T(h e_j) - T(0)``, which is worst when - the offset dwarfs the step: with a step of 1 and an offset of 1e15 the - subtraction keeps no significant digit at all, and probing silently - reports a zero matrix. Following the offset's magnitude keeps the two - terms comparable, so the difference stays good to a few ulps. - - The limit this leaves: a matrix coefficient whose contribution at the - probe scale falls below the rounding of the offset itself (roughly - ``|m| * h < ulp(|T(0)|)``) is unrecoverable by evaluation and folds into - the offset. Such a term is equally invisible to any consumer evaluating - the transform at that scale, so nothing representable is lost. + Both the probing step and the point the recovered affine is checked at + follow it, because either one fixed near the origin stops meaning anything + once the offset dominates: with a step of 1 and an offset of 1e15, + ``T(h e_j) - T(0)`` keeps no significant digit and probing reports a zero + matrix that a check near the origin cannot tell from the truth. An affine + map is exact for *any* step, so there is no truncation error to trade + against and the usual "small step" rule is exactly backwards here. """ return max(1.0, float(np.abs(offset).max())) -def _matrix_offset_by_probing(itk_transform, dimension: int): - """Recover ``(matrix, offset)`` by evaluating the transform. +def _reject_non_finite(itk_transform, matrix, offset) -> None: + """Refuse an affine whose own numbers are not usable.""" + if np.all(np.isfinite(matrix)) and np.all(np.isfinite(offset)): + return + msg = ( + f"{type(itk_transform).__name__} does not describe a finite affine " + "mapping; its matrix or offset holds a non-finite value" + ) + raise ValueError(msg) - ``offset = T(0)`` and ``matrix[:, j] = (T(h e_j) - T(0)) / h``. This is - exact for any linear transform and, unlike decoding ``GetParameters()``, - does not depend on how the particular transform type packs its parameters - -- an ``Euler3DTransform`` stores angles and a ``VersorRigid3DTransform`` a - quaternion, but both answer ``TransformPoint`` the same way. - - Probing a *non*-linear transform would succeed and return a plausible - affine that is simply wrong away from the probed points, so the recovered - model is checked against a point none of the probes reached. That point - scales with the probe step: a check fixed near the origin cannot tell a - zeroed matrix from a correct one once the offset dominates, which is the - very cancellation the step exists to avoid. - """ - origin = [0.0] * dimension - offset = np.asarray(itk_transform.TransformPoint(origin), dtype=float) - step = _probe_step(offset) - matrix = np.zeros((dimension, dimension)) - probes = [] - for axis in range(dimension): - basis = [0.0] * dimension - basis[axis] = step - column = np.asarray(itk_transform.TransformPoint(basis), dtype=float) - probes.append(column) - matrix[:, axis] = (column - offset) / step - - if not np.all(np.isfinite(matrix)) or not np.all(np.isfinite(offset)): - msg = ( - f"{type(itk_transform).__name__} maps finite points to non-finite " - "values; its parameters are not usable numbers" - ) - raise ValueError(msg) +def _check_affine_model(itk_transform, matrix, offset, samples) -> None: + """Refuse a transform the recovered affine does not actually describe. + Neither reading a transform's matrix nor probing it proves the transform + *is* affine. Probing a deformation succeeds and returns a plausible affine + that is simply wrong away from the probed points, and + ``AzimuthElevationToCartesianTransform`` inherits an affine's matrix and + offset, overrides ``TransformPoint``, and still reports itself linear + (InsightSoftwareConsortium/ITK#6791). So the recovered model is confronted + with one more evaluation, at a point no probe reached. + """ + dimension = offset.shape[0] # Distinct from every probe and from the origin, whatever the dimension. - check = step * (np.arange(dimension) + 2.0) / (dimension + 2.0) + check = _working_scale(offset) * (np.arange(dimension) + 2.0) / (dimension + 2.0) predicted = matrix @ check + offset actual = np.asarray(itk_transform.TransformPoint(check.tolist()), dtype=float) - rtol = _AFFINE_CHECK_RTOL[_working_precision(offset, *probes, actual)] + rtol = _AFFINE_CHECK_RTOL[_working_precision(offset, *samples, actual)] # Rounding in TransformPoint scales with the magnitudes the evaluation # passes through (|A| |x| and |t|), not with the result: a component of # T(check) can legitimately be tiny while the terms producing it are huge. @@ -181,10 +163,126 @@ def _matrix_offset_by_probing(itk_transform, dimension: int): msg = ( "only linear ITK transforms can be expressed as an RFC-5 affine; " f"{type(itk_transform).__name__} does not map " - f"{np.round(check, 6).tolist()} the way an affine recovered from " - "its behaviour at the origin would" + f"{np.round(check, 6).tolist()} the way the affine recovered from " + "it would" ) raise NotImplementedError(msg) + + +def _matrix_offset_by_probing(itk_transform, dimension: int): + """Recover ``(matrix, offset)`` by evaluating the transform. + + The fallback for a transform that carries no matrix and offset of its own: + ``offset = T(0)`` and ``matrix[:, j] = (T(h e_j) - T(0)) / h``. Exact for + any linear transform whatever parameterization it stores, but it pays for + that in cancellation, which is why the step follows the transform's own + scale (see :func:`_working_scale`). The limit it leaves: a matrix + coefficient whose contribution at the probe scale falls below the rounding + of the offset itself is unrecoverable by evaluation and folds into the + offset. Reading the matrix directly has no such limit, so this runs only + where that is impossible. + """ + origin = [0.0] * dimension + offset = np.asarray(itk_transform.TransformPoint(origin), dtype=float) + step = _working_scale(offset) + + matrix = np.zeros((dimension, dimension)) + probes = [] + for axis in range(dimension): + basis = [0.0] * dimension + basis[axis] = step + column = np.asarray(itk_transform.TransformPoint(basis), dtype=float) + probes.append(column) + matrix[:, axis] = (column - offset) / step + + _reject_non_finite(itk_transform, matrix, offset) + _check_affine_model(itk_transform, matrix, offset, probes) + return matrix, offset + + +def _homogeneous(matrix: np.ndarray, offset: np.ndarray) -> np.ndarray: + """``y = A x + b`` as a square matrix acting on homogeneous coordinates.""" + dimension = offset.shape[0] + result = np.eye(dimension + 1) + result[:dimension, :dimension] = matrix + result[:dimension, dimension] = offset + return result + + +def _matrix_offset_direct(itk_transform, dimension: int): + """``(matrix, offset)`` read from the transform's own numbers, or ``None``. + + Every ``MatrixOffsetTransformBase`` -- an affine, and the rigid, rotation + and similarity types built on it -- evaluates + ``y = GetMatrix() x + GetOffset()`` with the center of rotation already + folded into the offset, whatever it stores its parameters as. Reading that + pair beats recomposing it from three evaluations, which is a difference of + nearly equal points: a transform combining a large offset with a small + matrix coefficient, as nanometre coordinates produce, loses the + coefficient to cancellation and would be persisted as a singular mapping. + + ``None`` for a transform that carries no such pair, which then falls back + to probing. + """ + try: + import itk + except ImportError: # a transform this package did not get from itk + return None + try: + # A CompositeTransform hands its children out as base pointers, which + # expose neither accessor until they are cast back to their own type. + itk_transform = itk.down_cast(itk_transform) + except (AttributeError, TypeError, RuntimeError): + return None + + if hasattr(itk_transform, "GetNumberOfTransforms"): + # ITK applies the *last* transform in the queue first, so the + # homogeneous matrices multiply left to right in queue order. + total = np.eye(dimension + 1) + for index in range(itk_transform.GetNumberOfTransforms()): + decoded = _matrix_offset_direct( + itk_transform.GetNthTransform(index), dimension + ) + if decoded is None: + return None + total = total @ _homogeneous(*decoded) + return total[:dimension, :dimension], total[:dimension, dimension] + + if hasattr(itk_transform, "GetMatrix") and hasattr(itk_transform, "GetOffset"): + return ( + np.asarray(itk.array_from_matrix(itk_transform.GetMatrix()), dtype=float), + np.asarray(itk_transform.GetOffset(), dtype=float), + ) + if hasattr(itk_transform, "GetOffset"): + # itk.TranslationTransform, which has an offset and no matrix. + return np.eye(dimension), np.asarray(itk_transform.GetOffset(), dtype=float) + return None + + +def _matrix_offset_from_itk(itk_transform, dimension: int): + """Reduce a native ``itk.Transform`` to a single matrix and offset.""" + for name in ("GetInputSpaceDimension", "GetOutputSpaceDimension"): + # Left to ITK a mismatch surfaces as "Expecting an itkPointD2, an int, + # a float, ...", which names neither the transform nor `dims`, or as a + # shape error from comparing points of two different lengths. + declared = getattr(itk_transform, name, None) + if declared is not None and int(declared()) != dimension: + spaces = ( + f"{itk_transform.GetInputSpaceDimension()}D to " + f"{itk_transform.GetOutputSpaceDimension()}D" + ) + msg = ( + f"{type(itk_transform).__name__} maps {spaces}, but the " + f"coordinate system has {dimension} spatial axes" + ) + raise ValueError(msg) + + decoded = _matrix_offset_direct(itk_transform, dimension) + if decoded is None: + return _matrix_offset_by_probing(itk_transform, dimension) + matrix, offset = decoded + _reject_non_finite(itk_transform, matrix, offset) + _check_affine_model(itk_transform, matrix, offset, (matrix,)) return matrix, offset @@ -305,7 +403,7 @@ def _matrix_offset_from_itkwasm(entry, dimension: int): # The name check above only covers the parameterizations known at the time # of writing; ITK's own answer covers the rest. _reject_non_linear(rebuilt) - return _matrix_offset_by_probing(rebuilt, dimension) + return _matrix_offset_from_itk(rebuilt, dimension) def _itk_matrix_offset(transform, dimension: int): @@ -323,11 +421,18 @@ def _itk_matrix_offset(transform, dimension: int): ) raise NotImplementedError(msg) _reject_non_linear(transform) - return _matrix_offset_by_probing(transform, dimension) + return _matrix_offset_from_itk(transform, dimension) - entries = ( - [transform] if isinstance(transform, ItkWasmTransform) else list(transform) - ) + if isinstance(transform, ItkWasmTransform): + entries = [transform] + elif isinstance(transform, Sequence) and not isinstance(transform, (str, bytes)): + entries = list(transform) + else: + msg = ( + f"unsupported transform input {type(transform).__name__}. Expected an " + "itk.Transform, an itkwasm.Transform, or an ITK-Wasm TransformList." + ) + raise TypeError(msg) if not entries: msg = "transform list is empty" raise ValueError(msg) @@ -336,14 +441,25 @@ def _itk_matrix_offset(transform, dimension: int): # matrices multiply left to right in list order. total = np.eye(dimension + 1) for entry in entries: + if not isinstance(entry, ItkWasmTransform): + # itk.transformread returns a list of native transforms, which + # looks like a TransformList and decodes like nothing at all. + msg = ( + f"transform list entry is a {type(entry).__name__}, not an " + "itkwasm.Transform. A list of native itk.Transform objects is " + "not an ITK-Wasm transform list: compose them into an " + "itk.CompositeTransform, or pass a single itk.Transform." + ) + raise TypeError(msg) if _parameterization_name(entry.transformType) == "Composite": # A parameterless 'Composite' entry is ambiguous. The ITK-Wasm # pipeline writes one as a grouping header before the children, # but itk.dict_from_transform never writes a header at all: there # it is a *nested* composite whose children the serialization - # dropped, at any position including the first. Decoding past one - # would silently compose the wrong mapping, so refuse the entry - # wherever it appears and name the ways out. + # dropped (InsightSoftwareConsortium/ITK#6792), at any position + # including the first. Decoding past one would silently compose + # the wrong mapping, so refuse the entry wherever it appears and + # name the ways out. msg = ( "a 'Composite' entry in an ITK-Wasm transform list cannot be " "decoded: itk.dict_from_transform drops a nested composite's " @@ -353,11 +469,7 @@ def _itk_matrix_offset(transform, dimension: int): "leading header entry and pass the children." ) raise NotImplementedError(msg) - matrix, offset = _matrix_offset_from_itkwasm(entry, dimension) - homogeneous = np.eye(dimension + 1) - homogeneous[:dimension, :dimension] = matrix - homogeneous[:dimension, dimension] = offset - total = total @ homogeneous + total = total @ _homogeneous(*_matrix_offset_from_itkwasm(entry, dimension)) return total[:dimension, :dimension], total[:dimension, dimension] @@ -380,12 +492,36 @@ def _permutation_from_itk(spatial) -> np.ndarray: return permutation -def _frame_geometry(fixed, moving, itk_dims): - """The direction matrices and origins ``ngff_image_to_itk_image`` gives - the two images, in ITK component order.""" +def _inverse_direction(direction: np.ndarray) -> np.ndarray: + """The inverse of an RFC-4 direction matrix. + + Every column names one LPS axis with a sign, so the matrix is a signed + permutation: orthogonal, and its transpose is its exact inverse. Going + through ``np.linalg.inv`` would round where the transpose does not, and + would answer a direction that somehow came through singular with a + ``LinAlgError`` rather than with geometry. + """ + return direction.T + + +class _FrameGeometry(NamedTuple): + """The direction matrices and origins of an image pair, in ITK order. + + A tuple, so it still unpacks straight into :func:`_change_of_frame`, whose + arguments it names in order. + """ + + direction_in: np.ndarray + direction_out: np.ndarray + origin_in: np.ndarray + origin_out: np.ndarray + + +def _frame_geometry(fixed, moving, itk_dims) -> _FrameGeometry: + """The geometry ``ngff_image_to_itk_image`` gives the two images.""" from .itk_transform_resample_bounding_box import _itk_direction - return ( + return _FrameGeometry( _itk_direction(fixed, itk_dims), _itk_direction(moving, itk_dims), np.array([float(fixed.translation[d]) for d in itk_dims]), @@ -414,7 +550,7 @@ def _change_of_frame( would not be enough. With no anatomical orientation every direction is the identity and the mapping comes back unchanged. """ - inverse_out = np.linalg.inv(direction_out) + inverse_out = _inverse_direction(direction_out) identity = np.eye(len(origin_in)) conjugated = inverse_out @ matrix @ direction_in shifted = ( diff --git a/py/ngff_zarr/ngff_transform_to_itk_transform.py b/py/ngff_zarr/ngff_transform_to_itk_transform.py index f3e9f563..8d26660e 100644 --- a/py/ngff_zarr/ngff_transform_to_itk_transform.py +++ b/py/ngff_zarr/ngff_transform_to_itk_transform.py @@ -241,16 +241,17 @@ def ngff_transform_to_itk_transform( :param transform: An RFC-5 (OME-Zarr v0.6) coordinate transformation: a linear mapping, or a ``displacements`` transformation. :type transform: Transform + + :param dims: The axis names of the coordinate system the transformation is + defined on, in RFC-5 (Zarr) order. Only the spatial axes take part. + :type dims: Sequence[str] + :param fields: The field images a ``displacements`` transformation points at, keyed by its ``path``: an ``NgffImage``, or the ``NgffMultiscales`` read from ``f"{store}/{transform.path}"``. Required for a ``displacements`` transformation, ignored otherwise. :type fields: Mapping[str, NgffImage | NgffMultiscales], optional - :param dims: The axis names of the coordinate system the transformation is - defined on, in RFC-5 (Zarr) order. - :type dims: Sequence[str] - :param fixed: The fixed and moving images the transform relates. Passing both lets the conversion re-express the intrinsic-space mapping on ITK physical space, including the direction matrix derived from RFC-4 @@ -270,10 +271,13 @@ def ngff_transform_to_itk_transform( ngff_displacement_field_to_itk_transform, ) + # ITK has no notion of a non-spatial axis, and neither has a field: + # take the same spatial subset the linear path below takes, so `dims` + # can be the image's own dimension names on either branch. return ngff_displacement_field_to_itk_transform( transform, _fields_entry(transform, fields), - dims, + [dim for dim in dims if dim in _SPATIAL_DIMS], fixed=fixed, moving=moving, ) @@ -294,6 +298,7 @@ def ngff_transform_to_itk_transform( _change_of_frame, _check_frame_images, _frame_geometry, + _inverse_direction, ) spatial = [dim for dim in dims if dim in _SPATIAL_DIMS] @@ -307,8 +312,8 @@ def ngff_transform_to_itk_transform( matrix, offset = _change_of_frame( matrix, offset, - np.linalg.inv(direction_fixed), - np.linalg.inv(direction_moving), + _inverse_direction(direction_fixed), + _inverse_direction(direction_moving), origin_fixed, origin_moving, ) diff --git a/py/test/test_displacement_field_transform.py b/py/test/test_displacement_field_transform.py index afc89b02..cd15c3a3 100644 --- a/py/test/test_displacement_field_transform.py +++ b/py/test/test_displacement_field_transform.py @@ -74,12 +74,17 @@ def _transform_point(transform, point): return np.array(transform.TransformPoint([float(value) for value in point])) -@pytest.mark.parametrize("ndim", [2, 3]) -def test_round_trip_matches_transform_point(ndim): +@pytest.mark.parametrize( + "dims", + # The canonical orders, and one of each dimensionality that reversing + # would bind to the wrong ITK axis. + [("y", "x"), ("x", "y"), ("z", "y", "x"), ("x", "z", "y")], +) +def test_round_trip_matches_transform_point(dims): + ndim = len(dims) size = (5, 4, 3)[:ndim] spacing = (0.5, 2.0, 1.5)[:ndim] origin = (10.0, 20.0, -3.0)[:ndim] - dims = CANONICAL[ndim] original = _field_transform(size, spacing, origin) transform, field = itk_displacement_field_to_ngff_transform( @@ -91,8 +96,8 @@ def test_round_trip_matches_transform_point(ndim): assert transform.interpolation == "linear" assert tuple(field.dims) == ("c", *dims) assert field.axes_types == {"c": "displacement"} - assert field.data.shape == (ndim, *size[::-1]) itk_order = [dim for dim in ("x", "y", "z") if dim in dims] + assert field.data.shape == (ndim, *(size[itk_order.index(d)] for d in dims)) for dim in dims: assert field.scale[dim] == spacing[itk_order.index(dim)] assert field.translation[dim] == origin[itk_order.index(dim)] @@ -287,6 +292,49 @@ def test_frames_are_applied_point_by_point(moving_orientation): ) +def test_a_non_spatial_axis_in_dims_is_ignored(): + """``dims`` is usually the image's own dimension names, channel axis and + all. ITK has no non-spatial axis, so the linear path drops them; the field + path drops them the same way rather than refuse the caller's ``dims``.""" + original = _field_transform((3, 2), (1.0, 1.5), (0.0, -2.0)) + transform, field = itk_displacement_field_to_ngff_transform( + original, ("y", "x"), path="w" + ) + + entry = ngff_transform_to_itk_transform( + transform, ("c", "y", "x"), fields={"w": field} + ) + + assert entry[0].transformType.inputDimension == 2 + rebuilt = _native(entry) + for point in _points_inside(original): + np.testing.assert_allclose( + _transform_point(rebuilt, point), _transform_point(original, point) + ) + + +def test_an_oriented_field_needs_its_images_to_reach_physical_space(): + """Going back to ITK without them would silently drop the orientation. + + The message names the way through for a bounding box, whose RFC-5 branch + works on the intrinsic systems and cannot take the pair. + """ + size, spacing, origin = (4, 3, 5), (1.0, 1.0, 1.0), (0.0, 0.0, 0.0) + fixed = _frame_image(size, spacing, origin, RAS) + from ngff_zarr.itk_transform_resample_bounding_box import _itk_direction + + original = _field_transform( + size, spacing, origin, direction=_itk_direction(fixed, ["x", "y", "z"]) + ) + transform, field = itk_displacement_field_to_ngff_transform( + original, CANONICAL[3], path="w", fixed=fixed, moving=fixed + ) + assert field.axes_orientations == RAS + + with pytest.raises(ValueError, match="itk_transform_resample_bounding_box"): + ngff_transform_to_itk_transform(transform, CANONICAL[3], fields={"w": field}) + + def test_grid_direction_must_match_the_fixed_image(): size, spacing, origin = (4, 3, 5), (1.0, 1.0, 1.0), (0.0, 0.0, 0.0) flipped = _field_transform( diff --git a/py/test/test_itk_transform_resample_bounding_box.py b/py/test/test_itk_transform_resample_bounding_box.py index 40b866ab..58ff6fcf 100644 --- a/py/test/test_itk_transform_resample_bounding_box.py +++ b/py/test/test_itk_transform_resample_bounding_box.py @@ -18,12 +18,14 @@ from ngff_zarr import ( RAS, NgffImage, + itk_displacement_field_to_ngff_transform, itk_transform_resample_bounding_box, ngff_image_to_itk_image, ) from ngff_zarr.itk_transform_resample_bounding_box import ( _itk_direction, _metadata_only_itk_image, + _shifted_translation, ) from ngff_zarr.ngff_transform_to_itk_transform import _ngff_transform_to_itk_matrix from ngff_zarr.v06.zarr_metadata import ( @@ -532,6 +534,27 @@ def test_crop_is_lazy_and_shifts_the_translation(): assert np.prod(cropped.data.shape) < 0.01 * np.prod(moving.data.shape) +def test_crop_binds_itk_axes_by_name_in_a_non_canonical_order(): + """A sub-grid's origin moves along the *oriented* axes. + + ``translation`` is keyed by name while the direction matrix is in ITK + component order, so the shift has to be read back by name too. Reversing + ``dims`` gives that order for ``zyx`` and ``yx``, and for nothing else. + """ + moving = _image( + "xyz", + {"x": 32, "y": 32, "z": 32}, + {"x": 0.5, "y": 1.0, "z": 2.0}, + {"x": 3.0, "y": 2.0, "z": 1.0}, + RAS, + ) + + shifted = _shifted_translation(moving, {"x": 4, "y": 6, "z": 8}) + + # RAS is diag(-1, -1, 1) in ITK order, so x and y count backwards. + assert shifted == pytest.approx({"x": 1.0, "y": -4.0, "z": 17.0}) + + def test_crop_preserves_orientation_and_scale(): moving = _image( "zyx", @@ -744,6 +767,33 @@ def test_non_linear_displacement_field_is_supported(): assert bounding_box.size == {"y": 34, "x": 34} +def test_rfc5_displacements_matches_the_itk_field_it_came_from(): + """The RFC-5 branch has to reach the same region for a field as for an + affine, which means the field it points at has to reach the pipeline.""" + itk = pytest.importorskip("itk") + + fixed = _image("yx", {"y": 8, "x": 8}, {"y": 8.0, "x": 8.0}, {"y": 0.0, "x": 0.0}) + moving = _image( + "yx", {"y": 64, "x": 64}, {"y": 1.0, "x": 1.0}, {"y": 0.0, "x": 0.0} + ) + warp = _constant_displacement_field(itk, [5.0, -3.0], size=8, spacing=8.0) + + via_itk = itk_transform_resample_bounding_box(warp, fixed, moving) + transform, field = itk_displacement_field_to_ngff_transform( + warp, ("y", "x"), path="warp" + ) + via_rfc5 = itk_transform_resample_bounding_box( + transform, fixed, moving, fields={"warp": field} + ) + + assert via_rfc5.start_index == via_itk.start_index + assert via_rfc5.size == via_itk.size + + # Without the field there is nothing to convert, and the message says so. + with pytest.raises(ValueError, match="no field was passed"): + itk_transform_resample_bounding_box(transform, fixed, moving) + + def test_float_displacement_field_matches_double(): """A float32-parameterized transform yields the double-precision region. diff --git a/py/test/test_itk_transform_to_ngff_transform.py b/py/test/test_itk_transform_to_ngff_transform.py index 89220dd5..44f8c570 100644 --- a/py/test/test_itk_transform_to_ngff_transform.py +++ b/py/test/test_itk_transform_to_ngff_transform.py @@ -225,11 +225,9 @@ def test_simplify_can_be_disabled(): def test_itk_transform_with_angle_parameters_is_converted(): - """Euler stores an angle, not a matrix, so decoding parameters would fail. - - The conversion probes the mapping instead, which is independent of how the - transform packs its parameters. - """ + """Euler stores an angle, not a matrix, so reading its parameters would + need type-specific arithmetic. Every ``MatrixOffsetTransformBase`` answers + ``GetMatrix``/``GetOffset`` alike, whatever it stores underneath.""" itk = pytest.importorskip("itk") euler = itk.Euler2DTransform[itk.D].New() @@ -464,35 +462,57 @@ def _float32_affine(matrix, translation): return transform -# Stored as the nearest doubles; recovery is compared against those doubles, -# so the tolerance below covers only the probing arithmetic itself. +# Stored as the nearest doubles; every recovery below is compared against +# those doubles rather than against the decimals. _ROTATION_3D = [[1.0, 0.0, 0.0], [0.0, 0.8, 0.6], [0.0, -0.6, 0.8]] -@pytest.mark.parametrize("offset", [0.0, 1e3, 1e8, 1e12, 1e15]) -def test_probing_survives_an_offset_that_dwarfs_the_matrix(offset): - """``T(e_j) - T(0)`` cancels catastrophically when the offset is large. - - A step fixed at 1 keeps no significant digit of the matrix once the offset - reaches 1e15, and the recovered transform silently collapses towards a - zero matrix. Worse, the check point has to grow with it: one fixed near - the origin cannot tell a zeroed matrix from a correct one, because the - offset dominates the prediction either way. - - Nanometre coordinates put real electron-microscopy data in this range. - """ +def _affine_3d(matrix, offset): itk = pytest.importorskip("itk") transform = itk.AffineTransform[itk.D, 3].New() - transform.SetMatrix(itk.matrix_from_array(np.asarray(_ROTATION_3D))) - transform.SetTranslation([offset] * 3) + transform.SetMatrix(itk.matrix_from_array(np.asarray(matrix, dtype=float))) + transform.SetTranslation([float(offset)] * 3) transform.SetCenter([0.0] * 3) + return transform - matrix, _ = itk_transform_to_ngff_matrix(transform, ("z", "y", "x")) + +@pytest.mark.parametrize("offset", [0.0, 1e3, 1e8, 1e12, 1e15]) +@pytest.mark.parametrize("coefficient", [1.0, 1e-20]) +def test_an_offset_that_dwarfs_the_matrix_does_not_swallow_it(offset, coefficient): + """The matrix must survive an offset of any magnitude beside it. + + Nanometre coordinates put real electron-microscopy data in the 1e15 range, + where a matrix read back from ``T(e_j) - T(0)`` would be pure cancellation + noise: at a coefficient of 1e-20 the difference rounds to zero outright, + and a transform that inverts is persisted as one that does not. Reading + ``GetMatrix``/``GetOffset`` has no such limit, so equality is exact. + """ + rotation = np.asarray(_ROTATION_3D) * coefficient + matrix, _ = itk_transform_to_ngff_matrix( + _affine_3d(rotation, offset), ("z", "y", "x") + ) reversal = np.eye(3)[::-1] - expected = reversal @ np.asarray(_ROTATION_3D) @ reversal - assert np.allclose(matrix, expected, rtol=0, atol=1e-12) + assert np.array_equal(matrix, reversal @ rotation @ reversal) + + +def test_probing_follows_the_offset_scale(): + """The fallback path, for a transform that carries no matrix of its own. + + Probing is a difference of two nearly equal points, so a step fixed at 1 + keeps no significant digit once the offset reaches 1e15 and the recovered + matrix collapses towards zero. Following the offset's own magnitude keeps + the two terms comparable; the check point has to grow with it too, since + one fixed near the origin cannot tell a zeroed matrix from a correct one. + """ + from ngff_zarr.itk_transform_to_ngff_transform import _matrix_offset_by_probing + + expected = np.asarray(_ROTATION_3D) + for offset in (0.0, 1e3, 1e8, 1e12, 1e15): + matrix, recovered = _matrix_offset_by_probing(_affine_3d(expected, offset), 3) + assert np.allclose(matrix, expected, rtol=0, atol=1e-12) + assert np.allclose(recovered, [offset] * 3) @pytest.mark.parametrize("offset", [0.0, 1e3, 1e8]) @@ -513,15 +533,18 @@ def test_single_precision_transforms_are_not_rejected_as_non_linear(offset): assert np.allclose(matrix, expected, rtol=0, atol=1e-6) -def test_probing_survives_hidden_large_intermediates(): +def test_a_composite_with_hidden_large_intermediates_converts(): """A composite may pass through internal frames that dwarf its outputs. Two translations out to a global frame at 1e9 and back leave T(x) - x exactly constant, but the rounding of those hidden intermediates lands in - the probed values. A tolerance measured against the *output* rejects such + every evaluation. A tolerance measured against the *output* rejects such transforms as non-linear; measured against the working scale it must not. + Composing the children keeps the intermediates out of the result entirely, + so both routes are checked here. """ itk = pytest.importorskip("itk") + from ngff_zarr.itk_transform_to_ngff_transform import _matrix_offset_by_probing big = 1e9 composite = itk.CompositeTransform[itk.D, 3].New() @@ -533,9 +556,12 @@ def test_probing_survives_hidden_large_intermediates(): composite.AddTransform(backward) matrix, offset = itk_transform_to_ngff_matrix(composite, ("z", "y", "x")) + assert np.array_equal(matrix, np.eye(3)) + assert np.array_equal(offset, [1.5, -3.125, 5.25]) - assert np.allclose(matrix, np.eye(3), rtol=0, atol=1e-12) - assert np.allclose(offset, [1.5, -3.125, 5.25]) + probed, probed_offset = _matrix_offset_by_probing(composite, 3) + assert np.allclose(probed, np.eye(3), rtol=0, atol=1e-12) + assert np.allclose(probed_offset, [5.25, -3.125, 1.5]) def test_the_affine_check_still_refuses_a_near_affine_transform(): @@ -563,14 +589,16 @@ def TransformPoint(self, point): @pytest.mark.parametrize("magnitude", [1e-6, 1.0, 1e6]) -def test_probing_accepts_a_genuinely_linear_transform(magnitude): +def test_a_genuinely_linear_transform_is_accepted(magnitude): """The affine check must not fire on ordinary float error. - Probing recomposes the mapping from three evaluations, so the check - compares two arithmetic paths rather than a value against itself. It has to - stay quiet across the range of magnitudes a registration produces. + It compares two arithmetic paths -- the recovered model against the + transform's own evaluation -- rather than a value against itself, so it + has to stay quiet across the range of magnitudes a registration produces, + whichever route recovered the model. """ itk = pytest.importorskip("itk") + from ngff_zarr.itk_transform_to_ngff_transform import _matrix_offset_by_probing euler = itk.Euler3DTransform[itk.D].New() euler.SetRotation(0.3, -0.7, 1.1) @@ -578,12 +606,52 @@ def test_probing_accepts_a_genuinely_linear_transform(magnitude): euler.SetCenter([0.5 * magnitude, magnitude, -1.5 * magnitude]) matrix, offset = itk_transform_to_ngff_matrix(euler, ("z", "y", "x")) + _matrix_offset_by_probing(euler, 3) point = np.array([12.0, -34.0, 56.0]) * magnitude # (z, y, x) expected = np.asarray(euler.TransformPoint(point[::-1].tolist()))[::-1] assert np.allclose(matrix @ point + offset, expected) +def test_a_transform_whose_matrix_contradicts_its_mapping_is_refused(): + """``IsLinear()`` and the stored matrix can both be wrong at once. + + ``AzimuthElevationToCartesianTransform`` inherits ``AffineTransform``, so + it carries an identity matrix and offset and reports itself linear, while + overriding ``TransformPoint`` with a spherical-to-Cartesian mapping + (InsightSoftwareConsortium/ITK#6791). Nothing but confronting the model + with an evaluation catches it. + """ + itk = pytest.importorskip("itk") + template = getattr(itk, "AzimuthElevationToCartesianTransform", None) + if template is None: # not every ITK build wraps it + pytest.skip("this ITK build does not wrap AzimuthElevationToCartesian") + + transform = template[itk.D, 3].New() + assert transform.IsLinear() # the upstream defect this guards against + assert np.array_equal(itk.array_from_matrix(transform.GetMatrix()), np.eye(3)) + + with pytest.raises(NotImplementedError, match="only linear"): + itk_transform_to_ngff_transform(transform, ("z", "y", "x")) + + +def test_a_transform_of_the_wrong_dimensionality_is_named(): + """Left to ITK this is a SWIG TypeError naming an ``itkPointD2``.""" + itk = pytest.importorskip("itk") + + with pytest.raises(ValueError, match="maps 2D to 2D, but the coordinate system"): + itk_transform_to_ngff_matrix( + itk.AffineTransform[itk.D, 2].New(), ("z", "y", "x") + ) + + +@pytest.mark.parametrize("transform", [5, "affine", None, {"affine": 1}]) +def test_an_input_that_is_no_transform_at_all_is_named(transform): + """Not 'int object is not iterable' from inside a list() call.""" + with pytest.raises(TypeError, match="unsupported transform input"): + itk_transform_to_ngff_matrix(transform, ("y", "x")) + + def _itkwasm_scale(scale, center): """An ITK-Wasm scale, with the center of scaling as its fixed parameters.""" from itkwasm import ( @@ -795,9 +863,9 @@ def test_a_composite_entry_is_refused_at_any_position(position): itk_transform_to_ngff_matrix(entries, ("y", "x")) -def test_a_nested_native_composite_converts_by_probing(): - """The safe route for any composite, nested included: probing evaluates - the native transform, so no serialization can drop its children.""" +def test_a_nested_native_composite_converts_exactly(): + """The safe route for any composite, nested included: the native transform + still holds its children, so no serialization can drop them.""" itk = pytest.importorskip("itk") inner = itk.CompositeTransform[itk.D, 2].New() diff --git a/ts/src/io/itk_transform_resample_bounding_box-node.ts b/ts/src/io/itk_transform_resample_bounding_box-node.ts index 4ae7e1f6..51b17eb9 100644 --- a/ts/src/io/itk_transform_resample_bounding_box-node.ts +++ b/ts/src/io/itk_transform_resample_bounding_box-node.ts @@ -31,13 +31,16 @@ import { * geometry is built the way {@link ngffImageToItkImage} builds it, including * the direction matrix derived from RFC-4 anatomical orientation. * - * In both cases the transform maps *fixed* points into *moving* space. + * In both cases the transform maps *fixed* points into *moving* space. An ITK + * transform need not be linear; an RFC-5 transformation is converted first, so + * it must be a linear mapping or a `displacements` transformation whose field + * is passed in `options.fields`. * * @param transform An RFC-5 coordinate transformation or an ITK-Wasm * `TransformList`. * @param fixed The image whose grid is resampled. Geometry only. * @param moving The image to be sampled. Geometry only. - * @param options Padding options. + * @param options `padding`, and `fields` for a `displacements` transformation. * @returns The region, keyed by dimension name in Zarr order. */ export function itkTransformResampleBoundingBox( diff --git a/ts/src/io/itk_transform_resample_bounding_box-shared.ts b/ts/src/io/itk_transform_resample_bounding_box-shared.ts index 7533d8ae..20565160 100644 --- a/ts/src/io/itk_transform_resample_bounding_box-shared.ts +++ b/ts/src/io/itk_transform_resample_bounding_box-shared.ts @@ -12,9 +12,11 @@ import * as zarr from "zarrita"; import type { Image, TransformList } from "itk-wasm"; import { NgffImage } from "../types/ngff_image.ts"; import type { V06Transform } from "../types/zarr_metadata.ts"; +import type { NgffMultiscales } from "../types/multiscales.ts"; import { identityDirection, itkDirection } from "../utils/itk_direction.ts"; export { itkDirection }; import { ngffTransformToItkTransform } from "../utils/ngff_transform_to_itk_transform.ts"; +import { ngffDisplacementFieldToItkTransform } from "../utils/displacement_field_transform.ts"; const SPATIAL_DIMS = ["x", "y", "z"]; @@ -34,6 +36,15 @@ export interface ItkTransformResampleBoundingBoxOptions { * bound. Use 0 for the tight region, or more for wider kernels. */ padding?: number; + /** + * The field images an RFC-5 `displacements` transformation points at, keyed + * by its `path`. Required for a `displacements` transformation, ignored + * otherwise. A field carrying an anatomical orientation is refused here, + * since this branch works on the intrinsic systems where none applies: + * convert it with `ngffDisplacementFieldToItkTransform`, passing `fixed` + * and `moving`, and pass the transform list that returns. + */ + fields?: Record; } /** @@ -289,6 +300,24 @@ export function metadataOnlyItkImage( return itkImage; } +/** The field `fields` holds for a `displacements` transform, with a message. */ +function fieldFor( + transform: { path: string }, + fields: Record | undefined, +): NgffImage | NgffMultiscales { + const field = fields?.[transform.path]; + if (field === undefined) { + const available = Object.keys(fields ?? {}).sort().join(", "); + throw new Error( + `the displacements transform points at '${transform.path}', but no ` + + `field was passed for it (fields given: [${available}]). Load it ` + + `with fromOmeZarr(\`\${store}/${transform.path}\`) and pass ` + + `{ fields: { "${transform.path}": field } }.`, + ); + } + return field; +} + function isV06Transform(value: unknown): value is V06Transform { return typeof value === "object" && value !== null && "type" in value && typeof (value as { type: unknown }).type === "string"; @@ -334,7 +363,6 @@ export async function resampleBoundingBoxShared( checkGeometry("fixed", fixed, fixedSpatial); checkGeometry("moving", moving, fixedSpatial); - // ITK orders points fastest-axis-first, the reverse of the Zarr order. // ITK orders points fastest-axis-first by name: x, then y, then z. // Reversing the dims is only right for the canonical (z, y, x). const itkDims = SPATIAL_DIMS.filter((dim) => fixedSpatial.includes(dim)); @@ -374,7 +402,15 @@ export async function resampleBoundingBoxShared( fixedDirection = itkDirection(fixed, itkDims); movingDirection = itkDirection(moving, itkDims); } else if (isV06Transform(transform)) { - transformList = ngffTransformToItkTransform(transform, fixed.dims); + transformList = transform.type === "displacements" + // The field is an array, so it comes in beside the transformation + // rather than inside it. + ? await ngffDisplacementFieldToItkTransform( + transform, + fieldFor(transform, options.fields), + fixedSpatial, + ) + : ngffTransformToItkTransform(transform, fixed.dims); // An RFC-5 transformation is defined on the intrinsic coordinate system, // which carries no direction matrix. fixedDirection = identityDirection(itkDims.length); diff --git a/ts/src/utils/displacement_field_transform.ts b/ts/src/utils/displacement_field_transform.ts index d95dac84..867ebec6 100644 --- a/ts/src/utils/displacement_field_transform.ts +++ b/ts/src/utils/displacement_field_transform.ts @@ -46,7 +46,14 @@ import { NgffImage } from "../types/ngff_image.ts"; import type { NgffMultiscales } from "../types/multiscales.ts"; import type { Displacements } from "../types/zarr_metadata.ts"; import { toNgffImage } from "../io/to_ngff_image.ts"; -import { directionRows, frameGeometry, itkDirection } from "./itk_direction.ts"; +import { + changeOfFrame, + directionRows, + type FrameGeometry, + itkDirection, + optionalFrameGeometry, + transposed, +} from "./itk_direction.ts"; const SPATIAL_DIMS = ["x", "y", "z"]; @@ -64,13 +71,6 @@ interface ItkField { direction: number[][]; } -interface Frames { - directionIn: number[][]; - directionOut: number[][]; - originIn: number[]; - originOut: number[]; -} - /** The fixed and moving images a converted field relates. */ export interface FieldFrames { /** The image whose grid the field is sampled on and maps from. */ @@ -120,31 +120,71 @@ function allClose(left: number[][], right: number[][]): boolean { ); } -function transposed(matrix: number[][]): number[][] { - return matrix[0].map((_, col) => matrix.map((row) => row[col])); -} - function matvec(matrix: number[][], vector: number[]): number[] { return matrix.map((row) => row.reduce((sum, value, col) => sum + value * vector[col], 0) ); } -function frames( - fixed: NgffImage | undefined, - moving: NgffImage | undefined, - dims: string[], -): Frames | undefined { - if ((fixed === undefined) !== (moving === undefined)) { - throw new Error("pass both fixed and moving, or neither"); - } - if (fixed === undefined || moving === undefined) return undefined; - const geometry = frameGeometry(fixed, moving, itkAxisOrder(dims)); +/** + * The frame pair of two images with no orientation and no translation. + * + * With it the terms below all vanish, so the conversion that changes frames + * and the one that does not are the same arithmetic rather than two branches. + */ +function unorientedFrames(dimension: number): FrameGeometry { + const zero = new Array(dimension).fill(0); + return { + directionFixed: identity(dimension), + directionMoving: identity(dimension), + originFixed: zero, + originMoving: zero, + }; +} + +/** `D_out`, `M - I` and `b`: the per-point terms of the two conversions. */ +interface FrameTerms { + directionOut: number[][]; + shiftMatrix: number[][]; + shiftVector: number[]; + /** Whether either `q` term is non-zero; a shared frame makes both vanish. */ + shifts: boolean; +} + +/** + * The per-point terms relating ITK vectors and RFC-5 displacements. + * + * An ITK vector is a difference of two *physical* points; an RFC-5 + * displacement is a difference of two *intrinsic* points. Writing + * `phi_out^-1 . phi_in` as `q -> M q + b` -- the change of frame the affine + * conversion applies, given the identity as its mapping -- the defining + * identity `phi_out(q + d(q)) = phi_in(q) + v(q)` rearranges to + * + * d(q) = D_out^-1 v + (M - I) q + b + * v(q) = D_out (d - (M - I) q - b) + * + * so one derivation serves both directions. The two `q` terms vanish when the + * images share a frame, leaving `d = D_out^-1 v`. + */ +function frameTerms(frame: FrameGeometry): FrameTerms { + const dimension = frame.originFixed.length; + const { matrix, offset } = changeOfFrame( + identity(dimension), + new Array(dimension).fill(0), + frame.directionFixed, + frame.directionMoving, + frame.originFixed, + frame.originMoving, + ); + const shiftMatrix = matrix.map((row, i) => + row.map((value, j) => value - (i === j ? 1 : 0)) + ); return { - directionIn: geometry.directionFixed, - directionOut: geometry.directionMoving, - originIn: geometry.originFixed, - originOut: geometry.originMoving, + directionOut: frame.directionMoving, + shiftMatrix, + shiftVector: offset, + shifts: shiftMatrix.some((row) => row.some((value) => value !== 0)) || + offset.some((value) => value !== 0), }; } @@ -300,10 +340,11 @@ export async function itkDisplacementFieldToNgffTransform( ); } const itkDims = itkAxisOrder(dims); - const frame = frames(options.fixed, options.moving, dims); - - let gridOrigin: number[]; - let toIntrinsic: (vector: number[], point: number[]) => number[]; + let frame = optionalFrameGeometry( + options.fixed, + options.moving, + itkDims, + ); if (frame === undefined) { if (!allClose(direction, identity(dimension))) { throw new Error( @@ -313,44 +354,33 @@ export async function itkDisplacementFieldToNgffTransform( "resample the field onto the fixed grid.", ); } - gridOrigin = origin; - toIntrinsic = (vector) => vector; - } else { - if (!allClose(direction, frame.directionIn)) { - throw new Error( - "the field's grid is not oriented like the fixed image: its " + - `direction is ${JSON.stringify(direction)} where the fixed image ` + - `gives ${JSON.stringify(frame.directionIn)}. Resample the field ` + - "onto the fixed grid first.", - ); - } - const inverseIn = transposed(frame.directionIn); - const inverseOut = transposed(frame.directionOut); - gridOrigin = matvec( - inverseIn, - origin.map((value, i) => value - frame.originIn[i]), - ).map((value, i) => value + frame.originIn[i]); - const shared = allClose(frame.directionIn, frame.directionOut) && - frame.originIn.every((value, i) => - Math.abs(value - frame.originOut[i]) <= 1e-9 - ); - toIntrinsic = shared - ? (vector) => matvec(inverseOut, vector) - : (vector, point) => { - const rotated = matvec( - frame.directionIn, - point.map((value, i) => value - frame.originIn[i]), - ); - const shifted = vector.map( - (value, i) => - value + rotated[i] + frame.originIn[i] - frame.originOut[i], - ); - return matvec(inverseOut, shifted).map( - (value, i) => value + frame.originOut[i] - point[i], - ); - }; + frame = unorientedFrames(dimension); + } else if (!allClose(direction, frame.directionFixed)) { + throw new Error( + "the field's grid is not oriented like the fixed image: its " + + `direction is ${JSON.stringify(direction)} where the fixed image ` + + `gives ${JSON.stringify(frame.directionFixed)}. Resample the field ` + + "onto the fixed grid first.", + ); } + // The field's grid follows phi_in^-1, so its origin moves and its spacing + // does not. + const gridOrigin = matvec( + transposed(frame.directionFixed), + origin.map((value, i) => value - frame.originFixed[i]), + ).map((value, i) => value + frame.originFixed[i]); + + // d(q) = D_out^-1 v + (M - I) q + b -- see frameTerms. + const terms = frameTerms(frame); + const inverseOut = transposed(terms.directionOut); + const toIntrinsic = (vector: number[], point: number[]): number[] => { + const rotated = matvec(inverseOut, vector); + if (!terms.shifts) return rotated; + const shift = matvec(terms.shiftMatrix, point); + return rotated.map((value, i) => value + shift[i] + terms.shiftVector[i]); + }; + // (c, *dims) with components in dims order, C-contiguous. const shape = dims.map((dim) => size[itkDims.indexOf(dim)]); const voxels = size.reduce((a, b) => a * b, 1); @@ -520,10 +550,7 @@ export async function ngffDisplacementFieldToItkTransform( const translation = itkDims.map((dim) => image.translation[dim]); const ownDirection = directionRows(itkDirection(image, itkDims), dimension); - const frame = frames(frames_.fixed, frames_.moving, dims); - let origin: number[]; - let direction: number[][]; - let toPhysical: (displacement: number[], point: number[]) => number[]; + let frame = optionalFrameGeometry(frames_.fixed, frames_.moving, itkDims); if (frame === undefined) { if (!allClose(ownDirection, identity(dimension))) { throw new Error( @@ -531,44 +558,35 @@ export async function ngffDisplacementFieldToItkTransform( "moving images so its grid is placed in their frame", ); } - origin = translation; - direction = identity(dimension); - toPhysical = (displacement) => displacement; - } else { - if ( - !allClose(ownDirection, identity(dimension)) && - !allClose(ownDirection, frame.directionIn) - ) { - throw new Error( - "the field's orientation is not the fixed image's: it gives " + - `${JSON.stringify(ownDirection)} where the fixed image gives ` + - `${JSON.stringify(frame.directionIn)}`, - ); - } - origin = matvec( - frame.directionIn, - translation.map((value, i) => value - frame.originIn[i]), - ).map((value, i) => value + frame.originIn[i]); - direction = frame.directionIn; - const shared = allClose(frame.directionIn, frame.directionOut) && - frame.originIn.every((value, i) => - Math.abs(value - frame.originOut[i]) <= 1e-9 - ); - toPhysical = shared - ? (displacement) => matvec(frame.directionOut, displacement) - : (displacement, point) => { - const moved = matvec( - frame.directionOut, - displacement.map((value, i) => value + point[i] - frame.originOut[i]), - ).map((value, i) => value + frame.originOut[i]); - const rotated = matvec( - frame.directionIn, - point.map((value, i) => value - frame.originIn[i]), - ); - return moved.map((value, i) => value - rotated[i] - frame.originIn[i]); - }; + frame = unorientedFrames(dimension); + } else if ( + !allClose(ownDirection, identity(dimension)) && + !allClose(ownDirection, frame.directionFixed) + ) { + throw new Error( + "the field's orientation is not the fixed image's: it gives " + + `${JSON.stringify(ownDirection)} where the fixed image gives ` + + `${JSON.stringify(frame.directionFixed)}`, + ); } + const direction = frame.directionFixed; + const origin = matvec( + direction, + translation.map((value, i) => value - frame.originFixed[i]), + ).map((value, i) => value + frame.originFixed[i]); + + // v(q) = D_out (d - (M - I) q - b) -- see frameTerms. + const terms = frameTerms(frame); + const toPhysical = (displacement: number[], point: number[]): number[] => { + if (!terms.shifts) return matvec(terms.directionOut, displacement); + const shift = matvec(terms.shiftMatrix, point); + return matvec( + terms.directionOut, + displacement.map((value, i) => value - shift[i] - terms.shiftVector[i]), + ); + }; + if ( transform.interpolation !== undefined && transform.interpolation !== "linear" diff --git a/ts/src/utils/itk_direction.ts b/ts/src/utils/itk_direction.ts index d1a7d0a5..d9a2fd78 100644 --- a/ts/src/utils/itk_direction.ts +++ b/ts/src/utils/itk_direction.ts @@ -136,6 +136,25 @@ export function frameGeometry( }; } +/** + * The frame geometry of an optional image pair, or `undefined` for neither. + * + * The counterpart of Python's `_check_frame_images`: every converter takes the + * pair as two optional arguments and every one of them owes the same rule, so + * it lives here rather than in each of them. + */ +export function optionalFrameGeometry( + fixed: NgffImage | undefined, + moving: NgffImage | undefined, + itkDims: string[], +): FrameGeometry | undefined { + if ((fixed === undefined) !== (moving === undefined)) { + throw new Error("pass both fixed and moving, or neither"); + } + if (fixed === undefined || moving === undefined) return undefined; + return frameGeometry(fixed, moving, itkDims); +} + /** * Re-express `y = A x + t` through a change of frame on each side. * diff --git a/ts/src/utils/itk_transform_to_ngff_transform.ts b/ts/src/utils/itk_transform_to_ngff_transform.ts index c9158740..52ec5495 100644 --- a/ts/src/utils/itk_transform_to_ngff_transform.ts +++ b/ts/src/utils/itk_transform_to_ngff_transform.ts @@ -26,7 +26,7 @@ import type { Transform, TransformList } from "itk-wasm"; import type { NgffImage } from "../types/ngff_image.ts"; -import { changeOfFrame, frameGeometry } from "./itk_direction.ts"; +import { changeOfFrame, optionalFrameGeometry } from "./itk_direction.ts"; import { type Affine, createAffine, @@ -234,6 +234,12 @@ export interface FrameImages { * @param transform An ITK-Wasm `Transform` or `TransformList`. * @param dims The axis names of the coordinate system the result should be * expressed on, in RFC-5 (Zarr) order. Only the spatial axes take part. + * @param frames The `fixed` and `moving` images the transform was produced on. + * An ITK transform acts on ITK physical space, which includes the direction + * matrix derived from RFC-4 anatomical orientation; an RFC-5 transformation + * acts on the intrinsic coordinate systems. Passing both lets the conversion + * change frames exactly. Omitting them is exact only when neither image + * carries an anatomical orientation; pass both or neither. * @returns The matrix and offset over the spatial axes, in Zarr order. */ export function itkTransformToNgffMatrix( @@ -268,9 +274,10 @@ export function itkTransformToNgffMatrix( // A parameterless 'Composite' entry is ambiguous. The ITK-Wasm // pipeline writes one as a grouping header before the children, but // itk.dict_from_transform never writes a header at all: there it is a - // *nested* composite whose children the serialization dropped, at any - // position including the first. Decoding past one would silently - // compose the wrong mapping, so refuse it wherever it appears. + // *nested* composite whose children the serialization dropped + // (InsightSoftwareConsortium/ITK#6792), at any position including the + // first. Decoding past one would silently compose the wrong mapping, + // so refuse it wherever it appears. throw new Error( `a 'Composite' entry in an ITK-Wasm transform list cannot be ` + `decoded: the serialization drops a nested composite's children, ` + @@ -299,13 +306,10 @@ export function itkTransformToNgffMatrix( (_, row) => total[row][dimension], ); - if ((frames.fixed === undefined) !== (frames.moving === undefined)) { - throw new Error("pass both fixed and moving, or neither"); - } - if (frames.fixed !== undefined && frames.moving !== undefined) { + const itkOrder = SPATIAL_DIMS.filter((dim) => spatial.includes(dim)); + const geometry = optionalFrameGeometry(frames.fixed, frames.moving, itkOrder); + if (geometry !== undefined) { // ITK physical space -> the intrinsic systems: phi_m^-1 . T . phi_f. - const itkDims = SPATIAL_DIMS.filter((dim) => spatial.includes(dim)); - const geometry = frameGeometry(frames.fixed, frames.moving, itkDims); ({ matrix, offset } = changeOfFrame( matrix, offset, @@ -320,7 +324,6 @@ export function itkTransformToNgffMatrix( // ITK orders components by name (x, then y, then z), so the mapping is // built by name rather than by reversing, which is only equivalent for // the canonical (z, y, x). - const itkOrder = SPATIAL_DIMS.filter((dim) => spatial.includes(dim)); const order = spatial.map((dim) => itkOrder.indexOf(dim)); return { matrix: order.map((row) => order.map((col) => matrix[row][col])), @@ -347,6 +350,8 @@ export function itkTransformToNgffMatrix( * and translation, so a bare `translation` or an `affine` belongs in the * multiscales-level `coordinateTransformations` instead. Pass `false` to * always get an `affine`. + * @param frames The `fixed` and `moving` images the transform was produced on; + * see {@link itkTransformToNgffMatrix}. Pass both or neither. * @returns An RFC-5 coordinate transformation over `dims`. */ export function itkTransformToNgffTransform( diff --git a/ts/src/utils/ngff_transform_to_itk_transform.ts b/ts/src/utils/ngff_transform_to_itk_transform.ts index ce86a38f..90c9096e 100644 --- a/ts/src/utils/ngff_transform_to_itk_transform.ts +++ b/ts/src/utils/ngff_transform_to_itk_transform.ts @@ -28,7 +28,11 @@ import type { Transform, TransformList } from "itk-wasm"; import type { NgffImage } from "../types/ngff_image.ts"; -import { changeOfFrame, frameGeometry, transposed } from "./itk_direction.ts"; +import { + changeOfFrame, + optionalFrameGeometry, + transposed, +} from "./itk_direction.ts"; import type { V06Transform } from "../types/zarr_metadata.ts"; const SPATIAL_DIMS = ["x", "y", "z"]; @@ -297,15 +301,12 @@ export function ngffTransformToItkTransform( let { matrix, offset } = ngffTransformToItkMatrix(transform, dims); const dimension = offset.length; - if ((frames.fixed === undefined) !== (frames.moving === undefined)) { - throw new Error("pass both fixed and moving, or neither"); - } - if (frames.fixed !== undefined && frames.moving !== undefined) { + const spatial = dims.filter((dim) => SPATIAL_DIMS.includes(dim)); + const itkDims = SPATIAL_DIMS.filter((dim) => spatial.includes(dim)); + const geometry = optionalFrameGeometry(frames.fixed, frames.moving, itkDims); + if (geometry !== undefined) { // The intrinsic systems -> ITK physical space: phi_m . T . phi_f^-1, // which is the same change of frame with the directions inverted. - const spatial = dims.filter((dim) => SPATIAL_DIMS.includes(dim)); - const itkDims = SPATIAL_DIMS.filter((dim) => spatial.includes(dim)); - const geometry = frameGeometry(frames.fixed, frames.moving, itkDims); ({ matrix, offset } = changeOfFrame( matrix, offset, diff --git a/ts/test/displacement_field_transform_test.ts b/ts/test/displacement_field_transform_test.ts index 7bc1800e..72a8b880 100644 --- a/ts/test/displacement_field_transform_test.ts +++ b/ts/test/displacement_field_transform_test.ts @@ -195,12 +195,16 @@ Deno.test("components follow dims order", async () => { assertEquals(xy.field.scale, { c: 1, x: 0.5, y: 2.0 }); }); -for (const ndim of [2, 3]) { - Deno.test(`round trip gives back the entry (${ndim}D)`, async () => { +// The canonical orders, and one of each dimensionality that reversing would +// bind to the wrong ITK axis. +for ( + const dims of [["y", "x"], ["x", "y"], ["z", "y", "x"], ["x", "z", "y"]] +) { + Deno.test(`round trip gives back the entry (${dims.join("")})`, async () => { + const ndim = dims.length; const size = [5, 4, 3].slice(0, ndim); const spacing = [0.5, 2.0, 1.5].slice(0, ndim); const origin = [10.0, 20.0, -3.0].slice(0, ndim); - const dims = CANONICAL[ndim]; const entry = fieldTransform(size, spacing, origin); const { transform, field } = await itkDisplacementFieldToNgffTransform( @@ -208,8 +212,11 @@ for (const ndim of [2, 3]) { dims, { path: "warp" }, ); - assertEquals(field.data.shape, [ndim, ...size.slice().reverse()]); const itkOrder = ["x", "y", "z"].slice(0, ndim); + assertEquals(field.data.shape, [ + ndim, + ...dims.map((dim) => size[itkOrder.indexOf(dim)]), + ]); for (const dim of dims) { assertEquals(field.scale[dim], spacing[itkOrder.indexOf(dim)]); assertEquals(field.translation[dim], origin[itkOrder.indexOf(dim)]); diff --git a/ts/test/itk_transform_resample_bounding_box_test.ts b/ts/test/itk_transform_resample_bounding_box_test.ts index d55443d1..d4437241 100644 --- a/ts/test/itk_transform_resample_bounding_box_test.ts +++ b/ts/test/itk_transform_resample_bounding_box_test.ts @@ -27,6 +27,7 @@ import { createScale, createTransformSequence, createTranslation, + itkDisplacementFieldToNgffTransform, itkTransformResampleBoundingBox, itkTransformToNgffTransform, NgffImage, @@ -778,6 +779,82 @@ Deno.test("the RFC-5 branch ignores anatomical orientation", async () => { assertEquals(oriented.size, plain.size); }); +Deno.test("an RFC-5 displacements transform reaches the same region", async () => { + // The RFC-5 branch has to reach the same region for a field as for an + // affine, which means the field it points at has to reach the pipeline. + const size = [8, 8]; + const spacing = [8.0, 8.0]; + const shift = [5.0, -3.0]; + const parameters = new Float64Array(size[0] * size[1] * 2); + for (let i = 0; i < parameters.length; i += 2) { + parameters[i] = shift[0]; + parameters[i + 1] = shift[1]; + } + const warp = { + transformType: { + transformParameterization: "DisplacementField", + parametersValueType: "float64", + inputDimension: 2, + outputDimension: 2, + }, + name: "DisplacementFieldTransform", + inputSpaceName: "", + outputSpaceName: "", + numberOfFixedParameters: 10, + numberOfParameters: parameters.length, + fixedParameters: new Float64Array([ + ...size, + 0, + 0, + ...spacing, + 1, + 0, + 0, + 1, + ]), + parameters, + metadata: new Map(), + // deno-lint-ignore no-explicit-any + } as any; + + const fixed = await geometryImage( + ["y", "x"], + { y: 8, x: 8 }, + { y: 8, x: 8 }, + { + y: 0, + x: 0, + }, + ); + const moving = await geometryImage(["y", "x"], { y: 64, x: 64 }, { + y: 1, + x: 1, + }, { y: 0, x: 0 }); + + const viaItk = await itkTransformResampleBoundingBox([warp], fixed, moving); + const { transform, field } = await itkDisplacementFieldToNgffTransform( + warp, + ["y", "x"], + { path: "warp" }, + ); + const viaRfc5 = await itkTransformResampleBoundingBox( + transform, + fixed, + moving, + { fields: { warp: field } }, + ); + + assertEquals(viaRfc5.startIndex, viaItk.startIndex); + assertEquals(viaRfc5.size, viaItk.size); + + // Without the field there is nothing to convert, and the message says so. + await assertRejects( + () => itkTransformResampleBoundingBox(transform, fixed, moving), + Error, + "no field was passed", + ); +}); + Deno.test("converting with frames matches the ITK path on oriented images", async () => { // The acceptance test for the change of frame: an ITK transform acts on // physical space (direction matrix included), the RFC-5 branch on the @@ -859,13 +936,29 @@ Deno.test("converting with frames matches the ITK path on oriented images", asyn assertEquals(viaRfc5.startIndex, viaItk.startIndex); assertEquals(viaRfc5.size, viaItk.size); - // Passing only one image is refused; unoriented frames are a no-op. + // Passing only one image is refused. assertThrows( () => itkTransformToNgffTransform(transform, ["z", "y", "x"], true, { fixed }), Error, "both fixed and moving", ); + + // Passing an unoriented pair is a no-op: every direction is the identity + // and the change of frame comes back with the mapping it was given. + const plain = await geometryImage( + ["z", "y", "x"], + { z: 8, y: 8, x: 8 }, + { z: 1, y: 2, x: 3 }, + { z: 1.3, y: -2.7, x: 5.1 }, + ); + assertEquals( + itkTransformToNgffTransform(transform, ["z", "y", "x"], false, { + fixed: plain, + moving: plain, + }), + itkTransformToNgffTransform(transform, ["z", "y", "x"], false), + ); }); Deno.test("frames round trip through both converters", async () => { From d3e48f53500d1f0726ff3e4334d03f231a58b62b Mon Sep 17 00:00:00 2001 From: Valentin Boussot Date: Tue, 25 Aug 2026 14:02:26 +0200 Subject: [PATCH 08/11] refactor(py,ts)!: name the resamplers after what they take itk_transform_resample_bounding_box takes an RFC-5 coordinate transformation as readily as an ITK transform, so its prefix names half of what it accepts. It is now resample_bounding_box, and itk_transform_resample is resample, with the modules renamed to match. In TypeScript itkTransformResampleBoundingBox is resampleBoundingBox and its options type is ResampleBoundingBoxOptions. BREAKING CHANGE: the old names are removed rather than kept as aliases. --- docs/itk.md | 26 ++--- docs/rfc5.md | 2 +- .../itk_elastix_transform_resample_s3.ipynb | 22 ++--- py/ngff_zarr/__init__.py | 14 +-- py/ngff_zarr/displacement_field_transform.py | 4 +- .../itk_transform_to_ngff_transform.py | 2 +- ...{itk_transform_resample.py => resample.py} | 14 ++- ...unding_box.py => resample_bounding_box.py} | 6 +- py/test/test_displacement_field_transform.py | 8 +- .../test_itk_transform_to_ngff_transform.py | 12 +-- ...transform_resample.py => test_resample.py} | 60 ++++++------ ...g_box.py => test_resample_bounding_box.py} | 98 ++++++++----------- ts/src/browser-mod.ts | 6 +- ...er.ts => resample_bounding_box-browser.ts} | 14 +-- ...-node.ts => resample_bounding_box-node.ts} | 8 +- ...red.ts => resample_bounding_box-shared.ts} | 6 +- ...unding_box.ts => resample_bounding_box.ts} | 10 +- ts/src/mod.ts | 2 +- ..._test.ts => resample_bounding_box_test.ts} | 64 ++++++------ 19 files changed, 178 insertions(+), 200 deletions(-) rename py/ngff_zarr/{itk_transform_resample.py => resample.py} (97%) rename py/ngff_zarr/{itk_transform_resample_bounding_box.py => resample_bounding_box.py} (99%) rename py/test/{test_itk_transform_resample.py => test_resample.py} (89%) rename py/test/{test_itk_transform_resample_bounding_box.py => test_resample_bounding_box.py} (91%) rename ts/src/io/{itk_transform_resample_bounding_box-browser.ts => resample_bounding_box-browser.ts} (67%) rename ts/src/io/{itk_transform_resample_bounding_box-node.ts => resample_bounding_box-node.ts} (91%) rename ts/src/io/{itk_transform_resample_bounding_box-shared.ts => resample_bounding_box-shared.ts} (98%) rename ts/src/io/{itk_transform_resample_bounding_box.ts => resample_bounding_box.ts} (58%) rename ts/test/{itk_transform_resample_bounding_box_test.ts => resample_bounding_box_test.ts} (93%) diff --git a/docs/itk.md b/docs/itk.md index ff78b859..0cdbf910 100644 --- a/docs/itk.md +++ b/docs/itk.md @@ -53,7 +53,7 @@ samples inside the transformed footprint of that grid. When the moving image is large, remote, or chunked, materializing all of it to resample a small overlapping region is wasteful. -`itk_transform_resample_bounding_box` answers *which moving-image indices will +`resample_bounding_box` answers *which moving-image indices will the resample actually read?* from image geometry alone. The pixel buffers are never touched and the Dask graphs are never computed, so both images can be described by a handful of numbers: @@ -81,7 +81,7 @@ described by a handful of numbers: >>> transform = Affine(affine=[[1.0, 0.0, 12.0], ... [0.0, 1.0, -4.0]]) >>> ->>> region = nz.itk_transform_resample_bounding_box(transform, fixed, moving) +>>> region = nz.resample_bounding_box(transform, fixed, moving) >>> region.start_index {'y': 11, 'x': -5} >>> region.size @@ -115,7 +115,7 @@ bound; pass `0` for the tight region or a larger value for wider kernels. ### Resampling the whole grid -`itk_transform_resample` does the loop for you: it returns a lazy `NgffImage` +`resample` does the loop for you: it returns a lazy `NgffImage` on the grid of `fixed`, where every block reads only the chunks of `moving` inside its own region and resamples that crop. The regions are computed when the graph is built, and the blocks are tasks of one Dask graph that reference @@ -123,14 +123,14 @@ the moving chunks directly, so a chunk that several blocks need is read and decoded once. The full moving image is never loaded, and nothing runs until the result is computed. -`itk_transform_resample` takes an ITK or ITK-Wasm transform, so the RFC-5 +`resample` takes an ITK or ITK-Wasm transform, so the RFC-5 `Affine` above is converted first; `ngff_transform_to_itk_transform` keeps the axis order straight (see [Converting transforms](#converting-transforms)). ```python >>> itk_transform = nz.ngff_transform_to_itk_transform( # doctest: +SKIP ... transform, dims=['y', 'x']) ->>> resampled = nz.itk_transform_resample( # doctest: +SKIP +>>> resampled = nz.resample( # doctest: +SKIP ... itk_transform, fixed, moving) >>> nz.to_ome_zarr("resampled.zarr", # doctest: +SKIP ... nz.to_multiscales(resampled)) @@ -220,7 +220,7 @@ is small. Displacement-field transforms work directly: ```python ->>> region = nz.itk_transform_resample_bounding_box( # doctest: +SKIP +>>> region = nz.resample_bounding_box( # doctest: +SKIP ... displacement_field_transform, fixed, moving) ``` @@ -232,7 +232,7 @@ the `itk.CompositeTransform` that Elastix returns: ```python >>> import itk >>> composite = registration_method.GetCombinedTransform() # doctest: +SKIP ->>> region = nz.itk_transform_resample_bounding_box( # doctest: +SKIP +>>> region = nz.resample_bounding_box( # doctest: +SKIP ... composite, fixed_block, moving) ``` @@ -367,7 +367,7 @@ transformation going the other way. RFC-5 represents deformations with its Computing a bounding box from an **ITK** transform does not require linearity -- that is the section above. An RFC-5 `displacements` transformation is -converted first, so `itk_transform_resample_bounding_box` takes the same +converted first, so `resample_bounding_box` takes the same `fields=` mapping to find its field. Because that branch works on the intrinsic systems, where no direction matrix applies, a field carrying an anatomical orientation is refused there: convert it with `ngff_transform_to_itk_transform` @@ -416,7 +416,7 @@ store, keyed by its `path`: >>> itk_transforms = nz.ngff_transform_to_itk_transform( # doctest: +SKIP ... transform, imported.metadata.dimension_names, ... fields={transform.path: field}) ->>> region = nz.itk_transform_resample_bounding_box( # doctest: +SKIP +>>> region = nz.resample_bounding_box( # doctest: +SKIP ... transform, fixed, moving, fields={transform.path: field}) ``` @@ -448,7 +448,7 @@ In the TypeScript package the equivalents are `ngffTransformToItkTransform`, `itkTransformToNgffTransform` and `itkTransformToNgffMatrix`, and for fields `itkDisplacementFieldToNgffTransform` and `ngffDisplacementFieldToItkTransform`, both async since the field is read from and written to a Zarr array. -`itkTransformResampleBoundingBox` takes the fields as an option there rather +`resampleBoundingBox` takes the fields as an option there rather than an argument, `{ fields: { [path]: field } }`, and `ngffTransformToItkTransform` stays synchronous by leaving fields to the pair above. TypeScript has no `itk` package to fall back on, so only @@ -458,7 +458,7 @@ quaternion-based ones must be converted to an affine first. ## TypeScript -The TypeScript package provides `itkTransformResampleBoundingBox`. It is async, +The TypeScript package provides `resampleBoundingBox`. It is async, takes options as an object, and returns a `ResampleBoundingBox` whose `selection()` yields a zarrita selection instead of Python slices. It accepts an RFC-5 transformation just as the Python function does: @@ -466,11 +466,11 @@ RFC-5 transformation just as the Python function does: ```typescript import { createAffine, - itkTransformResampleBoundingBox, + resampleBoundingBox, zarrGet, } from "@fideus-labs/ngff-zarr"; -const region = await itkTransformResampleBoundingBox( +const region = await resampleBoundingBox( createAffine([[1, 0, 12], [0, 1, -4]]), fixed, moving, diff --git a/docs/rfc5.md b/docs/rfc5.md index 7789b01d..d07ac4a5 100644 --- a/docs/rfc5.md +++ b/docs/rfc5.md @@ -241,7 +241,7 @@ A `displacements` transformation converts too, with array rather than a handful of numbers, so those return, and take, the field image alongside the transformation. -Building on that, `itk_transform_resample_bounding_box` computes which region +Building on that, `resample_bounding_box` computes which region of a moving image a resample through the transformation would read, from geometry alone. See [Out-of-core resampling](./itk.md#out-of-core-resampling) and [Converting transforms](./itk.md#converting-transforms). diff --git a/py/examples/itk_elastix_transform_resample_s3.ipynb b/py/examples/itk_elastix_transform_resample_s3.ipynb index 31827b61..830a9e9f 100644 --- a/py/examples/itk_elastix_transform_resample_s3.ipynb +++ b/py/examples/itk_elastix_transform_resample_s3.ipynb @@ -16,8 +16,8 @@ " registers the coarsest level of the pyramids, which is small enough to fetch\n", " in a few seconds. The transform lives in physical space, so it applies at\n", " any level.\n", - "3. `itk_transform_resample` applies the transform at a fine level. For every\n", - " output block it asks `itk_transform_resample_bounding_box` which region of\n", + "3. `resample` applies the transform at a fine level. For every\n", + " output block it asks `resample_bounding_box` which region of\n", " the moving image is needed, reads only the S3 chunks inside it, and\n", " resamples through `itkwasm-downsample`. The blocks are tasks of one Dask\n", " graph that reference the moving chunks directly, so a chunk that several\n", @@ -65,9 +65,9 @@ "from ngff_zarr import (\n", " NgffImage,\n", " from_ome_zarr,\n", - " itk_transform_resample,\n", - " itk_transform_resample_bounding_box,\n", " ngff_image_to_itk_image,\n", + " resample,\n", + " resample_bounding_box,\n", " to_multiscales,\n", " to_ngff_image,\n", " to_ome_zarr,\n", @@ -285,7 +285,7 @@ "source": [ "## Resample a fine level, streaming from S3 to a local OME-Zarr\n", "\n", - "`itk_transform_resample` returns a lazy `NgffImage` on the fixed grid; nothing\n", + "`resample` returns a lazy `NgffImage` on the fixed grid; nothing\n", "has been read yet. `to_ome_zarr` then drives the computation: each output block\n", "reads the S3 chunks inside its own moving-image bounding box, resamples them,\n", "and is written to disk as soon as it is done, with sixteen blocks in flight at\n", @@ -326,9 +326,7 @@ "source": [ "FINE = 2\n", "\n", - "resampled_brain = itk_transform_resample(\n", - " brain_transform, fixed_levels[FINE], moving_levels[FINE]\n", - ")\n", + "resampled_brain = resample(brain_transform, fixed_levels[FINE], moving_levels[FINE])\n", "print(\n", " f\"Output grid: shape={resampled_brain.data.shape}, blocks={resampled_brain.data.npartitions}\"\n", ")\n", @@ -393,7 +391,7 @@ "## The regions behind the blocks\n", "\n", "Each output block reads its own region of the moving image, and\n", - "`itk_transform_resample_bounding_box` computes that region for any sub-grid of\n", + "`resample_bounding_box` computes that region for any sub-grid of\n", "the fixed image from geometry alone. The views below use level 2 with 64³\n", "output blocks and one axial slice through the middle of the fixed brain; every\n", "block whose z-range contains that slice is drawn on it, and the moving-image\n", @@ -453,7 +451,7 @@ " scale=fixed_view.scale,\n", " translation=translation,\n", " )\n", - " return itk_transform_resample_bounding_box(\n", + " return resample_bounding_box(\n", " brain_transform, block_grid, moving_view, padding=padding\n", " )\n", "\n", @@ -623,7 +621,7 @@ "## Benchmark\n", "\n", "Two knobs decide what a resample costs, and neither is an argument of\n", - "`itk_transform_resample`: the size of an output block is the chunking of the\n", + "`resample`: the size of an output block is the chunking of the\n", "fixed image, and the number of blocks in flight is Dask's `num_workers`.\n", "`benchmark` takes both, and reports the graph build, the wall clock of the\n", "write, the throughput, and the peak resident memory of the process while it\n", @@ -702,7 +700,7 @@ " fixed = replace(fixed, data=fixed.data.rechunk((block,) * fixed.data.ndim))\n", "\n", " start = time.perf_counter()\n", - " image = itk_transform_resample(brain_transform, fixed, moving_levels[level])\n", + " image = resample(brain_transform, fixed, moving_levels[level])\n", " build = time.perf_counter() - start\n", "\n", " store = f\"benchmark_level{level}.ome.zarr\"\n", diff --git a/py/ngff_zarr/__init__.py b/py/ngff_zarr/__init__.py index 6fa6b105..84f07817 100644 --- a/py/ngff_zarr/__init__.py +++ b/py/ngff_zarr/__init__.py @@ -28,11 +28,6 @@ write_hcs_well_image, ) from .itk_image_to_ngff_image import itk_image_to_ngff_image -from .itk_transform_resample import itk_transform_resample -from .itk_transform_resample_bounding_box import ( - ResampleBoundingBox, - itk_transform_resample_bounding_box, -) from .itk_transform_to_ngff_transform import ( itk_transform_to_ngff_matrix, itk_transform_to_ngff_transform, @@ -53,6 +48,11 @@ extract_omero_metadata_from_nibabel, nibabel_image_to_ngff_image, ) +from .resample import resample +from .resample_bounding_box import ( + ResampleBoundingBox, + resample_bounding_box, +) from .rfc4 import ( LPS, RAS, @@ -140,8 +140,8 @@ "itk_displacement_field_to_ngff_transform", "ngff_displacement_field_to_itk_transform", # Out-of-core resampling - "itk_transform_resample", - "itk_transform_resample_bounding_box", + "resample", + "resample_bounding_box", "ResampleBoundingBox", "memory_usage", "task_count", diff --git a/py/ngff_zarr/displacement_field_transform.py b/py/ngff_zarr/displacement_field_transform.py index d2a6fcd9..c7a4f125 100644 --- a/py/ngff_zarr/displacement_field_transform.py +++ b/py/ngff_zarr/displacement_field_transform.py @@ -438,8 +438,8 @@ def ngff_displacement_field_to_itk_transform( from itkwasm import FloatTypes, TransformParameterizations, TransformType from itkwasm import Transform as ItkWasmTransform - from .itk_transform_resample_bounding_box import _itk_direction from .itk_transform_to_ngff_transform import _itk_axis_order + from .resample_bounding_box import _itk_direction dims = _check_dims(dims) if hasattr(field, "images") and hasattr(field, "metadata"): @@ -500,7 +500,7 @@ def ngff_displacement_field_to_itk_transform( msg = ( "the field carries an anatomical orientation, so its grid " "cannot be placed in ITK physical space on its own; pass the " - "fixed and moving images. itk_transform_resample_bounding_box " + "fixed and moving images. resample_bounding_box " "has no place for them, because its RFC-5 branch works on the " "intrinsic systems where no orientation applies: call " "ngff_transform_to_itk_transform with both images yourself and " diff --git a/py/ngff_zarr/itk_transform_to_ngff_transform.py b/py/ngff_zarr/itk_transform_to_ngff_transform.py index 3c13502c..ff689898 100644 --- a/py/ngff_zarr/itk_transform_to_ngff_transform.py +++ b/py/ngff_zarr/itk_transform_to_ngff_transform.py @@ -519,7 +519,7 @@ class _FrameGeometry(NamedTuple): def _frame_geometry(fixed, moving, itk_dims) -> _FrameGeometry: """The geometry ``ngff_image_to_itk_image`` gives the two images.""" - from .itk_transform_resample_bounding_box import _itk_direction + from .resample_bounding_box import _itk_direction return _FrameGeometry( _itk_direction(fixed, itk_dims), diff --git a/py/ngff_zarr/itk_transform_resample.py b/py/ngff_zarr/resample.py similarity index 97% rename from py/ngff_zarr/itk_transform_resample.py rename to py/ngff_zarr/resample.py index 1317dbff..f800dc79 100644 --- a/py/ngff_zarr/itk_transform_resample.py +++ b/py/ngff_zarr/resample.py @@ -6,16 +6,16 @@ import numpy as np -from .itk_transform_resample_bounding_box import ( +from .ngff_image import NgffImage +from .resample_bounding_box import ( _as_itk_transform_list, _check_geometry, _itk_direction, _metadata_only_itk_image, _shifted_translation, _spatial_dims, - itk_transform_resample_bounding_box, + resample_bounding_box, ) -from .ngff_image import NgffImage _INTERPOLATORS = ( "linear", @@ -207,7 +207,7 @@ def _resample_block( return np.asarray(resampled.data).reshape(grid_shape).astype(out_dtype, copy=False) -def itk_transform_resample( +def resample( transform, fixed: NgffImage, moving: NgffImage, @@ -220,7 +220,7 @@ def itk_transform_resample( The result is a lazy :class:`NgffImage`: nothing is read or computed until the returned Dask array is. Each output block is resampled on its own from the moving chunks inside the region its resample reads, as reported by - :func:`ngff_zarr.itk_transform_resample_bounding_box`. The regions are + :func:`ngff_zarr.resample_bounding_box`. The regions are computed once, when the graph is built, and the blocks are tasks of a single Dask graph that reference the moving chunks directly, so a chunk that several blocks need is read and decoded once per computation. The @@ -353,9 +353,7 @@ def moving_key(index): } shape = tuple(int(out_chunks[axis][index[axis]]) for axis in range(len(index))) grid = _block_grid(fixed, starts, shape) - region = itk_transform_resample_bounding_box( - transform_list, grid, moving, padding=padding - ) + region = resample_bounding_box(transform_list, grid, moving, padding=padding) if region.is_empty: graph[(name, *index)] = (np.full, shape, default_value, dtype) continue diff --git a/py/ngff_zarr/itk_transform_resample_bounding_box.py b/py/ngff_zarr/resample_bounding_box.py similarity index 99% rename from py/ngff_zarr/itk_transform_resample_bounding_box.py rename to py/ngff_zarr/resample_bounding_box.py index 18c5bfdf..6636cedf 100644 --- a/py/ngff_zarr/itk_transform_resample_bounding_box.py +++ b/py/ngff_zarr/resample_bounding_box.py @@ -388,7 +388,7 @@ def _transform_from_dict(entry: dict): return ItkTransform(**entry) -def itk_transform_resample_bounding_box( +def resample_bounding_box( transform, fixed: NgffImage, moving: NgffImage, @@ -458,7 +458,7 @@ def itk_transform_resample_bounding_box( the region spans more than the index range the pipeline can represent. :raises NotImplementedError: If the RFC-5 transformation is not linear. """ - from itkwasm_downsample import resample_bounding_box + from itkwasm_downsample import resample_bounding_box as itkwasm_bounding_box if not isinstance(padding, int) or isinstance(padding, bool) or padding < 0: msg = f"padding must be a non-negative integer, got {padding!r}" @@ -525,7 +525,7 @@ def itk_transform_resample_bounding_box( fixed_direction = _itk_direction(fixed, itk_dims) moving_direction = _itk_direction(moving, itk_dims) - result = resample_bounding_box( + result = itkwasm_bounding_box( transform_list, _metadata_only_itk_image(fixed, itk_dims, fixed_direction), _metadata_only_itk_image(moving, itk_dims, moving_direction), diff --git a/py/test/test_displacement_field_transform.py b/py/test/test_displacement_field_transform.py index cd15c3a3..a6f8cc81 100644 --- a/py/test/test_displacement_field_transform.py +++ b/py/test/test_displacement_field_transform.py @@ -239,7 +239,7 @@ def _frame_image(size, spacing, origin, orientations): def _phi(image, point_itk, itk_dims): """An image's intrinsic point to ITK physical space, ``D (q - o) + o``.""" - from ngff_zarr.itk_transform_resample_bounding_box import _itk_direction + from ngff_zarr.resample_bounding_box import _itk_direction direction = _itk_direction(image, itk_dims) origin = np.array([image.translation[dim] for dim in itk_dims]) @@ -257,7 +257,7 @@ def test_frames_are_applied_point_by_point(moving_orientation): moving = _frame_image( size, spacing, (1.0, 1.0, 1.0), RAS if moving_orientation == "same" else None ) - from ngff_zarr.itk_transform_resample_bounding_box import _itk_direction + from ngff_zarr.resample_bounding_box import _itk_direction direction_in = _itk_direction(fixed, itk_dims) assert not np.allclose(direction_in, np.eye(3)) @@ -321,7 +321,7 @@ def test_an_oriented_field_needs_its_images_to_reach_physical_space(): """ size, spacing, origin = (4, 3, 5), (1.0, 1.0, 1.0), (0.0, 0.0, 0.0) fixed = _frame_image(size, spacing, origin, RAS) - from ngff_zarr.itk_transform_resample_bounding_box import _itk_direction + from ngff_zarr.resample_bounding_box import _itk_direction original = _field_transform( size, spacing, origin, direction=_itk_direction(fixed, ["x", "y", "z"]) @@ -331,7 +331,7 @@ def test_an_oriented_field_needs_its_images_to_reach_physical_space(): ) assert field.axes_orientations == RAS - with pytest.raises(ValueError, match="itk_transform_resample_bounding_box"): + with pytest.raises(ValueError, match="resample_bounding_box"): ngff_transform_to_itk_transform(transform, CANONICAL[3], fields={"w": field}) diff --git a/py/test/test_itk_transform_to_ngff_transform.py b/py/test/test_itk_transform_to_ngff_transform.py index 44f8c570..259b7822 100644 --- a/py/test/test_itk_transform_to_ngff_transform.py +++ b/py/test/test_itk_transform_to_ngff_transform.py @@ -376,7 +376,7 @@ def test_non_linear_transform_is_rejected_as_an_itkwasm_entry(): and it would be written into the store without a word. """ itk = pytest.importorskip("itk") - from ngff_zarr.itk_transform_resample_bounding_box import _as_itk_transform_list + from ngff_zarr.resample_bounding_box import _as_itk_transform_list displacement = _displacement_field_transform(itk) # A displacement field is refused with a pointer to its own conversion, @@ -401,7 +401,7 @@ def test_non_linear_transform_is_rejected_as_an_itkwasm_entry(): def test_non_linear_transform_is_rejected_without_itk(monkeypatch): """Refusing a deformation must not depend on the optional ``itk`` extra.""" itk = pytest.importorskip("itk") - from ngff_zarr.itk_transform_resample_bounding_box import _as_itk_transform_list + from ngff_zarr.resample_bounding_box import _as_itk_transform_list entries = _as_itk_transform_list(_displacement_field_transform(itk)) @@ -698,7 +698,7 @@ def test_itkwasm_scale_agrees_with_the_native_itk_transform(): ITK-Wasm entry is decoded from its parameters. """ itk = pytest.importorskip("itk") - from ngff_zarr.itk_transform_resample_bounding_box import _as_itk_transform_list + from ngff_zarr.resample_bounding_box import _as_itk_transform_list scaling = itk.ScaleTransform[itk.D, 2].New() scaling.SetScale([2.0, 3.0]) @@ -1067,7 +1067,7 @@ def test_conversion_with_frames_matches_the_itk_path_on_oriented_images(): """ itk = pytest.importorskip("itk") - from ngff_zarr import itk_transform_resample_bounding_box + from ngff_zarr import resample_bounding_box # Fractional translations keep every corner away from an integer, so the # comparison cannot ride a floor/ceil knife edge. @@ -1083,11 +1083,11 @@ def test_conversion_with_frames_matches_the_itk_path_on_oriented_images(): ) transform = _sheared_itk_affine(itk) - via_itk = itk_transform_resample_bounding_box(transform, fixed, moving, padding=0) + via_itk = resample_bounding_box(transform, fixed, moving, padding=0) converted = itk_transform_to_ngff_transform( transform, ("z", "y", "x"), fixed=fixed, moving=moving ) - via_rfc5 = itk_transform_resample_bounding_box(converted, fixed, moving, padding=0) + via_rfc5 = resample_bounding_box(converted, fixed, moving, padding=0) assert via_rfc5.start_index == via_itk.start_index assert via_rfc5.size == via_itk.size diff --git a/py/test/test_itk_transform_resample.py b/py/test/test_resample.py similarity index 89% rename from py/test/test_itk_transform_resample.py rename to py/test/test_resample.py index 51f489f1..a5a0b262 100644 --- a/py/test/test_itk_transform_resample.py +++ b/py/test/test_resample.py @@ -7,10 +7,10 @@ import pytest from ngff_zarr import ( NgffImage, - itk_transform_resample, - itk_transform_resample_bounding_box, + resample, + resample_bounding_box, ) -from ngff_zarr.itk_transform_resample import _INTERPOLATOR_PADDING +from ngff_zarr.resample import _INTERPOLATOR_PADDING itk = pytest.importorskip("itk") @@ -40,14 +40,14 @@ def _identity(dimension): def _whole_image_reference(transform, fixed, moving, interpolator="linear"): """Resample in a single ITK-Wasm call, without any block decomposition.""" from itkwasm_downsample import resample_to_reference - from ngff_zarr.itk_transform_resample import _component_type - from ngff_zarr.itk_transform_resample_bounding_box import ( + from ngff_zarr.ngff_image_to_itk_image import ngff_image_to_itk_image + from ngff_zarr.resample import _component_type + from ngff_zarr.resample_bounding_box import ( _as_itk_transform_list, _itk_direction, _metadata_only_itk_image, _spatial_dims, ) - from ngff_zarr.ngff_image_to_itk_image import ngff_image_to_itk_image itk_dims = list(reversed(_spatial_dims(fixed))) reference = _metadata_only_itk_image( @@ -71,7 +71,7 @@ def test_block_decomposition_matches_a_single_whole_image_call(): ) transform = _translation(2, [3.5, -2.25]) - result = itk_transform_resample(transform, fixed, moving) + result = resample(transform, fixed, moving) assert result.data.numblocks == (4, 4) expected = _whole_image_reference(transform, fixed, moving) @@ -84,7 +84,7 @@ def test_geometry_and_dtype_follow_the_right_image(): ) fixed = _image("yx", {"y": 16, "x": 8}, {"y": 2.0, "x": 4.0}, {"y": 5.0, "x": 7.0}) - result = itk_transform_resample(_identity(2), fixed, moving) + result = resample(_identity(2), fixed, moving) assert result.data.shape == (16, 8) assert result.dims == ("y", "x") @@ -115,7 +115,7 @@ def counting(block, block_info=None): # dask probes the wrapper as it is built, so measure from there. baseline = len(reads) - result = itk_transform_resample(_identity(2), fixed, moving) + result = resample(_identity(2), fixed, moving) assert len(reads) == baseline np.asarray(result.data) @@ -126,7 +126,7 @@ def test_a_block_outside_the_moving_image_gets_the_default_value(): moving = _image("yx", {"y": 16, "x": 16}, {"y": 1, "x": 1}, {"y": 0, "x": 0}) fixed = _image("yx", {"y": 32, "x": 32}, {"y": 1, "x": 1}, {"y": 1000, "x": 1000}) - result = itk_transform_resample(_identity(2), fixed, moving, default_value=7.0) + result = resample(_identity(2), fixed, moving, default_value=7.0) assert np.all(np.asarray(result.data) == 7.0) @@ -139,17 +139,17 @@ def test_each_block_reads_only_its_own_region(): ) transform = _translation(2, [2.0, 3.0]) - whole = itk_transform_resample_bounding_box(transform, fixed, moving) + whole = resample_bounding_box(transform, fixed, moving) whole_bounds = whole.clamped() - from ngff_zarr.itk_transform_resample import _block_grid + from ngff_zarr.resample import _block_grid block = 16 pad = _INTERPOLATOR_PADDING["linear"] for y_start in range(0, 64, block): for x_start in range(0, 64, block): grid = _block_grid(fixed, {"y": y_start, "x": x_start}, (block, block)) - region = itk_transform_resample_bounding_box(transform, grid, moving) + region = resample_bounding_box(transform, grid, moving) bounds = region.clamped() for dim in ("y", "x"): assert bounds[dim][0] >= whole_bounds[dim][0] @@ -166,7 +166,7 @@ def test_interpolators_are_forwarded(interpolator): transform = _translation(2, [0.5, 0.5]) result = np.asarray( - itk_transform_resample(transform, fixed, moving, interpolator=interpolator).data + resample(transform, fixed, moving, interpolator=interpolator).data ) expected = _whole_image_reference( transform, fixed, moving, interpolator=interpolator @@ -177,14 +177,14 @@ def test_interpolators_are_forwarded(interpolator): def test_an_unknown_interpolator_is_rejected(): fixed = _image("yx", {"y": 8, "x": 8}, {"y": 1, "x": 1}, {"y": 0, "x": 0}) with pytest.raises(ValueError, match="interpolator must be one of"): - itk_transform_resample(_identity(2), fixed, fixed, interpolator="cubic") + resample(_identity(2), fixed, fixed, interpolator="cubic") @pytest.mark.parametrize("bad", [-1, 1.5, float("nan")]) def test_padding_that_is_not_a_non_negative_integer_is_rejected(bad): fixed = _image("yx", {"y": 8, "x": 8}, {"y": 1, "x": 1}, {"y": 0, "x": 0}) with pytest.raises(ValueError, match="padding must be a non-negative integer"): - itk_transform_resample(_identity(2), fixed, fixed, padding=bad) + resample(_identity(2), fixed, fixed, padding=bad) def test_mismatched_dims_are_rejected(): @@ -196,7 +196,7 @@ def test_mismatched_dims_are_rejected(): {"z": 0, "y": 0, "x": 0}, ) with pytest.raises(ValueError, match="they must match"): - itk_transform_resample(_identity(2), fixed, moving) + resample(_identity(2), fixed, moving) def test_a_three_dimensional_grid_is_resampled_block_wise(): @@ -215,7 +215,7 @@ def test_a_three_dimensional_grid_is_resampled_block_wise(): ) transform = _translation(3, [1.5, -2.0, 0.75]) - result = itk_transform_resample(transform, fixed, moving) + result = resample(transform, fixed, moving) assert result.data.numblocks == (2, 2, 2) expected = _whole_image_reference(transform, fixed, moving) @@ -236,7 +236,7 @@ def test_default_padding_reproduces_an_undecomposed_resample(interpolator): transform = _translation(2, [3.5, -2.25]) result = np.asarray( - itk_transform_resample(transform, fixed, moving, interpolator=interpolator).data + resample(transform, fixed, moving, interpolator=interpolator).data ) expected = _whole_image_reference( transform, fixed, moving, interpolator=interpolator @@ -267,7 +267,7 @@ def test_the_fixed_image_pixels_are_never_read(): ) baseline = len(reads) - result = itk_transform_resample(_identity(2), fixed, moving) + result = resample(_identity(2), fixed, moving) np.asarray(result.data) assert len(reads) == baseline @@ -297,7 +297,7 @@ def test_moving_chunks_shared_by_several_blocks_are_read_once(): ) baseline = len(reads) - result = itk_transform_resample(_translation(2, [0.5, 0.5]), fixed, moving) + result = resample(_translation(2, [0.5, 0.5]), fixed, moving) np.asarray(result.data) assert len(reads) - baseline == moving.data.npartitions @@ -324,7 +324,7 @@ def test_a_transform_that_shifts_off_the_moving_image_reads_no_chunks(): ) baseline = len(reads) - result = itk_transform_resample(_identity(2), fixed, moving, default_value=3.0) + result = resample(_identity(2), fixed, moving, default_value=3.0) assert np.all(np.asarray(result.data) == 3.0) assert len(reads) == baseline @@ -348,8 +348,8 @@ def test_moving_geometry_is_part_of_the_graph_name(): "yx", {"y": 32, "x": 32}, {"y": 1, "x": 1}, {"y": 0, "x": 0}, chunks=(16, 16) ) - first = itk_transform_resample(_identity(2), fixed, moving) - second = itk_transform_resample(_identity(2), fixed, shifted) + first = resample(_identity(2), fixed, moving) + second = resample(_identity(2), fixed, shifted) assert first.data.name != second.data.name alone_first = np.asarray(first.data) @@ -382,7 +382,7 @@ def lazy(shape, chunks): fixed = lazy((1024, 1024), (256, 256)) moving = lazy((1024, 1024), (256, 256)) - result = itk_transform_resample(_identity(2), fixed, moving) + result = resample(_identity(2), fixed, moving) layer = result.data.dask.layers[result.data.name] weight = sum(len(pickle.dumps(task, protocol=5)) for task in layer.values()) @@ -400,7 +400,7 @@ def test_a_large_transform_crosses_the_graph_once(): """ import pickle - from ngff_zarr.itk_transform_resample_bounding_box import _as_itk_transform_list + from ngff_zarr.resample_bounding_box import _as_itk_transform_list def lazy(shape, chunks): return NgffImage( @@ -416,7 +416,7 @@ def lazy(shape, chunks): transform.SetTransformDomainPhysicalDimensions([1024.0, 1024.0]) image = lazy((1024, 1024), (256, 256)) - result = itk_transform_resample(transform, image, image) + result = resample(transform, image, image) layer = result.data.dask.layers[result.data.name] weight = sum(len(pickle.dumps(task, protocol=5)) for task in layer.values()) once = len(pickle.dumps(_as_itk_transform_list(transform), protocol=5)) @@ -449,7 +449,7 @@ def test_b_spline_default_padding_is_exact_on_float64_images(): transform = _translation(2, [3.5, -2.25]) result = np.asarray( - itk_transform_resample(transform, fixed, moving, interpolator="b_spline").data + resample(transform, fixed, moving, interpolator="b_spline").data ) expected = _whole_image_reference(transform, fixed, moving, interpolator="b_spline") np.testing.assert_array_equal(result, expected) @@ -494,7 +494,7 @@ def oriented(image): ) transform = _translation(3, [1.5, -2.25, 3.0]) - result = np.asarray(itk_transform_resample(transform, fixed, moving).data) + result = np.asarray(resample(transform, fixed, moving).data) expected = _whole_image_reference(transform, fixed, moving) np.testing.assert_array_equal(result, expected) @@ -514,4 +514,4 @@ def test_non_spatial_dims_are_rejected(): ) with pytest.raises(ValueError, match="non-spatial dims"): - itk_transform_resample(_identity(2), fixed, moving) + resample(_identity(2), fixed, moving) diff --git a/py/test/test_itk_transform_resample_bounding_box.py b/py/test/test_resample_bounding_box.py similarity index 91% rename from py/test/test_itk_transform_resample_bounding_box.py rename to py/test/test_resample_bounding_box.py index 58ff6fcf..8143bcf8 100644 --- a/py/test/test_itk_transform_resample_bounding_box.py +++ b/py/test/test_resample_bounding_box.py @@ -19,15 +19,15 @@ RAS, NgffImage, itk_displacement_field_to_ngff_transform, - itk_transform_resample_bounding_box, ngff_image_to_itk_image, + resample_bounding_box, ) -from ngff_zarr.itk_transform_resample_bounding_box import ( +from ngff_zarr.ngff_transform_to_itk_transform import _ngff_transform_to_itk_matrix +from ngff_zarr.resample_bounding_box import ( _itk_direction, _metadata_only_itk_image, _shifted_translation, ) -from ngff_zarr.ngff_transform_to_itk_transform import _ngff_transform_to_itk_matrix from ngff_zarr.v06.zarr_metadata import ( Affine, Displacements, @@ -144,7 +144,7 @@ def test_ngff_translation_matches_the_documented_worked_example(): fixed = _image("yx", {"y": 16, "x": 16}, {"y": 2, "x": 2}, {"y": 20, "x": 10}) moving = _image("yx", {"y": 64, "x": 64}, {"y": 1, "x": 1}, {"y": 0, "x": 0}) - bounding_box = itk_transform_resample_bounding_box( + bounding_box = resample_bounding_box( Translation(translation=[5.0, 10.0]), fixed, moving, padding=1 ) @@ -161,8 +161,8 @@ def test_padding_is_symmetric(): moving = _image("yx", {"y": 64, "x": 64}, {"y": 1, "x": 1}, {"y": 0, "x": 0}) transform = Translation(translation=[5.0, 10.0]) - padded = itk_transform_resample_bounding_box(transform, fixed, moving, padding=1) - tight = itk_transform_resample_bounding_box(transform, fixed, moving, padding=0) + padded = resample_bounding_box(transform, fixed, moving, padding=1) + tight = resample_bounding_box(transform, fixed, moving, padding=0) for dim in ("y", "x"): assert tight.start_index[dim] == padded.start_index[dim] + 1 @@ -191,7 +191,7 @@ def test_asymmetric_three_dimensional_ngff_affine_matches_oracle(): offset = np.array([4.0, -6.0, 11.0]) affine = np.hstack([matrix, offset.reshape(-1, 1)]).tolist() - bounding_box = itk_transform_resample_bounding_box( + bounding_box = resample_bounding_box( Affine(affine=affine), fixed, moving, padding=2 ) @@ -268,9 +268,7 @@ def test_sequence_order_survives_the_whole_pipeline(): ] ) - bounding_box = itk_transform_resample_bounding_box( - sequence, fixed, moving, padding=0 - ) + bounding_box = resample_bounding_box(sequence, fixed, moving, padding=0) # First entry first: y = x, x = 2(x + 10) = 2x + 20. expected_start, expected_size = _oracle_region( @@ -333,13 +331,13 @@ def test_rfc5_branch_ignores_anatomical_orientation(): translation = dict.fromkeys(spatial, 0.0) transform = Translation(translation=[2.0, 3.0, 4.0]) - plain = itk_transform_resample_bounding_box( + plain = resample_bounding_box( transform, _image(spatial, shape, scale, translation), _image(spatial, {"z": 32, "y": 64, "x": 96}, scale, translation), padding=0, ) - oriented = itk_transform_resample_bounding_box( + oriented = resample_bounding_box( transform, _image(spatial, shape, scale, translation, RAS), _image(spatial, {"z": 32, "y": 64, "x": 96}, scale, translation, RAS), @@ -374,11 +372,11 @@ def test_non_canonical_spatial_order_binds_itk_axes_by_name(): expected = {"z": 1, "x": 7, "y": 5} # RFC-5 parameters are in dims order: (z, x, y). - via_rfc5 = itk_transform_resample_bounding_box( + via_rfc5 = resample_bounding_box( Translation(translation=[1.0, 7.0, 5.0]), fixed, moving, padding=0 ) # ITK parameters are fastest-axis-first by name: (x, y, z). - via_itk = itk_transform_resample_bounding_box( + via_itk = resample_bounding_box( _translation([7.0, 5.0, 1.0]), fixed, moving, padding=0 ) @@ -418,7 +416,7 @@ def test_v04_transform_dataclasses_are_accepted(): fixed = _image("yx", {"y": 16, "x": 16}, {"y": 2, "x": 2}, {"y": 20, "x": 10}) moving = _image("yx", {"y": 64, "x": 64}, {"y": 1, "x": 1}, {"y": 0, "x": 0}) - bounding_box = itk_transform_resample_bounding_box( + bounding_box = resample_bounding_box( nz.Translation(translation=[5.0, 10.0]), fixed, moving, padding=1 ) @@ -434,7 +432,7 @@ def test_mismatched_spatial_dims_are_rejected(): ) moving = _image("yx", {"y": 4, "x": 4}, {"y": 1, "x": 1}, {"y": 0, "x": 0}) with pytest.raises(ValueError, match="they must match"): - itk_transform_resample_bounding_box(_identity(2), fixed, moving) + resample_bounding_box(_identity(2), fixed, moving) def test_pixel_buffers_are_never_computed(): @@ -458,9 +456,7 @@ def explode(block): # dask probes the block function once while building the graph above, so # only what happens from here on counts. computed.clear() - bounding_box = itk_transform_resample_bounding_box( - _identity(2), fixed, fixed, padding=1 - ) + bounding_box = resample_bounding_box(_identity(2), fixed, fixed, padding=1) assert not computed assert bounding_box.start_index == {"y": -1, "x": -1} @@ -474,7 +470,7 @@ def test_non_spatial_axes_are_passed_through(): fixed = _image("tczyx", shape, unit, zero) moving = _image("tczyx", {"t": 3, "c": 2, "z": 16, "y": 32, "x": 32}, unit, zero) - bounding_box = itk_transform_resample_bounding_box( + bounding_box = resample_bounding_box( _translation([3.0, 2.0, 1.0]), fixed, moving, padding=0 ) @@ -490,7 +486,7 @@ def test_region_outside_the_moving_image_is_empty(): fixed = _image("yx", {"y": 4, "x": 4}, {"y": 1, "x": 1}, {"y": 0, "x": 0}) moving = _image("yx", {"y": 32, "x": 32}, {"y": 1, "x": 1}, {"y": 0, "x": 0}) - bounding_box = itk_transform_resample_bounding_box( + bounding_box = resample_bounding_box( _translation([1000.0, 1000.0]), fixed, moving, padding=1 ) @@ -503,9 +499,7 @@ def test_negative_start_index_is_clamped_not_wrapped(): fixed = _image("yx", {"y": 4, "x": 4}, {"y": 1, "x": 1}, {"y": 0, "x": 0}) moving = _image("yx", {"y": 32, "x": 32}, {"y": 1, "x": 1}, {"y": 0, "x": 0}) - bounding_box = itk_transform_resample_bounding_box( - _identity(2), fixed, moving, padding=2 - ) + bounding_box = resample_bounding_box(_identity(2), fixed, moving, padding=2) assert bounding_box.start_index == {"y": -2, "x": -2} assert bounding_box.clamped() == {"y": (0, 6), "x": (0, 6)} @@ -517,7 +511,7 @@ def test_crop_is_lazy_and_shifts_the_translation(): fixed = _image("yx", {"y": 64, "x": 64}, {"y": 1, "x": 1}, {"y": 512, "x": 1024}) moving = _image("yx", {"y": 4096, "x": 4096}, {"y": 2, "x": 2}, {"y": 0, "x": 0}) - bounding_box = itk_transform_resample_bounding_box( + bounding_box = resample_bounding_box( _translation([0.0, 0.0]), fixed, moving, padding=1 ) cropped = bounding_box.crop(moving) @@ -570,7 +564,7 @@ def test_crop_preserves_orientation_and_scale(): {"z": 1.0, "y": 2.0, "x": 3.0}, RAS, ) - bounding_box = itk_transform_resample_bounding_box(_identity(3), fixed, moving) + bounding_box = resample_bounding_box(_identity(3), fixed, moving) cropped = bounding_box.crop(moving) assert cropped.scale == moving.scale @@ -622,9 +616,7 @@ def test_itk_translation_transform_is_accepted(): transform = itk.TranslationTransform[itk.D, 2].New() transform.SetOffset([10.0, 5.0]) # ITK order (x, y) - bounding_box = itk_transform_resample_bounding_box( - transform, fixed, moving, padding=1 - ) + bounding_box = resample_bounding_box(transform, fixed, moving, padding=1) assert bounding_box.start_index == {"y": 24, "x": 19} assert bounding_box.size == {"y": 33, "x": 33} @@ -647,9 +639,7 @@ def test_itk_composite_transform_is_accepted(): fixed = _image("yx", {"y": 16, "x": 16}, {"y": 2, "x": 2}, {"y": 20, "x": 10}) moving = _image("yx", {"y": 512, "x": 512}, {"y": 1, "x": 1}, {"y": 0, "x": 0}) - bounding_box = itk_transform_resample_bounding_box( - composite, fixed, moving, padding=0 - ) + bounding_box = resample_bounding_box(composite, fixed, moving, padding=0) # Cross-checked against the composite's own point mapping. low = composite.TransformPoint([10.0, 20.0]) @@ -672,20 +662,20 @@ def test_index_range_overflow_is_reported_not_silently_empty(): moving = _image("yx", {"y": 64, "x": 64}, {"y": 1, "x": 1}, {"y": 0, "x": 0}) with pytest.raises(ValueError, match="does not contain the transformed grid"): - itk_transform_resample_bounding_box(_identity(2), fixed, moving, padding=1) + resample_bounding_box(_identity(2), fixed, moving, padding=1) @pytest.mark.parametrize("bad", [-1, 1.5, float("nan"), float("inf")]) def test_padding_that_is_not_a_non_negative_integer_is_rejected(bad): fixed = _image("yx", {"y": 4, "x": 4}, {"y": 1, "x": 1}, {"y": 0, "x": 0}) with pytest.raises(ValueError, match="padding must be a non-negative integer"): - itk_transform_resample_bounding_box(_identity(2), fixed, fixed, padding=bad) + resample_bounding_box(_identity(2), fixed, fixed, padding=bad) def test_unsupported_spatial_dimensionality_is_rejected(): fixed = _image("x", {"x": 8}, {"x": 1}, {"x": 0}) with pytest.raises(ValueError, match="only 2 and 3 are supported"): - itk_transform_resample_bounding_box(_identity(2), fixed, fixed) + resample_bounding_box(_identity(2), fixed, fixed) @pytest.mark.parametrize("bad", [float("nan"), float("inf")]) @@ -693,27 +683,27 @@ def test_non_finite_geometry_is_rejected(bad): fixed = _image("yx", {"y": 4, "x": 4}, {"y": 1, "x": 1}, {"y": 0, "x": 0}) fixed.scale["x"] = bad with pytest.raises(ValueError, match="must be finite"): - itk_transform_resample_bounding_box(_identity(2), fixed, fixed) + resample_bounding_box(_identity(2), fixed, fixed) def test_missing_scale_entry_is_rejected(): fixed = _image("yx", {"y": 4, "x": 4}, {"y": 1, "x": 1}, {"y": 0, "x": 0}) del fixed.scale["x"] with pytest.raises(ValueError, match="no entry for dimension 'x'"): - itk_transform_resample_bounding_box(_identity(2), fixed, fixed) + resample_bounding_box(_identity(2), fixed, fixed) def test_zero_scale_is_rejected(): fixed = _image("yx", {"y": 4, "x": 4}, {"y": 1, "x": 0}, {"y": 0, "x": 0}) with pytest.raises(ValueError, match="scale for dimension 'x' is zero"): - itk_transform_resample_bounding_box(_identity(2), fixed, fixed) + resample_bounding_box(_identity(2), fixed, fixed) def test_degenerate_fixed_grid_yields_an_empty_region(): fixed = _image("yx", {"y": 0, "x": 4}, {"y": 1, "x": 1}, {"y": 0, "x": 0}) moving = _image("yx", {"y": 32, "x": 32}, {"y": 1, "x": 1}, {"y": 0, "x": 0}) - bounding_box = itk_transform_resample_bounding_box(_identity(2), fixed, moving) + bounding_box = resample_bounding_box(_identity(2), fixed, moving) assert bounding_box.is_empty assert bounding_box.crop(moving) is None @@ -755,9 +745,7 @@ def test_non_linear_displacement_field_is_supported(): fixed = _image("yx", {"y": 32, "x": 32}, {"y": 1, "x": 1}, {"y": 0, "x": 0}) moving = _image("yx", {"y": 256, "x": 256}, {"y": 1, "x": 1}, {"y": 0, "x": 0}) - bounding_box = itk_transform_resample_bounding_box( - transform, fixed, moving, padding=1 - ) + bounding_box = resample_bounding_box(transform, fixed, moving, padding=1) # A constant field shifts ITK (x, y) by (5, 3), so NGFF (y, x) by (3, 5). # The fixed grid spans 0..31 on both axes. @@ -778,20 +766,18 @@ def test_rfc5_displacements_matches_the_itk_field_it_came_from(): ) warp = _constant_displacement_field(itk, [5.0, -3.0], size=8, spacing=8.0) - via_itk = itk_transform_resample_bounding_box(warp, fixed, moving) + via_itk = resample_bounding_box(warp, fixed, moving) transform, field = itk_displacement_field_to_ngff_transform( warp, ("y", "x"), path="warp" ) - via_rfc5 = itk_transform_resample_bounding_box( - transform, fixed, moving, fields={"warp": field} - ) + via_rfc5 = resample_bounding_box(transform, fixed, moving, fields={"warp": field}) assert via_rfc5.start_index == via_itk.start_index assert via_rfc5.size == via_itk.size # Without the field there is nothing to convert, and the message says so. with pytest.raises(ValueError, match="no field was passed"): - itk_transform_resample_bounding_box(transform, fixed, moving) + resample_bounding_box(transform, fixed, moving) def test_float_displacement_field_matches_double(): @@ -806,13 +792,11 @@ def test_float_displacement_field_matches_double(): fixed = _image("yx", {"y": 32, "x": 32}, {"y": 1, "x": 1}, {"y": 0, "x": 0}) moving = _image("yx", {"y": 256, "x": 256}, {"y": 1, "x": 1}, {"y": 0, "x": 0}) - reference = itk_transform_resample_bounding_box( + reference = resample_bounding_box( _constant_displacement_field(itk, (5.0, 3.0)), fixed, moving, padding=1 ) float_transform = _constant_displacement_field(itk, (5.0, 3.0), ctype=itk.F) - bounding_box = itk_transform_resample_bounding_box( - float_transform, fixed, moving, padding=1 - ) + bounding_box = resample_bounding_box(float_transform, fixed, moving, padding=1) assert bounding_box.start_index == reference.start_index == {"y": 2, "x": 4} assert bounding_box.size == reference.size == {"y": 34, "x": 34} @@ -841,7 +825,7 @@ def test_bspline_transform_is_supported(): fixed = _image("yx", {"y": 32, "x": 32}, {"y": 1, "x": 1}, {"y": 0, "x": 0}) moving = _image("yx", {"y": 64, "x": 64}, {"y": 1, "x": 1}, {"y": 0, "x": 0}) - bounding_box = itk_transform_resample_bounding_box( + bounding_box = resample_bounding_box( _identity_bspline(itk), fixed, moving, padding=1 ) @@ -863,9 +847,7 @@ def test_composite_with_bspline_stage_is_supported(): fixed = _image("yx", {"y": 32, "x": 32}, {"y": 1, "x": 1}, {"y": 0, "x": 0}) moving = _image("yx", {"y": 256, "x": 256}, {"y": 1, "x": 1}, {"y": 0, "x": 0}) - bounding_box = itk_transform_resample_bounding_box( - composite, fixed, moving, padding=1 - ) + bounding_box = resample_bounding_box(composite, fixed, moving, padding=1) # The identity B-spline stage leaves the affine translation of ITK # (x, y) = (5, 3), so NGFF (y, x) = (3, 5), matching the displacement @@ -877,7 +859,7 @@ def test_composite_with_bspline_stage_is_supported(): def test_unsupported_transform_type_is_rejected(): fixed = _image("yx", {"y": 4, "x": 4}, {"y": 1, "x": 1}, {"y": 0, "x": 0}) with pytest.raises(TypeError, match="unsupported transform type"): - itk_transform_resample_bounding_box("not a transform", fixed, fixed) + resample_bounding_box("not a transform", fixed, fixed) def test_asymmetric_three_dimensional_affine_matches_oracle(): @@ -901,7 +883,7 @@ def test_asymmetric_three_dimensional_affine_matches_oracle(): offset = np.array([4.0, -6.0, 11.0]) reversal = np.eye(3)[::-1] - bounding_box = itk_transform_resample_bounding_box( + bounding_box = resample_bounding_box( _affine(reversal @ matrix @ reversal, reversal @ offset), fixed, moving, @@ -947,4 +929,4 @@ def test_unusable_pipeline_index_arrays_are_rejected( fixed = _image("yx", {"y": 4, "x": 4}, {"y": 1, "x": 1}, {"y": 0, "x": 0}) with pytest.raises(ValueError, match=match): - itk_transform_resample_bounding_box(_identity(2), fixed, fixed) + resample_bounding_box(_identity(2), fixed, fixed) diff --git a/ts/src/browser-mod.ts b/ts/src/browser-mod.ts index 29599300..e63c780f 100644 --- a/ts/src/browser-mod.ts +++ b/ts/src/browser-mod.ts @@ -23,11 +23,11 @@ export { itkImageToNgffImage, type ItkImageToNgffImageOptions, } from "./io/itk_image_to_ngff_image.ts"; -export { itkTransformResampleBoundingBox } from "./io/itk_transform_resample_bounding_box-browser.ts"; +export { resampleBoundingBox } from "./io/resample_bounding_box-browser.ts"; export { - type ItkTransformResampleBoundingBoxOptions, ResampleBoundingBox, -} from "./io/itk_transform_resample_bounding_box-shared.ts"; + type ResampleBoundingBoxOptions, +} from "./io/resample_bounding_box-shared.ts"; export { itkTransformToNgffMatrix, itkTransformToNgffTransform, diff --git a/ts/src/io/itk_transform_resample_bounding_box-browser.ts b/ts/src/io/resample_bounding_box-browser.ts similarity index 67% rename from ts/src/io/itk_transform_resample_bounding_box-browser.ts rename to ts/src/io/resample_bounding_box-browser.ts index 4bfa94a9..fec2ae9e 100644 --- a/ts/src/io/itk_transform_resample_bounding_box-browser.ts +++ b/ts/src/io/resample_bounding_box-browser.ts @@ -3,30 +3,30 @@ /** Browser implementation: dispatches through the package's web worker pool. */ -import { resampleBoundingBox } from "@itk-wasm/downsample"; +import { resampleBoundingBox as resampleBoundingBoxWasm } from "@itk-wasm/downsample"; import type { TransformList } from "itk-wasm"; import type { NgffImage } from "../types/ngff_image.ts"; import type { V06Transform } from "../types/zarr_metadata.ts"; import { - type ItkTransformResampleBoundingBoxOptions, type ResampleBoundingBox, + type ResampleBoundingBoxOptions, resampleBoundingBoxShared, -} from "./itk_transform_resample_bounding_box-shared.ts"; +} from "./resample_bounding_box-shared.ts"; /** * Compute the moving-image region needed to resample a fixed image grid. * * Browser counterpart of the Node implementation; see - * `itk_transform_resample_bounding_box-node.ts` for the full description. + * `resample_bounding_box-node.ts` for the full description. */ -export function itkTransformResampleBoundingBox( +export function resampleBoundingBox( transform: V06Transform | TransformList, fixed: NgffImage, moving: NgffImage, - options: ItkTransformResampleBoundingBoxOptions = {}, + options: ResampleBoundingBoxOptions = {}, ): Promise { return resampleBoundingBoxShared( - resampleBoundingBox, + resampleBoundingBoxWasm, transform, fixed, moving, diff --git a/ts/src/io/itk_transform_resample_bounding_box-node.ts b/ts/src/io/resample_bounding_box-node.ts similarity index 91% rename from ts/src/io/itk_transform_resample_bounding_box-node.ts rename to ts/src/io/resample_bounding_box-node.ts index 51b17eb9..7346a1fc 100644 --- a/ts/src/io/itk_transform_resample_bounding_box-node.ts +++ b/ts/src/io/resample_bounding_box-node.ts @@ -8,10 +8,10 @@ import type { TransformList } from "itk-wasm"; import type { NgffImage } from "../types/ngff_image.ts"; import type { V06Transform } from "../types/zarr_metadata.ts"; import { - type ItkTransformResampleBoundingBoxOptions, type ResampleBoundingBox, + type ResampleBoundingBoxOptions, resampleBoundingBoxShared, -} from "./itk_transform_resample_bounding_box-shared.ts"; +} from "./resample_bounding_box-shared.ts"; /** * Compute the moving-image region needed to resample a fixed image grid. @@ -43,11 +43,11 @@ import { * @param options `padding`, and `fields` for a `displacements` transformation. * @returns The region, keyed by dimension name in Zarr order. */ -export function itkTransformResampleBoundingBox( +export function resampleBoundingBox( transform: V06Transform | TransformList, fixed: NgffImage, moving: NgffImage, - options: ItkTransformResampleBoundingBoxOptions = {}, + options: ResampleBoundingBoxOptions = {}, ): Promise { return resampleBoundingBoxShared( resampleBoundingBoxNode, diff --git a/ts/src/io/itk_transform_resample_bounding_box-shared.ts b/ts/src/io/resample_bounding_box-shared.ts similarity index 98% rename from ts/src/io/itk_transform_resample_bounding_box-shared.ts rename to ts/src/io/resample_bounding_box-shared.ts index 20565160..f74ebc32 100644 --- a/ts/src/io/itk_transform_resample_bounding_box-shared.ts +++ b/ts/src/io/resample_bounding_box-shared.ts @@ -28,8 +28,8 @@ interface RawBoundingBox { corners: { min: number[]; max: number[] }; } -/** Options for {@link itkTransformResampleBoundingBox}. */ -export interface ItkTransformResampleBoundingBoxOptions { +/** Options for {@link resampleBoundingBox}. */ +export interface ResampleBoundingBoxOptions { /** * Pixels of padding added per side. The default of 1 covers linear * interpolation, which reads one neighbor beyond the continuous index @@ -338,7 +338,7 @@ export async function resampleBoundingBoxShared( transform: V06Transform | TransformList, fixed: NgffImage, moving: NgffImage, - options: ItkTransformResampleBoundingBoxOptions = {}, + options: ResampleBoundingBoxOptions = {}, ): Promise { const padding = options.padding ?? 1; if (!Number.isInteger(padding) || padding < 0) { diff --git a/ts/src/io/itk_transform_resample_bounding_box.ts b/ts/src/io/resample_bounding_box.ts similarity index 58% rename from ts/src/io/itk_transform_resample_bounding_box.ts rename to ts/src/io/resample_bounding_box.ts index 4d3b1c1d..d1358c21 100644 --- a/ts/src/io/itk_transform_resample_bounding_box.ts +++ b/ts/src/io/resample_bounding_box.ts @@ -6,16 +6,16 @@ * * This module provides conditional exports for browser and Node environments. * The actual implementation is delegated to environment-specific modules: - * - itk_transform_resample_bounding_box-browser.ts: WebWorker-based functions - * - itk_transform_resample_bounding_box-node.ts: native WASM for Node/Deno + * - resample_bounding_box-browser.ts: WebWorker-based functions + * - resample_bounding_box-node.ts: native WASM for Node/Deno * * For Deno runtime, we default to the node implementation. * For browser bundlers, they should use conditional exports in package.json * to resolve to the browser implementation. */ -export { itkTransformResampleBoundingBox } from "./itk_transform_resample_bounding_box-node.ts"; +export { resampleBoundingBox } from "./resample_bounding_box-node.ts"; export { - type ItkTransformResampleBoundingBoxOptions, ResampleBoundingBox, -} from "./itk_transform_resample_bounding_box-shared.ts"; + type ResampleBoundingBoxOptions, +} from "./resample_bounding_box-shared.ts"; diff --git a/ts/src/mod.ts b/ts/src/mod.ts index bb88c0ff..8ea25e4c 100644 --- a/ts/src/mod.ts +++ b/ts/src/mod.ts @@ -5,7 +5,7 @@ export { config, setWorkerPoolSize } from "./config.ts"; export * from "./io/from_ngff_zarr.ts"; export * from "./io/hcs.ts"; export * from "./io/itk_image_to_ngff_image.ts"; -export * from "./io/itk_transform_resample_bounding_box.ts"; +export * from "./io/resample_bounding_box.ts"; export * from "./io/ngff_image_to_itk_image.ts"; export type { MemoryStoreToZipOptions } from "./io/rfc9_zip.ts"; // RFC-9 exports diff --git a/ts/test/itk_transform_resample_bounding_box_test.ts b/ts/test/resample_bounding_box_test.ts similarity index 93% rename from ts/test/itk_transform_resample_bounding_box_test.ts rename to ts/test/resample_bounding_box_test.ts index d4437241..53006671 100644 --- a/ts/test/itk_transform_resample_bounding_box_test.ts +++ b/ts/test/resample_bounding_box_test.ts @@ -4,7 +4,7 @@ /** * RFC-5 to ITK transform bridge and resample bounding box tests. * - * Mirrors `py/test/test_itk_transform_resample_bounding_box.py`. The expected + * Mirrors `py/test/test_resample_bounding_box.py`. The expected * regions are not taken from the pipeline itself: they come either from the * worked examples in the ITK-Wasm `resample-bounding-box` documentation, or * from `oracleRegion` below, which recomputes the region from first principles @@ -28,13 +28,13 @@ import { createTransformSequence, createTranslation, itkDisplacementFieldToNgffTransform, - itkTransformResampleBoundingBox, itkTransformToNgffTransform, NgffImage, ngffTransformToItkTransform, + resampleBoundingBox, } from "../src/mod.ts"; import { ngffTransformToItkMatrix } from "../src/utils/ngff_transform_to_itk_transform.ts"; -import { resampleBoundingBoxShared } from "../src/io/itk_transform_resample_bounding_box-shared.ts"; +import { resampleBoundingBoxShared } from "../src/io/resample_bounding_box-shared.ts"; import { RAS } from "../src/types/rfc4.ts"; import type { AnatomicalOrientation } from "../src/types/rfc4.ts"; @@ -174,7 +174,7 @@ Deno.test("an NGFF translation reproduces the documented 2D example", async () = x: 1, }, { y: 0, x: 0 }); - const boundingBox = await itkTransformResampleBoundingBox( + const boundingBox = await resampleBoundingBox( createTranslation([5, 10]), fixed, moving, @@ -200,7 +200,7 @@ Deno.test("padding is applied symmetrically", async () => { }, { y: 0, x: 0 }); const transform = createTranslation([5, 10]); - const padded = await itkTransformResampleBoundingBox( + const padded = await resampleBoundingBox( transform, fixed, moving, @@ -208,7 +208,7 @@ Deno.test("padding is applied symmetrically", async () => { padding: 1, }, ); - const tight = await itkTransformResampleBoundingBox( + const tight = await resampleBoundingBox( transform, fixed, moving, @@ -244,7 +244,7 @@ Deno.test("an asymmetric 3D NGFF affine matches the oracle", async () => { const offset = [4, -6, 11]; const affine = matrix.map((row, i) => [...row, offset[i]]); - const boundingBox = await itkTransformResampleBoundingBox( + const boundingBox = await resampleBoundingBox( createAffine(affine), fixed, moving, @@ -325,7 +325,7 @@ Deno.test("sequence order survives the whole pipeline", async () => { x: 1, }, { y: 0, x: 0 }); - const region = await itkTransformResampleBoundingBox( + const region = await resampleBoundingBox( createTransformSequence([ createTranslation([0, 10]), createScale([1, 2]), @@ -417,7 +417,7 @@ Deno.test("mismatched spatial dims are rejected", async () => { }, { y: 0, x: 0 }); await assertRejects( - () => itkTransformResampleBoundingBox(identity(2), fixed, moving), + () => resampleBoundingBox(identity(2), fixed, moving), Error, "they must match", ); @@ -459,7 +459,7 @@ Deno.test("pixel data is never read", async () => { computedCallbacks: undefined, }); - const boundingBox = await itkTransformResampleBoundingBox( + const boundingBox = await resampleBoundingBox( identity(2), image, image, @@ -492,7 +492,7 @@ Deno.test("non-spatial axes are passed through", async () => { { t: 0, c: 0, z: 0, y: 0, x: 0 }, ); - const boundingBox = await itkTransformResampleBoundingBox( + const boundingBox = await resampleBoundingBox( itkTranslation([3, 2, 1]), fixed, moving, @@ -520,7 +520,7 @@ Deno.test("a region outside the moving image is empty", async () => { x: 1, }, { y: 0, x: 0 }); - const boundingBox = await itkTransformResampleBoundingBox( + const boundingBox = await resampleBoundingBox( itkTranslation([1000, 1000]), fixed, moving, @@ -540,7 +540,7 @@ Deno.test("a negative start index is clamped, not wrapped", async () => { x: 1, }, { y: 0, x: 0 }); - const boundingBox = await itkTransformResampleBoundingBox( + const boundingBox = await resampleBoundingBox( identity(2), fixed, moving, @@ -562,7 +562,7 @@ Deno.test("the cropped translation shifts by start * scale", async () => { x: 2, }, { y: 0, x: 0 }); - const boundingBox = await itkTransformResampleBoundingBox( + const boundingBox = await resampleBoundingBox( itkTranslation([0, 0]), fixed, moving, @@ -581,7 +581,7 @@ Deno.test("the cropped translation shifts by start * scale", async () => { Deno.test("RAS orientation yields a non-identity direction", async () => { const { itkDirection } = await import( - "../src/io/itk_transform_resample_bounding_box-shared.ts" + "../src/io/resample_bounding_box-shared.ts" ); const image = await geometryImage( ["z", "y", "x"], @@ -596,7 +596,7 @@ Deno.test("RAS orientation yields a non-identity direction", async () => { Deno.test("a 3D-only orientation on a 2D image falls back to identity", async () => { const { itkDirection } = await import( - "../src/io/itk_transform_resample_bounding_box-shared.ts" + "../src/io/resample_bounding_box-shared.ts" ); const { AnatomicalOrientationValues, createAnatomicalOrientation } = await import("../src/types/rfc4.ts"); @@ -634,7 +634,7 @@ Deno.test("an index range overflow is reported, not silently empty", async () => await assertRejects( () => - itkTransformResampleBoundingBox(identity(2), fixed, moving, { + resampleBoundingBox(identity(2), fixed, moving, { padding: 1, }), Error, @@ -650,7 +650,7 @@ Deno.test("padding that is not a non-negative integer is rejected", async () => for (const padding of [-1, 1.5, NaN, Infinity]) { await assertRejects( () => - itkTransformResampleBoundingBox(identity(2), fixed, fixed, { + resampleBoundingBox(identity(2), fixed, fixed, { padding, }), Error, @@ -662,7 +662,7 @@ Deno.test("padding that is not a non-negative integer is rejected", async () => Deno.test("unsupported spatial dimensionality is rejected", async () => { const fixed = await geometryImage(["x"], { x: 8 }, { x: 1 }, { x: 0 }); await assertRejects( - () => itkTransformResampleBoundingBox(identity(2), fixed, fixed), + () => resampleBoundingBox(identity(2), fixed, fixed), Error, "only 2 and 3 are supported", ); @@ -686,7 +686,7 @@ Deno.test("a missing scale entry is rejected rather than defaulted", async () => }); await assertRejects( - () => itkTransformResampleBoundingBox(identity(2), partial, fixed), + () => resampleBoundingBox(identity(2), partial, fixed), Error, "no entry for dimension 'x'", ); @@ -698,7 +698,7 @@ Deno.test("a non-finite scale is rejected", async () => { x: NaN, }, { y: 0, x: 0 }); await assertRejects( - () => itkTransformResampleBoundingBox(identity(2), fixed, fixed), + () => resampleBoundingBox(identity(2), fixed, fixed), Error, "must be finite", ); @@ -710,7 +710,7 @@ Deno.test("a zero scale is rejected", async () => { x: 0, }, { y: 0, x: 0 }); await assertRejects( - () => itkTransformResampleBoundingBox(identity(2), fixed, fixed), + () => resampleBoundingBox(identity(2), fixed, fixed), Error, "scale for dimension 'x' is zero", ); @@ -726,7 +726,7 @@ Deno.test("a degenerate fixed grid yields an empty region", async () => { x: 1, }, { y: 0, x: 0 }); - const boundingBox = await itkTransformResampleBoundingBox( + const boundingBox = await resampleBoundingBox( identity(2), fixed, moving, @@ -761,13 +761,13 @@ Deno.test("the RFC-5 branch ignores anatomical orientation", async () => { const plainImages = await geometry(false); const orientedImages = await geometry(true); - const plain = await itkTransformResampleBoundingBox( + const plain = await resampleBoundingBox( transform, plainImages.fixed, plainImages.moving, { padding: 0 }, ); - const oriented = await itkTransformResampleBoundingBox( + const oriented = await resampleBoundingBox( transform, orientedImages.fixed, orientedImages.moving, @@ -831,13 +831,13 @@ Deno.test("an RFC-5 displacements transform reaches the same region", async () = x: 1, }, { y: 0, x: 0 }); - const viaItk = await itkTransformResampleBoundingBox([warp], fixed, moving); + const viaItk = await resampleBoundingBox([warp], fixed, moving); const { transform, field } = await itkDisplacementFieldToNgffTransform( warp, ["y", "x"], { path: "warp" }, ); - const viaRfc5 = await itkTransformResampleBoundingBox( + const viaRfc5 = await resampleBoundingBox( transform, fixed, moving, @@ -849,7 +849,7 @@ Deno.test("an RFC-5 displacements transform reaches the same region", async () = // Without the field there is nothing to convert, and the message says so. await assertRejects( - () => itkTransformResampleBoundingBox(transform, fixed, moving), + () => resampleBoundingBox(transform, fixed, moving), Error, "no field was passed", ); @@ -909,7 +909,7 @@ Deno.test("converting with frames matches the ITK path on oriented images", asyn // deno-lint-ignore no-explicit-any } as any]; - const viaItk = await itkTransformResampleBoundingBox( + const viaItk = await resampleBoundingBox( transform, fixed, moving, @@ -926,7 +926,7 @@ Deno.test("converting with frames matches the ITK path on oriented images", asyn moving, }, ); - const viaRfc5 = await itkTransformResampleBoundingBox( + const viaRfc5 = await resampleBoundingBox( converted, fixed, moving, @@ -1043,7 +1043,7 @@ Deno.test("an ITK-Wasm transform list is accepted", async () => { metadata: new Map(), }]; - const boundingBox = await itkTransformResampleBoundingBox( + const boundingBox = await resampleBoundingBox( transformList as never, fixed, moving, @@ -1072,7 +1072,7 @@ Deno.test("an asymmetric 3D affine matches the oracle", () => { x: 0.25, }, { z: -5, y: 7, x: 3 }); - const boundingBox = await itkTransformResampleBoundingBox( + const boundingBox = await resampleBoundingBox( itkAffine(reversed(matrix), [...offset].reverse()), fixed, moving, From 1d4f063f09b6b1704500137be37651fddbd6752b Mon Sep 17 00:00:00 2001 From: Valentin Boussot Date: Tue, 25 Aug 2026 14:03:32 +0200 Subject: [PATCH 09/11] feat(py,ts): convert every RFC-5 transformation type to ITK mapAxis, byDimension and bijection describe a linear mapping the way the RFC lets a writer describe it, rather than as a matrix, so each folds into the single affine ITK gets: a mapAxis becomes its permutation matrix, a byDimension writes each item into the rows its output_axes name, and a bijection contributes its forward direction, since ITK inverts an affine itself. A byDimension that leaves an output axis unproduced is refused rather than resampled, because the zero row it would leave collapses the image. coordinates joins displacements on the field path. A coordinates field holds the absolute output position of each grid point where a displacements field holds the offset from it, so the two differ by the position of the grid point itself, which the conversion subtracts along with the frame term. Both reach ITK as one DisplacementField, which is all ITK has. resample now takes an RFC-5 transformation as well as an ITK transform, which is what its new name claims. It reads one on the intrinsic coordinate systems exactly as resample_bounding_box does, so the region reported for a transformation is the region resampling through it reads, and takes the same fields= mapping for a field transform. Every case is anchored on itk's own TransformPoint in Python and on an oracle that reads each convention from the RFC in TypeScript. --- docs/itk.md | 52 ++- docs/rfc5.md | 35 +- py/ngff_zarr/displacement_field_transform.py | 68 ++-- .../ngff_transform_to_itk_transform.py | 117 +++++- py/ngff_zarr/resample.py | 53 ++- py/ngff_zarr/resample_bounding_box.py | 16 +- py/test/test_displacement_field_transform.py | 101 +++++- .../test_ngff_transform_to_itk_transform.py | 337 ++++++++++++++++++ py/test/test_resample.py | 93 +++++ py/test/test_resample_bounding_box.py | 129 +++++++ ts/src/io/resample_bounding_box-shared.ts | 29 +- ts/src/utils/displacement_field_transform.ts | 49 ++- .../utils/ngff_transform_to_itk_transform.ts | 125 ++++++- ts/test/displacement_field_transform_test.ts | 154 ++++++++ .../ngff_transform_to_itk_transform_test.ts | 312 ++++++++++++++++ ts/test/resample_bounding_box_test.ts | 108 ++++++ 16 files changed, 1653 insertions(+), 125 deletions(-) create mode 100644 py/test/test_ngff_transform_to_itk_transform.py create mode 100644 ts/test/ngff_transform_to_itk_transform_test.ts diff --git a/docs/itk.md b/docs/itk.md index 0cdbf910..28a50341 100644 --- a/docs/itk.md +++ b/docs/itk.md @@ -123,19 +123,22 @@ the moving chunks directly, so a chunk that several blocks need is read and decoded once. The full moving image is never loaded, and nothing runs until the result is computed. -`resample` takes an ITK or ITK-Wasm transform, so the RFC-5 -`Affine` above is converted first; `ngff_transform_to_itk_transform` keeps the -axis order straight (see [Converting transforms](#converting-transforms)). +`resample` takes the same two kinds of transform as +`resample_bounding_box`, the RFC-5 `Affine` above included: ```python ->>> itk_transform = nz.ngff_transform_to_itk_transform( # doctest: +SKIP -... transform, dims=['y', 'x']) ->>> resampled = nz.resample( # doctest: +SKIP -... itk_transform, fixed, moving) +>>> resampled = nz.resample(transform, fixed, moving) # doctest: +SKIP >>> nz.to_ome_zarr("resampled.zarr", # doctest: +SKIP ... nz.to_multiscales(resampled)) ``` +An RFC-5 transformation is read on the intrinsic coordinate systems in both +functions, so anatomical orientation does not enter and the region reported +for a transformation is the region resampling it reads. An ITK transform is +read on ITK physical space, direction matrix included. `fields=` carries the +field of a `displacements` or `coordinates` transformation, as it does for the +bounding box. + Resampling runs through `itkwasm-downsample`, so no native ITK build is needed and the result does not depend on the platform. @@ -358,20 +361,31 @@ takes any RFC-5 type. `multiscales > datasets` takes exactly one entry, and only a single `scale`, a single `identity`, or a two-element `sequence` of scale and translation: a bare `translation` and an `affine` are both rejected there. -Linear transforms and displacement fields convert; other deformations do not. -A B-spline or a velocity field has no RFC-5 equivalent, so such an ITK -transform raises `NotImplementedError`, as does an RFC-5 `coordinates` -transformation going the other way. RFC-5 represents deformations with its -`displacements` and `coordinates` field types, described in the -[RFC-5 documentation](./rfc5.md); the first of those is covered next. +Every RFC-5 transformation type converts to ITK. `identity`, `scale`, +`translation`, `rotation`, `affine`, `mapAxis`, `byDimension` and `bijection`, +and any `sequence` of them, describe a linear mapping, and each is folded into +the single affine ITK gets: a `mapAxis` becomes its permutation matrix, a +`byDimension` writes each item into the rows its `output_axes` name, and a +`bijection` contributes its `forward` direction, since ITK inverts an affine +itself. A `byDimension` that leaves an output axis unproduced is refused rather +than resampled, because the zero row it would leave collapses the image. +`displacements` and `coordinates` become a `DisplacementField`, given the field +they point at; the two differ by the position of the grid point itself, which +the conversion subtracts, since ITK has no absolute-coordinate transform. + +Going the other way is narrower, because an ITK transform is either affine or a +field. A B-spline or a velocity field has no RFC-5 equivalent and raises +`NotImplementedError`; a field comes back as `displacements` rather than +`coordinates`. Computing a bounding box from an **ITK** transform does not require linearity --- that is the section above. An RFC-5 `displacements` transformation is -converted first, so `resample_bounding_box` takes the same -`fields=` mapping to find its field. Because that branch works on the intrinsic -systems, where no direction matrix applies, a field carrying an anatomical -orientation is refused there: convert it with `ngff_transform_to_itk_transform` -and its `fixed=`/`moving=` pair, and pass the ITK transform that returns. +-- that is the section above. An RFC-5 `displacements` or `coordinates` +transformation is converted first, so `resample_bounding_box` and `resample` +take the same `fields=` mapping to find its field. Because that branch works on +the intrinsic systems, where no direction matrix applies, a field carrying an +anatomical orientation is refused there: convert it with +`ngff_transform_to_itk_transform` and its `fixed=`/`moving=` pair, and pass the +ITK transform that returns. ### Displacement fields diff --git a/docs/rfc5.md b/docs/rfc5.md index d07ac4a5..93c36ca4 100644 --- a/docs/rfc5.md +++ b/docs/rfc5.md @@ -224,26 +224,35 @@ is identical. ### Interoperating with ITK -Linear transformations convert to and from ITK in both directions, with -`ngff_transform_to_itk_transform` and `itk_transform_to_ngff_transform`. The -second is how a registration result gets into the store: convert the -`CompositeTransform` an Elastix registration returns and attach it to the -multiscales metadata as shown above. - -Both reconcile the places where the conventions differ: RFC-5 orders +Every transformation in the table above converts to ITK with +`ngff_transform_to_itk_transform`, and `itk_transform_to_ngff_transform` +converts back. The second is how a registration result gets into the store: +convert the `CompositeTransform` an Elastix registration returns and attach it +to the multiscales metadata as shown above. + +`identity`, `scale`, `translation`, `rotation`, `affine`, `mapAxis`, +`byDimension`, `bijection` and any `sequence` of them describe a linear mapping +and are folded into the single affine ITK gets. A `mapAxis` becomes its +permutation matrix, a `byDimension` writes each item into the rows its +`output_axes` name, and a `bijection` contributes its `forward` direction. + +Both directions reconcile the places where the conventions differ: RFC-5 orders parameters in Zarr axis order while ITK orders them fastest-axis-first, an RFC-5 `sequence` applies its first entry first while an ITK transform list applies its last entry first, and ITK's center of rotation is folded into the offset since an RFC-5 affine has none. -A `displacements` transformation converts too, with +A `displacements` or `coordinates` transformation converts too, with `itk_displacement_field_to_ngff_transform` and its inverse: the field is an array rather than a handful of numbers, so those return, and take, the field -image alongside the transformation. - -Building on that, `resample_bounding_box` computes which region -of a moving image a resample through the transformation would read, from -geometry alone. See [Out-of-core resampling](./itk.md#out-of-core-resampling) +image alongside the transformation. Both reach ITK as a displacement field, +since ITK has no absolute-coordinate transform; coming back, a field is +`displacements`. + +Building on that, `resample_bounding_box` computes which region of a moving +image a resample through the transformation would read, from geometry alone, +and `resample` resamples the grid block by block through the same +transformation. See [Out-of-core resampling](./itk.md#out-of-core-resampling) and [Converting transforms](./itk.md#converting-transforms). ## TypeScript diff --git a/py/ngff_zarr/displacement_field_transform.py b/py/ngff_zarr/displacement_field_transform.py index c7a4f125..70c23ac7 100644 --- a/py/ngff_zarr/displacement_field_transform.py +++ b/py/ngff_zarr/displacement_field_transform.py @@ -48,7 +48,7 @@ from .itk_transform_to_ngff_transform import _FrameGeometry from .ngff_image import NgffImage -from .v06.zarr_metadata import Displacements +from .v06.zarr_metadata import Coordinates, Displacements #: The name given to the component axis of a converted field. RFC-5 puts that #: axis after a time axis and before the spatial ones, with @@ -399,26 +399,32 @@ def itk_displacement_field_to_ngff_transform( def ngff_displacement_field_to_itk_transform( - transform: Displacements, + transform: Displacements | Coordinates, field, dims: Sequence[str], *, fixed: NgffImage | None = None, moving: NgffImage | None = None, ) -> list: - """Convert an RFC-5 ``displacements`` transform and its field to ITK. + """Convert an RFC-5 ``displacements`` or ``coordinates`` transform to ITK. The counterpart of :func:`itk_displacement_field_to_ngff_transform`, and what :func:`~ngff_zarr.ngff_transform_to_itk_transform` calls when handed - a ``displacements`` transform with its field. The field is the image - stored at ``transform.path``; load it with + a field transform with its field. The field is the image stored at + ``transform.path``; load it with ``from_ome_zarr(f"{store}/{transform.path}")``. - :param transform: The ``displacements`` transform. - :type transform: Displacements + A ``coordinates`` field holds the absolute output position of each grid + point where a ``displacements`` field holds the offset from it. The two + differ by the position of the grid point itself, so both reach ITK as one + ``DisplacementField``: ITK has no absolute-coordinate transform. + + :param transform: The ``displacements`` or ``coordinates`` transform. + :type transform: Displacements | Coordinates :param field: The field image: an ``NgffImage`` whose component axis is - the one with ``axes_types`` ``displacement``, followed by ``dims`` in - order; or an ``NgffMultiscales``, whose finest level is used. + the one with ``axes_types`` ``displacement`` (``coordinate`` for a + ``coordinates`` transform), followed by ``dims`` in order; or an + ``NgffMultiscales``, whose finest level is used. :param dims: The spatial axis names of the input coordinate system, in RFC-5 (Zarr) order. :type dims: Sequence[str] @@ -442,31 +448,34 @@ def ngff_displacement_field_to_itk_transform( from .resample_bounding_box import _itk_direction dims = _check_dims(dims) + absolute = transform.type == "coordinates" + component_type = "coordinate" if absolute else "displacement" if hasattr(field, "images") and hasattr(field, "metadata"): # A read multiscales keeps the axis types in its metadata, not on the - # image: the component axis is the one typed "displacement" there. + # image: the component axis is the one typed there. axes = field.metadata.intrinsic_coordinate_system.axes - component_dims = [axis.name for axis in axes if axis.type == "displacement"] + component_dims = [axis.name for axis in axes if axis.type == component_type] field = field.images[0] else: component_dims = [ dim for dim, axis_type in (field.axes_types or {}).items() - if axis_type == "displacement" + if axis_type == component_type ] if len(component_dims) != 1: msg = ( - "the field image must have exactly one axis of type 'displacement' " - f"(axes_types on an NgffImage, the axes metadata of a multiscales); " - f"got {component_dims or 'none'} on dims {tuple(field.dims)}" + f"the field image must have exactly one axis of type " + f"'{component_type}' (axes_types on an NgffImage, the axes metadata " + f"of a multiscales); got {component_dims or 'none'} on dims " + f"{tuple(field.dims)}" ) raise ValueError(msg) expected_dims = (component_dims[0], *dims) if tuple(field.dims) != expected_dims: msg = ( - f"the field's dims are {tuple(field.dims)}; a displacements transform " - f"over dims {dims} needs {expected_dims}: the component axis first, " - "then the input axes in order" + f"the field's dims are {tuple(field.dims)}; a {transform.type} " + f"transform over dims {dims} needs {expected_dims}: the component " + "axis first, then the input axes in order" ) raise ValueError(msg) @@ -500,11 +509,11 @@ def ngff_displacement_field_to_itk_transform( msg = ( "the field carries an anatomical orientation, so its grid " "cannot be placed in ITK physical space on its own; pass the " - "fixed and moving images. resample_bounding_box " - "has no place for them, because its RFC-5 branch works on the " - "intrinsic systems where no orientation applies: call " + "fixed and moving images. resample_bounding_box and resample " + "have no place for them, because their RFC-5 branch works on " + "the intrinsic systems where no orientation applies: call " "ngff_transform_to_itk_transform with both images yourself and " - "hand the bounding box the ITK transform it returns." + "hand them the ITK transform it returns." ) raise ValueError(msg) frames = _unoriented_frames(dimension) @@ -522,8 +531,13 @@ def ngff_displacement_field_to_itk_transform( origin = direction @ (translation - frames.origin_in) + frames.origin_in direction_out, shift_matrix, shift_vector = _frame_terms(frames) + # ``shift_matrix`` is ``M - I``, so subtracting it turns an RFC-5 + # displacement into an ITK vector. A coordinates field holds ``q + d`` + # rather than ``d``, so subtracting ``M`` instead removes the grid point + # along with the frame term, in one pass over the grid. + grid_matrix = shift_matrix + np.eye(dimension) if absolute else shift_matrix shift = _grid_shift( - displacements.shape[:-1], translation, spacing, shift_matrix, shift_vector + displacements.shape[:-1], translation, spacing, grid_matrix, shift_vector ) vectors = displacements if shift is None else displacements - shift if not _is_identity(direction_out): @@ -531,7 +545,7 @@ def ngff_displacement_field_to_itk_transform( if transform.interpolation not in (None, "linear"): warnings.warn( - f"the displacements transform asks for '{transform.interpolation}' " + f"the {transform.type} transform asks for '{transform.interpolation}' " "interpolation; ITK interpolates a displacement field linearly. RFC-5 " "leaves the choice to the consumer.", stacklevel=2, @@ -565,12 +579,14 @@ def ngff_displacement_field_to_itk_transform( ] -def _fields_entry(transform: Displacements, fields: Mapping[str, object] | None): +def _fields_entry( + transform: Displacements | Coordinates, fields: Mapping[str, object] | None +): """The field ``fields`` holds for ``transform``, with a message otherwise.""" if not fields or transform.path not in fields: available = sorted(fields) if fields else [] msg = ( - f"the displacements transform points at '{transform.path}', but no " + f"the {transform.type} transform points at '{transform.path}', but no " f"field was passed for it (fields given: {available}). Load it with " f'from_ome_zarr(f"{{store}}/{transform.path}") and pass ' f"fields={{'{transform.path}': field}}." diff --git a/py/ngff_zarr/ngff_transform_to_itk_transform.py b/py/ngff_zarr/ngff_transform_to_itk_transform.py index 8d26660e..1f3d2d68 100644 --- a/py/ngff_zarr/ngff_transform_to_itk_transform.py +++ b/py/ngff_zarr/ngff_transform_to_itk_transform.py @@ -31,8 +31,13 @@ from .v06.zarr_metadata import ( Affine, + Bijection, + ByDimension, + ByDimensionItem, + Coordinates, Displacements, Identity, + MapAxis, Rotation, Scale, Transform, @@ -118,6 +123,30 @@ def _homogeneous_from_transform(transform: Transform, ndim: int) -> np.ndarray: matrix = _homogeneous_from_transform(sub_transform, ndim) @ matrix return matrix + if isinstance(transform, MapAxis): + if len(transform.mapAxis) != ndim: + msg = ( + f"mapAxis transformation permutes {len(transform.mapAxis)} axes " + f"but the coordinate system has {ndim}" + ) + raise ValueError(msg) + matrix = np.zeros((ndim + 1, ndim + 1)) + matrix[ndim, ndim] = 1.0 + # The value at position i names the input axis that becomes output + # axis i, so the row for output i selects that input. + for output_axis, input_axis in enumerate(transform.mapAxis): + matrix[output_axis, input_axis] = 1.0 + return matrix + + if isinstance(transform, ByDimension): + return _homogeneous_from_by_dimension(transform, ndim) + + if isinstance(transform, Bijection): + # The forward direction is the mapping; RFC-5 keeps the inverse + # alongside it so a reader need not invert the forward one, which is + # what ITK does for an affine anyway. + return _homogeneous_from_transform(transform.forward, ndim) + # ``ngff_zarr.Scale`` and friends are the v0.4 dataclasses, which carry the # same parameters under the same names but do not share the v0.6 base # class. Accept them rather than making callers hunt for the v0.6 twin. @@ -133,13 +162,73 @@ def _homogeneous_from_transform(transform: Transform, ndim: int) -> np.ndarray: msg = ( f"transformation type '{transform_type or type(transform).__name__}'" - " cannot be converted to an ITK transform. Only identity, scale, " - "translation, rotation, affine and sequences of them describe a linear " - "mapping that ITK can represent as a single affine transform." + " cannot be converted to an ITK transform. identity, scale, " + "translation, rotation, affine, mapAxis, byDimension, bijection and " + "sequences of them describe a linear mapping that ITK can represent as " + "a single affine transform; displacements and coordinates convert to a " + "displacement field, given the field they point at." ) raise NotImplementedError(msg) +def _homogeneous_from_by_dimension(transform: ByDimension, ndim: int) -> np.ndarray: + """Assemble a byDimension transformation into one homogeneous matrix. + + Each item is a lower-dimensional transformation between two subsets of + axes, so its own matrix is written into the rows its ``output_axes`` name + and the columns its ``input_axes`` name. Axes no item produces would leave + a zero row, which collapses the image rather than transforming it, so a + gap is refused here rather than resampled. + """ + matrix = np.zeros((ndim + 1, ndim + 1)) + matrix[ndim, ndim] = 1.0 + for item in transform.transformations: + block, offset = _by_dimension_item_block(item, ndim) + for row, output_axis in enumerate(item.output_axes): + for column, input_axis in enumerate(item.input_axes): + matrix[output_axis, input_axis] = block[row, column] + matrix[output_axis, ndim] = offset[row] + + produced = transform.produced_output_axes + missing = sorted(set(range(ndim)) - produced) + if missing: + msg = ( + f"byDimension transformation produces output axes {sorted(produced)}, " + f"leaving {missing} of the {ndim} axes of the coordinate system " + "unset; every output axis must be produced by exactly one item" + ) + raise ValueError(msg) + return matrix + + +def _by_dimension_item_block( + item: ByDimensionItem, ndim: int +) -> tuple[np.ndarray, np.ndarray]: + """One byDimension item as a matrix and offset over its own axes.""" + if len(item.input_axes) != len(item.output_axes): + msg = ( + f"byDimension item of type '{item.transformation.type}' maps " + f"{len(item.input_axes)} input axes to {len(item.output_axes)} " + "output axes; only a square mapping converts to an ITK transform" + ) + raise ValueError(msg) + for axes in (item.input_axes, item.output_axes): + beyond = [axis for axis in axes if axis >= ndim] + if beyond: + msg = ( + f"byDimension axis indices {beyond} exceed the {ndim} axes of " + "the coordinate system" + ) + raise ValueError(msg) + if len(set(item.input_axes)) != len(item.input_axes): + msg = f"byDimension input axes {item.input_axes} name an axis twice" + raise ValueError(msg) + + sub_ndim = len(item.input_axes) + homogeneous = _homogeneous_from_transform(item.transformation, sub_ndim) + return homogeneous[:sub_ndim, :sub_ndim], homogeneous[:sub_ndim, sub_ndim] + + def _as_matrix(values, path: str | None, field: str) -> np.ndarray: if values is None or len(values) == 0: if path is not None: @@ -167,7 +256,8 @@ def _ngff_transform_to_itk_matrix( :param transform: An RFC-5 (OME-Zarr v0.6) coordinate transformation. It must describe a linear mapping -- ``identity``, ``scale``, - ``translation``, ``rotation``, ``affine``, or a ``sequence`` of those. + ``translation``, ``rotation``, ``affine``, ``mapAxis``, + ``byDimension``, ``bijection``, or a ``sequence`` of those. :type transform: Transform :param dims: The axis names of the coordinate system the transformation is @@ -235,21 +325,24 @@ def ngff_transform_to_itk_transform( A linear transformation is collapsed into a single ``Affine`` entry, so the result is independent of ITK's own list-composition order. A - ``displacements`` transformation becomes a single ``DisplacementField`` - entry built from the field passed in ``fields``. + ``displacements`` or ``coordinates`` transformation becomes a single + ``DisplacementField`` entry built from the field passed in ``fields``. :param transform: An RFC-5 (OME-Zarr v0.6) coordinate transformation: - a linear mapping, or a ``displacements`` transformation. + a linear mapping -- ``identity``, ``scale``, ``translation``, + ``rotation``, ``affine``, ``mapAxis``, ``byDimension``, ``bijection``, + or a ``sequence`` of them -- or a ``displacements`` or ``coordinates`` + transformation. :type transform: Transform :param dims: The axis names of the coordinate system the transformation is defined on, in RFC-5 (Zarr) order. Only the spatial axes take part. :type dims: Sequence[str] - :param fields: The field images a ``displacements`` transformation points - at, keyed by its ``path``: an ``NgffImage``, or the ``NgffMultiscales`` - read from ``f"{store}/{transform.path}"``. Required for a - ``displacements`` transformation, ignored otherwise. + :param fields: The field images a ``displacements`` or ``coordinates`` + transformation points at, keyed by its ``path``: an ``NgffImage``, or + the ``NgffMultiscales`` read from ``f"{store}/{transform.path}"``. + Required for those two, ignored otherwise. :type fields: Mapping[str, NgffImage | NgffMultiscales], optional :param fixed: The fixed and moving images the transform relates. Passing @@ -265,7 +358,7 @@ def ngff_transform_to_itk_transform( :return: A single-entry ITK-Wasm ``TransformList``. :rtype: list[itkwasm.Transform] """ - if isinstance(transform, Displacements): + if isinstance(transform, (Displacements, Coordinates)): from .displacement_field_transform import ( _fields_entry, ngff_displacement_field_to_itk_transform, diff --git a/py/ngff_zarr/resample.py b/py/ngff_zarr/resample.py index f800dc79..451f63b8 100644 --- a/py/ngff_zarr/resample.py +++ b/py/ngff_zarr/resample.py @@ -3,13 +3,17 @@ """Resample a moving image onto a fixed image grid, one block at a time.""" import functools +from collections.abc import Mapping +from dataclasses import replace import numpy as np from .ngff_image import NgffImage +from .ngff_transform_to_itk_transform import ngff_transform_to_itk_transform from .resample_bounding_box import ( _as_itk_transform_list, _check_geometry, + _is_ngff_transform, _itk_direction, _metadata_only_itk_image, _shifted_translation, @@ -214,25 +218,36 @@ def resample( padding: int | None = None, interpolator: str = "linear", default_value: float = 0.0, + *, + fields: Mapping[str, object] | None = None, ) -> NgffImage: """Resample ``moving`` onto the grid of ``fixed`` through ``transform``. The result is a lazy :class:`NgffImage`: nothing is read or computed until the returned Dask array is. Each output block is resampled on its own from the moving chunks inside the region its resample reads, as reported by - :func:`ngff_zarr.resample_bounding_box`. The regions are - computed once, when the graph is built, and the blocks are tasks of a - single Dask graph that reference the moving chunks directly, so a chunk - that several blocks need is read and decoded once per computation. The + :func:`ngff_zarr.resample_bounding_box`. The regions are computed once, + when the graph is built, and the blocks are tasks of a single Dask graph + that reference the moving chunks directly, so a chunk that several blocks + need is read and decoded once per computation. The full moving image is never loaded, which is what makes this usable when it is larger than memory, remote, or chunked. Resampling runs through ``itkwasm-downsample``, so no native ITK build is required and the result is identical to one across platforms. - :param transform: An ``itk.Transform`` (including the ``CompositeTransform`` - an Elastix registration returns), or an ITK-Wasm ``Transform`` / - ``TransformList``. It maps *fixed* points into *moving* space. + :param transform: An RFC-5 coordinate transformation, an ``itk.Transform`` + (including the ``CompositeTransform`` an Elastix registration + returns), or an ITK-Wasm ``Transform`` / ``TransformList``. It maps + *fixed* points into *moving* space. + + The two are interpreted in different coordinate spaces, exactly as + :func:`ngff_zarr.resample_bounding_box` interprets them. An RFC-5 + transformation acts on the intrinsic coordinate systems, where a point + is ``translation + scale * index`` and no direction matrix applies, so + the anatomical orientation of either image does not enter; its + ``input`` and ``output`` identifiers are not resolved either. An ITK + transform acts on ITK physical space, direction matrix included. :param fixed: The image whose grid defines the output. Geometry only, its pixels are never read. @@ -256,6 +271,12 @@ def resample( ``moving``. :type default_value: float + :param fields: The field images an RFC-5 ``displacements`` or + ``coordinates`` transformation points at, keyed by its ``path``, as + :func:`ngff_zarr.ngff_transform_to_itk_transform` takes them. Required + for those two, ignored otherwise. + :type fields: Mapping[str, NgffImage | NgffMultiscales], optional + :return: The resampled image, carrying the geometry of ``fixed`` and the dtype of ``moving``. :rtype: NgffImage @@ -304,7 +325,19 @@ def resample( _check_geometry(label, image, fixed_spatial) dtype = moving.data.dtype - transform_list = _as_itk_transform_list(transform) + out_orientations = fixed.axes_orientations + if _is_ngff_transform(transform): + transform_list = ngff_transform_to_itk_transform( + transform, fixed.dims, fields=fields + ) + # An RFC-5 transformation acts on the intrinsic coordinate systems, + # which carry no direction matrix, so the blocks below are resampled + # with none either. That is what resample_bounding_box does with the + # same transformation, and the two have to read the same pixels. + fixed = replace(fixed, axes_orientations=None) + moving = replace(moving, axes_orientations=None) + else: + transform_list = _as_itk_transform_list(transform) out_chunks = fixed.data.chunks out_offsets = [np.concatenate([[0], np.cumsum(sizes)[:-1]]) for sizes in out_chunks] @@ -324,7 +357,7 @@ def moving_key(index): # image's geometry is not carried by its array name, so leaving it out # gives two resamples of the same array at different origins the same key, # and one silently stands in for the other. - name = "itk-transform-resample-" + tokenize( + name = "ngff-zarr-resample-" + tokenize( moving.data.name, moving.dims, moving.scale, @@ -415,7 +448,7 @@ def moving_key(index): translation=dict(fixed.translation), name=moving.name, axes_units=fixed.axes_units, - axes_orientations=fixed.axes_orientations, + axes_orientations=out_orientations, axes_types=fixed.axes_types, channel_names=moving.channel_names, channel_colors=moving.channel_colors, diff --git a/py/ngff_zarr/resample_bounding_box.py b/py/ngff_zarr/resample_bounding_box.py index 6636cedf..265e4796 100644 --- a/py/ngff_zarr/resample_bounding_box.py +++ b/py/ngff_zarr/resample_bounding_box.py @@ -315,6 +315,9 @@ def _metadata_only_itk_image( "translation", "rotation", "affine", + "mapAxis", + "byDimension", + "bijection", "sequence", "coordinates", "displacements", @@ -419,8 +422,11 @@ def resample_bounding_box( direction matrix derived from RFC-4 anatomical orientation. An ITK transform need not be linear. An RFC-5 transformation is converted - first, so it must be one this package can convert: a linear mapping, or a - ``displacements`` transformation whose field is passed in ``fields``. + first, so it must be one this package can convert: a linear mapping -- + ``identity``, ``scale``, ``translation``, ``rotation``, ``affine``, + ``mapAxis``, ``byDimension``, ``bijection``, or a ``sequence`` of them -- + or a ``displacements`` or ``coordinates`` transformation whose field is + passed in ``fields``. In both cases the transform maps *fixed* points into *moving* space. @@ -438,10 +444,10 @@ def resample_bounding_box( index bound. Use 0 for the tight region, or more for wider kernels. :type padding: int - :param fields: The field images an RFC-5 ``displacements`` transformation - points at, keyed by its ``path``, as + :param fields: The field images an RFC-5 ``displacements`` or + ``coordinates`` transformation points at, keyed by its ``path``, as :func:`ngff_zarr.ngff_transform_to_itk_transform` takes them. Required - for a ``displacements`` transformation, ignored otherwise. A field + for those two, ignored otherwise. A field carrying an anatomical orientation is refused here, since this branch works on the intrinsic systems where none applies: convert it with :func:`ngff_zarr.ngff_transform_to_itk_transform`, passing ``fixed`` diff --git a/py/test/test_displacement_field_transform.py b/py/test/test_displacement_field_transform.py index a6f8cc81..128e31d8 100644 --- a/py/test/test_displacement_field_transform.py +++ b/py/test/test_displacement_field_transform.py @@ -24,7 +24,11 @@ to_ome_zarr, ) from ngff_zarr.rfc4 import RAS -from ngff_zarr.v06.zarr_metadata import CoordinateSystemIdentifier, Displacements +from ngff_zarr.v06.zarr_metadata import ( + Coordinates, + CoordinateSystemIdentifier, + Displacements, +) itk = pytest.importorskip("itk") @@ -413,3 +417,98 @@ def test_single_precision_is_preserved(): ) assert entry.transformType.parametersValueType == FloatTypes.Float32 assert entry.parameters.dtype == np.float32 + + +def _as_coordinates(field, dims): + """The ``coordinates`` field holding what ``field`` displaces from. + + A coordinates field gives the absolute output position of each grid point + where a displacements field gives the offset, so the two differ by the + position of the grid point itself, in the field's own intrinsic system. + """ + data = np.asarray(field.data) + grid = np.meshgrid( + *[ + field.translation[dim] + field.scale[dim] * np.arange(extent) + for dim, extent in zip(dims, data.shape[1:]) + ], + indexing="ij", + ) + return NgffImage( + data=da.from_array(data + np.stack(grid)), + dims=field.dims, + scale=dict(field.scale), + translation=dict(field.translation), + axes_types={"c": "coordinate"}, + axes_orientations=field.axes_orientations, + ) + + +@pytest.mark.parametrize("dims", [("y", "x"), ("z", "y", "x")]) +def test_a_coordinates_field_maps_points_like_the_displacements_it_equals(dims): + ndim = len(dims) + size = (5, 4, 3)[:ndim] + original = _field_transform( + size, (0.5, 2.0, 1.5)[:ndim], (10.0, 20.0, -3.0)[:ndim], seed=3 + ) + displacements, field = itk_displacement_field_to_ngff_transform( + original, dims, path="warp" + ) + coordinates = Coordinates(path="warp", interpolation="linear") + + rebuilt = _native( + ngff_transform_to_itk_transform( + coordinates, dims, fields={"warp": _as_coordinates(field, dims)} + ) + ) + for point in _points_inside(original): + np.testing.assert_allclose( + _transform_point(rebuilt, point), + _transform_point(original, point), + atol=1e-9, + ) + + +def test_a_coordinates_field_changes_frames_like_a_displacements_one(): + size, spacing, origin = (4, 3, 5), (1.0, 2.0, 0.5), (5.0, -2.0, 8.0) + dims = CANONICAL[3] + itk_dims = ["x", "y", "z"] + fixed = _frame_image(size, spacing, origin, RAS) + moving = _frame_image(size, spacing, (1.0, 1.0, 1.0), None) + from ngff_zarr.resample_bounding_box import _itk_direction + + original = _field_transform( + size, spacing, origin, direction=_itk_direction(fixed, itk_dims), seed=11 + ) + displacements, field = itk_displacement_field_to_ngff_transform( + original, dims, path="warp", fixed=fixed, moving=moving + ) + + rebuilt = _native( + ngff_transform_to_itk_transform( + Coordinates(path="warp"), + dims, + fields={"warp": _as_coordinates(field, dims)}, + fixed=fixed, + moving=moving, + ) + ) + for point in _points_inside(original): + np.testing.assert_allclose( + _transform_point(rebuilt, point), _transform_point(original, point) + ) + + +def test_a_coordinates_transform_wants_a_coordinate_component_axis(): + original = _field_transform((3, 2), (1.0, 1.5), (0.0, -2.0)) + _, field = itk_displacement_field_to_ngff_transform(original, ("y", "x"), path="w") + + with pytest.raises(ValueError, match="exactly one axis of type 'coordinate'"): + ngff_transform_to_itk_transform( + Coordinates(path="w"), ("y", "x"), fields={"w": field} + ) + + +def test_a_coordinates_transform_names_itself_when_its_field_is_missing(): + with pytest.raises(ValueError, match="the coordinates transform points at"): + ngff_transform_to_itk_transform(Coordinates(path="w"), ("y", "x"), fields={}) diff --git a/py/test/test_ngff_transform_to_itk_transform.py b/py/test/test_ngff_transform_to_itk_transform.py new file mode 100644 index 00000000..99246470 --- /dev/null +++ b/py/test/test_ngff_transform_to_itk_transform.py @@ -0,0 +1,337 @@ +# SPDX-FileCopyrightText: Copyright (c) Fideus Labs LLC +# SPDX-License-Identifier: MIT +"""RFC-5 transformations that are not an affine, converted to ITK. + +``mapAxis``, ``byDimension`` and ``bijection`` describe a linear mapping the +way the RFC lets a writer describe it -- as a permutation, as one +transformation per group of axes, or alongside its inverse -- rather than as a +matrix. Each is folded into the single affine ITK gets, so the anchor here is +``itk``'s own ``TransformPoint``: the rebuilt transform must map a point the +way the transformation says it does, evaluated by an oracle that shares no +code with the conversion. +""" + +from dataclasses import asdict + +import numpy as np +import pytest +from ngff_zarr import ngff_transform_to_itk_transform +from ngff_zarr.v06.zarr_metadata import ( + Affine, + Bijection, + ByDimension, + ByDimensionItem, + Identity, + MapAxis, + Rotation, + Scale, + TransformSequence, + Translation, +) + +itk = pytest.importorskip("itk") + +_ITK_ORDER = ("x", "y", "z") + + +def _apply(transform, point): + """Evaluate an RFC-5 transformation, independently of the module. + + Shares no code with the conversion under test: every convention it + encodes -- the direction a ``mapAxis`` permutation runs in, the order a + ``sequence`` applies its entries in, which side of a ``bijection`` is the + mapping -- is read straight from the RFC rather than from the matrix + builder. + """ + point = np.asarray(point, dtype=float) + if isinstance(transform, Identity): + return point + if isinstance(transform, Scale): + return point * np.asarray(transform.scale, dtype=float) + if isinstance(transform, Translation): + return point + np.asarray(transform.translation, dtype=float) + if isinstance(transform, Rotation): + return np.asarray(transform.rotation, dtype=float) @ point + if isinstance(transform, Affine): + affine = np.asarray(transform.affine, dtype=float) + return affine[:, :-1] @ point + affine[:, -1] + if isinstance(transform, MapAxis): + # The value at position i names the input axis that becomes output i. + return np.array([point[axis] for axis in transform.mapAxis]) + if isinstance(transform, ByDimension): + result = np.zeros(point.shape) + for item in transform.transformations: + sub = _apply(item.transformation, point[list(item.input_axes)]) + for position, axis in enumerate(item.output_axes): + result[axis] = sub[position] + return result + if isinstance(transform, Bijection): + return _apply(transform.forward, point) + if isinstance(transform, TransformSequence): + for sub_transform in transform.transformations: + point = _apply(sub_transform, point) + return point + raise AssertionError(f"the oracle does not handle {type(transform).__name__}") + + +def _itk_order(values, dims): + """``values``, given in ``dims`` order, reordered fastest-axis-first.""" + values = np.asarray(values, dtype=float) + return np.array([values[dims.index(dim)] for dim in _ITK_ORDER if dim in dims]) + + +def _native(itkwasm_list): + """The native ITK transform an ITK-Wasm list rebuilds to.""" + assert len(itkwasm_list) == 1 + rebuilt = itk.transform_from_dict(asdict(itkwasm_list[0])) + if hasattr(rebuilt, "GetNthTransform"): + rebuilt = rebuilt.GetNthTransform(0) + return rebuilt + + +def _sample_points(ndim): + """Points that pin every column of the matrix and the offset.""" + rng = np.random.default_rng(20260825) + return [np.zeros(ndim), *rng.normal(scale=10.0, size=(5, ndim))] + + +def _assert_maps_like_the_oracle(transform, dims): + rebuilt = _native(ngff_transform_to_itk_transform(transform, dims)) + for point in _sample_points(len(dims)): + mapped = rebuilt.TransformPoint(list(_itk_order(point, dims))) + np.testing.assert_allclose( + np.array(mapped), _itk_order(_apply(transform, point), dims), atol=1e-9 + ) + + +MAPPING_CASES = [ + ("mapAxis_reversal_3d", MapAxis(mapAxis=[2, 1, 0]), ("z", "y", "x")), + ("mapAxis_swap_2d", MapAxis(mapAxis=[1, 0]), ("y", "x")), + ( + "mapAxis_non_canonical_dims", + MapAxis(mapAxis=[1, 2, 0]), + ("x", "z", "y"), + ), + ( + "by_dimension_scale_and_translation", + ByDimension( + transformations=[ + ByDimensionItem( + transformation=Translation(translation=[7.0]), + input_axes=[0], + output_axes=[0], + ), + ByDimensionItem( + transformation=Scale(scale=[2.0, 3.0]), + input_axes=[1, 2], + output_axes=[1, 2], + ), + ] + ), + ("z", "y", "x"), + ), + ( + "by_dimension_that_permutes", + ByDimension( + transformations=[ + ByDimensionItem( + transformation=Scale(scale=[4.0]), + input_axes=[0], + output_axes=[2], + ), + ByDimensionItem( + transformation=Affine(affine=[[1.0, 0.5, 3.0], [0.0, 2.0, -1.0]]), + input_axes=[1, 2], + output_axes=[0, 1], + ), + ] + ), + ("z", "y", "x"), + ), + ( + "by_dimension_holding_a_map_axis", + ByDimension( + transformations=[ + ByDimensionItem( + transformation=MapAxis(mapAxis=[1, 0]), + input_axes=[0, 1], + output_axes=[0, 1], + ), + ByDimensionItem( + transformation=Translation(translation=[-5.0]), + input_axes=[2], + output_axes=[2], + ), + ] + ), + ("z", "y", "x"), + ), + ( + "bijection_uses_its_forward_direction", + Bijection( + forward=Scale(scale=[2.0, 4.0]), + inverse=Scale(scale=[0.5, 0.25]), + ), + ("y", "x"), + ), + ( + "sequence_of_the_new_types", + TransformSequence( + transformations=[ + MapAxis(mapAxis=[1, 0]), + Translation(translation=[10.0, -20.0]), + Bijection( + forward=Affine(affine=[[0.8, -0.6, 1.0], [0.6, 0.8, 2.0]]), + inverse=Identity(), + ), + ] + ), + ("y", "x"), + ), +] + + +@pytest.mark.parametrize( + "transform, dims", + [(case[1], case[2]) for case in MAPPING_CASES], + ids=[case[0] for case in MAPPING_CASES], +) +def test_conversion_matches_transform_point(transform, dims): + _assert_maps_like_the_oracle(transform, dims) + + +def test_map_axis_is_not_its_own_inverse_when_it_is_a_rotation(): + # [1, 2, 0] and [2, 0, 1] are inverse permutations, so a conversion that + # reads the transpose vector backwards passes every symmetric case and + # fails this one. + dims = ("z", "y", "x") + rebuilt = _native(ngff_transform_to_itk_transform(MapAxis(mapAxis=[1, 2, 0]), dims)) + point = np.array([1.0, 2.0, 3.0]) + mapped = np.array(rebuilt.TransformPoint(list(_itk_order(point, dims)))) + np.testing.assert_allclose(mapped, _itk_order([2.0, 3.0, 1.0], dims)) + + +def test_bijection_ignores_an_inverse_that_disagrees(): + # RFC-5 does not require the two directions to be consistent, and the + # forward one is the mapping. A conversion that read `inverse` instead + # would come back with a scale of 9. + dims = ("y", "x") + transform = Bijection( + forward=Scale(scale=[2.0, 2.0]), inverse=Scale(scale=[9.0, 9.0]) + ) + rebuilt = _native(ngff_transform_to_itk_transform(transform, dims)) + np.testing.assert_allclose(np.array(rebuilt.TransformPoint([1.0, 1.0])), [2.0, 2.0]) + + +def test_map_axis_length_must_match_the_coordinate_system(): + with pytest.raises(ValueError, match="permutes 2 axes"): + ngff_transform_to_itk_transform(MapAxis(mapAxis=[1, 0]), ("z", "y", "x")) + + +def test_map_axis_that_couples_a_channel_axis_is_refused(): + # Swapping c and x is a permutation ITK has no room for: dropping it + # would leave the image in the wrong place rather than merely untimed. + with pytest.raises(ValueError, match="couples spatial and non-spatial"): + ngff_transform_to_itk_transform(MapAxis(mapAxis=[2, 1, 0]), ("c", "y", "x")) + + +def test_by_dimension_may_leave_a_non_spatial_axis_to_itself(): + # A time scale is projected away, exactly as it is for an affine: it is + # not part of the spatial mapping an ITK transform describes. + dims = ("t", "y", "x") + transform = ByDimension( + transformations=[ + ByDimensionItem( + transformation=Scale(scale=[0.25]), input_axes=[0], output_axes=[0] + ), + ByDimensionItem( + transformation=Translation(translation=[3.0, -4.0]), + input_axes=[1, 2], + output_axes=[1, 2], + ), + ] + ) + rebuilt = _native(ngff_transform_to_itk_transform(transform, dims)) + np.testing.assert_allclose( + np.array(rebuilt.TransformPoint([1.0, 2.0])), [1.0 - 4.0, 2.0 + 3.0] + ) + + +def test_by_dimension_that_leaves_an_output_axis_unset_is_refused(): + transform = ByDimension( + transformations=[ + ByDimensionItem( + transformation=Scale(scale=[2.0]), input_axes=[0], output_axes=[0] + ) + ] + ) + with pytest.raises(ValueError, match="leaving \\[1, 2\\]"): + ngff_transform_to_itk_transform(transform, ("z", "y", "x")) + + +def test_by_dimension_item_of_unequal_arity_is_refused(): + item = ByDimensionItem( + transformation=Affine(affine=[[1.0, 0.0, 2.0, 0.0]]), + input_axes=[0, 1, 2], + output_axes=[0], + ) + transform = ByDimension( + transformations=[ + item, + ByDimensionItem( + transformation=Identity(), input_axes=[1, 2], output_axes=[1, 2] + ), + ] + ) + with pytest.raises(ValueError, match="only a square mapping"): + ngff_transform_to_itk_transform(transform, ("z", "y", "x")) + + +def test_by_dimension_axis_index_beyond_the_coordinate_system_is_refused(): + transform = ByDimension( + transformations=[ + ByDimensionItem( + transformation=Scale(scale=[2.0, 2.0]), + input_axes=[0, 5], + output_axes=[0, 1], + ) + ] + ) + with pytest.raises(ValueError, match="exceed the 2 axes"): + ngff_transform_to_itk_transform(transform, ("y", "x")) + + +def test_by_dimension_matches_the_affine_it_is_equivalent_to(): + dims = ("z", "y", "x") + by_dimension = ByDimension( + transformations=[ + ByDimensionItem( + transformation=Translation(translation=[7.0]), + input_axes=[0], + output_axes=[0], + ), + ByDimensionItem( + transformation=Scale(scale=[2.0, 3.0]), + input_axes=[1, 2], + output_axes=[1, 2], + ), + ] + ) + affine = Affine( + affine=[ + [1.0, 0.0, 0.0, 7.0], + [0.0, 2.0, 0.0, 0.0], + [0.0, 0.0, 3.0, 0.0], + ] + ) + (from_by_dimension,) = ngff_transform_to_itk_transform(by_dimension, dims) + (from_affine,) = ngff_transform_to_itk_transform(affine, dims) + np.testing.assert_allclose(from_by_dimension.parameters, from_affine.parameters) + + +def test_an_unsupported_type_names_the_ones_that_convert(): + class Unsupported: + type = "someFutureType" + + with pytest.raises(NotImplementedError, match="mapAxis, byDimension, bijection"): + ngff_transform_to_itk_transform(Unsupported(), ("y", "x")) diff --git a/py/test/test_resample.py b/py/test/test_resample.py index a5a0b262..4dbf0fdd 100644 --- a/py/test/test_resample.py +++ b/py/test/test_resample.py @@ -515,3 +515,96 @@ def test_non_spatial_dims_are_rejected(): with pytest.raises(ValueError, match="non-spatial dims"): resample(_identity(2), fixed, moving) + + +def _oriented(image, orientations): + from dataclasses import replace + + return replace(image, axes_orientations=orientations) + + +def test_an_rfc5_transformation_resamples_like_the_itk_transform_it_equals(): + """The two entry points differ in convention, not in the pixels they read. + + RFC-5 orders the translation in ``dims`` order and ITK orders it + fastest-axis-first, so the same shift is written two ways; the resampled + grids must come out identical. + """ + from ngff_zarr.v06.zarr_metadata import Translation + + moving = _image( + "yx", {"y": 64, "x": 64}, {"y": 1, "x": 1}, {"y": 0, "x": 0}, chunks=(16, 16) + ) + fixed = _image( + "yx", + {"y": 48, "x": 40}, + {"y": 1, "x": 1}, + {"y": 2.0, "x": 3.0}, + chunks=(16, 16), + ) + + through_rfc5 = resample(Translation(translation=[3.5, -2.25]), fixed, moving) + through_itk = resample(_translation(2, [-2.25, 3.5]), fixed, moving) + + np.testing.assert_array_equal( + np.asarray(through_rfc5.data), np.asarray(through_itk.data) + ) + + +def test_an_rfc5_map_axis_resamples_like_the_affine_it_equals(): + from ngff_zarr.v06.zarr_metadata import Affine, MapAxis + + moving = _image("yx", {"y": 32, "x": 32}, {"y": 1, "x": 1}, {"y": 0, "x": 0}) + fixed = _image( + "yx", {"y": 32, "x": 32}, {"y": 1, "x": 1}, {"y": 0, "x": 0}, chunks=(8, 8) + ) + + swapped = resample(MapAxis(mapAxis=[1, 0]), fixed, moving) + as_affine = resample( + Affine(affine=[[0.0, 1.0, 0.0], [1.0, 0.0, 0.0]]), fixed, moving + ) + + np.testing.assert_array_equal(np.asarray(swapped.data), np.asarray(as_affine.data)) + # Swapping y and x transposes the sampled grid. + np.testing.assert_allclose( + np.asarray(swapped.data), np.asarray(moving.data).T, atol=1e-5 + ) + + +def test_an_rfc5_transformation_ignores_anatomical_orientation(): + """RFC-5 acts on the intrinsic systems, where no direction matrix applies. + + The fixed and moving images are given opposite orientations, so an ITK + transform would be resampled through two different physical frames. An + RFC-5 identity must still return the moving pixels, and must read the + region ``resample_bounding_box`` reports for the same transformation. + """ + from ngff_zarr.rfc4 import LPS, RAS + from ngff_zarr.v06.zarr_metadata import Identity + + moving = _image("yx", {"y": 24, "x": 24}, {"y": 1, "x": 1}, {"y": 0, "x": 0}) + fixed = _image( + "yx", {"y": 24, "x": 24}, {"y": 1, "x": 1}, {"y": 0, "x": 0}, chunks=(8, 8) + ) + fixed_ras = _oriented(fixed, {"y": RAS["y"], "x": RAS["x"]}) + moving_lps = _oriented(moving, {"y": LPS["y"], "x": LPS["x"]}) + + result = resample(Identity(), fixed_ras, moving_lps) + + np.testing.assert_allclose( + np.asarray(result.data), np.asarray(moving.data), atol=1e-5 + ) + assert result.axes_orientations == fixed_ras.axes_orientations + + region = resample_bounding_box(Identity(), fixed_ras, moving_lps) + assert region.clamped() == {"y": (0, 24), "x": (0, 24)} + + +def test_a_displacements_transformation_needs_its_field(): + from ngff_zarr.v06.zarr_metadata import Displacements + + moving = _image("yx", {"y": 16, "x": 16}, {"y": 1, "x": 1}, {"y": 0, "x": 0}) + fixed = _image("yx", {"y": 16, "x": 16}, {"y": 1, "x": 1}, {"y": 0, "x": 0}) + + with pytest.raises(ValueError, match="no field was passed"): + resample(Displacements(path="warp"), fixed, moving) diff --git a/py/test/test_resample_bounding_box.py b/py/test/test_resample_bounding_box.py index 8143bcf8..f1f26351 100644 --- a/py/test/test_resample_bounding_box.py +++ b/py/test/test_resample_bounding_box.py @@ -30,8 +30,13 @@ ) from ngff_zarr.v06.zarr_metadata import ( Affine, + Bijection, + ByDimension, + ByDimensionItem, + Coordinates, Displacements, Identity, + MapAxis, Rotation, Scale, TransformSequence, @@ -930,3 +935,127 @@ def test_unusable_pipeline_index_arrays_are_rejected( fixed = _image("yx", {"y": 4, "x": 4}, {"y": 1, "x": 1}, {"y": 0, "x": 0}) with pytest.raises(ValueError, match=match): resample_bounding_box(_identity(2), fixed, fixed) + + +def test_map_axis_region_matches_the_oracle(): + """A permutation reaches the region the matrix it stands for reaches.""" + spatial = ("z", "y", "x") + fixed = _image( + spatial, + {"z": 12, "y": 20, "x": 16}, + {"z": 2.0, "y": 1.0, "x": 0.5}, + {"z": 3.0, "y": -4.0, "x": 6.0}, + ) + moving = _image( + spatial, + {"z": 64, "y": 64, "x": 64}, + {"z": 1.0, "y": 1.0, "x": 1.0}, + {"z": 0.0, "y": 0.0, "x": 0.0}, + ) + # Output axis i takes input axis mapAxis[i], so row i selects that column. + matrix = np.array([[0.0, 1.0, 0.0], [0.0, 0.0, 1.0], [1.0, 0.0, 0.0]]) + + bounding_box = resample_bounding_box( + MapAxis(mapAxis=[1, 2, 0]), fixed, moving, padding=1 + ) + + expected_start, expected_size = _oracle_region( + matrix, np.zeros(3), fixed, moving, spatial, 1 + ) + assert [bounding_box.start_index[d] for d in spatial] == expected_start.tolist() + assert [bounding_box.size[d] for d in spatial] == expected_size.tolist() + + +def test_by_dimension_region_matches_the_oracle(): + spatial = ("z", "y", "x") + fixed = _image( + spatial, + {"z": 10, "y": 24, "x": 24}, + {"z": 1.0, "y": 0.5, "x": 0.5}, + {"z": -2.0, "y": 1.0, "x": 4.0}, + ) + moving = _image( + spatial, + {"z": 64, "y": 128, "x": 128}, + {"z": 1.0, "y": 0.25, "x": 0.25}, + {"z": 0.0, "y": 0.0, "x": 0.0}, + ) + transform = ByDimension( + transformations=[ + ByDimensionItem( + transformation=Translation(translation=[5.0]), + input_axes=[0], + output_axes=[0], + ), + ByDimensionItem( + transformation=Affine(affine=[[0.8, -0.6, 2.0], [0.6, 0.8, -3.0]]), + input_axes=[1, 2], + output_axes=[1, 2], + ), + ] + ) + matrix = np.array([[1.0, 0.0, 0.0], [0.0, 0.8, -0.6], [0.0, 0.6, 0.8]]) + offset = np.array([5.0, 2.0, -3.0]) + + bounding_box = resample_bounding_box(transform, fixed, moving, padding=2) + + expected_start, expected_size = _oracle_region( + matrix, offset, fixed, moving, spatial, 2 + ) + assert [bounding_box.start_index[d] for d in spatial] == expected_start.tolist() + assert [bounding_box.size[d] for d in spatial] == expected_size.tolist() + + +def test_bijection_region_follows_its_forward_direction(): + spatial = ("y", "x") + fixed = _image(spatial, {"y": 16, "x": 16}, {"y": 1.0, "x": 1.0}, {"y": 0, "x": 0}) + moving = _image( + spatial, {"y": 128, "x": 128}, {"y": 1.0, "x": 1.0}, {"y": 0, "x": 0} + ) + forward = Scale(scale=[2.0, 4.0]) + transform = Bijection(forward=forward, inverse=Scale(scale=[0.5, 0.25])) + + bounding_box = resample_bounding_box(transform, fixed, moving, padding=1) + directly = resample_bounding_box(forward, fixed, moving, padding=1) + + assert bounding_box.start_index == directly.start_index + assert bounding_box.size == directly.size + + +def test_rfc5_coordinates_matches_the_displacements_it_equals(): + """A coordinates field names the same points a displacements field does.""" + itk = pytest.importorskip("itk") + + fixed = _image("yx", {"y": 8, "x": 8}, {"y": 8.0, "x": 8.0}, {"y": 0.0, "x": 0.0}) + moving = _image( + "yx", {"y": 64, "x": 64}, {"y": 1.0, "x": 1.0}, {"y": 0.0, "x": 0.0} + ) + warp = _constant_displacement_field(itk, [5.0, -3.0], size=8, spacing=8.0) + displacements, field = itk_displacement_field_to_ngff_transform( + warp, ("y", "x"), path="warp" + ) + + grid = np.meshgrid( + *[ + field.translation[dim] + field.scale[dim] * np.arange(extent) + for dim, extent in zip(("y", "x"), field.data.shape[1:]) + ], + indexing="ij", + ) + absolute = NgffImage( + data=da.from_array(np.asarray(field.data) + np.stack(grid)), + dims=field.dims, + scale=dict(field.scale), + translation=dict(field.translation), + axes_types={"c": "coordinate"}, + ) + + via_displacements = resample_bounding_box( + displacements, fixed, moving, fields={"warp": field} + ) + via_coordinates = resample_bounding_box( + Coordinates(path="warp"), fixed, moving, fields={"warp": absolute} + ) + + assert via_coordinates.start_index == via_displacements.start_index + assert via_coordinates.size == via_displacements.size diff --git a/ts/src/io/resample_bounding_box-shared.ts b/ts/src/io/resample_bounding_box-shared.ts index f74ebc32..74e746f0 100644 --- a/ts/src/io/resample_bounding_box-shared.ts +++ b/ts/src/io/resample_bounding_box-shared.ts @@ -37,8 +37,8 @@ export interface ResampleBoundingBoxOptions { */ padding?: number; /** - * The field images an RFC-5 `displacements` transformation points at, keyed - * by its `path`. Required for a `displacements` transformation, ignored + * The field images an RFC-5 `displacements` or `coordinates` transformation + * points at, keyed by its `path`. Required for those two, ignored * otherwise. A field carrying an anatomical orientation is refused here, * since this branch works on the intrinsic systems where none applies: * convert it with `ngffDisplacementFieldToItkTransform`, passing `fixed` @@ -300,16 +300,16 @@ export function metadataOnlyItkImage( return itkImage; } -/** The field `fields` holds for a `displacements` transform, with a message. */ +/** The field `fields` holds for a field transform, with a message. */ function fieldFor( - transform: { path: string }, + transform: { type: string; path: string }, fields: Record | undefined, ): NgffImage | NgffMultiscales { const field = fields?.[transform.path]; if (field === undefined) { const available = Object.keys(fields ?? {}).sort().join(", "); throw new Error( - `the displacements transform points at '${transform.path}', but no ` + + `the ${transform.type} transform points at '${transform.path}', but no ` + `field was passed for it (fields given: [${available}]). Load it ` + `with fromOmeZarr(\`\${store}/${transform.path}\`) and pass ` + `{ fields: { "${transform.path}": field } }.`, @@ -402,15 +402,16 @@ export async function resampleBoundingBoxShared( fixedDirection = itkDirection(fixed, itkDims); movingDirection = itkDirection(moving, itkDims); } else if (isV06Transform(transform)) { - transformList = transform.type === "displacements" - // The field is an array, so it comes in beside the transformation - // rather than inside it. - ? await ngffDisplacementFieldToItkTransform( - transform, - fieldFor(transform, options.fields), - fixedSpatial, - ) - : ngffTransformToItkTransform(transform, fixed.dims); + transformList = + transform.type === "displacements" || transform.type === "coordinates" + // The field is an array, so it comes in beside the transformation + // rather than inside it. + ? await ngffDisplacementFieldToItkTransform( + transform, + fieldFor(transform, options.fields), + fixedSpatial, + ) + : ngffTransformToItkTransform(transform, fixed.dims); // An RFC-5 transformation is defined on the intrinsic coordinate system, // which carries no direction matrix. fixedDirection = identityDirection(itkDims.length); diff --git a/ts/src/utils/displacement_field_transform.ts b/ts/src/utils/displacement_field_transform.ts index 867ebec6..b73d43b4 100644 --- a/ts/src/utils/displacement_field_transform.ts +++ b/ts/src/utils/displacement_field_transform.ts @@ -44,7 +44,7 @@ import * as zarr from "zarrita"; import type { Image, Transform, TransformList } from "itk-wasm"; import { NgffImage } from "../types/ngff_image.ts"; import type { NgffMultiscales } from "../types/multiscales.ts"; -import type { Displacements } from "../types/zarr_metadata.ts"; +import type { Coordinates, Displacements } from "../types/zarr_metadata.ts"; import { toNgffImage } from "../io/to_ngff_image.ts"; import { changeOfFrame, @@ -470,17 +470,22 @@ export async function itkDisplacementFieldToNgffTransform( } /** - * Convert an RFC-5 `displacements` transform and its field to ITK. + * Convert an RFC-5 `displacements` or `coordinates` transform to ITK. * * The counterpart of {@link itkDisplacementFieldToNgffTransform}. The field * is the image stored at `transform.path`; load it with * `fromOmeZarr(`${store}/${transform.path}`)`. * - * @param transform The `displacements` transform. + * A `coordinates` field holds the absolute output position of each grid point + * where a `displacements` field holds the offset from it. The two differ by + * the position of the grid point itself, so both reach ITK as one + * `DisplacementField`: ITK has no absolute-coordinate transform. + * + * @param transform The `displacements` or `coordinates` transform. * @param field The field image: an `NgffImage` whose component axis is the one - * with `axesTypes` `displacement`, followed by `dims` in order; or an - * `NgffMultiscales`, whose finest level is used and whose metadata names the - * component axis. + * with `axesTypes` `displacement` (`coordinate` for a `coordinates` + * transform), followed by `dims` in order; or an `NgffMultiscales`, whose + * finest level is used and whose metadata names the component axis. * @param dims The spatial axis names of the input coordinate system, in RFC-5 * (Zarr) order. * @param frames The fixed and moving images the field relates; see @@ -492,30 +497,36 @@ export async function itkDisplacementFieldToNgffTransform( * or if its orientation is not the fixed image's. */ export async function ngffDisplacementFieldToItkTransform( - transform: Displacements, + transform: Displacements | Coordinates, field: NgffImage | NgffMultiscales, dims: string[], frames_: FieldFrames = {}, ): Promise { checkDims(dims); + // A coordinates field holds the absolute output position of each grid point + // where a displacements field holds the offset from it, so the two differ by + // the position of the grid point itself. ITK has no absolute-coordinate + // transform, so both reach it as one DisplacementField. + const absolute = transform.type === "coordinates"; + const componentType = absolute ? "coordinate" : "displacement"; let componentDims: string[]; let image: NgffImage; if ("images" in field && "metadata" in field) { // A read multiscales keeps the axis types in its metadata, not on the - // image: the component axis is the one typed "displacement" there. + // image: the component axis is the one typed there. componentDims = field.metadata.axes - .filter((axis) => axis.type === "displacement") + .filter((axis) => axis.type === componentType) .map((axis) => axis.name); image = field.images[0]; } else { image = field; componentDims = Object.entries(image.axesTypes ?? {}) - .filter(([, type]) => type === "displacement") + .filter(([, type]) => type === componentType) .map(([dim]) => dim); } if (componentDims.length !== 1) { throw new Error( - "the field image must have exactly one axis of type 'displacement' " + + `the field image must have exactly one axis of type '${componentType}' ` + "(axesTypes on an NgffImage, the axes metadata of a multiscales); " + `got [${componentDims.join(", ")}] on dims [${image.dims.join(", ")}]`, ); @@ -526,7 +537,7 @@ export async function ngffDisplacementFieldToItkTransform( image.dims.some((dim, i) => dim !== expectedDims[i]) ) { throw new Error( - `the field's dims are [${image.dims.join(", ")}]; a displacements ` + + `the field's dims are [${image.dims.join(", ")}]; a ${transform.type} ` + `transform over dims [${dims.join(", ")}] needs ` + `[${expectedDims.join(", ")}]: the component axis first, then the ` + "input axes in order", @@ -576,14 +587,20 @@ export async function ngffDisplacementFieldToItkTransform( translation.map((value, i) => value - frame.originFixed[i]), ).map((value, i) => value + frame.originFixed[i]); - // v(q) = D_out (d - (M - I) q - b) -- see frameTerms. + // v(q) = D_out (d - (M - I) q - b) -- see frameTerms. A coordinates field + // holds q + d rather than d, so the grid point comes off along with the + // frame term, which is subtracting M q rather than (M - I) q. const terms = frameTerms(frame); const toPhysical = (displacement: number[], point: number[]): number[] => { - if (!terms.shifts) return matvec(terms.directionOut, displacement); + if (!terms.shifts && !absolute) { + return matvec(terms.directionOut, displacement); + } const shift = matvec(terms.shiftMatrix, point); return matvec( terms.directionOut, - displacement.map((value, i) => value - shift[i] - terms.shiftVector[i]), + displacement.map((value, i) => + value - shift[i] - terms.shiftVector[i] - (absolute ? point[i] : 0) + ), ); }; @@ -592,7 +609,7 @@ export async function ngffDisplacementFieldToItkTransform( transform.interpolation !== "linear" ) { console.warn( - `the displacements transform asks for '${transform.interpolation}' ` + + `the ${transform.type} transform asks for '${transform.interpolation}' ` + "interpolation; ITK interpolates a displacement field linearly. " + "RFC-5 leaves the choice to the consumer.", ); diff --git a/ts/src/utils/ngff_transform_to_itk_transform.ts b/ts/src/utils/ngff_transform_to_itk_transform.ts index 90c9096e..9d244141 100644 --- a/ts/src/utils/ngff_transform_to_itk_transform.ts +++ b/ts/src/utils/ngff_transform_to_itk_transform.ts @@ -33,13 +33,24 @@ import { optionalFrameGeometry, transposed, } from "./itk_direction.ts"; -import type { V06Transform } from "../types/zarr_metadata.ts"; +import type { + ByDimension, + ByDimensionItem, + V06Transform, +} from "../types/zarr_metadata.ts"; const SPATIAL_DIMS = ["x", "y", "z"]; /** A square matrix stored as an array of rows. */ type Matrix = number[][]; +function zeroMatrix(size: number): Matrix { + return Array.from( + { length: size }, + () => Array.from({ length: size }, () => 0), + ); +} + function identityMatrix(size: number): Matrix { return Array.from( { length: size }, @@ -188,17 +199,113 @@ function homogeneousFromTransform( return matrix; } + case "mapAxis": { + if (transform.mapAxis.length !== ndim) { + throw new Error( + `mapAxis transformation permutes ${transform.mapAxis.length} axes ` + + `but the coordinate system has ${ndim}`, + ); + } + const matrix = zeroMatrix(ndim + 1); + matrix[ndim][ndim] = 1; + // The value at position i names the input axis that becomes output + // axis i, so the row for output i selects that input. + transform.mapAxis.forEach((inputAxis, outputAxis) => { + matrix[outputAxis][inputAxis] = 1; + }); + return matrix; + } + + case "byDimension": + return homogeneousFromByDimension(transform, ndim); + + case "bijection": + // The forward direction is the mapping; RFC-5 keeps the inverse + // alongside it so a reader need not invert the forward one, which is + // what ITK does for an affine anyway. + return homogeneousFromTransform(transform.forward, ndim); + default: throw new Error( `transformation type '${ (transform as { type: string }).type - }' cannot be converted to an ITK transform. Only identity, scale, ` + - `translation, rotation, affine and sequences of them describe a ` + - `linear mapping that ITK can represent as a single affine transform.`, + }' cannot be converted to an ITK transform. identity, scale, ` + + `translation, rotation, affine, mapAxis, byDimension, bijection ` + + `and sequences of them describe a linear mapping that ITK can ` + + `represent as a single affine transform; displacements and ` + + `coordinates convert to a displacement field, given the field ` + + `they point at.`, ); } } +/** + * Assemble a byDimension transformation into one homogeneous matrix. + * + * Each item is a lower-dimensional transformation between two subsets of + * axes, so its own matrix is written into the rows its `output_axes` name and + * the columns its `input_axes` name. Axes no item produces would leave a zero + * row, which collapses the image rather than transforming it, so a gap is + * refused here rather than resampled. + */ +function homogeneousFromByDimension( + transform: ByDimension, + ndim: number, +): Matrix { + const matrix = zeroMatrix(ndim + 1); + matrix[ndim][ndim] = 1; + const produced = new Set(); + + for (const item of transform.transformations) { + const block = byDimensionItemBlock(item, ndim); + item.output_axes.forEach((outputAxis, row) => { + item.input_axes.forEach((inputAxis, col) => { + matrix[outputAxis][inputAxis] = block[row][col]; + }); + matrix[outputAxis][ndim] = block[row][item.input_axes.length]; + produced.add(outputAxis); + }); + } + + const missing = Array.from({ length: ndim }, (_, axis) => axis) + .filter((axis) => !produced.has(axis)); + if (missing.length > 0) { + throw new Error( + `byDimension transformation produces output axes ` + + `[${Array.from(produced).sort((a, b) => a - b).join(", ")}], leaving ` + + `[${missing.join(", ")}] of the ${ndim} axes of the coordinate ` + + `system unset; every output axis must be produced by exactly one item`, + ); + } + return matrix; +} + +/** One byDimension item as a homogeneous matrix over its own axes. */ +function byDimensionItemBlock(item: ByDimensionItem, ndim: number): Matrix { + if (item.input_axes.length !== item.output_axes.length) { + throw new Error( + `byDimension item of type '${item.transformation.type}' maps ` + + `${item.input_axes.length} input axes to ${item.output_axes.length} ` + + `output axes; only a square mapping converts to an ITK transform`, + ); + } + const beyond = [...item.input_axes, ...item.output_axes] + .filter((axis) => axis >= ndim); + if (beyond.length > 0) { + throw new Error( + `byDimension axis indices [${beyond.join(", ")}] exceed the ${ndim} ` + + `axes of the coordinate system`, + ); + } + if (new Set(item.input_axes).size !== item.input_axes.length) { + throw new Error( + `byDimension input axes [${item.input_axes.join(", ")}] name an axis ` + + `twice`, + ); + } + return homogeneousFromTransform(item.transformation, item.input_axes.length); +} + /** An ITK matrix and offset for the spatial axes, fastest-axis-first. */ export interface ItkMatrixAndOffset { /** Row-major square matrix, in ITK axis order. */ @@ -218,7 +325,7 @@ export interface ItkMatrixAndOffset { * @internal * @param transform An RFC-5 (OME-Zarr v0.6) coordinate transformation. It must * describe a linear mapping: `identity`, `scale`, `translation`, `rotation`, - * `affine`, or a `sequence` of those. + * `affine`, `mapAxis`, `byDimension`, `bijection`, or a `sequence` of those. * @param dims The axis names of the coordinate system the transformation is * defined on, in RFC-5 (Zarr) order, e.g. `["z", "y", "x"]`. * @returns The matrix and offset for the spatial axes, in ITK order. ITK has no @@ -291,11 +398,11 @@ export function ngffTransformToItkTransform( dims: string[], frames: { fixed?: NgffImage; moving?: NgffImage } = {}, ): TransformList { - if (transform.type === "displacements") { + if (transform.type === "displacements" || transform.type === "coordinates") { throw new Error( - `the displacements transform points at '${transform.path}'; convert ` + - "it with ngffDisplacementFieldToItkTransform, passing the field " + - "loaded from that path", + `the ${transform.type} transform points at '${transform.path}'; ` + + "convert it with ngffDisplacementFieldToItkTransform, passing the " + + "field loaded from that path", ); } let { matrix, offset } = ngffTransformToItkMatrix(transform, dims); diff --git a/ts/test/displacement_field_transform_test.ts b/ts/test/displacement_field_transform_test.ts index 72a8b880..e1ca5c67 100644 --- a/ts/test/displacement_field_transform_test.ts +++ b/ts/test/displacement_field_transform_test.ts @@ -519,3 +519,157 @@ Deno.test("single precision is preserved", async () => { assertEquals(String(back.transformType.parametersValueType), "float32"); assertEquals(back.parameters instanceof Float32Array, true); }); + +/** + * The `coordinates` field holding what `field` displaces from. + * + * A coordinates field gives the absolute output position of each grid point + * where a displacements field gives the offset, so the two differ by the + * position of the grid point itself, in the field's own intrinsic system. + */ +async function asCoordinates( + field: NgffImage, + dims: string[], +): Promise { + const shape = field.data.shape as number[]; + const source = await fieldData(field); + const voxels = shape.slice(1).reduce((a, b) => a * b, 1); + const strides = new Array(dims.length); + let step = 1; + for (let axis = dims.length - 1; axis >= 0; axis--) { + strides[axis] = step; + step *= shape[1 + axis]; + } + const values = new Float64Array(source.length); + for (let component = 0; component < dims.length; component++) { + const dim = dims[component]; + for (let voxel = 0; voxel < voxels; voxel++) { + const index = Math.floor(voxel / strides[component]) % + shape[1 + component]; + values[component * voxels + voxel] = source[component * voxels + voxel] + + field.translation[dim] + field.scale[dim] * index; + } + } + const data = await zarr.create(zarr.root(new Map()).resolve("coords"), { + shape, + chunk_shape: shape, + data_type: "float64", + fill_value: 0, + }); + await zarr.set(data, null, { + data: values, + shape, + stride: [voxels, ...strides], + }); + return new NgffImage({ + data, + dims: field.dims, + scale: field.scale, + translation: field.translation, + name: "coordinates", + axesUnits: undefined, + axesTypes: { c: "coordinate" }, + axesOrientations: field.axesOrientations, + computedCallbacks: undefined, + }); +} + +for (const dims of [["y", "x"], ["z", "y", "x"]]) { + Deno.test( + `a coordinates field converts like the displacements it equals (${ + dims.join("") + })`, + async () => { + const ndim = dims.length; + const size = [5, 4, 3].slice(0, ndim); + const entry = fieldTransform( + size, + [0.5, 2.0, 1.5].slice(0, ndim), + [10.0, 20.0, -3.0].slice(0, ndim), + undefined, + 3, + ); + const { transform, field } = await itkDisplacementFieldToNgffTransform( + entry, + dims, + { path: "warp" }, + ); + + const [viaDisplacements] = await ngffDisplacementFieldToItkTransform( + transform, + field, + dims, + ); + const [viaCoordinates] = await ngffDisplacementFieldToItkTransform( + { type: "coordinates", path: "warp", interpolation: "linear" }, + await asCoordinates(field, dims), + dims, + ); + + assertAllClose( + viaCoordinates.parameters as unknown as ArrayLike, + viaDisplacements.parameters as unknown as ArrayLike, + ); + assertAllClose( + viaCoordinates.fixedParameters as unknown as ArrayLike, + viaDisplacements.fixedParameters as unknown as ArrayLike, + ); + }, + ); +} + +Deno.test("a coordinates field changes frames like a displacements one", async () => { + const size = [4, 3, 5]; + const spacing = [1.0, 2.0, 0.5]; + const origin = [5.0, -2.0, 8.0]; + const dims = CANONICAL[3]; + const itkDims = ["x", "y", "z"]; + const fixed = await frameImage(size, spacing, origin, RAS); + const moving = await frameImage(size, spacing, [1.0, 1.0, 1.0]); + const direction = directionRows(itkDirection(fixed, itkDims), 3); + const entry = fieldTransform(size, spacing, origin, direction, 11); + + const { transform, field } = await itkDisplacementFieldToNgffTransform( + entry, + dims, + { path: "warp", fixed, moving }, + ); + + const [viaDisplacements] = await ngffDisplacementFieldToItkTransform( + transform, + field, + dims, + { fixed, moving }, + ); + const [viaCoordinates] = await ngffDisplacementFieldToItkTransform( + { type: "coordinates", path: "warp" }, + await asCoordinates(field, dims), + dims, + { fixed, moving }, + ); + + assertAllClose( + viaCoordinates.parameters as unknown as ArrayLike, + viaDisplacements.parameters as unknown as ArrayLike, + ); +}); + +Deno.test("a coordinates transform wants a coordinate component axis", async () => { + const entry = fieldTransform([3, 2], [1, 1.5], [0, -2]); + const { field } = await itkDisplacementFieldToNgffTransform( + entry, + ["y", "x"], + { path: "w" }, + ); + + await assertRejects( + () => + ngffDisplacementFieldToItkTransform( + { type: "coordinates", path: "w" }, + field, + ["y", "x"], + ), + Error, + "exactly one axis of type 'coordinate'", + ); +}); diff --git a/ts/test/ngff_transform_to_itk_transform_test.ts b/ts/test/ngff_transform_to_itk_transform_test.ts new file mode 100644 index 00000000..b8113fda --- /dev/null +++ b/ts/test/ngff_transform_to_itk_transform_test.ts @@ -0,0 +1,312 @@ +// SPDX-FileCopyrightText: Copyright (c) Fideus Labs LLC +// SPDX-License-Identifier: MIT + +/** + * RFC-5 transformations that are not an affine, converted to ITK. + * + * Mirrors `py/test/test_ngff_transform_to_itk_transform.py`. `mapAxis`, + * `byDimension` and `bijection` describe a linear mapping the way the RFC lets + * a writer describe it -- as a permutation, as one transformation per group of + * axes, or alongside its inverse -- rather than as a matrix. Each is folded + * into the single affine ITK gets. There is no `itk` here to evaluate the + * result against, so the anchor is an oracle that reads each convention from + * the RFC rather than from the matrix builder. + */ + +import { assertAlmostEquals, assertThrows } from "@std/assert"; +import { + type ByDimensionItem, + createAffine, + createBijection, + createByDimension, + createIdentity, + createMapAxis, + createScale, + createTransformSequence, + createTranslation, + type V06Transform, +} from "../src/mod.ts"; +import { ngffTransformToItkMatrix } from "../src/utils/ngff_transform_to_itk_transform.ts"; + +const ITK_ORDER = ["x", "y", "z"]; + +/** + * Evaluate an RFC-5 transformation, independently of the module under test. + * + * Every convention it encodes -- the direction a `mapAxis` permutation runs + * in, the order a `sequence` applies its entries in, which side of a + * `bijection` is the mapping -- is read straight from the RFC. + */ +function apply(transform: V06Transform, point: number[]): number[] { + switch (transform.type) { + case "identity": + return [...point]; + case "scale": + return point.map((value, i) => value * transform.scale[i]); + case "translation": + return point.map((value, i) => value + transform.translation[i]); + case "affine": + return transform.affine.map((row) => + row.slice(0, -1).reduce((total, v, i) => total + v * point[i], 0) + + row[row.length - 1] + ); + case "mapAxis": + // The value at position i names the input axis that becomes output i. + return transform.mapAxis.map((inputAxis) => point[inputAxis]); + case "byDimension": { + const result = new Array(point.length).fill(0); + for (const item of transform.transformations) { + const sub = apply( + item.transformation, + item.input_axes.map((axis) => point[axis]), + ); + item.output_axes.forEach((axis, position) => { + result[axis] = sub[position]; + }); + } + return result; + } + case "bijection": + return apply(transform.forward, point); + case "sequence": + return transform.transformations.reduce( + (current, sub) => apply(sub, current), + point, + ); + default: + throw new Error(`the oracle does not handle ${transform.type}`); + } +} + +/** `values`, given in `dims` order, reordered fastest-axis-first. */ +function itkOrder(values: number[], dims: string[]): number[] { + return ITK_ORDER.filter((dim) => dims.includes(dim)) + .map((dim) => values[dims.indexOf(dim)]); +} + +function samplePoints(ndim: number): number[][] { + const points = [new Array(ndim).fill(0)]; + for (let row = 0; row < 4; row++) { + points.push( + Array.from({ length: ndim }, (_, axis) => (row + 1) * 3.5 - axis * 2.25), + ); + } + return points; +} + +function assertMapsLikeTheOracle(transform: V06Transform, dims: string[]) { + const { matrix, offset } = ngffTransformToItkMatrix(transform, dims); + for (const point of samplePoints(dims.length)) { + const input = itkOrder(point, dims); + const expected = itkOrder(apply(transform, point), dims); + for (let row = 0; row < offset.length; row++) { + const mapped = matrix[row].reduce( + (total, value, col) => total + value * input[col], + offset[row], + ); + assertAlmostEquals(mapped, expected[row], 1e-9); + } + } +} + +const item = ( + transformation: V06Transform, + input_axes: number[], + output_axes: number[], +): ByDimensionItem => ({ transformation, input_axes, output_axes }); + +Deno.test("a mapAxis reversal maps like the oracle", () => { + assertMapsLikeTheOracle(createMapAxis([2, 1, 0]), ["z", "y", "x"]); +}); + +Deno.test("a mapAxis on non-canonical dims binds axes by name", () => { + assertMapsLikeTheOracle(createMapAxis([1, 2, 0]), ["x", "z", "y"]); +}); + +Deno.test("a mapAxis rotation is not read backwards", () => { + // [1, 2, 0] and [2, 0, 1] are inverse permutations, so a conversion that + // reads the transpose vector backwards passes every symmetric case and + // fails this one. + const dims = ["z", "y", "x"]; + const { matrix, offset } = ngffTransformToItkMatrix( + createMapAxis([1, 2, 0]), + dims, + ); + const input = itkOrder([1, 2, 3], dims); + const expected = itkOrder([2, 3, 1], dims); + for (let row = 0; row < offset.length; row++) { + const mapped = matrix[row].reduce( + (total, value, col) => total + value * input[col], + offset[row], + ); + assertAlmostEquals(mapped, expected[row], 1e-9); + } +}); + +Deno.test("a byDimension of a translation and a scale maps like the oracle", () => { + assertMapsLikeTheOracle( + createByDimension([ + item(createTranslation([7]), [0], [0]), + item(createScale([2, 3]), [1, 2], [1, 2]), + ]), + ["z", "y", "x"], + ); +}); + +Deno.test("a byDimension that permutes maps like the oracle", () => { + assertMapsLikeTheOracle( + createByDimension([ + item(createScale([4]), [0], [2]), + item(createAffine([[1, 0.5, 3], [0, 2, -1]]), [1, 2], [0, 1]), + ]), + ["z", "y", "x"], + ); +}); + +Deno.test("a byDimension holding a mapAxis maps like the oracle", () => { + assertMapsLikeTheOracle( + createByDimension([ + item(createMapAxis([1, 0]), [0, 1], [0, 1]), + item(createTranslation([-5]), [2], [2]), + ]), + ["z", "y", "x"], + ); +}); + +Deno.test("a bijection maps like its forward direction", () => { + assertMapsLikeTheOracle( + createBijection(createScale([2, 4]), createScale([0.5, 0.25])), + ["y", "x"], + ); +}); + +Deno.test("a bijection ignores an inverse that disagrees", () => { + // RFC-5 does not require the two directions to be consistent, and the + // forward one is the mapping. + const { matrix } = ngffTransformToItkMatrix( + createBijection(createScale([2, 2]), createScale([9, 9])), + ["y", "x"], + ); + assertAlmostEquals(matrix[0][0], 2, 1e-12); +}); + +Deno.test("a sequence of the new types maps like the oracle", () => { + assertMapsLikeTheOracle( + createTransformSequence([ + createMapAxis([1, 0]), + createTranslation([10, -20]), + createBijection( + createAffine([[0.8, -0.6, 1], [0.6, 0.8, 2]]), + createIdentity(), + ), + ]), + ["y", "x"], + ); +}); + +Deno.test("a mapAxis length must match the coordinate system", () => { + assertThrows( + () => ngffTransformToItkMatrix(createMapAxis([1, 0]), ["z", "y", "x"]), + Error, + "permutes 2 axes", + ); +}); + +Deno.test("a mapAxis that couples a channel axis is refused", () => { + assertThrows( + () => ngffTransformToItkMatrix(createMapAxis([2, 1, 0]), ["c", "y", "x"]), + Error, + "couples spatial and non-spatial", + ); +}); + +Deno.test("a byDimension may leave a non-spatial axis to itself", () => { + // A time scale is projected away, exactly as it is for an affine: it is not + // part of the spatial mapping an ITK transform describes. + const { matrix, offset } = ngffTransformToItkMatrix( + createByDimension([ + item(createScale([0.25]), [0], [0]), + item(createTranslation([3, -4]), [1, 2], [1, 2]), + ]), + ["t", "y", "x"], + ); + assertAlmostEquals(matrix[0][0], 1, 1e-12); + assertAlmostEquals(offset[0], -4, 1e-12); + assertAlmostEquals(offset[1], 3, 1e-12); +}); + +Deno.test("a byDimension that leaves an output axis unset is refused", () => { + assertThrows( + () => + ngffTransformToItkMatrix( + createByDimension([item(createScale([2]), [0], [0])]), + ["z", "y", "x"], + ), + Error, + "leaving [1, 2]", + ); +}); + +Deno.test("a byDimension item of unequal arity is refused", () => { + assertThrows( + () => + ngffTransformToItkMatrix( + createByDimension([ + item(createAffine([[1, 0, 2, 0]]), [0, 1, 2], [0]), + item(createIdentity(), [1, 2], [1, 2]), + ]), + ["z", "y", "x"], + ), + Error, + "only a square mapping", + ); +}); + +Deno.test("a byDimension axis index beyond the coordinate system is refused", () => { + assertThrows( + () => + ngffTransformToItkMatrix( + createByDimension([item(createScale([2, 2]), [0, 5], [0, 1])]), + ["y", "x"], + ), + Error, + "exceed the 2 axes", + ); +}); + +Deno.test("a byDimension matches the affine it is equivalent to", () => { + const dims = ["z", "y", "x"]; + const byDimension = ngffTransformToItkMatrix( + createByDimension([ + item(createTranslation([7]), [0], [0]), + item(createScale([2, 3]), [1, 2], [1, 2]), + ]), + dims, + ); + const affine = ngffTransformToItkMatrix( + createAffine([[1, 0, 0, 7], [0, 2, 0, 0], [0, 0, 3, 0]]), + dims, + ); + for (let row = 0; row < 3; row++) { + assertAlmostEquals(byDimension.offset[row], affine.offset[row], 1e-12); + for (let col = 0; col < 3; col++) { + assertAlmostEquals( + byDimension.matrix[row][col], + affine.matrix[row][col], + 1e-12, + ); + } + } +}); + +Deno.test("an unsupported type names the ones that convert", () => { + assertThrows( + () => + ngffTransformToItkMatrix( + { type: "someFutureType" } as unknown as V06Transform, + ["y", "x"], + ), + Error, + "mapAxis, byDimension, bijection", + ); +}); diff --git a/ts/test/resample_bounding_box_test.ts b/ts/test/resample_bounding_box_test.ts index 53006671..a38fcbb5 100644 --- a/ts/test/resample_bounding_box_test.ts +++ b/ts/test/resample_bounding_box_test.ts @@ -22,7 +22,10 @@ import { import * as zarr from "zarrita"; import { createAffine, + createBijection, + createByDimension, createIdentity, + createMapAxis, createRotation, createScale, createTransformSequence, @@ -1124,3 +1127,108 @@ Deno.test("unusable pipeline index arrays are rejected", async () => { ); } }); + +Deno.test("a mapAxis region matches the oracle", async () => { + const dims = ["z", "y", "x"]; + const fixed = await geometryImage(dims, { z: 12, y: 20, x: 16 }, { + z: 2, + y: 1, + x: 0.5, + }, { z: 3, y: -4, x: 6 }); + const moving = await geometryImage(dims, { z: 64, y: 64, x: 64 }, { + z: 1, + y: 1, + x: 1, + }, { z: 0, y: 0, x: 0 }); + + const region = await resampleBoundingBox( + createMapAxis([1, 2, 0]), + fixed, + moving, + { padding: 1 }, + ); + + // Output axis i takes input axis mapAxis[i], so row i selects that column. + const { start, size } = oracleRegion( + [[0, 1, 0], [0, 0, 1], [1, 0, 0]], + [0, 0, 0], + [12, 20, 16], + [2, 1, 0.5], + [3, -4, 6], + [1, 1, 1], + [0, 0, 0], + 1, + ); + assertEquals(dims.map((dim) => region.startIndex[dim]), start); + assertEquals(dims.map((dim) => region.size[dim]), size); +}); + +Deno.test("a byDimension region matches the oracle", async () => { + const dims = ["z", "y", "x"]; + const fixed = await geometryImage(dims, { z: 10, y: 24, x: 24 }, { + z: 1, + y: 0.5, + x: 0.5, + }, { z: -2, y: 1, x: 4 }); + const moving = await geometryImage(dims, { z: 64, y: 128, x: 128 }, { + z: 1, + y: 0.25, + x: 0.25, + }, { z: 0, y: 0, x: 0 }); + + const region = await resampleBoundingBox( + createByDimension([ + { + transformation: createTranslation([5]), + input_axes: [0], + output_axes: [0], + }, + { + transformation: createAffine([[0.8, -0.6, 2], [0.6, 0.8, -3]]), + input_axes: [1, 2], + output_axes: [1, 2], + }, + ]), + fixed, + moving, + { padding: 2 }, + ); + + const { start, size } = oracleRegion( + [[1, 0, 0], [0, 0.8, -0.6], [0, 0.6, 0.8]], + [5, 2, -3], + [10, 24, 24], + [1, 0.5, 0.5], + [-2, 1, 4], + [1, 0.25, 0.25], + [0, 0, 0], + 2, + ); + assertEquals(dims.map((dim) => region.startIndex[dim]), start); + assertEquals(dims.map((dim) => region.size[dim]), size); +}); + +Deno.test("a bijection region follows its forward direction", async () => { + const fixed = await geometryImage(["y", "x"], { y: 16, x: 16 }, { + y: 1, + x: 1, + }, { y: 0, x: 0 }); + const moving = await geometryImage(["y", "x"], { y: 128, x: 128 }, { + y: 1, + x: 1, + }, { y: 0, x: 0 }); + const forward = createScale([2, 4]); + + const region = await resampleBoundingBox( + createBijection(forward, createScale([0.5, 0.25])), + fixed, + moving, + { padding: 1 }, + ); + const directly = await resampleBoundingBox(forward, fixed, moving, { + padding: 1, + }); + + assertEquals(region.startIndex, directly.startIndex); + assertEquals(region.size, directly.size); +}); From 945b590ab9e8746ff0b5618e26a326a7d0aad003 Mon Sep 17 00:00:00 2001 From: Valentin Boussot Date: Tue, 25 Aug 2026 14:19:54 +0200 Subject: [PATCH 10/11] fix(py,ts): follow the byDimension axis key rename #677 spells the byDimension axis lists inputAxes and outputAxes, which is what the 0.6rc0 schema declares, and dropped the snake_case form from the model. The ITK conversion still built and read input_axes and output_axes, so ByDimensionItem raised TypeError on construction in Python and the TypeScript port did not type-check. The reader keeps accepting the snake_case spelling on the wire for stores ngff-zarr 0.43.0 wrote; only the model fields are renamed here. --- docs/itk.md | 2 +- docs/rfc5.md | 2 +- .../ngff_transform_to_itk_transform.py | 22 ++++---- .../test_ngff_transform_to_itk_transform.py | 54 +++++++++---------- py/test/test_resample_bounding_box.py | 8 +-- .../utils/ngff_transform_to_itk_transform.ts | 22 ++++---- .../ngff_transform_to_itk_transform_test.ts | 10 ++-- ts/test/resample_bounding_box_test.ts | 8 +-- 8 files changed, 64 insertions(+), 64 deletions(-) diff --git a/docs/itk.md b/docs/itk.md index 28a50341..5216306a 100644 --- a/docs/itk.md +++ b/docs/itk.md @@ -365,7 +365,7 @@ Every RFC-5 transformation type converts to ITK. `identity`, `scale`, `translation`, `rotation`, `affine`, `mapAxis`, `byDimension` and `bijection`, and any `sequence` of them, describe a linear mapping, and each is folded into the single affine ITK gets: a `mapAxis` becomes its permutation matrix, a -`byDimension` writes each item into the rows its `output_axes` name, and a +`byDimension` writes each item into the rows its `outputAxes` name, and a `bijection` contributes its `forward` direction, since ITK inverts an affine itself. A `byDimension` that leaves an output axis unproduced is refused rather than resampled, because the zero row it would leave collapses the image. diff --git a/docs/rfc5.md b/docs/rfc5.md index 93c36ca4..1b25410a 100644 --- a/docs/rfc5.md +++ b/docs/rfc5.md @@ -234,7 +234,7 @@ to the multiscales metadata as shown above. `byDimension`, `bijection` and any `sequence` of them describe a linear mapping and are folded into the single affine ITK gets. A `mapAxis` becomes its permutation matrix, a `byDimension` writes each item into the rows its -`output_axes` name, and a `bijection` contributes its `forward` direction. +`outputAxes` name, and a `bijection` contributes its `forward` direction. Both directions reconcile the places where the conventions differ: RFC-5 orders parameters in Zarr axis order while ITK orders them fastest-axis-first, an diff --git a/py/ngff_zarr/ngff_transform_to_itk_transform.py b/py/ngff_zarr/ngff_transform_to_itk_transform.py index 1f3d2d68..66904e06 100644 --- a/py/ngff_zarr/ngff_transform_to_itk_transform.py +++ b/py/ngff_zarr/ngff_transform_to_itk_transform.py @@ -175,8 +175,8 @@ def _homogeneous_from_by_dimension(transform: ByDimension, ndim: int) -> np.ndar """Assemble a byDimension transformation into one homogeneous matrix. Each item is a lower-dimensional transformation between two subsets of - axes, so its own matrix is written into the rows its ``output_axes`` name - and the columns its ``input_axes`` name. Axes no item produces would leave + axes, so its own matrix is written into the rows its ``outputAxes`` name + and the columns its ``inputAxes`` name. Axes no item produces would leave a zero row, which collapses the image rather than transforming it, so a gap is refused here rather than resampled. """ @@ -184,12 +184,12 @@ def _homogeneous_from_by_dimension(transform: ByDimension, ndim: int) -> np.ndar matrix[ndim, ndim] = 1.0 for item in transform.transformations: block, offset = _by_dimension_item_block(item, ndim) - for row, output_axis in enumerate(item.output_axes): - for column, input_axis in enumerate(item.input_axes): + for row, output_axis in enumerate(item.outputAxes): + for column, input_axis in enumerate(item.inputAxes): matrix[output_axis, input_axis] = block[row, column] matrix[output_axis, ndim] = offset[row] - produced = transform.produced_output_axes + produced = transform.produced_outputAxes missing = sorted(set(range(ndim)) - produced) if missing: msg = ( @@ -205,14 +205,14 @@ def _by_dimension_item_block( item: ByDimensionItem, ndim: int ) -> tuple[np.ndarray, np.ndarray]: """One byDimension item as a matrix and offset over its own axes.""" - if len(item.input_axes) != len(item.output_axes): + if len(item.inputAxes) != len(item.outputAxes): msg = ( f"byDimension item of type '{item.transformation.type}' maps " - f"{len(item.input_axes)} input axes to {len(item.output_axes)} " + f"{len(item.inputAxes)} input axes to {len(item.outputAxes)} " "output axes; only a square mapping converts to an ITK transform" ) raise ValueError(msg) - for axes in (item.input_axes, item.output_axes): + for axes in (item.inputAxes, item.outputAxes): beyond = [axis for axis in axes if axis >= ndim] if beyond: msg = ( @@ -220,11 +220,11 @@ def _by_dimension_item_block( "the coordinate system" ) raise ValueError(msg) - if len(set(item.input_axes)) != len(item.input_axes): - msg = f"byDimension input axes {item.input_axes} name an axis twice" + if len(set(item.inputAxes)) != len(item.inputAxes): + msg = f"byDimension input axes {item.inputAxes} name an axis twice" raise ValueError(msg) - sub_ndim = len(item.input_axes) + sub_ndim = len(item.inputAxes) homogeneous = _homogeneous_from_transform(item.transformation, sub_ndim) return homogeneous[:sub_ndim, :sub_ndim], homogeneous[:sub_ndim, sub_ndim] diff --git a/py/test/test_ngff_transform_to_itk_transform.py b/py/test/test_ngff_transform_to_itk_transform.py index 99246470..1998fed1 100644 --- a/py/test/test_ngff_transform_to_itk_transform.py +++ b/py/test/test_ngff_transform_to_itk_transform.py @@ -61,8 +61,8 @@ def _apply(transform, point): if isinstance(transform, ByDimension): result = np.zeros(point.shape) for item in transform.transformations: - sub = _apply(item.transformation, point[list(item.input_axes)]) - for position, axis in enumerate(item.output_axes): + sub = _apply(item.transformation, point[list(item.inputAxes)]) + for position, axis in enumerate(item.outputAxes): result[axis] = sub[position] return result if isinstance(transform, Bijection): @@ -118,13 +118,13 @@ def _assert_maps_like_the_oracle(transform, dims): transformations=[ ByDimensionItem( transformation=Translation(translation=[7.0]), - input_axes=[0], - output_axes=[0], + inputAxes=[0], + outputAxes=[0], ), ByDimensionItem( transformation=Scale(scale=[2.0, 3.0]), - input_axes=[1, 2], - output_axes=[1, 2], + inputAxes=[1, 2], + outputAxes=[1, 2], ), ] ), @@ -136,13 +136,13 @@ def _assert_maps_like_the_oracle(transform, dims): transformations=[ ByDimensionItem( transformation=Scale(scale=[4.0]), - input_axes=[0], - output_axes=[2], + inputAxes=[0], + outputAxes=[2], ), ByDimensionItem( transformation=Affine(affine=[[1.0, 0.5, 3.0], [0.0, 2.0, -1.0]]), - input_axes=[1, 2], - output_axes=[0, 1], + inputAxes=[1, 2], + outputAxes=[0, 1], ), ] ), @@ -154,13 +154,13 @@ def _assert_maps_like_the_oracle(transform, dims): transformations=[ ByDimensionItem( transformation=MapAxis(mapAxis=[1, 0]), - input_axes=[0, 1], - output_axes=[0, 1], + inputAxes=[0, 1], + outputAxes=[0, 1], ), ByDimensionItem( transformation=Translation(translation=[-5.0]), - input_axes=[2], - output_axes=[2], + inputAxes=[2], + outputAxes=[2], ), ] ), @@ -242,12 +242,12 @@ def test_by_dimension_may_leave_a_non_spatial_axis_to_itself(): transform = ByDimension( transformations=[ ByDimensionItem( - transformation=Scale(scale=[0.25]), input_axes=[0], output_axes=[0] + transformation=Scale(scale=[0.25]), inputAxes=[0], outputAxes=[0] ), ByDimensionItem( transformation=Translation(translation=[3.0, -4.0]), - input_axes=[1, 2], - output_axes=[1, 2], + inputAxes=[1, 2], + outputAxes=[1, 2], ), ] ) @@ -261,7 +261,7 @@ def test_by_dimension_that_leaves_an_output_axis_unset_is_refused(): transform = ByDimension( transformations=[ ByDimensionItem( - transformation=Scale(scale=[2.0]), input_axes=[0], output_axes=[0] + transformation=Scale(scale=[2.0]), inputAxes=[0], outputAxes=[0] ) ] ) @@ -272,14 +272,14 @@ def test_by_dimension_that_leaves_an_output_axis_unset_is_refused(): def test_by_dimension_item_of_unequal_arity_is_refused(): item = ByDimensionItem( transformation=Affine(affine=[[1.0, 0.0, 2.0, 0.0]]), - input_axes=[0, 1, 2], - output_axes=[0], + inputAxes=[0, 1, 2], + outputAxes=[0], ) transform = ByDimension( transformations=[ item, ByDimensionItem( - transformation=Identity(), input_axes=[1, 2], output_axes=[1, 2] + transformation=Identity(), inputAxes=[1, 2], outputAxes=[1, 2] ), ] ) @@ -292,8 +292,8 @@ def test_by_dimension_axis_index_beyond_the_coordinate_system_is_refused(): transformations=[ ByDimensionItem( transformation=Scale(scale=[2.0, 2.0]), - input_axes=[0, 5], - output_axes=[0, 1], + inputAxes=[0, 5], + outputAxes=[0, 1], ) ] ) @@ -307,13 +307,13 @@ def test_by_dimension_matches_the_affine_it_is_equivalent_to(): transformations=[ ByDimensionItem( transformation=Translation(translation=[7.0]), - input_axes=[0], - output_axes=[0], + inputAxes=[0], + outputAxes=[0], ), ByDimensionItem( transformation=Scale(scale=[2.0, 3.0]), - input_axes=[1, 2], - output_axes=[1, 2], + inputAxes=[1, 2], + outputAxes=[1, 2], ), ] ) diff --git a/py/test/test_resample_bounding_box.py b/py/test/test_resample_bounding_box.py index f1f26351..17d48f02 100644 --- a/py/test/test_resample_bounding_box.py +++ b/py/test/test_resample_bounding_box.py @@ -984,13 +984,13 @@ def test_by_dimension_region_matches_the_oracle(): transformations=[ ByDimensionItem( transformation=Translation(translation=[5.0]), - input_axes=[0], - output_axes=[0], + inputAxes=[0], + outputAxes=[0], ), ByDimensionItem( transformation=Affine(affine=[[0.8, -0.6, 2.0], [0.6, 0.8, -3.0]]), - input_axes=[1, 2], - output_axes=[1, 2], + inputAxes=[1, 2], + outputAxes=[1, 2], ), ] ) diff --git a/ts/src/utils/ngff_transform_to_itk_transform.ts b/ts/src/utils/ngff_transform_to_itk_transform.ts index 9d244141..b435f9f7 100644 --- a/ts/src/utils/ngff_transform_to_itk_transform.ts +++ b/ts/src/utils/ngff_transform_to_itk_transform.ts @@ -243,8 +243,8 @@ function homogeneousFromTransform( * Assemble a byDimension transformation into one homogeneous matrix. * * Each item is a lower-dimensional transformation between two subsets of - * axes, so its own matrix is written into the rows its `output_axes` name and - * the columns its `input_axes` name. Axes no item produces would leave a zero + * axes, so its own matrix is written into the rows its `outputAxes` name and + * the columns its `inputAxes` name. Axes no item produces would leave a zero * row, which collapses the image rather than transforming it, so a gap is * refused here rather than resampled. */ @@ -258,11 +258,11 @@ function homogeneousFromByDimension( for (const item of transform.transformations) { const block = byDimensionItemBlock(item, ndim); - item.output_axes.forEach((outputAxis, row) => { - item.input_axes.forEach((inputAxis, col) => { + item.outputAxes.forEach((outputAxis, row) => { + item.inputAxes.forEach((inputAxis, col) => { matrix[outputAxis][inputAxis] = block[row][col]; }); - matrix[outputAxis][ndim] = block[row][item.input_axes.length]; + matrix[outputAxis][ndim] = block[row][item.inputAxes.length]; produced.add(outputAxis); }); } @@ -282,14 +282,14 @@ function homogeneousFromByDimension( /** One byDimension item as a homogeneous matrix over its own axes. */ function byDimensionItemBlock(item: ByDimensionItem, ndim: number): Matrix { - if (item.input_axes.length !== item.output_axes.length) { + if (item.inputAxes.length !== item.outputAxes.length) { throw new Error( `byDimension item of type '${item.transformation.type}' maps ` + - `${item.input_axes.length} input axes to ${item.output_axes.length} ` + + `${item.inputAxes.length} input axes to ${item.outputAxes.length} ` + `output axes; only a square mapping converts to an ITK transform`, ); } - const beyond = [...item.input_axes, ...item.output_axes] + const beyond = [...item.inputAxes, ...item.outputAxes] .filter((axis) => axis >= ndim); if (beyond.length > 0) { throw new Error( @@ -297,13 +297,13 @@ function byDimensionItemBlock(item: ByDimensionItem, ndim: number): Matrix { `axes of the coordinate system`, ); } - if (new Set(item.input_axes).size !== item.input_axes.length) { + if (new Set(item.inputAxes).size !== item.inputAxes.length) { throw new Error( - `byDimension input axes [${item.input_axes.join(", ")}] name an axis ` + + `byDimension input axes [${item.inputAxes.join(", ")}] name an axis ` + `twice`, ); } - return homogeneousFromTransform(item.transformation, item.input_axes.length); + return homogeneousFromTransform(item.transformation, item.inputAxes.length); } /** An ITK matrix and offset for the spatial axes, fastest-axis-first. */ diff --git a/ts/test/ngff_transform_to_itk_transform_test.ts b/ts/test/ngff_transform_to_itk_transform_test.ts index b8113fda..a4f18e8d 100644 --- a/ts/test/ngff_transform_to_itk_transform_test.ts +++ b/ts/test/ngff_transform_to_itk_transform_test.ts @@ -58,9 +58,9 @@ function apply(transform: V06Transform, point: number[]): number[] { for (const item of transform.transformations) { const sub = apply( item.transformation, - item.input_axes.map((axis) => point[axis]), + item.inputAxes.map((axis) => point[axis]), ); - item.output_axes.forEach((axis, position) => { + item.outputAxes.forEach((axis, position) => { result[axis] = sub[position]; }); } @@ -111,9 +111,9 @@ function assertMapsLikeTheOracle(transform: V06Transform, dims: string[]) { const item = ( transformation: V06Transform, - input_axes: number[], - output_axes: number[], -): ByDimensionItem => ({ transformation, input_axes, output_axes }); + inputAxes: number[], + outputAxes: number[], +): ByDimensionItem => ({ transformation, inputAxes, outputAxes }); Deno.test("a mapAxis reversal maps like the oracle", () => { assertMapsLikeTheOracle(createMapAxis([2, 1, 0]), ["z", "y", "x"]); diff --git a/ts/test/resample_bounding_box_test.ts b/ts/test/resample_bounding_box_test.ts index a38fcbb5..f77bae0f 100644 --- a/ts/test/resample_bounding_box_test.ts +++ b/ts/test/resample_bounding_box_test.ts @@ -1180,13 +1180,13 @@ Deno.test("a byDimension region matches the oracle", async () => { createByDimension([ { transformation: createTranslation([5]), - input_axes: [0], - output_axes: [0], + inputAxes: [0], + outputAxes: [0], }, { transformation: createAffine([[0.8, -0.6, 2], [0.6, 0.8, -3]]), - input_axes: [1, 2], - output_axes: [1, 2], + inputAxes: [1, 2], + outputAxes: [1, 2], }, ]), fixed, From 967e4387c1dc4b1c21c5db30c116b2a8c54372f9 Mon Sep 17 00:00:00 2001 From: Valentin Boussot Date: Wed, 26 Aug 2026 13:14:39 +0200 Subject: [PATCH 11/11] fix(py,ts): refuse the conversions that returned a wrong matrix silently byDimension checked only the union of produced output axes, so two items naming the same axis left the second overwriting the first row and the union none the wiser: the conversion returned a mapping neither item describes. Both error messages already stated the rule. A mapAxis index outside the coordinate system assigned to a slot nothing reads back in TypeScript, leaving that row zero and the matrix singular with no error, where the Python port raises. The two agree now. resample documents that an RFC-5 field transform is read in full: the field bounds where every block reads, so the regions cannot be computed without it. Streaming it is #692. A test asserted the opposite of what its name describes, and a docstring named only displacements where coordinates is handled too. --- .../ngff_transform_to_itk_transform.py | 14 ++++++++- py/ngff_zarr/resample.py | 7 +++++ .../test_itk_transform_to_ngff_transform.py | 5 ++- ts/src/io/resample_bounding_box-node.ts | 4 +-- .../utils/ngff_transform_to_itk_transform.ts | 31 ++++++++++++++++--- 5 files changed, 53 insertions(+), 8 deletions(-) diff --git a/py/ngff_zarr/ngff_transform_to_itk_transform.py b/py/ngff_zarr/ngff_transform_to_itk_transform.py index 66904e06..a98be9ad 100644 --- a/py/ngff_zarr/ngff_transform_to_itk_transform.py +++ b/py/ngff_zarr/ngff_transform_to_itk_transform.py @@ -182,9 +182,21 @@ def _homogeneous_from_by_dimension(transform: ByDimension, ndim: int) -> np.ndar """ matrix = np.zeros((ndim + 1, ndim + 1)) matrix[ndim, ndim] = 1.0 - for item in transform.transformations: + claimed: dict[int, int] = {} + for index, item in enumerate(transform.transformations): block, offset = _by_dimension_item_block(item, ndim) for row, output_axis in enumerate(item.outputAxes): + # Two items writing the same row would leave the second overwriting + # the first and the union below none the wiser, so the mapping would + # come back silently wrong rather than refused. + if output_axis in claimed: + msg = ( + f"byDimension items {claimed[output_axis]} and {index} both " + f"produce output axis {output_axis}; every output axis must " + "be produced by exactly one item" + ) + raise ValueError(msg) + claimed[output_axis] = index for column, input_axis in enumerate(item.inputAxes): matrix[output_axis, input_axis] = block[row, column] matrix[output_axis, ndim] = offset[row] diff --git a/py/ngff_zarr/resample.py b/py/ngff_zarr/resample.py index 451f63b8..827b37f8 100644 --- a/py/ngff_zarr/resample.py +++ b/py/ngff_zarr/resample.py @@ -233,6 +233,13 @@ def resample( full moving image is never loaded, which is what makes this usable when it is larger than memory, remote, or chunked. + One input is not streamed: an RFC-5 ``displacements`` or ``coordinates`` + transform is converted before the graph is built, and that conversion reads + its field in full. The field bounds where every block reads, so the regions + cannot be computed without it. A field the size of the volume therefore has + to fit in memory, while the moving image does not. Pass an ITK transform to + keep the field out of this call. + Resampling runs through ``itkwasm-downsample``, so no native ITK build is required and the result is identical to one across platforms. diff --git a/py/test/test_itk_transform_to_ngff_transform.py b/py/test/test_itk_transform_to_ngff_transform.py index 259b7822..890e52e0 100644 --- a/py/test/test_itk_transform_to_ngff_transform.py +++ b/py/test/test_itk_transform_to_ngff_transform.py @@ -1254,5 +1254,8 @@ def test_registration_result_can_be_attached_to_multiscales(): assert len(transform.affine) == 2 assert all(len(row) == 3 for row in transform.affine) assert transform.type == "affine" - # It is shaped to go straight onto the multiscales metadata. + # It is shaped to go straight onto the multiscales metadata, which is what + # attaching a registration result means. assert multiscales.metadata.coordinateTransformations is None + multiscales.metadata.coordinateTransformations = [transform] + assert multiscales.metadata.coordinateTransformations == [transform] diff --git a/ts/src/io/resample_bounding_box-node.ts b/ts/src/io/resample_bounding_box-node.ts index 7346a1fc..a4ac6983 100644 --- a/ts/src/io/resample_bounding_box-node.ts +++ b/ts/src/io/resample_bounding_box-node.ts @@ -33,8 +33,8 @@ import { * * In both cases the transform maps *fixed* points into *moving* space. An ITK * transform need not be linear; an RFC-5 transformation is converted first, so - * it must be a linear mapping or a `displacements` transformation whose field - * is passed in `options.fields`. + * it must be a linear mapping, or a `displacements` or `coordinates` + * transformation whose field is passed in `options.fields`. * * @param transform An RFC-5 coordinate transformation or an ITK-Wasm * `TransformList`. diff --git a/ts/src/utils/ngff_transform_to_itk_transform.ts b/ts/src/utils/ngff_transform_to_itk_transform.ts index b435f9f7..b40d0da0 100644 --- a/ts/src/utils/ngff_transform_to_itk_transform.ts +++ b/ts/src/utils/ngff_transform_to_itk_transform.ts @@ -208,8 +208,18 @@ function homogeneousFromTransform( } const matrix = zeroMatrix(ndim + 1); matrix[ndim][ndim] = 1; - // The value at position i names the input axis that becomes output - // axis i, so the row for output i selects that input. + // Output axis i takes input axis mapAxis[i], so row i selects that + // column. An index outside the system would leave its row zero and the + // matrix singular; the Python port raises, so this one does too. + const invalid = transform.mapAxis.filter( + (axis) => !Number.isInteger(axis) || axis < 0 || axis >= ndim, + ); + if (invalid.length > 0) { + throw new Error( + `mapAxis indices [${invalid.join(", ")}] are outside the ` + + `${ndim} axes of the coordinate system`, + ); + } transform.mapAxis.forEach((inputAxis, outputAxis) => { matrix[outputAxis][inputAxis] = 1; }); @@ -256,16 +266,29 @@ function homogeneousFromByDimension( matrix[ndim][ndim] = 1; const produced = new Set(); - for (const item of transform.transformations) { + const claimed = new Map(); + transform.transformations.forEach((item, index) => { const block = byDimensionItemBlock(item, ndim); item.outputAxes.forEach((outputAxis, row) => { + // Two items writing the same row would leave the second overwriting the + // first and the union below none the wiser, so the mapping would come + // back silently wrong rather than refused. + const first = claimed.get(outputAxis); + if (first !== undefined) { + throw new Error( + `byDimension items ${first} and ${index} both produce output axis ` + + `${outputAxis}; every output axis must be produced by exactly ` + + `one item`, + ); + } + claimed.set(outputAxis, index); item.inputAxes.forEach((inputAxis, col) => { matrix[outputAxis][inputAxis] = block[row][col]; }); matrix[outputAxis][ndim] = block[row][item.inputAxes.length]; produced.add(outputAxis); }); - } + }); const missing = Array.from({ length: ndim }, (_, axis) => axis) .filter((axis) => !produced.has(axis));