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.
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.
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.
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.
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.
# 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:
# 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.
Related
- Automated Generation Pipelines with Tippecanoe — the parent section covering the full GeoJSON→MBTiles/PMTiles pipeline, of which simplification is one control point.
- GeoParquet Input Processing — optimising columnar source reads and spatial filtering before vertex reduction to prevent upstream bottlenecks.
- Tippecanoe CLI Fundamentals — flag taxonomy and production command patterns for the encoding step that consumes simplified geometries.