Skip to content

Commit 8fe898c

Browse files
committed
Fix loom matrix orientation
1 parent b6e2895 commit 8fe898c

5 files changed

Lines changed: 119 additions & 22 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ All notable changes to the C++ line of Baysor are documented here.
5656

5757
### Fixed
5858

59+
- Fixed Loom matrix orientation so `/matrix` rows match `/row_attrs` genes and
60+
columns match `/col_attrs` cells.
5961
- Reduced clustering memory by removing the per-thread dense gene-by-gene correlation matrix.
6062
- Reduced segmentation memory by switching persistent component gene counts away from dense `double` storage.
6163
- Reduced NCV memory by removing global all-molecule neighborhood materialization and the full all-molecule high-dimensional NCV matrix from the `run` and `preview` paths.

docs/output_files.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ Structure:
149149
HDF5 layout written by the current C++ implementation:
150150
- `/matrix`
151151
- dataset type: `float32`
152-
- shape: `(C, G)`
152+
- shape: `(G, C)`
153153
- `/attrs/LOOM_SPEC_VERSION`
154154
- variable-length UTF-8 string
155155
- value: `"3.0.0"`
@@ -171,7 +171,7 @@ HDF5 layout written by the current C++ implementation:
171171

172172
Notes:
173173
- matrix values are counts
174-
- the matrix is written in a row-oriented block path for performance
174+
- rows are genes and columns are cells, matching Loom row/column attributes
175175

176176
### `segmentation_counts.tsv`
177177

include/baysor/reporting/output.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ using LoomColAttrs = std::map<std::string,
7373

7474
/// Save count matrix to Loom (HDF5) format.
7575
/// matrix : n_cells × n_genes (rows = cells/NCVs, cols = genes)
76+
/// on disk : /matrix is n_genes × n_cells to match Loom row/column attributes
7677
/// col_attrs: optional extra per-cell attributes (e.g. ncv_color, confidence)
7778
void save_matrix_to_loom(const Eigen::SparseMatrix<float>& matrix,
7879
const std::vector<std::string>& gene_names,

src/reporting/output.cpp

Lines changed: 28 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -241,7 +241,7 @@ static nlohmann::json polygons_to_geojson_json(
241241
// Loom spec: https://linnarssonlab.org/loompy/format/index.html
242242
//
243243
// Layout produced (mirrors Julia's save_matrix_to_loom):
244-
// /matrix float32 n_cells × n_genes (chunked, shuffle+deflate)
244+
// /matrix float32 n_genes × n_cells (chunked, shuffle+deflate)
245245
// /row_attrs/Name variable-length UTF-8 strings [n_genes]
246246
// /col_attrs/Name variable-length UTF-8 strings [n_cells]
247247
// /col_attrs/CellID float64 [n_cells] (1-based indices)
@@ -312,18 +312,26 @@ void save_matrix_to_loom(
312312
throw std::runtime_error("save_matrix_to_loom: gene_names length mismatch");
313313
if (static_cast<int>(cell_names.size()) != n_cells)
314314
throw std::runtime_error("save_matrix_to_loom: cell_names length mismatch");
315+
for (const auto& [key, val] : col_attrs) {
316+
size_t attr_len = std::holds_alternative<std::vector<std::string>>(val)
317+
? std::get<std::vector<std::string>>(val).size()
318+
: std::get<std::vector<double>>(val).size();
319+
if (attr_len != static_cast<size_t>(n_cells)) {
320+
throw std::runtime_error("save_matrix_to_loom: col_attrs '" + key + "' length mismatch");
321+
}
322+
}
315323

316324
// Open file
317325
hid_t fid = H5Fcreate(path.c_str(), H5F_ACC_TRUNC, H5P_DEFAULT, H5P_DEFAULT);
318326
check(fid, "H5Fcreate");
319327

320328
// ---- /matrix (float32, chunked, shuffle + deflate-3) ----
321329
{
322-
hsize_t dims[2] = { static_cast<hsize_t>(n_cells),
323-
static_cast<hsize_t>(n_genes) };
324-
constexpr int row_block = 128;
325-
hsize_t chunk[2] = { static_cast<hsize_t>(std::min(n_cells, row_block)),
326-
static_cast<hsize_t>(std::min(n_genes, 256)) };
330+
hsize_t dims[2] = { static_cast<hsize_t>(n_genes),
331+
static_cast<hsize_t>(n_cells) };
332+
constexpr int cell_block = 128;
333+
hsize_t chunk[2] = { static_cast<hsize_t>(std::min(n_genes, 256)),
334+
static_cast<hsize_t>(std::min(n_cells, cell_block)) };
327335

328336
hid_t space = H5Screate_simple(2, dims, nullptr);
329337
hid_t plist = H5Pcreate(H5P_DATASET_CREATE);
@@ -335,27 +343,27 @@ void save_matrix_to_loom(
335343
H5P_DEFAULT, plist, H5P_DEFAULT);
336344
check(ds, "create /matrix");
337345

338-
// Write in row blocks to reduce HDF5 call overhead without
339-
// materializing the full dense matrix.
340-
std::vector<float> block_buf(static_cast<size_t>(row_block) * static_cast<size_t>(n_genes), 0.0f);
346+
// Write Loom rows as genes. The input matrix stays cells x genes to
347+
// match the other count-matrix writers.
348+
std::vector<float> block_buf(static_cast<size_t>(n_genes) * static_cast<size_t>(cell_block), 0.0f);
341349
hsize_t block_offset[2] = {0, 0};
342350

343-
for (int block_start = 0; block_start < n_cells; block_start += row_block) {
344-
int n_block_rows = std::min(row_block, n_cells - block_start);
345-
std::fill(block_buf.begin(), block_buf.begin() + static_cast<size_t>(n_block_rows) * static_cast<size_t>(n_genes), 0.0f);
346-
for (int local_row = 0; local_row < n_block_rows; ++local_row) {
347-
int row = block_start + local_row;
348-
float* row_ptr = block_buf.data() + static_cast<size_t>(local_row) * static_cast<size_t>(n_genes);
349-
for (Eigen::SparseMatrix<float, Eigen::RowMajor>::InnerIterator it(matrix, row); it; ++it) {
350-
row_ptr[it.col()] = it.value();
351+
for (int block_start = 0; block_start < n_cells; block_start += cell_block) {
352+
int n_block_cols = std::min(cell_block, n_cells - block_start);
353+
std::fill(block_buf.begin(), block_buf.begin() + static_cast<size_t>(n_genes) * static_cast<size_t>(n_block_cols), 0.0f);
354+
for (int local_col = 0; local_col < n_block_cols; ++local_col) {
355+
int cell = block_start + local_col;
356+
for (Eigen::SparseMatrix<float, Eigen::RowMajor>::InnerIterator it(matrix, cell); it; ++it) {
357+
int gene = it.col();
358+
block_buf[static_cast<size_t>(gene) * static_cast<size_t>(n_block_cols) + static_cast<size_t>(local_col)] = it.value();
351359
}
352360
}
353361

354362
hsize_t block_dims[2] = {
355-
static_cast<hsize_t>(n_block_rows),
356-
static_cast<hsize_t>(n_genes)
363+
static_cast<hsize_t>(n_genes),
364+
static_cast<hsize_t>(n_block_cols)
357365
};
358-
block_offset[0] = static_cast<hsize_t>(block_start);
366+
block_offset[1] = static_cast<hsize_t>(block_start);
359367

360368
hid_t file_space = H5Dget_space(ds);
361369
H5Sselect_hyperslab(file_space, H5S_SELECT_SET,

tests/test_main.cpp

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2038,6 +2038,92 @@ TEST(Output, SaveMatrixTo10xH5WritesExpectedStructure) {
20382038
H5Fclose(fid);
20392039
}
20402040

2041+
TEST(Output, SaveMatrixToLoomWritesGeneByCellMatrix) {
2042+
Eigen::SparseMatrix<float, Eigen::RowMajor> matrix(2, 3); // cells x genes
2043+
std::vector<Eigen::Triplet<float>> trips = {
2044+
{0, 0, 2.0f},
2045+
{0, 2, 5.0f},
2046+
{1, 1, 7.0f}
2047+
};
2048+
matrix.setFromTriplets(trips.begin(), trips.end());
2049+
matrix.makeCompressed();
2050+
2051+
const std::vector<std::string> gene_names = {"G1", "G2", "G3"};
2052+
const std::vector<std::string> cell_names = {"cell_1", "cell_2"};
2053+
const std::string path = write_temp_csv("", ".loom");
2054+
2055+
baysor::save_matrix_to_loom(matrix, gene_names, cell_names, path);
2056+
2057+
hid_t fid = H5Fopen(path.c_str(), H5F_ACC_RDONLY, H5P_DEFAULT);
2058+
ASSERT_GE(fid, 0);
2059+
2060+
hid_t matrix_ds = H5Dopen2(fid, "/matrix", H5P_DEFAULT);
2061+
ASSERT_GE(matrix_ds, 0);
2062+
hid_t matrix_space = H5Dget_space(matrix_ds);
2063+
hsize_t matrix_dims[2] = {0, 0};
2064+
ASSERT_EQ(H5Sget_simple_extent_ndims(matrix_space), 2);
2065+
ASSERT_EQ(H5Sget_simple_extent_dims(matrix_space, matrix_dims, nullptr), 2);
2066+
EXPECT_EQ(matrix_dims[0], 3);
2067+
EXPECT_EQ(matrix_dims[1], 2);
2068+
2069+
std::vector<float> values(6, -1.0f);
2070+
ASSERT_GE(H5Dread(matrix_ds, H5T_NATIVE_FLOAT, H5S_ALL, H5S_ALL, H5P_DEFAULT, values.data()), 0);
2071+
const std::vector<float> expected = {
2072+
2.0f, 0.0f,
2073+
0.0f, 7.0f,
2074+
5.0f, 0.0f
2075+
};
2076+
EXPECT_EQ(values, expected);
2077+
H5Sclose(matrix_space);
2078+
H5Dclose(matrix_ds);
2079+
2080+
hid_t row_name_ds = H5Dopen2(fid, "/row_attrs/Name", H5P_DEFAULT);
2081+
ASSERT_GE(row_name_ds, 0);
2082+
hid_t row_name_space = H5Dget_space(row_name_ds);
2083+
hsize_t row_dims[1] = {0};
2084+
ASSERT_EQ(H5Sget_simple_extent_ndims(row_name_space), 1);
2085+
ASSERT_EQ(H5Sget_simple_extent_dims(row_name_space, row_dims, nullptr), 1);
2086+
EXPECT_EQ(row_dims[0], 3);
2087+
H5Sclose(row_name_space);
2088+
H5Dclose(row_name_ds);
2089+
2090+
hid_t col_name_ds = H5Dopen2(fid, "/col_attrs/Name", H5P_DEFAULT);
2091+
ASSERT_GE(col_name_ds, 0);
2092+
hid_t col_name_space = H5Dget_space(col_name_ds);
2093+
hsize_t col_dims[1] = {0};
2094+
ASSERT_EQ(H5Sget_simple_extent_ndims(col_name_space), 1);
2095+
ASSERT_EQ(H5Sget_simple_extent_dims(col_name_space, col_dims, nullptr), 1);
2096+
EXPECT_EQ(col_dims[0], 2);
2097+
H5Sclose(col_name_space);
2098+
H5Dclose(col_name_ds);
2099+
2100+
hid_t cell_id_ds = H5Dopen2(fid, "/col_attrs/CellID", H5P_DEFAULT);
2101+
ASSERT_GE(cell_id_ds, 0);
2102+
hid_t cell_id_space = H5Dget_space(cell_id_ds);
2103+
hsize_t cell_id_dims[1] = {0};
2104+
ASSERT_EQ(H5Sget_simple_extent_ndims(cell_id_space), 1);
2105+
ASSERT_EQ(H5Sget_simple_extent_dims(cell_id_space, cell_id_dims, nullptr), 1);
2106+
EXPECT_EQ(cell_id_dims[0], 2);
2107+
H5Sclose(cell_id_space);
2108+
H5Dclose(cell_id_ds);
2109+
2110+
H5Fclose(fid);
2111+
}
2112+
2113+
TEST(Output, SaveMatrixToLoomRejectsMismatchedColAttrLength) {
2114+
Eigen::SparseMatrix<float> matrix(2, 3); // cells x genes
2115+
const std::vector<std::string> gene_names = {"G1", "G2", "G3"};
2116+
const std::vector<std::string> cell_names = {"cell_1", "cell_2"};
2117+
const std::string path = write_temp_csv("", ".loom");
2118+
2119+
baysor::LoomColAttrs col_attrs;
2120+
col_attrs["confidence"] = std::vector<double>{0.5};
2121+
2122+
EXPECT_THROW(
2123+
baysor::save_matrix_to_loom(matrix, gene_names, cell_names, path, col_attrs),
2124+
std::runtime_error);
2125+
}
2126+
20412127
TEST(Xenium, ManifestHelpers) {
20422128
EXPECT_TRUE(baysor::is_xenium_manifest_path("experiment.xenium"));
20432129
EXPECT_FALSE(baysor::is_xenium_manifest_path("transcripts.parquet"));

0 commit comments

Comments
 (0)