How to Validate EXIF GPS Data Before Processing
If a reconstruction job suddenly collapses every camera to a single point off the coast of West Africa, produces a kilometre-wide georeferencing offset, or aborts during bundle adjustment with a divergence warning, the root cause is almost always malformed EXIF GPS metadata that entered the pipeline unchecked. Validating EXIF GPS data before processing is the deterministic gate that stops null coordinates, flipped hemispheres, and miscast rational numbers from reaching the structure-from-motion stage. This page shows the exact symptom each failure produces, a minimal Python routine that catches all of them, and the escalation path when the imagery itself cannot be salvaged.
Why EXIF GPS data breaks photogrammetry pipelines
Drone cameras write geospatial metadata into a dedicated GPS sub-IFD (tag 0x8825) rather than the main image IFD, and every coordinate is stored as a rational — a pair of integers (numerator, denominator) — per the Exif 2.32 specification. Latitude and longitude are not single numbers; they are three rationals each, encoding degrees, minutes, and seconds, with a separate single-character reference tag (GPSLatitudeRef, GPSLongitudeRef) that carries the hemisphere sign. This layered encoding is where pipelines silently break, and the failures cluster into three deterministic patterns:
- Null or zeroed coordinates (“Null Island”). Firmware bugs, power cycling mid-capture, or a GPS lock that never acquired will write
0/0or0.0across the GPS tags. Structure-from-motion engines treat(0, 0)as a perfectly valid fix, so the entire project collapses to the Gulf of Guinea at 0°N 0°E. Bundle adjustment then either diverges outright or returns a geometrically plausible but spatially meaningless model. - Reference-tag mismatch. When
GPSLatitudeReforGPSLongitudeRefis missing or wrong (Nwritten whereSbelongs), naive parsers default to a positive value and flip the hemisphere. The result is a multi-kilometre offset that quietly breaks alignment against any surveyed control points. - Rational-to-float conversion errors. A parser that divides denominator by numerator, ignores the denominator, or truncates to an integer yields coordinates scaled by a million or stripped of precision. This is most common when migrating between
Pillowversions or wrapping legacypiexifoutput without explicit type casting.
Because metadata integrity directly governs whether bundle adjustment converges, this check belongs at the very front of the ingestion stage described in Core Photogrammetry Fundamentals for Python Pipelines and immediately before any job is handed to OpenDroneMap setup.
Minimal reproducible solution
The routine below parses the GPS sub-IFD with Pillow, converts the DMS rationals to decimal degrees, applies the hemisphere reference, and rejects the three failure modes above. It is intentionally focused — under 60 lines, no batch machinery — so the validation logic is auditable at a glance. Pillow returns rationals as IFDRational (a float subclass) in most cases, but a (numerator, denominator) tuple can still appear from raw or legacy sources, so both forms are handled with a zero-denominator guard.
from pathlib import Path
from PIL import Image
from PIL.ExifTags import GPSTAGS
def _rational(v) -> float:
# Pillow returns IFDRational (a float subclass) OR a (num, den) tuple.
if isinstance(v, (tuple, list)):
num, den = v
return float(num) / float(den) # ZeroDivisionError on den == 0
return float(v)
def _dms_to_deg(dms) -> float:
# GPSLatitude/GPSLongitude are stored as (deg, min, sec) rationals.
d, m, s = (_rational(x) for x in dms)
return d + m / 60.0 + s / 3600.0
def validate_gps(path: Path) -> tuple[float, float] | None:
with Image.open(path) as img:
gps = img.getexif().get_ifd(0x8825) # 0x8825 = GPS sub-IFD
if not gps:
return None # MISSING_GPS_IFD
tags = {GPSTAGS.get(k, k): v for k, v in gps.items()}
if "GPSLatitude" not in tags or "GPSLongitude" not in tags:
return None # MISSING_COORD_TAGS
lat = _dms_to_deg(tags["GPSLatitude"])
lon = _dms_to_deg(tags["GPSLongitude"])
if tags.get("GPSLatitudeRef", "N") == "S": # apply hemisphere sign
lat = -lat
if tags.get("GPSLongitudeRef", "E") == "W":
lon = -lon
if not (-90.0 <= lat <= 90.0) or not (-180.0 <= lon <= 180.0):
return None # OUT_OF_BOUNDS
if abs(lat) < 1e-9 and abs(lon) < 1e-9:
return None # NULL_ISLAND (0, 0) reject
return (lat, lon)
The key design choice is that validate_gps returns None for any unusable image rather than raising — callers branch on the return value, and a single corrupt frame never aborts a batch. Map each None path to the failure code in the comment when you need a per-image reason for the rejection.
Edge-case matrix
These are the input variants that production UAV datasets actually contain, the symptom each one produces downstream, and how the routine above handles it.
| Input variant | Downstream symptom if unchecked | Expected handling |
|---|---|---|
Missing GPS sub-IFD (0x8825) |
SfM falls back to relative reconstruction, output is unreferenced | get_ifd returns empty → None (MISSING_GPS_IFD) |
GPSLatitude present, GPSLongitude absent |
Parser raises KeyError mid-batch, job dies |
Tag presence check → None (MISSING_COORD_TAGS) |
Coordinates 0/0 (“Null Island”) |
Project collapses to 0°N 0°E, bundle adjustment diverges | abs(lat) < 1e-9 and abs(lon) < 1e-9 → None |
GPSLongitudeRef = "W" missing on a Western flight |
Hemisphere flip, multi-kilometre georeferencing offset | Defaults applied, sign honoured when ref present |
Rational denominator 0 |
ZeroDivisionError aborts the run |
_rational raises explicitly → catch and reject the frame |
Altitude 0.0 from barometric dropout |
Z-drift in the dense cloud and DSM | Add an altitude gate (see production wrapper) |
A wrong but present hemisphere reference (firmware writing N where S belongs) cannot be detected from the EXIF alone — it requires a coarse expected-region bounding box, which is where the production wrapper below earns its place.
Production batch wrapper
The minimal routine answers “is this one image’s GPS usable?” A pipeline also needs an altitude sanity gate, a project-wide bounding box to catch the undetectable hemisphere flip, a minimum valid-image ratio so a mostly-broken flight aborts early, and a CSV audit trail. The wrapper preserves those checks without obscuring the core logic.
import sys
import logging
from pathlib import Path
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
# Project-specific sanity bounds — tighten these to the survey region.
LAT_BOX = (-90.0, 90.0)
LON_BOX = (-180.0, 180.0)
ALT_BOUNDS = (-500.0, 15000.0) # metres; outside = sensor/baro failure
def in_bounds(lat: float, lon: float) -> bool:
return LAT_BOX[0] <= lat <= LAT_BOX[1] and LON_BOX[0] <= lon <= LON_BOX[1]
def batch_validate(image_dir: Path, report_csv: Path,
min_valid_ratio: float = 0.95) -> None:
images = sorted(image_dir.glob("*.JPG")) + sorted(image_dir.glob("*.jpg"))
if not images:
logging.error("No JPEG images found in %s", image_dir)
sys.exit(1)
rows, valid = [], 0
for img in images:
coords = validate_gps(img) # reuse the minimal routine
ok = coords is not None and in_bounds(*coords)
valid += int(ok)
lat, lon = coords if coords else ("", "")
rows.append(f"{img.name},{ok},{lat},{lon}")
ratio = valid / len(images)
logging.info("Valid GPS: %d/%d (%.1f%%)", valid, len(images), ratio * 100)
if ratio < min_valid_ratio:
logging.critical("Below %.0f%% threshold — aborting before SfM.",
min_valid_ratio * 100)
sys.exit(2)
report_csv.write_text("image,valid,lat,lon\n" + "\n".join(rows) + "\n")
logging.info("Wrote audit report to %s", report_csv)
Tighten LAT_BOX and LON_BOX to the actual survey extent (for example a 1° box around the project centroid) and the wrapper will reject the hemisphere-flip case that EXIF alone cannot. The min_valid_ratio gate is the one that saves compute: a flight where GPS logging silently failed aborts in seconds instead of after hours of feature extraction. Mirror this acceptance ratio against ODM’s --gps-accuracy weighting when you align coordinate handling with managing coordinate reference systems in GDAL, and feed the same validated image list to the flight overlap validation routine before submitting any job.
Acceptance thresholds
Enforce these numeric boundaries before feature extraction. They are derived from coordinate-system limits and empirical SfM convergence behaviour, not arbitrary padding:
- Latitude:
-90.000000to90.000000decimal degrees. - Longitude:
-180.000000to180.000000decimal degrees. - Altitude (ellipsoidal or MSL):
-500.0to15000.0metres; values outside indicate sensor drift or a barometric calibration fault. - Coordinate precision: at least 6 decimal places for sub-metre work — truncation below 4 decimals causes feature-matching jitter in high-GSD surveys.
- Horizontal accuracy / DOP: reject above
5.0for standard surveys, or above0.1for RTK/PPK workflows. - Project-wide valid ratio: below 85%, disable automatic georeferencing and rely on surveyed control points or external navigation logs.
For edge cases in rational reduction, the standard library fractions documentation covers exact integer-ratio handling, and version-specific tag behaviour is documented in Pillow’s EXIF handling guide.
Verification snippet
Confirm the fix on a single known-good frame before trusting it across a flight. This asserts the routine returns a coordinate, that it falls within global bounds, and — critically — that it is not the silent (0, 0) collapse.
from pathlib import Path
coords = validate_gps(Path("DJI_0001.JPG"))
assert coords is not None, "GPS validation failed — inspect the 0x8825 sub-IFD"
lat, lon = coords
assert -90.0 <= lat <= 90.0 and -180.0 <= lon <= 180.0, "Coordinate out of range"
# Null Island guard: a (0, 0) fix collapses SfM to the Gulf of Guinea.
assert not (abs(lat) < 1e-6 and abs(lon) < 1e-6), "Coordinates resolve to 0, 0"
print(f"OK: {lat:.7f}, {lon:.7f}")
For a whole dataset, assert on the return code of batch_validate instead: a clean exit means the valid-image ratio cleared the threshold, a non-zero exit means the flight should not proceed to reconstruction.
When to escalate
This validation layer rejects bad metadata; it cannot manufacture good metadata. Stop here and return to Setting Up OpenDroneMap with Python for the recovery workflow when:
- The valid ratio is structurally low (GPS logging failed in flight). Run a GPS-free relative reconstruction (
--ignore-gpsto OpenSfM/COLMAP) and georeference afterward against surveyed control points or an RTK base log — the parent guide covers wiring this fallback into ODM. - Coordinates are present but systematically offset (a real hemisphere flip or datum mismatch). This is a coordinate-transformation problem, not a validation one; resolve it with the coordinate transformation workflows in pyproj before re-running ingestion.
- GPS was intentionally disabled (indoor or GPS-denied capture). Skip EXIF georeferencing entirely and drive alignment from surveyed targets via automating GCP detection with Python.