From de21f059c0835a0371c259681fe547ae2f39a206 Mon Sep 17 00:00:00 2001 From: Caglar Pir Date: Tue, 27 Oct 2026 15:56:30 +0100 Subject: [PATCH 1/2] Report why a GPX track was not used when no other source geotags the file A GPX track that misses a file lets the later geotag sources take their turn. When none of them geotagged the file, the error reported was that of the last one to fail, which hid the miss. A video whose own GPS is too noisy, given a GPX of another day, failed as "GPS is too noisy": the very problem the GPX was given to work around. The Desktop Uploader, whose chain runs two GPX sources, exiftool_xml, native and exiftool_runtime, shows only that error. Report the miss instead: "The GPX track in X (...) misses the video Y (...) by 105.1 days. Check that the GPX file belongs to this video, ...". An error that ends the chain, such as "Stationary video", is still reported, as it says something about the file itself, and a file that a later source geotags still only gets the warning it gets now. --- mapillary_tools/geotag/factory.py | 11 ++++- tests/unit/test_gpx_over_noisy_gps.py | 61 +++++++++++++++++++++++++++ tests/unit/test_gpx_video_coverage.py | 24 +++++++++++ 3 files changed, 95 insertions(+), 1 deletion(-) diff --git a/mapillary_tools/geotag/factory.py b/mapillary_tools/geotag/factory.py index 87c0016d4..e11984ecc 100644 --- a/mapillary_tools/geotag/factory.py +++ b/mapillary_tools/geotag/factory.py @@ -289,7 +289,8 @@ def process( rejected_gps_paths: set[Path] = set() # Paths whose GPX track missed them, with the source that rejected them, to - # warn about if a later source geotags them instead + # warn about if a later source geotags them instead, and to report if none + # does gpx_rejections: dict[Path, tuple[SourceOption, types.ErrorMetadata]] = {} # Videos let through to sources that cannot geotag videos (e.g. exif), with @@ -399,6 +400,14 @@ def process( if not reprocessable_paths: break + # Of a file that a GPX track missed, and that no later source geotagged, + # report the miss: why the track given for it was not used says more than + # the later sources failing too, such as "GPS is too noisy" for a video + # whose GPS the track was given to replace + for path, (_, gpx_miss) in gpx_rejections.items(): + if path in pending_errors: + pending_errors[path] = gpx_miss + final_metadatas.extend(pending_errors.values()) unread_paths = [ diff --git a/tests/unit/test_gpx_over_noisy_gps.py b/tests/unit/test_gpx_over_noisy_gps.py index f8c494112..ac586847a 100644 --- a/tests/unit/test_gpx_over_noisy_gps.py +++ b/tests/unit/test_gpx_over_noisy_gps.py @@ -374,6 +374,13 @@ def build_video_geotag(option: SourceOption): GPX_NOT_FOUND = exceptions.MapillaryVideoGPSNotFoundError( "GPX file not found for video" ) +GPX_MISS = exceptions.MapillaryOutsideGPXTrackError( + "The GPX track misses the video by 105.1 days", + image_time="2026_09_11_15_39_41_000", + gpx_start_time="2026_05_29_12_47_06_000", + gpx_end_time="2026_05_29_12_53_00_000", +) +XML_NOT_FOUND = exceptions.MapillaryExifToolXMLNotFoundError("No XML file found") DEFAULT_CHAIN = [SourceType(source) for source in DEFAULT_GEOTAG_SOURCE_OPTIONS] @@ -505,6 +512,60 @@ def test_gpx_that_fails_replaces_the_rejection(self, monkeypatch): assert isinstance(result, types.ErrorMetadata) assert result.error is mismatch + def test_gpx_that_misses_is_reported_over_rejected_gps(self, monkeypatch): + """ + The chain of the Desktop Uploader, with a GPX that misses a video whose + GPS is too noisy: the miss says why the GPX was not used, while "GPS + is too noisy" is what the GPX was given to work around. + """ + results, called = _run_chain( + monkeypatch, + [ + SourceType.GPX, + SourceType.EXIFTOOL_XML, + SourceType.NATIVE, + SourceType.EXIFTOOL_RUNTIME, + ], + { + SourceType.GPX: GPX_MISS, + SourceType.EXIFTOOL_XML: XML_NOT_FOUND, + SourceType.NATIVE: NOISE, + SourceType.EXIFTOOL_RUNTIME: None, + }, + ) + result = _only_result(results) + assert isinstance(result, types.ErrorMetadata) + assert result.error is GPX_MISS + assert called == [SourceType.GPX, SourceType.EXIFTOOL_XML, SourceType.NATIVE] + + @pytest.mark.parametrize( + "later_error", + [NOISE, EMPTY, NOT_FOUND, exceptions.MapillaryExiftoolNotFoundError("no")], + ) + def test_gpx_that_misses_is_reported_over_later_failures( + self, monkeypatch, later_error + ): + results, _ = _run_chain( + monkeypatch, + [SourceType.GPX, SourceType.NATIVE], + {SourceType.GPX: GPX_MISS, SourceType.NATIVE: later_error}, + ) + result = _only_result(results) + assert isinstance(result, types.ErrorMetadata) + assert result.error is GPX_MISS + + def test_gpx_that_misses_does_not_hide_a_final_error(self, monkeypatch): + """An error that ends the chain says something about the file itself""" + stationary = exceptions.MapillaryStationaryVideoError("Stationary") + results, _ = _run_chain( + monkeypatch, + [SourceType.GPX, SourceType.NATIVE], + {SourceType.GPX: GPX_MISS, SourceType.NATIVE: stationary}, + ) + result = _only_result(results) + assert isinstance(result, types.ErrorMetadata) + assert result.error is stationary + def test_not_found_still_falls_through_to_exiftool(self, monkeypatch): """Finding nothing is a verdict on the reader, so retrying is fair.""" results, called = _run_chain( diff --git a/tests/unit/test_gpx_video_coverage.py b/tests/unit/test_gpx_video_coverage.py index 8fa98c199..2e657e30c 100644 --- a/tests/unit/test_gpx_video_coverage.py +++ b/tests/unit/test_gpx_video_coverage.py @@ -527,6 +527,30 @@ def test_video_is_geotagged_by_the_next_source_with_a_warning( assert "misses the video" in warning and "by 2.0 hours" in warning assert warning.endswith("Geotagged it from the native source instead") + def test_the_miss_is_reported_if_no_later_source_geotags_the_video(self, tmp_path): + video_path = _write_camm_mp4(tmp_path / "video.mp4", _video_track()) + gpx_path = _write_gpx(tmp_path / "track.gpx", A_UNIX_TIME + 2 * 3600, 10) + options = [ + SourceOption( + SourceType.GPX, + num_processes=0, + source_path=SourcePathOption(source_path=gpx_path), + ), + # Finds no GPX file to read + SourceOption( + SourceType.GPX, + num_processes=0, + source_path=SourcePathOption(source_path=tmp_path / "none.gpx"), + ), + ] + + [metadata] = factory.process([video_path], options) + + assert isinstance(metadata, types.ErrorMetadata) + assert isinstance(metadata.error, exceptions.MapillaryOutsideGPXTrackError) + assert "misses the video" in str(metadata.error) + assert "by 2.0 hours" in str(metadata.error) + @pytest.mark.parametrize( "gpx_start, source", [(A_UNIX_TIME, SourceType.GPX), (A_UNIX_TIME + 2 * 3600, SourceType.NATIVE)], From 0b078715708b4584fe47cfded9d8127c55552548 Mon Sep 17 00:00:00 2001 From: Caglar Pir Date: Tue, 27 Oct 2026 16:04:51 +0100 Subject: [PATCH 2/2] Trust the GPS timestamps of a video, never its creation time A video's GPS clock places a GPX track along it, and tells whether the track is of the same recording. Two things kept it from doing so: - A GoPro stamps a placeholder date until its GPS has the time, and a GPS rejected as noise may never get past it. Such a clock was told apart by the creation time of the video, which comes from the camera's own clock and can be anything. A noisy GPS whose clock disagreed with the creation time got any GPX synced from the start of the video, even one recorded months before (#858). - Only a leap of more than a day was taken for the GPS acquiring the time. Judge the timestamps alone. Leave out those before 2016 (the placeholder dates are 2000-01-01 and dates in October 2015) or more than a day in the future, and keep only those after the last leap of more than a minute ahead of the video. What is left is the GPS time, whether or not the positions were too noisy to publish, so it places the GPX, and a GPX of another time misses the video. The creation time is no longer read for that. When the clock has no timestamps, sampling looked for a start time in the GPS timestamps by rules of its own, which leave out only those before 2010. Now that the clock leaves out placeholder dates, that would bring them back, so it reads the same clock. --- .../geotag/video_extractors/gpx.py | 120 +++++++----------- mapillary_tools/sample_video.py | 47 ++----- tests/unit/test_gpx_over_noisy_gps.py | 60 ++++++--- tests/unit/test_gpx_video_timestamps.py | 31 +++++ tests/unit/test_sample_video.py | 22 +++- 5 files changed, 142 insertions(+), 138 deletions(-) diff --git a/mapillary_tools/geotag/video_extractors/gpx.py b/mapillary_tools/geotag/video_extractors/gpx.py index c1a7b1ff4..f7e4b6da8 100644 --- a/mapillary_tools/geotag/video_extractors/gpx.py +++ b/mapillary_tools/geotag/video_extractors/gpx.py @@ -13,6 +13,7 @@ import math import statistics import sys +import time import typing as T from pathlib import Path @@ -25,7 +26,6 @@ from typing_extensions import override from ... import constants, exceptions, geo, telemetry, types, utils -from ...camm import camm_parser from ...mp4 import construct_mp4_parser as cparser, simple_mp4_parser as sparser from ...mp4.mp4_sample_parser import MovieBoxParser from ...serializer.description import build_capture_time @@ -41,6 +41,21 @@ # of magnitude. _IMPLAUSIBLE_OFFSET_SECONDS = 24 * 3600 +# A video GPS timestamp before this is a placeholder, not the GPS time: until it +# acquires the time, a GoPro stamps 2000-01-01, or a date in October 2015 +_MIN_GPS_UNIX_TIME = datetime.datetime( + 2016, 1, 1, tzinfo=datetime.timezone.utc +).timestamp() + +# Nor can a video GPS timestamp lie further than this in the future +_MAX_GPS_FUTURE_SECONDS = 24 * 3600 + +# A GPS clock that leaps ahead of the video by more than this has just acquired +# the GPS time, as a GoPro does from its placeholder date: only its timestamps +# from there on tell when the video was recorded. Stale first timestamps (see +# _STALE_STEP_RATE) lag by seconds +_MAX_CLOCK_JUMP_SECONDS = 60.0 + # A GPX track that lasts longer than this is not that of one recording, such as # a GPS logger left running after the video, but a log of several trips _MAX_RECORDING_SECONDS = 24 * 3600 @@ -208,7 +223,7 @@ def extract(self) -> types.VideoMetadata: ) try: - native_video_metadata, noisy = self._extract_native_video_metadata() + native_video_metadata, _ = self._extract_native_video_metadata() except exceptions.MapillaryVideoGPSUnusableError as ex: # The GPX replaces the video's own GPS, so GPS that is missing, # empty or too noisy must not reject the video @@ -228,16 +243,7 @@ def extract(self) -> types.VideoMetadata: ) video_clock = self._read_video_clock(native_video_metadata.points) - clock = self._sync_clock(video_clock, gpx_points, noisy) - if video_clock is not None and clock is None: - LOG.warning( - "The GPS clock of %s, whose GPS was rejected as noise, is %.1f days away from %s. " - "Syncing the %s from the start of the video instead", - self.video_path, - abs(gpx_points[0].time - video_clock.to_unix_time(0)) / 86400, - self.gpx_path, - self.file_format, - ) + clock = self._sync_clock(video_clock, gpx_points) if clock is not None and gpx_points and self.sync_mode is SyncMode.REBASE: # Distance sampling reads the frames by their composition times: # start the track at that of the first frame @@ -841,14 +847,19 @@ def _sync_clock( self, clock: VideoClock | None, gpx_points: T.Sequence[geo.Point], - noisy: bool, ) -> VideoClock | None: """ The clock to place the GPX track by along the video, from the GPS - clock of the video (None if it has none), whose GPS was rejected as - noise if **noisy**. Return None if the track is to be placed as for a - video without a GPS clock. --interpolation_use_gpx_start_time starts - the track at video time 0. + clock of the video (None if it has none). Return None if the track is + to be placed as for a video without a GPS clock. + --interpolation_use_gpx_start_time starts the track at video time 0. + + The GPS clock places the track whether or not the GPS positions were + rejected as noise: its timestamps, which _read_timestamps() keeps only + once they are the GPS time, tell when the video was recorded, and a + track recorded at another time misses the video. The camera's own + clock, such as the creation time of the video, is never consulted: + it can be anything. """ if clock is None or not gpx_points: return clock @@ -861,40 +872,8 @@ def _sync_clock( video_time=0.0, unix_time=gpx_points[0].time, rate=clock.rate ) - # GPS rejected as noise may not have GPS time yet: GoPro stamps a - # placeholder date until it acquires it. That GPS is what the GPX - # replaces, so do not let a placeholder clock place the GPX days away - # from the video. A clock that agrees with the creation time of the - # video is no placeholder: it places the GPX as for any video with GPS, - # and a GPX days away from it misses the video - offset = gpx_points[0].time - clock.to_unix_time(0) - if ( - abs(offset) > _IMPLAUSIBLE_OFFSET_SECONDS - and noisy - and self.sync_mode is SyncMode.SYNC - and not self._clock_matches_creation_time(clock) - ): - return None - return clock - def _clock_matches_creation_time(self, clock: VideoClock) -> bool: - """ - Whether the GPS clock of the video starts within a day of the mvhd - creation time of the video, or False if that cannot be read. - - A day absorbs any time zone the creation time is written in (GoPro - writes local time), and whether it marks the start or the end of the - recording. Some cameras write a creation time that means nothing (a - GoPro HERO10 recorded in 2022 reports 2016-01-01), so a clock that does - not match it may still be real. - """ - creation_time = _video_creation_time(self.video_path) - if creation_time is None: - return False - gap = abs(clock.to_unix_time(0) - creation_time) - return gap <= _IMPLAUSIBLE_OFFSET_SECONDS - def _warn_of_a_long_track(self, gpx_points: T.Sequence[geo.Point]) -> None: """ Warn, in process, of a GPX track assumed to start with the video that @@ -1027,17 +1006,22 @@ def _read_timestamps( ) -> list[tuple[float, float]]: """ Read the (video time, Unix time) pairs of the GPS timestamps of the - points, leaving out those that the others show to be wrong: those out - of order, those before a jump of days (from a placeholder date to the - GPS time), and stale first ones + points, leaving out those that cannot be the GPS time (zero, before + 2016, or in the future), and those that the others show to be wrong: + those out of order, those before a leap of more than a minute (from a + placeholder date to the GPS time), and stale first ones. + + Only the timestamps are judged, not the positions: a GPS can have the + time before it has a position good enough to publish. >>> points = [ - ... telemetry.GPSPoint(time=v, lat=1, lon=1, alt=None, angle=None, epoch_time=u, fix=None, precision=None, ground_speed=None) - ... for v, u in [(0, 992.4), (0.4, 1000.4), (1.4, 1001.4), (1.8, 1001.4), (2.4, 1002.4)] + ... telemetry.GPSPoint(time=v, lat=1, lon=1, alt=None, angle=None, epoch_time=1789141180 + u, fix=None, precision=None, ground_speed=None) + ... for v, u in [(0, -7.6), (0.4, 0.4), (1.4, 1.4), (1.8, 1.4), (2.4, 2.4)] ... ] - >>> GPXVideoExtractor._read_timestamps(points) - [(0.4, 1000.4), (1.4, 1001.4), (2.4, 1002.4)] + >>> [(v, round(u - 1789141180, 1)) for v, u in GPXVideoExtractor._read_timestamps(points)] + [(0.4, 0.4), (1.4, 1.4), (2.4, 2.4)] """ + latest = time.time() + _MAX_GPS_FUTURE_SECONDS pairs: list[tuple[float, float]] = [] for p in video_gps_points: # Video GPS timestamps are stored in whatever epoch their container @@ -1046,6 +1030,11 @@ def _read_timestamps( unix_time = p.get_unix_time() if unix_time is None: continue + if not _MIN_GPS_UNIX_TIME <= unix_time <= latest: + # A placeholder date, or a clock gone wrong + continue + if not math.isfinite(p.time): + continue if pairs and unix_time == pairs[-1][1]: # GoPro GPS5 stamps a whole block (~1s) of points with one # timestamp. Keep the first point of the block @@ -1062,11 +1051,13 @@ def _read_timestamps( if pace is None: return pairs - # GoPro stamps a placeholder date until the GPS acquires the time + # Until it acquires the GPS time, a GoPro stamps a placeholder date, + # which can be one since 2016: keep the timestamps after the last leap + # (see _MAX_CLOCK_JUMP_SECONDS) start = 0 for idx in range(1, len(pairs)): (v0, u0), (v1, u1) = pairs[idx - 1], pairs[idx] - if _IMPLAUSIBLE_OFFSET_SECONDS < (u1 - u0) - (v1 - v0) * pace: + if _MAX_CLOCK_JUMP_SECONDS < (u1 - u0) - (v1 - v0) * pace: start = idx # Stale first timestamps (see _STALE_STEP_RATE). Judged by the steps of @@ -1356,19 +1347,6 @@ def _first_frame_time(video_path: Path) -> float: return 0.0 -def _video_creation_time(video_path: Path) -> float | None: - """ - The creation time of the video as Unix time, from its mvhd box, or None if - it is unset or cannot be read. - """ - try: - moov = MovieBoxParser.parse_file(video_path) - except (OSError, sparser.ParsingError, C.ConstructError) as ex: - LOG.debug("Failed to read the creation time of %s: %s", video_path, ex) - return None - return camm_parser._extract_creation_time(moov) - - def _format_duration(seconds: float) -> str: """ Format a duration in the largest unit it fills, rounded rather than diff --git a/mapillary_tools/sample_video.py b/mapillary_tools/sample_video.py index c3256b203..cf0ae607c 100644 --- a/mapillary_tools/sample_video.py +++ b/mapillary_tools/sample_video.py @@ -11,7 +11,6 @@ import os import re import shutil -import statistics import time import typing as T from contextlib import contextmanager @@ -457,21 +456,6 @@ def wip_sample_dir(sample_dir: Path) -> Path: ) -# GPS clocks that report a time before this have never been set: GoPro writes -# 2000-01-01 until it gets its first fix, for example -_MIN_PLAUSIBLE_START_TIME = datetime.datetime( - 2010, 1, 1, tzinfo=datetime.timezone.utc -).timestamp() - -# Leave room for the clock of the machine running this to be behind -_MAX_FUTURE_START_TIME_SECONDS = 24 * 3600 - -# How many GPS timestamps to take the median of, so that a single bad one cannot -# shift the start time: the Labpano PanoX V2 can record a stale first fix, -# seconds older than the rest. Only the first few are used because in timelapses -# the video clock runs slower than the GPS clock, so the two drift apart -_GPS_CLOCK_SAMPLES = 5 - # Cameras known to stamp the creation time at the end of the recording, as # lowercase (make, model) from the container tags. BlackVue is not listed # because it does not write those tags: blackvue_parser.is_blackvue detects it @@ -499,32 +483,17 @@ def _gps_clock_start_time( points: T.Sequence[geo.Point], ) -> datetime.datetime | None: """ - Map the absolute GPS timestamps at the start of a track back to the video's time 0. - - Point times are relative to the start of the video, so subtracting one from - its own absolute timestamp gives the wall clock at which the video started. - Timestamps outside the plausible range are skipped. + The wall clock time at which the video started, by its GPS clock, or None + if it has none: by the timestamps that GPX sync reads the clock from (see + GPXVideoExtractor._read_timestamps), at the clock's pace, which a timelapse + packs several GPS seconds into each second of video at """ - max_start_time = time.time() + _MAX_FUTURE_START_TIME_SECONDS - - start_times: list[float] = [] - for point in points: - unix_time = point.get_unix_time() - if unix_time is None: - continue - start_time = unix_time - point.time - # Written as a negated range check so that NaN is skipped too - if not (_MIN_PLAUSIBLE_START_TIME <= start_time <= max_start_time): - continue - start_times.append(start_time) - if len(start_times) >= _GPS_CLOCK_SAMPLES: - break - - if not start_times: + clock = GPXVideoExtractor._read_video_clock(points) + if clock is None: return None return datetime.datetime.fromtimestamp( - statistics.median(start_times), tz=datetime.timezone.utc + clock.to_unix_time(0.0), tz=datetime.timezone.utc ) @@ -787,7 +756,7 @@ def _gpx_sync_clock( if not noisy: return None video_clock = extractor._read_video_clock(native_video_metadata.points) - return extractor._sync_clock(video_clock, gpx_points, noisy) + return extractor._sync_clock(video_clock, gpx_points) return None diff --git a/tests/unit/test_gpx_over_noisy_gps.py b/tests/unit/test_gpx_over_noisy_gps.py index ac586847a..0ba2d5ee9 100644 --- a/tests/unit/test_gpx_over_noisy_gps.py +++ b/tests/unit/test_gpx_over_noisy_gps.py @@ -188,12 +188,18 @@ def test_good_fixes_are_preferred_as_the_clock( assert [p.time for p in metadata.points] == pytest.approx([2.0, 4.0]) - def test_noisy_clock_days_away_is_ignored(self, monkeypatch, video_path, gpx_path): + @pytest.mark.parametrize("creation_time", [None, A_UNIX_TIME]) + def test_placeholder_clock_is_ignored( + self, monkeypatch, video_path, gpx_path, creation_time + ): """ - Until it acquires GPS time, a GoPro stamps a placeholder date, which a - GPS rejected as noise may never have got past. Syncing by it would put - the GPX years away from the video. + Until it acquires the GPS time, a GoPro stamps a placeholder date, which + a GPS rejected as noise may never have got past. Such a clock says + nothing of when the video was recorded, so the GPX is synced from the + start of the video, whatever the creation time of the video says. """ + if creation_time is not None: + _write_mvhd(video_path, creation_time=creation_time) points = [ _point(time=i * 0.1, epoch_time=PLACEHOLDER_UNIX_TIME + i * 0.1) for i in range(32) @@ -205,31 +211,47 @@ def test_noisy_clock_days_away_is_ignored(self, monkeypatch, video_path, gpx_pat assert [p.time for p in metadata.points] == [0.0, 2.0] assert (metadata.make, metadata.model) == ("GoPro", "MAX2") - def test_noisy_clock_far_from_creation_time_is_ignored( + def test_noisy_clock_after_its_placeholder_date_places_the_gpx( self, monkeypatch, video_path, gpx_path ): - """A noisy clock days away from the creation time is ignored too.""" - _write_mvhd(video_path, creation_time=A_UNIX_TIME) + """Once the GPS acquires the time, its timestamps are the GPS time""" points = [ _point(time=i * 0.1, epoch_time=PLACEHOLDER_UNIX_TIME + i * 0.1) - for i in range(32) + for i in range(10) + ] + points += [ + _point(time=i * 0.1, epoch_time=A_UNIX_TIME + i * 0.1) + for i in range(10, 32) ] _stub_gopro(monkeypatch, points) metadata = GPXVideoExtractor(video_path, gpx_path).extract() - assert [p.time for p in metadata.points] == [0.0, 2.0] + assert [p.time for p in metadata.points] == pytest.approx([2.0, 4.0]) @pytest.mark.parametrize("video_sync", [None, VideoSyncOption()]) - def test_noisy_clock_at_creation_time_rejects_a_gpx_days_away( - self, video_path, tmp_path, noisy_gopro, video_sync + @pytest.mark.parametrize( + "creation_time", + [ + # In local time, 8 hours behind UTC, as GoPro writes it + A_UNIX_TIME - 8 * 3600, + # A creation time that means nothing: 2016-01-01T01:44:00Z, as a + # GoPro HERO10 recorded in 2022 reports + 1451612640, + # None + None, + ], + ) + def test_noisy_clock_rejects_a_gpx_days_away( + self, video_path, tmp_path, noisy_gopro, video_sync, creation_time ): """ - A noisy clock that agrees with the creation time of the video is real, - so a GPX days away from it misses the video, as for good GPS. + The timestamps of GPS rejected as noise are the GPS time all the same, + so a GPX days away from them misses the video, as for good GPS. The + creation time of the video, from the camera's own clock, is not asked. """ - # In local time, 8 hours behind UTC, as GoPro writes it - _write_mvhd(video_path, creation_time=A_UNIX_TIME - 8 * 3600) + if creation_time is not None: + _write_mvhd(video_path, creation_time=creation_time) gpx_path = tmp_path / "another_day.gpx" gpx_path.write_text(_gpx_xml("2026-09-09T15:39:43Z", "2026-09-09T15:39:45Z")) @@ -242,8 +264,7 @@ def test_noisy_clock_syncs_despite_a_meaningless_creation_time( ): """ A GoPro HERO10 recorded in 2022 reports 2016-01-01 as its creation - time, so a noisy clock that disagrees with it but agrees with the GPX is - still real. + time, which does not keep its GPS clock from placing the GPX. """ # 2016-01-01T01:44:00Z _write_mvhd(video_path, creation_time=1451612640) @@ -255,10 +276,7 @@ def test_noisy_clock_syncs_despite_a_meaningless_creation_time( def test_good_clock_days_away_rejects_the_gpx( self, monkeypatch, video_path, gpx_path ): - """ - Only a clock rejected with its noisy track is second-guessed. A good - clock days away from the GPX says the GPX misses the video. - """ + """A good clock days away from the GPX says the GPX misses the video""" day = 24 * 3600.0 points = [ _point( diff --git a/tests/unit/test_gpx_video_timestamps.py b/tests/unit/test_gpx_video_timestamps.py index ea3346c69..b98103ab4 100644 --- a/tests/unit/test_gpx_video_timestamps.py +++ b/tests/unit/test_gpx_video_timestamps.py @@ -118,6 +118,37 @@ def test_without_a_placeholder_date(self): assert _read(pairs) == [(2, 2), (3, 3)] + def test_without_timestamps_before_a_leap_of_minutes(self): + """ + A clock that leaps 2 hours ahead of the video has just got the GPS + time, such as from a camera clock in local time + """ + pairs = [(0, -7200), (1, -7199), (2, -7198), (3, 3), (4, 4), (5, 5)] + + assert _read(pairs) == [(3, 3), (4, 4), (5, 5)] + + def test_with_a_step_of_less_than_a_minute(self): + """Further on, a step of seconds is the clock's, not a leap to the time""" + pairs = [(0, 0), (1, 1), (2, 32), (3, 33), (4, 34)] + + assert _read(pairs) == pairs + + def test_without_timestamps_before_2016(self): + """A placeholder date that the GPS never got past""" + before = datetime.datetime(2015, 10, 22, tzinfo=datetime.timezone.utc) + offset = before.timestamp() - START.timestamp() + + assert _read([(v, offset + v) for v in range(5)]) == [] + + def test_without_timestamps_in_the_future(self): + future = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta( + days=2 + ) + offset = future.timestamp() - START.timestamp() + pairs = [(0, 0), (1, 1), (2, 2), (3, offset + 3), (4, offset + 4)] + + assert _read(pairs) == [(0, 0), (1, 1), (2, 2)] + def test_none_without_timestamps(self): points = _points([(0, 0)]) points[0] = geo.Point(time=0, lat=37, lon=-122, alt=None, angle=None) diff --git a/tests/unit/test_sample_video.py b/tests/unit/test_sample_video.py index 3953fdebc..d82c69e5b 100644 --- a/tests/unit/test_sample_video.py +++ b/tests/unit/test_sample_video.py @@ -878,10 +878,11 @@ def test_started_with_the_gpx_at_the_pace_of_the_clock( With --interpolation_use_gpx_start_time, the clock gives only the pace: the samples were all stamped within the first minute of the track """ - # A placeholder date, which the GPX start replaces + # A clock a year away from the GPX, whose start replaces it. A clock + # that never got past a placeholder date before 2016 gives no pace self._mock_noisy_telemetry( monkeypatch, - datetime.datetime(2015, 1, 1, tzinfo=datetime.timezone.utc).timestamp(), + datetime.datetime(2024, 1, 1, tzinfo=datetime.timezone.utc).timestamp(), ) gpx_start = self.GPS_START_TIME + datetime.timedelta(minutes=5) gpx = self._write_gpx(Path(tmpdir) / "track.gpx", gpx_start) @@ -1235,16 +1236,23 @@ def test_median_ignores_a_bad_timestamp(self) -> None: 2021, 8, 10, 12, 38, 6, tzinfo=datetime.timezone.utc ) - def test_uses_the_start_of_a_timelapse(self) -> None: + def test_uses_the_clock_rate_of_a_timelapse(self) -> None: # Each second of this timelapse spans 10 seconds of GPS time, so the - # offset between the two clocks grows by 9 seconds per point. Reading it - # at the start of the track keeps the error to a couple of points - # rather than half the track + # offset between the two clocks grows by 9 seconds per point: mapped + # back at the rate of the clock, every point says the same start points = [self._gps_point(float(i), 1628599086.0 + 10 * i) for i in range(1000)] assert sample_video._gps_clock_start_time(points) == datetime.datetime( - 2021, 8, 10, 12, 38, 24, tzinfo=datetime.timezone.utc + 2021, 8, 10, 12, 38, 6, tzinfo=datetime.timezone.utc ) + def test_skips_a_placeholder_date(self) -> None: + # GoPro stamps a date in October 2015 until it acquires the GPS time + points = [ + self._gps_point(0.0, 1445507624.0), + self._gps_point(1.0, 1445507625.0), + ] + assert sample_video._gps_clock_start_time(points) is None + def test_no_absolute_timestamps(self) -> None: assert sample_video._gps_clock_start_time(_make_gps_points(3)) is None