Diagnosing Doming and Bowl Effect in Reconstructions
The site is flat. The survey says it rises fifty centimetres toward the middle and falls away symmetrically at the edges. Every image aligned, the mean reprojection error is 0.38 pixels, and the ground control residuals are all under two centimetres because the control is around the perimeter, which is exactly where the surface is anchored.
Doming — and its inverse, the bowl — is the characteristic failure of a self-calibrated nadir survey, and it is invisible in every statistic a reconstruction reports about itself. This page covers detecting it, separating it from the errors it resembles, and the four remedies in ascending order of cost. It extends camera calibration and lens models in Python.
Why the reconstruction cannot see it
A bundle adjustment minimises reprojection error: how far each tie point lands from where the model predicts. Doming arises when two errors cancel in that measure — a radial distortion coefficient that is slightly too large, and a surface that curves to compensate.
With every image taken straight down from the same height, the two are almost perfectly correlated. The solver has no basis to prefer the flat surface with the correct lens over the domed surface with the wrong one, and it will settle wherever its initialisation and regularisation put it.
That is why the residuals are excellent. They are excellent because the solver found a consistent explanation; it simply found the wrong one, and no amount of iterating improves it.
Figure 1 — Two failures with the same magnitude and completely different signatures.
Minimal reproducible solution
import numpy as np
def fit_curvature(x: np.ndarray, y: np.ndarray, dz: np.ndarray) -> dict:
"""Fit a radially symmetric quadratic to vertical residuals.
Doming is a second-order term in the residual field, so fitting one
directly gives its magnitude in metres — a number that can be compared
against a tolerance instead of a surface that looks wrong.
"""
x0, y0 = float(np.mean(x)), float(np.mean(y))
dx, dy = x - x0, y - y0
r2 = dx ** 2 + dy ** 2
A = np.column_stack([r2, dx, dy, np.ones_like(dx)])
coeffs, *_ = np.linalg.lstsq(A, dz, rcond=None)
curvature, tilt_x, tilt_y, offset = (float(c) for c in coeffs)
span = float(np.sqrt(r2).max())
sag = curvature * span ** 2
residual = dz - A @ coeffs
return {
"sag_m": sag,
"offset_m": offset,
"tilt_mm_per_100m": (tilt_x * 1e5, tilt_y * 1e5),
"unexplained_sd_m": float(np.std(residual, ddof=1)),
"diagnosis": _diagnose(sag, offset, tilt_x, tilt_y),
}
def _diagnose(sag: float, offset: float, tx: float, ty: float) -> str:
tilt = float(np.hypot(tx, ty)) * 1e5
if abs(sag) > 0.05:
return ("dome" if sag > 0 else "bowl") + " — the lens model absorbed a surface error"
if abs(offset) > 0.05:
return "uniform vertical offset — a datum or antenna height problem"
if tilt > 50:
return "systematic tilt — control distribution or a datum realisation difference"
return "no significant systematic component"
Separating the three terms is what makes the diagnosis mechanical. A dome, a uniform offset and a tilt have different causes and different remedies, and a single RMSE conflates all three into one number that identifies none of them.
Figure 3 — The signature that names the cause.
Edge-case matrix
| Residual pattern | Cause | Remedy |
|---|---|---|
| Quadratic, zero at the perimeter | Doming from self-calibration | Fix intrinsics or re-fly with obliques |
| Uniform offset | Vertical datum or antenna height | Check the geoid model and the lever arm |
| Linear tilt | Control distribution or datum realisation | Add control, check the datum |
| Quadratic with perimeter control only | Doming, anchored where nobody measured | Add interior control or checkpoints |
| Large unexplained scatter | Not systematic; a measurement problem | Look at the checkpoints themselves |
| Quadratic in one axis only | Corridor survey with weak cross-geometry | Add cross lines |
| Dome that varies between flights | Temperature-dependent focal length | Calibrate closer to flight conditions |
| No checkpoints at all | Nothing is detectable | This is the real problem |
The last row is not glib. A survey delivered with control but no independent checkpoints has no way to detect any of the failures above, because the control was consumed by the solution and reports only how well the solver fitted it.
Verification snippet
import numpy as np
def control_versus_checkpoint(control_residuals: np.ndarray,
checkpoint_residuals: np.ndarray) -> dict:
"""Compare how well the solution fits its control against independent points.
A solution that fits its control far better than its checkpoints has
absorbed error into the parameters rather than removing it — which is
precisely the doming signature.
"""
c = control_residuals[np.isfinite(control_residuals)]
k = checkpoint_residuals[np.isfinite(checkpoint_residuals)]
if c.size < 3 or k.size < 3:
return {"note": "need at least three of each"}
rms_c = float(np.sqrt(np.mean(c ** 2)))
rms_k = float(np.sqrt(np.mean(k ** 2)))
ratio = rms_k / max(rms_c, 1e-9)
return {"control_rms_m": rms_c, "checkpoint_rms_m": rms_k, "ratio": ratio,
"overfitted": ratio > 3.0,
"note": ("checkpoints are much worse than control — the solution is "
"fitting its constraints rather than the site"
if ratio > 3.0 else "control and checkpoints agree")}
A ratio above three is the single clearest signal that something systematic is present. It requires no surface fitting and no assumptions, and it is computable on any project that kept some points out of the adjustment.
The four remedies, in order of cost
Fix the intrinsics from a stored calibration and re-run. Costs minutes and works whenever a good calibration exists. This is always the first thing to try.
Add interior control or checkpoints from existing survey data if any was collected across the site. Costs nothing if the points exist and constrains the middle where the dome lives.
Re-process with a subset of parameters free — focal length and the first radial term only — which often removes most of the curvature while keeping some adaptability.
Re-fly with obliques or a cross pattern. Costs a mobilisation and is the only remedy that fixes the underlying observability problem rather than working around it.
def remedy_order(has_calibration: bool, interior_points: int,
sag_m: float) -> list[str]:
"""Which remedies apply, cheapest first."""
steps = []
if has_calibration:
steps.append("re-run with fixed intrinsics from the stored calibration")
if interior_points >= 3:
steps.append("add the interior points to the adjustment and re-run")
steps.append("re-run freeing only focal length and k1")
if abs(sag_m) > 0.15 or not has_calibration:
steps.append("re-fly with a cross pattern and an oblique orbit")
return steps
Figure 2 — What each remedy is worth on one real survey.
Catching it before delivery
Doming is cheap to fix and expensive to discover after a client has built something on it, so the detection belongs in the pipeline rather than in a review.
Three checks, run automatically on every survey that has checkpoints, catch essentially all of it. The control-to-checkpoint ratio needs nothing but the two residual sets and flags overfitting in one number. The curvature fit turns a suspicion into a sag in metres. And a comparison against the previous survey of the same site catches a dome that appeared this month, which is usually a changed processing setting rather than a changed lens.
def gate_survey_geometry(control_res, checkpoint_res, x, y, dz,
*, max_sag_m: float = 0.05,
max_ratio: float = 3.0) -> None:
"""Refuse to deliver a survey with an unexplained systematic deformation."""
import numpy as np
ratio = (np.sqrt(np.mean(checkpoint_res ** 2))
/ max(np.sqrt(np.mean(control_res ** 2)), 1e-9))
if ratio > max_ratio:
raise ValueError(f"checkpoints are {ratio:.1f}× worse than control — "
"the solution is fitting its constraints")
fit = fit_curvature(x, y, dz)
if abs(fit["sag_m"]) > max_sag_m:
raise ValueError(f"{fit['diagnosis']}: sag {fit['sag_m']:+.3f} m across the site")
A survey that fails this gate is not necessarily unusable — the tolerance may allow it — but the failure should be a decision somebody makes rather than a property nobody measured.
When to escalate
- No checkpoints and no interior control. The dome cannot be measured, only suspected. Collect a handful of independent points before doing anything else; without them every remedy is applied blind.
- The dome persists with fixed intrinsics. The stored calibration may itself be wrong, or the effect is not doming. Re-check the residual shape.
- The client’s tolerance is tighter than anything achievable. A nadir-only flight with perimeter control has a floor on its vertical accuracy across the interior. Say so before flying rather than after processing.