Coordinate systems
The problem: multiple pixel spaces
Instruments like CosMx and MERFISH acquire data field-of-view by field-of-view. Each FOV has its own pixel coordinate space. A single slide may contain hundreds of FOVs, each offset and (for some instruments) rotated relative to the others. Stitching them into a global coordinate space requires a per-FOV affine transformation.
A naïve approach would stitch immediately: transform all coordinates into a single global frame at load time, then discard the FOV structure. This works for small datasets but fails at scale — you cannot analyse a single FOV independently once its coordinates have been collapsed into global space, and global-pixel images are far too large to hold in memory.
Named coordinate systems as a graph
SpatialOmics models coordinate spaces explicitly as a directed acyclic graph (DAG). Each node is a CoordinateSystem with a name, axis labels, and units. Each edge is an AbstractTransformation carrying src and dst coordinate system names.
"fov_1_pixel" ──Affine──► "fov_1" ──Affine──► "global"
"fov_2_pixel" ──Affine──► "fov_2" ──Affine──► "global"
...Every element (SpatialPoints, SpatialShapes, etc.) stores a coord_system name. That name places the element at a node in the graph. Elements at different nodes are not directly comparable — the package raises an error rather than silently misaligning data.
Transformation primitives
Affine transformations in 2-D are represented as 3×3 augmented matrices in homogeneous coordinates. This representation lets rotation, scaling, shear, and translation be encoded uniformly, and lets sequential transforms be fused by matrix multiplication.
The constructor helpers — translation, scaling, rotation, flip_y — each produce an Affine with explicit src and dst names:
t = compose(
scaling(0.325, 0.325, "pixel", "fov_1"), # pixel size in µm
translation(1024.0, 768.0, "fov_1", "global"),
)
push!(ds, CoordinateSystem("pixel"))
push!(ds, CoordinateSystem("fov_1"))
push!(ds, CoordinateSystem("global"))
push!(ds, t)compose fuses two Affine transforms into one (matrix product), or wraps mixed types in a Sequence. The src/dst chain must be consistent — a.dst == b.src is enforced.
Path resolution
transform(ds, "pixel", "global") calls resolve, which performs BFS over the transform graph and returns a composed transformation from source to destination. Affine edges are traversed in both directions (the inverse is computed automatically); Identity edges are bidirectional by construction.
If the graph has no path between the requested systems, resolve raises an error listing the known edges — a much clearer signal than a silent wrong answer.
Why not CoordinateTransformations.jl?
CoordinateTransformations.jl is a general-purpose library for function-based transforms. SpatialOmics uses its own Affine type for two reasons: (1) the augmented-matrix representation enables O(1) fusion via compose, which matters when resolving paths through multi-hop graphs at load time; (2) every transformation carries explicit src and dst names, making the graph structure first-class rather than implicit in calling code.