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 |
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.
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.
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 |
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 |
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.
Related
- Geodesic vs Planar Distance for Geofence Tests — the parent topic and the two-frame architecture this fits into.
- Haversine vs Equirectangular Approximation Error Budgets — the cheaper frame that suffices for distance but not for buffering.
- Boundary Hysteresis & Debounce Tuning — the consumer whose band width depends entirely on this being right.