Normalizing Timestamps and Timezones in EXIF

The PPK geotagging run reports that no camera events matched any image. The trajectory covers the flight, the image count is right, and the timestamps look correct in every viewer. They are out by exactly 3,600 seconds.

EXIF’s DateTimeOriginal has no timezone. It is a local wall-clock time with no indication of which locality, and whether it is UTC, local standard time or local summer time depends on how the camera’s clock was set — which is a decision somebody made months ago and did not record.

This page covers establishing what the timestamps actually mean, normalising them to a single frame, and handling the sub-second precision that trajectory matching depends on. It supports the ingest work in validating and repairing EXIF metadata at scale.

What each timestamp field actually means

DateTimeOriginal is when the shutter fired, in the camera’s local time, to one-second resolution. No offset.

OffsetTimeOriginal carries the UTC offset when the camera knows it, which many drone cameras do not write. When it is present, the ambiguity disappears.

SubSecTimeOriginal carries fractional seconds. Its absence is the quiet killer for PPK: at 8 m/s, one second of uncertainty is eight metres of position error, which is larger than everything the correction was meant to achieve.

GPSDateStamp and GPSTimeStamp are, when present, UTC by definition. That makes them the most useful field on the whole image for resolving the ambiguity, and they are routinely ignored.

The timestamp fields available on a drone image and what each resolves Four fields compared. DateTimeOriginal gives local wall-clock time to one-second resolution with no timezone, resolving nothing on its own. OffsetTimeOriginal gives the UTC offset when present, which removes the ambiguity entirely. SubSecTimeOriginal gives fractional seconds, without which one second of uncertainty is eight metres of position error at typical survey speed. GPS time stamp fields are UTC by definition and resolve the offset directly by comparison with DateTimeOriginal. A note records that the last of these is present more often than it is used. DateTimeOriginal local wall clock, 1 s resolution resolves nothing on its own OffsetTimeOriginal the UTC offset, when written removes the ambiguity entirely SubSecTimeOriginal fractional seconds without it, 8 m of PPK error GPSDateStamp / GPSTimeStamp UTC by definition resolves the offset directly The bottom-right field answers the question and is the one most often ignored. Comparing it against DateTimeOriginal gives the camera's offset, measured rather than assumed.

Figure 1 — Four fields, one of which resolves the ambiguity by itself.

Minimal reproducible solution

Measure the offset from the images rather than assuming it.

from datetime import datetime, timedelta, timezone

import numpy as np


def infer_utc_offset(records: list[dict]) -> dict:
    """Recover the camera's UTC offset by comparing local and GPS timestamps.

    GPSTimeStamp is UTC by definition, so the difference between it and
    DateTimeOriginal is the camera's offset — measured from the data rather
    than assumed from the site's location.
    """
    deltas = []
    for r in records:
        local = r.get("DateTimeOriginal")
        gps_date, gps_time = r.get("GPSDateStamp"), r.get("GPSTimeStamp")
        if not (local and gps_date and gps_time):
            continue
        try:
            lt = datetime.strptime(local, "%Y:%m:%d %H:%M:%S")
            gt = datetime.strptime(f"{gps_date} {gps_time}", "%Y:%m:%d %H:%M:%S")
        except ValueError:
            continue
        deltas.append((lt - gt).total_seconds())

    if not deltas:
        return {"offset_hours": None,
                "note": "no GPS timestamps — the offset cannot be measured"}

    arr = np.array(deltas)
    median = float(np.median(arr))
    spread = float(np.percentile(arr, 95) - np.percentile(arr, 5))
    rounded = round(median / 900) * 900          # offsets are quarter-hour multiples

    return {"offset_seconds": rounded,
            "offset_hours": rounded / 3600.0,
            "samples": int(arr.size),
            "spread_seconds": spread,
            "confident": spread < 3.0,
            "note": ("offset measured from GPS timestamps" if spread < 3.0 else
                     "GPS and local times disagree inconsistently — a clock drifted")}

Rounding to the nearest quarter hour encodes a real constraint: every timezone offset in use is a multiple of fifteen minutes, so a measured value of 3,598 seconds is one hour with a two-second clock error rather than an unusual offset.

Handling the survey that crosses a boundary

Two boundaries cause trouble, and both are rare enough to be forgotten and common enough to happen.

A daylight-saving transition during a flight shifts the wall clock by an hour partway through, so timestamps jump or repeat. A survey flown across the change is best handled by converting everything through the GPS timestamps rather than by applying a single offset.

A midnight crossing breaks any code that sorts on time-of-day rather than on a full datetime, and produces an ordering where the last frames of a flight precede the first.

import pandas as pd


def to_utc(df: pd.DataFrame, offset_seconds: int) -> pd.DataFrame:
    """Convert to UTC, preferring GPS time where it exists.

    A per-image preference for GPS time handles a daylight-saving transition
    for free, because each image carries its own unambiguous UTC reading.
    """
    out = df.copy()
    local = pd.to_datetime(out["DateTimeOriginal"], format="%Y:%m:%d %H:%M:%S",
                           errors="coerce")
    from_offset = (local - pd.Timedelta(seconds=offset_seconds)).dt.tz_localize("UTC")

    gps = pd.to_datetime(out.get("GPSDateStamp", "").astype(str) + " "
                         + out.get("GPSTimeStamp", "").astype(str),
                         format="%Y:%m:%d %H:%M:%S", errors="coerce",
                         utc=True)

    out["timestamp"] = gps.fillna(from_offset)
    out["timestamp_source"] = np.where(gps.notna(), "GPS (UTC)",
                                       f"local minus {offset_seconds}s")

    subsec = pd.to_numeric(out.get("SubSecTimeOriginal", 0), errors="coerce").fillna(0)
    out["timestamp"] = out["timestamp"] + pd.to_timedelta(subsec / 1000.0, unit="s")
    out["has_subsecond"] = subsec.gt(0)
    return out
Three timestamp faults, and how each announces itself Three rows. A naive local timestamp with no offset recorded is the common case, and it announces itself as a constant whole-hour discrepancy against a trajectory, which is easy to spot and easy to correct once suspected. A camera clock drifting against true time announces itself as a discrepancy that grows across the flight, and correcting it needs a linear fit rather than a constant offset. A camera clock set to a wholly wrong date announces itself immediately and is the least damaging of the three, precisely because nobody can overlook it. naive local time, no offset a constant whole-hour discrepancy against the trajectory clock drift a discrepancy that grows across the flight — needs a fit, not an offset a wholly wrong date obvious, and therefore the least damaging of the three The first is the dangerous one: a whole-hour error looks like a plausible time.

Figure 3 — Three faults, and the subtlest is the most common.

Edge-case matrix

Situation Symptom Handling
No offset tag, no GPS time Offset unknowable Assume, record the assumption, flag it
GPS time present Offset measurable Use it; the ambiguity disappears
Camera clock drifted GPS and local disagree inconsistently Use GPS time per image
DST transition mid-flight Timestamps jump or repeat Prefer GPS time per image
Midnight crossing Ordering breaks Sort on full datetime, never time of day
No sub-second field PPK matches poorly Match on sequence, or accept metre-level error
Sub-second in a vendor tag Appears absent Check XMP as well as EXIF
Two cameras, different clocks Timestamps not comparable Offset each separately

The no-sub-second case deserves a fallback rather than a failure, because it is common on older aircraft:

import numpy as np


def match_by_sequence(image_times, event_times) -> dict:
    """Pair images to camera events by order when sub-second time is unavailable.

    Requires that no image and no event is missing, which is checkable: the
    counts must match and the interval patterns must correlate.
    """
    if len(image_times) != len(event_times):
        return {"matched": False,
                "note": f"{len(image_times)} images against {len(event_times)} events "
                        "— sequence matching needs an exact correspondence"}
    di = np.diff(np.sort(np.asarray(image_times, dtype=float)))
    de = np.diff(np.sort(np.asarray(event_times, dtype=float)))
    corr = float(np.corrcoef(di, de)[0, 1]) if di.size > 2 else 0.0
    return {"matched": corr > 0.95, "interval_correlation": corr,
            "note": "sequence matching is safe only when the intervals agree"}

Verification snippet

import numpy as np
import pandas as pd


def verify_timestamps(df: pd.DataFrame, *, expected_interval_s: float) -> dict:
    """Do the normalised timestamps describe a single coherent flight?"""
    t = pd.to_datetime(df["timestamp"], utc=True).sort_values()
    gaps = t.diff().dt.total_seconds().dropna()

    problems = []
    if (gaps <= 0).any():
        problems.append("non-monotonic timestamps — a clock reset or a merge")
    if gaps.max() > 20 * expected_interval_s:
        problems.append(f"largest gap is {gaps.max():.0f} s — two flights merged")
    median = float(gaps.median()) if len(gaps) else float("nan")
    if abs(median - expected_interval_s) > 0.3 * expected_interval_s:
        problems.append(f"median interval {median:.1f} s against an expected "
                        f"{expected_interval_s:.1f} s — images may be missing")
    if not df.get("has_subsecond", pd.Series(dtype=bool)).all():
        problems.append("some images lack sub-second time — PPK accuracy will suffer")

    return {"images": len(df), "duration_min": float((t.max() - t.min()).total_seconds() / 60),
            "median_interval_s": median, "problems": problems}
Position error from timestamp uncertainty at survey speed A curve of position error against timestamp uncertainty for an aircraft flying at eight metres per second. At one millisecond the error is under a centimetre. At ten milliseconds it is eight centimetres. At one hundred milliseconds it is eighty centimetres. At one second, the resolution of the EXIF field without a sub-second tag, it is eight metres. A shaded band marks the region below three centimetres where the timing is not the limiting factor for a PPK survey. timing is not the limit here 1 ms 10 ms 100 ms 500 ms 1 s timestamp uncertainty position error 8 m — EXIF without sub-second 8 cm at 10 ms A centimetre-accurate trajectory is wasted on a one-second timestamp.

Figure 2 — Why the sub-second field decides whether a PPK survey was worth flying.

Setting the camera up so none of this is needed

Every technique above exists to recover information that could have been recorded correctly at capture. Three settings, checked once per aircraft, remove the problem permanently.

Set the camera clock to UTC. Not local time. The offset then has one value, it is zero, and nothing about daylight saving or site location enters the pipeline. Operators occasionally object because image timestamps in a file browser no longer match the time of day, which is a small cost against the class of failure it removes.

Enable GPS time stamping. Where the camera supports writing GPSDateStamp and GPSTimeStamp, turn it on. It makes every image self-describing in UTC and removes the need to infer anything.

Confirm sub-second recording. Where the camera can write SubSecTimeOriginal, it should. Where it cannot, that is a hard limit on what any PPK workflow with that aircraft can deliver, and it is better known before a client is promised centimetres.

A short checklist covering those three, run when an aircraft enters service and after every firmware update, is cheaper than any of the recovery code on this page — and firmware updates are exactly when these settings quietly revert.

When to escalate

  • No GPS timestamps and no offset tag. The offset cannot be measured from the images. Establish it from the flight log, or from a photograph of a clock, and record the method.
  • The camera clock drifted during the flight. GPS time per image handles it; a single offset does not. If neither is available, the survey’s timing is not trustworthy for PPK.
  • Sub-second time is absent and centimetre accuracy is required. The hardware cannot support the claim. Sequence matching helps only when nothing is missing.

Validating and Repairing EXIF Metadata at Scale