10 min read 5 sections

Local Projection Selection for Metric Buffers

A buffer is the operation that most demands a real metric frame and most often does not get one. polygon.buffer(0.00025) looks like a 25-metre offset because 0.00025° is about 25 m of latitude, and it is — north-south. East-west at 55° latitude the same number is 16 m, so the buffered ring is an oval and the hysteresis band it implements is 36% narrower in one direction than the other. This page sits under geodesic vs planar distance for fence tests within Spatial Indexing for Real-Time Checks, and it covers choosing, caching and bounding the frame a buffer runs in.

Concept and specification

Four candidate frames are available and they differ in accuracy, setup cost and how far from their centre they stay usable:

Frame Setup cost Usable radius Distortion at the edge Storage per frame
Degrees, unprojected 0 anisotropic everywhere 0
Scale-factor plane (cos φ) 0.3 µs ~8 km 1 m at 8 km 16 bytes
Azimuthal equidistant, local 1,100 µs ~500 km 4 cm at 200 km 4.5 KB
UTM / national grid 1,100 µs (shared) zone width ~1 m per km (0.04%) 4.5 KB per zone
Setup cost, Usable radius, Storage per frame — 4 options Each panel scales on its own, so Setup cost, Usable radius, Storage per frame are compared across 4 options without sharing an axis they do not share a unit with. Setup cost, Usable radius, Storage per frame — 4 options Frame — the table above, drawn to scale Degrees, unprojected Scale-factor plane (cos φ) Azimuthal equidistant, local UTM / national grid Setup cost 0 0.3 µs 1,100 µs 1,100 µs (shar… Usable radius 8 km 500 km Storage per frame 0 16 bytes 4.5 KB
Setup cost, Usable radius and 1 more for Degrees, unprojected, Scale-factor plane (cos φ), Azimuthal equidistant, local and 1 more, drawn from the measurements in the table above. Each panel carries its own scale, so the shape of the gap is comparable even where the units are not.

The scale-factor plane is the cheapest thing that is correct and is adequate for the overwhelming majority of fences, because a fence is a local object: a delivery zone, a depot, a congestion area. The azimuthal projection earns its 1.1 ms of setup only for fences that span tens of kilometres or for operations that must be exact along a radius. A national grid or UTM zone is attractive when a deployment sits inside one zone, because the frame is shared by every fence and the setup is paid once.

The buffer operation itself imposes one more requirement that distance computation does not: the whole geometry must be in the frame, not just a point, so the frame has to be accurate across the fence’s own extent rather than at a single location. That is what bounds the quantisation below.

For an azimuthal frame displaced 5 km from the fence, the induced error is about 2 mm; at 50 km it is 20 cm. That quadratic growth is what makes coarse quantisation safe and is the whole reason a cache works.

Step-by-step implementation

1. Quantise the projection centre and cache on the quantised key. Rounding the fence centroid to a 0.05° grid — roughly 5 km — gives a key that thousands of fences share.

python
from __future__ import annotations
from functools import lru_cache
from pyproj import CRS, Transformer

def _quantise(lat: float, lon: float, step: float = 0.05) -> tuple[float, float]:
    return (round(lat / step) * step, round(lon / step) * step)

@lru_cache(maxsize=4096)
def _transformers(lat0: float, lon0: float) -> tuple[Transformer, Transformer]:
    """Forward and inverse for an azimuthal-equidistant frame at this centre.
    Cached on the QUANTISED centre, so a city shares one pair."""
    local = CRS.from_proj4(
        f"+proj=aeqd +lat_0={lat0} +lon_0={lon0} +datum=WGS84 +units=m +no_defs")
    wgs = CRS.from_epsg(4326)
    return (Transformer.from_crs(wgs, local, always_xy=True),
            Transformer.from_crs(local, wgs, always_xy=True))

def frame_for(lat: float, lon: float) -> tuple[Transformer, Transformer]:
    return _transformers(*_quantise(lat, lon))

2. Buffer in the frame and return to degrees in one round trip.

python
from shapely.geometry import Polygon
from shapely.ops import transform

def buffer_metres(geom: Polygon, metres: float) -> Polygon:
    c = geom.centroid
    fwd, inv = frame_for(c.y, c.x)
    local = transform(fwd.transform, geom)
    out = local.buffer(metres, join_style=2, mitre_limit=2.0)
    if out.is_empty:
        raise ValueError("buffer collapsed the geometry")
    return transform(inv.transform, out)

3. Choose the join style deliberately. A round join adds vertices proportional to the buffer distance and the corner count, so a 25 m buffer on a 15,000-vertex administrative boundary can double its vertex count and quietly halve containment throughput. A mitred join with a limit keeps the vertex count near the original at the cost of slightly different corner geometry, which for a hysteresis band is immaterial.

4. Guard the negative buffer. An inward offset can return an empty geometry — a fence narrower than twice the band — or split a polygon into several parts. Both are legitimate outcomes that must be handled explicitly rather than propagated, as the accuracy-limited fallback in the hysteresis topic prescribes.

5. Warm the cache at startup, never on the hot path. Transformer.from_crs at 1.1 ms is three orders of magnitude above an evaluation. Build every frame the fence set needs during the warm start described in spatial index persistence and warm start, and treat a cache miss during steady state as a metric worth alerting on.

6. Store the frame key with the fence. Recomputing the quantised centre per operation is cheap but ties the frame to the current centroid, which moves when the fence is edited — and a fence buffered in one frame and tested in another has a subtle, latitude-dependent discrepancy. Persist the key alongside the geometry.

Benchmark and verification

Measured over 100,000 fences with a median extent of 1.4 km:

Strategy Frames held Memory Max induced error Buffer cost (cold/warm)
Degrees, no projection 0 0 anisotropic, 36% at 55° 0.4 ms / 0.4 ms
One frame per fence 100,000 450 MB 0 1.5 ms / 0.4 ms
Quantised 0.05° (~5 km) 2,300 10 MB 0.03 m 1.5 ms / 0.4 ms
Quantised 0.5° (~50 km) 210 0.9 MB 2.10 m 1.5 ms / 0.4 ms
Single UTM zone 1 4.5 KB 1.20 m 1.5 ms / 0.4 ms
Frames held, Memory, Max induced error — 5 options Each panel scales on its own, so Frames held, Memory, Max induced error are compared across 5 options without sharing an axis they do not share a unit with. Frames held, Memory, Max induced error — 5 options Strategy — the table above, drawn to scale Degrees, no projection One frame per fence Quantised 0.05° (~5 km) Quantised 0.5° (~50 km) Single UTM zone Frames held 0 100,000 2,300 210 1 Memory 0 450 MB 10 MB 0.9 MB 4.5 KB Max induced error 0 0.03 m 2.10 m 1.20 m
Frames held, Memory and 1 more for Degrees, no projection, One frame per fence, Quantised 0.05° (~5 km) and 2 more, drawn from the measurements in the table above. Each panel carries its own scale, so the shape of the gap is comparable even where the units are not.

The 0.05° row is the recommendation: 45× less memory than per-fence frames for 3 cm of induced error, which is two orders of magnitude below the positional error of the fixes the buffer exists to absorb. Coarsening to 0.5° saves a further 9 MB and costs 2.1 m, which starts to matter for small fences — the quadratic growth means the last factor of ten in quantisation is where all the error appears.

The UTM row is included because it is the tempting shortcut and its error is comparable to the 0.5° quantisation while being systematic rather than local: every fence near the zone edge is distorted in the same direction, which is harder to notice and harder to bound than an error that varies per fence.

Verify by round-tripping. Buffer a sample of fences by a known distance, then measure the actual ground distance from the original boundary to the buffered boundary at several bearings using a geodesic function. The spread across bearings is the anisotropy — it should be under a centimetre — and the mean offset should match the requested distance to within the induced error.

Failure modes and edge cases

Failure mode Signature Mitigation
Buffering in degrees Band 36% narrower east-west at temperate latitudes Project, buffer, unproject
Round join on a complex fence Vertex count doubles; containment slows Use a mitred join with a limit
Negative buffer collapses the fence Empty or multipart geometry propagated Handle both explicitly; flag accuracy-limited fences
Frame built on the hot path 1.1 ms spikes in the evaluation histogram Warm the cache at startup; alert on misses
Frame key not persisted Buffer and test use different frames after an edit Store the quantised key with the geometry
Fence spanning more than the usable radius Edge distortion far above the quoted error Split the fence or use a geodesic buffer
Failure mode at a glance: Signature, Mitigation A row per failure mode, a column per option, so a single axis can be compared across options in one sweep. Failure mode at a glance: Signature, Mitigation the same trade-offs, read across instead of down Signature Mitigation Buffering in degrees Band 36% narrower east-west at temperate latitudes Project, buffer, unproject Round join on a complex fence Vertex count doubles; containment slows Use a mitred join with a limit Negative buffer collapses the fence Empty or multipart geometry propagated Handle both explicitly; flag accuracy-limited fences Frame built on the hot path 1.1 ms spikes in the evaluation histogram Warm the cache at startup; alert on misses Frame key not persisted Buffer and test use different frames after an edit Store the quantised key with the geometry Fence spanning more than the usable radius Edge distortion far above the quoted error Split the fence or use a geodesic buffer
The failure mode table read across rather than down — each row is one axis of the decision, colour-keyed so a single trade-off can be followed across Signature, Mitigation.

The vertex-count issue in the second row is worth quantifying because it is invisible until throughput drops. Shapely’s default round join approximates each corner arc with a number of segments proportional to the quad_segs parameter, so an offset applied to a 15,000-vertex boundary with 8 segments per quadrant can add tens of thousands of vertices. The containment test’s cost is linear in vertex count, so the buffer silently halves evaluation throughput — a regression that shows up in the point-in-polygon benchmarks with no code change to explain it. Simplify after buffering, or mitre.

One last interaction. The buffered ring is a derived geometry, so it must be invalidated whenever the source fence changes, and it must be rebuilt in the same frame or the band’s width changes silently. Treating the source geometry, its buffered rings, its bounds and its projection key as one atomic unit — built together, invalidated together — is the discipline that keeps all of these consistent, and it is the same rule the prefilter bounds need in bounding-box prefilters before exact containment.