Skip to content

Honour --geotag_source in distance-based sampling - #841

Open
caglarpir wants to merge 3 commits into
mapillary:mainfrom
caglarpir:fix-geotag-source-in-distance-sampling
Open

caglarpir wants to merge 3 commits into
mapillary:mainfrom
caglarpir:fix-geotag-source-in-distance-sampling

Conversation

@caglarpir

Copy link
Copy Markdown
Contributor

Stacked on #839. The first commit is that PR; review only b95d573. The diff shrinks to one commit once #839 lands.

Problem

Distance sampling called GeotagVideosFromVideo() directly, bypassing the geotag factory, so --geotag_source never reached it. Attaching a GPX is the documented answer for a camera whose embedded GPS is unusable — but the sampler needs positions just as much as geotagging does, and it only ever looked at the video's own telemetry. The GPX was read, announced in the log, then ignored:

video_process V.mp4 OUT --video_sample_distance 3 \
    --geotag_source gpx --geotag_source_path V.gpx
-> 0 frames

Fix

Go through factory.process(). The consumer was already generic — _sample_video_stream_by_distance() takes a plain Sequence[geo.Point] and doesn't care about provenance, and GPXVideoExtractor already returns points rebased onto the video timeline. Only the producer was hardcoded.

Routing rather than adding a fallback keeps the source-selection policy in one place: sample_video.py has no business re-deciding which source may rescue a file, _build_video_geotag() stays the only map from source to extractor, and chaining (native,gpx) comes for free.

Sampling defaults to NATIVE alone, not DEFAULT_GEOTAG_SOURCE_OPTIONS, which continues on to exiftool_runtime. That reader cannot see every field the noise filter rejects on (see #840), so defaulting to the full chain would make sampling accept tracks the native parser refuses. NATIVE-only is also exactly today's behaviour.

_parse_source_options() moves from process_geotag_properties to geotag.factory, next to parse_source_option(), so both callers share one implementation instead of the sampler reaching for a private helper.

commands/sample_video.py needs no change: it splats vars_args filtered by the signature of sample_video(), so the new parameters flow through from video_process, which registers the process command's arguments into the same parser. The standalone sample_video command doesn't register them, so they stay absent and the defaults apply.

Verification

scenario before after
no-GPS video + overlapping GPX 0 frames 86 frames
noisy GoPro + overlapping GPX 0 frames 299 frames (with #831)
noisy GoPro, no GPX rejected rejected

The noisy-GoPro row needs #831 as well: without it GPXVideoExtractor still lets MapillaryGPSNoiseError escape before the GPX is consulted. This PR is what gets the GPX to the sampler at all.

Full suite passes, mypy and ruff clean. (test_persistent_cache::test_multiprocess_shared_cache_comprehensive is flaky under parallel load on this machine — passes in isolation, with and without this change.)

Not fixed here

A GPX is still mapped onto the video's timeline by a constant offset, which is wrong for a timelapse, where the video clock runs slower than wall time. The reported clip is a 12.5x timelapse, so it still yields nothing even with this PR. That is a separate change in GPXVideoExtractor — see the correction on #831.

Distance sampling warned and returned when it could not read a video's
GPS, so the command exited 0 having written nothing:

  WARNING - GPS is too noisy
  ==> Processing 0 files with source gpx...
  ==> Validating 0 metadatas...
  ==> Process summary

No error, no frames, no non-zero exit -- the sample directory is never
created and the geotag stage then runs over zero files. The user is told
the run succeeded and has nothing to upload. Reported for a GoPro MAX 2
whose embedded GPS is rejected as noise, where attaching a GPX made no
difference, but nothing about it is specific to noisy GPS: a video with
no GPS at all takes the same path, which is every "camera without
embedded GPS, bring your own GPX" workflow.

Distance sampling needs positions to decide which frames to cut, so
failing to read them is a failed sample. Raise MapillaryVideoError, the
same error the rest of this function already raises for an unreadable
start time or a frame count mismatch, and which exits 7 rather than
dumping a traceback. sample_video() already funnels sampling errors
through --skip_sample_errors, so callers who want to tolerate this keep
a supported way to do it and the default stops lying.

Two neighbouring silent paths get the same treatment: a missing video
stream, which also returned after a warning, and an empty point list,
which was an assert and so disappeared under `python -O`, leaving an
IndexError further down instead.

Note this changes batch behaviour. Sampling a directory containing one
unreadable video now aborts unless --skip_sample_errors is passed. That
matches what every other sampling error in this function already does,
and the alternative is continuing to hide the failure, but it is a
behaviour change for callers who relied on the skip.

Three integration tests covered directories containing hero8.mp4, whose
32 embedded points are all dropped by remove_noisy_points(); they were
passing while it contributed no frames at all. They now pass
--skip_sample_errors, which is what they always meant. A new test pins
the loud behaviour, mirroring test_sample_video_without_video_time.

This does not make --geotag_source reach distance sampling; that call
site still hardcodes GeotagVideosFromVideo() and is a separate fix. It
only stops the failure from being silent.
The raise told users the sample failed but not how to get past it, and
the obvious guess is wrong: --skip_process_errors governs the later
geotagging stage and does not cover sampling, so reaching for it leaves
the run failing with the same message.

Append the hint, matching the existing wording in
process_geotag_properties.py. The message now reads:

  MapillaryVideoError: Unable to sample GS018205.360 by distance:
  GPS is too noisy. To skip these errors, specify --skip_sample_errors
Distance sampling called GeotagVideosFromVideo() directly, bypassing the
geotag factory, so --geotag_source never reached it. Attaching a GPX is
the documented answer for a camera whose embedded GPS is unusable, but
the sampler needs positions just as much as geotagging does, and it only
ever looked at the video's own telemetry. The GPX was read, reported in
the log, and then ignored:

  video_process V.mp4 OUT --video_sample_distance 3 \
      --geotag_source gpx --geotag_source_path V.gpx
  -> 0 frames

Go through factory.process() instead. The consumer was already generic:
_sample_video_stream_by_distance() takes a plain Sequence[geo.Point] and
does not care where they came from, and GPXVideoExtractor already
returns points rebased onto the video timeline. Only the producer was
hardcoded.

Routing rather than adding a fallback here keeps the source-selection
policy in one place. sample_video.py has no business re-deciding which
source may rescue a file; _build_video_geotag() stays the only map from
a source to an extractor, and chaining (native,gpx) comes for free.

Sampling defaults to NATIVE alone rather than to
DEFAULT_GEOTAG_SOURCE_OPTIONS, which continues on to exiftool_runtime.
That reader cannot see every field the noise filter rejects on, so
defaulting to the full chain would make sampling accept tracks the
native parser refuses. NATIVE-only is also exactly today's behaviour.

_parse_source_options() moves from process_geotag_properties to
geotag.factory, next to parse_source_option(), so both callers share one
implementation instead of the sampler reaching for a private helper.

commands/sample_video.py needs no change: it splats vars_args filtered
by the signature of sample_video(), so the new parameters flow through
from video_process, which registers the process command's arguments into
the same parser. The standalone sample_video command does not register
them, so they stay absent and the defaults apply.

Verified end to end:

  no-GPS video + overlapping GPX       0 frames -> 86 frames
  noisy GoPro + overlapping GPX        0 frames -> 299 frames (with mapillary#831)
  noisy GoPro, no GPX                  still rejected
@meta-cla meta-cla Bot added the cla signed label Sep 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant