SpaMTP: Modern Data Access and Workflow Migration

One container, public accessors

The submission-branch API uses existing Bioconductor S4 containers. Raw, unaligned spectra remain in Cardinal; a common feature-by-pixel matrix belongs in SpatialExperiment. No knowledge of internal S4 slots is needed to read, modify or subset these objects. The historical Seurat tutorials remain on the published-workflow branch; their code is not the submission-branch API.

Data to access Public interface
Expression/intensity matrices assay(), assayNames()
Feature m/z and annotations rowData()
Pixel metadata and groups colData(), colLabels()
Pixel coordinates spatialCoords(), spatialCoordsNames()
Optical images imgData(), imgRaster(), addImg()
Paired modalities altExp(), altExpNames()
PCA and other embeddings reducedDim(), reducedDimNames()
Analysis provenance metadata()

These accessors also have replacement forms. For example, colData(spe)$region <- values modifies pixel metadata through the container’s public API. A slot = "counts" argument retained by some SpaMTP functions is a compatibility spelling for the expression assay name; it is not an instruction to inspect an S4 slot.

The two selections are distinct: assay = "transcriptome" selects altExp(spe, "transcriptome"), while slot = "logcounts" selects the matrix within that experiment. Use assay = "main" for the primary experiment and exact character matrix names; numeric positions are not accepted by these SpaMTP selectors. See help("experimentAccess", package = "SpaMTP") for the shared convention and compatibility aliases.

library(SpaMTP)

Read a binned MSI table

The CSV contains one row per pixel, x and y coordinate columns, and one column per measured m/z. This runnable example writes a small temporary input file. Replace it with your own path for real data. Raw imzML processing is demonstrated in the native workflow.

input <- data.frame(
  x = rep(1:3, each = 2), y = rep(1:2, 3),
  check.names = FALSE,
  "100.01" = c(0, 2, 3, 5, 7, 9),
  "200.02" = c(4, 1, 6, 2, 8, 3),
  "300.03" = c(2, 3, 1, 4, 2, 6)
)
spe <- local({
  path <- tempfile(fileext = ".csv")
  on.exit(unlink(path))
  utils::write.csv(input, path, row.names = FALSE)
  readSMMatrix(path, project.name = "section1", verbose = FALSE)
})
stopifnot(methods::is(spe, "SpatialExperiment"), nrow(spe) == 3, ncol(spe) == 6)
SummarizedExperiment::assayNames(spe)
## [1] "counts"
SummarizedExperiment::assay(spe, "counts")
## 3 x 6 sparse Matrix of class "dgCMatrix"
##           1_1 1_2 2_1 2_2 3_1 3_2
## mz-100.01   .   2   3   5   7   9
## mz-200.02   4   1   6   2   8   3
## mz-300.03   2   3   1   4   2   6
SummarizedExperiment::rowData(spe)$mz
## [1] 100.01 200.02 300.03

Pixel and feature metadata

Use feature identifiers to index matrix rows, and pixel identifiers to index columns. m/z is explicit feature metadata, not something to recover by editing feature names. For discrete plotting groups, use a factor even if labels were originally stored as integer cluster numbers.

SummarizedExperiment::colData(spe)$region <- factor(rep(c("edge", "core"), 3))
SingleCellExperiment::colLabels(spe) <- spe$region
SummarizedExperiment::rowData(spe)$feature_label <- c("peak A", "peak B", "peak C")

intensity <- SummarizedExperiment::assay(spe, "counts")
SummarizedExperiment::colData(spe)$total_intensity <- Matrix::colSums(intensity)
SummarizedExperiment::colData(spe)$detected_features <- Matrix::colSums(intensity > 0)
as.data.frame(SummarizedExperiment::colData(spe))
##     orig.ident sample_id region label total_intensity detected_features
## 1_1   section1  section1   edge  edge               6                 2
## 1_2   section1  section1   core  core               6                 3
## 2_1   section1  section1   edge  edge              10                 3
## 2_2   section1  section1   core  core              11                 3
## 3_1   section1  section1   edge  edge              17                 3
## 3_2   section1  section1   core  core              18                 3

QC values above are summaries of MSI intensities, not UMI counts. They are computed from the selected matrix instead of assuming that historical Seurat-generated QC columns still describe the current features and assay.

spe <- normalizeSMData(spe, "LogNormalize", verbose = FALSE)
SummarizedExperiment::assayNames(spe)
## [1] "counts"    "logcounts"
scater::plotExpression(
  spe, features = rownames(spe)[1], x = "region", exprs_values = "logcounts"
)

Coordinates and optical images

spatialCoords() is the coordinate source for native spatial analysis. Historical x_coord/y_coord metadata may describe an earlier alignment; retaining these columns for provenance does not make them authoritative. SpaMTP expects named x and y axes in a shared coordinate system. If an importer uses names such as pxl_col_in_fullres and pxl_row_in_fullres, map the known column axis to x and row axis to y with spatialCoordsNames(). Renaming does not convert units, reverse an axis or perform image registration.

xy <- SpatialExperiment::spatialCoords(spe)
SpatialExperiment::spatialCoordsNames(spe)
## [1] "x" "y"
shifted <- spe
SpatialExperiment::spatialCoords(shifted)[, "x"] <- xy[, "x"] + 10
stopifnot(identical(SpatialExperiment::spatialCoords(spe), xy))

# Demonstrate an explicit name mapping on a copy, without changing the values.
imported <- spe
SpatialExperiment::spatialCoordsNames(imported) <-
  c("pxl_col_in_fullres", "pxl_row_in_fullres")
axisNames <- SpatialExperiment::spatialCoordsNames(imported)
axisNames[match(c("pxl_col_in_fullres", "pxl_row_in_fullres"), axisNames)] <- c("x", "y")
SpatialExperiment::spatialCoordsNames(imported) <- axisNames
stopifnot(identical(SpatialExperiment::spatialCoords(imported), xy))

An image has its own sample ID, image ID and scale factor in imgData(). Adding a raster does not register it to the MSI pixels. This small artificial image illustrates storage only. With load = TRUE, the raster remains usable after the temporary PNG is removed; a path-backed image requires a lasting path.

spe <- local({
  path <- tempfile(fileext = ".png")
  on.exit(unlink(path))
  png::writePNG(matrix(seq(0, 1, length.out = 12), nrow = 3), path)
  addSpatialImage(spe, imageSource = path, sampleId = "section1",
    imageId = "demo", scaleFactor = 1, load = TRUE)
})
SpatialExperiment::imgData(spe)[, c("sample_id", "image_id", "scaleFactor")]
## DataFrame with 1 row and 3 columns
##     sample_id    image_id scaleFactor
##   <character> <character>   <numeric>
## 1    section1        demo           1
raster <- SpatialExperiment::imgRaster(spe, sample_id = "section1", image_id = "demo")
stopifnot(inherits(raster, "raster"))

Paired modalities and standard subsetting

Only measurements of the same pixels belong in an altExp(). First align and map independent MSI pixels and transcriptomic spots with mapSpatialOmics(); do not rename unrelated spots to force a match. addTranscriptome() validates pixel names and reorders an already paired matrix.

transcriptome <- matrix(seq_len(12), nrow = 2,
  dimnames = list(c("gene1", "gene2"), rev(colnames(spe))))
spe <- addTranscriptome(spe, transcriptome)
rna <- SingleCellExperiment::altExp(spe, "transcriptome")
stopifnot(identical(colnames(rna), colnames(spe)))
SummarizedExperiment::assay(rna, "counts")
##       1_1 1_2 2_1 2_2 3_1 3_2
## gene1  11   9   7   5   3   1
## gene2  12  10   8   6   4   2

selected <- spe[, spe$region == "edge"]
stopifnot(identical(colnames(SingleCellExperiment::altExp(selected, "transcriptome")),
  colnames(selected)))
stopifnot(nrow(SpatialExperiment::spatialCoords(selected)) == ncol(selected))

Feature subsetting affects the primary experiment’s rows; each alternative experiment retains its own feature space. Standard column subsetting keeps coordinates and alternative experiments aligned to the selected pixels.

S4Vectors::metadata(spe)$analysis <- list(
  input_type = "binned MSI", normalization = "LogNormalize")
S4Vectors::metadata(spe)$analysis
## $input_type
## [1] "binned MSI"
## 
## $normalization
## [1] "LogNormalize"

For full annotation examples, see the indexed annotation pipeline. It reads candidate summaries from rowData() and stores ranked candidates and annotation provenance in metadata().

Optional Seurat boundary

SeuratObject is optional and is needed only for explicit conversion. In Seurat 5, LayerData() reads an exact expression layer, double brackets read cell or feature metadata, and GetTissueCoordinates() reads image/FOV coordinates. SpaMTP’s converters use these public APIs. Older serialized objects that cannot be read by current accessors should be updated explicitly with SeuratObject::UpdateSeuratObject() in the external Seurat workflow; SpaMTP does not patch their internal slots.

if (requireNamespace("SeuratObject", quietly = TRUE)) {
  seurat <- spatialExperimentToSeurat(spe)
  values <- SeuratObject::LayerData(seurat, assay = "Spatial", layer = "counts")
  pixel_metadata <- seurat[[]]
  feature_metadata <- seurat[["Spatial"]][[]]
  centres <- SeuratObject::GetTissueCoordinates(seurat, image = "fov", full = FALSE)

  converted <- seuratToSpatialExperiment(seurat, image = "fov", layer = "counts")
  stopifnot(identical(colnames(converted), colnames(spe)))
  stopifnot(isTRUE(all.equal(
    as.matrix(SummarizedExperiment::assay(converted, "counts")), as.matrix(values))))
  S4Vectors::metadata(converted)$seurat_interoperability
}
## $primary_assay
## [1] "Spatial"
## 
## $layer
## [1] "counts"
## 
## $image
## [1] "fov"
## 
## $coordinate_source
## [1] "image"
## 
## $coordinate_columns
## [1] "x" "y"

When no image is specified, a single image/FOV is authoritative over any historical metadata coordinates. Metadata is used automatically only if there are no images. With multiple images, select image or supply explicit named coordinates; the converter never guesses which slice to use. Centroids are read as one centre per pixel, not as expanded boundary vertices.

Only the requested exact layer is transferred. Join split Seurat layers first if the complete dataset is intended. Unpaired assays are skipped with a warning. Optical rasters, graphs and reductions are not transferred. After conversion, continue with native accessors; Seurat is not used for SpaMTP analysis.

sessionInfo()
## R version 4.6.1 (2026-06-24)
## Platform: x86_64-pc-linux-gnu
## Running under: Ubuntu 26.04.1 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               LC_TIME=en_US.UTF-8       
##  [4] LC_COLLATE=en_US.UTF-8     LC_MONETARY=en_US.UTF-8    LC_MESSAGES=en_US.UTF-8   
##  [7] LC_PAPER=en_US.UTF-8       LC_NAME=C                  LC_ADDRESS=C              
## [10] LC_TELEPHONE=C             LC_MEASUREMENT=en_US.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] SpaMTP_0.99.11   BiocStyle_2.41.0
## 
## loaded via a namespace (and not attached):
##   [1] splines_4.6.1               later_1.4.8                 filelock_1.0.3             
##   [4] tibble_3.3.1                lifecycle_1.0.5             httr2_1.3.0                
##   [7] sf_1.1-3                    edgeR_4.99.7                globals_0.20.0             
##  [10] lattice_0.23-1              crosstalk_1.2.2             magrittr_2.0.5             
##  [13] SpaMTPdb_0.99.4             limma_3.99.2                plotly_4.12.1              
##  [16] sass_0.4.10                 rmarkdown_2.32              jquerylib_0.1.4            
##  [19] yaml_2.3.12                 metapod_1.21.0              httpuv_1.6.17              
##  [22] otel_0.2.0                  spam_2.11-4                 sp_2.2-4                   
##  [25] cowplot_1.2.0               DBI_1.3.0                   buildtools_1.0.0           
##  [28] RColorBrewer_1.1-3          abind_1.4-8                 GenomicRanges_1.65.4       
##  [31] purrr_1.2.2                 BiocGenerics_0.59.12        rappdirs_0.3.4             
##  [34] SpaMTPData_0.99.5           IRanges_2.47.5              S4Vectors_0.51.10          
##  [37] ggrepel_0.9.8               irlba_2.4.1                 listenv_1.1.0              
##  [40] maketools_1.3.2             pheatmap_1.0.13             units_1.0-1                
##  [43] RSpectra_0.16-2             matter_2.15.0               dqrng_0.4.1                
##  [46] parallelly_1.48.0           codetools_0.2-20            DelayedArray_0.39.8        
##  [49] scuttle_1.23.2              tidyselect_1.2.1            farver_2.1.2               
##  [52] ScaledMatrix_1.21.0         viridis_0.6.5               matrixStats_1.5.0          
##  [55] stats4_4.6.1                BiocFileCache_3.3.0         Seqinfo_1.3.2              
##  [58] jsonlite_2.0.0              CardinalIO_1.11.0           BiocNeighbors_2.7.3        
##  [61] e1071_1.7-17                progressr_1.0.0             scater_1.41.2              
##  [64] tools_4.6.1                 Rcpp_1.1.2                  glue_1.8.1                 
##  [67] gridExtra_2.3.1             SparseArray_1.13.4          BiocBaseUtils_1.15.1       
##  [70] xfun_0.61                   MatrixGenerics_1.25.0       dplyr_1.2.1                
##  [73] withr_3.0.3                 BiocManager_1.30.27         fastmap_1.2.0              
##  [76] bluster_1.23.1              digest_0.6.39               rsvd_1.0.5                 
##  [79] R6_2.6.1                    mime_0.13                   RSQLite_3.53.3             
##  [82] tidyr_1.3.2                 generics_0.1.4              data.table_1.18.6.1        
##  [85] FNN_1.1.4.1                 class_7.3-24                httr_1.4.9                 
##  [88] htmlwidgets_1.6.4           S4Arrays_1.13.2             ontologyIndex_2.12         
##  [91] pkgconfig_2.0.3             gtable_0.3.6                blob_1.3.0                 
##  [94] S7_0.2.2                    SingleCellExperiment_1.35.2 XVector_0.53.0             
##  [97] sys_3.4.3                   htmltools_0.5.9             dotCall64_1.2              
## [100] fgsea_1.39.2                ProtGenerics_1.45.0         SeuratObject_5.4.0         
## [103] scales_1.4.0                Biobase_2.73.2              png_0.1-9                  
## [106] SpatialExperiment_1.23.0    scran_1.41.1                knitr_1.52                 
## [109] rjson_0.2.23                nlme_3.1-171                curl_8.0.0                 
## [112] proxy_0.4-29                cachem_1.1.0                stringr_1.6.0              
## [115] Cardinal_3.15.0             BiocVersion_3.24.0          KernSmooth_2.23-27         
## [118] parallel_4.6.1              vipor_0.4.7                 AnnotationDbi_1.75.2       
## [121] pillar_1.11.1               grid_4.6.1                  vctrs_0.7.3                
## [124] promises_1.5.0              BiocSingular_1.29.2         dbplyr_2.6.0               
## [127] beachmat_2.29.3             xtable_1.8-8                cluster_2.1.8.3            
## [130] beeswarm_0.4.0              evaluate_1.0.5              magick_2.9.1               
## [133] cli_3.6.6                   locfit_1.5-9.12             compiler_4.6.1             
## [136] rlang_1.3.0                 crayon_1.5.3                future.apply_1.20.2        
## [139] labeling_0.4.3              mclust_6.1.3                classInt_0.4-11            
## [142] naturalsort_0.1.3           ggbeeswarm_0.7.3            stringi_1.8.9              
## [145] viridisLite_0.4.3           BiocParallel_1.47.0         Biostrings_2.81.9          
## [148] Matrix_1.7-6                ExperimentHub_3.3.2         bit64_4.8.6                
## [151] future_1.76.0               ggplot2_4.0.3               KEGGREST_1.53.6            
## [154] statmod_1.5.2               shiny_1.14.0                SummarizedExperiment_1.43.0
## [157] AnnotationHub_4.3.2         igraph_2.3.4                memoise_2.0.1              
## [160] bslib_0.12.0                fastmatch_1.1-8             bit_4.6.0