Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 12 additions & 7 deletions mapillary_tools/geotag/video_extractors/gpx.py
Original file line number Diff line number Diff line change
Expand Up @@ -417,26 +417,31 @@ def _read_video_clock(
if not pairs:
return None

# Anchored at its own video time, which is not 0 when the video's GPS
# starts late (e.g. GoPro drops noisy fixes at the start of a recording)
video_time, unix_time = pairs[0]

rate = cls._estimate_clock_rate(pairs)
# One timestamp far off (e.g. a placeholder date before GPS time is
# acquired) can pull the least-squares slope far off, so a timelapse
# must show in the median step between neighbouring pairs too. That is
# too coarse to measure the rate, but not to tell a timelapse from a
# normal video
step_rate = cls._median_step_rate(pairs)
if (
if not (
rate is not None
and _MIN_TIMELAPSE_RATE < rate
and step_rate is not None
and _MIN_TIMELAPSE_RATE < step_rate
):
return VideoClock(video_time=video_time, unix_time=unix_time, rate=rate)
rate = 1.0

return VideoClock(video_time=video_time, unix_time=unix_time)
# Anchored at its own video time, which is not 0 when the video's GPS
# starts late (e.g. GoPro drops noisy fixes at the start of a recording),
# and at the median of all its timestamps mapped back to that time, not
# at the first alone: that can be seconds off the rest (the first GPS
# block of a GoPro timelapse, a stale first fix), and some GPS clocks
# wander by a second or so against the video
video_time = pairs[0][0]
unix_time = statistics.median(u - (v - video_time) * rate for v, u in pairs)

return VideoClock(video_time=video_time, unix_time=unix_time, rate=rate)

@classmethod
def _estimate_clock_rate(
Expand Down
4 changes: 3 additions & 1 deletion tests/integration/test_process.py
Original file line number Diff line number Diff line change
Expand Up @@ -688,7 +688,9 @@ def test_process_video_geotag_source_with_gpx_outside_video(
assert len(descs) == 1
assert descs[0]["error"]["type"] == "MapillaryOutsideGPXTrackError"
assert descs[0]["error"]["vars"] == {
"image_time": "2019_11_18_23_44_42_590",
# Its first GPS timestamp is 23:44:42.590, a second before the rest
# place the start of the video
"image_time": "2019_11_18_23_44_43_613",
"gpx_start_time": "2025_03_14_07_00_00_000",
"gpx_end_time": "2025_03_14_07_01_33_624",
}
Expand Down
17 changes: 17 additions & 0 deletions tests/unit/test_gpx_video_clock.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,23 @@ def test_block_timestamps_anchor_at_the_block_start(self):
def test_one_jump_does_not_make_a_timelapse(self, pairs):
assert _read(pairs).rate == 1.0

@pytest.mark.parametrize("rate", [1.0, 30.0])
def test_a_first_timestamp_off_the_rest_does_not_anchor_the_clock(self, rate):
"""The first GPS block of a GoPro timelapse can be stamped 3s early."""
pairs = [(0, A_UNIX_TIME - 3)] + [
(i / rate, A_UNIX_TIME + i) for i in range(1, 60)
]
clock = _read(pairs)
assert clock.rate == pytest.approx(rate, rel=0.05)
assert clock.to_unix_time(0) == pytest.approx(A_UNIX_TIME, abs=0.2)

def test_a_placeholder_date_does_not_anchor_the_clock(self):
"""GoPro stamps a placeholder date until it acquires GPS time."""
pairs = [(i, PLACEHOLDER_UNIX_TIME + i) for i in range(5)] + [
(i, A_UNIX_TIME + i) for i in range(5, 15)
]
assert _read(pairs).to_unix_time(0) == A_UNIX_TIME

def test_no_timestamps(self):
points = [
geo.Point(time=i, lat=1, lon=1, alt=None, angle=None) for i in range(3)
Expand Down
Loading