Detecting Silent Reprojection Errors in Pull Requests

Flag a branch whose geometry quietly moved β€” from an undeclared reprojection or an unapplied datum shift β€” before the pull request merges β€” part of CRS Conflict Resolution Across Branches.

Concept & Context

The most expensive coordinate reference system bugs are the ones that leave the label intact. A contributor opens a layer in a desktop tool, the tool reprojects on the fly, and an export re-stamps the original authority code onto coordinates that have already moved. The pull request shows a plausible binary or text diff, the declared CRS reads exactly as it did on the base branch, and every downstream check that trusts the label passes. The features, meanwhile, now sit somewhere else entirely.

This check exists precisely because a declared-CRS comparison β€” the front line of the parent guide on CRS conflict resolution across branches β€” cannot see this class of defect. When the label is honest but wrong, or unchanged while the numbers changed, the only reliable signal is the geometry itself. By comparing the total bounds and the dataset centroid of the base and head versions of the same layer, the check detects that coordinates moved even though nothing in the CRS metadata says they should have. It complements normalizing EPSG codes before a spatial merge: normalization fixes disagreements in the declared code, while this check catches disagreements between the code and the actual coordinates.

Core Detection Steps

The check runs as a pull-request stage over every changed layer, comparing the base-branch version against the head-branch version.

  1. Load both versions untouched. Read the layer from the base branch and from the pull-request head. Do not reproject either side; the whole point is to observe what the head actually stored.
  2. Assert the declared CRS matches. If the head’s authority code differs from the base’s, this is either a legitimate declared reprojection or an outright relabel. Record it as a declared change and require it to be intentional.
  3. Compare bounds and centroid in a common frame. Reproject copies of both sides to a shared metric CRS purely for measurement, then compute total bounds and centroid on each and measure the distance between them.
  4. Classify the drift. A drift of kilometres or more indicates an undeclared reprojection; a small, uniform offset of sub-metre to a few metres indicates a datum shift such as NAD83 versus WGS84.
  5. Report and gate. Emit a structured finding and fail the check when the measured drift exceeds tolerance on a change that did not legitimately alter geometry, so the failing gate can be surfaced through automated conflict detection in merge requests.

Working Implementation

"""Pull-request check: flag silent reprojection / datum shift in a changed layer.

Compares the base-branch and head-branch versions of one layer by declared CRS,
total bounds, and centroid drift. Fails when coordinates moved but the change did
not legitimately reproject the data.
"""
import sys
from dataclasses import dataclass
import geopandas as gpd

MEASURE_CRS = "EPSG:3857"     # common metric frame used ONLY for measurement
REPROJECTION_M = 50.0         # centroid drift above this = undeclared reprojection
DATUM_SHIFT_M = 0.10          # drift above this (but below reprojection) = datum shift


@dataclass
class DriftFinding:
    layer: str
    base_crs: str
    head_crs: str
    centroid_drift_m: float
    bounds_drift_m: float
    verdict: str              # "ok" | "declared_reprojection" | "datum_shift" | "silent_reprojection"


def _measure(gdf: gpd.GeoDataFrame):
    """Return (centroid_point, total_bounds) in MEASURE_CRS without mutating input."""
    metric = gdf.to_crs(MEASURE_CRS)
    # union_all().centroid is robust to per-feature centroid averaging bias
    centroid = metric.geometry.union_all().centroid
    return centroid, metric.total_bounds


def check_layer(base_path: str, head_path: str) -> DriftFinding:
    base = gpd.read_file(base_path)
    head = gpd.read_file(head_path)

    if base.crs is None or head.crs is None:
        raise ValueError("Both layers must declare a CRS; run EPSG normalization first")

    base_code = ":".join(base.crs.to_authority() or ("UNKNOWN", "?"))
    head_code = ":".join(head.crs.to_authority() or ("UNKNOWN", "?"))

    # Measure both sides in a shared metric frame.
    base_centroid, base_bounds = _measure(base)
    head_centroid, head_bounds = _measure(head)

    centroid_drift = base_centroid.distance(head_centroid)
    # Bounds drift: max corner displacement between the two envelopes.
    bounds_drift = max(
        abs(base_bounds[0] - head_bounds[0]), abs(base_bounds[1] - head_bounds[1]),
        abs(base_bounds[2] - head_bounds[2]), abs(base_bounds[3] - head_bounds[3]),
    )

    # Classify.
    if head_code != base_code:
        # The label changed: a declared reprojection. Legitimate, but must be intended.
        verdict = "declared_reprojection"
    elif centroid_drift >= REPROJECTION_M:
        # Same label, large jump: coordinates were rewritten under the old code.
        verdict = "silent_reprojection"
    elif centroid_drift >= DATUM_SHIFT_M:
        # Same label, small uniform offset: unapplied or wrong datum transformation.
        verdict = "datum_shift"
    else:
        verdict = "ok"

    return DriftFinding(
        layer=head_path,
        base_crs=base_code,
        head_crs=head_code,
        centroid_drift_m=round(centroid_drift, 4),
        bounds_drift_m=round(bounds_drift, 4),
        verdict=verdict,
    )


def gate(finding: DriftFinding) -> int:
    """Return a process exit code and print a reviewer-friendly line."""
    blocking = {"silent_reprojection", "datum_shift"}
    print(
        f"[crs-drift] {finding.layer}: {finding.verdict} "
        f"(centroid {finding.centroid_drift_m} m, bounds {finding.bounds_drift_m} m, "
        f"{finding.base_crs} -> {finding.head_crs})"
    )
    if finding.verdict in blocking:
        print("::error:: geometry moved without a declared reprojection β€” investigate before merge")
        return 1
    return 0


if __name__ == "__main__":
    base_layer, head_layer = sys.argv[1], sys.argv[2]
    result = check_layer(base_layer, head_layer)
    sys.exit(gate(result))

The measurement CRS is deliberately separate from the data’s declared CRS: reprojecting copies into EPSG:3857 gives a metric yardstick for distance regardless of what units each side declares, while the originals are never mutated. The centroid is computed from the unioned geometry rather than an average of per-feature centroids so that a few outlying features cannot mask a uniform shift of the whole layer.

Validation & Output Verification

Confirm the check behaves correctly against known-good and known-bad fixtures before trusting it in a gate.

A no-op change reports ok. Run the check with identical base and head files; the centroid drift must be effectively zero:

finding = check_layer("fixtures/parcels_base.gpkg", "fixtures/parcels_base.gpkg")
assert finding.verdict == "ok"
assert finding.centroid_drift_m < DATUM_SHIFT_M

A relabel-only defect is caught as silent_reprojection. Build a fixture whose coordinates were reprojected but whose CRS code was reset to the base value; the check must flag it:

finding = check_layer("fixtures/parcels_base.gpkg", "fixtures/parcels_relabelled.gpkg")
assert finding.verdict == "silent_reprojection"
assert finding.centroid_drift_m >= REPROJECTION_M

A datum-only offset is caught as datum_shift. A fixture shifted from NAD83 to WGS84 without a transformation should land in the small-offset band:

finding = check_layer("fixtures/roads_nad83.gpkg", "fixtures/roads_as_wgs84.gpkg")
assert finding.verdict == "datum_shift"
assert DATUM_SHIFT_M <= finding.centroid_drift_m < REPROJECTION_M

Exit code drives the pipeline. In CI, the script’s non-zero exit on a blocking verdict fails the job; confirm the ::error:: annotation appears in the run log so reviewers see the finding inline on the pull request.

Failure Modes

  • Genuine edits mistaken for a shift β€” a commit legitimately moves many features (a large re-survey) and trips the reprojection threshold. Root cause: the check cannot distinguish real bulk edits from a reprojection by drift alone. Fix: scope the check to commits that did not touch geometry, or require a labelled geometry-change marker to suppress it.

  • Centroid masks a partial shift β€” only a subset of features was reprojected, so the whole-layer centroid barely moves. Root cause: a single aggregate centroid averages the moved and unmoved features. Fix: also compare per-feature bounds by stable ID, or tile the layer and measure drift per tile.

  • Measurement CRS distorts drift near the poles β€” EPSG:3857 inflates distances at extreme latitudes, exaggerating the drift figure. Root cause: the web-mercator yardstick is not equal-area. Fix: choose a measurement CRS suited to the data extent, such as the relevant UTM zone, for high-latitude datasets.

  • Undefined CRS on either side β€” one version has no declared CRS, so to_authority() returns nothing and the comparison is meaningless. Root cause: an unnormalized input reached the drift check. Fix: run EPSG normalization first and quarantine undefined layers before this stage.

Back to CRS Conflict Resolution Across Branches