diff --git a/mapillary_tools/geotag/factory.py b/mapillary_tools/geotag/factory.py index 87c0016d..e11984ec 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/mapillary_tools/geotag/video_extractors/gpx.py b/mapillary_tools/geotag/video_extractors/gpx.py index c1a7b1ff..f7e4b6da 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 c3256b20..cf0ae607 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 f8c49411..0ba2d5ee 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( @@ -374,6 +392,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 +530,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 8fa98c19..2e657e30 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)], diff --git a/tests/unit/test_gpx_video_timestamps.py b/tests/unit/test_gpx_video_timestamps.py index ea3346c6..b98103ab 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 3953fdeb..d82c69e5 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