Skip to content

Coordinate Systems

In plain words

To look at a result, see Look at the result. This page is for programs that place Thyra's data on a slide or an image.

Every SpatialData zarr Thyra writes carries a small but crucial promise: all elements within the zarr resolve to the same physical frame at the "global" coordinate system, and that frame is documented in the zarr metadata. This page explains the contract so consumers (Ousia, registration tooling, custom analysis scripts) can render and compute without guessing what the numbers mean.


The contract in one sentence

In every Thyra-produced zarr,

the "global" coordinate system is one self-consistent frame, its unit and pixel-size calibration are recorded under zarr.attrs["coordinate_systems"]["global"], and every element registers a transform that lands in that frame.

If you only remember one thing, remember that.

A store in micrometers also carries a second shared frame, "acquisition": "global" moved to where the source put its raster. Two stores cut from one slide overlap in "global" and sit side by side there. See The acquisition system.

One thing the contract deliberately does not cover: ion mobility. A TIMS acquisition has a third measured dimension, but it is a coordinate on a feature -- like m/z, it says what was measured, not where. Thyra never writes mobility into a coordinate system, a transform, obs, or a z axis; it lives in var (on a mobility-resolved table) and in the summed table's uns["mobility_axis"] / uns["mobility_heatmap"], see Output Format. Anything that reads coordinate_systems can ignore mobility entirely.


The schema

At the zarr top level Thyra writes:

zarr.attrs["coordinate_systems"] = {
    "global": {
        "unit": "micrometer" | "pixel",
        "pixel_size_um_x": float | None,
        "pixel_size_um_y": float | None,
        "reference_element": str | None,
        "convention_version": 1,
        "produced_by": "thyra/<version>",
        "raster_to_global_affine": [[a, b, tx], [c, d, ty], [0, 0, 1]],

        # Only when the reader reports raw coordinate offsets
        "coordinate_offsets_px": [x, y, z],
        # Micrometer variant only, when the source states an origin
        "stage_offset_um": [x_um, y_um],

        # Multi-slice volumes only -- see "The z axis" below
        "z_spacing_um": float,
        "z_spacing_source": "manual" | "automatic" | "assumed_isotropic",
    },

    # Micrometer variant only, when the source states an origin
    "acquisition": {
        "unit": "micrometer",
        "pixel_size_um_x": float,
        "pixel_size_um_y": float,
        "reference_element": None,
        "convention_version": 1,
        "produced_by": "thyra/<version>",
        "origin_um": [x_um, y_um],  # [x_um, y_um, z_um] on a volume
        "origin_source": "raster_index" | "stage_position",
        "raster_to_acquisition_affine": [[a, b, tx], [c, d, ty], [0, 0, 1]],
        "z_spacing_um": float,  # volumes only
    },
}
Field Meaning
unit Unit of one step in "global" -- either "micrometer" or "pixel".
pixel_size_um_x, pixel_size_um_y The MSI raster pitch in micrometers -- the spacing between two acquisition positions, which is the same scale raster_to_global_affine carries. Written only when unit="micrometer"; None when unit="pixel", because there the field would have to describe the optical photo's um-per-pixel calibration and FlexImaging photos do not generally carry one. It is not a factor to multiply a "global" coordinate by.
reference_element Key of the canonical raster element that defines pixel space, as it appears in sdata.images, when unit="pixel". None otherwise -- including when unit="pixel" but this store does not hold that element (optical images not included, or its pixels could not be read): "global" is still that image's pixel grid, there is just no element here that is it. Which file that was is in optical_images.
convention_version Schema version; bump when the shape of this attr changes. Currently 1.
produced_by "thyra/<version>" for Thyra-produced zarrs.
raster_to_global_affine Explicit 3x3 row-major affine from TIC coordinates to "global" -- the same mapping the TIC element's transform expresses, duplicated here so a consumer that reads only attrs still gets the full placement. A TIC cell i spans [i, i + 1), so to place pixel i, apply the matrix to its centre i + 0.5. In the micrometer variant that lands on obs["spatial_x"] = i * pixel_size_um_x: the matrix is the pixel size with a translation of minus half a pixel. In the pixel variant it is the optical alignment matrix, and the centre lands on the spot. Purely additive (convention_version stays 1, same reasoning as the z fields below).
coordinate_offsets_px The source's raw acquisition-index offsets [x, y, z], which 0-based normalisation otherwise erases. z is 0 when the source states no z. Only present when the reader reports them.
stage_offset_um Where grid position 0 sits in the source's own frame, in micrometers: coordinate_offsets_px times the pixel size, or, for Waters, the fitted stage origin. The same numbers as acquisition.origin_um. Only written when unit="micrometer", so it cannot be misread in the optical-pixel variant.
z_spacing_um Micrometers between consecutive slices. Only present on multi-slice volumes. Always an absolute micrometer distance, even when unit="pixel": the optical affine governs only x and y, while z is always scaled directly. Slice k is centred on k * z_spacing_um, the row's obs["spatial_z"], so it spans [(k - 0.5), (k + 0.5)) * z_spacing_um. raster_to_global_affine carries no z.
z_spacing_source Where z_spacing_um came from. Only present on multi-slice volumes.

The acquisition system

Readers re-zero the raster: grid position 0 is the first position the source acquired, wherever on the slide that was. So "global" starts every store at 0, and two stores cut from one slide land on top of each other. Two --region runs of one .d are the usual case.

In a store whose "global" is in micrometers, every TIC image, pixel polygon element and optical image placed in micrometers also carries a transform to "acquisition". It is the element's "global" transform followed by a Translation to the source's origin:

Source Origin
Bruker timsTOF, rapifleX, solariX the raster index of grid position 0 (coordinate_offsets_px), times the pitch
imzML, mzPeak the same; a file numbered from 1 has an origin one pitch from 0
PHI 0, which it states
Waters the fitted stage origin, a measured stage position in micrometers

origin_source says which kind it is: "raster_index" or "stage_position". A raster index times the pitch is a position in the source's own grid, not a measured stage position.

On a volume the TIC volume also moves in z, by the z offset times z_spacing_um. Pixel polygons stay flat, as in "global".

The system is not written:

  • in a store placed in a FlexImaging optical scan's pixels (unit="pixel"). There "global" is already the scan's frame, and the alignment affine absorbs the offset;
  • when the source reports no origin, because a zero there would claim one;
  • on an optical image that is not placed relative to the raster, one with no alignment, which sits at Identity.

A dataset_id of "acquisition" is refused, because the per-dataset system is named by the id.

Tables carry no transform, so obs["spatial_x"] and ["spatial_y"] stay in "global". Add origin_um to place them in "acquisition".


The z axis

A 2D store has no slice-to-slice distance, so it carries neither z field. Their absence is the signal that there is no z axis — which is also why adding them did not move convention_version: they are purely additive, and a consumer that never reads volumes sees exactly the schema it already knew.

z_spacing_source is the field that matters:

Value Meaning
"manual" The caller supplied it (--z-spacing / z_spacing_um=). Trust it.
"automatic" The source metadata reported it. Trust it.
"assumed_isotropic" Nobody supplied one. The in-plane pixel pitch was reused, which asserts that slices sit exactly one pixel width apart. Treat the depth as unknown.

Section thickness is set by the microtome that cut the sections; the in-plane pitch is set by the stage that rastered them. The two agree only by coincidence, and for 3D MSI usually do not — often by an order of magnitude. A volume whose z_spacing_source is "assumed_isotropic" has correct voxel values and an unreliable depth, so rendering it in micrometers stretches or squashes the stack along z.

Thyra cannot detect this: imzML has no standard term for slice spacing, and 3D acquisitions are frequently non-consecutive sections, so the value has to be supplied by whoever prepared them.

Consumers that don't know the schema can still open the zarr; they just won't be able to render distances in micrometers without making assumptions. Consumers that do know it (Ousia, the EscDat registration script, this project's tests) read this attr first and trust it.


What "global" is in each Thyra mode

Thyra operates in two modes depending on whether the input came with FlexImaging optical alignment data. The two modes pick different "global" conventions because they have different canonical references.

Mode A: standalone (no optical alignment)

When there is no optical photo to align against, the only naturally meaningful frame is physical micrometers of the imaged tissue.

  • TIC / ion images: stored intrinsically in raster pixel indices, with a transform to "global" that moves each cell back half a step, then scales by [pixel_size_x_um, pixel_size_y_um]. Cell x spans [x, x + 1), so its centre lands on x * pixel_size_x_um.
  • Pixel-polygon shapes: stored intrinsically in micrometers, centred on spatial_x = x * pixel_size_x_um and spatial_y = y * pixel_size_y_um, with Identity to "global". A polygon is the square its TIC cell covers.
  • zarr.attrs["coordinate_systems"]["global"] declares unit="micrometer" and fills pixel_size_um_x/y with the MSI grid pixel size.
  • Every element also carries "acquisition", the same transform moved to the source's origin, when the source states one.

The two pitches are equal on a square raster and are not assumed to be: an anisotropic acquisition (a DESI method with DesiXStep != DesiYStep) scales each axis by its own, here and in every other block of the store that states a pitch.

Both elements resolve to the same micrometer extent at "global". The raster starts at -pixel_size_um / 2, not at 0: position 0 is the centre of the first pixel (D32).

For a multi-slice volume (--handle-3d over more than one plane) the promise holds in x and y, and is deliberately silent in z for the shapes element. The TIC volume carries the depth on its transform, and every table row carries it in obs["spatial_z"]; the pixel polygons stay two-dimensional and make no claim about depth at all.

So a volume's elements agree like this:

Element x, y at "global" z at "global"
TIC volume half a step back, then Scale([pixel_size_x_um, pixel_size_y_um]) half a step back, then Scale([z_spacing_um])
Table spatial_x, spatial_y spatial_z
Pixel shapes micrometres, Identity absent — join via spatial_z

Why the footprints carry no depth

They briefly did. v3.2.0 made them POLYGON Z at the depth of their slice, which is the geometrically honest representation, and it broke spatialdata's spatial queries: a bounding box enclosing an entire test volume returned 26 of 30 footprints and 26 of 30 table rows, silently. A z-restricted query returned the same rows whether z was inside or far outside the data.

Upstream asks for 2D here. ShapesModel.validate warns that a 3-dimensional geometry column "could led to unexpected behaviors" and names force_2d() as the remedy — the query result above is that behaviour. 3D shapes are not on the spatialdata roadmap (#109, idle since June 2023, covers images, labels and transformations), and the live 2.5D discussion (#961) scopes itself to points, images and labels. Serial-section MSI is 2.5D in that taxonomy.

Expressing the depth as a Translation on flat geometry — the obvious remaining alternative — does not work either: the transform silently drops z, leaving a depth that exists only in metadata.

Both negative results are pinned by tests/unit/converters/test_3d_pixel_shapes_z.py. If either starts passing, upstream has changed and this decision should be reopened.

Mode B: with FlexImaging optical alignment

When the input includes a .mis ImageFile and registration landmarks, Thyra picks a different canonical reference: the optical photo itself. The natural frame is then that image's pixel grid, because the photo can be drawn with no transform.

  • TIC / ion images: stored intrinsically in raster pixel indices, with an Affine transform mapping into the optical pixel grid.
  • Pixel-polygon shapes: stored directly in optical pixels, with Identity to "global".
  • Primary optical image: Identity to "global", or a Translation by the crop's origin when the conversion covers one region and the image was cropped to it. The frame is still the whole scan's pixel grid.
  • Other optical images (overview, etc.): a Scale to align with the primary image's pixel grid.
  • zarr.attrs["coordinate_systems"]["global"] declares unit="pixel" with reference_element set to the primary optical image's element key; pixel_size_um_x/y is typically None because FlexImaging photos do not generally carry a um-per-pixel calibration.
  • No "acquisition" system: the scan's pixel grid is already the frame two stores of one slide share.

Mode B with the alignment declined

A caller can pass apply_optical_alignment=False to :func:~thyra.convert_msi on data that has FlexImaging alignment. That is what a downstream tool computing its own MSI-to-target registration wants: the alignment Thyra could apply is not the canonical one, so applying it first would have to be undone.

The result is Mode A -- unit="micrometer", pixel_size_um_x/y filled with the raster pitch, reference_element null, and the pixel-size raster_to_global_affine. The MSI table, the TIC image and the pixel polygons are all in micrometers, and the optical photo is carried in alongside them by the inverse of the alignment affine rather than being the frame everything else is expressed in.

So the alignment matrix existing does not by itself mean the store is in optical pixels; whether it was applied does, and that is what Thyra reads when it writes this attr.

Note that the modes pick the right convention for what is actually known about the data; consumers should look at unit rather than assuming Thyra always uses one or the other.


Reading the coordinate-system metadata

import json
from pathlib import Path

zarr_path = Path("output.zarr")
with open(zarr_path / "zarr.json") as f:
    attrs = json.load(f)["attributes"]

cs = attrs["coordinate_systems"]["global"]
print(f"global unit:        {cs['unit']}")
print(f"pixel size (um):    {cs['pixel_size_um_x']} x {cs['pixel_size_um_y']}")
print(f"reference element:  {cs['reference_element']}")
print(f"schema version:     {cs['convention_version']}")

pixel_size_um_x/y is not a conversion factor, and multiplying a "global" coordinate by it is wrong in both variants. Read unit instead:

  • unit="micrometer" -- "global" already is micrometers, so nothing is applied. pixel_size_um_x/y is the raster pitch, which is what raster_to_global_affine scales the TIC indices by; multiplying a resolved coordinate by it applies that scale a second time.
  • unit="pixel" -- "global" is the primary optical photo's pixel grid, and pixel_size_um_x/y is None. The um-per-pixel calibration of that photo is genuinely unknown to Thyra, so there is no factor to recover from the store; a consumer that needs micrometers here has to supply the calibration itself.

Why two conventions?

Picking unit="micrometer" for everything would be simpler, but also wrong:

  • In Mode B there is a canonical raster image (the optical photo). Putting "global" in micrometers would force that image to carry a transform, since its data is intrinsically in pixels. With unit="pixel", the canonical image carries Identity (or a plain translation, when cropped to one region) and downstream tools can blit it without thinking.
  • In Mode A there is no canonical raster image - the MSI itself is the data. There's no privileged pixel grid to use as "global", so unit="micrometer" is the natural and unambiguous choice.

The rule generalises: "global" is whichever frame is most natural for that dataset's canonical reference, and the metadata declares which one was picked.

This matches how the broader spatial-omics ecosystem works: spatialdata-io's Xenium reader, for example, uses unit="pixel" because Xenium has a canonical morphology image; its MERFISH-style readers use unit="micrometer" because they do not.


Verifying the contract

Because the contract is just metadata, it can drift if a producer has a bug. Every Thyra release has a unit-test guard that:

  1. Runs a representative conversion end-to-end.
  2. Reads the resulting zarr.
  3. Asserts the bbox of the TIC image and the pixel-polygon shapes resolve to the same "global" extent, and that each TIC cell's centre lands on its row's obs position.
  4. Asserts the coordinate_systems.global attr is present and self-consistent.

See tests/unit/converters/test_coordinate_systems.py for the extents, test_anisotropic_pixel_size.py and test_3d_tic_z_spacing.py for each cell's centre, and test_acquisition_coordinate_system.py for the "acquisition" system.

A consumer can run the same check at load time. The recommended pattern is to compute the bbox at "global" for every element and warn if any pair differs by more than ~10x; that catches the Scale(1/pixel_size) vs identity unit-confusion class of bug without flagging legitimate small-vs-large element pairs (e.g. a small ROI within a full-tissue dataset).


Cross-modality registration

When a Thyra zarr is later registered against another modality (e.g. Xenium via the EscDat registration pipeline), the registration step rewrites every Thyra element's transform to "global" so that the merged zarr's "global" matches the other modality's convention. The merged zarr's coordinate_systems.global attr is rewritten to declare the new unit and reference element.

That is, the coordinate-system contract is per-zarr, not per-element-or-modality. A standalone Thyra zarr can have unit="micrometer" while a merged Thyra+Xenium zarr produced from the same data will have unit="pixel". Both are correct for their respective zarrs.


What if I open a zarr that doesn't have this attr?

Two kinds of Thyra output lack the coordinate_systems attr, and the second is more recent than it looks:

  • Anything written before v1.22.0, which introduced the schema.
  • Anything written between v1.22.0 and v2.3.1 by the streaming route, which in those releases was one of four converters and hand-wrote its own Zarr root attributes. Its copy was short: 7 attrs against the 10 the other three produced, missing coordinate_systems, format_specific_metadata and msi_dataset_info. Fixed in v3.0.0. This is the awkward one, because that route was chosen by estimated size -- so the biggest datasets are exactly the ones that lost it, and nothing in the store records which route wrote it. There is no live setting to check: the four converters became one in v3.23 (design decision D11) and every store written since v3.0.0 carries the attr.

Either way the store is still readable, but you have to infer the convention from element layouts and accept some risk that two elements disagree silently.

If you control the data: regenerate. The schema is cheap to write and the resulting zarrs are self-describing forever after. If you do not control the data, the best fallback is to write a producer-specific heuristic (see Ousia's coordinate-system loader for an example: it recognises Xenium-from-spatialdata-io zarrs by their morphology_focus image and assumes Xenium's documented pixel size).