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.

How error accumulates along a chain against a network Two block geometries compared. A compact block is drawn as a two-dimensional grid of images where each is connected to several neighbours in different directions, so a pose error is contradicted from multiple sides and stays local. A corridor is drawn as a single chain of images connected only along its length, so a small angular error between two adjacent images is inherited by every image after them and accumulates into a smooth bend. Below, a curve shows positional error against distance along the corridor, rising faster than linearly and anchored at zero where control is placed. compact block — a network an error is contradicted from several directions corridor — a chain each error is inherited by everything downstream distance from the nearest control → zero at the control grows faster than linearly

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.

Cross strips convert chain segments into small networks A corridor flight drawn as two parallel passes along a pipeline. In the first version, only the two parallel passes exist, so the block is a chain and drift accumulates along its whole length. In the second, three short perpendicular cross strips are flown at intervals, each only a few frames long. At each cross strip the images gain neighbours in a second direction, so the chain is interrupted by small two-dimensional networks that arrest accumulation. A note gives the cost as a few minutes of extra flying per cross strip against a drift reduction of roughly the square of the segment ratio. corridor alone — one long chain accumulation runs the full length with three short cross strips network network network A few minutes of extra flying each, against a drift reduction of roughly the square of the segment-length ratio.

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.

Troubleshooting Alignment and Matching Failures

Drift against control spacing, at quadratic growth A chart of maximum drift against the spacing between control points along a corridor, assuming drift grows with the square of the unconstrained span. At four kilometre spacing the drift is well over a metre; at two kilometres it falls to roughly a quarter of that; at one kilometre to about a sixteenth. Horizontal lines mark a five centimetre and a three centimetre tolerance, showing that the required spacing for a tight class is under one kilometre. A note observes that because the growth is quadratic, halving the spacing quarters the drift, which is why adding one intermediate control point is unusually effective. 5 cm tolerance 3 cm tolerance 0.5 km 1 km 2 km 3 km 4 km spacing between control points along the corridor maximum drift 1 km spacing clears both tolerances Quadratic growth means halving the spacing quarters the drift, which is why one intermediate point buys so much.

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.