Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 50 additions & 58 deletions src/spatialdata_plot/pl/_geometry.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,12 @@
import math
from typing import Any

import matplotlib.patches as mpatches
import matplotlib.path as mpath
import numpy as np
import pandas as pd
import shapely
from geopandas import GeoDataFrame
from matplotlib.collections import PatchCollection
from matplotlib.collections import PathCollection
from matplotlib.colors import ColorConverter
from scipy.spatial import ConvexHull
from shapely.errors import GEOSException
Expand All @@ -21,9 +20,7 @@
from spatialdata_plot.pl.utils import _extract_scalar_value


def _get_centroid_of_pathpatch(pathpatch: mpatches.PathPatch) -> tuple[float, float]:
# Extract the vertices from the PathPatch
path = pathpatch.get_path()
def _get_centroid_of_path(path: mpath.Path) -> tuple[float, float]:
vertices = path.vertices
x = vertices[:, 0]
y = vertices[:, 1]
Expand All @@ -37,12 +34,10 @@ def _get_centroid_of_pathpatch(pathpatch: mpatches.PathPatch) -> tuple[float, fl
return centroid_x, centroid_y


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


def _normalize_geom(geom: Any) -> Any:
Expand All @@ -67,16 +62,16 @@ def _normalize_geom(geom: Any) -> Any:
return geom


def _make_patch_from_multipolygon(mp: shapely.MultiPolygon) -> list[mpatches.PathPatch]:
def _make_paths_from_multipolygon(mp: shapely.MultiPolygon) -> list[mpath.Path]:
"""
Create PathPatches from a MultiPolygon, preserving holes robustly.
Create matplotlib ``Path``s from a MultiPolygon, preserving holes robustly.

This follows the same strategy as GeoPandas' internal Polygon plotting:
each (multi)polygon part becomes a compound Path composed of the exterior
ring and all interior rings. Orientation is handled by prior geometry
normalization rather than manual ring reversal.
"""
patches: list[mpatches.PathPatch] = []
paths: list[mpath.Path] = []

for poly in mp.geoms:
if poly.is_empty:
Expand All @@ -88,36 +83,37 @@ def _make_patch_from_multipolygon(mp: shapely.MultiPolygon) -> list[mpatches.Pat

if len(interiors) == 0:
# Simple polygon without holes
patches.append(mpatches.Polygon(exterior, closed=True))
paths.append(mpath.Path(exterior, closed=True))
continue

# Build a compound path: exterior + all interior rings
compound_path = mpath.Path.make_compound_path(
mpath.Path(exterior, closed=True),
*[mpath.Path(ring, closed=True) for ring in interiors],
paths.append(
mpath.Path.make_compound_path(
mpath.Path(exterior, closed=True),
*[mpath.Path(ring, closed=True) for ring in interiors],
)
)
patches.append(mpatches.PathPatch(compound_path))

return patches
return paths


def _build_shape_patches(
def _build_shape_paths(
shapes: GeoDataFrame,
scale: float,
) -> tuple[list[mpatches.Patch], list[int], int]:
"""Build matplotlib patches from shape geometries, once.
) -> tuple[list[mpath.Path], list[int], int]:
"""Build matplotlib ``Path``s from shape geometries, once.

Patch geometry is independent of colour/alpha, so it can be built a single time and
shared across the fill and outline ``PatchCollection``s in :func:`_render_shapes`
instead of being rebuilt per layer (the dominant cost for shape elements).
Built once and shared across the fill and outline ``PathCollection``s in :func:`_render_shapes`.
Emitting ``Path``s directly avoids constructing one ``matplotlib.patches.*`` object per shape — the
dominant cost for large shape elements.

Returns
-------
patches
The matplotlib patches (a MultiPolygon expands to several patches).
patch_row_idx
For each patch, the index into the empty-filtered, re-indexed shapes — used to
look up the per-shape colour.
paths
The matplotlib ``Path``s (a MultiPolygon expands to several paths).
row_idx
For each path, the index into the empty-filtered, re-indexed shapes — used to look up the
per-shape colour.
n_shapes
Number of shapes after empty filtering (used for the single-colour broadcast rule).
"""
Expand All @@ -139,27 +135,27 @@ def _build_shape_patches(
# Resolve the scale scalar once instead of per shape.
scale_value = _extract_scalar_value(scale, default=1.0)

patches: list[mpatches.Patch] = []
patch_row_idx: list[int] = []
paths: list[mpath.Path] = []
row_idx: list[int] = []
for i, geom in enumerate(geoms):
geom_type = geom.geom_type
if geom_type == "Polygon":
coords = np.asarray(geom.exterior.coords)
centroid = np.mean(coords, axis=0)
scaled = centroid + (coords - centroid) * scale_value
patches.append(mpatches.Polygon(scaled, closed=True))
patch_row_idx.append(i)
paths.append(mpath.Path(scaled, closed=True))
row_idx.append(i)
elif geom_type == "MultiPolygon":
for m in _make_patch_from_multipolygon(geom):
_scale_pathpatch_around_centroid(m, scale_value)
patches.append(m)
patch_row_idx.append(i)
for p in _make_paths_from_multipolygon(geom):
_scale_path_around_centroid(p, scale_value)
paths.append(p)
row_idx.append(i)
elif geom_type == "Point":
radius_value = _extract_scalar_value(radii[i], default=0.0) if radii is not None else 0.0
patches.append(mpatches.Circle((geom.x, geom.y), radius=radius_value * scale_value))
patch_row_idx.append(i)
paths.append(mpath.Path.circle((geom.x, geom.y), radius_value * scale_value))
row_idx.append(i)

return patches, patch_row_idx, len(geoms)
return paths, row_idx, len(geoms)


def _get_collection_shape(
Expand All @@ -171,10 +167,10 @@ def _get_collection_shape(
outline_alpha: None | float = None,
outline_color: None | str | list[float] | np.ndarray = "white",
linewidth: float = 0.0,
prebuilt_patches: tuple[list[mpatches.Patch], list[int], int] | None = None,
prebuilt_paths: tuple[list[mpath.Path], list[int], int] | None = None,
**kwargs: Any,
) -> PatchCollection:
"""Build a PatchCollection for shapes.
) -> PathCollection:
"""Build a PathCollection for shapes.

``c`` is the per-row fill: an ``(N, 4)`` RGBA array (from :meth:`ColorSpec.to_rgba`) or a single
color / list of color specs (broadcast). ``outline_color`` may be an ``(N, 4)`` float RGBA array,
Expand Down Expand Up @@ -208,26 +204,22 @@ def _get_collection_shape(
else:
outline_c = [None] * fill_c.shape[0]

# Build (or reuse) the matplotlib patches. Geometry is colour-independent, so the
# caller can build it once via `_build_shape_patches` and share it across the fill
# and outline collections instead of rebuilding it on every call.
patches, patch_row_idx, n_shapes = (
prebuilt_patches if prebuilt_patches is not None else _build_shape_patches(shapes, s)
)
# Reuse the shared paths when provided (see _build_shape_paths), else build them.
paths, row_idx, n_shapes = prebuilt_paths if prebuilt_paths is not None else _build_shape_paths(shapes, s)

if not patches:
return PatchCollection([])
if not paths:
return PathCollection([])

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

return PatchCollection(
patches,
return PathCollection(
paths,
snap=False,
lw=linewidth,
facecolor=patch_fill,
facecolor=path_fill,
edgecolor=None if all(o is None for o in outline_c) else outline_c,
**kwargs,
)
Expand Down
31 changes: 11 additions & 20 deletions src/spatialdata_plot/pl/render.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@
_shade_datashader_aggregate,
)
from spatialdata_plot.pl._geometry import (
_build_shape_patches,
_build_shape_paths,
_convert_shapes,
_get_collection_shape,
_validate_polygons,
Expand Down Expand Up @@ -922,9 +922,12 @@ def _draw_centroids(xy: np.ndarray, radius: float | None = None) -> None:
cax = _build_ds_colorbar(reduction_bounds, norm, render_params.cmap_params.cmap)

elif method == "matplotlib":
# Build the matplotlib patches once and share them across the fill and outline
# collections; the geometry is identical, only colours/alpha/linewidth differ.
prebuilt_patches = _build_shape_patches(shapes, render_params.scale)
# Build the paths once and share them across the fill and outline collections (geometry is
# identical; only colours/alpha/linewidth differ), then apply the coordinate-system affine
# once to the shared Path objects rather than once per collection.
prebuilt_paths = _build_shape_paths(shapes, render_params.scale)
for path in prebuilt_paths[0]:
path.vertices = trans.transform(path.vertices)

# render outlines separately to ensure they are always underneath the shape
if col_for_outline_color is not None and render_params.outline_alpha[0] > 0:
Expand All @@ -939,13 +942,11 @@ def _draw_centroids(xy: np.ndarray, radius: float | None = None) -> None:
fill_alpha=0.0,
outline_alpha=render_params.outline_alpha[0],
outline_color=outline_rgba,
prebuilt_patches=prebuilt_patches,
prebuilt_paths=prebuilt_paths,
linewidth=render_params.outline_params.outer_outline_linewidth,
zorder=render_params.zorder,
)
ax.add_collection(_cax)
for path in _cax.get_paths():
path.vertices = trans.transform(path.vertices)
elif render_params.outline_alpha[0] > 0 and isinstance(render_params.outline_params.outer_outline_color, Color):
_cax = _get_collection_shape(
shapes=shapes,
Expand All @@ -957,15 +958,12 @@ def _draw_centroids(xy: np.ndarray, radius: float | None = None) -> None:
fill_alpha=0.0,
outline_alpha=render_params.outline_alpha[0],
outline_color=render_params.outline_params.outer_outline_color.get_hex(),
prebuilt_patches=prebuilt_patches,
prebuilt_paths=prebuilt_paths,
linewidth=render_params.outline_params.outer_outline_linewidth,
zorder=render_params.zorder,
# **kwargs,
)
cax = ax.add_collection(_cax)
# Transform the paths in PatchCollection
for path in _cax.get_paths():
path.vertices = trans.transform(path.vertices)
if render_params.outline_alpha[1] > 0 and isinstance(render_params.outline_params.inner_outline_color, Color):
_cax = _get_collection_shape(
shapes=shapes,
Expand All @@ -977,21 +975,18 @@ def _draw_centroids(xy: np.ndarray, radius: float | None = None) -> None:
fill_alpha=0.0,
outline_alpha=render_params.outline_alpha[1],
outline_color=render_params.outline_params.inner_outline_color.get_hex(),
prebuilt_patches=prebuilt_patches,
prebuilt_paths=prebuilt_paths,
linewidth=render_params.outline_params.inner_outline_linewidth,
zorder=render_params.zorder,
# **kwargs,
)
cax = ax.add_collection(_cax)
# Transform the paths in PatchCollection
for path in _cax.get_paths():
path.vertices = trans.transform(path.vertices)

_cax = _get_collection_shape(
shapes=shapes,
s=render_params.scale,
c=color_spec.to_rgba(render_params.cmap_params),
prebuilt_patches=prebuilt_patches,
prebuilt_paths=prebuilt_paths,
render_params=render_params,
rasterized=sc_settings._vector_friendly,
cmap=render_params.cmap_params.cmap,
Expand All @@ -1002,10 +997,6 @@ def _draw_centroids(xy: np.ndarray, radius: float | None = None) -> None:
)
cax = ax.add_collection(_cax)

# Transform the paths in PatchCollection
for path in _cax.get_paths():
path.vertices = trans.transform(path.vertices)

if color_spec.is_continuous:
# Colorbar uses the same resolved norm the fill pixels use, including its subclass
# (LogNorm/PowerNorm) — set_norm, not set_clim, which would leave the collection's
Expand Down
25 changes: 23 additions & 2 deletions tests/pl/test_render_shapes.py
Original file line number Diff line number Diff line change
Expand Up @@ -1877,11 +1877,11 @@ def test_render_shapes_as_points_default_is_matplotlib(sdata_blobs: SpatialData)

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

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

Expand Down Expand Up @@ -1957,3 +1957,24 @@ def spy(*args, **kwargs):
assert seen.get("radius") is not None # fast path ran and sized the dots to the disc radius
assert len(ax.images) >= 1 # datashader raster produced
plt.close(fig)


def test_shapes_outline_does_not_double_apply_transform():
# The coordinate-system affine must be applied once regardless of outlines: the fill and outline
# PathCollections share the same Path objects, so applying it per-collection would double it.
gdf = ShapesModel.parse(gpd.GeoDataFrame({"geometry": [Polygon([(10, 10), (20, 10), (20, 20), (10, 20)])]}))
set_transformation(gdf, Scale([3, 3], axes=("x", "y")), "global")
sdata = SpatialData(shapes={"p": gdf})

def bbox(**kw):
fig, ax = plt.subplots()
ax.set_xlim(0, 100)
ax.set_ylim(0, 100)
sdata.pl.render_shapes("p", color="#3366cc", **kw).pl.show(ax=ax)
fig.canvas.draw()
buf = np.asarray(fig.canvas.buffer_rgba())
ys, xs = np.where((buf[:, :, :3] < 250).any(axis=2))
plt.close(fig)
return xs.min(), xs.max(), ys.min(), ys.max()

assert bbox() == bbox(outline_width=1.0, outline_alpha=1.0, outline_color="black")
Loading