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 underzarr.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]. Cellxspans[x, x + 1), so its centre lands onx * pixel_size_x_um. - Pixel-polygon shapes: stored intrinsically in micrometers, centred
on
spatial_x = x * pixel_size_x_umandspatial_y = y * pixel_size_y_um, withIdentityto"global". A polygon is the square its TIC cell covers. zarr.attrs["coordinate_systems"]["global"]declaresunit="micrometer"and fillspixel_size_um_x/ywith 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
Affinetransform mapping into the optical pixel grid. - Pixel-polygon shapes: stored directly in optical pixels, with
Identityto"global". - Primary optical image:
Identityto"global", or aTranslationby 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
Scaleto align with the primary image's pixel grid. zarr.attrs["coordinate_systems"]["global"]declaresunit="pixel"withreference_elementset to the primary optical image's element key;pixel_size_um_x/yis typicallyNonebecause 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/yis the raster pitch, which is whatraster_to_global_affinescales 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, andpixel_size_um_x/yisNone. 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. Withunit="pixel", the canonical image carriesIdentity(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", sounit="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:
- Runs a representative conversion end-to-end.
- Reads the resulting zarr.
- 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'sobsposition. - Asserts the
coordinate_systems.globalattr 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_metadataandmsi_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).