Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 53 additions & 4 deletions src/spatialdata_plot/pl/_validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import numbers
import warnings
from collections import Counter
from collections.abc import Callable, Sequence
Expand All @@ -13,7 +14,7 @@
import spatialdata as sd
from anndata import AnnData
from matplotlib.axes import Axes
from matplotlib.colors import Colormap, Normalize
from matplotlib.colors import Colormap, Normalize, is_color_like
from matplotlib.figure import Figure
from spatialdata import (
SpatialData,
Expand Down Expand Up @@ -234,12 +235,62 @@ def _validate_show_parameters(
if not isinstance(legend_params, dict):
raise TypeError("Parameter 'legend_params' must be a dictionary or None.")
# `loc` is matplotlib.Legend's native key; `location` aligns with colorbar_params / scalebar_params.
allowed_legend_keys = {"loc", "location", "fontsize", "fontweight", "fontoutline", "na_in_legend"}
# `ncol` is accepted as an alias of `ncols` (matplotlib renamed it in 3.6); we normalise later.
allowed_legend_keys = {
"loc",
"location",
"fontsize",
"fontweight",
"fontoutline",
"na_in_legend",
"ncols",
"ncol",
"markerscale",
"frameon",
"framealpha",
"title_fontsize",
"labelcolor",
}
unknown = set(legend_params) - allowed_legend_keys
if unknown:
raise ValueError(
f"Unknown legend_params key(s): {sorted(unknown)}. Allowed keys: {sorted(allowed_legend_keys)}."
)
_check_legend_styling_params(legend_params)


def _is_number(val: Any) -> bool:
"""Return ``True`` for a real number (incl. numpy scalars), excluding bool."""
return isinstance(val, numbers.Real) and not isinstance(val, bool)


def _resolve_ncols(legend_params: dict[str, Any]) -> Any:
"""Resolve the ``ncols``/``ncol`` alias by precedence (``ncols`` wins), treating None as unset."""
ncols = legend_params.get("ncols")
return legend_params.get("ncol") if ncols is None else ncols


def _check_legend_styling_params(legend_params: dict[str, Any]) -> None:
"""Validate the curated categorical-legend styling keys with actionable errors."""
if (n := _resolve_ncols(legend_params)) is not None and (
not isinstance(n, numbers.Integral) or isinstance(n, bool) or n < 1
):
raise ValueError(f"legend_params 'ncols' must be a positive integer, got {n!r}.")

if (ms := legend_params.get("markerscale")) is not None and (not _is_number(ms) or ms <= 0):
raise ValueError(f"legend_params['markerscale'] must be a positive number, got {ms!r}.")

if (fo := legend_params.get("frameon")) is not None and not isinstance(fo, bool):
raise TypeError(f"legend_params['frameon'] must be a bool, got {fo!r}.")

if (fa := legend_params.get("framealpha")) is not None and (not _is_number(fa) or not 0.0 <= fa <= 1.0):
raise ValueError(f"legend_params['framealpha'] must be a number in [0, 1], got {fa!r}.")

if (tf := legend_params.get("title_fontsize")) is not None and not (_is_number(tf) or isinstance(tf, str)):
raise TypeError(f"legend_params['title_fontsize'] must be a number or a matplotlib size string, got {tf!r}.")

if (lc := legend_params.get("labelcolor")) is not None and not is_color_like(lc):
raise ValueError(f"legend_params['labelcolor'] must be a matplotlib color, got {lc!r}.")


def _check_color_column_collision(
Expand Down Expand Up @@ -608,8 +659,6 @@ def _check_cmap_palette_groups(param_dict: dict[str, Any], element_type: str) ->

# dict palettes (e.g. from make_palette_from_data) bypass groups validation
if isinstance(palette, dict):
from matplotlib.colors import is_color_like

invalid = [f"'{k}': '{v}'" for k, v in palette.items() if not is_color_like(v)]
if invalid:
raise ValueError(f"Dict palette contains invalid color values: {', '.join(invalid)}.")
Expand Down
19 changes: 17 additions & 2 deletions src/spatialdata_plot/pl/basic.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
)
from spatialdata_plot.pl._validate import (
_expand_color_panels,
_resolve_ncols,
_validate_as_points_size,
_validate_graph_render_params,
_validate_image_render_params,
Expand Down Expand Up @@ -1428,8 +1429,11 @@ def show(
See the matplotlib-scalebar documentation for the full list of options.
legend_params : dict[str, Any] | None
Bundled legend options; overrides the matching ``legend_*`` flat kwargs. Accepted keys:
``location`` (or ``loc``), ``fontsize``, ``fontweight``, ``fontoutline``,
``na_in_legend``. Unknown keys raise ``ValueError``.
``location`` (or ``loc``), ``fontsize``, ``fontweight``, ``fontoutline``, ``na_in_legend``,
and the categorical-legend styling overrides ``ncols`` (or ``ncol``), ``markerscale``,
``frameon``, ``framealpha``, ``title_fontsize`` and ``labelcolor``. Styling overrides default
to the current auto behaviour (column count picked from the number of entries, no frame).
Unknown keys raise ``ValueError``.

Returns
-------
Expand Down Expand Up @@ -1850,13 +1854,23 @@ def _build_legend_params(
Keys in the ``legend_params`` dict take precedence over the matching flat ``legend_*``
keyword arguments.
"""
# Curated styling overrides; absent keys stay at LegendParams' None defaults.
styling: dict[str, Any] = {}
if legend_params:
legend_fontsize = legend_params.get("fontsize", legend_fontsize)
legend_fontweight = legend_params.get("fontweight", legend_fontweight)
# `loc` is matplotlib.Legend's native key; `location` aligns with colorbar/scalebar.
legend_loc = legend_params.get("location", legend_params.get("loc", legend_loc))
legend_fontoutline = legend_params.get("fontoutline", legend_fontoutline)
na_in_legend = legend_params.get("na_in_legend", na_in_legend)
styling = {
"legend_ncols": _resolve_ncols(legend_params),
"legend_markerscale": legend_params.get("markerscale"),
"legend_frameon": legend_params.get("frameon"),
"legend_framealpha": legend_params.get("framealpha"),
"legend_title_fontsize": legend_params.get("title_fontsize"),
"legend_labelcolor": legend_params.get("labelcolor"),
}

if legend_loc == "on data":
raise ValueError("legend_loc='on data' is not supported in spatialdata-plot.")
Expand All @@ -1870,6 +1884,7 @@ def _build_legend_params(
colorbar=colorbar,
legend_title=legend_title,
outline_legend_title=outline_legend_title,
**styling,
)


Expand Down
9 changes: 6 additions & 3 deletions src/spatialdata_plot/pl/render.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@
colormap_with_alpha,
)
from spatialdata_plot.pl.utils import (
_apply_legend_overrides,
_bbox_mask_points,
_bbox_mask_shapes,
_bbox_to_element_space,
Expand All @@ -87,6 +88,7 @@
_first_color_per_category,
_join_table_for_element,
_legend_ncol,
_legend_style_kwargs,
_mpl_ax_contains_elements,
_multiscale_to_spatial_image,
_pixel_to_coord,
Expand Down Expand Up @@ -455,6 +457,7 @@ def _add_legend_and_colorbar(
col_for_color if isinstance(col_for_color, str) else None,
),
legend_title=fill_title,
legend_params=legend_params,
)

if outline_has_decorations and outline_cmap_params is not None:
Expand Down Expand Up @@ -594,11 +597,9 @@ def _add_outline_legend(
ax.legend(
handles=outline_handles,
title=title,
frameon=False,
loc=loc,
bbox_to_anchor=anchor,
fontsize=legend_params.legend_fontsize,
ncol=_legend_ncol(len(outline_handles)),
**_legend_style_kwargs(legend_params, default_ncols=_legend_ncol(len(outline_handles)), default_frameon=False),
)


Expand Down Expand Up @@ -1797,6 +1798,8 @@ def _draw_channel_legend(
na_in_legend=False,
multi_panel=needs_multi_panel,
)
# Honour the curated styling overrides on the channel legend too.
_apply_legend_overrides(ax, legend_loc, legend_params)


def _composite_channels(
Expand Down
23 changes: 23 additions & 0 deletions src/spatialdata_plot/pl/render_params.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ class BBox(NamedTuple):
x1: float
y1: float


# Canonical definition for the package; imported by basic.py and utils.py.
# replace with
# from spatialdata._types import ColorLike
Expand Down Expand Up @@ -237,6 +238,28 @@ class LegendParams:
# column, in which case they default to "fill" / "outline" to disambiguate.
legend_title: str | None = None
outline_legend_title: str | None = None
# Curated categorical-legend styling overrides; None => existing auto behaviour.
legend_ncols: int | None = None
legend_markerscale: int | float | None = None
legend_frameon: bool | None = None
legend_framealpha: float | None = None
legend_title_fontsize: int | float | _FontSize | None = None
legend_labelcolor: ColorLike | None = None

@property
def has_style_overrides(self) -> bool:
"""True if any curated styling override is set (else the auto legend behaviour applies)."""
return any(
v is not None
for v in (
self.legend_ncols,
self.legend_markerscale,
self.legend_frameon,
self.legend_framealpha,
self.legend_title_fontsize,
self.legend_labelcolor,
)
)


@dataclass
Expand Down
80 changes: 72 additions & 8 deletions src/spatialdata_plot/pl/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
GraphRenderParams,
ImageRenderParams,
LabelsRenderParams,
LegendParams,
PointsRenderParams,
ScalebarParams,
ShapesRenderParams,
Expand Down Expand Up @@ -412,6 +413,59 @@ def _legend_ncol(n: int) -> int:
return 1 if n <= 14 else 2 if n <= 30 else 3


def _legend_style_kwargs(lp: LegendParams, *, default_ncols: int, default_frameon: bool) -> dict[str, Any]:
"""Curated legend styling as ``ax.legend()`` kwargs, shared by every legend builder.

Unset ``ncols``/``frameon`` fall back to the builder defaults; the rest are omitted so
matplotlib's defaults apply. ``framealpha`` implies ``frameon``, but an explicit
``frameon=False`` wins (matplotlib ignores ``framealpha`` on a hidden frame either way).
"""
kwargs: dict[str, Any] = {
"fontsize": lp.legend_fontsize,
"ncols": default_ncols if lp.legend_ncols is None else lp.legend_ncols,
"frameon": lp.legend_frameon
if lp.legend_frameon is not None
else (default_frameon or lp.legend_framealpha is not None),
}
for key, val in (
("markerscale", lp.legend_markerscale),
("framealpha", lp.legend_framealpha),
("title_fontsize", lp.legend_title_fontsize),
("labelcolor", lp.legend_labelcolor),
):
if val is not None:
kwargs[key] = val
return kwargs


def _apply_legend_overrides(ax: Axes, legend_loc: str | None, lp: LegendParams) -> None:
"""Re-lay-out scanpy's categorical legend to honour the curated styling overrides.

scanpy's ``_add_categorical_legend`` takes none of these kwargs and ``ncols``/``markerscale``
cannot be changed on a built ``Legend``, so we rebuild from its handles/labels/title. Placement
mirrors scanpy so a lone override does not move the legend; unset values preserve its layout.
"""
# No legend is built when placement is suppressed, so there is nothing to restyle.
if not lp.has_style_overrides or legend_loc in (None, "none"):
return
if (leg := ax.get_legend()) is None:
return
placement = (
{"loc": "center left", "bbox_to_anchor": (1, 0.5)} if legend_loc == "right margin" else {"loc": legend_loc}
)
tag = getattr(leg, "_sdata_column", None)
new_leg = ax.legend(
list(leg.legend_handles),
[t.get_text() for t in leg.get_texts()],
title=leg.get_title().get_text() or None,
# `_ncols`: private column-count attr, stable since mpl 3.6 (floor >=3.8); no public getter.
**_legend_style_kwargs(lp, default_ncols=leg._ncols, default_frameon=leg.get_frame_on()),
**placement,
)
if tag is not None:
new_leg._sdata_column = tag # type: ignore[attr-defined]


def _categorical_legend_handles(ax: Axes, color_map: Mapping[Any, Any], na_hex: str | None = None) -> list[Any]:
"""Empty-scatter handles (colored dots) for a categorical legend, with an optional NA entry."""
handles = [ax.scatter([], [], c=color, label=str(cat)) for cat, color in color_map.items()]
Expand All @@ -427,23 +481,22 @@ def _stack_categorical_legend(
na_hex: str | None,
title: str | None,
column: str | None,
legend_fontsize: int | float | _FontSize | None,
lp: LegendParams,
) -> None:
"""Build the 2nd+ categorical legend on a shared axes without dropping existing ones (#364).

Placement and the column auto-title are finalized later by ``_setup_stacked_legends``.
Curated styling overrides are honoured here directly since we own this ``ax.legend()``.
"""
handles = _categorical_legend_handles(ax, color_mapping, na_hex)
if (cur := ax.get_legend()) is not None:
ax.add_artist(cur) # else ax.legend() below drops it
new_leg = ax.legend(
handles=handles,
title=title,
frameon=False,
loc="upper left",
bbox_to_anchor=(1.02, 1.0),
fontsize=legend_fontsize,
ncol=_legend_ncol(len(handles)),
**_legend_style_kwargs(lp, default_ncols=_legend_ncol(len(handles)), default_frameon=False),
)
new_leg._sdata_column = column # type: ignore[attr-defined]

Expand Down Expand Up @@ -492,7 +545,10 @@ def _decorate_axs(
colorbar_requests: list[ColorbarSpec] | None = None,
colorbar_label: str | None = None,
legend_title: str | None = None,
legend_params: LegendParams | None = None,
) -> Axes:
# Curated styling overrides; the flat legend_* args above stay authoritative otherwise.
lp = legend_params if legend_params is not None else LegendParams()
if value_to_plot is not None:
# if only dots were plotted without an associated value
# there is not need to plot a legend or a colorbar
Expand Down Expand Up @@ -533,7 +589,7 @@ def _decorate_axs(
na_hex=na_hex,
title=legend_title,
column=value_to_plot,
legend_fontsize=legend_fontsize,
lp=lp,
)
else:
_add_categorical_legend(
Expand All @@ -548,6 +604,7 @@ def _decorate_axs(
na_in_legend=na_in_legend,
multi_panel=fig_params.axs is not None,
)
_apply_legend_overrides(ax, legend_loc, lp) # before tagging: may rebuild the legend
# Tag with the column; the column auto-title (when 2+ legends) is applied in
# `_setup_stacked_legends`. An explicit title wins now.
if (legend := ax.get_legend()) is not None:
Expand Down Expand Up @@ -821,7 +878,9 @@ def _rasterize_to_bbox(
"""
x0, y0, x1, y1 = bbox
target_unit_to_pixels = min(target_y_dims / (y1 - y0), target_x_dims / (x1 - x0))
return rasterize(image, ("y", "x"), [y0, x0], [y1, x1], coordinate_system, target_unit_to_pixels=target_unit_to_pixels)
return rasterize(
image, ("y", "x"), [y0, x0], [y1, x1], coordinate_system, target_unit_to_pixels=target_unit_to_pixels
)


def _rasterize_if_necessary(
Expand Down Expand Up @@ -934,7 +993,10 @@ def _datashader_window_image(
# aggregated grid then lines up with ``base`` (same window, same resolution).
cvs = ds.Canvas(plot_width=out_x, plot_height=out_y, x_range=(px0, px1), y_range=(py0, py1))
agg = np.stack(
[np.asarray(cvs.raster(src.isel(c=i), downsample_method=downsample_method).values) for i in range(src.sizes["c"])],
[
np.asarray(cvs.raster(src.isel(c=i), downsample_method=downsample_method).values)
for i in range(src.sizes["c"])
],
axis=0,
)
base.values = agg.astype(base.dtype, copy=False)
Expand Down Expand Up @@ -969,7 +1031,9 @@ def _rasterize_if_necessary_datashader(
target_x_dims = int(dpi * width)

if crop is not None:
windowed = _datashader_window_image(image, crop, coordinate_system, target_x_dims, target_y_dims, downsample_method)
windowed = _datashader_window_image(
image, crop, coordinate_system, target_x_dims, target_y_dims, downsample_method
)
if windowed is not None:
return windowed
# rotation/shear or empty window: fall through to the full render (axis limits clip)
Expand Down
Binary file added tests/_images/LegendParams_legend_framealpha.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added tests/_images/LegendParams_legend_frameon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added tests/_images/LegendParams_legend_labelcolor.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added tests/_images/LegendParams_legend_markerscale.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added tests/_images/LegendParams_legend_ncols.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading