Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
8621c12
feat: classic Visium (10x v1/v2) support
msraredon Aug 7, 2026
b9e46cb
Merge branch 'RaredonLab:dev' into dev
msraredon Aug 7, 2026
86569ec
fix: correct the Visium pixel-size derivation against real 10x data
msraredon Aug 7, 2026
8911ed9
Merge pull request #53 from msraredon/dev
msraredon Aug 7, 2026
caf5922
feat: edges and edge-metadata on every bundled dataset
msraredon Aug 7, 2026
da17564
fix: revalidate index.html so a rebuild actually reaches the browser
msraredon Aug 7, 2026
d3de650
Merge pull request #54 from msraredon/dev
msraredon Aug 7, 2026
0b31c98
feat: split screen can compare two different datasets
msraredon Aug 7, 2026
e668eda
fix: support large JPEG2000 OME-TIFFs and add seqFISH per-cell counts
noahwang1991 Aug 12, 2026
07782d0
Merge pull request #55 from noahwang1991/dev
noahwang1991 Aug 12, 2026
f4b2f2b
fix: clicking a gene in the picker unchecks it instead of selecting o…
msraredon Aug 13, 2026
762c116
Merge branch 'RaredonLab:dev' into dev
msraredon Aug 13, 2026
8bdd038
Merge branch 'dev' of https://github.com/msraredon/TissuePlex-msbr in…
msraredon Aug 13, 2026
6a7ffad
fix: report 0.8.3, the actual released version, not the unreleased 0.9.0
msraredon Aug 13, 2026
ab9d739
fix: transcripts never render on large datasets — OOM kill + blocking…
msraredon Aug 13, 2026
73575ac
chore: untrack .DS_Store and ignore it at any depth
msraredon Aug 13, 2026
b975919
fix: gene filter travels as allowlist or complement, whichever is sho…
msraredon Aug 13, 2026
b601059
chore: v0.8.4
msraredon Aug 13, 2026
e835f7f
fix: usePanels.js was a binary file — literal NUL in the source
msraredon Aug 13, 2026
7081908
Merge pull request #56 from msraredon/dev
msraredon Aug 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,7 @@ backend/.dzi_cache/
.env.prod
.claude/


# macOS Finder metadata — created in every directory browsed in Finder.
# Pattern is unanchored so it matches at any depth, not just the repo root.
.DS_Store
288 changes: 273 additions & 15 deletions CLAUDE.md

Large diffs are not rendered by default.

30 changes: 22 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,20 @@ An interactive spatial transcriptomics viewer for exploring cell-cell communicat

![TissuePlex demo](docs/demo.gif)

📖 **[User manual](https://raredonlab.github.io/TissuePlex/)** — step-by-step setup and a reference for every control in the interface. No programming experience assumed.

---

## What it does

Spatial transcriptomics platforms (Xenium, seqFISH, Visium HD, MERSCOPE, CosMx) produce high-resolution images with hundreds of genes measured per cell. NICHESv2 infers which cells are communicating and through which ligand-receptor mechanisms (LRMs). TissuePlex bridges those two outputs: it overlays the NICHESv2 communication graph on the tissue image and lets you explore it interactively.
Spatial transcriptomics platforms (Xenium, seqFISH, Visium, Visium HD, MERSCOPE, CosMx) produce high-resolution images with hundreds of genes measured per cell. NICHESv2 infers which cells are communicating and through which ligand-receptor mechanisms (LRMs). TissuePlex bridges those two outputs: it overlays the NICHESv2 communication graph on the tissue image and lets you explore it interactively.

**Key capabilities:**

- **Toggle individual LRMs in real time** — select any subset of 100s of ligand-receptor mechanisms and instantly see which cell pairs are communicating through them
- **Color edges by communication score or metadata** — visualize LRM set strength, cell type, or any custom column from your analysis as a continuous or categorical color scale
- **Click any edge for full detail** — inspect every active LRM for a given cell pair with their individual scores
- **Directed edges with arrowheads** — A→B and B→A are visually distinct; autocrine communication renders as rings
- **Directed edges with arrowheads** — A→B and B→A are visually distinct; autocrine communication renders as a ring per cell, on the same color scale and mechanism filter as the edges
- **Multiple edge sets per dataset** — drop several `.parquet` files into an `edges/` folder and flip between scoring approaches on the same tissue without duplicating the image or cell data
- **Pan and zoom on high-resolution morphology images** — OME-TIFF tile pyramid with smooth zoom from whole-tissue to single-cell scale
- **Multi-channel morphology** — Xenium `morphology_focus/` channels are selectable alongside the top-level morphology image
Expand All @@ -24,6 +26,7 @@ Spatial transcriptomics platforms (Xenium, seqFISH, Visium HD, MERSCOPE, CosMx)
- **Per-panel rotation** — rotate either panel to any angle to align tissue orientation
- **Transcript dot overlay** — per-gene colored dots, filterable by gene species, with hover tooltips
- **Cell/spot segmentation** — polygon boundaries with color-by-gene-set or color-by-metadata, and editable per-category colors
- **Autocrine signalling** — self-signalling drawn as a ring per cell, colored by the same scale as the directed edges and obeying the same mechanism filter
- **Metadata filtering** — restrict the view to a subset of cells or edges (a sample, a few cell types, a value range). Applied server-side before sampling, so a rare cluster renders at full density instead of being sampled away
- **Treat-as-categorical toggle** — integer-coded cluster IDs get a discrete editable palette rather than a viridis gradient, with the numeric order preserved in the legend
- **Region drawing and measurement tools** — annotate areas, export cell selections, save PNG screenshots
Expand All @@ -39,12 +42,15 @@ Spatial transcriptomics platforms (Xenium, seqFISH, Visium HD, MERSCOPE, CosMx)
| **Xenium** | 10x Genomics | ✓ | ✓ | ✓ | ✓ |
| **seqFISH** | Spatial Genomics | ✓ | ✓ | ✓ | ✓ |
| **Visium HD** | 10x Genomics | ✓ | — | ✓ (bins) | ✓ |
| **MERSCOPE** | Vizgen | — | ✓ | — | ✓ |
| **CosMx** | Nanostring | — | ✓ | — | ✓ |
| **Visium** | 10x Genomics | ✓ | — | ✓ (spots) | ✓ |
| **MERSCOPE** | Vizgen | ✓ | ✓ | ✓ | ✓ |
| **CosMx** | Nanostring | placeholder | ✓ | ✓ | ✓ |

All nine bundled demo datasets ship `edges.parquet` and an `edge-metadata/` folder, so the connectivity layer and the annotation workflow have something to show on every platform. Two caveats worth knowing: the MERSCOPE panel carries only **2** complete ligand-receptor pairs and the seqFISH reference panel **none**, because targeted panels are chosen for cell typing rather than signalling — `check_lr_coverage()` reports this before NICHESv2 runs, and `seqfish_instrument2` therefore uses synthetic edges from `make_edges.py`. The `edge-metadata/` folders are generated demo annotations, not analysis output; each carries a README saying which columns are derived and which is invented.

Xenium and seqFISH are the complete implementations. seqFISH means the commercial **Spatial Genomics GenePS** output, not the academic seqFISH/seqFISH+ method, which has no standard file layout; the current v2 layout is fully supported, and legacy v1 reads cells and transcripts but not boundaries.
seqFISH means the commercial **Spatial Genomics GenePS** output, not the academic seqFISH/seqFISH+ method, which has no standard file layout; the current v2 layout is fully supported, and legacy v1 reads cells and transcripts but not boundaries.

The other readers cover cells, transcripts, and metadata coloring; boundary parsing is platform-specific and not yet implemented for them (MERSCOPE stores polygons in HDF5, CosMx in per-FOV label TIFFs). Visium HD renders each square bin as a polygon — so fill, colour-by, picking and region selection all work — but has no per-molecule transcript coordinates. Each reader declares what it supports via a capability flag, and the UI hides layers the platform cannot serve.
The two array-based platforms have no per-molecule transcript coordinates — only spot- or bin-level UMI counts — so they synthesise a polygon per unit instead: a square for a Visium HD bin, a circle for a 55 µm Visium spot. Fill, colour-by, picking and region selection then all work through the same layers. CosMx datasets often ship no morphology image, in which case TissuePlex renders the data onto a blank canvas sized to the tissue. Each reader declares what it supports via a capability flag, and the UI hides layers the platform cannot serve — and relabels itself, so a Visium dataset says "spot" wherever a Xenium one says "cell".

The edge connectivity layer (NICHESv2 output) works with any platform — it is platform-agnostic as long as cell barcodes match.

Expand Down Expand Up @@ -101,13 +107,21 @@ DATA_PATH=/absolute/path/to/your/datasets docker compose up --build
spatial/{tissue_positions.parquet, scalefactors_json.json, tissue_hires_image.png}
edges.parquet

merscope_run_D/
visium_run_D/
filtered_feature_bc_matrix.h5
spatial/ ← Visium sentinel: scalefactors_json.json +
scalefactors_json.json tissue_positions.csv
tissue_positions.csv
tissue_hires_image.png
edges.parquet

merscope_run_E/
cell_by_gene.csv ← MERSCOPE sentinel
cell_metadata.csv
detected_transcripts.csv
edges.parquet

cosmx_run_E/
cosmx_run_F/
my_experiment_tx_file.csv ← CosMx sentinel
edges.parquet
```
Expand Down
2 changes: 1 addition & 1 deletion backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

from app.routers import tiles, spatial, edges, layers

APP_VERSION = "0.8.0"
APP_VERSION = "0.8.4"

app = FastAPI(title="TissuePlex API", version=APP_VERSION)

Expand Down
78 changes: 77 additions & 1 deletion backend/app/readers/duck.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,89 @@
"""
import math
import os
import tempfile
from pathlib import Path
from typing import Optional

import duckdb
import pyarrow.parquet as pq

_MEMORY_LIMIT = os.getenv("DUCKDB_MEMORY_LIMIT", "8GB")
def _available_memory_bytes() -> Optional[int]:
"""Memory this process may actually use, or None if it cannot be determined.

Takes the **minimum** of the cgroup limit and physical RAM, because either
can be the real ceiling and they routinely disagree. On Docker Desktop the
container limit is whatever compose declares (12 GB here) while the Linux VM
hosting it may have far less (7.8 GB) — trusting the cgroup alone invites the
VM's OOM killer, which kills the process without the container ever reporting
OOMKilled.
"""
limits = []
for p in ("/sys/fs/cgroup/memory.max", # cgroup v2
"/sys/fs/cgroup/memory/memory.limit_in_bytes"): # cgroup v1
try:
raw = Path(p).read_text().strip()
if raw and raw != "max":
v = int(raw)
# v1 reports a sentinel near 2^63 to mean "unlimited".
if 0 < v < (1 << 62):
limits.append(v)
except (OSError, ValueError):
pass
try:
limits.append(os.sysconf("SC_PAGE_SIZE") * os.sysconf("SC_PHYS_PAGES"))
except (OSError, ValueError, AttributeError):
pass
return min(limits) if limits else None


def _default_memory_limit() -> str:
"""A memory cap that leaves room for everything else in the process.

The old default was a flat ``8GB`` regardless of the machine. That is not a
cap at all on a standard 8 GB Docker Desktop VM — it authorises DuckDB to
take essentially all of RAM, and a large sort then dies to the OOM killer
mid-request, taking uvicorn with it. Sizing from what is actually present
keeps the same intent (bound the scan) while making the bound real.

60% leaves headroom for the Python process, pyvips tile builds, and the
page cache the parquet scan itself depends on.
"""
total = _available_memory_bytes()
if not total:
return "4GB" # unknowable → conservative, not greedy
mb = max(1024, int(total * 0.60 / (1024 * 1024)))
return f"{mb}MB"


_MEMORY_LIMIT = os.getenv("DUCKDB_MEMORY_LIMIT") or _default_memory_limit()
_THREADS = os.getenv("DUCKDB_THREADS", "4")


def _temp_dir() -> Optional[str]:
"""Writable scratch directory for DuckDB to spill to, or None.

Without this an in-memory DuckDB cannot spill, so any operation whose working
set exceeds ``memory_limit`` fails outright instead of going out-of-core. The
spatial-index build sorts the entire transcripts file, which on a real Xenium
run is 132M rows — far past any sane cap — so spilling is what makes that
build possible at all rather than merely slower.

CACHE_DIR is the right home: it is the one writable volume the backend owns
(/data is mounted read-only), and it already holds derived artifacts.
"""
base = os.getenv("CACHE_DIR") or tempfile.gettempdir()
try:
d = Path(base) / "duckdb-tmp"
d.mkdir(parents=True, exist_ok=True)
return str(d)
except OSError:
return None


_TEMP_DIR = _temp_dir()


def connect() -> duckdb.DuckDBPyConnection:
"""Return a fresh, isolated DuckDB connection.

Expand All @@ -31,6 +105,8 @@ def connect() -> duckdb.DuckDBPyConnection:
conn = duckdb.connect()
conn.execute(f"SET memory_limit='{_MEMORY_LIMIT}'")
conn.execute(f"SET threads={_THREADS}")
if _TEMP_DIR:
conn.execute(f"SET temp_directory='{_TEMP_DIR}'")
return conn


Expand Down
18 changes: 17 additions & 1 deletion backend/app/readers/reader_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
Supported platforms (detection order):
Xenium (10x Genomics) — experiment.xenium
Visium HD (10x Genomics) — square_???um/ subdirectory
Visium (10x Genomics) — spatial/scalefactors_json.json + tissue_positions*
MERSCOPE (Vizgen) — cell_by_gene.csv or cell_metadata.csv
CosMx (Nanostring) — *_tx_file.csv
seqFISH (Spatial Genomics) — *_CellCoordinates*.csv (glob; registered last)
Expand Down Expand Up @@ -46,6 +47,15 @@ def _is_visium_hd(path: Path) -> bool:
or any(path.glob("square_???um")))


def _is_visium(path: Path) -> bool:
# Classic Visium. Registered *after* Visium HD: HD also has a top-level
# spatial/ folder, and although that one holds images only (no scalefactors),
# ordering the more specific sentinel first means a future HD layout change
# cannot silently reroute HD datasets to this reader.
from app.readers.visium_reader import VisiumReader
return VisiumReader.looks_like_visium(path)


def _is_merscope(path: Path) -> bool:
return (
(path / "cell_by_gene.csv").exists() or
Expand Down Expand Up @@ -88,6 +98,11 @@ def _make_visium_hd(path: Path) -> SpatialDatasetReader:
return VisiumHDReader(path)


def _make_visium(path: Path) -> SpatialDatasetReader:
from app.readers.visium_reader import VisiumReader
return VisiumReader(path)


def _make_merscope(path: Path) -> SpatialDatasetReader:
from app.readers.merscope_reader import MerscopeReader
return MerscopeReader(path)
Expand All @@ -105,6 +120,7 @@ def _make_seqfish(path: Path) -> SpatialDatasetReader:

_register(_is_xenium, _make_xenium, "experiment.xenium (Xenium / 10x)")
_register(_is_visium_hd, _make_visium_hd, "square_???um/ directory (Visium HD / 10x)")
_register(_is_visium, _make_visium, "spatial/scalefactors_json.json + tissue_positions (Visium / 10x)")
_register(_is_merscope, _make_merscope, "cell_by_gene.csv or cell_metadata.csv (MERSCOPE / Vizgen)")
_register(_is_cosmx, _make_cosmx, "*_tx_file.csv (CosMx / Nanostring)")
_register(_is_seqfish, _make_seqfish, "*_CellCoordinates*.csv (seqFISH / Spatial Genomics)")
Expand Down Expand Up @@ -133,4 +149,4 @@ def is_dataset(path: Path) -> bool:
@staticmethod
def supported_platforms() -> list[str]:
"""Names of all registered platforms, in detection-priority order."""
return ["xenium", "visium_hd", "merscope", "cosmx", "seqfish"]
return ["xenium", "visium_hd", "visium", "merscope", "cosmx", "seqfish"]
13 changes: 13 additions & 0 deletions backend/app/readers/seqfish_reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,19 @@ def _cells_raw(self) -> Optional[pd.DataFrame]:
# microns (div == pixel_size), px² when it is in pixels (div == 1.0).
ps = self.pixel_size
out["cell_area"] = df["area"] * ((ps / div) ** 2)

# Per-cell counts from CellxGene. seqFISH v2 ships no transcript→cell
# assignment, so unlike Xenium there is no molecule table to count; the
# authoritative per-cell total is the CellxGene row sum (total detected
# transcripts across all genes). Populate both fields the CellInfoPanel
# renders — otherwise "transcripts" / "total counts" sit blank on every
# seqFISH cell. Reuses the cached CellxGene frame, joined on cell_id.
cxg = self._cxg()
if cxg is not None:
totals = cxg.sum(axis=1) # Series indexed by cell_id
counts = out["cell_id"].map(totals).fillna(0).astype(int)
out["transcript_counts"] = counts
out["total_counts"] = counts
return out

def _cells_full(self) -> Optional[pd.DataFrame]:
Expand Down
Loading
Loading