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.

Checkpoint residuals across a site, for a dome and for a datum offset Two profiles of vertical checkpoint residual against distance from the site centre. The dome case shows a smooth quadratic curve, near zero at the perimeter where the control sits and rising to fifty centimetres in the middle. The datum offset case shows a flat line at plus thirty centimetres across the whole site, including at the perimeter. A note states that the shape of the residual identifies the cause without any further investigation, and that perimeter-only control hides the first case entirely. 0 edge centre edge position across the site vertical residual dome — quadratic, zero at the control datum offset — flat everywhere control The shape of the residual names the cause. And perimeter-only control anchors the dome exactly where nobody is measuring.

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.

Vertical residual against distance from the survey centre A profile of vertical checkpoint residual against distance from the centre of a nadir survey. At the centre the residual is strongly positive, the surface sitting high. It falls steadily outward, crosses zero partway to the perimeter, and is negative at the edges where the control sits. A note states that this symmetric radial signature is the doming diagnostic, that it distinguishes a systematic lens-parameter problem from random error, and that control placed only on the perimeter pins the edges and leaves the centre free to rise. distance from the survey centre to the perimeter vertical residual Symmetric and radial means the lens model, not noise. Perimeter-only control lets the centre rise.

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
Residual sag after each remedy on the same survey Four bars showing the remaining dome sag after each remedy applied to a survey that started with fifty-two centimetres. Fixing the intrinsics from a stored calibration reduces it to nine centimetres. Adding four interior control points reduces it to six. Freeing only focal length and the first radial term gives eleven centimetres, slightly worse than fixing them outright. Re-flying with a cross pattern and obliques reduces it to two centimetres. A tolerance line at five centimetres is crossed only by the last option and nearly by the second. as delivered 52 cm fixed intrinsics from the stored calibration 9 cm plus four interior control points 6 cm re-flown with a cross pattern and obliques 2 cm 5 cm tolerance The cheapest remedy gets most of the way there, which is why it is always worth trying first.

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.

Camera Calibration and Lens Models in Python