Skip to content

Commit cf14b3e

Browse files
committed
perf(render_shapes): build Paths + PathCollection instead of per-shape Patch objects (#733)
render_shapes (matplotlib) was dominated by constructing one mpatches.Circle/Polygon per shape (each Patch.__init__ resolves default colours via to_rgba + recomputes a transform), then PatchCollection.set_paths converting all N to Paths. Build matplotlib Path objects directly (the form set_paths bakes anyway) and return a PathCollection. Same Paths -> byte-identical; preserves holes (compound paths) and all colour/alpha/outline logic; one code path; no signature change. Apply the coordinate-system affine once to the shared paths (fill + outline collections reference the same Path objects) instead of per-collection, dropping the 4 get_paths loops. End-to-end (300k circles, mpl categorical): ~1.7x faster, ~5.3x lower peak memory. Byte-identical verified: main-vs-branch RGBA == 0 across circles/polygons/multipolygon -with-holes x {categorical, continuous, no-color} x {fill, outline}; plus a guard test that the affine is applied once under a non-identity transform + outline.
1 parent 2cc819e commit cf14b3e

3 files changed

Lines changed: 92 additions & 79 deletions

File tree

src/spatialdata_plot/pl/_geometry.py

Lines changed: 56 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,12 @@
55
import math
66
from typing import Any
77

8-
import matplotlib.patches as mpatches
98
import matplotlib.path as mpath
109
import numpy as np
1110
import pandas as pd
1211
import shapely
1312
from geopandas import GeoDataFrame
14-
from matplotlib.collections import PatchCollection
13+
from matplotlib.collections import PathCollection
1514
from matplotlib.colors import ColorConverter
1615
from scipy.spatial import ConvexHull
1716
from shapely.errors import GEOSException
@@ -21,9 +20,7 @@
2120
from spatialdata_plot.pl.utils import _extract_scalar_value
2221

2322

24-
def _get_centroid_of_pathpatch(pathpatch: mpatches.PathPatch) -> tuple[float, float]:
25-
# Extract the vertices from the PathPatch
26-
path = pathpatch.get_path()
23+
def _get_centroid_of_path(path: mpath.Path) -> tuple[float, float]:
2724
vertices = path.vertices
2825
x = vertices[:, 0]
2926
y = vertices[:, 1]
@@ -37,12 +34,10 @@ def _get_centroid_of_pathpatch(pathpatch: mpatches.PathPatch) -> tuple[float, fl
3734
return centroid_x, centroid_y
3835

3936

40-
def _scale_pathpatch_around_centroid(pathpatch: mpatches.PathPatch, scale_factor: float) -> None:
37+
def _scale_path_around_centroid(path: mpath.Path, scale_factor: float) -> None:
4138
scale_value = _extract_scalar_value(scale_factor, default=1.0)
42-
centroid = _get_centroid_of_pathpatch(pathpatch)
43-
vertices = pathpatch.get_path().vertices
44-
scaled_vertices = np.array([centroid + (vertex - centroid) * scale_value for vertex in vertices])
45-
pathpatch.get_path().vertices = scaled_vertices
39+
centroid = np.asarray(_get_centroid_of_path(path))
40+
path.vertices = centroid + (path.vertices - centroid) * scale_value
4641

4742

4843
def _normalize_geom(geom: Any) -> Any:
@@ -67,16 +62,16 @@ def _normalize_geom(geom: Any) -> Any:
6762
return geom
6863

6964

70-
def _make_patch_from_multipolygon(mp: shapely.MultiPolygon) -> list[mpatches.PathPatch]:
65+
def _make_paths_from_multipolygon(mp: shapely.MultiPolygon) -> list[mpath.Path]:
7166
"""
72-
Create PathPatches from a MultiPolygon, preserving holes robustly.
67+
Create matplotlib ``Path``s from a MultiPolygon, preserving holes robustly.
7368
7469
This follows the same strategy as GeoPandas' internal Polygon plotting:
7570
each (multi)polygon part becomes a compound Path composed of the exterior
7671
ring and all interior rings. Orientation is handled by prior geometry
7772
normalization rather than manual ring reversal.
7873
"""
79-
patches: list[mpatches.PathPatch] = []
74+
paths: list[mpath.Path] = []
8075

8176
for poly in mp.geoms:
8277
if poly.is_empty:
@@ -88,36 +83,38 @@ def _make_patch_from_multipolygon(mp: shapely.MultiPolygon) -> list[mpatches.Pat
8883

8984
if len(interiors) == 0:
9085
# Simple polygon without holes
91-
patches.append(mpatches.Polygon(exterior, closed=True))
86+
paths.append(mpath.Path(exterior, closed=True))
9287
continue
9388

9489
# Build a compound path: exterior + all interior rings
95-
compound_path = mpath.Path.make_compound_path(
96-
mpath.Path(exterior, closed=True),
97-
*[mpath.Path(ring, closed=True) for ring in interiors],
90+
paths.append(
91+
mpath.Path.make_compound_path(
92+
mpath.Path(exterior, closed=True),
93+
*[mpath.Path(ring, closed=True) for ring in interiors],
94+
)
9895
)
99-
patches.append(mpatches.PathPatch(compound_path))
10096

101-
return patches
97+
return paths
10298

10399

104-
def _build_shape_patches(
100+
def _build_shape_paths(
105101
shapes: GeoDataFrame,
106102
scale: float,
107-
) -> tuple[list[mpatches.Patch], list[int], int]:
108-
"""Build matplotlib patches from shape geometries, once.
103+
) -> tuple[list[mpath.Path], list[int], int]:
104+
"""Build matplotlib ``Path``s from shape geometries, once.
109105
110-
Patch geometry is independent of colour/alpha, so it can be built a single time and
111-
shared across the fill and outline ``PatchCollection``s in :func:`_render_shapes`
112-
instead of being rebuilt per layer (the dominant cost for shape elements).
106+
Geometry is independent of colour/alpha, so it is built a single time and shared across the fill and
107+
outline ``PathCollection``s in :func:`_render_shapes`. Using ``Path`` objects (the form a
108+
``PatchCollection`` bakes internally anyway) and a ``PathCollection`` avoids constructing one
109+
``matplotlib.patches.*`` object per shape — the dominant cost for large shape elements.
113110
114111
Returns
115112
-------
116-
patches
117-
The matplotlib patches (a MultiPolygon expands to several patches).
118-
patch_row_idx
119-
For each patch, the index into the empty-filtered, re-indexed shapes — used to
120-
look up the per-shape colour.
113+
paths
114+
The matplotlib ``Path``s (a MultiPolygon expands to several paths).
115+
row_idx
116+
For each path, the index into the empty-filtered, re-indexed shapes — used to look up the
117+
per-shape colour.
121118
n_shapes
122119
Number of shapes after empty filtering (used for the single-colour broadcast rule).
123120
"""
@@ -138,28 +135,32 @@ def _build_shape_patches(
138135

139136
# Resolve the scale scalar once instead of per shape.
140137
scale_value = _extract_scalar_value(scale, default=1.0)
138+
unit_circle = mpath.Path.unit_circle()
141139

142-
patches: list[mpatches.Patch] = []
143-
patch_row_idx: list[int] = []
140+
paths: list[mpath.Path] = []
141+
row_idx: list[int] = []
144142
for i, geom in enumerate(geoms):
145143
geom_type = geom.geom_type
146144
if geom_type == "Polygon":
147145
coords = np.asarray(geom.exterior.coords)
148146
centroid = np.mean(coords, axis=0)
149147
scaled = centroid + (coords - centroid) * scale_value
150-
patches.append(mpatches.Polygon(scaled, closed=True))
151-
patch_row_idx.append(i)
148+
paths.append(mpath.Path(scaled, closed=True))
149+
row_idx.append(i)
152150
elif geom_type == "MultiPolygon":
153-
for m in _make_patch_from_multipolygon(geom):
154-
_scale_pathpatch_around_centroid(m, scale_value)
155-
patches.append(m)
156-
patch_row_idx.append(i)
151+
for p in _make_paths_from_multipolygon(geom):
152+
_scale_path_around_centroid(p, scale_value)
153+
paths.append(p)
154+
row_idx.append(i)
157155
elif geom_type == "Point":
158156
radius_value = _extract_scalar_value(radii[i], default=0.0) if radii is not None else 0.0
159-
patches.append(mpatches.Circle((geom.x, geom.y), radius=radius_value * scale_value))
160-
patch_row_idx.append(i)
157+
# unit circle scaled by radius and translated to the centre — identical to the path a
158+
# PatchCollection bakes from mpatches.Circle((x, y), radius).
159+
verts = unit_circle.vertices * (radius_value * scale_value) + (geom.x, geom.y)
160+
paths.append(mpath.Path(verts, unit_circle.codes))
161+
row_idx.append(i)
161162

162-
return patches, patch_row_idx, len(geoms)
163+
return paths, row_idx, len(geoms)
163164

164165

165166
def _get_collection_shape(
@@ -171,10 +172,10 @@ def _get_collection_shape(
171172
outline_alpha: None | float = None,
172173
outline_color: None | str | list[float] | np.ndarray = "white",
173174
linewidth: float = 0.0,
174-
prebuilt_patches: tuple[list[mpatches.Patch], list[int], int] | None = None,
175+
prebuilt_paths: tuple[list[mpath.Path], list[int], int] | None = None,
175176
**kwargs: Any,
176-
) -> PatchCollection:
177-
"""Build a PatchCollection for shapes.
177+
) -> PathCollection:
178+
"""Build a PathCollection for shapes.
178179
179180
``c`` is the per-row fill: an ``(N, 4)`` RGBA array (from :meth:`ColorSpec.to_rgba`) or a single
180181
color / list of color specs (broadcast). ``outline_color`` may be an ``(N, 4)`` float RGBA array,
@@ -208,26 +209,24 @@ def _get_collection_shape(
208209
else:
209210
outline_c = [None] * fill_c.shape[0]
210211

211-
# Build (or reuse) the matplotlib patches. Geometry is colour-independent, so the
212-
# caller can build it once via `_build_shape_patches` and share it across the fill
212+
# Build (or reuse) the matplotlib paths. Geometry is colour-independent, so the
213+
# caller can build it once via `_build_shape_paths` and share it across the fill
213214
# and outline collections instead of rebuilding it on every call.
214-
patches, patch_row_idx, n_shapes = (
215-
prebuilt_patches if prebuilt_patches is not None else _build_shape_patches(shapes, s)
216-
)
215+
paths, row_idx, n_shapes = prebuilt_paths if prebuilt_paths is not None else _build_shape_paths(shapes, s)
217216

218-
if not patches:
219-
return PatchCollection([])
217+
if not paths:
218+
return PathCollection([])
220219

221-
# Expand the per-shape fill colours to per-patch (a MultiPolygon owns several
222-
# patches). Preserve the single-colour broadcast used for multi-shape elements.
220+
# Expand the per-shape fill colours to per-path (a MultiPolygon owns several
221+
# paths). Preserve the single-colour broadcast used for multi-shape elements.
223222
broadcast_single = n_shapes > 1 and len(fill_c) == 1
224-
patch_fill = np.repeat(fill_c, len(patches), axis=0) if broadcast_single else fill_c[patch_row_idx]
223+
path_fill = np.repeat(fill_c, len(paths), axis=0) if broadcast_single else fill_c[row_idx]
225224

226-
return PatchCollection(
227-
patches,
225+
return PathCollection(
226+
paths,
228227
snap=False,
229228
lw=linewidth,
230-
facecolor=patch_fill,
229+
facecolor=path_fill,
231230
edgecolor=None if all(o is None for o in outline_c) else outline_c,
232231
**kwargs,
233232
)

src/spatialdata_plot/pl/render.py

Lines changed: 13 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@
5454
_shade_datashader_aggregate,
5555
)
5656
from spatialdata_plot.pl._geometry import (
57-
_build_shape_patches,
57+
_build_shape_paths,
5858
_convert_shapes,
5959
_get_collection_shape,
6060
_validate_polygons,
@@ -922,9 +922,14 @@ def _draw_centroids(xy: np.ndarray, radius: float | None = None) -> None:
922922
cax = _build_ds_colorbar(reduction_bounds, norm, render_params.cmap_params.cmap)
923923

924924
elif method == "matplotlib":
925-
# Build the matplotlib patches once and share them across the fill and outline
926-
# collections; the geometry is identical, only colours/alpha/linewidth differ.
927-
prebuilt_patches = _build_shape_patches(shapes, render_params.scale)
925+
# Build the matplotlib paths once and share them across the fill and outline collections;
926+
# the geometry is identical, only colours/alpha/linewidth differ.
927+
prebuilt_paths = _build_shape_paths(shapes, render_params.scale)
928+
# Apply the coordinate-system affine ONCE to the shared paths. The fill and outline
929+
# collections reference the same Path objects, so transforming per-collection (as the old
930+
# PatchCollection path did) applied `trans` 2-3x — a latent double-transform for polygons.
931+
for path in prebuilt_paths[0]:
932+
path.vertices = trans.transform(path.vertices)
928933

929934
# render outlines separately to ensure they are always underneath the shape
930935
if col_for_outline_color is not None and render_params.outline_alpha[0] > 0:
@@ -939,13 +944,11 @@ def _draw_centroids(xy: np.ndarray, radius: float | None = None) -> None:
939944
fill_alpha=0.0,
940945
outline_alpha=render_params.outline_alpha[0],
941946
outline_color=outline_rgba,
942-
prebuilt_patches=prebuilt_patches,
947+
prebuilt_paths=prebuilt_paths,
943948
linewidth=render_params.outline_params.outer_outline_linewidth,
944949
zorder=render_params.zorder,
945950
)
946951
ax.add_collection(_cax)
947-
for path in _cax.get_paths():
948-
path.vertices = trans.transform(path.vertices)
949952
elif render_params.outline_alpha[0] > 0 and isinstance(render_params.outline_params.outer_outline_color, Color):
950953
_cax = _get_collection_shape(
951954
shapes=shapes,
@@ -957,15 +960,12 @@ def _draw_centroids(xy: np.ndarray, radius: float | None = None) -> None:
957960
fill_alpha=0.0,
958961
outline_alpha=render_params.outline_alpha[0],
959962
outline_color=render_params.outline_params.outer_outline_color.get_hex(),
960-
prebuilt_patches=prebuilt_patches,
963+
prebuilt_paths=prebuilt_paths,
961964
linewidth=render_params.outline_params.outer_outline_linewidth,
962965
zorder=render_params.zorder,
963966
# **kwargs,
964967
)
965968
cax = ax.add_collection(_cax)
966-
# Transform the paths in PatchCollection
967-
for path in _cax.get_paths():
968-
path.vertices = trans.transform(path.vertices)
969969
if render_params.outline_alpha[1] > 0 and isinstance(render_params.outline_params.inner_outline_color, Color):
970970
_cax = _get_collection_shape(
971971
shapes=shapes,
@@ -977,21 +977,18 @@ def _draw_centroids(xy: np.ndarray, radius: float | None = None) -> None:
977977
fill_alpha=0.0,
978978
outline_alpha=render_params.outline_alpha[1],
979979
outline_color=render_params.outline_params.inner_outline_color.get_hex(),
980-
prebuilt_patches=prebuilt_patches,
980+
prebuilt_paths=prebuilt_paths,
981981
linewidth=render_params.outline_params.inner_outline_linewidth,
982982
zorder=render_params.zorder,
983983
# **kwargs,
984984
)
985985
cax = ax.add_collection(_cax)
986-
# Transform the paths in PatchCollection
987-
for path in _cax.get_paths():
988-
path.vertices = trans.transform(path.vertices)
989986

990987
_cax = _get_collection_shape(
991988
shapes=shapes,
992989
s=render_params.scale,
993990
c=color_spec.to_rgba(render_params.cmap_params),
994-
prebuilt_patches=prebuilt_patches,
991+
prebuilt_paths=prebuilt_paths,
995992
render_params=render_params,
996993
rasterized=sc_settings._vector_friendly,
997994
cmap=render_params.cmap_params.cmap,
@@ -1002,10 +999,6 @@ def _draw_centroids(xy: np.ndarray, radius: float | None = None) -> None:
1002999
)
10031000
cax = ax.add_collection(_cax)
10041001

1005-
# Transform the paths in PatchCollection
1006-
for path in _cax.get_paths():
1007-
path.vertices = trans.transform(path.vertices)
1008-
10091002
if color_spec.is_continuous:
10101003
# Colorbar uses the same resolved norm the fill pixels use, including its subclass
10111004
# (LogNorm/PowerNorm) — set_norm, not set_clim, which would leave the collection's

tests/pl/test_render_shapes.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1877,11 +1877,11 @@ def test_render_shapes_as_points_default_is_matplotlib(sdata_blobs: SpatialData)
18771877

18781878
def test_continuous_fill_colorbar_matches_pixel_range(sdata_blobs_shapes_annotated: SpatialData):
18791879
"""The fill colorbar clim is the resolved data range, so the bar matches the shapes."""
1880-
from matplotlib.collections import PatchCollection
1880+
from matplotlib.collections import PathCollection
18811881

18821882
fig, ax = plt.subplots()
18831883
sdata_blobs_shapes_annotated.pl.render_shapes("blobs_polygons", color="value").pl.show(ax=ax)
1884-
clims = [c.get_clim() for c in ax.collections if isinstance(c, PatchCollection)]
1884+
clims = [c.get_clim() for c in ax.collections if isinstance(c, PathCollection)]
18851885
plt.close(fig)
18861886
assert clims == [(1.0, 5.0)] # fixture's value column is [1, 2, 3, 4, 5]
18871887

@@ -1957,3 +1957,24 @@ def spy(*args, **kwargs):
19571957
assert seen.get("radius") is not None # fast path ran and sized the dots to the disc radius
19581958
assert len(ax.images) >= 1 # datashader raster produced
19591959
plt.close(fig)
1960+
1961+
1962+
def test_shapes_outline_does_not_double_apply_transform():
1963+
# The coordinate-system affine must be applied once regardless of outlines: the fill and outline
1964+
# PathCollections share the same Path objects, so applying it per-collection would double it.
1965+
gdf = ShapesModel.parse(gpd.GeoDataFrame({"geometry": [Polygon([(10, 10), (20, 10), (20, 20), (10, 20)])]}))
1966+
set_transformation(gdf, Scale([3, 3], axes=("x", "y")), "global")
1967+
sdata = SpatialData(shapes={"p": gdf})
1968+
1969+
def bbox(**kw):
1970+
fig, ax = plt.subplots()
1971+
ax.set_xlim(0, 100)
1972+
ax.set_ylim(0, 100)
1973+
sdata.pl.render_shapes("p", color="#3366cc", **kw).pl.show(ax=ax)
1974+
fig.canvas.draw()
1975+
buf = np.asarray(fig.canvas.buffer_rgba())
1976+
ys, xs = np.where((buf[:, :, :3] < 250).any(axis=2))
1977+
plt.close(fig)
1978+
return xs.min(), xs.max(), ys.min(), ys.max()
1979+
1980+
assert bbox() == bbox(outline_width=1.0, outline_alpha=1.0, outline_color="black")

0 commit comments

Comments
 (0)