From 88c9fe8f222563e6cbc71169f15b2edbd86e5b7a Mon Sep 17 00:00:00 2001 From: Caglar Pir Date: Tue, 20 Oct 2026 13:29:23 +0200 Subject: [PATCH 1/3] Apply the interpolation options to videos geotagged from a GPX process ignored --interpolation_offset_time and --interpolation_use_gpx_start_time for videos. They reached the image geotaggers through SourceOption.interpolation, but the GPX video geotagger read them only from VideoSyncOption, which only distance sampling sets. The track uploaded with a video stayed where the GPS clock of the video, or the start of the video, put it, whatever the options said. GPXVideoExtractor now reads them from SourceOption.interpolation, which process sets on every source. Sampling sets it too, unless a source has options of its own, as process does. VideoSyncOption keeps only the start time, and with it the start-time check, which stays out of process: process has no --video_start_time to offer. The offset moves the track along the video. The absolute times of its points stay those of the GPX, just as an image geotagged with the offset takes the time of its position along the GPX. Only sampling shifts them as well, for the geotagging stage to add the offset back. Without the options, process gives the same tracks as before, and video_process the same frames and descriptions, with or without them. Fixes #827 --- README.md | 17 ++- mapillary_tools/commands/process.py | 4 +- mapillary_tools/geotag/factory.py | 1 + .../geotag/geotag_videos_from_gpx.py | 3 + mapillary_tools/geotag/options.py | 11 +- .../geotag/video_extractors/gpx.py | 28 +++-- mapillary_tools/sample_video.py | 33 ++++-- tests/integration/test_process.py | 57 +++++++++ tests/unit/test_gpx_video_coverage.py | 13 +- tests/unit/test_gpx_video_sync.py | 112 ++++++++++++++++-- tests/unit/test_sample_video.py | 39 +++++- 11 files changed, 263 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index 93ea96218..a0be14cfe 100644 --- a/README.md +++ b/README.md @@ -472,9 +472,20 @@ VIDEO_DIR/ ### Limitations of `--video_geotag_source` -**External geolocation sources will be aligned with the start of video, there is -currently no way of applying offsets or scaling the time.** This means, for instance, that GPX tracks must begin precisely -at the instant of the video, and that timelapse videos are supported only for sources `camm, gopro, blackvue`. +**A GPX track is synced by the GPS clock of the video, if the video has GPS of its own, and otherwise aligned with +the start of the video.** A timelapse video is supported for a GPX only if it has GPS of its own. + +As with images, `--interpolation_offset_time N` adds N seconds to the time of the video to find its positions along +the GPX track, and `--interpolation_use_gpx_start_time` aligns the start of the video with the start of the track, even +if the video has GPS: + +```sh +# Start recording the video 2.5 seconds after starting the GPS logger +mapillary_tools process VIDEO_DIR/ \ + --video_geotag_source gpx \ + --interpolation_use_gpx_start_time \ + --interpolation_offset_time 2.5 +``` ## Authenticate diff --git a/mapillary_tools/commands/process.py b/mapillary_tools/commands/process.py index fc205c5de..6ac77289a 100644 --- a/mapillary_tools/commands/process.py +++ b/mapillary_tools/commands/process.py @@ -149,7 +149,7 @@ def add_basic_arguments(self, parser: argparse.ArgumentParser): ) group_geotagging.add_argument( "--interpolation_use_gpx_start_time", - help=f"If supplied, the first image will use the first GPX point time for interpolation, which means the image location will be interpolated to the first GPX point too. Only works for geotagging from {', '.join(geotag_gpx_based_sources)}.", + help=f"If supplied, the first image, or the start of a video, will use the first GPX point time for interpolation, which means the image location will be interpolated to the first GPX point too. Only works for geotagging from {', '.join(geotag_gpx_based_sources)}.", action="store_true", required=False, ) @@ -157,7 +157,7 @@ def add_basic_arguments(self, parser: argparse.ArgumentParser): "--interpolation_offset_time", default=0.0, type=float, - help=f"Time offset, in seconds, that will be added for GPX interpolation, which affects image locations. Note that it is applied after --interpolation_use_gpx_start_time. Only works for geotagging from {', '.join(geotag_gpx_based_sources)}. [default %(default)s]", + help=f"Time offset, in seconds, that will be added for GPX interpolation, which affects image and video locations. Note that it is applied after --interpolation_use_gpx_start_time. Only works for geotagging from {', '.join(geotag_gpx_based_sources)}. [default %(default)s]", required=False, ) group_geotagging.add_argument( diff --git a/mapillary_tools/geotag/factory.py b/mapillary_tools/geotag/factory.py index 9cdd84795..dc9d21e25 100644 --- a/mapillary_tools/geotag/factory.py +++ b/mapillary_tools/geotag/factory.py @@ -463,6 +463,7 @@ def _build_video_geotag(option: SourceOption) -> base.GeotagVideosFromGeneric | source_path=option.source_path, num_processes=option.num_processes, video_sync=option.video_sync, + interpolation=option.interpolation, ) elif option.source is SourceType.NMEA: diff --git a/mapillary_tools/geotag/geotag_videos_from_gpx.py b/mapillary_tools/geotag/geotag_videos_from_gpx.py index 342731eca..8e0acd4e6 100644 --- a/mapillary_tools/geotag/geotag_videos_from_gpx.py +++ b/mapillary_tools/geotag/geotag_videos_from_gpx.py @@ -30,12 +30,14 @@ def __init__( source_path: options.SourcePathOption | None = None, num_processes: int | None = None, video_sync: options.VideoSyncOption | None = None, + interpolation: options.InterpolationOption | None = None, ): super().__init__(num_processes=num_processes) if source_path is None: source_path = options.SourcePathOption(pattern="%g.gpx") self.source_path = source_path self.video_sync = video_sync + self.interpolation = interpolation @override def _generate_video_extractors( @@ -50,6 +52,7 @@ def _generate_video_extractors( video_path, source_path, video_sync=self.video_sync, + interpolation=self.interpolation, # The track that process uploads with the video. # Sampling reads its track only within the video clip_to_video=self.video_sync is None, diff --git a/mapillary_tools/geotag/options.py b/mapillary_tools/geotag/options.py index 108d59d52..896dab71d 100644 --- a/mapillary_tools/geotag/options.py +++ b/mapillary_tools/geotag/options.py @@ -151,21 +151,14 @@ class VideoSyncOption: """ How to line up a GPX track with a video that is being sampled, so that its frames are picked where the geotagging stage of video_process will place - them. process geotags videos without it, and places a GPX as it always has. + them. The interpolation options, which process applies to videos as well, + are in SourceOption.interpolation. """ # Unix time of the first frame of the video (--video_start_time), to place # a GPX by when the video has no GPS of its own to sync it with start_time: float | None = None - # --interpolation_offset_time: seconds the geotagging stage adds to the - # capture time of a frame to find its position along the GPX - offset_time: float = 0.0 - - # --interpolation_use_gpx_start_time: the geotagging stage puts the first - # frame at the start of the GPX - use_gpx_start_time: bool = False - # --video_duration_ratio: GPS seconds per second of a video without a GPS # clock of its own to tell, such as a timelapse duration_ratio: float = 1.0 diff --git a/mapillary_tools/geotag/video_extractors/gpx.py b/mapillary_tools/geotag/video_extractors/gpx.py index f03b0cd41..f6c191c27 100644 --- a/mapillary_tools/geotag/video_extractors/gpx.py +++ b/mapillary_tools/geotag/video_extractors/gpx.py @@ -105,16 +105,18 @@ def __init__( gpx_path: Path, sync_mode: SyncMode = SyncMode.SYNC, video_sync: options.VideoSyncOption | None = None, + interpolation: options.InterpolationOption | None = None, clip_to_video: bool = False, ): self.video_path = video_path self.gpx_path = gpx_path self.sync_mode = sync_mode self.video_sync = video_sync + self.interpolation = interpolation # Clip the track to the video when a clock gives its pace along the # video (see _clip_to_video) self.clip_to_video = clip_to_video - if video_sync is not None and video_sync.use_gpx_start_time: + if interpolation is not None and interpolation.use_gpx_start_time: self.sync_mode = SyncMode.REBASE @override @@ -314,23 +316,29 @@ def _apply_offset_time( self, gpx_points: T.Sequence[geo.Point], rate: float = 1.0 ) -> None: """ - Shift the track back by --interpolation_offset_time, which the - geotagging stage adds to the capture time of a frame to find its - position along the GPX. The offset is in GPS seconds, of which a - timelapse packs **rate** into each second of video. + Shift the track back by --interpolation_offset_time, which is added to + the capture time of a frame to find its position along the GPX. The + offset is in GPS seconds, of which a timelapse packs **rate** into each + second of video. - The absolute times shift with the track, so that a frame is stamped - with its capture time, and the geotagging stage, adding the offset back, + The absolute times stay those of the GPX, just as an image geotagged + with the offset takes the time of its position along the GPX. When + sampling, they shift with the track, so that a frame is stamped with + its capture time, and the geotagging stage, adding the offset back, finds the position the frame was picked by. """ - if self.video_sync is None or not self.video_sync.offset_time: + if self.interpolation is None or not self.interpolation.offset_time: return - offset_time = self.video_sync.offset_time + offset_time = self.interpolation.offset_time for p in gpx_points: p.time -= offset_time / rate # parse_gpx() returns CAMMGPSPoint, with epoch_time 0 for no time - if isinstance(p, telemetry.CAMMGPSPoint) and 0 < p.epoch_time: + if ( + self.video_sync is not None + and isinstance(p, telemetry.CAMMGPSPoint) + and 0 < p.epoch_time + ): p.epoch_time -= offset_time def _check_coverage( diff --git a/mapillary_tools/sample_video.py b/mapillary_tools/sample_video.py index df00860e9..4714844f8 100644 --- a/mapillary_tools/sample_video.py +++ b/mapillary_tools/sample_video.py @@ -30,7 +30,13 @@ from .exif_write import ExifEdit from .geotag import factory from .geotag.geotag_images_from_video import GPSTimeline -from .geotag.options import SourceOption, SourcePathOption, SourceType, VideoSyncOption +from .geotag.options import ( + InterpolationOption, + SourceOption, + SourcePathOption, + SourceType, + VideoSyncOption, +) from .geotag.video_extractors.gpx import GPXVideoExtractor from .geotag.video_extractors.native import NativeVideoExtractor from .mp4 import mp4_sample_parser @@ -72,6 +78,7 @@ def _parse_geotag_options( video_geotag_source: list[str] | None, video_import_path: Path, video_sync: VideoSyncOption | None = None, + interpolation: InterpolationOption | None = None, ) -> list[SourceOption]: """ Resolve which sources distance sampling may take its positions from. @@ -82,8 +89,9 @@ def _parse_geotag_options( cannot see every field the noise filter rejects on, so routing sampling through it would accept tracks the native parser refuses. - Every option carries video_sync, for a GPX source to place its track where - the geotagging stage will place the frames. + Every option carries video_sync and the interpolation options, for a GPX + source to place its track where the geotagging stage will place the frames. + As there, the interpolation options of a source override the global ones. """ options = _parse_video_sources( geotag_source, geotag_source_path, video_geotag_source, video_import_path @@ -91,6 +99,8 @@ def _parse_geotag_options( for option in options: option.video_sync = video_sync + if option.interpolation is None: + option.interpolation = interpolation return options @@ -185,8 +195,6 @@ def sample_video( start_time=( video_start_time_dt.timestamp() if video_start_time_dt is not None else None ), - offset_time=interpolation_offset_time, - use_gpx_start_time=interpolation_use_gpx_start_time, duration_ratio=video_duration_ratio, ) @@ -196,6 +204,10 @@ def sample_video( video_geotag_source, video_import_path, video_sync, + InterpolationOption( + offset_time=interpolation_offset_time, + use_gpx_start_time=interpolation_use_gpx_start_time, + ), ) if rerun: @@ -610,10 +622,17 @@ def _check_gpx_start_by_interval( for option in geotag_options: if option.source is not SourceType.GPX or option.source_path is None: continue + if option.interpolation is None or not option.interpolation.use_gpx_start_time: + continue gpx_path = option.source_path.resolve(video_path) if not gpx_path.is_file(): continue - extractor = GPXVideoExtractor(video_path, gpx_path, video_sync=video_sync) + extractor = GPXVideoExtractor( + video_path, + gpx_path, + video_sync=video_sync, + interpolation=option.interpolation, + ) if _gpx_sync_reads_a_clock(extractor): continue gpx_points = extractor._read_gpx_points() @@ -691,7 +710,7 @@ def _sample_single_video_by_interval( # times the video's track to place the samples along it timeline = GPSTimeline.read(telemetry_points, clock_rate) - if clock_rate is None and video_sync is not None and video_sync.use_gpx_start_time: + if clock_rate is None and video_sync is not None: _check_gpx_start_by_interval(video_path, geotag_options, video_sync) probe = ffmpeglib.Probe(ffmpeg.probe_format_and_streams(video_path)) diff --git a/tests/integration/test_process.py b/tests/integration/test_process.py index ea87a3f2d..46aa0e66c 100644 --- a/tests/integration/test_process.py +++ b/tests/integration/test_process.py @@ -696,6 +696,63 @@ def test_process_video_geotag_source_with_gpx_outside_video( } +def test_process_video_from_gpx_with_interpolation_options( + setup_data: py.path.local, +): + """ + The interpolation options line up a GPX with a video, as they do with + images. They were ignored for videos. + """ + video_path = setup_data.join("gopro_data").join("max-360mode.mp4") + # 3.613 seconds before the GPS clock of the video starts + start = datetime.datetime(2019, 11, 18, 23, 44, 40, tzinfo=datetime.timezone.utc) + points = "".join( + f'' + f"" + for i in range(21) + ) + gpx_file = setup_data.join("track.gpx") + gpx_file.write( + '\n' + '' + f"{points}\n" + ) + + def describe(*options: str): + [desc] = run_process_for_descs( + [ + *["--geotag_source", "gpx"], + *["--geotag_source_path", str(gpx_file)], + *options, + str(video_path), + ] + ) + return desc + + # The track is clipped to the video, so look a point up by its GPX time. + # The video takes the times of its positions along the GPX, as images do, + # so the point keeps that time whatever the options + recorded = start.timestamp() + 10 + + def video_time_of_recorded(*options: str) -> float: + [point] = [p for p in describe(*options)["MAPGPSTrack"] if p[5] == recorded] + return point[0] + + synced = video_time_of_recorded() + assert synced == pytest.approx(10000 - 3613, abs=1) + + assert video_time_of_recorded("--interpolation_use_gpx_start_time") == 10000 + assert ( + video_time_of_recorded( + "--interpolation_use_gpx_start_time", "--interpolation_offset_time=2.5" + ) + == 7500 + ) + + shifted = video_time_of_recorded("--interpolation_offset_time=2") + assert shifted == pytest.approx(synced - 2000, abs=1) + + def test_process_geotag_with_gpx_pattern_not_found(setup_data: py.path.local): video_path = setup_data.join("gopro_data").join("max-360mode.mp4") diff --git a/tests/unit/test_gpx_video_coverage.py b/tests/unit/test_gpx_video_coverage.py index 7ad03592e..cea2fed15 100644 --- a/tests/unit/test_gpx_video_coverage.py +++ b/tests/unit/test_gpx_video_coverage.py @@ -28,6 +28,7 @@ from mapillary_tools.camm import camm_builder from mapillary_tools.geotag import factory from mapillary_tools.geotag.options import ( + InterpolationOption, SourceOption, SourcePathOption, SourceType, @@ -164,7 +165,7 @@ def _extract( gpx_seconds: float, video_track: T.Sequence[geo.Point] | None = None, duration: float = VIDEO_DURATION, - video_sync: VideoSyncOption | None = None, + interpolation: InterpolationOption | None = None, ) -> types.VideoMetadata: video_path = _write_camm_mp4( tmp_path / "video.mp4", @@ -172,7 +173,9 @@ def _extract( duration, ) gpx_path = _write_gpx(tmp_path / "track.gpx", gpx_start, gpx_seconds) - return GPXVideoExtractor(video_path, gpx_path, video_sync=video_sync).extract() + return GPXVideoExtractor( + video_path, gpx_path, interpolation=interpolation + ).extract() @pytest.mark.parametrize( "start, seconds", @@ -291,15 +294,15 @@ def test_epoch_mixup_raises(self, tmp_path): def test_the_interpolation_offset_is_part_of_the_placement(self, tmp_path, caplog): """ - The geotagging stage looks the frames up 5s later along the track, so - a track that covers the video exactly leaves its last 5s uncovered. + The frames are looked up 5s later along the track, so a track that + covers the video exactly leaves its last 5s uncovered. """ with caplog.at_level(logging.WARNING): self._extract( tmp_path, A_UNIX_TIME, VIDEO_DURATION, - video_sync=VideoSyncOption(offset_time=5), + interpolation=InterpolationOption(offset_time=5), ) [warning] = _warnings(caplog) assert "5 seconds of the video fall outside the track" in warning diff --git a/tests/unit/test_gpx_video_sync.py b/tests/unit/test_gpx_video_sync.py index fb376aa04..1a7cc252b 100644 --- a/tests/unit/test_gpx_video_sync.py +++ b/tests/unit/test_gpx_video_sync.py @@ -5,8 +5,8 @@ """ How GPXVideoExtractor lines up a GPX track with the video it geotags, when -distance sampling hands it a VideoSyncOption, and that process, which does -not, places a GPX as it always has. +distance sampling hands it a VideoSyncOption, and when process, which does not, +geotags the video for upload. """ from __future__ import annotations @@ -19,6 +19,7 @@ from mapillary_tools import exceptions, types from mapillary_tools.geotag import factory from mapillary_tools.geotag.options import ( + InterpolationOption, SourceOption, SourcePathOption, SourceType, @@ -61,8 +62,15 @@ def _write_gpx(path: Path, seconds: int, start=GPX_START) -> Path: return path -def _extract(video: Path, gpx: Path, video_sync: VideoSyncOption | None): - return GPXVideoExtractor(video, gpx, video_sync=video_sync).extract() +def _extract( + video: Path, + gpx: Path, + video_sync: VideoSyncOption | None, + interpolation: InterpolationOption | None = None, +): + return GPXVideoExtractor( + video, gpx, video_sync=video_sync, interpolation=interpolation + ).extract() class TestVideoWithoutGPS: @@ -324,7 +332,8 @@ def test_the_offset_shifts_the_track_and_its_times(self, tmp_path): metadata = _extract( NO_GPS_VIDEO, gpx, - VideoSyncOption(start_time=video_start.timestamp(), offset_time=2), + VideoSyncOption(start_time=video_start.timestamp()), + InterpolationOption(offset_time=2), ) # the point recorded at start + 2 is where the first frame is @@ -341,7 +350,9 @@ def test_the_offset_applies_to_a_track_synced_by_the_video_gps(self, tmp_path): ) synced = _extract(GPS_VIDEO, gpx, VideoSyncOption()) - shifted = _extract(GPS_VIDEO, gpx, VideoSyncOption(offset_time=2)) + shifted = _extract( + GPS_VIDEO, gpx, VideoSyncOption(), InterpolationOption(offset_time=2) + ) assert [p.time for p in shifted.points] == pytest.approx( [p.time - 2 for p in synced.points] @@ -358,22 +369,92 @@ def test_use_gpx_start_time_starts_the_track_with_the_video(self, tmp_path): NO_GPS_VIDEO, gpx, VideoSyncOption( - start_time=(GPX_START + datetime.timedelta(seconds=30)).timestamp(), - use_gpx_start_time=True, + start_time=(GPX_START + datetime.timedelta(seconds=30)).timestamp() ), + InterpolationOption(use_gpx_start_time=True), ) assert metadata.points[0].time == 0 - def test_process_applies_neither(self, tmp_path): - """process passes no VideoSyncOption, and ignores both for videos.""" + +class TestProcessInterpolationOptions: + """ + process applies --interpolation_offset_time and + --interpolation_use_gpx_start_time to the track it geotags a video with, as + it does when it geotags images from a GPX. It ignored them for videos. + """ + + # 3.613 seconds before the GPS clock of GPS_VIDEO starts + START = datetime.datetime(2019, 11, 18, 23, 44, 40, tzinfo=datetime.timezone.utc) + + def test_use_gpx_start_time_starts_the_track_with_a_video_with_gps(self, tmp_path): + """Instead of syncing it by the GPS clock of the video.""" + gpx = _write_gpx(tmp_path / "track.gpx", seconds=20, start=self.START) + + synced = _extract(GPS_VIDEO, gpx, None, InterpolationOption()) + started = _extract( + GPS_VIDEO, gpx, None, InterpolationOption(use_gpx_start_time=True) + ) + + assert synced.points[0].time == pytest.approx(-3.613) + assert started.points[0].time == 0 + + def test_the_offset_shifts_the_track_but_not_its_times(self, tmp_path): + """ + The frame at video time t is at the position the GPX recorded at + t + offset, and takes that time, as an image does. + """ + gpx = _write_gpx(tmp_path / "track.gpx", seconds=20, start=self.START) + + synced = _extract(GPS_VIDEO, gpx, None, InterpolationOption()) + shifted = _extract(GPS_VIDEO, gpx, None, InterpolationOption(offset_time=2)) + + assert [p.time for p in shifted.points] == pytest.approx( + [p.time - 2 for p in synced.points] + ) + assert [p.get_unix_time() for p in shifted.points] == [ + p.get_unix_time() for p in synced.points + ] + + def test_both_on_a_video_without_gps(self, tmp_path): + """As the README describes for a GPX started 2.5 seconds before the video.""" gpx = _write_gpx(tmp_path / "track.gpx", seconds=6) - metadata = _extract(NO_GPS_VIDEO, gpx, video_sync=None) + metadata = _extract( + NO_GPS_VIDEO, + gpx, + None, + InterpolationOption(use_gpx_start_time=True, offset_time=2.5), + ) + + assert metadata.points[0].time == pytest.approx(-2.5) + assert metadata.points[0].get_unix_time() == GPX_START.timestamp() + + def test_without_them_the_track_is_placed_as_before(self, tmp_path): + gpx = _write_gpx(tmp_path / "track.gpx", seconds=3600) + + metadata = _extract(NO_GPS_VIDEO, gpx, None, InterpolationOption()) assert metadata.points[0].time == 0 assert metadata.points[0].get_unix_time() == GPX_START.timestamp() + def test_the_factory_hands_them_to_the_gpx_source(self, tmp_path): + gpx = _write_gpx(tmp_path / "track.gpx", seconds=6) + + [metadata] = factory.process( + [NO_GPS_VIDEO], + [ + SourceOption( + SourceType.GPX, + source_path=SourcePathOption(source_path=gpx), + interpolation=InterpolationOption(offset_time=2), + ) + ], + ) + + assert isinstance(metadata, types.VideoMetadata) + assert metadata.points[0].time == pytest.approx(-2) + def _write_gpx_tracks(path: Path, tracks: list[list[str]]) -> Path: """A GPX file with one track per list of times, heading north at 10 m/s.""" @@ -432,7 +513,10 @@ def test_points_before_1980_are_dropped_and_the_rest_sorted( def test_use_gpx_start_time_starts_at_the_earliest_point(self, tmp_path): """Not at the first point in the file, nor at a junk one.""" metadata = _extract( - NO_GPS_VIDEO, self._gpx(tmp_path), VideoSyncOption(use_gpx_start_time=True) + NO_GPS_VIDEO, + self._gpx(tmp_path), + VideoSyncOption(), + InterpolationOption(use_gpx_start_time=True), ) assert metadata.points[0].time == 0 @@ -482,7 +566,9 @@ def test_the_offset_is_in_gps_seconds(self, tmp_path, monkeypatch): gpx = _write_gpx(tmp_path / "track.gpx", seconds=30, start=start) synced = _extract(GPS_VIDEO, gpx, VideoSyncOption()) - shifted = _extract(GPS_VIDEO, gpx, VideoSyncOption(offset_time=2)) + shifted = _extract( + GPS_VIDEO, gpx, VideoSyncOption(), InterpolationOption(offset_time=2) + ) assert [p.time for p in shifted.points] == pytest.approx( [p.time - 0.2 for p in synced.points] diff --git a/tests/unit/test_sample_video.py b/tests/unit/test_sample_video.py index 7bf9f398d..1cd2b0c6d 100644 --- a/tests/unit/test_sample_video.py +++ b/tests/unit/test_sample_video.py @@ -25,7 +25,11 @@ sample_video, telemetry, ) -from mapillary_tools.geotag.options import SourceType, VideoSyncOption +from mapillary_tools.geotag.options import ( + InterpolationOption, + SourceType, + VideoSyncOption, +) from mapillary_tools.mp4 import construct_mp4_parser as cparser, mp4_sample_parser from mapillary_tools.serializer import description from mapillary_tools.types import describe_error_metadata, FileType, VideoMetadata @@ -421,9 +425,14 @@ def test_a_track_far_longer_than_the_video_is_refused( def test_a_track_that_ends_with_the_video_at_the_ratio_passes(self, tmp_path): """The video spans 57.6s of GPS time at 10 seconds per second.""" gpx = self._write_gpx(tmp_path / "track.gpx", seconds=57) - video_sync = VideoSyncOption(use_gpx_start_time=True, duration_ratio=10) + video_sync = VideoSyncOption(duration_ratio=10) options = sample_video._parse_geotag_options( - ["gpx"], gpx, None, tmp_path, video_sync + ["gpx"], + gpx, + None, + tmp_path, + video_sync, + InterpolationOption(use_gpx_start_time=True), ) sample_video._check_gpx_start_by_interval(self.VIDEO, options, video_sync) @@ -462,9 +471,14 @@ def test_gps_rejected_as_noise_still_has_a_clock(self, tmp_path, monkeypatch): ), ) gpx = self._write_gpx(tmp_path / "track.gpx", seconds=60) - video_sync = VideoSyncOption(use_gpx_start_time=True) + video_sync = VideoSyncOption() options = sample_video._parse_geotag_options( - ["gpx"], gpx, None, tmp_path, video_sync + ["gpx"], + gpx, + None, + tmp_path, + video_sync, + InterpolationOption(use_gpx_start_time=True), ) sample_video._check_gpx_start_by_interval(self.VIDEO, options, video_sync) @@ -1925,10 +1939,23 @@ def fake_process(paths, options): interpolation_use_gpx_start_time=True, ) - assert seen[0].video_sync == VideoSyncOption( + assert seen[0].interpolation == InterpolationOption( offset_time=2.5, use_gpx_start_time=True ) + def test_the_interpolation_options_of_a_source_win(self): + """As they do when the geotagging stage geotags the frames.""" + options = sample_video._parse_geotag_options( + ['{"source": "gpx", "interpolation_offset_time": 1}'], + Path("/data/track.gpx"), + None, + Path("/data/v.mp4"), + VideoSyncOption(), + InterpolationOption(offset_time=2.5), + ) + + assert options[0].interpolation == InterpolationOption(offset_time=1) + def test_without_it_the_start_time_is_left_unknown(self): """ A video without a GPS clock would get its start time from its creation From fc3095737b9454c9a41ccbbfa52f0256b90a871e Mon Sep 17 00:00:00 2001 From: Caglar Pir Date: Wed, 21 Oct 2026 11:25:22 +0200 Subject: [PATCH 2/3] Fail a GPX track that an offset moves off the video, and name the offset With --interpolation_offset_time, process could move a track placed at the start of the video, assumed to start with it or by --interpolation_use_gpx_start_time, entirely off the video and still exit 0. The coverage check raised only for a track placed by a clock, and warned of the others, blaming the GPX file and its time zone. The video was then uploaded with no position for any of its frames. Placed at the start of the video, a track can miss it only by the offset, so it now raises as well, and says to check the offset. With an offset, the error gave the times of the video as they are, which overlap the track when the offset moves them off it, and so contradicted the gap it reported. It now also gives the times at which the offset looks the video up along the track, and image_time is the first of them, as it is the capture time plus the offset for an image. --- .../geotag/video_extractors/gpx.py | 53 ++++++++++++--- tests/integration/test_process.py | 8 +++ tests/unit/test_gpx_video_coverage.py | 67 +++++++++++++++++++ 3 files changed, 118 insertions(+), 10 deletions(-) diff --git a/mapillary_tools/geotag/video_extractors/gpx.py b/mapillary_tools/geotag/video_extractors/gpx.py index f6c191c27..bd348bbea 100644 --- a/mapillary_tools/geotag/video_extractors/gpx.py +++ b/mapillary_tools/geotag/video_extractors/gpx.py @@ -355,14 +355,16 @@ def _check_coverage( spans **rate** GPS seconds. A track that misses the video has no position for any moment of it, so - it raises when the clock it was placed by says so. A track that covers - only part of the video warns, and one that covers all of it is silent. + it raises when its placement says so: the clock it was placed by, or + --interpolation_offset_time. A track that covers only part of the video + warns, and one that covers all of it is silent. Without the duration of the video, the video is known only up to its last GPS point, and a track that starts after it may still overlap the video. Then a gap only warns, unless it is too large for any clock or - time zone mistake to explain. A track assumed to start with the video - has no clock to say that it misses the video, so it only warns. + time zone mistake to explain. A track placed at the start of the video, + whether assumed to start with it or by --interpolation_use_gpx_start_time, + can miss it only once --interpolation_offset_time moves it. """ if not gpx_points or gpx_unix_times is None: return @@ -376,12 +378,30 @@ def _check_coverage( gpx_first = min(p.time for p in gpx_points) gpx_last = max(p.time for p in gpx_points) + offset_time = ( + 0.0 if self.interpolation is None else self.interpolation.offset_time + ) + + # Where the start of the video is looked up along the track, in its + # Unix time: the offset is added to the time of every frame, as it is + # to the capture time of an image + lookup_start = ( + gpx_unix_times[0] if clock is None else clock.to_unix_time(0) + ) + offset_time gpx_track = f"The GPX track in {self.gpx_path} ({_isoformat(gpx_unix_times[0])} to {_isoformat(gpx_unix_times[1])})" if clock is None: video = f"the video {self.video_path}" else: video = f"the video {self.video_path} ({_isoformat(clock.to_unix_time(0))} to {_isoformat(clock.to_unix_time(video_end))})" + # Without it, the times of the video read as if the track missed it + # by more or less than it does + offset_clause = "" + if offset_time: + offset_clause = ( + f", as --interpolation_offset_time {offset_time:g} looks the video up along it " + f"({_isoformat(lookup_start)} to {_isoformat(lookup_start + video_end * rate)})" + ) # In GPS seconds, which is where clock and time zone mistakes are made gap = max(gpx_first - video_end, -gpx_last) * rate @@ -390,23 +410,36 @@ def _check_coverage( uncovered = max(gpx_first, 0.0) + max(video_end - gpx_last, 0.0) if _LOGGER_TOLERANCE_SECONDS < uncovered * rate: LOG.warning( - f"{gpx_track} covers only part of {video}: " + f"{gpx_track} covers only part of {video}{offset_clause}: " f"{_format_duration(uncovered)} of the video fall outside the track" ) return + if clock is None: + # Placed at the start of the video, only the offset moved it off + advice = "Check --interpolation_offset_time" + elif offset_time: + advice = ( + "Check --interpolation_offset_time, that the GPX file belongs to " + "this video, then the camera clock and the time zone of the GPX " + "timestamps" + ) + else: + advice = ( + "Check that the GPX file belongs to this video, then the camera " + "clock and the time zone of the GPX timestamps" + ) message = ( - f"{gpx_track} misses {video} by {_format_duration(gap)}. Check that " - "the GPX file belongs to this video, then the camera clock and the " - "time zone of the GPX timestamps" + f"{gpx_track} misses {video} by {_format_duration(gap)}{offset_clause}. " + f"{advice}" ) - if clock is not None and ( + if (clock is not None or offset_time) and ( video_duration is not None or _IMPLAUSIBLE_OFFSET_SECONDS < gap ): raise exceptions.MapillaryOutsideGPXTrackError( message, - image_time=build_capture_time(clock.to_unix_time(0)), + image_time=build_capture_time(lookup_start), gpx_start_time=build_capture_time(gpx_unix_times[0]), gpx_end_time=build_capture_time(gpx_unix_times[1]), ) diff --git a/tests/integration/test_process.py b/tests/integration/test_process.py index 46aa0e66c..067ba3ddd 100644 --- a/tests/integration/test_process.py +++ b/tests/integration/test_process.py @@ -752,6 +752,14 @@ def video_time_of_recorded(*options: str) -> float: shifted = video_time_of_recorded("--interpolation_offset_time=2") assert shifted == pytest.approx(synced - 2000, abs=1) + # an offset that moves the track off the video fails it, as it does a + # track placed by the clock of the video + error = describe( + "--interpolation_use_gpx_start_time", "--interpolation_offset_time=60" + )["error"] + assert error["type"] == "MapillaryOutsideGPXTrackError" + assert "by 40 seconds, as --interpolation_offset_time 60 " in error["message"] + def test_process_geotag_with_gpx_pattern_not_found(setup_data: py.path.local): video_path = setup_data.join("gopro_data").join("max-360mode.mp4") diff --git a/tests/unit/test_gpx_video_coverage.py b/tests/unit/test_gpx_video_coverage.py index cea2fed15..2c3687472 100644 --- a/tests/unit/test_gpx_video_coverage.py +++ b/tests/unit/test_gpx_video_coverage.py @@ -412,6 +412,73 @@ def test_assumed_start_is_silent_on_a_covering_track(self, tmp_path, caplog): assert not _warnings(caplog) +class TestInterpolationOffset: + """ + --interpolation_offset_time moves the track along the video, and can move + it off, however the track was placed: a track placed at the start of the + video then misses it as surely as one placed by a clock. + """ + + def _extract( + self, + tmp_path, + video_track: T.Sequence[geo.Point], + offset_time: float, + use_gpx_start_time: bool = False, + ) -> types.VideoMetadata: + video_path = _write_camm_mp4(tmp_path / "video.mp4", video_track) + gpx_path = _write_gpx(tmp_path / "track.gpx", A_UNIX_TIME, VIDEO_DURATION) + return GPXVideoExtractor( + video_path, + gpx_path, + interpolation=InterpolationOption( + offset_time=offset_time, use_gpx_start_time=use_gpx_start_time + ), + ).extract() + + @pytest.mark.parametrize( + "video_track, use_gpx_start_time", + [([], False), (UNTIMED_TRACK, False), (_video_track(), True)], + ids=["no GPS", "GPS without timestamps", "use_gpx_start_time"], + ) + # Before the start of the video, and after its end + @pytest.mark.parametrize("offset_time", [100, -100]) + def test_a_track_placed_at_the_start_moved_off_raises( + self, tmp_path, video_track, use_gpx_start_time, offset_time + ): + with pytest.raises(exceptions.MapillaryOutsideGPXTrackError) as info: + self._extract(tmp_path, video_track, offset_time, use_gpx_start_time) + message = str(info.value) + assert ( + f"by 1.5 minutes, as --interpolation_offset_time {offset_time} " in message + ) + # Nothing but the offset can have moved it off + assert message.endswith(". Check --interpolation_offset_time") + + def test_the_error_gives_the_times_the_video_is_looked_up_at(self, tmp_path): + """ + Its own times overlap the track, and would contradict the gap. The + offset is added to them, as it is to the capture time of an image. + """ + # The clock of the video puts the track at video times -20 to -8 + with pytest.raises(exceptions.MapillaryOutsideGPXTrackError) as info: + self._extract(tmp_path, _video_track(), offset_time=20) + message = str(info.value) + assert ( + "by 8 seconds, as --interpolation_offset_time 20 looks the video up " + "along it (2026-08-12T08:26:47.979846+00:00 to 2026-08-12T08:26:59.979846+00:00)" + ) in message + assert "Check --interpolation_offset_time, that the GPX file belongs" in message + assert info.value.image_time == "2026_08_12_08_26_47_979" + + def test_moved_partly_off_it_warns(self, tmp_path, caplog): + with caplog.at_level(logging.WARNING): + self._extract(tmp_path, [], offset_time=6) + [warning] = _warnings(caplog) + assert "as --interpolation_offset_time 6 looks the video up" in warning + assert "6 seconds of the video fall outside the track" in warning + + class TestFallThrough: """ A GPX that misses a file lets the next geotag source take its turn, with a From 6a3bc726e25357ba9a5fb2eb680466081b5d8b4b Mon Sep 17 00:00:00 2001 From: Caglar Pir Date: Wed, 21 Oct 2026 11:38:05 +0200 Subject: [PATCH 3/3] Say in the README that the offset moves videos too, and scope the timelapse limit A directory that mixes images and videos gets one --interpolation_offset_time for both. An offset that corrects the camera clock of the images now also moves the track of each video, which can then miss the video and fail it. Say so under the example of such a correction, with the way around it. The limit on timelapses without GPS is that of process. --- README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a0be14cfe..53205badc 100644 --- a/README.md +++ b/README.md @@ -375,6 +375,10 @@ mapillary_tools process MY_IMAGE_DIR \ --interpolation_offset_time -28800 ``` +The offset applies to videos geotagged from the GPX as well. In a directory that mixes images and videos, an offset +that corrects the camera clock of the images also moves the track of each video, and a video that the moved track +misses fails. To avoid that, process the images on their own with `--file_types image`. + Another option `--interpolation_use_gpx_start_time` moves your images to align with the beginning of the GPS track. This is useful when you can confirm that you start GPS recording and capturing at the same time, or with a known delay. For a video without a GPS clock, a track that lasts more than twice as long as the video spans at its @@ -473,7 +477,7 @@ VIDEO_DIR/ ### Limitations of `--video_geotag_source` **A GPX track is synced by the GPS clock of the video, if the video has GPS of its own, and otherwise aligned with -the start of the video.** A timelapse video is supported for a GPX only if it has GPS of its own. +the start of the video.** In `process`, a timelapse video is supported for a GPX only if it has GPS of its own. As with images, `--interpolation_offset_time N` adds N seconds to the time of the video to find its positions along the GPX track, and `--interpolation_use_gpx_start_time` aligns the start of the video with the start of the track, even