Detecting Changed Features with Geometry Hashing

A feature fingerprint is only useful if two exports of an unchanged feature produce the same digest — and by default they do not. Ring orientation flips, starting vertices rotate, projection round-trips perturb the sixteenth decimal, and attribute column order varies between runs. Any of those changes the bytes without changing the feature, and a naive hash reports a 100% change rate. This page is the normalisation procedure, extending incremental conversion and change data capture.

Quick Reference

Harmless variation What it changes Normalisation Primary Use Case
Re-projection round-trip Low-order coordinate digits Snap to the storage grid Any source that reprojects
Ring orientation flip Vertex sequence direction Canonical orientation Exports from mixed toolchains
Different starting vertex Where a closed ring begins Rotate to a canonical start Sources that re-tessellate
Interior ring order Order of holes in a polygon Sort rings deterministically Multi-part polygons
Attribute column order Serialisation order Sort field names Any schema-flexible source
Encoding differences Byte representation of text Pin UTF-8 explicitly Legacy sources

What Has to Be Normalised

Two geometries are equal if they describe the same set of points. Two geometries are identical in bytes only if they also agree on a long list of representational choices that carry no meaning. The gap between those two notions is the entire problem.

Coordinate precision is the biggest source. Any pipeline that reprojects, buffers, or passes geometry through a library that works in a different internal representation will produce coordinates differing in the last few bits. Snapping to the grid the dataset actually stores — the precision reduction step that already exists in a well-built pipeline — removes it entirely, and does so consistently because both runs snap to the same absolute grid.

Ring orientation is next. The same polygon can be stored with its exterior ring clockwise or counter-clockwise, and different tools have different conventions. A canonical orientation makes the choice deterministic.

Starting vertex is the subtle one. A closed ring A→B→C→D→A is the same ring as C→D→A→B→C, and some sources rotate it when re-tessellating. Rotating every ring to start at its lexicographically smallest vertex removes the ambiguity.

Attribute serialisation is the easy one that people still get wrong: sort the field names, render nulls as an explicit sentinel rather than as an omitted key, format numbers with a fixed representation, and encode as UTF-8.

Four byte-different encodings of one unchanged feature Four panels each show the same square polygon with a different representational choice: counter-clockwise winding starting at the lower-left vertex, clockwise winding from the same vertex, counter-clockwise winding starting at the upper-right vertex, and counter-clockwise winding with coordinates differing in the sixteenth decimal place after a projection round-trip. All four describe the identical shape and all four hash differently. A fifth panel shows the canonical form they all reduce to after snapping, orienting, and rotating to the smallest vertex. Same feature, four encodings, four different digests CCW from lower-left digest a1f4… CW from lower-left digest 7c02… CCW from upper-right digest 3b9e… CCW, 16th decimal differs digest e550… Canonical form: snap to the grid → orient CCW → rotate to the smallest vertex → one digest All four reduce to a1f4… and the run reports zero changes, which is the truth. Without normalisation this dataset reports a 100% change rate every other day.

Implementation

python
# Requires: shapely>=2.0, geopandas>=1.0, numpy>=1.26  (Python 3.10+)
from __future__ import annotations

import hashlib
import json
import math
from dataclasses import dataclass

import geopandas as gpd
import numpy as np
from shapely import get_coordinates, normalize, set_precision, to_wkb
from shapely.geometry.base import BaseGeometry


@dataclass(frozen=True)
class Fingerprint:
    geometry: str
    attributes: str

    @property
    def combined(self) -> str:
        return hashlib.blake2b(
            f"{self.geometry}:{self.attributes}".encode("ascii"), digest_size=16
        ).hexdigest()


def canonical_geometry(geom: BaseGeometry | None, grid: float) -> bytes:
    """Reduce a geometry to one representation per shape.

    normalize() fixes ring orientation and ring order; set_precision() removes
    projection round-trip noise; the rotation below removes the remaining
    freedom in where a closed ring starts.
    """
    if geom is None or geom.is_empty:
        return b""
    snapped = set_precision(geom, grid_size=grid)
    if snapped is None or snapped.is_empty:
        return b""
    oriented = normalize(snapped)

    coords = get_coordinates(oriented)
    if coords.size:
        # Rotating to the lexicographically smallest vertex makes the starting
        # point deterministic; sources that re-tessellate otherwise rotate it.
        start = int(np.lexsort((coords[:, 1], coords[:, 0]))[0])
        if start:
            rotated = np.roll(coords[:-1], -start, axis=0)
            coords = np.vstack([rotated, rotated[:1]])
    return to_wkb(oriented) + coords.tobytes()


def canonical_attributes(values: dict[str, object]) -> str:
    """Deterministic text for an attribute tuple, nulls made explicit."""
    rendered: dict[str, object] = {}
    for name in sorted(values):
        value = values[name]
        if value is None or (isinstance(value, float) and math.isnan(value)):
            rendered[name] = None                     # one spelling of "absent"
        elif isinstance(value, float):
            rendered[name] = f"{value:.12g}"          # stable float formatting
        else:
            rendered[name] = str(value)
    return json.dumps(rendered, sort_keys=True, ensure_ascii=False, separators=(",", ":"))


def fingerprint_frame(
    frame: gpd.GeoDataFrame,
    key_column: str,
    *,
    grid: float,
    attributes: tuple[str, ...],
) -> dict[str, Fingerprint]:
    """Two digests per feature: geometry and attributes, kept separate."""
    if key_column not in frame.columns:
        raise KeyError(f"key column {key_column!r} missing")
    if frame[key_column].duplicated().any():
        raise ValueError("duplicate feature keys — the key is not stable")

    out: dict[str, Fingerprint] = {}
    for row in frame.itertuples(index=False):
        record = row._asdict()
        geom_bytes = canonical_geometry(record[frame.geometry.name], grid)
        attr_text = canonical_attributes({k: record.get(k) for k in attributes})
        out[str(record[key_column])] = Fingerprint(
            geometry=hashlib.blake2b(geom_bytes, digest_size=16).hexdigest(),
            attributes=hashlib.blake2b(attr_text.encode("utf-8"), digest_size=16).hexdigest(),
        )
    return out

Validation

The single most valuable check is an alarm on the change rate. A detector that has silently degraded reports an implausible number, and that number is easy to test against.

python
# Requires: the module above — refuse to proceed on an implausible change rate
PLAUSIBLE_MAX = 0.20        # 20% of features changing in one run is suspicious

current = fingerprint_frame(today, "uprn", grid=0.01, attributes=ATTRS)
previous = load_manifest("s3://bucket/parcels/_manifests/v000041.json")

changed = {k for k, fp in current.items()
           if k in previous and previous[k]["combined"] != fp.combined}
rate = len(changed) / max(len(current), 1)

if rate > PLAUSIBLE_MAX:
    raise RuntimeError(
        f"{rate:.1%} of features changed — normalisation has probably regressed. "
        f"Compare a sample of canonical forms across the two runs before rerunning."
    )
print(f"{len(changed):,} changed ({rate:.2%}) — within the plausible band")

Expected ranges: a mature address or parcel estate changes 0.1–1% of features per day; a live utility network 1–3%; a survey layer under active field capture up to 5%. Anything above 20% is either a genuine bulk update, which someone will know about, or a normalisation regression.

Change rate as a regression detector Daily change rate plotted over four weeks. For most days the rate sits between a tenth and one per cent, which is the estate's true edit rate. On one day it jumps to nearly one hundred per cent and stays there for two days before returning, which corresponds to an upstream export that flipped ring orientation. A horizontal alarm threshold at twenty per cent is drawn, showing that a simple rate check would have stopped both of those runs before they rewrote the entire dataset. Daily change rate — the cheapest regression detector you have alarm threshold 20% upstream flipped ring orientation days → changed → Without the alarm, those two days rewrite the entire estate and nobody notices until the bill arrives. Which fields belong in the digest A feature schema split into three groups. Geometry and the substantive attributes are included, because a change in any of them is a real change. Export timestamps, row sequence numbers and processing metadata are excluded, because they differ on every export and would mark every feature changed. A third group — derived fields such as computed area — is excluded because it is a function of geometry that is already covered. Three groups, two decisions Included geometry use_class owner address Excluded — volatile exported_at row_sequence processing_job_id Excluded — derived computed_area already covered by the geometry digest Record the exclusion list in the manifest, so a later reader knows what a matching digest guarantees.

Edge Cases and Caveats

Hashing before snapping. Applying the digest to raw source coordinates and snapping afterwards defeats the whole exercise, because the noise the snap would have removed is already baked into the hash. Order matters: snap, canonicalise, then hash.

Narrow hash outputs. A 32-bit checksum over ten million features has a better-than-even chance of at least one collision, and a collision means a genuinely changed feature is reported unchanged and never rewritten. Use at least 128 bits; the storage cost in the manifest is negligible next to a silently stale feature.

Attributes that legitimately change every run. A last_exported_at timestamp or a row sequence number will differ on every export and mark every feature changed. Exclude such fields from the fingerprint explicitly and list the exclusions in the manifest, so the choice is visible when someone later wonders why a real change was missed.

Frequently Asked Questions

Why does my change detector report that every feature changed?

Almost always because the fingerprint is not normalised. A source that exports rings clockwise this week and counter-clockwise next week, or starts each ring at a different vertex, or round-trips coordinates through a projection library producing sixteenth-decimal differences, produces different bytes for identical features. Normalise geometry to a canonical form and attributes to a fixed field order before hashing, and the rate drops to the real change rate.

Should the fingerprint cover attributes as well as geometry?

Yes, but consider keeping them as two digests rather than one. A combined digest tells you a feature changed; separate geometry and attribute digests tell you which, and that distinction drives real decisions — an attribute-only change may not require re-tiling or re-indexing, while a geometry change does. The extra sixteen bytes per feature in the manifest is a small price for that.

Which hash function should I use?

Any fast non-cryptographic or modern cryptographic digest with a wide output — BLAKE2 at 128 bits is a good default. The property you need is collision resistance across tens of millions of features, which 128 bits provides with an enormous margin. Avoid 32-bit checksums such as CRC32: at ten million features the birthday bound makes accidental collisions likely, and a collision means a changed feature is silently reported as unchanged.


← Back to Incremental Conversion and Change Data Capture