v0.7.0 — Visium HD + seqFISH support, NICHESv2 pipeline, spatial index - #49
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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: Falsebecause 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):
CellCoordinates.csvcenter_xTranscriptList.csvxBoundaries.geojsonverticesApplying 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-iomaps 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:square_*um/lives underbinned_outputs/, so the top-level glob never fired on real data.tissue_hires_image.png. Added PNG to source resolution plus a Pillow fallback, since the tifffile path cannot open one.limit=instead offraction=, returning[]instead of the documented dict), so a direct call raisedTypeError. 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, socell_boundaries()emits four vertices per bin instead of declaringhas_boundaries: False. This matters because nothing renderscells()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.parquetOne 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.niches_xenium.Rniches_seqfish.Rniches_visium_hd.Rmicrons_per_pixelniches_common.RThe Visium HD conversion is the point of that script.
export_to_TissuePlex()copiesmeta.data$x/$yverbatim intox1..y2and the backend divides bypixel_sizeassuming microns, so raw pixels offset every edge from its bin silently. Verified by joining the exported edges back totissue_positions: max |dx| = max |dy| = 0 px across all 3,321 bins.Results on the demo data:
xenium_human_breast_2fovvisium_hd_tinyseqfish_syntheticTop Xenium hits are
CDH1|CDH1,CXCL12|CXCR4,PTN|SDC4— plausible for breast.Two things running this for real forced:
mouse_ileum_tinycannot 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 frommake_edges.py.Gene00/Gene01/… match nothing in any LR database, so NICHESv2 aborted.make_seqfish.pynow 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 insidecompute_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:
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.parquetin 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_cacherewrites the file sorted by a coarse spatial grid with small row groups, built on first access and cached, the same patternensure_pyramidalready 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: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 fromXeniumReaderonto the base class, so every platform gets it, andedge-metadata/is new — annotate cell pairs without regeneratingedges.parquetfrom R: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_tinynow 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.py— 133 probes across 5 datasets, covering every reader method plus every edge endpoint for every edge file.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_SEEDfails 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_groupedusesORDER BY RANDOM()), and theedge_detailprobe picks the lexicographic minimum rather thangrouped[0], which was a different edge each run.Other fixes
morphology_focus/channels were unreachable;list_imagesscanned only the dataset root. Salvaged from an unmerged stash.CellInfoPanelrendered the literal string"undefined µm²"on any platform not reporting a cell area — the optional chain was not paired with a null check.activeImagekept the previous dataset's value until the image list resolved. Latent until now because every platform usedmorphology.vite.config.jsdefaulted the dev server to port 3000, the same portdocker composebinds, which is whylaunch.jsonhad to override it. Now 5173.export_for_TissuePlex()does not exist — corrected toexport_to_TissuePlex()in four places, anddocs/data_format.mdchecked 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.mdandREADME.mdbrought back in line with the code — an entire platform (Visium HD), the cloud-deploy path, per-panel rotation andRenderingStatuswere all undocumented, and ther/listing named a file deleted several commits earlier. Superseded planning docs moved toOBS/with a README explaining why each is obsolete;EDGE_UI_PLAN.mdin 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
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, andsample_data/.gitignoreexcludes it.EdgeReaderis not spatially indexed and still binds its bbox, so it would need the same literal treatment before an edge index would pay off.CellInfoPanelstill renders a hardcoded field list rather than showing supplemental columns generically the wayEdgeInfoPanelnow does.