Skip to content

Let a GPX override a video whose own GPS is unusable - #831

Merged
caglarpir merged 2 commits into
mapillary:mainfrom
caglarpir:fix-gpx-override-noisy-gps
Oct 20, 2026
Merged

caglarpir merged 2 commits into
mapillary:mainfrom
caglarpir:fix-gpx-override-noisy-gps

Conversation

@caglarpir

@caglarpir caglarpir commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

Problem

Attaching a GPX is the documented escape hatch for a video with bad embedded GPS, but for a GoPro whose GPS is rejected as noise, --geotag_source gpx still fails with MapillaryGPSNoiseError: GPS is too noisy, whatever the GPX.

Reported against a GoPro MAX2 .360 timelapse: all 32 of its GPS points report a DoP of 21.0 to 21.4 against a limit of 10, so remove_noisy_points() drops every one of them, the 24 with a 3D fix included.

A GoPro HERO12, which has no GPS, fails the same way with any GPX: its telemetry has a GPS stream with no points, and the video is rejected with MapillaryGPXEmptyError: Empty GPS data found.

Cause

Before #743, the GPX extractor fell back to the GPX on any error from the native extractor. Now it catches only MapillaryVideoGPSNotFoundError, and MapillaryGPXEmptyError and MapillaryGPSNoiseError are siblings of that class, not subclasses. The geotag source chain has the same gap: only "not found" passes a video on to the next source, so --geotag_source native,gpx stops at native.

Changes

  • The three errors are now subclasses of a new MapillaryVideoGPSUnusableError, which the GPX extractor catches. Such a video is geotagged from the GPX as one without GPS is: synced from the start of the video, as a plain VIDEO.
  • Once a video's own GPS is rejected as empty or noisy, only a source that reads GPS from outside the video (GPX, NMEA) is given it. Another reader of the same telemetry, such as exiftool in the default chain, could only repeat that verdict or overturn it.
  • An error is held until a later source geotags the path, and reported if none does, so a source that skips videos (NMEA, EXIF) no longer drops them. Main already drops a video without GPS this way with native,nmea or native,exif, with exit code 0.

The noise filter is unchanged: without a GPX, the video is still rejected.

Not in this PR

Verification

process on the reported clip and its GPX:

Sources main this PR
default GPS is too noisy GPS is too noisy
native GPS is too noisy GPS is too noisy
exiftool_runtime GPS is too noisy GPS is too noisy
gpx GPS is too noisy GPX, from the start of the video
native,gpx GPS is too noisy GPX, from the start of the video
native,exiftool_runtime,gpx GPS is too noisy GPX, from the start of the video
native,nmea GPS is too noisy GPS is too noisy

A noisy GoPro HERO8 sample behaves the same. A HERO12 video with the GPX recorded alongside it fails on main with "Empty GPS data found", and here is geotagged with all 3,810 points of the GPX. A GoPro MAX clip with good GPS and a GPX syncs exactly as on main, and a Sony clip without GPS now reports "No GPS data found" with native,nmea and native,exif instead of vanishing. Across 168 runs over sample files from over 20 cameras, with this PR and the two stacked on it applied, that HERO12 run is the only output that differs from main.

Unit: 727 passed, 18 skipped, 1 xfailed. Integration: 56 passed. mypy, ruff check and ruff format --check are clean. 11 of the 24 new tests fail on main; the others guard what must not change.

@meta-cla meta-cla Bot added the cla signed label Sep 18, 2026
@caglarpir

Copy link
Copy Markdown
Contributor Author

Pushed 898988b to fix a regression in the first commit — thanks to @caglarpir for catching it.

Making MapillaryGPSNoiseError reprocessable was too broad. The default chain is native, exiftool_runtime, so a video the native parser had just rejected as noise fell through to exiftool, and mapillary_tools process with no flags at all started accepting the very file this PR is about.

The two readers disagree because they do not see the same fields:

reader fix DoP (limit 1000) after remove_noisy_points()
native GPMF 24×FIX_3D, 8×NO_FIX ~2100–2139 0 / 32 → rejected
exiftool 24×FIX_3D, 8×NO_FIX None for all 32 24 / 32 → accepted

exiftool losing GPSP is a pre-existing bug — --geotag_source exiftool_runtime already accepts this file on main, independently of this PR. What changed here was only that the default chain started reaching it. I've left that one alone as out of scope; happy to file it separately.

The fix: unusable GPS is a verdict on the data, not on the reader that reported it, so only a source supplying GPS from outside the video can overturn it. The fall-through is now gated on the remaining sources — GPX and NMEA can rescue the file, another reader of the same embedded telemetry cannot. MapillaryVideoGPSNotFoundError is unaffected, since "could not read it" really is a verdict on the reader.

Verified on the reported file:

process (default)                    -> GPS is too noisy
process --geotag_source native       -> GPS is too noisy
process --geotag_source gpx          -> 1 gopro ready
process --geotag_source native,gpx   -> 1 gopro ready

Tests are up to 20 (from 11), including one pinning the default chain specifically. 705 unit + 52 integration pass, mypy clean.

@caglarpir

Copy link
Copy Markdown
Contributor Author

Correction to the verification in the description above

The description offers this as evidence the fix works:

a dry-run upload produces an mp4 whose CAMM track carries the GPX coordinates at t=2.0, 4.0, …

That is not a clean result, and I should not have presented it as one. The clip is 1.24 s long, so a track starting at t=2.0 lands almost entirely past the end of the video. I noticed the oddity at the time and wrongly put it down to the clip being a truncated excerpt.

It isn't truncated — it is a 12.5× timelapse, compressing 15.5 s of wall clock into 1.24 s of video. So the GPX (15:39:43–15:39:55) sits comfortably inside the video's real capture window; it is the mapping onto the video's time axis that is wrong:

GPX -> video axis, two ways:
  today  (_rebase_times, real elapsed): 2.000 .. 14.000 s
  correct (scaled onto video axis)    : 0.160 .. 1.120 s

frames occupy 0.000 .. 1.240 s
  today   -> overlap: NO  (track starts after the clip ends)
  correct -> overlap: YES, 25 of 32 frames fall inside the track

_rebase_times() produces real elapsed seconds, and _gpx_offset() reads only video_gps_points[0] — a single anchor, so it can shift the track but never rescale it. For a real-time video the two axes coincide and nobody notices; for a timelapse they diverge by the timelapse factor.

What still stands

Everything the PR actually claims to change:

  • a valid GPX is no longer ignored when the embedded GPS is rejected as noise
  • filetype=gopro and make/model survive instead of degrading to a bare VIDEO
  • the noise gate is unchanged: a noisy video with no GPX supplied is still rejected
  • the native → exiftool_runtime fall-through fixed in 898988b

What it does not do

It does not make the resulting track correctly timed on a timelapse. That is a separate fix, and this PR is a prerequisite for it: keeping the noisy points preserves the GPS clock that the correct mapping needs to invert.

Same family as #832, which fixes when a video starts; this is how fast its clock runs. Worth noting that #832's reported case failed as MapillaryOutsideGPXTrackError and turned out to be a time-mapping bug too — so that error is a poor signal to treat as a genuine data mismatch.

caglarpir added a commit to caglarpir/mapillary_tools that referenced this pull request Sep 23, 2026
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
Attaching a GPX is the documented escape hatch for a video with bad
embedded GPS, but for a GoPro whose GPS is rejected as noise

  mapillary_tools process VIDEO --geotag_source gpx --geotag_source_path GPX

still failed with "GPS is too noisy", whatever the GPX. Reported against
a GoPro MAX2 .360 timelapse: all 32 of its GPS points report a DoP of
21.0 to 21.4 against a limit of 10, so remove_noisy_points() drops every
one of them, the 24 with a 3D fix included.

This used to work. Before mapillary#743, NativeVideoExtractor returned an
ErrorMetadata and GPXVideoExtractor fell back to the GPX on any of them.
It now raises instead, and the GPX extractor catches only
MapillaryVideoGPSNotFoundError. MapillaryGPXEmptyError and
MapillaryGPSNoiseError are siblings of that class rather than
subclasses, so they escaped and failed the video. That is also why the
same GPX worked on a video with no GPS at all.

Make the three "no usable GPS in the video" errors subclasses of a new
MapillaryVideoGPSUnusableError and catch that, so that a fourth cannot
be missed the same way. They stay MapillaryDescriptionError subclasses,
so nothing that catches them changes. A video whose GPS is unusable is
geotagged from the GPX as one without GPS is: synced from the start of
the video, as a plain VIDEO.

The noise filter itself is unchanged: without a GPX, the video is still
rejected with "GPS is too noisy".
The previous commit lets --geotag_source gpx geotag a video whose own
GPS is empty or too noisy, but a chain such as

  --geotag_source native --geotag_source gpx

still stopped at native: only MapillaryVideoGPSNotFoundError passes a
video on to the next source.

Passing every unusable-GPS error on is not enough by itself:

- The default chain is native, exiftool_runtime. A video that native
  rejected would go on to exiftool, which reads the same telemetry and
  can only repeat the verdict or overturn it by reading the data
  differently: until mapillary#840 taught it the GPS9 DoP, exiftool accepted the
  reported clip that native rejects. Where exiftool is not installed,
  "exiftool not found" would replace "GPS is too noisy".
- A source that cannot geotag videos (NMEA, EXIF) skips them, and a
  video left for the next source is then dropped from the results: no
  error, and exit code 0. Main already drops a video without GPS this
  way with --geotag_source native,nmea or native,exif.

So once a video's own GPS is rejected as empty or noisy, only a source
that reads GPS from outside the video (GPX, NMEA) is given it. Every
error is held until a later source geotags the path, and reported if
none does, so no source can drop a video by skipping it. A fallback
that finds nothing, such as a missing GPX, does not replace the reason
the video's own GPS was rejected; any other error from it does.

Held errors are reported after the other results, so the order of the
results can change.
@caglarpir

Copy link
Copy Markdown
Contributor Author

Rewritten on current main and narrowed to the original fix; the description is updated. The two comments above describe an earlier version: its look-ahead in _is_reprocessable() and its sync by the noisy points are gone.

Syncing the reported timelapse correctly is now #849 (stacked on this and #847), and rejecting a GPX that misses the video is #848.

@caglarpir
caglarpir merged commit 024301c into mapillary:main Oct 20, 2026
16 checks passed
caglarpir added a commit to caglarpir/mapillary_tools that referenced this pull request Oct 20, 2026
main reworked GPXVideoExtractor (mapillary#831, mapillary#847, mapillary#849): it syncs a GPX
through a VideoClock, with a rate for timelapses, falls back to the start
of the video when the video's GPS has no usable clock, and reports a GPX
it cannot parse. This branch changed the same method, so take main's and
re-apply this branch's changes to it:

- Drop GPX points timestamped before 1980, and sort the rest.
- Place the GPX by --video_start_time wherever the video has no clock to
  sync it with: no usable GPS, as before, and now also GPS without a
  clock, such as noise with a placeholder date, which main places at the
  start of the video.
- Apply --interpolation_offset_time after the sync. The offset is in GPS
  seconds, so with a timelapse clock the track moves by offset / rate
  seconds of video; by the whole offset, frames would be picked
  offset * (rate - 1) seconds of track away from where they are geotagged.

In factory.py, keep both main's EXTERNAL_GPS_SOURCES and this branch's
_ALL_VIDEO_TYPES. Tests cover the clockless placement and the timelapse
offset.
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