# Packages are installed in Tutorial 00 (Setup → bonus modules); the stxBrain
# dataset install is on the Datasets page. Here we just load the libraries.
library(Seurat)
library(SeuratData)
library(tidyverse)
library(patchwork)
# ---------------------------------------------------------------------------
# Output directory for this module's figures and tables.
# Every figure/table chunk below writes a file named Mod16_C<chunk>_<name>
# into ../output/Mod16/ so it can be cross-referenced from the rest of the site.
# Mod16 = Module 16 (this tutorial); C<n> = the nth code chunk.
# ---------------------------------------------------------------------------
out_dir <- "../output/Mod16"
dir.create(out_dir, recursive = TRUE, showWarnings = FALSE)
dir.create("../data", showWarnings = FALSE) # shared .rds hand-off folder (sibling of scripts/, created one level up)
message("This module writes its figures & tables to: ", normalizePath(out_dir))
# --- Figure saving --------------------------------------------------------
# save_fig() writes every figure as a .png (for viewing) AND a .svg (vector,
# editable in Illustrator / Inkscape for a manuscript). Pass the .png path; the
# .svg is written alongside with the same basename. (.svg uses the 'svglite'
# package, installed in Tutorial 00.)
save_fig <- function(filename, plot, width, height, dpi = 300, ...) {
ggplot2::ggsave(filename, plot, width = width, height = height, dpi = dpi, ...)
svg_path <- paste0(tools::file_path_sans_ext(filename), ".svg")
tryCatch(ggplot2::ggsave(svg_path, plot, width = width, height = height, ...),
error = function(e) message(" (could not write ", basename(svg_path),
" - install 'svglite'? ", conditionMessage(e), ")"))
}
set.seed(2026)Tutorial 16 — Spatial Transcriptomics with Visium
Load a 10x Visium tissue, find spatially variable genes, integrate two sections, and sketch cell-type deconvolution
Every code chunk here is tagged with #| eval: false, so the published page shows the code without running it. To run it yourself, open the downloaded .qmd in RStudio:
- Run one chunk: click the green ▶ (Run Current Chunk) at the chunk’s top-right, or press Ctrl/Cmd + Enter. This runs the chunk regardless of its
evalsetting — the easiest way to work through the tutorial interactively. - Run a chunk on render: change that chunk’s
#| eval: falseto#| eval: true(or delete the line) so it executes when the document is rendered. - Render the whole tutorial: click the Render button in RStudio (or run
quarto renderin a terminal) to execute the chunks top-to-bottom and knit a finished HTML. To run everything on render, seteval: trueonce in the YAML header at the top.
About this tutorial
A laptop-friendly introduction to spatial transcriptomics using 10x Visium — the most accessible spatial platform today. We use the stxBrain dataset from SeuratData: matched anterior and posterior sagittal sections of an adult mouse brain, ~50 MB total, installed with one R command (no manual download, no 10x click-through).
Companion lecture: Lecture 16 — Spatial Transcriptomics: Gene Expression in Tissue Context · Companion book chapter: Chapter 16 — Spatial Transcriptomics — the long-form prose treatment of this tutorial’s material, with cross-references to the prerequisite appendices.
You’ll learn to:
- Load Visium data into Seurat (counts + tissue image + spot coordinates)
- Run the spatial-aware QC, normalization (
SCTransform), and clustering workflow - Plot expression on top of the histology image
- Find spatially variable features — genes whose expression has spatial structure beyond cluster membership
- Integrate two tissue sections (the same anterior + posterior idea as
ifnb’s CTRL + STIM)
Why mouse brain for a primer? It has dramatic, well-known anatomy (cortex, hippocampus, thalamus, hypothalamus, cerebellum) — easy to recognize on the histology image, easy to validate with marker genes (Hpca in hippocampus, Plp1 in white matter, Pcp4 in cerebellum). A clean teaching dataset; no laptop will struggle with it.
- Download the
.qmdsource: Tutorial_16_Spatial_Transcriptomics.qmd. If your browser saves the file asTutorial_16_Spatial_Transcriptomics.qmd.txt, drop the trailing.txtso the filename ends in.qmd, then open it in RStudio. - Install the
stxBraindataset once (one R command — see Setup). - Work through the chunks, flipping
eval: trueas you go.
Dataset — stxBrain (10x Visium adult mouse brain)
What it is. Two 10x Visium sections from the same adult mouse brain — one anterior (~2,700 spots), one posterior (~3,300 spots). Each spot is ~55 µm in diameter and captures the transcripts from ~1–10 cells underneath. The “spots” are the unit of analysis here, not single cells.
- Distribution:
SeuratData::InstallData("stxBrain")(no manual download) - Source: 10x Genomics public Visium dataset
- Used in: this tutorial; the Seurat spatial vignette
A Visium “spot” is not a single cell. Most analyses borrow the language of single-cell (clusters, marker genes, integration) but interpret each unit as a small neighbourhood of cells. For true single-cell resolution use higher-resolution spatial platforms — Xenium, MERFISH, or Visium HD.
Learning goals
- Load a Visium dataset and understand the four objects in a
SpatialAssay(counts, image, coordinates, scale factors) - Run
SCTransformon spatial data and explain why it’s preferred overLogNormalizehere - Use
SpatialFeaturePlotandSpatialDimPlotto overlay expression / cluster on tissue - Find spatially variable features with Moran’s I
- Merge two tissue sections and run a joint clustering
SeuratData::InstallData("stxBrain") is slow — ~50 MB download. First run takes a few minutes; subsequent runs are instant.
SCTransform warns “iteration limit reached” — usually safe to ignore. Visium spots have a wider count distribution than single cells, so SCT’s NB-regression sometimes hits its iteration limit on outlier spots. The result is still usable.
SpatialFeaturePlot shows nothing on the tissue — the images slot wasn’t loaded. After SaveH5Seurat / Convert, the histology image can be lost; Read10X_Image(dirname(seu@tools$Staffli@imgs[1])) re-attaches it.
Anterior + posterior integration is slow / OOMs — the SCT-anchor path is RAM-hungry on Visium. If you have <16 GB RAM, downsample spots first (subset(brain, downsample = 1500)) or use Harmony on the SCT residuals instead of IntegrateData.
The Think about it prompts on this page have inline answers (click “Show answer” / “Click for answer” to expand). For the full runnable instructor copy with every chunk pre-evaluated and outputs shown:
- For students: the same
.qmdyou downloaded already contains every solution inline — just expand the collapsed answers - For instructors: render with
quarto render --profile solutionsfrom theExercise_Folder/directory. The fully-evaluated HTML is written todocs/Exercise_Folder/_solutions/Tutorial_16_Spatial_Transcriptomics.html. See Exercise_Folder/_quarto-solutions.yml for the build profile
Setup
Step 1 — Load both sections
ant <- LoadData("stxBrain", type = "anterior1")
post <- LoadData("stxBrain", type = "posterior1")
ant
post
# Check the assays — Visium objects have a "Spatial" assay (not "RNA")
DefaultAssay(ant)
# --- Table out: per-section dimensions (features x spots) -------------------
tibble::tibble(
section = c("anterior1", "posterior1"),
features = c(nrow(ant), nrow(post)),
spots = c(ncol(ant), ncol(post))
) |>
readr::write_csv(file.path(out_dir, "Mod16_C2_section_dimensions.csv"))- How many features (genes) are in each section? How many spots?
- What’s stored in
ant@images?
Show answers
- About 32,000 genes × ~2,700 spots (anterior) and ~3,300 spots (posterior). Each spot is bigger than a single cell, so the absolute spot count is much smaller than a typical scRNA-seq experiment.
- An object of class
VisiumV1containing the histology image (low-res JPEG), the per-spot tissue-coordinate table (which pixel each spot maps to), and the scale factors that translate between Visium’s three coordinate systems (full-res, low-res, hi-res).SpatialFeaturePlotuses all of these to draw expression on top of the H&E.
Step 2 — Spatial QC
The same per-spot QC metrics as scRNA-seq apply: nCount_Spatial, nFeature_Spatial, percent mitochondrial. But also visualize them spatially — a localized QC outlier (e.g. one corner of the tissue with low UMI counts) is a tissue artefact (bubble, fold, edge cells), not random noise.
ant[["percent.mt"]] <- PercentageFeatureSet(ant, pattern = "^mt-")
post[["percent.mt"]] <- PercentageFeatureSet(post, pattern = "^mt-")
# Standard violin
p1 <- VlnPlot(ant,
features = c("nCount_Spatial", "nFeature_Spatial", "percent.mt"),
pt.size = 0, ncol = 3) &
xlab("Anterior section") &
theme(plot.title = element_text(size = 11))
p1 <- p1 +
patchwork::plot_annotation(
title = "Per-spot QC metrics — stxBrain anterior section",
subtitle = "UMIs/spot, genes/spot and mitochondrial % distributions",
caption = "Module 16 · Spatial Transcriptomics"
)
# Spatial overlay — same metrics, on the tissue
p2 <- SpatialFeaturePlot(ant,
features = c("nCount_Spatial", "percent.mt"),
pt.size.factor = 1.6) +
plot_layout(ncol = 2)
p2 <- p2 +
patchwork::plot_annotation(
title = "Spatial QC overlay — stxBrain anterior section",
subtitle = "UMIs/spot and mitochondrial % on the histology image",
caption = "Module 16 · Spatial Transcriptomics"
)
p1
p2
# --- Figures out -----------------------------------------------------------
save_fig(file.path(out_dir, "Mod16_C3_qc_violins.png"), p1,
width = 10, height = 4, dpi = 300)
save_fig(file.path(out_dir, "Mod16_C3_qc_spatial.png"), p2,
width = 10, height = 5, dpi = 300)pattern = "^mt-" (lowercase) for mouse mitochondrial genes. For human use "^MT-".
The violin panel shows the distribution of nCount_Spatial, nFeature_Spatial, and percent.mt across all spots: a wide lower tail on UMI counts is normal for Visium (edge spots, white-matter spots) but a very high upper tail on percent.mt would flag tissue damage. The spatial overlay (p2) is the key diagnostic: each circle is a spot colored by its metric value, placed on the H&E image. Uniform color variation that matches anatomy — lower UMI counts over white matter, higher over the cortex — is expected and not a reason to filter. Isolated patches of very low counts in otherwise high-count tissue regions, or a single corner of the section with elevated percent.mt, are more likely artefacts (a bubble, a fold, a damaged area).
On the spatial nCount_Spatial overlay, regions of consistently low UMI counts often correspond to specific tissue features. What might they be?
Show answers
White matter is famously low-yield on Visium because the tissue is mostly axons (myelin), with relatively few neuronal cell bodies — fewer cells per spot, lower transcript counts per spot. Tissue edges also drop off because spots there are only partially covered. Don’t filter these spots reflexively — for the brain dataset they are real biology.
Step 3 — Normalize with SCTransform
SCTransform is the recommended normalization for Visium because spot-level counts have a wider dynamic range than single-cell counts (each spot pools 1–10 cells), and SCTransform’s regularized negative binomial model handles that better than LogNormalize.
ant <- SCTransform(ant, assay = "Spatial", verbose = FALSE)
post <- SCTransform(post, assay = "Spatial", verbose = FALSE)
DefaultAssay(ant) # Should now be "SCT"Step 4 — Visualize known marker genes on the tissue
Sanity-check that the data captures known anatomy. For the mouse brain:
Hpca— hippocampusPcp4— cerebellum, parts of cortexPlp1— myelin / white matterSst— somatostatin interneuronsTh— dopaminergic / noradrenergic neurons (substantia nigra)Cck— broadly cortical inhibitory neurons
p_markers_ant <- SpatialFeaturePlot(ant, features = c("Hpca", "Plp1", "Sst"),
ncol = 3, alpha = c(0.1, 1)) +
patchwork::plot_annotation(
title = "Anatomical markers on tissue — anterior section",
subtitle = "Hpca (hippocampus), Plp1 (white matter), Sst (interneurons)",
caption = "Module 16 · Spatial Transcriptomics"
)
p_markers_post <- SpatialFeaturePlot(post, features = c("Hpca", "Plp1", "Pcp4"),
ncol = 3, alpha = c(0.1, 1)) +
patchwork::plot_annotation(
title = "Anatomical markers on tissue — posterior section",
subtitle = "Hpca (hippocampus), Plp1 (white matter), Pcp4 (cerebellum)",
caption = "Module 16 · Spatial Transcriptomics"
)
p_markers_ant
p_markers_post
# --- Figures out -----------------------------------------------------------
save_fig(file.path(out_dir, "Mod16_C5_markers_anterior.png"), p_markers_ant,
width = 12, height = 4, dpi = 300)
save_fig(file.path(out_dir, "Mod16_C5_markers_posterior.png"), p_markers_post,
width = 12, height = 4, dpi = 300)Each panel is a spatial feature plot: every circle is one Visium spot placed on the H&E histology image, and the color gradient from light to dark encodes expression level (low → high). Hpca should light up in the medial hippocampus (more prominent on the posterior section), Plp1 should trace white-matter tracts (corpus callosum, internal capsule), and Sst/Pcp4 should mark specific cortical and cerebellar layers respectively. Spots that match the expected anatomy validate that normalization worked and that the tissue section is oriented correctly; unexpected expression patterns (e.g. Plp1 smeared uniformly) can signal high ambient RNA or a failed normalization step.
Hpca should be highest in the hippocampus (a banana-shaped structure in the medial part of each section). Can you see the hippocampus light up? If not, you may have the section orientation flipped — SeuratData’s stxBrain is stored with the cortical surface roughly at the top of the image.
Show answers
If you don’t recognize the hippocampus, open the Allen Mouse Brain Atlas for a side-by-side reference. The anterior section catches the front of the hippocampus and not much else of it; the posterior section gets the bulk. Hpca will appear stronger on posterior1 than on anterior1.
Step 5 — Cluster spots
Same workflow as scRNA-seq, but on the SCT assay:
ant <- ant |>
RunPCA(assay = "SCT", verbose = FALSE) |>
FindNeighbors(reduction = "pca", dims = 1:30) |>
FindClusters(resolution = 0.5, verbose = FALSE) |>
RunUMAP(reduction = "pca", dims = 1:30)
# UMAP and tissue side-by-side
p_umap <- DimPlot(ant, reduction = "umap", label = TRUE) +
labs(title = "Anterior — UMAP", x = "UMAP 1", y = "UMAP 2",
colour = "Cluster")
p_tis <- SpatialDimPlot(ant, label = TRUE, label.size = 3) +
labs(title = "Anterior — clusters on tissue", fill = "Cluster")
p_cluster <- (p_umap + p_tis) +
patchwork::plot_annotation(
title = "Spot clustering — stxBrain anterior section",
subtitle = "SNN clusters on the SCT assay, in UMAP space and on the histology image",
caption = "Module 16 · Spatial Transcriptomics"
)
p_cluster
# --- Figure out ------------------------------------------------------------
save_fig(file.path(out_dir, "Mod16_C6_anterior_clusters.png"), p_cluster,
width = 12, height = 5, dpi = 300)The left panel is the standard UMAP: each point is a spot, colored by cluster assignment; well-separated round islands indicate distinct transcriptional populations. The right panel is the spatial payoff — the same cluster colors mapped back onto the H&E image. Clusters that form contiguous anatomical regions (a continuous band matching the cortex, a distinct patch in the hippocampal area) support biological interpretation; clusters whose colored spots are scattered randomly across the tissue are more likely technical artifacts or low-frequency cell types. If a major brain region appears split across two or three clusters, try increasing resolution; if the entire tissue collapses to two or three coarse clusters, lower it.
A spatial cluster that occupies a single contiguous anatomical region (e.g. one cluster covers the entire hippocampus) is biologically meaningful. A cluster that’s scattered randomly across the section in single spots is more likely a technical / mixed-cell artefact. Why?
Show answers
Real cell types are spatially organized in tissue — neurons in cortex, oligodendrocytes in white matter, etc. A cluster whose spots all share spatial proximity is recovering an anatomical structure. A cluster whose spots are scattered is recovering either (a) very rare cells distributed throughout (immune cells, vasculature — sometimes legitimate) or (b) a technical signature (depth, ambient RNA, batch). The spatial layout is itself a quality check.
Step 6 — Spatially variable features (Moran’s I)
Some genes are variable and their variation has spatial structure (a clear pattern on the tissue), some are variable but spatially random. The first kind is what you usually want to study in a spatial dataset.
ant <- FindSpatiallyVariableFeatures(
ant,
assay = "SCT",
features = VariableFeatures(ant)[1:1000], # restrict to top-1000 HVGs for speed
selection.method = "moransi"
)
# Top spatially variable features
top_sv <- SpatiallyVariableFeatures(ant, selection.method = "moransi")[1:6]
top_sv
p_svf <- SpatialFeaturePlot(ant, features = top_sv, ncol = 3, alpha = c(0.1, 1)) +
patchwork::plot_annotation(
title = "Top spatially variable features (Moran's I) — anterior section",
subtitle = "Genes whose expression has the strongest spatial structure",
caption = "Module 16 · Spatial Transcriptomics"
)
p_svf
# --- Outputs: top spatially variable genes (table) + tissue overlay --------
tibble::tibble(rank = seq_along(top_sv), gene = top_sv) |>
readr::write_csv(file.path(out_dir, "Mod16_C7_spatially_variable_features.csv"))
save_fig(file.path(out_dir, "Mod16_C7_spatially_variable_features.png"), p_svf,
width = 12, height = 7, dpi = 300)The console first prints top_sv — a character vector of the six genes with the highest Moran’s I statistic, meaning their expression is most strongly autocorrelated across the tissue (neighboring spots tend to have similar values). The six-panel SpatialFeaturePlot then maps each gene onto the H&E: spots with high expression are dark, low expression is light. A good spatially variable gene looks like a distinct patch or stripe confined to a recognizable anatomical structure — not a uniform wash across the tissue. In the mouse brain anterior section you typically see white-matter genes (Plp1, Mbp) and hippocampal or cortical markers dominating the top six. A gene that tiles the tissue with no clear anatomy despite a high Moran’s I may reflect a confounding gradient (slide edge effects, sequencing depth gradient) rather than real biology.
A gene with high variance but low Moran’s I is variable spot-to-spot but with no spatial pattern — what does that suggest?
Show answers
Either (a) genuine cell-cell heterogeneity within an anatomical region (rare cell types scattered across a region), or more often (b) a technical signature like ambient-RNA bleed, mitochondrial percent, or sequencing depth. Spatial variability is a stronger biological signal than overall variability.
Step 7 — Merge anterior + posterior, joint cluster
The same two-sample integration logic as ifnb, but on Visium. We use SCTransform integration:
# Each section is its own "batch"
ant$slice <- "anterior"
post$slice <- "posterior"
brain.list <- list(ant = ant, post = post)
features <- SelectIntegrationFeatures(object.list = brain.list,
nfeatures = 3000,
verbose = FALSE)
brain.list <- PrepSCTIntegration(object.list = brain.list,
anchor.features = features,
verbose = FALSE)
anchors <- FindIntegrationAnchors(object.list = brain.list,
normalization.method = "SCT",
anchor.features = features,
verbose = FALSE)
brain <- IntegrateData(anchorset = anchors,
normalization.method = "SCT",
verbose = FALSE)
DefaultAssay(brain) <- "integrated"
brain <- brain |>
RunPCA(verbose = FALSE) |>
FindNeighbors(dims = 1:30) |>
FindClusters(resolution = 0.5, verbose = FALSE) |>
RunUMAP(dims = 1:30)
# Joint UMAP
p_int_umap <- DimPlot(brain, group.by = c("slice", "seurat_clusters")) +
patchwork::plot_annotation(
title = "Integrated UMAP — anterior + posterior sections",
subtitle = "Coloured by section of origin and by joint cluster",
caption = "Module 16 · Spatial Transcriptomics"
)
p_int_umap
# And per-slice spatial cluster maps
p_int_spatial <- SpatialDimPlot(brain, label = TRUE) +
patchwork::plot_annotation(
title = "Integrated clusters on tissue — both sections",
subtitle = "Joint cluster assignments mapped back onto each section",
caption = "Module 16 · Spatial Transcriptomics"
)
p_int_spatial
# --- Figures out -----------------------------------------------------------
save_fig(file.path(out_dir, "Mod16_C8_integrated_umap.png"), p_int_umap,
width = 12, height = 5, dpi = 300)
save_fig(file.path(out_dir, "Mod16_C8_integrated_spatial_clusters.png"), p_int_spatial,
width = 10, height = 5, dpi = 300)p_int_umap shows two side-by-side UMAPs from the integrated object: the left is colored by slice (anterior vs. posterior) and the right by seurat_clusters. Before integration, coloring by slice would show two blobs; after successful integration, spots from both sections should interleave in the UMAP, meaning the same anatomical cell type from both sections lands in the same cluster regardless of which chip it came from. p_int_spatial maps those joint cluster assignments back onto each section’s H&E — anatomically equivalent structures (e.g. cortex, hippocampus) in both anterior and posterior sections should receive the same cluster color, which is the validation that integration succeeded.
Why integrate sections from the same brain at all? The animal is one animal — what could differ?
Show answers
Even matched sections can drift: minor differences in tissue handling (RNA quality, embedding, cryostat thickness), Visium chip-to-chip variation, different lots of capture probes, different sequencing depth. Integration aligns the technical axes so the anatomical differences (anterior catches striatum, posterior catches cerebellum) are what dominates the joint embedding rather than chip-to-chip noise.
Step 8 — (Optional) cell-type deconvolution from a single-cell reference
A Visium spot covers ~1–10 cells, so each spot’s transcriptome is a mixture. Tools like spacexr (the RCTD method), CARD, cell2location (Python), and SPOTlight decompose each spot into estimated cell-type proportions, using a single-cell reference of the same tissue.
This is too heavy to install and run end-to-end here, but the recipe is:
Optional reference recipe — left #| eval: false; it’s heavy to run and is here as a pattern for your own data, so you don’t need to change anything.
# Reference recipe for an actual deconvolution with spacexr (RCTD).
library(spacexr)
# 1. Build a reference from a single-cell brain dataset (e.g. Allen Mouse Brain
# Atlas or a Tabula Muris brain subset) — a Reference() object with cell-type labels.
# 2. Build a SpatialRNA() object from your Visium counts + spot coordinates.
# 3. Run RCTD:
rctd <- create.RCTD(spatial_rna, reference, max_cores = 4)
rctd <- run.RCTD(rctd, doublet_mode = "doublet")
# 4. Each spot now has weights for the contributing cell types; visualize as a
# per-cell-type SpatialFeaturePlot.For the laptop workflow here, a fast proxy is to compute a module score for each cell type using its top markers from a single-cell reference. We borrow markers from the ifnb PBMC reference for one quick example (mouse brain doesn’t have PBMCs, so this is purely illustrative — for a real run, use a brain-tissue scRNA-seq reference):
Optional illustrative sketch — left #| eval: false; you don’t need to change it.
# Illustrative sketch only (cross-species gene-name conversion is non-trivial):
brain <- AddModuleScore(brain,
features = list(Microglia = c("Cx3cr1", "P2ry12", "Tmem119")),
name = "Microglia")
SpatialFeaturePlot(brain, features = "Microglia1")Step 9 — Save
dir.create("../data", showWarnings = FALSE)
saveRDS(brain, "../data/stxBrain_integrated.rds")Wrap-up
You’ve done the canonical Visium workflow on a real two-section dataset:
- Loaded Visium data including the histology image
- Visualized QC and marker expression on the tissue
- Normalized with
SCTransformand clustered the spots - Found spatially variable features (Moran’s I)
- Integrated two sections and re-clustered jointly
- Sketched the recipe for cell-type deconvolution
- Higher-resolution platforms. Visium HD (subcellular bins), 10x Xenium, MERFISH, NanoString CosMx — same conceptual workflow, much higher resolution. Seurat 5 supports Visium HD natively; Xenium requires Vitessce / Seurat 5 /
xeniumtools. - Cell–cell communication in space. Tools like
LIANA+(Python) extend the LIANA framework to spatial data — they ask which ligand-receptor pairs are spatially co-located, not just co-expressed. - Spatial domains via graph methods.
BANKSYandSTAGATEproduce spatially-aware clusters that explicitly use coordinates as features, often cleaner than the SNN approach above. - Image-aware normalization.
STUtilityandsemlaadd tissue-image-based normalization that handles section-edge effects better than off-the-shelf SCT.
See also
- Seurat spatial vignette — the canonical reference workflow this tutorial follows
- scNotebooks Module 10 — covers Visium + 3D visualization + deconvolution on 10x breast cancer
spacexr(RCTD) — github.com/dmcable/spacexr- Moses & Pachter 2022 Nat Methods — landscape review of spatial methods, doi:10.1038/s41592-022-01409-2