Skip to content

Commit 01120e5

Browse files
timtreisclaude
andcommitted
fix(labels): separate per-render categorical legends with distinct palettes (#364)
Stacking multiple categorical `render_labels` calls produced a single merged legend (scanpy's bare `ax.legend()` auto-collected every labeled artist and dropped the previous legend) and reused the same default palette, so the overlaid layers were indistinguishable. - Each categorical render now gets its own legend; 2nd+ legends are built with explicit handles and laid out left-to-right along the top margin in a post-render pass (`_layout_panel_legends_rightward`), robust to tall legends and `constrained_layout` reflow. Only legends this code owns (tagged `_sdata_column`) are touched, so fill/outline and channel legends keep their placement. - Later categorical label renders offset into the palette to skip colors already used on the panel (`_next_palette_colors`), keeping layers distinct. - A legend is titled by its column only when 2+ legends share an axis (to tell them apart); a lone legend stays untitled. - Shared helpers `_legend_ncol`, `_categorical_legend_handles`, and `_default_categorical_palette` remove duplicated logic. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b4ad6f7 commit 01120e5

5 files changed

Lines changed: 252 additions & 42 deletions

File tree

src/spatialdata_plot/pl/_color.py

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1160,6 +1160,29 @@ def _modify_categorical_color_mapping(
11601160
return modified_mapping
11611161

11621162

1163+
def _default_categorical_palette(n: int) -> list[str]:
1164+
"""Return the scanpy default categorical palette sized for ``n`` categories (grey beyond 103)."""
1165+
if n <= 20:
1166+
return list(default_20)
1167+
if n <= 28:
1168+
return list(default_28)
1169+
if n <= len(default_102):
1170+
return list(default_102)
1171+
logger.info("input has more than 103 categories. Uniform 'grey' color will be used for all categories.")
1172+
return ["grey"] * n
1173+
1174+
1175+
def _next_palette_colors(used_colors: set[str], n: int) -> list[str]:
1176+
"""Pick ``n`` default-palette colors skipping ``used_colors`` (keeps a 2nd categorical render distinct, #364).
1177+
1178+
Falls back to the full palette if too few unused colors remain.
1179+
"""
1180+
used_norm = {to_hex(to_rgba(c)) for c in used_colors}
1181+
pool = _default_categorical_palette(n + len(used_norm))
1182+
unused = [c for c in pool if to_hex(to_rgba(c)) not in used_norm]
1183+
return (unused if len(unused) >= n else pool)[:n]
1184+
1185+
11631186
def _get_default_categorial_color_mapping(
11641187
color_source_vector: ArrayLike | pd.Series[CategoricalDtype],
11651188
cmap_params: CmapParams | None = None,
@@ -1179,17 +1202,8 @@ def _get_default_categorial_color_mapping(
11791202
else:
11801203
palette = None
11811204

1182-
# Fall back to default palettes if needed
11831205
if palette is None:
1184-
if len_cat <= 20:
1185-
palette = default_20
1186-
elif len_cat <= 28:
1187-
palette = default_28
1188-
elif len_cat <= len(default_102): # 103 colors
1189-
palette = default_102
1190-
else:
1191-
palette = ["grey"] * len_cat
1192-
logger.info("input has more than 103 categories. Uniform 'grey' color will be used for all categories.")
1206+
palette = _default_categorical_palette(len_cat)
11931207

11941208
return dict(zip(color_source_vector.categories, palette[:len_cat], strict=True))
11951209

src/spatialdata_plot/pl/basic.py

Lines changed: 72 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,9 @@
2020
from geopandas import GeoDataFrame
2121
from matplotlib.axes import Axes
2222
from matplotlib.backend_bases import RendererBase
23-
from matplotlib.colors import Colormap, LogNorm, Normalize
23+
from matplotlib.colors import Colormap, LogNorm, Normalize, to_hex, to_rgba
2424
from matplotlib.figure import Figure
25+
from matplotlib.legend import Legend
2526
from mpl_toolkits.axes_grid1.inset_locator import inset_axes
2627
from spatialdata._utils import _deprecation_alias
2728
from spatialdata.transformations.operations import get_transformation
@@ -31,6 +32,7 @@
3132
from spatialdata_plot._logging import _log_context, logger
3233
from spatialdata_plot.pl._color import (
3334
_maybe_set_colors,
35+
_next_palette_colors,
3436
_prepare_cmap_norm,
3537
_set_outline,
3638
)
@@ -1584,6 +1586,11 @@ def show(
15841586

15851587
_draw_scalebar(ax, scalebar_params_obj, panel_idx=i)
15861588

1589+
if fig_params.fig is not None:
1590+
for panel_ax in fig_params.axs if fig_params.axs is not None else [fig_params.ax]:
1591+
if isinstance(panel_ax, Axes):
1592+
_layout_panel_legends_rightward(panel_ax, fig_params.fig)
1593+
15871594
_layout_pending_colorbars(pending_colorbars, fig_params, colorbar_params)
15881595

15891596
if fig_params.fig is not None and save is not None:
@@ -1870,6 +1877,42 @@ def _draw_colorbar(
18701877
trackers_axes[location] = pad_axes + (bbox_axes.width if vertical else bbox_axes.height)
18711878

18721879

1880+
def _layout_panel_legends_rightward(ax: Axes, fig: Figure, gap: float = 0.01) -> None:
1881+
"""Lay the per-render categorical legends (#364) left-to-right along the top of the right margin.
1882+
1883+
Only legends this code created (tagged ``_sdata_column``) are touched, so fill/outline and
1884+
channel legends keep their own placement. Layout is in figure-fraction so the axis shrink that
1885+
spreading them triggers under ``constrained_layout`` can't rescale the measured widths into overlap.
1886+
"""
1887+
legends = [c for c in ax.get_children() if isinstance(c, Legend) and hasattr(c, "_sdata_column")]
1888+
if len(legends) < 2:
1889+
return
1890+
# 2+ legends share the axis: title each by its column so they can be told apart (an explicit
1891+
# title set earlier stays as-is).
1892+
for leg in legends:
1893+
if not leg.get_title().get_text():
1894+
leg.set_title(leg._sdata_column)
1895+
# Let constrained_layout settle the axes, then freeze it: otherwise it shrinks the axes to
1896+
# "make room" for the margin legends (squashing the plot, leaving a gap). Frozen, the legends
1897+
# still count for `bbox_inches="tight"` on save. Reached only for panels with 2+ legends.
1898+
fig.canvas.draw()
1899+
fig.set_layout_engine("none")
1900+
invf = fig.transFigure.inverted()
1901+
ax_bb = ax.get_window_extent().transformed(invf)
1902+
left, top = ax_bb.x1 + gap, ax_bb.y1
1903+
# Anchor all at one point and settle, so widths are measured in a single consistent layout state.
1904+
for leg in legends:
1905+
leg.set_bbox_to_anchor((left, top), transform=fig.transFigure)
1906+
if hasattr(leg, "set_loc"):
1907+
leg.set_loc("upper left")
1908+
fig.canvas.draw()
1909+
widths = [leg.get_window_extent().transformed(invf).width for leg in legends]
1910+
x = left
1911+
for leg, w in zip(legends, widths, strict=True):
1912+
leg.set_bbox_to_anchor((x, top), transform=fig.transFigure)
1913+
x += w + gap
1914+
1915+
18731916
def _layout_pending_colorbars(
18741917
pending_colorbars: list[tuple[Axes, list[ColorbarSpec]]],
18751918
fig_params: FigParams,
@@ -1944,19 +1987,32 @@ def _should_rasterize(
19441987
return scale is None or (isinstance(scale, str) and scale != "full" and (dpi is not None or figsize is not None))
19451988

19461989

1947-
def _maybe_set_label_colors(sdata: sd.SpatialData, render_params: LabelsRenderParams) -> None:
1948-
"""Materialize a categorical palette on the table annotating a labels element, if applicable."""
1990+
def _maybe_set_label_colors(
1991+
sdata: sd.SpatialData,
1992+
render_params: LabelsRenderParams,
1993+
used_colors: set[str] | None = None,
1994+
) -> None:
1995+
"""Materialize a categorical palette on the table annotating a labels element, if applicable.
1996+
1997+
``used_colors`` accumulates the colors already taken by earlier categorical label renders on
1998+
the same panel. When a column's colors are auto-generated (no user palette, not already in
1999+
``.uns``), they are shifted to skip ``used_colors`` so stacked legends stay distinct (#364).
2000+
"""
19492001
table = render_params.table_name
1950-
if table is None or render_params.col_for_color is None:
2002+
col = render_params.col_for_color
2003+
if table is None or col is None:
19512004
return
1952-
colors = sc.get.obs_df(sdata[table], [render_params.col_for_color])
1953-
if isinstance(colors[render_params.col_for_color].dtype, pd.CategoricalDtype):
1954-
_maybe_set_colors(
1955-
source=sdata[table],
1956-
target=sdata[table],
1957-
key=render_params.col_for_color,
1958-
palette=render_params.palette,
1959-
)
2005+
colors = sc.get.obs_df(sdata[table], [col])
2006+
if not isinstance(colors[col].dtype, pd.CategoricalDtype):
2007+
return
2008+
adata = sdata[table]
2009+
color_key = f"{col}_colors"
2010+
if render_params.palette is None and used_colors and color_key not in adata.uns:
2011+
adata.uns[color_key] = _next_palette_colors(used_colors, len(colors[col].cat.categories))
2012+
else:
2013+
_maybe_set_colors(source=adata, target=adata, key=col, palette=render_params.palette)
2014+
if used_colors is not None and color_key in adata.uns:
2015+
used_colors.update(to_hex(to_rgba(c)) for c in adata.uns[color_key])
19602016

19612017

19622018
def _render_panel(
@@ -1985,6 +2041,9 @@ def _render_panel(
19852041
"""
19862042
wants = dict.fromkeys(("images", "labels", "points", "shapes"), False)
19872043
wanted_elements: list[str] = []
2044+
# Colors already taken by categorical label renders on this panel, so later renders can
2045+
# avoid reusing them and their stacked legends stay distinct (#364).
2046+
used_label_colors: set[str] = set()
19882047

19892048
for cmd, params in render_cmds:
19902049
# Skip render entries that belong to a different color panel. Entries with no
@@ -2033,7 +2092,7 @@ def _render_panel(
20332092
cast("ImageRenderParams | LabelsRenderParams", element_params), dpi, figsize
20342093
)
20352094
if cmd == "render_labels":
2036-
_maybe_set_label_colors(sdata, cast(LabelsRenderParams, element_params))
2095+
_maybe_set_label_colors(sdata, cast(LabelsRenderParams, element_params), used_label_colors)
20372096
_RENDERERS[cmd](**kwargs)
20382097

20392098
# Panel finalization depends only on per-panel values, so run it once after the loop.

src/spatialdata_plot/pl/render.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@
8181
_decorate_axs,
8282
_fast_extent,
8383
_join_table_for_element,
84+
_legend_ncol,
8485
_mpl_ax_contains_elements,
8586
_multiscale_to_spatial_image,
8687
_pixel_to_coord,
@@ -548,7 +549,7 @@ def _add_outline_legend(
548549
loc=loc,
549550
bbox_to_anchor=anchor,
550551
fontsize=legend_params.legend_fontsize,
551-
ncol=(1 if len(outline_handles) <= 14 else 2 if len(outline_handles) <= 30 else 3),
552+
ncol=_legend_ncol(len(outline_handles)),
552553
)
553554

554555

@@ -697,8 +698,7 @@ def _render_shapes(
697698
nan_count = int(pd.isna(cv).sum())
698699
if nan_count:
699700
logger.warning(
700-
f"Found {nan_count} NaN values in color data. "
701-
"These observations will be colored with the 'na_color'."
701+
f"Found {nan_count} NaN values in color data. These observations will be colored with the 'na_color'."
702702
)
703703
color_spec = color_spec.evolve(color_vector=cv)
704704

src/spatialdata_plot/pl/utils.py

Lines changed: 79 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
)
2727
from matplotlib.figure import Figure
2828
from matplotlib.gridspec import GridSpec
29+
from matplotlib.legend import Legend
2930
from matplotlib_scalebar.scalebar import ScaleBar
3031
from pandas.api.types import CategoricalDtype, is_numeric_dtype
3132
from pandas.core.arrays.categorical import Categorical
@@ -405,6 +406,49 @@ def _build_alignment_dtype_hint(
405406
return ""
406407

407408

409+
def _legend_ncol(n: int) -> int:
410+
"""Column count for a categorical legend with ``n`` entries."""
411+
return 1 if n <= 14 else 2 if n <= 30 else 3
412+
413+
414+
def _categorical_legend_handles(ax: Axes, color_map: Mapping[Any, Any], na_hex: str | None = None) -> list[Any]:
415+
"""Empty-scatter handles (colored dots) for a categorical legend, with an optional NA entry."""
416+
handles = [ax.scatter([], [], c=color, label=str(cat)) for cat, color in color_map.items()]
417+
if na_hex is not None:
418+
handles.append(ax.scatter([], [], c=na_hex, label="NA"))
419+
return handles
420+
421+
422+
def _stack_categorical_legend(
423+
ax: Axes,
424+
color_mapping: Mapping[Any, Any],
425+
*,
426+
na_hex: str | None,
427+
title: str | None,
428+
column: str | None,
429+
legend_fontsize: int | float | _FontSize | None,
430+
) -> None:
431+
"""Build the 2nd+ categorical legend on a shared axes without dropping existing ones (#364).
432+
433+
Placement is finalized later by ``_layout_panel_legends_rightward``; the anchor here is provisional.
434+
"""
435+
handles = _categorical_legend_handles(ax, color_mapping, na_hex)
436+
if (cur := ax.get_legend()) is not None:
437+
ax.add_artist(cur) # only the current legend would be dropped by ax.legend() below
438+
# Auto-title (by column) is applied in `_layout_panel_legends_rightward`, where the legend
439+
# count is known, so a lone legend stays untitled; an explicit `title` still wins here.
440+
new_leg = ax.legend(
441+
handles=handles,
442+
title=title,
443+
frameon=False,
444+
loc="upper left",
445+
bbox_to_anchor=(1.02, 1.0),
446+
fontsize=legend_fontsize,
447+
ncol=_legend_ncol(len(handles)),
448+
)
449+
new_leg._sdata_column = column # type: ignore[attr-defined]
450+
451+
408452
def _decorate_axs(
409453
ax: Axes,
410454
cax: PatchCollection,
@@ -449,22 +493,41 @@ def _decorate_axs(
449493
}
450494
)
451495
color_mapping = group_to_color_matching.drop_duplicates("cats").set_index("cats")["color"].to_dict()
452-
_add_categorical_legend(
453-
ax,
454-
pd.Categorical(values=color_source_vector, categories=clusters),
455-
palette=color_mapping,
456-
legend_loc=legend_loc,
457-
legend_fontweight=legend_fontweight,
458-
legend_fontsize=legend_fontsize,
459-
legend_fontoutline=path_effect,
460-
na_color=[na_color.get_hex()],
461-
na_in_legend=na_in_legend,
462-
multi_panel=fig_params.axs is not None,
463-
)
464-
# scanpy's helper doesn't accept a title; set it post-hoc so the user can
465-
# disambiguate fill vs outline when both legends are drawn.
466-
if legend_title is not None and (legend := ax.get_legend()) is not None:
467-
legend.set_title(legend_title)
496+
color_mapping = {k: v for k, v in color_mapping.items() if not pd.isnull(k)} # NA handled separately
497+
# A lone categorical legend goes through scanpy unchanged. A 2nd categorical render would
498+
# otherwise make scanpy's bare `ax.legend()` merge every labeled artist into one legend and
499+
# drop the first (#364), so route 2nd+ legends through a helper that keeps them separate.
500+
if legend_loc in (None, "none"):
501+
pass
502+
elif any(getattr(c, "_sdata_column", None) is not None for c in ax.get_children() if isinstance(c, Legend)):
503+
na_hex = na_color.get_hex() if (na_in_legend and pd.isnull(color_source_vector).any()) else None
504+
_stack_categorical_legend(
505+
ax,
506+
color_mapping,
507+
na_hex=na_hex,
508+
title=legend_title,
509+
column=value_to_plot,
510+
legend_fontsize=legend_fontsize,
511+
)
512+
else:
513+
_add_categorical_legend(
514+
ax,
515+
pd.Categorical(values=color_source_vector, categories=clusters),
516+
palette=color_mapping,
517+
legend_loc=legend_loc,
518+
legend_fontweight=legend_fontweight,
519+
legend_fontsize=legend_fontsize,
520+
legend_fontoutline=path_effect,
521+
na_color=[na_color.get_hex()],
522+
na_in_legend=na_in_legend,
523+
multi_panel=fig_params.axs is not None,
524+
)
525+
# Tag with the column; the column auto-title is applied only when 2+ legends share
526+
# the axis (see `_layout_panel_legends_rightward`). An explicit title wins now.
527+
if (legend := ax.get_legend()) is not None:
528+
legend._sdata_column = value_to_plot # type: ignore[attr-defined]
529+
if legend_title is not None:
530+
legend.set_title(legend_title)
468531
elif colorbar and colorbar_requests is not None and cax is not None:
469532
colorbar_requests.append(
470533
ColorbarSpec(

0 commit comments

Comments
 (0)