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 → to | call | language |
|---|---|---|
| AnnData → cytome | cytome.from_anndata(adata, output=path) | Python |
| cytome → AnnData | ds.to_anndata(modality="RNA") | Python |
| Seurat → cytome | write_cytome(obj, path) | R |
| cytome → Seurat | read_cytome(path, as = "Seurat") | R |
| SCE → cytome | write_cytome(sce, path) | R |
| cytome → SCE | read_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.
# Rlibrary(cytome)write_cytome(seurat_obj, "shared.cytome")import piasopiaso.settings.set_figure_params(style="cell") # one figure style across every tutorial# Pythonimport cytomeds = cytome.open("shared.cytome")adata = ds.to_anndata(modality="RNA")adata.shapeThe other direction:
# Pythonimport cytomecytome.from_anndata(adata, output="shared.cytome")# Rso <- 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 → cytome | SCE → cytome | cytome → Seurat | cytome → SCE | |
|---|---|---|---|---|
| main counts matrix | yes | yes | yes | yes |
| second modality | yes — ATAC_counts | yes — from altExp | yes — extra assay | yes — altExp |
| embeddings | yes — X_umap | yes — from reducedDims | yes — DimReduc | yes — reducedDims |
| graphs / KNN | yes — graphs slot | yes — from colPairs | yes — graphs | yes — colPairs |
| normalized layers | layers = TRUE | layers = TRUE | yes — as layers | yes — as assays |
| cell annotations | all meta.data columns | all colData columns | yes | yes |
| feature annotations | id + symbol only | id + symbol only | from the feature table | from 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 layerwrite_cytome(sce, path, layers = TRUE) # every assayOff 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: logcountsGraphs 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. notapeakFailing 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 demandscuttle::logNormCounts(sce) # block-processed; the matrix is never wholeExtraction 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.