Skip to contents

Overview

Note: This tutorial demonstrates how to use Seurat with 10x Atera data. A full vignette will be published at a later date.

In this tutorial, we analyze an Atera FFPE human kidney dataset with Seurat. We load cell-level counts and spatial coordinates, visualize individual transcripts and codewords, and examine cell boundaries in a selected region. We then use a sketch of the dataset to identify clusters and project the results back to all cells.

We use the publicly available Atera In Situ Gene Expression, FFPE Human Kidney dataset from 10x Genomics. This non-diseased tissue sample was profiled with the Atera Human Whole Transcriptome Assay (WTA) and processed with Atera Onboard Analysis v1.0.4.

The workflow keeps the full count matrix on disk with BPCells and performs clustering on a smaller sketch of cells. Transcript coordinates are fetched for selected genes, while cell boundaries are loaded only for a cropped region. These choices reduce the memory needed to explore the tissue.

Load libraries

We load the analysis and plotting libraries below and configure parallel processing. Setting a future plan controls the number of processes used for Atera reads. setThreads() controls threading in downstream Seurat functions independently.

The blosc package is required for reading compressed Zarr arrays.

if (!requireNamespace("blosc", quietly = TRUE)) {
  install.packages("blosc")
}

Load the Atera dataset

We use LoadAtera() to create a Seurat object containing the counts, cell centroids, and a handle for fetching transcript coordinates.

Download and extract the Atera FFPE human kidney dataset from 10x Genomics, and set data_dir to the extracted directory. The bundle contains expression matrices, cell boundaries, transcript coordinates, and morphology images.

To keep the analysis practical for large datasets, we store counts on disk with BPCells by specifying a cache directory with bpcells_dir. We recommend using a separate cache for each dataset, and reusing that cache for subsequent analyses. Opening an existing cache avoids rebuilding the count matrices, but transcript coordinates are still read separately when requested. Here, we defer segmentations until we select a region of interest and skip morphology images.

data_dir <- "/brahms/zhangbr/vignette_data/atera_kidney"
bpcells_dir <- "atera-kidney-bpcells"
atera_obj <- LoadAtera(data.dir = data_dir,
                      fov = "fov",
                      assay = "Atera",
                      cell.centroids = TRUE,
                      molecule.coordinates = TRUE,
                      bpcells.dir = bpcells_dir,
                      segmentations = NULL,
                      cell.annotations = TRUE,
                      morphology.image = FALSE)

atera_obj
## An object of class Seurat 
## 24674 features across 399120 samples within 6 assays 
## Active assay: Atera (18049 features, 0 variable features)
##  1 layer present: counts
##  5 other assays present: DeprecatedCodeword, BlankCodeword, ControlCodeword, ControlProbe, GenomicControl
##  1 spatial field of view present: fov

In this dataset, Atera is the gene-expression assay used for normalization and clustering. The other assays store the non-gene feature categories, with the spatial and morphological information required for downstream visualization and analysis.

Assays(atera_obj)
## [1] "Atera"              "DeprecatedCodeword" "BlankCodeword"     
## [4] "ControlCodeword"    "ControlProbe"       "GenomicControl"

Cell type annotations

The Atera Onboard Analysis pipeline performs cell type annotation with Pan-human Azimuth for human samples by default.

LoadAtera() will add the azimuth_* columns from obs in cell_feature_matrix.zarr.zip to the Seurat object metadata by default if they are available, or if specified via the cell.annotations parameter as we did above.

Annotations by themselves can also be requested with ReadAtera(); for example: annotations <- ReadAtera(data.dir = data_dir, outs = "annotations")$annotations.

annotation.columns <- grep("^azimuth_", colnames(atera_obj[[]]), value = TRUE)

tail(atera_obj[[]][, annotation.columns])
##                azimuth_broad              azimuth_coarse
## 8199291971  Endothelial cell            Endothelial cell
## 8199292807       Immune cell                      B cell
## 8199308871 Perivascular cell Vascular smooth muscle cell
## 8199311332        Unassigned                  Unassigned
## 8199332461        Unassigned                  Unassigned
## 8199348186        Unassigned                  Unassigned
##                           azimuth_fine azimuth_score
## 8199291971                Capillary EC     0.6097147
## 8199292807                 Plasma cell     0.4555703
## 8199308871 Vascular smooth muscle cell     0.9319242
## 8199311332                  Unassigned     0.9868732
## 8199332461                  Unassigned     0.9960636
## 8199348186                  Unassigned     0.8666266
##                                   azimuth_full_hierarchy
## 8199291971                 Endothelial cell|Capillary EC
## 8199292807  Immune cell|Lymphoid cell|B cell|Plasma cell
## 8199308871 Perivascular cell|Vascular smooth muscle cell
## 8199311332                                    Unassigned
## 8199332461                                    Unassigned
## 8199348186                                    Unassigned

Below, we see how (broad) cell types are organized within the kidney tissue:

ImageDimPlot(atera_obj,
            fov = "fov",
            group.by = "azimuth_broad",
            size = 0.1) + ggtitle("azimuth_broad")

Visualize spatial data

ImageDimPlot() and ImageFeaturePlot() display Atera coordinates with x horizontally and y increasing downward, matching the morphology images’ top-left origin. Stored coordinates remain unchanged. We use these same tissue coordinates when cropping the field of view below. To reproduce the older plot layout, set flip_xy = TRUE in either plotting function.

Quality control

We first visualize the number of transcripts and genes detected per cell. Spatial variation in these values can help identify regions worth inspecting more closely, but differences can also reflect cell size and tissue composition. We therefore examine these maps alongside the cell boundaries rather than choosing a single cutoff from the plots alone.

p1 <- ImageFeaturePlot(atera_obj,
                      fov = "fov",
                      features = "nCount_Atera",
                      size = 1) + ggtitle("Transcript counts per cell")

p2 <- ImageFeaturePlot(atera_obj,
                      fov = "fov",
                      features = "nFeature_Atera",
                      size = 1) + ggtitle("Genes detected per cell")

p1 + p2

Visualize individual transcripts

Transcript coordinates are fetched on demand with LoadAteraMolecules(). Here, we retrieve coordinates for PDK1. Subsequent calls reuse coordinates that have already been loaded.

The transcript file stores physical coordinates and calibrated quality scores for individual detections. The file contains all decoded transcripts, whereas the cell-feature matrix uses transcripts that pass the default quality value (Q-Score) threshold of Q20. LoadAtera() defaults to mols.qv.threshold = 20 for gene-level molecule reads.

gene_name <- "PDK1"

atera_obj <- LoadAteraMolecules(atera_obj, genes = gene_name)

We overlay the molecules on cell centroids, using orange to distinguish transcripts from the gray tissue background.

p3 <- ImageDimPlot(atera_obj,
                  fov = "fov",
                  # Only centroids have been loaded; segmentation polygons are not yet available.
                  boundaries = NULL,
                  size = 0.1,
                  cols = "grey80",
                  molecules = gene_name,
                  mols.size = 0.1,
                  mols.cols = "orange",
                  axes = TRUE) + ggplot2::ggtitle(paste(gene_name, "transcripts"))

p3

We can also compare the spatial distributions of multiple genes of interest. Let’s look at COL4A5 in addition to PDK1, first loading the molecules for both genes.

genes <- c("PDK1", "COL4A5")

atera_obj <- LoadAteraMolecules(atera_obj, genes = genes)

Next, we can visualize both genes together on the same plot of the tissue. The colors identify which gene each displayed molecule belongs to, so these visualizations help us inspect whether signals occupy similar tissue regions or different cellular neighborhoods. It may be helpful to adjust mols.size for the field of view, as larger sizes can obscure spatial detail in dense regions.

p4 <- ImageDimPlot(atera_obj,
                    fov = "fov",
                    boundaries = NULL,
                    size = 0.1,
                    cols = "grey80",
                    molecules = genes,
                    mols.size = 0.1,
                    mols.cols = c("orange", "cyan"),
                    axes = TRUE) + ggtitle("Multi-gene transcripts")
p4

Compare codewords for a gene

A gene can be represented by multiple codewords. LoadAteraCodewords() retrieves their identities and stores their coordinates under labels such as COL4A5_codeword_<id>. We can compare the codewords together or plot them separately.

The transcript archive records a codeword_identity for each detection and a mapping from codewords to genes. Comparing the individual panels can reveal whether codewords show similar spatial distributions.

This reader retrieves codeword detections (without applying the gene-level molecule quality filter that we used for the gene-level plots).

gene_name <- "COL4A5"
atera_obj <- LoadAteraCodewords(atera_obj, data.dir = data_dir, genes = gene_name)
codeword.labels <- sort(unique(grep(paste0("^", gene_name, "_codeword_"),
                        Misc(atera_obj, "atera.molecules")$cache$gene, value = TRUE)))

cat(length(codeword.labels), "codewords for", gene_name, "\n")
## 3 codewords for COL4A5
# Extend the palette if a gene has more codewords.
palette <- c("orange", "cyan", "magenta", "yellow", "green", "red")

p5 <- ImageDimPlot(atera_obj,
                  fov = "fov",
                  boundaries = NULL,
                  size = 0.1,
                  cols = "grey80",
                  molecules = codeword.labels,
                  mols.size = 0.1,
                  mols.cols = palette[seq_along(codeword.labels)],
                  axes = TRUE) + ggtitle(paste(gene_name, "transcripts by codeword"))
p5

plots <- lapply(codeword.labels, function(cw) {
                ImageDimPlot(atera_obj,
                            fov = "fov",
                            boundaries = NULL,
                            size = 0.1,
                            cols = "grey80",
                            molecules = cw,
                            mols.size = 0.1,
                            mols.cols = "orange",
                            axes = TRUE) + ggtitle(cw) })

p6 <- wrap_plots(plots, ncol = length(plots))
p6

Explore a region of interest

Crop the tissue and load cell boundaries

Cell boundaries and individual molecules are easier to inspect in a smaller field of view. We can use Crop() to define a region in tissue coordinates, then load segmentation boundaries for the cells in that crop.

Creating fov.crop preserves the original field of view so that we can return to whole-tissue plots at a later stage. The crop ranges below are in microns based on the tissue coordinate system. Setting coords = "tissue" makes Crop() use the original x/y ranges directly.

Loading polygons only for this crop avoids constructing boundaries for every cell in the bundle. Change crop.x and crop.y when working with another sample; these coordinates select a region of the kidney dataset used here.

crop.x <- c(5000, 5200)
crop.y <- c(2000, 2200)

atera_obj[["fov.crop"]] <- Crop(atera_obj[["fov"]],
                                x = crop.x,
                                y = crop.y,
                                coords = "tissue")

atera_obj <- LoadAteraSegmentations(atera_obj,
                                    data.dir = data_dir,
                                    fov = "fov.crop",
                                    segmentations = "cell")

atera_obj <- LoadAteraMolecules(atera_obj, genes = "COL4A5")

We can now visualize transcripts relative to the cell boundaries in this region. The white polygons show cell outlines, while orange points show the selected gene. Looking at their relative positions provides a closer view of the signal than a cell-level expression map alone.

p7 <- ImageDimPlot(atera_obj,
                  fov = "fov.crop",
                  boundaries = "segmentations",
                  cols = "white",
                  axes = TRUE,
                  molecules = "COL4A5",
                  mols.size = 1,
                  mols.cols = "orange",
                  coord.fixed = TRUE)
p7

Preprocessing and unsupervised analysis

Analyze a sketch of the dataset

Rather than running dimensional reduction and clustering on every cell, we select a sketch of 50,000 cells with SketchData(). We normalize the full assay and identify variable features before sampling the sketch.

The LeverageScore method favors cells that contribute distinct expression patterns, helping retain variation that uniform subsampling can miss. The sketch becomes a separate in-memory assay, while the full count matrix remains available in the BPCells-backed Atera assay. The sketch size can be adjusted to the dataset and available memory.

set.seed(42)
setThreads(8) # set the number of threads for parallel processing

DefaultAssay(atera_obj) <- "Atera"
atera_obj <- NormalizeData(atera_obj)

atera_obj <- FindVariableFeatures(atera_obj)

atera_obj <- SketchData(object = atera_obj,
                        assay = "Atera",
                        ncells = 50000,
                        method = "LeverageScore",
                        sketched.assay = "sketch")

We then analyze the sketch by running the standard Seurat workflow.

DefaultAssay(atera_obj) <- "sketch"

atera_obj <- NormalizeData(atera_obj, verbose = FALSE) %>%
                FindVariableFeatures(verbose = FALSE) %>%
                ScaleData(verbose = FALSE) %>%
                RunPCA(npcs = 20, verbose = FALSE) %>%
                FindNeighbors(dims = 1:10, verbose = FALSE) %>%
                FindClusters(verbose = FALSE) %>%
                RunUMAP(dims = 1:10, return.model = TRUE, verbose = FALSE)

p8 <- DimPlot(atera_obj, group.by = "seurat_clusters", label = TRUE) + NoLegend()
p8

Project the results to all cells

ProjectData() projects the full dataset into the sketch PCA and UMAP spaces and transfers the sketch cluster labels. We store the transferred labels as cluster_full and switch back to the full Atera assay.

The earlier return.model = TRUE saves the UMAP model needed for this projection. The resulting reductions are pca.full and full.umap, which let us visualize all cells using the structure learned from the sketch.

atera_obj <- ProjectData(object = atera_obj,
                        assay = "Atera",
                        sketched.assay = "sketch",
                        sketched.reduction = "pca",
                        full.reduction = "pca.full",
                        dims = 1:10,
                        k.weight = 5,
                        umap.model = "umap",
                        refdata = list(cluster_full = "seurat_clusters"))
## Computing: [========================================] 100% (done)
DefaultAssay(atera_obj) <- "Atera"

Visualize clusters

We view both the projected clusters in UMAP space and their locations across the tissue to examine whether an expression group is localized to a tissue compartment or distributed across the sample.

p9 <- DimPlot(atera_obj,
              reduction = "full.umap",
              group.by = "cluster_full",
              label = TRUE) +
              NoLegend() + ggtitle("All cells, projected UMAP")

p9

We can color the same projected UMAP by the cell type annotations from Pan-human Azimuth.

DimPlot(atera_obj,
        reduction = "full.umap",
        group.by = "azimuth_broad") + ggtitle("All cells, supplied Azimuth broad cell types")

p10 <- ImageDimPlot(atera_obj,
                    fov = "fov",
                    group.by = "cluster_full",
                    size = 0.1) + ggtitle("Clusters, all cells (centroids only)")

p10

p11 <- ImageDimPlot(atera_obj,
                    fov = "fov.crop",
                    group.by = "cluster_full",
                    boundaries = "segmentations",
                    size = 0.1) + ggtitle("Clusters, cropped region (with cell boundaries)")

p11

Acknowledgements

We would like to thank Stephen Williams and the Computational Biology team at 10x for their contributions to the code for loading Atera data.

Session Info
sessioninfo::session_info(pkgs = "attached")
## ─ Session info ───────────────────────────────────────────────────────────────
##  setting  value
##  version  R version 4.5.2 (2025-10-31)
##  os       Ubuntu 24.04.4 LTS
##  system   x86_64, linux-gnu
##  ui       X11
##  language en
##  collate  en_US.UTF-8
##  ctype    en_US.UTF-8
##  tz       Etc/UTC
##  date     2026-11-06
##  pandoc   3.9 @ /usr/bin/ (via rmarkdown)
##  quarto   1.8.25 @ /usr/local/bin/quarto
## 
## ─ Packages ───────────────────────────────────────────────────────────────────
##  package      * version date (UTC) lib source
##  future       * 1.76.0  2026-09-24 [3] CRAN (R 4.5.2)
##  ggplot2      * 4.0.3   2026-04-22 [3] CRAN (R 4.5.2)
##  magrittr     * 2.0.5   2026-04-04 [3] CRAN (R 4.5.2)
##  patchwork    * 1.3.2   2025-08-25 [3] CRAN (R 4.5.2)
##  Seurat       * 6.0.0   2026-11-06 [1] local
##  SeuratObject * 5.4.0   2026-04-11 [3] RSPM (R 4.5.0)
##  sp           * 2.2-4   2026-10-11 [3] RSPM (R 4.5.0)
## 
##  [1] /scratch/build-QLRyQPI9/library
##  [2] /usr/local/lib/R/custom-lib
##  [3] /usr/local/lib/R/site-library
##  [4] /usr/local/lib/R/library
##  * ── Packages attached to the search path.
## 
## ──────────────────────────────────────────────────────────────────────────────