Skip to content

Commit 5854ae6

Browse files
committed
feat(as_points): datashader backend + fix shapes centroid coordinates
- Route as_points centroids through the shared _datashader_points when method= 'datashader' or above ~500k dots (AS_POINTS_DS_AUTO); matplotlib otherwise. - No-color labels (one random colour per cell) cannot be aggregated by datashader; force matplotlib there (warn on explicit method='datashader'). - Fix: shapes as_points drew dots at intrinsic centroid coords while the axes are in coordinate-system coords, so non-identity transforms misplaced them. Transform centroids to CS via the element->CS affine for both backends (labels too). - render_labels gains 'method'; LabelsRenderParams gains 'method'. as_points uses a fixed datashader reduction ('max', closest to matplotlib) — no user knob.
1 parent 4c0c478 commit 5854ae6

3 files changed

Lines changed: 89 additions & 22 deletions

File tree

‎src/spatialdata_plot/pl/basic.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -963,6 +963,7 @@ def render_labels(
963963
transfunc: Callable[[float], float] | None = None,
964964
as_points: bool = False,
965965
size: float | int = 1.0,
966+
method: str | None = None,
966967
) -> sd.SpatialData:
967968
"""
968969
Render labels elements in SpatialData.
@@ -1048,6 +1049,10 @@ def render_labels(
10481049
in another column of ``var``. Mimics scanpy's ``gene_symbols`` parameter.
10491050
transfunc : Callable[[float], float] | None, optional
10501051
Optional transformation applied to the continuous color vector before normalization and colormap mapping.
1052+
method : str | None, optional
1053+
Backend for ``as_points`` centroids: ``'matplotlib'`` or ``'datashader'``. When ``None``,
1054+
matplotlib is used unless there are more than ~500k centroids. Datashader is skipped (with a
1055+
warning) when the colouring cannot be aggregated (e.g. labels with no color column).
10511056
10521057
Returns
10531058
-------
@@ -1115,6 +1120,7 @@ def render_labels(
11151120
colorbar_params=param_values["colorbar_params"],
11161121
as_points=as_points,
11171122
size=size,
1123+
method=method,
11181124
panel_key=panel_key,
11191125
)
11201126
n_steps += 1

‎src/spatialdata_plot/pl/render.py‎

Lines changed: 81 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -757,16 +757,17 @@ def _render_shapes(
757757
# Fast mode: draw one dot per shape at its centroid instead of its geometry.
758758
logger.info("`as_points=True`: rendering shape centroids; `outline_*` and `shape` are ignored.")
759759
centroids = shapes.geometry.centroid # intrinsic coords, positionally aligned to color_vector
760+
# transform to coordinate-system coords so dots land correctly under non-identity transforms
761+
xy = trans.transform(np.column_stack([centroids.x.to_numpy(), centroids.y.to_numpy()]))
760762
_render_centroids_as_points(
761763
ax,
762764
render_params,
763-
x=centroids.x.to_numpy(),
764-
y=centroids.y.to_numpy(),
765+
x=xy[:, 0],
766+
y=xy[:, 1],
765767
color_vector=color_vector,
766768
color_source_vector=color_source_vector,
767769
norm=norm,
768770
na_color=render_params.cmap_params.na_color,
769-
transform=trans_data, # intrinsic -> coordinate system -> display
770771
adata=table,
771772
col_for_color=col_for_color,
772773
palette=palette,
@@ -1110,6 +1111,34 @@ def _scatter_points(
11101111
)
11111112

11121113

1114+
# as_points: matplotlib draws crisp markers; above this many dots its per-glyph draw dominates
1115+
# (≈18 µs/dot), so auto-switch to datashader. Datashader changes appearance (density raster), so
1116+
# the threshold is conservative and only applies when the user did not pick a backend explicitly.
1117+
AS_POINTS_DS_AUTO = 500_000
1118+
1119+
1120+
def _resolve_as_points_method(
1121+
render_params: ShapesRenderParams | LabelsRenderParams, *, n: int, allow_datashader: bool
1122+
) -> str:
1123+
"""Pick the as_points backend. matplotlib by default; datashader only when it can represent the colors."""
1124+
method = render_params.method
1125+
if not allow_datashader or n == 0:
1126+
# e.g. no-color labels get one distinct random colour per cell (`_map_color_seg` Case C),
1127+
# which datashader's aggregate-then-shade model cannot represent.
1128+
if method == "datashader":
1129+
logger.warning("`as_points` cannot use datashader for this colouring; falling back to matplotlib.")
1130+
return "matplotlib"
1131+
if method == "datashader":
1132+
return "datashader"
1133+
if method is None and n > AS_POINTS_DS_AUTO:
1134+
logger.info(
1135+
f"`as_points`: {n} centroids exceed {AS_POINTS_DS_AUTO}; using the datashader backend "
1136+
"(pass `method='matplotlib'` to override)."
1137+
)
1138+
return "datashader"
1139+
return "matplotlib"
1140+
1141+
11131142
def _render_centroids_as_points(
11141143
ax: matplotlib.axes.SubplotBase,
11151144
render_params: ShapesRenderParams | LabelsRenderParams,
@@ -1120,31 +1149,56 @@ def _render_centroids_as_points(
11201149
color_source_vector: pd.Series | None,
11211150
norm: Normalize | None,
11221151
na_color: Any,
1123-
transform: Any,
11241152
adata: AnnData | None,
11251153
col_for_color: str | None,
11261154
palette: Any,
11271155
fig_params: FigParams,
11281156
legend_params: LegendParams,
11291157
colorbar_requests: list[ColorbarSpec] | None,
1158+
allow_datashader: bool = True,
11301159
) -> None:
11311160
"""Render one dot per cell at ``(x, y)`` colored like the fill, with legend/colorbar.
11321161
1133-
Shared "fast mode" draw for shapes/labels; style comes off ``render_params``. ``norm``/``na_color``
1134-
stay explicit because they differ between the shapes (locally adjusted) and labels paths.
1162+
Shared "fast mode" draw for shapes/labels. ``x``/``y`` are in **coordinate-system coords** (so the
1163+
datashader canvas and matplotlib's ``transData`` agree). Backend is matplotlib unless
1164+
``render_params.method`` / the size threshold selects datashader (and the colouring supports it).
1165+
``norm``/``na_color`` stay explicit because they differ between the shapes and labels paths.
11351166
"""
1136-
cax = _scatter_points(
1137-
ax,
1138-
x,
1139-
y,
1140-
color_vector,
1141-
size=render_params.size,
1142-
cmap=render_params.cmap_params.cmap,
1143-
norm=norm,
1144-
alpha=render_params.fill_alpha,
1145-
trans_data=transform,
1146-
zorder=render_params.zorder,
1147-
)
1167+
method = _resolve_as_points_method(render_params, n=len(np.asarray(x)), allow_datashader=allow_datashader)
1168+
if method == "datashader":
1169+
df = pd.DataFrame({"x": np.asarray(x), "y": np.asarray(y)})
1170+
cax, color_vector, color_source_vector = _datashader_points(
1171+
ax,
1172+
df,
1173+
col_for_color=col_for_color,
1174+
color_vector=color_vector,
1175+
color_source_vector=color_source_vector,
1176+
norm=norm,
1177+
cmap_params=render_params.cmap_params,
1178+
alpha=render_params.fill_alpha,
1179+
size=render_params.size,
1180+
zorder=render_params.zorder,
1181+
# as_points has no user reduction knob: centroids rarely share a pixel, and "max"
1182+
# (the top cell) keeps the datashader output closest to the matplotlib backend.
1183+
ds_reduction=None,
1184+
default_reduction="max",
1185+
density=False,
1186+
density_how="linear",
1187+
fig_params=fig_params,
1188+
)
1189+
else:
1190+
cax = _scatter_points(
1191+
ax,
1192+
x,
1193+
y,
1194+
color_vector,
1195+
size=render_params.size,
1196+
cmap=render_params.cmap_params.cmap,
1197+
norm=norm,
1198+
alpha=render_params.fill_alpha,
1199+
trans_data=ax.transData, # x/y are coordinate-system coords
1200+
zorder=render_params.zorder,
1201+
)
11481202
_add_legend_and_colorbar(
11491203
ax=ax,
11501204
cax=cax,
@@ -2266,7 +2320,7 @@ def _render_labels(
22662320
# get instance id based on subsetted table
22672321
instance_id = np.unique(table.obs[instance_key].values)
22682322

2269-
_, trans_data = _prepare_transformation(label, coordinate_system, ax)
2323+
trans, trans_data = _prepare_transformation(label, coordinate_system, ax)
22702324

22712325
na_color = (
22722326
render_params.color
@@ -2387,11 +2441,14 @@ def _render_labels(
23872441
)
23882442
# coerce so str/object table ids (e.g. Xenium) match the integer raster labels instead of NaN
23892443
centroids = centroids.reindex(point_ids.astype(labels.dtype, copy=False))
2444+
# datashader cannot represent one distinct random colour per cell (the no-color path below)
2445+
allow_datashader = True
23902446
if col_for_color is None and not na_color.color_modified_by_user():
23912447
# no color column: one distinct random colour per cell, matching the mask path
23922448
# (`_map_color_seg` Case C) instead of collapsing every dot to a single na_color.
23932449
point_color_vector = np.random.default_rng(42).random((len(point_ids), 3))
23942450
point_color_source_vector = None
2451+
allow_datashader = False
23952452
elif len(color_vector) == len(instance_id):
23962453
# data-driven colour is per-instance
23972454
point_color_vector = np.asarray(color_vector)[keep]
@@ -2400,22 +2457,24 @@ def _render_labels(
24002457
# literal colour / user-set na_color -> one colour per centroid
24012458
point_color_vector = np.asarray([na_color.get_hex_with_alpha()] * len(point_ids))
24022459
point_color_source_vector = None
2460+
# transform rendered-raster intrinsic centroids to coordinate-system coords
2461+
xy = trans.transform(np.column_stack([centroids["x"].to_numpy(), centroids["y"].to_numpy()]))
24032462
_render_centroids_as_points(
24042463
ax,
24052464
render_params,
2406-
x=centroids["x"].to_numpy(),
2407-
y=centroids["y"].to_numpy(),
2465+
x=xy[:, 0],
2466+
y=xy[:, 1],
24082467
color_vector=point_color_vector,
24092468
color_source_vector=point_color_source_vector,
24102469
norm=copy(render_params.cmap_params.norm), # ax.scatter autoscales in place; don't mutate the shared norm
24112470
na_color=na_color,
2412-
transform=trans_data, # rendered-raster intrinsic coords -> coordinate system -> display
24132471
adata=table if table_name is not None else None,
24142472
col_for_color=col_for_color,
24152473
palette=palette,
24162474
fig_params=fig_params,
24172475
legend_params=legend_params,
24182476
colorbar_requests=colorbar_requests,
2477+
allow_datashader=allow_datashader,
24192478
)
24202479
return
24212480

‎src/spatialdata_plot/pl/render_params.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,8 @@ class LabelsRenderParams:
334334
# Fast mode: render each label as a single dot at its centroid instead of the mask.
335335
as_points: bool = False
336336
size: float = 1.0 # marker size for as_points (matplotlib scatter ``s``)
337+
# Backend for the as_points centroids: None auto-selects (datashader above ~500k dots).
338+
method: str | None = None
337339
# Multi-panel color: when set, this render entry belongs to the panel identified by this
338340
# color key. ``None`` means the entry is shared across every panel (e.g. a background layer).
339341
panel_key: str | None = None

0 commit comments

Comments
 (0)