Generalisation for Zoom Levels Explained

Problem statement

The instinct for making a map work at low zoom is to simplify the geometry. Measured on 59,391 real building footprints, that instinct is almost entirely wrong.

Simplifying to a one-pixel tolerance at each zoom:

zoom   tolerance    vertices kept
  10      90.98 m       57.9%
  12      22.74 m       58.4%
  14       5.69 m       65.2%
  16       1.42 m       83.5%

Even at 91 metres of tolerance, simplification keeps 57.9% of the vertices. It cannot do better, because a building footprint averages under seven vertices and a valid polygon needs at least four.

Dropping features by size instead:

zoom   drop below     features kept
  10     33,106 mΒ²      3 of 59,391   (0.0%)
  12      2,069 mΒ²    834             (1.4%)
  14        129 mΒ²  12,785            (21.5%)
  16          8 mΒ²  59,260            (99.8%)

At zoom 10, simplification keeps 57.9% of the data and selection keeps 0.005%. That is a factor of ten thousand.

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

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

Simplification keeping 57.9 percent of vertices at zoom 10 against selection keeping 3 of 59,391 features.
At low zoom, simplification is nearly useless and selection is decisive.

Step-by-step solution

1. Work out what one pixel is worth

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

At 53.5Β° north: 91 m at zoom 10, 22.7 m at zoom 12, 5.7 m at zoom 14, 1.4 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 33,106 mΒ² β€” three hectares. Three buildings in the dataset qualify.

That is the correct output. A zoom-10 view of a city should show its footprint, not 59,391 individually invisible polygons, and no styling makes 59,391 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 410,285 vertices across 59,391 buildings β€” under seven each β€” most footprints are already near that minimum. Hence the floor at 57.9%, independent of tolerance.

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.

Vertex count falling to a floor at 57.9 percent as tolerance increases, because preserve_topology refuses to collapse polygons.
`preserve_topology=True` refuses to destroy a polygon, so simplification bottoms out well above zero.

Code examples

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.nlargest(keep_largest, subset.geometry.area.name
                                 if hasattr(subset.geometry.area, "name")
                                 else None) if False else \
            subset.iloc[subset.geometry.area.argsort()[::-1][:keep_largest]]

    before = int(sum(len(g.exterior.coords) for g in subset.geometry
                     if g is not None and g.geom_type == "Polygon"))
    subset["geometry"] = subset.geometry.simplify(simplify_px * m_per_px,
                                                  preserve_topology=True)
    after = int(sum(len(g.exterior.coords) for g in subset.geometry
                    if g is not None and g.geom_type == "Polygon"))

    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)

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)

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]

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.

Explanation

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 6.9 vertices per building, so most are two or three vertices above the floor.

At 91 m tolerance the algorithm removes everything it is allowed to and stops at 57.9%. Increasing the tolerance further changes nothing, 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 33,106 mΒ² and three buildings exceed it. That is a reduction of five orders of magnitude, against simplification's factor of 1.7.

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 59,391 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 β€” 91 m at zoom 10 and 1.4 m at zoom 16, 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.

Four replacements for dropped features: aggregate, substitute a point, sample per tile, or rank by importance.
Selection removes almost everything at low zoom. Something has to stand in for it.

Edge cases or notes

  • Selection before simplification. At low zoom, selection is worth ten thousand times more.
  • 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 building footprints averaging seven vertices, simplification kept 57.9% even at 91 m tolerance.

How small is too small to draw?

Under about two by two screen pixels. At zoom 10 and 53.5Β° north that is 33,106 mΒ² β€” three 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.