Skip to content

dev to main v0.8.4 - #57

Merged
msraredon merged 20 commits into
mainfrom
dev
Aug 13, 2026
Merged

dev to main v0.8.4#57
msraredon merged 20 commits into
mainfrom
dev

Conversation

@msraredon

Copy link
Copy Markdown
Collaborator

No description provided.

msraredon and others added 20 commits August 6, 2026 20:05
Adds the platform with the largest published corpus in spatial
transcriptomics, which was the one obvious gap: a Space Ranger `outs/`
folder previously gave "Cannot detect spatial platform".

Spots render as 55 um circle polygons on their hex lattice, with
expression, both colour-value modes, the metadata filter, and NICHESv2
edges. Nothing renders cells() centroids, so synthesising polygons is
what makes fill, colour-by, picking and region selection work — the same
reasoning that made Visium HD bins squares. Circles rather than squares
here because a bin really is square and a spot really is round.

Shared base — readers/spaceranger.py
  Classic Visium and Visium HD agree on scalefactors, tissue_positions,
  the feature-matrix h5, the cells table, gene-set colouring and the
  sample-then-slice boundary logic. They differ in layout, unit shape and
  where the pixel size comes from. Only the differences stay in the two
  readers.

Fixes an unapplied tissue_hires_scalef
  visium_hd_reader computed the factor and reported it in info() but
  never applied it to coordinates, while its own docstring said it did.
  Space Ranger reports positions in FULL-RESOLUTION pixels and ships only
  the hires PNG, which is what the tile pyramid is built from — so
  everything landed off the image by 1/scalef on any real dataset. It was
  invisible because visium_hd_tiny has the factor set to exactly 1.0.

  The factor is now applied in the shared positions loader and folded
  into pixel_size, which matters beyond distance labels: EdgeReader
  divides edges.parquet micron coordinates by pixel_size, so a
  fullres-based value would scatter the connectivity layer off the
  tissue. Behaviour on the bundled HD fixture is byte-identical (scalef
  is 1.0 there), which is precisely why the fixture could not catch it.

Three classic-Visium traps, all handled
  - No microns_per_pixel key. 10x does not record image pixel size for
    classic Visium, so it is derived from the one guaranteed constant
    (55 um / spot_diameter_fullres) and flagged pixel_size_derived in
    info(), since 10x calls spot diameters estimates.
  - tissue_positions_list.csv (Space Ranger < 2.0) has no header row;
    default header inference eats the first spot and mislabels the rest.
  - tissue_hires_scalef really is ~0.08 here, not ~1.0.

Fixture that can actually fail
  sample_data/make_visium.py generates a non-identity scalef of 0.08 on
  purpose. With the multiply, 252/252 spots land on the image; without
  it, 0/252. The derived pixel size recovers 0.25 um/px exactly, and
  niches_visium.R's independent lattice check reports a median
  nearest-neighbour distance of 100.0 um — the Visium pitch.

Also
  - r/niches_visium.R, run end to end: 252 spots, 1642 edges, 30 LRMs,
    avg 6.5 neighbours per spot, which is the hex lattice's 6.
  - The gene-set hint no longer points at the Transcript Species section
    on platforms that hide it, and the colour-by mode is unit-labelled.
  - README platform table corrected: MERSCOPE and CosMx boundaries have
    been implemented since v0.7.1 but were still marked absent.

Guard: 217 probes across 8 datasets, verified to reproduce without the
gitignored metrics_summary.csv.

Not yet verified against a real Space Ranger outs/ tree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Testing the reader against real Space Ranger output (V1_Mouse_Kidney and
V1_Adult_Mouse_Brain) found the previous commit's `pixel_size` derivation
wrong by 18%, and turned up an unrelated autocrine bug on the way.

pixel_size now comes from the lattice pitch, not the spot diameter
  Deriving it as `55 um / spot_diameter_fullres` looked obvious and was
  wrong. Measured independently on both datasets: the in-row lattice
  pitch is exactly 138.00 fullres px while spot_diameter_fullres is
  89.45 — a ratio of 0.648. That field is Space Ranger's DETECTED spot
  footprint (~64.8 um), not the 55 um nominal capture spot; 10x's own
  docs describe classic Visium spot diameters as "approximately 60-70 um"
  and warn they are estimates.

  It now derives from the 100 um centre-to-centre pitch, measured off the
  positions table. That reconstructs 10x's specified 6.5 x 6.5 mm capture
  area where the old route gave 5.4 x 5.7 mm, and it is better
  conditioned — a median over thousands of positions in a rigid array
  template rather than one estimate of a fuzzy edge. `info()` reports
  `pixel_size_source` so which route was taken is visible.

  This matters beyond distance labels: EdgeReader divides edges.parquet
  micron coordinates by pixel_size, so the whole connectivity layer was
  placed 18% off.

The fixture could not have caught it, and now can
  make_visium.py set spot_diameter_fullres to exactly 55/mpp, making the
  fixture a tautology — it confirmed whatever derivation the reader used.
  It now emits the real 0.648 ratio, so the bad derivation yields an 85 um
  nearest-neighbour spacing against the 100 um truth, the same failure
  signature the real datasets show.

Verified against real data
  1438/1438 spots land on the hires image (0/1438 without the scale
  multiply). Registration: 98.1% of in_tissue spots sit on stained tissue
  and 99.5% of out-of-tissue spots on bare slide, measured as mean
  inverse intensity under each spot polygon. NICHESv2 end to end: 9,666
  edges, 2,479 LRMs, 3.2M rows, top mechanisms Apoe|Lrp2 and Cst3|Lrp2 —
  megalin-mediated proximal tubule uptake, the expected kidney biology.

Autocrine signalling
  Autocrine edges were excluded from the lrm_set colour computation, so
  the rings stayed default orange whatever the colour control said — the
  one edge type that ignored "colour by LRM set". They also skipped the
  visible_lrm_count test, so hiding every mechanism removed the lines but
  left a ring on every cell. Both fixed. Sharing the colour scale is safe:
  autocrine score medians run 1.00-1.33x the directed medians across the
  bundled datasets. Verified on Visium, Xenium, seqFISH and Visium HD.

User manual (docs/index.html, published to GitHub Pages)
  Listed three platforms; now lists six with their sentinels and units.
  Adds sections for metadata filtering and the treat-as-categorical
  toggle, rewrites the autocrine section, de-Xenium-ifies the intro, and
  is now linked from the README, which never pointed at it.

Also: r/niches_visium.R uses the same corrected derivation with an
independent lattice-pitch check; docs/public_datasets.md records the
verification downloads; README platform table corrected (MERSCOPE and
CosMx have had boundaries since v0.7.1 but were still marked absent).

v0.8.2. Guard: 217 probes across 8 datasets.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Classic Visium (10x v1/v2) support — v0.8.2 + autocrine fix
All nine demo datasets now carry edges.parquet and an edge-metadata/
folder, so the connectivity layer and the annotation workflow have
something to show whichever platform you load. Previously six of nine had
edges and exactly one had annotations.

Two new NICHESv2 scripts complete the set — one per platform
  r/niches_merscope.R  The easy case for coordinates: cell_metadata.csv is
    already microns. Two traps instead: EntityID is a 19-digit integer
    that R silently mangles if read as numeric, and small targeted panels
    usually carry no complete LR pair.
  r/niches_cosmx.R  Cell identity is the (fov, cell_ID) pair, and
    coordinates are slide-frame PIXELS that must be shifted to the
    reader's origin (min - 50) and THEN scaled to microns. Skip the shift
    and edges land tens of thousands of pixels away; skip the scale and
    they are off by ~8x.

Results: CosMx 169,219 edges across 228 LRMs, top mechanisms App|Aplp2 and
Prnp|Ncam1 — neural adhesion, as expected for mouse brain. MERSCOPE 14,503
edges but only 2 LRMs, because that 130-gene human panel contains just
THBS1->LRP1 and CEMIP->LRP1.

Why targeted panels often cannot be scored
  seqfish_instrument2 has 12 genes and ZERO complete ligand-receptor
  pairs, so no real run is possible. check_lr_coverage() catches that in a
  second rather than ten minutes. It gets synthetic edges from
  make_edges.py instead, which is now platform-agnostic: it reads
  centroids through the reader and converts image pixels back to microns,
  so it works on any of the six platforms rather than only Xenium.

Fixes a Visium HD bin mismatch that shipped in v0.7.0
  niches_visium_hd.R defaulted to square_016um while visium_hd_reader
  served square_008um. Barcodes are bin-size specific, so the fixture had
  ZERO ids in common between its edges and its bins. The edges still drew,
  because they carry their own coordinates, but nothing joined: clicking a
  bin found no edge, the metadata filter dropped every edge, and the
  tissue graph floated free of the bins beneath it.

  _bin_from_edges() now reads one barcode from the edge file and serves
  the matching bin, falling back to Space Ranger's default when a dataset
  has no edges. That is also right on the merits — 8 um bins are usually
  too sparse to score (233,531 edges of which 83 scored, against 66,001
  edges across 69 LRMs at 16 um), and whoever ran NICHESv2 already made
  that call.

  Verified: all nine datasets now have 100% barcode overlap between edges
  and units, with a median endpoint-to-centroid offset of 0.000 px.

sample_data/make_edge_metadata.py
  Derives interaction_class, score_tier and score_percentile from the edge
  file itself, plus one clearly-named invented demo_review_flag so the
  categorical filter has something to bite on. score_percentile is
  continuous on purpose — every other column is categorical, and the
  gradient path needs exercising too.

  Each folder gets a README naming which columns are derived and which is
  fabricated. A CSV of plausible-looking annotations sitting next to real
  kidney or brain data is a hazard; someone will eventually read
  "score_tier: high" as a result. None of it is analysis output.

Also: the four LayerPanel sections that fetch per-dataset endpoints now
wait for `dataset` to resolve. They were firing five 404s against
/spatial/null/... on every cold load, which is noise that buries real
errors.

v0.8.3. Guard: 238 probes across 8 datasets.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nginx served index.html with only Last-Modified/ETag and no Cache-Control,
so browsers fell back to heuristic caching and could reuse it for hours
without revalidating.

index.html is the only file that names the current bundle. Serving a stale
one after `docker compose up --build` leaves the tab running the previous
JS: the app looks healthy, reports the new version from the backend — that
comes over the API, not the bundle — and silently lacks every frontend fix
in the update.

That is what "I rebuilt and my change isn't there" looks like from the
outside, and it is what made the autocrine colour fix appear absent on
:3000 when the container was in fact serving the corrected bundle all
along.

  index.html  ->  Cache-Control: no-cache
                  Revalidate before use. Not "do not store" — the usual
                  response is a 304, so the cost is one round trip on the
                  smallest file we serve.
  /assets/    ->  Cache-Control: public, max-age=31536000, immutable
                  Vite fingerprints these by content, so the name changes
                  whenever the bytes do. Caching them forever is what makes
                  revalidating the HTML cheap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fine tuning, operational, 9 in-repo datasets with edge data
Split screen showed two views of one dataset — a single global `dataset`,
a single dataset picker, a single `imageSize`. Each panel can now show a
different dataset, including a different platform.

Per-panel state
  Everything bound to *which dataset a panel shows* moves into
  panels[panelIndex]: dataset, activeImage, imageSize,
  platformCapabilities, pixelSize, edgeFile, lrmCatalogue, allGenes, the
  colour ranges, and the shown/total stats. Each differs between datasets,
  so none of them could stay global.

  Style and choice settings stay shared on purpose. One sidebar driving
  both panels is what makes a comparison comparable, and it avoids the
  focus model — "which panel is the sidebar editing?" — that is the
  genuinely hard part of full independence. Phase 2 splits these behind
  sidebar tabs.

Reconciling one sidebar across two datasets — hooks/usePanels.js
  The rule is union, then degrade per panel: offer a control when either
  panel can use it, and let the panel that cannot render nothing.
  Intersecting would hide controls that work fine on one side, which is
  worse when the whole point is comparing unlike things. Gene panels,
  metadata columns, edge columns and LRM catalogues are all unioned;
  unit_label becomes the neutral "unit" when panels disagree, because
  calling a Visium spot a "cell" would be worse than saying nothing.

Match zoom now matches PHYSICAL scale
  It divided both viewports by the local image width — "the same
  proportion of the picture". Identical behaviour when both panels showed
  one dataset, and meaningless across two: 20% of a 6.5 mm Visium capture
  area and 20% of a 55 µm seqFISH ROI differ by 55x. It now converts
  through each panel's own pixel_size, so the same number of microns spans
  the same screen width, as a scalebar would.

  Verified Visium <-> seqFISH: 2996.9 µm and 54.9 µm across, matched to
  54.9 µm both — at which point one 55 µm Visium spot fills the same field
  as the entire seqFISH ROI.

Shared colour scale, on by default
  Two viridis panels that each auto-ranged to their own data look
  comparable and are not: one's yellow might be 40 counts and the other's
  4,000. Linked, both clamp to the union of the two ranges so the single
  legend is true for everything on screen. Verified [131,547] and [66,447]
  unioning to [66,547]. Unlockable, with the panel saying plainly that
  colours are then not comparable. An explicit slider clamp always wins.

Also
  - Dataset / image / edge-source pickers move into each panel's header in
    split mode; an image name or edge file only means something relative to
    one dataset. Single-panel mode is unchanged, pickers still in the
    sidebar.
  - Selection carries its panel index, so CellInfoPanel, EdgeInfoPanel and
    region export resolve against the dataset that was actually clicked.
    EdgeInfoPanel used to be pinned to panel 0.
  - DatasetPicker is its own module, breaking the cycle it would otherwise
    close: Viewer -> LayerPanel -> App -> Viewer.

Known trade: changing either panel's dataset resets the shared
column-named settings (filters, colour-by field, gene allowlist, hidden
LRMs), because a filter naming a column the new dataset lacks 400s on
every viewport change. That clears the other panel's filter too, and goes
away in Phase 2.

v0.9.0. Guard unaffected (238 probes, reader-level).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two independent fixes surfaced while bringing up a full-size Spatial
Genomics seqFISH dataset (91250x44667 DAPI, ~4 gigapixels).

Tile pyramid — large JPEG2000 OME-TIFFs (pyramid.py, tiles.py, compose):
  libvips 8.16 ships OpenJPEG but still cannot decode this file's tiles
  (TIFF compression tag 34712) because it goes through libtiff, which has
  no codec for it; only tifffile+imagecodecs can. The tifffile fallback
  only builds levels up to MAX_TIFFFILE_DIM yet used to advertise the
  native size in the DZI, so the viewer requested deeper tiles that 404'd
  and rendered black on deep zoom.
  - Keep the descriptor at NATIVE size so overlay coordinates (cells,
    edges, transcripts, emitted as um / pixel_size) stay aligned with the
    image; a capped descriptor had misplaced every overlay by the scale
    factor.
  - Synthesise the un-built top levels on demand by upscaling the deepest
    built level (get_or_synth_tile), so deep zoom is smooth-blurry instead
    of black, with no large pre-build and bounded memory.
  - Log the previously-silent pyvips -> tifffile fallback so codec
    failures are visible.
  - Expose MAX_TIFFFILE_DIM as an env knob (default 16384) to trade RAM
    for a sharper deepest real level.

seqFISH per-cell counts (seqfish_reader.py):
  Derive transcript_counts / total_counts from the CellxGene row sum so
  the cell-info panel shows real numbers instead of blanks (seqFISH v2 has
  no transcript->cell assignment; the row sum is the authoritative total).
  Also makes the counts available as a color-by / filter field.

Existing datasets (Xenium, small images, the pyvips path) are unaffected:
synthesis only engages above the deepest built level.
fix: support large JPEG2000 OME-TIFFs and add seqFISH per-cell counts
…nly it

The gene picker renders every checkbox as ticked when `selectedGenes` is null
(`checked = selectedGenes === null || selectedGenes.has(gene)`), but
`toggleSelectedGene` treated a click in that state as "start an allowlist with
just this gene". So the first click on a ticked box did the opposite of what the
box said: it selected that one gene rather than excluding it.

On a small demo panel that is merely surprising. On real data it does not look
like a filter bug at all — on a 480-gene Xenium run one click took the transcript
layer from 200,000 rendered dots to 363 over the same viewport, which presents as
"transcripts are broken", with nothing in the UI obviously wrong beyond a
"1 / 480 genes selected" line that reads as intentional.

A click on a ticked box now excludes that gene, building the allowlist from the
union of the visible panels' gene lists. Toggling the last gene back on collapses
to null so "no filter" stays single-valued rather than becoming an explicit
480-name allowlist on every request.

Behaviour predates v0.3.0 — `git tag --contains c52dbc0` puts it in v0.2.0.

Verified on the reporting dataset (Rat-PPLR-Run1, 480 genes, 132M transcripts):
clicking Abca3 now yields 479/480 with Abca3 excluded and 200,000 transcripts
still rendering; clicking it again returns to no filter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both version strings claimed 0.9.0, but no 0.9.0 release exists anywhere. The
v0.9.0 tag is local-only: `git ls-remote --tags` shows origin carries no tags at
all, and upstream's newest is v.0.8.3. So the browser tab, the sidebar badge and
GET /health all advertised a version nobody could obtain, and the badge's
frontend-vs-backend mismatch check compared two equally wrong strings.

The frontend reads frontend/package.json at build time via vite's
__APP_VERSION__; the backend has its own constant. They have to be edited
together or the badge turns red.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… index build

Reported as "toggle transcripts on and nothing shows up" on a real Xenium run
(132,789,299 transcripts, 1736 MB). The layer was empty because the request never
returned data, for two independent reasons that compounded.

1. The backend was being killed. DUCKDB_MEMORY_LIMIT defaulted to 8GB in both
   compose files, but the Docker Desktop VM has 7.8 GB, so the spatial-index
   build's global ORDER BY over 132M rows ran the VM out of memory and the kernel
   killed uvicorn. This is invisible in the obvious places: the container limit is
   12 GB so `OOMKilled` stays false and the exit code is 0 — only RestartCount
   climbing (5, here) shows it. It also cannot be caught by the build's own
   `except Exception`, since SIGKILL is not an exception.

   The limit is now derived from 60% of the minimum of the cgroup limit and
   physical RAM, and connect() sets a temp_directory under CACHE_DIR so DuckDB can
   spill — an in-memory DuckDB cannot go out-of-core without one, which is what
   made a sort this size impossible rather than merely slow.

2. The build blocked the request. Even surviving, it takes ~94 s against nginx's
   120 s proxy_read_timeout. It now runs in a daemon thread while queries continue
   against the unsorted file, which answers them correctly, just slower.

Either failure reached the frontend as a non-ok response, and the hooks return []
on non-ok — so the canvas showed nothing, exactly as if the platform served no
transcripts. Small datasets are under the 64 MB cache threshold, which is why this
never appeared on the bundled fixtures.

Verified on the reporting dataset: 200,000 transcripts render across all sections,
index builds in 94 s with 0 restarts, viewport query 2.3 s -> 0.37 s once adopted.

Golden guard: 238 probes across 8 datasets, all matching. The baseline also
absorbs the two seqFISH per-cell count columns added in e668eda, which was never
re-recorded; verified by stashing this change that the drift is unrelated to it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A .DS_Store was committed to the repo root in e668eda alongside unrelated
tiling work. It is macOS Finder view metadata — per-machine, meaningless to
anyone else, and rewritten whenever the folder is opened in Finder, so it shows
up as spurious churn in every status and diff.

`git rm --cached` untracks it without deleting it, so local Finder view settings
survive. The pattern is left unanchored (`.DS_Store`, not `/.DS_Store`) so it
also covers the copies already sitting in r/, docs/ and sample_data/, plus any
directory browsed in future.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rter

Review follow-up to f4b2f2b. That fix made "uncheck one gene" produce an
allowlist of everything else, which is correct behaviour but put the whole panel
into the URL of a GET. Measured against this stack: 479 of 480 genes is a
7,780-byte URL, ~220 bytes below nginx's 8 KB request-line limit, and 600 genes
returns 414. So it worked on the reporting dataset by luck, and a Xenium Prime 5K
panel would have failed on the first click.

The complement is short exactly when the allowlist is long, so the client now
sends whichever is smaller. `exclude_genes` is resolved back into an allowlist in
the router against reader.gene_list(), leaving the reader interface — six
implementations — with a single gene argument. Unchecking one gene now sends 157
bytes instead of 7,780. nginx's large_client_header_buffers goes to 64k to cover
the worst case the rule can still produce, half a panel.

Verified equivalent, not just shorter: exclude_genes=Abca3 and the explicit
479-gene allowlist both return total=34884 on the same viewport.

Also in this pass:

- An empty selectedGenes Set now short-circuits the fetch. It means "show no
  species", but omitting the parameter means "no filter", so the request returned
  the full 200K-row cap for Viewer's client-side filter to throw away: nothing
  drew, which looked correct, while ~20 MB moved per pan and the layer badge
  showed the unfiltered total against an empty canvas.

- spatial_cache's _resolved memo is keyed on the sort columns, not just the path.
  The manifest check refuses a cache sorted on a different column pair, but the
  memo sat in front of it and would have returned that exact file on every later
  call. Not reachable today — each source is queried with one fixed pair — but it
  silently defeated the check written to prevent it.

- Tidied a comment in _start_background_build that had been split mid-sentence
  across two lines.

Golden guard: 238 probes across 8 datasets, all matching.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
useUnionList joins dataset names on a NUL to build its effect key, which is
sound (a NUL cannot appear in a directory name, so two datasets can never
collide into one key), but the byte was written literally into the source
rather than as an escape. Git classifies any file containing a NUL as binary,
so the file showed as "Bin 0 -> 3418 bytes" in every diff: no line-level
review, no blame, no meaningful conflict resolution.

The escape form is the identical value at runtime and keeps the file text.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Split-screen dataset comparison, and fix transcripts never rendering on large datasets; v0.8.4
@msraredon
msraredon merged commit d103d40 into main Aug 13, 2026
2 of 3 checks passed
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.

2 participants