Skip to content

Commit 68cc388

Browse files
committed
perf(shapes): datashader renders large uniform circle sets as points
On the datashader backend, buffering every circle (Point+radius) to a polygon dominates the render for large sets. A large (>50k), uniform-radius, outline-free circle element is a dot-field where a filled disc and a spread point are visually equivalent, so rasterize centroids as radius-faithful points (_circles_render_as_points gate + radius-aware spread in _datashader_points) instead of buffering. This is datashader-backend behavior; use method="matplotlib" for a pixel-exact rendering. Per-circle varying radii, outlines, and custom shapes keep the polygon path. `as_points` stays a simple bool (style: dots vs geometry) and is itself a speedup on both backends; it is orthogonal to this datashader optimization. Adds a 2x2 visual test (geometry/as_points x matplotlib/datashader) verifying the four render paths look alike, incl. the datashader fast-path matching exact matplotlib discs. End-to-end on Visium HD (single coordinate system): 91k circles 6.85s->0.56s vs v0.4.0, 352k 22.95s->1.14s, 5.5M 002um impractical->~12.7s; method="matplotlib" stays exact.
1 parent e039008 commit 68cc388

3 files changed

Lines changed: 132 additions & 14 deletions

File tree

src/spatialdata_plot/pl/basic.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -444,10 +444,16 @@ def render_shapes(
444444
Reduction method for datashader when coloring by continuous values. When ``None``, defaults to ``"max"``.
445445
transfunc : Callable[[float], float] | None, optional
446446
Optional transformation applied to the continuous color vector before normalization and colormap mapping.
447+
as_points : bool
448+
If ``True``, draw one ``size``-d dot per shape centroid instead of its full geometry
449+
(faster for large sets; available on both the matplotlib and datashader backends).
447450
448451
Notes
449452
-----
450453
- Empty geometries will be removed at the time of plotting.
454+
- On the datashader backend, a large (>50k) uniform-radius, outline-free circle element is
455+
rendered as radius-faithful points for speed (visually equivalent at that scale); pass
456+
``method="matplotlib"`` for a pixel-exact rendering of every circle.
451457
- An `outline_width` of 0.0 leads to no border being plotted.
452458
- If ``color`` is a string that is both a matplotlib color name and a column name in the
453459
element or an annotating table, a ``ValueError`` is raised. Disambiguate by passing

src/spatialdata_plot/pl/render.py

Lines changed: 64 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -573,6 +573,32 @@ def _check_instance_ids_overlap(
573573
)
574574

575575

576+
# Above this many circles, a uniform-radius outline-free element is a dot-field where buffering every
577+
# circle to a polygon dominates the render; rasterizing centroids as spread discs is far cheaper and
578+
# visually equivalent at that scale.
579+
_CIRCLE_FAST_PATH_MIN = 50_000
580+
581+
582+
def _circles_render_as_points(shapes: gpd.GeoDataFrame, is_point: Any, render_params: ShapesRenderParams) -> bool:
583+
"""Gate for the datashader circle fast-path: a large, uniform-radius, outline-free, default-shape element.
584+
585+
A datashader speed optimization (use ``method="matplotlib"`` for exact circles), restricted so the
586+
point approximation never silently distorts: per-circle varying radii and outlines can't be
587+
reproduced by a single uniform spread, and a custom ``shape`` is meaningless for points.
588+
"""
589+
if (
590+
render_params.shape is not None
591+
or "radius" not in shapes.columns
592+
or len(shapes) <= _CIRCLE_FAST_PATH_MIN
593+
or render_params.outline_alpha[0] > 0
594+
or render_params.outline_alpha[1] > 0
595+
or not bool(is_point.all())
596+
):
597+
return False
598+
radius = pd.to_numeric(shapes["radius"], errors="coerce").to_numpy()
599+
return bool(np.isfinite(radius).all() and np.ptp(radius) == 0)
600+
601+
576602
def _render_shapes(
577603
sdata: sd.SpatialData,
578604
render_params: ShapesRenderParams,
@@ -718,12 +744,8 @@ def _render_shapes(
718744

719745
shapes = gpd.GeoDataFrame(shapes, geometry="geometry")
720746

721-
if render_params.as_points:
722-
# Fast mode: draw one dot per shape at its centroid instead of its geometry.
723-
logger.info("`as_points=True`: rendering shape centroids; `outline_*` and `shape` are ignored.")
724-
centroids = shapes.geometry.centroid # intrinsic coords, positionally aligned to color_vector
725-
# transform to coordinate-system coords so dots land correctly under non-identity transforms
726-
xy = trans.transform(np.column_stack([centroids.x.to_numpy(), centroids.y.to_numpy()]))
747+
def _draw_centroids(xy: np.ndarray, radius: float | None = None) -> None:
748+
"""Render the element's centroids (coordinate-system coords) as dots; ``radius`` sizes the disc."""
727749
_render_centroids_as_points(
728750
ax,
729751
render_params,
@@ -739,7 +761,14 @@ def _render_shapes(
739761
legend_params=legend_params,
740762
colorbar_requests=colorbar_requests,
741763
axes_extent=_fast_extent(sdata_filt.shapes[element], coordinate_system),
764+
radius=radius,
742765
)
766+
767+
if render_params.as_points:
768+
# Fast mode: draw one dot per shape at its centroid instead of its geometry.
769+
logger.info("`as_points=True`: rendering shape centroids; `outline_*` and `shape` are ignored.")
770+
centroids = shapes.geometry.centroid # intrinsic; transform so dots land under non-identity transforms
771+
_draw_centroids(trans.transform(np.column_stack([centroids.x.to_numpy(), centroids.y.to_numpy()])))
743772
return
744773

745774
# convert shapes if necessary
@@ -773,6 +802,16 @@ def _render_shapes(
773802
is_point = _geometry.type == "Point"
774803
tm = trans.get_matrix() # coordinate-system affine; reused for circle sizing and the transform below
775804

805+
# Fast path: a large uniform-radius circle element with no outline rasterizes (to within a
806+
# pixel) the same as spread points, skipping the per-circle buffer/polygon-aggregation cost.
807+
if _circles_render_as_points(shapes, is_point, render_params):
808+
logger.info(f"Rendering {len(shapes)} uniform circles as datashader points (fast path).")
809+
stretch = float(np.linalg.svd(tm[:2, :2], compute_uv=False).max()) # circle radius in CS units
810+
radius_cs = float(pd.to_numeric(shapes["radius"], errors="coerce").iloc[0]) * render_params.scale * stretch
811+
xy = trans.transform(np.column_stack([_geometry.x.to_numpy(), _geometry.y.to_numpy()]))
812+
_draw_centroids(xy, radius=radius_cs)
813+
return
814+
776815
# Handle circles encoded as points with radius
777816
if is_point.any():
778817
# Convert to numeric, replacing non-numeric values with NaN
@@ -1078,12 +1117,14 @@ def _render_centroids_as_points(
10781117
colorbar_requests: list[ColorbarSpec] | None,
10791118
axes_extent: dict[str, tuple[float, float]],
10801119
allow_datashader: bool = True,
1120+
radius: float | None = None,
10811121
) -> None:
10821122
"""Render one dot per cell at ``(x, y)`` (coordinate-system coords), colored like the fill.
10831123
10841124
Shared "fast mode" for shapes/labels; backend chosen by ``_resolve_as_points_method``. ``axes_extent``
10851125
(the element's extent, i.e. the frame the axes will use) is what the datashader backend rasterizes over
1086-
so its dots match the matplotlib markers.
1126+
so its dots match the matplotlib markers. ``radius`` (coordinate-system units), when set, sizes the
1127+
datashader spread to a faithful disc of that radius instead of the marker ``size``.
10871128
"""
10881129
method = _resolve_as_points_method(render_params, n=len(x), allow_datashader=allow_datashader)
10891130
if method == "datashader":
@@ -1108,6 +1149,7 @@ def _render_centroids_as_points(
11081149
fig_params=fig_params,
11091150
as_markers=True,
11101151
axes_extent=axes_extent,
1152+
radius=radius,
11111153
)
11121154
color_spec = color_spec.evolve(source_vector=csv, color_vector=cv)
11131155
else:
@@ -1159,23 +1201,20 @@ def _datashader_points(
11591201
default_reduction: _DsReduction = "sum",
11601202
as_markers: bool = False,
11611203
axes_extent: dict[str, tuple[float, float]] | None = None,
1204+
radius: float | None = None,
11621205
) -> tuple[Any, Any, Any]:
11631206
"""Datashade an x/y(+color) point frame onto ``ax``; return ``(cax, color_vector, color_source_vector)``.
11641207
11651208
Shared by ``render_points`` and the centroid "fast mode" of shapes/labels; ``df`` holds ``x``/``y`` in
11661209
coordinate-system coords. The (possibly recomputed) color vectors are returned so the caller's legend
11671210
matches. ``as_markers`` mimics matplotlib markers: it rasterizes over ``axes_extent`` (the plot frame),
1168-
sizes the spread to the marker radius, and uses a uniform alpha.
1211+
sizes the spread to the marker radius, and uses a uniform alpha. ``radius`` (coordinate-system units)
1212+
overrides ``size`` to spread each dot to a faithful disc of that radius (circle fast-path).
11691213
"""
1170-
# Spread radius = matplotlib marker radius: an 'o' marker has diameter sqrt(s)*dpi/72 px, so radius
1171-
# sqrt(s)*dpi/144. render_points keeps the looser sqrt(s)*dpi/100 it was calibrated with.
1172-
px_div = 144 if as_markers else 100
1173-
px: int | None = None if density else int(np.round(np.sqrt(size) * (fig_params.fig.dpi / px_div)))
1174-
11751214
if as_markers and axes_extent is not None:
11761215
# Size the canvas to the AXES display box, not the figure: the datashader output is a
11771216
# data-coordinate image that scales with the (smaller) axes, so a figure-sized canvas shrinks the
1178-
# dots. With 1 canvas px == 1 axes-display px, the spread radius above matches the marker.
1217+
# dots. With 1 canvas px == 1 axes-display px, the spread radius below matches the marker.
11791218
x_ext = [float(axes_extent["x"][0]), float(axes_extent["x"][1])]
11801219
y_ext = [float(axes_extent["y"][0]), float(axes_extent["y"][1])]
11811220
bb = ax.get_window_extent()
@@ -1184,6 +1223,17 @@ def _datashader_points(
11841223
plot_width, plot_height = int(round(rx / factor)), int(round(ry / factor))
11851224
else:
11861225
plot_width, plot_height, x_ext, y_ext, factor = _datashader_canvas_from_dataframe(df, fig_params)
1226+
1227+
if density:
1228+
px: int | None = None
1229+
elif radius is not None:
1230+
# Faithful disc: spread to the circle's on-screen pixel radius (factor = CS units per axes px).
1231+
# ds.tf.spread's footprint radius is ~px+0.5, so subtract 0.5 to match a filled disc of radius r.
1232+
px = max(int(round(radius / factor - 0.5)), 0)
1233+
else:
1234+
# Spread radius = matplotlib marker radius: an 'o' marker has diameter sqrt(s)*dpi/72 px, so
1235+
# radius sqrt(s)*dpi/144. render_points keeps the looser sqrt(s)*dpi/100 it was calibrated with.
1236+
px = int(np.round(np.sqrt(size) * (fig_params.fig.dpi / (144 if as_markers else 100))))
11871237
cvs = ds.Canvas(plot_width=plot_width, plot_height=plot_height, x_range=x_ext, y_range=y_ext)
11881238

11891239
# ensure color column exists on the frame with positional alignment

tests/pl/test_render_shapes.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,29 @@ def _annotate_polygons_with_outline_columns(sdata: SpatialData) -> SpatialData:
4848

4949

5050
class TestShapes(PlotTester, metaclass=PlotTesterMeta):
51+
def test_plot_circle_render_permutations(self, monkeypatch):
52+
"""2x2 of (geometry / as_points) x (matplotlib / datashader); each row should look similar across backends."""
53+
import spatialdata_plot.pl.render as render_mod
54+
55+
# exercise the datashader circle fast-path (points) on this small uniform set
56+
monkeypatch.setattr(render_mod, "_CIRCLE_FAST_PATH_MIN", 1)
57+
58+
grid = np.arange(8) * 10.0
59+
cx, cy = (a.ravel() for a in np.meshgrid(grid, grid))
60+
gdf = gpd.GeoDataFrame({"radius": np.full(cx.size, 3.0)}, geometry=gpd.points_from_xy(cx, cy))
61+
sdata = SpatialData(shapes={"circ": ShapesModel.parse(gdf)})
62+
63+
_, axs = plt.subplots(2, 2, figsize=(6, 6))
64+
panels = [
65+
(axs[0, 0], "geometry · matplotlib", {"method": "matplotlib"}),
66+
(axs[0, 1], "geometry · datashader", {"method": "datashader"}),
67+
(axs[1, 0], "as_points · matplotlib", {"method": "matplotlib", "as_points": True, "size": 100}),
68+
(axs[1, 1], "as_points · datashader", {"method": "datashader", "as_points": True, "size": 100}),
69+
]
70+
for ax, title, kw in panels:
71+
sdata.pl.render_shapes("circ", **kw).pl.show(ax=ax)
72+
ax.set_title(title, fontsize=8)
73+
5174
def test_plot_can_render_circles(self, sdata_blobs: SpatialData):
5275
sdata_blobs.pl.render_shapes(element="blobs_circles").pl.show()
5376

@@ -1861,3 +1884,42 @@ def test_circle_buffer_fidelity_to_default():
18611884
full = c.buffer(1.0, quad_segs=16)
18621885
iou = reduced.intersection(full).area / reduced.union(full).area
18631886
assert iou >= 0.97
1887+
1888+
1889+
def _uniform_circle_gdf(n: int, radius) -> gpd.GeoDataFrame:
1890+
import shapely
1891+
1892+
radii = radius if hasattr(radius, "__len__") else [radius] * n
1893+
geom = gpd.GeoSeries(shapely.points(np.column_stack([np.arange(n), np.zeros(n)])))
1894+
return gpd.GeoDataFrame({"radius": radii}, geometry=geom)
1895+
1896+
1897+
def test_circles_render_as_points_gate():
1898+
"""Phase 2 gate fires only for a large, uniform-radius, outline-free, default-shape circle element."""
1899+
from types import SimpleNamespace
1900+
1901+
from spatialdata_plot.pl.render import _CIRCLE_FAST_PATH_MIN, _circles_render_as_points
1902+
1903+
big = _CIRCLE_FAST_PATH_MIN + 1
1904+
1905+
def gate(gdf, **kw):
1906+
rp = SimpleNamespace(**{"shape": None, "outline_alpha": (0.0, 0.0), **kw})
1907+
return _circles_render_as_points(gdf, gdf.geometry.type == "Point", rp)
1908+
1909+
assert gate(_uniform_circle_gdf(big, 2.0)) is True
1910+
assert gate(_uniform_circle_gdf(10, 2.0)) is False # too few
1911+
assert gate(_uniform_circle_gdf(big, np.arange(big) + 1.0)) is False # varying radius
1912+
assert gate(_uniform_circle_gdf(big, 2.0), outline_alpha=(1.0, 0.0)) is False # outline requested
1913+
assert gate(_uniform_circle_gdf(big, 2.0), shape="square") is False # custom shape
1914+
1915+
1916+
def test_circle_fast_path_renders_without_error(monkeypatch):
1917+
"""A uniform circle element above the (patched-low) threshold renders via the datashader point path."""
1918+
import spatialdata_plot.pl.render as render_mod
1919+
1920+
monkeypatch.setattr(render_mod, "_CIRCLE_FAST_PATH_MIN", 4)
1921+
sdata = SpatialData(shapes={"circ": ShapesModel.parse(_uniform_circle_gdf(20, 2.0))})
1922+
fig, ax = plt.subplots()
1923+
sdata.pl.render_shapes("circ", method="datashader").pl.show(ax=ax)
1924+
assert len(ax.images) >= 1 # datashader raster produced
1925+
plt.close(fig)

0 commit comments

Comments
 (0)