Fixing a CRS Mismatch Between Images and the GCP File

The run completes. The residual report shows control errors of several metres where centimetres were expected, or — worse — a handful of centimetres that look almost acceptable. The imagery is fine, the control survey is fine, and they are expressed in two different coordinate reference systems that the reconstruction has quietly done its best with.

This page detects that mismatch before submission, using a check that does not depend on either file’s declared CRS being correct.

Why the solver does not simply refuse

Two inputs carry position: EXIF camera coordinates, in whatever the receiver wrote, and the control file, whose first line declares its own projection. Nothing enforces agreement between them. The solver treats the camera positions as a weak prior and the control as a strong constraint, so when they disagree it does what it is designed to do — it trusts the control and warps the block to fit.

That produces three distinct outcomes depending on how the two systems differ.

Different units entirely — degrees against metres — puts the camera prior thousands of kilometres from the control. The reconstruction usually fails or produces something obviously absurd, which is the benign case.

Same datum, different projection — say UTM zone 32 against zone 33 — puts them hundreds of kilometres apart, again obvious.

Same projection, different datum realisation — the dangerous case — puts them a metre or two apart. The solve succeeds, the residuals are small, and the delivered product is biased by the datum difference with nothing in the report to say so.

Three magnitudes of mismatch and how each ends Three cases arranged by the size of the disagreement between camera positions and control coordinates. A unit mismatch of thousands of kilometres causes the reconstruction to fail outright and is therefore harmless. A projection mismatch of hundreds of kilometres also fails visibly. A datum-realisation mismatch of one to two metres allows the solve to succeed with small residuals and delivers a biased product, which is marked as the dangerous case because nothing in the output reports it. units differ degrees vs metres ~10 000 km apart reconstruction fails harmless — you find out projection differs UTM 32 vs UTM 33 ~400 km apart fails or looks absurd obvious once plotted datum realisation differs same grid, different epoch 1–2 m apart solve succeeds, residuals small delivered biased, unreported The bigger the mistake, the safer it is. Only the small one reaches a client. Which is why the check has to be quantitative rather than a glance at whether the run succeeded.

Figure 1 — Mismatch severity is inverted: the errors that fail loudly cost nothing, and the one that survives to delivery is the smallest.

Minimal reproducible solution

The check compares the two point sets in a common frame and looks at the distance between the control and the nearest camera positions. Control points are on the ground inside the flight footprint, so on a correct pair that distance is bounded by the flight altitude and the block extent — a few hundred metres at most.

import numpy as np
from pyproj import CRS, Transformer


def control_camera_separation(gcp_xyz: np.ndarray, gcp_crs: str,
                              cam_lonlat: np.ndarray) -> np.ndarray:
    """Horizontal distance from each control point to its nearest camera.

    gcp_xyz: (N, 3) in gcp_crs. cam_lonlat: (M, 2) WGS84 degrees from EXIF.
    Both are moved into the control CRS so the comparison is like for like.
    """
    to_gcp = Transformer.from_crs(CRS.from_user_input("EPSG:4326"),
                                  CRS.from_user_input(gcp_crs), always_xy=True)
    cam_e, cam_n = to_gcp.transform(cam_lonlat[:, 0], cam_lonlat[:, 1])
    cams = np.column_stack([cam_e, cam_n])
    if not np.all(np.isfinite(cams)):
        raise ValueError("camera positions did not transform — check the CRS pair")

    d = np.linalg.norm(gcp_xyz[:, None, :2] - cams[None, :, :], axis=2)
    return d.min(axis=1)


def assert_crs_consistent(gcp_xyz, gcp_crs, cam_lonlat,
                          max_separation_m: float = 500.0) -> None:
    sep = control_camera_separation(gcp_xyz, gcp_crs, cam_lonlat)
    worst = float(sep.max())
    if worst > max_separation_m:
        raise ValueError(
            f"control point {int(np.argmax(sep))} is {worst:,.0f} m from the "
            f"nearest camera — the GCP file's declared CRS ({gcp_crs}) does not "
            "match where the imagery was flown")

Using the nearest camera rather than the block centroid is deliberate: a survey that legitimately spans several kilometres has a large centroid distance and small nearest-camera distances, so the nearest-neighbour form works on corridors as well as compact blocks.

This catches the first two severity classes decisively. The third — a metre-scale datum difference — is below any threshold that would not also reject legitimate control at the block edge, and it needs a different test.

Edge-case matrix

Variant Separation check Correct handling
Degrees written into a metric field ~10⁷ m Rejected
Wrong UTM zone ~10⁵ m Rejected
Wrong hemisphere (missing false northing) ~10⁷ m Rejected
Easting and northing swapped ~10⁶ m Rejected
Same grid, different datum realisation ~1 m Passes — needs the epoch check
Control legitimately outside the block up to a few km Raise the threshold deliberately, per project
Camera EXIF absent transform of empty input Fall back to the flight plan’s extent
Vertical datum differs, horizontal agrees 0 m horizontally Invisible here; check Z separately

The last two rows are the reason this check is necessary and not sufficient. It is a horizontal test on camera positions, so it says nothing about heights and nothing about a datum difference small enough to sit inside the block.

For the metre-scale case, the test is on metadata rather than geometry: compare the declared datum realisation and epoch of both sources and require them to be equal or explicitly transformed.

from pyproj import CRS


def assert_datum_compatible(gcp_crs: str, camera_crs: str = "EPSG:4326") -> None:
    """Same horizontal datum, or an explicit transformation must be declared."""
    a, b = CRS.from_user_input(gcp_crs), CRS.from_user_input(camera_crs)
    da, db = a.datum, b.datum
    if da is None or db is None:
        raise ValueError("a CRS has no datum — it is engineering or undefined")
    if da.name != db.name:
        raise ValueError(
            f"datum mismatch: control is on {da.name}, cameras on {db.name}. "
            "Transform one into the other explicitly, with both epochs supplied, "
            "rather than relying on the solver to absorb the difference.")
Nearest-camera separation, correct and mismatched Two plan views. In the first, control points sit inside a block of camera positions and each control point's nearest camera is tens of metres away, well inside the threshold. In the second, the control points have been transformed with the wrong zone and sit far outside the camera block, so every nearest-camera distance is hundreds of kilometres. A note explains that using the nearest camera rather than the block centroid keeps the test valid for long corridor surveys, where a centroid distance is legitimately large. consistent — control inside the block nearest camera: 20–60 m mismatched — control far outside nearest camera: 412 000 m Nearest-camera distance, not centroid distance — a corridor survey has a large centroid distance legitimately. The threshold is a property of the flight, so derive it from the planned block extent rather than fixing it globally.

Figure 2 — The geometric test. It is a comparison between two point sets rather than between two CRS declarations, which is why a mislabelled file cannot defeat it.

Verify the fix worked

After correcting the CRS, the same separation check should pass, and one further comparison confirms the correction was a transformation rather than a relabelling.

def assert_transformed_not_relabelled(before: np.ndarray, after: np.ndarray,
                                      min_shift_m: float = 0.001) -> None:
    """A CRS fix that changed no coordinates changed nothing.

    Relabelling a file's declared CRS without transforming its numbers is the
    most common 'fix' and it leaves the data exactly as wrong as before.
    """
    shift = float(np.max(np.linalg.norm(after[:, :2] - before[:, :2], axis=1)))
    assert shift > min_shift_m, (
        "coordinates are unchanged — the declared CRS was edited but the "
        "numbers were not transformed")

That assertion exists because editing the header line of a control file is quicker than transforming it, produces a file that passes every syntactic check, and is wrong in exactly the way the original was.

Where the check belongs

Run it at submission, not at review. The whole value of a geometric consistency test is that it costs milliseconds and runs before any compute is spent, so a mismatched pair is rejected while the operator who assembled it is still at their desk rather than after an overnight reconstruction.

Two placements are worth having. The first is inside the job validation described in orchestrating photogrammetry jobs with Python schedulers, alongside the other checks that run before a job is queued — it belongs in the same class as “the images directory exists”. The second is in whatever tool assembles the control file in the first place, because the mismatch is created there and catching it at creation gives the clearest possible error message: the person who just exported the file is the person who knows which system they meant.

Recording the measured separation in the run manifest, rather than only the pass or fail, is worth the extra field. On a correct pair the number is a property of the flight — roughly the block’s half-diagonal — so a survey whose separation is suddenly ten times its usual value has changed something even if it still sits under the threshold. That is the earliest possible warning of a datum realisation drifting, and it costs one number.

When to escalate

  • The separation check passes and control residuals are still metre-scale. The horizontal frames agree and something else does not — most often the vertical datum, which this check cannot see. Compare declared vertical references, as in resolving vertical datum mismatch in GCP heights.
  • Both frames agree and the residuals form a pattern. A systematic pattern in the residuals is a geometry problem rather than a coordinate one; the signatures are catalogued in troubleshooting GCP and coordinate errors.
  • The control file has no declared CRS at all. Do not guess it from the coordinate magnitudes — several zones produce plausible numbers for the same site. Ask the surveyor; a wrong guess here is the datum-realisation case, which nothing downstream will catch.

Troubleshooting Ingestion and CRS Failures

Relabelling against transforming Two edits to the same control file. In the first, the projection header is changed and the coordinate numbers are left alone, so the file now claims a different system while describing the same wrong positions; every syntactic check passes and the data is unchanged. In the second, the coordinates are transformed into the target system and the header updated to match, so both the numbers and the declaration change together. A note gives the one-line test that distinguishes them: if no coordinate moved, nothing was fixed. relabelled header: EPSG:25832 → EPSG:25833 easting: 412 883.21 → 412 883.21 northing: 5 348 102.66 → unchanged syntactically valid, semantically identical exactly as wrong as before transformed header: EPSG:25832 → EPSG:25833 easting: 412 883.21 → 189 447.02 northing: 5 348 102.66 → 5 351 986.10 numbers and declaration moved together the same ground position, restated If no coordinate moved, nothing was fixed. One assertion on the maximum coordinate shift separates the two, and it belongs in the pipeline rather than in a review.

Figure 3 — The most common non-fix. Editing a declaration is faster than transforming coordinates and produces a file that satisfies every check except the one that matters.