Skip to content

seqFISH functionality; v0.6.0; beta - #48

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

seqFISH functionality; v0.6.0; beta#48
msraredon merged 8 commits into
RaredonLab:devfrom
msraredon:dev

Conversation

@msraredon

Copy link
Copy Markdown
Collaborator

No description provided.

msraredon and others added 8 commits August 4, 2026 13:53
Xenium writes its multi-channel stack to morphology_focus/, but list_images
only scanned the dataset root, so those channels were unreachable from the
image picker even though the files were sitting right there. Both sample
datasets ship four of them.

list_images now scans the root and one level of subdirectories, returning
bare filename stems. _find_source resolves a stem back to a path by searching
the same two locations in the same order, so the picker can never list an
image the tile builder cannot open. Root-level files are added first and
stems are de-duplicated, so a root file wins any name collision with a
subdirectory file in both functions. Hidden directories are skipped so
.dzi_cache is never scanned.

Salvaged from a GitHub Desktop stash (28616a9) whose edge-file changes were
superseded by the merged RaredonLab#46 implementation; this part had never landed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…d plans; v0.4.0

CLAUDE.md had drifted far enough to mislead. Corrections:

- Visium HD was entirely absent despite being a registered reader (second in
  detection order). Added it, plus a note that VisiumHDReader.transcripts and
  .cell_boundaries still carry the pre-refactor limit= signature and would
  raise TypeError if the capability flags did not stop the frontend calling
  them.
- Per-panel rotation (RaredonLab#31) and RenderingStatus.jsx were undocumented. Added a
  Rotation section covering the two places the angle must be applied, why the
  fetch bbox is padded, and why viewportActual exists separately.
- The cloud deployment story was missing entirely: docs/cloud-deploy.md,
  Caddyfile, deploy.sh, docker-compose.prod.yml, upload-data.sh. Corrected the
  claim that there is no auth at all — Caddy basicauth exists but is opt-in and
  off by default.
- Fixed the r/ listing, which named a file deleted in b30bc82, and the App.jsx
  path. Documented the actual three-script pipeline.
- Replaced the stale fracW zoom-skip thresholds with the sampling fractions
  that actually govern fetch volume now.
- Reordered What's Not Built Yet to lead with the real bottleneck: spatial
  reads pull whole parquet files into pandas on every viewport change instead
  of pushing the bbox down to DuckDB the way the edge path does. Added the
  CellInfoPanel supplemental-metadata gap and open issues RaredonLab#35 and RaredonLab#45.

README: replaced your-lab placeholder URLs with RaredonLab, added Visium HD to
the platform table with an honest note on per-platform completeness, documented
multi-channel morphology, multiple edge sets, split-screen and rotation, linked
the cloud-deploy runbook, corrected export_for_TissuePlex to export_to_TissuePlex,
and added a roadmap pointing at the open issues.

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. Set it to 5173 so
the config matches the documented intent and npm run dev stops colliding with a
running container.

PLAN.md, PLAN_v2_2026-05-02.md and EDGE_UI_PLAN.md move to OBS/ with a README
explaining why each is obsolete. EDGE_UI_PLAN in particular specifies the
opposite of what shipped on both edge aggregation and lrm_set coloring.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both methods loaded their entire parquet with pq.read_table(...).to_pandas()
and then masked in pandas, so every viewport change materialized the whole
file to keep a viewport-sized slice of it.

Measured on a synthetic 40M-row / 0.78 GB transcripts.parquet, one zoomed-in
viewport query:

    pandas full read + mask   2903 MB peak RSS    912 ms
    DuckDB streaming           233 MB peak RSS   1369 ms

12x less memory. That is the binding constraint: production runs on a 16 GB
droplet, where a multi-GB transcripts.parquet under the old path exhausts RAM
long before it is merely slow.

DuckDB is somewhat slower here, and the reason is worth recording. Xenium
writes transcripts.parquet in row order, not spatial order, with very large
row groups -- the bundled breast dataset is 1.1M rows in 2 row groups, the
first spanning the entire x-range. Row-group statistics are therefore useless
and DuckDB scans everything anyway, paying predicate-evaluation cost with none
of the pruning payoff. Sorting the file spatially and rewriting it with small
row groups takes the same query from 1010 ms to 29 ms; that is a follow-up,
noted in CLAUDE.md.

Also fixes polygon clipping. Boundaries previously filtered individual
vertices by bbox, so cells straddling the viewport edge came back missing
part of their outline and rendered as torn shapes -- 97 such cells on the
bundled breast dataset. Selection is now per cell: a cell qualifies if any
vertex is in the bbox, and all of its vertices are returned. Sampling likewise
draws whole cells.

Shared query helpers live in readers/duck.py so the other platform readers can
adopt the same path. Verified against pandas ground truth on both bundled
datasets: identical totals for full-extent, bbox-quadrant and gene-filtered
queries, identical pixel-space conversion, and sampling that is deterministic
across repeated calls.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Design proposal only -- no reader implemented yet.

Records the format research and the decisions taken: target Spatial Genomics
GenePS rather than academic seqFISH+ (which has no standard output), one ROI
per folder, bare integer cell labels as cell_id, and an edge-metadata/ folder
mirroring the existing cell-metadata/ convention.

The format claims are verified against the real scverse CI fixture rather than
taken from documentation. Two findings changed the plan:

A single seqFISH dataset mixes coordinate systems. Measured on the 1000x1000
DAPI at 0.107161 um/px: CellCoordinates center_x spans 1.82-105.66 and
TranscriptList x spans 0-107.05, both microns, while Boundaries.geojson
vertices span 0-999, pixels. Cells and transcripts need dividing by pixel_size
and boundaries must pass through untouched. One global transform in either
direction would put cells and their own outlines in different places, which
presents as a rendering bug rather than a unit bug. The px-vs-um heuristic
(ratio of max coordinate to image width) separates all three cases here with
two orders of magnitude of headroom.

GeoJSON features carry the cell label in their `id` field -- strings "1".."62"
matching CellCoordinates.label exactly. spatialdata-io does not read this and
maps polygons to cells positionally, with an open issue about the fragility
(scverse/spatialdata-io#249). Joining on id is correct by construction; a
silent off-by-one there would draw every outline on the wrong cell.

sample_data/.gitignore excludes seqfish*/ (and visium-*/). The test dataset is
public for CI by written permission from Spatial Genomics, not under an open
licence, so it must not be redistributed from this repo.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Groundwork for seqFISH support. Two changes, neither altering Xenium behaviour.

backend/tests/golden_snapshot.py exercises every reader method against the
bundled datasets, digests the results, and diffs them against a recorded
baseline -- 62 probes across the two Xenium datasets, covering info,
capabilities, gene lists, cells, schema, transcripts (full/bbox/gene-filtered/
sampled), boundaries (full/sampled), per-cell detail and expression, colour
values, and every edge endpoint for each edge file. It calls readers directly
rather than over HTTP, so no server is needed and failures point at the reader
instead of the transport.

This repo has no test suite, so cross-platform reader work had nothing to catch
a regression. Verified the guard actually bites: flipping duck.SAMPLE_SEED from
42 to 43 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, because query_grouped uses ORDER BY RANDOM() and SQL
GROUP BY promises no ordering -- hashing in returned order failed on every run.
And the edge_detail probe picked grouped[0], a different edge each time; it now
takes the lexicographic minimum.

_load_supplemental_metadata and _read_csv_with_barcodes move from XeniumReader
to SpatialDatasetReader, so cell-metadata/ becomes available to every platform
rather than Xenium alone. Nothing platform-specific was in them; all Xenium
contributes now is the list of its own root CSVs to ignore.

That list is the trap this opens, so it is closed here too: MERSCOPE and CosMx
both write CSVs at the dataset root that the shared loader would otherwise
ingest as user metadata. MERSCOPE gets an exact-name skip set; CosMx prefixes
every output with the experiment name, so it needs suffix matching, which
_is_platform_csv now supports.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds a fifth platform reader. Xenium is untouched: all 62 of its golden-snapshot
probes are byte-identical across the change.

"seqFISH" names two unrelated things. The academic Cai-lab method has no
standard output layout; this targets the commercial Spatial Genomics GenePS
platform, which does. Current v2 layout is fully supported -- cells,
transcripts, boundaries, expression, both colour-value modes. Legacy v1 reads
cells and transcripts but declares has_boundaries: False, since v1 ships only a
label mask and polygonising it would mean either a heavy new dependency or
hand-rolled contour tracing; deferred deliberately.

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

    CellCoordinates center_x   1.82 -> 105.66    microns
    TranscriptList  x          0.00 -> 107.05    microns
    Boundaries      vertices   0    -> 999       pixels

Cells and transcripts are divided by pixel_size; boundaries pass through
untouched. One global transform in either direction would put cells and their
own outlines in different places, presenting 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. On real data the
ratios are 0.106 / 0.107 / 0.999 -- two orders of magnitude apart.

Verified geometrically rather than by digest alone: every cell centroid falls
inside its own polygon, 62/62 on the reference dataset and 36/36 on the
synthetic fixture, with zero false positives against a control. Independently,
polygon area recomputed from boundary vertices agrees with the reported
cell_area to within 3%.

Cell identity comes from each GeoJSON feature's id, which equals label.
spatialdata-io maps polygons positionally instead 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.

sample_data/make_seqfish.py generates a committable synthetic v2 ROI (400K) and
deliberately reproduces the mixed units, so a reader that got them wrong would
fail on it. The real reference dataset is public by written permission from
Spatial Genomics rather than under an open licence, so it stays gitignored.

Three bugs fixed along the way, all pre-existing and none seqFISH-specific:

- CellInfoPanel rendered the literal string "undefined um2" for any platform
  not reporting a cell area -- the optional chain was not paired with a null
  check the way nucleus_area's is.
- Switching datasets kept the previous dataset's activeImage until the image
  list resolved, so OSD spent that window requesting the old image from the new
  dataset and logging 404s. Only visible now because image names differ across
  platforms ("morphology" vs "Roi1_DAPI"). setDataset clears it and OSD init
  waits.
- cell_area is kept in um2 to match Xenium, which never converts it, so the
  "um2" label in the info panel is true on every platform.

Verified end-to-end in the browser across Xenium -> seqFISH -> Xenium with zero
console errors and no backend errors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The edge-side mirror of cell-metadata/: annotate cell pairs -- a call, a
confidence, a review flag -- without regenerating edges.parquet from R.

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

Rather than write a second loader, the cell-metadata implementation moves to
readers/supplemental.py parameterised on the key column, and both features now
share it. Same file types, same outer-join, same forgiving key resolution
(explicit key column -> "Unnamed: 0", pandas' name for R's unnamed rowname
column -> first column if unique strings), so the R default just works. Moving
it is behaviour-preserving: all Xenium probes stayed identical across the
refactor.

The folder sits beside the dataset, not beside the edge file. _dataset_dir
walks up out of edges/ when the edge file is nested there, so one set of
annotations applies across every edge source in the dataset -- annotations
describe cell pairs, which are a property of the tissue rather than of one
scoring run. Verified against both edges.parquet and edges/*.parquet.

Three integration points in edge_reader.py, and no frontend change was needed
for the main one: schema() merges supplemental columns into the returned map,
and LayerPanel builds the edge colour-by dropdown straight from the schema, so
they appear on their own. edge_color_values() checks the parquet first then the
supplemental frame, skipping the GROUP BY since supplemental data is already
one row per edge. edge_detail() attaches matches under a `metadata` key.
Parquet wins a name collision -- a supplemental column silently shadowing a
real one would be painful to debug.

EdgeInfoPanel renders those generically as an "Annotations" block above the LRM
table, so any column the user adds shows up untouched.

mouse_ileum_tiny gains worked examples of both cell-metadata/ and
edge-metadata/. Neither feature had a sample dataset before, so cell-metadata
had been unexercisable locally since it shipped.

Verified in the browser: the three annotation columns appear in the edge
colour-by dropdown, a categorical one renders its legend and colours the edges,
and clicking an edge shows the annotations alongside its LRM scores --
interaction_class "heterotypic" on a Fibroblast -> Endothelial edge, confirming
the join lands on the right row. No console or backend errors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Version bump covering seqFISH platform support, the reader regression guard,
and supplemental edge metadata.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@msraredon
msraredon merged commit 8982971 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