Skip to content

Commit 9707295

Browse files
committed
perf(render_points): keep categorical colour as codes+palette (memory at scale)
The resolved per-point colour is already a pd.Categorical (int codes + a small hex palette), but every consumer re-expanded it to a per-point object array. Keep it compact instead: - _datashader._build_datashader_color_key: index color_vector at the one first-occurrence per category instead of np.asarray-ing the whole per-point vector. - _datashader strip: strip alpha on the k categories and remap codes (no expand + refactorize + re-expand); array fallback unchanged. - render._scatter_points: for non-uniform categorical hex, pass int codes + ListedColormap(categories) + BoundaryNorm instead of a per-point hex array; single category keeps the scalar color= fast path. NaN is already baked into the na_color category upstream (codes >= 0); a stray -1 falls back to the old path. Byte-identical output: a main-vs-branch render of {categorical, continuous, uniform} x {matplotlib, datashader} matches to the byte (max RGBA diff 0). At 20M points the datashader strip drops from 720 MB / 962 ms to 340 MB / 117 ms (2.1x mem, 8x faster); the matplotlib c= payload shrinks ~8x. Tests: codes-path RGBA == per-point hex; single category -> scalar color; color-key equivalence with the previous expansion.
1 parent d3fef03 commit 9707295

3 files changed

Lines changed: 111 additions & 14 deletions

File tree

src/spatialdata_plot/pl/_datashader.py

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -97,11 +97,10 @@ def _build_datashader_color_key(
9797
) -> dict[str, str]:
9898
"""Build a datashader ``color_key`` dict from a categorical series and its color vector."""
9999
na_hex = _hex_no_alpha(na_color_hex) if na_color_hex.startswith("#") else na_color_hex
100-
colors_arr = np.asarray(color_vector, dtype=object)
101100
categories = np.asarray(cat_series.categories, dtype=str)
102101
codes = np.asarray(cat_series.codes)
103102

104-
if len(colors_arr) != len(codes):
103+
if len(color_vector) != len(codes):
105104
logger.warning(
106105
f"color_vector length ({len(color_vector)}) does not match categorical series length "
107106
f"({len(codes)}); some categories may receive the na_color fallback."
@@ -111,11 +110,13 @@ def _build_datashader_color_key(
111110
# avoiding a Python loop over all points. See #379.
112111
unique_codes, first_indices = np.unique(codes, return_index=True)
113112

113+
# Index color_vector only at those first occurrences (one per category) rather than expanding the
114+
# whole per-point vector to an object array — color_vector is a compact pd.Categorical at scale.
114115
first_color: dict[str, str] = {}
115116
for code, idx in zip(unique_codes, first_indices, strict=True):
116-
if code < 0 or idx >= len(colors_arr):
117+
if code < 0 or idx >= len(color_vector):
117118
continue
118-
c = colors_arr[idx]
119+
c = color_vector[idx]
119120
first_color[categories[code]] = _hex_no_alpha(c) if isinstance(c, str) and c.startswith("#") else c
120121

121122
return {cat: first_color.get(cat, na_hex) for cat in categories}
@@ -403,10 +404,17 @@ def _shade_datashader_aggregate(
403404
and isinstance(color_vector[0], str)
404405
and color_vector[0].startswith("#")
405406
):
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]
407+
# Strip alpha on the unique colours, never on the per-point vector. color_vector is already a
408+
# pd.Categorical (codes + a small palette) at scale: strip its k categories and remap the codes,
409+
# staying compact. (Plain-array fallback factorizes in O(n) — hash, no sort.)
410+
if isinstance(color_vector, pd.Categorical):
411+
stripped = np.asarray([_hex_no_alpha(c) for c in color_vector.categories])
412+
uniq_codes, uniques = pd.factorize(stripped) # dedup over k categories, not n points
413+
new_codes = np.where(color_vector.codes >= 0, uniq_codes[color_vector.codes], -1)
414+
color_vector = pd.Categorical.from_codes(new_codes, categories=uniques)
415+
else:
416+
codes, uniques = pd.factorize(np.asarray(color_vector))
417+
color_vector = np.asarray([_hex_no_alpha(c) for c in uniques])[codes]
410418

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

src/spatialdata_plot/pl/render.py

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
import xarray as xr
2020
from matplotlib import patheffects
2121
from matplotlib.cm import ScalarMappable
22-
from matplotlib.colors import Colormap, ListedColormap, Normalize
22+
from matplotlib.colors import BoundaryNorm, Colormap, ListedColormap, Normalize, to_rgba_array
2323
from scanpy._settings import settings as sc_settings
2424
from scanpy.plotting._tools.scatterplots import _add_categorical_legend
2525
from spatialdata import get_extent, get_values
@@ -1056,12 +1056,28 @@ def _scatter_points(
10561056
# scalar ``color=`` instead of a per-point ``c=`` array: matplotlib then skips its per-point colour
10571057
# machinery — the dominant cost at scale (10M points: ~9s -> ~3.7s) — for a visually identical result.
10581058
# Numeric vectors keep the ``c=``/``cmap``/``norm`` path (they need the colormap).
1059-
cv = np.asarray(color_vector)
10601059
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])}
1060+
if isinstance(color_vector, pd.Categorical) and (color_vector.codes >= 0).all():
1061+
# Categorical hex colours: pass the int codes + a ListedColormap of the (few) category colours
1062+
# instead of expanding to a per-point hex object array (~8x more memory at scale). BoundaryNorm
1063+
# edges at the half-integers map code i -> colormap entry i exactly, so the RGBA is identical.
1064+
# (_color.py bakes NaN into the na_color category, so codes >= 0; a stray -1 falls back below.)
1065+
categories = list(color_vector.categories)
1066+
if len(categories) == 1:
1067+
color_kwargs = {"color": str(categories[0])} # uniform: scalar color=, skip per-point machinery
1068+
else:
1069+
n_cat = len(categories)
1070+
color_kwargs = {
1071+
"c": color_vector.codes,
1072+
"cmap": ListedColormap(to_rgba_array(categories)),
1073+
"norm": BoundaryNorm(np.arange(-0.5, n_cat, 1.0), ncolors=n_cat),
1074+
}
10631075
else:
1064-
color_kwargs = {"c": color_vector, "cmap": cmap, "norm": norm}
1076+
cv = np.asarray(color_vector)
1077+
if cv.ndim == 1 and cv.dtype.kind in "US" and _color_vector_is_uniform(cv):
1078+
color_kwargs = {"color": str(cv[0])}
1079+
else:
1080+
color_kwargs = {"c": color_vector, "cmap": cmap, "norm": norm}
10651081
return ax.scatter(
10661082
x,
10671083
y,

tests/pl/test_render_points.py

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
_ds_shade_categorical,
3232
_pad_degenerate_extent,
3333
)
34-
from spatialdata_plot.pl.render import _marker_spread_px, _warn_groups_ignored_continuous
34+
from spatialdata_plot.pl.render import _marker_spread_px, _scatter_points, _warn_groups_ignored_continuous
3535
from tests.conftest import (
3636
CANVAS_HEIGHT,
3737
CANVAS_WIDTH,
@@ -1338,3 +1338,76 @@ def test_datashader_zero_extent_renders(coords):
13381338
sdata = SpatialData(points={"points": PointsModel.parse(df)})
13391339
sdata.pl.render_points("points", method="datashader").pl.show()
13401340
plt.close("all")
1341+
1342+
1343+
def _categorical_hex_vector(n: int, k: int, seed: int = 0) -> pd.Categorical:
1344+
"""Per-point hex colour vector as _set_color_source_vec builds it: Categorical(source.map(palette))."""
1345+
rng = np.random.default_rng(seed)
1346+
cats = [f"ct{i}" for i in range(k)]
1347+
src = pd.Categorical(rng.choice(cats, n), categories=cats)
1348+
palette = {c: f"#{(i * 9973) % 0xFFFFFF:06x}ff" for i, c in enumerate(cats)}
1349+
return pd.Categorical(pd.Series(src).map(palette))
1350+
1351+
1352+
def test_scatter_points_categorical_codes_match_per_point_hex():
1353+
# The codes+ListedColormap path must produce byte-identical RGBA to passing the per-point hex array.
1354+
cv = _categorical_hex_vector(400, k=6, seed=1)
1355+
rng = np.random.default_rng(2)
1356+
x, y = rng.random(400), rng.random(400)
1357+
common = {
1358+
"size": 10.0,
1359+
"cmap": plt.get_cmap("viridis"),
1360+
"norm": Normalize(),
1361+
"alpha": 1.0,
1362+
"trans_data": None,
1363+
"zorder": 1,
1364+
}
1365+
1366+
fig, ax = plt.subplots()
1367+
sc_codes = _scatter_points(ax, x, y, cv, **common) # exercises the new codes path
1368+
sc_codes.update_scalarmappable()
1369+
fc_codes = sc_codes.get_facecolors()
1370+
plt.close(fig)
1371+
1372+
fig, ax = plt.subplots()
1373+
sc_hex = ax.scatter(x, y, c=np.asarray(cv), s=10.0) # reference: literal per-point hex
1374+
fc_hex = sc_hex.get_facecolors()
1375+
plt.close(fig)
1376+
1377+
assert np.abs(fc_codes - fc_hex).max() == 0.0
1378+
1379+
1380+
def test_scatter_points_single_category_uses_scalar_color():
1381+
# A single resolved colour must keep the scalar color= fast path (one facecolor, not a per-point array).
1382+
cv = _categorical_hex_vector(300, k=1, seed=3)
1383+
rng = np.random.default_rng(5)
1384+
fig, ax = plt.subplots()
1385+
sc = _scatter_points(
1386+
ax,
1387+
rng.random(300),
1388+
rng.random(300),
1389+
cv,
1390+
size=10.0,
1391+
cmap=plt.get_cmap("viridis"),
1392+
norm=Normalize(),
1393+
alpha=1.0,
1394+
trans_data=None,
1395+
zorder=1,
1396+
)
1397+
assert sc.get_facecolors().shape[0] == 1 # scalar color -> single facecolor
1398+
plt.close(fig)
1399+
1400+
1401+
def test_build_datashader_color_key_categorical_indexes_palette():
1402+
# The color key must map each category to its alpha-stripped colour without expanding the full vector.
1403+
cv = _categorical_hex_vector(500, k=5, seed=4)
1404+
source = pd.Categorical([f"g{i % 5}" for i in range(500)], categories=[f"g{i}" for i in range(5)])
1405+
key = _build_datashader_color_key(source, cv, "#808080ff")
1406+
# equivalence with the previous object-array expansion:
1407+
colors_arr = np.asarray(cv, dtype=object)
1408+
codes = source.codes
1409+
expected = {}
1410+
for code, idx in zip(*np.unique(codes, return_index=True), strict=True):
1411+
c = colors_arr[idx]
1412+
expected[str(np.asarray(source.categories)[code])] = c[:7] if c.startswith("#") else c
1413+
assert key == expected

0 commit comments

Comments
 (0)