Step-by-step discovery of Visium data analysis using embryonic heart sections.

Author

Raphaël Mauron

Published

August 26, 2026

2026 V4SDB Student Summer School

Visium workshop

In this workshop we will go step by step through the analysis of Visium data from 10x Genomics. All the code is provided so you are not asked to write it yourself, but to read each step carefully and understand what it does and why. We will load the data, filter it, process it, and cluster it on a single tissue section. If time allows, all the steps are then repeated on three sections of different developmental ages simultaneously, where more advanced analyses are suggested. If we do not get there together, you are welcome to work through it on your own and contact me with any questions.

What Visium measures

A Visium slide carries a grid of ~5,000 spots, each 55 µm across and spaced 100 µm apart (distance from center to center of spots). Every spot carries millions of capture oligos sharing one spatial barcode unique to that position. When a tissue section is placed on the slide and permeabilised, mRNA diffuses down into the spots and is captured by a poly-T sequence that binds the poly-A tail of the transcript. After sequencing, the barcode tells us where on the slide a transcript came from, and the rest of the read tells us which gene it was.

Two consequences shape everything that follows.

  • First, a spot is a fixed position, not a cell: at 55 µm it typically covers a handful of cells, so what we measure is a local mixture.
  • Second, poly-A capture is indiscriminate: any polyadenylated transcript is sequenced, whether or not we are interested in it. A large part of this workshop is deciding what to keep.

Poly-A capture on the Visium array

Where this data comes from

The sections we analyse today are a small subset of a published atlas of the developing human heart, produced in our lab:

Lázár, E.*, Mauron, R.*, Andrusivová, Ž.* et al. Spatiotemporal gene expression and cellular dynamics of the developing human heart. Nature Genetics 57, 2756–2771 (2025). doi:10.1038/s41588-025-02352-6 (*equal contribution)

The full study combined Visium spatial transcriptomics on 16 hearts (38 sections, 69,114 spots, PCW 6–12) with single-cell RNA-seq on 15 hearts (76,991 cells, PCW 5.5–14) and in situ sequencing of 150 transcripts. Unsupervised clustering of 25,208 spots from 17 sections yielded 23 spatial clusters corresponding to the major structural components of the developing heart, and deconvolution against the single-cell data mapped 72 fine-grained cell states onto tissue positions.

Today we work with three sections. Everything we do is a simplified version of the first steps of that pipeline.

Resources you can use during and after the workshop:

The tissue was donated after elective medical abortion with written informed consent, under Swedish ethical permit 2018/769-31.

By the end of the session you will have

On a single section:

  • Loaded a Visium section into R and plotted a gene on the tissue
  • Decided which genes to filter out, and justified why
  • Normalized, scaled, and reduced the data
  • Clustered the spots and mapped the clusters back onto the anatomy
  • Found the genes that define each cluster, and compared them against the published atlas

On three sections of different ages:

  • Repeated the whole pipeline on three sections at once
  • Seen what a batch effect looks like, and what correcting it costs
  • Looked for genes that change with developmental stage within a cluster
  • Asked whether a variable gene is actually spatially structured

Practical notes

Grey boxes are code chunks. Run the chunk your cursor is in with Cmd/Ctrl + Shift + Enter, or a single line with Cmd/Ctrl + Enter.

Run them in order, top to bottom. Many steps deliberately overwrite an object with an updated version, so skipping one, or re-running an earlier one out of order, will produce confusing errors later. Three places to be aware of:

  • The single-section analysis builds up an object called object.
  • The three-section analysis uses a separate object called se. The two are independent — object is not affected by anything in the second half.
  • In section 7 we cluster twice and FindClusters() writes to the same seurat_clusters column both times. We copy the result into clusters_pca and clusters_harmony so that nothing is lost, but if you re-run only part of that chunk, seurat_clusters may not hold what you expect.

Red text in the console is not always an error; warnings are common and usually harmless. If something breaks, say so and we will look at it together.

Set up

Set project library

When working in R, there is multiple ways to set up your working environment. For the sake of that workshop, you are provided with a fully working environment that contains the code, the data, and the R packages already installed. Below we are just specifying R where to look for the installed packages.

Load library

R packages might be installed, but for us to be able to use them later in the analysis, we have to attach them to our session. In R, we do it by using the library() function. This slightly more advanced way below, allow to call the function library() in a loop. You could also do: library(semla) library(Seurat) etc. This works equally well.

package.list <- c("tidyverse", "semla", "Seurat", "patchwork", "RColorBrewer", "harmony")

invisible(lapply(package.list, function(element) {
  library(element, character.only = TRUE)
}))
rm(package.list)

Set seed for project

Sometimes, some code and functions rely on some random drawing of numbers. For instance, if I tell R to sample 100 random cells from my single-cell RNA-seq data, everytime I run the function, I will have different randomly picked cells. It does what we ask, but for the sake of reproducibility, we want to have always the same cells picked when we run the same code the next day. To do so, we can specify a seed by using set.seed(). Some functions have that option in the function’s parameter (worth checking because it might not be used by default), but otherwise you can use set.seed() with any number you want in the code chunk where you need it.

set.seed(1)

Loading my first Visium section

Our session is now fully ready to start. We have the packages loaded, we are ready to load some data.

To analyse a Visium section, we need to load the data. 10x Genomics provides a software (spaceranger) that takes the raw sequences and the microscopy images, and generates a count matrix (gene counts per barcode), and several files that help us align the microscopy image to the count matrix.

How do we load this count matrix in R?

In R, scientists have developed convenience packages to effortlessly load the spaceranger outputs, store them into an object class, and provide multiple functions to analyse, plot and else.

In the single-cell RNA seq world, such a convenience package is Seurat, and is very well established and maintained by a large team. They are at the origin of the object class that we call Seurat object.

When Spatial Transcriptomics appeared, there were no such toolkit, so other labs put effort into creating such a package. Today we will rely on the semla package developed in our lab (Lundeberg). semla, under the hood, uses the Seurat object class, but added a way to register the spatial information that single-cell data do not have (image, coordinates, etc.).

In the next steps, most functions we will use come from either semla or Seurat.

Load data & create infoTable from metadata + spaceranger outs

Let’s start!

Below we create a table that provides the path so R (and specific packages e.g., semla) knows where the spaceranger outputs are. We have here:

  1. filtered_feature_bc_matrix.h5 : the count data
  2. tissue_hires_image.png : an H&E image that can be aligned to the counts
  3. tissue_positions_list.csv: a table that contains the info to align the pixel of the image and the Visium spots
  4. scalefactors_json.json: a ratio value that help us visualize the image and the spots in the right scale.
data_root_directory <- file.path("./data/spaceranger/V10F24-105_C1")

samples <- Sys.glob(paths = file.path(data_root_directory, "filtered_feature_bc_matrix.h5"))
imgs <- Sys.glob(paths = file.path(data_root_directory, "spatial", "tissue_hires_image.png"))
spotfiles <- Sys.glob(paths = file.path(data_root_directory, "spatial", "tissue_positions_list.csv"))
json <- Sys.glob(paths = file.path(data_root_directory, "spatial", "scalefactors_json.json"))

We create a table containing all these paths

infoTable <- tibble(samples,
                    imgs,
                    spotfiles,
                    json)

We can inspect the table:

infoTable
# A tibble: 1 × 4
  samples                                                  imgs  spotfiles json 
  <chr>                                                    <chr> <chr>     <chr>
1 ./data/spaceranger/V10F24-105_C1/filtered_feature_bc_ma… ./da… ./data/s… ./da…

We can now use ReadVisiumData() function from semla package to create an R-object with all the data we linked with their paths in the infoTable. semla uses the data structure from the well-known Seurat package, originally developed to facilitate the analysis of single-cell RNA-seq data. semla added, on top of this architecture, all the spatial information (image, coordinates, etc.) that are required for us to know where are the spots and to be able to later use this information in analyses and visualization.

object <- ReadVisiumData(infoTable)
object <- LoadImages(object) #we specify semla to load the image in the object

Let’s inspect what we have so far.

object
An object of class Seurat 
36601 features across 2234 samples within 1 assay 
Active assay: Spatial (36601 features, 0 variable features)
 1 layer present: counts
Question
  1. What is the dimension of the data?
  2. How many spots does that section contain?
  3. How many genes does that section contain?
  4. Does it make sense?

Hint: look at the printed object!

The print-out gives features x samples, i.e. genes x spots.

dim(object)      # genes, spots
[1] 36601  2234
nrow(object)     # genes
[1] 36601
ncol(object)     # spots
[1] 2234

About 36,600 genes for roughly 2,200 spots. That gene count is close to twice the ~19,000 human protein-coding genes, which is expected: poly-A capture also picks up lncRNAs, pseudogenes, mitochondrial transcripts and antisense transcripts. We deal with that in the next section.

What are we actually looking at?

Before we touch the data, it is worth spending two minutes on the tissue. Everything we do from here on produces coloured dots on this image, and those dots only mean something if you know what is underneath them.

This section is a PCW 10 human heart, cryosectioned at 10 µm. At this stage the four chambers are formed and the heart is growing and maturing rather than being built from scratch. Depending on the cutting plane you should be able to make out:

  • Compact myocardium — the dense outer wall of the ventricles
  • Trabeculated myocardium — the sponge-like inner ventricular wall, with deep recesses opening into the lumen
  • Atrial wall — thinner, smoother, above the atrioventricular plane
  • Interventricular septum — the wall between the two ventricles
  • Valve region / endocardial cushions — pale, loosely packed mesenchyme at the atrioventricular plane
  • Chamber lumens — empty space, often full of blood
  • Epicardium — the thin layer at the outer surface
ImagePlot(object)

Two features of this image will explain a lot of what we see later. The lumens are holes: there is no tissue there, so spots covering them capture very little RNA. And the wall thickness varies enormously across the section, so the amount of tissue under a 55 µm spot is far from constant. Neither is a technical problem — both are anatomy — but both show up in every quality metric we are about to plot.

We can start orienting ourselves molecularly straight away. MYH6 and MYH7 are the atrial and ventricular myosin heavy chains, so plotting them together separates the upper and lower halves of the heart:

MapFeatures(
  object    = object,
  slot      = "counts",
  features  = c("MYH6", "MYH7"),
  pt_size   = 1,
  image_use = "raw"
)

Question
  1. Which regions light up for MYH6, and which for MYH7? Does that match where you expected the atria and ventricles to be?
  2. Find a region where neither is expressed. What is there instead?
  3. Try TNNT2 (all cardiomyocytes), COL1A1 (mesenchyme and valves) or PECAM1 (endothelium and endocardium). Where does each sit?
  1. MYH6 marks the atria, MYH7 the ventricles (strongest in the compact myocardium). They are largely mutually exclusive, which we can check numerically rather than by eye:
d <- FetchData(object, vars = c("MYH6", "MYH7"), layer = "counts")
cor(d$MYH6, d$MYH7, method = "spearman")   # negative = complementary domains
[1] -0.1332847
  1. Spots with neither are not myocardium: chamber lumens (little tissue at all), valve mesenchyme, and the epicardial surface.

  2. TNNT2 covers all myocardium (atria and ventricles), COL1A1 picks out the valve region and mesenchyme, PECAM1 traces endocardium and vessels — a thin outline rather than a solid block.

MapFeatures(object, features = c("TNNT2", "COL1A1", "PECAM1"),
            slot = "counts", pt_size = 1, ncol = 3)

Keep this image in mind

When we cluster the spots later, come back to this section. Clusters that trace a recognisable structure are believable. Clusters that scatter across unrelated regions usually reflect a technical gradient rather than biology.

Cleaning up a bit…

We see about twice as many unique genes as there are human protein-coding genes. Our data comes from poly-A capture, so every polyadenylated transcript gets sequenced — including a lot we did not set out to measure.

Four families are worth removing. All are very highly expressed, so they eat a large share of each spot’s counts, and all vary for reasons unrelated to the anatomy we want to map:

  • **Hemoglobin (HB*)** — an embryonic heart is full of blood, and erythrocytes are packed with hemoglobin transcripts. Keep them and our clusters will report how much blood a spot contains rather than what tissue it is.
  • Mitochondrial (MT-) — only 13 genes, but extremely abundant. In heart they track muscle density and metabolic state, and they also rise with RNA degradation across the slide. Technical gradient mixed with global cell state.
  • Ribosomal proteins (RPL, RPS) — ~80 housekeeping genes, tightly correlated, often 10–25% of a spot’s counts. They move as a block and can dominate the first principal component with “ribosome content”.
  • MALAT1 — a single nuclear lncRNA, often the most-detected feature of all. How much we capture depends mostly on permeabilisation, so it reads out the experiment more than the biology.

This is a project-specific choice, not a rule: if you studied erythropoiesis, hemoglobin would be your signal, not your noise.

In R, we can use simple regular expression or called regex(text patterns that match) to retrieve these genes:

pattern <- paste0(
  "^MT-",                      # 13 mitochondrial genes: MT-ND*, MT-CO*, MT-ATP*, MT-CYB
  "|^HB[ABDEGMQZ][0-9]?$",     # HBA1/2, HBB, HBD, HBE1, HBG1/2, HBM, HBQ1, HBZ — not HBEGF, HBP1, HBS1L
  "|^RP[LS][0-9]+[A-Z]?[0-9]?$|^RPL36AL$|^RPLP[0-9]$|^RPSA$", # ~85 ribosomal proteins — not the RPS6K kinases
  "|^MALAT1$"                  # nuclear lncRNA, often the single most-detected feature
)

undesirable_genes <- grep(pattern, rownames(object), value = TRUE)
undesirable_genes <- setdiff(undesirable_genes, "RPL3L")   # muscle-specific paralog, real cardiac marker

length(undesirable_genes)   # 110
[1] 110
# check nothing was missed
grep("^HB|^RP|^MT-", setdiff(rownames(object), undesirable_genes), value = TRUE)
 [1] "RPS6KA1"     "RPA2"        "RPE65"       "RPF1"        "RPAP2"      
 [6] "RPRD2"       "RPTN"        "RPS6KC1"     "RPIA"        "RPRM"       
[11] "RPE"         "RPUSD3"      "RPP14"       "RPN1"        "RPL34-AS1"  
[16] "HBEGF"       "RPP40"       "RPP21"       "RPS10-NUDT3" "RPF2"       
[21] "HBS1L"       "RPS6KA2"     "RPS6KA2-IT1" "RPS6KA2-AS1" "RPA3"       
[26] "RP9"         "HBP1"        "RP1L1"       "RP1"         "RPP25L"     
[31] "RPP38-DT"    "RPP38"       "RPP30"       "RPARP-AS1"   "RPEL1"      
[36] "RPS6KA4"     "RPS6KB2"     "RPS6KB2-AS1" "RPUSD4"      "RPAP3"      
[41] "RPH3A"       "RPGRIP1"     "RPS6KL1"     "RPS6KA5"     "RPUSD2"     
[46] "RPAP1"       "RPP25"       "RPUSD1"      "RPL3L"       "RPGRIP1L"   
[51] "RPH3AL"      "RPA1"        "RPAIN"       "RPRML"       "RPS6KB1"    
[56] "RPTOR"       "RPRD1A"      "RPN2"        "RPRD1B"      "RPS19BP1"   
[61] "RPS6KA3"     "RPGR"        "RP2"         "RPS6KA6"     "RPA4"       
# check the list of undesirable genes
undesirable_genes
  [1] "RPL22"   "RPL11"   "RPS8"    "RPL5"    "RPS27"   "RPS7"    "RPS27A" 
  [8] "RPL31"   "RPL37A"  "RPL32"   "RPL15"   "RPSA"    "RPL14"   "RPL29"  
 [15] "RPL24"   "RPL22L1" "RPL39L"  "RPL35A"  "RPL9"    "RPL34"   "RPS3A"  
 [22] "RPL37"   "RPS23"   "RPS14"   "RPL26L1" "RPS18"   "RPS10"   "RPL10A" 
 [29] "RPL7L1"  "RPS12"   "RPS20"   "RPL7"    "RPL30"   "RPL8"    "RPS6"   
 [36] "RPL35"   "RPL12"   "RPL7A"   "RPS24"   "RPLP2"   "HBB"     "HBD"    
 [43] "HBG1"    "HBG2"    "HBE1"    "RPL27A"  "RPS13"   "MALAT1"  "RPS3"   
 [50] "RPS25"   "RPS26"   "RPL41"   "RPL6"    "RPLP0"   "RPL21"   "RPL10L" 
 [57] "RPS29"   "RPL36AL" "RPS27L"  "RPL4"    "RPLP1"   "RPS17"   "HBZ"    
 [64] "HBM"     "HBA2"    "HBA1"    "HBQ1"    "RPS2"    "RPS15A"  "RPL13"  
 [71] "RPL26"   "RPL23A"  "RPL23"   "RPL19"   "RPL27"   "RPL38"   "RPL17"  
 [78] "RPS15"   "RPL36"   "RPS28"   "RPL18A"  "RPS16"   "RPS19"   "RPL18"  
 [85] "RPL13A"  "RPS11"   "RPS9"    "RPL28"   "RPS5"    "RPS21"   "RPL3"   
 [92] "RPS4X"   "RPL36A"  "RPL39"   "RPL10"   "RPS4Y1"  "RPS4Y2"  "MT-ND1" 
 [99] "MT-ND2"  "MT-CO1"  "MT-CO2"  "MT-ATP8" "MT-ATP6" "MT-CO3"  "MT-ND3" 
[106] "MT-ND4L" "MT-ND4"  "MT-ND5"  "MT-ND6"  "MT-CYB" 

We can get a list of all the other genes (the ones we want to keep), and see the difference compared to the original object.

genes_to_keep <- setdiff(rownames(object), undesirable_genes)
length(rownames(object)) - length(genes_to_keep)
[1] 110
Question
  1. How many genes have we identified for removal so far?
length(undesirable_genes)                    # total flagged
[1] 110
table(sub("^(MT-|HB|RP|MALAT1).*", "\\1", undesirable_genes))   # by family

    HB MALAT1    MT-     RP 
    10      1     13     86 

110 genes: 13 mitochondrial, 10 hemoglobin, ~86 ribosomal proteins and MALAT1. A small number of genes, but a large share of the counts:

sum(LayerData(object, "counts")[undesirable_genes, ]) /
  sum(LayerData(object, "counts"))           # fraction of all counts removed
[1] 0.3046143

Now we can proceed with a first filtering. We want to remove the undesirable genes, meaning we want to keep only all the other genes. We use the function SubsetSTData() from the semla package, which ensure that the spatial information is also modified correctly.

object <- SubsetSTData(object, features = genes_to_keep)
object
An object of class Seurat 
36491 features across 2234 samples within 1 assay 
Active assay: Spatial (36491 features, 0 variable features)
 1 layer present: counts

Note that when using SubsetSTData() we assigned the result of the function to the same original name object. That action overwrites the previous state of our object. If you want to keep the unfiltered and filtered versions side by side, assign the result to a different variable name.

More systematic filtering

The extra transcripts we sequence and gather in our object do not limit themselves to the 4 categories we successfully removed above. Databases allow us to annotate the remaining genes we sequenced into some categories. Here, we will use the GRCh38-2020-A genome reference which lets us find out which category each gene belongs to.

The complete GRCh38-2020-A genome reference is quite large so here we provide the single file of that reference that you can load in the next chunk.

You do not need to run the next chunk This chunk commented out is for you to see what has been done from the complete reference to get the file we will use next.

# package that help us load large files
library(rtracklayer)

# import the 
gtf <- import("/srv/home/10x_references/refdata-gex-GRCh38-2020-A/genes/genes.gtf")

unique(gtf$type)
# [1] gene           transcript     exon           CDS            start_codon   
# [6] stop_codon     UTR            Selenocysteine

annot <- gtf[gtf$type %in% c("gene")] |>
  mcols() |>
  as.data.frame() |>
  dplyr::select(gene_id, gene_name, gene_type) |>
  dplyr::distinct()

saveRDS(annot, "./data/GRCh38-2020-A_gene_biotypes.rds")

Here we load the lighter version of the reference genome:

annot <- readRDS("./data/GRCh38-2020-A_gene_biotypes.rds")
head(annot)
          gene_id   gene_name      gene_type
1 ENSG00000243485 MIR1302-2HG         lncRNA
2 ENSG00000237613     FAM138A         lncRNA
3 ENSG00000186092       OR4F5 protein_coding
4 ENSG00000238009  AL627309.1         lncRNA
5 ENSG00000239945  AL627309.3         lncRNA
6 ENSG00000239906  AL627309.2         lncRNA
unique(annot$gene_type)
 [1] "lncRNA"          "protein_coding"  "IG_V_pseudogene" "IG_V_gene"      
 [5] "IG_C_gene"       "IG_J_gene"       "TR_C_gene"       "TR_J_gene"      
 [9] "TR_V_gene"       "TR_V_pseudogene" "TR_D_gene"       "IG_C_pseudogene"
[13] "TR_J_pseudogene" "IG_J_pseudogene" "IG_D_gene"      

Here we will use the information of the gene types to keep only the protein coding genes. This step is project dependent, in other cases you might want to also keep the long-non-coding RNA or any types of immunoglobulin transcripts.

# subset the reference genes to only the protein coding genes
annot_protein_coding <- subset(annot, gene_type == "protein_coding")

# fetch the gene names from our object that are annotated as protein coding genes in the reference genome
genes_to_keep_2 <- intersect(rownames(object), annot_protein_coding$gene_name)

# use the same subset idea as before
object <- SubsetSTData(object, features = genes_to_keep_2)

# correct metadata after subset
object$nCount_Spatial   <- colSums(LayerData(object, "counts"))
object$nFeature_Spatial <- colSums(LayerData(object, "counts") > 0)

object
An object of class Seurat 
19280 features across 2234 samples within 1 assay 
Active assay: Spatial (19280 features, 0 variable features)
 1 layer present: counts

We can now see that our object size significantly decreased.

Question
  1. What is the dimension of the data now?
  2. How many genes are we going to work with now?
dim(object)     # genes, spots
[1] 19280  2234
ncol(object)    # spots: unchanged, we only ever filtered genes
[1] 2234

Roughly 19,300 genes remain, down from 36,600. The number of spots has not changed — we removed genes, never spots. That is deliberate, and the next section explains why.

Some QC plots

We now cleaned the genes that we are not interested in. We can now analyse the distributions of these genes with the commonly used histograms, but also spatially.

# check within the object more in details, we see the metadata
head(object@meta.data)
                      orig.ident nCount_Spatial nFeature_Spatial
AAACAGAGCGACTCCT-1 SeuratProject           5651             2586
AAACAGTGTTCCTGGG-1 SeuratProject           8249             3613
AAACCGGGTAGGTACC-1 SeuratProject          10590             4006
AAACCGTTCGTCCAGG-1 SeuratProject           7285             3274
AAACCTCATGAAGTTG-1 SeuratProject           6727             2861
AAACGAAGAACATACC-1 SeuratProject           1756             1106
  • nFeature_Spatial = number of unique genes
  • nCount_Spatial = total number of counts (a genes can be present multiple times)
p1 <- ggplot(object@meta.data, aes(nCount_Spatial)) + geom_histogram()
p2 <- MapFeatures(object = object, features = "nCount_Spatial", slot = "counts", pt_size = 1)

p <- p1 | p2
p

We could decide to remove all spots that have less than a certain number of counts since the section we analyse could have holes or empty regions.

We would do it by running:

# Define thresholds for each subset object (adjust the values as needed)
threshold <- 2000

object$to_keep <- factor(ifelse(object$nCount_Spatial > threshold, "keep", "drop"))
sum(object$to_keep == "drop")
[1] 164
p <- MapLabels(
  object = object,
  column_name = "to_keep",
  pt_size = 1.5
)
p

# spots_to_keep <- names(object$nCount_Spatial[object$nCount_Spatial > threshold])
# object2 <- SubsetSTData(object, spots = spots_to_keep)

Before we continue with the processing, we just need to make sure that no spots contains exactly 0 count. That would produces NaN in some function, then breaking others.

Check:

sum(object$nCount_Spatial == 0) # check that no spots have 0 counts
[1] 0
all(object$nCount_Spatial == colSums(LayerData(object, "counts"))) # check that the metadata is the same data as the layer counts
[1] TRUE

Processing

The data collected by the array and sequenced after library preparation are prone to biological and technical variation. This raw data is called “counts” in a Seurat object.

Before we can compare spots and genes to each other, we apply two corrections:

  • NormalizeData() works per spot. Spots capture different amounts of RNA, so a gene can look highly expressed simply because that spot yielded more molecules overall. Normalization puts every spot on a comparable scale.
  • ScaleData() works per gene. It converts each gene to a z-score, so that a highly expressed gene does not dominate the PCA purely because its values are large.

Note the order: scaling is applied to the normalized values, not to the raw counts.

We checked before by printing the outlook of the object, that we have only the raw counts from after the spaceranger run.

Now, we will need to go through multiple steps:

1. Normalization

Normalize the counts (the amount of RNA and UMIs differ between spots (cells in case of single-cell RNA-seq)). The normalization is done per spot and is a log1p of a per spot scaled count.

Formula:

normalized count = log1p(count / total_counts × 10000)

object_norm <- NormalizeData(object = object)
gene <- "MYH6"

p1 <- ggplot(data.frame(x = LayerData(object, "counts")[gene, ]), aes(x)) +
  geom_histogram(bins = 50) +
  labs(title = paste(gene, "- raw counts"), x = "count per spot")

p2 <- ggplot(data.frame(x = LayerData(object_norm, "data")[gene, ]), aes(x)) +
  geom_histogram(bins = 50) +
  labs(title = paste(gene, "- normalized"), x = "log-normalized value")


p3 <- MapFeatures(
  object = object,
  slot = "counts", # Here we use the non-normalized & unscaled layer
  features = gene,
  pt_size = 1
)

p4 <- MapFeatures(
  object = object_norm,
  slot = "data", # Here we use the normalized and scaled layer
  features = gene,
  pt_size = 1
)

p <- (p1 | p3) / (p2 | p4)
p

2. Find variable features

Some of the downstream processing are relatively computing costly, so we gain from restricting the gene space to only the genes that are the most variable across spots. Excluding the invariable genes help for:

  • Improved signal-to-noise ratio
  • Reduced computational requirements

Using the Seurat suite, we can do that easily by using the function FindVariableFeatures(). That function fits a line to the relationship between the log(variance) and the log(mean) using the local polynomial regression (loess) per gene.

# Find the top 3000 variable features
object_norm <- FindVariableFeatures(
  object_norm, 
  selection.method = "vst", 
  nfeatures = 3000
)
# Generate the scatter plot (Black = non-variable, Red = top 3000 variable)
p1 <- VariableFeaturePlot(object_norm, cols = c("black", "red"))

# Add gene labels for the top 10 genes
p_final <- LabelPoints(
  plot = p1, 
  points = head(VariableFeatures(object_norm), 10), 
  repel = TRUE,
  xnudge = 0,
  ynudge = 0
)
suppressWarnings(print(p_final))

3. Scaling

The scaling correspond to a Z-score transformation which is performed on the normalized count (after normalization step), and is applied per gene (and not per spot).

Formula: Z = (x - mu) / sigma

  • x: normalized count
  • mu: mean expression of the gene we normalize
  • sigma: standard deviation of the gene we normalize

Scaled data have a mean of 0 and a variance of 1. That means that some genes have positive values and some are negative. This scaled data is used mainly when we want to further reduce dimension using PCA.

# let's assign the scaled data again to our variable called object. We will then have all the layers on the same object.
object <- ScaleData(object = object_norm)

Now we can inspect the object again:

object
An object of class Seurat 
19280 features across 2234 samples within 1 assay 
Active assay: Spatial (19280 features, 3000 variable features)
 3 layers present: counts, data, scale.data
Question
  1. How many layers of data do we have in our object now?
  2. Which ones?
  3. What each of these layers correspond to?
Layers(object)
[1] "counts"     "data"       "scale.data"
sapply(Layers(object), function(l) dim(LayerData(object, layer = l)))
     counts  data scale.data
[1,]  19280 19280       3000
[2,]   2234  2234       2234

Three layers:

  • counts — the raw UMI counts, untouched
  • data — NormalizeData(): corrected per spot for sequencing depth
  • scale.data — ScaleData(): z-scored per gene

Note the dimensions differ. scale.data holds only the 3,000 variable features, which is why asking for a non-variable gene from that layer returns an error.

That is all the transformation that we will perform on our data. Let’s visualize how different the same gene can look on the different layers;

p1 <- MapFeatures(object = object,
                  features = "MYH6",
                  slot = "counts") &
  labs(title = "counts")
p2 <- MapFeatures(object = object,
                  features = "MYH6",
                  slot = "scale.data") &
  labs(title = "scale.data")
p3 <- MapFeatures(object = object,
                  features = "MYH6",
                  slot = "data") &
  labs(title = "data")

p <- p1 | p2 | p3
p

These 3 steps allowed us to correct for sequencing depths difference in genes and in spots. We found the genes that have the most amount of variance in the data (the genes that do not change in space in our section are not the most interesting), and scaled the data for PCA.

Clustering

Our data is now ready for downstream analysis. While we could explore individual genes of interest, a state-of-the-art approach borrowed from single-cell RNA-seq is cluster analysis. The primary goal of clustering is to group data points based on overall similarity. In spatial transcriptomics, we group spots that share similar gene expression profiles, which helps differentiate spots corresponding to distinct anatomical structures or cellular microenvironments.

It is again a multi-step processes:

  1. Principal Component Analysis (PCA)
  2. Find the neighbors & Build graph
  3. Find the clusters
  4. Visualize the clusters on a UMAP embedding
  5. Visualize the clusters on the tissue section
  6. Differentially Expressed Genes (DEG)

1. Principal Component Analysis (PCA)

PCA is a data-simplification technique. Instead of tracking 3000 individual genes across every spot, PCA groups together genes that behave similarly, genes telling the same story. This condenses 3000 separate measurements into a few master trends (Principal Components) that still capture the main biological differences in our sample.

The RunPCA() function from Seurat allows us to do this easily. The function will use the scale.data layer under the hood, and use only the top 3000 genes.

object <- RunPCA(object)
p <- ElbowPlot(object = object,
               ndims = 50,
               reduction = "pca",
               plot_type = "variance"
               )
p

2. Find the neighbors & Build graph

Calculate distances between spots in PCA space to construct a Shared Nearest Neighbor (SNN) graph. In that example, we decide to take the first 20 principal components.

object <- FindNeighbors(object = object, reduction = "pca", dims = 1:20)

3. Find the clusters

Partition the SNN graph into distinct groups using community detection algorithms (e.g., Louvain or Leiden). You see that there is a resolution parameter for the FindClusters() function.

object <- FindClusters(object = object, 
                       algorithm = 1, #1 = original Louvain algorithm
                       resolution = 1) #higher number gets more clusters
Modularity Optimizer version 1.3.0 by Ludo Waltman and Nees Jan van Eck

Number of nodes: 2234
Number of edges: 88122

Running Louvain algorithm...
Maximum modularity in 10 random starts: 0.8059
Number of communities: 10
Elapsed time: 0 seconds

4. Visualize the clusters on a UMAP embedding

Project the clusters onto a 2D layout to inspect global relationships. Here we want to visualize each of our spots as clusters that we just computed. So we take the first 20 principal components from our PCA analysis, and project them in a 2D space using the Uniform Manifold Approximation and Projection (UMAP), “a popular data science tool used for machine learning and data visualization. It shrinks complex information with many variables down into a simple 2D or 3D picture.”

object <- RunUMAP(object = object,
                  reduction = "pca",
                  dims = 1:20,
                  seed.use = 42
                  )

Once UMAP is ran, we can visualize the results in a scatter plot. We need to specify the metadata column that contains the label of our clusters. In that case, we use the column called seurat_clusters.

p1 <- DimPlot(object = object, 
             reduction = "umap", 
             group.by = "seurat_clusters") &
  labs(title = "UMAP - each point corresponds to a spot")
p1

5. Visualize the clusters on the tissue section

Overlay cluster colors directly onto the histology image to verify anatomical alignment.

p2 <- MapLabels(object = object,
               column_name = "seurat_clusters",
               pt_size = 1.5)
p2

It might be easier to visualize them side-by-side

p1 <- DimPlot(object = object, 
             reduction = "umap", 
             group.by = "seurat_clusters")
p2 <- MapLabels(object = object,
               column_name = "seurat_clusters",
               pt_size = 1)

p <- p1 | p2
p

6. Differentially Expressed Genes (DEG)

We would like now to identify which genes are the most specific to each of the clusters we identified. To do so we use an analysis method called Differentially Expressed Genes or DEG.

We rely mainly on the FindAllMarkers() function from the Seurat package. If you check the function documentation online, you can find out about the different test that one can choose from. We will use the default Wilcoxon test. The function accounts for the multiple testing. After running the test, we will explore quickly its results.

# Set identity to the clustering column corresponding to the current resolution
object <- SetIdent(object, value = "seurat_clusters")
  
# Differential expression analysis
detable <- FindAllMarkers(object,
                          only.pos = TRUE,
                          max.cells.per.ident = 300,
                          logfc.threshold = 0.1,
                          assay = "Spatial",
                          test.use = "wilcox",
                          min.pct = 0.05)
  
# Optionally, print a glimpse of the markers table
print(head(detable))
             p_val avg_log2FC pct.1 pct.2    p_val_adj cluster  gene
MYL2  2.768468e-49  0.9670315     1 0.949 5.337607e-45       0  MYL2
TPM1  5.060174e-45  0.4885766     1 0.998 9.756016e-41       0  TPM1
CKM   1.978728e-39  0.6731430     1 0.949 3.814988e-35       0   CKM
MYL3  2.991804e-37  0.6973320     1 0.973 5.768198e-33       0  MYL3
TNNC1 1.139420e-36  0.4573792     1 0.992 2.196802e-32       0 TNNC1
ACTC1 1.748147e-36  0.4189044     1 0.995 3.370427e-32       0 ACTC1

We can see that the results is a dataframe containing several metrics in the columns such as the average log fold change (avg_log2FC) or the adjusted p-values (p_val_adj), but also the name of the genes and the clusters it belongs to. We expect to recover known anatomical markers since the test is supposed to pull out which genes are the most specific to our clusters.

We hope to find some of these: - MYH6/NPPA atria, - MYH7/IRX4 ventricle, - TNNT2 pan-cardiomyocyte, - COL1A1/POSTN valve mesenchyme, - PECAM1 endothelium

gene_to_look_up <- "NPPA"
detable |> filter(gene == gene_to_look_up)
             p_val avg_log2FC pct.1 pct.2    p_val_adj cluster gene
NPPA  5.448989e-66  2.3398905     1 0.997 1.050565e-61       3 NPPA
NPPA1 7.498068e-52  2.2608631     1 0.997 1.445627e-47       7 NPPA
NPPA2 7.471038e-06  0.8491217     1 0.997 1.440416e-01       9 NPPA
Question
  1. Do you see some of these genes in the detable?
  2. What do you observe that seems odd?

Yes — and the odd thing is that the same gene appears in several clusters.

nrow(detable)
[1] 10686
detable |> count(gene, sort = TRUE) |> head(5)      # genes flagged most often
   gene n
1 ACTA2 5
2 CALD1 5
3 CDH11 5
4 CORIN 5
5  ENO3 5
detable |> filter(gene == "MYH6") |> select(cluster, avg_log2FC, p_val_adj)
      cluster avg_log2FC    p_val_adj
MYH6        3   2.866364 6.834702e-59
MYH61       7   3.031206 1.431847e-49
MYH62       9   1.554290 8.731867e-08

FindAllMarkers() tests each cluster against all others pooled, one cluster at a time. A gene can therefore be significantly enriched in cluster 2 and cluster 5, as long as each is higher than the rest combined. With several clusters covering myocardium, MYH6 comes out repeatedly. This is why we add pct.diff next: it rewards genes that are specific, not merely high.

Let’s find out more:

dim(detable)
[1] 10686     7
detable |> filter(gene == "MYH6")
             p_val avg_log2FC pct.1 pct.2    p_val_adj cluster gene
MYH6  3.544970e-63   2.866364 1.000 0.776 6.834702e-59       3 MYH6
MYH61 7.426592e-54   3.031206 0.984 0.784 1.431847e-49       7 MYH6
MYH62 4.528977e-12   1.554290 0.902 0.792 8.731867e-08       9 MYH6

We can see that the dimension of the table is quite large, which means that some genes appear in multiple clusters. We also see that when checking all the rows that contain the gene MYH6.

We will see in a second how we can visualize these results easily.

But first, we want to filter these results and add some more metrics

# Filter and add extra columns
detable <- detable[detable$p_val_adj < 0.05, ] #keep only the rows that have an adjusted p-val smaller than 0.05
detable$pct.diff <- detable$pct.1 - detable$pct.2 # add a column called "pct.diff" which is the difference in percentage 1 (%age of spots expressing that gene in cluster of interest) and percentage 2 (%age of spots expressing that gene in all other clusters). We can think that the largest the pct.diff, the more specific the gene is to that cluster.
detable$log.pct.diff <- log2((detable$pct.1 * 99 + 1) / (detable$pct.2 * 99 + 1)) #calculate the log of the pct.diff

Then we can select the top5 markers per clusters

# Select top genes by p_val_adj, pct.diff and avg_log2FC
top5 <- detable |> 
  group_by(cluster) |> 
  slice_min(p_val_adj, n = 20, with_ties = FALSE) |>
  slice_max(pct.diff,  n = 10, with_ties = FALSE) |>
  slice_max(avg_log2FC, n = 5, with_ties = FALSE) |>
  ungroup()
top5
# A tibble: 50 × 9
      p_val avg_log2FC pct.1 pct.2 p_val_adj cluster gene  pct.diff log.pct.diff
      <dbl>      <dbl> <dbl> <dbl>     <dbl> <fct>   <chr>    <dbl>        <dbl>
 1 1.76e-21      2.10  0.505 0.134  3.39e-17 0       FABP4    0.371        1.84 
 2 2.51e-31      1.32  0.908 0.503  4.83e-27 0       PDK4     0.405        0.839
 3 6.96e-21      1.04  0.847 0.443  1.34e-16 0       MASP1    0.404        0.920
 4 6.98e-24      0.855 0.994 0.734  1.35e-19 0       LBH      0.26         0.432
 5 2.40e-25      0.744 1     0.797  4.63e-21 0       COX6…    0.203        0.324
 6 1.43e-47      1.83  0.935 0.416  2.76e-43 1       HEY2     0.519        1.15 
 7 4.24e-42      1.59  0.845 0.402  8.17e-38 1       MT3      0.443        1.05 
 8 1.21e-30      1.25  0.906 0.576  2.33e-26 1       RASD1    0.33         0.644
 9 1.58e-33      0.967 0.978 0.735  3.05e-29 1       LGAL…    0.243        0.407
10 2.17e-30      0.798 0.993 0.816  4.18e-26 1       GJA1     0.177        0.280
# ℹ 40 more rows

And finally visualize it

# Generate a dot plot for the selected genes with a title indicating the grid info
plts <- DotPlot(object, features = unique(top5 |> pull(gene)), assay = "Spatial") +
  coord_flip() +
  theme(axis.text.y = element_text(angle = 0, hjust = 1, vjust = 0.5)) +
  scale_color_gradientn(colours = RColorBrewer::brewer.pal(n = 11, name = "RdBu") |> rev()) +
  scale_x_discrete(limits = rev) +
  scale_y_discrete() +
  labs(x = "", y = "", title = "Dot Plot of spatial clusters") +
  theme(axis.text.x = element_text(angle = 0, hjust = 0.5))
  
plts

Comparing our clusters with the published atlas

Our clustering came from one section, ~2,200 spots, default parameters, and about 20 minutes of work. The published atlas clustered 25,208 spots from 17 sections and resolved 23 spatial clusters covering the major structural components of the developing heart. Comparing the two is a good way to calibrate what a quick analysis can and cannot recover.

Below is a small set of markers for structures the atlas resolved. This is a teaching subset — the complete per-cluster marker tables are in Supplementary Table 1 of the paper.

reference_markers <- list(
  "Atrial myocardium"        = c("MYH6", "NR2F2"),
  "Ventricular myocardium"   = c("MYH7", "IRX4", "MYL2"),
  "Trabecular myocardium"    = c("NPPA", "BMP10"),
  "Cardiomyocyte (general)"  = c("TNNT2", "ACTC1", "TTN"),
  "Endocardium / endothelium"= c("PECAM1", "NPR3", "CDH5"),
  "Valve mesenchyme"         = c("POSTN", "COL1A1", "PENK"),
  "Epicardium"               = c("WT1", "UPK3B", "TBX18"),
  "Conduction system"        = c("SHOX2", "TBX3", "HCN4"),
  "Neural / autonomic"       = c("PRPH", "STMN2", "SOX10"),
  "Proliferating"            = c("MKI67", "TOP2A")
)

# keep only what survived our filtering
reference_markers <- lapply(reference_markers, intersect, y = rownames(object))
reference_markers <- reference_markers[lengths(reference_markers) > 0]

# gene → structure lookup, de-duplicated
g2s <- stack(reference_markers) |>
  setNames(c("gene", "structure")) |>
  dplyr::filter(!duplicated(gene))

p <- DotPlot(object, features = as.character(g2s$gene), assay = "Spatial")

d <- p$data
d$structure <- factor(g2s$structure[match(d$features.plot, g2s$gene)],
                      levels = names(reference_markers))

ggplot(d, aes(x = id, y = features.plot,
              size = pct.exp, colour = avg.exp.scaled)) +
  geom_point() +
  facet_grid(structure ~ ., scales = "free_y", space = "free_y") +
  scale_colour_gradientn(colours = rev(RColorBrewer::brewer.pal(11, "RdBu")),
                         name = "Avg. expression") +
  scale_size(range = c(0, 6), name = "% expressed") +
  labs(x = "cluster", y = NULL,
       title = "Our clusters against published structural markers") +
  theme_bw(base_size = 11) +
  theme(
    strip.text.y.right = element_text(angle = 0, hjust = 0, size = 9),
    strip.background   = element_rect(fill = "grey95", colour = NA),
    axis.text.x = element_text(size = 10),
    axis.text.y = element_text(size = 9),
    panel.grid.minor = element_blank()
  )

Passing a named list to DotPlot() groups the genes by structure, so each block of columns is one candidate identity. A cluster with a strong, isolated block is straightforward to name. A cluster with moderate signal in several blocks is probably a mixture — which is expected, since a 55 µm spot rarely contains only one cell type.

Now try annotating. Fill in your best guess for each cluster:

# Edit the labels, then run. Use NA where you are unsure.
annotation <- c(
  "0" = "?",
  "1" = "?",
  "2" = "?",
  "3" = "?",
  "4" = "?",
  "5" = "?",
  "6" = "?",
  "7" = "?",
  "8" = "?",
  "9" = "?"
)

object$manual_annotation <- unname(annotation[as.character(object$seurat_clusters)])
MapLabels(object, column_name = "manual_annotation", pt_size = 1.5)

Question
  1. Which structures from the list could you assign confidently? Which not at all?
  2. The atlas resolved 23 spatial clusters. How many did we get, and what would you change to get closer?
  3. Several of the missing structures — the sinoatrial and atrioventricular nodes, the chromaffin cells, the distinct valve endothelial populations on either side of a leaflet — occupy very few spots. Why would a single section at this resolution struggle to find them?
  4. Look up one of your clusters’ top markers in the interactive viewer (https://hdcaheart.serve.scilifelab.se). Does its spatial pattern in the full atlas agree with what you see here?
  1. The abundant structures are assignable: atrial and ventricular myocardium, valve mesenchyme, epicardium, endocardium. The rare ones are not.

  2. How many did we get, and how big is each?

nlevels(object$seurat_clusters)
[1] 10
sort(table(object$seurat_clusters))       # spots per cluster

  9   8   7   6   5   4   3   2   1   0 
 61  70 129 157 193 196 197 307 459 465 

To get closer you would need more sections, more spots, and a higher resolution — but resolution alone cannot invent signal that is not there.

  1. Because a cluster needs spots to exist. The smallest structures cover only a handful of 55 µm spots in any one section:
min(table(object$seurat_clusters))        # our smallest cluster
[1] 61
round(100 * min(table(object$seurat_clusters)) / ncol(object), 1)  # % of section
[1] 2.7

The SA node is smaller than that. Louvain has nothing to find.

  1. Open the viewer and compare — most abundant-structure markers will agree, which is a good sign that our coarse clustering is not wrong, just incomplete.

Three reasons, all worth internalising.

Statistical power. Rare structures need spots. The conduction system occupies a tiny fraction of any section, so with ~2,200 spots there may be only a handful of spots covering the sinoatrial node — not enough to form their own cluster at any resolution.

Sampling. Whether a structure appears at all depends on the cutting plane. The atlas selected 17 sections that each contained at least three chambers; ours is whatever we were given.

Resolution and parameters. We used resolution = 1 and 20 principal components without examining alternatives. Try resolution = 0.4 and 1.5 and watch structures merge and split. There is no correct value — there is a value appropriate to the question.

None of this makes our clustering wrong. It makes it coarse, and knowing which of your results are robust to these choices is most of the skill in this kind of analysis.

Now we will do the same but with 3 sections

Researchers usually have specific questions which require the joint analysis of multiple sections simultaneously. Here we will add 2 extra heart sections of 2 different time points. We will repeat all the steps we did above, this time less explanation to read, and you can run quicker the code that allows you to do some temporal exploration.

1. Build infoTable

set.seed(1)

# Join metadata on sample_id rather than trusting that file order matches CSV
# row order — otherwise sections can silently get the wrong age.

metadata <- read.csv("./data/metadata.csv")

samples   <- Sys.glob("./data/spaceranger/*/filtered_feature_bc_matrix.h5")
imgs      <- Sys.glob("./data/spaceranger/*/spatial/tissue_hires_image.png")
spotfiles <- Sys.glob("./data/spaceranger/*/spatial/tissue_positions_list.csv")
json      <- Sys.glob("./data/spaceranger/*/spatial/scalefactors_json.json")

stopifnot(length(samples) == length(imgs),
          length(samples) == length(spotfiles),
          length(samples) == length(json))

infoTable <- tibble(samples, imgs, spotfiles, json) |>
  mutate(sample_id = basename(dirname(samples)),
         slide_id  = sample_id) |>
  left_join(metadata, by = "sample_id") |>
  arrange(pcw_age)

stopifnot(!any(is.na(infoTable$pcw_age)))
print(infoTable)                            # eyeball before loading
# A tibble: 3 × 10
  samples  imgs  spotfiles json  sample_id slide_id organ organism pcw_age sex  
  <chr>    <chr> <chr>     <chr> <chr>     <chr>    <chr> <chr>      <int> <chr>
1 ./data/… ./da… ./data/s… ./da… V10A13-1… V10A13-… heart human          6 male 
2 ./data/… ./da… ./data/s… ./da… V10A13-1… V10A13-… heart human          9 fema…
3 ./data/… ./da… ./data/s… ./da… V10F24-1… V10F24-… heart human         10 male 

Here you see in the column pcw_age (post conceptional week), that we have 3 sections of 3 distinct ages. We also know the sex of the embryos, let’s figure it out first!

2. Load data

This time we will call our object se, short for Seurat object. Note that now we are loading all the information of the 3 sections into a single object.

se <- ReadVisiumData(infoTable)
se <- LoadImages(se, image_height = 400)

se$sample_id <- factor(se$sample_id, levels = unique(se$sample_id))
ImagePlot(se, ncol = 3)

3. Gene filtering

We repeat the 2 filtering steps we did earlier. Removing the MT, HB, RP and MALAT1, and annotating the genes in order to keep only the protein coding genes.

But first, let’s take advantage of some transcript to identify the sex of each section:

sex_genes <- c("XIST", "RPS4Y1", "DDX3Y", "UTY", "EIF1AY", "KDM5D")
FetchData(se, layer = "counts", vars = intersect(sex_genes, rownames(se))) |>
  mutate(sample_id = se$sample_id) |>
  group_by(sample_id) |>
  summarise(across(everything(), mean))
# A tibble: 3 × 7
  sample_id        XIST RPS4Y1 DDX3Y    UTY EIF1AY KDM5D
  <fct>           <dbl>  <dbl> <dbl>  <dbl>  <dbl> <dbl>
1 V10A13-160_A1 0.00163  16.0  1.81  0.553    2.45 1.14 
2 V10A13-157_C1 1.97      0    0     0        0    0    
3 V10F24-105_C1 0         4.12 0.481 0.0868   1.09 0.226

We used raw counts (before normalization), because we do the processing steps after filtering. Here the values are more of a qualitative measurement rather than quantitative. We want to know if the genes are there (male) or not (female).

Question
  1. Does it correspond to what is reported from the metadata?

Yes. XIST is high only in the female section; the Y-linked genes are present only in the male ones. We can make the call explicit and compare it to the metadata:

FetchData(se, layer = "counts", vars = intersect(sex_genes, rownames(se))) |>
  mutate(sample_id = se$sample_id) |>
  group_by(sample_id) |>
  summarise(across(everything(), mean)) |>
  mutate(predicted = ifelse(XIST > DDX3Y, "female", "male")) |>
  left_join(infoTable |> select(sample_id, sex), by = "sample_id") |>
  select(sample_id, XIST, DDX3Y, predicted, reported = sex)
# A tibble: 3 × 5
  sample_id        XIST DDX3Y predicted reported
  <chr>           <dbl> <dbl> <chr>     <chr>   
1 V10A13-160_A1 0.00163 1.81  male      male    
2 V10A13-157_C1 1.97    0     female    female  
3 V10F24-105_C1 0       0.481 male      male    

Note that this only works before filtering: XIST is a lncRNA and would be removed by the protein-coding filter, and RPS4Y1 is caught by our ribosomal pattern.

infoTable |> 
  select(sample_id, pcw_age, sex)
# A tibble: 3 × 3
  sample_id     pcw_age sex   
  <chr>           <int> <chr> 
1 V10A13-160_A1       6 male  
2 V10A13-157_C1       9 female
3 V10F24-105_C1      10 male  

Now we can proceed with our filtering:

pattern <- paste0(
  "^MT-",
  "|^HB[ABDEGMQZ][0-9]?$",
  "|^RP[LS][0-9]+[A-Z]?[0-9]?$|^RPL36AL$|^RPLP[0-9]$|^RPSA$",
  "|^MALAT1$"
)

undesirable_genes <- setdiff(grep(pattern, rownames(se), value = TRUE), "RPL3L")
annot             <- readRDS("./data/GRCh38-2020-A_gene_biotypes.rds")
protein_coding    <- subset(annot, gene_type == "protein_coding")$gene_name

genes_to_keep <- setdiff(rownames(se), undesirable_genes) |> intersect(protein_coding)
se <- SubsetSTData(se, features = genes_to_keep)

# metadata is NOT refreshed by subsetting — recompute it
se$nCount_Spatial   <- colSums(LayerData(se, "counts"))
se$nFeature_Spatial <- colSums(LayerData(se, "counts") > 0)

stopifnot(sum(se$nCount_Spatial == 0) == 0)
se
An object of class Seurat 
19280 features across 4394 samples within 1 assay 
Active assay: Spatial (19280 features, 0 variable features)
 1 layer present: counts

4. Per-section QC

p1 <- ggplot(se@meta.data, aes(nCount_Spatial, fill = sample_id)) +
  geom_histogram(bins = 50) +
  facet_wrap(~ sample_id, scales = "free_y") +
  labs(title = "Library size per spot, by section")

p2 <- MapFeatures(se, features = "nCount_Spatial", pt_size = 1, ncol = 3, label_by = "sample_id")

p <- p1 / p2
p

5. Processing

se <- NormalizeData(se)
se <- FindVariableFeatures(se, selection.method = "vst", nfeatures = 3000)
se <- ScaleData(se)
se <- RunPCA(se, npcs = 50)

ElbowPlot(se, ndims = 50, reduction = "pca", plot_type = "variance")

6. Batch correction

Three sections were captured on different slides, so part of what separates them is technical. Harmony adjusts the PCA embedding so that spots group by biology rather than by slide. Run the clustering below BOTH ways and compare: if clusters split perfectly by sample_id on “pca”, that split is batch.

se <- harmony::RunHarmony(se, group.by.vars = "sample_id",
                          reduction = "pca", reduction.save = "harmony")
Initializing centroids

7. Clustering: uncorrected versus corrected

We now cluster twice, once on the raw PCA and once on the Harmony-corrected embedding, and keep both results so we can compare them side by side. Everything else — the number of dimensions, the algorithm, the resolution — stays identical, so any difference we see comes from the correction alone.

# 7.1 — clustering on the uncorrected PCA
se <- FindNeighbors(se, reduction = "pca", dims = 1:20)
se <- FindClusters(se, algorithm = 1, resolution = 0.8)
Modularity Optimizer version 1.3.0 by Ludo Waltman and Nees Jan van Eck

Number of nodes: 4394
Number of edges: 152102

Running Louvain algorithm...
Maximum modularity in 10 random starts: 0.8819
Number of communities: 17
Elapsed time: 0 seconds
se$clusters_pca <- se$seurat_clusters      # save before it gets overwritten
se <- RunUMAP(se, reduction = "pca", dims = 1:20, seed.use = 42,
              reduction.name = "umap_pca")

# 7.2 — same steps, this time on the Harmony embedding
se <- FindNeighbors(se, reduction = "harmony", dims = 1:20)
se <- FindClusters(se, algorithm = 1, resolution = 0.8)
Modularity Optimizer version 1.3.0 by Ludo Waltman and Nees Jan van Eck

Number of nodes: 4394
Number of edges: 163592

Running Louvain algorithm...
Maximum modularity in 10 random starts: 0.8428
Number of communities: 16
Elapsed time: 0 seconds
se$clusters_harmony <- se$seurat_clusters
se <- RunUMAP(se, reduction = "harmony", dims = 1:20, seed.use = 42,
              reduction.name = "umap_harmony")

Note that we copy seurat_clusters into a new column each time. FindClusters() always writes to that same column, so without the copy the first result would be lost. The same applies to RunUMAP(), which is why we give each embedding its own reduction.name.

Are the spots grouping by biology or by section?

The clearest diagnostic is to colour the UMAP by section rather than by cluster. If spots separate cleanly by sample_id, the dominant signal is where the spot came from, not what tissue it is.

p1 <- DimPlot(se, reduction = "umap_pca",     group.by = "sample_id") + ggtitle("Before (PCA)")
p2 <- DimPlot(se, reduction = "umap_harmony", group.by = "sample_id") + ggtitle("After (Harmony)")

p <- p1 | p2
p

On the uncorrected embedding, the three sections form largely separate territories. After Harmony they overlap, meaning spots from different sections now sit next to each other when they are transcriptionally similar.

We can also ask how the two clusterings relate. Each row is an uncorrected cluster, each column a corrected one:

table(se$clusters_pca, se$clusters_harmony)
    
       0   1   2   3   4   5   6   7   8   9  10  11  12  13  14  15
  0   21 374   0   2   0  57   3   1   2   0  47   0   0  11  12   0
  1    2  87 188 186   0   8   1   0   1   0   0   0   0   0   2   3
  2  442   8   3   0   0   1   0   0   5   0   2   0   0   0  10   0
  3    0   0   0   0 203   0 122   0   0   0   0   0  54   0   0   0
  4  324  17   1   0   0  15   0   0   1   0   6   0   0   2   2   0
  5   61   4   8 102   0  93   0  12   5   0   3   0   0  13   2   9
  6   11   3 193  27   1  31   0   0   1   0   0   0   0   0   0  30
  7    1   1   0   0   0   1   2 189   1   0  36  19   1  42   0   0
  8    0   0   0   0 100  30 124   3   0   0   3   0   7   0   0   0
  9    0 165   0  83   0   2   0   0   9   0   0   0   0   1   0   0
  10   0   0   0   0   0   0   0  12   0 151   0   0   0   0   0   0
  11   1   0   0   2   0  96   0   2   2   0   0  45   0   0   0   0
  12   0   0  66  11   0   0   0   0  58   0   0   0   0   0   2   4
  13   0   0   0   0  38   0  41   0   0   0   0   0  18   0   0   0
  14   0   0   0   5   0   0  10   0  80   0   0   0   0   0   0   0
  15   0   0   0   0   0   3   0  36   0   0   0  24   0   0   0   0
  16   1   1   0   0   0   1   0   1   0   0   0   0   0   0  28   0

A cluster whose spots spread across many columns was split or reassigned by the correction. A tight diagonal block means that cluster was robust either way.

Back to the tissue

The UMAP is a summary; the section is the ground truth. A good correction should make the same anatomical structure receive the same cluster label in all three sections.

MapLabels(se, column_name = "clusters_pca", pt_size = 1, ncol = 3) +
  plot_layout(guides = "collect") &
  guides(fill = guide_legend(override.aes = list(size = 3), 
                             ncol = 2)) &
  theme(legend.position = "right")

MapLabels(se, column_name = "clusters_harmony", pt_size = 1, ncol = 3) +
  plot_layout(guides = "collect") &
  guides(fill = guide_legend(override.aes = list(size = 3), 
                             ncol = 2)) &
  theme(legend.position = "right")

Question
  1. In the uncorrected version, do the three sections share cluster labels, or does each section have largely its own?
  2. Pick a structure you can identify on the H&E, for example the ventricular wall. Does it get the same label across sections before correction? After?
  1. Before correction, many clusters are dominated by a single section. We can count them rather than squint at the UMAP — for each cluster, what share of its spots comes from its most common section?
purity <- function(clusters, samples) {
  tab <- table(clusters, samples)
  round(apply(prop.table(tab, 1), 1, max), 2)
}

p_before <- purity(se$clusters_pca,     se$sample_id)
p_after  <- purity(se$clusters_harmony, se$sample_id)

c(n_clusters_before = length(p_before),
  n_clusters_after  = length(p_after))
n_clusters_before  n_clusters_after 
               17                16 
summary(p_before)
   Min. 1st Qu.  Median    Mean 3rd Qu.    Max. 
 0.7800  0.9800  1.0000  0.9776  1.0000  1.0000 
summary(p_after)
   Min. 1st Qu.  Median    Mean 3rd Qu.    Max. 
 0.4200  0.4950  0.5350  0.5725  0.6125  1.0000 

A value near 1 means “this cluster is essentially one section”. Count how many clusters cross 90%:

c(before = sum(p_before > 0.9),
  after  = sum(p_after  > 0.9))
before  after 
    16      1 
  1. After correction the ventricular wall should carry the same label in all three sections. Before, it typically has one label per section — the same biology split three ways by slide.

Careful though: with one section per age, some of what Harmony removed is real developmental difference. See the limitation box below.

An important limitation of this dataset

We have one section per age, so sample_id and pcw_age carry exactly the same information:

table(se$sample_id, se$pcw_age)
               
                   6    9   10
  V10A13-160_A1  615    0    0
  V10A13-157_C1    0 1545    0
  V10F24-105_C1    0    0 2234

That means batch and biology are perfectly confounded here. When we tell Harmony to remove differences between sections, we are also telling it to remove differences between developmental stages — it has no way to tell them apart. The corrected clustering is therefore useful for finding shared anatomy across sections, but it cannot be used as evidence that a structure is or is not changing with age.

The published atlas avoided this by using at least two near-consecutive sections per heart as technical replicates, across 16 hearts spanning PCW 6 to 12. With replicates, the section-to-section variation can be estimated separately from the age effect.

Batch correction is as much judgement as method, and it depends on the question being asked. Over-correct and you erase real differences between samples; under-correct and the technical signal drives your clusters. Harmony exposes several parameters (notably theta, which controls how aggressively it mixes groups) that are worth reading about the day you need this on your own data.

8. Cluster markers

se <- SetIdent(se, value = "clusters_harmony")

detable <- FindAllMarkers(se,
                          only.pos = TRUE,
                          max.cells.per.ident = 300,
                          logfc.threshold = 0.1,
                          assay = "Spatial",
                          test.use = "wilcox",
                          min.pct = 0.05)

detable <- detable |>
  filter(p_val_adj < 0.05) |>
  mutate(pct.diff = pct.1 - pct.2)

# slice_* rather than top_n: p_val_adj has many exact ties at 0
top5 <- detable |>
  group_by(cluster) |>
  slice_min(p_val_adj, n = 20, with_ties = FALSE) |>
  slice_max(pct.diff,  n = 10, with_ties = FALSE) |>
  slice_max(avg_log2FC, n = 5, with_ties = FALSE) |>
  ungroup()

DotPlot(se, features = unique(top5$gene), assay = "Spatial") +
  coord_flip() +
  scale_color_gradientn(colours = rev(RColorBrewer::brewer.pal(11, "RdBu"))) +
  scale_x_discrete(limits = rev) +
  labs(x = "", y = "", title = "Cluster markers across 3 sections")

Here, each cluster shares spots coming from our 3 sections, which means that the markers we find in our DEG analysis, are markers that best describe these populations. This can be very useful to understand which markers drive these spots to cluster together and we expect to find markers that are significantly present in all sections analysed.

Question
  1. Do we see difference with the clustering from the first part (1 section)?
  2. Why do you think we observe these differences?
c(one_section    = nlevels(object$seurat_clusters),
  three_sections = nlevels(se$clusters_harmony))
   one_section three_sections 
            10             16 
c(spots_one   = ncol(object),
  spots_three = ncol(se))
  spots_one spots_three 
       2234        4394 

Three reasons for the difference:

  • More spots means more statistical power, so structures too small to separate in one section can now form their own cluster.
  • Three developmental stages are pooled, so a cluster must describe a structure across PCW 6 to 10 rather than at a single moment.
  • Harmony reshaped the embedding, and cluster numbering is arbitrary anyway — cluster 3 here has nothing to do with cluster 3 in part 1.

The markers are the reliable comparison, not the labels:

top_now <- detable |> group_by(cluster) |> slice_max(avg_log2FC, n = 10) |>
  pull(gene) |> unique()
length(top_now)
[1] 158

We can also plot a single marker on all the sections simultaneously:

gene <- "MYH6"

p <- MapFeatures(
  object = se,
  features = gene,
  ncol = 3
)
p

9. Checkpoint

When analysis get long, it is common practice to save the status of our object. Many results such as plots and tables can be saved along the way too. Here we will simply see how it is done, but in order to not clutter the server at our disposal, we will not save it.

# dir.create("./checkpoints", showWarnings = FALSE)         # create a directory to save results
# saveRDS(se,      "./checkpoints/3sections_clustered.rds") # save the 3 sections object 
# saveRDS(detable, "./checkpoints/3sections_markers.rds")   # save the detable

# se <- readRDS("./checkpoints/3sections_clustered.rds")    # load the saved object, when we want to continue working on the project

Advanced analysis

10. Temporal DEG

We just did a DEG analysis on the 3 sections together, but an information we do not know yet is what markers are specific per clusters AND time point. For that we need to subset each clusters per time points individually and repeat FindAllMarkers() function to look at each of the time point separately.

Let’s try it out:

1. Select cluster of choice to split per time point

# pick a cluster that is well represented in all age
table(se$clusters_harmony, se$pcw_age)
    
       6   9  10
  0   62 338 464
  1    4 189 467
  2   74 194 191
  3  113 117 188
  4   38 110 194
  5   96 174  68
  6   43 130 130
  7   48  35 173
  8   62  88  15
  9    0   0 151
  10   3  24  70
  11  24  45  19
  12  18  17  45
  13  13  27  29
  14   4  30  24
  15  13  27   6
cl  <- "3"

sub <- SubsetSTData(se, spots = colnames(se)[which(se$clusters_harmony == cl)]) # subset the se object to only the spots from our cluster of choice
sub$pcw_age <- factor(sub$pcw_age, levels = sort(unique(as.numeric(se$pcw_age)))) # to get ages in the right order
Idents(sub) <- "pcw_age"

res <- FindAllMarkers(sub,
                      only.pos = TRUE,
                      max.cells.per.ident = 300,
                      logfc.threshold = 0.1,
                      assay = "Spatial",
                      test.use = "wilcox",
                      min.pct = 0.05) |>
  filter(p_val_adj < 0.05)

res <- as_tibble(res)
res_top <- res |> dplyr::rename(age = cluster) |> group_by(age) |> slice_max(avg_log2FC, n = 10) |> ungroup()

By doing this, we isolate all the spots corresponding to a cluster of interest and ask “Within this cluster, which are the markers that differ the most across age/section?”. The way it is calculated is, taking the spots of the clusters of choice of 1 age (e.g. pcw 6) and compare it to the spots of age 2 and 3 together (e.g. pcw 9 and 10 pooled together). This step is repeated for all the age categories (pcw 9 against 6 & 10, and then pcw 10 against 6 and 9 together).

2. Visualisation

top2_per_age <- res_top |>
  group_by(age) |>
  slice_min(p_val_adj, n = 2, with_ties = FALSE) |>
  ungroup()

top_2_markers <- unique(top2_per_age$gene)

VlnPlot(sub, features = top_2_markers, group.by = "pcw_age", ncol = 2)

# Plot top2 markers per category
DotPlot(sub, features = top_2_markers, assay = "Spatial") +
  coord_flip() +
  scale_color_gradientn(colours = rev(RColorBrewer::brewer.pal(11, "RdBu"))) +
  scale_x_discrete(limits = rev) +
  labs(x = "", y = "", title = "Age-associated genes within a cluster")

But maybe, the actual spatial plot might be even easier. Let’s plot it on the 3 sections.

MapFeatures(
  object = se,
  features = top_2_markers,
  ncol = 3,
  pt_size = 0.8
)

11. How to take advantage of the spatial information bioinformatically

We covered many steps until here. Many of the steps that are covered in most Visium data analysis. But it is important to note that most of these steps are borrowed from single-cell RNA seq. But we can do way more with spatial data by taking advantage of the spots coordinates as an actual spatial information, instead of just as a barcode.

A question that often comes out is:

“Are the genes we called variable actually spatially structured?”

A gene might have an intrinsic high variability across spots, but its expression be completely scattered across the tissue section. We can rely on some spatial statistics to answer that question.

# select the top 500 variable features
test_features <- VariableFeatures(se)[1:500]

# evaluate their spatial autocorrelation
sp_cor <- CorSpatialFeatures(object = se, features = test_features)

# Check the list on section 1
# genes with the most spatially structured expression
head(sp_cor[[1]], 20)   
# A tibble: 20 × 2
   gene      cor
   <chr>   <dbl>
 1 NPPA    0.970
 2 MYH6    0.958
 3 FBLN5   0.915
 4 ELN     0.895
 5 COL1A1  0.893
 6 SULT1E1 0.880
 7 OGN     0.866
 8 BMP10   0.850
 9 CRABP2  0.847
10 SLN     0.847
11 PAM     0.844
12 COL1A2  0.843
13 PI15    0.835
14 GPC3    0.834
15 HTRA1   0.829
16 CXCL12  0.829
17 ANKRD1  0.815
18 ACTA1   0.812
19 MFAP4   0.810
20 IRX3    0.805
# genes with the least spatially structured expression
tail(sp_cor[[1]], 20)
# A tibble: 20 × 2
   gene           cor
   <chr>        <dbl>
 1 ONECUT2   -0.00546
 2 CELF3     -0.00951
 3 CPA3      -0.0101 
 4 PHOX2B    -0.0104 
 5 SIGLEC15  -0.0108 
 6 DUSP9     -0.0112 
 7 PRG4      -0.0113 
 8 NEFM      -0.0115 
 9 MYO3B     -0.0116 
10 NECAB2    -0.0150 
11 CXCL13    -0.0169 
12 DBH       -0.0197 
13 TREML1    -0.0259 
14 TAC1      -0.0270 
15 NKG7      -0.0328 
16 DEFA3    NaN      
17 KRT13    NaN      
18 RHEX     NaN      
19 CHL1     NaN      
20 OPRM1    NaN      

Again, it is the best to visualize them spatially:

Section 1

section_number <- 1

positive_corr <- sp_cor[[section_number]]$gene[1:6]

MapFeatures(
  object = se,
  features = positive_corr,
  section_number = section_number,
  ncol = 3
)

negative_corr <- tail(subset(sp_cor[[section_number]], !is.nan(cor))$gene, 6)

MapFeatures(
  object = se,
  features = negative_corr,
  section_number = section_number,
  ncol = 3
)

Section 2

section_number <- 2

positive_corr <- sp_cor[[section_number]]$gene[1:6]

MapFeatures(
  object = se,
  features = positive_corr,
  section_number = section_number,
  ncol = 3
)

negative_corr <- tail(subset(sp_cor[[section_number]], !is.nan(cor))$gene, 6)

MapFeatures(
  object = se,
  features = negative_corr,
  section_number = section_number,
  ncol = 3
)

Section 3

section_number <- 3

positive_corr <- sp_cor[[section_number]]$gene[1:6]

MapFeatures(
  object = se,
  features = positive_corr,
  section_number = section_number,
  ncol = 3
)

negative_corr <- tail(subset(sp_cor[[section_number]], !is.nan(cor))$gene, 6)

MapFeatures(
  object = se,
  features = negative_corr,
  section_number = section_number,
  ncol = 3
)

Closing note

In this workshop we went through the most important pre-processing, processing, clustering steps for a single section and for a couple of sections together. We explored some quality metrics, tried to correct for batch effect using Harmony, and explored a way to identify temporal changes across clusters of interest. Finally we understood that this is just the start, and from there much more spatially aware analyses can be performed.

Due to lack of time, we have not covered deconvolution, niche analysis, pathways, spatially aware clustering, spatial interactions, pseudotime analysis, and more.

For further explorations, here are some suggested resources:

sessionInfo()
R version 4.3.3 (2024-02-29)
Platform: x86_64-pc-linux-gnu (64-bit)
Running under: Ubuntu 24.04.4 LTS

Matrix products: default
BLAS:   /usr/lib/x86_64-linux-gnu/blas/libblas.so.3.12.0 
LAPACK: /usr/lib/x86_64-linux-gnu/lapack/liblapack.so.3.12.0

locale:
 [1] LC_CTYPE=C.UTF-8       LC_NUMERIC=C           LC_TIME=C.UTF-8       
 [4] LC_COLLATE=C.UTF-8     LC_MONETARY=C.UTF-8    LC_MESSAGES=C.UTF-8   
 [7] LC_PAPER=C.UTF-8       LC_NAME=C              LC_ADDRESS=C          
[10] LC_TELEPHONE=C         LC_MEASUREMENT=C.UTF-8 LC_IDENTIFICATION=C   

time zone: Etc/UTC
tzcode source: system (glibc)

attached base packages:
[1] stats     graphics  grDevices utils     datasets  methods   base     

other attached packages:
 [1] future_1.75.0      harmony_2.0.5      Rcpp_1.1.2         RColorBrewer_1.1-3
 [5] patchwork_1.3.2    semla_1.4.2        Seurat_5.5.1       SeuratObject_5.4.0
 [9] sp_2.2-3           lubridate_1.9.5    forcats_1.0.1      stringr_1.6.0     
[13] dplyr_1.2.1        purrr_1.2.2        readr_2.2.0        tidyr_1.3.2       
[17] tibble_3.3.1       ggplot2_4.0.3      tidyverse_2.0.0   

loaded via a namespace (and not attached):
  [1] rstudioapi_0.19.0      jsonlite_2.0.0         magrittr_2.0.5        
  [4] spatstat.utils_3.2-4   magick_2.9.1           farver_2.1.2          
  [7] rmarkdown_2.31         ragg_1.5.2             vctrs_0.7.3           
 [10] ROCR_1.0-12            spatstat.explore_3.8-2 htmltools_0.5.9       
 [13] sctransform_0.4.3      parallelly_1.48.0      KernSmooth_2.23-22    
 [16] htmlwidgets_1.6.4      ica_1.0-3              plyr_1.8.9            
 [19] plotly_4.12.1          zoo_1.9-0              igraph_2.3.3          
 [22] mime_0.13              lifecycle_1.0.5        pkgconfig_2.0.3       
 [25] Matrix_1.6-5           R6_2.6.1               fastmap_1.2.0         
 [28] fitdistrplus_1.2-6     shiny_1.14.0           digest_0.6.39         
 [31] tensor_1.5.1           RSpectra_0.16-2        irlba_2.3.7           
 [34] textshaping_1.0.5      labeling_0.4.3         progressr_1.0.0       
 [37] spatstat.sparse_3.2-0  timechange_0.4.0       httr_1.4.8            
 [40] polyclip_1.10-7        abind_1.4-8            compiler_4.3.3        
 [43] bit64_4.8.4            withr_3.0.3            S7_0.2.2              
 [46] fastDummies_1.7.6      MASS_7.3-60.0.1        tools_4.3.3           
 [49] lmtest_0.9-40          otel_0.2.0             httpuv_1.6.17         
 [52] future.apply_1.20.2    goftest_1.2-3          glue_1.8.1            
 [55] dbscan_1.2.6           nlme_3.1-164           promises_1.5.0        
 [58] grid_4.3.3             Rtsne_0.17             cluster_2.1.6         
 [61] reshape2_1.4.5         generics_0.1.4         hdf5r_1.3.12          
 [64] gtable_0.3.6           spatstat.data_3.1-9    tzdb_0.5.0            
 [67] data.table_1.18.6.1    hms_1.1.4              utf8_1.2.6            
 [70] spatstat.geom_3.8-2    RcppAnnoy_0.0.23       ggrepel_0.9.8         
 [73] RANN_2.6.3             pillar_1.11.1          spam_2.11-4           
 [76] RcppHNSW_0.7.0         later_1.4.8            splines_4.3.3         
 [79] lattice_0.22-5         bit_4.6.0              survival_3.5-8        
 [82] deldir_2.0-4           tidyselect_1.2.1       miniUI_0.1.2          
 [85] pbapply_1.7-4          knitr_1.51             gridExtra_2.3.1       
 [88] scattermore_1.2        RhpcBLASctl_0.23-42    xfun_0.60             
 [91] matrixStats_1.5.0      stringi_1.8.9          yaml_2.3.12           
 [94] evaluate_1.0.5         codetools_0.2-19       cli_3.6.6             
 [97] uwot_0.2.4             xtable_1.8-8           reticulate_1.46.0     
[100] systemfonts_1.3.2      globals_0.19.1         spatstat.random_3.5-1 
[103] zeallot_0.2.0          png_0.1-9              spatstat.univar_3.2-0 
[106] parallel_4.3.3         dotCall64_1.2          listenv_1.0.0         
[109] viridisLite_0.4.3      scales_1.4.0           ggridges_0.5.7        
[112] rlang_1.3.0            cowplot_1.2.0          shinyjs_2.1.1