Skip to content

v0.7.0 — Visium HD + seqFISH support, NICHESv2 pipeline, spatial index - #49

Merged
msraredon merged 5 commits into
RaredonLab:devfrom
msraredon:dev
Aug 6, 2026
Merged

v0.7.0 — Visium HD + seqFISH support, NICHESv2 pipeline, spatial index#49
msraredon merged 5 commits into
RaredonLab:devfrom
msraredon:dev

Conversation

@msraredon

Copy link
Copy Markdown
Collaborator

v0.7.0 — Visium HD + seqFISH support, NICHESv2 pipeline, spatial index

Adds two platforms, a NICHESv2 workflow that produces real edge data, and a large performance change to the spatial query path. Xenium behaviour is unchanged throughout, and that claim is now checkable rather than asserted — see Regression guard below.

13 commits · 41 source files · +5,284 / −364 (excluding sample data and the recorded test baseline).


Platforms

seqFISH — Spatial Genomics GenePS

Full support for the current v2 layout: cells, transcripts, boundaries, expression and both colour-value modes. Legacy v1 reads cells and transcripts, and declares has_boundaries: False because v1 ships only a label mask.

The hard part is coordinates. A single seqFISH dataset mixes units, measured on the reference dataset (1000×1000 DAPI at 0.107161 µm/px):

Source Extent Units
CellCoordinates.csv center_x 1.82 → 105.66 microns
TranscriptList.csv x 0.00 → 107.05 microns
Boundaries.geojson vertices 0 → 999 pixels

Applying one transform to everything puts cells and their own outlines in different places, which reads as a rendering bug rather than a unit bug. The convention also differs across GenePS software versions, so it cannot be hard-coded. _units_divisor() decides per table by comparing that table's extent to the image width, and logs its verdict.

Verified geometrically rather than by digest: every cell centroid falls inside its own polygon — 62/62 on the reference dataset, 36/36 on the synthetic fixture, zero false positives against a control.

One improvement over the reference implementation: cell identity comes from each GeoJSON feature's id. spatialdata-io maps polygons positionally and has an open issue about the fragility (scverse/spatialdata-io#249); a silent off-by-one there would draw every outline on the wrong cell.

Visium HD

Built against a real Space Ranger 4.0.1 outs/ tree, not a hand-assembled folder — which mattered, because three separate things prevented genuine output from loading:

  • Detection globbed the wrong level. square_*um/ lives under binned_outputs/, so the top-level glob never fired on real data.
  • The tile pipeline was TIFF-only. Visium HD ships tissue_hires_image.png. Added PNG to source resolution plus a Pillow fallback, since the tifffile path cannot open one.
  • The reader's method signatures did not match the base class (limit= instead of fraction=, returning [] instead of the documented dict), so a direct call raised TypeError. It was unreachable only because the capability flags stopped the frontend asking.

Bins render as square polygons. A bin is literally a square of side spot_diameter_fullres, so cell_boundaries() emits four vertices per bin instead of declaring has_boundaries: False. This matters because nothing renders cells() centroids — boundaries are the only path to drawing a unit — so a points-only reader would show an empty canvas. Emitting squares makes fill, outline, colour-by, picking and region selection all work with no frontend change at all.

sample_data/visium_hd_tiny/ is 10x's Tiny Mouse Brain dataset (CC BY 4.0, so redistributable), trimmed from the 297 MB download to ~26 MB.


NICHESv2 → edges.parquet

One script per platform in r/, each run against the demo datasets to produce real edge files. No button in the UI — deliberately out of scope.

Script Distinctive part
niches_xenium.R The simple case: coordinates already µm. Read first.
niches_seqfish.R Dense cells × genes CSV needing transposition; units vary by GenePS version
niches_visium_hd.R Coordinates are pixels, converted with microns_per_pixel
niches_common.R Shared helpers only — 10x h5 reader, LR-coverage check, validation

The Visium HD conversion is the point of that script. export_to_TissuePlex() copies meta.data$x/$y verbatim into x1..y2 and the backend divides by pixel_size assuming microns, so raw pixels offset every edge from its bin silently. Verified by joining the exported edges back to tissue_positions: max |dx| = max |dy| = 0 px across all 3,321 bins.

Results on the demo data:

Dataset Units Edges Scored rows LRMs
xenium_human_breast_2fov 7,275 cells 181,523 189,063 32
visium_hd_tiny 3,321 bins 66,001 147 69
seqfish_synthetic 36 cells 250 7,364 30

Top Xenium hits are CDH1|CDH1, CXCL12|CXCR4, PTN|SDC4 — plausible for breast.

Two things running this for real forced:

  • mouse_ileum_tiny cannot be used for NICHES, and that is the data not the script — its matrix is a near-empty placeholder (467 counts, 7 expressed genes), so no LR pair can score. It keeps synthetic edges from make_edges.py.
  • The synthetic seqFISH panel was unusable. Gene00/Gene01/… match nothing in any LR database, so NICHESv2 aborted. make_seqfish.py now generates 36 real mouse symbols forming 20 verified pairs in connectomedb2025. check_lr_coverage() also now reports scorable pairs up front, so a genuinely unscorable panel explains itself instead of failing deep inside compute_CellToCell().

Performance

Transcript and boundary queries moved from "read the whole parquet into pandas, then mask" to DuckDB, then gained a spatial index. Measured on a synthetic 40M-row / 0.78 GB transcripts file, one zoomed-in viewport query:

peak RSS wall time
pandas full read + mask 2903 MB 912 ms
DuckDB streaming 233 MB 1369 ms
DuckDB + spatial index 233 MB 44 ms

12× less memory was the first win, and the binding one: production runs on a 16 GB droplet, where a multi-GB transcripts file under the old path exhausts RAM long before it is merely slow.

Speed needed two further steps. Xenium writes transcripts.parquet in acquisition order with very large row groups — the bundled breast dataset is 1.1M rows in two row groups, the first spanning the entire x-range — so statistics exclude nothing and DuckDB scans everything anyway. spatial_cache rewrites the file sorted by a coarse spatial grid with small row groups, built on first access and cached, the same pattern ensure_pyramid already uses.

That alone gave only 1.4×. DuckDB prunes row groups at plan time, and with bound ? parameters the filter values are unknown then, so it cannot prune:

sorted file, COUNT    literals 6.8 ms  vs  ? params 155 ms
unsorted file, COUNT  literals 186 ms  vs  ? params 178 ms

On an unsorted file the two are identical, which is why this never mattered before. Inlining the bbox as literals is what unlocked the 27×; it is safe because the values pass through float() with non-finite rejected, while string filters still bind.

Also fixed: boundaries previously filtered individual vertices by bbox, so cells straddling the viewport edge came back missing part of their outline — 97 cells on the bundled breast dataset rendered as torn polygons.


Cross-platform metadata

cell-metadata/ moved from XeniumReader onto the base class, so every platform gets it, and edge-metadata/ is new — annotate cell pairs without regenerating edges.parquet from R:

dataset_dir/
  edge-metadata/
    annotations.csv     key column `edge` = "SendingCell|ReceivingCell"

Both share one loader (readers/supplemental.py) parameterised on the key column, so the two conventions cannot drift. Columns appear in the edge colour-by dropdown automatically — no frontend change was needed, since the dropdown is built from the schema.

mouse_ileum_tiny now ships worked examples of both. Cell metadata had no sample dataset at all before, so despite being documented it could not be exercised locally.


Regression guard

backend/tests/golden_snapshot.py133 probes across 5 datasets, covering every reader method plus every edge endpoint for every edge file.

cd backend && python3 tests/golden_snapshot.py          # check
cd backend && python3 tests/golden_snapshot.py --record # adopt intentional changes

The repo had no test suite, so cross-platform reader work had nothing to catch a regression. Verified the guard actually bites: flipping duck.SAMPLE_SEED fails exactly the six sampled probes and passes again on revert.

Two sources of false failure had to be removed first — record-list digests are now order-independent (query_grouped uses ORDER BY RANDOM()), and the edge_detail probe picks the lexicographic minimum rather than grouped[0], which was a different edge each run.


Other fixes

  • Multi-channel morphology — Xenium morphology_focus/ channels were unreachable; list_images scanned only the dataset root. Salvaged from an unmerged stash.
  • CellInfoPanel rendered the literal string "undefined µm²" on any platform not reporting a cell area — the optional chain was not paired with a null check.
  • Dataset switching logged 404sactiveImage kept the previous dataset's value until the image list resolved. Latent until now because every platform used morphology.
  • vite.config.js defaulted the dev server to port 3000, the same port docker compose binds, which is why launch.json had to override it. Now 5173.
  • export_for_TissuePlex() does not exist — corrected to export_to_TissuePlex() in four places, and docs/data_format.md checked against the actual writer: it always emits all 16 columns, and unscored edges get a placeholder row with null LRM/score fields (which is the origin of the null-LRM handling already in the backend).

Docs

CLAUDE.md and README.md brought back in line with the code — an entire platform (Visium HD), the cloud-deploy path, per-panel rotation and RenderingStatus were all undocumented, and the r/ listing named a file deleted several commits earlier. Superseded planning docs moved to OBS/ with a README explaining why each is obsolete; EDGE_UI_PLAN.md in particular specifies the opposite of what shipped.

Verification

Guard green at 133 probes. Verified end-to-end in the browser across Xenium → seqFISH → Visium HD → Xenium with no console or backend errors: bins render as a regular grid registered on the H&E, seqFISH outlines sit on their own cells, and NICHESv2 edges overlay correctly on both.

Notes for reviewers

  • Sample data grew by ~26 MB (visium_hd_tiny, CC BY 4.0) plus ~4.5 MB of generated edge files. The seqFISH reference dataset is not committed — it is public for CI by written permission from Spatial Genomics rather than under an open licence, and sample_data/.gitignore excludes it.
  • Still open: EdgeReader is not spatially indexed and still binds its bbox, so it would need the same literal treatment before an edge index would pay off. CellInfoPanel still renders a hardcoded field list rather than showing supplemental columns generically the way EdgeInfoPanel now does.

msraredon and others added 5 commits August 6, 2026 08:30
Viewport queries on a 40M-row transcripts.parquet go from 1180 ms to 44 ms.

Moving these queries to DuckDB earlier fixed their memory use but not their
speed, and this is why: Xenium writes transcripts.parquet in acquisition order
with very large row groups -- the bundled breast dataset is 1.1M rows in two
row groups, the first spanning the entire x-range -- so row-group statistics
exclude nothing and every bbox query scans the whole file.

spatial_cache.sorted_path() rewrites the file sorted by a coarse spatial grid
with 100K-row row groups, built on first access and cached on disk, the same
pattern ensure_pyramid already uses for tiles. Measured, with identical results
in every case:

    Xenium transcripts   600 MB / 40M rows    1180 ms ->  44 ms   27x
    seqFISH transcripts  229 MB CSV / 8M rows 1595 ms -> 156 ms   10x
    Xenium boundaries     40 MB / 3.6M verts   102 ms ->  73 ms  1.4x

Boundaries gain least by design: half that query is a cell_id semi-join to pull
whole polygons, which spatial sorting cannot help. For seqFISH the same pass
also converts CSV to parquet, which is why it helps a format that cannot be
range-scanned at all.

The index alone was not enough, and the reason is worth recording. DuckDB prunes
row groups at plan time by comparing the filter to per-group statistics; with
bound `?` parameters those values are unknown then, so it cannot prune. The
first cached build measured only 1.4x faster until bbox_predicate switched to
inlining the bounds as SQL literals:

    sorted file, COUNT    literals 6.8 ms  vs  ? params 155 ms
    sorted file, SELECT   literals  35 ms  vs  ? params 321 ms
    unsorted file, COUNT  literals 186 ms  vs  ? params 178 ms

On an unsorted file the two are identical, which is why this never mattered
before. Inlining is safe because these are numbers, never user text: every value
goes through float() and non-finite values are rejected, while string filters
such as gene names still bind through in_predicate.

Deliberately conservative in three ways. Files under SPATIAL_CACHE_MIN_BYTES
(64 MB) are left alone, since the build cost is not repaid and it keeps the
bundled datasets uncached so the golden baseline does not depend on whether a
cache happens to exist. A failed build returns None and the query falls back to
the source file, so indexing can never make a dataset unreadable. SPATIAL_CACHE=0
disables it outright.

Cache validity covers the sort columns, not just the source stamp -- testing
turned up that a cache built for one column pair was being served for a query on
another, sorted by the wrong axis and silently so. Not reachable through current
callers, but a trap worth closing.

Two golden probes moved: same population and sample size, different arbitrary
subset, because seeded reservoir sampling draws differently once row order
changes. Totals verified against pandas ground truth, and no returned row falls
outside the requested bbox.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The reader was a stub built on guessed paths. Rewritten against a genuine
Space Ranger 4.0.1 outs/ tree, now bundled as sample_data/visium_hd_tiny.

Three things were broken beyond the known signature bug, and the first two
meant no real dataset could ever have worked:

- Detection globbed `square_???um` at the dataset root, but Space Ranger nests
  the bin directories under `binned_outputs/`. Genuine output was never
  detected at all.
- `tissue_positions.parquet` and `scalefactors_json.json` are per bin, under
  `binned_outputs/square_NNNum/spatial/`. The reader looked for them in the
  top-level `spatial/`, which holds images only.
- Morphology is a PNG. The tile pipeline accepted TIFF only, so even a detected
  dataset would have shown no image. `_SOURCE_EXTS` and `spatial._TIFF_EXTS`
  now include .png, and _build_dzi_pillow handles it where libvips is missing,
  since the tifffile fallback cannot open one.

Bins are served as square polygons rather than points. A bin is literally a
square of side spot_diameter_fullres, so cell_boundaries() emits four vertices
per bin. This is not cosmetic: nothing renders cells() centroids -- the
boundary layers are the only path to drawing a unit -- so the previous
has_boundaries: False would have left the canvas empty. As squares, fill,
outline, colour-by, picking and region selection all work through the existing
layers with no frontend change.

Also implemented from filtered_feature_bc_matrix.h5, previously all stubs:
gene_list, cell_expression and gene-set colour values. pixel_size now comes
from microns_per_pixel instead of being hardcoded to 1.0, and transcripts()
returns the dict shape every other reader uses instead of a bare list.

Verified end to end: 20,830 in-tissue bins at 8 um render as a regular grid
registered on the H&E, the UI relabels itself to "Bin Segments" / "Color bins"
/ "Click a bin to inspect" off unit_label, transcripts hides itself, and
clicking a bin shows real per-bin expression. No console or backend errors.
The guard grows to 115 probes across 5 datasets, with the four existing
datasets byte-identical.

Fixture is a 26 MB subset of 10x's 297 MB Tiny Mouse Brain dataset, CC BY 4.0
and so redistributable; PROVENANCE.md records the source, what was dropped and
why. It also records the trap this fixture cannot catch -- tissue_hires_scalef
is 1.0 and microns_per_pixel is 1.003, both effectively identity, so a missing
scale multiply looks correct here and breaks on every real dataset.

Separately, docs called the NICHESv2 exporter export_for_TissuePlex; the real
name is export_to_TissuePlex, verified against the package. It exists only on
the dev branch -- main 404s -- and needs arrow, which is only in Suggests. Both
noted in docs/data_format.md, along with the coordinate-unit constraint that
matters for driving NICHESv2 from a pixel-space platform like Visium HD.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Verified docs/data_format.md against R/export_to_TissuePlex.R on the dev branch
rather than against memory. Three corrections.

The function name was already fixed in 794ffad; confirmed all four occurrences
are right and the two that were already correct are untouched.

sending_type / receiving_type were described as "optional but recommended".
The writer's final.cols always emits all 16 columns; when celltype.col is NULL
or the named column is missing from $edge.meta it sets NA_character_ and warns.
So the columns are always present and only their values may be NA. They are
optional only for a hand-written parquet.

The placeholder-row behaviour was undocumented. Every edge in $edge.list is
exported, so an edge with no scored signal gets one row with lrm, lrm_id,
ligand, receptor, score and score_norm all null. That is deliberate -- it lets
the tissue-graph layer show which pairs are neighbours independently of which
pairs have signal -- and it is the origin of the "completely null LRM rows"
gotcha the backend already guards against. Also noted that validation must use
na.rm = TRUE, since checking min(score) or the per-edge score_norm sum without
it reports failure whenever placeholders exist.

The coordinate table was actively misleading. It said coordinates should be
"native (typically µm)" while listing pixel-valued source columns for CosMx and
Visium HD. The backend always divides x1..y2 by pixel_size, and no platform has
pixel_size 1.0 (Xenium 0.2125, seqFISH 0.107, MERSCOPE 0.108, CosMx 0.18,
Visium HD microns_per_pixel), so pixel-valued coordinates land wrong by exactly
that factor. The table now states the native unit per platform and the
conversion needed, with the seqFISH auto-detection caveat.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
One script per data type in r/, plus r/README.md. Each builds a gene x cell
count matrix and a metadata frame, runs NICHESv2 in spatial mode, and exports
edges.parquet. What differs between platforms is the first two steps, which is
why they are separate scripts rather than one with branches:

  niches_xenium.R     coordinates already um; the simple case to read first
  niches_seqfish.R    dense cells x genes CSV needing transposition, and
                      coordinate units that vary by GenePS version
  niches_visium_hd.R  coordinates in PIXELS, multiplied by microns_per_pixel
                      before NICHESv2 sees them
  niches_common.R     shared only: 10x h5 reader, barcode alignment,
                      LR-coverage check, output validation

The coordinate conversion in the Visium HD script is the point of that script.
export_to_TissuePlex() copies meta.data$x/$y verbatim into x1..y2 and the
backend divides by pixel_size assuming microns, so raw pixels put every edge
offset from its bin by exactly that factor, silently. Verified by joining the
exported edges back to tissue_positions: max |dx| = max |dy| = 0 px across all
3,321 bins.

Run against every demo dataset:

  xenium_human_breast_2fov  7,275 cells -> 181,523 edges, 189,063 scored rows,
                            32 LRMs. Top hits CDH1|CDH1, CXCL12|CXCR4, PTN|SDC4
                            -- plausible for breast. Written to
                            edges/niches_rad30.parquet so the existing synthetic
                            edges.parquet stays the default and both are
                            selectable from the dropdown.
  visium_hd_tiny            3,321 bins -> 66,001 edges, 69 LRMs. Sparse, as the
                            data is (32k UMIs total), but real.
  seqfish_synthetic         36 cells -> 250 edges, 30 LRMs, no placeholders.
  mouse_ileum_tiny          cannot run, and this is the data not the script: its
                            matrix is a near-empty placeholder, 467 counts with
                            7 expressed genes, so no LR pair can score. It stays
                            on synthetic edges from make_edges.py.

Two changes fell out of running this for real.

make_seqfish.py now generates a panel of 36 real mouse gene symbols forming 20
complete LR pairs in connectomedb2025, instead of Gene00/Gene01/... Invented
symbols match nothing, so the committed seqFISH fixture could not demonstrate
the edge pipeline at all -- NICHESv2 aborted with "No valid LR pairs remain".

check_lr_coverage() counts scorable pairs before the run and stops with an
explanation naming the likely causes. Previously a small targeted panel failed
with that same message thrown from deep inside compute_CellToCell(), which
reads as a bug rather than as a property of the panel. The real 12-gene
seqfish_instrument2 panel is exactly this case.

Validation uses na.rm = TRUE throughout, because unscored edges are exported as
placeholder rows with NA scores; checking min(score) or the per-edge score_norm
sum without it reports failure whenever placeholders exist.

Guard grows to 133 probes across 5 datasets. mouse_ileum_tiny and
seqfish_instrument2 are byte-identical; everything that moved is the
regenerated seqFISH panel or a newly added edge file.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Version bump covering Visium HD support against real Space Ranger output, the
per-platform NICHESv2 scripts, and the edges.parquet spec corrections.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@msraredon
msraredon merged commit 7202c8a into RaredonLab:dev Aug 6, 2026
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