Skip to content

Commit 1973f22

Browse files
committed
Add Visvalingam-Whyatt and wire simplification into polygonize API (#1151)
1 parent a9898f6 commit 1973f22

File tree

2 files changed

+386
-4
lines changed

2 files changed

+386
-4
lines changed

xrspatial/polygonize.py

Lines changed: 147 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1002,6 +1002,109 @@ def _douglas_peucker(coords, tolerance):
10021002
return result
10031003

10041004

1005+
@ngjit
1006+
def _visvalingam_whyatt(coords, tolerance):
1007+
"""Visvalingam-Whyatt area-based line simplification.
1008+
1009+
Iteratively removes the vertex that forms the smallest triangle
1010+
area with its neighbors, until no triangle area is below tolerance.
1011+
Endpoints are always preserved.
1012+
1013+
Parameters
1014+
----------
1015+
coords : np.ndarray, shape (N, 2)
1016+
Input coordinate array.
1017+
tolerance : float
1018+
Minimum triangle area threshold. Vertices forming triangles
1019+
with area below this value are removed.
1020+
1021+
Returns
1022+
-------
1023+
np.ndarray, shape (M, 2)
1024+
Simplified coordinate array.
1025+
"""
1026+
n = len(coords)
1027+
if n <= 2:
1028+
return coords.copy()
1029+
1030+
# Use a doubly-linked list via prev/next index arrays.
1031+
prev_idx = np.empty(n, dtype=np.int64)
1032+
next_idx = np.empty(n, dtype=np.int64)
1033+
for i in range(n):
1034+
prev_idx[i] = i - 1
1035+
next_idx[i] = i + 1
1036+
# Endpoints are never removed (sentinel values).
1037+
prev_idx[0] = -1
1038+
next_idx[n - 1] = -1
1039+
1040+
# Compute triangle areas for interior vertices.
1041+
areas = np.full(n, np.inf, dtype=np.float64)
1042+
for i in range(1, n - 1):
1043+
ax, ay = coords[prev_idx[i], 0], coords[prev_idx[i], 1]
1044+
bx, by = coords[i, 0], coords[i, 1]
1045+
cx, cy = coords[next_idx[i], 0], coords[next_idx[i], 1]
1046+
areas[i] = abs((ax * (by - cy) + bx * (cy - ay) + cx * (ay - by)) / 2.0)
1047+
1048+
removed = np.zeros(n, dtype=np.bool_)
1049+
remaining = n
1050+
1051+
while remaining > 2:
1052+
# Find vertex with minimum area.
1053+
min_area = np.inf
1054+
min_idx = -1
1055+
for i in range(1, n - 1):
1056+
if not removed[i] and areas[i] < min_area:
1057+
min_area = areas[i]
1058+
min_idx = i
1059+
1060+
if min_idx == -1 or min_area >= tolerance:
1061+
break
1062+
1063+
# Remove vertex.
1064+
removed[min_idx] = True
1065+
remaining -= 1
1066+
1067+
# Update linked list.
1068+
p = prev_idx[min_idx]
1069+
nx_i = next_idx[min_idx]
1070+
if p >= 0:
1071+
next_idx[p] = nx_i
1072+
if nx_i >= 0 and nx_i < n:
1073+
prev_idx[nx_i] = p
1074+
1075+
# Recompute areas for affected neighbors.
1076+
if p > 0 and prev_idx[p] >= 0:
1077+
ax, ay = coords[prev_idx[p], 0], coords[prev_idx[p], 1]
1078+
bx, by = coords[p, 0], coords[p, 1]
1079+
cx, cy = coords[next_idx[p], 0], coords[next_idx[p], 1]
1080+
new_area = abs((ax * (by - cy) + bx * (cy - ay) + cx * (ay - by)) / 2.0)
1081+
# Enforce monotonicity: area can only increase.
1082+
areas[p] = max(new_area, min_area)
1083+
1084+
if nx_i >= 0 and nx_i < n - 1 and next_idx[nx_i] >= 0:
1085+
ax, ay = coords[prev_idx[nx_i], 0], coords[prev_idx[nx_i], 1]
1086+
bx, by = coords[nx_i, 0], coords[nx_i, 1]
1087+
cx, cy = coords[next_idx[nx_i], 0], coords[next_idx[nx_i], 1]
1088+
new_area = abs((ax * (by - cy) + bx * (cy - ay) + cx * (ay - by)) / 2.0)
1089+
areas[nx_i] = max(new_area, min_area)
1090+
1091+
# Collect remaining vertices.
1092+
count = 0
1093+
for i in range(n):
1094+
if not removed[i]:
1095+
count += 1
1096+
1097+
result = np.empty((count, 2), dtype=np.float64)
1098+
j = 0
1099+
for i in range(n):
1100+
if not removed[i]:
1101+
result[j, 0] = coords[i, 0]
1102+
result[j, 1] = coords[i, 1]
1103+
j += 1
1104+
1105+
return result
1106+
1107+
10051108
def _find_junctions(all_rings):
10061109
"""Find junction vertices that must be pinned during simplification.
10071110
@@ -1169,20 +1272,23 @@ def _chain_key(chain):
11691272
return (start, end, interior)
11701273

11711274

1172-
def _simplify_polygons(polygon_points, tolerance):
1275+
def _simplify_polygons(polygon_points, tolerance, method="douglas-peucker"):
11731276
"""Topology-preserving simplification of all polygons.
11741277
11751278
Uses shared-edge decomposition: finds junction vertices, splits
11761279
rings into chains at junctions, simplifies each unique chain once
1177-
with Douglas-Peucker, then reassembles rings.
1280+
with the chosen algorithm, then reassembles rings.
11781281
11791282
Parameters
11801283
----------
11811284
polygon_points : list of list of np.ndarray
11821285
Output of polygonize backend: list of polygons, each polygon
11831286
is [exterior_ring, *hole_rings].
11841287
tolerance : float
1185-
Douglas-Peucker tolerance in coordinate units.
1288+
Simplification tolerance in coordinate units.
1289+
method : str, default="douglas-peucker"
1290+
Simplification algorithm: ``"douglas-peucker"`` or
1291+
``"visvalingam-whyatt"``.
11861292
11871293
Returns
11881294
-------
@@ -1210,7 +1316,10 @@ def _simplify_polygons(polygon_points, tolerance):
12101316
for chain in chains:
12111317
key = _chain_key(chain)
12121318
if key not in simplified_chains:
1213-
simplified_chains[key] = _douglas_peucker(chain, tolerance)
1319+
if method == "douglas-peucker":
1320+
simplified_chains[key] = _douglas_peucker(chain, tolerance)
1321+
else:
1322+
simplified_chains[key] = _visvalingam_whyatt(chain, tolerance)
12141323
# Determine if this chain was reversed relative to canonical.
12151324
start = (chain[0, 0], chain[0, 1])
12161325
canonical_start = (simplified_chains[key][0, 0],
@@ -1376,6 +1485,8 @@ def polygonize(
13761485
transform: Optional[np.ndarray] = None, # shape (6,)
13771486
column_name: str = "DN",
13781487
return_type: str = "numpy",
1488+
simplify_tolerance: Optional[float] = None,
1489+
simplify_method: str = "douglas-peucker",
13791490
):
13801491
"""
13811492
Polygonize creates vector polygons for connected regions of pixels in a
@@ -1416,6 +1527,23 @@ def polygonize(
14161527
"geopandas", "awkward" and "geojson". "numpy" and "geojson" are
14171528
always available, the others require optional dependencies.
14181529
1530+
simplify_tolerance: float, optional
1531+
Simplification tolerance in coordinate units. When set, polygon
1532+
boundaries are simplified using shared-edge decomposition to
1533+
preserve topology between adjacent polygons. Default is None
1534+
(no simplification).
1535+
1536+
For ``"douglas-peucker"``, this is the maximum perpendicular
1537+
distance a vertex may deviate from the simplified line.
1538+
1539+
For ``"visvalingam-whyatt"``, this is the minimum triangle area
1540+
threshold; vertices forming triangles smaller than this are removed.
1541+
1542+
simplify_method: str, default="douglas-peucker"
1543+
Simplification algorithm. Options are ``"douglas-peucker"``
1544+
(distance-based, good for general use) and ``"visvalingam-whyatt"``
1545+
(area-based, tends to produce better cartographic results).
1546+
14191547
Returns
14201548
-------
14211549
Polygons and their corresponding values in a format determined by
@@ -1462,6 +1590,16 @@ def polygonize(
14621590
raise ValueError(
14631591
f"Incorrect transform length of {len(transform)} instead of 6")
14641592

1593+
# Check simplification parameters.
1594+
if simplify_tolerance is not None and simplify_tolerance < 0:
1595+
raise ValueError(
1596+
"simplify_tolerance must be non-negative, "
1597+
f"got {simplify_tolerance}")
1598+
if simplify_method not in ("douglas-peucker", "visvalingam-whyatt"):
1599+
raise ValueError(
1600+
f"simplify_method must be 'douglas-peucker' or "
1601+
f"'visvalingam-whyatt', got '{simplify_method}'")
1602+
14651603
mapper = ArrayTypeFunctionMapping(
14661604
numpy_func=_polygonize_numpy,
14671605
cupy_func=_polygonize_cupy,
@@ -1471,6 +1609,11 @@ def polygonize(
14711609
column, polygon_points = mapper(raster)(
14721610
raster.data, mask_data, connectivity_8, transform)
14731611

1612+
# Apply simplification if requested.
1613+
if simplify_tolerance is not None and simplify_tolerance > 0:
1614+
polygon_points = _simplify_polygons(
1615+
polygon_points, simplify_tolerance, method=simplify_method)
1616+
14741617
# Convert to requested return_type.
14751618
if return_type == "numpy":
14761619
return column, polygon_points

0 commit comments

Comments
 (0)