Skip to main content

data_beans/
convert.rs

1use crate::hdf5_io::*;
2use crate::sparse_io::*;
3use crate::sparse_util::*;
4use crate::zarr_io::*;
5
6use legume_numeric::matrix::common_io::*;
7use log::info;
8
9/// Convert a 10x-format HDF5 file (Cell Ranger / h5ad) to a data-beans backend.
10///
11/// Uses standard 10x field layout:
12///   root_group/data, root_group/indices, root_group/indptr (CSC),
13///   root_group/features/{id,name,feature_type}, root_group/barcodes
14#[cfg(feature = "hdf5")]
15pub fn convert_h5_to_backend(h5_file: &str, output: &str) -> anyhow::Result<()> {
16    let (backend, backend_file) =
17        resolve_backend_file(&Box::from(output), Some(SparseIoBackend::Zarr))?;
18
19    if std::path::Path::new(backend_file.as_ref()).exists() {
20        info!("Removing existing backend file: {}", &backend_file);
21        remove_file(&backend_file)?;
22    }
23
24    let file = hdf5::File::open(h5_file)?;
25    info!("Opened data file: {}", h5_file);
26
27    let root_group_name = "matrix";
28    let root = file.group(root_group_name).map_err(|_| {
29        anyhow::anyhow!(
30            "Unable to find root group '{}' in {}",
31            root_group_name,
32            h5_file,
33        )
34    })?;
35
36    // Read triplets
37    let CooTripletsShape { triplets, shape } = {
38        let values = root
39            .dataset("data")
40            .map_err(|_| anyhow::anyhow!("missing 'data' dataset"))?
41            .read_1d::<f32>()?
42            .to_vec();
43        let indices = root
44            .dataset("indices")
45            .map_err(|_| anyhow::anyhow!("missing 'indices' dataset"))?
46            .read_1d::<u64>()?
47            .to_vec();
48        let indptr = root
49            .dataset("indptr")
50            .map_err(|_| anyhow::anyhow!("missing 'indptr' dataset"))?
51            .read_1d::<u64>()?
52            .to_vec();
53
54        ValuesIndicesPointers {
55            values: &values,
56            indices: &indices,
57            indptr: &indptr,
58        }
59        .to_coo(IndexPointerType::Column)?
60    };
61
62    let TripletsShape { nrows, ncols, nnz } = shape;
63    info!("Read {} non-zero elements in {} x {}", nnz, nrows, ncols);
64
65    // Row IDs
66    let mut row_ids: Vec<Box<str>> = match root.dataset("features/id") {
67        Ok(rows) => read_hdf5_strings(rows)?,
68        _ => {
69            info!("row (feature) IDs not found");
70            (0..nrows).map(|x| x.to_string().into_boxed_str()).collect()
71        }
72    };
73
74    // Row names
75    let mut row_names: Vec<Box<str>> = match root.dataset("features/name") {
76        Ok(rows) => read_hdf5_strings(rows)?,
77        _ => {
78            info!("row (feature) names not found");
79            vec![Box::from(""); nrows]
80        }
81    };
82
83    if nrows < row_ids.len() {
84        row_ids.truncate(nrows);
85    }
86    if nrows < row_names.len() {
87        row_names.truncate(nrows);
88    }
89    assert_eq!(nrows, row_ids.len());
90    assert_eq!(nrows, row_names.len());
91
92    // Composite row names: id_name
93    let row_ids: Vec<Box<str>> = row_ids
94        .into_iter()
95        .zip(row_names)
96        .map(|(id, name)| {
97            if !name.is_empty() {
98                format!("{}_{}", id, name).into_boxed_str()
99            } else {
100                id
101            }
102        })
103        .collect();
104
105    // Row types for filtering
106    let mut row_types: Vec<Box<str>> = match root.dataset("features/feature_type") {
107        Ok(rows) => read_hdf5_strings(rows)?,
108        _ => {
109            info!("use all the types");
110            vec!["Gene Expression".to_string().into_boxed_str(); nrows]
111        }
112    };
113    if nrows < row_types.len() {
114        row_types.truncate(nrows);
115    }
116    assert_eq!(nrows, row_types.len());
117
118    // Column names
119    let mut column_names: Vec<Box<str>> = match root.dataset("barcodes") {
120        Ok(columns) => read_hdf5_strings(columns)?,
121        _ => {
122            info!("column (cell) names not found");
123            (0..ncols).map(|x| x.to_string().into_boxed_str()).collect()
124        }
125    };
126    if ncols < column_names.len() {
127        column_names.truncate(ncols);
128    }
129    assert_eq!(ncols, column_names.len());
130
131    // Create backend
132    let mut out = create_sparse_from_triplets(
133        &triplets,
134        (nrows, ncols, nnz),
135        Some(&backend_file),
136        Some(&backend),
137    )?;
138    info!("Created sparse matrix: {}", backend_file);
139    out.register_row_names_vec(&row_ids);
140    out.register_column_names_vec(&column_names);
141
142    // Filter by gene expression type
143    let select_pattern = "gene expression";
144    let remove_pattern = "aggregate";
145    let select_rows: Vec<usize> = row_types
146        .iter()
147        .enumerate()
148        .filter_map(|(i, x)| {
149            let lower = x.to_lowercase();
150            if lower.contains(select_pattern) && !lower.contains(remove_pattern) {
151                Some(i)
152            } else {
153                None
154            }
155        })
156        .collect();
157
158    if select_rows.len() < nrows {
159        info!(
160            "Filtering features: {} -> {} rows of gene expression type",
161            nrows,
162            select_rows.len()
163        );
164        out.subset_columns_rows(None, Some(&select_rows))?;
165    }
166
167    finalize_zarr_output(&backend_file, output)?;
168    info!("Conversion done: {}", output);
169    Ok(())
170}
171
172/// Convert a 10x-format Zarr file (Xenium zarr.zip or directory) to a data-beans backend.
173///
174/// Uses standard 10x Xenium field layout:
175///   /cell_features/{data,indices,indptr} (CSC),
176///   /cell_features/features/{id,name,feature_type},
177///   /cell_features/cell_id
178pub fn convert_zarr_to_backend(zarr_file: &str, output: &str) -> anyhow::Result<()> {
179    let (backend, backend_file) =
180        resolve_backend_file(&Box::from(output), Some(SparseIoBackend::Zarr))?;
181
182    if std::path::Path::new(backend_file.as_ref()).exists() {
183        info!("Removing existing backend file: {}", &backend_file);
184        remove_file(&backend_file)?;
185    }
186
187    if !std::path::Path::new(zarr_file).exists() {
188        let zip_variant = format!("{}.zip", zarr_file);
189        let hint: Box<str> = if std::path::Path::new(&zip_variant).exists() {
190            format!(" (did you mean {}?)", zip_variant).into()
191        } else {
192            Box::from("")
193        };
194        anyhow::bail!("Zarr file not found: {}{}", zarr_file, hint);
195    }
196
197    let store = open_zarr_store(zarr_file)?;
198    info!("Opened zarr store: {}", zarr_file);
199
200    let indices: Vec<u64> = read_zarr_numerics(store.clone(), "/cell_features/indices")?;
201    let indptr: Vec<u64> = read_zarr_numerics(store.clone(), "/cell_features/indptr")?;
202    let values: Vec<f32> = read_zarr_numerics(store.clone(), "/cell_features/data")?;
203    info!("Read the arrays");
204
205    let CooTripletsShape { triplets, shape } = ValuesIndicesPointers {
206        values: &values,
207        indices: &indices,
208        indptr: &indptr,
209    }
210    .to_coo(IndexPointerType::Column)?;
211
212    let TripletsShape { nrows, ncols, nnz } = shape;
213    info!("Read {} non-zero elements in {} x {}", nnz, nrows, ncols);
214
215    let row_id_field = "/cell_features/features/id";
216    let row_name_field = "/cell_features/features/name";
217    let row_type_field = "/cell_features/features/feature_type";
218    let column_name_field = "/cell_features/cell_id";
219
220    let mut row_ids = read_zarr_group_attr::<Vec<Box<str>>>(store.clone(), row_id_field)
221        .or_else(|_| read_zarr_strings(store.clone(), row_id_field))
222        .unwrap_or_else(|_| (0..nrows).map(|x| x.to_string().into_boxed_str()).collect());
223
224    let mut row_names = read_zarr_group_attr::<Vec<Box<str>>>(store.clone(), row_name_field)
225        .or_else(|_| read_zarr_strings(store.clone(), row_name_field))
226        .unwrap_or_else(|_| (0..nrows).map(|x| x.to_string().into_boxed_str()).collect());
227
228    info!("Read {} row names", row_ids.len());
229    if nrows < row_ids.len() {
230        row_ids.truncate(nrows);
231    }
232    if nrows < row_names.len() {
233        row_names.truncate(nrows);
234    }
235    assert_eq!(nrows, row_ids.len());
236    assert_eq!(nrows, row_names.len());
237
238    // Composite row names
239    let row_ids: Vec<Box<str>> = row_ids
240        .into_iter()
241        .zip(row_names)
242        .map(|(id, name)| {
243            if !name.is_empty() {
244                format!("{}_{}", id, name).into_boxed_str()
245            } else {
246                id
247            }
248        })
249        .collect();
250
251    let mut row_types = read_zarr_group_attr::<Vec<Box<str>>>(store.clone(), row_type_field)
252        .or_else(|_| read_zarr_strings(store.clone(), row_type_field))
253        .unwrap_or_else(|_| vec!["Gene Expression".to_string().into_boxed_str(); nrows]);
254    if nrows < row_types.len() {
255        row_types.truncate(nrows);
256    }
257    assert_eq!(nrows, row_types.len());
258
259    let mut column_names =
260        parse_10x_cell_id(read_zarr_ndarray::<u32>(store.clone(), column_name_field)?.view())
261            .or_else(|_| read_zarr_group_attr::<Vec<Box<str>>>(store.clone(), column_name_field))
262            .or_else(|_| read_zarr_strings(store.clone(), column_name_field))
263            .unwrap_or_else(|_| (0..ncols).map(|x| x.to_string().into_boxed_str()).collect());
264
265    if ncols < column_names.len() {
266        column_names.truncate(ncols);
267    }
268    assert_eq!(ncols, column_names.len());
269
270    let mut out = create_sparse_from_triplets(
271        &triplets,
272        (nrows, ncols, nnz),
273        Some(&backend_file),
274        Some(&backend),
275    )?;
276    info!("Created sparse matrix: {}", backend_file);
277    out.register_row_names_vec(&row_ids);
278    out.register_column_names_vec(&column_names);
279
280    // Filter by gene expression type
281    let select_pattern = "gene expression";
282    let remove_pattern = "aggregate";
283    let select_rows: Vec<usize> = row_types
284        .iter()
285        .enumerate()
286        .filter_map(|(i, x)| {
287            let lower = x.to_lowercase();
288            if lower.contains(select_pattern) && !lower.contains(remove_pattern) {
289                Some(i)
290            } else {
291                None
292            }
293        })
294        .collect();
295
296    if select_rows.len() < nrows {
297        info!(
298            "Filtering features: {} -> {} rows of gene expression type",
299            nrows,
300            select_rows.len()
301        );
302        out.subset_columns_rows(None, Some(&select_rows))?;
303    }
304
305    finalize_zarr_output(&backend_file, output)?;
306    info!("Conversion done: {}", output);
307    Ok(())
308}
309
310/// Try to open a data file directly; if that fails, attempt automatic
311/// conversion from raw 10x formats (h5/h5ad, zarr/zarr.zip).
312///
313/// Converted backends are cached as `{data_file}.db.zarr` next to
314/// the original file so subsequent calls skip conversion.
315pub fn try_open_or_convert(
316    data_file: &str,
317) -> anyhow::Result<Box<dyn SparseIo<IndexIter = Vec<usize>>>> {
318    let ext = file_ext(data_file)?;
319    let backend = match ext.as_ref() {
320        "h5" | "h5ad" => SparseIoBackend::HDF5,
321        _ => SparseIoBackend::Zarr,
322    };
323
324    match open_sparse_matrix(data_file, &backend) {
325        Ok(data) => Ok(data),
326        Err(original_err) => {
327            let base = strip_backend_suffix(data_file);
328            let converted = format!("{}.db.zarr", base);
329
330            if std::path::Path::new(&converted).exists() {
331                info!("Using cached conversion: {}", converted);
332                return open_sparse_matrix(&converted, &SparseIoBackend::Zarr);
333            }
334
335            match ext.as_ref() {
336                "h5" | "h5ad" => {
337                    #[cfg(feature = "hdf5")]
338                    {
339                        info!(
340                            "Converting h5/h5ad to backend: {} -> {}",
341                            data_file, converted
342                        );
343                        convert_h5_to_backend(data_file, &converted)?;
344                    }
345                    #[cfg(not(feature = "hdf5"))]
346                    {
347                        anyhow::bail!(
348                            "{} is an HDF5 file but data-beans was built without the `hdf5` \
349                             feature. Reinstall with `--features hdf5` (and a working libhdf5) \
350                             to read .h5/.h5ad inputs.",
351                            data_file
352                        );
353                    }
354                }
355                "zarr" | "zip" => {
356                    info!("Converting zarr to backend: {} -> {}", data_file, converted);
357                    convert_zarr_to_backend(data_file, &converted)?;
358                }
359                _ => return Err(original_err),
360            }
361
362            open_sparse_matrix(&converted, &SparseIoBackend::Zarr)
363        }
364    }
365}