6 min read 5 sections

Douglas-Peucker vs Visvalingam-Whyatt for Geofence Boundaries

The algorithm you pick to thin a geofence boundary decides whether a vehicle sitting one meter inside a zone still reads as inside after simplification. This page expands the trade-offs surveyed in Polygon Simplification for High-Throughput Streams and the broader spatial index lookup architecture, comparing the two dominant line-simplification algorithms — Douglas-Peucker and Visvalingam-Whyatt — on the axis that matters for point-in-polygon containment: how aggressively each cuts vertices for a given tolerable boundary error, and where each one silently flips containment near an edge.

Concept and specification

Both algorithms greedily discard vertices, but they rank vertices by different geometric criteria.

Douglas-Peucker (DP) is a recursive, tolerance-driven split. Given a polyline and an anchor–float endpoint pair, it finds the vertex with the greatest perpendicular distance $d$ to the chord . For a point $p$ and chord endpoints ,

If the vertex is kept and the polyline splits at it recursively; otherwise the whole span collapses to the chord. The single parameter (in coordinate units — meters if projected) is a hard error ceiling: no retained vertex deviates from the simplified line by more than .

Visvalingam-Whyatt (VW) ranks vertices by the area of the triangle each forms with its two neighbours. For vertex with neighbours ,

The vertex with the smallest area is removed, its neighbours’ areas are recomputed, and the process repeats until either an area threshold or a target vertex count is reached. VW’s effective area metric produces a smoother, more perceptually faithful boundary and degrades gracefully — you can keep removing the least significant vertex until a budget is hit.

Property Douglas-Peucker Visvalingam-Whyatt
Rank criterion perpendicular distance $d$ triangle area $A$
Parameter tolerance (meters) area threshold or target count
Error guarantee hard max deviation statistical, no per-point ceiling
Complexity (typical / worst) / with a heap
Shape character preserves spikes, can leave jaggies smooth, sheds thin spikes
Budget control indirect (tune ) direct (stop at N vertices)

For geofencing the decision hinges on error semantics. DP’s maps directly to a maximum boundary displacement in meters, which is exactly the quantity a containment SLA cares about. VW gives no per-point ceiling but lets you hit a precise vertex budget, which is what the spatial index lookup cares about. The pragmatic rule: DP when the SLA is “no point misclassified beyond X meters,” VW when the constraint is “every polygon must fit in N vertices.” Many production pipelines run both in sequence — a VW pass to hit a hard vertex budget the index enforces, followed by a DP check that rejects any polygon whose residual deviation still exceeds the positional-accuracy ceiling — trading a second linear pass for a guarantee neither algorithm gives alone.

Douglas-Peucker perpendicular distance vs Visvalingam-Whyatt triangle area Left: DP measures perpendicular distance from each vertex to a chord and keeps those exceeding epsilon. Right: VW removes the vertex with the smallest neighbour-triangle area until a budget is met. Douglas-Peucker · perpendicular distance chord a–b d > ε → keep ε is a hard max-deviation ceiling (meters) Visvalingam-Whyatt · triangle area min area → remove area drives smoothness + exact vertex budget
DP keeps vertices past a distance ceiling; VW peels off the vertex with the least triangle area until a budget is met.

Step-by-step implementation

Prerequisites: Python 3.11+, shapely>=2.0, numpy>=1.26. Work in a projected CRS (e.g. UTM) so and are in meters, not degrees. Input is a closed ring list[tuple[float, float]]; output is a simplified ring.

1. Douglas-Peucker via Shapely. Shapely’s simplify with preserve_topology=True is DP under the hood and guards against ring self-intersection.

python
from shapely.geometry import Polygon

def simplify_dp(ring: list[tuple[float, float]], eps_m: float) -> Polygon:
    """DP simplify; eps_m is the max boundary deviation in meters (projected CRS)."""
    poly = Polygon(ring)
    if not poly.is_valid:
        poly = poly.buffer(0)                         # repair self-intersections first
    return poly.simplify(eps_m, preserve_topology=True)  # DP with topology guard

2. Visvalingam-Whyatt with an explicit area heap. Shapely has no built-in VW, so implement the effective-area removal directly; a heap gives .

python
import heapq
import numpy as np

def _tri_area(a: np.ndarray, b: np.ndarray, c: np.ndarray) -> float:
    # half the absolute cross product of (a-b) and (c-b)
    return 0.5 * abs((a[0] - b[0]) * (c[1] - b[1]) - (c[0] - b[0]) * (a[1] - b[1]))

def simplify_vw(ring: list[tuple[float, float]], target: int) -> list[tuple[float, float]]:
    """Remove least-significant vertices until `target` remain (target >= 4)."""
    pts = [np.asarray(p, dtype=np.float64) for p in ring]
    n = len(pts)
    alive = list(range(n))
    prev = {i: (i - 1) % n for i in alive}
    nxt = {i: (i + 1) % n for i in alive}
    heap: list[tuple[float, int, int]] = []
    ver = {i: 0 for i in alive}                       # stale-entry guard
    for i in alive:
        a = _tri_area(pts[prev[i]], pts[i], pts[nxt[i]])
        heapq.heappush(heap, (a, i, ver[i]))
    live = n
    while live > target and heap:
        _area, i, v = heapq.heappop(heap)
        if v != ver[i] or i not in prev:
            continue                                  # stale: neighbour changed since push
        p, q = prev[i], nxt[i]
        nxt[p], prev[q] = q, p                        # unlink i
        del prev[i], nxt[i]
        live -= 1
        for j in (p, q):                              # recompute affected neighbours
            ver[j] += 1
            aj = _tri_area(pts[prev[j]], pts[j], pts[nxt[j]])
            heapq.heappush(heap, (aj, j, ver[j]))
    order = []
    cur = p                                           # walk the surviving linked list
    for _ in range(live):
        order.append(tuple(pts[cur]))
        cur = nxt[cur]
    return order

Gotcha: never let VW drop below 4 vertices for a closed ring — a triangle degenerates and the buffer-zero repair in step 1 can return an empty geometry. Clamp target >= 4.

3. Re-close and validate before indexing. Either output must round-trip to a valid polygon before it enters the index.

python
def finalize(ring: list[tuple[float, float]]) -> Polygon:
    poly = Polygon(ring)
    return poly if poly.is_valid else poly.buffer(0)  # last-resort topology repair

Benchmark and verification

Simplify a 4,200-vertex municipal boundary, then time 100k point-in-polygon tests against each output and measure the maximum boundary displacement.

python
import time
from shapely.geometry import Point

def pip_latency_us(poly: Polygon, pts: list[tuple[float, float]]) -> float:
    t0 = time.perf_counter()
    for x, y in pts:
        poly.contains(Point(x, y))
    return (time.perf_counter() - t0) / len(pts) * 1e6  # microseconds per test

Representative figures (4,200-vertex source, m for DP, VW target 500):

Metric Original Douglas-Peucker (ε=5m) Visvalingam-Whyatt (500 vtx)
Vertex count 4,200 512 500
Vertex reduction 88% 88%
PIP P50 210 µs 27 µs 26 µs
PIP P99 340 µs 41 µs 39 µs
Max boundary error 0 m 4.9 m 7.3 m
Containment flips (edge band) 0 6 11

DP holds its error under the ceiling exactly (4.9 m < 5 m) while VW, lacking a per-point guarantee, drifts to 7.3 m at the same vertex budget — and that difference shows up as roughly twice the containment flips in the near-edge band. Both cut PIP latency ~8x. Verify by sampling points in a narrow band inside each boundary and asserting the simplified contains matches the original within your SLA before promoting the geometry.

Failure modes and edge cases

  • Over-simplification flips containment near edges. A vertex removed on a concave notch can move the boundary across a point that was legitimately inside, turning a true “inside” into a false “outside.” Always test in a band around the boundary and cap below your positional-accuracy budget.
  • Self-intersecting output. Aggressive simplification of a thin peninsula can make the ring cross itself. preserve_topology=True prevents it in DP; VW needs an explicit buffer(0) repair afterward, guarded against returning an empty geometry.
  • Degenerate rings. VW driven to fewer than 4 vertices collapses a closed ring to a line or point. Clamp the target and skip simplification for already-small polygons.
  • Degree-space epsilon. Running either algorithm on unprojected lon/lat makes vary from ~111 km per degree at the equator to far less near the poles, so a “5 m” tolerance is silently wrong. Project to a metric CRS first.
  • NaN / duplicate vertices. A repeated or NaN coordinate produces a zero-length chord (DP division by zero) or a degenerate triangle (VW zero area removed first, hiding real vertices). Dedupe and drop NaN coordinates at ingest.