Diagnosing Systematic Tilt in GCP Residuals

The vertical RMSE is four centimetres, which passes. Look at the individual points and the northern control is consistently +6 cm while the southern control is consistently −5 cm, with the middle near zero. The survey is not noisy; it is tilted, and the RMSE averaged that tilt into a number that looks acceptable.

A tilt is worth separating from noise for two reasons. It is a systematic error, so it does not shrink with more points and it does not average out over an area — a volume computed on a tilted surface is wrong by an amount proportional to the volume. And it has a small set of causes, all identifiable.

Fitting a plane instead of reading a number

A tilt is a plane through the residuals: an offset plus a gradient in each horizontal direction. Fitting it takes three parameters and immediately separates the systematic part from the random part.

import numpy as np


def fit_residual_plane(easting: np.ndarray, northing: np.ndarray,
                       dz: np.ndarray) -> dict:
    """Least-squares plane through the vertical residuals.

    Returns the offset, the two gradients in millimetres per hundred metres,
    the tilt magnitude and azimuth, and how much of the variance the plane
    explains — which is what decides whether 'tilt' is the right word.
    """
    e = np.asarray(easting, float) - np.mean(easting)
    n = np.asarray(northing, float) - np.mean(northing)
    z = np.asarray(dz, float)
    if z.size < 4:
        raise ValueError("a plane through fewer than four points is not evidence")

    A = np.column_stack([np.ones_like(e), e, n])
    coef, *_ = np.linalg.lstsq(A, z, rcond=None)
    offset, ge, gn = (float(c) for c in coef)

    residual = z - A @ coef
    explained = 1.0 - float(np.var(residual) / max(np.var(z), 1e-12))

    return {
        "offset_m": offset,
        "grad_e_mm_per_100m": ge * 1000 * 100,
        "grad_n_mm_per_100m": gn * 1000 * 100,
        "tilt_mm_per_100m": float(np.hypot(ge, gn) * 1000 * 100),
        "azimuth_deg": float(np.degrees(np.arctan2(ge, gn)) % 360.0),
        "explained_fraction": explained,
        "residual_rms_m": float(np.sqrt(np.mean(residual ** 2))),
    }

explained_fraction is the number that decides whether to act. Below about 0.4 the residuals are essentially random and the fitted plane is describing noise; above 0.7 the tilt is real and dominates. In between, more control is needed before drawing a conclusion.

The azimuth is the second useful output, because the direction of the tilt narrows the cause. A tilt aligned with the flight direction, with the survey boundary, or with nothing in particular are three different situations.

Noise, tilt, and a bowl — three residual fields Three plan views of the same control layout, with each point's vertical residual drawn as a bar above or below the plane. In the first, the residuals are random in sign and size, so a fitted plane explains almost none of the variance. In the second, the residuals rise steadily from one side of the block to the other, so a plane explains most of the variance and the field is a tilt. In the third, the residuals are negative at the centre and positive around the edges, so a plane explains almost nothing even though the field is strongly systematic — a bowl, which needs a different remedy. noise plane explains < 20% nothing to fix tilt plane explains > 70% systematic — fix the cause bowl plane explains < 20% systematic, but not a tilt The first and third both defeat a plane fit, and only one of them is harmless. Which is why a low explained fraction is a prompt to plot, not a certificate of health.

Figure 1 — The plane fit separates tilt from noise and does not separate tilt from curvature. A low explained fraction means “not a tilt”, which is not the same as “not a problem”.

The four causes, and how the azimuth distinguishes them

Control clustered along one axis. If every control point lies near a line, the plane perpendicular to that line is unconstrained and the solver is free to rotate about it. The tilt azimuth is perpendicular to the control’s long axis, and the fix is a control point off that line.

No elevation spread in the control. All control at the same height leaves vertical scale weakly determined, which appears as tilt on a site with relief. The tilt correlates with terrain elevation rather than with plan position.

Unmodelled radial distortion. Self-calibration absorbing distortion imperfectly produces a bowl, not a plane — but on a block that is much longer than it is wide, half a bowl looks exactly like a tilt. The clue is that the tilt azimuth aligns with the block’s long axis.

A base station height error. This produces a uniform offset, not a tilt, unless two base stations were used for different parts of the survey — in which case the tilt is a step, and the plane fit smooths it into a gradient.

def suggest_cause(fit: dict, control_xy: np.ndarray, control_z: np.ndarray,
                  block_long_axis_deg: float) -> str:
    """Narrow the cause from the tilt's direction and the control geometry."""
    import numpy as np
    if fit["explained_fraction"] < 0.4:
        return "not a tilt — plot the residual field before acting"

    # Control geometry: is it strung out along a line?
    c = control_xy - control_xy.mean(axis=0)
    s = np.linalg.svd(c, compute_uv=False)
    elongation = float(s[0] / max(s[1], 1e-9))
    if elongation > 4.0:
        return ("control is nearly collinear; the block can rotate about that "
                "line — add a point well off it")

    if float(np.ptp(control_z)) < 2.0:
        return ("control spans under 2 m of elevation; vertical scale is weakly "
                "determined — add control at a different height")

    delta = abs((fit["azimuth_deg"] - block_long_axis_deg + 90) % 180 - 90)
    if delta < 25.0:
        return ("tilt aligns with the block's long axis — suspect unmodelled "
                "radial distortion rather than control geometry")
    return "tilt is not explained by control geometry or block shape; check for two base stations"

Edge-case matrix

Observation Likely cause Action
Tilt ⟂ a collinear control line Rotational freedom Add control off the line
Tilt with no elevation spread Weak vertical scale Add control at a different height
Tilt along the block’s long axis Radial distortion Fix intrinsics, or add cross strips
Tilt with a step in the middle Two base stations Reconcile the two setups
Tilt only in the vertical Normal — height is weakest Same causes, vertical only
Tilt in plan as well Horizontal datum or scale Not a control-geometry issue
Explained fraction 0.4–0.7 Ambiguous More control before concluding
Explained fraction low, residuals large A bowl, not a tilt Distortion; see the intrinsics guide

The plan-tilt row is worth separating. A tilt in the horizontal residuals is not a photogrammetric geometry problem at all — plan position is well constrained by nadir imagery — and points instead at a coordinate transformation, most often a scale factor or a missing grid, as covered in converting WGS84 to a local grid with Python.

Verification snippet

After the fix, the plane’s explained fraction should collapse and the residual RMS should fall — both, not either.

def assert_tilt_removed(before: dict, after: dict,
                        max_explained: float = 0.4,
                        min_rms_gain: float = 1.3) -> None:
    """The tilt must be gone and the residuals must be smaller."""
    assert after["explained_fraction"] <= max_explained, (
        f"a plane still explains {after['explained_fraction']:.0%} of the "
        "residual variance — the systematic component remains")
    assert before["residual_rms_m"] / max(after["residual_rms_m"], 1e-9) >= min_rms_gain, (
        f"RMS barely improved: {before['residual_rms_m']:.3f} → "
        f"{after['residual_rms_m']:.3f} m. Removing a tilt that was not there "
        "adds parameters without adding accuracy.")

Requiring both conditions guards against the tempting non-fix: subtracting the fitted plane from the delivered surface. That drives the explained fraction to zero by construction and improves nothing, because the plane was fitted to the same points it is then evaluated on. A genuine fix — better control geometry, corrected intrinsics — improves the residuals at points that were not used to fit anything.

Removing the tilt against fixing its cause Two responses to a tilted residual field. In the first, the fitted plane is subtracted from the delivered surface: the control residuals become flat by construction, and independent checkpoints withheld from the fit still show the original tilt, because nothing about the geometry changed. In the second, the cause is fixed by adding control off the collinear line: both the control residuals and the withheld checkpoints become flat, because the block itself is now constrained. A note observes that only the checkpoints distinguish the two, which is why they must be withheld. subtract the fitted plane control points flat — by construction withheld checkpoints tilt is still there fix the control geometry control points flat withheld checkpoints flat too — the block is constrained Both look identical if you only examine the control. Withheld checkpoints are the only measurement that separates a fix from a cosmetic correction.

Figure 2 — The reason checkpoints exist. Subtracting a fitted plane flattens exactly the points it was fitted to, and a report based on those points cannot tell you it did nothing.

When to escalate

  • The tilt persists after adding control off the collinear line. The rotational freedom was not the cause. Look at the block’s long axis against the tilt azimuth; if they align, this is distortion rather than geometry.
  • The tilt correlates with terrain elevation rather than plan position. Vertical scale, not tilt. Adding control at a different height is what constrains it; more control at the same elevation does nothing.
  • The tilt reverses between two flights of the same site. Two base station setups, or two datum realisations. Reconcile the setups before reprocessing; the tilt is inherited from the control, not created by the reconstruction.

Troubleshooting GCP and Coordinate Errors

Why a tilt matters more than its RMSE suggests A stockpile measured on a tilted surface. The tilt is four centimetres across the stockpile's footprint, which contributes only a small amount to the reported vertical RMSE because it averages toward zero. The volume error, however, does not average out: the surface is high on one side and low on the other, and the resulting volume error scales with the footprint area rather than cancelling. A worked figure shows a stockpile of two thousand square metres accruing roughly forty cubic metres of error from a tilt whose RMSE reads under three centimetres. stockpile, 2000 m² tilted base surface: +2 cm one side, −2 cm the other reported vertical RMSE 2.8 cm — passes volume error ≈ 40 m³ and it scales with the footprint Random error averages toward zero over an area; a tilt does not, which is why a passing RMSE is not a passing volume.

Figure 3 — The consequence that makes this worth chasing. The same residual magnitude costs nothing when it is noise and costs a priced quantity when it is a plane.