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.
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
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}
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.