Choosing RMSE Thresholds by Survey Class
A project manager forwards you a check-shot report: the drone survey’s horizontal RMSE is 0.045 m and vertical RMSE is 0.08 m. Is that a pass? The honest answer is it depends on what the deliverable is — 0.08 m vertical is comfortable for a topographic contour map, marginal for a stockpile volume, and an outright failure for an as-built foundation check. The single most common accuracy-governance mistake in UAV mapping is applying one hard-coded RMSE number to every job regardless of what the client actually contracted. This page shows how to derive horizontal and vertical thresholds per survey class, encode them in a table, and wire that table into an automated acceptance gate so a run that misses its class tolerance fails loudly before anyone signs a deliverable.
Why one RMSE threshold cannot serve every deliverable
RMSE is the root-mean-square of the residuals between checked coordinates and their surveyed truth. For a single axis of check points it is
where is the computed coordinate and the surveyed truth. Horizontal accuracy combines the easting and northing components in quadrature,
and vertical accuracy is reported separately because height error is driven by different physics — weak vertical geometry in nadir imagery and geoid/datum handling rather than tie-point matching. The reason a single threshold fails is that these two numbers must be judged against the tolerance the deliverable can absorb, and that tolerance varies by an order of magnitude across classes. A contour map with a 0.25 m contour interval tolerates several centimetres of vertical error invisibly; a volumetric stockpile survey converts every centimetre of vertical bias directly into tonnes of miscounted material; an as-built engineering check must resolve position tighter than the construction tolerance it verifies. Deriving the number is the point of the parent setting accuracy thresholds for survey projects workflow; this page turns those derivations into a class-keyed gate.
Minimal reproducible solution
The solution has two parts: a threshold table that encodes the class-specific tolerances, and a gate function that looks up the right row and evaluates a measured horizontal and vertical RMSE against it. The thresholds below are engineering starting points for RTK/PPK-flown surveys with checked control — tighten them to your contract and local specification, never loosen them to make a marginal run pass.
| Survey class | Horizontal RMSE ≤ | Vertical RMSE ≤ | Typical driver |
|---|---|---|---|
| Reconnaissance / orthophoto | 0.10 m | 0.20 m | visual context, no measurement |
| Topographic mapping | 0.05 m | 0.10 m | contour interval, planimetric detail |
| Volumetric / stockpile | 0.03 m | 0.05 m | height error → volume error |
| As-built / engineering | 0.02 m | 0.03 m | construction tolerance verification |
The gate keys on the survey class with a match statement, computes the combined horizontal RMSE from the per-axis values in quadrature, and returns a structured pass/fail with the reason — so a failing run cannot be silently rounded into acceptance.
from dataclasses import dataclass
from math import hypot
# Class -> (horizontal RMSE limit, vertical RMSE limit) in metres.
THRESHOLDS: dict[str, tuple[float, float]] = {
"recon": (0.10, 0.20),
"topographic": (0.05, 0.10),
"volumetric": (0.03, 0.05),
"as_built": (0.02, 0.03),
}
@dataclass(frozen=True)
class AcceptanceResult:
survey_class: str
passed: bool
rmse_h: float
rmse_v: float
reason: str
def acceptance_gate(survey_class: str, rmse_x: float, rmse_y: float,
rmse_z: float) -> AcceptanceResult:
"""Evaluate measured per-axis RMSE against the class thresholds."""
match survey_class:
case cls if cls in THRESHOLDS:
h_limit, v_limit = THRESHOLDS[cls]
case _:
raise KeyError(f"unknown survey_class {survey_class!r}; "
f"valid: {sorted(THRESHOLDS)}")
rmse_h = hypot(rmse_x, rmse_y) # combine E/N in quadrature
h_ok = rmse_h <= h_limit
v_ok = rmse_z <= v_limit
if h_ok and v_ok:
reason = "within class tolerance"
elif not h_ok and not v_ok:
reason = f"horizontal {rmse_h:.3f}>{h_limit} and vertical {rmse_z:.3f}>{v_limit}"
elif not h_ok:
reason = f"horizontal {rmse_h:.3f} m exceeds {h_limit} m"
else:
reason = f"vertical {rmse_z:.3f} m exceeds {v_limit} m"
return AcceptanceResult(survey_class, h_ok and v_ok, rmse_h, rmse_z, reason)
The design choice that matters is returning a structured result rather than a bare boolean: the reason string tells the operator which axis failed, which immediately narrows the fix — a horizontal failure points at control distribution or a CRS problem, a vertical failure points at datum handling or weak height geometry.
Edge-case matrix
These are the inputs that make a naive gate give the wrong verdict.
| Input variant | Downstream symptom if unchecked | Expected handling |
|---|---|---|
| Horizontal passes, vertical fails | orthomosaic accepted, DSM/volumes wrong | evaluate axes independently, never a single blended RMSE |
rmse_x and rmse_y reported separately |
operator averages instead of combining | combine in quadrature with hypot, not arithmetic mean |
Unknown survey_class string |
gate defaults to a lax class, bad data passes | raise KeyError, never fall back to a permissive default |
| RMSE computed on GCPs used in adjustment | over-optimistic — measures fit, not accuracy | compute on independent check points, not control points |
| Thresholds hard-coded per script | drift between jobs, no audit trail | keep one central table, load it, log the version used |
| Vertical RMSE in feet, limit in metres | unit mismatch silently passes/fails | normalise units at ingestion; assert metres before the gate |
The check-point versus control-point distinction is the subtle one: if you compute RMSE against the same markers the bundle adjustment already fitted, you are measuring how well the solver minimised its own cost function, not independent accuracy. Reserve a subset of surveyed markers as check points, exclude them from adjustment, and run the gate on those.
Verification snippet
Assert that the gate accepts a clean run at its class and rejects a run that busts vertical tolerance, so the gate’s own logic is proven before it guards a real deliverable.
# A topographic run: horizontal 0.032 m combined, vertical 0.07 m — should pass.
ok = acceptance_gate("topographic", rmse_x=0.021, rmse_y=0.024, rmse_z=0.07)
assert ok.passed, ok.reason
assert ok.rmse_h <= 0.05 and ok.rmse_v <= 0.10
# The same numbers judged as volumetric must fail on vertical (limit 0.05 m).
strict = acceptance_gate("volumetric", rmse_x=0.021, rmse_y=0.024, rmse_z=0.07)
assert not strict.passed
assert "vertical" in strict.reason
print("gate verified:", ok.reason, "|", strict.reason)
The second assertion is the whole point of class-keyed thresholds: identical measured accuracy is a pass for one deliverable and a fail for another, and the gate encodes that difference instead of leaving it to a human eyeballing a report.
When to escalate
The gate decides pass/fail; when it fails, the fix lives in the sibling clusters.
- The gate fails and you need to know why the residuals are high. Read the per-marker report and separate a bad marker from a bad flight in interpreting GCP residual reports in ODM.
- Horizontal failed with a structured offset. That is a coordinate error, not a threshold one — run the ordered diagnosis in troubleshooting GCP and coordinate errors.
- Residuals cluster in one part of the block. The error needs redistributing, covered in distributing GCP errors across orthomosaics.