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

Feature geometry decides the algorithmSix feature types plotted against how angular their geometry is and how much their overall silhouette matters to the reader.WHERE EACH WINSangularity of the geometry →silhouette matters to the reader →11Coastlines — Visvalingam22Contours — Visvalingam33Rivers — Visvalingam44Roads — Douglas-Peucker55Buildings — Douglas-Peucker66Admin borders — either, pinned
The two clusters are the whole recommendation. Anything landing in the middle is a case where either works and the cheaper one should win.
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.

Which algorithm to reach for, by feature typeSix feature types with the recommended algorithm, the reason, and the artefact the wrong choice produces.CHOOSINGchoosebecausewrong choice shows asRoad centrelinesDouglas-Peuckerjunction angles matterjunctions driftoff-nodeBuilding footprintsDouglas-Peuckerright angles mattercorners roundedCoastlinesVisvalingamsilhouette mattersspiky headlandsContour linesVisvalingamsmoothness mattersvisible kinksAdministrative bordersEither, pinnedshared edges dominateslivers between unitsRiver networksVisvalingammeanders mattermeanders straightened
The right-hand column is how you diagnose a build that used the wrong one — each algorithm fails in a characteristic, recognisable way.

Performance Impact

What each algorithm costs to runFour runs measured over four million vertices: Douglas-Peucker, Visvalingam-Whyatt, Visvalingam with a heap, and Tippecanoe's built-in pass.SIMPLIFICATION COSTseconds for 4 M verticesDouglas-Peucker (recursive)3.1 sVisvalingam with a heap5.4 sVisvalingam, naive re-sort47 sTippecanoe built-in2.2 s
Visvalingam re-sorts after each removal, so a naive implementation is an order of magnitude slower. With a heap it is competitive — which is what production code should use.

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")

FAQ

Can I tell which algorithm a build used by looking at the output?

Often, yes. Douglas-Peucker leaves sharp corners and can straighten long curves; Visvalingam preserves the silhouette and can round off corners. Spiky headlands on a coastline point at the first; rounded building corners at the second.

Is Visvalingam much slower?

Only when implemented naively. Re-sorting after every removal is an order of magnitude slower; with a heap it is within a factor of two of Douglas-Peucker.

Does Tippecanoe let me choose?

Tippecanoe applies its own simplification and exposes the aggressiveness rather than the algorithm. Choosing explicitly means simplifying upstream, in PostGIS or shapely, before tiling.

What about administrative boundaries?

Either algorithm works, and the shared-edge problem dominates the choice of algorithm entirely. Pin the shared nodes and the difference between the two becomes marginal.

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

Related pages: