Validating and Repairing EXIF Metadata at Scale
Everything a photogrammetry pipeline knows about an image before it reads a pixel comes from its metadata: where the camera was, when the shutter fired, which way it was pointing, what lens it had. A survey of ten thousand images carries ten thousand copies of that record, written by firmware that varies by vendor, by model and by firmware version, and the pipeline has to treat them as one dataset.
The failures this produces are not dramatic. A few dozen images with a timestamp in the wrong timezone, a batch whose GPS altitude is above the ellipsoid where the rest are above the geoid, a vendor tag that moved between firmware releases. Each is small, each is silent, and each produces a reconstruction that converges on slightly the wrong answer.
This page covers reading the metadata a drone actually writes, validating it as a dataset rather than per image, and repairing what can be repaired. It sits upstream of everything in core photogrammetry fundamentals for Python pipelines, and it is the stage where EXIF GPS validation fits.
Audience and prerequisites. Python 3.10+, ExifTool available on the path, and a directory of images straight from the aircraft. Images that have been through a photo editor have usually lost the vendor tags this page depends on.
Prerequisites
| Library / tool | Minimum version | Install command | Role |
|---|---|---|---|
| ExifTool | ≥ 12.60 | system package | Reads and writes every tag that matters |
pyexiftool |
≥ 0.5 | pip install pyexiftool |
Batch access without a process per file |
piexif |
≥ 1.1 | pip install piexif |
Writing EXIF without recompressing |
numpy |
≥ 1.24 | pip install numpy |
Dataset-level statistics |
pyproj |
≥ 3.6 | pip install pyproj |
Altitude reference conversions |
Conceptual architecture
Drone metadata lives in three places, and a pipeline that reads only the first is missing most of it.
Standard EXIF carries the exposure, the focal length, the timestamp and a GPS position. It is well specified and universally supported, and it does not include anything about the aircraft’s attitude.
XMP carries the vendor’s own record: yaw, pitch and roll of both the gimbal and the airframe, the flight’s relative altitude, the RTK status and accuracy estimates. The tag names are vendor-specific and the namespaces differ, which is why a generic EXIF reader returns nothing useful from them.
MakerNotes carry whatever else the vendor chose, in a binary format that changes between firmware versions. Useful when documented and dangerous to depend on.
The practical consequence is that reading metadata means running ExifTool over the whole set once, into a structured record per image, rather than reaching for individual tags as they are needed.
Figure 1 — Three containers, one file. The attitude a pipeline needs is in the middle one.
Step 1: Read the whole set once
import exiftool
import pandas as pd
TAGS = [
"-EXIF:DateTimeOriginal", "-EXIF:SubSecTimeOriginal", "-EXIF:OffsetTimeOriginal",
"-EXIF:GPSLatitude", "-EXIF:GPSLongitude", "-EXIF:GPSAltitude",
"-EXIF:GPSAltitudeRef", "-EXIF:FocalLength", "-EXIF:ExposureTime",
"-EXIF:ISO", "-EXIF:Model", "-EXIF:Make",
"-XMP:GimbalYawDegree", "-XMP:GimbalPitchDegree", "-XMP:GimbalRollDegree",
"-XMP:FlightYawDegree", "-XMP:RelativeAltitude", "-XMP:RtkFlag",
"-XMP:RtkStdLon", "-XMP:RtkStdLat", "-XMP:RtkStdHgt",
]
def read_survey_metadata(paths: list[str], batch: int = 500) -> pd.DataFrame:
"""One structured record per image, from a small number of ExifTool calls.
ExifTool's startup cost dominates when it is invoked per file, so batching
turns an operation that takes twenty minutes on ten thousand images into
one that takes under a minute.
"""
rows = []
with exiftool.ExifToolHelper() as et:
for i in range(0, len(paths), batch):
chunk = paths[i:i + batch]
for meta in et.get_tags(chunk, TAGS):
rows.append({k.split(":")[-1]: v for k, v in meta.items()})
df = pd.DataFrame(rows)
df["path"] = [m.get("SourceFile") for m in rows] if "SourceFile" in df else paths
return df
Reading into a table rather than a per-image dictionary is the structural choice that makes everything after it easy. Dataset-level questions — is the timezone consistent, does the altitude reference change partway through, did the camera model change — are one line each on a table and awkward on ten thousand dictionaries.
Step 2: Validate as a dataset, not per image
Most metadata faults are only visible across the set. A single image with a plausible timestamp is fine; a set where the timestamps jump by an hour partway through is not.
import numpy as np
import pandas as pd
def validate_survey(df: pd.DataFrame) -> dict:
"""Dataset-level checks that no per-image validation would catch."""
problems = []
if df["Model"].nunique() > 1:
problems.append(f"{df['Model'].nunique()} camera models in one set: "
f"{sorted(df['Model'].dropna().unique())}")
if "GPSAltitudeRef" in df and df["GPSAltitudeRef"].nunique() > 1:
problems.append("GPS altitude reference changes within the set — "
"some images are above the ellipsoid and some above sea level")
times = pd.to_datetime(df["DateTimeOriginal"], errors="coerce",
format="%Y:%m:%d %H:%M:%S")
gaps = times.sort_values().diff().dt.total_seconds().dropna()
if (gaps > 3000).any():
problems.append(f"a gap of {gaps.max() / 60:.0f} minutes between consecutive "
"images — two flights merged, or a clock jump")
if (gaps < 0).any():
problems.append("timestamps are not monotonic — a clock reset mid-flight")
if "FocalLength" in df and df["FocalLength"].nunique() > 1:
problems.append(f"focal length varies: {sorted(df['FocalLength'].dropna().unique())}")
missing_gps = df["GPSLatitude"].isna().sum()
if missing_gps:
problems.append(f"{missing_gps} images have no GPS position")
attitude = [c for c in ("GimbalYawDegree", "GimbalPitchDegree") if c in df]
if not attitude:
problems.append("no gimbal attitude in the XMP — oblique handling will be blind")
return {"images": len(df), "problems": problems}
The altitude-reference check earns its place most often. A firmware update that changes GPSAltitudeRef between flights produces a dataset where half the images are referenced to the ellipsoid and half to mean sea level, differing by the geoid separation — tens of metres in many regions. The reconstruction absorbs it as a vertical offset between the two halves of the survey.
Step 3: Normalise what can be normalised
import pandas as pd
def normalise_timestamps(df: pd.DataFrame, *, assume_utc_offset_hours: float | None = None
) -> pd.DataFrame:
"""One timezone-aware timestamp column from whatever the camera wrote.
Cameras variously write local time with no offset, local time with an
offset tag, or UTC. Guessing is unavoidable when no offset is present, but
the guess must be recorded rather than applied silently.
"""
out = df.copy()
naive = pd.to_datetime(out["DateTimeOriginal"], errors="coerce",
format="%Y:%m:%d %H:%M:%S")
if "OffsetTimeOriginal" in out and out["OffsetTimeOriginal"].notna().any():
offsets = out["OffsetTimeOriginal"].fillna("+00:00")
out["timestamp"] = pd.to_datetime(
naive.dt.strftime("%Y-%m-%d %H:%M:%S") + offsets, utc=True, format="mixed")
out["timestamp_source"] = "offset tag"
elif assume_utc_offset_hours is not None:
out["timestamp"] = (naive - pd.Timedelta(hours=assume_utc_offset_hours)
).dt.tz_localize("UTC")
out["timestamp_source"] = f"assumed UTC{assume_utc_offset_hours:+g}"
else:
out["timestamp"] = naive.dt.tz_localize("UTC")
out["timestamp_source"] = "assumed already UTC — UNVERIFIED"
if "SubSecTimeOriginal" in out:
subsec = pd.to_numeric(out["SubSecTimeOriginal"], errors="coerce").fillna(0)
out["timestamp"] = out["timestamp"] + pd.to_timedelta(subsec / 1000, unit="s")
return out
Recording timestamp_source is what stops an assumption becoming a fact. A survey whose timestamps were assumed to be UTC and were actually local will fail to match a PPK trajectory by exactly the offset, and the column is what makes that diagnosable in a minute rather than an afternoon. The trajectory side of that is covered in applying PPK corrections to image timestamps.
Step 4: Repair, carefully
Some faults are repairable from within the dataset; others are not, and the distinction matters.
import numpy as np
import pandas as pd
def repair_missing_positions(df: pd.DataFrame, *, max_gap: int = 3) -> pd.DataFrame:
"""Interpolate positions for a few isolated images with no GPS fix.
Only for short gaps within a flight line, where the aircraft was flying a
straight, constant-speed path. A longer gap, or one spanning a turn, is
not interpolable and the images should be excluded instead.
"""
out = df.sort_values("timestamp").copy()
missing = out["GPSLatitude"].isna()
if not missing.any():
return out
runs = (missing != missing.shift()).cumsum()[missing]
for _, idx in out[missing].groupby(runs).groups.items():
if len(idx) > max_gap:
out.loc[idx, "position_status"] = "excluded: gap too long to interpolate"
continue
out.loc[idx, "position_status"] = f"interpolated across {len(idx)} images"
for col in ("GPSLatitude", "GPSLongitude", "GPSAltitude"):
short_gaps = out[col].isna() & (out.get("position_status", "").astype(str)
.str.startswith("interpolated"))
out.loc[short_gaps, col] = out[col].interpolate(limit=max_gap)[short_gaps]
return out
Marking rather than silently filling is the pattern throughout. An interpolated position is a reasonable estimate and is not a measurement, and a pipeline that cannot tell them apart afterwards has lost information it will want.
Step 5: Gate the survey before it reaches the reconstruction
Everything above produces information; a gate is what turns it into a decision. The useful shape is a small set of conditions that stop a survey at ingest, where fixing it is cheap, rather than after a four-hour reconstruction.
def gate_survey(report: dict, *, require_attitude: bool = False,
min_gps_fraction: float = 0.98,
max_models: int = 1) -> None:
"""Refuse a survey whose metadata cannot support the pipeline downstream."""
if len(report["models"]) > max_models:
raise ValueError(f"{len(report['models'])} camera models: {report['models']} "
"— split the dataset before processing")
if len(report["altitude_refs"]) > 1:
raise ValueError("GPS altitude reference is not constant across the survey — "
"resolve before the reconstruction absorbs it as a step")
gps_fraction = report["with_gps"] / max(report["images"], 1)
if gps_fraction < min_gps_fraction:
raise ValueError(f"only {gps_fraction:.1%} of images carry a GPS position")
if require_attitude and report["with_gimbal_attitude"] < report["images"]:
raise ValueError("gimbal attitude is missing on some images and this job needs it")
if report["timestamp_source"] and "UNVERIFIED" in str(report["timestamp_source"]):
raise ValueError("timestamps were assumed to be UTC without evidence — "
"confirm before matching against a trajectory")
The unverified-timestamp condition is worth having as a hard failure on any job that will use a PPK trajectory, and as a warning otherwise. It is the single assumption most likely to be wrong and least likely to be noticed.
Step 6: Keep the metadata table with the survey
The table produced in step 1 is worth more than the sum of the checks that read it. Stored alongside the imagery, it makes a great many later questions into queries: which flight line does this image belong to, what was the gimbal pitch when the reconstruction lost track, how many images were taken with an RTK fix.
Two conventions make it durable. Write it as a file next to the imagery, in a format anything can read — a CSV or a Parquet file, not a pickle. And record the ExifTool version that produced it, because tag extraction changes between releases and a table produced by one version may differ subtly from another’s.
from pathlib import Path
import subprocess
import pandas as pd
def persist_metadata(df: pd.DataFrame, survey_dir: str) -> str:
"""Store the metadata table with the survey, with its provenance."""
version = subprocess.run(["exiftool", "-ver"], capture_output=True,
text=True, check=True).stdout.strip()
out = Path(survey_dir) / "metadata.csv"
df.attrs["exiftool_version"] = version
df.to_csv(out, index=False)
(Path(survey_dir) / "metadata.provenance.txt").write_text(
f"exiftool {version}
images {len(df)}
columns {list(df.columns)}
")
return str(out)
On a fleet running weekly surveys, that table becomes the index for everything else: a year of flights queryable by date, camera, RTK status and duration, with no need to re-read a terabyte of imagery to answer any of it.
What happens when metadata is simply absent
Some datasets arrive with nothing useful: images renamed, stripped by an editor, or exported from software that discarded everything but the pixels. Three things are still possible, in decreasing order of usefulness.
Recover from a flight log. If the aircraft’s log survives, it carries positions and attitudes at a higher rate than the images were taken, and matching on capture order recovers most of what was lost. The matching is by sequence rather than by timestamp, so it depends on no images being missing.
Reconstruct without positions. Structure from motion does not require GPS; it requires overlap. A survey with adequate overlap reconstructs into a self-consistent model with an arbitrary scale and orientation, which ground control can then place. The cost is a slower, less reliable alignment and a hard dependency on control points.
Accept that the survey is not georeferenced. A model with no positions and no control is geometrically valid and has no location. That is a legitimate deliverable for some purposes and must be stated rather than implied.
None of these is as good as metadata that was never lost, which is the argument for reading and storing the table at ingest rather than assuming the originals will always be available.
Parameter deep-dive
| Field | Where | Typical fault | Repairable? |
|---|---|---|---|
DateTimeOriginal |
EXIF | No timezone offset | Only with external knowledge |
SubSecTimeOriginal |
EXIF | Absent on some models | No; PPK accuracy suffers |
GPSAltitudeRef |
EXIF | Changes between firmware | Yes, if the reference is known |
GPSLatitude |
EXIF | Missing on a few frames | Short gaps only |
GimbalPitchDegree |
XMP | Absent entirely | No |
RelativeAltitude |
XMP | Relative to take-off, not ground | Not a fault; must not be read as elevation |
RtkFlag |
XMP | Present but not checked | N/A; gate on it |
FocalLength |
EXIF | Varies on a zoom lens | No; split the dataset |
Model |
EXIF | Two cameras in one folder | Split the dataset |
The RelativeAltitude row is a recurring misunderstanding. It is the height above the take-off point, not above the terrain and not above any datum, so an aircraft that took off from a hill records a relative altitude that has no relationship to the ground it is flying over.
Verification and output inspection
import numpy as np
import pandas as pd
def metadata_report(df: pd.DataFrame) -> dict:
"""A summary that belongs in the run manifest for every survey."""
times = pd.to_datetime(df["timestamp"], errors="coerce", utc=True)
intervals = times.sort_values().diff().dt.total_seconds().dropna()
return {
"images": len(df),
"models": sorted(df["Model"].dropna().unique().tolist()),
"duration_min": float((times.max() - times.min()).total_seconds() / 60),
"median_interval_s": float(intervals.median()) if len(intervals) else None,
"timestamp_source": df["timestamp_source"].mode().iat[0]
if "timestamp_source" in df else None,
"with_gps": int(df["GPSLatitude"].notna().sum()),
"with_gimbal_attitude": int(df.get("GimbalPitchDegree",
pd.Series(dtype=float)).notna().sum()),
"rtk_fixed": int((df.get("RtkFlag", pd.Series(dtype=float)) == 50).sum()),
"altitude_refs": sorted(df.get("GPSAltitudeRef",
pd.Series(dtype=object)).dropna().unique().tolist()),
}
A median interval far from the expected capture rate is a quick signal that images are missing, and a duration much longer than a battery is a signal that two flights have been merged — both of which are easier to fix at ingest than after a reconstruction has tried to make sense of them.
Figure 3 — Four faults, ordered by what they cost rather than by how often they occur.
Figure 4 — One pass, four stages, and a record of every change.
Troubleshooting
No attitude data anywhere. The XMP was read with a generic EXIF library, or the images have been through an editor that stripped it. Re-read with ExifTool from the originals.
Timestamps are an hour out against a trajectory.
A timezone assumption. Check timestamp_source; if it says unverified, that is the answer.
Altitudes are tens of metres out for part of the survey.
GPSAltitudeRef changed. One half is above the ellipsoid and the other above sea level.
A reconstruction splits into two halves with a vertical step. Same cause as above, seen downstream.
Positions exist but the reconstruction ignores them. The values may be written as rationals the reader does not parse, or the hemisphere reference may be missing. See how to validate EXIF GPS data before processing.
Two camera models in one folder. Split the dataset. Mixed sensors need the treatment in handling mixed sensor data in photogrammetry pipelines.