1 Installation

if (!require("BiocManager"))
    install.packages("BiocManager")
BiocManager::install("MultiAssaySpatialExperiment")

Load the package:

library(MultiAssaySpatialExperiment)
library(SummarizedExperiment)
library(S4Vectors)

2 Citing MultiAssaySpatialExperiment

If you use MultiAssaySpatialExperiment in your research, please cite the package. Citation information is available via:

citation("MultiAssaySpatialExperiment")

3 Why another spatial data structure?

If you have spent a few decades designing databases, you have seen the same story many times: someone builds a system that works for one use case, and it spreads. Later, a new use case appears that does not quite fit. People bolt on workarounds, and the design gets messy. Eventually, someone steps back and asks: what would we build if we started from the problem we actually have?

Spatial omics is at that stage. We already have excellent tools for single-assay spatial data: SpatialExperiment and SpatialFeatureExperiment handle one experiment at a time, one technology, one matrix, one set of spatial coordinates. They work well when your question is “what genes are expressed in this Visium section?” or “where are these MERFISH cells in tissue?” But increasingly, people ask questions that cut across assays: “I have RNA, protein, and chromatin from the same tissue, how do I link them and their spatial coordinates in one place?” That is a different problem. It needs a multi-assay structure that can carry spatial context and keep everything consistent when you subset, or merge.

MultiAssayExperiment (MAE) solves the multi-assay part. It gives you a central mapping table, the sampleMap, that links assay columns to specimens. One source of truth, explicit foreign keys, and validity checks that keep the links correct. When you subset by specimen, the maps update. No orphaned rows. No hunting through metadata to figure out which assay column belongs to which specimen.

What MAE does not have is spatial geometry: coordinates for cells or spots, cell boundaries, tissue images. That is where MultiAssaySpatialExperiment (MASE) comes in. MASE extends MAE with spatial elements, points, shapes, images, labels, and a second central mapping table, the spatialMap, that links assay columns to those elements. Same philosophy as MAE: one place to look, one place to update, referential integrity enforced. Subsetting updates spatialMap (and, for most column- and assay-level operations, linked spatial layers) so you do not have to clean up coordinates or drop orphaned geometries manually.

4 When to use MASE

Use MultiAssaySpatialExperiment when:

  • You have multiple spatial assays from the same or related tissue (e.g., transcriptomics, proteomics, ATAC-seq).
  • You need one object that holds the experiments, the specimen metadata, the spatial elements (coordinates, shapes, images), and the links between them.
  • You want subsetting and merging that keep all links consistent, no manual cleanup, no orphaned geometry.
  • You need to convert to or from SpatialExperiment or SpatialFeatureExperiment for downstream tools.

5 Prerequisites

This vignette assumes you are comfortable with SummarizedExperiment and, ideally, MultiAssayExperiment: MASE is a MultiAssayExperiment with spatial slots added, and the assay, colData and sampleMap machinery behaves exactly as it does there.

No prior sf experience is needed to read this vignette. sf appears in one place only, the shapes slot, whose geometries are stored in an sf geometry column so that polygon operations (point-in-polygon, area, intersection) come from a standard spatial package rather than a bespoke one. If you only work with point coordinates, images or labels, you can use MASE without touching sf at all. When you do reach for shapes, the sf vignettes are the reference.

6 A worked example

The rest of this vignette follows one small example from construction to a per-cell summary, building the object by hand, argument by argument, so that every piece the constructor expects is visible. Real data rarely arrives this way: if you already have Xenium, Visium, CosMx or MERSCOPE output, the read*() functions build a MASE directly from platform files, and the MultiAssaySpatialExperiment use cases vignette runs this same workflow on that real output instead.

The scenario is the one MASE exists for: two assays measured over the same piece of tissue, sharing one set of cell boundaries.

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

6.1 Building the object

Two specimens, P1 and P2, contribute four observations between them. Both an RNA and a protein assay were run on those same four observations.

make_assay <- function(seed) {
  set.seed(seed)
  SummarizedExperiment(
    assays = list(counts = matrix(rpois(20, 10), nrow = 5, ncol = 4,
      dimnames = list(paste0("Gene", 1:5), paste0("S", 1:4)))))
}

rna <- make_assay(1)
protein <- make_assay(2)

The observations sit somewhere in the tissue, and each falls inside one of two cell boundaries:

centroids <- DataFrame(
  x = c(1.5, 2.5, 4.5, 5.5),
  y = c(1.5, 2.5, 4.5, 5.5),
  instance_id = paste0("S", 1:4))

square <- function(x0, y0, side) {
  st_polygon(list(cbind(
    c(x0, x0 + side, x0 + side, x0, x0),
    c(y0, y0, y0 + side, y0 + side, y0))))
}

cells <- DataFrame(
  instance_id = c("left", "right"),
  geometry = st_sfc(square(0, 0, 3), square(3, 3, 3)))

st_polygon() builds one polygon from a matrix of ring coordinates, and st_sfc() collects one or more such geometries into a single list-column (an sfc_POLYGON) that a DataFrame can hold like any other column. That is the only sf machinery this vignette needs; everything downstream (point-in-polygon joins, subsetting by a bounding box) works through this geometry column.

Three tables tie the pieces together. ExperimentList is a named list of the assay SummarizedExperiments (rna, protein). colData holds one row per specimen (P1, P2). sampleMap is the crosswalk between them: each row reads “column colname of assay assay belongs to specimen primary.” spatialMap is the same idea for geometry: each row reads “column colname of assay assay sits at instance instance_id of the region layer.” Both assays point at the same centroids layer, which is the part a single-assay container cannot express:

observations <- paste0("S", 1:4)
specimens <- c("P1", "P1", "P2", "P2")

mase <- MultiAssaySpatialExperiment(
  experiments = ExperimentList(rna = rna, protein = protein),
  colData = DataFrame(row.names = c("P1", "P2")),
  sampleMap = DataFrame(
    assay = factor(rep(c("rna", "protein"), each = 4)),
    primary = rep(specimens, 2),
    colname = rep(observations, 2)),
  points = PointsLayerList(centroids = centroids),
  shapes = ShapesLayerList(cells = cells),
  spatialMap = DataFrame(
    assay = factor(rep(c("rna", "protein"), each = 4)),
    colname = rep(observations, 2),
    element_type = "points",
    region = "centroids",
    instance_id = rep(observations, 2)))

mase
#> A MultiAssaySpatialExperiment object of 2 listed
#>  experiments with user-defined names and respective classes.
#>  Containing an ExperimentList class object of length 2:
#>  [1] rna: SummarizedExperiment with 5 rows and 4 columns
#>  [2] protein: SummarizedExperiment with 5 rows and 4 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: 1 element
#>   spatialShapes: 1 element
#>   imgData: NULL
#>   spatialMap: present

6.2 Looking at what it holds

The MultiAssayExperiment accessors behave as they always do, and the spatial slots have accessors of their own:

experiments(mase)
#> ExperimentList class object of length 2:
#>  [1] rna: SummarizedExperiment with 5 rows and 4 columns
#>  [2] protein: SummarizedExperiment with 5 rows and 4 columns
spatialPoints(mase)[["centroids"]]
#> DataFrame with 4 rows and 3 columns
#>           x         y instance_id
#>   <numeric> <numeric> <character>
#> 1       1.5       1.5          S1
#> 2       2.5       2.5          S2
#> 3       4.5       4.5          S3
#> 4       5.5       5.5          S4
spatialShapes(mase)[["cells"]]
#> DataFrame with 2 rows and 2 columns
#>   instance_id               geometry
#>   <character>          <sfc_POLYGON>
#> 1        left list(c(0, 3, 3, 0, 0..
#> 2       right list(c(3, 6, 6, 3, 3..

S1 and S2 sit inside left; S3 and S4 sit inside right, the layout the bounding-box subset and the annotation step below both act on.

spatialMap() is the table that ties the two together, one row per assay column:

head(spatialMap(mase), 4)
#> DataFrame with 4 rows and 5 columns
#>      assay     colname element_type      region instance_id
#>   <factor> <character>  <character> <character> <character>
#> 1      rna          S1       points   centroids          S1
#> 2      rna          S2       points   centroids          S2
#> 3      rna          S3       points   centroids          S3
#> 4      rna          S4       points   centroids          S4

6.4 Annotating and aggregating

annotateWithRegions() runs the point-in-polygon join between a points layer and a shapes layer, and stores the answer as a new spatialMap column named after the shapes layer, here cells:

mase <- annotateWithRegions(mase, points = "centroids", shapes = "cells")
spatialMap(mase)
#> DataFrame with 8 rows and 6 columns
#>      assay     colname element_type      region instance_id       cells
#>   <factor> <character>  <character> <character> <character> <character>
#> 1  rna              S1       points   centroids          S1        left
#> 2  rna              S2       points   centroids          S2        left
#> 3  rna              S3       points   centroids          S3       right
#> 4  rna              S4       points   centroids          S4       right
#> 5  protein          S1       points   centroids          S1        left
#> 6  protein          S2       points   centroids          S2        left
#> 7  protein          S3       points   centroids          S3       right
#> 8  protein          S4       points   centroids          S4       right

spatialMap now has two layer-related columns, and they mean different things. region was set when the object was built; it names the points layer each row’s instance_id comes from (centroids). The new cells column is the annotation annotateWithRegions() just added; it holds, for each observation, the instance_id of the shape (left or right) its point fell inside.

aggregateByRegion() then summarises the assays over that annotation, turning observation-level counts into per-shape ones:

aggregateByRegion(mase, by = "cells", FUN = "sum")
#> $rna
#>       left right
#> Gene1   20    20
#> Gene2   21    13
#> Gene3   16    20
#> Gene4   25    22
#> Gene5   25    17
#> 
#> $protein
#>       left right
#> Gene1   16    21
#> Gene2   22    17
#> Gene3   15    22
#> Gene4   21    18
#> Gene5   20    22

Because the annotation lives in spatialMap rather than in one assay’s metadata, both assays were aggregated over the same geometry in a single call.

6.5 Handing the data to other tools

A single-assay slice can be converted to SpatialExperiment for tools written against that class, and converted back:

library(SpatialExperiment)

spe <- SpatialExperiment(
  assays = list(counts = matrix(rpois(20, 5), 5, 4,
    dimnames = list(paste0("Gene", 1:5), paste0("S", 1:4)))),
  colData = DataFrame(sample_id = rep("P1", 4), row.names = paste0("S", 1:4)),
  spatialCoords = cbind(x = c(1.5, 2.5, 4.5, 5.5), y = c(1.5, 2.5, 4.5, 5.5)))

from_spe <- as(spe, "MultiAssaySpatialExperiment")
spatialPoints(from_spe)
#> PointsLayerList of length 1 
#> [1] coordinates: DFrame (4 x 3)

identical(unname(spatialCoords(as(from_spe, "SpatialExperiment"))),
          unname(spatialCoords(spe)))
#> [1] TRUE

Converting to SpatialExperiment or SpatialFeatureExperiment requires exactly one compatible assay, since neither class has a notion of several assays over shared geometry.

7 See also

That example touched every part of the object. The remaining vignettes go deeper:

  • Working with MultiAssaySpatialExperiment: construction, subsetting, annotation and aggregation
  • MultiAssaySpatialExperiment use cases: platform readers and complete analysis workflows
  • Design of MultiAssaySpatialExperiment: slots, mapping tables and the relational schema
  • MultiAssaySpatialExperiment cheatsheet: one-page API reference

8 Session info

sessionInfo()
#> R version 4.6.1 Patched (2026-06-24 r90190)
#> Platform: x86_64-apple-darwin20
#> Running under: macOS Ventura 13.7.8
#> 
#> Matrix products: default
#> BLAS:   /Library/Frameworks/R.framework/Versions/4.6-x86_64/Resources/lib/libRblas.0.dylib 
#> LAPACK: /Library/Frameworks/R.framework/Versions/4.6-x86_64/Resources/lib/libRlapack.dylib;  LAPACK version 3.12.1
#> 
#> locale:
#> [1] C/en_US.UTF-8/en_US.UTF-8/C/en_US.UTF-8/en_US.UTF-8
#> 
#> time zone: America/New_York
#> tzcode source: internal
#> 
#> attached base packages:
#> [1] stats4    stats     graphics  grDevices utils     datasets  methods  
#> [8] base     
#> 
#> other attached packages:
#>  [1] SpatialExperiment_1.23.0            ggplot2_4.0.3                      
#>  [3] sf_1.1-2                            SingleCellExperiment_1.35.2        
#>  [5] MultiAssaySpatialExperiment_0.99.12 MultiAssayExperiment_1.39.1        
#>  [7] SummarizedExperiment_1.43.0         Biobase_2.73.2                     
#>  [9] GenomicRanges_1.65.4                Seqinfo_1.3.2                      
#> [11] IRanges_2.47.5                      S4Vectors_0.51.9                   
#> [13] BiocGenerics_0.59.12                generics_0.1.4                     
#> [15] MatrixGenerics_1.25.0               matrixStats_1.5.0                  
#> [17] 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.12.0         lattice_0.23-1       vctrs_0.7.3         
#>  [7] tools_4.6.1          tibble_3.3.1         proxy_0.4-29        
#> [10] BiocBaseUtils_1.15.1 pkgconfig_2.0.3      Matrix_1.7-6        
#> [13] KernSmooth_2.23-27   RColorBrewer_1.1-3   S7_0.2.2            
#> [16] lifecycle_1.0.5      compiler_4.6.1       farver_2.1.2        
#> [19] tinytex_0.60         codetools_0.2-20     htmltools_0.5.9     
#> [22] class_7.3-24         sass_0.4.10          yaml_2.3.12         
#> [25] pillar_1.11.1        jquerylib_0.1.4      classInt_0.4-11     
#> [28] DelayedArray_0.39.6  cachem_1.1.0         magick_2.9.1        
#> [31] abind_1.4-8          tidyselect_1.2.1     digest_0.6.39       
#> [34] dplyr_1.2.1          bookdown_0.48        fastmap_1.2.0       
#> [37] grid_4.6.1           cli_3.6.6            SparseArray_1.13.2  
#> [40] magrittr_2.0.5       S4Arrays_1.13.0      dichromat_2.0-1     
#> [43] e1071_1.7-17         withr_3.0.3          scales_1.4.0        
#> [46] rmarkdown_2.32       XVector_0.53.0       otel_0.2.0          
#> [49] evaluate_1.0.5       knitr_1.51           rlang_1.3.0         
#> [52] Rcpp_1.1.2           glue_1.8.1           DBI_1.3.0           
#> [55] BiocManager_1.30.27  jsonlite_2.0.0       R6_2.6.1            
#> [58] units_1.0-1