Interpreting GCP Residual Reports in ODM
Your OpenDroneMap run finished, the project RMSE printed by odm_georeferencing reads 0.14 m, and you are about to sign off — but one marker in the report shows a Z residual of 0.62 m while every other value sits under 5 cm. Is that one bad ground control point, or is the whole flight tilted? The residual report holds the answer, but only if you read it as per-marker signed X/Y/Z components rather than a single averaged number. This page shows exactly what ODM’s georeferencing output contains, how to parse it in Python, and how the shape of the residuals tells you whether to drop a marker, re-shoot the observation, or escalate to a datum problem that no single GCP edit will fix.
Why ODM residual reports are misread
After bundle adjustment and georeferencing, ODM writes a georeferencing summary under odm_georeferencing/ (commonly odm_georeferencing_gcp_utm_offset.txt plus the per-marker statistics that also surface in the run log). For each control point it reports the residual — the vector from the surveyed coordinate to where the adjusted model actually placed the marker — as separate easting (X), northing (Y), and vertical (Z) components in metres, then aggregates them into a root-mean-square error. The trap is treating the aggregate RMSE as the quality metric. RMSE is a mean; it hides distribution. A project RMSE of 0.14 m can come from ten markers all at 0.14 m (a uniformly mediocre network) or from nine markers at 0.03 m and one at 0.60 m (a near-perfect network with one bad marker). Those two cases demand opposite actions — the first is a systematic problem, the second is a single outlier you delete — and the aggregate number cannot distinguish them.
The three signatures worth separating are: a single-marker spike (one X/Y/Z component far above the rest, usually a mis-clicked image observation or a fat-fingered surveyed coordinate); a systematic bias (every marker offset in the same direction, which is a CRS, zone, or axis problem, not a marker problem); and a vertical-only bias (X and Y clean, every Z off by a similar amount, which is an ellipsoidal-versus-orthometric height mismatch). Reading the per-marker components is how you tell them apart, and it is the first move in the broader distributing GCP errors across orthomosaics workflow.
Minimal reproducible solution
The routine below parses a residual table into per-marker records, computes each marker’s horizontal and total error, and flags any marker whose total residual exceeds a multiple of the project RMSE. That flag is the single decision the report exists to support: which marker, if any, is the outlier. It is intentionally small so the classification logic is auditable at a glance. Adapt the _parse_line split to your exact report layout — the columns are marker name, then signed X, Y, Z residuals in metres.
import math
from pathlib import Path
def _parse_line(line: str) -> tuple[str, float, float, float] | None:
# Expected: "gcp_name dx dy dz" (residuals in metres). Skip headers/blanks.
cols = line.split()
if len(cols) < 4:
return None
try:
return cols[0], float(cols[1]), float(cols[2]), float(cols[3])
except ValueError:
return None # header row, not data
def flag_outliers(report: Path, k: float = 3.0) -> list[dict]:
"""Return per-marker residual records, flagging any total error > k * RMSE."""
records = []
for line in Path(report).read_text().splitlines():
parsed = _parse_line(line.strip())
if parsed is None:
continue
name, dx, dy, dz = parsed
horiz = math.hypot(dx, dy) # horizontal magnitude
total = math.sqrt(dx * dx + dy * dy + dz * dz) # 3D residual magnitude
records.append({"marker": name, "dx": dx, "dy": dy, "dz": dz,
"horiz": horiz, "total": total})
if not records:
raise ValueError("no residual rows parsed — check the report path/format")
# Project RMSE from the 3D residuals, then flag markers over k * RMSE.
rmse = math.sqrt(sum(r["total"] ** 2 for r in records) / len(records))
for r in records:
r["rmse_ratio"] = r["total"] / rmse if rmse else 0.0
r["flag"] = "OUTLIER" if r["rmse_ratio"] > k else "OK"
return records
The k multiplier is the knob: k=3.0 flags a marker whose 3D residual is more than three times the project RMSE, which reliably catches a single mis-tagged point without tripping on ordinary spread. Lower it to 2.0 for a tight network where you want to scrutinise borderline markers. Critically, the routine flags but does not delete — deletion is a judgement call you make after checking whether the outlier is a bad observation (re-clickable) or a bad surveyed coordinate (must drop).
Production wrapper: iterate to a clean network
Flagging once is not enough, because removing the worst marker changes the RMSE and can promote a previously-borderline marker into a new outlier. The wrapper below re-computes after each removal, stops when no marker exceeds the threshold, and refuses to strip the network below a minimum count so it cannot dissolve your control entirely.
import logging
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
def iterate_removals(records: list[dict], k: float = 3.0,
min_markers: int = 5) -> list[dict]:
"""Iteratively drop the single worst outlier until the network is clean."""
kept = list(records)
while len(kept) > min_markers:
rmse = math.sqrt(sum(r["total"] ** 2 for r in kept) / len(kept))
worst = max(kept, key=lambda r: r["total"])
if rmse == 0 or worst["total"] / rmse <= k:
break # no outlier left — done
logging.info("Dropping %s (total %.3f m, %.1fx RMSE)",
worst["marker"], worst["total"], worst["total"] / rmse)
kept.remove(worst)
else:
logging.warning("Hit min_markers=%d floor — stop dropping, re-survey instead",
min_markers)
return kept
Hitting the min_markers floor is itself a signal: if you are still deleting markers when only five remain, the problem is not a handful of bad points, it is a bad flight or a systematic error, and you should stop and re-check the CRS before you delete your way to a meaningless “clean” RMSE.
Edge-case matrix
These are the residual patterns real ODM projects produce and what each one actually means.
| Residual pattern | Downstream symptom if unchecked | Expected handling |
|---|---|---|
| One marker’s total >> 3× RMSE, rest clean | that marker drags the adjustment, warps nearby tiles | flag, inspect the image observation, re-click or drop |
| All markers offset the same X/Y direction | whole model shifted, RMSE may still look small | systematic bias — stop, check CRS/zone, do not delete markers |
| X and Y under 5 cm, every Z ~0.3 m high | DSM sits above true ground, volumes wrong | vertical datum mismatch — apply a geoid via compound CRS |
| RMSE low but two adjacent markers both high | local warp in one block, corners fail tolerance | densify control there; distribute error across tiles |
| Marker present in report but dz blank/NaN | parser skips it, silent under-count | guard the float cast; treat unparsable Z as missing, not zero |
| Fewer markers in report than in gcp_list.txt | dropped markers had too few image observations | re-tag those markers with ≥3 observations before re-run |
A systematic bias masquerading as low RMSE is the dangerous one: because the error is uniform, the root-mean-square of the residuals about their own mean can look acceptable even though every point is displaced. Never read RMSE without also reading the mean signed residual per axis.
Verification snippet
After you decide which markers to keep, confirm the surviving network clears tolerance and that no single marker still dominates. This is the proof that a removal actually fixed the network rather than just hiding the number.
records = flag_outliers(Path("odm_georeferencing/gcp_residuals.txt"), k=3.0)
kept = iterate_removals(records, k=3.0, min_markers=5)
import statistics
totals = [r["total"] for r in kept]
assert kept, "no markers survived — network is not salvageable by removal"
assert max(totals) <= 0.10, f"worst residual {max(totals):.3f} m still over 0.10 m"
# No survivor should carry an outsized share after cleanup.
assert max(totals) < 3 * statistics.mean(totals) + 1e-9, "an outlier remains — re-inspect"
print(f"clean network: {len(kept)} markers, worst {max(totals):.3f} m")
If the mean-dominance assertion still fires after iterating, the report is telling you the residuals are structural, not a single bad point — which is your cue to leave the marker list alone and go looking for a datum or zone error.
When to escalate
Reading the report tells you what is wrong; fixing a systematic cause belongs to the parent guide and its siblings.
- The bias is systematic, not per-marker. Every marker offset the same way is a CRS, zone, or axis problem. Stop editing the marker list and run the ordered checks in troubleshooting GCP and coordinate errors.
- You have confirmed which markers are good but the block still warps. The residuals need spreading, not deleting — return to distributing GCP errors across orthomosaics for the weighting and block-wise correction approach.
- You are unsure whether 0.10 m is even the right threshold. The acceptance number depends on the deliverable class, derived in setting accuracy thresholds for survey projects.