RFC-5: Coordinate Systems and TransformationsΒΆ

RFC-5 extends OME-NGFF with named coordinate systems and a richer set of coordinate transformations β€” identity, scale, translation, rotation, affine, transformation sequences, and array-backed displacement and coordinate fields. This is the OME-Zarr v0.6 data model. ngff-zarr reads and writes it in both the Python and TypeScript packages.

OverviewΒΆ

In v0.4/v0.5 each dataset carries only a scale and translation. RFC-5 (v0.6) generalizes this: a multiscales document declares one or more coordinateSystems (the intrinsic pixel system plus any output systems), and maps between them with coordinateTransformations. Transformations may live on a dataset (the per-scale scale/translation) or at the top level of the multiscales (an affine, a displacement field, a sequence, …), mapping the image into another coordinate system.

Two of these transformations reference their parameters as an array stored in the Zarr hierarchy rather than inline:

  • displacements β€” a vector field added to each coordinate.

  • coordinates β€” a vector field of absolute output coordinates.

The field is itself an ordinary OME-Zarr image, so it can be written into the same store as the image it transforms. That end-to-end pattern is the focus of the Write an image and its transformation into one store section below.

Displacement and coordinate fieldsΒΆ

A displacement (or coordinate) field is a multiscale image whose vector-component axis, in the channel position, carries type="displacement" (or type="coordinate") instead of type="channel". Like a channel/component axis it is discrete β€” it indexes vector components rather than a continuous coordinate. Set the type with the axes_types argument of NgffImage, mapping a dimension name to its axis type:

import dask.array as da
import numpy as np
import ngff_zarr as nz

# A 2-component displacement field over a yx image: the "c" dimension holds the
# (y, x) displacement vector, one component per output spatial axis.
data = da.from_array(np.zeros((2, 256, 256), dtype=np.float32))
field = nz.NgffImage(
    data=data,
    dims=("c", "y", "x"),
    scale={"c": 1.0, "y": 1.0, "x": 1.0},
    translation={"c": 0.0, "y": 0.0, "x": 0.0},
    axes_types={"c": "displacement"},
)

multiscales = nz.to_multiscales(field, scale_factors=[])
nz.to_ome_zarr("displacement.ome.zarr", multiscales, version="0.6")

The axis type round-trips through reading and writing. It can also be set after the fact by editing the metadata directly, for example multiscales.metadata.intrinsic_coordinate_system.axes[0].type = "displacement".

TransformationsΒΆ

The transformation data classes live in ngff_zarr.v06.zarr_metadata:

Class

type

Parameters

Identity

identity

β€”

Scale

scale

scale: list[float]

Translation

translation

translation: list[float]

Rotation

rotation

rotation: list[list[float]]

Affine

affine

affine: list[list[float]]

Displacements

displacements

path: str, interpolation: str

Coordinates

coordinates

path: str, interpolation: str

TransformSequence

sequence

transformations: list[Transform]

Every transform has an input and output, each a CoordinateSystemIdentifier naming a coordinate system (name=) or referencing a dataset (path=). Displacements/Coordinates additionally carry a path pointing at the field array within the store.

Inline transforms (affine, rotation, scale, …) are stored directly in the multiscales metadata, so a single to_ome_zarr call writes both the image pixel data and the transformation:

import numpy as np
import ngff_zarr as nz
from ngff_zarr.v06.zarr_metadata import (
    Affine,
    Axis,
    CoordinateSystem,
    CoordinateSystemIdentifier,
)

array = np.random.random((64, 64, 64)).astype(np.float32)
image = nz.to_ngff_image(array, dims=["z", "y", "x"])
multiscales = nz.to_multiscales(image, scale_factors=[])

# An affine that maps the intrinsic pixel system to an "output" system.
output_cs = CoordinateSystem(
    name="output",
    axes=[Axis(name=d, type="space") for d in ("z", "y", "x")],
)
affine = Affine(
    affine=[
        [1.0, 0.0, 0.0, 5.0],
        [0.0, 1.0, 0.0, 10.0],
        [0.0, 0.0, 1.0, 15.0],
    ],
    input=CoordinateSystemIdentifier(
        name=multiscales.metadata.intrinsic_coordinate_system.name
    ),
    output=CoordinateSystemIdentifier(name=output_cs.name),
    name="to_output",
)
multiscales.metadata.coordinateSystems.append(output_cs)
multiscales.metadata.coordinateTransformations = [affine]

nz.to_ome_zarr("affine.ome.zarr", multiscales, version="0.6")

Write an image and its transformation into one storeΒΆ

A displacements or coordinates transform points at a field array by path. To keep the image and the field it references together, write the field as an OME-Zarr image into a subgroup of the same store, then reference it from the image’s transform.

Because the store metadata is consolidated after the last write, write the field subgroup first, then the image at the store root with overwrite=False. The final write consolidates metadata for the whole store, so the field is discoverable under its path.

import dask.array as da
import numpy as np
import ngff_zarr as nz
from ngff_zarr.v06.zarr_metadata import (
    Axis,
    CoordinateSystem,
    CoordinateSystemIdentifier,
    Displacements,
)

store = "warped.ome.zarr"
field_path = "displacement_field"

# The primary image.
image = nz.to_ngff_image(
    np.random.random((256, 256)).astype(np.float32), dims=["y", "x"]
)
multiscales = nz.to_multiscales(image, scale_factors=[])

# The displacement field, an image with a displacement component axis.
field = nz.NgffImage(
    data=da.from_array(np.zeros((2, 256, 256), dtype=np.float32)),
    dims=("c", "y", "x"),
    scale={"c": 1.0, "y": 1.0, "x": 1.0},
    translation={"c": 0.0, "y": 0.0, "x": 0.0},
    axes_types={"c": "displacement"},
)
field_multiscales = nz.to_multiscales(field, scale_factors=[])

# A displacements transform whose `path` resolves to the field subgroup.
output_cs = CoordinateSystem(
    name="output",
    axes=[Axis(name="y", type="space"), Axis(name="x", type="space")],
)
transform = Displacements(path=field_path, interpolation="linear")
transform.input = CoordinateSystemIdentifier(
    name=multiscales.metadata.intrinsic_coordinate_system.name
)
transform.output = CoordinateSystemIdentifier(name=output_cs.name)
transform.name = "warp"
multiscales.metadata.coordinateSystems.append(output_cs)
multiscales.metadata.coordinateTransformations = [transform]

# Write the field subgroup first, then the image at the store root.
nz.to_ome_zarr(f"{store}/{field_path}", field_multiscales, version="0.6")
nz.to_ome_zarr(store, multiscales, version="0.6", overwrite=False)

Reading back resolves both the image and the field from the one store:

imported = nz.from_ome_zarr(store)
transform = imported.metadata.coordinateTransformations[0]
assert transform.path == field_path

field = nz.from_ome_zarr(f"{store}/{transform.path}")
axes = field.metadata.intrinsic_coordinate_system.axes
assert [a.type for a in axes] == ["displacement", "space", "space"]

Use Coordinates instead of Displacements (and axes_types={"c": "coordinate"} on the field) for an absolute coordinate field; the store layout is identical.

TypeScriptΒΆ

The TypeScript package (@fideus-labs/ngff-zarr) mirrors the Python API. Field axis types are set with the axesTypes option, and the same field-first, overwrite: false ordering writes an image and its field into one store:

import {
  createAxis,
  createCoordinateSystem,
  fromOmeZarr,
  toMultiscales,
  toNgffImage,
  toOmeZarr,
  type V06Transform,
} from "@fideus-labs/ngff-zarr";

const store = "warped.ome.zarr";
const fieldPath = "displacement_field";

const image = await toNgffImage(new Float32Array(256 * 256), {
  dims: ["y", "x"],
  shape: [256, 256],
});
const multiscales = await toMultiscales(image, { scaleFactors: [] });

const field = await toNgffImage(new Float32Array(2 * 256 * 256), {
  dims: ["c", "y", "x"],
  shape: [2, 256, 256],
  axesTypes: { c: "displacement" },
});
const fieldMultiscales = await toMultiscales(field, { scaleFactors: [] });

const intrinsic = multiscales.metadata.coordinateSystems![0];
const outputCs = createCoordinateSystem("output", [
  createAxis("y", "space"),
  createAxis("x", "space"),
]);
multiscales.metadata.coordinateSystems!.push(outputCs);
multiscales.metadata.coordinateTransformations = [{
  type: "displacements",
  path: fieldPath,
  interpolation: "linear",
  input: { name: intrinsic.name },
  output: { name: outputCs.name },
  name: "warp",
} as V06Transform];

// Write the field subgroup first, then the image at the store root.
await toOmeZarr(`${store}/${fieldPath}`, fieldMultiscales, { version: "0.6" });
await toOmeZarr(store, multiscales, { version: "0.6", overwrite: false });

const imported = await fromOmeZarr(store, { version: "0.6" });
const transform = imported.metadata.coordinateTransformations![0];
const field2 = await fromOmeZarr(`${store}/${(transform as { path: string }).path}`, {
  version: "0.6",
});

CompatibilityΒΆ

The v0.6 data model requires a Zarr v3 store, so writing version="0.6" needs zarr-python >= 3.0.0b1. Reading v0.1–v0.5 stores is unchanged; on read, older metadata is converted into the v0.6 data model (a single intrinsic coordinate system with per-dataset scale/translation sequences).