Skip to content
Merged
15 changes: 15 additions & 0 deletions src/spatialdata_plot/pl/_validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ def _validate_show_parameters(
xlabel: str | None,
ylabel: str | None,
pad_extent: int | float,
crop_coord: tuple[float, float, float, float] | None,
ax: list[Axes] | Axes | None,
return_ax: bool,
save: str | Path | None,
Expand Down Expand Up @@ -192,6 +193,20 @@ def _validate_show_parameters(
if not isinstance(pad_extent, int | float):
raise TypeError("Parameter 'pad_extent' must be numeric.")

if crop_coord is not None:
if (
not isinstance(crop_coord, tuple)
or len(crop_coord) != 4
or not all(isinstance(v, int | float) for v in crop_coord)
):
raise TypeError("Parameter 'crop_coord' must be a tuple of four numbers (xmin, xmax, ymin, ymax).")
xmin, xmax, ymin, ymax = crop_coord
if not (xmin < xmax and ymin < ymax):
raise ValueError(
f"Parameter 'crop_coord' must satisfy xmin < xmax and ymin < ymax, got (xmin={xmin}, xmax={xmax}, "
f"ymin={ymin}, ymax={ymax})."
)

if ax is not None and not isinstance(ax, Axes | list):
raise TypeError("Parameter 'ax' must be a matplotlib.axes.Axes or a list of Axes.")

Expand Down
79 changes: 59 additions & 20 deletions src/spatialdata_plot/pl/basic.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
_split_colorbar_params,
)
from spatialdata_plot.pl.render_params import (
BBox,
CBAR_DEFAULT_FRACTION,
CBAR_DEFAULT_LOCATION,
CBAR_DEFAULT_PAD,
Expand Down Expand Up @@ -1318,6 +1319,7 @@ def show(
xlabel: str | None = None,
ylabel: str | None = None,
pad_extent: int | float = 0,
crop_coord: tuple[float, float, float, float] | None = None,
ax: list[Axes] | Axes | None = None,
return_ax: bool = False,
save: str | Path | None = None,
Expand Down Expand Up @@ -1385,7 +1387,21 @@ def show(
ylabel : str | None, default None
Label for the y axis, applied to every rendered panel. ``None`` leaves it unlabelled.
pad_extent : int | float, default 0
Padding added around the computed spatial extent on all sides.
Padding added around the computed spatial extent on all sides. Ignored when ``crop_coord`` is set.
crop_coord : tuple[float, float, float, float] | None, default None
Restrict the plot to a bounding box ``(xmin, xmax, ymin, ymax)`` in the rendered coordinate
system's units (same order as :meth:`matplotlib.axes.Axes.axis` and as ``crop_coord`` in
:func:`scanpy.pl.spatial` / :func:`squidpy.pl.spatial_scatter`). Points and shapes are
subsetted before drawing for speed; large images (both the matplotlib and ``datashader``
backends) and labels are rasterized to the window only, so the full array is never
materialized (fast at Visium HD scale) and the zoom keeps full figure resolution. For points
and shapes, auto-scaled color ranges are computed from the full element so colors match the
uncropped plot; a cropped image's contrast auto-scales over the window (pass explicit
``vmin``/``vmax`` or a ``norm`` to fix it). Labels coloured by a plain-string column with the
default palette fall back to full render + clip (windowing could otherwise reshuffle
colours); use a categorical dtype or an explicit palette to keep the windowed fast path.
Requires a single coordinate system (pass ``coordinate_systems`` with one entry if several
would otherwise be rendered).
ax : list[Axes] | Axes | None
Pre-existing matplotlib axes to plot on. Can be a single :class:`~matplotlib.axes.Axes` or a list
matching the number of coordinate systems. If ``None``, a new figure and axes are created.
Expand Down Expand Up @@ -1449,6 +1465,7 @@ def show(
xlabel=xlabel,
ylabel=ylabel,
pad_extent=pad_extent,
crop_coord=crop_coord,
ax=ax,
return_ax=return_ax,
save=save,
Expand Down Expand Up @@ -1502,6 +1519,19 @@ def show(
ax=ax,
)

# `crop_coord` is one box in one coordinate system's units; applying the same numbers across
# coordinate systems with different scales/units would be incoherent. Convert the public
# (xmin, xmax, ymin, ymax) order into the internal BBox once, here at the boundary.
crop_box: BBox | None = None
if crop_coord is not None:
if len(coordinate_systems) > 1:
raise ValueError(
f"`crop_coord` requires a single coordinate system, but {len(coordinate_systems)} would be "
f"rendered ({coordinate_systems}). Pass `coordinate_systems=` with exactly one entry."
)
xmin, xmax, ymin, ymax = crop_coord
crop_box = BBox(xmin, ymin, xmax, ymax)

panels = _plan_panels(
coordinate_systems=coordinate_systems,
render_cmds=render_cmds,
Expand Down Expand Up @@ -1534,6 +1564,7 @@ def show(
scalebar_units=scalebar_units,
scalebar_kwargs=scalebar_params,
)
fig_params.crop = crop_box
legend_params_obj = _build_legend_params(
legend_params=legend_params,
legend_fontsize=legend_fontsize,
Expand Down Expand Up @@ -1591,25 +1622,33 @@ def show(
"all geometries are empty. Drop the element or restore at least one non-empty geometry."
)

# fast path for axis-aligned transforms; identical result, falls back to get_extent otherwise
extent = _get_extent_fast(
sdata,
coordinate_system=cs,
has_images=has_images and wants["images"],
has_labels=has_labels and wants["labels"],
has_points=has_points and wants["points"],
has_shapes=has_shapes and wants["shapes"],
elements=wanted_elements,
)
cs_x_min, cs_x_max = extent["x"]
cs_y_min, cs_y_max = extent["y"]

if any([has_images, has_labels, has_points, has_shapes]):
# If the axis already has limits, only expand them but not overwrite
x_min = min(ax_x_min, cs_x_min) - pad_extent
x_max = max(ax_x_max, cs_x_max) + pad_extent
y_min = min(ax_y_min, cs_y_min) - pad_extent
y_max = max(ax_y_max, cs_y_max) + pad_extent
if crop_box is not None:
# `crop_coord` pins the view to the exact box: bypass the expand-don't-overwrite merge
# (which would expand back out to any pre-existing axes limits) and ignore `pad_extent`.
x_min, x_max, y_min, y_max = crop_box.x0, crop_box.x1, crop_box.y0, crop_box.y1
set_limits = True
else:
# fast path for axis-aligned transforms; identical result, falls back to get_extent otherwise
extent = _get_extent_fast(
sdata,
coordinate_system=cs,
has_images=has_images and wants["images"],
has_labels=has_labels and wants["labels"],
has_points=has_points and wants["points"],
has_shapes=has_shapes and wants["shapes"],
elements=wanted_elements,
)
cs_x_min, cs_x_max = extent["x"]
cs_y_min, cs_y_max = extent["y"]
set_limits = any([has_images, has_labels, has_points, has_shapes])
if set_limits:
# If the axis already has limits, only expand them but not overwrite
x_min = min(ax_x_min, cs_x_min) - pad_extent
x_max = max(ax_x_max, cs_x_max) + pad_extent
y_min = min(ax_y_min, cs_y_min) - pad_extent
y_max = max(ax_y_max, cs_y_max) + pad_extent

if set_limits:
ax.set_xlim(x_min, x_max)
ax.set_ylim(y_max, y_min) # (0, 0) is top-left

Expand Down
122 changes: 116 additions & 6 deletions src/spatialdata_plot/pl/render.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import dataclasses
from collections import abc
from collections.abc import Sequence
from collections.abc import Callable, Sequence
from copy import copy
from typing import Any, Literal, cast

Expand Down Expand Up @@ -78,6 +78,9 @@
colormap_with_alpha,
)
from spatialdata_plot.pl.utils import (
_bbox_mask_points,
_bbox_mask_shapes,
_bbox_to_element_space,
_categorical_legend_handles,
_decorate_axs,
_fast_extent,
Expand Down Expand Up @@ -105,6 +108,55 @@
)


def _full_element_norm_range(
color_spec: ColorSpec, cmap_params: CmapParams, transfunc: Callable[..., Any] | None
) -> tuple[float, float] | None:
"""``(vmin, vmax)`` over the full (pre-crop) continuous color vector, after ``transfunc``.

A crop drops rows before drawing, but the matplotlib backend must keep the uncropped colour range
so the zoom matches the full plot. Returns the range resolved over the full element's transfunc'd
values (what the uncropped plot autoscales to), or ``None`` for categorical color or when the user
already fixed vmin/vmax. Non-mutating and applied only on the matplotlib path: the datashader and
image backends never receive it and autoscale over the visible window instead.
"""
if not color_spec.is_continuous:
return None
if cmap_params.norm.vmin is not None and cmap_params.norm.vmax is not None:
return (cmap_params.norm.vmin, cmap_params.norm.vmax)
vec = color_spec.apply_transfunc(transfunc).color_vector if transfunc is not None else color_spec.color_vector
resolved = _resolve_continuous_norm(vec, cmap_params)
return (float(resolved.vmin), float(resolved.vmax))


def _pin_norm_to_full_range(
norm: Normalize,
full_color_spec: ColorSpec | None,
cmap_params: CmapParams,
transfunc: Callable[..., Any] | None,
) -> None:
"""Pin ``norm`` in place to the full (pre-crop) continuous range so a cropped matplotlib zoom keeps
the uncropped plot's colours. No-op when uncropped (``full_color_spec is None``) or categorical.
"""
if full_color_spec is None:
return
full_range = _full_element_norm_range(full_color_spec, cmap_params, transfunc)
if full_range is not None:
norm.vmin, norm.vmax = full_range


def _crop_color_is_stable(color_col: pd.Series | None, palette: Any) -> bool:
"""Whether windowing a labels raster keeps colours stable (else the caller full-renders + clips).

Windowing drops off-window instances before colours resolve. That is colour-stable for numeric
(continuous) columns, pandas-Categorical columns (fixed levels), and explicit palettes; a plain-string
column with the default palette would reshuffle colours as whole categories drop out of the window
(their sorted positions shift), so windowing is unsafe there.
"""
if palette is not None or color_col is None:
return True
return pd.api.types.is_numeric_dtype(color_col) or isinstance(color_col.dtype, pd.CategoricalDtype)


def _get_top_data_array(element: xr.DataArray | DataTree) -> xr.DataArray:
if isinstance(element, DataTree):
return next(iter(next(iter(element.values())).data_vars.values()))
Expand Down Expand Up @@ -700,6 +752,23 @@ def _render_shapes(
if outline_color_spec is not None:
outline_color_spec = outline_color_spec.filter(keep)

# On-the-fly crop: drop shapes outside the box. Keep a reference to the pre-crop colour spec so the
# matplotlib backend can pin the uncropped norm; the range itself is resolved lazily below (only on
# the matplotlib path — datashader autoscales over the window, so it must not pay the full scan).
full_color_spec: ColorSpec | None = None
if fig_params.crop is not None:
elem_bbox = _bbox_to_element_space(sdata_filt.shapes[element], coordinate_system, fig_params.crop)
if elem_bbox is not None: # None = rotation/shear; render full and let axis limits clip
full_color_spec = color_spec # capture before filtering (filter returns a new object)
keep_crop = _bbox_mask_shapes(shapes, elem_bbox)
color_spec = color_spec.filter(keep_crop)
shapes = shapes[keep_crop].reset_index(drop=True)
if len(shapes) == 0:
return
sdata_filt[element] = shapes
if outline_color_spec is not None:
outline_color_spec = outline_color_spec.filter(keep_crop)

color_spec = color_spec.apply_transfunc(render_params.transfunc)

norm = render_params.cmap_params.fresh_norm()
Expand Down Expand Up @@ -917,6 +986,10 @@ def _draw_centroids(xy: np.ndarray, radius: float | None = None) -> None:
cax = _build_ds_colorbar(reduction_bounds, norm, render_params.cmap_params.cmap)

elif method == "matplotlib":
# Under crop, pin the norm to the full-element range so the zoom matches the uncropped plot
# (datashader, handled above, autoscales over the window instead). Resolved here (not in the crop
# block) so the datashader path never pays the full-element scan.
_pin_norm_to_full_range(norm, full_color_spec, render_params.cmap_params, render_params.transfunc)
# Build the paths once and share them across the fill and outline collections (geometry is
# identical; only colours/alpha/linewidth differ), then apply the coordinate-system affine
# once to the shared Path objects rather than once per collection.
Expand Down Expand Up @@ -1451,6 +1524,22 @@ def _render_points(
points = points[keep].reset_index(drop=True)
_reparse_points(sdata_filt, element, points, transformation_in_cs, coordinate_system, col_for_color)

# On-the-fly crop: drop points outside the box. Keep a reference to the pre-crop colour spec so the
# matplotlib backend can pin the uncropped norm; the range itself is resolved lazily below (only on
# the matplotlib path — datashader autoscales over the window, so it must not pay the full scan).
full_color_spec: ColorSpec | None = None
if fig_params.crop is not None:
elem_bbox = _bbox_to_element_space(sdata.points[element], coordinate_system, fig_params.crop)
if elem_bbox is not None: # None = rotation/shear; render full and let axis limits clip
keep_crop = _bbox_mask_points(points["x"].to_numpy(), points["y"].to_numpy(), elem_bbox)
if not keep_crop.any():
return
full_color_spec = color_spec # capture before filtering (filter returns a new object)
color_spec = color_spec.filter(keep_crop)
points = points[keep_crop].reset_index(drop=True)
n_points = len(points) # method auto-threshold below should see the drawn count
_reparse_points(sdata_filt, element, points, transformation_in_cs, coordinate_system, col_for_color)

color_spec = color_spec.apply_transfunc(render_params.transfunc)

trans, trans_data = _prepare_transformation(sdata.points[element], coordinate_system, ax)
Expand Down Expand Up @@ -1509,11 +1598,12 @@ def _render_points(
elif method == "matplotlib":
# matplotlib colors each point by its own value, so resolve the norm to match shapes/labels
# instead of letting ax.scatter autoscale a fresh one. Non-continuous keeps the fresh norm.
norm = (
_resolve_continuous_norm(color_spec.color_vector, render_params.cmap_params)
if color_spec.is_continuous
else render_params.cmap_params.fresh_norm()
)
# Under crop, pin to the full-element range so the zoom matches the uncropped plot.
if color_spec.is_continuous:
norm = _resolve_continuous_norm(color_spec.color_vector, render_params.cmap_params)
_pin_norm_to_full_range(norm, full_color_spec, render_params.cmap_params, render_params.transfunc)
else:
norm = render_params.cmap_params.fresh_norm()
# update axis limits if plot was empty before (necessary if datashader comes after)
update_parameters = not _mpl_ax_contains_elements(ax)
cax = _scatter_points(
Expand Down Expand Up @@ -1756,6 +1846,8 @@ def _render_images(
width=fig_params.fig.get_size_inches()[0],
height=fig_params.fig.get_size_inches()[1],
scale=scale,
crop=fig_params.crop,
extent=extent,
)
# rasterize spatial image if necessary to speed up performance
use_datashader = render_params.method == "datashader"
Expand All @@ -1774,6 +1866,7 @@ def _render_images(
coordinate_system=coordinate_system,
extent=extent,
downsample_method=downsample_method,
crop=fig_params.crop,
)
elif rasterize:
img = _rasterize_if_necessary(
Expand All @@ -1783,6 +1876,7 @@ def _render_images(
height=fig_params.fig.get_size_inches()[1],
coordinate_system=coordinate_system,
extent=extent,
crop=fig_params.crop,
)

channels = img.coords["c"].values.tolist() if render_params.channel is None else render_params.channel
Expand Down Expand Up @@ -2182,6 +2276,14 @@ def _render_labels(
_guard_2d_only(label, element, "labels")
extent = get_extent(label, coordinate_system=coordinate_system)

# Only window the label raster when doing so keeps colours stable; otherwise fall back to full
# render + axis-clip so cropped colours always match the uncropped plot (see _crop_color_is_stable).
crop_labels = fig_params.crop
if crop_labels is not None:
color_col = sdata[table_name].obs.get(col_for_color) if col_for_color and table_name else None
if not _crop_color_is_stable(color_col, palette):
crop_labels = None

# get best scale out of multiscale label
if isinstance(label, DataTree):
label = _multiscale_to_spatial_image(
Expand All @@ -2191,6 +2293,8 @@ def _render_labels(
height=fig_params.fig.get_size_inches()[1],
scale=scale,
is_label=True,
crop=crop_labels,
extent=extent,
)

# spatialdata >= 0.8 rejects non-integer label rasters at the model boundary, but the library
Expand All @@ -2212,6 +2316,7 @@ def _render_labels(
height=fig_params.fig.get_size_inches()[1],
coordinate_system=coordinate_system,
extent=extent,
crop=crop_labels,
)

# the above adds a useless c dimension of 1 (y, x) -> (1, y, x)
Expand All @@ -2221,6 +2326,11 @@ def _render_labels(
# rasterize mask below; compute them once over the (possibly rasterized) raster.
unique_labels = np.unique(label.values)

if crop_labels is not None and not np.any(unique_labels != 0):
# The crop window covers no labels (only background). Nothing to draw; the panel's axis
# limits already clip to the box, so return before the instance-overlap/colour machinery.
return

if table_name is None:
instance_id = unique_labels
table = None
Expand Down
Loading