Skip to content

Commit e039008

Browse files
committed
perf(shapes): adaptive circle-buffer resolution for the datashader path
Circles (Point+radius) were buffered to polygons at shapely's default resolution=16 (65 vertices/circle) before datashader rasterization. For large circle sets this coordinate explosion dominates the render (buffer + per-vertex transform + polygon aggregation), e.g. ~5.9M coords for 91k circles. Choose the buffer resolution from the largest disc's on-screen pixel radius (_circle_buffer_quad_segs / _circle_quad_segs): 4 segments/quadrant for small discs (<=8px, where extra vertices are sub-pixel), 8 (<=32px), and shapely's full 16 once discs are large enough to show facets. Faithful (IoU >=0.98 vs the 65-vertex circle) and handles per-circle varying radii. End-to-end on Visium HD (single coordinate system): 91k circles 2.0s->1.5s, 352k circles 8.3s->4.9s. Note: shifts the datashader-circle visual baselines (17- vs 65-vertex circles); regenerate those from CI artifacts.
1 parent 480d9f0 commit e039008

3 files changed

Lines changed: 74 additions & 5 deletions

File tree

src/spatialdata_plot/pl/_datashader.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -721,6 +721,48 @@ def _pad_degenerate_extent(ext: list[Any]) -> list[Any]:
721721
return [ext[0] - 0.5, ext[1] + 0.5] if ext[1] == ext[0] else ext
722722

723723

724+
def _circle_quad_segs(max_radius_px: float) -> int:
725+
"""Segments-per-quadrant for buffering circles, chosen by the largest disc's on-screen radius.
726+
727+
Circles are stored as ``Point + radius`` and buffered to polygons before datashader rasterizes
728+
them; shapely's default (``resolution=16`` = 65 vertices) is far more than a small disc needs and
729+
dominates the render cost for large circle sets. Use fewer vertices when discs are small (their
730+
extra vertices are sub-pixel and invisible) and only step up to the full default once discs are
731+
large enough to show facets. ``NaN`` (e.g. all-NaN radius) falls through to the faithful default.
732+
"""
733+
if max_radius_px <= 8:
734+
return 4
735+
if max_radius_px <= 32:
736+
return 8
737+
return 16
738+
739+
740+
def _circle_buffer_quad_segs(
741+
centroids_xy: np.ndarray,
742+
max_radius: float,
743+
tm: np.ndarray,
744+
fig_params: FigParams,
745+
) -> int:
746+
"""Pick the circle-buffer ``resolution`` from the largest circle's on-screen pixel radius.
747+
748+
Estimates the same world-units-per-pixel ``factor`` the datashader canvas will use (mirrors
749+
``_compute_datashader_canvas_params``), computed *before* buffering from the transformed centroids
750+
expanded by the (major-axis) radius. ``tm`` is the coordinate-system affine; an anisotropic/shear
751+
transform turns the circle into an ellipse, so size to its largest stretch (major axis).
752+
"""
753+
linear = tm[:2, :2]
754+
stretch = float(np.linalg.svd(linear, compute_uv=False).max()) # circle -> ellipse major-axis scale
755+
r_t = float(max_radius) * stretch
756+
xy_t = centroids_xy @ linear.T + tm[:2, 2]
757+
ext_w = (xy_t[:, 0].max() + r_t) - (xy_t[:, 0].min() - r_t)
758+
ext_h = (xy_t[:, 1].max() + r_t) - (xy_t[:, 1].min() - r_t)
759+
fig = fig_params.fig
760+
fig_px_w = fig.get_size_inches()[0] * fig.dpi
761+
fig_px_h = fig.get_size_inches()[1] * fig.dpi
762+
factor = max(ext_w / fig_px_w, ext_h / fig_px_h)
763+
return _circle_quad_segs(r_t / factor if factor > 0 else 0.0)
764+
765+
724766
def _compute_datashader_canvas_params(
725767
x_ext: list[Any],
726768
y_ext: list[Any],

src/spatialdata_plot/pl/render.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
from spatialdata_plot.pl._datashader import (
4747
_ax_show_and_transform,
4848
_build_ds_colorbar,
49+
_circle_buffer_quad_segs,
4950
_datashader_canvas_from_dataframe,
5051
_get_extent_and_range_for_datashader_canvas,
5152
_hex_no_alpha,
@@ -770,14 +771,19 @@ def _render_shapes(
770771
if method == "datashader":
771772
_geometry = shapes["geometry"]
772773
is_point = _geometry.type == "Point"
774+
tm = trans.get_matrix() # coordinate-system affine; reused for circle sizing and the transform below
773775

774776
# Handle circles encoded as points with radius
775777
if is_point.any():
776-
radius_values = shapes[is_point]["radius"]
777778
# Convert to numeric, replacing non-numeric values with NaN
778-
radius_numeric = pd.to_numeric(radius_values, errors="coerce")
779-
scale = radius_numeric * render_params.scale
780-
shapes.loc[is_point, "geometry"] = _geometry[is_point].buffer(scale.to_numpy())
779+
radius = (pd.to_numeric(shapes[is_point]["radius"], errors="coerce") * render_params.scale).to_numpy()
780+
points = _geometry[is_point]
781+
# Buffer at a vertex count matched to the largest disc's on-screen size: tiny discs don't
782+
# need shapely's 65-vertex default, which otherwise dominates the render for large sets.
783+
quad_segs = _circle_buffer_quad_segs(
784+
np.column_stack([points.x.to_numpy(), points.y.to_numpy()]), float(np.nanmax(radius)), tm, fig_params
785+
)
786+
shapes.loc[is_point, "geometry"] = points.buffer(radius, resolution=quad_segs)
781787

782788
# Handle polygon/multipolygon scaling
783789
is_polygon = _geometry.type.isin(["Polygon", "MultiPolygon"])
@@ -789,7 +795,6 @@ def _render_shapes(
789795
)
790796

791797
# apply transformations to the individual points
792-
tm = trans.get_matrix()
793798
transformed_geometry = shapes["geometry"].transform(
794799
lambda x: (np.hstack([x, np.ones((x.shape[0], 1))]) @ tm.T)[:, :2]
795800
)

tests/pl/test_render_shapes.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1839,3 +1839,25 @@ def test_continuous_fill_colorbar_matches_pixel_range(sdata_blobs_shapes_annotat
18391839
clims = [c.get_clim() for c in ax.collections if isinstance(c, PatchCollection)]
18401840
plt.close(fig)
18411841
assert clims == [(1.0, 5.0)] # fixture's value column is [1, 2, 3, 4, 5]
1842+
1843+
1844+
# ---------------------------------------------------------------------------
1845+
# Adaptive circle buffer resolution (datashader perf)
1846+
# ---------------------------------------------------------------------------
1847+
1848+
1849+
def test_circle_quad_segs_step_rule():
1850+
"""quad_segs steps up with on-screen disc radius: ≤8px→4, ≤32px→8, else 16 (NaN→faithful 16)."""
1851+
from spatialdata_plot.pl._datashader import _circle_quad_segs
1852+
1853+
assert [_circle_quad_segs(r) for r in (0.5, 8.0, 8.1, 32.0, 33.0, 500.0)] == [4, 4, 8, 8, 16, 16]
1854+
assert _circle_quad_segs(float("nan")) == 16 # all-NaN radius falls back to the faithful default
1855+
1856+
1857+
def test_circle_buffer_fidelity_to_default():
1858+
"""A reduced-vertex circle (quad_segs=4) still matches shapely's default (resolution=16) within 3%."""
1859+
c = Point(5.0, 5.0)
1860+
reduced = c.buffer(1.0, quad_segs=4)
1861+
full = c.buffer(1.0, quad_segs=16)
1862+
iou = reduced.intersection(full).area / reduced.union(full).area
1863+
assert iou >= 0.97

0 commit comments

Comments
 (0)