Interpreting Reprojection Error Histograms
Every reconstruction report quotes a mean reprojection error, and it is close to useless on its own. A block with a 0.4 px mean can be excellent, can be a well-converged fit to partly wrong data, or can be a solver that discarded a third of its observations to reach that number. The distribution’s shape distinguishes them, and it costs one plot.
This page reads that shape: what a healthy distribution looks like, the three abnormal shapes and what each means, and why the spatial arrangement of the errors matters at least as much as the histogram.
What the distribution should look like
Reprojection error is the distance in pixels between where an observation was measured and where the solved geometry says it should be. On a sound block those residuals are dominated by measurement noise in the keypoint localisation, which is approximately Gaussian in each image axis — so their magnitude follows a Rayleigh-like shape: zero at zero, a single peak below one pixel, and a tail that decays quickly.
Three deviations from that shape are diagnostic.
A heavy tail — a visible population beyond three or four pixels — means outliers survived into the solution. Under a squared loss those observations dominate the cost, which is the divergence mechanism described in fixing OpenSfM bundle adjustment divergence.
A second mode — a distinct bump at two or three pixels — means two populations of observations are being fitted at once, typically two camera models or two flights whose intrinsics differ.
A distribution that is too tight — almost everything under 0.1 px — means the model has more freedom than the data constrains. That happens when self-calibration is estimating distortion parameters a small block cannot determine, and the price is paid at the block edges where nothing was measured.
Figure 1 — Four distributions that a single mean cannot tell apart. Reading the shape takes a glance and rules out three quite different faults.
Minimal reproducible solution
Compute the residuals per observation, then report the statistics that describe shape rather than centre.
import numpy as np
def residual_stats(residuals_px: np.ndarray) -> dict:
"""Shape-describing statistics for a reprojection residual set."""
r = np.asarray(residuals_px, dtype=float)
r = r[np.isfinite(r)]
if r.size < 100:
raise ValueError("too few observations to describe a distribution")
p50, p95, p99 = np.percentile(r, [50, 95, 99])
return {
"n": int(r.size),
"median_px": float(p50),
"p95_px": float(p95),
"p99_px": float(p99),
"max_px": float(r.max()),
# Tail heaviness: on a Rayleigh-like shape this ratio sits near 2.
"tail_ratio": float(p99 / max(p50, 1e-9)),
# Fraction beyond a threshold a sound block should almost never reach.
"beyond_3px": float((r > 3.0).mean()),
}
def classify_shape(stats: dict) -> str:
if stats["median_px"] < 0.08:
return "over-fitted: residuals are below plausible keypoint localisation noise"
if stats["beyond_3px"] > 0.01:
return f"heavy tail: {stats['beyond_3px']:.1%} of observations beyond 3 px"
if stats["tail_ratio"] > 5.0:
return f"tail ratio {stats['tail_ratio']:.1f} — outliers or a second population"
return "healthy"
The over-fitting bound deserves justification. Keypoint localisation on real imagery is good to roughly a tenth of a pixel at best; a median residual below that means the model is reproducing measurement noise, which it can only do by having spare parameters. On a small block that is almost always self-calibration estimating distortion terms the geometry does not determine.
tail_ratio is the cheapest single number for the shape. On a Rayleigh distribution the ratio of the 99th percentile to the median is about 2.2; anything above 5 means the tail is not the tail of a single population.
Edge-case matrix
| Distribution | Median | Beyond 3 px | Meaning |
|---|---|---|---|
| Healthy | 0.3–0.8 px | < 0.1% | Nothing to do |
| Heavy tail | 0.3–0.8 px | 1–5% | Outliers survived; use a robust loss |
| Bimodal | 0.5–1.2 px | < 1% | Two camera models or two flights |
| Over-fitted | < 0.1 px | ~0% | Too many free parameters |
| Uniformly high | 1.5–3 px | 5–20% | Wrong intrinsics, or blurred imagery |
| Tight with a few extremes | 0.4 px | < 0.1%, max > 20 px | A handful of gross mismatches |
| Bimodal at 0 and 2 px | — | — | Some observations were fixed, not solved |
| Heavy tail on one flight only | varies | localised | Flight-specific, not block-specific |
The last two rows are worth separating out. Observations pinned at exactly zero residual are not being adjusted at all — usually control points weighted so heavily they are effectively fixed, which makes their residual meaningless as a quality measure. And a tail confined to one flight is a data problem in that flight rather than a solver problem in the block.
Verification snippet
The histogram describes the population; the map describes where it lives. A block can have a textbook distribution and concentrate every large residual in one corner, which is a geometry problem the histogram cannot see.
import numpy as np
def assert_residuals_spatially_uniform(residuals_px: np.ndarray,
positions_xy: np.ndarray,
cells: int = 4, ratio: float = 2.0) -> None:
"""No region of the block may carry a much larger median residual.
Bins observations on a coarse grid over the block and compares each cell's
median against the block median, which catches a corner the histogram hides.
"""
x, y = positions_xy[:, 0], positions_xy[:, 1]
xi = np.clip(((x - x.min()) / (np.ptp(x) + 1e-9) * cells).astype(int), 0, cells - 1)
yi = np.clip(((y - y.min()) / (np.ptp(y) + 1e-9) * cells).astype(int), 0, cells - 1)
overall = float(np.median(residuals_px))
worst_cell, worst_val = None, 0.0
for i in range(cells):
for j in range(cells):
sel = (xi == i) & (yi == j)
if sel.sum() < 30:
continue
m = float(np.median(residuals_px[sel]))
if m > worst_val:
worst_cell, worst_val = (i, j), m
assert worst_val <= overall * ratio, (
f"cell {worst_cell} has a median residual of {worst_val:.2f} px against "
f"{overall:.2f} px overall — the error is concentrated, not distributed")
A concentrated residual field has a small number of causes and they are all worth knowing about: a region flown at a different altitude, an area of repetitive texture producing systematically wrong matches, or the edge of the block where camera poses are weakly constrained. All three are visible on the map and invisible in the histogram.
Figure 2 — Why the map matters as much as the histogram. The distribution answers “how large are the errors”; only the map answers “where”, and the second question is the one with an actionable answer.
What to record per run
Three numbers and one plot are enough to make this a routine check rather than an investigation, and all four come free from data the solver already produced.
Record the median, the 99th percentile and the fraction beyond three pixels in the run manifest alongside the engine version and the worker count. Those three describe the shape well enough that a comparison between two runs is meaningful, which a mean does not support: two blocks with the same mean can differ by an order of magnitude in their tail.
Write the per-cell residual map as a small raster next to the outputs. It costs a few kilobytes and it is the artefact that answers “was this region always like that?” when a client queries a particular corner of a deliverable months later.
Accumulated across a fleet, those numbers become a baseline. The useful property is not any single run’s value but the distribution of values across runs with the same camera and the same settings — a survey whose tail fraction is five times the fleet norm has something specific wrong with it, and that is visible immediately rather than after somebody thinks to look.
When to escalate
- The distribution is healthy and the checkpoint accuracy is poor. Reprojection error measures internal consistency, not accuracy. A block can be perfectly self-consistent and systematically displaced; that is what independent checkpoints exist to catch, as described in control points versus checkpoints.
- Residuals tighten every time self-calibration is given more freedom. That is the over-fitting signature. Fix the intrinsics from a calibration and solve for pose only; if the residuals rise substantially, the extra parameters were absorbing something real and the calibration is wrong.
- A second mode appears only after adding a flight. The two flights have different effective intrinsics — a different camera, a changed zoom, or a firmware update that altered the reported focal length. Reconstruct them with separate camera models rather than forcing one.
Related
- Optimizing bundle adjustment with Python
- Fixing OpenSfM bundle adjustment divergence
- Interpreting GCP residual reports in ODM
← Optimizing Bundle Adjustment with Python
Figure 3 — The reason a smaller residual is not automatically better. The two curves diverge at the point where extra parameters stop describing the camera and start describing the noise.