Interactive web application for exploring and analyzing scRNA-seq and spatial transcriptomics data. Load an h5ad, 10x Genomics h5, 10x Visium HD feature_slice.h5, Seurat .rds file, 10x CellRanger matrix folder, or prefixed 10x file trio from GEO, visualize cells on a scatter plot, run Scanpy analysis pipelines, and explore results — all from your browser.
XCell uses pixi to manage its environment. A single
pixi install provisions the exact Python and Node versions plus every
dependency — no manual venv, no Node-version juggling, no version troubleshooting.
If you've never installed software from GitHub before, follow every step below in order. Anything in a code block is meant to be pasted into a terminal:
- macOS — open the Terminal app (⌘+Space, type "Terminal", press Enter).
- Linux — open your terminal emulator (GNOME Terminal, Konsole, etc.).
- Windows — open PowerShell (Start menu → type "PowerShell" → Enter).
Git is the tool that downloads the source code from GitHub.
- macOS — run
git --version. If Git isn't installed, macOS will prompt you to install the Command Line Tools; click Install and wait for it to finish. - Linux (Debian/Ubuntu) —
sudo apt-get install git - Linux (Fedora) —
sudo dnf install git - Windows — download and run the installer from https://git-scm.com/download/win, accepting the defaults.
Verify with:
git --versionPrefer not to use Git? You can also click the green Code button at https://github.com/cahanlab/xcell, choose Download ZIP, then unzip it anywhere on your machine. Skip ahead to step 3.
Pick a folder where you'd like XCell to live (your home directory is fine) and clone the repository into it:
cd ~ # or wherever you want the xcell/ folder created
git clone https://github.com/cahanlab/xcell.git
cd xcellThis creates an xcell/ directory containing the source code. The final
cd xcell puts your terminal inside that directory — every command from here
on must be run from there.
pixi is what installs Python, Node, and every project dependency in one shot.
curl -fsSL https://pixi.sh/install.sh | bash # macOS / Linux
# Windows (PowerShell): iwr -useb https://pixi.sh/install.ps1 | iexpixi is a single self-contained binary. It does not require — or conflict with —
an existing conda installation. Close and reopen your terminal after the
install so pixi is on PATH, then cd xcell again. Verify with:
pixi --versionFrom inside the xcell/ directory:
pixi install # creates ./.pixi/ with Python, Node, and all dependenciesThis reads pixi.lock, so every platform gets identical, reproducible versions.
The first run downloads several hundred MB and can take a few minutes — that's
normal. You only do this once (or after pulling updates).
Optional: cell type classification needs
PySingleCellNet, which is kept
out of the default environment because it pulls in a large extra stack. If you
want it, install the pyscn environment instead and launch the backend from it
(step 5 becomes pixi run -e pyscn backend):
pixi install -e pyscnEverything else works the same either way — without it, the Cell Typing panel shows install instructions rather than failing.
XCell runs as two processes: a Python backend and a JavaScript frontend. You'll
need two terminal windows, both cd'd into the xcell/ directory.
In the first terminal:
pixi run backend # FastAPI on http://localhost:8000
# (or `pixi run -e pyscn backend` if you installed that environment)Wait for it to print Application startup complete. before using the app.
The very first start in a freshly installed environment can take up to a
minute — Python is compiling caches for the whole scientific stack — versus a
few seconds on later starts. During that window the port is already open but
not answering, so requests hang rather than failing fast; the frontend will
report the backend as "still starting up".
In the second terminal:
pixi run dev # Vite dev server on http://localhost:5173 (installs frontend deps on first run)Wait until the second terminal prints something like Local: http://localhost:5173/,
then open http://localhost:5173 in your browser. Leave both terminals running
while you use XCell; press Ctrl+C in each one to stop the servers when done.
A bundled toy dataset (toy_spatial.h5ad) loads automatically if no data path is specified. A second bundled dataset, toy_spatial_3sections.h5ad (three separated sections of the same tissue, with an obs['section'] label), is handy for testing spatial workflows — like (multi)contour — where distances between spots on different sections aren't meaningful; point XCELL_DATA_PATH at it to use it. To load your own data, set the XCELL_DATA_PATH environment variable when starting the backend:
XCELL_DATA_PATH=/path/to/your/data.h5ad pixi run backend # also .h5, .rds, a 10x matrix folder, or a *_matrix.mtx(.gz) trioFrom inside the xcell/ directory:
git pull # fetch the latest code
pixi install # refresh dependencies if they changedThen restart the two pixi run commands.
Loading
.rdsfiles is optional and needs R with the Seurat and SeuratDisk packages installed separately — SeuratDisk is not available as a conda package.Loading a 10x Visium HD
feature_slice.h5works directly — pointXCELL_DATA_PATH(or the file browser) at the*_feature_slice.h5. XCell rebins the 2 µm expression to 8 µm tissue bins, attaches spatial coordinates, and imports the precomputed graph/k-means clusters as colorable metadata. First load takes ~1–2 minutes; the result is cached as a.h5adnext to the source file (can be ~1 GB — delete it to reclaim space), so later loads are instant.Loading 10x matrix folders and GEO file trios. A CellRanger folder (
matrix.mtx,barcodes.tsv, andfeatures.tsvorgenes.tsv) loads as a folder; a prefixed GEO trio (GSM1234_matrix.mtx.gzwith matching_barcodesand_featuresor_genescompanions) loads from its*_matrix.mtx(.gz)file and is listed by its prefix in the file browser. Each file may be gzipped or not, independently, and both Cell Ranger v2 (genes.tsv, two columns) and v3 (features.tsv, three columns) gene lists are accepted — including GEO's gzipped v2 output,genes.tsv.gz, which scanpy's own reader cannot open.Not using pixi? XCell still installs the classic way (
pip install -e backendin a Python 3.10+ venv,npm installinfrontend/on Node 18+). pixi just removes the version-matching guesswork.
The included test_data/toy_spatial.h5ad dataset is a small spatial transcriptomics dataset for exploring XCell's features. Here's a step-by-step walkthrough:
- The center panel shows cells as points at their embedding coordinates (spatial, UMAP, PCA, …). The tab is labeled Embedding; if multiple embeddings are available, switch via the in-plot Embedding dropdown.
- Pan by clicking and dragging
- Zoom with scroll wheel
- Zoom/pan are preserved across in-place data changes (cell delete, filter, normalize, etc.). The camera only re-centers when you explicitly switch embeddings.
- Open Cell Manager (left panel)
- Select a metadata column to color cells by that annotation
⋯→ Show labels on plot on a categorical column overlays each category's name at its cluster centroid. Labels never overlap: the largest clusters keep their centroid, smaller ones are nudged aside with a leader line, and any that still cannot fit are hidden until you zoom in. Pinned snapshots shrink the labels to fit their pane. Label Font Size in Display settings sets the base size.
- Click the Select button in the toolbar (use the dropdown arrow to choose between Lasso and Polygon tools)
- Lasso: click and drag to draw a freehand selection
- Polygon: click to add vertices, double-click to close and select cells inside
- Hold Shift while selecting to add to the existing selection
- Checkboxes in the Cell Manager also select/deselect cells by category
- Rename a category label by double-clicking the label in the expanded category list. Press Enter to commit (or Escape to cancel). Works on Leiden clusters, Contourize results, user annotations — any categorical metadata.
- Merge two or more labels by clicking the
⋯menu in a column header and choosing Merge labels…. Pick the labels to merge, type a new name (or reuse an existing one to fold them in), then click Merge. - Selected cells can be masked or deleted
The Adjust toolbar dropdown has three sections:
- Rotate — enter Rotate mode then drag inside the plot to rotate around the data centroid. A live angle badge and a faint orange ring at the pivot show what's happening. Hold Shift to snap to 15° increments. The bottom-of-viewport toolbar gives ±90° quick buttons and a precise degree input (Enter to apply).
- Quilt — lasso a cell subset, then drag to translate it (or Shift+drag to rotate it) — for stitching together adjacent tissue sections. Arrow keys nudge the selection (Shift+arrow for 10× larger step). Press Ctrl/Cmd+Z (or click "Undo") to revert the last quilt transform.
- Flip — one-shot actions: Flip Horizontal mirrors the embedding left↔right (about the y-axis), Flip Vertical mirrors top↔bottom (about the x-axis). If you're in Quilt mode with cells selected, the flip applies only to those cells.
All adjustments persist on the backend and are saved on h5ad export.
- Open the Scanpy modal (top toolbar)
- Go to Preprocessing and run in order:
- Normalize Total — normalize counts per cell
- Log1p — log-transform the data
- Highly Variable Genes — identify informative genes
Datasets rarely document what was already done to them, and the answer matters: running Normalize Total on already-normalized data is a no-op that looks fine, and rank-based methods (UCell, cell typing) quietly misbehave on z-scored values.
The Source dropdown in the View box (bottom-left of the plot) names the
scale of each matrix inline — .X (default) — log-normalized, counts — raw counts — and the small ⓘ next to it opens the reasoning:
- the verdict and how confident it is
- what the file records —
uns['log1p']left by scanpy, xcell's own Normalize Total / Log1p / Smooth history, and whether thecountslayer was in the original file or inferred by xcell at load time - what the numbers say — value range, whether they're integers, whether
every cell sums to the same total (the fingerprint of
normalize_total), and the same check after undoinglog1p - the raw statistics behind all of it
Possible verdicts are raw counts, normalized (linear), log-normalized, log-transformed (log scale, but library sizes were never equalized — or genes were subset afterwards), scaled / z-scored, and binary. The same badge appears next to the layer picker in the UCell and Cell Typing dialogs, which warn outright if you point them at a z-scored layer.
-
In the Scanpy modal, go to Cell Analysis and run in order:
- PCA — reduce dimensionality
- PCA Loadings (optional) — scan the top-loading genes on each side of every PC (hover a gene to see its exact loading). If you spot PCs dominated by technical signal (cell cycle, mitochondrial genes, etc.), check them and click Create PC subset to persist a derived embedding (e.g.
X_pca_noPC2_5). - Neighbors — build cell neighborhood graph (requires PCA). If you created derived subsets in step 2, pick one from the PC source dropdown — UMAP and Leiden inherit the choice automatically through the neighbors graph.
- UMAP — compute 2D embedding (requires Neighbors)
- Leiden — cluster cells (requires Neighbors)
Re-running PCA clears all derived PC subsets (with a toast) since their column indices refer to the previous eigenvectors.
UMAP and Leiden both take a kNN graph. The kNN graph dropdown lists every connectivity graph in the dataset — the expression neighbors from step 3 (the default), the graph from Spatial Neighbors, and anything built with Combine Neighbors. Clustering the spatial graph finds spatial domains — contiguous regions of tissue — rather than cell types, and needs neither PCA nor step 3, so it works on a spatial dataset the moment Spatial Neighbors has run. Results are named after the graph (
X_umap_spatial,leiden_spatial), so an expression map and a spatial one sit side by side instead of overwriting each other; the name is editable.
- In Cell Manager, select the
leidencolumn to color by cluster - Switch the embedding to
X_umapto see the UMAP layout
- Open Gene Manager (right panel)
- If the dataset has alternative gene identifier columns (e.g., gene symbols alongside Ensembl IDs), use the Gene IDs dropdown at the top of the panel to switch
- Search or browse genes
- Click a gene to color cells by its expression
- Click the
⧉beside any gene — in search results, inside a gene set, or among a directional set's↓genes — to copy its symbol to the clipboard; it flashes✓on success
Every gene row has an ⓘ beside the copy button. It opens a card from
MyGene.info: full name and aliases, the RefSeq summary, GO terms (strongest
evidence first, codes shown), InterPro domains, pathways, the human or mouse
ortholog, and links to NCBI, Ensembl, MGI/HGNC and UniProt. A mouse gene
without a summary of its own shows its human ortholog's, labelled as such.
Annotations are cached on your machine per species
(~/.cache/xcell/gene_annotations.sqlite), so each gene is fetched once;
⋯ → Fetch gene annotations for all genes… in the Genes panel header warms
the cache for the whole dataset in the background.
To scope the Gene Panel to a relevant gene universe, click the ⋯ button in the Genes panel header and choose Gene mask…. The modal lists all boolean columns in your dataset's .var (for example, highly_variable after running Highly Variable Genes, or spatially_variable after spatial autocorrelation). For each column, choose:
- Off — ignore this column
- Keep — include genes where this column is True
- Hide — exclude genes where this column is True
When you have multiple Keep columns, choose whether to match ANY (union) or ALL (intersection). Hide columns always combine as a union.
The mask sets the visible gene universe. It applies to everything that reports or displays genes: the gene browse list and search, expanded gene set rows, gene set score aggregation, the heatmap, Diff Exp, Marker Genes, line association, gene NMF and meta-programs, the gene map, gene PCA and gene neighbours, gene-set clustering, spatial autocorrelation, and ligand–receptor analysis. Where an operation also has its own gene subset dropdown, the two intersect — and if the mask hides the whole subset, the run stops with a message rather than silently testing nothing.
It deliberately does not apply to three things, which read the whole .var
axis whatever the mask says:
- The scanpy chain (Filter Genes, Highly Variable Genes, PCA, Neighbors,
UMAP, Leiden) — these build cell-space structure that gets written into the
file, and a session-only view should not silently change an embedding.
highly_variableis also a common mask column, which would make masking and then re-running it circular. - Merge Spots — it rebuilds the cells from raw counts.
- The Localize reference bundle — those genes become the shared space with the query dataset, which has its own mask, or none.
Where an operation drops genes because of the mask, it says so: the heatmap
toolbar reads · 3 hidden by gene mask, and the line-association result names
the count. The mask is per-dataset and session-only; reloading the page clears
it.
- Create gene sets manually in Gene Manager
- Import gene lists from files
- Browse and import from MSigDB, Enrichr and STRING with the Library button (below)
Click Library in the Genes panel header (or ⋯ → Gene set library…).
Pick a species (guessed from your gene names; override if needed) and a
source:
- MSigDB — every collection for human or mouse (Hallmark, curated perturbations including the NABA matrisome sets, Reactome, WikiPathways, GO, cell-type signatures, …), from the newest release.
- Enrichr — 228 libraries (GO, Reactome, KEGG, WikiPathways, PanglaoDB, CellMarker, Tabula Muris, …). Most are human-symbol libraries; they work on mouse data through case-insensitive matching.
- STRING — not a library but a query: type seed genes or pick one of your gene sets, and each seed becomes a set of its interaction partners (combined score above the threshold you set).
A library is downloaded once (click Fetch) into
~/.cache/xcell/gene_set_sources and searched locally after that, by set
name or by a gene it must contain. For every set the table shows how many
members are present in your dataset and how many of those fall in each
boolean .var column you tick (highly_variable, spatially_variable, …);
the threshold boxes hide sets below a minimum, so "ECM sets with at least ten
HVGs in this tissue" is one search. Tick the sets you want and Import:
they land in a Manual folder named after the library, in your dataset's own
gene spelling and holding only the genes that exist in it. Each imported set
remembers its source, library and version (hover the set name). Cache
location and STRING defaults are configurable under gene_set_sources: in
docs/config.example.yaml.
The Manual category at the top of the Gene Panel is the home for gene sets
you create by hand. Click + 📁 to create a named folder (e.g. "Fig 3 markers").
Inside a folder, click + to add a new empty set, or drag an existing
top-level set onto the folder row to move it in. Drag a set back onto the thin
strip above the first folder to move it out. Drag sets within the same container
to reorder them.
Each gene set and folder row has a ⋯ button with secondary actions.
On a gene set row, that's where you find Pin and Cluster genes.
On a manual folder row, that's where you find Pin and Export (JSON/GMT/CSV).
Use the Pin/Unpin option in the ⋯ menu on any set or folder to float it to
the top of its container. Pinning works in every category — including
auto-generated ones — and survives moving a set between folders.
The Export ▸ option in the ⋯ menu on any manual folder lets you export just
that folder's gene sets to JSON, GMT, or CSV. Filename defaults to the sanitized
folder name. JSON round-trips via the existing Import modal.
Use the 👁 button on a category header to hide a whole category from view
(useful when an analysis has filled Gene Clusters or Differential Expression
with results you're done with). A N hidden ▸ footer appears at the bottom of
the Gene Panel — click it and then Unhide to bring a category back.
Tip: double-click any gene set name or manual folder name to rename it inline.
Any gene set with at least 4 genes can be sub-clustered by expression
pattern. Click the ⋯ button on a gene set row and choose Cluster genes….
Pick a method (Hierarchical or K-means), a number of clusters K (default 3),
and a cell context ("All cells", "Current selection" if you've lasso-picked
some cells, or "Annotation category" to restrict to specific categorical
values in a .obs column). Clicking Run creates a new folder in
Gene Clusters named after the source set, containing one gene set per
cluster. Re-running with different K or a different cell context appends
another folder so you can compare runs side by side.
When a gene mask is active, a Restrict to active gene mask checkbox appears, checked, and shows how many of the set's genes pass. Uncheck it to cluster the set's genes regardless of the mask.
When the run finishes, Show heatmap opens the Heatmap tab with one row band per cluster, restricted to the cells the clustering ran on (the toolbar says so; Heatmap Settings can drop the restriction).
⋯ → Map genes… on a gene-set row (or ⋯ → Gene map… in the Genes
panel header, which lets you pick any gene subset) builds a gene–gene
similarity from up to three channels, each with a weight: expression
(correlation across cells, on .X or a smoothed layer, optionally within the
current selection), annotation (how many of the same cached library sets
two genes belong to — fetch GO, Reactome, the matrisome categories or
cell-type markers in the Library first), and STRING (interaction scores,
queried live). Leiden on that similarity gives modules; UMAP lays the genes
out. In the map view, hover for a gene and drag a lasso to save the
genes inside it as a set; the similarity view is the clustered heatmap
with module boxes; the modules view saves modules as gene sets. A map
holds up to 3,000 genes.
Some sets are several expression patterns under one name — the collagens
split into fibrillar, basement-membrane and cartilage programs — and a single
score hides that. ⋯ → Decompose into programs… on a gene-set row first
tells you whether the set is one pattern or several: the eigen-spectrum of
its correlation matrix against the noise floor a set of independent genes
would produce (teal bars stand above it, the dashed line is the floor). Then
PCA or NMF on the cells × set-genes submatrix produces k programs. Each
program's per-cell score becomes a pill in the Genes panel (colour the plot
by it), the score matrix can be viewed as an embedding (View PC1 × PC2),
and Save programs as gene sets files one set per program under Gene
Clusters — PCA programs keep their up and down lists, so UCell can score
them directionally. Sparse spot data makes every correlation small; pick a
smoothed layer as the source matrix when you have one.
The reverse question — which states do the cells take with respect to one
gene set? — is ⋯ → Cluster cells on this set… on any gene-set row. It runs
PCA → neighbours → Leiden (→ UMAP) on the cells × set-genes submatrix, with
genes z-scored so a highly expressed member does not dominate. Everything is
written under suffixed keys (leiden_<key>, X_umap_<key>,
<key>_connectivities), so the dataset's own PCA, graph and clusters are
untouched, and the graph appears in the UMAP/Leiden graph picker for
re-clustering at another resolution. Start with a low resolution (default
0.3): a gene-set PCA fragments easily. The results panel colours the plot by
the new clusters or switches to the new UMAP.
You can select cells based on a gene's expression or a gene set score without needing to eyeball the scatter plot:
- In the Gene Panel, click the
⋯menu on any gene row or gene set row and chooseSelect cells…. - The modal opens and the scatter plot switches to expression coloring for that source. An interactive histogram of the values is shown.
- Pick a threshold mode (
Above,Below, orBetween) and drag the red cutoff line(s). The match counter updates live. - Choose an action:
- Update selection replaces, adds to, or intersects with your current lasso selection.
- Label cells creates a new annotation column with
high/lowlabels for the cells in the chosen context (current selection or all cells). On success, clickOpen Diff Exp ▸to immediately run differential expression between the two groups.
Typical workflow for "find DEGs by expression state in a region": lasso a region → ⋯ → Select cells… on a gene → drag the threshold → Label cells → Open Diff Exp.
- Open the Analyze modal (top toolbar) → Cell Analysis → Compare Cells
- Select an .obs column (e.g.,
leiden) from the dropdown - Check 2 or more groups to compare:
- 2 checked → pairwise differential expression
- 3+ checked → one-vs-rest marker gene analysis
- Set Top N genes and click Run
- You can also use lasso selection: select cells → Set as Group 1 / Set as Group 2 → click Compare in the comparison bar
- Draw lines on the scatter plot
- Click the gear icon on a shape in the Shapes panel to open Line Tools
- Under Gene Association, configure:
- Test against: position along line or distance from line
- Gene subset: filter to highly variable genes or other boolean columns
- Spline knots: number of interior knots for the B-spline model (default 5; higher = more flexible fit)
- FDR: significance threshold (default 0.05)
- Max genes/direction (or /module when clustering is on): cap on genes returned
- Cluster genes into modules (default off): when checked, significant genes are grouped by expression profile shape (increasing, decreasing, peak, trough, complex); when unchecked, only positive/negative lists are returned
- Click Find Associated Genes to run the analysis
- In the results modal, use the Filters bar to refine results interactively: adjust min R², min amplitude, max FDR, or toggle pattern types (increasing, decreasing, peak, trough, complex)
- Click Add to Gene Sets in the results modal to save the genes — each run creates its own folder in the Line Association category of the Gene Panel (one set per module if clustering is on, or a single combined
Associated genesset if clustering is off) - Click Download CSV in the results modal to export stats (gene, f_stat, pval, fdr, r_squared, amplitude, direction) for every gene tested — a ranked-list suitable for GSEA or other external analyses
- Draw a line on each tissue section representing the same biological axis
- For each line, select cells (via lasso or clicking a category value in the Cells panel) and click + to associate them with the line
- Check the lines to include using the checkboxes that appear on lines with projected cells
- Click Find Associated Genes in the action bar
- In the multi-line modal, toggle direction per line if needed (arrow button) and set analysis parameters
- Results pool cells across all lines for a single, higher-powered analysis
- After computing both Neighbors (Cell Analysis) and Spatial Neighbors (Spatial Analysis), open Analyze → Cell Analysis → Combine Neighbors
- Select two or more graphs and set their weights (default: equal weights; weights are normalized to sum to 1)
- Click Combine graphs — by default the combined graph goes to its own
<name>_connectivitiesslot; targetingconnectivitiesoverwrites the expression graph in place - Run Leiden (or UMAP) afterward and pick the combined graph from their kNN graph dropdown; clustering/embedding then reflects both graphs, encouraging spatially neighboring cells to cluster together when the spatial graph is weighted in
- In the Scanpy modal, go to Gene Analysis:
- Build Gene Graph — compute gene-gene similarity
- Cluster Genes — group genes by expression pattern
The Cell Typing toolbar button annotates cells with PySingleCellNet, which votes a random forest over top-scoring gene pairs — "is gene A above gene B in this cell?". Because that question is asked within each cell, it is unaffected by per-cell rescaling: raw counts, CPM, and log-normalized data all give the same answer. Per-gene scaling is not safe, which is why the dialog warns if you pick a z-scored layer.
This is an optional dependency. Install it with:
pixi install -e pyscn # then run the backend from that environment:
pixi run -e pyscn backendWithout it the panel explains how to install rather than failing — and you can still inspect a classifier against your data, which needs only scikit-learn.
Classify — point the dialog at a pickled classifier, either by pasting a
path or with Browse (the same filesystem picker as File → Load, filtered to
.pkl/.pickle and remembering where you keep them). Selecting one inspects it
straight away; a pasted path needs Inspect. Either way, look at what it
reports before running anything. It reports the classifier (cell types, gene pairs,
trees) and, more importantly, how much of its gene set your data actually
has. This matters because PySingleCellNet fills genes it can't find with
zeros: a poorly matched query still returns confident-looking scores rather than
an error. Coverage is banded ok / warning / error, missing symbols are listed,
and a pure ACTB vs Actb casing difference is recognized as such and offers a
one-click case-insensitive match instead of reporting 0% overlap.
Running it writes, for a result prefix SCN:
| Where | What |
|---|---|
obs['SCN_class_argmax'] |
the cell type call, colored with the classifier's own palette |
obs['SCN_class_score'] |
the winning vote proportion — how confident that call is |
obs['SCN_class_type'] |
Singular / Ambiguous / None / Rand (optional) |
obsm['SCN_score'] |
the full cells × types score matrix |
The results panel shows the composition and call quality, with buttons to color by any of them. The score matrix appears in the Gene Manager under ◈ SCN_score, so you can color by a single cell type's score, and in the embedding dropdown, so you can plot two cell types against each other. Re-running under a different prefix keeps both results side by side.
Singular means exactly one cell type scored above its threshold, None means
none did (thresholds are self-calibrated per class, as in PySingleCellNet's
comp_ct_thresh). PySingleCellNet further splits the multi-type case into
Intermediate and Hybrid using a graph of cell-type relatedness; xcell has no
such graph, so it reports those cells as Ambiguous rather than guessing.
Train builds a classifier from a categorical .obs column — balancing cells
per type, preprocessing on a private copy so your loaded dataset is never
modified — writes the .pkl, and hands it straight to the Classify tab. Its
Browse picks the destination folder and lists the classifiers already there,
so you can see what you have, reuse a name, or be warned before overwriting one;
a name without an extension gets .pkl.
How it preprocesses depends on what scale your reference is already on, which it detects (see "Which scale is my data on?" above) and shows in a Scale of that matrix dropdown you can override:
| Source scale | What training does |
|---|---|
| raw counts | normalize_total → log1p → HVG (seurat_v3 on counts) |
| normalized, not logged | log1p → HVG (seurat) |
| already log-normalized | nothing → HVG (seurat) |
| scaled / z-scored | refused — see below |
This matters because many public references are distributed only as log-normalized values. Normalizing and logging those again distorts the marker ranking that picks the gene pairs. The result panel states exactly what was applied, so you can check it rather than trust it.
Training refuses a z-scored source outright: per-gene centering reorders genes within a cell, which is precisely what the pair transform reads.
Gene names with underscores. PySingleCellNet encodes each gene pair as the string
geneA_geneBand decodes it by splitting on_, so symbols containing an underscore cannot be represented. Training excludes them and tells you how many; if too few genes remain it stops and says so. The bundledtoy_spatial.h5adis exactly this case — its genes are all namedMesen_1and friends — so use a dataset with conventional symbols, or rename them first.
Open the Analyze modal → Spatial → Contour → Open Contour tool…. The Contour tool handles both single- and multi-gene-set contouring; grid resolution and smoothing sigma are prefilled with data-aware suggestions (you can override), and each parameter has a tooltip explaining what raising or lowering it costs. Click Choose gene sets… to pick sources — any saved gene set, or the current Gene Panel selection.
The tool checks your settings against the data as you type, and Show how to choose these opens a guide with starting recipes per platform, the trade-offs, and what to avoid. The two things worth knowing before you touch anything:
- Grid resolution and smoothing sigma are one setting, not two. Sigma is
measured in grid pixels, and a pixel is
extent / grid, so doubling the grid halves the real smoothing radius. Raise the grid alone and the tool tells you what the smoothing just became, in spot spacings, and what sigma would restore it. Every warning is phrased in spot spacings for this reason — under 1 the filter never reaches the neighbouring spot and the bands speckle; over about 6 it spans a whole zone and adjacent tissues merge. - Bands are equal-width, not equal-count. Thresholds are spaced evenly between zero and the field's maximum, so on a skewed field — a module high in one corner, the normal case — the top band holds few spots. That is the shape of the field, not weak expression.
It also reads the detected scale of .X (see expression-scale detection above)
and says so when Log transform disagrees with it — off on raw counts, or on
over an already-logged matrix.
One gene set → a banded expression column. Pick a single source, optionally name the output column, and click Run contour. A new ordered-categorical column appears in the Cell Panel; color cells by it to see spatial expression zones.
Two or more gene sets → a fused tissue annotation (requires X_pca — run PCA
first). Pick several modules (e.g. cartilage, muscle, tendon, interzone, skin,
dermis) and click Compute:
- Review & bin — for each module, a histogram shows how spots spread across bands; choose the cutoff at/above which a spot counts as "high" (auto-default is the top band).
- Finalize — every spot is labeled with the module it's high in. Spots high
in exactly one module get that tissue; spots high in several are resolved by a
vote among their spatial neighbors, ranked by expression-profile (
X_pca) similarity; spots high in none stayunassigned. Optionally save QC columns (<name>_status, per-module<set>_high). - The result is one categorical
.obscolumn (defaulttissue) you can color by.
Both paths have a Section column dropdown. If your dataset holds several
distinct sections in one coordinate space (see Define Sections and Working
with multi-section data below), pick the section column so each section is
contoured independently — expression won't bleed across the gaps between
sections. It auto-detects a section or sample column.
When several tissue sections share one coordinate space, label them so spatial analyses can treat them independently. Open the Analyze modal → Spatial → Define Sections → Open Define Sections…. A small floating panel appears (it doesn't block the plot):
- Name the section column (default
section) and click Start defining — the view switches to the spatial embedding and the polygon tool is armed. - Click points on the plot to enclose a section, type its name, and click Add region. Repeat for each section.
- Click Finish — the new categorical
.obscolumn is created and colored.
Use that column as the Section column in Contour and Spatial Neighbors.
Euclidean distance between cells on different sections is not meaningful, so spatial analyses that treat the whole plane as one tissue can couple sections across the gaps. Two analyses are section-aware via a Section column:
- Contour — interpolates and smooths per section (above).
- Spatial Neighbors — set the Section column to build a per-section (block-diagonal) graph, so neighborhoods never span a gap. This also fixes the downstream steps that consume that graph (Spatial Autocorrelation, Smooth, Combine Neighbors).
Expression-based analyses (PCA, Neighbors, UMAP, Leiden, differential expression, gene clustering) use expression rather than geometry, so they are not affected by the gaps (batch effects across sections are a separate concern).
To compare samples in one view (timepoints of a tissue, replicates, or any cross-sample analysis), you can load 2+ h5ads into one dataset:
- Click File → Combine datasets… in the toolbar
- In the load modal, switch the mode toggle to Combine (already set when you arrive via the menu)
- Click
.h5adfiles in the browser to add them to the list — each file gets an editable label (defaults to the filename stem) - Choose the slot to load into, and — for spatial inputs — the gap (% of mean section width)
- Click Combine — a new
samplecategorical.obscolumn tags each cell with its source file label - The combined dataset behaves like any other — color by
sample, run Compare Cells across samples, etc.
What happens depends on the inputs:
- All spatial (each file has
spatialorX_spatialcoordinates, or names its array inuns['xcell_spatial_key']): sections are placed left-to-right along the spatial x-axis with the configured gap, and the result keeps onlyX_spatial. - Anything else: rows are concatenated with no geometry invented.
.obsmarrays present in every input (a sharedX_pca, say) are kept, so the combined dataset opens with a usable view; one-sided arrays are dropped.
Notes:
- Genes = intersection of the input files' var indices. Use Gene IDs swap in the Gene Panel beforehand if your files use different identifier columns.
.h5adonly. For.rds/ 10x files, load them once via single-file Load and export as h5ad first.- In spatial mode per-file UMAPs/PCAs are dropped — re-run PCA/UMAP via the Scanpy modal on the combined data.
- The result records what was done in
uns['xcell_combine'](mode: "spatial" | "concat"plus the labels).
- Click Load in the toolbar — the modal shows a sidebar with quick-access locations (Home, Desktop, Documents, Downloads) and recently loaded files, plus breadcrumb path navigation for clicking any ancestor directory
- Choose Secondary from the "Load into" dropdown
- Browse or enter the path to a second h5ad, h5, rds file, 10x matrix folder, or prefixed 10x file trio and click Load
- A dataset switcher dropdown appears in the header — switch between Primary and Secondary to compare datasets
- Click the Split button to view both datasets side by side
- Click on either plot to make it the active dataset — the Cell and Gene panels update accordingly
- Each plot has its own embedding selector, legend, and independent pan/zoom
- Click Export in the toolbar to download annotations and results
xcell ships with hardcoded defaults for every form in the Scanpy modal, the Line Association dialog, and the Display Settings panel (e.g. filter_cells → min genes = 25, point size = 3). To change these without touching code, drop a YAML (or JSON) file at ~/.xcell/config.yaml — or set XCELL_CONFIG_PATH to point somewhere else. A sample is included at docs/config.example.yaml.
Shape is a nested mapping matching the form namespace — only include keys you want to override, everything else falls back to the built-in default:
scanpy:
filter_cells:
min_genes: 15 # was 25
neighbors:
n_neighbors: 20 # was 15
line_association:
fdr_threshold: 0.1 # was 0.05
cluster_genes: true # was false
display:
point_size: 4 # was 3
point_opacity: 0.7 # was 0.85
background_color: '#000000' # was '#1a1a2e'
color_scale: magma # was viridis
clip_percentile: 0.5 # was 1.0
gene_set_aggregation: median # was meanA backend restart is required to pick up edits. Verify what was loaded by hitting GET /api/config/defaults; unknown keys are silently ignored. Display defaults are applied to every dataset slot at startup and re-applied on each fresh dataset load — you can still tweak any value in the Display Settings panel for the current session.
Most changes you make in a session survive on the backend process: deleted cells, transformed embeddings, computed PCA / neighbors / UMAP / Leiden, drawn lines, and — as of this version — your gene sets (categories, folders, individual sets). If the browser tab accidentally reloads, the gene panel is rehydrated from the server. Restarting the backend still clears everything; persist important sets via the Gene Panel export controls before shutting down.
Analyze → Spatial → Localize predicts spatial coordinates for an scRNA-seq
dataset using a spatial dataset loaded in the other slot as the map. Each cell
is placed by its k nearest transcriptional neighbours among the spatial cells,
and the result is a new embedding — so everything xcell can already do with an
embedding then works on data that never had coordinates.
The reason the tool leads with confidence rather than parameters: averaging the
positions of k similar cells only means something if those cells sit together.
A cell type scattered through the tissue has neighbours everywhere, and their
average lands in the middle — a smooth, convincing, wrong answer that the
coordinate itself gives no hint about. So every cell gets two scores, written to
.obs: whether it resembles the reference at all, and whether its neighbours
agree on a location. Colour by <key>_confidence after a run and the
untrustworthy regions of the map are immediately visible.
Before trusting anything, Check accuracy holds out a fifth of the spatial reference and predicts it from the rest, reporting the error next to two baselines — predicting the tissue centre, and predicting at random. An error without those is unfalsifiable, and the panel says so outright when the method fails to beat the centre.
Map quality answers a different question: given maps you have already produced, which is best? It scores every predicted embedding in the query against the reference, so a handful of parameter variants can be ranked instead of eyeballed. Four columns:
- Area — how much of the tissue the map fills, 1.0 being right. Well under 1 means the map collapsed toward the centre, which is what averaging many neighbours does; well over 1 means it overshot, and outside then says how many cells were pushed beyond the tissue entirely.
- Spots used — distinct reference locations the predictions land on, out of the number of cells. A small number means many cells piled onto the same few spots, which happens when a better-recovered spot correlates well with everything.
- One column per gene set — spatial pattern fidelity: does that cell type land where it actually lives? This is the column that matters most. A negative value is not merely a weak result, it means the cell type was placed where it is not — an epidermis predicted into the middle of the tissue rather than around its rim. Hover any of these for the gradient (axis) correlation, reported as x of y possible, where y is what the reference itself achieves. An attenuated gradient is only interpretable against that ceiling.
Every metric is rank-based, so none of them can be moved by rescaling either dataset — which is what makes maps from different settings comparable at all.
Filling the tissue and carrying a gradient are the two ends of one
trade-off, and no aggregation escapes it. weighted mean averages the
neighbours, so noise cancels and the gradient survives, but the estimate shrinks
toward the middle: on an E11.5 limb pair it held the proximodistal gradient
while collapsing to 15% of the tissue area and placing the epidermis — the
outermost tissue in the embryo — in the centre of the bud. Anything that picks a
single location per cell (best match, injective) keeps the full extent and
essentially no gradient. Score them under Map quality and pick against what you
need; treating either end as "more accurate" is the mistake the panel exists to
prevent.
injective is best match solved as a set rather than one cell at a time, so
no reference spot absorbs many cells — on that limb pair, 2,683 cells onto 2,683
distinct spots instead of 1,522, for a few percent of mean similarity. It needs
at least as many spots as cells (the option is disabled otherwise), and it
assumes the query's composition matches the tissue's, which dissociation makes
untrue in a way that pushes over-represented types where they do not belong. It
fixes pile-up, not placement.
On a large pair, injective assigns over each cell's best candidate spots
rather than every spot, because solving exactly means holding the whole query ×
reference similarity matrix at once — 12 GB for 50,000 cells against 20,000
spots. Restricting to each cell's 128 best costs almost nothing: measured on the
limb pair, the total similarity lands 0.18% below the exact answer with 87% of
cells on the identical spot, and the Map quality numbers cannot tell the two
apart. Every cell still gets its own spot. Below the size where the exact solver
is affordable nothing changes, and the run says which one produced it — a
near-optimal answer should never be read as an exact one.
Similarity can be computed over every shared gene, a .var flag on the
reference (spatially_variable from Spatial Autocorrelation is the principled
choice — those are the genes carrying positional information), or a gene set you
curated in the Gene panel. The overlap preview follows the choice, and the tool
warns about parameter combinations that are actually bad rather than listing
caveats up front.
To run the spatial tools on a predicted map, choose it as the dataset's
coordinates. A Localize result lands in .obsm['X_spatial_pred'], which is
not a name auto-detection looks for, so Spatial Neighbors, Contour, Define
Sections and Ligand-Receptor will report HAS_SPATIAL missing. Their
prerequisite warning carries a picker: choose the map and they unblock. The
choice is remembered with the dataset, so switching between two predicted maps
re-points every spatial tool at once. It stays an explicit choice rather than a
wider auto-detect because a query localized several ways carries several
predicted maps, and because a prediction is a weaker claim than a measurement —
anything computed over these coordinates inherits the map's error, which Map
quality is there to quantify.
One of those warnings reads the reference's geometry before anything runs.
For each gene set you have curated, it asks where the mean of that population's
own positions falls: if the population forms a ring or hugs the tissue edge,
that mean is a place none of its cells occupy, and weighted mean will send
every query cell of the type there. The panel then names the sets at risk while
the parameters are still being chosen, rather than leaving the map to be
believed first and doubted later.
A benchmark pair with exact ground truth ships with xcell
(toy_localize_spatial.h5ad and toy_localize_scrna.h5ad), deliberately
containing populations the method cannot place, so the confidence scores can be
seen doing their job.
Clicking through a GUI leaves no methods section. xcell records every mutating operation as you go — no need to arm anything first — and File → Analysis record… turns that into a notebook that re-runs the analysis, or Markdown you can paste into a paper.
The panel lists what happened, lets you annotate any step and attach figures (the ◧ Figure button on the plot), and marks where the report should start. Export writes into a folder you pick, next to your data.
Every step is labelled with its fidelity, and the exported document leads with the tally — "11 steps. 11 re-run as written; 0 need the xcell Python API; 0 are manual" — because a methods supplement describing an analysis nobody ran is worse than none at all. Steps that ran on a cell selection rather than the whole dataset are flagged individually; the emitted code runs on everything, and the selection itself is written alongside the notebook so you can restore it.
The record is stored in uns['xcell_analysis_record'], so an exported .h5ad
carries its own provenance and re-opening it continues the history.
- Interactive scatter plot — deck.gl-powered visualization with pan, zoom, lasso selection
- Cell Manager — browse/color by metadata, mask/delete cells
- Gene Manager — search genes, create gene sets, import gene lists
- Scanpy integration — run preprocessing, cell analysis (PCA, Neighbors, UMAP, Leiden), gene analysis, spatial analysis (contourize), and differential expression directly in the browser. Long-running operations (gene neighbors, spatial neighbors, spatial autocorrelation, contourize, line gene association) can be cancelled mid-run without corrupting session data.
- Cell type classification — annotate cells with PySingleCellNet (optional dependency;
pixi install -e pyscn). Apply a trained classifier, or train one from a labelled.obscolumn. Gene coverage against the classifier is checked before the run, since unmatched genes are silently zero-filled and would otherwise yield confident-looking nonsense. Results land as a colored cell-type call, a per-cell confidence, an optional Singular/Ambiguous/None/Rand quality call, and a full cells × types score matrix you can color by or plot as an embedding. - Expression scale detection — every matrix (
.Xand each layer) is classified from its own values as raw counts, normalized, log-normalized, log-transformed, z-scored, or binary, so you always know what scale you're looking at. The verdict shows inline in the Source dropdown; an ⓘ opens the evidence and any provenance recorded by scanpy or by xcell's own preprocessing history. - Trajectory analysis — draw lines and associate genes with spatial trajectories
- Quilt mode — lasso and rearrange tissue pieces: drag to translate, shift+drag to rotate, flip to reflect selected cell subsets
- Display settings — adjust point size, opacity, colormaps, bivariate coloring, and an optional coordinate grid behind the plot (with data-coordinate tick labels along the bottom/left axes for visual reference and troubleshooting)
- Highlight overlay — stack one or more colored layers on top of the active coloring without replacing it. Each layer is either a gene-set expression threshold (above / below / between, with a draggable histogram cutoff) or a frozen cell-set mask (current selection or category value). Useful for marking e.g. epithelium in green while keeping bivariate coloring on the rest.
- Figure builder — compose multi-panel publication figures from a cell selection (or the full dataset). Each panel renders the same cells colored independently (single gene, gene set, bivariate — each axis a gene or a gene set — or metadata column), with its own color scale and title. Per-figure point size, opacity, background, and optional N×N grid overlay are shared so panels stay visually consistent. Per-panel "show highlight layers" toggle blends the dataset's current Highlight overlays into the panel. Shared pan/zoom keeps panels aligned. Export to PNG at 1×–4× DPI from the new Figure tab.
- Multi-dataset support — load two datasets (h5ad, h5, rds, 10x matrix folders, or prefixed 10x file trios from GEO), switch between them, or view side by side in split mode
- Localize — predict where dissociated cells came from. Load a spatial dataset alongside an scRNA-seq one and each scRNA-seq cell is placed by its nearest transcriptional neighbours among the spatial cells, producing a new embedding you can then explore like any other. Two confidence scores say which placements to believe — whether the cell resembles the reference at all, and whether its neighbours agree on a location — because a dispersed cell type produces a smooth, convincing map of nowhere. A built-in hold-out check reports accuracy against random and centroid baselines before you trust it. Analyze → Spatial → Localize
- Analysis record — every operation you run is recorded (parameters, result, and the cell selection it ran on), and exports as a Jupyter notebook you can execute or a Markdown methods supplement, with your own notes and figures captured from the plot. Each step is labelled with how faithfully it reproduces —
exact(the emitted line is the scanpy call xcell really made),xcell(needs xcell's Python API), ormanual— and the document opens by stating that split in numbers, so nobody has to trust it blindly. File → Analysis record… - Export — download annotations and analysis results
xcell/
├── backend/
│ ├── xcell/
│ │ ├── main.py # FastAPI app entry point
│ │ ├── adaptor.py # DataAdaptor class (wraps AnnData)
│ │ ├── diffexp.py # Differential expression
│ │ ├── layer_scale.py # Counts vs normalized vs logged detection
│ │ ├── pyscn.py # PySingleCellNet adapter (optional dep)
│ │ ├── ligrec.py # Ligand-receptor spatial signaling
│ │ ├── task_manager.py # Cancellable background jobs
│ │ ├── data/
│ │ │ └── toy_spatial.h5ad # Bundled toy dataset
│ │ └── api/
│ │ └── routes.py # REST API endpoints
│ ├── tests/ # pytest suite (pixi run -e dev pytest)
│ └── pyproject.toml # Python dependencies
├── frontend/
│ ├── src/
│ │ ├── App.tsx # Main app component
│ │ ├── store.ts # Zustand state management
│ │ ├── main.tsx # Entry point
│ │ ├── components/
│ │ │ ├── ScatterPlot.tsx # deck.gl scatter plot
│ │ │ ├── CellPanel.tsx # Cell metadata manager
│ │ │ ├── GenePanel.tsx # Gene browser / gene sets
│ │ │ ├── ScanpyModal.tsx # Scanpy analysis pipeline UI
│ │ │ ├── DiffExpModal.tsx # Differential expression
│ │ │ ├── LineAssociationModal.tsx # Trajectory analysis
│ │ │ ├── DisplaySettings.tsx # Visualization settings
│ │ │ ├── ShapeManager.tsx # Shape/selection tools
│ │ │ ├── PyscnModal.tsx # Cell typing (classify / train)
│ │ │ ├── LayerScaleInfo.tsx # Expression-scale badge + popover
│ │ │ └── ImportModal.tsx # Gene list import
│ │ └── hooks/
│ │ └── useData.ts # Data fetching hooks
│ ├── package.json # Node dependencies
│ └── vite.config.ts # Vite configuration
├── README.md
test_data/
├── toy_spatial.h5ad # Toy dataset for testing
└── generate_toy.py # Script to regenerate toy data
- Backend: FastAPI + AnnData + Scanpy, serving data and running analysis via REST API
- Frontend: React + TypeScript + Vite + deck.gl + Zustand for state management
- Data flow: h5ad file → DataAdaptor → REST API → React hooks → deck.gl visualization
- API docs: Available at http://localhost:8000/docs when the backend is running
