Checkpoint-Based Accuracy Validation in Python

A survey report states a horizontal accuracy of 1.8 cm, derived from the ground control residuals. Those residuals measure how closely the bundle adjustment fitted the points it was told to fit — which, with enough free parameters, can be made arbitrarily small without the survey becoming any more accurate.

The honest measure comes from points the adjustment never saw. Holding a subset of the control out as checkpoints costs nothing in the field and converts a self-assessment into a measurement. This page covers the split, the statistics that follow, and the reporting that makes them defensible. It builds on the placement work in ground control point optimization and coordinate sync.

Audience and prerequisites. Python 3.10+, a survey with more control points than the minimum, and a reconstruction that can be run with a subset of them.

Prerequisites

Library / tool Minimum version Install command Role
numpy ≥ 1.24 pip install numpy Residual statistics
scipy ≥ 1.10 pip install scipy Distance transforms for spatial splitting
shapely ≥ 2.0 pip install shapely Site geometry and hull tests
pandas ≥ 2.0 pip install pandas Result tables across runs

Conceptual architecture

Control points and checkpoints are the same measurements used for different purposes. A control point enters the adjustment as a constraint; a checkpoint is withheld and compared against the result afterwards.

That difference makes their residuals mean opposite things. A small control residual says the solver fitted its constraints, which it will do whether or not the survey is correct. A small checkpoint residual says the survey predicts a measurement it never saw, which is the definition of accuracy.

The corollary is that a survey with all its points used as control has no accuracy measurement at all — only a consistency measurement, which is what the report usually quotes.

What control residuals and checkpoint residuals each measure Two paths through an adjustment. Control points enter the solve as constraints and their residuals afterwards measure how closely the solver fitted them, which more free parameters always improve. Checkpoints are withheld from the solve and compared against the result, so their residuals measure whether the survey predicts a measurement it never saw. A note states that a survey using every point as control has a consistency measurement and no accuracy measurement, which is what most reports quote. control points bundle adjustment control residuals how well the solver fitted its constraints checkpoints checkpoint residuals whether the survey predicts what it never saw A survey using every point as control has no accuracy measurement at all. Only a consistency measurement — which is what most reports quote as accuracy.

Figure 1 — Two residuals with opposite meanings, routinely reported as the same thing.

Step 1: Split without biasing either set

A random split is the obvious choice and is wrong for a small number of points: it can leave the control clustered in one part of the site, which weakens the adjustment, or the checkpoints clustered, which measures accuracy in one place only.

import numpy as np


def stratified_split(positions_xy: np.ndarray, *, checkpoint_fraction: float = 0.3,
                     seed: int = 0) -> dict:
    """Split control and checkpoints so both sets span the site.

    Points are grouped into spatial cells and the split is made within each
    cell, so neither set can end up clustered — which a random split over a
    dozen points does surprisingly often.
    """
    xy = np.asarray(positions_xy, dtype=float)
    rng = np.random.default_rng(seed)

    cells = max(int(np.sqrt(len(xy) / 3)), 1)
    x_edges = np.quantile(xy[:, 0], np.linspace(0, 1, cells + 1))
    y_edges = np.quantile(xy[:, 1], np.linspace(0, 1, cells + 1))
    ix = np.clip(np.digitize(xy[:, 0], x_edges[1:-1]), 0, cells - 1)
    iy = np.clip(np.digitize(xy[:, 1], y_edges[1:-1]), 0, cells - 1)
    key = ix * cells + iy

    control, checkpoints = [], []
    for cell in np.unique(key):
        members = np.flatnonzero(key == cell)
        rng.shuffle(members)
        n_check = max(int(round(len(members) * checkpoint_fraction)), 0)
        if len(members) - n_check < 1:
            n_check = max(len(members) - 1, 0)
        checkpoints.extend(members[:n_check].tolist())
        control.extend(members[n_check:].tolist())

    return {"control": sorted(control), "checkpoints": sorted(checkpoints),
            "control_count": len(control), "checkpoint_count": len(checkpoints)}

Guaranteeing at least one control point per occupied cell is what keeps the adjustment constrained everywhere. A split that leaves a corner of the site with checkpoints and no control measures the accuracy of an unconstrained extrapolation, which is informative but not what was intended.

Step 2: Compute the statistics that mean something

import numpy as np


def checkpoint_statistics(residuals_xyz: np.ndarray) -> dict:
    """Horizontal and vertical accuracy statistics from checkpoint residuals.

    Bias and scatter are separated throughout, because a systematic offset
    and random scatter have different causes and different remedies, and the
    conventional RMSE conflates them.
    """
    r = np.asarray(residuals_xyz, dtype=float)
    r = r[np.isfinite(r).all(axis=1)]
    if len(r) < 3:
        return {"note": "at least three checkpoints are needed"}

    horizontal = np.linalg.norm(r[:, :2], axis=1)
    vertical = r[:, 2]

    return {
        "checkpoints": int(len(r)),
        "horizontal": {
            "rmse_m": float(np.sqrt(np.mean(horizontal ** 2))),
            "mean_m": float(np.mean(horizontal)),
            "max_m": float(np.max(horizontal)),
            "bias_east_m": float(np.mean(r[:, 0])),
            "bias_north_m": float(np.mean(r[:, 1])),
        },
        "vertical": {
            "rmse_m": float(np.sqrt(np.mean(vertical ** 2))),
            "bias_m": float(np.mean(vertical)),
            "scatter_m": float(np.std(vertical, ddof=1)),
            "max_abs_m": float(np.max(np.abs(vertical))),
        },
        "systematic_fraction": float(
            np.linalg.norm([np.mean(r[:, 0]), np.mean(r[:, 1]), np.mean(r[:, 2])])
            / max(np.sqrt(np.mean((r ** 2).sum(axis=1))), 1e-9)),
    }

The systematic_fraction is the summary worth watching across a programme: it is the share of the total error that is a common offset rather than scatter. A value near zero is an ordinary survey; a value near one means almost everything wrong with the survey is a single shift, which is a datum or control problem rather than a photogrammetric one.

Step 3: Detect blunders before computing anything

One mis-identified checkpoint drags every statistic, and it is detectable because a blunder is different in kind from measurement noise.

import numpy as np


def flag_blunders(residuals_xyz: np.ndarray, *, threshold: float = 3.5) -> dict:
    """Identify checkpoints whose residual is inconsistent with the rest.

    A modified z-score on the robust scale, rather than a standard deviation,
    because the blunder being detected would inflate a standard deviation
    enough to hide itself.
    """
    magnitude = np.linalg.norm(np.asarray(residuals_xyz, dtype=float), axis=1)
    median = float(np.median(magnitude))
    mad = float(np.median(np.abs(magnitude - median)))
    if mad < 1e-9:
        return {"blunders": [], "note": "residuals are identical; nothing to flag"}

    score = 0.6745 * (magnitude - median) / mad
    blunders = np.flatnonzero(score > threshold).tolist()
    return {"blunders": blunders, "scores": score.tolist(),
            "clean_indices": [i for i in range(len(magnitude)) if i not in blunders],
            "note": (f"{len(blunders)} checkpoint(s) inconsistent with the rest — "
                     "inspect before excluding" if blunders else "no blunders detected")}

Flagging rather than automatically excluding is deliberate. A checkpoint with a large residual may be a mis-identified target, a disturbed marker, or the one place where the survey genuinely is wrong — and only inspection distinguishes them.

Step 4: Decide how many points a survey needs

The split above assumes there are enough points to divide. Deciding how many to collect is a field-planning question with a defensible answer, and it is driven by what the statistics need rather than by convention.

The adjustment needs enough control to constrain the solution: a minimum of three for a rigid placement, and in practice five or more distributed across the interior as well as the perimeter to suppress the deformation modes described in the camera calibration work. The accuracy statement needs enough checkpoints that its own uncertainty is small relative to the tolerance being claimed — which, for a normal distribution, means the standard error of the RMSE falls roughly as one over the square root of twice the checkpoint count.

import numpy as np


def required_points(tolerance_m: float, expected_accuracy_m: float,
                    *, confidence: float = 0.95) -> dict:
    """How many checkpoints make an accuracy claim statistically meaningful.

    The claim is that the true accuracy is below the tolerance. The number of
    checkpoints needed grows as the expected accuracy approaches the
    tolerance, which is why a survey claiming to just meet a specification
    needs far more validation than one comfortably inside it.
    """
    margin = max(tolerance_m - expected_accuracy_m, 1e-6) / expected_accuracy_m
    z = 1.96 if confidence >= 0.95 else 1.64
    needed = int(np.ceil(0.5 * (z / margin) ** 2)) + 1

    return {"checkpoints_needed": max(needed, 5),
            "control_needed": max(5, int(np.ceil(needed * 0.7 / 0.3 * 0.3))),
            "margin_fraction": margin,
            "note": ("comfortable margin; five checkpoints suffice" if margin > 0.8 else
                     "the claimed tolerance is close to the expected accuracy — "
                     "more checkpoints are needed to support it")}

The result is often uncomfortable. A survey expecting 3 cm accuracy and claiming a 3.5 cm tolerance needs dozens of checkpoints to support the claim at 95 % confidence; the same survey claiming 6 cm needs five. That is not a statistical technicality — it is the reason a tight tolerance costs field time, and it is better known before the flight.

Step 5: Report the result so it cannot be misread

Three conventions make an accuracy statement robust against the ways it is usually misread.

State the confidence level. An RMSE is a one-sigma figure covering about 68 % of outcomes, and readers routinely treat it as a bound. Where a specification says “within X”, it almost always means 95 %, which is roughly two RMSE for a well-behaved distribution.

State the checkpoint count. An RMSE from five points and one from fifty are different claims, and only the count distinguishes them.

State what was held out. “Accuracy assessed against eight checkpoints withheld from the adjustment” is a different sentence from “control residual RMSE”, and clients who have been given the second when they wanted the first are the reason this page exists.

def accuracy_statement(stats: dict, *, confidence: float = 0.95) -> str:
    """The paragraph that belongs in a survey report."""
    k = 1.96 if confidence >= 0.95 else 1.0
    h = stats["horizontal"]["rmse_m"]
    v = stats["vertical"]["rmse_m"]
    return (
        f"Accuracy was assessed against {stats['checkpoints']} checkpoints withheld "
        f"from the adjustment. Horizontal RMSE is {h:.3f} m and vertical RMSE is "
        f"{v:.3f} m, corresponding to approximately {k * h:.3f} m horizontal and "
        f"{k * v:.3f} m vertical at {confidence:.0%} confidence. "
        f"Vertical bias is {stats['vertical']['bias_m']:+.3f} m."
    )

Generating the sentence from the computed statistics rather than typing it keeps it true, which is the same argument made for every other generated statement in this pipeline: a number written by hand is a claim, and one written by code is a record.

Step 6: Track accuracy across a programme

A single survey’s accuracy is a number; a programme’s accuracy history is a diagnostic. Three patterns are worth watching over a season of flights.

A gradual rise in checkpoint RMSE usually means something in the workflow has drifted — a camera that needs re-calibrating, control markers that have degraded, or a processing setting that changed. It is invisible in any single survey and obvious in a series.

A step change points at a specific event: a firmware update, a new camera, a changed geoid model, a different operator. Correlating the step against the change log usually names it immediately.

A rising vertical bias with stable scatter is the signature of a datum problem creeping in, and it is the one most worth alerting on because it is the largest error available and the least visible.

import numpy as np


def accuracy_trend(history: list[dict]) -> dict:
    """Trend in checkpoint accuracy across a programme's surveys."""
    if len(history) < 4:
        return {"note": "too few surveys to establish a trend"}

    t = np.arange(len(history), dtype=float)
    h = np.array([s["horizontal"]["rmse_m"] for s in history])
    bias = np.array([s["vertical"]["bias_m"] for s in history])

    h_slope = float(np.polyfit(t, h, 1)[0])
    bias_slope = float(np.polyfit(t, bias, 1)[0])
    steps = np.abs(np.diff(h))
    step_index = int(np.argmax(steps)) if steps.size else -1

    findings = []
    if h_slope > 0.002:
        findings.append(f"horizontal RMSE rising by {h_slope * 1000:.1f} mm per survey")
    if abs(bias_slope) > 0.002:
        findings.append(f"vertical bias drifting by {bias_slope * 1000:+.1f} mm per survey "
                        "— check the vertical datum chain")
    if steps.size and steps.max() > 3 * np.median(steps):
        findings.append(f"a step change at survey {step_index + 1} — correlate against "
                        "the change log")

    return {"surveys": len(history), "horizontal_trend_mm_per_survey": h_slope * 1000,
            "bias_trend_mm_per_survey": bias_slope * 1000, "findings": findings}

The objection, and the answer to it

The standard objection to holding points out is that it wastes them: a point used as a checkpoint is a point not constraining the adjustment, so the survey is less accurate than it could have been.

That is true and it is the wrong trade to optimise. A survey constrained by ten control points and validated by none has an unknown accuracy; one constrained by seven and validated by three has a slightly worse accuracy that is known. For any deliverable that carries an accuracy statement, the second is worth more, because the statement on the first is not supported by anything.

There is also a practical answer for projects where every point genuinely matters: run the adjustment twice. Once with the split, to measure the accuracy, and once with every point as control, to produce the deliverable. The measured accuracy is then a slight overestimate of the delivered product’s error, which is the conservative direction and is exactly what an accuracy statement should be.

The cost is one extra adjustment run, which on any modern pipeline is minutes.

Parameter deep-dive

Parameter Typical Effect
Checkpoint fraction 0.3 Too high weakens the adjustment; too low weakens the measurement
Minimum control per cell 1 Prevents unconstrained extrapolation
Minimum checkpoints 5 Below this the statistics are not meaningful
Blunder threshold 3.5 Modified z-score; flags rather than excludes
Confidence level 95 % State it; a one-sigma figure is routinely misread
Split seed recorded Makes the split reproducible

Verification and output inspection

import numpy as np


def compare_control_and_checkpoints(control_residuals: np.ndarray,
                                    checkpoint_residuals: np.ndarray) -> dict:
    """The ratio that reveals whether a solution is fitting or measuring."""
    c = np.linalg.norm(np.asarray(control_residuals), axis=1)
    k = np.linalg.norm(np.asarray(checkpoint_residuals), axis=1)
    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_rmse_m": rms_c, "checkpoint_rmse_m": rms_k, "ratio": ratio,
            "verdict": ("checkpoints and control agree — the accuracy statement is sound"
                        if ratio < 2 else
                        "checkpoints are much worse than control — the solution is "
                        "fitting its constraints rather than the site")}

A ratio near one is the healthy state and is what a well-constrained survey produces. A ratio above about three is the doming signature described in diagnosing doming and bowl effect in reconstructions, and it means the quoted accuracy is not the survey’s.

What a checkpoint measures that a control residual does not Three rows. A control point residual measures how well the adjustment fitted the observations it was given, which is a statement about the fit and not about the survey. It improves as more freedom is given to the solution, which is why a reconstruction can report a shrinking control residual while getting worse. A checkpoint residual measures the difference between the model and a point the adjustment never saw, which is an estimate of the error a consumer will actually encounter. A checkpoint that was used as control at any stage measures neither, because the adjustment has already been pulled toward it. a control residual how well the fit matched what it was given — a statement about the fit a checkpoint residual the model against a point it never saw — the error a consumer meets a reused checkpoint neither, because the adjustment was already pulled toward it A control residual can shrink while accuracy worsens. That is not a subtlety, it is routine.

Figure 3 — Two numbers that look alike and answer different questions.

Checkpoint validation as a sequence with one irreversible step A five-stage sequence. Stage one observes more points in the field than the adjustment needs. Stage two splits them into control and checkpoints before the adjustment runs, which is the irreversible step — after the adjustment, no split is unbiased. Stage three runs the adjustment on the control alone. Stage four computes residuals at the checkpoints and nominates any blunders. Stage five records the statistics, the residuals, the split and the method in a manifest. A note states that stage two performed after stage three produces a figure that is a description of the fit rather than a test of it. 1. observe more points than the adjustment needs 2. split before the adjustment — the irreversible step 3. adjust on the control alone 4. evaluate residuals, and nominate any blunders 5. record statistics, residuals, split and method Stage 2 after stage 3 produces a description of the fit rather than a test of it.

Figure 4 — Five stages, one of which cannot be reordered.

Troubleshooting

Control residuals are excellent and checkpoints are poor. The solution absorbed error into its parameters. Check the intrinsics policy and the control distribution.

One checkpoint is far worse than the rest. A blunder. Inspect the marker and the identification before excluding it.

Checkpoint accuracy varies across the site. Control is unevenly distributed. The poor area is the one furthest from a control point.

Vertical accuracy is much worse than horizontal. Normal to a degree — vertical is typically one and a half to two times horizontal — but a factor of five suggests a datum or calibration problem.

The statistics change a lot with the split. Too few points. Report the uncertainty, or collect more control.

Accuracy is reported and no checkpoints were held out. The figure is a consistency measure. Say so, or hold points out and re-run.

Ground Control Point Optimization & Coordinate Sync