Skip to content

Stream transcript aggregation onto a hex lattice and harden Xenium ingest - #6

Merged
rushin682 merged 2 commits into
mainfrom
xenium-hex-aggregation
Aug 31, 2026
Merged

Stream transcript aggregation onto a hex lattice and harden Xenium ingest#6
rushin682 merged 2 commits into
mainfrom
xenium-hex-aggregation

Conversation

@rushin682

Copy link
Copy Markdown
Collaborator

SpatialData.aggregate cannot bin a whole-transcriptome section: it builds one shapely Point per transcript and groups with observed=False, materialising the dense n_spots x n_genes product regardless of sparsity — far more than the 500 GB a node has for 18k targets and ~1e9 transcripts.

Streaming hex aggregation

The hex lattice is regular, so membership needs no spatial join.

  • hex_lattice_params now owns the single lattice definition; hex_grid_centroids materialises it as centroids and the new assign_points_to_hexes decides membership in closed form, which is what keeps the two from drifting apart.
  • bin_points_to_hex_counts streams (x, y, gene_code) batches over that lattice and keeps only non-zero cells, bounding peak memory by one dask partition plus the output.
  • test_assign_points_to_hexes_matches_shapely_sjoin pins the result pair-for-pair against the sjoin it replaces, including the duplicates that overlapping spots produce.
  • Aggregating to an all-zero table now raises rather than writing it: that only happens when the lattice and the transcripts disagree, or the gene axis never matched.

max_spots_per_chunk is kept on the public signatures for backwards compatibility and ignored — peak memory no longer depends on how many spots there are.

Fixes for real bundles that failed to convert

  • Older "Preview"/"With_Addon" outputs store cell_id/fov_name as parquet binary, which reaches pyarrow as a bare object dtype and makes to_parquet raise ArrowInvalid on the dummy meta frame before any data is touched. decode_bytes_columns re-types those columns to string.
  • Scanners writing OME-TIFFs with planarconfig=SEPARATE produce (1, c, y, x), tripping xenium_aligned_image's shape[-1] == 3 assert. _aligned_image_dims probes the lazy array and passes explicit dims.
  • Bundles naming the alignment <sample>_he_alignment.csv went unmatched, leaving the H&E and its tissue contours silently on an Identity transform. The alignment is now parsed once and applied to both, so they cannot end up on different transforms.
  • Tissue is segmented in the H&E's own pixel grid. The alignment matrix already carries that image's pixel size, so pre-scaling by the Xenium morphology image's pixel_size double-applied a scale factor and misplaced the contours.

Resumable downloads

A leftover .part is continued with an HTTP Range request instead of refetched from byte 0, and any download whose length the server declares is size-checked before being promoted onto its destination. Servers that ignore Range answer 200 with the whole body, which is detected so a resumed file is never a mix of two responses.

Testing

tests/test_utils.py gains coverage for the lattice geometry, the analytic hex membership (against a shapely sjoin reference), the streaming counter, and the bytes-column decoding; tests/test_xenium_io.py is new and covers the H&E channel-layout probe and the aggregation's table-annotation contract. 47 tests pass; ruff check and ruff format --check are clean.

…gest

`SpatialData.aggregate` cannot bin a whole-transcriptome section: it builds one
shapely Point per transcript and groups with `observed=False`, materialising the
dense `n_spots x n_genes` product regardless of sparsity -- far more than the
500 GB a node has for 18k targets and ~1e9 transcripts.

The hex lattice is regular, so membership needs no spatial join.
`hex_lattice_params` now owns the single lattice definition; `hex_grid_centroids`
materialises it as centroids and the new `assign_points_to_hexes` decides
membership in closed form, which is what keeps the two from drifting apart.
`bin_points_to_hex_counts` streams `(x, y, gene_code)` batches over that lattice
and keeps only non-zero cells, bounding peak memory by one dask partition plus
the output. `test_assign_points_to_hexes_matches_shapely_sjoin` pins the result
pair-for-pair against the sjoin it replaces, including the duplicates that
overlapping spots produce. Aggregating to an all-zero table now raises rather
than writing it: that only happens when the lattice and the transcripts
disagree, or the gene axis never matched.

Alongside it, four fixes for real bundles that failed to convert:

- Older "Preview"/"With_Addon" outputs store `cell_id`/`fov_name` as parquet
  binary, which reaches pyarrow as a bare `object` dtype and makes `to_parquet`
  raise `ArrowInvalid` on the dummy meta frame before any data is touched.
  `decode_bytes_columns` re-types those columns to `string`.
- Scanners writing OME-TIFFs with `planarconfig=SEPARATE` produce `(1, c, y, x)`,
  tripping `xenium_aligned_image`'s `shape[-1] == 3` assert. `_aligned_image_dims`
  probes the lazy array and passes explicit dims.
- Bundles naming the alignment `<sample>_he_alignment.csv` went unmatched,
  leaving the H&E and its tissue contours silently on an Identity transform. The
  alignment is now parsed once and applied to both, so they cannot end up on
  different transforms.
- Tissue is segmented in the H&E's own pixel grid. The alignment matrix already
  carries that image's pixel size, so pre-scaling by the Xenium morphology
  image's `pixel_size` double-applied a scale factor and misplaced the contours.

Downloads are now resumable: a leftover `.part` is continued with an HTTP `Range`
request instead of refetched from byte 0, and any download whose length the
server declares is size-checked before being promoted onto its destination.
@read-the-docs-community

read-the-docs-community Bot commented Aug 30, 2026

Copy link
Copy Markdown

@codecov

codecov Bot commented Aug 30, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 63.17829% with 95 lines in your changes missing coverage. Please review.
✅ Project coverage is 64.43%. Comparing base (f7d34f7) to head (99cadbf).

Files with missing lines Patch % Lines
src/spatialrefinery/core/downloader.py 8.62% 53 Missing ⚠️
src/spatialrefinery/io/xenium.py 57.95% 37 Missing ⚠️
src/spatialrefinery/core/utils.py 95.53% 5 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main       #6      +/-   ##
==========================================
+ Coverage   61.00%   64.43%   +3.43%     
==========================================
  Files           9        9              
  Lines         900     1119     +219     
==========================================
+ Hits          549      721     +172     
- Misses        351      398      +47     
Files with missing lines Coverage Δ
src/spatialrefinery/core/utils.py 82.25% <95.53%> (+17.85%) ⬆️
src/spatialrefinery/io/xenium.py 36.18% <57.95%> (+12.90%) ⬆️
src/spatialrefinery/core/downloader.py 29.72% <8.62%> (-7.04%) ⬇️
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

The streaming aggregation imports `scipy.sparse`, the OME-TIFF layout probe
imports `dask_image.imread`, and the points schema test imports `pyarrow`.
None of the three ship type information, so the mypy pre-commit hook failed
with `import-untyped` on all four call sites -- which `pytest` and `ruff`
do not catch, since mypy only runs through that hook.

Extend the existing `ignore_missing_imports` override, which already covers
bioio, geopandas, shapely, and spatialdata, rather than adding `scipy-stubs`
and type-checking one of the four differently from the rest.
@rushin682
rushin682 merged commit fa9ad74 into main Aug 31, 2026
9 checks passed
@rushin682
rushin682 deleted the xenium-hex-aggregation branch August 31, 2026 12:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant