Fixing GCP File Format Errors in ODM

The control file is accepted, the run completes, and the residual report either shows nothing at all — as though no control were supplied — or shows errors of several metres on points that were surveyed to a centimetre. Nothing in the log says the file was rejected, because in most of these cases it was not: it was parsed, and it meant something other than what was intended.

This page enumerates the six ways that happens and gives one validator that catches all of them before submission.

The format, and the six ways it is misread

An ODM gcp_list.txt is a text file whose first line is a projection and whose remaining lines are whitespace-separated observations:

EPSG:25832
412883.21  5348102.66  118.44   2455  1837  DJI_0148.JPG
412883.21  5348102.66  118.44   1902  2210  DJI_0149.JPG
412883.21  5348102.66  118.44    877  1455  DJI_0151.JPG

Six columns: ground easting, northing, elevation, then image column, image row, then the filename. One line per observation, so a point marked in three photographs occupies three lines that share their first three values.

Each of the six fields has a characteristic failure.

The projection header is not validated against the coordinates. A file whose numbers are in one zone and whose header names another is parsed happily and shifts the whole survey.

Column order — easting before northing — is the opposite of the latitude-first convention some survey software exports. Swapped, the points land in a different part of the world, or, in a symmetric zone, somewhere plausible and wrong.

The pixel origin is the image’s top-left corner, column then row, at full resolution. A detector that returned row-then-column, or coordinates against a downsampled image, produces observations that are internally consistent and geometrically wrong.

Filename case must match the file on disk exactly. Unmatched rows are skipped silently.

Observation count below three per point leaves that point untriangulated and weakly constrained rather than rejected.

The delimiter must be whitespace. A file exported with commas parses as one field per line and yields zero usable observations, which at least fails visibly.

The six fields and the failure each admits A single control-file line broken into its six fields, with the characteristic failure of each annotated beneath. The projection header is unvalidated against the coordinates. Easting and northing can be exported in the opposite order. Elevation can be in a different vertical datum. The image column and row can be swapped or measured against a downsampled image. The filename can differ in case from the file on disk. A note states that five of the six failures leave a file that parses successfully, so a syntax check confirms nothing. EPSG:25832 · 412883.21 5348102.66 118.44 2455 1837 DJI_0148.JPG header never checked against the numbers below it easting / northing swapped by software that exports lat first elevation vertical datum unstated column top-left origin, full resolution row swapped with column by plotting libraries filename case-sensitive; unmatched rows skipped Five of the six leave a file that parses successfully. Only the comma-delimited case fails loudly, which makes it the least dangerous of the set.

Figure 1 — Every field has a plausible wrong value. The file format carries no redundancy, so nothing inside it can contradict a mistake.

Minimal reproducible solution

The validator checks each field against something outside the file: the imagery on disk, the image dimensions, and the camera positions.

from collections import Counter
from pathlib import Path

import numpy as np
from PIL import Image


def validate_gcp_list(path: Path, image_dir: Path,
                      camera_positions: dict[str, tuple[float, float]] | None = None,
                      min_views: int = 3) -> list[str]:
    """Return every problem found. An empty list means the file is consistent."""
    lines = [ln.strip() for ln in path.read_text().splitlines() if ln.strip()]
    problems: list[str] = []
    if len(lines) < 2:
        return ["file has a header and no observations"]

    header = lines[0]
    if not (header.upper().startswith("EPSG:") or header.startswith("+proj=")):
        problems.append(f"line 1 is not a projection: {header!r}")

    if "," in lines[1] and len(lines[1].split()) < 6:
        problems.append("rows appear comma-delimited; ODM expects whitespace")

    on_disk = {p.name for p in image_dir.iterdir() if p.is_file()}
    lower_map = {n.lower(): n for n in on_disk}
    sizes: dict[str, tuple[int, int]] = {}
    views: Counter = Counter()

    for n, row in enumerate(lines[1:], start=2):
        parts = row.split()
        if len(parts) < 6:
            problems.append(f"line {n}: {len(parts)} fields, expected 6")
            continue
        try:
            e, no, z, col, rowpx = (float(v) for v in parts[:5])
        except ValueError:
            problems.append(f"line {n}: non-numeric coordinate")
            continue
        name = parts[5]

        if name not in on_disk:
            hint = " (case differs)" if name.lower() in lower_map else ""
            problems.append(f"line {n}: image {name!r} not found{hint}")
            continue

        if name not in sizes:
            with Image.open(image_dir / name) as im:
                sizes[name] = im.size            # (width, height)
        w, h = sizes[name]
        if not (0 <= col < w and 0 <= rowpx < h):
            swapped = 0 <= rowpx < w and 0 <= col < h
            problems.append(
                f"line {n}: pixel ({col:.0f}, {rowpx:.0f}) outside {w}×{h}"
                + (" — column and row look swapped" if swapped else ""))

        views[(round(e, 3), round(no, 3), round(z, 3))] += 1

    for point, count in views.items():
        if count < min_views:
            problems.append(f"point {point} has {count} observation(s), need {min_views}")

    if camera_positions:
        problems.extend(_check_against_cameras(views, camera_positions))
    return problems


def _check_against_cameras(views, camera_positions) -> list[str]:
    """Control must lie within the flown footprint."""
    cams = np.array(list(camera_positions.values()), dtype=float)
    out = []
    for (e, n, _z) in views:
        d = float(np.min(np.linalg.norm(cams - np.array([e, n]), axis=1)))
        if d > 2000.0:
            out.append(f"point ({e:.1f}, {n:.1f}) is {d:,.0f} m from any camera "
                       "— check the projection header and the column order")
    return out

The pixel-bounds check is the one that earns the most. A swapped column and row is undetectable from the numbers alone on a square image and immediately detectable on a 4:3 one, because one of the two orderings puts a coordinate outside the frame. The validator says so explicitly rather than leaving the reader to notice.

Edge-case matrix

Fault Parses? Symptom in the report Caught by
Wrong projection header yes Metre-scale residuals or none Camera-distance check
Easting/northing swapped yes Points on the far side of the zone Camera-distance check
Column/row swapped yes Large residuals on non-square frames Pixel-bounds check
Downsampled pixel coordinates yes Residuals scale with the factor Pixel-bounds check, partly
Filename case mismatch yes Point silently absent Filename check
Comma-delimited no Zero observations Field-count check
Two observations per point yes Weak, high-residual point Observation-count check
Elevation in a different datum yes Uniform vertical bias Not caught — see below

The final row is the honest gap. Nothing in the file states the vertical datum of the elevation column, so a validator cannot detect an orthometric-versus-ellipsoidal mismatch. That check belongs with the datum metadata rather than with the file, and it is covered in resolving vertical datum mismatch in GCP heights.

The downsampled-coordinate row is caught only partly: coordinates from a half-resolution image fall inside the full-resolution frame, so the bounds check passes. What gives it away is that every observation clusters in the top-left quadrant, which is worth testing explicitly.

def assert_pixels_use_full_frame(observations, sizes, quadrant_frac: float = 0.9):
    """Observations confined to the top-left quadrant suggest downsampling."""
    in_tl = sum(1 for (c, r, name) in observations
                if c < sizes[name][0] / 2 and r < sizes[name][1] / 2)
    frac = in_tl / max(len(observations), 1)
    assert frac < quadrant_frac, (
        f"{frac:.0%} of observations fall in the top-left quadrant — pixel "
        "coordinates were probably measured on a downsampled image")

Verification snippet

After the file passes, confirm the reconstruction actually used it. A file that parsed and matched nothing produces a report with no control section at all, which is easy to overlook.

import json
from pathlib import Path


def assert_control_was_used(project: Path, expected_points: int) -> None:
    """The georeferencing stage must report the control it consumed."""
    report = project / "odm_georeferencing" / "odm_georeferencing_model_geo.txt"
    assert report.exists(), "no georeferencing report — control was not applied"

    text = report.read_text()
    assert "GCP" in text or "control" in text.lower(), (
        "georeferencing report mentions no control — the file was parsed and "
        "matched zero observations")

    stats = json.loads((project / "odm_report" / "stats.json").read_text())
    used = int(stats.get("gcp", {}).get("count", 0))
    assert used == expected_points, (
        f"{used} control points used, {expected_points} supplied — "
        "the difference is silently dropped observations")

Comparing the count against what was supplied is the check that closes the loop. Every silent failure in this page’s list ends the same way — fewer points than intended reached the solver — and one integer comparison detects all of them regardless of which field was wrong.

Supplied against used: the count that closes the loop A funnel from a supplied control file to the points actually used by the solver. Nine points are supplied as twenty-seven observations. Four observations are dropped because their filenames differ in case, removing one point entirely. Two more points fall below the three-observation minimum after a detector failure. The solver therefore uses six points, and the report states six while the operator believes nine. A note observes that comparing the supplied and used counts detects every silent failure in this page regardless of which field caused it. supplied 9 points · 27 observations after filename match 8 points · 23 observations after the 3-view minimum 6 points used −4 obs −2 points the report says 6; the operator believes 9 One integer comparison catches every silent failure on this page. It does not say which field was wrong — the validator does that — but it says that something was.

Figure 2 — The funnel. Each stage drops observations for a different reason and none of them is an error, so only the count at the far end reveals the loss.

When to escalate

  • The file validates and the residuals are still metre-scale. The format is right and the coordinates are in the wrong frame; that is the CRS mismatch case, which the camera-distance check will usually have flagged first.
  • Residuals are small horizontally and large vertically. A vertical datum difference, which no format validator can see.
  • The used count matches and one point is still an outlier. The file is fine and that observation is mis-tagged; see how to auto-tag GCPs in drone images for how to identify which observation rather than which point.

Troubleshooting GCP and Coordinate Errors

A swapped column and row is detectable on a non-square frame Two frames of the same 4000 by 3000 pixel image. In the first, an observation at column three thousand five hundred and row two thousand two hundred falls inside the frame and is correct. In the second, the same pair swapped places the observation at column two thousand two hundred and row three thousand five hundred, which is outside the three-thousand-pixel image height and therefore detectable by a bounds check. A note adds that on a square frame both orderings fall inside, so the bounds check cannot help and only the residual will reveal it. column, row — inside 4000 × 3000 px (3500, 2200) row, column — outside 4000 × 3000 px (2200, 3500) — row past 3000 On a square frame both orderings fall inside, and only the residual reveals the swap.

Figure 3 — Why the bounds check works at all. The frame’s aspect ratio is the redundancy the file format itself lacks, and a 4:3 sensor supplies it for free.