Skip to content

convert: Visium (Space Ranger) and Xenium spatial outputs - #79

Merged
Claptar merged 6 commits into
mainfrom
feat/spatial
Oct 26, 2026
Merged

Claptar merged 6 commits into
mainfrom
feat/spatial

Conversation

@Claptar

@Claptar Claptar commented Oct 26, 2026

Copy link
Copy Markdown
Contributor

Reads 10x's spatial outputs with their coordinates, not just their matrix: standard Visium from Space Ranger 1–4, and Xenium from XOA 1–4. No new dependency. Visium HD waits for a later release, because its positions exist only as Parquet. Part of #31.

What converts

Visium. adata convert outs -o section.h5ad gives what squidpy.read.visium gives:

  • obs: in_tissue, array_row, array_col;
  • obsm["spatial"]: [x, y] in full-resolution pixels;
  • uns["spatial"][library]: the images as uint8, the scale factors and the metadata.

New flags: --library-id and --no-images. --unfiltered now reads raw_feature_bc_matrix.h5 for Visium too.

Xenium. adata convert bundle -o cells.h5ad gives:

  • obs: the cells.csv.gz columns, typed as pandas reads them;
  • obsm["spatial"]: the centroids, in µm;
  • Xenium's own analysis: obs["graphclust"], obsm["X_umap"] and obsm["X_pca"], read from analysis.tar.gz as Xenium ships it.

A bug this fixes. An outs/ folder used to be taken for a Cell Ranger 2 MTX layout and refused with "pick one with --genome". visium and xenium now come before 10x-mtx in FORMATS. A bare .h5 is still a plain 10x matrix.

How it streams

  • New conversion/spatial.py. It matches a table to the matrix's barcodes during scan_table's pass, through the new on_index hook.
  • Rows already in the matrix's order are written straight through. Xenium's cells are, on every real bundle checked.
  • Rows in another order go through the new core/scatter.py, the bucket-through-scratch writer generalised from CellBender's.
  • Refusals come before anything is written: a matrix barcode with no row, or with two.
  • Extra Xenium cells are left out, with a warning, and their categories pruned.

Tests

  • Unit tests:
    • test_visium.py: Space Ranger 1 headerless, 2, and 3 with decimal positions; every flag and refusal; detection.
    • test_xenium.py: integer and string ids; in order and shuffled; extra and missing cells; analysis from the folder or the archive, including an archive that tries to write outside its folder.
    • Plus test_scatter.py and new test_table.py cases.
  • Performance guards: work grows linearly in the number of cells, and Xenium's writes stay in blocks, on both paths.
  • Integration:
    • Visium against squidpy.read.visium, comparing values and dtypes.
    • Xenium against 10x's scanpy recipe.
    • spatialdata-io was not used: it needs boundary and image files the fixtures don't carry.
  • Benchmark: a new import-xenium case. At smoke tier, adata-cli took 0.32 s and 75 MB; the scanpy recipe took 1.76 s and 260 MB.
  • Real 10x data, checked by hand: Visium SR 1.1 mouse brain, SR 2.1 CytAssist, and the Xenium XOA 3.0 and 4.0 tiny bundles. A squidpy plot shows the spots on the tissue.

uv run pytest -m "not integration" --cov=adata --cov-fail-under=90: 1,956 passed, coverage 95.4%. The local site build passes html-proofer.

Docs

  • New format pages: reference/formats/visium.md and xenium.md.
  • Updated: the format tables, the convert guide and --help, Troubleshooting, Developers and AGENTS.md.
  • STATUS lists the follow-ups:
    • Visium HD;
    • bundles with Matrix Market folders only;
    • Space Ranger's own analysis/ for Visium;
    • moving CellBender onto core/scatter.py.

🤖 Generated with Claude Code

Claptar and others added 5 commits October 26, 2026 15:12
An outs/ folder (or the run folder holding it) converts with its spot
positions in obs and obsm['spatial'], and its images, scale factors and
metadata in uns['spatial'][library_id], laid out as squidpy.read.visium
lays them out; images stay the PNGs' uint8 pixels. Positions are matched
to the matrix's barcodes as they are scanned, and written in its order
through core/scatter.py when they come in another.

Before this, an outs/ folder was taken for a Cell Ranger 2 MTX layout
and refused with 'pick one with --genome'.

table.py reads files with no header line (Space Ranger 1) and hands each
block's index to a caller during the scan; its column creation and value
typing are reusable. Visium HD is refused by name until its Parquet
positions are read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A bundle (recognised by experiment.xenium) converts with cells.csv.gz's
columns in obs, typed as pandas reads them, the centroids in
obsm['spatial'] in microns, and Xenium's own analysis: graph-based
clusters as obs['graphclust'], UMAP and PCA into obsm. Xenium writes the
cell table in the matrix's order, so it is written straight through; a
table in another order goes through core/scatter.py, and cells the
matrix lacks are left out with their categories.

Performance guards cover both paths, and Visium's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… Xenium import

test_external_readers checks Visium (Space Ranger 1, 2 and 3 with decimal
positions) against squidpy.read.visium, values and dtypes, and Xenium
against 10x's scanpy recipe, in order and shuffled. import-xenium times
adata-cli against that recipe on a bundle built from the benchmark store.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every real bundle checked (XOA 3.0 and 4.0) packs analysis/ as
analysis.tar.gz, so the clusters, UMAP and PCA were never read without
unpacking first. The three tables used are copied out of the archive by
name into a temporary folder, never extracted where the archive says.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Format pages for both, with their mappings, flags, how they stream and
where they differ from squidpy and spatialdata-io; the formats tables,
the convert guide and help, Troubleshooting for the new refusals, and
AGENTS.md and Developers for core/scatter.py and the FORMATS order. The
10x HDF5 page says that a Space Ranger or Xenium matrix given alone
converts without its positions. STATUS records the work and its
follow-ups.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 26, 2026 15:31
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ⚠️ Failed 2026-10-26T15:31:50.175038Z
We couldn’t complete this review. Comment @codex review to try again.
9e9a784 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota.

An organization owner can choose the repository organization as the billing source for reviews requested by members with a paid Copilot license. Learn about Copilot code review billing.

@github-actions

github-actions Bot commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (py3.13)

1 970 tests   1 970 ✅  5m 4s ⏱️
    1 suites      0 💤
    1 files        0 ❌

Results for commit 82a3abe.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (py3.12)

1 970 tests   1 970 ✅  5m 13s ⏱️
    1 suites      0 💤
    1 files        0 ❌

Results for commit 82a3abe.

♻️ This comment has been updated with latest results.

- core/scatter.py: a layout that keeps no source row no longer asks HDF5
  for a chunk larger than its empty scratch, which crashed the h5ad write.
- A table with a row for none of the matrix's barcodes (positions, cells,
  an analysis table) is refused as another run's.
- Refused at plan time, not in the write after staging began: a
  coordinate, centroid or projection column that holds text, a clusters
  table with no Cluster column, a --library-id that cannot be a key of
  uns (empty, or with '/'). Missing numbers become NaN.
- A CSV saved with a byte order mark keeps its header (table.py and the
  positions' header check).
- analysis.tar.gz unpacks beside the output, not into the system's
  temporary folder, and an archive holding none of the tables says so.
- --from xenium refuses a file other than cell_feature_matrix.h5.
- graphclust drops categories only cells the matrix lacks used.
- The Xenium page says how the archive is read, and when text becomes a
  categorical; Troubleshooting covers the new refusals.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Claptar
Claptar merged commit 0dac16a into main Oct 26, 2026
6 checks passed
@Claptar Claptar mentioned this pull request Oct 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants