schard — h5ad → Seurat / SingleCellExperiment (pure R)
schard reads scanpy/AnnData .h5ad files into R without Python: it parses the HDF5
file directly with rhdf5 and builds SingleCellExperiment or Seurat objects.
Upstream: <https://github.com/cellgeni/schard> (default branch main, version 1.1.0, not on CRAN/Bioconductor).
When to Use
- An
.h5ad(cellxgene, Sanger atlas, or your own scanpy pipeline) must be analysed in R. - The usual route is broken or annoying:
sceasyneedsreticulate+ a working Python env,
SeuratDiskneeds an intermediate.h5Seuratand is barely maintained. - You need Visium spatial data out of an h5ad with its images and spot coordinates, not just the matrix.
- You only want to read a slice of an h5ad (e.g.
obsor oneobsmembedding) without loading expression.
Not for: writing h5ad (schard is read-only), or .h5Seurat files (that is SeuratDisk).
Overview
schard loads X, obs, var, obsm (as reductions), raw and — for Visium —
/uns/spatial/<library_id>/images + scalefactors, then assembles the target object.
It deliberately does not carry uns, obsp, varm, varp, or layers other than those
you explicitly request through layers=. Everything is plain R + rhdf5; no Python, no reticulate,
so it works in containers where conda/reticulate handshakes fail.
Three entry points (plus two low-level helpers):
| h5ad content | function | returns |
|---|---|---|
| any | h5ad2sce(path) |
SingleCellExperiment (single assay named X) |
| any, non-spatial | h5ad2seurat(path) |
Seurat (RNA assay by default) |
| Visium (spots + images) | h5ad2seurat_spatial(path) |
Seurat with Spatial assay + image(s) |
| one slot only | h5ad2data.frame(path, 'obs'), h5ad2Matrix(path, 'obsm/X_umap') |
data.frame / matrix |
Inputs
Slots that must exist in the h5ad for each target (verified against the upstream sources
R/functions.R and R/h5ad_util.R):
| slot | sce | seurat | seurat_spatial | notes |
|---|---|---|---|---|
/X (csr/csc sparse or dense) |
required | required | required | --no-load-x zero-fills it instead |
/obs |
required | required | required | cell metadata; rownames come from its _index column |
/var |
required | required | required | feature metadata (rowData / feature meta.data) |
/obsm/<name> |
optional (load.obsm=TRUE) |
optional | required (spatial or X_spatial for coordinates) |
|
/raw/{X,var} |
optional (use.raw=TRUE) |
optional (use.raw=TRUE) |
optional | absent ⇒ use.raw=TRUE errors |
/layers/<name> |
not used | via layers= |
not used | also accepts the literals X / raw.X |
/uns/spatial/<library_id>/images/{lowres,hires} + scalefactors |
not used | not used | required | one image resolution is attached |
an obs column whose values ⊆ names(uns/spatial) |
not used | not used | required* | *or a single library, or schard stops |
Is X counts or log-normalized? (decide BEFORE trusting the object)
schard does not care: it drops whatever X (or raw.X) is into the Seurat counts slot.
For vis.heart.h5ad, sn.heart.h5ad and ba16.h5ad that is wrong — measured, not guessed:
| file | X/data (probe of 20 000 values) |
/raw/X/data |
verdict |
|---|---|---|---|
ba16.h5ad |
float, min 0.51 max 8.27, 0 % integer | — (no raw) |
X = log-normalized, counts are gone |
sn.heart.h5ad |
float, min 0.25 max 6.39, 0 % integer | min 1 max 2022, 100 % integer | X = log-normalized, raw.X = counts |
vis.heart.h5ad |
float, min 0.52 max 6.28, 0 % integer | min 1 max 774, 100 % integer | X = log-normalized, raw.X = counts |
Rules of thumb: integer values with max ≥ 20 ⇒ raw counts; non-integer with max ≤ 30 ⇒ log1p;
non-integer with a large max ⇒ normalized but probably not log-transformed (check before DE).
uns/log1p is only a weak hint — it is present in vis.heart.h5ad and absent in
sn.heart.h5ad, although both have log-normalized X. Always trust the numbers. The script's
--dry-run does exactly this probe for you (≈1 s, reads 20 k values):
Rscript scripts/schard_h5ad_conversion.R.txt --input-h5ad sample.h5ad --dry-run
# manual alternatives:
# R: rhdf5::h5ls('f.h5ad'); rhdf5::h5read('f.h5ad','/X/data', index=list(1:20000))
# py: h5py.File('f.h5ad')['X/data'][:20000]
Steps
Reusable, fully parameterised script: scripts/schard_h5ad_conversion.R.txt
(no hard-coded paths; every input/output is a CLI flag).
# 0. quickstart + cheap slot inspection (does NOT need schard installed)
Rscript scripts/schard_h5ad_conversion.R.txt --input-h5ad sample.h5ad --dry-run
# sce
Rscript scripts/schard_h5ad_conversion.R.txt --input-h5ad sample.h5ad --to sce --output-rds sample_sce.rds
# seurat (adata.X into the counts slot -- only if X really is counts)
Rscript scripts/schard_h5ad_conversion.R.txt --input-h5ad sample.h5ad --to seurat --output-rds sample.rds
# seurat with REAL counts
Rscript scripts/schard_h5ad_conversion.R.txt --input-h5ad sample.h5ad --to seurat --use-raw --output-rds cnt.rds
# spatial: one object per slide, hires image
Rscript scripts/schard_h5ad_conversion.R.txt --input-h5ad vis.h5ad --to seurat_spatial \
--no-simplify --img-res hires --output-rds vis_list.rds
# batch directory
Rscript scripts/schard_h5ad_conversion.R.txt --input-dir ./h5ads --glob '*.h5ad' --to sce --output-dir ./sce
The five package calls, as the upstream README / user script uses them:
library(schard)
## 1) SingleCellExperiment ---------------------------------------------------
ba16.sce <- schard::h5ad2sce('ba16.h5ad') # assay named "X"
# use.raw=TRUE swaps adata.X for adata.raw.X; load.obsm=FALSE skips the reductions
## 2) Seurat (non-spatial) ---------------------------------------------------
snhx <- schard::h5ad2seurat('sn.heart.h5ad')
Seurat::DimPlot(snhx, group.by = 'cell_state', reduction = 'Xumap_') # h5ad 'X_umap' -> Seurat 'Xumap_'
## 3) Visium / spatial -------------------------------------------------------
visx <- schard::h5ad2seurat_spatial('vis.heart.h5ad')
Seurat::SpatialDimPlot(visx, group.by = 'cell_type', ncol = 2)
Seurat::SpatialPlot(visx, features = 'total_counts', images = 'HCAHeartST11702009')
## 4) raw counts instead of the (log-normalized) adata.X ---------------------
snhr <- schard::h5ad2seurat('sn.heart.h5ad', use.raw = TRUE)
visr <- schard::h5ad2seurat_spatial('vis.heart.h5ad', use.raw = TRUE)
plot(colSums(visx), colSums(visr), pch = 16) # they really are different matrices
## 5) one Seurat object per slide (list) -------------------------------------
visl <- schard::h5ad2seurat_spatial('vis.heart.h5ad', simplify = FALSE)
Seurat::SpatialPlot(visl$HCAHeartST11702010, features = 'total_counts')
## 6) matrices / routing layers into Seurat slots (v1.1.0 feature) -----------
seu <- schard::h5ad2seurat('adata.h5ad', layers = c(counts = 'raw.X', data = 'X'))
# 'layers' wins over 'use.raw'; only 'counts' is mandatory (data / scale.data optional)
## 7) metadata only, no expression ------------------------------------------
obs <- schard::h5ad2data.frame('sn.heart.h5ad', 'obs') # keep.rownames.as.column=TRUE
umap <- t(schard::h5ad2Matrix('sn.heart.h5ad', '/obsm/X_umap')) # obsm is stored transposed
ls <- rhdf5::h5ls('sn.heart.h5ad'); ls[ls$group == '/obsm', ] # discover obsm names
Parameters
Package arguments (exact upstream signatures, main @ v1.1.0) and the matching script flag:
| argument | signature (default) | script flag | meaning |
|---|---|---|---|
filename |
positional | --input-h5ad / --input-dir |
path to the .h5ad |
use.raw |
FALSE |
--use-raw / --no-use-raw |
load adata.raw.X (+ raw/var) instead of adata.X |
simplify |
TRUE (spatial only) |
--simplify / --no-simplify |
merge slides into one object, else return a per-slide list |
assay |
'RNA' (h5ad2seurat) |
--assay-name |
assay name; always 'Spatial' for spatial |
load.obsm |
TRUE |
--load-obsm / --no-load-obsm |
load obsm as reductions |
load.X |
TRUE |
--load-x / --no-load-x |
FALSE = zero-filled matrix (much faster) |
layers |
NULL (h5ad2seurat only) |
--layers 'counts=raw.X,data=X' |
route h5ad matrices into Seurat slots; overrides use.raw |
img.res |
'lowres' |
--img-res lowres\|hires |
which Visium image to attach |
image.type |
NULL → auto |
--image-type auto\|VisiumV1\|VisiumV2 |
auto = VisiumV2 on Seurat ≥ 5.1, else VisiumV1 |
use_spam |
FALSE (h5ad2list, h5ad2Matrix) |
— | >2^31-1 non-zeros; needs spam+spam64, not Seurat-compatible |
Script-only flags: --to sce|seurat|seurat_spatial, --output-rds,
--output-dir DIR (output root; creates Img/, Stat/, Rdata/ under it),
--manifest,
--save-meta/--no-save-meta (write <name>_obs.csv), --overwrite/--no-overwrite,
--glob, --recursive/--no-recursive, --dry-run, --no-inspect, --probe N,
--no-layers, --quiet, --help.
Exit code 0 = all files converted, 1 = at least one failed/skipped (a manifest CSV is always written).
Outputs
Directory layout — --output-dir is the output root; the script creates three
subdirectories under it (Img/, Stat/, Rdata/) on every run, including --dry-run:
| produced file | location |
|---|---|
<name>_<mode>[_raw][_list].rds (the converted object; any .h5ad/.h5seurat/.qs side-product) |
<out>/Rdata/ |
schard_conversion_manifest.csv (per-file run manifest) |
<out>/Stat/ |
<name>_<mode>[_raw][_list]_obs.csv (cell metadata) |
<out>/Stat/ |
figures, if any (e.g. SpatialDimPlot / DimPlot exports) |
<out>/Img/ |
--output-rds FILE is the exception: it is an explicit path used verbatim, and its
directory then serves as the root for Stat/ and Img/. The run ends with a printed
summary of all three directories.
- SCE (
h5ad2sce): one assay namedX;colData=obs,rowData=var;
reducedDim(sce, 'X_umap')— reducedDim names keep the raw h5ad obsm names (no Seurat renaming);
cell names =obsrownames (index column,make.unique-ed, and still kept as a column). - Seurat (
h5ad2seurat): assayRNA(or--assay-name) with the h5ad matrix in thecountslayer;
obscolumns inmeta.data;varcolumns as feature metadata (seu[['RNA']][[]]);
reductions renamed to Seurat style —paste0(gsub('_','',name),'_'), i.e.X_umap→Xumap_,
X_spatial/spatial→Xspatial_, with dims named<key>1..n;
feature names have_→-and empty names →unnamed_gene_<i>. - Spatial (
h5ad2seurat_spatial): assaySpatial; one image per slide under
seu[['<library_id>']](VisiumV2here: Seurat 5.5.1 > 5.1), spot table with
tissue/row/col/imagerow/imagecol, coordinates taken fromobsm[['X_spatial']]with the two
columns swapped internally;simplify=TRUEmerges all slides (merge()) and returns the bare
object when there is only one slide (not a list of length 1).
Pitfalls
adata.Xis not necessarily counts — the single most expensive mistake here. In the shipped
example filesXis log1p-normalized, soh5ad2seurat('sn.heart.h5ad')puts non-integer values
into thecountsslot; a laterNormalizeData/FindMarkerssilently operates on log data.
ProbeX/datafirst (see Inputs) and use--use-raw/layers = c(counts='raw.X')when
/raw/Xexists. The script prints acounts checkline for exactly this reason.use.raw = TRUEon a file without/raw/Xis a hard error, not the warning the docs promise:
upstreamh5ad2listcallsstop("There is no raw slot ...").ba16.h5adhas norawat all.- h5ad version compatibility: very old anndata (e.g.
ba16.h5ad) storesobs/var/obsmas
compound datasets at the root rather than groups.obs/varstill parse (schard handles both
factor encodings,__categoriesand/uns/*_categories), butobsmis looked up as
/obsm/<name>groups only, so old-formatobsmis silently skipped — expect no reductions.
Newer files have groups everywhere,X_spatialinobsm, images inuns/spatial. Any h5ad
written by a much newer anndata with unknownencoding-typevalues may fail to parse — then
convert in Python (anndata→csr_matrixh5ad orzarr) first. [partly UNVERIFIED: not run here] - Visium
library_iddetection can silently drop slides: schard picks the firstobscolumn
whose values are all insidenames(uns/spatial). Invis.heart.h5adthat column issangerID,
which only references 3 of the 4 libraries present underuns/spatial(HCAHeartST11702011has
an image but no spots), so the simplified object has 3 images, not 4 — verify with
SeuratObject::Images(obj). With no matching column it only works for single-library files
(it then invents alibrary_idcolumn) and otherwise stops with
"The h5ad seems to contain multiple visium samples but library is not specified correctly". - Spot grid metadata may be fake:
vis.heart.h5adhas noin_tissue,array_row,array_col
inobs, so schard substitutes1/0/0. Plotting uses the realimagerow/imagecolfrom
obsm, but anything relying on the array grid is meaningless. Also, the coordinates must live in
obsmunderspatialorX_spatial(other names ⇒ error). - One image resolution only:
img.resdefaults tolowres; withhiresschard remaps
scale.factors$tissue_lowres_scalef. A missing resolution warns and falls back to the other one. - Seurat version decides the image class:
image.type=NULL→VisiumV2for Seurat > 5.1
(container has 5.5.1),VisiumV1otherwise. Code digging into@coordinates/@scale.factors
must be written for the right class. - Feature-name mangling for Seurat:
_→-and empty names →unnamed_gene_<i>(roxygen:
"Seurat for some reasons doesn't like underscores in feature names"). This breaks joins with
tables keyed on exact symbols; also duplicate gene symbols survive asmake.unique-d rownames
from the h5ad index, so aggregate duplicates (scanpy-side, orSeurat::AggregateExpression)
before DE. - Huge matrices: > 2^31-1 non-zeros ⇒ schard stops with a pointer to
use_spam=TRUE
(spam/spam64, but that object is then not Seurat-compatible) — the realistic answer is to
subset in Python or use--no-load-x. Memory:rhdf5::h5readmaterialises the whole matrix
(vis.heart.h5ad: 36.2 M non-zeros ⇒ ~0.6 GB numeric, more once Seurat copies it), so convert
one file per process; the script does exactly that andgc()s between files. - Dense, dense-looking
X: ifXis a dense array with > 80 % non-zero values (or > 2^31-1
non-zeros) schard assumes it is scaled and passes it toCreateAssayObject(data=)instead of
counts=. Check which layer ended up populated. simplify=TRUEwith a single slide returns the object, not a list — guard with
inherits(x, 'Seurat')before indexingx[[1]].--to scehas no spatial support:h5ad2scehas noimageshandling and noassay/layers
argument; a Visium file becomes a plain SCE whoseX_spatialis only areducedDim. Use
h5ad2seurat_spatialfor spot plots.h5ad2images()is internal (not in NAMESPACE): useSeuratObject::Images(obj)instead.- Zero-arg traps:
schardmust be installed (the script says so and exits 1;--dry-runworks
without it).h5ad2sce(load.X=FALSE)still returns a matrix, just an empty one.
Environment
- Container R 4.5.3;
.libPaths()=/opt/user_R_libs,/opt/public_R_libs,/opt/conda/lib/R/library. - Already installed:
rhdf52.54.1,Matrix1.7.6,Seurat5.5.1,SeuratObject5.4.0,
SingleCellExperiment1.32.0,BiocManager1.30.27,devtools2.5.2. - Not installed:
scharditself — install it before converting (dry-run needs nothing extra).
zeallot(often listed next to h5ad tooling) is not an schard dependency;spam/spam64
are only needed foruse_spam=TRUE. - Install:
r BiocManager::install('rhdf5') # if missing # install.packages('devtools') devtools::install_github('cellgeni/schard') # main branch, v1.1.0 - Everything documented above was read from the upstream sources at
main
(R/functions.R,R/h5ad_util.R,NAMESPACE,DESCRIPTION,README.md) and cross-checked with
real slot probes of the three example files (ba16.h5ad,sn.heart.h5ad,vis.heart.h5ad).
The conversion calls themselves were not executed here becauseschardis not installed in
this container; only the script's inspection/reporting paths were run for real.
Files
scripts/schard_h5ad_conversion.R.txt— CLI wrapper around the threeh5ad2*functions:
parameterised (--to,--use-raw,--layers,--simplify,--assay-name,--img-res, …),
no hard-coded paths, cheap pre-flight slot/counts inspection, per-file manifest CSV,
--dry-runthat works without schard.
> Packaging note. Scripts and tables in this skill carry a .txt suffix on their
> original extension (e.g. .R.txt, .tsv.txt) because the SkillHub upload allowlist
> does not accept .R or .tsv. This is cosmetic: Rscript is extension-agnostic, so
> Rscript scripts/<name>.R.txt ... behaves exactly like .R, and tab-separated data
> parses identically from .tsv.txt. Keep the suffix when repackaging.