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.

Where a drone image keeps each piece of metadata Three containers within one image file. Standard EXIF holds exposure, focal length, timestamp and a GPS position, and is universally readable. XMP holds the vendor's record of gimbal and airframe yaw, pitch and roll, relative altitude, RTK status and accuracy estimates, under vendor-specific namespaces that a generic reader misses. MakerNotes hold additional binary data whose layout changes between firmware versions. A note states that a pipeline reading only standard EXIF has no attitude information at all. standard EXIF exposure, ISO, focal length timestamp GPS latitude, longitude, altitude universally readable no attitude at all XMP gimbal yaw, pitch, roll airframe yaw, pitch, roll relative altitude, RTK status vendor namespaces a generic reader returns nothing MakerNotes vendor binary blob calibration, sensor state undocumented fields layout changes with firmware useful, never load-bearing Read all three once, into one record per image. Reaching for individual tags as they are needed is what makes vendor differences into bugs.

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.

Metadata faults ranked by what they cost downstream Four rows in decreasing severity. A missing or wrong coordinate reference for the GPS values stops reconstruction outright or places the survey in the wrong country, and is the cheapest fault to detect and the most expensive to miss. A wrong timestamp or timezone breaks any correlation with a post-processed trajectory, which silently degrades RTK and PPK work rather than failing it. A missing camera model prevents any calibration lookup and forces self-calibration on a survey that may not support it. A missing or wrong yaw, pitch and roll costs initialisation quality, which usually costs processing time rather than accuracy. GPS reference wrong or absent reconstruction stops, or the survey lands in another country timestamp or timezone wrong breaks trajectory correlation — degrades PPK silently camera model absent no calibration lookup, forcing self-calibration on a weak survey attitude absent or wrong costs initialisation quality, usually paid in time not accuracy The top two fail in ways nothing downstream reports as a metadata problem.

Figure 3 — Four faults, ordered by what they cost rather than by how often they occur.

Validation and repair as one pass over the dataset A four-stage pass. Stage one reads every image's metadata once into a table, because repeated per-image reads dominate the cost on a large survey. Stage two validates each field against what the pipeline requires, producing a per-image verdict rather than stopping at the first fault. Stage three repairs what can be repaired from other sources — a trajectory file, a flight log, a sibling image — and records each repair. Stage four writes the corrected metadata without recompressing the image data. A note states that a repair without a record is indistinguishable from an original value later. 1. read once one pass into a table; repeated reads dominate 2. validate a verdict per image, not a stop at the first 3. repair from trajectory, log or sibling images 4. write back without recompressing the image data A repair without a record is indistinguishable from an original value six months later.

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.

Core Photogrammetry Fundamentals for Python Pipelines