Skip to content

Commit 95e31c6

Browse files
committed
perf(legend): skip the categorical legend past scanpy's color-palette limit
Coloring by a high-cardinality categorical (e.g. Xenium points by gene, ~3000 genes) spent ~10s building the legend: scanpy's _add_categorical_legend adds one autoscaling artist per category, so matplotlib re-autoscales O(categories^2) (sticky_edges called ~categories^2 times). Past len(default_102)=102 categories scanpy already colors every point uniform grey, so a per-entry legend carries no information anyway. Skip it with a warning above that limit (tied to scanpy's palette so the two stay in sync). 2M points x 3085 genes: 16.9s -> 6.5s.
1 parent 21c1796 commit 95e31c6

1 file changed

Lines changed: 16 additions & 0 deletions

File tree

src/spatialdata_plot/pl/utils.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
from pandas.api.types import CategoricalDtype, is_numeric_dtype
3232
from pandas.core.arrays.categorical import Categorical
3333
from scanpy import settings
34+
from scanpy.plotting import palettes
3435
from scanpy.plotting._tools.scatterplots import _add_categorical_legend
3536
from spatialdata import (
3637
SpatialData,
@@ -447,6 +448,13 @@ def _stack_categorical_legend(
447448
new_leg._sdata_column = column # type: ignore[attr-defined]
448449

449450

451+
# scanpy assigns distinguishable colors only up to len(default_102) categories; beyond that it warns
452+
# and colors every point uniform grey (_set_default_colors_for_categorical_obs). At that point a
453+
# per-entry legend is meaningless AND scanpy builds it in O(categories^2) (one autoscaling artist each,
454+
# dominating the render), so we skip it with a warning. Tied to scanpy's palette so the two stay in sync.
455+
_MAX_LEGEND_CATEGORIES = len(palettes.default_102)
456+
457+
450458
def _decorate_axs(
451459
ax: Axes,
452460
cax: PatchCollection,
@@ -498,6 +506,14 @@ def _decorate_axs(
498506
already = any(tagged)
499507
if legend_loc in (None, "none"):
500508
pass # legend suppressed
509+
elif len(clusters) > _MAX_LEGEND_CATEGORIES:
510+
# Past scanpy's palette limit the points are all uniform grey anyway, so a per-entry legend
511+
# carries no information and (built in O(categories^2)) dominates the render. Skip it.
512+
logger.warning(
513+
f"Skipping the categorical legend for '{value_to_plot}': {len(clusters)} categories "
514+
f"exceed scanpy's {_MAX_LEGEND_CATEGORIES}-color palette (points are uniform grey past "
515+
f"that). Pass a `groups` subset to get a legend."
516+
)
501517
elif already:
502518
na_hex = na_color.get_hex() if (na_in_legend and pd.isnull(color_source_vector).any()) else None
503519
_stack_categorical_legend(

0 commit comments

Comments
 (0)