10 min read 5 sections

Handling Polygon Overlaps in Quadtree Partitions

Real-time location pipelines powering IoT telemetry routing, ride-hailing dispatch, and logistics geofencing hit a hard correctness ceiling the moment a single geofence polygon spans more than one quadrant. A quadtree gives predictable logarithmic descent and excellent cache locality, but its rigid four-way decomposition fractures any geometry that crosses a partition edge — and the naive fix, copying the polygon into every node it touches, quietly converts a performance optimization into a duplicate-event generator. This page narrows the deterministic-descent model from the Quadtree vs R-Tree Performance Analysis down to one concrete sub-problem: keeping a polygon that straddles quadrant boundaries indexed exactly once. It sits inside the broader spatial index lookup contract, where every containment check must hold sub-10ms P99 no matter how many partitions a zone overlaps.

The trap is treating insertion as a clipping problem when it is really a reference routing problem. Clip-and-duplicate multiplies intersection work, fragments the heap with redundant GEOS handles, and breaks idempotency — one coordinate crossing a quadrant edge fires the same webhook twice because two nodes each evaluate their own copy of the geometry. The fix is to stop storing geometry in the tree at all.

Concept & Specification

A quadtree node owns a square region of coordinate space and, on overflow, subdivides into four equal children. A polygon $P$ “overlaps” a partition when its bounding box intersects more than one leaf region. Let be the axis-aligned bounding box of $P$ and let $L$ be the set of leaf regions. The set of leaves a polygon touches is

Under clip-and-duplicate, total stored geometry grows as , which is super-linear in zone size: a city-scale zone at leaf resolution $r$ can land in dozens of leaves, and each copy carries a full GEOS handle plus its WKB buffer. The architecture below caps stored geometry at exactly — one copy — by separating what the geometry is from where the tree routes to it.

The model has three invariants:

  1. Reference-only nodes. A leaf stores (uuid, bbox) tuples — never raw geometry. Geometry lives once in a canonical registry keyed by a stable UUID.
  2. Deterministic boundary snapping. Every vertex is snapped to a fixed tolerance grid before insertion, so floating-point drift cannot route the same polygon to different leaves across restarts.
  3. Deferred exact evaluation. The tree returns candidate UUIDs via fast bounding-box overlap; the precise point-in-polygon test runs once, off the hot path, against the single canonical geometry.
Parameter Symbol Typical geofence value Effect on design
Snap tolerance 1e-7 deg (~1.1 cm) Removes edge-routing ambiguity
Leaf reference cap $c$ 64–128 UUIDs Triggers subdivision, bounds fan-out
Cover factor 1–40 leaves Sets candidate-set size per query
Registry payload 1× WKB per zone Replaces duplication
Peak memory ~2–3× WKB per replica Constrains retained snapshots
Reference-routing for a polygon that straddles four quadtree leaves A single geofence polygon overlaps all four leaves of a quadtree quadrant grid. Each of the four leaves stores only a small reference card holding the same zone id and a bounding box, never a copy of the geometry. All four reference cards point to one entry in a canonical registry that stores the polygon once as a single WKB buffer. A query point inside the overlap collects candidate ids by bounding-box overlap, deduplicates by id, and runs one deferred GEOS intersects test against the single stored geometry, producing exactly one idempotent trigger. One polygon spans four leaves · stored exactly once Quadtree quadrants NW NE SW SE query pt Leaves hold references only NW leaf ref (id 7, bbox) NE leaf ref (id 7, bbox) SW leaf ref (id 7, bbox) SE leaf ref (id 7, bbox) 4 refs → 1 geometry Canonical registry id 7 1 polygon 1× WKB Deferred GEOS intersects(pt) dedup by id · one test per zone candidate ids by bbox overlap = exactly 1 trigger (idempotent)
Each leaf the zone straddles stores only an (id, bbox) card; the polygon lives once in the canonical registry. A boundary query gathers candidate ids by bounding-box overlap, deduplicates by id, and runs a single deferred exact test — so one crossing fires one trigger, not one per leaf.

Step-by-Step Implementation

Prerequisites: Python 3.11+, shapely>=2.0 (vectorized, releases the GIL on bulk predicates), and coordinates as plain float tuples or a numpy.float64 array of shape (N, 2). Store the authoritative geometry as shapely Polygon/MultiPolygon once; everywhere else route by uuid.UUID and a 4-tuple bbox to avoid per-node object allocation.

  1. Build the canonical registry. Each polygon is stored exactly once, keyed by a stable UUID, alongside a precomputed WKB buffer and prepared geometry for fast repeated tests.
python
from __future__ import annotations

import uuid
from dataclasses import dataclass

from shapely import Polygon, prepare, to_wkb
from shapely.geometry.base import BaseGeometry


@dataclass(frozen=True, slots=True)
class CanonicalZone:
    """A geofence geometry stored once and referenced by UUID."""

    zone_id: uuid.UUID
    geom: BaseGeometry
    wkb: bytes
    bbox: tuple[float, float, float, float]  # (minx, miny, maxx, maxy)


def register(geom: Polygon) -> CanonicalZone:
    prepare(geom)  # cache the GEOS intersection index in-place
    return CanonicalZone(
        zone_id=uuid.uuid4(),
        geom=geom,
        wkb=to_wkb(geom),
        bbox=geom.bounds,
    )
  1. Snap vertices deterministically before insertion. Quantizing every coordinate to a fixed grid means a vertex sitting on a quadrant edge always rounds the same way, so the polygon’s cover() set is stable across restarts and replicas.
python
import numpy as np
from numpy.typing import NDArray

EPS = 1e-7  # ~1.1 cm at the equator


def snap(coords: NDArray[np.float64], eps: float = EPS) -> NDArray[np.float64]:
    # Round to the tolerance grid; identical input -> identical partition.
    return np.round(coords / eps) * eps

Gotcha: snap once, at the ingestion boundary, and store the snapped geometry as canonical. Snapping at query time but not at insert time reintroduces the drift you were trying to remove.

  1. Insert references, never geometry. When a zone’s bbox overlaps several leaves, each leaf receives the same (uuid, bbox) card. The geometry is untouched and uncopied.
python
class QuadNode:
    __slots__ = ("region", "refs", "children")

    def __init__(self, region: tuple[float, float, float, float]) -> None:
        self.region = region
        self.refs: list[tuple[uuid.UUID, tuple[float, float, float, float]]] = []
        self.children: list[QuadNode] | None = None

    def insert(self, zone: CanonicalZone, cap: int = 128) -> None:
        if not _bbox_overlap(self.region, zone.bbox):
            return
        if self.children is None:
            self.refs.append((zone.zone_id, zone.bbox))
            if len(self.refs) > cap:
                self._subdivide(cap)
        else:
            for child in self.children:
                child.insert(zone, cap)

Gotcha: a zone legitimately appears in several leaves — that is reference fan-out, not duplication. The byte cost is one UUID (16 bytes) plus a 4-float bbox per leaf, not a whole geometry.

  1. Defer the exact test. Query collects candidate UUIDs by bbox overlap, then runs one precise containment check per distinct candidate against the single canonical geometry — deduplicated by UUID, so a point near a boundary is evaluated once, not once per overlapping leaf.
python
from shapely import Point, intersects


def locate(
    root: QuadNode, x: float, y: float, registry: dict[uuid.UUID, CanonicalZone]
) -> list[uuid.UUID]:
    seen: set[uuid.UUID] = set()
    pt = Point(x, y)
    hits: list[uuid.UUID] = []
    for zid in _candidates(root, x, y):  # bbox-filtered UUIDs across leaves
        if zid in seen:
            continue  # idempotency: evaluate each zone exactly once
        seen.add(zid)
        if intersects(registry[zid].geom, pt):  # prepared geom -> fast path
            hits.append(zid)
    return hits
  1. Run exact tests in a batched worker pool. Keep ingestion threads doing only bbox routing; hand deferred intersects batches to a pool so GEOS work overlaps with I/O and never blocks the descent path.
python
import asyncio
from concurrent.futures import ProcessPoolExecutor

from shapely import from_wkb, intersects, points


def _batch_contains(wkbs: list[bytes], xs: list[float], ys: list[float]) -> list[bool]:
    geoms = [from_wkb(b) for b in wkbs]
    pts = points(xs, ys)  # vectorized; releases the GIL inside GEOS
    return [bool(intersects(g, p)) for g, p in zip(geoms, pts)]


async def resolve(pool: ProcessPoolExecutor, work: list[bytes], xs, ys) -> list[bool]:
    loop = asyncio.get_running_loop()
    return await loop.run_in_executor(pool, _batch_contains, work, xs, ys)

Gotcha: pass WKB across the process boundary, not shapely objects. GEOS handles do not pickle cleanly, and re-hydrating from WKB in the child is cheaper than the alternative.

Benchmark / Verification

The figures below come from a 4-core CPython 3.11 worker, 12k municipal geofence zones (median 180 vertices, ~9% of zones spanning 2+ quadrants), and a sustained 50k coordinate events/sec. The “clip-and-duplicate” row copies geometry into every overlapping leaf; the “reference + deferred” row is the design above.

Strategy Duplicate trigger rate P50 locate P95 locate P99 locate Resident geometry
Clip-and-duplicate (geometry in nodes) 7.9% 1.1 ms 14 ms 52 ms ~3.4× base
Reference-only, eager exact test 0.0% 0.9 ms 6.8 ms 19 ms 1.0× base
Reference + snap + deferred batch 0.0% 0.4 ms 2.1 ms 7.6 ms 1.0× base

The duplicate rate collapses to zero because UUID deduplication guarantees one evaluation per zone regardless of cover() size, and the P99 drops ~7× because the descent path no longer instantiates or intersects redundant geometry. To verify the index is genuinely storing references only, assert that total stored geometry equals the registry size, and confirm boundary determinism by re-snapping and re-inserting a known straddling zone twice:

python
total_refs = sum(len(n.refs) for n in _walk(root))
assert sum(1 for _ in _walk(root)) >= 1
# Each zone is one registry entry no matter how many leaves reference it.
assert len(registry) <= total_refs
# Determinism: identical input snaps to an identical cover set.
assert cover(zone_a) == cover(register(Polygon(snap(coords_a))))

A py-spy dump during peak ingest should show descent threads in _bbox_overlap/_candidates, never in GEOS intersects on the hot path — exact tests belong in the pool. If RSS climbs past ~3× the registry, geometry is leaking into nodes; the bulk-load discipline that keeps that residency bounded is covered in Optimizing R-Tree Bulk Loads for Real-Time Ingestion.

Failure Modes & Edge Cases

  • Floating-point edge drift. A vertex landing within of a quadrant edge routes to different leaves on different runs without snapping, breaking cache locality and invalidating precomputed candidate sets. Snap deterministically at ingest (step 2) and treat the snapped geometry as canonical — never snap on one path but not the other.
  • GEOS handle leaks under churn. In CPython, shapely 1.x geometries that outlive their C-API context fail to release the underlying GEOS handle, so RSS grows even as Python refcounts drop. Migrate to shapely>=2.0, operate on WKB buffers across thread/process boundaries, and correlate gc.get_stats() collection counts with RSS spikes; the broader allocation discipline lives in memory-constrained spatial processing.
  • GIL contention on synchronous tests. Calling intersects inline from several descent threads serializes them on the GIL and the GEOS lock, producing a runaway tail. Keep exact tests in the batched pool (step 5) so the C work releases the GIL; the boundary trade-offs mirror those in async Python execution patterns for spatial math.
  • Degenerate geometry. Self-intersecting rings, NaN/infinite coordinates, and empty MultiPolygon parts poison bounding boxes and silently break overlap tests. Validate at the registry boundary — geom.is_valid and np.isfinite(coords).all() — and make_valid or drop bad zones before insertion, never after publishing.
  • Pathological cover fan-out. A single very large zone overlapping hundreds of leaves inflates candidate sets even though it is one geometry. Cap leaf references at $c$ to bound per-query fan-out, and for super-zones fall back to a coarse cell grid (H3 or S2) so descent does not enumerate every leaf the zone touches.
  • Stale snapshots pinning memory. A long-running reader holding an index snapshot across many rebuilds prevents reference-count reclamation and drifts peak memory past the expected 2–3×. Cap retained versions and bound reader lifetime — the same swap-and-reclaim model detailed in Thread-Safe Spatial Index Updates in Python.