Skip to main content

reglscatterpy

PyPI Python versions Docs Live demo License: MIT

Fast, interactive scatterplots for single-cell and spatial data — right in your notebook.

Plot millions of cells, then use your mouse: pan, zoom, lasso a population, toggle cell types in the legend — and read your selection straight back into Python. It speaks AnnData, MuData, SpatialData, pandas and numpy, and works in Jupyter, JupyterLab, VS Code and Colab.

Panning, lassoing and legend-filtering an interactive UMAP

Live demo  •  Examples  •  Documentation

Under the hood it renders with regl-scatterplot (WebGL), and it's the Python twin of the R package reglScatterplotR — both drive the same compiled widget, so a plot looks and behaves identically in R and Python.

Install

pip install reglscatterpy            # numpy, pandas, anywidget
pip install anndata                  # for AnnData; mudata / spatialdata as needed

Quick start

Give it an AnnData, say which embedding to show (basis=) and what to colour by — an obs column or a gene name. That's it:

import scanpy as sc
import reglscatterpy as rs

adata = sc.datasets.pbmc3k_processed()
rs.scatterplot(adata, basis="umap", color_by="louvain")   # colour by cell type
rs.scatterplot(adata, basis="umap", color_by="CST3")      # …or by a gene

basis= accepts short names like "umap" / "pca" (resolved to the obsm key X_umap, … — case-insensitive). For a plain DataFrame, give the coordinate columns with x= / y= instead:

import numpy as np, pandas as pd
df = pd.DataFrame({"x": np.random.rand(10_000), "y": np.random.rand(10_000),
                   "ct": np.random.choice(list("ABC"), 10_000)})
rs.scatterplot(df, x="x", y="y", color_by="ct")

Plots are 700 px wide by default (not the full cell width). Pass width= (pixels) for another size, or width=None to fill the cell.

Gallery


Categorical colouring

Continuous (gene) colouring

filter_by distribution sliders

Linked grid (compose)

All panels are rendered from real pbmc3k (2,638 PBMCs) — regenerate them with the maintainer scripts.

See it in action

Markers of a selection — lasso a population and call diff_expression; you get scanpy-native results (here, the T-cell markers of a lassoed T/NK region).

Lasso a cluster and get its differential-expression markers

Morph between embeddings — cells glide from a UMAP into their tissue coordinates and back, with colour and selection carried through the transition.

Morph a UMAP into spatial tissue coordinates

Selections and analysis

The point of an interactive plot is the round-trip — circle some cells and keep working with them in Python. Pass interactive=True to get the live, kernel-linked widget (the default plot is a portable snapshot; see Static vs. interactive):

w = rs.scatterplot(adata, basis="umap", color_by="louvain", interactive=True)
w                          # show it, then lasso a population in the browser

Read the selection back — or set it from Python:

w.selection                # -> [12, 87, 134, ...]  positional indices
adata[w.selection]         # subset the AnnData directly
sub = w.subset()           # same thing, convenience

w.selection = list(range(100))   # drive it from Python to highlight points

Label cells you lassoed (curate cell types by hand) — the label is written straight into adata.obs:

w.annotate("cell_type", "T cells")    # writes adata.obs["cell_type"] for those cells
# lasso another cluster, w.annotate("cell_type", "B cells"), …then re-plot:
rs.scatterplot(adata, basis="umap", color_by="cell_type")

Get the top marker genes of a selection vs the rest (or vs another lasso). Both DE calls return scanpy's native result and auto-save it to adata.uns, so the rest of the scanpy world just works:

import scanpy as sc
w.diff_expression(n=10)                        # selection "A" vs rest -> adata.uns["rank_genes_groups"]
sc.get.rank_genes_groups_df(adata, group="A")  # tidy table when you want one
sc.pl.rank_genes_groups(adata)                 # scanpy's own plot, straight after

a = w.selection                                # save group A
# (lasso group B)
w.diff_expression(a, w.selection)              # A vs B

Or split a single lasso by an obs column (e.g. condition / time) and compare its levels — one clean adata.uns entry, one column per level:

res = w.diff_expression_by("condition")        # each level vs the rest
res["names"].dtype.names                        # ('D30', 'Y1', 'Y2')
w.diff_expression_by("condition", group_a="D30", group_b="Y1")   # one pair
w.diff_expression_by("condition", key_added="cond_de")           # choose the uns key

Which engine? By default DE uses scanpy when it's installed and warns if it has to fall back. Pass engine="scanpy" to require it (raises instead of falling back), or run it on the GPU: engine="gpu" needs only cupy (~19× faster on 120k cells — a Welch t-test with the reductions on the GPU), and engine="rapids" uses rapids-singlecell for GPU logreg. See the Widget API for the full table.

See what a region is made of:

w.composition("louvain")           # count + fraction per cluster in the selection

Spatial data

Anything with coordinates in obsm["spatial"] plots the same way — just point basis at it. Great for Visium, Xenium, MERFISH, CosMx, … (see the squidpy Xenium tutorial for getting data in):

import squidpy as sq
import reglscatterpy as rs

adata = sq.datasets.visium_hne_adata()         # public mouse-brain Visium
rs.scatterplot(adata, basis="spatial", color_by="cluster")    # tissue map
rs.scatterplot(adata, basis="spatial", color_by="Olfm1")      # a gene, in situ

A Xenium / MERFISH run read with sq.read.xenium(...) (or any AnnData with a spatial embedding) works identically — bump point_size for sparse tissue:

rs.scatterplot(adata, basis="spatial", color_by="leiden", point_size=3)

And because both layouts live in the same object, you can morph between them — watch clusters snap from a UMAP into their tissue positions:

w = rs.scatterplot(adata, basis="umap", color="leiden", interactive=True)
w                                  # the UMAP
w.morph_to("spatial")              # animate UMAP -> tissue coordinates
w.morph_to("umap", duration=800)   # …and back (ms)

The active filter, legend selection, colours and lasso are kept through the transition. (Atlas-scale progressive=True plots don't morph — the point set isn't fully resident.)

Linked views

Colour one embedding by several genes / columns at once — pass a list and you get a linked grid, one panel per value, with camera + lasso kept in sync:

rs.scatterplot(adata, basis="umap", color_by=["louvain", "CST3", "NKG7"])

A list of names means "one panel per name". A raw per-point colour vector must be a numpy array / pandas Series (not a list of strings).

Or compose pre-built plots — e.g. compare embeddings side by side. compose() auto-upgrades plain plots to live widgets, so you don't need interactive=True on each:

from reglscatterpy import scatterplot, compose

a = scatterplot(adata, basis="umap", color_by="leiden")
b = scatterplot(adata, basis="pca",  color_by="leiden")
compose([a, b])            # 2-up grid, linked camera + selection

A lasso or legend filter on one panel propagates to the others by original cell — even across panels coloured by different variables, and even when the panels are progressive=True. A view reset on one panel resets the group. A list of bases works the same way, and basis × color can both be lists (the basis × colour grid, capped at 16 panels):

rs.scatterplot(adata, basis=["umap", "tsne", "pca"], color="leiden")

Atlas-scale rendering

By default scatterplot() keeps huge datasets interactive without silently hiding cells, via max_points (default "auto"):

# AUTO (default): caps at 500k with a density-preserving subsample that KEEPS
# rare cell types. The plot stays honest: an on-figure "500,000 of 3,900,000
# shown" caption, a one-time warning, and w.selection still indexes ALL rows.
rs.scatterplot(adata, basis="umap", color_by="cell_type")

# ALL POINTS RESIDENT (the Allen ABC-Atlas method): every cell on the GPU,
# camera-only pan/zoom — smooth up to ~4M cells on a decent GPU.
rs.scatterplot(adata, basis="umap", color_by="cell_type", max_points=None)

For datasets larger than ~4M, use progressive=True — a light density overview that re-renders all cells inside the viewport as you zoom in (no preprocessing, lasso stays complete):

rs.scatterplot(adata, basis="umap", color_by="cell_type", progressive=True,
               progressive_opts={"detail_max_points": 300_000, "overscan": 0.6})
  • detail_max_points — max points per zoomed-in viewport (lower = smoother pan).
  • overscan — margin fetched around the view so panning has no hard cuts.

Rule of thumb: max_points=None for ~2–4M real atlases; progressive=True beyond that. The two subsample modes are "density" (default, keeps rare types) and "random" (uniform fallback).

Static by default, interactive on request

By default a plot renders as a self-contained snapshot (a sandboxed <iframe> with the WebGL bundle and data baked in) — like a plotly figure, it shows in JupyterLab, Notebook 7, VS Code and Colab, and survives reopening the notebook with no kernel. It stays fully interactive visually — pan, zoom, lasso, legend, tooltips, PNG/SVG/PDF export — but, having no kernel link, it can't send a selection back to Python.

For the Python round-trip (w.selection, annotate, diff_expression, linked compose grids) pass interactive=True:

w = rs.scatterplot(adata, basis="umap", color_by="leiden", interactive=True)
w                          # lasso some cells…
adata[w.selection]         # …read them back in Python

Use the default for figures you want to keep/share, and interactive=True while you're actively selecting (it needs a running kernel and, like any Jupyter widget, may show blank on reopen).

Save a standalone HTML (offline, kernel-free)

The Python equivalent of R's htmlwidgets::saveWidget: one self-contained .html that inlines the widget and the plot's data, so it opens in any browser with no kernel and no internet:

w = rs.scatterplot(adata, x="X_umap", color_by="leiden")
rs.save_html(w, "umap.html")      # or:  w.to_html("umap.html")

It's fully interactive (pan/zoom, legend, lasso, tooltips, PNG/SVG/PDF export) but a snapshot — no kernel, so the Python round-trips only work in the live notebook. The bundle is inlined gzip-compressed (~0.5 MB), so a one-plot file is well under 1 MB.

A whole notebook → one HTML report (no re-running)

Plain jupyter nbconvert --to html leaves the plots blank. The fix that avoids re-executing a heavy notebook is record mode: call rs.record_html() once at the top, run your notebook normally, and each plot bakes a static interactive copy into its own cell output. After that:

import reglscatterpy as rs
rs.record_html()                 # run once near the top, then work as usual
# ... rs.scatterplot(...) cells ...
# now either of these makes a report WITHOUT re-running anything:
jupyter nbconvert --to html analysis.ipynb
reglscatterpy-report analysis.ipynb -o analysis_report.html

reglscatterpy-report (and rs.save_notebook_html(...)) default to not re-executing — they reuse recorded outputs and share one copy of the bundle across all plots. Pass --execute / execute=True to re-run a notebook that wasn't recorded. Needs nbconvert + ipykernel (pip install 'reglscatterpy[report]').

Recorded plots are a one-way snapshot — w.selection / w.annotate no longer round-trip. Call rs.record_html(False) to go back to the live widget.

Theme (light / dark / auto)

Plots are a white "figure card" by default — portable, and matching the exported HTML. To make the live widget follow your notebook, pass theme=:

rs.scatterplot(adata, basis="umap", color="leiden", theme="auto")   # dark in a dark theme
rs.scatterplot(adata, basis="umap", color="leiden", theme="dark")   # always dark

Dark-theme figure card

  • "light" (default) — white card.
  • "dark" — dark card with light axes/legend.
  • "auto" (alias "system") — dark only when the host (VS Code / JupyterLab) is in a dark theme.

Set it once per session with rs.set_theme("auto") (rs.get_theme() reads it back). A per-call theme= overrides the global; an explicit background_color= / axis_color= always wins. The theme only affects the live widget; an exported .html stays portably light.

Additional options

Richer tooltips — show extra fields (obs columns or genes) on hover:

rs.scatterplot(adata, x="X_umap", color_by="leiden",
               tooltip_by=["n_genes", "sample", "CST3"])

Outlines & highlightingadd_outline=True rings every point (scanpy-style, small/medium plots); w.highlight([...]) persistently marks a chosen subset that survives double-click and new lassoes:

rs.scatterplot(adata, basis="umap", color_by="cluster", add_outline=True)

w = rs.scatterplot(adata, basis="umap", color_by="cluster", interactive=True)
w.highlight([12, 87, 134], color="red")   # ring + size bump on these cells
w.highlight([])                            # clear

Encode more variables — put a numeric column or a gene on point size or opacity too:

rs.scatterplot(adata, basis="umap", color_by="leiden", size_by="n_genes")  # or size_by="CST3"
rs.scatterplot(adata, basis="umap", color_by="leiden", opacity_by="total_counts")

Toolbar & legendtoolbar="left" (or "top", "none") shows an in-plot toolbar (pan, lasso, zoom-to-selection, reset, screenshot); zoom_on_selection=True auto-frames a lasso; the categorical legend shows a live per-category count (legend_counts=False to hide); alpha= sets a global transparency.

Supported objects

Input x (embedding) color_by / group_by
AnnData obsm key ("X_umap", "umap", "spatial", …) obs column or var_names feature
MuData global obsm or "modality:embedding" obs column or "modality:feature"
SpatialData table's obsm (defaults to "spatial") table's obs / features
pandas.DataFrame column name column name or vector
numpy.ndarray column index vector

Same plot in R and Python

rs.scatterplot(...) mirrors R's reglScatterplot(...): color_by / group_by, point_size, opacity, point_color, pixel_ratio, continuous_palette / categorical_palette, custom_colors, vmin / vmax, center_zero, filter_by, legend styling, enable_download, and more. Equivalence is locked down by tests/test_payload_parity.py, which checks the Python payload byte-for-byte against R fixtures.

A backend="jscatter" option also exists if you'd rather render with jupyter-scatter (pip install reglscatterpy[render]); the default native widget is recommended.

The widget bundle

src/reglscatterpy/static/widget.js is a built artifact (an anywidget ESM bundle). Its source — the shared rendering widget plus the anywidget adapter — lives in the reglScatterplotR repo under js/. To refresh it after a JS change, build there and copy the result here:

# from a sibling checkout of reglScatterplotR
cd reglScatterplotR/js && npm install && npm run build
cp dist/widget.js ../../reglscatterpy/src/reglscatterpy/static/widget.js

Develop / test

pip install -e .[dev]
pytest          # extraction tests skip cleanly without anndata/scipy

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

reglscatterpy-0.6.35.tar.gz (983.2 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

reglscatterpy-0.6.35-py3-none-any.whl (502.4 kB view details)

Uploaded Python 3

File details

Details for the file reglscatterpy-0.6.35.tar.gz.

File metadata

  • Download URL: reglscatterpy-0.6.35.tar.gz
  • Upload date:
  • Size: 983.2 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.11.15

File hashes

Hashes for reglscatterpy-0.6.35.tar.gz
Algorithm Hash digest
SHA256 c8c56e32ed12758a0122a8dcad9b94588b123b59f858c4bee2fdfdb0e2aaea1d
MD5 ea4e77ef30989238b989c0f819d98e8f
BLAKE2b-256 f635680ba89ff90dce6ff6884a933d6a7faf03b3ff703222b33073b1828ca62f

See more details on using hashes here.

File details

Details for the file reglscatterpy-0.6.35-py3-none-any.whl.

File metadata

  • Download URL: reglscatterpy-0.6.35-py3-none-any.whl
  • Upload date:
  • Size: 502.4 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.11.15

File hashes

Hashes for reglscatterpy-0.6.35-py3-none-any.whl
Algorithm Hash digest
SHA256 f5f2dc459412c2542b7d1e52a4bc468362df529ee5c9d641f6f08cb39212db14
MD5 07114fdd743786bf112105cae0e6597e
BLAKE2b-256 7d2e3304e9c209cf78a8c641d2d827cafaba9c7ad7f4d24c697dc020ae78299d

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page