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.

Two simplification paths and where they diverge Four stages. Both paths start from validated input features. The independent path simplifies each polygon on its own, which is fast and correct for isolated features but produces slivers wherever two polygons shared a boundary. The topological path first decomposes the coverage into unique shared edges, simplifies each edge exactly once, then rebuilds every polygon from its simplified edges so adjacent areas continue to match exactly. Both paths end by validating the result and falling back to the original where simplification produced invalid geometry. Isolated features one way, coverages the other validate in reject broken input simplify makes it worse choose a path isolated or coverage adjacency decides simplify per feature or per edge once each, either way validate out fall back if invalid keep the original The second step is the whole decision: running the fast path on a coverage is what puts gaps between every pair of adjacent areas.
Both paths are correct for their own input, and neither is correct for the other's.

Runnable solution Jump to heading

python
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

  1. Derive from the grid, not from taste. ZoomTolerance computes 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. Rebuild by polygonizing. Reassembling areas from the simplified edge network guarantees adjacent polygons still share their boundaries exactly.
  8. 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.
Ground size of the simplification tolerance at each zoom, at fifty degrees latitude Six zoom levels with the tolerance in metres that two tile grid units corresponds to at fifty degrees north. At zoom four the tolerance is roughly seven hundred and seventy metres. At zoom six it is about one hundred and ninety metres. At zoom eight it is about forty eight metres. At zoom ten it is about twelve metres. At zoom twelve it is about three metres. At zoom fourteen it is under one metre. A note observes that the same factor at the equator would be about fifty percent larger. Two grid units, expressed in metres, by zoom zoom 4 about 770 m zoom 6 about 193 m zoom 8 about 48 m zoom 10 about 12 m zoom 12 about 3 m zoom 14 about 0.8 m At the equator each figure is roughly fifty percent larger, which is why latitude belongs in the derivation rather than in a comment.
A single tolerance in metres would be invisible at the top of this table and destructive at the bottom.
Three ways simplification damages geometry and what each looks like on the map Three panels. A sliver appears where two polygons that shared a boundary were simplified independently, leaving a thin wedge of unclaimed space visible as a hairline of background colour. A self-intersection appears where a narrow neck collapsed, producing a bowtie that renders unpredictably and fails validity checks. A collapse appears where a small feature was reduced below three distinct vertices, so the area vanishes entirely and nothing marks its absence. Three damage modes, only one of them errors Sliver Between two adjacent areas Shared edge reduced twice Hairline of background shows Validity check passes Fix: simplify edges once Self-intersection A narrow neck collapsed Ring crosses itself Renders unpredictably Validity check catches it Fix: validate, fall back Collapse Feature below three vertices Area becomes nothing Silently absent from the map No check catches it alone Fix: guard on vertex count Only the middle panel is caught by a validity check, which is why the other two need their own explicit guards.
A simplification pass that only checks validity ships the first and third of these into production.

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 simplify implements 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 for simplify, linemerge and polygonize, 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.

Up one level: Cartographic Generalization of OSM Data.