Skip to content

Spatial: Xenium with tissue-image overlay

This tutorial runs 10x Genomics’ public Xenium human breast cancer dataset end to end: 167,780 segmented cells × 313 genes, clustered and drawn on the DAPI morphology image — with the image, the coordinates, and their spatial index all stored inside one .cytome file. Requires cytome >= 0.2.6 and piaso >= 1.2.2; reading the OME-TIFF morphology once needs tifffile + imagecodecs.

Two routes to the same file. This page builds an AnnData, clusters it, and converts the result. The other route imports the 10x matrix straight into a cytome and never holds it in memory — Xenium, cytome-native. Use this one when the analysis is scanpy-shaped; use that one when the section is large or is one of many.

1. Download (10x public data, ~660 MB total)

Terminal window
B=https://cf.10xgenomics.com/samples/xenium/1.0.1/Xenium_FFPE_Human_Breast_Cancer_Rep1/Xenium_FFPE_Human_Breast_Cancer_Rep1
curl -O ${B}_cell_feature_matrix.h5
curl -O ${B}_cells.csv.gz
curl -O ${B}_morphology_mip.ome.tif # DAPI maximum-intensity projection

2. Cells, coordinates, clustering

import numpy as np
import pandas as pd
import piaso
adata = piaso.pp.read_10x_h5("Xenium_FFPE_Human_Breast_Cancer_Rep1_cell_feature_matrix.h5")
adata.var_names_make_unique()
cells = pd.read_csv("Xenium_FFPE_Human_Breast_Cancer_Rep1_cells.csv.gz")
cells["cell_id"] = cells["cell_id"].astype(str) # h5 obs_names are strings
cells = cells.set_index("cell_id").loc[adata.obs_names]
adata.obsm["spatial"] = cells[["x_centroid", "y_centroid"]].to_numpy() # microns
adata = adata[np.asarray(adata.X.sum(1)).ravel() >= 10].copy() # light QC
piaso.tl.infog(adata, n_top_genes=2000)
piaso.tl.runSVD(adata, layer="infog", n_components=30, key_added="X_svd")
piaso.tl.neighbors(adata, use_rep="X_svd", n_neighbors=15)
piaso.tl.leiden(adata, resolution=1.0)
piaso.settings.set_figure_params(style="cell") # one house style across every figure

164,000 cells and 29 clusters in under a minute.

3. The morphology image, at overlay resolution

The OME-TIFF is pyramidal; one mid-pyramid level (~3,200 × 4,400 px) is plenty for overlays. The one number that must be right is the scale factor: Xenium coordinates are microns, the full-resolution image is 0.2125 µm/px, and the pyramid level is a further downsampling — so scalef = level_scale / 0.2125 converts a micron coordinate to a stored pixel.

import tifffile
with tifffile.TiffFile("Xenium_FFPE_Human_Breast_Cancer_Rep1_morphology_mip.ome.tif") as tf:
level = next(i for i, l in enumerate(tf.series[0].levels)
if max(l.shape[:2]) <= 8000)
img = tf.series[0].levels[level].asarray()
full_w = tf.series[0].levels[0].shape[1]
scalef = (img.shape[1] / full_w) / 0.2125 # micron -> stored pixel
p99 = np.percentile(img, 99) # display-normalise to uint8
img8 = np.clip(img.astype(np.float32) / p99 * 255, 0, 255).astype(np.uint8)

4. One file: matrix + coordinates + index + image

import cytome
ds = cytome.from_anndata(adata, output="xenium_breast.cytome")
# from_anndata already stored obsm['spatial'] as the `spatial` embedding AND
# built the R*-tree coordinate index. Add the image:
ds.add_spatial_image("xenium_rep1", "morphology", img8,
scalefactors={"tissue_morphology_scalef": scalef,
"spot_diameter_fullres": 10.0})

The array is stored losslessly (raw + zstd) inside the same SQLite file — this dataset lands at ~1.1 GB all-in, and the image travels with the data.

5. Clusters on the tissue

piaso.pl.plotEmbedding(ds, color="leiden", basis="spatial",
image=True, img_key="morphology",
point_size=1.2, legend_loc="right")
Xenium clusters on morphology

The cyan cluster traces the ductal boundaries — on a breast panel that is the myoepithelial signature, and having the DAPI behind the cells is what makes it readable. Orientation and units are handled for you: the image is drawn in coordinate space, so nothing needs flipping or scaling.

Per-cluster panels over the same tissue:

piaso.pl.plot_embeddings_split(ds, color="leiden", splitby="leiden",
basis="spatial", image=True,
img_key="morphology", ncol=5)
Each cluster over the morphology image

Twenty-nine panels, each one cluster over the same DAPI. This is the view that separates a cluster with a place from one without: ductal epithelium, stroma and immune infiltrate each occupy their own territory, while several clusters are scattered through the section and are telling you about state rather than location.

6. Regions of interest: cells and pixels from the same rectangle

cells_in_region is an indexed R*-tree lookup; spatial_images.crop cuts the matching pixels — both take the same coordinate ranges (microns here):

roi_x, roi_y = (cx - 400, cx + 400), (cy - 400, cy + 400) # an 800 um window
cells_in = ds.cells_in_region(x=roi_x, y=roi_y) # -> 2,599 cells
sub, info = ds.spatial_images.crop("xenium_rep1", "morphology",
x=roi_x, y=roi_y)
import matplotlib.pyplot as plt
fig, ax = plt.subplots()
ax.imshow(sub, extent=info["extent"], origin="upper", cmap="gray")
xy = ds.embeddings["RNA_spatial"]
ax.scatter(xy[cells_in, 0], xy[cells_in, 1], s=4)
ROI: indexed cells + cropped morphology

info["extent"] is already in your coordinate units and already ordered for origin='upper', so the crop and the cells line up with no arithmetic.