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.

Classifying ODM GCP residuals into three actionable patterns A top box parses the per-marker residuals into signed X, Y, and Z components and the RMSE. Three arrows fan down from it to three outcome boxes: a single-marker spike, meaning one component is far above the rest, which is fixed by dropping or re-shooting one marker; a systematic bias, meaning the error grows across the site or all markers shift the same way, which is a CRS or zone problem; and a vertical-only bias, meaning X and Y are clean while every Z is off by a similar amount, which is a geoid or datum mismatch. Parse per-marker residuals signed X / Y / Z · project RMSE one spike all shifted z-only Single-marker spike mis-tag / bad survey value → drop or re-shoot 1 marker Systematic bias all markers offset alike → CRS / zone problem Vertical-only bias XY clean · every Z off → geoid / datum fix

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.

Distributing GCP Errors Across Orthomosaics