MultiAssaySpatialExperiment use cases

Introduction

This vignette demonstrates realistic workflows using MultiAssaySpatialExperiment (MASE) for multi-assay spatial transcriptomics analysis. It is divided into two parts:

Part 1: Data Import — Reading spatial omics data from vendor outputs (Xenium, Visium, Visium HD, CosMx, MERSCOPE) and converting from other Bioconductor spatial formats.

Part 2: Real-World Workflows — Complete analysis examples integrating multiple technologies, spatial transformations, and deconvolution.

Scientific question: How do cell-level and tissue-level spatial patterns complement each other? Can we leverage high-resolution Xenium data to deconvolve Visium spots and improve our understanding of tissue architecture?

library(MultiAssaySpatialExperiment)
library(SummarizedExperiment)
library(SingleCellExperiment)
library(S4Vectors)
library(sf)

# For visualization
library(ggplot2)
set.seed(123)

Part 1: Data Import

MASE provides readers for major spatial omics platforms and coercion methods for interoperability with other Bioconductor spatial classes.

Reading Xenium data

10x Genomics Xenium provides single-cell resolution subcellular imaging. The Xenium Explorer output directory contains:

  • cell_feature_matrix.h5: Cell × gene counts
  • cells.parquet or cells.csv.gz: Cell coordinates and metadata
  • nucleus_boundaries.parquet: Cell segmentation polygons (optional)
  • transcripts.parquet: Transcript-level coordinates (optional, large)

Basic usage

# Read Xenium data from output directory
mase_xenium <- readXeniumMASE(
  data_dir = "path/to/xenium_output",
  sample_id = "sample1",
  segmentations = "cell",
  add_transcripts = FALSE  # Skip transcripts for faster loading
)

# The MASE object contains:
# - experiments$rna: SingleCellExperiment with cell × gene matrix
# - spatialPoints(): cell centroid coordinates (x, y, z)
# - spatialShapes(): cell or nucleus segmentation polygons (when segmentations loaded)
# - spatialMap(): links assay columns to spatial elements

Options

Parameter Default Purpose
data_dir required Path to Xenium output directory
sample_id NULL Sample identifier (uses directory name if NULL)
segmentations "cell" Segmentation to load: "cell", "nucleus", or "both"
add_transcripts FALSE Load transcript-level coordinates (large!)
images TRUE Load image metadata
load_images FALSE Load image raster data

When to load transcripts

Transcript-level data (millions of points) is useful for:

  • Visualizing subcellular localization
  • Quality control (transcript counts per cell)
  • Custom aggregation strategies

Skip transcripts (add_transcripts = FALSE) for faster loading and smaller memory footprint when working with cell-level data only.

Reading Visium data

10x Genomics Visium provides spot-based spatial transcriptomics. Space Ranger output contains:

  • filtered_feature_bc_matrix.h5: Spot × gene counts
  • spatial/tissue_positions.csv: Spot coordinates
  • spatial/scalefactors_json.json: Image scale factors
  • spatial/tissue_hires_image.png: H&E image (optional)

Basic usage

# Read Visium data
mase_visium <- readVisiumMASE(
  data_dir = "path/to/spaceranger_output",
  sample_id = "sample1",
  images = TRUE,
  data = "filtered"
)

# The MASE object contains:
# - experiments: spot × gene matrix (SummarizedExperiment or SCE)
# - spatialPoints(): spot coordinates (x, y in pixels or microns)
# - spatialImages(): H&E image metadata (if images = TRUE)
# - imgData(): links specimens to images
# - spatialMap(): links assay columns to spatial elements

Visium HD support

Visium HD provides higher resolution with multiple binning levels (2 µm, 8 µm, 16 µm). Use the dedicated HD reader and specify bin size codes "002", "008", or "016":

# Read Visium HD at 8 µm resolution
mase_visium_hd <- readVisiumHDMASE(
  data_dir = "path/to/spaceranger_hd_output",
  sample_id = "sample_hd",
  bin_size = "008",
  images = TRUE
)

Reading CosMx data

NanoString CosMx provides subcellular resolution with compartment-specific segmentation (nucleus, membrane, cytoplasm). Output files include:

  • *_exprMat_file.csv: Expression matrix
  • *_metadata_file.csv: Cell metadata
  • *_tx_file.csv: Transcript coordinates
  • CellComposite/: Composite images

Basic usage

# Read CosMx data
mase_cosmx <- readCosMxMASE(
  data_dir = "path/to/cosmx_output",
  sample_id = "cosmx_sample",
  fov_ids = NULL  # NULL loads all FOVs; or c("1", "3", "5")
)

# The MASE object contains:
# - experiments: cell × gene matrix
# - spatialPoints(): cell coordinates
# - spatialShapes(): cell segmentation (includes compartments when available)
# - spatialMap(): links assay columns to spatial elements

FOV (Field of View) handling

CosMx data is organized by FOVs. Options:

  • fov_ids = NULL: Load all FOVs (combines into single MASE)
  • fov_ids = c("1", "3", "5"): Load specific FOVs
  • fov_ids = "1": Load a single FOV

Reading MERSCOPE data (MERFISH)

Vizgen MERSCOPE provides high-throughput spatial transcriptomics (often referred to as MERFISH). Output directory contains:

  • cell_by_gene.csv: Cell × gene matrix
  • cell_metadata.csv: Cell coordinates and metadata
  • detected_transcripts.csv: Transcript coordinates
  • cell_boundaries/: Cell segmentation polygons (Parquet)

Basic usage

# Read MERSCOPE data
mase_merscope <- readMERSCOPEMASE(
  data_dir = "path/to/vizgen_output",
  sample_id = "merscope_sample",
  segmentation = "cellpose",
  load_transcripts = FALSE
)

# The MASE object contains:
# - experiments: cell × gene matrix
# - spatialPoints(): cell coordinates (x, y, z when present)
# - spatialShapes(): cell segmentation polygons
# - spatialMap(): links assay columns to spatial elements

Choosing a reader

Select the reader that matches your platform output directory:

Platform Reader
10x Xenium readXeniumMASE()
10x Visium readVisiumMASE()
10x Visium HD readVisiumHDMASE()
NanoString CosMx readCosMxMASE()
Vizgen MERSCOPE readMERSCOPEMASE()

Converting from other Bioconductor classes

From SpatialExperiment

library(SpatialExperiment)

# Load or create a SpatialExperiment
# spe <- read10xVisium("path/to/visium")

# Convert to MASE
mase_from_spe <- as(spe, "MultiAssaySpatialExperiment")

# The conversion:
# - Places SPE assay(s) into experiments
# - Copies colData to specimen metadata
# - Extracts spatialCoords to points layer
# - Creates appropriate sampleMap and spatialMap

From SpatialFeatureExperiment

library(SpatialFeatureExperiment)

# Load or create a SpatialFeatureExperiment
# sfe <- read10xVisiumSFE("path/to/visium")

# Convert to MASE
mase_from_sfe <- as(sfe, "MultiAssaySpatialExperiment")

# The conversion:
# - Preserves all assays
# - Converts colGeometries to points or shapes
# - Preserves annotGeometries
# - Creates spatialMap linking geometries to assay columns

See Part 2 below and the Working with MultiAssaySpatialExperiment vignette for coercion details and limitations (e.g., multi-assay objects require manual assembly).

Reading multiple samples

For multi-sample experiments, read each sample separately and combine:

# Read sample 1 (Xenium)
mase1 <- readXeniumMASE(data_dir = "path/to/sample1", sample_id = "P001_xenium")

# Read sample 2 (Xenium)
mase2 <- readXeniumMASE(data_dir = "path/to/sample2", sample_id = "P002_xenium")

# Combine samples
mase_multi <- c(mase1, mase2)

# The combined MASE has:
# - All experiments from both samples
# - Updated colData with sample identifiers
# - Preserved spatial elements from both samples
# - Updated sampleMap and spatialMap

Part 2: Real-World Workflows

Workflow 1: Xenium + Visium integration

Background

A common experimental design combines:

  • Xenium: Single-cell resolution, subcellular localization, limited gene panel (~300-500 genes)
  • Visium: Spot-level resolution (55 µm spots), whole transcriptome, but multiple cells per spot

By integrating both, we can: 1. Use Xenium to identify cell types and their spatial organization 2. Use Visium to capture broader transcriptomic programs 3. Transfer cell type labels from Xenium to Visium spots 4. Deconvolve Visium spots using Xenium cell proportions

Data preparation

We will create synthetic data that mimics a real experiment. In practice, you would load your own data from: - Xenium: Cell-by-gene matrix + cell centroids - Visium: Spot-by-gene matrix + spot centroids

# Xenium data: single-cell resolution
# Simulate 500 cells, 50 genes (subset of full panel)
n_cells <- 500
n_genes_xenium <- 50

xenium_counts <- matrix(
  rpois(n_cells * n_genes_xenium, lambda = 5),
  nrow = n_genes_xenium,
  ncol = n_cells,
  dimnames = list(
    paste0("Gene", 1:n_genes_xenium),
    paste0("Cell", 1:n_cells)
  )
)

# Cell centroids (tissue coordinates in micrometers)
xenium_coords <- DataFrame(
  x = runif(n_cells, 0, 1000),
  y = runif(n_cells, 0, 1000),
  instance_id = paste0("Cell", 1:n_cells)
)

# Cell type annotations (from clustering or known markers)
cell_types <- sample(
  c("Neuron", "Astrocyte", "Microglia", "Oligodendrocyte"),
  n_cells,
  replace = TRUE,
  prob = c(0.5, 0.2, 0.15, 0.15)
)

# Create SingleCellExperiment
xenium_sce <- SingleCellExperiment(
  assays = list(counts = xenium_counts),
  colData = DataFrame(
    cell_type = cell_types,
    tech = "xenium"
  )
)
# Visium data: spot-level resolution
# Simulate 100 spots, 1000 genes (whole transcriptome, downsampled)
n_spots <- 100
n_genes_visium <- 1000

# Create gene names: first 50 overlap with Xenium, rest are Visium-only
visium_gene_names <- c(
  paste0("Gene", 1:n_genes_xenium),  # Overlapping genes (50)
  paste0("GeneV", 1:(n_genes_visium - n_genes_xenium))  # Visium-only (950)
)

visium_counts <- matrix(
  rpois(n_spots * n_genes_visium, lambda = 10),
  nrow = n_genes_visium,
  ncol = n_spots,
  dimnames = list(
    visium_gene_names,
    paste0("Spot", 1:n_spots)
  )
)

# Spot centroids (same coordinate system as Xenium)
visium_coords <- DataFrame(
  x = runif(n_spots, 0, 1000),
  y = runif(n_spots, 0, 1000),
  instance_id = paste0("Spot", 1:n_spots)
)

# Create SummarizedExperiment
visium_se <- SummarizedExperiment(
  assays = list(counts = visium_counts),
  colData = DataFrame(
    tech = rep("visium", n_spots),
    row.names = paste0("Spot", 1:n_spots)
  )
)

Build MASE object

Combine Xenium and Visium from the same tissue section into one MASE object. Both assays share a single specimen (colData row); individual cells and spots are observations linked through sampleMap and spatialMap.

# Specimen metadata: one row per biological unit (here, one tissue section)
specimen_id <- "section_01"
specimens <- DataFrame(
  patient_id = "P001",
  tissue = "mouse_brain",
  section_id = specimen_id,
  row.names = specimen_id
)

n_total <- n_cells + n_spots

# Sample map: link each assay column (cell or spot) to the shared specimen
sample_map <- DataFrame(
  assay = factor(
    c(rep("xenium", n_cells), rep("visium", n_spots)),
    c("xenium", "visium")
  ),
  primary = rep(specimen_id, n_total),
  colname = c(paste0("Cell", 1:n_cells), paste0("Spot", 1:n_spots))
)

# Spatial map: link assay columns to spatial coordinates
spatial_map <- DataFrame(
  assay = factor(
    c(rep("xenium", n_cells), rep("visium", n_spots)),
    c("xenium", "visium")
  ),
  colname = c(paste0("Cell", 1:n_cells), paste0("Spot", 1:n_spots)),
  element_type = rep("points", n_total),
  region = factor(
    c(rep("xenium_coords", n_cells), rep("visium_coords", n_spots)),
    c("xenium_coords", "visium_coords")
  ),
  instance_id = c(paste0("Cell", 1:n_cells), paste0("Spot", 1:n_spots))
)

# Construct MASE
mase <- MultiAssaySpatialExperiment(
  experiments = ExperimentList(
    xenium = xenium_sce,
    visium = visium_se
  ),
  colData = specimens,
  sampleMap = sample_map,
  points = PointsLayerList(
    xenium_coords = xenium_coords,
    visium_coords = visium_coords
  ),
  spatialMap = spatial_map
)

mase
#> A MultiAssaySpatialExperiment object of 2 listed
#>  experiments with user-defined names and respective classes.
#>  Containing an ExperimentList class object of length 2:
#>  [1] xenium: SingleCellExperiment with 50 rows and 500 columns
#>  [2] visium: SummarizedExperiment with 1000 rows and 100 columns
#> Functionality:
#>  experiments() - obtain the ExperimentList instance
#>  colData() - the primary/phenotype DataFrame
#>  sampleMap() - the sample coordination DataFrame
#>  `$`, `[`, `[[` - extract colData columns, subset, or experiment
#>  *Format() - convert into a long or wide DataFrame
#>  assays() - convert ExperimentList to a SimpleList of matrices
#>  exportClass() - save data to flat files
#> Spatial elements:
#>   spatialImages: 0 elements
#>   spatialLabels: 0 elements
#>   spatialPoints: 2 elements
#>   spatialShapes: 0 elements
#>   imgData: NULL
#>   spatialMap: present

Exploratory analysis

Visualize spatial distribution

# Extract coordinates
xenium_pts <- spatialPoint(mase, "xenium_coords")
visium_pts <- spatialPoint(mase, "visium_coords")

# Plot both assays
par(mfrow = c(1, 2))

# Xenium cells colored by type
plot(xenium_pts$x, xenium_pts$y,
     col = as.factor(cell_types),
     pch = 16, cex = 0.5,
     main = "Xenium: Single cells",
     xlab = "x (µm)", ylab = "y (µm)")
legend("topright", legend = unique(cell_types),
       col = 1:length(unique(cell_types)), pch = 16, cex = 0.7)

# Visium spots
plot(visium_pts$x, visium_pts$y,
     pch = 21, bg = "lightblue", cex = 2,
     main = "Visium: Spots (55 µm)",
     xlab = "x (µm)", ylab = "y (µm)")

Gene expression overlap

Check which genes are shared between assays:

xenium_genes <- rownames(experiments(mase)$xenium)
visium_genes <- rownames(experiments(mase)$visium)

shared_genes <- intersect(xenium_genes, visium_genes)
cat("Shared genes:", length(shared_genes), "\n")
#> Shared genes: 50
cat("Xenium-only genes:", length(setdiff(xenium_genes, visium_genes)), "\n")
#> Xenium-only genes: 0
cat("Visium-only genes:", length(setdiff(visium_genes, xenium_genes)), "\n")
#> Visium-only genes: 950

Spatial annotation: Cell types in Visium spots

Use Xenium cell types to annotate Visium spots. For each spot, find which cells fall within it and calculate cell type proportions. The loop below is explicit for teaching; when spot polygons are stored in spatialShapes(), you can also use annotateWithRegions() (see Working with MultiAssaySpatialExperiment).

# Create circular regions for Visium spots (55 µm diameter = 27.5 µm radius)
visium_circles <- st_buffer(
  st_sf(visium_pts,
        geometry = st_as_sfc(paste0("POINT(", visium_pts$x, " ", visium_pts$y, ")"))),
  dist = 27.5
)

visium_shapes <- DataFrame(
  geometry = st_geometry(visium_circles),
  instance_id = visium_pts$instance_id
)

# Add shapes to MASE
spatialShapes(mase) <- ShapesLayerList(visium_spots = visium_shapes)

# For each Visium spot, find which Xenium cells are inside
xenium_sf <- st_sf(xenium_pts,
                   geometry = st_as_sfc(paste0("POINT(", xenium_pts$x, " ", xenium_pts$y, ")")))
visium_sf <- st_sf(
  instance_id = visium_shapes$instance_id,
  geometry = visium_shapes$geometry
)

# Spatial intersection
intersections <- st_intersects(visium_sf, xenium_sf)

# Calculate cell type proportions per spot
spot_cell_types <- lapply(seq_along(intersections), function(i) {
  cell_indices <- intersections[[i]]
  if (length(cell_indices) == 0) {
    return(data.frame(
      spot = visium_pts$instance_id[i],
      Neuron = 0, Astrocyte = 0, Microglia = 0, Oligodendrocyte = 0,
      n_cells = 0
    ))
  }
  types <- cell_types[cell_indices]
  props <- base::table(types) / length(types)
  data.frame(
    spot = visium_pts$instance_id[i],
    Neuron = as.numeric(props["Neuron"]),
    Astrocyte = as.numeric(props["Astrocyte"]),
    Microglia = as.numeric(props["Microglia"]),
    Oligodendrocyte = as.numeric(props["Oligodendrocyte"]),
    n_cells = length(cell_indices),
    stringsAsFactors = FALSE
  )
})

spot_annotations <- do.call(rbind, spot_cell_types)
spot_annotations[is.na(spot_annotations)] <- 0

head(spot_annotations)
#>    spot Neuron Astrocyte Microglia Oligodendrocyte n_cells
#> 1 Spot1    1.0         0       0.0               0       1
#> 2 Spot2    0.0         0       0.0               0       0
#> 3 Spot3    0.0         1       0.0               0       1
#> 4 Spot4    0.0         0       0.0               0       0
#> 5 Spot5    0.0         0       0.0               0       0
#> 6 Spot6    0.5         0       0.5               0       2

Visualize deconvolution results

# Plot cell type proportions per spot
par(mfrow = c(2, 2))

for (cell_type in c("Neuron", "Astrocyte", "Microglia", "Oligodendrocyte")) {
  plot(visium_pts$x, visium_pts$y,
       pch = 21, cex = 3,
       bg = rgb(spot_annotations[[cell_type]], 0, 0, alpha = 0.7),
       main = paste(cell_type, "proportion"),
       xlab = "x (µm)", ylab = "y (µm)")
}

Spatial aggregation: Summarize Xenium by regions

Aggregate Xenium gene expression by tissue regions (e.g., cortical layers). First, define regions, then use aggregateByRegion().

# Define two regions: "cortex" (y > 500) and "subcortex" (y <= 500)
# In practice, these would be anatomical annotations
region_polygons <- list(
  cortex = st_polygon(list(cbind(
    c(0, 1000, 1000, 0, 0),
    c(500, 500, 1000, 1000, 500)
  ))),
  subcortex = st_polygon(list(cbind(
    c(0, 1000, 1000, 0, 0),
    c(0, 0, 500, 500, 0)
  )))
)

regions_sf <- st_sf(
  region_id = c("cortex", "subcortex"),
  geometry = st_sfc(region_polygons)
)

# Convert to DataFrame for MASE
regions_df <- DataFrame(
  geometry = st_geometry(regions_sf),
  instance_id = c("cortex", "subcortex")
)

# Add to MASE
spatialShapes(mase) <- ShapesLayerList(
  visium_spots = visium_shapes,
  regions = regions_df
)
# Annotate Xenium centroids with anatomical regions, then aggregate expression.
# Run on the full MASE so the regions layer is retained (subsetByAssay drops
# shapes not referenced in spatialMap).
mase <- annotateWithRegions(mase,
    points = "xenium_coords", shapes = "regions")
xenium_by_region <- aggregateByRegion(mase, by = "regions",
    assays = "xenium", FUN = "sum")
xenium_by_region[["xenium"]][1:5, ]
#>       cortex subcortex
#> Gene1   1256      1285
#> Gene2   1215      1220
#> Gene3   1311      1248
#> Gene4   1194      1276
#> Gene5   1171      1286

Comparative analysis

Compare gene expression patterns between Xenium and Visium for shared genes.

# Extract counts for shared genes
xenium_shared <- assay(experiments(mase)$xenium, "counts")[shared_genes, ]
visium_shared <- assay(experiments(mase)$visium, "counts")[shared_genes, ]

# Calculate mean expression per gene
xenium_means <- rowMeans(xenium_shared)
visium_means <- rowMeans(visium_shared)

# Plot correlation
plot(log1p(xenium_means), log1p(visium_means),
     pch = 16, col = rgb(0, 0, 0, 0.5),
     xlab = "log1p(mean Xenium counts)",
     ylab = "log1p(mean Visium counts)",
     main = "Gene expression correlation")
abline(0, 1, col = "red", lty = 2)

# Correlation
cor_val <- cor(xenium_means, visium_means, method = "spearman")
text(0.5, max(log1p(visium_means)) * 0.9,
     paste("Spearman rho =", round(cor_val, 3)),
     pos = 4)

Integration insights

Key findings from this analysis:

  1. Spatial resolution: Xenium provides single-cell resolution, while Visium captures tissue-level patterns with multiple cells per spot.

  2. Gene overlap: Shared genes allow direct comparison and validation between technologies.

  3. Cell type deconvolution: Xenium cell type information can be transferred to Visium spots, revealing cellular composition.

  4. Regional patterns: Both assays can be aggregated by anatomical regions for comparative analysis.

  5. Complementary data: Xenium’s high resolution complements Visium’s broad transcriptomic coverage.

Workflow 2: Coordinate transformation and alignment

When integrating data from different spatial platforms, coordinate systems often differ. This workflow demonstrates how to align Xenium and Visium data using spatial transformations.

Background

Xenium and Visium may use different coordinate systems:

  • Xenium: Microns (µm) from a reference origin
  • Visium: Grid indices (row/column) or pixel coordinates

To overlay them, you need to:

  1. Convert Visium grid indices to physical coordinates (microns)
  2. Apply transformations (translation, scaling, rotation) to align modalities
  3. Store transformed coordinates in MASE

Converting Visium grid to microns

Visium spots are arranged in a hexagonal grid. Each spot is ~55 µm diameter, with center-to-center spacing of ~100 µm.

# Visium spot coordinates (grid indices from Space Ranger)
visium_grid <- DataFrame(
  row_idx = c(0, 0, 1, 1, 2, 2),
  col_idx = c(0, 1, 0, 1, 0, 1),
  instance_id = paste0("Spot", 1:6)
)

# Convert to microns (approximate)
# Spot spacing: ~100 µm horizontally, ~87 µm vertically (hexagonal)
visium_microns <- DataFrame(
  x = visium_grid$col_idx * 100,  # Column → x
  y = visium_grid$row_idx * 87,   # Row → y (hexagonal offset)
  instance_id = visium_grid$instance_id
)

visium_microns
#> DataFrame with 6 rows and 3 columns
#>           x         y instance_id
#>   <numeric> <numeric> <character>
#> 1         0         0       Spot1
#> 2       100         0       Spot2
#> 3         0        87       Spot3
#> 4       100        87       Spot4
#> 5         0       174       Spot5
#> 6       100       174       Spot6

Applying affine transformations

For precise alignment, use affine transformations (translation, rotation, scaling):

# Define affine matrix: translate by (50, 30), scale by 1.2, no rotation
# Format: 2x3 matrix [a b c; d e f] where:
#   x' = a*x + b*y + c
#   y' = d*x + e*y + f

affine_matrix <- matrix(c(
  1.2, 0.0, 50,   # x' = 1.2*x + 0*y + 50 (scale + translate)
  0.0, 1.2, 30    # y' = 0*x + 1.2*y + 30 (scale + translate)
), nrow = 2, byrow = TRUE)

# Apply transformation
apply_affine <- function(pts, mat) {
  x_new <- mat[1, 1] * pts$x + mat[1, 2] * pts$y + mat[1, 3]
  y_new <- mat[2, 1] * pts$x + mat[2, 2] * pts$y + mat[2, 3]
  DataFrame(x = x_new, y = y_new, instance_id = pts$instance_id)
}

# Simulate Xenium coordinates
xenium_original <- DataFrame(
  x = c(100, 150, 200, 250, 300),
  y = c(100, 150, 200, 150, 100),
  instance_id = paste0("Cell", 1:5)
)

# Transform Xenium to align with Visium
xenium_aligned <- apply_affine(xenium_original, affine_matrix)

xenium_aligned
#> DataFrame with 5 rows and 3 columns
#>           x         y instance_id
#>   <numeric> <numeric> <character>
#> 1       170       150       Cell1
#> 2       230       210       Cell2
#> 3       290       270       Cell3
#> 4       350       210       Cell4
#> 5       410       150       Cell5

Landmark-based alignment

When you have known landmarks (tissue features visible in both modalities), use them to compute an optimal transformation:

# Example: three landmarks in both Xenium and Visium
# In practice, these would be manually identified features (blood vessels, etc.)

xenium_landmarks <- matrix(c(
  100, 100,
  200, 150,
  300, 200
), ncol = 2, byrow = TRUE)

visium_landmarks <- matrix(c(
  50, 50,
  150, 100,
  250, 150
), ncol = 2, byrow = TRUE)

cat("Landmark-based alignment workflow:\n")
#> Landmark-based alignment workflow:
cat("1. Identify 3+ corresponding landmarks in both modalities\n")
#> 1. Identify 3+ corresponding landmarks in both modalities
cat("2. Compute affine/rigid transformation using:\n")
#> 2. Compute affine/rigid transformation using:
cat("   - Procrustes analysis (for rigid transformations)\n")
#>    - Procrustes analysis (for rigid transformations)
cat("   - Least squares fitting (for affine transformations)\n")
#>    - Least squares fitting (for affine transformations)
cat("   - Or use packages like RNiftyReg, imager, or EBImage\n")
#>    - Or use packages like RNiftyReg, imager, or EBImage
cat("3. Apply transformation to all points\n")
#> 3. Apply transformation to all points
cat("4. Validate alignment visually\n")
#> 4. Validate alignment visually
cat("5. Store transformed coordinates in MASE\n")
#> 5. Store transformed coordinates in MASE

Storing transformed coordinates

Store both original and aligned coordinates as separate layers for flexibility:

# Create MASE with both coordinate systems
# Original Xenium coordinates
pts_original <- PointsLayerList(xenium_original = xenium_original)

# Aligned Xenium coordinates
pts_aligned <- PointsLayerList(xenium_aligned = xenium_aligned)

# Visium coordinates
pts_visium <- PointsLayerList(visium_coords = visium_microns)

# You can store all in the same MASE object
# This allows:
# - Comparing original vs aligned coordinates
# - Refining alignment iteratively
# - Switching between coordinate systems as needed

Visualizing alignment

Check alignment quality visually:

par(mfrow = c(1, 2))

# Before alignment
plot(xenium_original$x, xenium_original$y,
     pch = 16, col = "red", cex = 1.5,
     main = "Before alignment",
     xlab = "x (µm)", ylab = "y (µm)",
     xlim = c(0, 400), ylim = c(0, 350))
points(visium_microns$x, visium_microns$y,
       pch = 21, bg = "blue", cex = 3)
legend("topright", legend = c("Xenium (cells)", "Visium (spots)"),
       col = c("red", "blue"), pch = c(16, 21), pt.cex = c(1.5, 2))

# After alignment
plot(xenium_aligned$x, xenium_aligned$y,
     pch = 16, col = "red", cex = 1.5,
     main = "After alignment",
     xlab = "x (µm)", ylab = "y (µm)",
     xlim = c(0, 400), ylim = c(0, 350))
points(visium_microns$x, visium_microns$y,
       pch = 21, bg = "blue", cex = 3)
legend("topright", legend = c("Xenium (aligned)", "Visium (spots)"),
       col = c("red", "blue"), pch = c(16, 21), pt.cex = c(1.5, 2))

Workflow 3: Multi-sample analysis

MASE excels at managing multiple tissue sections or patients. Here’s a brief example:

# Suppose we have data from 3 patients
# Each with Xenium + Visium

# Specimen metadata
specimens_multi <- DataFrame(
  patient_id = c(rep("P001", 150), rep("P002", 120), rep("P003", 180)),
  tissue = "mouse_brain",
  condition = c(rep("control", 150), rep("treated", 120), rep("treated", 180)),
  row.names = paste0("Sample", 1:450)
)

# Build sampleMap linking all assays to specimens
# ... (similar to single-sample example)

# With MASE, you can:
# - Subset by patient: mase[, colData(mase)$patient_id == "P001", ]
# - Subset by condition: mase[, colData(mase)$condition == "control", ]
# - Subset one assay: subsetByAssay(mase, "xenium")
# - Analyze spatial patterns per patient
# - Compare conditions across patients

Workflow 4: Iterative analysis pattern

A typical analysis workflow using MASE generics:

# 1. Load data with the appropriate reader
mase <- readXeniumMASE(data_dir = "path/to/xenium_output", sample_id = "sample1")

# 2. Quality control: subset specimens
mase_qc <- mase[, colData(mase)$qc_pass, ]

# 3. Spatial filtering: focus on region of interest
library(sf)
roi <- st_polygon(list(matrix(c(100, 200, 500, 200, 500, 600, 100, 600, 100, 200),
                              ncol = 2, byrow = TRUE)))
mase_roi <- subsetByPolygon(mase_qc, roi)

# 4. Annotate points with cell boundaries
mase_annotated <- annotateWithRegions(mase_roi,
    points = "centroids", shapes = "cells")

# 5. Aggregate expression per cell for downstream analysis
cell_expr <- aggregateByRegion(mase_annotated, by = "cells", FUN = "sum")

# 6. Differential expression, clustering, etc. on cell_expr matrices

Summary

MultiAssaySpatialExperiment provides a unified framework for:

  • Integrating multiple spatial assays (Xenium, Visium, MERSCOPE, CosMx, etc.)
  • Managing specimen metadata and spatial coordinates consistently
  • Linking assay columns to spatial elements via sampleMap and spatialMap
  • Subsetting by specimen, observation metadata, or spatial ROI
  • Annotating and aggregating spatial data
  • Analyzing multi-assay, multi-sample spatial experiments

The relational database design ensures: - Referential integrity: All links stay consistent during subsetting - Flexibility: Add or remove assays/spatial elements as needed - Interoperability: Convert to/from other Bioconductor spatial classes

For more examples, see:

  • Introduction to MultiAssaySpatialExperiment: MASE structure and design principles
  • Working with MultiAssaySpatialExperiment: Construction, subsetting, annotation, and aggregation
  • MultiAssaySpatialExperiment Cheatsheet: Quick reference for common operations

Session info

sessionInfo()
#> R version 4.6.1 (2026-06-24)
#> Platform: x86_64-pc-linux-gnu
#> Running under: Ubuntu 26.04 LTS
#> 
#> Matrix products: default
#> BLAS:   /usr/lib/x86_64-linux-gnu/openblas-pthread/libblas.so.3 
#> LAPACK: /usr/lib/x86_64-linux-gnu/openblas-pthread/libopenblasp-r0.3.32.so;  LAPACK version 3.12.0
#> 
#> locale:
#>  [1] LC_CTYPE=en_US.UTF-8       LC_NUMERIC=C              
#>  [3] LC_TIME=en_US.UTF-8        LC_COLLATE=en_US.UTF-8    
#>  [5] LC_MONETARY=en_US.UTF-8    LC_MESSAGES=en_US.UTF-8   
#>  [7] LC_PAPER=en_US.UTF-8       LC_NAME=C                 
#>  [9] LC_ADDRESS=C               LC_TELEPHONE=C            
#> [11] LC_MEASUREMENT=en_US.UTF-8 LC_IDENTIFICATION=C       
#> 
#> time zone: Etc/UTC
#> tzcode source: system (glibc)
#> 
#> attached base packages:
#> [1] stats4    stats     graphics  grDevices utils     datasets  methods  
#> [8] base     
#> 
#> other attached packages:
#>  [1] ggplot2_4.0.3                      sf_1.1-1                          
#>  [3] SingleCellExperiment_1.35.2        MultiAssaySpatialExperiment_0.99.2
#>  [5] MultiAssayExperiment_1.39.0        SummarizedExperiment_1.43.0       
#>  [7] Biobase_2.73.1                     GenomicRanges_1.65.1              
#>  [9] Seqinfo_1.3.0                      IRanges_2.47.2                    
#> [11] S4Vectors_0.51.5                   BiocGenerics_0.59.10              
#> [13] generics_0.1.4                     MatrixGenerics_1.25.0             
#> [15] matrixStats_1.5.0                  BiocStyle_2.41.0                  
#> 
#> loaded via a namespace (and not attached):
#>  [1] gtable_0.3.6             rjson_0.2.23             xfun_0.60               
#>  [4] bslib_0.11.0             lattice_0.22-9           vctrs_0.7.3             
#>  [7] tools_4.6.1              tibble_3.3.1             proxy_0.4-29            
#> [10] pkgconfig_2.0.3          BiocBaseUtils_1.15.1     Matrix_1.7-5            
#> [13] KernSmooth_2.23-26       RColorBrewer_1.1-3       S7_0.2.2                
#> [16] lifecycle_1.0.5          compiler_4.6.1           farver_2.1.2            
#> [19] codetools_0.2-20         htmltools_0.5.9          sys_3.4.3               
#> [22] buildtools_1.0.0         class_7.3-23             sass_0.4.10             
#> [25] yaml_2.3.12              pillar_1.11.1            jquerylib_0.1.4         
#> [28] classInt_0.4-11          DelayedArray_0.39.3      cachem_1.1.0            
#> [31] magick_2.9.1             abind_1.4-8              tidyselect_1.2.1        
#> [34] digest_0.6.39            dplyr_1.2.1              maketools_1.3.2         
#> [37] fastmap_1.2.0            grid_4.6.1               cli_3.6.6               
#> [40] SparseArray_1.13.2       magrittr_2.0.5           S4Arrays_1.13.0         
#> [43] e1071_1.7-17             withr_3.0.3              scales_1.4.0            
#> [46] rmarkdown_2.31           XVector_0.53.0           otel_0.2.0              
#> [49] SpatialExperiment_1.23.0 evaluate_1.0.5           knitr_1.51              
#> [52] rlang_1.3.0              Rcpp_1.1.2               glue_1.8.1              
#> [55] DBI_1.3.0                BiocManager_1.30.27      jsonlite_2.0.0          
#> [58] R6_2.6.1                 units_1.0-1