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.
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.")
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.
Related
- Troubleshooting ingestion and CRS failures
- Managing coordinate reference systems in GDAL
- Fixing GCP file format errors in ODM
← Troubleshooting Ingestion and CRS Failures
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.