Diagnosing Drifting Camera Poses in Corridor Mapping
An eight-kilometre pipeline survey reconstructs cleanly and the far end is two metres out of position. The reprojection residuals average 0.4 pixels. The control at the start fits perfectly. There are no outliers to remove and no threshold to tighten, because nothing about the reconstruction is wrong — it is under-constrained in a way that only long, thin blocks are.
This page explains the mechanism, measures it, and covers the two interventions that actually work.
Why corridors bend and compact blocks do not
Structure from motion determines relative orientation between overlapping images very well and absolute orientation not at all. In a compact block, every image sees ground that many other images also see, and the network of constraints is dense in two dimensions — an error in one pose is contradicted from several directions at once.
A corridor is a chain. Each image overlaps its neighbours along the strip and, at the ends of the block, nothing else. A small error in relative orientation between two adjacent images propagates to every image after them, and because the errors are systematic rather than random they accumulate rather than cancelling. The result is a smooth bend — in plan, in height, or in both — whose magnitude grows with distance from the nearest constraint.
Three properties make it distinctive. The deformation is smooth, so it looks like real terrain rather than an error. It is anchored: zero at the control and largest as far from it as possible. And it grows super-linearly with corridor length, because the accumulated angular error is itself increasing.
Figure 1 — The structural difference. A corridor has no second dimension in which to contradict an error, so what would be a local wobble in a block becomes a systematic bend.
Minimal reproducible solution
Measure the drift before attempting to fix it, because its shape names the missing constraint.
import numpy as np
def drift_profile(stations: np.ndarray, control_idx: list[int]) -> dict:
"""Residual from a straight-line fit, as a function of along-track distance.
Fits the corridor's principal axis, then reports cross-track and vertical
departure per station relative to the nearest control point.
"""
centred = stations - stations.mean(axis=0)
axis = np.linalg.svd(centred[:, :2], full_matrices=False)[2][0]
along = centred[:, :2] @ axis
cross = centred[:, :2] @ np.array([-axis[1], axis[0]])
anchors = along[control_idx]
distance = np.abs(along[:, None] - anchors[None, :]).min(axis=1)
order = np.argsort(distance)
return {
"distance_m": distance[order],
"cross_track_m": cross[order],
"vertical_m": centred[order, 2],
"corridor_length_m": float(along.max() - along.min()),
}
def classify_drift(profile: dict) -> str:
"""A curved profile is drift; a step is something else."""
d, c = profile["distance_m"], profile["cross_track_m"]
if d.max() < 1.0:
return "no unconstrained span — drift is not the explanation"
lin = np.polyfit(d, c, 1)
quad = np.polyfit(d, c, 2)
lin_rms = float(np.sqrt(np.mean((np.polyval(lin, d) - c) ** 2)))
quad_rms = float(np.sqrt(np.mean((np.polyval(quad, d) - c) ** 2)))
if quad_rms < lin_rms * 0.6:
return "curved with distance — classic accumulation drift"
if lin_rms < np.std(c) * 0.5:
return "linear with distance — a scale or heading error, not accumulation"
return "no clear trend — look for a local defect rather than drift"
Separating curved from linear matters because they have different fixes. Curvature is accumulation, and the remedy is more constraints along the corridor. A linear departure is a single systematic error — a scale factor, or a heading rotation — applied uniformly, and adding control does not remove it so much as hide it at the points you added.
The intervention that works is to break the chain into shorter unconstrained spans. Control every one to two kilometres turns one eight-kilometre accumulation into four two-kilometre ones, and because the growth is super-linear the total is far less than a quarter.
def required_control_spacing(corridor_length_m: float, tolerance_m: float,
observed_drift_m: float,
observed_span_m: float) -> float:
"""Span between control points that keeps drift inside tolerance.
Uses the measured drift at a measured span and assumes quadratic growth,
which matches the observed behaviour of chained blocks more closely than
a linear model and is conservative if the true exponent is lower.
"""
if observed_span_m <= 0 or observed_drift_m <= 0:
raise ValueError("need a measured drift over a measured span")
k = observed_drift_m / observed_span_m ** 2
return float((tolerance_m / k) ** 0.5)
Edge-case matrix
| Observation | Meaning | Action |
|---|---|---|
| Curved cross-track profile | Accumulation | Denser control along the corridor |
| Linear cross-track profile | Heading or scale error | Fix the systematic term; control hides it |
| Vertical drift only | Weak height geometry | Add oblique imagery or cross strips |
| Step at one point | A local join, not drift | Treat as a bridge problem |
| Drift symmetric about the centre | Control only at the centre | Move control to the ends |
| Drift on one side only | Control only at one end | Anchor both ends |
| No unconstrained span | Not drift | Look elsewhere entirely |
| Drift changes between runs | Non-determinism, not geometry | Pin the worker count first |
The vertical-only row is worth calling out. Height is the weakest direction in nadir photogrammetry regardless of block shape, and on a corridor it is weak and chained. Cross strips flown perpendicular to the corridor at intervals are the geometric fix — each one turns a point on the chain into a small network — and they are cheaper than they look because they need only be a few frames long.
Figure 2 — The geometric fix. A cross strip is short, cheap, and turns one point of the chain into a small network that accumulation cannot pass through unchallenged.
Verification snippet
The honest measure is checkpoints along the corridor, particularly at the points furthest from any control.
import numpy as np
def assert_no_residual_drift(check_errors_m: np.ndarray,
distance_from_control_m: np.ndarray,
tolerance_m: float) -> None:
"""Checkpoint error must not trend with distance from control."""
assert check_errors_m.max() <= tolerance_m, (
f"worst checkpoint error {check_errors_m.max():.3f} m exceeds "
f"tolerance {tolerance_m:.3f} m")
# A correlation with distance means drift remains, even inside tolerance.
if len(check_errors_m) >= 4:
r = float(np.corrcoef(distance_from_control_m, check_errors_m)[0, 1])
assert r < 0.7, (
f"checkpoint error correlates with distance from control (r={r:.2f}) "
"— drift is present and a longer corridor will exceed tolerance")
The correlation test is the part that generalises. A corridor can pass its tolerance today and carry a clear distance trend, which means the same configuration on a longer job will fail — and that is worth knowing before the longer job is quoted, not after.
One planning consequence is worth stating plainly, because it changes what gets quoted rather than what gets processed. Drift is a property of the flight geometry, so it is decided before any imagery is captured and it cannot be recovered afterwards by better processing. A corridor flown as two parallel passes with control only at its ends has an accuracy ceiling set by its length, and no solver setting raises that ceiling. Estimating the expected drift at quote time — from a previous corridor of known length and known control spacing — turns an unpleasant discovery at delivery into a line item for two extra control points and three cross strips.
When to escalate
- Drift persists after doubling the control density. The accumulation is not the dominant term; look for an unmodelled systematic, most often lens distortion absorbed imperfectly, whose signature is described in automating camera intrinsic matrix extraction.
- Vertical drift only, with cross strips already flown. The geometry is as good as nadir imagery allows. Oblique imagery at the ends, or an independent levelling run on the checkpoints, is the next step.
- The corridor is split into submodels rather than bent. Different failure entirely — the chain is broken rather than bending, and the diagnosis is in fixing an OpenSfM reconstruction split into multiple submodels.
Related
- Troubleshooting alignment and matching failures
- Optimizing bundle adjustment with Python
- Setting accuracy thresholds for survey projects
← Troubleshooting Alignment and Matching Failures
Figure 3 — The spacing that follows from a measured drift. Because the relationship is quadratic rather than linear, the first extra control point in the middle of a corridor is worth far more than the tenth.