|
52 | 52 | from scanpy.plotting._utils import add_colors_for_categorical_sample_annotation |
53 | 53 | from scanpy.plotting.palettes import default_20, default_28, default_102 |
54 | 54 | from scipy.spatial import ConvexHull |
| 55 | +from shapely.errors import GEOSException |
55 | 56 | from skimage.color import label2rgb |
56 | 57 | from skimage.morphology import erosion, square |
57 | 58 | from skimage.segmentation import find_boundaries |
@@ -445,8 +446,35 @@ def _as_rgba_array(x: Any) -> np.ndarray: |
445 | 446 | else: |
446 | 447 | outline_c = [None] * fill_c.shape[0] |
447 | 448 |
|
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 | + |
450 | 478 | shapes_df = shapes_df[shapes_df["geometry"].apply(lambda geom: not geom.is_empty)] |
451 | 479 | shapes_df = shapes_df.reset_index(drop=True) |
452 | 480 |
|
@@ -1672,52 +1700,36 @@ def _validate_polygons(shapes: GeoDataFrame) -> GeoDataFrame: |
1672 | 1700 | return shapes |
1673 | 1701 |
|
1674 | 1702 |
|
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. |
1695 | 1706 |
|
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] = [] |
1700 | 1713 |
|
| 1714 | + for poly in mp.geoms: |
| 1715 | + if poly.is_empty: |
| 1716 | + continue |
1701 | 1717 |
|
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] |
1704 | 1721 |
|
1705 | | - patches = [] |
1706 | | - for exterior, interiors in _collect_polygon_rings(mp): |
1707 | 1722 | if len(interiors) == 0: |
| 1723 | + # Simple polygon without holes |
1708 | 1724 | patches.append(mpatches.Polygon(exterior, closed=True)) |
1709 | 1725 | continue |
1710 | 1726 |
|
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)) |
1721 | 1733 |
|
1722 | 1734 | return patches |
1723 | 1735 |
|
|
0 commit comments