Visvalingam vs Douglas-Peucker in Tile Generation

For most tile pipelines, Douglas-Peucker is the correct default: its perpendicular-distance metric maps directly to the MVT 4096-unit grid, scales linearly across zoom levels, and runs in O(n log n) time. Switch to Visvalingam-Whyatt when you need area-based smoothing to preserve organic shapes — hydrography, ecological zones, admin boundaries — at mid-zoom levels where high-frequency noise becomes visually dominant.

When to Use Each Algorithm

Condition Choose Douglas-Peucker Choose Visvalingam-Whyatt
Zoom range 0–8 (overview/base) 8–14 (mid-zoom detail)
Feature type Roads, engineered lines, parcels Rivers, admin boundaries, coastlines
Pipeline stage Inline in Tippecanoe Preprocessing (mapshaper / Python)
Tolerance mapping base_px × (4096 / 2^(14−zoom)) — linear Area threshold — must be squared to match px scale
Output format .mbtiles / .pmtiles direct from Tippecanoe Pre-simplified GeoJSON fed into Tippecanoe
Topology requirement Acceptable — use --no-simplification-of-shared-nodes Requires explicit topology repair after VW pass

Tippecanoe applies Douglas-Peucker simplification natively at every zoom level unless you override the simplification level with --simplification or bypass it entirely with --no-simplify-shared-nodes. Visvalingam has no Tippecanoe flag — it must run as an upstream preprocessing step before the automated generation pipeline ingests your source file.

Algorithm Specification

Douglas-Peucker

Recursively splits a polyline at the vertex with the maximum perpendicular distance from the current baseline. Vertices below tolerance are discarded. The process repeats on each sub-segment until no vertex exceeds the threshold.

Parameter Value in MVT context
Metric Perpendicular distance (Euclidean)
Complexity O(n log n) average (Hershberger-Snoeyink stack variant)
Tolerance unit Tile grid units (4096 per tile) or geographic degrees
Tippecanoe flag --simplification=<N> (default: 2 at z14; doubles each zoom step down)
GEOS binding shapely.simplify(geom, tolerance, preserve_topology=False)

Visvalingam-Whyatt

Iteratively removes the vertex whose removal produces the smallest effective triangle area (formed by the vertex and its two immediate neighbours). The heap-optimised variant achieves O(n log n) by maintaining a min-heap of triangle areas and recomputing affected neighbours after each removal.

Parameter Value in MVT context
Metric Effective triangle area
Complexity O(n²) naive; O(n log n) heap-optimised
Tolerance unit Area in squared coordinate units — set to tolerance² to match DP pixel scale
Tippecanoe flag None — preprocess externally
Python binding simplification.cutil.simplify_coords_vw(coords, threshold)

Production Command

The following script selects the algorithm per zoom level, calculates tolerances against the MVT 4096-unit grid, and feeds Tippecanoe with pre-simplified GeoJSON for layers that benefit from Visvalingam preprocessing. It depends on shapely>=2.0, the simplification package, and pyogrio for fast I/O.

python
import math
import json
import subprocess
import tempfile
import os
from typing import Literal
import numpy as np
from shapely.geometry import shape, mapping, Polygon, LineString, MultiPolygon, MultiLineString
from shapely import simplify as dp_simplify
from shapely.validation import make_valid
from simplification.cutil import simplify_coords_vw
import pyogrio

# MVT tile grid: 4096 units per tile
MVT_EXTENT = 4096
BASE_ZOOM = 14
BASE_PX = 2.0


def dp_tolerance(zoom: int, base_px: float = BASE_PX) -> float:
    """
    Linear pixel-to-grid-unit mapping.
    At z14, BASE_PX=2.0 → tolerance 2.0 tile-grid units.
    Each zoom step halves the scale, doubling the effective tolerance.
    """
    return base_px * (MVT_EXTENT / (2 ** (BASE_ZOOM - zoom)))


def vw_threshold(zoom: int, base_px: float = BASE_PX) -> float:
    """
    Area-based threshold for Visvalingam.
    Must be squared to produce an area comparable to DP's linear distance.
    """
    return dp_tolerance(zoom, base_px) ** 2


def _simplify_ring(coords: list, threshold: float, algorithm: str) -> list:
    """Simplify a coordinate ring; always close the ring."""
    if algorithm == "vw":
        simplified = simplify_coords_vw(coords, threshold)
    else:
        line = LineString(coords)
        simplified = list(dp_simplify(line, tolerance=threshold, preserve_topology=False).coords)
    # Ensure ring closure
    if simplified and simplified[0] != simplified[-1]:
        simplified.append(simplified[0])
    # Degenerate: need at least 4 points to form a valid ring
    return simplified if len(simplified) >= 4 else coords


def simplify_geom(geom, zoom: int, algorithm: Literal["dp", "vw"]):
    """Dispatch by geometry type; return make_valid result."""
    tol = vw_threshold(zoom) if algorithm == "vw" else dp_tolerance(zoom)

    if isinstance(geom, (Polygon, MultiPolygon)):
        polys = [geom] if isinstance(geom, Polygon) else list(geom.geoms)
        new_polys = []
        for poly in polys:
            ext = _simplify_ring(list(poly.exterior.coords), tol, algorithm)
            ints = [_simplify_ring(list(r.coords), tol, algorithm) for r in poly.interiors]
            new_polys.append(Polygon(ext, ints))
        result = new_polys[0] if len(new_polys) == 1 else MultiPolygon(new_polys)

    elif isinstance(geom, (LineString, MultiLineString)):
        lines = [geom] if isinstance(geom, LineString) else list(geom.geoms)
        if algorithm == "vw":
            new_lines = [LineString(simplify_coords_vw(list(ln.coords), tol)) for ln in lines]
        else:
            new_lines = [dp_simplify(ln, tolerance=tol, preserve_topology=False) for ln in lines]
        result = new_lines[0] if len(new_lines) == 1 else MultiLineString(new_lines)

    else:
        # Points or unsupported types — pass through
        return geom

    return make_valid(result)


def preprocess_vw_layer(input_path: str, output_path: str, zoom: int = 10) -> None:
    """
    Read a GeoJSON/GeoParquet layer, apply Visvalingam at the given zoom,
    write pre-simplified GeoJSON ready for Tippecanoe ingestion.
    """
    gdf = pyogrio.read_dataframe(input_path)
    gdf["geometry"] = gdf["geometry"].apply(
        lambda g: simplify_geom(shape(mapping(g)), zoom, "vw")
    )
    with tempfile.NamedTemporaryFile(
        mode="w", suffix=".geojson", delete=False
    ) as tmp:
        tmp_path = tmp.name
        pyogrio.write_dataframe(gdf, tmp_path, driver="GeoJSON")

    os.replace(tmp_path, output_path)
    print(f"VW-simplified {input_path}{output_path} at zoom {zoom}")


def run_tippecanoe(
    vw_input: str,
    output_mbtiles: str,
    layer_name: str,
    max_zoom: int = 14,
) -> None:
    """
    Ingest pre-simplified GeoJSON into Tippecanoe with DP disabled
    for the mid-zoom range where VW has already handled simplification.
    Use --no-simplification-of-shared-nodes to keep shared borders aligned.
    """
    cmd = [
        "tippecanoe",
        f"--layer={layer_name}",
        f"--maximum-zoom={max_zoom}",
        "--minimum-zoom=8",
        "--no-simplification-of-shared-nodes",
        "--drop-densest-as-needed",
        "--force",
        f"--output={output_mbtiles}",
        vw_input,
    ]
    subprocess.run(cmd, check=True)
    print(f"Tiles written to {output_mbtiles}")


# --- Example usage ---
if __name__ == "__main__":
    preprocess_vw_layer(
        input_path="admin_boundaries.geojson",
        output_path="admin_boundaries_vw10.geojson",
        zoom=10,
    )
    run_tippecanoe(
        vw_input="admin_boundaries_vw10.geojson",
        output_mbtiles="admin_boundaries.mbtiles",
        layer_name="admin",
        max_zoom=14,
    )

Interaction Effects

--simplification (Tippecanoe DP scale) — defaults to 2 at --maximum-zoom and doubles per zoom step downward. If you set --simplification=4, DP removes more vertices at every zoom, which may conflict with a VW preprocessing pass that already reduced vertex density. For VW-preprocessed layers, set --simplification=1 to let Tippecanoe apply only minimal cleanup without re-simplifying aggressively.

--no-simplification-of-shared-nodes — critical when adjacent polygon rings share edges (census tracts, land cover, admin boundaries). Neither DP nor VW guarantees edge-alignment across features; this flag locks shared nodes before Tippecanoe’s DP pass runs. Without it, sliver gaps appear between adjacent polygons in MapLibre GL rendering. See essential Tippecanoe flags for production builds for the full flag interaction matrix.

--drop-densest-as-needed — Tippecanoe’s safety valve that drops features when a tile exceeds 500 KB. When VW has already reduced vertex density, fewer tiles will trigger this limit. If you observe unexpected feature loss in dense urban areas, check whether the tile size budget is being hit before simplification can help — reduce base_px to simplify more aggressively upstream.

Output storage format — both MBTiles and PMTiles accept the same simplified geometry. If you use an MBTiles SQLite container for local development but deploy PMTiles to a CDN, run topology validation before converting with pmtiles convert admin_boundaries.mbtiles admin_boundaries.pmtiles.

Douglas-Peucker vs Visvalingam-Whyatt simplification Two side-by-side diagrams. Left: Douglas-Peucker retains sharp corners by measuring perpendicular distance from a baseline. Right: Visvalingam-Whyatt smooths the curve by removing vertices with the smallest triangle area. Douglas-Peucker Visvalingam-Whyatt ⊥ dist Simplified Original Kept Dropped area Simplified Original Triangle

Performance Impact

Build time: VW preprocessing via Python adds 15–40% CPU overhead compared to letting Tippecanoe apply DP inline. For a 500 MB GeoJSON of admin boundaries, expect an extra 60–90 seconds on a 4-core machine at zoom 10.

Tile size: VW’s area-based removal eliminates more visually redundant vertices on organic shapes than DP at equivalent effective tolerances. In testing on river-network layers, VW preprocessing at zoom 10 reduces per-tile average payload by ~18% compared to Tippecanoe’s default DP, because VW removes closely-spaced meander vertices that DP retains (their perpendicular distance exceeds threshold but their visual contribution is negligible).

Rendering artifacts: DP can produce isolated spikes on highly curved features where a single vertex is geometrically distant from its neighbors but visually cohesive with the overall shape. VW avoids spikes because area evaluation naturally deprioritizes vertices in dense, low-curvature regions. MapLibre GL’s anti-aliasing reduces this difference at high DPR screens, but it remains visible on low-resolution mobile renders.

Zoom-level scaling:

Zoom DP tolerance (base_px=2, 4096 grid) VW threshold (squared) Typical vertex reduction
14 2.0 units 4.0 area units 5–10%
12 8.0 units 64.0 area units 25–40%
10 32.0 units 1024.0 area units 50–70%
8 128.0 units 16384.0 area units 75–90%

Common Mistakes

1. Passing DP linear tolerance directly to VW

VW expects an area threshold, not a linear distance. If you set simplify_coords_vw(coords, 32.0) when you meant to match a DP tolerance of 32.0 tile-grid units, you are supplying a threshold that is 32 area-units — far too small to remove any vertices at that zoom level. Square the tolerance: simplify_coords_vw(coords, 32.0 ** 2) = simplify_coords_vw(coords, 1024.0).

bash
# Verify VW threshold is squared by inspecting vertex counts before/after:
python3 -c "
from simplification.cutil import simplify_coords_vw
import json, sys
with open('sample_ring.json') as f:
    coords = json.load(f)
print('before:', len(coords))
print('after (linear threshold):', len(simplify_coords_vw(coords, 32.0)))
print('after (squared threshold):', len(simplify_coords_vw(coords, 1024.0)))
"

2. Running VW on already-quantized MVT coordinates

Tippecanoe quantizes coordinates to 4096 grid units when writing tiles. If you extract coordinates from an existing .mbtiles via tippecanoe-decode and then run VW on them, the integer snapping has already distorted coordinate spacing. Always run VW on source coordinates in geographic space (EPSG:4326 degrees or EPSG:3857 meters) before any quantization step.

3. Skipping make_valid after VW on complex polygon rings

VW can produce self-intersecting rings when the area threshold is set aggressively on polygons with narrow protrusions (e.g., elongated administrative boundaries). GEOS will raise TopologicalError when Tippecanoe tries to clip these features to tile boundaries. Always call make_valid() after VW:

python
from shapely.validation import make_valid
from shapely.geometry import shape, mapping

# After VW simplification
result = make_valid(vw_simplified_geom)
if result.is_empty:
    # Feature was degenerate — log and skip rather than passing empty geometry
    print(f"WARNING: feature {feature_id} collapsed to empty after VW + make_valid")

Parent: Geometry Simplification Algorithms — covers the full algorithm selection framework, prerequisite environment setup, and integration with the broader tiling pipeline.

Related pages: