Generalisation for Zoom Levels Explained

On this page

Problem statement

The instinct for making a map work at low zoom is to simplify the geometry. Measured on Leiden's 58,879 OpenStreetMap building footprints, that instinct is almost entirely wrong.

Simplifying every building to a one-pixel tolerance at each zoom (Example 1):

zoom   tolerance    vertices kept
  10      93.79 m      54.5%
  12      23.45 m      54.6%
  14       5.86 m      56.9%
  16       1.47 m      74.1%

Even at zoom 10's tolerance of 93.79 m, simplification keeps 54.5% of the vertices. It cannot do better, because a building footprint averages 7.4 vertices, closing point included, and a valid polygon needs at least four.

Dropping features smaller than two by two pixels instead:

zoom   drop below     features kept
  10      35,183 mΒ²        1 of 58,879  (0.002%)
  12       2,199 mΒ²      211 of 58,879  (0.358%)
  14         137 mΒ²    3,603 of 58,879  (6.119%)
  16           9 mΒ²   43,752 of 58,879  (74.308%)

At zoom 10, simplification keeps 54.5% of the vertices and selection keeps 0.002% of the features β€” one building.

Quick answer

Generalise by selection first, simplification second:

import math

def generalise(gdf_utm, zoom, lat, min_visible_px=2.0):
    """Drop what cannot be seen, then simplify what remains."""
    m_per_px = 156543.03392 * math.cos(math.radians(lat)) / (2 ** zoom)
    min_area = (min_visible_px * m_per_px) ** 2

    visible = gdf_utm[gdf_utm.geometry.area >= min_area].copy()
    visible["geometry"] = visible.geometry.simplify(m_per_px,
                                                    preserve_topology=True)
    print(f"  z{zoom}: {len(visible):,} of {len(gdf_utm):,} features "
          f"({len(visible) / len(gdf_utm):.1%}) at {m_per_px:.1f} m/px")
    return visible
import geopandas as gpd

buildings = gpd.read_parquet("buildings.parquet")
LAT = float(buildings.geometry.centroid.y.mean())
utm = buildings.to_crs(buildings.estimate_utm_crs())
for zoom in (10, 14):
    generalise(utm, zoom, LAT)
  z10: 1 of 58,879 features (0.0%) at 93.8 m/px
  z14: 3,603 of 58,879 features (6.1%) at 5.9 m/px

A feature smaller than about two screen pixels cannot be seen. Sending it costs bytes and renders nothing.

Simplification keeping 54.5 percent of vertices at zoom 10 against selection keeping 1 of 58,879 features. generalising 58,879 Leiden buildings for zoom 10simplify to 1 px (94 m)54.5% of vertices keptdrop below 2 px1 of 58,879 features070%At low zoom, selection is worth four orders of magnitude more than simplification spatialworkflow.io
At low zoom, simplification is nearly useless and selection is decisive.

What a pixel is worth, drop the invisible, simplification's floor

1. Work out what one pixel is worth

m_per_px = 156543.03392 * cos(latitude) / 2^zoom

At Leiden's 52.2Β° north: 93.8 m at zoom 10, 23.4 m at zoom 12, 5.9 m at zoom 14, 1.5 m at zoom 16.

Everything else follows from that number. A tolerance finer than a pixel is invisible; a feature smaller than a couple of pixels is invisible.

2. Drop what cannot be seen

A two-by-two-pixel threshold at zoom 10 is 35,183 mΒ² β€” about three and a half hectares. One building in the dataset qualifies.

That is the correct output. A zoom-10 view of a city should show its footprint, not 58,879 individually invisible polygons, and no styling makes 58,879 sub-pixel rectangles legible.

3. Understand why simplification hits a floor

simplify(preserve_topology=True) will not collapse a polygon into something invalid, so it keeps at least four distinct vertices plus a closing point.

With 434,355 vertices across 58,879 buildings β€” 7.4 each β€” most footprints are already near that minimum. Hence the floor in the table above, which barely moves between zoom 10 and zoom 14.

Simplification is effective on features with many vertices: coastlines, administrative boundaries, rivers, contours. On small compact polygons it does nothing.

4. Replace, rather than only remove

Dropping everything leaves a hole. The standard replacements:

  • Aggregate β€” dissolve buildings into built-up areas at low zoom.
  • Substitute β€” draw a point where a polygon would be sub-pixel.
  • Sample β€” keep the largest n per tile so density is suggested without every feature.
  • Rank β€” keep by importance, not just size, where an attribute expresses it.

5. Generalise once per zoom, not once

Each zoom level needs its own generalisation, applied before tiling. Generalising once and reusing it means the wrong detail at every level but one.

Vertices kept by topology-preserving simplification at four zoom levels. vertices kept by simplify(preserve_topology=True)zoomtolerancevertices keptz161.47 m74.1%z145.86 m56.9%z1223.5 m54.6%z1093.8 m54.5%a valid polygon needs four vertices; these average 7.4, so there is little left to removeFrom z14 to z10 a sixteenfold rise in tolerance removes 2.4 points more spatialworkflow.io
preserve_topology=True refuses to destroy a polygon, so simplification bottoms out well above zero.

Per-zoom generalisation with its cost

The data used on this page

The layer is every building in Leiden in OpenStreetMap β€” about 59,000 footprints with their building type and, where mapped, a name, height and number of levels β€” fetched with OSMnx through an Overpass mirror and saved as GeoParquet in EPSG:4326. The OpenStreetMap answers are read as the Overpass mirror gave them on 10 October 2026.

import io
import os
import requests
import zipfile

import geopandas as gpd
import osmnx as ox

# OpenStreetMap as Overpass returned it on 10 October 2026, saved as osmnx's own cache so the counts
# below do not drift as OSM is edited; delete the cache folder to query live instead
if not os.path.exists("cache"):
    reply = requests.get("https://data.spatialworkflow.io/osm-cache-zoom-generalisation-explained-2026-10-10.zip", timeout=300)
    reply.raise_for_status()
    with zipfile.ZipFile(io.BytesIO(reply.content)) as archive:
        archive.extractall("cache")

ox.settings.overpass_url = "https://maps.mail.ru/osm/tools/overpass/api"   # overpass-api.de mirror
ox.settings.overpass_rate_limit = False
if not os.path.exists("buildings.parquet"):
    found = ox.features_from_place("Leiden, Netherlands", {"building": True}).reset_index()
    found = found[found.geom_type.isin(["Polygon", "MultiPolygon"])]
    keep = [c for c in ("osmid", "building", "name", "height", "building:levels") if c in found.columns]
    found[keep + ["geometry"]].rename(columns={"building:levels": "levels"}).to_parquet("buildings.parquet")
buildings = gpd.read_parquet("buildings.parquet")
print(f"{len(buildings):,} OSM buildings in Leiden, EPSG:{buildings.crs.to_epsg()}; "
      f"columns {[c for c in buildings.columns if c != 'geometry']}")
58,879 OSM buildings in Leiden, EPSG:4326; columns ['building', 'name', 'height', 'levels']

Example 1 β€” a per-zoom generalisation with the cost reported

import math
import geopandas as gpd


def generalise_for_zoom(gdf, zoom, lat=None, min_visible_px=2.0,
                        simplify_px=1.0, keep_largest=None):
    """Select by visible size, then simplify to a pixel."""
    utm = gdf.to_crs(gdf.estimate_utm_crs())
    lat = lat if lat is not None else float(gdf.geometry.centroid.y.mean())
    m_per_px = 156543.03392 * math.cos(math.radians(lat)) / (2 ** zoom)

    areas = utm.geometry.area
    min_area = (min_visible_px * m_per_px) ** 2
    keep = areas >= min_area
    subset = utm[keep].copy()

    if keep_largest and len(subset) > keep_largest:
        subset = subset.iloc[subset.geometry.area.argsort()[::-1][:keep_largest]]

    before = int(subset.geometry.count_coordinates().sum())
    subset["geometry"] = subset.geometry.simplify(simplify_px * m_per_px,
                                                  preserve_topology=True)
    after = int(subset.geometry.count_coordinates().sum())

    print(f"  z{zoom:<3} {m_per_px:8.2f} m/px  "
          f"features {len(subset):6,}/{len(gdf):,} ({keep.mean():6.2%})  "
          f"vertices {after:8,}/{before:,} ({after / max(before, 1):6.2%})")
    return subset.to_crs(gdf.crs)
for zoom in (10, 12, 14, 16):
    generalise_for_zoom(buildings, zoom)
  z10     93.79 m/px  features      1/58,879 ( 0.00%)  vertices      517/1,600 (32.31%)
  z12     23.45 m/px  features    211/58,879 ( 0.36%)  vertices    1,781/12,665 (14.06%)
  z14      5.86 m/px  features  3,603/58,879 ( 6.12%)  vertices   20,984/65,436 (32.07%)
  z16      1.47 m/px  features 43,752/58,879 (74.31%)  vertices  248,966/355,778 (69.98%)

And the two levers measured separately over all 58,879 buildings β€” simplification alone, then selection alone β€” which is where the tables at the top of the page come from:

import math

vertices = int(utm.geometry.count_coordinates().sum())
print(f"{vertices:,} vertices, {vertices / len(utm):.1f} per building\n")
print("zoom   tolerance    vertices kept")
for zoom in (10, 12, 14, 16):
    m_per_px = 156543.03392 * math.cos(math.radians(LAT)) / 2 ** zoom
    kept = utm.geometry.simplify(m_per_px, preserve_topology=True).count_coordinates().sum()
    print(f"  {zoom}   {m_per_px:8.2f} m     {kept / vertices:6.1%}")
print("\nzoom   drop below     features kept")
for zoom in (10, 12, 14, 16):
    m_per_px = 156543.03392 * math.cos(math.radians(LAT)) / 2 ** zoom
    threshold = (2 * m_per_px) ** 2
    kept = int((utm.geometry.area >= threshold).sum())
    print(f"  {zoom}   {threshold:9,.0f} mΒ²   {kept:6,} of {len(utm):,}  ({kept / len(utm):.3%})")
434,355 vertices, 7.4 per building

zoom   tolerance    vertices kept
  10      93.79 m      54.5%
  12      23.45 m      54.6%
  14       5.86 m      56.9%
  16       1.47 m      74.1%

zoom   drop below     features kept
  10      35,183 mΒ²        1 of 58,879  (0.002%)
  12       2,199 mΒ²      211 of 58,879  (0.358%)
  14         137 mΒ²    3,603 of 58,879  (6.119%)
  16           9 mΒ²   43,752 of 58,879  (74.308%)

Reporting both reductions side by side is what makes the point. At low zoom the feature column collapses and the vertex column barely moves.

Example 2 β€” aggregation as a replacement for the dropped features

import geopandas as gpd


def aggregate_for_low_zoom(gdf, cell_m=500, min_features=3):
    """Dissolve dense clusters into built-up polygons."""
    utm = gdf.to_crs(gdf.estimate_utm_crs())
    centroids = utm.geometry.representative_point()

    utm["cell"] = (
        (centroids.x // cell_m).astype(int).astype(str) + "_" +
        (centroids.y // cell_m).astype(int).astype(str))

    counts = utm.groupby("cell").size()
    dense = counts[counts >= min_features].index
    subset = utm[utm["cell"].isin(dense)]

    dissolved = (subset.assign(geometry=subset.geometry.buffer(cell_m / 10))
                       .dissolve(by="cell")
                       .reset_index())
    dissolved["geometry"] = dissolved.geometry.buffer(-cell_m / 10)
    dissolved["n_features"] = counts.loc[dissolved["cell"]].values

    print(f"  {len(gdf):,} features -> {len(dissolved):,} aggregates "
          f"({len(dissolved) / len(gdf):.2%})")
    return dissolved.to_crs(gdf.crs)
aggregates = aggregate_for_low_zoom(buildings)
print(f"  largest aggregate holds {aggregates['n_features'].max():,} buildings")
  58,879 features -> 109 aggregates (0.19%)
  largest aggregate holds 1,913 buildings

Buffering out, dissolving and buffering back in is the standard way to merge features that are close but not touching. The buffer distance sets what counts as "close", and it is the parameter to expose.

Example 3 β€” keeping the most important features per tile

import mercantile
import numpy as np


def top_per_tile(gdf, zoom, max_per_tile=200, importance=None):
    """Cap the feature count per tile, keeping the most important."""
    utm = gdf.to_crs(gdf.estimate_utm_crs())
    score = (gdf[importance] if importance and importance in gdf
             else utm.geometry.area)

    centroids = gdf.geometry.representative_point()
    tiles = [mercantile.tile(x, y, zoom)
             for x, y in zip(centroids.x, centroids.y)]
    keys = np.array([f"{t.z}/{t.x}/{t.y}" for t in tiles])

    keep = np.zeros(len(gdf), bool)
    over = 0
    for key in np.unique(keys):
        idx = np.nonzero(keys == key)[0]
        if len(idx) <= max_per_tile:
            keep[idx] = True
            continue
        over += 1
        best = idx[np.argsort(score.values[idx])[::-1][:max_per_tile]]
        keep[best] = True

    print(f"  {over} of {len(np.unique(keys))} tiles were over "
          f"{max_per_tile} features; kept {keep.sum():,} of {len(gdf):,}")
    return gdf[keep]
kept = top_per_tile(buildings, zoom=14, max_per_tile=200)
  16 of 18 tiles were over 200 features; kept 3,385 of 58,879

Capping per tile rather than globally keeps the map balanced: a dense city centre and a sparse suburb both get up to the cap, so neither is empty and neither is unreadable.

Why selection beats simplification

Why simplification hits a floor

Douglas-Peucker removes vertices whose deviation from the simplified line is below the tolerance. preserve_topology=True additionally refuses any removal that would make the geometry invalid or self-intersecting.

A polygon needs at least three distinct vertices plus a closing point. The measured dataset averages 7.4 vertices per building, so most are only a few vertices above the floor.

At zoom 10's 93.79 m tolerance the algorithm removes everything it is allowed to and stops at 54.5% β€” barely less than the 56.9% it keeps at zoom 14's 5.86 m. Increasing the tolerance further changes little, because the constraint is validity rather than tolerance.

Why selection is so much more effective

Selection has no floor. If a feature is below the visibility threshold, all of it goes β€” every vertex, every attribute, every byte.

At zoom 10 the threshold is 35,183 mΒ² and one building exceeds it. That is a reduction of more than four orders of magnitude, against simplification's factor of 1.8.

The two are not alternatives; they are for different zoom ranges. At high zoom everything is visible and simplification trims the vertices. At low zoom almost nothing is visible and selection removes the features.

Why sub-pixel features are worse than useless

A polygon smaller than a pixel renders as at most one pixel, indistinguishable from a point and often anti-aliased into invisibility.

It still costs bytes in the tile, time in the encoder, and time in the browser's rendering loop. At zoom 10 the dataset is 58,879 features producing at most a few hundred distinguishable pixels.

The result is slow tiles that look like noise. Removing them makes the map both faster and more legible.

Why generalisation must be per zoom

Each zoom level has its own pixel size, so its own visibility threshold and its own useful tolerance β€” 93.8 m at zoom 10 and 1.5 m at zoom 16 in Leiden, a factor of 64.

Generalising once and reusing the result means either over-detailed low zooms or over-simplified high ones.

The cost is real: a pyramid of eight zoom levels needs eight generalisation passes over the source. That is a batch job, and it is what tile generation actually consists of.

Ways to replace features dropped at low zoom rather than leaving a hole. dropping without replacing leaves a holewhat it drawssuitsaggregatedissolved built-up areasβœ“dense urbansubstitutea point per polygonβœ“scattered featuressamplethe largest n per tileβœ“uneven densityrankby an importance attributewhere one existsAt zoom 10 only 3 of 59,391 buildings are visible β€” the rest need representing somehow spatialworkflow.io
Selection removes almost everything at low zoom. Something has to stand in for it.

Selection order and two-pixel rules

  • Selection before simplification. At low zoom, selection removes nearly everything and simplification removes under half.
  • preserve_topology=True floors the vertex reduction at whatever a valid geometry needs.
  • Two screen pixels is a reasonable visibility threshold.
  • Simplification suits long features β€” coastlines, boundaries, rivers β€” not small footprints.
  • Replace what you drop: aggregate, substitute a point, or sample per tile.
  • Cap per tile, not globally, so dense and sparse areas are both readable.
  • Simplify in a projected CRS; a tolerance in degrees is not a distance.
  • Generalise once per zoom level.

FAQ

How do I generalise data for low zoom levels?

Drop features below about two screen pixels first, then simplify what remains to about one pixel. Selection does far more work than simplification.

Why does simplification barely reduce my data?

Because preserve_topology=True will not collapse a polygon below validity. On Leiden's building footprints, averaging 7.4 vertices, simplification kept 54.5% even at zoom 10's 93.79 m tolerance.

How small is too small to draw?

Under about two by two screen pixels. At zoom 10 and Leiden's 52.2Β° north that is 35,183 mΒ² β€” about three and a half hectares.

What should replace the features I drop?

Aggregate them into built-up areas, substitute points, or keep the largest few per tile. Dropping without replacing leaves a hole.

Should I generalise once or per zoom?

Per zoom. The pixel size varies by a factor of 64 between zoom 10 and 16, so one generalisation is right for at most one level.

Does this apply to lines as well as polygons?

Simplification works much better on lines with many vertices β€” coastlines, rivers, boundaries. Selection still matters for short lines at low zoom.

What tolerance should I simplify to?

About one screen pixel at the target zoom: 156543.03392 * cos(latitude) / 2^zoom. Finer than that is invisible.