Geometry Simplification Algorithms for Vector Tile Pipelines

Raw geospatial datasets contain far more vertices than any zoom level can render usefully. Applying a mathematically sound simplification step before tile encoding keeps payload sizes within the 500 KB tile size budget, maintains topological integrity, and prevents client-side WebGL from choking on dense polygon rings. This page covers algorithm selection, tolerance scaling, and production-hardened integration patterns for automated Tippecanoe pipelines.


Geometry simplification pipeline stages Four boxes connected by arrows: Source Geometry (GeoJSON/GeoParquet) → Algorithm Selection (DP or VW) with Tolerance Scaling → Topology Validation (make_valid / is_valid) → Tippecanoe Encoding (.mbtiles / .pmtiles). Labels beneath each stage show key tools and checks. Source Geometry .geojson / .geoparquet Algorithm Selection Douglas-Peucker Visvalingam-Whyatt + tolerance scaling Topology Validation shapely.make_valid() Tippecanoe Encode .mbtiles / .pmtiles z=base_tol × 2^(14−zoom)

Prerequisites

Before integrating simplification, confirm these dependencies and input conditions are in place.

Requirement Minimum version Notes
shapely 2.0 (GEOS-backed) Enables vectorized simplify(), is_valid(), make_valid()
pyogrio 0.7 Fast, vectorized GeoDataFrame I/O; supports .geoparquet and .geojson
numpy 1.24 Required for zoom-tier tolerance arrays
Tippecanoe CLI 2.0+ Compiled with zlib and sqlite3; Felt fork preferred
Source CRS EPSG:4326 Tippecanoe expects WGS 84 input; reproject before simplification
RAM ≥8 GB National-scale polygon datasets require chunked processing

If your source data arrives as columnar .geoparquet files, align schema and spatial indexing first — the GeoParquet input processing guide covers column projection and bbox filtering before vertex reduction begins.


Core Algorithms: Parameters and Trade-offs

Both algorithms are available through shapely.simplify(). The choice between them determines accuracy, computational cost, and which feature classes degrade gracefully.

Algorithm parameter table

Parameter Douglas-Peucker Visvalingam-Whyatt
Core metric Perpendicular distance from baseline Effective area of vertex triangle
preserve_topology Supported Supported
Tolerance unit Degrees (EPSG:4326) Degrees² (area-based; requires empirical calibration)
Complexity O(n log n) average O(n²) naïve; O(n log n) heap-optimised
Tile grid alignment Maps linearly to MVT 4096-unit quantisation grid Requires squared or empirical calibration per zoom
Self-intersection risk High on convoluted curves Low — area-based removal smooths curvature
Best feature classes Road networks, cadastral parcels, engineered boundaries Hydrography, admin boundaries, ecological zones
Shapely call shapely.simplify(geom, tol, preserve_topology=True) shapely.simplify(geom, tol, preserve_topology=True) (same API; algorithm selected via internal flag)

For a head-to-head benchmark of fidelity and tile size outcomes at zoom 0–14, see Visvalingam vs Douglas-Peucker in Tile Generation.


Step-by-Step Implementation

1. Ingest and validate source geometries

Load features and run validation before any transformation. Invalid rings cause silent failures deeper in the pipeline.

python
import pyogrio
import shapely
from shapely.validation import make_valid

# Fast batch read — works with .geojson, .geoparquet, GPKG
gdf = pyogrio.read_dataframe("source_admin_boundaries.geoparquet")

# Verify: all rows must be EPSG:4326
assert gdf.crs.to_epsg() == 4326, f"Unexpected CRS: {gdf.crs}"

# Vectorized validity check and in-place repair
invalid_mask = ~shapely.is_valid(gdf.geometry.values)
if invalid_mask.any():
    gdf.loc[invalid_mask, "geometry"] = make_valid(
        gdf.loc[invalid_mask, "geometry"].values
    )
    print(f"Repaired {invalid_mask.sum()} invalid geometries")

Verify: shapely.is_valid(gdf.geometry.values).all() must return True before proceeding.


2. Apply deterministic tolerance scaling per zoom level

A fixed tolerance applied across all zoom levels over-simplifies at high zooms and under-simplifies at low zooms. Scale tolerance logarithmically against the map zoom so that vertex density matches rendering resolution at every tier.

python
import numpy as np

def zoom_tolerance(zoom: int, base_degrees: float = 0.0001) -> float:
    """
    Returns per-zoom simplification tolerance in degrees (EPSG:4326).
    base_degrees ≈ ~11 m at the equator for z14.
    Doubles for each zoom step down (z13 → 0.0002, z10 → 0.0016).
    """
    return base_degrees * (2 ** (14 - zoom))

# Build a simplified geometry column per zoom tier
zoom_range = range(6, 15)
for z in zoom_range:
    tol = zoom_tolerance(z)
    gdf[f"geom_z{z}"] = shapely.simplify(
        gdf.geometry.values,
        tolerance=tol,
        preserve_topology=True,
    )
    vertex_counts = shapely.get_num_coordinates(gdf[f"geom_z{z}"].values)
    print(f"z{z}: tol={tol:.6f}°  avg_vertices={vertex_counts.mean():.0f}")

Verify: vertex counts should decrease monotonically as zoom decreases; any zoom tier where counts increase indicates an invalid geometry slipping through.


3. Enforce topology and boundary integrity post-simplification

Simplification can create sliver polygons, collapsed rings, and boundary gaps even when preserve_topology=True is set. Run checks after each zoom tier before writing output.

python
def clean_simplified(gdf_in, geom_col: str, min_area_deg2: float = 1e-10):
    """Remove collapsed and newly-invalid geometries from a simplified tier."""
    geoms = gdf_in[geom_col].values

    # Re-validate after simplification
    valid = shapely.is_valid(geoms)
    # Filter out zero-area or collapsed features
    area = shapely.area(geoms)
    keep = valid & (area > min_area_deg2)

    dropped = (~keep).sum()
    if dropped:
        print(f"  Dropped {dropped} collapsed/invalid features from {geom_col}")

    return gdf_in[keep].copy()

for z in zoom_range:
    gdf = clean_simplified(gdf, f"geom_z{z}", min_area_deg2=1e-10)

Use shapely.buffer(0) as a secondary repair pass only if make_valid() leaves residual self-intersections — buffer(0) can alter polygon orientation on multipart geometries.


4. Export per-zoom slices and encode with Tippecanoe

Write each zoom tier to a separate .geojson or intermediary, then pass to Tippecanoe CLI with zoom-locked layer flags.

bash
# Encode simplified GeoJSON with explicit zoom bounds
# --no-simplification disables Tippecanoe's internal simplification
# (we have already simplified externally and want exact vertex fidelity)

tippecanoe \
  --output=admin_boundaries.mbtiles \
  --layer=admin \
  --minimum-zoom=6 \
  --maximum-zoom=14 \
  --no-simplification \
  --drop-densest-as-needed \
  --extend-zooms-if-still-dropping \
  --force \
  simplified_admin_z6_to_z14.geojson

Verify tile sizes with tippecanoe-decode or a tile inspector:

bash
# Decode a sample tile and count coordinates
tippecanoe-decode admin_boundaries.mbtiles 10 512 384 \
  | python3 -c "import sys,json; d=json.load(sys.stdin); \
    print(sum(len(f['geometry']['coordinates'][0]) \
    for f in d['admin']['features']))"

No tile should exceed 500 KB. If any do, reduce base_degrees by 20% and re-run the zoom tier that overflows.

For fine-grained control over which attributes survive encoding, see the attribute filtering rules guide — dropping unused properties before simplification reduces memory pressure and shrinks output tile sizes further.


Optimization Knobs

Three parameters account for most of the tile size vs. fidelity trade-off space:

Knob Low setting High setting Effect on tiles
base_degrees (tolerance at z14) 0.00005 (~5 m) 0.0005 (~55 m) Higher → smaller tiles, more shape loss at high zoom
min_area_deg2 (collapse filter) 1e-12 1e-8 Higher → fewer slivers, small features dropped earlier
--drop-densest-as-needed Off On Tippecanoe drops features in dense tiles; safe for point/line layers, risky for polygon completeness

Trade-off summary:

  • Tighter tolerance + aggressive collapse filter — smallest tiles, suitable for z0–8 base layers; detail loss at z12+ becomes noticeable.
  • Loose tolerance + no collapse filter — largest tiles, full vertex fidelity; use only for z12–14 detail layers served from a regional tile cache.
  • Per-feature-class tolerance profiles — apply tighter tolerances to engineered boundaries (roads, parcels) and looser ones to organic features (rivers, vegetation). Requires a join key between feature class and tolerance table.

Integration with Adjacent Pipeline Stages

Simplified geometries feed two downstream stages:

Tippecanoe encoding consumes the simplified .geojson and applies its own coordinate quantisation to the MVT 4096-unit integer grid. When you pre-simplify externally, pass --no-simplification to prevent Tippecanoe from double-simplifying. When you let Tippecanoe handle simplification internally (with --simplification=N), skip the Python step and rely on Tippecanoe’s zoom-aware simplification flags, documented in essential Tippecanoe flags for production builds.

MBTiles/PMTiles output lands on a tile server or CDN origin. Tile sizes from the simplification step directly affect cache-hit ratios and CDN egress costs. Oversized tiles (>500 KB) in an MBTiles SQLite container trigger read latency spikes under concurrent load. Pre-simplification that keeps per-tile payloads under 200 KB at each zoom tier is the most reliable lever for keeping P99 serve latency below 50 ms.


Troubleshooting

Symptom Diagnosis command Fix
TopologicalError: This operation could not be performed shapely.is_valid(geom) returns False after simplify() Run make_valid() on the affected geometry, then re-simplify with a tighter tolerance
Tile exceeds 500 KB at z12 tippecanoe-decode <file> 12 <x> <y> | wc -c Reduce base_degrees for z10–z12 tier by 30%; reprocess only affected zoom range
Rivers/coastlines appear blocky at z10 Visual inspection or automated vertex-count diff Switch to Visvalingam-Whyatt for feature_class == "hydrology"; apply tolerance calibrated against area not distance
Self-intersecting output polygons after encoding tippecanoe-decode output + shapely.is_valid() check Add shapely.buffer(0) repair pass before Tippecanoe input; check if duplicate ring coordinates exist
Memory exhaustion (OOM) on national dataset Monitor RSS during batch; compare with shapely.get_num_coordinates(gdf.geometry.values).sum() Switch to chunked reads via pyogrio.read_dataframe("file.geoparquet", rows=50000); process zoom tiers sequentially

Further Reading

Visvalingam vs Douglas-Peucker in Tile Generation — A direct head-to-head comparison: tolerance calibration, tile size benchmarks, and the specific feature classes where each algorithm wins. Start here if you need to pick an algorithm for a new layer type.

Tuning Simplification with the Detail Flag — how --detail, --simplification, and --detect-shared-borders trade tile resolution for size, and how the tile extent grid follows from the detail value.


Next reading Tuning Simplification with the Detail Flag Next reading Visvalingam vs Douglas-Peucker in Tile Generation