"""Masked and highlighted UMAP (or any 2D embedding) plots.
These highlight a subset of cells (e.g. one lineage) against the rest of an
atlas, which is a common need when illustrating where a population of
interest sits within a larger dataset.
"""
from __future__ import annotations
import math
from collections.abc import Sequence
from pathlib import Path
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import scanpy as sc
from matplotlib.colors import to_rgb
from scipy.interpolate import RegularGridInterpolator
from scipy.ndimage import gaussian_filter
from ._config import PlotConfig
from ._palettes import colors_list
from ._style import save_figure
from ._utils import ensure_categorical, require_columns
__all__ = [
"masked_umap",
"masked_umap_highlight",
"gene_expression_umap",
"gene_coexpression_umap",
"embedding_density",
]
def _figure_dir(output_dir, figure_name: str) -> Path:
dir_name = figure_name.replace(" ", "_")
figure_dir = Path(output_dir) / dir_name
figure_dir.mkdir(parents=True, exist_ok=True)
return figure_dir
[docs]
def masked_umap(
adata,
color_by: str,
figure_name: str,
mask_column: str | None = None,
mask_values: Sequence | None = None,
palette: dict | None = None,
config: PlotConfig | None = None,
output_dir: str | Path = "figures",
basis: str = "umap",
show: bool = False,
**kwargs,
):
"""Plot a UMAP colored by ``color_by``, restricted to ``mask_values``.
Cells outside ``mask_values`` are greyed out. Saves three variants: no
legend, with legend, and a version subset to only the masked cells.
Parameters
----------
adata:
Annotated data with a computed embedding (``adata.obsm['X_<basis>']``).
color_by:
``adata.obs`` column to color cells by.
figure_name:
Used as both plot title and output subdirectory name.
mask_column:
Column whose unique values become ``mask_values`` if not given
explicitly. Only used as a default source for ``mask_values``.
mask_values:
Categories of ``color_by`` to keep visible. Defaults to all unique
values of ``mask_column`` (or of ``color_by`` if neither is set).
palette:
Optional ``{category: color}`` mapping. Falls back to the config's
registered palette for ``color_by``, then to an auto-generated one.
config:
Active :class:`~scplotkit.PlotConfig`. Uses defaults if omitted.
output_dir:
Directory figures are written under.
show:
Display each figure inline (e.g. in a Jupyter notebook) as it's
drawn. Scanpy closes the figure right after saving it, so without
this the plots are written to disk but never rendered in the cell.
**kwargs:
Forwarded to every ``sc.pl.embedding`` call, overriding this
function's own defaults (e.g. ``na_color``, ``frameon``, ``size``) --
anything the underlying scanpy function accepts.
"""
config = PlotConfig.load(config)
require_columns(adata, color_by, mask_column)
figure_dir = _figure_dir(output_dir, figure_name)
sc.settings.figdir = str(figure_dir)
ensure_categorical(adata, color_by)
if mask_values is None:
source = mask_column or color_by
mask_values = adata.obs[source].unique().tolist()
color_col_filtered = f"{color_by}_filtered"
adata.obs[color_col_filtered] = adata.obs[color_by].astype(str)
adata.obs.loc[~adata.obs[color_by].isin(mask_values), color_col_filtered] = np.nan
categories = list(mask_values)
adata.obs[color_col_filtered] = pd.Categorical(adata.obs[color_col_filtered], categories=categories)
adata.uns[f"{color_col_filtered}_colors"] = colors_list(config, color_by, mask_values, palette)
common_kwargs = dict(
basis=basis,
show=show,
na_color="white",
outline_width=(0.1, 0.05),
add_outline=True,
title=figure_name,
frameon=False,
)
sc.pl.embedding(
adata,
**{
**common_kwargs,
"color": color_col_filtered,
"legend_loc": None,
"save": f"_{figure_dir.name}_masked_colored_{color_by}.png",
**kwargs,
},
)
sc.pl.embedding(
adata,
**{
**common_kwargs,
"color": color_col_filtered,
"na_in_legend": False,
"save": f"_{figure_dir.name}_masked_colored_{color_by}_legend.png",
**kwargs,
},
)
adata_subset = adata[adata.obs[color_by].isin(mask_values)].copy()
adata_subset.obs[color_by] = adata_subset.obs[color_by].cat.remove_unused_categories()
adata_subset.uns[f"{color_by}_colors"] = colors_list(
config, color_by, list(adata_subset.obs[color_by].cat.categories), palette
)
sc.pl.embedding(
adata_subset,
**{
**common_kwargs,
"color": color_by,
"na_in_legend": False,
"save": f"_{figure_dir.name}_subset_only_{color_by}_legend.png",
**kwargs,
},
)
[docs]
def masked_umap_highlight(
adata,
color_by: str,
figure_name: str,
mask_column: str | None = None,
mask_values: Sequence | None = None,
highlight_size: float = 1.5,
background_size: float = 0.25,
ordered: bool = True,
palette: dict | None = None,
config: PlotConfig | None = None,
output_dir: str | Path = "figures",
basis: str = "umap",
show: bool = False,
**kwargs,
):
"""Like :func:`masked_umap`, but draws the masked population enlarged on top
of a small greyed-out background rather than hiding the rest entirely.
``show`` displays each figure inline (e.g. in a Jupyter notebook) as it's
drawn -- scanpy closes the figure right after saving it, so without this
the plots are written to disk but never rendered in the cell.
``**kwargs`` are forwarded to every ``sc.pl.embedding`` call, overriding
this function's own defaults -- anything the underlying scanpy function accepts.
"""
config = PlotConfig.load(config)
require_columns(adata, color_by, mask_column)
figure_dir = _figure_dir(output_dir, figure_name)
sc.settings.figdir = str(figure_dir)
ensure_categorical(adata, color_by)
if mask_values is None:
source = mask_column or color_by
mask_values = adata.obs[source].unique().tolist()
color_col_filtered = f"{color_by}_filtered"
adata.obs[color_col_filtered] = adata.obs[color_by].astype(str)
adata.obs.loc[~adata.obs[color_by].isin(mask_values), color_col_filtered] = np.nan
size_col = f"{color_col_filtered}_sizes"
adata.obs[size_col] = background_size
adata.obs.loc[~pd.isna(adata.obs[color_col_filtered]), size_col] = highlight_size
value_counts = adata.obs[color_by].value_counts()
mask_values_ordered = sorted(mask_values, key=lambda v: value_counts.get(v, 0), reverse=True)
adata.obs[color_col_filtered] = pd.Categorical(adata.obs[color_col_filtered], categories=mask_values_ordered)
adata.uns[f"{color_col_filtered}_colors"] = colors_list(config, color_by, mask_values_ordered, palette)
plot_adata = adata
if ordered:
sort_idx = adata.obs[color_col_filtered].cat.codes.argsort(kind="stable")
plot_adata = adata[sort_idx, :]
suffix = f"{color_by}_{highlight_size}_highlight"
common_kwargs = dict(
basis=basis,
color=color_col_filtered,
size=plot_adata.obs[size_col],
na_color="white",
outline_width=(0.1, 0.05),
add_outline=True,
sort_order=False,
show=show,
title=figure_name,
frameon=False,
)
sc.pl.embedding(
plot_adata,
**{
**common_kwargs,
"legend_loc": None,
"save": f"_{figure_dir.name}_masked_colored_{suffix}.png",
**kwargs,
},
)
sc.pl.embedding(
plot_adata,
**{
**common_kwargs,
"na_in_legend": False,
"save": f"_{figure_dir.name}_masked_colored_{suffix}_legend.png",
**kwargs,
},
)
def _expression_values(adata, gene: str, use_raw: bool, layer: str | None) -> np.ndarray:
if layer:
raw = adata[:, gene].layers[layer]
elif use_raw and adata.raw is not None:
raw = adata.raw[:, gene].X
else:
raw = adata[:, gene].X
return raw.toarray().flatten() if hasattr(raw, "toarray") else np.asarray(raw).flatten()
[docs]
def gene_expression_umap(
adata,
gene: str,
mask_column: str | None = None,
mask_values: Sequence | None = None,
figure_name: str | None = None,
use_raw: bool = True,
layer: str | None = None,
cmap: str = "magma",
vmin: float | None = 0,
vmax: float | None = None,
config: PlotConfig | None = None,
output_dir: str | Path = "figures",
basis: str = "umap",
show: bool = False,
**kwargs,
):
"""Plot a gene's expression on an embedding, optionally masked to a subset of cells.
Cells outside ``mask_values`` (if given) are greyed out rather than
dropped, so the full embedding shape is preserved. Saves two variants of
the plot: without and with a colorbar.
Parameters
----------
adata:
Annotated data with a computed embedding (``adata.obsm['X_<basis>']``).
gene:
Gene to plot, looked up in ``layer``, ``adata.raw`` (if ``use_raw``),
or ``adata.X``, in that order of precedence.
mask_column, mask_values:
Restrict colored (non-grey) cells to those where ``mask_column`` is
in ``mask_values``. Both must be given to enable masking.
figure_name:
Used as both plot title and output subdirectory name. Defaults to ``gene``.
config:
Active :class:`~scplotkit.PlotConfig`. Uses defaults if omitted.
output_dir:
Directory figures are written under.
show:
Display each figure inline (e.g. in a Jupyter notebook) as it's drawn.
**kwargs:
Forwarded to both ``sc.pl.embedding`` calls, overriding this
function's own defaults -- anything the underlying scanpy function accepts.
"""
config = PlotConfig.load(config)
require_columns(adata, mask_column)
figure_dir = _figure_dir(output_dir, figure_name or gene)
sc.settings.figdir = str(figure_dir)
tmp_col = f"_tmp_expr_{gene}"
adata.obs[tmp_col] = _expression_values(adata, gene, use_raw, layer)
suffix = "all_cells"
if mask_column and mask_values:
suffix = f"masked_{mask_column}"
mask = ~adata.obs[mask_column].isin(mask_values)
adata.obs.loc[mask, tmp_col] = np.nan
print(f"Masking applied: showing {gene} only in {mask_values}")
plot_kwargs = {
"basis": basis,
"color": tmp_col,
"title": figure_name or gene,
"frameon": False,
"cmap": cmap,
"vmin": vmin,
"vmax": vmax,
"na_color": "#e0e0e0",
"add_outline": True,
"outline_width": (0.1, 0.05),
"show": show,
**kwargs,
}
try:
sc.pl.embedding(
adata,
**{**plot_kwargs, "colorbar_loc": None, "save": f"_{figure_dir.name}_{gene}_{suffix}.png"},
)
sc.pl.embedding(
adata,
**{
**plot_kwargs,
"colorbar_loc": "right",
"save": f"_{figure_dir.name}_{gene}_{suffix}_with_colorbar.png",
},
)
finally:
del adata.obs[tmp_col]
def _normalized_expression(values: np.ndarray, vmax_percentile: float) -> np.ndarray:
"""Scale expression to [0, 1], clipping at the given percentile over expressing cells
so a handful of outliers don't compress the rest of the scale to near-zero."""
positive = values[values > 0]
vmax = np.percentile(positive, vmax_percentile) if positive.size else 0.0
if vmax <= 0:
vmax = 1.0
return np.clip(values / vmax, 0, 1)
[docs]
def gene_coexpression_umap(
adata,
gene1: str,
gene2: str,
color1: str = "#e6299e",
color2: str = "#3cb428",
mask_column: str | None = None,
mask_values: Sequence | None = None,
figure_name: str | None = None,
use_raw: bool = True,
layer: str | None = None,
vmax_percentile: float = 99.0,
threshold: float = 0.05,
grey_color: str | None = None,
point_size: float | None = None,
add_outline: bool = True,
outline_width: tuple[float, float] = (0.1, 0.05),
outline_color: tuple[str, str] = ("black", "white"),
show_legend: bool = True,
config: PlotConfig | None = None,
output_dir: str | Path = "figures",
basis: str = "umap",
show: bool = False,
):
"""Bivariate color-blend UMAP showing where two markers co-occur.
Each gene is normalized to [0, 1] (clipped at ``vmax_percentile`` to avoid
a few outlier cells compressing the rest of the scale), then blended
additively into a single RGB color per cell -- with the default
magenta/green colors, ``gene1``-only cells read magenta, ``gene2``-only
cells read green, and co-expressing cells read near-white. Magenta/green
is used instead of the traditional red/green overlay because red-green
color blindness (the most common form) makes that pairing indistinguishable;
magenta's blue component keeps it readable. Cells below ``threshold`` in
both genes are drawn flat grey, matching the below-threshold convention
used elsewhere in this module (see :func:`embedding_density`) rather than
showing dozens of near-black dots.
This is a purely descriptive visualization -- no statistical test for
co-expression is performed; ``threshold`` only controls what counts as
"on" for coloring purposes.
Parameters
----------
adata:
Annotated data with a computed embedding (``adata.obsm['X_<basis>']``).
gene1, gene2:
Genes to blend, looked up in ``layer``, ``adata.raw`` (if
``use_raw``), or ``adata.X``, in that order of precedence.
color1, color2:
Colors assigned to ``gene1``-only and ``gene2``-only cells. Colors
combine additively, so the default magenta/green produce a near-white
overlap; pick two colors with non-overlapping channels (e.g. a pure
magenta and a pure green) for the cleanest three-way read, and avoid
red/green -- the two are indistinguishable under red-green color
blindness.
mask_column, mask_values:
Restrict colored (non-grey) cells to those where ``mask_column`` is
in ``mask_values``. Both must be given to enable masking.
figure_name:
Used as both plot title and output subdirectory name. Defaults to
``"{gene1}_{gene2}_coexpression"``.
vmax_percentile:
Upper percentile (over cells with nonzero expression) each gene is
clipped to before normalizing to [0, 1].
threshold:
Normalized expression (0-1, per gene) below which a cell counts as
"off" for that gene. Cells "off" for both genes are drawn grey.
grey_color:
Fill color for cells below ``threshold`` in both genes. Defaults to
the config's ``general.fallback_color``.
point_size:
Marker area per cell. Defaults to scanpy's own ``120000 / n_obs``
heuristic so it scales sensibly with atlas size.
add_outline:
Draw each point with a thin ring (as ``sc.pl.embedding``'s own
``add_outline`` does) so cells stand out from the background and from
each other. On by default since the additive color blend otherwise
makes dense, similarly-colored regions hard to resolve into
individual cells.
outline_width, outline_color:
Same meaning as the identically-named ``sc.pl.embedding`` params:
``outline_width`` is ``(outline_size, gap_size)`` as a fraction of
the point radius, ``outline_color`` is ``(outline_color, gap_color)``.
show_legend:
Draw a small bivariate color-key inset showing how the blend maps to
each gene's expression level.
config:
Active :class:`~scplotkit.PlotConfig`. Uses defaults if omitted.
output_dir:
Directory figures are written under.
show:
Display the figure inline (e.g. in a Jupyter notebook) as it's drawn.
"""
config = PlotConfig.load(config)
require_columns(adata, mask_column)
figure_name = figure_name or f"{gene1}_{gene2}_coexpression"
dir_name = figure_name.replace(" ", "_")
norm1 = _normalized_expression(_expression_values(adata, gene1, use_raw, layer), vmax_percentile)
norm2 = _normalized_expression(_expression_values(adata, gene2, use_raw, layer), vmax_percentile)
on_mask = (norm1 >= threshold) | (norm2 >= threshold)
if mask_column and mask_values:
on_mask &= adata.obs[mask_column].isin(mask_values).to_numpy()
print(f"Masking applied: showing {gene1}/{gene2} co-expression only in {mask_values}")
rgb1, rgb2 = np.array(to_rgb(color1)), np.array(to_rgb(color2))
grey_color = grey_color or config.general.get("fallback_color", "lightgrey")
colors = np.tile(np.array(to_rgb(grey_color)), (adata.n_obs, 1))
blended = np.clip(np.outer(norm1, rgb1) + np.outer(norm2, rgb2), 0, 1)
colors[on_mask] = blended[on_mask]
coords = adata.obsm[f"X_{basis}"][:, :2]
point_size = point_size if point_size is not None else max(120000 / adata.n_obs, 3.0)
# draw grey (off/masked-out) cells first, colored cells on top so the signal isn't buried
order = np.argsort(on_mask.astype(int), kind="stable")
general = config.general
# Extra figure width reserves room for the legend outside the plot without
# shrinking the UMAP itself; the main axes gets a fixed position so
# tight_layout (which mishandles axes anchored outside [0, 1]) never touches it.
fig = plt.figure(figsize=(7.2, 6))
ax = fig.add_axes((0.03, 0.05, 0.74, 0.88))
if add_outline:
# Same construction as scanpy's own add_outline: two flat-colored rings
# (radius grown outward from the point radius by outline_width, as
# fractions of it) drawn behind the point, largest first, so a
# bg_color ring shows with a thin gap_color gap before the point.
bg_width, gap_width = outline_width
bg_color, gap_color = outline_color
point_radius = np.sqrt(point_size)
bg_size = (point_radius + point_radius * bg_width * 2) ** 2
gap_size = (point_radius + point_radius * gap_width * 2) ** 2
ax.scatter(coords[order, 0], coords[order, 1], s=bg_size, c=bg_color, linewidths=0, rasterized=True)
ax.scatter(coords[order, 0], coords[order, 1], s=gap_size, c=gap_color, linewidths=0, rasterized=True)
ax.scatter(coords[order, 0], coords[order, 1], c=colors[order], s=point_size, linewidths=0, rasterized=True)
ax.set_xticks([])
ax.set_yticks([])
for spine in ax.spines.values():
spine.set_visible(False)
ax.set_title(figure_name, fontsize=general["title_fontsize"], fontweight=general["title_fontweight"])
if show_legend:
legend_ax = fig.add_axes((0.82, 0.09, 0.15, 0.15))
grid_n = 60
g1, g2 = np.meshgrid(np.linspace(0, 1, grid_n), np.linspace(0, 1, grid_n))
legend_rgb = np.clip(g1[..., None] * rgb1 + g2[..., None] * rgb2, 0, 1)
legend_ax.imshow(legend_rgb, origin="lower", extent=(0, 1, 0, 1), aspect="auto")
legend_ax.set_xticks([])
legend_ax.set_yticks([])
for spine in legend_ax.spines.values():
spine.set_color("black")
spine.set_linewidth(1.0)
legend_ax.set_xlabel(gene1, fontsize=7, color=color1, fontweight="bold", labelpad=2)
legend_ax.set_ylabel(gene2, fontsize=7, color=color2, fontweight="bold", labelpad=2)
save_figure(fig, output_dir, dir_name, f"{dir_name}_{gene1}_{gene2}_coexpression.png", config)
if show:
plt.show()
return fig
def _fast_kde_grid(x: np.ndarray, y: np.ndarray, grid_size: int, xlim: tuple, ylim: tuple, bw_scale: float):
"""Approximate a 2D Gaussian KDE in O(n) via binning + blur instead of O(n^2) pairwise evaluation.
``scipy.stats.gaussian_kde`` (what ``sc.tl.embedding_density`` uses)
evaluates the kernel at every point against every other point, which is
quadratic in cell count and becomes unusable well before atlas scale (tens
of thousands of cells per group). Binning onto a fixed grid and blurring
with a Gaussian filter gives a visually equivalent smooth density surface
in time that only depends on ``grid_size``, not on the number of cells.
"""
xedges = np.linspace(xlim[0], xlim[1], grid_size + 1)
yedges = np.linspace(ylim[0], ylim[1], grid_size + 1)
hist, _, _ = np.histogram2d(x, y, bins=[xedges, yedges])
n = len(x)
bw_factor = bw_scale * (n ** (-1.0 / 6.0)) if n > 1 else 1.0 # Scott's rule for a 2D KDE
bin_w_x = (xlim[1] - xlim[0]) / grid_size
bin_w_y = (ylim[1] - ylim[0]) / grid_size
sigma_x = max((bw_factor * np.std(x)) / bin_w_x, 0.5)
sigma_y = max((bw_factor * np.std(y)) / bin_w_y, 0.5)
smoothed = gaussian_filter(hist, sigma=(sigma_x, sigma_y))
smoothed -= smoothed.min()
peak = smoothed.max()
if peak > 0:
smoothed /= peak
xcenters = (xedges[:-1] + xedges[1:]) / 2
ycenters = (yedges[:-1] + yedges[1:]) / 2
return smoothed, xcenters, ycenters
[docs]
def embedding_density(
adata,
groupby: str | None = None,
groups: Sequence[str] | None = None,
figure_name: str | None = None,
basis: str = "umap",
grid_size: int = 200,
bw_scale: float = 0.5,
density_threshold: float = 0.01,
cmap: str = "YlOrBr",
point_size: float | None = None,
grey_color: str | None = None,
grey_alpha: float = 1.0,
ncols: int = 4,
panel_size: tuple = (4.5, 4.5),
key_added: str | None = None,
config: PlotConfig | None = None,
output_dir: str | Path = "figures",
save_name: str = "embedding_density",
show: bool = False,
):
"""Fancy, fast per-cell density plot over an embedding, optionally per group.
Every cell is drawn as a dot and every dot is filled in -- colored smoothly
and continuously by its local density (no discrete contour bands, no
outline layer sitting on top, nothing colored outside of where cells
actually are): cells below ``density_threshold`` are flat grey (no signal
there), the rest are colored along ``cmap`` by how dense the embedding is
at that exact point.
When ``groupby`` is given, one independently-scaled panel is drawn per
category, on shared axis limits so panels stay visually comparable --
each panel colors *every* cell by that category's density field, so the
full embedding shape stays visible with the category's hotspots picked
out in color.
Unlike :func:`scanpy.tl.embedding_density` (which evaluates
``scipy.stats.gaussian_kde`` pairwise -- O(n^2) in cell count, and
impractically slow for atlas-scale groups), this bins cells onto a fixed
grid and smooths with a Gaussian filter -- O(n + grid_size^2), so runtime
stays flat as cell counts grow. Densities are also written to
``adata.obs[key_added]`` (scaled 0-1 within each group, evaluated at each
cell's own position), mirroring scanpy's output so downstream code can
rely on either.
Parameters
----------
adata:
Annotated data with a computed embedding (``adata.obsm['X_<basis>']``).
groupby:
``adata.obs`` column to compute one density panel per category.
Omit for a single overall density panel.
groups:
Subset/order of categories to plot when ``groupby`` is given.
Defaults to all categories.
figure_name:
Used as the figure's suptitle. Defaults to a description of ``basis``
(and ``groupby``, if given).
grid_size:
Resolution of the density grid per axis. Higher is smoother but
slower; 200 is a good default even for very large atlases.
bw_scale:
Multiplier on the Scott's-rule bandwidth; increase to smooth the
density surface further, decrease to sharpen it.
density_threshold:
Scaled density (0-1) below which a cell is drawn flat grey instead
of colored.
point_size:
Marker area per cell. Defaults to scanpy's own ``120000 / n_obs``
heuristic so it scales sensibly with atlas size.
grey_color:
Fill color for cells below ``density_threshold``. Defaults to the
config's ``general.fallback_color``.
grey_alpha:
Opacity of the below-threshold background cells, so the colored
density signal on top stays the visual focus.
key_added:
``adata.obs`` column the per-cell scaled density is written to.
Defaults to ``f"{basis}_density"`` or ``f"{basis}_density_{groupby}"``.
config:
Active :class:`~scplotkit.PlotConfig`. Uses defaults if omitted.
output_dir:
Directory figures are written under.
show:
Display the figure inline (e.g. in a Jupyter notebook) as it's drawn.
"""
config = PlotConfig.load(config)
require_columns(adata, groupby)
coords = adata.obsm[f"X_{basis}"][:, :2]
all_x, all_y = coords[:, 0], coords[:, 1]
all_points = np.column_stack([all_x, all_y])
pad_x = (all_x.max() - all_x.min()) * 0.05 or 1.0
pad_y = (all_y.max() - all_y.min()) * 0.05 or 1.0
xlim = (all_x.min() - pad_x, all_x.max() + pad_x)
ylim = (all_y.min() - pad_y, all_y.max() + pad_y)
point_size = point_size if point_size is not None else max(120000 / adata.n_obs, 3.0)
grey_color = grey_color or config.general.get("fallback_color", "lightgrey")
if groupby:
ensure_categorical(adata, groupby)
panels = list(groups) if groups is not None else list(adata.obs[groupby].cat.categories)
else:
panels = ["All cells"]
key_added = key_added or (f"{basis}_density_{groupby}" if groupby else f"{basis}_density")
density_values = np.zeros(adata.n_obs)
ncols_eff = min(ncols, len(panels))
nrows = math.ceil(len(panels) / ncols_eff)
fig, axes = plt.subplots(
nrows, ncols_eff, figsize=(panel_size[0] * ncols_eff, panel_size[1] * nrows), squeeze=False
)
last_cs = None
for i, panel in enumerate(panels):
ax = axes[i // ncols_eff][i % ncols_eff]
mask = np.ones(adata.n_obs, dtype=bool) if not groupby else (adata.obs[groupby] == panel).to_numpy()
px, py = all_x[mask], all_y[mask]
if mask.sum() >= 5:
smoothed, xcenters, ycenters = _fast_kde_grid(px, py, grid_size, xlim, ylim, bw_scale)
interp = RegularGridInterpolator((xcenters, ycenters), smoothed, bounds_error=False, fill_value=0.0)
density_all_cells = np.clip(interp(all_points), 0.0, 1.0)
else:
density_all_cells = np.zeros(adata.n_obs)
density_all_cells[mask] = 1.0
density_values[mask] = density_all_cells[mask]
# Grey backdrop for every cell, drawn under the colored ones regardless of
# density_threshold -- with real (non-uniform) embeddings the vast majority
# of cells clear a low threshold, so gating the backdrop on it left it
# empty in practice. Sized larger than the foreground point so a ring of
# it stays visible around every colored cell too, instead of being fully
# hidden underneath (an alpha on the colored points would do that too, but
# by blending into the density colormap it would misrepresent the values).
ax.scatter(
all_x, all_y, s=point_size * 2.6, c=grey_color, alpha=grey_alpha,
linewidths=0, zorder=1, rasterized=True,
)
colored_idx = np.where(density_all_cells >= density_threshold)[0]
if len(colored_idx) > 0:
order = colored_idx[np.argsort(density_all_cells[colored_idx])] # highest density drawn on top
last_cs = ax.scatter(
all_x[order], all_y[order], s=point_size, c=density_all_cells[order], cmap=cmap,
vmin=density_threshold, vmax=1.0, linewidths=0, zorder=4, rasterized=True,
)
ax.set_xlim(xlim)
ax.set_ylim(ylim)
ax.set_xticks([])
ax.set_yticks([])
for spine in ax.spines.values():
spine.set_visible(False)
ax.set_title(str(panel), fontsize=config.general["title_fontsize"], fontweight="bold")
for j in range(len(panels), nrows * ncols_eff):
axes[j // ncols_eff][j % ncols_eff].axis("off")
adata.obs[key_added] = density_values
# Reserve a fixed vertical band for the suptitle so it doesn't collide with the per-panel
# titles sitting right at the top of each axes -- the default subplot top margin doesn't
# leave room for a second line of (bold, larger) title text above them.
fig_height_in = panel_size[1] * nrows
fig.subplots_adjust(top=1 - min(0.6 / fig_height_in, 0.25))
suptitle = figure_name or (f"Cell density by {groupby} ({basis})" if groupby else f"Cell density ({basis})")
fig.suptitle(suptitle, fontsize=config.general["title_fontsize"] + 2, fontweight="bold")
if last_cs is not None:
fig.colorbar(
last_cs, ax=axes.ravel().tolist(), shrink=0.6, pad=0.02, label="Relative density (scaled per panel)",
)
if save_name is not None:
save_figure(fig, output_dir, "embedding_density", f"{save_name}.png", config)
if show:
plt.show()
return fig