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.
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.
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.
Related
- Troubleshooting GCP and coordinate errors
- Ground control point optimization & coordinate sync
- Fixing a CRS mismatch between images and the GCP file
← Troubleshooting GCP and Coordinate Errors
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.