Skip to content

Commit 04e17d4

Browse files
MeyerBenderpre-commit-ci[bot]timtreis
authored
Enabled rendering of shapes with multiple holes (#506)
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Tim Treis <[email protected]>
1 parent 2b3735a commit 04e17d4

File tree

4 files changed

+78
-33
lines changed

4 files changed

+78
-33
lines changed

src/spatialdata_plot/pl/utils.py

Lines changed: 38 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1246,47 +1246,52 @@ def _get_linear_colormap(colors: list[str], background: str) -> list[LinearSegme
12461246
return [LinearSegmentedColormap.from_list(c, [background, c], N=256) for c in colors]
12471247

12481248

1249-
def _split_multipolygon_into_outer_and_inner(mp: shapely.MultiPolygon): # type: ignore
1250-
# https://stackoverflow.com/a/21922058
1251-
1252-
for geom in mp.geoms:
1253-
if geom.geom_type == "MultiPolygon":
1254-
exterior_coords = []
1255-
interior_coords = []
1256-
for part in geom:
1257-
epc = _split_multipolygon_into_outer_and_inner(part) # Recursive call
1258-
exterior_coords += epc["exterior_coords"]
1259-
interior_coords += epc["interior_coords"]
1260-
elif geom.geom_type == "Polygon":
1261-
exterior_coords = geom.exterior.coords[:]
1262-
interior_coords = []
1263-
for interior in geom.interiors:
1264-
interior_coords += interior.coords[:]
1249+
def _collect_polygon_rings(
1250+
geom: shapely.Polygon | shapely.MultiPolygon,
1251+
) -> list[tuple[np.ndarray, list[np.ndarray]]]:
1252+
"""Collect exterior/interior coordinate rings from (Multi)Polygons."""
1253+
polygons: list[tuple[np.ndarray, list[np.ndarray]]] = []
1254+
1255+
def _collect(part: shapely.Polygon | shapely.MultiPolygon) -> None:
1256+
if part.geom_type == "Polygon":
1257+
exterior = np.asarray(part.exterior.coords)
1258+
interiors = [np.asarray(interior.coords) for interior in part.interiors]
1259+
polygons.append((exterior, interiors))
1260+
elif part.geom_type == "MultiPolygon":
1261+
for child in part.geoms:
1262+
_collect(child)
12651263
else:
1266-
raise ValueError(f"Unhandled geometry type: {repr(geom.type)}")
1264+
raise ValueError(f"Unhandled geometry type: {repr(part.geom_type)}")
12671265

1268-
return interior_coords, exterior_coords
1266+
_collect(geom)
1267+
return polygons
1268+
1269+
1270+
def _create_ring_codes(length: int) -> npt.NDArray[np.uint8]:
1271+
codes = np.full(length, mpath.Path.LINETO, dtype=mpath.Path.code_type)
1272+
codes[0] = mpath.Path.MOVETO
1273+
return codes
12691274

12701275

12711276
def _make_patch_from_multipolygon(mp: shapely.MultiPolygon) -> mpatches.PathPatch:
12721277
# https://matplotlib.org/stable/gallery/shapes_and_collections/donut.html
12731278

12741279
patches = []
1275-
for geom in mp.geoms:
1276-
if len(geom.interiors) == 0:
1277-
# polygon has no holes
1278-
patches += [mpatches.Polygon(geom.exterior.coords, closed=True)]
1279-
else:
1280-
inside, outside = _split_multipolygon_into_outer_and_inner(mp)
1281-
if len(inside) > 0:
1282-
codes = np.ones(len(inside), dtype=mpath.Path.code_type) * mpath.Path.LINETO
1283-
codes[0] = mpath.Path.MOVETO
1284-
all_codes = np.concatenate((codes, codes))
1285-
vertices = np.concatenate((outside, inside[::-1]))
1286-
else:
1287-
all_codes = []
1288-
vertices = np.concatenate(outside)
1289-
patches += [mpatches.PathPatch(mpath.Path(vertices, all_codes))]
1280+
for exterior, interiors in _collect_polygon_rings(mp):
1281+
if len(interiors) == 0:
1282+
patches.append(mpatches.Polygon(exterior, closed=True))
1283+
continue
1284+
1285+
ring_vertices = [exterior]
1286+
ring_codes = [_create_ring_codes(len(exterior))]
1287+
for hole in interiors:
1288+
reversed_hole = hole[::-1]
1289+
ring_vertices.append(reversed_hole)
1290+
ring_codes.append(_create_ring_codes(len(reversed_hole)))
1291+
1292+
vertices = np.concatenate(ring_vertices)
1293+
all_codes = np.concatenate(ring_codes)
1294+
patches.append(mpatches.PathPatch(mpath.Path(vertices, all_codes)))
12901295

12911296
return patches
12921297

7.51 KB
Loading
5.96 KB
Loading

tests/pl/test_render_shapes.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,46 @@ def _make_multi():
9999
sdata["table"] = table
100100
sdata.pl.render_shapes(color="val", fill_alpha=0.3).pl.show()
101101

102+
def test_plot_can_render_multipolygons_with_multiple_holes(self):
103+
square = [(0.0, 0.0), (5.0, 0.0), (5.0, 5.0), (0.0, 5.0), (0.0, 0.0)]
104+
first_hole = [(1.0, 1.0), (2.0, 1.0), (2.0, 2.0), (1.0, 2.0), (1.0, 1.0)]
105+
second_hole = [(3.0, 3.0), (4.0, 3.0), (4.0, 4.0), (3.0, 4.0), (3.0, 3.0)]
106+
multipoly = MultiPolygon([Polygon(square, holes=[first_hole, second_hole])])
107+
cell_polygon_table = gpd.GeoDataFrame(geometry=gpd.GeoSeries([multipoly]))
108+
sd_polygons = ShapesModel.parse(cell_polygon_table)
109+
sdata = SpatialData(shapes={"two_holes": sd_polygons})
110+
111+
fig, ax = plt.subplots()
112+
sdata.pl.render_shapes(element="two_holes").pl.show(ax=ax)
113+
ax.set_xlim(-1, 6)
114+
ax.set_ylim(-1, 6)
115+
116+
fig.tight_layout()
117+
118+
def test_plot_can_color_multipolygons_with_multiple_holes(self):
119+
square = [(0.0, 0.0), (5.0, 0.0), (5.0, 5.0), (0.0, 5.0), (0.0, 0.0)]
120+
first_hole = [(1.0, 1.0), (2.0, 1.0), (2.0, 2.0), (1.0, 2.0), (1.0, 1.0)]
121+
second_hole = [(3.0, 3.0), (4.0, 3.0), (4.0, 4.0), (3.0, 4.0), (3.0, 3.0)]
122+
multipoly = MultiPolygon([Polygon(square, holes=[first_hole, second_hole])])
123+
cell_polygon_table = gpd.GeoDataFrame(geometry=gpd.GeoSeries([multipoly]))
124+
cell_polygon_table["instance_id"] = [0]
125+
sd_polygons = ShapesModel.parse(cell_polygon_table)
126+
127+
adata = anndata.AnnData(pd.DataFrame({"value": [1]}))
128+
adata.obs["region"] = pd.Categorical(["two_holes"] * adata.n_obs)
129+
adata.obs["instance_id"] = [0]
130+
adata.obs["category"] = ["holey"]
131+
table = TableModel.parse(adata, region="two_holes", region_key="region", instance_key="instance_id")
132+
133+
sdata = SpatialData(shapes={"two_holes": sd_polygons}, tables={"table": table})
134+
135+
fig, ax = plt.subplots()
136+
sdata.pl.render_shapes(element="two_holes", color="category", table_name="table").pl.show(ax=ax)
137+
ax.set_xlim(-1, 6)
138+
ax.set_ylim(-1, 6)
139+
140+
fig.tight_layout()
141+
102142
def test_plot_can_color_from_geodataframe(self, sdata_blobs: SpatialData):
103143
blob = deepcopy(sdata_blobs)
104144
blob["table"].obs["region"] = pd.Categorical(["blobs_polygons"] * blob["table"].n_obs)

0 commit comments

Comments
 (0)