Skip to content

Converting: AnnData, Seurat and SingleCellExperiment

Getting a dataset from Python to R usually means an HDF5 round trip through SeuratDisk or zellkonverter, and the failure mode is not an error: it is a dropped layer, or a factor that arrives as an integer, discovered three plots later.

A .cytome is the same file for both languages. Nothing is converted on the way across: the Python package and the R package open the same SQLite tables and decode the same compressed chunks. Conversion happens only at the edges, when you ask for an in-memory object.

The six conversions

from → tocalllanguage
AnnData → cytomecytome.from_anndata(adata, output=path)Python
cytome → AnnDatads.to_anndata(modality="RNA")Python
Seurat → cytomewrite_cytome(obj, path)R
cytome → Seuratread_cytome(path, as = "Seurat")R
SCE → cytomewrite_cytome(sce, path)R
cytome → SCEread_cytome(path)R

write_cytome() is a generic: it dispatches on the object, so the same call takes a Seurat or a SingleCellExperiment. read_cytome() takes as to choose what comes back: "SingleCellExperiment" (default), "Seurat", or "cytome" for the open handle.

Seurat → AnnData, and back

This is the pairing people actually want, and it is two calls in two languages with a file in between.

# R
library(cytome)
write_cytome(seurat_obj, "shared.cytome")
import piaso
piaso.settings.set_figure_params(style="cell") # one figure style across every tutorial
# Python
import cytome
ds = cytome.open("shared.cytome")
adata = ds.to_anndata(modality="RNA")
adata.shape

The other direction:

# Python
import cytome
cytome.from_anndata(adata, output="shared.cytome")
# R
so <- read_cytome("shared.cytome", as = "Seurat")

No bridge process, no reticulate, no HDF5 intermediate. Either side can be the one that never opens the other language.

What transfers

Measured on the package’s reference file (12 cells, RNA + ATAC), by running each direction — not read off the code:

Seurat → cytomeSCE → cytomecytome → Seuratcytome → SCE
main counts matrixyesyesyesyes
second modalityyes — ATAC_countsyes — from altExpyes — extra assayyes — altExp
embeddingsyes — X_umapyes — from reducedDimsyes — DimReducyes — reducedDims
graphs / KNNyes — graphs slotyes — from colPairsyes — graphsyes — colPairs
normalized layerslayers = TRUElayers = TRUEyes — as layersyes — as assays
cell annotationsall meta.data columnsall colData columnsyesyes
feature annotationsid + symbol onlyid + symbol onlyfrom the feature tablefrom the feature table

Two of those need a sentence.

Normalized layers are opt-in

write_cytome(so, path) # counts only (default)
write_cytome(so, path, layers = "data") # plus the normalized layer
write_cytome(sce, path, layers = TRUE) # every assay

Off by default because an archive and a handoff want opposite things. For an archive, normalized values are a deterministic function of the counts and cost more space than the counts do: dropping a scaled matrix took one of the published cytomes from 1.59 GB to 0.19 GB, because float matrices compress far worse than integers. For a handoff, they are the difference between sending your object and sending counts plus an instruction to guess the parameters.

Naming a layer that is not there says which, and what is:

write_cytome(sce, path, layers = "lognorm")
#> Error: cytome: no such layer(s): lognorm. Available: logcounts

Graphs travel by default

A Seurat graphs slot and an SCE colPairs both land in the same graph_edges table the Python package writes, so a KNN graph built on either side is readable from the other. On by default, because recomputing a neighbour graph on millions of cells is the expensive part, and carrying it means your collaborator reproduces the same clusters rather than similar ones. graphs = FALSE opts out.

What still does not travel

Feature annotations beyond id and symbol. Anything else in rowData or meta.features is dropped. If a per-gene annotation matters, keep it beside the file.

Modalities the R writer understands

RNA maps to the genes feature table and ATAC to peaks. Anything else is an error naming what is supported, rather than a file the Python package cannot open:

write_cytome(sce, "out.cytome", main_modality = "PROTEIN")
#> Error: cytome: cannot write modality 'PROTEIN' from R. Supported: RNA, ATAC.
#> Build the cytome on the Python side for other modalities.

ATAC feature ids have to be coordinates, because the format’s peaks table requires chr / start / end:

#> Error: cytome: ATAC feature ids must look like 'chr1:100-200'; 1 did not,
#> e.g. notapeak

Failing on the id you gave it beats writing a file that fails to open later.

Data too large to convert

If the point of the exercise is that the matrix does not fit, do not convert it at all:

sce <- read_cytome("big.cytome", delayed = TRUE)
SummarizedExperiment::assay(sce) # a DelayedArray, read on demand
scuttle::logNormCounts(sce) # block-processed; the matrix is never whole

Extraction is chunk-aligned: only the storage chunks overlapping the cells being asked for are read, and chunkdim() reports the storage geometry so DelayedArray’s blocks land on chunk boundaries. Peak memory is set by the block size, not by the size of the matrix, which is what lets scran, scuttle and DelayedMatrixStats run on data that does not fit.

or work chunk-wise:

x <- read_cytome("big.cytome", as = "cytome")
totals <- cytome_stream(x, "RNA_counts",
function(chunk, i0, i1, k) Matrix::rowSums(chunk))
per_gene <- Reduce(`+`, totals)
cytome_close(x)

How this is kept honest

The claim “both languages read the same file” is a test, not a promise. The R package ships a reference.cytome written by the Python implementation, plus the expected values as CSVs, and its test suite asserts it reads them bit-for-bit — both codecs, zstd for RNA and lz4 for ATAC.

The reverse direction is checked too: CI writes a cytome from R and has Python read it back and compare against the same expectations. That check is what caught alternative assays being dropped on write — a bug no amount of reading-direction testing would have found.