Skip to content

Commit 8d7aa1c

Browse files
authored
Fix MultiPolygon patch creation for inverted inner rings (#517)
1 parent 927f689 commit 8d7aa1c

File tree

4 files changed

+127
-40
lines changed

4 files changed

+127
-40
lines changed

src/spatialdata_plot/pl/utils.py

Lines changed: 52 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
from scanpy.plotting._utils import add_colors_for_categorical_sample_annotation
5353
from scanpy.plotting.palettes import default_20, default_28, default_102
5454
from scipy.spatial import ConvexHull
55+
from shapely.errors import GEOSException
5556
from skimage.color import label2rgb
5657
from skimage.morphology import erosion, square
5758
from skimage.segmentation import find_boundaries
@@ -445,8 +446,35 @@ def _as_rgba_array(x: Any) -> np.ndarray:
445446
else:
446447
outline_c = [None] * fill_c.shape[0]
447448

448-
# Build DataFrame of valid geometries
449-
shapes_df = pd.DataFrame(shapes, copy=True)
449+
if isinstance(shapes, GeoDataFrame):
450+
shapes_df: GeoDataFrame | pd.DataFrame = shapes.copy()
451+
else:
452+
shapes_df = pd.DataFrame(shapes, copy=True)
453+
454+
# Robustly normalise geometries to a canonical representation.
455+
# This ensures consistent exterior/interior ring orientation so that
456+
# matplotlib's fill rules handle holes correctly regardless of user input.
457+
if "geometry" in shapes_df.columns:
458+
459+
def _normalize_geom(geom: Any) -> Any:
460+
if geom is None or getattr(geom, "is_empty", False):
461+
return geom
462+
# shapely.normalize is available in shapely>=2; fall back to geom.normalize()
463+
normalize_func = getattr(shapely, "normalize", None)
464+
if callable(normalize_func):
465+
try:
466+
return normalize_func(geom)
467+
except (GEOSException, TypeError, ValueError):
468+
return geom
469+
if hasattr(geom, "normalize"):
470+
try:
471+
return geom.normalize()
472+
except (GEOSException, TypeError, ValueError):
473+
return geom
474+
return geom
475+
476+
shapes_df["geometry"] = shapes_df["geometry"].apply(_normalize_geom)
477+
450478
shapes_df = shapes_df[shapes_df["geometry"].apply(lambda geom: not geom.is_empty)]
451479
shapes_df = shapes_df.reset_index(drop=True)
452480

@@ -1672,52 +1700,36 @@ def _validate_polygons(shapes: GeoDataFrame) -> GeoDataFrame:
16721700
return shapes
16731701

16741702

1675-
def _collect_polygon_rings(
1676-
geom: shapely.Polygon | shapely.MultiPolygon,
1677-
) -> list[tuple[np.ndarray, list[np.ndarray]]]:
1678-
"""Collect exterior/interior coordinate rings from (Multi)Polygons."""
1679-
polygons: list[tuple[np.ndarray, list[np.ndarray]]] = []
1680-
1681-
def _collect(part: shapely.Polygon | shapely.MultiPolygon) -> None:
1682-
if part.geom_type == "Polygon":
1683-
exterior = np.asarray(part.exterior.coords)
1684-
interiors = [np.asarray(interior.coords) for interior in part.interiors]
1685-
polygons.append((exterior, interiors))
1686-
elif part.geom_type == "MultiPolygon":
1687-
for child in part.geoms:
1688-
_collect(child)
1689-
else:
1690-
raise ValueError(f"Unhandled geometry type: {repr(part.geom_type)}")
1691-
1692-
_collect(geom)
1693-
return polygons
1694-
1703+
def _make_patch_from_multipolygon(mp: shapely.MultiPolygon) -> list[mpatches.PathPatch]:
1704+
"""
1705+
Create PathPatches from a MultiPolygon, preserving holes robustly.
16951706
1696-
def _create_ring_codes(length: int) -> npt.NDArray[np.uint8]:
1697-
codes = np.full(length, mpath.Path.LINETO, dtype=mpath.Path.code_type)
1698-
codes[0] = mpath.Path.MOVETO
1699-
return codes
1707+
This follows the same strategy as GeoPandas' internal Polygon plotting:
1708+
each (multi)polygon part becomes a compound Path composed of the exterior
1709+
ring and all interior rings. Orientation is handled by prior geometry
1710+
normalization rather than manual ring reversal.
1711+
"""
1712+
patches: list[mpatches.PathPatch] = []
17001713

1714+
for poly in mp.geoms:
1715+
if poly.is_empty:
1716+
continue
17011717

1702-
def _make_patch_from_multipolygon(mp: shapely.MultiPolygon) -> mpatches.PathPatch:
1703-
# https://matplotlib.org/stable/gallery/shapes_and_collections/donut.html
1718+
# Ensure 2D vertices in case geometries carry Z
1719+
exterior = np.asarray(poly.exterior.coords)[..., :2]
1720+
interiors = [np.asarray(ring.coords)[..., :2] for ring in poly.interiors]
17041721

1705-
patches = []
1706-
for exterior, interiors in _collect_polygon_rings(mp):
17071722
if len(interiors) == 0:
1723+
# Simple polygon without holes
17081724
patches.append(mpatches.Polygon(exterior, closed=True))
17091725
continue
17101726

1711-
ring_vertices = [exterior]
1712-
ring_codes = [_create_ring_codes(len(exterior))]
1713-
for hole in interiors:
1714-
reversed_hole = hole[::-1]
1715-
ring_vertices.append(reversed_hole)
1716-
ring_codes.append(_create_ring_codes(len(reversed_hole)))
1717-
1718-
vertices = np.concatenate(ring_vertices)
1719-
all_codes = np.concatenate(ring_codes)
1720-
patches.append(mpatches.PathPatch(mpath.Path(vertices, all_codes)))
1727+
# Build a compound path: exterior + all interior rings
1728+
compound_path = mpath.Path.make_compound_path(
1729+
mpath.Path(exterior, closed=True),
1730+
*[mpath.Path(ring, closed=True) for ring in interiors],
1731+
)
1732+
patches.append(mpatches.PathPatch(compound_path))
17211733

17221734
return patches
17231735

12.9 KB
Loading
12.7 KB
Loading

tests/pl/test_render_shapes.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,81 @@ def test_plot_can_render_multipolygons_that_say_they_are_polygons(self):
130130

131131
fig.tight_layout()
132132

133+
def test_plot_can_render_polygon_with_inverted_inner_ring(self):
134+
ext = [
135+
(7.866043666934409, 32.80184055229537),
136+
(19.016191271980425, 203.48380872801957),
137+
(75.90086964475744, 236.02570144190528),
138+
(229.48380872801957, 235.98380872801957),
139+
(235.98380872801957, 5.516191271980426),
140+
(197.42585593903195, 6.144892860751103),
141+
(116.5, 96.4575926540027),
142+
(55.65582863082729, 12.531294107459374),
143+
(7.866043666934409, 32.80184055229537),
144+
]
145+
146+
interior = [
147+
(160.12353079731844, 173.21221665537414),
148+
(181.80184055229537, 159.13395633306558),
149+
(198.86604366693442, 179.80184055229537),
150+
(178.19815944770465, 198.86604366693442),
151+
(160.12353079731844, 173.21221665537414),
152+
]
153+
154+
polygon = Polygon(ext, [interior])
155+
geo_df = gpd.GeoDataFrame(geometry=[polygon])
156+
sdata = SpatialData(shapes={"inverted_ring": ShapesModel.parse(geo_df)})
157+
158+
fig, ax = plt.subplots()
159+
sdata.pl.render_shapes(element="inverted_ring").pl.show(ax=ax)
160+
ax.set_xlim(0, 250)
161+
ax.set_ylim(0, 250)
162+
163+
fig.tight_layout()
164+
165+
def test_plot_can_render_multipolygon_with_inverted_inner_ring_and_disjoint_part(self):
166+
ext = [
167+
(7.866043666934409, 32.80184055229537),
168+
(19.016191271980425, 203.48380872801957),
169+
(75.90086964475744, 236.02570144190528),
170+
(229.48380872801957, 235.98380872801957),
171+
(235.98380872801957, 5.516191271980426),
172+
(197.42585593903195, 6.144892860751103),
173+
(116.5, 96.4575926540027),
174+
(55.65582863082729, 12.531294107459374),
175+
(7.866043666934409, 32.80184055229537),
176+
]
177+
178+
interior = [
179+
(160.12353079731844, 173.21221665537414),
180+
(181.80184055229537, 159.13395633306558),
181+
(198.86604366693442, 179.80184055229537),
182+
(178.19815944770465, 198.86604366693442),
183+
(160.12353079731844, 173.21221665537414),
184+
]
185+
186+
# Part with a hole and non-standard orientation, plus a disjoint simple part
187+
poly_with_hole = Polygon(ext, [interior])
188+
disjoint_poly = Polygon(
189+
[
190+
(300.0, 300.0),
191+
(320.0, 300.0),
192+
(320.0, 320.0),
193+
(300.0, 320.0),
194+
(300.0, 300.0),
195+
]
196+
)
197+
multipoly = MultiPolygon([poly_with_hole, disjoint_poly])
198+
geo_df = gpd.GeoDataFrame(geometry=[multipoly])
199+
sdata = SpatialData(shapes={"inverted_ring_multipoly": ShapesModel.parse(geo_df)})
200+
201+
fig, ax = plt.subplots()
202+
sdata.pl.render_shapes(element="inverted_ring_multipoly").pl.show(ax=ax)
203+
ax.set_xlim(0, 350)
204+
ax.set_ylim(0, 350)
205+
206+
fig.tight_layout()
207+
133208
def test_plot_can_color_multipolygons_with_multiple_holes(self):
134209
square = [(0.0, 0.0), (5.0, 0.0), (5.0, 5.0), (0.0, 5.0), (0.0, 0.0)]
135210
first_hole = [(1.0, 1.0), (2.0, 1.0), (2.0, 2.0), (1.0, 2.0), (1.0, 1.0)]

0 commit comments

Comments
 (0)