| name | spatialdata-squidpy |
| description | Spatial omics workflows with SpatialData and Squidpy alongside scanpy, anndata, and napari-viz. Use when working with Visium, Xenium, CosMx, MERFISH, Slide-seq, spatial transcriptomics, spatial proteomics, tissue images linked to AnnData, spatial neighbor graphs, spatial autocorrelation, ligand-receptor proximity, image features, or napari-spatialdata. |
SpatialData + Squidpy
Use this skill for spatial omics analysis in the scverse ecosystem. Prefer it when a task combines expression matrices, spatial coordinates, segmentation labels, microscopy/pathology images, or platform-specific outputs from 10x Visium/Xenium, NanoString CosMx, Vizgen MERFISH, Slide-seq, or related assays.
Reference versions: spatialdata 0.8.0, spatialdata-io 0.7.1, squidpy 1.8.3.
pip install spatialdata spatialdata-io squidpy
Routing
- Use
scanpy for ordinary scRNA-seq preprocessing and clustering.
- Use
anndata when the main issue is object structure, layers, backed mode, or h5ad I/O.
- Use this skill when spatial coordinates, images, regions, or spatial statistics matter.
- Use
napari-viz for headless rendering/inspection of microscopy volumes or labels.
The SpatialData Object
A SpatialData object holds five element dicts plus a set of named coordinate systems. Elements are not merged into one array; they are co-registered by transformations.
| Attribute | Holds | Type per element |
|---|
sdata.images | microscopy / H&E rasters | DataArray (single scale) or DataTree (multiscale) |
sdata.labels | segmentation masks | DataArray / DataTree, integer-valued |
sdata.points | transcript locations | dask DataFrame |
sdata.shapes | circles / polygons | GeoDataFrame |
sdata.tables | expression + obs | AnnData |
sdata.coordinate_systems | registered systems | list[str], "global" by default |
A table is linked to the element it annotates by adata.uns["spatialdata_attrs"]: region (element name), region_key (obs column naming the region), instance_key (obs column matching label ids / shape index). sdata.tables is a dict — a SpatialData object can carry several tables annotating different elements.
Reading Platform Data
spatialdata_io readers return a SpatialData object directly. Every reader takes the run directory as its first argument.
import spatialdata_io as sdio
sdata = sdio.xenium("path/to/xenium_run")
sdata = sdio.visium("path/to/visium_out")
sdata = sdio.visium_hd("path/to/vishd_out", bin_size=[8, 16])
sdata = sdio.merscope("path/to/merscope_out")
sdata = sdio.cosmx("path/to/cosmx_out")
Other readers: codex, curio, dbit, macsima, mcmicro, seqfish, steinbock, stereoseq, plus generic / image / geojson for loose files and xenium_aligned_image(image_path, alignment_file) for post-hoc aligned images.
Reading a platform run is slow and parses many files. Convert once to Zarr, then read the Zarr from then on:
import spatialdata as sd
sdata.write("run.zarr")
sdata = sd.read_zarr("run.zarr")
sdata = sd.read_zarr("run.zarr", selection=("images", "tables"))
After adding an element to a Zarr-backed object, sdata.write_element("clusters") persists just that element. Rewriting an element that Dask is already backing from the same store fails — write the object to a new path instead.
Coordinate Systems and Transformations
This is where spatial analyses go wrong. Elements do not share one pixel grid — each carries a transformation per coordinate system, and the system decides what units your coordinates are in.
from spatialdata.transformations import (
get_transformation, set_transformation, Identity, Scale, Translation, Affine, Sequence,
)
get_transformation(sdata.images["morphology_focus"])
get_transformation(sdata.images["morphology_focus"], get_all=True)
set_transformation(
sdata.images["morphology_focus"],
Scale([0.2125, 0.2125], axes=("y", "x")),
to_coordinate_system="microns",
)
print(sdata.coordinate_systems)
Check the physical extent before overlaying anything:
sd.get_extent(sdata, coordinate_system="global")
sdata.transform_element_to_coordinate_system("morphology_focus", "microns") takes the element name and returns the element with coordinates actually resampled; sdata.filter_by_coordinate_system("microns") drops elements not registered there.
Cropping and Querying
crop = sd.bounding_box_query(
sdata,
axes=("y", "x"),
min_coordinate=[0, 0],
max_coordinate=[256, 256],
target_coordinate_system="global",
)
Every element in crop is cut consistently and the table is filtered to the surviving instances (filter_table=True by default). sd.polygon_query(...) does the same for an arbitrary shape; both are also reachable as sdata.query.bounding_box(...) / sdata.query.polygon(...).
Linking Tables to Elements
values = sd.get_values("channel_0_sum", sdata=sdata, element_name="blobs_labels", table_name="table")
elements, table = sd.join_spatialelement_table(
sdata=sdata, spatial_element_names="blobs_labels", table_name="table", how="left"
)
Attaching a new table requires the annotation metadata, or it will not write:
from spatialdata.models import TableModel
adata = TableModel.parse(
adata, region="cell_labels", region_key="region", instance_key="cell_id"
)
sdata.tables["clusters"] = adata
Spatial Graphs (Squidpy)
sq.gr.spatial_neighbors is deprecated since squidpy 1.7.0 and is removed in 1.9.0. Use the mode-specific builders. Each takes an AnnData or a SpatialData — with a SpatialData, name the element and the table:
import squidpy as sq
sq.gr.spatial_neighbors_knn(
sdata,
elements_to_coordinate_systems={"cell_labels": "global"},
table_key="table",
n_neighs=6,
)
sq.gr.spatial_neighbors_grid(adata, n_neighs=6, n_rings=2)
sq.gr.spatial_neighbors_radius(adata, radius=30.0)
sq.gr.spatial_neighbors_delaunay(adata)
Grid mode assumes an approximately regular lattice — on a Xenium point cloud its ring distances are meaningless. Multi-slide objects need library_key= so edges are not built across slides. Results land in table.obsp["spatial_connectivities"] and table.obsp["spatial_distances"].
Spatial Statistics
sq.gr.spatial_autocorr(sdata, table_key="table", mode="moran", n_perms=100, seed=0)
sq.gr.spatial_autocorr(sdata, table_key="table", mode="geary")
sq.gr.nhood_enrichment(sdata, cluster_key="cluster", table_key="table", n_perms=1000, seed=0)
sq.gr.co_occurrence(sdata, cluster_key="cluster", table_key="table", interval=50)
sq.gr.ripley(adata, cluster_key="cluster", mode="L")
sq.gr.ligrec(adata, cluster_key="cluster")
spatial_autocorr already applies corr_method="fdr_bh"; read pval_norm_fdr_bh / pval_sim_fdr_bh, not the raw p-values. nhood_enrichment and spatial_autocorr are permutation tests — set seed= or results are not reproducible.
Plotting: sq.pl.nhood_enrichment, sq.pl.co_occurrence, sq.pl.centrality_scores, sq.pl.interaction_matrix, sq.pl.ripley, sq.pl.ligrec, sq.pl.spatial_scatter, sq.pl.spatial_segment, sq.pl.var_by_distance.
Core Workflow
- Read the run with the platform reader, then
sdata.write("run.zarr") once and work from the Zarr.
- Inspect
print(sdata) — element names, shapes, and the coordinate systems each is registered in.
- Confirm the units.
get_transformation(..., get_all=True) and sd.get_extent(...) before any overlay or radius.
- Preprocess the table with Scanpy (QC, normalize, HVGs, PCA, neighbors, UMAP, clustering) — expression preprocessing is not spatial.
- Build the spatial graph with the mode that matches the geometry (grid for Visium, kNN/Delaunay for cell-resolved).
- Run the spatial statistics, with
seed= set.
- Crop with
bounding_box_query / polygon_query rather than slicing arrays by hand.
- Write results back:
sdata.write(...) for the whole object, sdata.write_element(...) for a newly added element.
Quality Checks
- Confirm whether coordinates are pixels, microns, array indices, or platform-specific units; a radius in the wrong system is silently wrong, not an error.
- Check image origin and axis orientation before overlaying labels or points —
sd.get_extent per coordinate system.
region / region_key / instance_key must be set on every table, or the table will not write and cannot be joined to its element.
- Keep tissue masks and segmentation labels versioned; most downstream errors come from silent coordinate or mask mismatch.
- Stratify spatial statistics by sample/slide (
library_key=); do not pool slides unless batch and geometry are comparable.
- Report neighborhood graph parameters — mode,
n_neighs/radius/n_rings, coordinate system — because they strongly affect spatial-enrichment calls.
- Permutation-based results (
nhood_enrichment, spatial_autocorr, ligrec) need seed= and n_perms= reported.