Skip to content

Commit ff8e41b

Browse files
authored
Merge branch 'main' into fix/issue-449
2 parents ee32ffa + 68ef2ef commit ff8e41b

20 files changed

Lines changed: 1343 additions & 94 deletions

‎src/spatialdata_plot/pl/_datashader.py‎

Lines changed: 138 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from __future__ import annotations
77

8+
from copy import copy
89
from typing import Any, Literal
910

1011
import dask.dataframe as dd
@@ -19,13 +20,15 @@
1920
from spatialdata_plot._logging import logger
2021
from spatialdata_plot.pl.render_params import Color, FigParams, ShapesRenderParams, _DsReduction
2122
from spatialdata_plot.pl.utils import (
23+
_DS_REDUCTION_FUNCS,
2224
_ax_show_and_transform,
2325
_convert_alpha_to_datashader_range,
2426
_create_image_from_datashader_result,
2527
_datashader_aggregate_with_function,
2628
_datashader_map_aggregate_to_color,
2729
_datshader_get_how_kw_for_spread,
2830
_hex_no_alpha,
31+
_make_continuous_mappable,
2932
)
3033

3134
# ---------------------------------------------------------------------------
@@ -36,6 +39,11 @@
3639
# missing (NaN) values. Must not collide with realistic user category names.
3740
_DS_NAN_CATEGORY = "ds_nan"
3841

42+
# Private column name under which the outline color vector is attached to the
43+
# datashader rasterizer element. Must not collide with a real user column;
44+
# the leading/trailing dunders are deliberate.
45+
_OUTLINE_INTERNAL_COL = "__sdp_outline_col__"
46+
3947
# ---------------------------------------------------------------------------
4048
# Low-level helpers
4149
# ---------------------------------------------------------------------------
@@ -225,6 +233,7 @@ def _ds_shade_continuous(
225233
na_color_hex: str,
226234
spread_px: int | None = None,
227235
ds_reduction: _DsReduction | None = None,
236+
how: str = "linear",
228237
) -> tuple[Any, Any | None, tuple[Any, Any] | None]:
229238
"""Shade a continuous datashader aggregate, optionally applying spread and NaN coloring.
230239
@@ -253,6 +262,7 @@ def _ds_shade_continuous(
253262
min_alpha=_convert_alpha_to_datashader_range(alpha),
254263
span=color_span,
255264
clip=norm.clip,
265+
how=how,
256266
)
257267
shaded = _apply_user_alpha(shaded, alpha)
258268

@@ -276,6 +286,8 @@ def _ds_shade_categorical(
276286
color_vector: Any,
277287
alpha: float,
278288
spread_px: int | None = None,
289+
how: str = "linear",
290+
density: bool = False,
279291
) -> Any:
280292
"""Shade a categorical or no-color datashader aggregate."""
281293
ds_cmap = None
@@ -284,12 +296,20 @@ def _ds_shade_categorical(
284296
if isinstance(ds_cmap, str) and ds_cmap[0] == "#":
285297
ds_cmap = _hex_no_alpha(ds_cmap)
286298

299+
# The default min_alpha (~254) is a near-full-opacity floor — right for scatter
300+
# plots, but it collapses the count-driven alpha range and makes categorical
301+
# density read as a flat hue cloud. Drop the floor under density so per-pixel
302+
# alpha can actually encode count. A small non-zero floor (~15%) keeps the
303+
# sparse edges visible under density_how="linear" instead of vanishing.
304+
min_alpha = 40.0 if density else _convert_alpha_to_datashader_range(alpha)
305+
287306
agg_to_shade = ds.tf.spread(agg, px=spread_px) if spread_px is not None else agg
288307
shaded = _datashader_map_aggregate_to_color(
289308
agg_to_shade,
290309
cmap=ds_cmap,
291310
color_key=color_key,
292-
min_alpha=_convert_alpha_to_datashader_range(alpha),
311+
min_alpha=min_alpha,
312+
how=how,
293313
)
294314
return _apply_user_alpha(shaded, alpha)
295315

@@ -330,8 +350,16 @@ def _render_ds_outlines(
330350
factor: float,
331351
x_min: float = 0.0,
332352
y_min: float = 0.0,
353+
outline_color_vector: Any | None = None,
354+
outline_color_source_vector: pd.Series | None = None,
333355
) -> None:
334-
"""Aggregate, shade, and render shape outlines (outer and inner) with datashader."""
356+
"""Aggregate, shade, and render shape outlines (outer and inner) with datashader.
357+
358+
When ``outline_color_vector`` is provided, the outer outline is colored per-shape
359+
via ``ds.by`` (categorical) or a numeric reduction (continuous) instead of a
360+
single literal color. The two-outline form is rejected at validation, so this
361+
only affects the outer outline.
362+
"""
335363
ds_lw_factor = fig_params.fig.dpi / 72
336364
assert len(render_params.outline_alpha) == 2 # noqa: S101
337365

@@ -344,6 +372,24 @@ def _render_ds_outlines(
344372
alpha = render_params.outline_alpha[idx]
345373
if alpha <= 0:
346374
continue
375+
if idx == 0 and outline_color_vector is not None:
376+
_render_ds_outline_by_column(
377+
cvs=cvs,
378+
transformed_element=transformed_element,
379+
outline_color_vector=outline_color_vector,
380+
outline_color_source_vector=outline_color_source_vector,
381+
cmap_params=render_params.cmap_params,
382+
ds_reduction=render_params.ds_reduction,
383+
line_width=linewidth * ds_lw_factor,
384+
alpha=alpha,
385+
fig_params=fig_params,
386+
ax=ax,
387+
factor=factor,
388+
x_min=x_min,
389+
y_min=y_min,
390+
zorder=render_params.zorder,
391+
)
392+
continue
347393
agg_outline = cvs.line(
348394
transformed_element,
349395
geometry="geometry",
@@ -361,6 +407,95 @@ def _render_ds_outlines(
361407
_ax_show_and_transform(rgba, trans, ax, zorder=render_params.zorder)
362408

363409

410+
def _render_ds_outline_by_column(
411+
cvs: Any,
412+
transformed_element: Any,
413+
outline_color_vector: Any | None,
414+
outline_color_source_vector: pd.Series | None,
415+
cmap_params: Any,
416+
ds_reduction: _DsReduction | None,
417+
line_width: float,
418+
alpha: float,
419+
fig_params: FigParams,
420+
ax: matplotlib.axes.SubplotBase,
421+
factor: float,
422+
x_min: float,
423+
y_min: float,
424+
zorder: int,
425+
) -> None:
426+
"""Aggregate + shade an outline colored by an obs column via datashader.
427+
428+
Two-outline form is not supported for column-driven outline coloring,
429+
so this only renders the outer outline.
430+
"""
431+
color_by_categorical = outline_color_source_vector is not None
432+
na_color_hex = _hex_no_alpha(cmap_params.na_color.get_hex())
433+
434+
# Attach the outline vector under a private column name so a fill column with the
435+
# same key never gets overwritten. Assign positionally (via a Series indexed to the
436+
# element) — `.assign(col=series)` aligns by index, which silently inserts NaN when
437+
# the element's index is non-contiguous (e.g. after an inner-join). The NaNs would
438+
# then be lifted to the `ds_nan` sentinel and one polygon's outline would render as
439+
# `na_color` instead of its real category.
440+
transformed_element = transformed_element.copy()
441+
if color_by_categorical:
442+
cat = pd.Categorical(outline_color_source_vector)
443+
attach_cat = _inject_ds_nan_sentinel(pd.Series(cat))
444+
transformed_element[_OUTLINE_INTERNAL_COL] = pd.Categorical(
445+
attach_cat.to_numpy(), categories=attach_cat.cat.categories
446+
)
447+
else:
448+
transformed_element[_OUTLINE_INTERNAL_COL] = np.asarray(outline_color_vector)
449+
450+
if color_by_categorical:
451+
agg_outline = cvs.line(
452+
transformed_element,
453+
geometry="geometry",
454+
agg=ds.by(_OUTLINE_INTERNAL_COL, ds.count()),
455+
line_width=line_width,
456+
)
457+
color_key = _build_datashader_color_key(
458+
_coerce_categorical_source(transformed_element[_OUTLINE_INTERNAL_COL]),
459+
outline_color_vector,
460+
na_color_hex,
461+
)
462+
shaded = ds.tf.shade(
463+
agg_outline,
464+
color_key=color_key,
465+
min_alpha=_convert_alpha_to_datashader_range(alpha),
466+
how="linear",
467+
)
468+
else:
469+
reduction_name = ds_reduction if ds_reduction is not None else "max"
470+
try:
471+
reduction_function = _DS_REDUCTION_FUNCS[reduction_name](column=_OUTLINE_INTERNAL_COL)
472+
except KeyError as e:
473+
raise ValueError(
474+
f"Reduction '{reduction_name}' is not supported. Use one of: {', '.join(_DS_REDUCTION_FUNCS.keys())}."
475+
) from e
476+
agg_outline = cvs.line(
477+
transformed_element,
478+
geometry="geometry",
479+
agg=reduction_function,
480+
line_width=line_width,
481+
)
482+
# Apply the user-provided norm (vmin/vmax) the same way the fill path does so
483+
# an explicit Normalize takes effect for the outline cmap.
484+
norm = copy(cmap_params.norm)
485+
agg_outline, color_span = _apply_ds_norm(agg_outline, norm)
486+
shaded = ds.tf.shade(
487+
agg_outline,
488+
cmap=cmap_params.cmap,
489+
span=color_span,
490+
min_alpha=_convert_alpha_to_datashader_range(alpha),
491+
how="linear",
492+
)
493+
494+
shaded = _apply_user_alpha(shaded, alpha)
495+
rgba, trans = _create_image_from_datashader_result(shaded, factor, ax, x_min, y_min)
496+
_ax_show_and_transform(rgba, trans, ax, zorder=zorder)
497+
498+
364499
def _build_ds_colorbar(
365500
reduction_bounds: tuple[Any, Any] | None,
366501
norm: Normalize,
@@ -374,12 +509,4 @@ def _build_ds_colorbar(
374509
return None
375510
vmin = reduction_bounds[0].values if norm.vmin is None else norm.vmin
376511
vmax = reduction_bounds[1].values if norm.vmax is None else norm.vmax
377-
if (norm.vmin is not None or norm.vmax is not None) and norm.vmin == norm.vmax:
378-
assert norm.vmin is not None
379-
assert norm.vmax is not None
380-
vmin = norm.vmin - 0.5
381-
vmax = norm.vmin + 0.5
382-
return ScalarMappable(
383-
norm=matplotlib.colors.Normalize(vmin=vmin, vmax=vmax),
384-
cmap=cmap,
385-
)
512+
return _make_continuous_mappable(vmin, vmax, cmap)

‎src/spatialdata_plot/pl/basic.py‎

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -243,13 +243,18 @@ def render_shapes(
243243
Width of the border. If 2 values are given (tuple), 2 borders are shown with these widths (outer & inner).
244244
If `outline_color` and/or `outline_alpha` are used to indicate that one/two outlines should be drawn, the
245245
default outline widths 1.5 and 0.5 are used for outer/only and inner outline respectively.
246-
outline_color : ColorLike | tuple[ColorLike], optional
246+
outline_color : ColorLike | tuple[ColorLike] | str, optional
247247
Color of the border. Can either be a named color ("red"), a hex representation ("#000000") or a list of
248248
floats that represent RGB/RGBA values (1.0, 0.0, 0.0, 1.0). If the hex representation includes alpha, e.g.
249249
"#000000ff", and `outline_alpha` is not given, this value controls the opacity of the outline. If 2 values
250250
are given (tuple), 2 borders are shown with these colors (outer & inner). If `outline_width` and/or
251251
`outline_alpha` are used to indicate that one/two outlines should be drawn, the default outline colors
252252
"#000000" and "#ffffff are used for outer/only and inner outline respectively.
253+
A string that is not a recognized color is interpreted as a column key (in `obs` of the annotating table
254+
or in the element's own dataframe), mirroring how ``color`` is parsed. The outline is then colored
255+
per-shape using the same ``palette`` / ``cmap`` / ``na_color`` as the fill. When both ``color`` and
256+
``outline_color`` resolve to columns, two stacked legends are drawn. Column-based outline coloring is
257+
only supported for a single outline (not the 2-tuple form).
253258
outline_alpha : float | int | tuple[float | int, float | int] | None, optional
254259
Alpha value for the outline of shapes. Invisible by default, meaning outline_alpha=0.0 if both outline_color
255260
and outline_width are not specified. Else, outlines are rendered with the alpha implied by outline_color, or
@@ -346,6 +351,8 @@ def render_shapes(
346351
element=element,
347352
color=param_values["color"],
348353
col_for_color=param_values["col_for_color"],
354+
col_for_outline_color=param_values["col_for_outline_color"],
355+
outline_table_name=param_values["outline_table_name"],
349356
groups=param_values["groups"],
350357
scale=param_values["scale"],
351358
outline_params=outline_params,
@@ -387,6 +394,8 @@ def render_points(
387394
colorbar: bool | str | None = "auto",
388395
colorbar_params: dict[str, object] | None = None,
389396
datashader_reduction: _DsReduction | None = None,
397+
density: bool = False,
398+
density_how: Literal["linear", "log", "cbrt", "eq_hist"] = "linear",
390399
transfunc: Callable[[float], float] | None = None,
391400
) -> sd.SpatialData:
392401
"""
@@ -457,13 +466,38 @@ def render_points(
457466
in another column of ``var``. Mimics scanpy's ``gene_symbols`` parameter.
458467
datashader_reduction : Literal["sum", "mean", "any", "count", "std", "var", "max", "min"] | None, optional
459468
Reduction method for datashader when coloring by continuous values. When ``None``, defaults to ``"sum"``.
469+
density : bool, default False
470+
Render the points as a 2-D count density via datashader instead of plotting individual markers.
471+
When ``True``, ``method`` is forced to ``"datashader"`` (passing ``method="matplotlib"`` raises).
472+
Density supports ``color=None`` (plain density) or a categorical ``color`` column (per-category
473+
density via :func:`datashader.by`). A continuous ``color`` column or a literal color value
474+
(e.g. ``"red"``) raises an error. Under ``density=True`` the following parameters are ignored
475+
(with a warning if explicitly set): ``size``, ``transfunc``, ``norm.vmin/vmax``, and
476+
``datashader_reduction``.
477+
density_how : Literal["linear", "log", "cbrt", "eq_hist"], default "linear"
478+
How datashader maps aggregated counts to color intensity. ``"linear"`` (default) keeps the
479+
colorbar axis as a count; ``"log"`` and ``"cbrt"`` compress dynamic range; ``"eq_hist"``
480+
equalizes the histogram (rank-based, surfaces the most structure but the colorbar axis is
481+
no longer a count). Ignored when ``density=False``.
460482
transfunc : Callable[[float], float] | None, optional
461483
Optional transformation applied to the continuous color vector before normalization and colormap mapping.
462484
463485
Returns
464486
-------
465487
sd.SpatialData
466488
A copy of the SpatialData object with the rendering parameters stored in its plotting tree.
489+
490+
Examples
491+
--------
492+
Plain density of all transcripts:
493+
494+
>>> sdata.pl.render_points("transcripts", density=True).pl.show()
495+
496+
Per-gene density with a categorical palette:
497+
498+
>>> sdata.pl.render_points(
499+
... "transcripts", color="gene", groups=["Gad1", "Slc17a7"], palette="tab20", density=True
500+
... ).pl.show()
467501
"""
468502
params_dict = _validate_points_render_params(
469503
self._sdata,
@@ -482,6 +516,10 @@ def render_points(
482516
colorbar=colorbar,
483517
colorbar_params=colorbar_params,
484518
gene_symbols=gene_symbols,
519+
density=density,
520+
density_how=density_how,
521+
transfunc=transfunc,
522+
method=method,
485523
)
486524

487525
if method is not None:
@@ -490,6 +528,9 @@ def render_points(
490528
if method not in ["matplotlib", "datashader"]:
491529
raise ValueError("Parameter 'method' must be either 'matplotlib' or 'datashader'.")
492530

531+
if density and method is None:
532+
method = "datashader"
533+
493534
sdata = self._copy()
494535
sdata = _verify_plotting_tree(sdata)
495536
n_steps = len(sdata.plotting_tree.keys())
@@ -517,6 +558,8 @@ def render_points(
517558
ds_reduction=param_values["ds_reduction"],
518559
colorbar=param_values["colorbar"],
519560
colorbar_params=param_values["colorbar_params"],
561+
density=density,
562+
density_how=density_how,
520563
)
521564
n_steps += 1
522565

@@ -811,11 +854,14 @@ def render_labels(
811854
fill_alpha : float | int | None, optional
812855
Alpha value for the fill of the labels. By default, it is set to 0.4 or, if a color is given that implies
813856
an alpha, that value is used for `fill_alpha`.
814-
outline_color : ColorLike | None
857+
outline_color : ColorLike | str | None
815858
Color of the outline of the labels. Can either be a named color ("red"), a hex representation
816859
("#000000") or a list of floats that represent RGB/RGBA values (1.0, 0.0, 0.0, 1.0). If ``None``,
817860
the outline inherits from the ``color`` parameter when it is a literal color, or uses data-driven
818861
per-label colors when ``color`` refers to a column.
862+
A string that is not a recognized color is interpreted as a column key (in `obs` of the annotating
863+
table), mirroring how ``color`` is parsed. The outline is then colored per-label using the same
864+
``palette`` / ``cmap`` / ``na_color`` as the fill.
819865
scale : str | None
820866
Influences the resolution of the rendering. Possibilities for setting this parameter:
821867
1) None (default). The image is rasterized to fit the canvas size. For multiscale images, the best scale
@@ -881,6 +927,8 @@ def render_labels(
881927
element=element,
882928
color=param_values["color"],
883929
col_for_color=param_values["col_for_color"],
930+
col_for_outline_color=param_values["col_for_outline_color"],
931+
outline_table_name=param_values["outline_table_name"],
884932
groups=param_values["groups"],
885933
contour_px=param_values["contour_px"],
886934
cmap_params=cmap_params,
@@ -1047,6 +1095,8 @@ def show(
10471095
na_in_legend: bool = True,
10481096
colorbar: bool = True,
10491097
colorbar_params: dict[str, object] | None = None,
1098+
legend_title: str | None = None,
1099+
outline_legend_title: str | None = None,
10501100
wspace: float | None = None,
10511101
hspace: float = 0.25,
10521102
ncols: int = 4,
@@ -1091,6 +1141,12 @@ def show(
10911141
colorbar_params : dict[str, object] | None
10921142
Global overrides passed to colorbars for all axes. Accepts the same keys as per-layer ``colorbar_params``
10931143
(e.g., ``loc``, ``width``, ``pad``, ``label``).
1144+
legend_title : str | None
1145+
Title for the fill categorical legend. When both fill and outline are colored by an obs column, the
1146+
two legends default to ``"fill"`` / ``"outline"`` to disambiguate; pass an explicit string to override
1147+
the fill title. Set to ``None`` (default) to keep the auto-title behavior.
1148+
outline_legend_title : str | None
1149+
Title for the outline categorical legend. Mirrors ``legend_title`` for the outline channel.
10941150
wspace : float | None
10951151
Horizontal spacing between panels (passed to :class:`matplotlib.gridspec.GridSpec`).
10961152
hspace : float, default 0.25
@@ -1327,6 +1383,8 @@ def show(
13271383
legend_fontoutline=legend_fontoutline,
13281384
na_in_legend=na_in_legend,
13291385
colorbar=colorbar,
1386+
legend_title=legend_title,
1387+
outline_legend_title=outline_legend_title,
13301388
)
13311389

13321390
def _draw_colorbar(

0 commit comments

Comments
 (0)