Skip to content

Fix GPS epoch vs Unix epoch mix-up when geotagging CAMM videos - #828

Open
caglarpir wants to merge 2 commits into
mapillary:mainfrom
caglarpir:fix-camm-gps-epoch
Open

Fix GPS epoch vs Unix epoch mix-up when geotagging CAMM videos#828
caglarpir wants to merge 2 commits into
mapillary:mainfrom
caglarpir:fix-camm-gps-epoch

Conversation

@caglarpir

Copy link
Copy Markdown
Contributor

Summary

CAMM stores GPS time — CAMMGPSPoint.time_gps_epoch is seconds since the GPS epoch (1980-01-06), per the CAMM spec. GPX, GPMF and BlackVue all store Unix time. These two were being compared and rendered interchangeably, which is a 315,964,800 s (~10 year) error.

The crash

Geotagging a CAMM video from an external GPX made GPXVideoExtractor._gpx_offset() subtract a GPS time from a Unix time, so every GPX point got rebased ~10 years after the video start. At upload, camm_builder wrote that offset as the segment_duration of a version 0 elst, which is Int32sb:

construct.core.FormatFieldError: Error in path (building) -> data -> data -> data -> data
  -> entries -> items -> segment_duration
struct '>l' error during building, given value 315964800000

mapillary_tools then exits 1 and nothing is uploaded. Reproduced end-to-end on two real Labpano PanoX V2 captures (2.9 GB and 6.2 GB) with a sidecar GPX; both crash before the fix and both upload after it.

The silent variant

No crash, wrong data. sample_video, the GPX serializer and the description serializer each rendered a CAMM GPS timestamp directly as Unix time. On the videos above that stamps DateTimeOriginal/GPSDateTime as 2016-08-07 instead of 2026-08-12 — off by 3656 days — with exit code 0.

Changes

Epoch conversion is now explicit (telemetry.py)

  • gps_epoch_to_unix() / unix_to_gps_epoch(), including leap seconds. GPS time does not count them, so a naive +315964800 is currently 18 s off — about 250 m of positional error at highway speed, which matters for a mapping tool.
  • Point.get_unix_time() is the canonical wall-clock accessor. get_gps_epoch_time() is kept for the one place that needs GPS time: serializing CAMM. Both are now honest about their epoch on GPSPoint and CAMMGPSPoint.

Producers no longer store Unix time in a GPS-epoch field

  • parse_gpx() and uploader.prepare_camm_info() convert before populating time_gps_epoch. Previously a GPX-sourced upload wrote Unix time into CAMM type 6, so a spec-conformant reader decoded it as 2036.

Consumers go through get_unix_time()

  • _gpx_offset() compares Unix times, and by using the accessor it also ignores zero/invalid timestamps instead of treating them as 1970.
  • sample_video, serializer/gpx, serializer/description.
  • MAPGPSTrack[5] is now consistently Unix time. The schema described it as "GPS epoch time", but every consumer already read it as Unix, and it was only ever GPS-epoch for the CAMM-native path — i.e. the bug. Schema description updated to match.

Defence in depth

  • camm_builder falls back to a version 1 (64-bit) elst instead of overflowing, so an oversized offset can never abort an upload again.
  • Warn when a GPX has to be shifted more than a day to sync against a video.

Two unrelated bugs found while tracing this

  • camm_parser.extract_camm_info() filed every CAMM type 6 GPS point into CAMMInfo.mini_gps, leaving CAMMInfo.gps always empty — CAMMGPSPoint subclasses geo.Point and the isinstance checks were in the wrong order. Masked downstream by camm_info.gps or camm_info.mini_gps, so not currently user-visible, but it makes CAMMInfo.gps == [] mean the opposite of what it says and violates the declared types. Verified on a real capture: gps=0, mini_gps=1573 before, gps=1573, mini_gps=0 after.
  • SourceOption.from_dict() assigned to a misspelled .sourthe_path, silently dropping an explicit source_path passed alongside a pattern.

On leap seconds

The two test captures confirm the camera writes true GPS time: the first GPS sample plus the video duration lands 16 s after the mvhd creation_time, matching the 18 s GPS−UTC offset less a couple of seconds of file-finalization slop. Interpreting time_gps_epoch as GPS time also makes the derived timestamp agree with the camera's own filename (local capture time) to the second. The leap-second table needs an entry appended if one is ever announced; there has been none since 2017.

Testing

  • pytest tests — 726 passed, 18 skipped, 1 xfailed, 0 failures.
  • ruff check / ruff format --check / usort diff / mypy all clean.
  • New tests/unit/test_gps_epoch.py covers the conversion, both point accessors, GPX sync against CAMM and GoPro videos, invalid timestamps, the elst 64-bit fallback, and the source_path typo.
  • New cases in tests/unit/test_camm_parser.py for the type 5 / type 6 routing.
  • Existing tests that asserted the conflated behaviour were updated to state the epoch explicitly rather than to match new magic numbers.

Compatibility note

Description files written by earlier versions for CAMM-native videos carry a GPS-epoch value in MAPGPSTrack[5]; read back by this version they are interpreted as Unix, i.e. exactly as wrong as before — no regression, but re-running process will correct them. GPX-sourced description files are unaffected.

CAMM stores GPS time in CAMMGPSPoint.time_gps_epoch (seconds since
1980-01-06, per the CAMM spec), while GPX, GPMF and BlackVue all store
Unix time. Those two were being compared and rendered interchangeably,
which is a ~315,964,800s (10 year) error.

The visible symptom: geotagging a CAMM video from an external GPX made
GPXVideoExtractor._gpx_offset() subtract a GPS time from a Unix time, so
every GPX point was rebased ~10 years after the video start. At upload,
camm_builder wrote that as the segment_duration of a version 0 elst,
which is Int32sb, and the upload aborted with

  construct.core.FormatFieldError: Error in path (building) -> ... ->
  segment_duration struct '>l' error during building, given value
  315964800000

There is also a silent variant with no crash: sampled frames, exported
GPX and the description file all rendered a CAMM GPS timestamp directly
as Unix time, dating imagery ~10 years too early.

Changes:

- telemetry: add gps_epoch_to_unix()/unix_to_gps_epoch(), including
  leap seconds (GPS time does not count them, so a naive +315964800 is
  currently 18s off -- ~250m of error at highway speed).
- Point.get_unix_time() is now the canonical wall clock accessor;
  get_gps_epoch_time() is kept for the CAMM serialization boundary.
  Both are honest about their epoch for GPSPoint and CAMMGPSPoint.
- parse_gpx() and uploader.prepare_camm_info() now convert to GPS time
  when populating time_gps_epoch, instead of storing Unix time in it.
- _gpx_offset() compares Unix times via get_unix_time(), which also
  ignores zero/invalid timestamps rather than treating them as 1970.
- sample_video, the GPX serializer and the description serializer
  render get_unix_time(). MAPGPSTrack[5] is now consistently Unix time;
  the schema said "GPS epoch time" but every consumer read it as Unix.
- camm_builder falls back to a version 1 (64-bit) elst instead of
  overflowing, so an oversized offset can never abort an upload again.
- warn when a GPX has to be shifted more than a day to sync.

Two unrelated bugs found while tracing this:

- camm_parser filed every CAMM type 6 GPS point into CAMMInfo.mini_gps,
  leaving CAMMInfo.gps always empty, because CAMMGPSPoint subclasses
  geo.Point and the isinstance checks were in the wrong order.
- SourceOption.from_dict() assigned to a misspelled `.sourthe_path`,
  silently dropping an explicit source_path passed alongside a pattern.
@meta-cla meta-cla Bot added the cla signed label Sep 7, 2026
Follow-up to review of the previous commit. Treating time_gps_epoch as
uniformly GPS time was wrong, and broke two things.

What producers actually write in CAMM type 6 time_gps_epoch, measured
across 608 videos in the device corpus:

  Labpano Pilot One / Pilot Era / PanoX V2   GPS time    14 files
  Insta360 Pro                                Unix time    1 file
  mapillary_tools itself                      Unix time
  GPMF / NMEA sources (converted on write)    Unix time

Defect 1, read side: converting unconditionally put Insta360 Pro at
2030 and CAMM written by mapillary_tools at 2033.

Defect 2, write side: prepare_camm_info() converted GoPro/BlackVue/NMEA
timestamps to GPS time on the way out, so the uploaded artifact carried
capture times ~10 years in the past (a 2022-06-17 GoPro recording came
back as 2012-06-12). Read and write were inverses of each other, so the
round trip looked fine while being incompatible with every released
version. This is the higher severity of the two: it ships wrong data,
not just a red test.

Instead:

- CAMMGPSPoint.time_gps_epoch is renamed to epoch_time and now always
  holds Unix time, the same meaning GPSPoint.epoch_time already had.
  The old name described the CAMM box field, not the value in memory,
  which is what made both defects easy to write.
- The conversion happens exactly once, in camm_parser, keyed on the
  camera make, right after the samples are parsed. Nowhere else.
- The serializer writes epoch_time straight through, so on-disk stays
  Unix time exactly as every released version writes it. No format
  change, no migration needed, files stay readable both ways.
- get_gps_epoch_time() and unix_to_gps_epoch() are gone; nothing needed
  GPS time once the boundary was fixed. gps_epoch_to_unix() now has a
  single caller.

A camera writing GPS time that is not on the make list would be silently
wrong, so parsing also warns when the first GPS timestamp sits almost
exactly one GPS epoch from the container creation_time. It checks for
that specific distance rather than general implausibility because some
cameras write a meaningless creation_time -- a GoPro HERO7 recorded in
2022 reports 2016 -- which a generic bound would flag constantly.
@caglarpir

Copy link
Copy Markdown
Contributor Author

Pushed 4c21b35, which reworks the conversion after this review. Both defects are confirmed and fixed; the diagnosis was right and my original "time_gps_epoch is uniformly GPS time" premise was wrong.

What producers actually write

Swept all 608 videos in the device corpus. Only four CAMM type-6 producers exist in it, and they split exactly as the review said:

producer field contains files
Labpano Pilot One / Pilot Era / PanoX V2 GPS time 14
Insta360 Pro Unix time 1
mapillary_tools itself Unix time
GPMF / NMEA (converted on write) Unix time

Both defects reproduced, then fixed

Defect 1 (read). Confirmed: Insta360 Pro parsed as 2030. Now 2020-04-02, matching its container to within the 3 h of local time that camera writes into mvhd.

Defect 2 (write). Reproduced exactly on a real GoPro HERO7 — source 2022-06-17, generated CAMM read back as 2012-06-12. The commonality across the affected cases is not a missing-epoch fallback: it is the GPSPoint branch of prepare_camm_info(), i.e. every GPMF/NMEA/BlackVue source. CAMM sources pass through untouched, which is why only some cases showed it.

The fix

  • CAMMGPSPoint.time_gps_epochepoch_time, and it now always holds Unix time, the same meaning GPSPoint.epoch_time already had. The old name described the on-disk box field rather than the in-memory value, which is what made both defects easy to write.
  • Conversion happens once, in camm_parser, keyed on camera make, immediately after the samples are parsed. Nowhere else.
  • The serializer writes epoch_time straight through. No conversion in the serializer.
  • get_gps_epoch_time() and unix_to_gps_epoch() are deleted — nothing needed GPS time once the boundary was fixed. gps_epoch_to_unix() now has exactly one caller.

Compatibility: no change, and that is verified

The mp4 this branch writes is byte-identical (md5) to the one released main writes for the same input. On-disk stays Unix time, so no version marker or migration is needed:

written by main (released)   on-disk=1655503450.5  read-back=2022-06-17 22:04:10  OK
written by this PR           on-disk=1655503450.5  read-back=2022-06-17 22:04:10  OK
whole-file md5 identical: True

Acceptance criteria

1. Container cross-check — 9/10 devices pass within 24 h. The exception is a GoPro HERO7 whose mvhd says 2016-02-26 for a 2022-06-17 recording: a 6.3-year gap, not 10.01, so a broken camera clock rather than an epoch error. Two cameras also write local time into mvhd (Insta360 −3.0 h, GoPro MAX −1.0 h). So the criterion is a sound 10-year-error detector but "within a few minutes" is too tight to use as a gate.

2. Round trip is identity — verified end to end on a real GoPro, and covered by two new tests. Re-reading a CAMM written by the current release is correct, trivially so given the byte-identical output above.

3. exiftool agreement is uniform

source exiftool mapillary_tools delta
Insta360 Pro2 (Unix) 09:43:22.2 09:43:22 0 s
PANOX V2 (GPS) 11:50:15.3 11:49:57 −18 s
LabPano Pilot One (GPS) 06:57:32.1 06:57:14 −18 s
GoPro HERO7 22:00:29.1 22:04:10 +221 s

Zero for Unix producers, exactly −18 s for both GPS producers — the intended leap offset and nothing else. The GoPro row is not a conversion difference: unfiltered, our first point is 22:00:29, identical to exiftool. The 221 s is gpmf_gps_filter.remove_noisy_points dropping 979 leading fixes, which is pre-existing.

4. Residual check — relative to a golden generated from released main I predict the only remaining diffs are the Labpano/PANOX read correction of +315,964,782. Write-path output is byte-identical, so those 5 cases should now diff by nothing at all, and the 4 Insta360 / mapillary_tools-CAMM read cases should diff by nothing. Any surviving ±315,964,782 outside Labpano/PANOX would be a bug — please re-run and I will chase anything that remains.

One note on the "+18 s only" group of 76: measured against released main, which applies no conversion at all, I would expect those to show +315,964,782 for Labpano assets and zero elsewhere, not +18. If the +18 group persists it suggests the baseline already embeds a naive +315,964,800, which is worth knowing before re-baselining.

Safety net

A camera writing GPS time that is not on the make list would be silently wrong, so parsing now warns when the first GPS timestamp sits almost exactly one GPS epoch from the container creation_time. It tests for that specific distance rather than general implausibility precisely because of the HERO7 case above — a generic bound would fire constantly. Verified: silent on GoPro, fires when Labpano is removed from the list.

Suite: 733 passed, 18 skipped, 1 xfailed, 0 failures. ruff / usort / mypy clean. Windows CI will stay red until #829 lands (unrelated ffmpeg 9 issue).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant