Skip to content

Commit e454700

Browse files
authored
perf(shapes): vectorize datashader polygon scaling (re-land of #738) (#740)
1 parent 1af9398 commit e454700

3 files changed

Lines changed: 41 additions & 4 deletions

File tree

src/spatialdata_plot/pl/_geometry.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,17 @@ def _scale_path_around_centroid(path: mpath.Path, scale_factor: float) -> None:
4040
path.vertices = centroid + (path.vertices - centroid) * scale_value
4141

4242

43+
def _scale_geometries(geometries: np.ndarray, scale: float) -> np.ndarray:
44+
"""Scale each geometry about its bounding-box centre (``shapely.affinity.scale``'s default origin).
45+
46+
Vectorised over all coordinates at once — a per-geometry ``affinity.scale`` loop dominates large renders.
47+
"""
48+
bbox = shapely.bounds(geometries) # (n, 4): minx, miny, maxx, maxy
49+
centre = np.column_stack([(bbox[:, 0] + bbox[:, 2]) / 2, (bbox[:, 1] + bbox[:, 3]) / 2])
50+
coords, idx = shapely.get_coordinates(geometries, return_index=True)
51+
return shapely.set_coordinates(geometries.copy(), (coords - centre[idx]) * scale + centre[idx])
52+
53+
4354
def _normalize_geom(geom: Any) -> Any:
4455
"""Canonicalize ring orientation so matplotlib's fill rules render holes correctly.
4556

src/spatialdata_plot/pl/render.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
_build_shape_paths,
5858
_convert_shapes,
5959
_get_collection_shape,
60+
_scale_geometries,
6061
_validate_polygons,
6162
)
6263
from spatialdata_plot.pl._validate import (
@@ -817,10 +818,8 @@ def _draw_centroids(xy: np.ndarray, radius: float | None = None) -> None:
817818
# Handle polygon/multipolygon scaling
818819
is_polygon = _geometry.type.isin(["Polygon", "MultiPolygon"])
819820
if is_polygon.any() and render_params.scale != 1.0:
820-
from shapely import affinity
821-
822-
shapes.loc[is_polygon, "geometry"] = _geometry[is_polygon].apply(
823-
lambda geom: affinity.scale(geom, xfact=render_params.scale, yfact=render_params.scale)
821+
shapes.loc[is_polygon, "geometry"] = _scale_geometries(
822+
_geometry[is_polygon].to_numpy(), render_params.scale
824823
)
825824

826825
# apply transformations to the individual points

tests/pl/test_render_shapes.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1978,3 +1978,30 @@ def bbox(**kw):
19781978
return xs.min(), xs.max(), ys.min(), ys.max()
19791979

19801980
assert bbox() == bbox(outline_width=1.0, outline_alpha=1.0, outline_color="black")
1981+
1982+
1983+
def test_scale_geometries_matches_affinity_scale():
1984+
# The vectorised datashader polygon scale must equal shapely.affinity.scale's default
1985+
# (bounding-box-centre) origin, including for asymmetric shapes, multipolygons and holes.
1986+
import shapely
1987+
from shapely import affinity
1988+
1989+
from spatialdata_plot.pl._geometry import _scale_geometries
1990+
1991+
rng = np.random.default_rng(0)
1992+
geoms = []
1993+
for cx, cy in rng.random((50, 2)) * 100:
1994+
# asymmetric exterior (bbox-centre != centroid) with a hole
1995+
geoms.append(
1996+
Polygon(
1997+
[(cx, cy), (cx + 6, cy + 1), (cx + 5, cy + 4), (cx + 1, cy + 3)],
1998+
[[(cx + 2, cy + 2), (cx + 3, cy + 2), (cx + 3, cy + 3), (cx + 2, cy + 3)][::-1]],
1999+
)
2000+
)
2001+
geoms.append(MultiPolygon([geoms[0], affinity.translate(geoms[1], 10, 10)])) # multi-part
2002+
arr = np.array(geoms, dtype=object)
2003+
2004+
for scale in (0.6, 2.0):
2005+
expected = np.array([affinity.scale(g, xfact=scale, yfact=scale) for g in geoms], dtype=object)
2006+
result = _scale_geometries(arr, scale)
2007+
assert all(shapely.equals_exact(a, b, tolerance=1e-9) for a, b in zip(expected, result, strict=True))

0 commit comments

Comments
 (0)