Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
487 changes: 439 additions & 48 deletions CLAUDE.md

Large diffs are not rendered by default.

File renamed without changes.
File renamed without changes.
File renamed without changes.
17 changes: 17 additions & 0 deletions OBS/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# OBS — Obsolete planning documents

These documents describe earlier designs that have since been superseded by the shipped
implementation. They are kept for provenance and to explain why certain decisions were
made, **but they are not a specification and should not be used to guide new work.**

For current architecture, read `CLAUDE.md` at the repo root. For current data contracts
and deployment, read `docs/`.

| File | Written | Why it is obsolete |
|---|---|---|
| `PLAN.md` | 2026-05-01 | The original v1 plan, under the project's former name *ConnectivityExplorer*. Its edge schema (integer `lrm_id` 1–488, `strength`, `cell_id_source`/`cell_id_target`, Xenium pixel coordinates) was fully replaced by the NICHESv2 schema documented in `docs/data_format.md`. Still useful for the rationale behind storing connectivity as vector edges rather than 488 rasterized PNGs. |
| `PLAN_v2_2026-05-02.md` | 2026-05-02 | Status snapshot declaring v1 feature-complete. Its API reference lists the `/xenium/...` routes, which were replaced by the platform-agnostic `/spatial/...` router. Its state schema predates the `lrm_set` color mode and string-keyed `hiddenLrms`. Its P3 performance backlog is still partly relevant and has been carried into `CLAUDE.md`. |
| `EDGE_UI_PLAN.md` | 2026-05-02 | Design doc for the NICHESv2 edge-UI migration. Overtaken during implementation: it specifies **client-side** edge aggregation and a **server** round-trip for `lrm_set` coloring, and the shipped code does the opposite of both (server-side `/query-grouped`, client-side `lrm_set`). It also predates the tissue-graph layer, `edgeDensity`, `arrowStyle`, and `edgeColorClamp`. |

`NICHESv2_package_design.md` remains at the repo root: it documents the separate NICHESv2
R package rather than this codebase, and is still current for that package.
95 changes: 82 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,25 +1,30 @@
# TissuePlex

An interactive spatial transcriptomics viewer for exploring cell-cell communication from [NICHESv2](https://github.com/your-lab/NICHESv2) directly on the tissue image.
An interactive spatial transcriptomics viewer for exploring cell-cell communication from [NICHESv2](https://github.com/RaredonLab/NICHESv2) directly on the tissue image.

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

---

## What it does

Spatial transcriptomics platforms (Xenium, 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 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
- **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
- **Transcript dot overlay** — per-gene colored dots, filterable by gene species
- **Cell/spot segmentation** — polygon boundaries with color-by-gene-set or color-by-metadata
- **Region drawing and measurement tools** — annotate areas, export cell selections
- **Multi-channel morphology** — Xenium `morphology_focus/` channels are selectable alongside the top-level morphology image
- **Cross-platform metadata** — the `cell-metadata/` convention works the same way on every platform that supports it, so annotation workflows transfer between Xenium and seqFISH unchanged
- **Split-screen comparison** — two independently navigable panels sharing one set of layer controls, with a match-zoom button
- **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
- **Region drawing and measurement tools** — annotate areas, export cell selections, save PNG screenshots
- **Supplemental metadata** — drop any CSV or parquet into a `cell-metadata/` folder to add custom color-by columns (clusters, pseudotime, etc.) without touching the original data
- **Multi-dataset support** — switch between datasets without restarting; each is auto-detected by platform

Expand All @@ -30,9 +35,15 @@ Spatial transcriptomics platforms (Xenium, MERSCOPE, CosMx) produce high-resolut
| Platform | Vendor | Morphology | Transcripts | Cell segments | Edges |
|---|---|:---:|:---:|:---:|:---:|
| **Xenium** | 10x Genomics | ✓ | ✓ | ✓ | ✓ |
| **seqFISH** | Spatial Genomics | ✓ | ✓ | ✓ | ✓ |
| **Visium HD** | 10x Genomics | ✓ | — | — | ✓ |
| **MERSCOPE** | Vizgen | — | ✓ | — | ✓ |
| **CosMx** | Nanostring | — | ✓ | — | ✓ |

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.

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 bins as points rather than polygons and 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 edge connectivity layer (NICHESv2 output) works with any platform — it is platform-agnostic as long as cell barcodes match.

---
Expand All @@ -44,7 +55,7 @@ The edge connectivity layer (NICHESv2 output) works with any platform — it is
### Demo with sample data

```bash
git clone https://github.com/your-lab/TissuePlex.git
git clone https://github.com/RaredonLab/TissuePlex.git
cd TissuePlex
docker compose up --build
```
Expand All @@ -64,40 +75,63 @@ DATA_PATH=/absolute/path/to/your/datasets docker compose up --build
xenium_run_A/
experiment.xenium ← Xenium sentinel
morphology.ome.tif
morphology_focus/ ← optional; extra channels appear in the image picker
cells.parquet
transcripts.parquet
cell_boundaries.parquet
edges.parquet ← NICHESv2 output (optional)
edges/ ← optional; additional edge sets to flip between
raw_minimum.parquet
normalized_product.parquet

seqfish_run_B/ ← Spatial Genomics GenePS; one ROI per folder
Roi1_CellCoordinates.csv ← seqFISH sentinel
Roi1_CellxGene.csv
Roi1_TranscriptList.csv
Roi1_Boundaries.geojson
Roi1_DAPI.tiff
edges.parquet

merscope_run_B/
visium_hd_run_C/
square_008um/ ← Visium HD sentinel
edges.parquet

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

cosmx_run_C/
cosmx_run_E/
my_experiment_tx_file.csv ← CosMx sentinel
edges.parquet
```

If `edges.parquet` is absent the edge layers are hidden — all other layers work normally.
If `edges.parquet` is absent the edge layers are hidden — all other layers work normally. When a dataset has more than one edge source, a dropdown appears at the top of the Edge Data section; the selection applies to every open panel.

The first launch builds DZI tile pyramids from OME-TIFF morphology images. This takes ~30 seconds per dataset and is cached across restarts.

### Deploying to a server

[docs/cloud-deploy.md](docs/cloud-deploy.md) is a step-by-step DigitalOcean runbook (~$106–116/month) covering droplet setup, block storage for data, DNS, automatic TLS via Caddy, and data upload. Note that **access control is opt-in**: unless you enable Caddy's `basicauth`, anyone with the URL can view the data.

---

## NICHESv2 workflow

TissuePlex is designed as a downstream visualization step for [NICHESv2](https://github.com/your-lab/NICHESv2). After running NICHESv2 on your spatial dataset, export the connectivity object:
TissuePlex is designed as a downstream visualization step for [NICHESv2](https://github.com/RaredonLab/NICHESv2). After running NICHESv2 on your spatial dataset, export the connectivity object:

```r
# In R, after running NICHESv2:
export_for_TissuePlex(
export_to_TissuePlex(
niches_object,
output_path = "/your/datasets/xenium_run_A/edges.parquet"
output.path = "/your/datasets/xenium_run_A/edges.parquet",
celltype.col = "Type.6"
)
```

Working examples of the full pipeline — exporting Seurat metadata, running NICHESv2, and exporting the parquet — are in [`r/`](r/). Those scripts have hardcoded paths and are meant to be read and adapted, not run as-is.

Then launch TissuePlex — the edge layer will appear automatically.

The `edges.parquet` format is one row per **(directed edge) × (LRM)**. A→B and B→A are separate rows. Any additional columns in the file (cell types, scores, custom metadata) are automatically available as color-by options in the UI. See [docs/data_format.md](docs/data_format.md) for the full column specification.
Expand Down Expand Up @@ -125,6 +159,25 @@ write.csv(my_metadata, file.path(dataset_dir, "cell-metadata", "metadata.csv"))

Columns appear automatically in the **Cell Color** dropdown. Continuous columns get a gradient; string and low-cardinality integer columns get discrete colors.

## Supplemental edge metadata

The same idea for cell *pairs* — annotate edges without regenerating `edges.parquet` from R:

```
dataset_folder/
edges.parquet
edge-metadata/ ← create this directory
annotations.csv ← key column `edge` = "SendingCell|ReceivingCell"
```

```r
write.csv(annotations_df, file.path(dataset_dir, "edge-metadata", "annotations.csv"))
```

Columns appear automatically in the **edge color** dropdown, and show as an *Annotations* block when you click an edge. The folder sits beside the dataset rather than beside the edge file, so one set of annotations applies across every edge source — annotations describe cell pairs, which belong to the tissue rather than to one scoring run.

`sample_data/mouse_ileum_tiny` ships worked examples of both `cell-metadata/` and `edge-metadata/`.

---

## Development setup
Expand All @@ -138,9 +191,13 @@ DATA_ROOT=../sample_data uvicorn app.main:app --reload
# Frontend (React + Vite) — in a separate terminal
cd frontend
npm install
npm run dev # → http://localhost:3000, proxies /api → :8000
npm run dev # → http://localhost:5173, proxies /api → :8000
```

The dev server uses port 5173 so it does not collide with `docker compose`, which binds 3000 for the production frontend.

There is currently no automated test suite, CI, or linter — changes are verified by running the app.

---

## Architecture
Expand All @@ -154,6 +211,7 @@ FastAPI backend
/tiles — OME-TIFF → DZI tile pyramid (pyvips / tifffile fallback)
/spatial — transcripts, cell boundaries, cell metadata, gene expression
/edges — edge query, LRM catalogue, per-edge color values, edge detail
/layers — generic parquet layer serving
```

All data is served directly from parquet files via DuckDB — no database setup or import step. Tile pyramids are built on first access and cached.
Expand All @@ -169,6 +227,17 @@ The frontend automatically adapts its layer controls to the capabilities your re

---

## Roadmap

Tracked in [GitHub issues](https://github.com/RaredonLab/TissuePlex/issues). Currently open:

- **[#45](https://github.com/RaredonLab/TissuePlex/issues/45)** — select cells and edges by metadata, so you can focus on a sample or a few cell types instead of the whole dataset
- **[#35](https://github.com/RaredonLab/TissuePlex/issues/35)** — a "treat as categorical" toggle for numeric metadata columns, so integer-coded cluster IDs get a discrete editable palette instead of a continuous gradient

Also known and not yet addressed: transcript and cell-boundary queries read their full parquet file on every viewport change rather than pushing the bbox filter down to DuckDB the way the edge queries do. This is the main performance limit on very large datasets.

---

## Citation

If you use TissuePlex in published work, please cite: *(preprint / paper link — coming soon)*
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.3.1"
APP_VERSION = "0.6.0"

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

Expand Down
71 changes: 71 additions & 0 deletions backend/app/readers/base_reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@

import pandas as pd

from app.readers import supplemental


_UNSET = object() # sentinel: "not yet loaded" vs "loaded, no data"


class SpatialDatasetReader(ABC):
"""
Expand All @@ -25,8 +30,20 @@ class SpatialDatasetReader(ABC):
always receives pixel coordinates and never needs to know the native unit.
"""

# Filenames in the dataset root that belong to the platform itself rather than
# to the user, and must never be picked up as supplemental metadata. Subclasses
# override this with their own output filenames. `.csv.gz` at the root is always
# skipped, since every platform uses that only for its own data files.
_ROOT_CSV_SKIP: frozenset = frozenset()

# Suffix patterns for platforms whose output filenames carry a run-specific
# prefix (CosMx writes `<experiment>_tx_file.csv`), where an exact-name set
# cannot work.
_ROOT_CSV_SKIP_SUFFIXES: tuple = ()

def __init__(self, dataset_path: Path):
self.path = dataset_path
self._supp_meta_cache = _UNSET

# ── Identity ──────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -143,6 +160,60 @@ def capabilities(self) -> dict:
"unit_label": "cell",
}

# ── Supplemental cell metadata (platform-agnostic) ────────────────────────
#
# Users add their own per-cell columns (clusters, pseudotime, phenotype calls)
# by dropping CSV/parquet into a `cell-metadata/` subdirectory, without touching
# the platform's own output. These live on the base class so every reader gets
# the feature; only `_ROOT_CSV_SKIP` is platform-specific.

def _load_supplemental_metadata(self) -> Optional[pd.DataFrame]:
"""
Merge user-defined cell metadata from:
1. {dataset}/cell-metadata/ — all CSV / parquet
2. {dataset}/ — plain .csv only, skipping platform filenames
Multiple files are outer-joined on cell_id. Cached per reader instance.

The loading itself lives in `supplemental.py`, shared with the `edge-metadata/`
feature so the two cannot drift apart.
"""
if self._supp_meta_cache is not _UNSET:
return self._supp_meta_cache # type: ignore[return-value]
files = supplemental.collect_files(
self.path / "cell-metadata",
root=self.path,
root_filter=lambda n: not self._is_platform_csv(n),
)
self._supp_meta_cache = supplemental.load_supplemental(
files, key="cell_id", log_prefix=self.platform
)
return self._supp_meta_cache # type: ignore[return-value]

def _is_platform_csv(self, lowercase_name: str) -> bool:
"""True if a root-level CSV is the platform's own output, not user metadata."""
if lowercase_name in self._ROOT_CSV_SKIP:
return True
return any(lowercase_name.endswith(s) for s in self._ROOT_CSV_SKIP_SUFFIXES)

def _merge_supplemental(self, cells: Optional[pd.DataFrame]) -> Optional[pd.DataFrame]:
"""Left-join supplemental metadata onto a platform cells table.

Either side may be absent: with no supplemental files this returns `cells`
unchanged, and with no cells table it returns the supplemental frame alone
(so metadata-only datasets still expose their columns).
"""
supp = self._load_supplemental_metadata()
if cells is None and supp is None:
return None
if supp is None:
return cells
if cells is None:
return supp
new_cols = [c for c in supp.columns if c not in cells.columns]
if not new_cols:
return cells
return cells.merge(supp[["cell_id"] + new_cols], on="cell_id", how="left")

# ── Shared utilities ──────────────────────────────────────────────────────

def _to_px(self, val: float) -> float:
Expand Down
8 changes: 8 additions & 0 deletions backend/app/readers/cosmx_reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,14 @@

class CosMxReader(SpatialDatasetReader):

# CosMx prefixes every output with the experiment name (`<expt>_tx_file.csv`),
# so the shared supplemental-metadata loader has to match by suffix rather than
# by exact filename to avoid ingesting the platform's own tables.
_ROOT_CSV_SKIP_SUFFIXES = (
"_tx_file.csv", "_metadata_file.csv", "_fov_positions_file.csv",
"_exprmat_file.csv",
)

def __init__(self, dataset_path: Path):
super().__init__(dataset_path)
self._cells_cache: Optional[pd.DataFrame] = None
Expand Down
Loading