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.
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.
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).
# 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:
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")
Up-link and Related
Parent: Geometry Simplification Algorithms — covers the full algorithm selection framework, prerequisite environment setup, and integration with the broader tiling pipeline.
Related pages:
- Essential Tippecanoe Flags for Production Builds —
--simplification,--no-simplification-of-shared-nodes, and--drop-densest-as-neededinteract directly with how DP tolerance is applied after VW preprocessing. - Dropping Unused Attributes to Reduce Tile Size — attribute filtering and geometry simplification are complementary tile-size reduction strategies; apply both before writing to an MBTiles container.
- Converting Large GeoParquet Files to Vector Tiles — when your source is columnar GeoParquet rather than GeoJSON, read it with
pyogrioorgeopandasbefore applying VW simplification to avoid loading the full dataset into memory.