AnnData, Scanpy and SpatialData¶
truecell converts to and from AnnData, so an object
can move into Scanpy, Squidpy or SpatialData and back. The two functions live on
truecell.compat.anndata, not on the top-level package, and need the anndata extra.
from truecell.compat.anndata import as_anndata, from_anndata
adata = as_anndata(obj) # Truecell → AnnData
obj = from_anndata(adata, assay="RNA") # AnnData → Truecell
truecell stores features × cells, as Seurat does, and AnnData stores cells × features. The conversion transposes for you, so don't transpose again by hand.
What goes where¶
| truecell | AnnData |
|---|---|
| the assay's default layer | X |
| its other layers | layers |
meta_data, and the active identity |
obs, and obs["ident"] |
| the assay's feature metadata | var |
| a reduction's embeddings and loadings | obsm["X_<name>"] and varm["<NAME>s"] |
graphs |
obsp |
misc |
uns |
each cell's position in images |
obsm["spatial"] |
| the image each cell was placed from | obs["fov"] |
a VisiumV2's tissue image and scale factors |
uns["spatial"][image] |
from_anndata reverses the table, with X as the counts layer. Pass the assay name
on the way back, because it is not read from uns.
An AnnData layer is as wide as var, so a layer with fewer features than the assay
stays behind, and as_anndata warns and names it. That is scale.data after
scale_data()'s default, which scales only the variable features. To keep it, scale
every feature first: scale_data(obj, features=obj.feature_names()).
Space¶
The spatial fields follow the layout Scanpy and Squidpy read.
obsm["spatial"]holds one (x, y) per cell, row for row withobs_names, from the first image that places the cell. The point comes from the image'sCentroids. An image with only aSegmentationgives each cell the centroid of its ring, the point SeuratObject'sas(segmentation, "Centroids")gives, which truecell has asSegmentation.as_centroids(). A cell that no image places is NaN.obs["fov"]is a categorical naming each cell's image, in the order ofobj.images. Whenmeta_dataalready has an FOV column that names the images, as CosMx and MERSCOPE objects do, it is written the same way.uns["spatial"][image]holds a Visium image'simages, under the resolution it was loaded at, and itsscalefactors, underscalefactors_json.json's keys.
Visium spot positions stay in full-resolution pixels, column first. On 10x's V1
mouse-brain slide as_anndata(load_visium(...)) and scanpy.read_visium agree for all
2,695 spots: the same obsm["spatial"] to the bit and in dtype, the same scale factors
and the same lowres image. Scanpy's spatial plots therefore work unchanged:
import scanpy as sc
import truecell
adata = as_anndata(truecell.load_visium(path))
sc.pl.spatial(adata, img_key="lowres", color="Hpca")
The other direction works too. from_anndata turns a file scanpy.read_visium wrote
into a VisiumV2 named after its library, and on that slide its coordinates, image and
scale factors match load_visium's. When the file has both images, image_resolution=
chooses which one to keep.
obj = from_anndata(sc.read_visium(path), assay="Spatial")
truecell.normalize_data(obj)
truecell.spatial_feature_plot(obj, feature="Hpca")
AnnData has nowhere to put cell polygons or transcript molecules, so those stay
behind. So does a second image of cells another image already placed, such as a crop,
and a radius other than the automatic one for the cells placed, which comes back as
the automatic one. Everything else survives: objects from load_xenium, load_visium,
load_cosmx and load_merscope return from an .h5ad file with their coordinates
bit-identical, cells and images in order, and Visium's image and scale factors
unchanged.
CosMx and MERSCOPE objects need one extra argument. Their loaders build one image, as
Seurat's do, and keep the platform's own fov column in meta_data, which does not
name that image. as_anndata keeps the column under obs["fov"] and warns, so pass
fov_key="image", or any free name, to both calls:
adata = as_anndata(truecell.load_cosmx(path), fov_key="image")
obj = from_anndata(adata, assay="Nanostring", fov_key="image")
SpatialData¶
SpatialData keeps shapes and images as elements, with the expression in a table that
annotates one of them. truecell has no to_spatialdata, which would pull spatialdata's
dependency stack into the package. The recipes below build the same objects from an
as_anndata result. They were run with spatialdata 0.8.0 on the Xenium mouse-brain
section (36,602 cells) and the Visium slide above, written to zarr and read back. The
coordinates, polygons, expression and tissue image all came back identical.
Cells as circles¶
import numpy as np
import pandas as pd
import spatialdata as sd
from spatialdata.models import ShapesModel, TableModel
from truecell.compat.anndata import as_anndata
adata = as_anndata(obj)
adata = adata[~np.isnan(adata.obsm["spatial"]).any(axis=1)].copy() # placed cells only
radius = next(iter(obj.images.values())).boundaries["centroids"].radius()
cells = ShapesModel.parse(adata.obsm["spatial"], geometry=0,
radius=np.full(adata.n_obs, radius),
index=adata.obs_names.to_numpy())
adata.obs["region"] = pd.Categorical(["cells"] * adata.n_obs)
adata.obs["instance_id"] = adata.obs_names.to_numpy()
table = TableModel.parse(adata, region="cells", region_key="region",
instance_key="instance_id")
sdata = sd.SpatialData(shapes={"cells": cells}, tables={"table": table})
sdata.write("section.zarr")
With several images, the cells of all of them go into the one shapes element, and
obs["fov"] still says which image each cell came from.
Cell polygons¶
import geopandas as gpd
import shapely
segmentation = obj.images["fov"].boundaries["segmentation"]
rings = segmentation.get_tissue_coordinates().reset_index(drop=True)
polygons = gpd.GeoDataFrame(
geometry=[shapely.Polygon(ring[["x", "y"]].to_numpy())
for _, ring in rings.groupby("cell", sort=False)],
index=segmentation.cells(),
)
boundaries = ShapesModel.parse(polygons)
boundaries goes into shapes= next to the circles.
Visium spots over the tissue image¶
from spatialdata.models import Image2DModel
from spatialdata.transformations import Scale
adata = as_anndata(obj)
slide = obj.images["slice1"]
f = slide.scale_factor() # full-resolution pixels → pixels of the stored image
tissue = Image2DModel.parse(
np.moveaxis(slide.get_image(), -1, 0), dims=("c", "y", "x"),
transformations={"global": Scale([1 / f, 1 / f], axes=("y", "x"))},
)
spots = ShapesModel.parse(adata.obsm["spatial"], geometry=0,
radius=np.full(adata.n_obs, slide.radius()),
index=adata.obs_names.to_numpy())
adata.obs["region"] = pd.Categorical(["spots"] * adata.n_obs)
adata.obs["instance_id"] = adata.obs_names.to_numpy()
table = TableModel.parse(adata, region="spots", region_key="region",
instance_key="instance_id")
sdata = sd.SpatialData(images={"tissue": tissue}, shapes={"spots": spots},
tables={"table": table})
The spots stay in full-resolution pixels and the Scale moves the image into the
same space. On the slide above, every spot lands exactly where
slide.scale_coordinates() puts it on the image. slide.radius() is half of
spot_diameter_fullres, a true radius where Seurat stores the diameter; the
Spatial reference explains why.