Skip to content

Commit bea1f1c

Browse files
committed
fix: map counts to layers['counts'] across all Seurat->h5ad paths
Counts were written to the legacy /raw/X group, which modern scverse tools (scvi-tools, current scanpy workflows) do not look at. The in-memory direct writers were migrated to /layers/counts, but the h5seurat-file and CLI paths still emitted /raw/X, producing structurally different h5ad depending on the entry point and silently dropping counts on a CLI round-trip. Migrate the remaining writers and teach the CLI reader the modern convention: - H5SeuratToH5AD (R hub): counts -> /layers/counts (dict 0.1.0 + csr_matrix 0.1.0 + shape); drop the /raw and /raw/var machinery. Layers share /var. - CLI writer (sc_h5ad.c): counts -> /layers/counts instead of /raw/X + raw/var. - CLI reader (sc_h5ad.c): read /layers/counts into the assay counts layer and route X to data when a counts slot exists; /raw/X still read for back-compat. - sc_stream_layers: skip "counts"/"data" so the primary matrices are not duplicated into a stray top-level /layers or collided on round-trip. - Rename internal raw_list -> counts_list in .writeH5AD_c for accuracy. Encoding verified byte-for-byte against anndata 0.12.7 (layers dict 0.1.0, csr_matrix 0.1.0, shape; no dtype attr - anndata does not emit one). Full round-trips validated for the C writer, R writer, R hub writer, and CLI both directions; legacy /raw/X reading preserved. Test suites: cli-integration 117/0, roundtrip-property 193/0, saveload 19/0, misc-roundtrip 36/0, varp 18/0.
1 parent de9f270 commit bea1f1c

5 files changed

Lines changed: 155 additions & 237 deletions

File tree

‎R/Convert.R‎

Lines changed: 38 additions & 152 deletions
Original file line numberDiff line numberDiff line change
@@ -1291,6 +1291,18 @@ H5ADToH5Seurat <- function(
12911291
# would otherwise be decoded per assay-copy pass.
12921292
.categorical_cache <- new.env(parent = emptyenv())
12931293

1294+
# Counts source priority: raw/X (legacy h5ad) > layers/counts (modern
1295+
# anndata). When neither is present, X itself is treated as counts —
1296+
# matching scanpy's behaviour when only X exists.
1297+
counts_source <- if (source$exists(name = 'raw') &&
1298+
source[['raw']]$exists(name = 'X')) {
1299+
'raw/X'
1300+
} else if (source$exists(name = 'layers') &&
1301+
source[['layers']]$exists(name = 'counts')) {
1302+
'layers/counts'
1303+
} else {
1304+
'X'
1305+
}
12941306
ds.map <- c(
12951307
scale.data = if (inherits(x = source[['X']], what = 'H5D')) {
12961308
'X'
@@ -1299,11 +1311,7 @@ H5ADToH5Seurat <- function(
12991311
},
13001312
# Always use X for data slot - X contains normalized data when raw/X has counts
13011313
data = 'X',
1302-
counts = if (source$exists(name = 'raw')) {
1303-
'raw/X'
1304-
} else {
1305-
'X'
1306-
}
1314+
counts = counts_source
13071315
)
13081316
# Add assay data
13091317
assay.group <- dfile[['assays']]$create_group(name = assay)
@@ -3254,164 +3262,42 @@ H5SeuratToH5AD <- function(
32543262
)
32553263
}
32563264

3257-
# Add raw
3265+
# Add counts as layers/counts (modern anndata convention). Layers share the
3266+
# main /var, so no separate var group is written (unlike the legacy /raw).
32583267
if (!is.null(x = raw.data)) {
32593268
if (verbose) {
3260-
message("Adding ", raw.data, " from ", assay, " as raw")
3261-
}
3262-
3263-
# Get shape from source BEFORE copying (most reliable)
3264-
raw_source_dims <- NULL
3265-
if (grepl("/", raw.data)) {
3266-
# Handle nested paths like "layers/counts"
3267-
parts <- strsplit(raw.data, "/")[[1]]
3268-
src_obj <- assay.group
3269-
for (part in parts) {
3270-
src_obj <- src_obj[[part]]
3271-
}
3272-
if (src_obj$attr_exists(attr_name = 'dims')) {
3273-
raw_source_dims <- h5attr(x = src_obj, which = 'dims')
3274-
}
3275-
} else {
3276-
# Simple path
3277-
if (assay.group$exists(name = raw.data) &&
3278-
assay.group[[raw.data]]$attr_exists(attr_name = 'dims')) {
3279-
raw_source_dims <- h5attr(x = assay.group[[raw.data]], which = 'dims')
3280-
}
3269+
message("Adding ", raw.data, " from ", assay, " as layers/counts")
32813270
}
32823271

3283-
dfile$create_group(name = 'raw')
3284-
CopyH5MatrixData(
3285-
src_group = assay.group,
3286-
src_path = raw.data,
3287-
dst_loc = dfile[['raw']],
3288-
dst_name = 'X',
3289-
verbose = verbose
3290-
)
3291-
3292-
# Ensure shape attribute exists for raw/X (required by anndata)
3293-
if (!dfile[['raw/X']]$attr_exists(attr_name = 'shape')) {
3294-
# Try to get from copied dims attribute first
3295-
if (dfile[['raw/X']]$attr_exists(attr_name = 'dims')) {
3296-
dims <- h5attr(x = dfile[['raw/X']], which = 'dims')
3297-
dfile[['raw/X']]$create_attr(
3298-
attr_name = 'shape',
3299-
robj = rev(x = dims),
3300-
dtype = GuessDType(x = dims)
3301-
)
3302-
dfile[['raw/X']]$attr_delete(attr_name = 'dims')
3303-
} else if (!is.null(raw_source_dims)) {
3304-
# Use dims from source
3305-
dfile[['raw/X']]$create_attr(
3306-
attr_name = 'shape',
3307-
robj = rev(x = raw_source_dims),
3308-
dtype = GuessDType(x = raw_source_dims)
3309-
)
3310-
}
3311-
}
3312-
3313-
AddEncoding(dname = 'raw/X')
3314-
# Get number of features from the ACTUAL raw/X matrix dimensions
3315-
raw_x_shape <- NULL
3316-
if (dfile[['raw/X']]$attr_exists(attr_name = 'shape')) {
3317-
raw_x_shape <- h5attr(x = dfile[['raw/X']], which = 'shape')
3318-
} else if (dfile[['raw/X']]$attr_exists(attr_name = 'dims')) {
3319-
raw_x_shape <- rev(h5attr(x = dfile[['raw/X']], which = 'dims'))
3320-
}
3321-
if (!is.null(raw_x_shape) && length(raw_x_shape) >= 2) {
3322-
n_raw_features <- raw_x_shape[2]
3323-
} else {
3324-
n_raw_features <- prod(assay.group[['features']]$dims)
3325-
}
3326-
# Add meta.features with validation
3327-
if (assay.group$exists(name = 'meta.features')) {
3328-
meta.features.src <- assay.group[['meta.features']]
3329-
if (inherits(x = meta.features.src, what = c('H5D', 'H5Group'))) {
3330-
TransferDF(
3331-
src = meta.features.src,
3332-
dname = 'raw/var',
3333-
index = seq.default(from = 1, to = n_raw_features)
3334-
)
3335-
} else {
3336-
message("meta.features is not a valid H5D or H5Group in raw, creating empty var")
3337-
dfile[['raw']]$create_group(name = 'var')
3338-
}
3339-
} else {
3340-
dfile[['raw']]$create_group(name = 'var')
3341-
}
3342-
# Add feature names
3343-
if (Exists(x = dfile[['raw/var']], name = rownames)) {
3344-
dfile[['raw/var']]$link_delete(name = rownames)
3345-
}
3346-
# Get feature names from correct location based on structure
3347-
if (has_layers && assay.group$exists(name = 'meta.data/_index')) {
3348-
# V5 structure: feature names are in meta.data/_index
3349-
features_data_raw <- SafeH5DRead(assay.group[['meta.data/_index']])
3350-
} else {
3351-
# Legacy structure: feature names are in features dataset
3352-
features_data_raw <- SafeH5DRead(assay.group[['features']])
3353-
}
3354-
# Subset to match actual raw/X dimensions
3355-
features_data_raw <- as.character(features_data_raw[seq_len(n_raw_features)])
3356-
dfile[['raw/var']]$create_dataset(
3357-
name = rownames,
3358-
robj = features_data_raw,
3359-
dtype = GuessDType(x = features_data_raw[1])
3360-
)
3361-
# Add encoding attributes to raw/var/_index (required for AnnData to read gene names correctly)
3362-
dfile[['raw/var']][[rownames]]$create_attr(
3272+
layers.grp <- dfile$create_group(name = 'layers')
3273+
layers.grp$create_attr(
33633274
attr_name = 'encoding-type',
3364-
robj = 'string-array',
3365-
dtype = CachedGuessDType(x = 'string-array'),
3275+
robj = 'dict',
3276+
dtype = CachedGuessDType(x = 'dict'),
33663277
space = ScalarSpace()
33673278
)
3368-
dfile[['raw/var']][[rownames]]$create_attr(
3279+
layers.grp$create_attr(
33693280
attr_name = 'encoding-version',
3370-
robj = '0.2.0',
3371-
dtype = CachedGuessDType(x = '0.2.0'),
3281+
robj = '0.1.0',
3282+
dtype = CachedGuessDType(x = '0.1.0'),
33723283
space = ScalarSpace()
33733284
)
3374-
dfile[['raw/var']]$create_attr(
3375-
attr_name = rownames,
3376-
robj = rownames,
3377-
dtype = GuessDType(x = rownames),
3378-
space = ScalarSpace()
3285+
3286+
CopyH5MatrixData(
3287+
src_group = assay.group,
3288+
src_path = raw.data,
3289+
dst_loc = layers.grp,
3290+
dst_name = 'counts',
3291+
verbose = verbose
33793292
)
3380-
# Add dataframe encoding attributes to raw/var group (required for AnnData)
3381-
# Only add column-order if it doesn't already exist (TransferDF may have added it)
3382-
if (!dfile[['raw/var']]$attr_exists(attr_name = 'column-order')) {
3383-
# Get list of columns (everything except _index)
3384-
raw_var_cols <- setdiff(dfile[['raw/var']]$names, rownames)
3385-
if (length(raw_var_cols) > 0) {
3386-
dfile[['raw/var']]$create_attr(
3387-
attr_name = 'column-order',
3388-
robj = raw_var_cols,
3389-
dtype = GuessDType(x = raw_var_cols)
3390-
)
3391-
} else {
3392-
# Empty column-order for dataframes with only _index
3393-
dfile[['raw/var']]$create_attr(
3394-
attr_name = 'column-order',
3395-
robj = character(0),
3396-
dtype = StringType('utf8')
3397-
)
3398-
}
3399-
}
3400-
# Add encoding-type and encoding-version
3401-
encoding.info <- c('type' = 'dataframe', 'version' = '0.2.0')
3402-
names(x = encoding.info) <- paste0('encoding-', names(x = encoding.info))
3403-
for (i in seq_along(along.with = encoding.info)) {
3404-
attr.name <- names(x = encoding.info)[i]
3405-
attr.value <- encoding.info[i]
3406-
if (dfile[['raw/var']]$attr_exists(attr_name = attr.name)) {
3407-
dfile[['raw/var']]$attr_delete(attr_name = attr.name)
3408-
}
3409-
dfile[['raw/var']]$create_attr(
3410-
attr_name = attr.name,
3411-
robj = attr.value,
3412-
dtype = GuessDType(x = attr.value),
3413-
space = ScalarSpace()
3414-
)
3293+
3294+
# AddEncoding sets encoding-type/version and recomputes the anndata shape
3295+
# (n_obs, n_vars) from indptr plus the copied `dims` attribute. Drop the
3296+
# leftover h5seurat `dims` attribute afterwards for byte-parity with anndata.
3297+
AddEncoding(dname = 'layers/counts')
3298+
counts.grp <- layers.grp[['counts']]
3299+
if (counts.grp$attr_exists(attr_name = 'dims')) {
3300+
counts.grp$attr_delete(attr_name = 'dims')
34153301
}
34163302
}
34173303
# Add cell-level metadata with validation

‎R/WriteH5AD.R‎

Lines changed: 27 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -225,20 +225,21 @@ writeH5AD <- function(
225225
has_counts <- layer_has("counts")
226226
has_data <- layer_has("data")
227227

228-
# anndata convention: X holds normalized data, raw/X holds counts. When both
229-
# layers exist, route counts to the raw group; when only one exists, it
230-
# becomes X directly.
228+
# anndata convention: X holds normalized data, counts live in
229+
# layers['counts']. When both layers exist, counts is passed to the C writer
230+
# (which emits /layers/counts); when only one exists, it becomes X directly.
231231
mat <- if (has_data) GetAssayData(assay_obj, layer = "data")
232232
else if (has_counts) GetAssayData(assay_obj, layer = "counts")
233233
else NULL
234234
if (is.null(mat)) return(FALSE)
235235

236-
raw_list <- NULL
236+
# `counts_list` carries the counts CSR arrays to C_write_h5ad's 8th argument.
237+
counts_list <- NULL
237238
if (has_data && has_counts) {
238-
raw_mat <- GetAssayData(assay_obj, layer = "counts")
239-
raw_list <- list(i = raw_mat@i, p = raw_mat@p, x = raw_mat@x,
240-
dim = dim(raw_mat))
241-
rm(raw_mat)
239+
counts_mat <- GetAssayData(assay_obj, layer = "counts")
240+
counts_list <- list(i = counts_mat@i, p = counts_mat@p, x = counts_mat@x,
241+
dim = dim(counts_mat))
242+
rm(counts_mat)
242243
}
243244

244245
mat_list <- list(
@@ -282,7 +283,7 @@ writeH5AD <- function(
282283

283284
result <- .Call(C_write_h5ad,
284285
filename, mat_list, meta, reductions, graphs,
285-
assay_name, as.integer(gzip_level), raw_list
286+
assay_name, as.integer(gzip_level), counts_list
286287
)
287288

288289
if (isTRUE(result) && verbose) {
@@ -374,22 +375,33 @@ DirectSeuratToH5AD <- function(
374375
!is.null(d) && prod(dim(d)) > 0
375376
}, error = function(e) FALSE)
376377

378+
# X is the primary expression matrix: normalized data when present,
379+
# otherwise raw counts (keeps counts-only objects valid for readH5AD's
380+
# required /X skeleton).
377381
if (has_data) {
378382
data_mat <- GetAssayData(object, assay = assay, layer = 'data')
379383
write_csr_group(dfile, "X", data_mat)
380384
rm(data_mat); gc(verbose = FALSE)
381-
if (has_counts) {
382-
counts_mat <- GetAssayData(object, assay = assay, layer = 'counts')
383-
raw_grp <- dfile$create_group("raw")
384-
write_csr_group(raw_grp, "X", counts_mat)
385-
rm(counts_mat); gc(verbose = FALSE)
386-
}
387385
} else if (has_counts) {
388386
counts_mat <- GetAssayData(object, assay = assay, layer = 'counts')
389387
write_csr_group(dfile, "X", counts_mat)
390388
rm(counts_mat); gc(verbose = FALSE)
391389
}
392390

391+
# counts slot -> data.layers["counts"] (modern anndata convention).
392+
# Only when a separate data slot exists; counts-only objects already
393+
# placed counts in /X above to preserve round-trip with the reader.
394+
if (has_data && has_counts) {
395+
counts_mat <- GetAssayData(object, assay = assay, layer = 'counts')
396+
layers_grp <- dfile$create_group("layers")
397+
layers_grp$create_attr(attr_name = 'encoding-type', robj = 'dict',
398+
dtype = CachedGuessDType('dict'), space = ScalarSpace())
399+
layers_grp$create_attr(attr_name = 'encoding-version', robj = '0.1.0',
400+
dtype = CachedGuessDType('0.1.0'), space = ScalarSpace())
401+
write_csr_group(layers_grp, "counts", counts_mat)
402+
rm(counts_mat); gc(verbose = FALSE)
403+
}
404+
393405
# ========== obs (cell metadata) ==========
394406
if (verbose) message(" Writing obs...")
395407
meta <- object[[]]
@@ -420,12 +432,6 @@ DirectSeuratToH5AD <- function(
420432
}
421433
write_df_group(dfile, "var", meta_features, gene_names)
422434

423-
# Write raw/var if raw/X exists
424-
if (has_data && has_counts) {
425-
write_df_group(dfile[["raw"]], "var",
426-
data.frame(row.names = gene_names), gene_names)
427-
}
428-
429435
# ========== obsm (dimensional reductions) ==========
430436
reducs <- Reductions(object)
431437
if (length(reducs) > 0) {

‎src/sc_groups.c‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -636,6 +636,12 @@ int sc_stream_layers(hid_t src, hid_t dst, sc_direction_t dir, int gzip_level) {
636636
H5Gget_objname_by_idx(src_grp, i, name, sizeof(name));
637637
int obj_type = H5Gget_objtype_by_idx(src_grp, i);
638638

639+
/* "counts" and "data" are the primary matrices, handled explicitly as
640+
* X / layers-counts. Skip them here so they are neither duplicated into
641+
* a stray top-level /layers entry nor collide on round-trip. */
642+
if (strcmp(name, "counts") == 0 || strcmp(name, "data") == 0)
643+
continue;
644+
639645
if (obj_type == H5G_GROUP) {
640646
/* Fast path: H5Ocopy for sparse layers (CSR structure preserved) */
641647
int copy_rc = sc_copy_group_h5ocopy(src_grp, name, dst_grp, name);

0 commit comments

Comments
 (0)