diff --git a/docs/itk.md b/docs/itk.md index ecaabfa4..5216306a 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 @@ -53,45 +53,61 @@ 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: +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.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 @@ -99,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 @@ -107,13 +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 the same two kinds of transform as +`resample_bounding_box`, the RFC-5 `Affine` above included: + ```python ->>> resampled = nz.itk_transform_resample( # doctest: +SKIP -... transform, fixed, moving) ->>> nz.to_ngff_zarr("resampled.zarr", # doctest: +SKIP +>>> 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. @@ -126,7 +151,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=[])) ``` @@ -195,18 +220,276 @@ 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.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.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. 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 + +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 | +| `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 +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: + +- **Axis order.** RFC-5 orders parameters like the Zarr array, so a `zyx` image + 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 + 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. 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 +>>> 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. 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. + +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 `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. +`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` 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 + +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, ['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') +>>> 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. 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`: + +```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}) +>>> region = nz.resample_bounding_box( # doctest: +SKIP +... transform, fixed, moving, 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 +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`, and for fields +`itkDisplacementFieldToNgffTransform` and `ngffDisplacementFieldToItkTransform`, +both async since the field is read from and written to a Zarr array. +`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 +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 -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: +`selection()` yields a zarrita selection instead of Python slices. It accepts an +RFC-5 transformation just as the Python function does: ```typescript -import { itkTransformResampleBoundingBox, zarrGet } from "@fideus-labs/ngff-zarr"; - -const region = await itkTransformResampleBoundingBox(transform, fixed, moving, { - padding: 1, -}); +import { + createAffine, + resampleBoundingBox, + zarrGet, +} from "@fideus-labs/ngff-zarr"; + +const region = await resampleBoundingBox( + 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..1b25410a 100644 --- a/docs/rfc5.md +++ b/docs/rfc5.md @@ -222,6 +222,39 @@ 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 + +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 +`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 +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` 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. 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 The TypeScript package (`@fideus-labs/ngff-zarr`) mirrors the Python API. Field 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 e70adbe4..84f07817 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, @@ -24,10 +28,9 @@ 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, ) from .lif_to_ngff_image import ( has_mosaic_dimension, @@ -40,10 +43,16 @@ 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_transform from .nibabel_image_to_ngff_image import ( 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, @@ -124,9 +133,15 @@ "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_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", + "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 new file mode 100644 index 00000000..70c23ac7 --- /dev/null +++ b/py/ngff_zarr/displacement_field_transform.py @@ -0,0 +1,595 @@ +# 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``, 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 + 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 .itk_transform_to_ngff_transform import _FrameGeometry +from .ngff_image import NgffImage +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 +#: ``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) + 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; 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): + 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 _unoriented_frames(dimension: int) -> _FrameGeometry: + """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. + """ + 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. + + 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``. + + :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): + """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 _inverse_direction, _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) + 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 + ) + + 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]].astype( + vectors.dtype, copy=False + ) + + 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 | Coordinates, + field, + dims: Sequence[str], + *, + fixed: NgffImage | None = None, + moving: NgffImage | None = None, +) -> list: + """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 field transform with its field. The field is the image stored at + ``transform.path``; load it with + ``from_ome_zarr(f"{store}/{transform.path}")``. + + 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`` (``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] + :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_to_ngff_transform import _itk_axis_order + 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 there. + axes = field.metadata.intrinsic_coordinate_system.axes + 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 == component_type + ] + if len(component_dims) != 1: + msg = ( + 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 {transform.type} " + f"transform 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. 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]) + own_direction = _itk_direction(field, itk_dims) + + frames = _frames(fixed, moving, dims) + if frames is None: + 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, so its grid " + "cannot be placed in ITK physical space on its own; pass the " + "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 them the ITK transform it returns." + ) + raise ValueError(msg) + 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_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, grid_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( + 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, + ) + + if vectors.dtype == np.float32: + value_type = FloatTypes.Float32 + else: + value_type = FloatTypes.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")] + ).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 | 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 {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}}." + ) + 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 new file mode 100644 index 00000000..ff689898 --- /dev/null +++ b/py/ngff_zarr/itk_transform_to_ngff_transform.py @@ -0,0 +1,729 @@ +# 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 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: + + ITK: y = A (x - c) + t + c + RFC-5: y = A x + b with b = t + c - A c +""" + +from collections.abc import Sequence +from typing import NamedTuple + +import numpy as np + +from .v06.zarr_metadata import ( + Affine, + Identity, + Scale, + Transform, + TransformSequence, + Translation, +) + +_SPATIAL_DIMS = ("x", "y", "z") + +#: 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 _working_scale(offset: np.ndarray) -> float: + """The magnitude the transform's own numbers work at. + + 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 _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) + + +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 = _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, *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. + # 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 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 + + +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 + ) + + # 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.copy() + if parameterization == "Scale": + # 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 :] + # 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; " + 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: + 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) + # 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_from_itk(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, "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_from_itk(transform, dimension) + + 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) + + # 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: + 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 (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 " + "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) + total = total @ _homogeneous(*_matrix_offset_from_itkwasm(entry, dimension)) + 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 _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 .resample_bounding_box import _itk_direction + + return _FrameGeometry( + _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 = _inverse_direction(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. + + :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)) + + 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. + + 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. 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, fixed=fixed, moving=moving + ) + + 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``.""" + diagonal = np.diag(matrix) + is_identity = np.array_equal(matrix, np.eye(ndim)) + 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_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=diagonal.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..a98be9ad --- /dev/null +++ b/py/ngff_zarr/ngff_transform_to_itk_transform.py @@ -0,0 +1,445 @@ +# 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 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 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 Mapping, Sequence + +import numpy as np + +from .v06.zarr_metadata import ( + Affine, + Bijection, + ByDimension, + ByDimensionItem, + Coordinates, + Displacements, + Identity, + MapAxis, + 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): + # 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 {shape} but the coordinate " + f"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): + shape = "x".join(str(extent) for extent in affine.shape) + msg = ( + 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) + 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 + + 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. + 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. 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 ``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. + """ + matrix = np.zeros((ndim + 1, ndim + 1)) + matrix[ndim, ndim] = 1.0 + 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] + + produced = transform.produced_outputAxes + 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.inputAxes) != len(item.outputAxes): + msg = ( + f"byDimension item of type '{item.transformation.type}' maps " + 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.inputAxes, item.outputAxes): + 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.inputAxes)) != len(item.inputAxes): + msg = f"byDimension input axes {item.inputAxes} name an axis twice" + raise ValueError(msg) + + sub_ndim = len(item.inputAxes) + 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: + 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. + + 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``, ``mapAxis``, + ``byDimension``, ``bijection``, 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. 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. + :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 (`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], + *, + fields: Mapping[str, object] | None = None, + fixed=None, + moving=None, +) -> list: + """Convert an RFC-5 transformation to an ITK-Wasm transform list. + + A linear transformation is collapsed into a single ``Affine`` entry, so + the result is independent of ITK's own list-composition order. A + ``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 -- ``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`` 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 + 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] + """ + if isinstance(transform, (Displacements, Coordinates)): + from .displacement_field_transform import ( + _fields_entry, + 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), + [dim for dim in dims if dim in _SPATIAL_DIMS], + fixed=fixed, + moving=moving, + ) + + 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] + + from .itk_transform_to_ngff_transform import ( + _change_of_frame, + _check_frame_images, + _frame_geometry, + _inverse_direction, + ) + + 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, + _inverse_direction(direction_fixed), + _inverse_direction(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. + 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/ngff_zarr/itk_transform_resample.py b/py/ngff_zarr/resample.py similarity index 82% rename from py/ngff_zarr/itk_transform_resample.py rename to py/ngff_zarr/resample.py index 1317dbff..827b37f8 100644 --- a/py/ngff_zarr/itk_transform_resample.py +++ b/py/ngff_zarr/resample.py @@ -3,19 +3,23 @@ """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 .itk_transform_resample_bounding_box import ( +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, _spatial_dims, - itk_transform_resample_bounding_box, + resample_bounding_box, ) -from .ngff_image import NgffImage _INTERPOLATORS = ( "linear", @@ -207,32 +211,50 @@ 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, 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.itk_transform_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. + 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. - :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 +278,12 @@ def itk_transform_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 +332,19 @@ def itk_transform_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 +364,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, @@ -353,9 +393,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 @@ -417,7 +455,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/itk_transform_resample_bounding_box.py b/py/ngff_zarr/resample_bounding_box.py similarity index 80% rename from py/ngff_zarr/itk_transform_resample_bounding_box.py rename to py/ngff_zarr/resample_bounding_box.py index 4c347bef..265e4796 100644 --- a/py/ngff_zarr/itk_transform_resample_bounding_box.py +++ b/py/ngff_zarr/resample_bounding_box.py @@ -3,13 +3,15 @@ """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 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") @@ -218,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 @@ -245,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: @@ -252,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): @@ -292,6 +305,32 @@ 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", + "mapAxis", + "byDimension", + "bijection", + "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. @@ -352,11 +391,13 @@ def _transform_from_dict(entry: dict): return ItkTransform(**entry) -def itk_transform_resample_bounding_box( +def resample_bounding_box( transform, 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. @@ -366,15 +407,31 @@ 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: - :param transform: An ``itk.Transform`` (including the ``CompositeTransform`` - an Elastix registration returns), or an ITK-Wasm ``Transform`` / - ``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. 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 + 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 -- + ``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. + + :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 @@ -387,6 +444,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`` 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. 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 @@ -395,8 +462,9 @@ 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 + 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}" @@ -420,8 +488,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 = { @@ -448,11 +518,20 @@ 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) - - result = resample_bounding_box( + if _is_ngff_transform(transform): + 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)) + 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 = 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 new file mode 100644 index 00000000..128e31d8 --- /dev/null +++ b/py/test/test_displacement_field_transform.py @@ -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. + +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 ( + Coordinates, + 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( + "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] + 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"} + 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)] + + 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.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.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_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.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="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( + 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 + + +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_itk_transform_to_ngff_transform.py b/py/test/test_itk_transform_to_ngff_transform.py new file mode 100644 index 00000000..890e52e0 --- /dev/null +++ b/py/test/test_itk_transform_to_ngff_transform.py @@ -0,0 +1,1261 @@ +# 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 +import zarr +from ngff_zarr import ( + itk_transform_to_ngff_matrix, + itk_transform_to_ngff_transform, + 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")), + ("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"), + ), +] + + +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], + 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.""" + converted = itk_transform_to_ngff_transform( + ngff_transform_to_itk_transform(transform, dims), dims + ) + + for point in _sample_points(len(dims)): + assert np.allclose(_apply(converted, point), _apply(transform, point)) + + +@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_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( + 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 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() + 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_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 ( + 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 _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.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, + # 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="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.""" + itk = pytest.importorskip("itk") + from ngff_zarr.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="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(): + """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; 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]] + + +def _affine_3d(matrix, offset): + itk = pytest.importorskip("itk") + + transform = itk.AffineTransform[itk.D, 3].New() + transform.SetMatrix(itk.matrix_from_array(np.asarray(matrix, dtype=float))) + transform.SetTranslation([float(offset)] * 3) + transform.SetCenter([0.0] * 3) + return transform + + +@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] + 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]) +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_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 + 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() + 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.array_equal(matrix, np.eye(3)) + assert np.array_equal(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(): + """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_a_genuinely_linear_transform_is_accepted(magnitude): + """The affine check must not fire on ordinary float error. + + 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) + 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")) + _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 ( + 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.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. + + ``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]]) + + +@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_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() + 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 ( + 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 _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 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 = resample_bounding_box(transform, fixed, moving, padding=0) + converted = itk_transform_to_ngff_transform( + transform, ("z", "y", "x"), fixed=fixed, moving=moving + ) + 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 + + +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") + 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, 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/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..1998fed1 --- /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.inputAxes)]) + for position, axis in enumerate(item.outputAxes): + 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]), + inputAxes=[0], + outputAxes=[0], + ), + ByDimensionItem( + transformation=Scale(scale=[2.0, 3.0]), + inputAxes=[1, 2], + outputAxes=[1, 2], + ), + ] + ), + ("z", "y", "x"), + ), + ( + "by_dimension_that_permutes", + ByDimension( + transformations=[ + ByDimensionItem( + transformation=Scale(scale=[4.0]), + inputAxes=[0], + outputAxes=[2], + ), + ByDimensionItem( + transformation=Affine(affine=[[1.0, 0.5, 3.0], [0.0, 2.0, -1.0]]), + inputAxes=[1, 2], + outputAxes=[0, 1], + ), + ] + ), + ("z", "y", "x"), + ), + ( + "by_dimension_holding_a_map_axis", + ByDimension( + transformations=[ + ByDimensionItem( + transformation=MapAxis(mapAxis=[1, 0]), + inputAxes=[0, 1], + outputAxes=[0, 1], + ), + ByDimensionItem( + transformation=Translation(translation=[-5.0]), + inputAxes=[2], + outputAxes=[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]), inputAxes=[0], outputAxes=[0] + ), + ByDimensionItem( + transformation=Translation(translation=[3.0, -4.0]), + inputAxes=[1, 2], + outputAxes=[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]), inputAxes=[0], outputAxes=[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]]), + inputAxes=[0, 1, 2], + outputAxes=[0], + ) + transform = ByDimension( + transformations=[ + item, + ByDimensionItem( + transformation=Identity(), inputAxes=[1, 2], outputAxes=[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]), + inputAxes=[0, 5], + outputAxes=[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]), + inputAxes=[0], + outputAxes=[0], + ), + ByDimensionItem( + transformation=Scale(scale=[2.0, 3.0]), + inputAxes=[1, 2], + outputAxes=[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_itk_transform_resample.py b/py/test/test_resample.py similarity index 76% rename from py/test/test_itk_transform_resample.py rename to py/test/test_resample.py index 51f489f1..4dbf0fdd 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,97 @@ 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) + + +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_itk_transform_resample_bounding_box.py b/py/test/test_resample_bounding_box.py similarity index 51% rename from py/test/test_itk_transform_resample_bounding_box.py rename to py/test/test_resample_bounding_box.py index 1f168176..17d48f02 100644 --- a/py/test/test_itk_transform_resample_bounding_box.py +++ b/py/test/test_resample_bounding_box.py @@ -18,12 +18,29 @@ from ngff_zarr import ( RAS, NgffImage, - itk_transform_resample_bounding_box, + itk_displacement_field_to_ngff_transform, 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.v06.zarr_metadata import ( + Affine, + Bijection, + ByDimension, + ByDimensionItem, + Coordinates, + Displacements, + Identity, + MapAxis, + Rotation, + Scale, + TransformSequence, + Translation, ) @@ -121,6 +138,296 @@ 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 = 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 = 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 + 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 = 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_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 = 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])] + ) + 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_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 = resample_bounding_box( + transform, + _image(spatial, shape, scale, translation), + _image(spatial, {"z": 32, "y": 64, "x": 96}, scale, translation), + padding=0, + ) + oriented = 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 = 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 = 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(): + """``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 = 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", @@ -130,7 +437,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(): @@ -154,9 +461,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} @@ -170,7 +475,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 ) @@ -186,7 +491,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 ) @@ -199,9 +504,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)} @@ -213,7 +516,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) @@ -230,6 +533,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", @@ -245,7 +569,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 @@ -297,9 +621,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} @@ -322,9 +644,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]) @@ -347,20 +667,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")]) @@ -368,27 +688,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 @@ -430,9 +750,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. @@ -442,6 +760,31 @@ 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 = resample_bounding_box(warp, fixed, moving) + transform, field = itk_displacement_field_to_ngff_transform( + warp, ("y", "x"), path="warp" + ) + 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"): + resample_bounding_box(transform, fixed, moving) + + def test_float_displacement_field_matches_double(): """A float32-parameterized transform yields the double-precision region. @@ -454,13 +797,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} @@ -489,7 +830,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 ) @@ -511,9 +852,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 @@ -525,7 +864,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(): @@ -549,7 +888,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, @@ -595,4 +934,128 @@ 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) + + +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]), + inputAxes=[0], + outputAxes=[0], + ), + ByDimensionItem( + transformation=Affine(affine=[[0.8, -0.6, 2.0], [0.6, 0.8, -3.0]]), + inputAxes=[1, 2], + outputAxes=[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/browser-mod.ts b/ts/src/browser-mod.ts index f5fb49c0..e63c780f 100644 --- a/ts/src/browser-mod.ts +++ b/ts/src/browser-mod.ts @@ -23,11 +23,24 @@ 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, + 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/io/itk_transform_resample_bounding_box-node.ts b/ts/src/io/itk_transform_resample_bounding_box-node.ts deleted file mode 100644 index 6065fac0..00000000 --- a/ts/src/io/itk_transform_resample_bounding_box-node.ts +++ /dev/null @@ -1,47 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) Fideus Labs LLC -// SPDX-License-Identifier: MIT - -/** Node/Deno implementation: native WASM, no web worker. */ - -import { resampleBoundingBoxNode } from "@itk-wasm/downsample"; -import type { TransformList } from "itk-wasm"; -import type { NgffImage } from "../types/ngff_image.ts"; -import { - type ItkTransformResampleBoundingBoxOptions, - type ResampleBoundingBox, - resampleBoundingBoxShared, -} from "./itk_transform_resample_bounding_box-shared.ts"; - -/** - * Compute the moving-image region needed to resample a fixed image grid. - * - * The region is derived from image geometry alone -- the pixel buffers of - * `fixed` and `moving` are never read. That is the point: describe two images - * 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. - * - * @param transform 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, - fixed: NgffImage, - moving: NgffImage, - options: ItkTransformResampleBoundingBoxOptions = {}, -): Promise { - return resampleBoundingBoxShared( - resampleBoundingBoxNode, - transform, - fixed, - moving, - options, - ); -} diff --git a/ts/src/io/itk_transform_resample_bounding_box-browser.ts b/ts/src/io/resample_bounding_box-browser.ts similarity index 59% rename from ts/src/io/itk_transform_resample_bounding_box-browser.ts rename to ts/src/io/resample_bounding_box-browser.ts index e4744db7..fec2ae9e 100644 --- a/ts/src/io/itk_transform_resample_bounding_box-browser.ts +++ b/ts/src/io/resample_bounding_box-browser.ts @@ -3,29 +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( - transform: TransformList, +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/resample_bounding_box-node.ts b/ts/src/io/resample_bounding_box-node.ts new file mode 100644 index 00000000..a4ac6983 --- /dev/null +++ b/ts/src/io/resample_bounding_box-node.ts @@ -0,0 +1,59 @@ +// SPDX-FileCopyrightText: Copyright (c) Fideus Labs LLC +// SPDX-License-Identifier: MIT + +/** Node/Deno implementation: native WASM, no web worker. */ + +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 ResampleBoundingBox, + type ResampleBoundingBoxOptions, + resampleBoundingBoxShared, +} from "./resample_bounding_box-shared.ts"; + +/** + * Compute the moving-image region needed to resample a fixed image grid. + * + * The region is derived from image geometry alone -- the pixel buffers of + * `fixed` and `moving` are never read. That is the point: describe two images + * and a transform with a few numbers, learn exactly which block of the moving + * image a resample will touch, and only then move pixels. + * + * 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-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. An ITK + * transform need not be linear; an RFC-5 transformation is converted first, so + * 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`. + * @param fixed The image whose grid is resampled. Geometry only. + * @param moving The image to be sampled. Geometry only. + * @param options `padding`, and `fields` for a `displacements` transformation. + * @returns The region, keyed by dimension name in Zarr order. + */ +export function resampleBoundingBox( + transform: V06Transform | TransformList, + fixed: NgffImage, + moving: NgffImage, + options: ResampleBoundingBoxOptions = {}, +): Promise { + return resampleBoundingBoxShared( + resampleBoundingBoxNode, + transform, + fixed, + moving, + options, + ); +} diff --git a/ts/src/io/itk_transform_resample_bounding_box-shared.ts b/ts/src/io/resample_bounding_box-shared.ts similarity index 78% rename from ts/src/io/itk_transform_resample_bounding_box-shared.ts rename to ts/src/io/resample_bounding_box-shared.ts index eaf73a71..74e746f0 100644 --- a/ts/src/io/itk_transform_resample_bounding_box-shared.ts +++ b/ts/src/io/resample_bounding_box-shared.ts @@ -11,7 +11,12 @@ import * as zarr from "zarrita"; import type { Image, TransformList } from "itk-wasm"; import { NgffImage } from "../types/ngff_image.ts"; -import { anatomicalOrientationToItkDirection } from "../types/rfc4.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"]; @@ -23,14 +28,23 @@ 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 * bound. Use 0 for the tight region, or more for wider kernels. */ padding?: number; + /** + * 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` + * and `moving`, and pass the transform list that returns. + */ + fields?: Record; } /** @@ -256,51 +270,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. * @@ -331,6 +300,29 @@ export function metadataOnlyItkImage( return itkImage; } +/** The field `fields` holds for a field transform, with a message. */ +function fieldFor( + 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 ${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 } }.`, + ); + } + 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"; +} + /** * Compute the moving-image region needed to resample a fixed image grid. * @@ -343,10 +335,10 @@ export async function resampleBoundingBoxShared( moving: Image, options: { padding?: number }, ) => Promise<{ boundingBox: unknown }>, - transform: TransformList, + transform: V06Transform | TransformList, fixed: NgffImage, moving: NgffImage, - options: ItkTransformResampleBoundingBoxOptions = {}, + options: ResampleBoundingBoxOptions = {}, ): Promise { const padding = options.padding ?? 1; if (!Number.isInteger(padding) || padding < 0) { @@ -371,8 +363,9 @@ export async function resampleBoundingBoxShared( checkGeometry("fixed", fixed, fixedSpatial); 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) { @@ -398,11 +391,37 @@ 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 = + 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); + 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/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 c9310d26..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 @@ -69,6 +69,19 @@ export { createNgffImage, createNgffMultiscales, } from "./utils/factory.ts"; +export { + itkTransformToNgffMatrix, + itkTransformToNgffTransform, + 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..b73d43b4 --- /dev/null +++ b/ts/src/utils/displacement_field_transform.ts @@ -0,0 +1,678 @@ +// 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 { Coordinates, Displacements } from "../types/zarr_metadata.ts"; +import { toNgffImage } from "../io/to_ngff_image.ts"; +import { + changeOfFrame, + directionRows, + type FrameGeometry, + itkDirection, + optionalFrameGeometry, + transposed, +} 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[][]; +} + +/** 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 matvec(matrix: number[][], vector: number[]): number[] { + return matrix.map((row) => + row.reduce((sum, value, col) => sum + value * vector[col], 0) + ); +} + +/** + * 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 { + directionOut: frame.directionMoving, + shiftMatrix, + shiftVector: offset, + shifts: shiftMatrix.some((row) => row.some((value) => value !== 0)) || + offset.some((value) => value !== 0), + }; +} + +/** 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); + let frame = optionalFrameGeometry( + options.fixed, + options.moving, + itkDims, + ); + 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.", + ); + } + 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); + 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` 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}`)`. + * + * 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` (`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 + * {@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 | 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 there. + componentDims = field.metadata.axes + .filter((axis) => axis.type === componentType) + .map((axis) => axis.name); + image = field.images[0]; + } else { + image = field; + componentDims = Object.entries(image.axesTypes ?? {}) + .filter(([, type]) => type === componentType) + .map(([dim]) => dim); + } + if (componentDims.length !== 1) { + throw new Error( + `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(", ")}]`, + ); + } + 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 ${transform.type} ` + + `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); + + let frame = optionalFrameGeometry(frames_.fixed, frames_.moving, itkDims); + 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", + ); + } + 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. 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 && !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] - (absolute ? point[i] : 0) + ), + ); + }; + + if ( + transform.interpolation !== undefined && + transform.interpolation !== "linear" + ) { + console.warn( + `the ${transform.type} 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_direction.ts b/ts/src/utils/itk_direction.ts new file mode 100644 index 00000000..d9a2fd78 --- /dev/null +++ b/ts/src/utils/itk_direction.ts @@ -0,0 +1,197 @@ +// 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]), + }; +} + +/** + * 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. + * + * `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 new file mode 100644 index 00000000..52ec5495 --- /dev/null +++ b/ts/src/utils/itk_transform_to_ngff_transform.ts @@ -0,0 +1,427 @@ +// 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 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: + * + * ``` + * 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 { NgffImage } from "../types/ngff_image.ts"; +import { changeOfFrame, optionalFrameGeometry } from "./itk_direction.ts"; +import { + type Affine, + createAffine, + createIdentity, + createScale, + createTransformSequence, + createTranslation, + type V06Transform, +} from "../types/zarr_metadata.ts"; + +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. */ + 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); +} + +/** + * 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, +): { matrix: number[][]; offset: number[] } { + const parameterization = String( + entry.transformType.transformParameterization, + ); + 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), + offset: new Array(dimension).fill(0), + }; + } + if (parameterization === "Translation") { + return { + matrix: identityMatrix(dimension), + offset: parameters.slice(0, dimension), + }; + } + 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: foldCenter(matrix, 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, + ); + 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( + `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.`, + ); +} + +/** 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. + * + * @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( + transform: Transform | TransformList, + dims: string[], + frames: FrameImages = {}, +): 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 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 + // itk.dict_from_transform never writes a header at all: there it is a + // *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, ` + + `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++) { + for (let col = 0; col < dimension; col++) { + homogeneous[row][col] = matrix[row][col]; + } + homogeneous[row][dimension] = offset[row]; + } + total = multiply(total, homogeneous); + } + + let matrix = Array.from( + { length: dimension }, + (_, row) => total[row].slice(0, dimension), + ); + let offset = Array.from( + { length: dimension }, + (_, row) => total[row][dimension], + ); + + 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. + ({ 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 order = spatial.map((dim) => itkOrder.indexOf(dim)); + return { + matrix: order.map((row) => order.map((col) => matrix[row][col])), + offset: order.map((row) => offset[row]), + }; +} + +/** + * 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. 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`. + * @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( + transform: Transform | TransformList, + dims: string[], + simplify = true, + frames: FrameImages = {}, +): V06Transform { + const { matrix, offset } = itkTransformToNgffMatrix(transform, dims, frames); + + 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]); + // 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 (isScale && noOffset) return createScale(diagonal); + if (isScale) { + // 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..b40d0da0 --- /dev/null +++ b/ts/src/utils/ngff_transform_to_itk_transform.ts @@ -0,0 +1,479 @@ +// 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 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 + * 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 { NgffImage } from "../types/ngff_image.ts"; +import { + changeOfFrame, + optionalFrameGeometry, + transposed, +} from "./itk_direction.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 }, + (_, 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; + }), + ); +} + +/** + * 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, + 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 ${describeShape(rotation)} 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 ${describeShape(affine)} 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; + } + + 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; + // 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; + }); + 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. 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 `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. + */ +function homogeneousFromByDimension( + transform: ByDimension, + ndim: number, +): Matrix { + const matrix = zeroMatrix(ndim + 1); + matrix[ndim][ndim] = 1; + const produced = new Set(); + + 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)); + 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.inputAxes.length !== item.outputAxes.length) { + throw new Error( + `byDimension item of type '${item.transformation.type}' maps ` + + `${item.inputAxes.length} input axes to ${item.outputAxes.length} ` + + `output axes; only a square mapping converts to an ITK transform`, + ); + } + const beyond = [...item.inputAxes, ...item.outputAxes] + .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.inputAxes).size !== item.inputAxes.length) { + throw new Error( + `byDimension input axes [${item.inputAxes.join(", ")}] name an axis ` + + `twice`, + ); + } + return homogeneousFromTransform(item.transformation, item.inputAxes.length); +} + +/** 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. + * + * 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`, `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 + * 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, + 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 (`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 = itkIndices.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[], + frames: { fixed?: NgffImage; moving?: NgffImage } = {}, +): TransformList { + if (transform.type === "displacements" || transform.type === "coordinates") { + throw new Error( + `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); + const dimension = offset.length; + + 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. + ({ 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. + 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/displacement_field_transform_test.ts b/ts/test/displacement_field_transform_test.ts new file mode 100644 index 00000000..e1ca5c67 --- /dev/null +++ b/ts/test/displacement_field_transform_test.ts @@ -0,0 +1,675 @@ +// 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 }); +}); + +// 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 entry = fieldTransform(size, spacing, origin); + + const { transform, field } = await itkDisplacementFieldToNgffTransform( + entry, + dims, + { path: "warp" }, + ); + 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)]); + } + + 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); +}); + +/** + * 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/itk_transform_resample_bounding_box_test.ts b/ts/test/itk_transform_resample_bounding_box_test.ts deleted file mode 100644 index e3ef2229..00000000 --- a/ts/test/itk_transform_resample_bounding_box_test.ts +++ /dev/null @@ -1,583 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) Fideus Labs LLC -// SPDX-License-Identifier: MIT - -/** - * RFC-5 to ITK transform bridge and resample bounding box tests. - * - * Mirrors `py/test/test_itk_transform_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 - * in NGFF axis order. That keeps the tests honest about the two conventions - * that differ between RFC-5 and ITK: axis order and sequence composition - * order. - */ - -import { assertAlmostEquals, assertEquals, assertRejects } from "@std/assert"; -import * as zarr from "zarrita"; -import { itkTransformResampleBoundingBox, NgffImage } 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"; - -/** An ITK-Wasm translation, in ITK (fastest-axis-first) order. */ -// deno-lint-ignore no-explicit-any -function itkTranslation(offset: number[]): any { - return [{ - transformType: { - transformParameterization: "Translation", - parametersValueType: "float64", - inputDimension: offset.length, - outputDimension: offset.length, - }, - name: "TranslationTransform", - inputSpaceName: "", - outputSpaceName: "", - numberOfFixedParameters: 0, - numberOfParameters: offset.length, - fixedParameters: new Float64Array(0), - parameters: new Float64Array(offset), - metadata: new Map(), - }]; -} - -// deno-lint-ignore no-explicit-any -const identity = (dimension: number): any => - itkTranslation(new Array(dimension).fill(0)); - -/** An ITK-Wasm affine, row-major matrix then translation, centre at the origin. */ -// deno-lint-ignore no-explicit-any -function itkAffine(matrix: number[][], offset: number[]): any { - const dimension = offset.length; - return [{ - transformType: { - transformParameterization: "Affine", - parametersValueType: "float64", - inputDimension: dimension, - outputDimension: dimension, - }, - name: "AffineTransform", - inputSpaceName: "", - outputSpaceName: "", - numberOfFixedParameters: dimension, - numberOfParameters: dimension * dimension + dimension, - fixedParameters: new Float64Array(dimension), - parameters: new Float64Array([...matrix.flat(), ...offset]), - metadata: new Map(), - }]; -} - -/** Reverse a square matrix's rows and columns: NGFF order <-> ITK order. */ -function reversed(matrix: number[][]): number[][] { - const order = matrix.map((_, i) => matrix.length - 1 - i); - return order.map((r) => order.map((c) => matrix[r][c])); -} - -/** A geometry-only NgffImage; the data is never meant to be read. */ -async function geometryImage( - dims: string[], - shape: Record, - scale: Record, - translation: Record, - axesOrientations?: Record, -): Promise { - const store = new Map(); - const root = zarr.root(store); - const arrayShape = dims.map((dim) => shape[dim]); - const data = await zarr.create(root.resolve("data"), { - shape: arrayShape, - chunk_shape: arrayShape.map((n) => Math.min(n, 32)), - data_type: "uint8", - fill_value: 0, - }); - return new NgffImage({ - data, - dims, - scale, - translation, - name: "image", - axesUnits: undefined, - axesOrientations, - computedCallbacks: undefined, - }); -} - -/** Recompute the region in NGFF order, independently of the pipeline. */ -function oracleRegion( - matrix: number[][], - offset: number[], - fixedShape: number[], - fixedScale: number[], - fixedTranslation: number[], - movingScale: number[], - movingTranslation: number[], - padding: number, -): { start: number[]; size: number[] } { - const ndim = fixedShape.length; - const indexMin = new Array(ndim).fill(Infinity); - const indexMax = new Array(ndim).fill(-Infinity); - - // A linear map sends the fixed rectangle to a convex region, so sampling - // the corners is exact. - for (let mask = 0; mask < 1 << ndim; mask++) { - const corner = Array.from( - { length: ndim }, - (_, i) => (mask >> i) & 1 ? fixedShape[i] - 1 : 0, - ); - const point = corner.map((c, i) => fixedTranslation[i] + fixedScale[i] * c); - for (let row = 0; row < ndim; row++) { - let moved = offset[row]; - for (let col = 0; col < ndim; col++) { - moved += matrix[row][col] * point[col]; - } - const continuousIndex = (moved - movingTranslation[row]) / - movingScale[row]; - indexMin[row] = Math.min(indexMin[row], continuousIndex); - indexMax[row] = Math.max(indexMax[row], continuousIndex); - } - } - - const start = indexMin.map((v) => Math.floor(v) - padding); - const end = indexMax.map((v) => Math.ceil(v) + padding); - return { start, size: end.map((e, i) => Math.max(e - start[i] + 1, 0)) }; -} - -Deno.test("mismatched spatial dims are rejected", async () => { - const fixed = await geometryImage(["z", "y", "x"], { z: 4, y: 4, x: 4 }, { - z: 1, - y: 1, - x: 1, - }, { z: 0, y: 0, x: 0 }); - const moving = await geometryImage(["y", "x"], { y: 4, x: 4 }, { - y: 1, - x: 1, - }, { y: 0, x: 0 }); - - await assertRejects( - () => itkTransformResampleBoundingBox(identity(2), fixed, moving), - Error, - "they must match", - ); -}); - -Deno.test("pixel data is never read", async () => { - // A store that throws on any chunk read: if the pipeline touched pixels, - // this would reject rather than return a region. - const backing = new Map(); - const poison = { - get(key: string): Promise { - if (key.endsWith("zarr.json")) { - return Promise.resolve(backing.get(key)); - } - throw new Error("pixel data was read"); - }, - set(key: string, value: Uint8Array): Promise { - backing.set(key, value); - return Promise.resolve(); - }, - delete(key: string): Promise { - return Promise.resolve(backing.delete(key)); - }, - }; - const root = zarr.root(poison as never); - const data = await zarr.create(root.resolve("data"), { - shape: [32, 32], - chunk_shape: [8, 8], - data_type: "uint8", - fill_value: 0, - }); - const image = new NgffImage({ - data, - dims: ["y", "x"], - scale: { y: 1, x: 1 }, - translation: { y: 0, x: 0 }, - name: "image", - axesUnits: undefined, - computedCallbacks: undefined, - }); - - const boundingBox = await itkTransformResampleBoundingBox( - identity(2), - image, - image, - { padding: 1 }, - ); - - assertEquals(boundingBox.startIndex, { y: -1, x: -1 }); - assertEquals(boundingBox.size, { y: 34, x: 34 }); -}); - -Deno.test("non-spatial axes are passed through", async () => { - const dims = ["t", "c", "z", "y", "x"]; - const fixed = await geometryImage(dims, { t: 3, c: 2, z: 4, y: 8, x: 8 }, { - t: 1, - c: 1, - z: 1, - y: 1, - x: 1, - }, { t: 0, c: 0, z: 0, y: 0, x: 0 }); - const moving = await geometryImage( - dims, - { t: 3, c: 2, z: 16, y: 32, x: 32 }, - { - t: 1, - c: 1, - z: 1, - y: 1, - x: 1, - }, - { t: 0, c: 0, z: 0, y: 0, x: 0 }, - ); - - const boundingBox = await itkTransformResampleBoundingBox( - itkTranslation([3, 2, 1]), - fixed, - moving, - { padding: 0 }, - ); - - assertEquals(boundingBox.dims, ["z", "y", "x"]); - assertEquals(boundingBox.startIndex, { z: 1, y: 2, x: 3 }); - - const selection = boundingBox.selection(moving.dims); - assertEquals(selection[0], null); // t - assertEquals(selection[1], null); // c - assertEquals(selection[2], zarr.slice(1, 5)); // z - assertEquals(selection[3], zarr.slice(2, 10)); // y - assertEquals(selection[4], zarr.slice(3, 11)); // x -}); - -Deno.test("a region outside the moving image is empty", async () => { - const fixed = await geometryImage(["y", "x"], { y: 4, x: 4 }, { - y: 1, - x: 1, - }, { y: 0, x: 0 }); - const moving = await geometryImage(["y", "x"], { y: 32, x: 32 }, { - y: 1, - x: 1, - }, { y: 0, x: 0 }); - - const boundingBox = await itkTransformResampleBoundingBox( - itkTranslation([1000, 1000]), - fixed, - moving, - { padding: 1 }, - ); - - assertEquals(boundingBox.isEmpty, true); -}); - -Deno.test("a negative start index is clamped, not wrapped", async () => { - const fixed = await geometryImage(["y", "x"], { y: 4, x: 4 }, { - y: 1, - x: 1, - }, { y: 0, x: 0 }); - const moving = await geometryImage(["y", "x"], { y: 32, x: 32 }, { - y: 1, - x: 1, - }, { y: 0, x: 0 }); - - const boundingBox = await itkTransformResampleBoundingBox( - identity(2), - fixed, - moving, - { padding: 2 }, - ); - - assertEquals(boundingBox.startIndex, { y: -2, x: -2 }); - assertEquals(boundingBox.clamped(), { y: [0, 6], x: [0, 6] }); - assertEquals(boundingBox.isEmpty, false); -}); - -Deno.test("the cropped translation shifts by start * scale", async () => { - const fixed = await geometryImage(["y", "x"], { y: 64, x: 64 }, { - y: 1, - x: 1, - }, { y: 512, x: 1024 }); - const moving = await geometryImage(["y", "x"], { y: 1024, x: 1024 }, { - y: 2, - x: 2, - }, { y: 0, x: 0 }); - - const boundingBox = await itkTransformResampleBoundingBox( - itkTranslation([0, 0]), - fixed, - moving, - { padding: 1 }, - ); - const translation = boundingBox.croppedTranslation(moving); - const bounds = boundingBox.clamped(); - - for (const dim of ["y", "x"]) { - assertAlmostEquals( - translation[dim], - moving.translation[dim] + bounds[dim][0] * moving.scale[dim], - ); - } -}); - -Deno.test("RAS orientation yields a non-identity direction", async () => { - const { itkDirection } = await import( - "../src/io/itk_transform_resample_bounding_box-shared.ts" - ); - const image = await geometryImage( - ["z", "y", "x"], - { z: 4, y: 8, x: 16 }, - { z: 1, y: 1, x: 1 }, - { z: 0, y: 0, x: 0 }, - RAS, - ); - const direction = itkDirection(image, ["x", "y", "z"]); - assertEquals(Array.from(direction), [-1, 0, 0, 0, -1, 0, 0, 0, 1]); -}); - -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" - ); - const { AnatomicalOrientationValues, createAnatomicalOrientation } = - await import("../src/types/rfc4.ts"); - // An inferior/superior axis points along LPS z; truncating its column to - // 2D would produce a singular matrix. - const image = await geometryImage( - ["y", "x"], - { y: 8, x: 16 }, - { y: 1, x: 1 }, - { y: 0, x: 0 }, - { - x: createAnatomicalOrientation(AnatomicalOrientationValues.LeftToRight), - y: createAnatomicalOrientation( - AnatomicalOrientationValues.InferiorToSuperior, - ), - }, - ); - const direction = itkDirection(image, ["x", "y"]); - assertEquals(Array.from(direction), [1, 0, 0, 1]); -}); - -Deno.test("an index range overflow is reported, not silently empty", async () => { - // The region is computed in 32-bit index space while the corners come back - // as doubles, so a fixed/moving scale mismatch this large wraps the integer - // region. Reporting "no overlap" for a grid covering the whole moving image - // would be a silent, wrong answer. - const fixed = await geometryImage(["y", "x"], { y: 16, x: 16 }, { - y: 1e9, - x: 1e9, - }, { y: 0, x: 0 }); - const moving = await geometryImage(["y", "x"], { y: 64, x: 64 }, { - y: 1, - x: 1, - }, { y: 0, x: 0 }); - - await assertRejects( - () => - itkTransformResampleBoundingBox(identity(2), fixed, moving, { - padding: 1, - }), - Error, - "does not contain the transformed grid", - ); -}); - -Deno.test("padding that is not a non-negative integer is rejected", async () => { - const fixed = await geometryImage(["y", "x"], { y: 4, x: 4 }, { - y: 1, - x: 1, - }, { y: 0, x: 0 }); - for (const padding of [-1, 1.5, NaN, Infinity]) { - await assertRejects( - () => - itkTransformResampleBoundingBox(identity(2), fixed, fixed, { - padding, - }), - Error, - "padding must be a non-negative integer", - ); - } -}); - -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), - Error, - "only 2 and 3 are supported", - ); -}); - -Deno.test("a missing scale entry is rejected rather than defaulted", async () => { - // Python raises here; TypeScript used to substitute spacing 1 and return a - // plausible but wrong region. - const fixed = await geometryImage(["y", "x"], { y: 4, x: 4 }, { - y: 1, - x: 1, - }, { y: 0, x: 0 }); - const partial = new NgffImage({ - data: fixed.data, - dims: ["y", "x"], - scale: { y: 2 } as Record, - translation: { y: 0, x: 0 }, - name: "image", - axesUnits: undefined, - computedCallbacks: undefined, - }); - - await assertRejects( - () => itkTransformResampleBoundingBox(identity(2), partial, fixed), - Error, - "no entry for dimension 'x'", - ); -}); - -Deno.test("a non-finite scale is rejected", async () => { - const fixed = await geometryImage(["y", "x"], { y: 4, x: 4 }, { - y: 1, - x: NaN, - }, { y: 0, x: 0 }); - await assertRejects( - () => itkTransformResampleBoundingBox(identity(2), fixed, fixed), - Error, - "must be finite", - ); -}); - -Deno.test("a zero scale is rejected", async () => { - const fixed = await geometryImage(["y", "x"], { y: 4, x: 4 }, { - y: 1, - x: 0, - }, { y: 0, x: 0 }); - await assertRejects( - () => itkTransformResampleBoundingBox(identity(2), fixed, fixed), - Error, - "scale for dimension 'x' is zero", - ); -}); - -Deno.test("a degenerate fixed grid yields an empty region", async () => { - const fixed = await geometryImage(["y", "x"], { y: 0, x: 4 }, { - y: 1, - x: 1, - }, { y: 0, x: 0 }); - const moving = await geometryImage(["y", "x"], { y: 32, x: 32 }, { - y: 1, - x: 1, - }, { y: 0, x: 0 }); - - const boundingBox = await itkTransformResampleBoundingBox( - identity(2), - fixed, - moving, - ); - - assertEquals(boundingBox.isEmpty, true); -}); - -Deno.test("an ITK-Wasm transform list is accepted", 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 }); - - // Stated directly in ITK order (x, y). - const transformList = [{ - transformType: { - transformParameterization: "Translation", - parametersValueType: "float64", - inputDimension: 2, - outputDimension: 2, - }, - name: "TranslationTransform", - inputSpaceName: "", - outputSpaceName: "", - numberOfFixedParameters: 0, - numberOfParameters: 2, - fixedParameters: new Float64Array(0), - parameters: new Float64Array([10, 5]), - metadata: new Map(), - }]; - - const boundingBox = await itkTransformResampleBoundingBox( - transformList as never, - fixed, - moving, - { padding: 1 }, - ); - - assertEquals(boundingBox.startIndex, { y: 24, x: 19 }); - assertEquals(boundingBox.size, { y: 33, x: 33 }); -}); - -Deno.test("an asymmetric 3D affine matches the oracle", () => { - // Stated in NGFF (z, y, x) order for the oracle; the transform itself is - // built in ITK order, so both row and column ordering reverse. - const matrix = [[1, 0.2, 0], [0, 2, 0.3], [0.5, 0, 1]]; - const offset = [4, -6, 11]; - return (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 }); - - const boundingBox = await itkTransformResampleBoundingBox( - itkAffine(reversed(matrix), [...offset].reverse()), - 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("unusable pipeline index arrays are rejected", async () => { - const fixed = await geometryImage(["y", "x"], { y: 4, x: 4 }, { - y: 1, - x: 1, - }, { y: 0, x: 0 }); - const corners = { min: [0, 0], max: [1, 1] }; - const cases: [number[], number[], string][] = [ - [[0], [4, 4], "1 paddedStartIndex values for 2 dimensions"], - [[0, 0], [4], "1 paddedSize values for 2 dimensions"], - [[0, NaN], [4, 4], "paddedStartIndex for dimension 'y'"], - [[0, 0], [4, Infinity], "paddedSize for dimension 'y'"], - ]; - - for (const [paddedStartIndex, paddedSize, message] of cases) { - const pipeline = () => - Promise.resolve({ - boundingBox: { - paddedStartIndex, - paddedSize, - corners, - paddedCorners: corners, - }, - }); - await assertRejects( - () => resampleBoundingBoxShared(pipeline, identity(2), fixed, fixed), - Error, - message, - ); - } -}); 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..394d04af --- /dev/null +++ b/ts/test/itk_transform_to_ngff_transform_test.ts @@ -0,0 +1,365 @@ +// 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, + 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"]], + ["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("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( + 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[] = [], + dimension = 2, +): Transform { + return { + transformType: { + transformParameterization: parameterization, + parametersValueType: "float64", + inputDimension: dimension, + outputDimension: dimension, + }, + 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("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]); + 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 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 + // 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", () => { + 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("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"]), + 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", + ); +}); 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..a4f18e8d --- /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.inputAxes.map((axis) => point[axis]), + ); + item.outputAxes.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, + 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"]); +}); + +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 new file mode 100644 index 00000000..f77bae0f --- /dev/null +++ b/ts/test/resample_bounding_box_test.ts @@ -0,0 +1,1234 @@ +// SPDX-FileCopyrightText: Copyright (c) Fideus Labs LLC +// SPDX-License-Identifier: MIT + +/** + * RFC-5 to ITK transform bridge and resample bounding box tests. + * + * 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 + * in NGFF axis order. That keeps the tests honest about the two conventions + * that differ between RFC-5 and ITK: axis order and sequence composition + * order. + */ + +import { + assertAlmostEquals, + assertEquals, + assertRejects, + assertThrows, +} from "@std/assert"; +import * as zarr from "zarrita"; +import { + createAffine, + createBijection, + createByDimension, + createIdentity, + createMapAxis, + createRotation, + createScale, + createTransformSequence, + createTranslation, + itkDisplacementFieldToNgffTransform, + itkTransformToNgffTransform, + NgffImage, + ngffTransformToItkTransform, + resampleBoundingBox, +} from "../src/mod.ts"; +import { ngffTransformToItkMatrix } from "../src/utils/ngff_transform_to_itk_transform.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"; + +/** An ITK-Wasm translation, in ITK (fastest-axis-first) order. */ +// deno-lint-ignore no-explicit-any +function itkTranslation(offset: number[]): any { + return [{ + transformType: { + transformParameterization: "Translation", + parametersValueType: "float64", + inputDimension: offset.length, + outputDimension: offset.length, + }, + name: "TranslationTransform", + inputSpaceName: "", + outputSpaceName: "", + numberOfFixedParameters: 0, + numberOfParameters: offset.length, + fixedParameters: new Float64Array(0), + parameters: new Float64Array(offset), + metadata: new Map(), + }]; +} + +// deno-lint-ignore no-explicit-any +const identity = (dimension: number): any => + itkTranslation(new Array(dimension).fill(0)); + +/** An ITK-Wasm affine, row-major matrix then translation, centre at the origin. */ +// deno-lint-ignore no-explicit-any +function itkAffine(matrix: number[][], offset: number[]): any { + const dimension = offset.length; + return [{ + transformType: { + transformParameterization: "Affine", + parametersValueType: "float64", + inputDimension: dimension, + outputDimension: dimension, + }, + name: "AffineTransform", + inputSpaceName: "", + outputSpaceName: "", + numberOfFixedParameters: dimension, + numberOfParameters: dimension * dimension + dimension, + fixedParameters: new Float64Array(dimension), + parameters: new Float64Array([...matrix.flat(), ...offset]), + metadata: new Map(), + }]; +} + +/** Reverse a square matrix's rows and columns: NGFF order <-> ITK order. */ +function reversed(matrix: number[][]): number[][] { + const order = matrix.map((_, i) => matrix.length - 1 - i); + return order.map((r) => order.map((c) => matrix[r][c])); +} + +/** A geometry-only NgffImage; the data is never meant to be read. */ +async function geometryImage( + dims: string[], + shape: Record, + scale: Record, + translation: Record, + axesOrientations?: Record, +): Promise { + const store = new Map(); + const root = zarr.root(store); + const arrayShape = dims.map((dim) => shape[dim]); + const data = await zarr.create(root.resolve("data"), { + shape: arrayShape, + chunk_shape: arrayShape.map((n) => Math.min(n, 32)), + data_type: "uint8", + fill_value: 0, + }); + return new NgffImage({ + data, + dims, + scale, + translation, + name: "image", + axesUnits: undefined, + axesOrientations, + computedCallbacks: undefined, + }); +} + +/** Recompute the region in NGFF order, independently of the pipeline. */ +function oracleRegion( + matrix: number[][], + offset: number[], + fixedShape: number[], + fixedScale: number[], + fixedTranslation: number[], + movingScale: number[], + movingTranslation: number[], + padding: number, +): { start: number[]; size: number[] } { + const ndim = fixedShape.length; + const indexMin = new Array(ndim).fill(Infinity); + const indexMax = new Array(ndim).fill(-Infinity); + + // A linear map sends the fixed rectangle to a convex region, so sampling + // the corners is exact. + for (let mask = 0; mask < 1 << ndim; mask++) { + const corner = Array.from( + { length: ndim }, + (_, i) => (mask >> i) & 1 ? fixedShape[i] - 1 : 0, + ); + const point = corner.map((c, i) => fixedTranslation[i] + fixedScale[i] * c); + for (let row = 0; row < ndim; row++) { + let moved = offset[row]; + for (let col = 0; col < ndim; col++) { + moved += matrix[row][col] * point[col]; + } + const continuousIndex = (moved - movingTranslation[row]) / + movingScale[row]; + indexMin[row] = Math.min(indexMin[row], continuousIndex); + indexMax[row] = Math.max(indexMax[row], continuousIndex); + } + } + + const start = indexMin.map((v) => Math.floor(v) - padding); + const end = indexMax.map((v) => Math.ceil(v) + padding); + 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 resampleBoundingBox( + 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 resampleBoundingBox( + transform, + fixed, + moving, + { + padding: 1, + }, + ); + const tight = await resampleBoundingBox( + 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 resampleBoundingBox( + 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("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 resampleBoundingBox( + 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]), + 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, + y: 1, + x: 1, + }, { z: 0, y: 0, x: 0 }); + const moving = await geometryImage(["y", "x"], { y: 4, x: 4 }, { + y: 1, + x: 1, + }, { y: 0, x: 0 }); + + await assertRejects( + () => resampleBoundingBox(identity(2), fixed, moving), + Error, + "they must match", + ); +}); + +Deno.test("pixel data is never read", async () => { + // A store that throws on any chunk read: if the pipeline touched pixels, + // this would reject rather than return a region. + const backing = new Map(); + const poison = { + get(key: string): Promise { + if (key.endsWith("zarr.json")) { + return Promise.resolve(backing.get(key)); + } + throw new Error("pixel data was read"); + }, + set(key: string, value: Uint8Array): Promise { + backing.set(key, value); + return Promise.resolve(); + }, + delete(key: string): Promise { + return Promise.resolve(backing.delete(key)); + }, + }; + const root = zarr.root(poison as never); + const data = await zarr.create(root.resolve("data"), { + shape: [32, 32], + chunk_shape: [8, 8], + data_type: "uint8", + fill_value: 0, + }); + const image = new NgffImage({ + data, + dims: ["y", "x"], + scale: { y: 1, x: 1 }, + translation: { y: 0, x: 0 }, + name: "image", + axesUnits: undefined, + computedCallbacks: undefined, + }); + + const boundingBox = await resampleBoundingBox( + identity(2), + image, + image, + { padding: 1 }, + ); + + assertEquals(boundingBox.startIndex, { y: -1, x: -1 }); + assertEquals(boundingBox.size, { y: 34, x: 34 }); +}); + +Deno.test("non-spatial axes are passed through", async () => { + const dims = ["t", "c", "z", "y", "x"]; + const fixed = await geometryImage(dims, { t: 3, c: 2, z: 4, y: 8, x: 8 }, { + t: 1, + c: 1, + z: 1, + y: 1, + x: 1, + }, { t: 0, c: 0, z: 0, y: 0, x: 0 }); + const moving = await geometryImage( + dims, + { t: 3, c: 2, z: 16, y: 32, x: 32 }, + { + t: 1, + c: 1, + z: 1, + y: 1, + x: 1, + }, + { t: 0, c: 0, z: 0, y: 0, x: 0 }, + ); + + const boundingBox = await resampleBoundingBox( + itkTranslation([3, 2, 1]), + fixed, + moving, + { padding: 0 }, + ); + + assertEquals(boundingBox.dims, ["z", "y", "x"]); + assertEquals(boundingBox.startIndex, { z: 1, y: 2, x: 3 }); + + const selection = boundingBox.selection(moving.dims); + assertEquals(selection[0], null); // t + assertEquals(selection[1], null); // c + assertEquals(selection[2], zarr.slice(1, 5)); // z + assertEquals(selection[3], zarr.slice(2, 10)); // y + assertEquals(selection[4], zarr.slice(3, 11)); // x +}); + +Deno.test("a region outside the moving image is empty", async () => { + const fixed = await geometryImage(["y", "x"], { y: 4, x: 4 }, { + y: 1, + x: 1, + }, { y: 0, x: 0 }); + const moving = await geometryImage(["y", "x"], { y: 32, x: 32 }, { + y: 1, + x: 1, + }, { y: 0, x: 0 }); + + const boundingBox = await resampleBoundingBox( + itkTranslation([1000, 1000]), + fixed, + moving, + { padding: 1 }, + ); + + assertEquals(boundingBox.isEmpty, true); +}); + +Deno.test("a negative start index is clamped, not wrapped", async () => { + const fixed = await geometryImage(["y", "x"], { y: 4, x: 4 }, { + y: 1, + x: 1, + }, { y: 0, x: 0 }); + const moving = await geometryImage(["y", "x"], { y: 32, x: 32 }, { + y: 1, + x: 1, + }, { y: 0, x: 0 }); + + const boundingBox = await resampleBoundingBox( + identity(2), + fixed, + moving, + { padding: 2 }, + ); + + assertEquals(boundingBox.startIndex, { y: -2, x: -2 }); + assertEquals(boundingBox.clamped(), { y: [0, 6], x: [0, 6] }); + assertEquals(boundingBox.isEmpty, false); +}); + +Deno.test("the cropped translation shifts by start * scale", async () => { + const fixed = await geometryImage(["y", "x"], { y: 64, x: 64 }, { + y: 1, + x: 1, + }, { y: 512, x: 1024 }); + const moving = await geometryImage(["y", "x"], { y: 1024, x: 1024 }, { + y: 2, + x: 2, + }, { y: 0, x: 0 }); + + const boundingBox = await resampleBoundingBox( + itkTranslation([0, 0]), + fixed, + moving, + { padding: 1 }, + ); + const translation = boundingBox.croppedTranslation(moving); + const bounds = boundingBox.clamped(); + + for (const dim of ["y", "x"]) { + assertAlmostEquals( + translation[dim], + moving.translation[dim] + bounds[dim][0] * moving.scale[dim], + ); + } +}); + +Deno.test("RAS orientation yields a non-identity direction", async () => { + const { itkDirection } = await import( + "../src/io/resample_bounding_box-shared.ts" + ); + const image = await geometryImage( + ["z", "y", "x"], + { z: 4, y: 8, x: 16 }, + { z: 1, y: 1, x: 1 }, + { z: 0, y: 0, x: 0 }, + RAS, + ); + const direction = itkDirection(image, ["x", "y", "z"]); + assertEquals(Array.from(direction), [-1, 0, 0, 0, -1, 0, 0, 0, 1]); +}); + +Deno.test("a 3D-only orientation on a 2D image falls back to identity", async () => { + const { itkDirection } = await import( + "../src/io/resample_bounding_box-shared.ts" + ); + const { AnatomicalOrientationValues, createAnatomicalOrientation } = + await import("../src/types/rfc4.ts"); + // An inferior/superior axis points along LPS z; truncating its column to + // 2D would produce a singular matrix. + const image = await geometryImage( + ["y", "x"], + { y: 8, x: 16 }, + { y: 1, x: 1 }, + { y: 0, x: 0 }, + { + x: createAnatomicalOrientation(AnatomicalOrientationValues.LeftToRight), + y: createAnatomicalOrientation( + AnatomicalOrientationValues.InferiorToSuperior, + ), + }, + ); + const direction = itkDirection(image, ["x", "y"]); + assertEquals(Array.from(direction), [1, 0, 0, 1]); +}); + +Deno.test("an index range overflow is reported, not silently empty", async () => { + // The region is computed in 32-bit index space while the corners come back + // as doubles, so a fixed/moving scale mismatch this large wraps the integer + // region. Reporting "no overlap" for a grid covering the whole moving image + // would be a silent, wrong answer. + const fixed = await geometryImage(["y", "x"], { y: 16, x: 16 }, { + y: 1e9, + x: 1e9, + }, { y: 0, x: 0 }); + const moving = await geometryImage(["y", "x"], { y: 64, x: 64 }, { + y: 1, + x: 1, + }, { y: 0, x: 0 }); + + await assertRejects( + () => + resampleBoundingBox(identity(2), fixed, moving, { + padding: 1, + }), + Error, + "does not contain the transformed grid", + ); +}); + +Deno.test("padding that is not a non-negative integer is rejected", async () => { + const fixed = await geometryImage(["y", "x"], { y: 4, x: 4 }, { + y: 1, + x: 1, + }, { y: 0, x: 0 }); + for (const padding of [-1, 1.5, NaN, Infinity]) { + await assertRejects( + () => + resampleBoundingBox(identity(2), fixed, fixed, { + padding, + }), + Error, + "padding must be a non-negative integer", + ); + } +}); + +Deno.test("unsupported spatial dimensionality is rejected", async () => { + const fixed = await geometryImage(["x"], { x: 8 }, { x: 1 }, { x: 0 }); + await assertRejects( + () => resampleBoundingBox(identity(2), fixed, fixed), + Error, + "only 2 and 3 are supported", + ); +}); + +Deno.test("a missing scale entry is rejected rather than defaulted", async () => { + // Python raises here; TypeScript used to substitute spacing 1 and return a + // plausible but wrong region. + const fixed = await geometryImage(["y", "x"], { y: 4, x: 4 }, { + y: 1, + x: 1, + }, { y: 0, x: 0 }); + const partial = new NgffImage({ + data: fixed.data, + dims: ["y", "x"], + scale: { y: 2 } as Record, + translation: { y: 0, x: 0 }, + name: "image", + axesUnits: undefined, + computedCallbacks: undefined, + }); + + await assertRejects( + () => resampleBoundingBox(identity(2), partial, fixed), + Error, + "no entry for dimension 'x'", + ); +}); + +Deno.test("a non-finite scale is rejected", async () => { + const fixed = await geometryImage(["y", "x"], { y: 4, x: 4 }, { + y: 1, + x: NaN, + }, { y: 0, x: 0 }); + await assertRejects( + () => resampleBoundingBox(identity(2), fixed, fixed), + Error, + "must be finite", + ); +}); + +Deno.test("a zero scale is rejected", async () => { + const fixed = await geometryImage(["y", "x"], { y: 4, x: 4 }, { + y: 1, + x: 0, + }, { y: 0, x: 0 }); + await assertRejects( + () => resampleBoundingBox(identity(2), fixed, fixed), + Error, + "scale for dimension 'x' is zero", + ); +}); + +Deno.test("a degenerate fixed grid yields an empty region", async () => { + const fixed = await geometryImage(["y", "x"], { y: 0, x: 4 }, { + y: 1, + x: 1, + }, { y: 0, x: 0 }); + const moving = await geometryImage(["y", "x"], { y: 32, x: 32 }, { + y: 1, + x: 1, + }, { y: 0, x: 0 }); + + const boundingBox = await resampleBoundingBox( + identity(2), + fixed, + moving, + ); + + 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 resampleBoundingBox( + transform, + plainImages.fixed, + plainImages.moving, + { padding: 0 }, + ); + const oriented = await resampleBoundingBox( + 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("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 resampleBoundingBox([warp], fixed, moving); + const { transform, field } = await itkDisplacementFieldToNgffTransform( + warp, + ["y", "x"], + { path: "warp" }, + ); + const viaRfc5 = await resampleBoundingBox( + 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( + () => resampleBoundingBox(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 + // 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 resampleBoundingBox( + transform, + fixed, + moving, + { + padding: 0, + }, + ); + const converted = itkTransformToNgffTransform( + transform, + ["z", "y", "x"], + true, + { + fixed, + moving, + }, + ); + const viaRfc5 = await resampleBoundingBox( + converted, + fixed, + moving, + { padding: 0 }, + ); + + assertEquals(viaRfc5.startIndex, viaItk.startIndex); + assertEquals(viaRfc5.size, viaItk.size); + + // 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 () => { + // 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, + x: 2, + }, { y: 20, x: 10 }); + const moving = await geometryImage(["y", "x"], { y: 64, x: 64 }, { + y: 1, + x: 1, + }, { y: 0, x: 0 }); + + // Stated directly in ITK order (x, y). + const transformList = [{ + transformType: { + transformParameterization: "Translation", + parametersValueType: "float64", + inputDimension: 2, + outputDimension: 2, + }, + name: "TranslationTransform", + inputSpaceName: "", + outputSpaceName: "", + numberOfFixedParameters: 0, + numberOfParameters: 2, + fixedParameters: new Float64Array(0), + parameters: new Float64Array([10, 5]), + metadata: new Map(), + }]; + + const boundingBox = await resampleBoundingBox( + transformList as never, + fixed, + moving, + { padding: 1 }, + ); + + assertEquals(boundingBox.startIndex, { y: 24, x: 19 }); + assertEquals(boundingBox.size, { y: 33, x: 33 }); +}); + +Deno.test("an asymmetric 3D affine matches the oracle", () => { + // Stated in NGFF (z, y, x) order for the oracle; the transform itself is + // built in ITK order, so both row and column ordering reverse. + const matrix = [[1, 0.2, 0], [0, 2, 0.3], [0.5, 0, 1]]; + const offset = [4, -6, 11]; + return (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 }); + + const boundingBox = await resampleBoundingBox( + itkAffine(reversed(matrix), [...offset].reverse()), + 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("unusable pipeline index arrays are rejected", async () => { + const fixed = await geometryImage(["y", "x"], { y: 4, x: 4 }, { + y: 1, + x: 1, + }, { y: 0, x: 0 }); + const corners = { min: [0, 0], max: [1, 1] }; + const cases: [number[], number[], string][] = [ + [[0], [4, 4], "1 paddedStartIndex values for 2 dimensions"], + [[0, 0], [4], "1 paddedSize values for 2 dimensions"], + [[0, NaN], [4, 4], "paddedStartIndex for dimension 'y'"], + [[0, 0], [4, Infinity], "paddedSize for dimension 'y'"], + ]; + + for (const [paddedStartIndex, paddedSize, message] of cases) { + const pipeline = () => + Promise.resolve({ + boundingBox: { + paddedStartIndex, + paddedSize, + corners, + paddedCorners: corners, + }, + }); + await assertRejects( + () => resampleBoundingBoxShared(pipeline, identity(2), fixed, fixed), + Error, + message, + ); + } +}); + +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]), + inputAxes: [0], + outputAxes: [0], + }, + { + transformation: createAffine([[0.8, -0.6, 2], [0.6, 0.8, -3]]), + inputAxes: [1, 2], + outputAxes: [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); +});