Skip to content

Spatial Alignment

SMINT aligns spatial omics modalities in two quite different situations, and picking the right one matters more than any parameter you will tune afterwards.

Situation What is true Use
Sequential sections — the modalities come from different physical sections The tissue genuinely differs between them; no cell corresponds one-to-one STalign LDDMM (register_sm_to_st)
Same section, post-staining — a second modality is acquired on the exact same section Centroids really do correspond one-to-one Correspondence fitting (register_centroids)

Using the same-section tools on sequential sections will happily produce a number, but it will be fitting noise: nearest-neighbour "correspondences" between different sections pair cells that are merely nearby, not the same.


Coarse pre-registration

Spatial metabolomics arrives on its own pixel grid, frequently rotated or mirrored relative to the Xenium section and at a completely different scale. LDDMM will not recover from a gross orientation mismatch, so bring the datasets roughly together first.

from smint.alignment import build_pretransform, apply_pretransform, overlap_score

matrix = build_pretransform(
    sm_xy, xen_xy,
    scale_mode="extent",   # match bounding-box spans
    rotation=30,           # degrees counter-clockwise
    flip_x=True,
)
moved = apply_pretransform(sm_xy, matrix)
print(overlap_score(moved, xen_xy))     # 0-1, higher is better

Operations are applied in a fixed order — flip → rotate → scale → translate — about the source centroid. The order is fixed because rotations and flips do not commute.

Scale is fitted after orientation is corrected

Bounding-box extents change under rotation, so fitting scale on the raw source bakes in an error. On a 30° rotated test shape, fitting first gave 8.10 against a true 10.0. build_pretransform handles this for you by applying flip and rotation before fitting.

scale_mode options:

  • "extent" (default) — match the bounding-box span per axis. Robust to the two datasets having different origins.
  • "max" — match maximum coordinates directly. Literal, but wrong whenever either dataset does not start near zero.
  • "none" — no scaling.

Scaling is isotropic by default (preserve_aspect=True). Anisotropic scaling can force bounding boxes to agree while making the shapes match worse, which then misleads the landmark step.

Use describe_pretransform(matrix) to decompose a transform into scale, rotation, shear and whether it reflects — a negative determinant means a reflection crept in, which is easy to introduce by combining a flip with a rotation.


ST-SM registration

For sequential sections, using STalign's LDDMM. The workflow is deliberately split into two phases around a manual landmark step, because that step is where the biological judgement lives.

Phase 1 — prepare landmark inputs

from smint.alignment import prepare_landmark_inputs

out = prepare_landmark_inputs(
    st_file="Z2_final_aligned_annos.csv",
    sm_file="Ven5B_information_matrix_tissue_only_full_with_xy.csv",
    output_prefix="/path/to/ven5_z2",
    dx=30.0,
    sm_kwargs=dict(scale_xy=10.0, rotate_left_90=True),
)

This writes <prefix>_st.npz and <prefix>_sm.npz.

Phase 2 — annotate, then register

Annotate both .npz files, picking the same anatomical features in the same order in each — correspondence is positional, so an ordering mismatch silently produces a wrong registration. Either use the napari plugin or the standalone annotator:

python point_annotator.py ven5_z2_st.npz ven5_z2_sm.npz

That writes <prefix>_st_points.npy and <prefix>_sm_points.npy. Then:

from smint.alignment import register_sm_to_st

df = register_sm_to_st(
    st_file=..., sm_file=...,
    st_points_file=out["st_points"],
    sm_points_file=out["sm_points"],
    output_path="sm_transformed.csv",
    niter=1000, epV=200.0,
)

Coordinate orientation

STalign works in row-column order throughout. LDDMM takes grids as [Y, X], and transform_points_source_to_target both accepts and returns points as (row, col) — it applies the transform in place and does not transpose.

Registering through STalign therefore returns coordinates whose axes are transposed relative to the source dataset. transform_points exposes this:

  • orientation="dataset" (default) — restores the original dataset's orientation. This is what you want, and it reproduces historical outputs.
  • orientation="stalign" — true xy in the ST target frame.

The two differ by a transpose. Mixing them silently produces a flipped overlay, so the choice is always explicit and is recorded on the output frame.


Centroid registration

For post-staining on the same section, where centroids correspond one-to-one.

from smint.alignment import register_centroids

result = register_centroids(
    nucleus_xy, xenium_xy,
    method="ransac+tps",
    max_distance=100.0,
    validation_fraction=0.25,
)
print(result["tre_validation"])    # the number that matters
Method What it does
affine Least-squares affine on all matched pairs
ransac RANSAC-robust affine; rejects bad correspondences
tps Thin-plate spline after an affine pre-alignment
ransac+tps RANSAC affine, then TPS on the inliers

Measuring quality honestly

Target Registration Error is easy to compute in a way that is circular. Correspondences here are established by nearest neighbour, so a sufficiently flexible transform can drive TRE to ~0 on the pairs it was fitted to, regardless of whether those correspondences are correct. An unregularised TPS with as many control points as pairs will always report TRE ≈ 0 on those pairs — that is interpolation, not accuracy.

register_centroids therefore always holds out a fraction of pairs and reports TRE on both:

tre_initial      before any transform
tre_fit          on the pairs used for fitting     <- not evidence
tre_validation   on held-out pairs                 <- the real number

A large gap between the two means the transform is memorising correspondences. The function warns when it detects this.

Matching is mutual by default

mutual=True keeps only pairs that are each other's nearest neighbour. One-directional matching lets many source points collapse onto a single popular target, inflating the pair count. On a real Venture 5 section this was the difference between 75,146 apparent pairs and 40,135 real ones.

Interpreting the result

If no method beats the initial TRE, the datasets are already as aligned as a global transform can make them, and the remaining distance is correspondence ambiguity rather than misregistration. Two useful diagnostics:

  • Compare against the target's own inter-cell spacing. If the residual is a large fraction of that, many "matches" are neighbouring cells rather than the same cell.
  • Sweep max_distance. If TRE shrinks in proportion rather than reaching a plateau, the threshold is setting the residual, not alignment error.

Running on HPC

Registration can be submitted to SLURM or run locally through a job spec; see HPC Deployment and the napari plugin.

from smint.alignment.jobs import JobSpec, SlurmResources, submit, poll

spec = JobSpec(
    round="st_sm",
    inputs={"st_file": ..., "sm_file": ..., "st_points": ..., "sm_points": ...},
    params={"niter": 1000},
    output_dir="/vast/scratch/you/run1",
    backend="sbatch",
    resources=SlurmResources(partition="gpuq", gpus=1, memory="64G"),
)
info = submit(spec)
print(poll(spec.output_dir)["state"])

Batch jobs need shared storage

An sbatch job runs on a different machine, so node-local paths (/tmp, /var/tmp, /dev/shm) are invisible to it — the job dies with no logs at all, because the log directory does not exist there. JobSpec.validate() rejects these up front. Use /vast/scratch or /vast/projects.

GPU

LDDMM runs on GPU automatically when one is available. Request it with SlurmResources(partition="gpuq", gpus=1). GPU and CPU results are bit-identical; on a Venture 5 Z2 section the GPU run took 82 s against 112 s on 8 CPU cores, so the benefit is modest at typical raster sizes.


Environments

STalign requires numpy < 2 — the nptyping dependency it pulls in via nrrd uses np.object0, which numpy 2 removed. The centroid and pre-registration paths have no STalign dependency and run anywhere.

If stalign_available() returns False, check numpy first:

from smint.alignment import stalign_available
print(stalign_available())