"""Plotting -- a thin, transparent wrapper over scanpy and squidpy.
This module **never computes anything**. It reads the keys :func:`spatial_smooth.core.smooth`
wrote into the ``AnnData`` (``obs[name]``, ``obs[f"{name}_raw"]``,
``uns["spatial_smooth"][name]``), works out sensible defaults from the recorded provenance, and
hands everything to an existing plotting function. Write a smoothed object to ``.h5ad``, reload
it anywhere, and these calls render it -- no ``kompot``, ``KDEpy`` or ``palantir`` needed, and no
smoothing repeated.
Backends and where your ``**kwargs`` go
---------------------------------------
======================== ================================== ======================================
``backend`` underlying call when to use it
======================== ================================== ======================================
``"squidpy"`` ``squidpy.pl.spatial_scatter`` tissue coordinates, optional image
``"scanpy"`` ``scanpy.pl.embedding`` any ``obsm`` basis, imageless
``"scanpy-spatial"`` ``scanpy.pl.spatial`` Visium-style ``uns["spatial"]`` image
``"auto"`` (default) squidpy if installed, else scanpy --
======================== ================================== ======================================
Every ``**kwargs`` is forwarded **verbatim** to that function. ``color`` is set by this module
(to the raw and smoothed obs columns); passing it raises ``TypeError`` rather than being silently
ignored. Everything else -- ``cmap``, ``size``, ``figsize``, ``vmin``/``vmax``, ``title``,
``save``, ``ax`` -- is the backend's own parameter, documented in the backend's own docstring.
Defaults this module supplies (a perceptually uniform colour map, percentile colour limits where
the backend supports them, a grey ``na_color``) are applied only when you have not passed that
key yourself.
Two conventions this module *does* normalise, because leaving them to the backend produced plots
that differed for the same data:
**Orientation.** Imaging platforms store cell centroids as image coordinates -- origin top-left,
y increasing *downward*. ``squidpy.pl.spatial_scatter`` honours that; ``scanpy.pl.embedding``
treats ``obsm["spatial"]`` as an abstract Cartesian embedding and draws y upward, mirroring the
tissue vertically. Whenever the basis being drawn is ``"spatial"``, this module inverts the
y-axis and sets equal aspect, so every backend renders the section as the microscope saw it.
**``size`` is backend-native and is deliberately not translated.** In ``scanpy`` it is the marker
area in points squared; in ``squidpy`` it is a *scale factor* on the inferred spot size (default
``1.0``). A ``size=6`` that looks right in one is nearly invisible in the other, so this module
does not pretend they mean the same thing.
What it *does* do is pick a sane default for the scanpy backends on a spatial basis, where
scanpy's own ``120000 / n_obs`` leaves visible gaps between cells and the field reads as speckle
rather than anatomy. :func:`default_marker_size` keeps the ``1 / n_obs`` area scaling and raises
the constant, so the section renders continuous. Pass ``size`` yourself to override.
"""
from __future__ import annotations
import warnings
from typing import Any, Dict, List, Optional, Sequence
from ._deps import have, require
from .core import provenance
from .smoothers import SpatialSmoothWarning
from .steps import SPATIAL_KEY
__all__ = ["signature", "compare", "available_backends", "default_marker_size", "BACKENDS"]
#: ``backend -> (module, attribute)`` of the function each backend delegates to.
BACKENDS = {
"squidpy": ("squidpy", "pl.spatial_scatter"),
"scanpy": ("scanpy", "pl.embedding"),
"scanpy-spatial": ("scanpy", "pl.spatial"),
}
# Defaults injected per backend, only for keys the caller did not supply. Kept to parameters
# each backend actually declares -- squidpy's `spatial_scatter` forwards unknown keys to
# matplotlib's scatter, where a stray `vmin` would be silently mis-applied.
_DEFAULTS: Dict[str, Dict[str, Any]] = {
"squidpy": {"cmap": "viridis", "na_color": "#d9d9d9"},
"scanpy": {"cmap": "viridis", "vmin": "p1", "vmax": "p99", "na_color": "#d9d9d9"},
"scanpy-spatial": {"cmap": "viridis", "vmin": "p1", "vmax": "p99", "na_color": "#d9d9d9"},
}
[docs]
def available_backends() -> List[str]:
"""Backends whose plotting library is importable right now."""
out = []
if have("squidpy"):
out.append("squidpy")
if have("scanpy"):
out += ["scanpy", "scanpy-spatial"]
return out
def _resolve_backend(backend: str) -> str:
if backend != "auto":
if backend not in BACKENDS:
raise ValueError(f"unknown backend {backend!r}; expected one of {sorted(BACKENDS)} or 'auto'")
return backend
if have("squidpy"):
return "squidpy"
if have("scanpy"):
return "scanpy"
raise ImportError(
"spatial_smooth needs a plotting backend: install `scanpy` "
'(pip install "scanpy>=1.9") or `squidpy` (pip install "squidpy>=1.4").'
)
def _default_basis(adata, records: Sequence[Dict[str, Any]]) -> str:
"""Plot over the last step's basis when that is a 2-D physical basis, else fall back.
A result smoothed over ``"spatial"`` must not quietly land on a UMAP because the coordinates
were dropped: the picture would look plausible and mean nothing. If the recorded basis is
missing, say so.
"""
wanted = {
step.get("basis")
for record in records
for step in record.get("steps", [])
if step.get("basis") == SPATIAL_KEY
}
if wanted and SPATIAL_KEY not in adata.obsm:
raise KeyError(
f"this result was smoothed over adata.obsm[{SPATIAL_KEY!r}], which is no longer "
f"present (obsm has {sorted(adata.obsm)}). Restore it, or pass basis=... explicitly "
"to state deliberately that you want the field drawn on another embedding."
)
if SPATIAL_KEY in adata.obsm and wanted:
return SPATIAL_KEY
for candidate in (SPATIAL_KEY, "X_umap", "umap", "X_pca"):
if candidate in adata.obsm:
return candidate
raise KeyError(
f"cannot pick a plotting basis: adata.obsm has {sorted(adata.obsm)}. "
"Pass basis=... explicitly."
)
def _colors(adata, record: Dict[str, Any], raw: bool) -> List[str]:
smoothed, unsmoothed = record["obs_key"], record["obs_key_raw"]
if smoothed not in adata.obs:
raise KeyError(
f"adata.uns['spatial_smooth'][{record['name']!r}] exists but adata.obs[{smoothed!r}] "
"does not -- the object was modified after smoothing. Re-run spatial_smooth.smooth()."
)
if not raw:
return [smoothed]
if unsmoothed not in adata.obs:
# Dropping the panel silently would leave the reader comparing nothing. `signature` and
# `compare` used to disagree here -- one dropped it, the other raised.
warnings.warn(
f"raw=True but adata.obs[{unsmoothed!r}] is missing, so only the smoothed panel is "
"drawn. It was removed after smoothing; re-run spatial_smooth.smooth() to restore it.",
SpatialSmoothWarning,
stacklevel=3,
)
return [smoothed]
return [unsmoothed, smoothed]
#: scanpy sizes markers as ``120000 / n_obs`` points squared. On a tissue section that leaves
#: visible gaps between cells and the field reads as speckle rather than anatomy. Markers should
#: tile the section, so their *area* scales as ``1 / n_obs``; only the constant needed changing.
#: Calibrated by eye on a 36k-cell imaging section, where this gives ~14 pt**2.
MARKER_AREA_CONSTANT = 500_000.0
#: Ceiling, so a few hundred cells do not become saucers.
MAX_MARKER_SIZE = 200.0
def default_marker_size(n_obs: int) -> float:
"""Marker area (points squared) for drawing ``n_obs`` cells on a tissue section.
Only used for ``scanpy`` backends on a spatial basis, and only when the caller did not pass
``size``. squidpy sizes its own markers from the inferred spot size and is left alone.
"""
return float(min(MARKER_AREA_CONSTANT / max(int(n_obs), 1), MAX_MARKER_SIZE))
def _as_axes(result) -> List[Any]:
"""Normalise the several shapes scanpy/squidpy return into a flat list of Axes."""
if result is None:
return []
if hasattr(result, "flatten"): # numpy array of Axes
return list(result.flatten())
if isinstance(result, (list, tuple)):
out: List[Any] = []
for item in result:
out.extend(_as_axes(item))
return out
return [result] if hasattr(result, "yaxis") else []
def _apply_image_convention(axes) -> None:
"""Draw a spatial basis the way an imaging assay stores it: y increasing downward.
Cell centroids from imaging platforms are *image* coordinates -- the origin is the top-left
of the slide and y grows downward. ``squidpy.pl.spatial_scatter`` honours that and inverts
the y-axis; ``scanpy.pl.embedding`` treats any ``obsm`` basis as an abstract Cartesian
embedding and leaves y increasing upward. Rendering one section through both backends
therefore produced vertically mirrored tissue -- a plot that looks entirely plausible and is
upside down.
We normalise onto squidpy's convention: the tissue as the microscope saw it. Equal aspect
goes with it, because anisotropic scaling of physical coordinates distorts anatomy.
"""
for ax in axes:
if not ax.yaxis_inverted():
ax.invert_yaxis()
ax.set_aspect("equal")
def _dispatch(adata, backend: str, color: List[str], basis: str, kwargs: Dict[str, Any]):
if "color" in kwargs:
raise TypeError(
"`color` is set from the stored result and cannot be overridden; it names the raw "
f"and smoothed obs columns ({color}). To colour cells by something else, call the "
"backend function directly (see spatial_smooth.plot.BACKENDS)."
)
merged = dict(_DEFAULTS[backend])
merged.update(kwargs)
merged["color"] = color
if backend == "squidpy":
squidpy = require("squidpy")
merged.setdefault("shape", None) # point cloud, not a Visium hex grid
merged.setdefault("spatial_key", basis)
# squidpy has no `show`; it draws unconditionally and returns axes on `return_ax`.
# Accept `show` anyway so the backends present one interface, and translate it --
# forwarding it would land in `matplotlib.scatter` as an unknown keyword.
show = merged.pop("show", None)
if show is False:
merged.setdefault("return_ax", True)
return squidpy.pl.spatial_scatter(adata, **merged)
scanpy = require("scanpy")
on_tissue = backend == "scanpy-spatial" or basis == SPATIAL_KEY
if not on_tissue:
merged.setdefault("basis", basis)
return scanpy.pl.embedding(adata, **merged)
# A spatial basis through a scanpy backend: draw it, then impose the image convention
# before anything is shown. `sc.pl.spatial` already inverts (it overlays a tissue image),
# so this is a no-op there beyond enforcing equal aspect.
plt = require("matplotlib").pyplot
merged.setdefault("size", default_marker_size(adata.n_obs))
show = merged.pop("show", None)
if backend == "scanpy-spatial":
result = scanpy.pl.spatial(adata, show=False, **merged)
else:
merged.setdefault("basis", basis)
result = scanpy.pl.embedding(adata, show=False, **merged)
_apply_image_convention(_as_axes(result))
if show is False:
return result
plt.show()
return None
[docs]
def signature(
adata,
name: str = "signature",
*,
raw: bool = True,
backend: str = "auto",
basis: Optional[str] = None,
**kwargs,
):
"""Plot a stored smoothing result -- raw next to smoothed, by default.
Reads only what :func:`spatial_smooth.smooth` stored. Nothing is recomputed, so this works on
a reloaded ``.h5ad`` in an environment without the smoothing backends installed.
Parameters
----------
adata
Object carrying a result named ``name``.
name
The result to plot.
raw
Show the unsmoothed score alongside the smoothed one (two panels).
backend
``"auto"``, ``"squidpy"``, ``"scanpy"``, or ``"scanpy-spatial"``. See the module
docstring for what each delegates to.
basis
``adata.obsm`` key to lay the cells out on. Defaults to the last smoothing step's basis
when that is ``"spatial"``, else the first of ``spatial``/``X_umap``/``X_pca`` present.
**kwargs
Forwarded verbatim to the backend's plotting function.
Returns
-------
Whatever the backend returns (usually ``None`` when it shows, or axes when ``show=False``).
Raises
------
KeyError
If no result named ``name`` is stored (the message says how to create it).
"""
record = provenance(adata, name)
color = _colors(adata, record, raw)
resolved = _resolve_backend(backend)
basis = basis or _default_basis(adata, [record])
return _dispatch(adata, resolved, color, basis, kwargs)
[docs]
def compare(
adata,
names: Sequence[str],
*,
raw: bool = False,
backend: str = "auto",
basis: Optional[str] = None,
**kwargs,
):
"""Plot several stored results side by side -- e.g. one panel per smoothing pipeline.
``compare(adata, ["sig_spatial", "sig_dm", "sig_both"])`` puts the three composition modes on
a common canvas. With ``raw=True`` the first panel is the (shared) unsmoothed score.
"""
if isinstance(names, str):
names = [names]
records = [provenance(adata, n) for n in names]
color: List[str] = []
if raw and records:
color.extend(c for c in _colors(adata, records[0], raw=True)[:-1])
for record in records:
color.extend(c for c in _colors(adata, record, raw=False) if c not in color)
resolved = _resolve_backend(backend)
basis = basis or _default_basis(adata, records)
return _dispatch(adata, resolved, color, basis, kwargs)