"""
QC plots: 2x2 metric grid, 2x4 pre vs post, multipage spatial QC, and Scanpy-style
violin, scatter, HVG, and SVG plots.
- 2x2 grid: total_counts, n_genes_by_counts, log1p(total_counts), pct_counts_mt.
- 2x4 pre/post: left 2x2 = pre-filter metrics, right 2x2 = post-filter.
- Multipage spatial: one page per sample (total_count, log1p, % mt); common_scale=True.
- qc_violin_metrics: multi-panel violin for n_genes_by_counts, total_counts, pct_counts_mt.
- qc_scatter_counts_genes: scatter total_counts vs n_genes_by_counts colored by pct_counts_mt.
- plot_highly_variable_genes: mean vs dispersion with HVG highlighted.
- plot_spatially_variable_genes: scatter mean/rank vs Moran's I, colored by spatially_variable/pval.
Save under project figures/QC/raw/ (pre) or figures/QC/post/ (post).
"""
from __future__ import annotations
from pathlib import Path
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from anndata import AnnData
__all__ = [
"qc_2x2_grid",
"qc_2x4_pre_post",
"qc_spatial_multipage",
"qc_violin_metrics",
"qc_scatter_counts_genes",
"plot_highly_variable_genes",
"plot_spatially_variable_genes",
"qc_sample_comparison_bar",
"qc_sample_violin_grouped",
"qc_sample_scatter_matrix",
"qc_pct_mt_per_sample",
]
[docs]
def qc_2x2_grid(
adata: AnnData,
*,
total_counts_col: str = "total_counts",
n_genes_col: str = "n_genes_by_counts",
pct_mt_col: str = "pct_counts_mt",
output_dir: str | Path | None = None,
basename: str = "qc_2x2",
dpi: int = 300,
figsize: tuple[float, float] = (10, 10),
modality: str = "visium",
) -> plt.Figure:
"""
Plot 2x2 QC grid: total_counts, n_genes, log1p(total_counts), pct_counts_mt.
Panels: (1,1) total_counts histogram, (1,2) n_genes_by_counts histogram,
(2,1) log1p(total_counts) histogram, (2,2) pct_counts_mt histogram if present,
else log1p(n_genes_by_counts).
Parameters
----------
adata : AnnData
Annotated data with obs containing total_counts and n_genes_by_counts
(from calculate_qc_metrics). pct_counts_mt optional.
total_counts_col : str
Obs column for total counts (default 'total_counts').
n_genes_col : str
Obs column for number of genes (default 'n_genes_by_counts').
pct_mt_col : str
Obs column for percent mitochondrial (default 'pct_counts_mt').
output_dir : str or Path or None
If set, save PDF and PNG here (default None).
basename : str
Base name for files (default 'qc_2x2').
dpi : int
DPI for PNG (default 300).
figsize : tuple
Figure size (default (10, 10)).
Returns
-------
matplotlib.figure.Figure
The figure (caller may show or save).
"""
from .report_utils import get_modality_terms
terms = get_modality_terms(modality)
fig, axes = plt.subplots(2, 2, figsize=figsize)
for ax in axes.flat:
ax.set_visible(True)
# (1,1) total_counts
_intensity_title = terms["intensity_label"]
_feat_per_obs = f"{terms['features']} per {terms['observation_lower']}"
if total_counts_col in adata.obs.columns:
x = adata.obs[total_counts_col].values
x = x[~np.isnan(x) & np.isfinite(x)]
axes[0, 0].hist(
x, bins=min(80, max(20, len(x) // 20)), color="steelblue", edgecolor="white"
)
axes[0, 0].set_title(_intensity_title, fontsize=12, fontweight="bold")
axes[0, 0].set_xlabel(total_counts_col)
else:
axes[0, 0].text(
0.5,
0.5,
f"{total_counts_col} not in obs",
ha="center",
va="center",
transform=axes[0, 0].transAxes,
)
axes[0, 0].set_title(_intensity_title, fontsize=12, fontweight="bold")
# (1,2) n_genes_by_counts
if n_genes_col in adata.obs.columns:
x = adata.obs[n_genes_col].values
x = x[~np.isnan(x) & np.isfinite(x)]
axes[0, 1].hist(x, bins=min(80, max(20, len(x) // 20)), color="coral", edgecolor="white")
axes[0, 1].set_title(_feat_per_obs, fontsize=12, fontweight="bold")
axes[0, 1].set_xlabel(n_genes_col)
else:
axes[0, 1].text(
0.5,
0.5,
f"{n_genes_col} not in obs",
ha="center",
va="center",
transform=axes[0, 1].transAxes,
)
axes[0, 1].set_title(_feat_per_obs, fontsize=12, fontweight="bold")
# (2,1) log1p(total_counts)
if total_counts_col in adata.obs.columns:
x = np.log1p(adata.obs[total_counts_col].values.astype(float))
x = x[~np.isnan(x) & np.isfinite(x)]
axes[1, 0].hist(
x, bins=min(80, max(20, len(x) // 20)), color="seagreen", edgecolor="white", alpha=0.8
)
axes[1, 0].set_title("log1p(total counts)", fontsize=12, fontweight="bold")
axes[1, 0].set_xlabel("log1p(total_counts)")
else:
axes[1, 0].set_visible(False)
# (2,2) pct_counts_mt or log1p(n_genes)
if pct_mt_col in adata.obs.columns:
x = adata.obs[pct_mt_col].values
x = x[~np.isnan(x) & np.isfinite(x)]
axes[1, 1].hist(
x, bins=min(80, max(20, len(x) // 20)), color="purple", edgecolor="white", alpha=0.7
)
axes[1, 1].set_title("% mitochondrial", fontsize=12, fontweight="bold")
axes[1, 1].set_xlabel(pct_mt_col)
elif n_genes_col in adata.obs.columns:
x = np.log1p(adata.obs[n_genes_col].values.astype(float))
x = x[~np.isnan(x) & np.isfinite(x)]
axes[1, 1].hist(
x, bins=min(80, max(20, len(x) // 20)), color="gray", edgecolor="white", alpha=0.7
)
axes[1, 1].set_title(
f"log1p({terms['features_lower']} per {terms['observation_lower']})",
fontsize=12,
fontweight="bold",
)
axes[1, 1].set_xlabel("log1p(n_genes_by_counts)")
else:
axes[1, 1].text(
0.5,
0.5,
"No pct_mt or n_genes",
ha="center",
va="center",
transform=axes[1, 1].transAxes,
)
axes[1, 1].set_title("(optional)", fontsize=12, fontweight="bold")
plt.tight_layout()
if output_dir is not None:
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
fig.savefig(output_dir / f"{basename}.pdf", bbox_inches="tight", dpi=dpi)
fig.savefig(output_dir / f"{basename}.png", bbox_inches="tight", dpi=dpi)
return fig
[docs]
def qc_2x4_pre_post(
adata_pre: AnnData,
adata_post: AnnData,
*,
total_counts_col: str = "total_counts",
n_genes_col: str = "n_genes_by_counts",
pct_mt_col: str = "pct_counts_mt",
output_dir: str | Path | None = None,
basename: str = "qc_2x4_pre_post",
dpi: int = 300,
figsize: tuple[float, float] = (16, 10),
modality: str = "visium",
) -> plt.Figure:
"""
Plot pre- vs post-filter QC: 2 rows x 4 columns.
Left 2x2 = pre-filter (raw) metrics; right 2x2 = post-filter metrics.
Use this so post-filter distributions (e.g. after filter_cells/filter_genes)
are directly comparable to pre.
Panels: row0 = total_counts (pre), n_genes (pre) | total_counts (post), n_genes (post);
row1 = log1p(total_counts) (pre), pct_mt (pre) | log1p(total_counts) (post), pct_mt (post).
Parameters
----------
adata_pre : AnnData
Pre-filter (raw) adata with total_counts, n_genes_by_counts (and optionally pct_counts_mt).
adata_post : AnnData
Post-filter (and optionally normalized) adata. Should have been filtered so that
n_obs and metric distributions differ from pre. Must have same obs column names.
total_counts_col : str
Obs column for total counts (default 'total_counts').
n_genes_col : str
Obs column for number of genes (default 'n_genes_by_counts').
pct_mt_col : str
Obs column for percent mitochondrial (default 'pct_counts_mt').
output_dir : str or Path or None
If set, save PDF and PNG here (default None).
basename : str
Base name for files (default 'qc_2x4_pre_post').
dpi : int
DPI for PNG (default 300).
figsize : tuple
Figure size (default (16, 10)).
Returns
-------
matplotlib.figure.Figure
The figure.
"""
from .report_utils import get_modality_terms
terms = get_modality_terms(modality)
_feat_per_obs = f"{terms['features']} per {terms['observation_lower']}"
fig, axes = plt.subplots(2, 4, figsize=figsize)
def _draw_hist(ax, x, color, title, xlabel):
x = np.asarray(x, dtype=float)
x = x[~np.isnan(x) & np.isfinite(x)]
if len(x) == 0:
ax.text(0.5, 0.5, "No data", ha="center", va="center", transform=ax.transAxes)
else:
ax.hist(x, bins=min(80, max(20, len(x) // 20)), color=color, edgecolor="white")
ax.set_title(title, fontsize=11, fontweight="bold")
ax.set_xlabel(xlabel, fontsize=9)
# Pre: (0,0) total_counts, (0,1) n_genes, (1,0) log1p(total_counts), (1,1) pct_mt
if total_counts_col in adata_pre.obs.columns:
_draw_hist(
axes[0, 0],
adata_pre.obs[total_counts_col].values,
"steelblue",
"Pre: Total counts",
total_counts_col,
)
_draw_hist(
axes[1, 0],
np.log1p(adata_pre.obs[total_counts_col].values.astype(float)),
"seagreen",
"Pre: log1p(total counts)",
"log1p(total_counts)",
)
else:
axes[0, 0].set_title("Pre: Total counts (missing)", fontsize=11)
axes[1, 0].set_title("Pre: log1p (missing)", fontsize=11)
if n_genes_col in adata_pre.obs.columns:
_draw_hist(
axes[0, 1],
adata_pre.obs[n_genes_col].values,
"coral",
f"Pre: {_feat_per_obs}",
n_genes_col,
)
else:
axes[0, 1].set_title("Pre: n_genes (missing)", fontsize=11)
if pct_mt_col in adata_pre.obs.columns:
_draw_hist(
axes[1, 1],
adata_pre.obs[pct_mt_col].values,
"purple",
"Pre: % mitochondrial",
pct_mt_col,
)
else:
axes[1, 1].set_title("Pre: % mt (missing)", fontsize=11)
# Post: (0,2) total_counts, (0,3) n_genes, (1,2) log1p(total_counts), (1,3) pct_mt
if total_counts_col in adata_post.obs.columns:
_draw_hist(
axes[0, 2],
adata_post.obs[total_counts_col].values,
"steelblue",
"Post: Total counts",
total_counts_col,
)
_draw_hist(
axes[1, 2],
np.log1p(adata_post.obs[total_counts_col].values.astype(float)),
"seagreen",
"Post: log1p(total counts)",
"log1p(total_counts)",
)
else:
axes[0, 2].set_title("Post: Total counts (missing)", fontsize=11)
axes[1, 2].set_title("Post: log1p (missing)", fontsize=11)
if n_genes_col in adata_post.obs.columns:
_draw_hist(
axes[0, 3],
adata_post.obs[n_genes_col].values,
"coral",
f"Post: {_feat_per_obs}",
n_genes_col,
)
else:
axes[0, 3].set_title("Post: n_genes (missing)", fontsize=11)
if pct_mt_col in adata_post.obs.columns:
_draw_hist(
axes[1, 3],
adata_post.obs[pct_mt_col].values,
"purple",
"Post: % mitochondrial",
pct_mt_col,
)
else:
axes[1, 3].set_title("Post: % mt (missing)", fontsize=11)
fig.suptitle(
"QC: Pre-filter (left) vs Post-filter (right)", fontsize=14, fontweight="bold", y=1.02
)
plt.tight_layout(rect=[0, 0, 1, 0.98])
if output_dir is not None:
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
fig.savefig(output_dir / f"{basename}.pdf", bbox_inches="tight", dpi=dpi)
fig.savefig(output_dir / f"{basename}.png", bbox_inches="tight", dpi=dpi)
return fig
[docs]
def qc_spatial_multipage(
adata: AnnData,
library_id_col: str,
output_path: str | Path,
*,
total_counts_col: str = "total_counts",
pct_mt_col: str = "pct_counts_mt",
figsize: tuple[float, float] = (18, 6),
dpi: int = 300,
common_scale: bool = True,
) -> None:
"""
Multipage spatial QC report: one page per sample with 1x3 panels
(total_count, log1p(total_count), % mt).
When common_scale is True (default), the same vmin/vmax is used for each
metric across all pages so color scales are comparable across samples.
Requires adata.obs[library_id_col], adata.obsm['spatial'], and
adata.uns['spatial'] with per-library images for spatial plots.
Parameters
----------
adata : AnnData
Annotated data with spatial coords and (optionally) H&E in uns['spatial'].
library_id_col : str
Column in adata.obs identifying library/sample.
output_path : str or Path
Path to output PDF (e.g. figures/QC/raw/qc_spatial_multipage.pdf).
total_counts_col : str
Obs column for total counts (default 'total_counts').
pct_mt_col : str
Obs column for percent mitochondrial (default 'pct_counts_mt').
figsize : tuple
Figure size per page (default (18, 6)).
dpi : int
DPI for saved PDF (default 300).
common_scale : bool
If True, use global vmin/vmax (99th percentile) per metric across all
spots so every page uses the same color scale (default True).
"""
if total_counts_col not in adata.obs.columns:
raise ValueError(
f"adata.obs[{total_counts_col!r}] required. Run calculate_qc_metrics first."
)
tc = adata.obs[total_counts_col].values.astype(float)
tc = tc[~np.isnan(tc) & np.isfinite(tc)]
log1p_counts = pd.Series(
np.log1p(adata.obs[total_counts_col].values.astype(float)),
index=adata.obs_names,
)
if common_scale and len(tc) > 0:
vmin_tc, vmax_tc = 0.0, float(np.nanpercentile(tc, 99))
log1p_vals = np.log1p(tc)
vmin_log, vmax_log = 0.0, float(np.nanpercentile(log1p_vals, 99))
if pct_mt_col in adata.obs.columns:
pmt = adata.obs[pct_mt_col].values.astype(float)
pmt = pmt[~np.isnan(pmt) & np.isfinite(pmt)]
vmin_mt, vmax_mt = (
0.0,
float(np.nanpercentile(pmt, 99)) if len(pmt) > 0 else (0.0, 100.0),
)
else:
vmin_mt, vmax_mt = 0.0, 100.0
else:
vmin_tc = vmax_tc = vmin_log = vmax_log = vmin_mt = vmax_mt = None
cmap = "viridis"
panels = [
{
"type": "continuous",
"title": "Total counts",
"obs_col": total_counts_col,
"cmap": cmap,
"vmin": vmin_tc,
"vmax": vmax_tc,
},
{
"type": "continuous",
"title": "log1p(total counts)",
"values": log1p_counts,
"cmap": cmap,
"vmin": vmin_log,
"vmax": vmax_log,
},
]
if pct_mt_col in adata.obs.columns:
panels.append(
{
"type": "continuous",
"title": "% mitochondrial",
"obs_col": pct_mt_col,
"cmap": cmap,
"vmin": vmin_mt,
"vmax": vmax_mt,
}
)
else:
panels.append(
{
"type": "continuous",
"title": "N/A",
"values": pd.Series(0.0, index=adata.obs_names),
"obs_col": "",
"cmap": "gray",
"vmin": 0,
"vmax": 1,
}
)
out_path = Path(output_path)
out_path.parent.mkdir(parents=True, exist_ok=True)
from ..pl import spatial as st_spatial
st_spatial.multipage_spatial_pdf(
adata,
library_id_col,
panels,
str(out_path),
figsize=figsize,
dpi=dpi,
)
[docs]
def qc_violin_metrics(
adata: AnnData,
*,
keys: list[str] | None = None,
groupby: str | None = None,
output_dir: str | Path | None = None,
basename: str = "qc_violin",
dpi: int = 300,
figsize: tuple[float, float] | None = None,
) -> plt.Figure:
"""
Multi-panel violin plot for QC metrics (n_genes_by_counts, total_counts, pct_counts_mt).
Uses scanpy's violin with show=False and captures the figure for saving.
Requires adata.obs columns from calculate_qc_metrics.
Parameters
----------
adata : AnnData
Annotated data with obs containing total_counts, n_genes_by_counts, pct_counts_mt.
keys : list of str or None
Obs columns to plot (default: n_genes_by_counts, total_counts, pct_counts_mt).
groupby : str or None
Optional obs column to stratify violins (e.g. library_id, sample).
output_dir : str or Path or None
If set, save PDF and PNG here.
basename : str
Base name for files (default 'qc_violin').
dpi : int
DPI for PNG (default 300).
figsize : tuple or None
Figure size; if None, scanpy default is used.
Returns
-------
matplotlib.figure.Figure
"""
import scanpy as sc
if keys is None:
keys = ["n_genes_by_counts", "total_counts", "pct_counts_mt"]
keys = [k for k in keys if k in adata.obs.columns]
if not keys:
fig, ax = plt.subplots(figsize=figsize or (6, 4))
ax.text(
0.5,
0.5,
"No QC metric columns in obs",
ha="center",
va="center",
transform=ax.transAxes,
)
if output_dir is not None:
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
fig.savefig(output_dir / f"{basename}.pdf", bbox_inches="tight", dpi=dpi)
fig.savefig(output_dir / f"{basename}.png", bbox_inches="tight", dpi=dpi)
return fig
if figsize is not None:
plt.figure(figsize=figsize)
sc.pl.violin(
adata,
keys=keys,
groupby=groupby,
multi_panel=True,
show=False,
)
fig = plt.gcf()
plt.tight_layout()
if output_dir is not None:
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
fig.savefig(output_dir / f"{basename}.pdf", bbox_inches="tight", dpi=dpi)
fig.savefig(output_dir / f"{basename}.png", bbox_inches="tight", dpi=dpi)
return fig
[docs]
def qc_scatter_counts_genes(
adata: AnnData,
*,
x: str = "total_counts",
y: str = "n_genes_by_counts",
color: str = "pct_counts_mt",
output_dir: str | Path | None = None,
basename: str = "qc_scatter",
dpi: int = 300,
figsize: tuple[float, float] = (6, 5),
) -> plt.Figure:
"""
Scatter plot: total_counts (x) vs n_genes_by_counts (y), colored by pct_counts_mt.
Uses scanpy's scatter with show=False. Requires adata.obs from calculate_qc_metrics.
Parameters
----------
adata : AnnData
Annotated data with obs columns for x, y, and color.
x, y, color : str
Obs column names (defaults: total_counts, n_genes_by_counts, pct_counts_mt).
output_dir : str or Path or None
If set, save PDF and PNG here.
basename : str
Base name for files (default 'qc_scatter').
dpi : int
DPI for PNG (default 300).
figsize : tuple
Figure size (default (6, 5)).
Returns
-------
matplotlib.figure.Figure
"""
import scanpy as sc
for col in (x, y, color):
if col not in adata.obs.columns:
fig, ax = plt.subplots(figsize=figsize)
ax.text(
0.5,
0.5,
f"Missing obs column: {col}",
ha="center",
va="center",
transform=ax.transAxes,
)
if output_dir is not None:
output_dir_p = Path(output_dir)
output_dir_p.mkdir(parents=True, exist_ok=True)
fig.savefig(output_dir_p / f"{basename}.pdf", bbox_inches="tight", dpi=dpi)
fig.savefig(output_dir_p / f"{basename}.png", bbox_inches="tight", dpi=dpi)
return fig
plt.figure(figsize=figsize)
sc.pl.scatter(adata, x=x, y=y, color=color, show=False)
fig = plt.gcf()
plt.tight_layout()
if output_dir is not None:
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
fig.savefig(output_dir / f"{basename}.pdf", bbox_inches="tight", dpi=dpi)
fig.savefig(output_dir / f"{basename}.png", bbox_inches="tight", dpi=dpi)
return fig
[docs]
def plot_highly_variable_genes(
adata: AnnData,
*,
output_dir: str | Path | None = None,
basename: str = "hvg",
dpi: int = 300,
figsize: tuple[float, float] = (6, 4),
) -> plt.Figure:
"""
Plot mean vs dispersion (or normalized dispersion) with highly variable genes highlighted.
Requires adata.var with 'highly_variable' and flavor-specific columns (means, dispersions
or dispersions_norm). Uses sc.pl.highly_variable_genes(show=False).
Parameters
----------
adata : AnnData
Annotated data after highly_variable_genes (e.g. seurat flavor).
output_dir : str or Path or None
If set, save PDF and PNG here.
basename : str
Base name for files (default 'hvg').
dpi : int
DPI for PNG (default 300).
figsize : tuple
Figure size (default (6, 4)).
Returns
-------
matplotlib.figure.Figure
"""
import scanpy as sc
if "highly_variable" not in adata.var.columns:
fig, ax = plt.subplots(figsize=figsize)
ax.text(
0.5,
0.5,
"highly_variable not in adata.var",
ha="center",
va="center",
transform=ax.transAxes,
)
if output_dir is not None:
output_dir_p = Path(output_dir)
output_dir_p.mkdir(parents=True, exist_ok=True)
fig.savefig(output_dir_p / f"{basename}.pdf", bbox_inches="tight", dpi=dpi)
fig.savefig(output_dir_p / f"{basename}.png", bbox_inches="tight", dpi=dpi)
return fig
plt.figure(figsize=figsize)
sc.pl.highly_variable_genes(adata, show=False)
fig = plt.gcf()
plt.tight_layout()
if output_dir is not None:
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
fig.savefig(output_dir / f"{basename}.pdf", bbox_inches="tight", dpi=dpi)
fig.savefig(output_dir / f"{basename}.png", bbox_inches="tight", dpi=dpi)
return fig
def _plot_svg_single(
ax: plt.Axes,
spatial_i: np.ndarray,
x_vals: np.ndarray,
color_vals: np.ndarray | None,
color_by: str,
color_is_bool: bool,
title: str = "",
) -> None:
"""Draw one SVG scatter on ax (mean/rank vs spatial_i)."""
valid = ~np.isnan(spatial_i) & np.isfinite(spatial_i)
if not np.any(valid):
ax.text(0.5, 0.5, "No valid spatial_i", ha="center", va="center", transform=ax.transAxes)
return
if color_vals is not None and color_is_bool:
scatter = ax.scatter(
x_vals[valid],
spatial_i[valid],
c=color_vals[valid].astype(int),
cmap=plt.cm.tab10,
vmin=0,
vmax=1,
s=8,
alpha=0.7,
)
cbar = plt.colorbar(scatter, ax=ax, ticks=[0.25, 0.75])
cbar.ax.set_yticklabels(["False", "True"])
elif color_vals is not None:
scatter = ax.scatter(
x_vals[valid], spatial_i[valid], c=color_vals[valid], cmap="viridis", s=8, alpha=0.7
)
plt.colorbar(scatter, ax=ax, label=color_by)
else:
ax.scatter(x_vals[valid], spatial_i[valid], c="gray", s=8, alpha=0.7)
ax.set_xlabel("Mean expr." if title else "Mean expression" if x_vals is not None else "Rank")
ax.set_ylabel("Moran's I")
if title:
ax.set_title(title)
[docs]
def plot_spatially_variable_genes(
adata: AnnData,
*,
x_axis: str = "mean",
color_by: str = "spatially_variable",
output_dir: str | Path | None = None,
basename: str = "svg",
dpi: int = 300,
figsize: tuple[float, float] = (6, 5),
) -> plt.Figure:
"""
Scatter: x = mean expression (or rank), y = Moran's I (spatial_i), colored by spatially_variable or pval.
If adata.uns['spatial_variable_per_library'] exists (from spatially_variable_genes_per_library),
one subplot per library is drawn. Otherwise requires adata.var with spatial_i.
Parameters
----------
adata : AnnData
Annotated data after spatially_variable_genes or with uns['spatial_variable_per_library'].
x_axis : str
'mean' or 'rank': x-axis (default 'mean').
color_by : str
'spatially_variable' or 'spatial_pval' (default 'spatially_variable').
output_dir : str or Path or None
If set, save PDF and PNG here.
basename : str
Base name for files (default 'svg').
dpi : int
DPI for PNG (default 300).
figsize : tuple
Figure size per panel (default (6, 5)).
Returns
-------
matplotlib.figure.Figure
"""
per_lib = adata.uns.get("spatial_variable_per_library")
if isinstance(per_lib, dict) and len(per_lib) > 0:
nlib = len(per_lib)
ncol = min(3, nlib)
nrow = (nlib + ncol - 1) // ncol
fig, axes = plt.subplots(nrow, ncol, figsize=(figsize[0] * ncol, figsize[1] * nrow))
axes = np.atleast_1d(axes).flat
means_global = (
adata.var["means"].values.astype(float) if "means" in adata.var.columns else None
)
if means_global is None and hasattr(adata.X, "toarray"):
means_global = np.asarray(adata.X.mean(axis=0)).ravel()
elif means_global is None:
means_global = np.asarray(adata.X.mean(axis=0)).ravel()
for idx, (lib_id, df) in enumerate(per_lib.items()):
ax = axes[idx]
si = (
df["spatial_i"].reindex(adata.var_names).values.astype(float)
if "spatial_i" in df.columns
else None
)
if si is None or not np.any(np.isfinite(si)):
ax.text(
0.5, 0.5, f"{lib_id}: no data", ha="center", va="center", transform=ax.transAxes
)
continue
x_vals = (
means_global
if x_axis == "mean"
else np.argsort(
np.nan_to_num(means_global, nan=np.nanmin(means_global) - 1)
).astype(float)
)
c_vals = None
c_bool = False
if color_by in df.columns:
c_vals = df[color_by].reindex(adata.var_names).values
c_bool = np.issubdtype(c_vals.dtype, np.bool_)
_plot_svg_single(ax, si, x_vals, c_vals, color_by, c_bool, title=str(lib_id))
for j in range(len(per_lib), len(axes)):
axes[j].set_visible(False)
plt.tight_layout()
if output_dir is not None:
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
fig.savefig(output_dir / f"{basename}.pdf", bbox_inches="tight", dpi=dpi)
fig.savefig(output_dir / f"{basename}.png", bbox_inches="tight", dpi=dpi)
return fig
if "spatial_i" not in adata.var.columns:
fig, ax = plt.subplots(figsize=figsize)
ax.text(
0.5, 0.5, "spatial_i not in adata.var", ha="center", va="center", transform=ax.transAxes
)
if output_dir is not None:
output_dir_p = Path(output_dir)
output_dir_p.mkdir(parents=True, exist_ok=True)
fig.savefig(output_dir_p / f"{basename}.pdf", bbox_inches="tight", dpi=dpi)
fig.savefig(output_dir_p / f"{basename}.png", bbox_inches="tight", dpi=dpi)
return fig
x = adata.var["spatial_i"].values.astype(float)
valid = ~np.isnan(x) & np.isfinite(x)
if not np.any(valid):
fig, ax = plt.subplots(figsize=figsize)
ax.text(0.5, 0.5, "No valid spatial_i", ha="center", va="center", transform=ax.transAxes)
if output_dir is not None:
output_dir_p = Path(output_dir)
output_dir_p.mkdir(parents=True, exist_ok=True)
fig.savefig(output_dir_p / f"{basename}.pdf", bbox_inches="tight", dpi=dpi)
fig.savefig(output_dir_p / f"{basename}.png", bbox_inches="tight", dpi=dpi)
return fig
if "means" in adata.var.columns:
x_vals = adata.var["means"].values.astype(float)
else:
if hasattr(adata.X, "toarray"):
x_vals = np.asarray(adata.X.mean(axis=0)).ravel()
else:
x_vals = np.asarray(adata.X.mean(axis=0)).ravel()
if len(x_vals) != adata.n_vars:
x_vals = np.full(adata.n_vars, np.nan)
if x_axis == "rank":
order = np.argsort(np.nan_to_num(x_vals, nan=np.nanmin(x_vals) - 1))
x_vals = np.empty_like(order, dtype=float)
x_vals[order] = np.arange(len(order))
fig, ax = plt.subplots(figsize=figsize)
if color_by in adata.var.columns:
c_vals = adata.var[color_by].values[valid]
if np.issubdtype(adata.var[color_by].dtype, np.bool_):
scatter = ax.scatter(
x_vals[valid],
x[valid],
c=c_vals.astype(int),
cmap=plt.cm.tab10,
vmin=0,
vmax=1,
s=8,
alpha=0.7,
)
cbar = plt.colorbar(scatter, ax=ax, ticks=[0.25, 0.75])
cbar.ax.set_yticklabels(["False", "True"])
else:
scatter = ax.scatter(
x_vals[valid],
x[valid],
c=c_vals,
cmap="viridis",
s=8,
alpha=0.7,
)
plt.colorbar(scatter, ax=ax, label=color_by)
else:
ax.scatter(x_vals[valid], x[valid], c="gray", s=8, alpha=0.7)
ax.set_xlabel("Mean expression" if x_axis == "mean" else "Rank (mean)")
ax.set_ylabel("Moran's I (spatial_i)")
ax.set_title("Spatially variable genes")
plt.tight_layout()
if output_dir is not None:
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
fig.savefig(output_dir / f"{basename}.pdf", bbox_inches="tight", dpi=dpi)
fig.savefig(output_dir / f"{basename}.png", bbox_inches="tight", dpi=dpi)
return fig
# ---------------------------------------------------------------------------
# Cross-sample comparison plots
# ---------------------------------------------------------------------------
def _save_fig(fig: plt.Figure, output_dir: str | Path | None, basename: str, dpi: int) -> None:
"""Save figure as PDF + PNG if output_dir is provided."""
if output_dir is not None:
od = Path(output_dir)
od.mkdir(parents=True, exist_ok=True)
fig.savefig(od / f"{basename}.pdf", bbox_inches="tight", dpi=dpi)
fig.savefig(od / f"{basename}.png", bbox_inches="tight", dpi=dpi)
def _format_log10_ticks(ax, max_val: float) -> None:
"""Set custom log10(x+1) y-tick labels: 0 (1), 1 (10), 2 (100), etc."""
tick_map = {0: "0 (1)", 1: "1 (10)", 2: "2 (100)", 3: "3 (1K)", 4: "4 (10K)", 5: "5 (100K)"}
max_tick = int(np.ceil(max_val)) if np.isfinite(max_val) else 5
ticks = list(range(min(max_tick + 1, 6)))
ax.set_yticks(ticks)
ax.set_yticklabels([tick_map.get(t, str(t)) for t in ticks])
[docs]
def qc_sample_comparison_bar(
metrics: pd.DataFrame,
metric_cols: list[str] | None = None,
classified: pd.DataFrame | None = None,
output_dir: str | Path | None = None,
basename: str = "qc_sample_comparison",
dpi: int = 300,
log_scale: bool = False,
) -> plt.Figure:
"""
Bar chart per metric, one bar per sample, sorted by value.
Failed samples (from ``classified``) are highlighted in red.
Parameters
----------
metrics : pd.DataFrame
Output of ``compute_sample_metrics`` (indexed by sample).
metric_cols : list of str or None
Columns to plot (default: n_genes_median, total_counts_median,
pct_mt_median, n_spots).
classified : pd.DataFrame or None
If provided (output of ``classify_samples``), failed samples shown in red.
output_dir : str or Path or None
If set, save PDF and PNG.
basename : str
Base filename.
dpi : int
DPI for PNG.
log_scale : bool
If True, transform values with log10(x + 1) and annotate y-ticks
with original-scale labels (default False).
Returns
-------
matplotlib.figure.Figure
"""
if metric_cols is None:
metric_cols = [
c
for c in ["n_genes_median", "total_counts_median", "pct_mt_median", "n_spots"]
if c in metrics.columns
]
if not metric_cols:
fig, ax = plt.subplots()
ax.text(
0.5,
0.5,
"No metric columns available",
ha="center",
va="center",
transform=ax.transAxes,
)
_save_fig(fig, output_dir, basename, dpi)
return fig
n_metrics = len(metric_cols)
fig, axes = plt.subplots(n_metrics, 1, figsize=(max(8, len(metrics) * 0.5), 4 * n_metrics))
if n_metrics == 1:
axes = [axes]
fail_set = set()
if classified is not None and "qc_pass" in classified.columns:
fail_set = set(classified.index[~classified["qc_pass"]])
for ax, col in zip(axes, metric_cols, strict=False):
sorted_df = metrics[[col]].dropna().sort_values(col)
colors = ["#d62728" if s in fail_set else "#1f77b4" for s in sorted_df.index]
values = sorted_df[col].values.astype(float)
if log_scale:
values = np.log10(values + 1)
ax.bar(range(len(sorted_df)), values, color=colors)
ax.set_xticks(range(len(sorted_df)))
labels = [str(s) for s in sorted_df.index]
ax.set_xticklabels(labels, rotation=45, ha="right", fontsize=8)
ax.set_ylabel(col)
suffix = " (log10 scale)" if log_scale else ""
ax.set_title(f"{col}{suffix}", fontweight="bold")
if log_scale and len(values) > 0:
_format_log10_ticks(ax, float(np.nanmax(values)))
title_suffix = " (log10 scale)" if log_scale else ""
fig.suptitle(
f"Cross-sample QC comparison{title_suffix}", fontsize=14, fontweight="bold", y=1.01
)
plt.tight_layout()
_save_fig(fig, output_dir, basename, dpi)
return fig
[docs]
def qc_sample_violin_grouped(
adata: AnnData,
sample_col: str = "library_id",
keys: list[str] | None = None,
classified: pd.DataFrame | None = None,
output_dir: str | Path | None = None,
basename: str = "qc_sample_violin",
dpi: int = 300,
log_scale: bool = False,
) -> plt.Figure:
"""
Violin plots grouped by sample for direct distribution comparison.
Parameters
----------
adata : AnnData
Annotated data with QC columns in obs.
sample_col : str
Column in obs identifying samples.
keys : list of str or None
Obs columns to plot (default: n_genes_by_counts, total_counts, pct_counts_mt).
classified : pd.DataFrame or None
If provided, failed sample names are marked with ``(FAIL)`` suffix.
output_dir : str or Path or None
If set, save PDF and PNG.
basename : str
Base filename.
dpi : int
DPI for PNG.
log_scale : bool
If True, apply log10(x + 1) to all keys except pct_counts_mt
(which stays linear). Custom y-tick annotations are added (default False).
Returns
-------
matplotlib.figure.Figure
"""
if sample_col not in adata.obs.columns:
fig, ax = plt.subplots()
ax.text(
0.5, 0.5, f"{sample_col} not in obs", ha="center", va="center", transform=ax.transAxes
)
_save_fig(fig, output_dir, basename, dpi)
return fig
if keys is None:
keys = ["n_genes_by_counts", "total_counts", "pct_counts_mt"]
keys = [k for k in keys if k in adata.obs.columns]
if not keys:
fig, ax = plt.subplots()
ax.text(0.5, 0.5, "No QC columns", ha="center", va="center", transform=ax.transAxes)
_save_fig(fig, output_dir, basename, dpi)
return fig
# Keys that should NOT be log-transformed (percentage metrics)
_pct_keys = {"pct_counts_mt", "pct_counts_hb"}
fail_set = set()
if classified is not None and "qc_pass" in classified.columns:
fail_set = set(classified.index[~classified["qc_pass"]])
samples = sorted(adata.obs[sample_col].dropna().unique())
n_keys = len(keys)
fig, axes = plt.subplots(n_keys, 1, figsize=(max(8, len(samples) * 0.7), 4 * n_keys))
if n_keys == 1:
axes = [axes]
for ax, key in zip(axes, keys, strict=False):
do_log = log_scale and key not in _pct_keys
data_per_sample = []
labels = []
for s in samples:
vals = adata.obs.loc[adata.obs[sample_col] == s, key].dropna().values.astype(float)
if do_log:
vals = np.log10(vals + 1)
data_per_sample.append(vals)
label = f"{s} (FAIL)" if s in fail_set else str(s)
labels.append(label)
non_empty = [(i, d) for i, d in enumerate(data_per_sample) if len(d) > 0]
if non_empty:
parts = ax.violinplot(
[d for _, d in non_empty],
positions=[i for i, _ in non_empty],
showmeans=True,
showmedians=True,
)
for pc in parts.get("bodies", []):
pc.set_alpha(0.7)
ax.set_xticks(range(len(labels)))
ax.set_xticklabels(labels, rotation=45, ha="right", fontsize=8)
ax.set_ylabel(key)
suffix = " (log10 scale)" if do_log else ""
ax.set_title(f"{key}{suffix}", fontweight="bold")
if do_log and non_empty:
all_vals = np.concatenate([d for _, d in non_empty])
if len(all_vals) > 0:
_format_log10_ticks(ax, float(np.nanmax(all_vals)))
title_suffix = " (log10 scale)" if log_scale else ""
fig.suptitle(
f"Per-sample QC distributions{title_suffix}", fontsize=14, fontweight="bold", y=1.01
)
plt.tight_layout()
_save_fig(fig, output_dir, basename, dpi)
return fig
[docs]
def qc_sample_scatter_matrix(
metrics: pd.DataFrame,
metric_cols: list[str] | None = None,
classified: pd.DataFrame | None = None,
output_dir: str | Path | None = None,
basename: str = "qc_sample_scatter_matrix",
dpi: int = 300,
) -> plt.Figure:
"""
Pairwise scatter of sample-level metrics with pass/fail coloring.
Parameters
----------
metrics : pd.DataFrame
Output of ``compute_sample_metrics``.
metric_cols : list of str or None
Columns for scatter matrix (default: n_spots, n_genes_median,
total_counts_median, pct_mt_median).
classified : pd.DataFrame or None
If provided, color points by pass (blue) / fail (red).
output_dir : str or Path or None
If set, save PDF and PNG.
basename : str
Base filename.
dpi : int
DPI for PNG.
Returns
-------
matplotlib.figure.Figure
"""
if metric_cols is None:
metric_cols = [
c
for c in ["n_spots", "n_genes_median", "total_counts_median", "pct_mt_median"]
if c in metrics.columns
]
metric_cols = [c for c in metric_cols if c in metrics.columns]
if len(metric_cols) < 2:
fig, ax = plt.subplots()
ax.text(
0.5, 0.5, "Need >= 2 metric columns", ha="center", va="center", transform=ax.transAxes
)
_save_fig(fig, output_dir, basename, dpi)
return fig
n = len(metric_cols)
fig, axes = plt.subplots(n, n, figsize=(4 * n, 4 * n))
fail_set = set()
if classified is not None and "qc_pass" in classified.columns:
fail_set = set(classified.index[~classified["qc_pass"]])
colors = ["#d62728" if s in fail_set else "#1f77b4" for s in metrics.index]
for i in range(n):
for j in range(n):
ax = axes[i, j] if n > 1 else axes
if i == j:
vals = metrics[metric_cols[i]].dropna().values
ax.hist(
vals, bins=max(5, len(vals) // 3), color="#1f77b4", edgecolor="white", alpha=0.7
)
ax.set_xlabel(metric_cols[i])
else:
ax.scatter(
metrics[metric_cols[j]].values,
metrics[metric_cols[i]].values,
c=colors,
s=40,
alpha=0.8,
edgecolors="white",
linewidths=0.5,
)
if j == 0:
ax.set_ylabel(metric_cols[i])
if i == n - 1:
ax.set_xlabel(metric_cols[j])
fig.suptitle("Sample QC scatter matrix", fontsize=14, fontweight="bold", y=1.01)
plt.tight_layout()
_save_fig(fig, output_dir, basename, dpi)
return fig
def qc_pct_mt_per_sample(
adata: AnnData,
sample_col: str = "library_id",
pct_mt_col: str = "pct_counts_mt",
classified: pd.DataFrame | None = None,
output_dir: str | Path | None = None,
basename: str = "qc_pct_mt_per_sample",
dpi: int = 300,
figsize: tuple[float, float] | None = None,
) -> plt.Figure:
"""
Per-sample %MT distribution as box plots colored by pass/fail.
Parameters
----------
adata : AnnData
Annotated data with ``pct_counts_mt`` in obs.
sample_col : str
Column in obs identifying samples.
pct_mt_col : str
Obs column for percent mitochondrial (default ``pct_counts_mt``).
classified : pd.DataFrame or None
If provided, failed samples are colored red, pass samples blue.
output_dir : str or Path or None
If set, save PDF and PNG.
basename : str
Base filename.
dpi : int
DPI for PNG.
figsize : tuple or None
Figure size (auto-scaled by sample count if None).
Returns
-------
matplotlib.figure.Figure
"""
if pct_mt_col not in adata.obs.columns:
fig, ax = plt.subplots(figsize=figsize or (8, 5))
ax.text(
0.5,
0.5,
f"{pct_mt_col} not in obs",
ha="center",
va="center",
transform=ax.transAxes,
)
_save_fig(fig, output_dir, basename, dpi)
return fig
if sample_col not in adata.obs.columns:
fig, ax = plt.subplots(figsize=figsize or (8, 5))
ax.text(
0.5,
0.5,
f"{sample_col} not in obs",
ha="center",
va="center",
transform=ax.transAxes,
)
_save_fig(fig, output_dir, basename, dpi)
return fig
fail_set = set()
if classified is not None and "qc_pass" in classified.columns:
fail_set = set(classified.index[~classified["qc_pass"]])
samples = sorted(adata.obs[sample_col].dropna().unique())
if figsize is None:
figsize = (max(8, len(samples) * 0.7), 5)
fig, ax = plt.subplots(figsize=figsize)
data_per_sample = []
for s in samples:
vals = adata.obs.loc[adata.obs[sample_col] == s, pct_mt_col].dropna().values
data_per_sample.append(vals)
bp = ax.boxplot(
data_per_sample,
positions=range(len(samples)),
patch_artist=True,
widths=0.6,
)
for patch, s in zip(bp["boxes"], samples, strict=False):
color = "#d62728" if s in fail_set else "#1f77b4"
patch.set_facecolor(color)
patch.set_alpha(0.7)
labels = [f"{s} (FAIL)" if s in fail_set else str(s) for s in samples]
ax.set_xticks(range(len(samples)))
ax.set_xticklabels(labels, rotation=45, ha="right", fontsize=8)
ax.set_ylabel(pct_mt_col)
ax.set_title("% Mitochondrial per sample", fontweight="bold")
plt.tight_layout()
_save_fig(fig, output_dir, basename, dpi)
return fig