Skip to content

Commit d3fef03

Browse files
authored
perf(render_points): drop the AnnData hack + fix the categorical cliff (#730)
1 parent 078afb1 commit d3fef03

5 files changed

Lines changed: 60 additions & 64 deletions

File tree

src/spatialdata_plot/pl/_datashader.py

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,19 @@ def _ds_shade_categorical(
336336
return _apply_user_alpha(shaded, alpha)
337337

338338

339+
def _color_vector_is_uniform(color_vector: Any) -> bool:
340+
"""True if every entry of the per-point colour vector is identical (so per-point colouring collapses).
341+
342+
Shared by the datashader categorical collapse and the matplotlib scalar-colour fast path.
343+
"""
344+
if color_vector is None or len(color_vector) == 0:
345+
return False
346+
arr = np.asarray(color_vector)
347+
if arr.dtype.kind in "US": # fixed-width strings (the resolved-hex case): cheap vectorised compare
348+
return bool((arr == arr[0]).all())
349+
return pd.Series(arr).nunique(dropna=False) == 1 # object/other: hash-based, no sort
350+
351+
339352
def _shade_datashader_aggregate(
340353
cvs: ds.Canvas,
341354
frame: Any,
@@ -365,6 +378,14 @@ def _shade_datashader_aggregate(
365378
element-specific prep (geometry transform / point parse), outline rendering, ``_render_ds_image``
366379
and ``_build_ds_colorbar``.
367380
"""
381+
# Single-colour collapse: when every point resolves to the same colour (e.g. past scanpy's
382+
# 102-colour palette all categories are uniform grey), the per-category ds.by aggregate + composite
383+
# is wasted and byte-identical to a plain single-colour count render. Detect it from the colour
384+
# vector and route to the cheap count path — ~14x faster on high-cardinality categoricals (Xenium
385+
# points coloured by gene). _ds_shade_categorical then colours the count by color_vector[0].
386+
if color_by_categorical and _color_vector_is_uniform(color_vector):
387+
col_for_color, color_by_categorical = None, False
388+
368389
agg, reduction_bounds, nan_agg = _ds_aggregate(
369390
cvs, frame, col_for_color, color_by_categorical, ds_reduction, default_reduction, kind
370391
)
@@ -376,15 +397,16 @@ def _shade_datashader_aggregate(
376397

377398
if (
378399
strip_alpha_hex
400+
and col_for_color is not None # no-color/collapse: _ds_shade_categorical strips color_vector[0] itself
379401
and color_vector is not None
380402
and len(color_vector) > 0
381403
and isinstance(color_vector[0], str)
382404
and color_vector[0].startswith("#")
383405
):
384-
# color_vector usually holds only a few distinct hex strings (one per category), so strip
385-
# alpha on the unique values and map back rather than parsing once per point.
386-
unique_hex, inverse = np.unique(color_vector, return_inverse=True)
387-
color_vector = np.asarray([_hex_no_alpha(c) for c in unique_hex])[inverse]
406+
# Strip alpha on the unique colours and map back rather than parsing once per point; pd.factorize
407+
# dedups in O(n) (hash, no sort) where np.unique would sort millions of strings.
408+
codes, uniques = pd.factorize(np.asarray(color_vector))
409+
color_vector = np.asarray([_hex_no_alpha(c) for c in uniques])[codes]
388410

389411
# density without a color column collapses to a sequential count gradient; everything else with no
390412
# explicit continuous value (categorical or no color) goes through the categorical shader.

src/spatialdata_plot/pl/render.py

Lines changed: 19 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -15,17 +15,14 @@
1515
import matplotlib.ticker
1616
import numpy as np
1717
import pandas as pd
18-
import scanpy as sc
1918
import spatialdata as sd
2019
import xarray as xr
21-
from anndata import AnnData
2220
from matplotlib import patheffects
2321
from matplotlib.cm import ScalarMappable
2422
from matplotlib.colors import Colormap, ListedColormap, Normalize
2523
from scanpy._settings import settings as sc_settings
2624
from scanpy.plotting._tools.scatterplots import _add_categorical_legend
2725
from spatialdata import get_extent, get_values
28-
from spatialdata._core.query.relational_query import match_table_to_element
2926
from spatialdata.models import PointsModel, ShapesModel, get_table_keys
3027
from spatialdata.transformations import set_transformation
3128
from spatialdata.transformations.transformations import Identity
@@ -38,7 +35,6 @@
3835
_get_colors_for_categorical_obs,
3936
_get_linear_colormap,
4037
_map_color_seg,
41-
_maybe_set_colors,
4238
_prepare_cmap_norm,
4339
_resolve_continuous_norm,
4440
resolve_color,
@@ -48,6 +44,7 @@
4844
_ax_show_and_transform,
4945
_build_ds_colorbar,
5046
_circle_buffer_quad_segs,
47+
_color_vector_is_uniform,
5148
_datashader_canvas_from_dataframe,
5249
_get_extent_and_range_for_datashader_canvas,
5350
_hex_no_alpha,
@@ -333,7 +330,6 @@ def _add_legend_and_colorbar(
333330
ax: matplotlib.axes.SubplotBase,
334331
cax: ScalarMappable | None,
335332
fig_params: FigParams,
336-
adata: AnnData | None,
337333
col_for_color: str | None,
338334
color_spec: ColorSpec,
339335
palette: ListedColormap | list[str] | None,
@@ -387,7 +383,6 @@ def _add_legend_and_colorbar(
387383
ax=ax,
388384
cax=cax,
389385
fig_params=fig_params,
390-
adata=adata,
391386
value_to_plot=col_for_color,
392387
color_source_vector=color_source_vector,
393388
color_vector=color_vector,
@@ -755,7 +750,6 @@ def _draw_centroids(xy: np.ndarray, radius: float | None = None) -> None:
755750
color_spec=color_spec,
756751
norm=norm,
757752
na_color=render_params.cmap_params.na_color,
758-
adata=table,
759753
col_for_color=col_for_color,
760754
palette=palette,
761755
fig_params=fig_params,
@@ -1025,7 +1019,6 @@ def _draw_centroids(xy: np.ndarray, radius: float | None = None) -> None:
10251019
ax=ax,
10261020
cax=cax,
10271021
fig_params=fig_params,
1028-
adata=table,
10291022
col_for_color=col_for_color,
10301023
color_spec=color_spec,
10311024
palette=palette,
@@ -1059,18 +1052,26 @@ def _scatter_points(
10591052
Shared scatter primitive for points and the centroid "fast mode" of shapes/labels;
10601053
``color_vector`` is per-point hex strings or numeric values mapped through ``cmap``/``norm``.
10611054
"""
1055+
# When every marker is the same resolved hex colour (no-color / single colour / collapsed grey), pass a
1056+
# scalar ``color=`` instead of a per-point ``c=`` array: matplotlib then skips its per-point colour
1057+
# machinery — the dominant cost at scale (10M points: ~9s -> ~3.7s) — for a visually identical result.
1058+
# Numeric vectors keep the ``c=``/``cmap``/``norm`` path (they need the colormap).
1059+
cv = np.asarray(color_vector)
1060+
color_kwargs: dict[str, Any]
1061+
if cv.ndim == 1 and cv.dtype.kind in "US" and _color_vector_is_uniform(cv):
1062+
color_kwargs = {"color": str(cv[0])}
1063+
else:
1064+
color_kwargs = {"c": color_vector, "cmap": cmap, "norm": norm}
10621065
return ax.scatter(
10631066
x,
10641067
y,
10651068
s=size,
1066-
c=color_vector,
10671069
rasterized=sc_settings._vector_friendly,
1068-
cmap=cmap,
1069-
norm=norm,
10701070
alpha=alpha,
10711071
transform=trans_data,
10721072
zorder=zorder,
10731073
plotnonfinite=True, # nan points should be rendered as well
1074+
**color_kwargs,
10741075
)
10751076

10761077

@@ -1110,7 +1111,6 @@ def _render_centroids_as_points(
11101111
color_spec: ColorSpec,
11111112
norm: Normalize | None,
11121113
na_color: Any,
1113-
adata: AnnData | None,
11141114
col_for_color: str | None,
11151115
palette: Any,
11161116
fig_params: FigParams,
@@ -1170,7 +1170,6 @@ def _render_centroids_as_points(
11701170
ax=ax,
11711171
cax=cax,
11721172
fig_params=fig_params,
1173-
adata=adata,
11741173
col_for_color=col_for_color,
11751174
color_spec=color_spec,
11761175
palette=palette,
@@ -1382,50 +1381,15 @@ def _render_points(
13821381
else points_pd_with_color
13831382
)
13841383

1385-
# we construct an anndata to hack the plotting functions
1386-
if table_name is None:
1387-
adata = AnnData(
1388-
X=points[["x", "y"]].values,
1389-
obs=points[coords],
1390-
dtype=points[["x", "y"]].values.dtype,
1391-
)
1392-
else:
1393-
matched_table = match_table_to_element(sdata=sdata, element_name=element, table_name=table_name)
1394-
adata_obs = matched_table.obs.copy()
1395-
# if the points are colored by values in X (or a different layer), add the values to obs
1396-
if col_for_color in matched_table.var_names:
1397-
if table_layer is None:
1398-
adata_obs[col_for_color] = matched_table[:, col_for_color].X.flatten()
1399-
else:
1400-
adata_obs[col_for_color] = matched_table[:, col_for_color].layers[table_layer].flatten()
1401-
adata = AnnData(
1402-
X=points[["x", "y"]].values,
1403-
obs=adata_obs,
1404-
dtype=points[["x", "y"]].values.dtype,
1405-
uns=matched_table.uns,
1406-
)
1407-
sdata_filt[table_name] = adata
1408-
1409-
# we can modify the sdata because of dealing with a copy
1384+
# Color (from a points column, table obs, or table var/X) is already materialized on `points` via
1385+
# the get_values merge above; coordinates and the legend come straight from `points`/`color_spec`,
1386+
# so no AnnData round-trip is needed. resolve_color reads the original table (kept in sdata_filt)
1387+
# for any user-defined uns palette.
14101388

14111389
# Convert back to dask dataframe to modify sdata
14121390
transformation_in_cs = sdata_filt.points[element].attrs["transform"][coordinate_system]
14131391
_reparse_points(sdata_filt, element, points_for_model, transformation_in_cs, coordinate_system, col_for_color)
14141392

1415-
if col_for_color is not None:
1416-
assert isinstance(col_for_color, str)
1417-
cols = sc.get.obs_df(adata, [col_for_color])
1418-
# maybe set color based on type
1419-
if isinstance(cols[col_for_color].dtype, pd.CategoricalDtype):
1420-
uns_color_key = f"{col_for_color}_colors"
1421-
if uns_color_key in adata.uns:
1422-
_maybe_set_colors(
1423-
source=adata,
1424-
target=adata,
1425-
key=col_for_color,
1426-
palette=palette,
1427-
)
1428-
14291393
# when user specified a single color, we emulate the form of `na_color` and use it
14301394
default_color = (
14311395
render_params.color if col_for_color is None and color is not None else render_params.cmap_params.na_color
@@ -1478,9 +1442,8 @@ def _render_points(
14781442
color_spec = color_spec.filter(keep)
14791443
if int(keep.sum()) == 0:
14801444
return
1481-
# filter the materialized points, adata, and re-register in sdata_filt
1445+
# filter the materialized points and re-register in sdata_filt
14821446
points = points[keep].reset_index(drop=True)
1483-
adata = adata[keep]
14841447
_reparse_points(sdata_filt, element, points, transformation_in_cs, coordinate_system, col_for_color)
14851448

14861449
color_spec = color_spec.apply_transfunc(render_params.transfunc)
@@ -1550,8 +1513,8 @@ def _render_points(
15501513
update_parameters = not _mpl_ax_contains_elements(ax)
15511514
cax = _scatter_points(
15521515
ax,
1553-
adata[:, 0].X.flatten(),
1554-
adata[:, 1].X.flatten(),
1516+
points["x"].to_numpy(),
1517+
points["y"].to_numpy(),
15551518
color_spec.color_vector,
15561519
size=render_params.size,
15571520
cmap=render_params.cmap_params.cmap,
@@ -1570,7 +1533,6 @@ def _render_points(
15701533
ax=ax,
15711534
cax=cax,
15721535
fig_params=fig_params,
1573-
adata=adata,
15741536
col_for_color=col_for_color,
15751537
color_spec=color_spec,
15761538
palette=None,
@@ -2409,7 +2371,6 @@ def _render_labels(
24092371
),
24102372
norm=render_params.cmap_params.fresh_norm(), # ax.scatter autoscales in place; don't mutate the shared norm
24112373
na_color=na_color,
2412-
adata=table if table_name is not None else None,
24132374
col_for_color=col_for_color,
24142375
palette=palette,
24152376
fig_params=fig_params,
@@ -2510,7 +2471,6 @@ def _draw_labels(
25102471
ax=ax,
25112472
cax=cax,
25122473
fig_params=fig_params,
2513-
adata=table,
25142474
col_for_color=col_for_color,
25152475
color_spec=color_spec,
25162476
palette=palette,

src/spatialdata_plot/pl/utils.py

Lines changed: 15 additions & 1 deletion
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,14 +448,19 @@ def _stack_categorical_legend(
447448
new_leg._sdata_column = column # type: ignore[attr-defined]
448449

449450

451+
# A per-entry legend past this many categories is unreadable, and scanpy builds it in O(categories^2)
452+
# (one autoscaling artist each), dominating the render — so skip it with a warning. Tied to scanpy's
453+
# default_102 palette, beyond which its *default* colors also stop being distinguishable (uniform grey).
454+
_MAX_LEGEND_CATEGORIES = len(palettes.default_102)
455+
456+
450457
def _decorate_axs(
451458
ax: Axes,
452459
cax: PatchCollection,
453460
fig_params: FigParams,
454461
value_to_plot: str | None,
455462
color_source_vector: pd.Series[CategoricalDtype] | Categorical,
456463
color_vector: pd.Series[CategoricalDtype] | Categorical,
457-
adata: AnnData | None = None,
458464
palette: ListedColormap | str | list[str] | None = None,
459465
alpha: float = 1.0,
460466
na_color: Color = Color("default"),
@@ -499,6 +505,14 @@ def _decorate_axs(
499505
already = any(tagged)
500506
if legend_loc in (None, "none"):
501507
pass # legend suppressed
508+
elif len(clusters) > _MAX_LEGEND_CATEGORIES:
509+
# A per-entry legend this large is unreadable and scanpy builds it in O(categories^2)
510+
# (one autoscaling artist each), dominating the render. Skip it.
511+
logger.warning(
512+
f"Skipping the categorical legend for '{value_to_plot}': {len(clusters)} categories "
513+
f"exceed the {_MAX_LEGEND_CATEGORIES}-entry limit (unreadable and very slow to build). "
514+
f"Pass a `groups` subset to get a legend."
515+
)
502516
elif already:
503517
na_hex = na_color.get_hex() if (na_in_legend and pd.isnull(color_source_vector).any()) else None
504518
_stack_categorical_legend(
-1.99 KB
Loading
109 Bytes
Loading

0 commit comments

Comments
 (0)