Simplifying OSM Geometry per Zoom Level Jump to heading
Replace one hand-picked tolerance with a number derived from the tile grid at each zoom — and stop adjacent polygons drifting apart along boundaries they were supposed to share.
Prerequisites Jump to heading
Conceptual minimum Jump to heading
Two facts drive everything here.
Geometry is quantised anyway. Encoding rounds every coordinate onto a grid of \(E\) units across the tile, so detail finer than one grid unit cannot survive. A tolerance below that is pure waste: it costs processing time and removes nothing the encoder would not have removed.
Tolerance must vary with zoom. One grid unit at zoom 14 is well under a metre; at zoom 6 it is hundreds of metres. A single tolerance expressed in metres either does nothing at low zoom or destroys geometry at high zoom.
The third fact is topological rather than arithmetic. Douglas-Peucker on two polygons that share a boundary simplifies that boundary twice, independently, and the two results differ — leaving slivers and gaps along every shared edge. The fix is to simplify the edges, once each, and rebuild the polygons from them.
Runnable solution Jump to heading
from __future__ import annotations
import logging
from dataclasses import dataclass
from shapely.geometry import LineString, MultiLineString, Polygon
from shapely.geometry.base import BaseGeometry
from shapely.ops import linemerge, polygonize, unary_union
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
logger = logging.getLogger("osm.tiles.simplify")
EARTH_CIRCUMFERENCE = 40_075_016.686
EXTENT = 4096
@dataclass(frozen=True)
class ZoomTolerance:
zoom: int
grid_units: float = 2.0 # 1 removes only invisible detail; 2-4 is a base map
def metres(self, latitude_deg: float = 0.0) -> float:
"""Ground size of `grid_units` grid units at this zoom and latitude."""
import math
unit = EARTH_CIRCUMFERENCE / (2 ** self.zoom) / EXTENT
return self.grid_units * unit * math.cos(math.radians(latitude_deg))
def degrees(self, latitude_deg: float = 0.0) -> float:
"""The same tolerance expressed in degrees, for geographic geometry."""
return self.metres(latitude_deg) / (EARTH_CIRCUMFERENCE / 360.0)
def simplify_isolated(geom: BaseGeometry, tolerance: float) -> BaseGeometry:
"""Simplify one feature, falling back to the original if it breaks."""
reduced = geom.simplify(tolerance, preserve_topology=True)
if reduced.is_empty or not reduced.is_valid:
logger.warning("simplification produced invalid geometry; keeping original")
return geom
# A polygon reduced below three distinct vertices is no longer an area.
if geom.geom_type == "Polygon" and len(reduced.exterior.coords) < 4:
logger.warning("polygon collapsed at tolerance %.6f; keeping original",
tolerance)
return geom
return reduced
def simplify_coverage(polygons: list[Polygon], tolerance: float) -> list[Polygon]:
"""Simplify a set of adjacent polygons without opening gaps between them.
Each boundary shared by two polygons is simplified EXACTLY ONCE, so both
sides continue to match; simplifying the polygons independently would
reduce the shared edge twice, differently, leaving slivers.
"""
# 1. Reduce the coverage to its unique edges.
boundaries = unary_union([p.boundary for p in polygons])
merged = linemerge(boundaries) if boundaries.geom_type != "LineString" \
else boundaries
edges = list(merged.geoms) if isinstance(merged, MultiLineString) else [merged]
logger.info("coverage of %d polygon(s) decomposed into %d edge(s)",
len(polygons), len(edges))
# 2. Simplify each edge once.
reduced_edges: list[LineString] = []
for edge in edges:
reduced = edge.simplify(tolerance, preserve_topology=True)
reduced_edges.append(reduced if reduced.is_valid and not reduced.is_empty
else edge)
# 3. Rebuild areas from the simplified edge network.
rebuilt = list(polygonize(unary_union(reduced_edges)))
logger.info("rebuilt %d polygon(s) from simplified edges", len(rebuilt))
if len(rebuilt) < len(polygons) * 0.9:
logger.warning("rebuild lost %d polygon(s) — tolerance may be too coarse",
len(polygons) - len(rebuilt))
return rebuilt
def tolerance_table(min_zoom: int, max_zoom: int,
latitude_deg: float = 50.0) -> dict[int, float]:
table = {}
for z in range(min_zoom, max_zoom + 1):
t = ZoomTolerance(z)
table[z] = t.degrees(latitude_deg)
logger.info("zoom %2d: %8.2f m (%.7f deg)", z, t.metres(latitude_deg),
table[z])
return table
if __name__ == "__main__":
tolerance_table(4, 14)
Step-by-step walkthrough Jump to heading
- Derive from the grid, not from taste.
ZoomTolerancecomputes the ground size of a grid unit at a zoom and multiplies by a small factor. Two units is a reasonable base-map default; one removes only what the encoder would have removed anyway. - Adjust for latitude. The grid is finer away from the equator by the cosine of the latitude. Using the equatorial figure over-simplifies a Nordic map by a factor of two or more.
- Preserve topology. Shapely’s topology-preserving mode avoids producing self-intersections in most cases, at a modest cost. It is not a guarantee, which is why the validity check follows.
- Fall back rather than repair. If simplification produces something invalid or empty, keeping the original geometry is strictly better than shipping a broken one or attempting an automatic repair whose effect nobody reviewed.
- Guard against collapse. A polygon reduced to fewer than three distinct vertices no longer bounds an area; catching that explicitly avoids a confusing empty feature downstream.
- Decompose coverages into edges. Taking the union of all boundaries and merging the result yields each shared edge once, which is what makes the topological path work.
- Rebuild by polygonizing. Reassembling areas from the simplified edge network guarantees adjacent polygons still share their boundaries exactly.
- Check the rebuild count. Losing polygons during the rebuild means the tolerance closed a narrow area entirely; the warning names it rather than leaving a silent gap in the map.
Verification Jump to heading
- Adjacent polygons still share boundaries. Union two neighbours after simplification; the result must have no interior gap.
- No geometry became invalid. Run a validity check over the simplified set; the count of fallbacks should be small and explainable.
- The polygon count survives. A coverage rebuild that returns noticeably fewer polygons has closed small areas entirely.
- Vertex reduction is substantial. Compare total vertex counts before and after; at low zoom a reduction of ninety percent or more is normal.
- Visual shape survives. Overlay simplified and original geometry at the target zoom; the difference should be invisible at that scale and obvious at full zoom.
Common errors and fixes Jump to heading
| Symptom | Root cause | One-line fix |
|---|---|---|
| Slivers between adjacent areas | Polygons simplified independently | Decompose into shared edges and simplify each once |
| Self-intersections after simplifying | Topology preservation disabled | Enable topology preservation and validate afterwards |
| Small areas disappear | Tolerance exceeds the feature’s size | Guard on vertex count and fall back to the original |
| No visible reduction | Tolerance far below the grid unit | Derive tolerance from the grid at the target zoom |
| High-zoom geometry destroyed | One tolerance used at every zoom | Compute a tolerance per zoom level |
| Nordic maps over-simplified | Equatorial grid size assumed | Scale the tolerance by the cosine of the latitude |
| Rebuild returns fewer polygons | Narrow areas closed by simplification | Lower the tolerance, or exclude small features from it |
Specification reference Jump to heading
The Douglas-Peucker algorithm reduces a polyline to a subset of its vertices such that no removed vertex lies further than a given tolerance from the retained line. Shapely’s
simplifyimplements it, and its topology-preserving mode avoids producing self-intersections in the simplified result, though it does not guarantee validity for all inputs. See the Shapely documentation forsimplify,linemergeandpolygonize, which together implement the topological path used here.
Frequently Asked Questions Jump to heading
Why do gaps appear between areas that used to touch?
Because each polygon was simplified on its own, so the boundary they shared was reduced twice and the two results differ. The only reliable fix is topological: reduce the coverage to its unique edges, simplify each edge exactly once, and rebuild the polygons from the simplified network. Any per-feature approach, however careful, produces slivers along every shared boundary.
How do I choose the tolerance factor?
Start from the tile grid unit at the target zoom and multiply by a small factor. One unit removes only detail the encoder would have quantised away, which is free but not much of a reduction. Two to four units is the usual base-map range: real detail disappears while shapes stay recognisable. Beyond about eight units corner-cutting on curves becomes obvious, and a reader will notice that coastlines have gone polygonal.
Should I simplify before or after projecting?
Either works provided the tolerance is expressed in the same units as the geometry, but deriving the tolerance from the tile grid is easier in projected space, because the grid is defined there. If you simplify in geographic degrees, convert the grid-derived metric tolerance to degrees using the latitude of the data, not a global constant — a degree of longitude at sixty degrees north is half its equatorial length.
What should happen when simplification breaks a polygon?
Keep the original. An automatic repair changes the geometry in a way nobody reviewed, and shipping an invalid polygon produces unpredictable rendering. Falling back preserves correctness at the cost of a slightly larger tile, and counting the fallbacks gives you a signal: a handful is normal, a large number means the tolerance is too coarse for that feature class.
Related Jump to heading
- Cartographic Generalization of OSM Data — the parent topic and the other four operators.
- Merging Adjacent OSM Polygons for Low Zoom — the aggregation that usually follows simplification.
- The Mapbox Vector Tile Spec & Tile Geometry — the grid the tolerance is derived from.
- Detecting Self-Intersecting OSM Polygons with Shapely — the validity check applied after simplifying.
- Measuring Area Accurately on OSM Polygons — why latitude belongs in every metric derivation.
Up one level: Cartographic Generalization of OSM Data.