Xenium Spatial Tutorial — R Seurat vs Truecell (Python)¶
A step-by-step spatial analysis of a 10x Genomics Xenium dataset, with every R Seurat call paired with the equivalent Truecell Python code and both outputs shown side by side. This mirrors the spatial mast-cell / neighbourhood workflow used on internal celiac Xenium data — reproduced here on a fully public section so it can be shared.
Dataset: Xenium mouse brain, coronal CTX + HP subset — 10x Genomics (the same section featured in Seurat's spatial vignette). 36,602 cells × 248 genes, single FOV. Auto-downloads (~20 MB).
R reference: Seurat 5.5.1 · Python: Truecell v0.9.0
This tutorial exercises the spatial Seurat-parity layer added to Truecell:
load_xenium · get_tissue_coordinates · image_dim_plot ·
image_feature_plot · nearest_neighbor_distance · local_neighborhood ·
build_niche_assay · composition_test.
How faithful is the port? Every deterministic step below (cell counts, marker-defined cell types, spatial nearest-neighbour distances, local density, the region composition test) matches R Seurat to 8 significant figures — see the parity table at the end. Clustering / UMAP / niche layout are stochastic and agree in structure only, exactly as in the PBMC 3k tutorial.
Setup¶
Step 1 · Load the Xenium output¶
Key difference:
LoadXenium/load_xeniumboth build the expression assay and a populatedimagesslot (per-FOV centroids). Only the 248Gene Expressionfeatures go into the assay — the Negative-Control Probe / Codeword / Blank features are dropped by default in both.
Step 2 · QC filter¶
| R (Seurat) | Python (Truecell) |
|---|---|
![]() |
![]() |
Post-filter QC per cell type (
nCount_Xenium/nFeature_Xenium); the cell types come from Step 3.subset()filters the assay, metadata and the spatial centroids together, so every downstream spatial function stays aligned.
Step 3 · Cell types from marker panels¶
A deterministic classifier: each cell is assigned to the cell type whose
canonical marker panel it expresses most (arg-max of summed raw counts). Because
it is a pure function of the identical count matrix, R and Python produce
byte-identical labels — the anchor for every spatial comparison that follows
(the same idea as the internal KIT+ TPSAB1+ mast-cell rule).
Identical counts in both:
| cell_type | n | cell_type | n | |
|---|---|---|---|---|
| Excitatory | 18,044 | Oligodendrocyte | 3,570 | |
| Astrocyte | 5,513 | Vascular | 3,016 | |
| Inhibitory | 3,759 | Immune | 1,311 | |
| OPC | 1,157 | Other | 49 |
Step 4 · Normalise, cluster, UMAP (unsupervised view)¶
Top row: the UMAP coloured by cell type. Bottom row: the raw unsupervised clusters drawn back in tissue space (
ImageDimPlotby cluster) — the cortical layers and hippocampal structure fall out on their own. Louvain clustering is stochastic and the two UMAP libraries (uwotvsumap-learn) place clusters differently — R found 20 clusters, Truecell 18 — but the same spatial structure separates cleanly in both (see the PBMC 3k note).
Step 5 · Cells in tissue space — ImageDimPlot¶
| R (Seurat) | Python (Truecell) |
|---|---|
![]() |
![]() |
Both recover the same anatomy: excitatory neurons filling the cortex, oligodendrocytes tracing the white-matter tracts, and the hippocampal formation clearly outlined. Note — under
ggplot24.x, Seurat'sImageDimPlotrenders blank; the R panel here is drawn fromGetTissueCoordinates()withgeom_point. Truecell'simage_dim_plotdraws centroids directly with matplotlib, so it is immune to that issue.
Step 6 · A gene in tissue space — ImageFeaturePlot¶
| R (Seurat) | Python (Truecell) |
|---|---|
![]() |
![]() |
Slc17a7(VGLUT1) marks excitatory neurons — bright across cortex, dark in the white matter and ventricles, in both implementations.
Step 7 · Tissue coordinates & nearest-neighbour distance¶
We treat Vascular cells as the focal population (the analog of mast cells in
the internal analysis) and measure, per cell, the distance to its nearest other
vascular cell. Seurat users reach for FNN::get.knn; Truecell wraps the identical
KD-tree in nearest_neighbor_distance.
| R (Seurat) | Python (Truecell) |
|---|---|
| metric | R Seurat (FNN) | Truecell |
|---|---|---|
| n Vascular | 3,016 | 3,016 |
| nearest-neighbour median | 24.0739 µm | 24.0739 µm |
| nearest-neighbour mean | 30.5612 µm | 30.5612 µm |
Step 8 · Local neighbourhood density¶
For every vascular cell, what fraction of its 10 nearest neighbours are also vascular? (Local "clumpiness" of the focal type.)
| R (Seurat) | Python (Truecell) |
|---|---|
Both give a mean local vascular density of 0.18107 — 8-figure agreement.
![]() |
![]() |
Step 9 · Composition test across a spatial region¶
Split the section into dorsal / ventral halves (median y) and test which cell
types are enriched where. Seurat users build this table by hand (Fisher +
p.adjust); Truecell packages it as composition_test.
| R (Seurat) | Python (Truecell) |
|---|---|
Identical enrichment in both (log2 > 0 ⇒ enriched ventral):
| cell_type | log2 ratio | BH padj | enriched in |
|---|---|---|---|
| Other | +1.180 | 1.2e-02 | ventral |
| OPC | +1.037 | 8.5e-33 | ventral |
| Astrocyte | +0.748 | 8.5e-93 | ventral |
| Oligodendrocyte | +0.279 | 2.3e-09 | ventral |
| Vascular | +0.059 | 0.281 (ns) | ventral |
| Immune | +0.051 | 0.536 (ns) | ventral |
| Excitatory | −0.267 | 9.3e-68 | dorsal |
| Inhibitory | −0.448 | 3.4e-23 | dorsal |
Overall χ² p = 1.14e-163 in both. Neurons concentrate dorsally (cortex); glial/progenitor types (OPC, astrocyte, oligodendrocyte) skew ventrally.
composition_testreports a sample odds ratio (a·d / b·c); R'sfisher.testreturns the conditional-MLE odds ratio — they differ in the 4th decimal by definition, but the log2 ratios and p-values are identical.
Step 10 · Niche analysis — BuildNicheAssay¶
Cluster cells by the composition of their spatial neighbourhood into spatial
"niches" (Seurat v5's BuildNicheAssay).
| R (Seurat) | Python (Truecell) |
|---|---|
![]() |
![]() |
Both build a neighbourhood-composition matrix and k-means it into niches. The k-means initialisation differs between R and Python so niche labels differ, but both partition the section into the same spatial domains — cortical layers, white matter, and hippocampal subfields.
Parity — verified against R Seurat¶
Cross-checked by compare_xenium_anchors.py (R reference ↔ Truecell). Every
deterministic anchor matches to 8 significant figures:
| anchor | R Seurat | Truecell | |
|---|---|---|---|
| cells (raw / QC) | 36,602 / 36,419 | 36,602 / 36,419 | ✓ |
| genes | 248 | 248 | ✓ |
| cell-type counts (×8) | exact | exact | ✓ |
| Vascular nearest-neighbour median | 24.073884 | 24.073884 | ✓ |
| Vascular nearest-neighbour mean | 30.561227 | 30.561227 | ✓ |
| Vascular local density mean | 0.18106764 | 0.18106764 | ✓ |
| composition log2 ratios (×8) | exact | exact | ✓ |
| composition Fisher p / BH padj | exact | exact | ✓ |
| composition χ² p | 1.138165e-163 | 1.138165e-163 | ✓ |
| clusters / niches (stochastic) | 20 / 6 | 18 / 6 | structure |
Quick Reference — Spatial API Translation¶
| Task | R (Seurat) | Python (Truecell) |
|---|---|---|
| Load Xenium | LoadXenium(dir) |
load_xenium(dir) |
| Load Visium / CosMx | Load10X_Spatial / LoadNanostring |
load_visium / load_cosmx |
| Tissue coordinates | GetTissueCoordinates(obj) |
get_tissue_coordinates(obj) |
| Spatial k-NN | FNN::get.knn / get.knnx |
spatial_knn(coords, k, query) |
| Nearest-type distance | FNN::get.knn idiom |
nearest_neighbor_distance(obj, group_by, ref) |
| Neighbourhood composition | (hand-rolled) | local_neighborhood(obj, group_by, ref, k) |
| Niche clustering | BuildNicheAssay(obj, fov, group.by) |
build_niche_assay(obj, group_by, k, niches) |
| Composition / enrichment | (hand-rolled Fisher + p.adjust) |
composition_test(obj, group_by, split_by) |
| Cells in space | ImageDimPlot(obj, group.by) |
image_dim_plot(obj, group_by) |
| Feature in space | ImageFeaturePlot(obj, features) |
image_feature_plot(obj, feature) |
Reproducing this tutorial¶
git clone https://github.com/GenomicAI/truecell.git
cd truecell
git checkout feature/spatial-seurat-parity
uv venv && source .venv/bin/activate
uv pip install -e ".[analysis]"
# 1. Python side (auto-downloads the ~20 MB dataset) — writes figures_spatial/
python tutorials/generate_spatial_plots.py
# 2. R reference figures + figures_spatial/r_reference.json
# (needs Seurat, FNN, ggplot2, jsonlite; reads the cache from step 1)
Rscript tutorials/xenium_spatial_verify.R
# 3. Print the R-vs-Python parity table shown above
python tutorials/compare_xenium_anchors.py
All three scripts live in tutorials/ and read/write
tutorials/figures_spatial/, so the whole comparison is self-contained.
References¶
10x Genomics (2023). Xenium In Situ — Fresh Frozen Mouse Brain (Coronal), CTX + HP subset. https://www.10xgenomics.com/datasets
Hao Y, et al. (2024). Dictionary learning for integrative, multimodal and scalable single-cell analysis. Nature Biotechnology 42, 293–304.
Seurat spatial vignette (Xenium): https://satijalab.org/seurat/articles/spatial_vignette_2













