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.
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.
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.
Related
- Troubleshooting GCP and coordinate errors
- Resolving a vertical datum mismatch in GCP heights
- Distributing GCP errors across orthomosaics
← Troubleshooting GCP and Coordinate Errors
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.