Skip to content

Fail the sample instead of silently producing no frames - #839

Merged
caglarpir merged 8 commits into
mapillary:mainfrom
caglarpir:fix-silent-sample-failure
Oct 20, 2026
Merged

caglarpir merged 8 commits into
mapillary:mainfrom
caglarpir:fix-silent-sample-failure

Conversation

@caglarpir

@caglarpir caglarpir commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

Problem

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, so 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, but nothing about it is specific to noisy GPS — a video with no GPS at all takes the same path. Same clip, same GPX, only the sampling mode differs:

exit frames
--video_sample_distance 3 0 0
--video_sample_interval 2 0 3

Fix

Distance sampling needs positions to decide which frames to cut, so failing to read them is a failed sample. It now raises instead of warning and returning, with the error read from the video's metadata as it is (MapillaryGPSNoiseError, MapillaryVideoGPSNotFoundError, ...). Two neighbouring silent paths get the same treatment: a missing video stream (also returned after a warning), and an empty point list (an assert, so it disappeared under python -O, leaving an IndexError further down).

A track that covers none of the video, such as a GPX from another trip, selected no frames and exited 0 with an empty sample directory, the same silent success reached through a readable track. That is a failed sample too (3649f34).

A video that fails no longer stops the others (38ded6a). Raising out of the first failure made the result for a directory depend on which video that was, and left the frames of the videos sampled before it unprocessed. Sampling now does what process does with the files it cannot process:

  • It sorts the videos, so that they are sampled and reported in a stable order.
  • It carries on past a failed video, logs why it failed, and ends with a sample summary that counts the failures by type.
  • It then raises one MapillaryVideoSampleError, exit code 7, naming the flag that skips it.
  • video_process processes the frames of the videos that did sample before re-raising it, as process writes out the descriptions it did produce before failing. video_process_and_upload then stops before uploading, and says so.
ERROR   - Failed to sample .../gopro_data/hero8.mp4: GPS is too noisy
INFO    - ==> Sample summary
INFO    - 2 videos found
INFO    -       1 sampled
ERROR   -       1 MapillaryGPSNoiseError
INFO    - ==> Process summary
INFO    -       3 (406.2 kB) ready
ERROR   - MapillaryVideoSampleError: Failed to sample 1 videos. To skip these errors, specify --skip_sample_errors

With --skip_sample_errors the failures are logged as warnings, and the command exits 0. A missing ffmpeg still fails at once, and its message does not offer the flag, which does not suppress it. The sample directory is created as soon as there are videos to sample, so skipping every video no longer ends in "Import file or directory not found".

The README now documents --skip_sample_errors, and that it covers sampling only, not the errors --skip_process_errors skips (8423f1d).

Behaviour change worth flagging (for the release notes)

A directory with a video that cannot be sampled now exits 7 unless --skip_sample_errors is specified, where it used to exit 0 having silently left that video out. The other videos are still sampled and processed, and their descriptions written.

video_process_and_upload then uploads nothing, where it used to upload the other videos. That is deliberate: a run that failed should not pass for one that worked, and process_and_upload does the same with the files it fails to process. Because the last thing logged before the error is that the sample images are ready, it says that nothing was uploaded (7cd3025):

INFO    - ==> Process summary
INFO    -       3 (406.2 kB) ready
ERROR   - Nothing was uploaded, because some videos failed to sample. To upload the sample images of the others, specify --skip_sample_errors
ERROR   - MapillaryVideoSampleError: Failed to sample 1 videos. To skip these errors, specify --skip_sample_errors

With --skip_sample_errors it uploads the videos that did sample and exits 0, as it used to.

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, and new tests pin the loud behaviour, and that the other videos are still processed.

Scope

This does not make --geotag_source reach distance sampling. That is #841, which is stacked on this PR. This one only stops failures from being silent.

Independent of #831 (different file, branches off main).

Verification

Full suite passes (773), with mypy (for Python 3.9, as CI checks, and 3.13), ruff and usort clean.

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.
@meta-cla meta-cla Bot added the cla signed label Sep 21, 2026
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
@caglarpir

Copy link
Copy Markdown
Contributor Author

Pushed bb5873c — good catch, the trap is real.

The raise said the sample failed but not how to get past it, and the obvious guess is the wrong one: --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.

The message now reads:

MapillaryVideoError: Unable to sample GS018205.360 by distance: GPS is too noisy. To skip these errors, specify --skip_sample_errors

Applied to all three raises in this PR, wording matching process_geotag_properties.py. Added a test asserting the flag is named, so it cannot quietly drop out of the message later.

760 unit + integration tests pass, mypy clean.

Four other raises sit inside the same try block that
--skip_sample_errors guards and said nothing about it:

  _sample_single_video_by_interval  unable to extract video start time
  _sample_single_video_by_distance  unable to extract video start time
  _sample_single_video_by_distance  expect N samples but extracted M
  _sample_single_video_by_distance  expect X to be Nth sample but got M

Half the errors naming the flag is worse than none naming it: a user who
lands on a silent one reaches for --skip_process_errors, which governs
the later geotagging stage and leaves the run failing with the same
message.

Rather than append the hint at seven call sites, where it can drift out
of sync again, construct these errors through _sampling_error(), so a
raise added later cannot forget it.

MapillaryFFmpegNotFoundError keeps its plain message. It is re-raised by
its own handler before the skip check, so --skip_sample_errors really
does not suppress it and it must not say otherwise. There is a test for
that, alongside ones covering both newly hinted start-time paths.
@caglarpir

Copy link
Copy Markdown
Contributor Author

Pushed 976c135 — took the polish, thanks.

You were right that half the errors naming the flag is worse than none: a user who lands on a silent one reaches for --skip_process_errors and gets nowhere. All four now name it, alongside the three this PR already covered.

Rather than append the hint at seven call sites, where it can drift out of sync again, they go through a small constructor:

def _sampling_error(message: str) -> exceptions.MapillaryVideoError:
    return exceptions.MapillaryVideoError(f"{message}. {_SKIP_HINT}")

so a raise added later cannot forget it.

One deliberate exception: MapillaryFFmpegNotFoundError keeps its plain message. It is re-raised by its own handler before the skip check, so --skip_sample_errors genuinely does not suppress it and it must not claim otherwise. There is a test pinning that, plus tests for both newly hinted start-time paths — the two frame-count raises remain unexercised, as you noted, but they can no longer be inconsistent by construction.

713 unit + 57 integration pass, mypy and ruff clean.

Raising out of the first video that failed to sample made the result of
a directory depend on which video that was. find_videos() does not sort,
so the videos sampled before the failure depended on the order the
filesystem lists them in. The command exited 7 with their frames written
to the sample directory but never processed, because video_process never
reached the process step.
The same directory exits 0 on main, where the failure was silent.

Do what the process command does with the files it cannot process:

  - Sort the videos, so they are sampled and reported in a stable order.
  - Carry on past a failed video, log why it failed, and log a sample
    summary that counts the failures by type, like the process summary.
  - Then raise a single MapillaryVideoSampleError, still exit code 7:

      Failed to sample 1 videos. To skip these errors, specify
      --skip_sample_errors

  - video_process processes the frames of the videos that did sample
    before re-raising it, as process writes out the descriptions it did
    produce before failing. video_process_and_upload then stops before
    uploading, as process_and_upload does on a process error.

With --skip_sample_errors the failures are logged as warnings and the
command exits 0.

Since the aggregated error names the flag, the per-raise hint and
_sampling_error() are gone. The errors read from the video's metadata
are raised as they are, so the summary counts them by their own type,
MapillaryGPSNoiseError or MapillaryVideoGPSNotFoundError, instead of all
of them as MapillaryVideoError.

Skipping every video used to end in "Import file or directory not
found" for the sample directory, which nothing had created. Create it
once there are videos to sample, so the process step finds it empty. A
directory without videos still fails as before.
A GPS track that covers none of the video, for example a GPX from
another trip, puts no frame within the track, so distance sampling
selected nothing. The command exited 0 with an empty sample directory:
the same silent success as the unreadable GPS fixed earlier, reached
through a readable track instead.

Treat it as a failed sample, so the summary counts it and the command
fails unless --skip_sample_errors is specified:

  No video frames found within the GPS track from 100.000s to 109.000s
The flag was missing from the README. Say what happens without it when a
video cannot be sampled, and that it does not cover the errors that
--skip_process_errors skips.
CI type-checks with mypy on Python 3.9, which rejects the
`MapillaryVideoSampleError | None` annotation added to video_process
unless annotations are postponed, and so failed every build. Add
`from __future__ import annotations`, as the other modules do. The
annotation is on a local variable, which Python does not evaluate, so the
command itself was not affected.
caglarpir added a commit to caglarpir/mapillary_tools that referenced this pull request Oct 20, 2026
mapillary#839 now samples every video before failing and reports the failures
together (38ded6a), and fails distance sampling that selects no frames
(3649f34). Both meet this branch in sample_video.py:

- 38ded6a removed _sampling_error(): errors are raised as they are, and
  the summary error names --skip_sample_errors. The one call this branch
  added, in its check for a track that misses the video, now raises a
  plain MapillaryVideoError, and leaves out the video path, which the
  summary logs.
- 3649f34 added the same check at the same spot. Keep this branch's,
  whose message says where the track lies relative to the video.

Three tests follow: 3649f34's test of the message; this branch's unit
test of a missed track, which reads the log now that the error raised is
the summary; and its integration test, which checks for no frames rather
than no sample directory, now that the directory is created up front.
When a video fails to sample, video_process_and_upload exits 7 without
uploading the videos that did sample, as process_and_upload does with
the files it fails to process. But the last thing it logged before the
error was the process summary, with their sample images "ready", so it
read as if they were about to be uploaded:

  ==> Process summary
          3 (406.2 kB) ready
  MapillaryVideoSampleError: Failed to sample 1 videos. To skip these
  errors, specify --skip_sample_errors

Log that nothing was uploaded, and that --skip_sample_errors uploads the
others, before failing.
caglarpir added a commit to caglarpir/mapillary_tools that referenced this pull request Oct 20, 2026
Brings in 7cd3025, which has video_process_and_upload log that it uploaded nothing when a video fails to sample. No conflicts.
@caglarpir
caglarpir merged commit 9176393 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
mapillary#839 reached main through its own merge commit, so this branch and main
had two merge bases, mapillary#839's tip and the main merged in before it. git
merges them cleanly, but GitHub reported the pull request as conflicting.
Merging main again leaves it a single base. It brings in mapillary#843, which
merges without conflicts, and nothing else changes.
caglarpir added a commit to caglarpir/mapillary_tools that referenced this pull request Oct 20, 2026
The previous commit reads a video's start time from its telemetry and
otherwise takes creation_time as the start. That fixes the reported
dashcam, and Sony and Insta360, which also stamp the start. But it moves
every video from a camera that stamps the end one full duration late.
Those are most dashcams: Viofo, Vantrue, AZDome and others without GPS
telemetry, which people geotag from a GPX, plus the Ricoh Theta X and
the Labpano PanoX V2. With interval sampling and a GPX of their own
track, sample videos from a Viofo A129 and an AZDome GS63H went from 119
and 57 geotagged frames to 1 and 0.

Nothing in the metadata says which of the two a camera writes, so look
for evidence, in this order:

- a camera known to stamp the end: the Theta X and the PanoX V2 by their
  make and model tags, BlackVue by the boxes it writes its GPS log into,
  which it does whether or not it ever gets a fix
- a date and time in the file name that matches only one of the two,
  allowing for a whole UTC offset because cameras name files in local
  time
- otherwise the start, with a warning that prints the
  --video_start_time value to use if the camera stamps the end

Also:

- Take the median of the first 5 plausible GPS timestamps instead of the
  first one. The PanoX V2 can record a stale first fix seconds older
  than the rest, and a GPS clock that was never set (GoPro writes
  2000-01-01 until its first fix) must not become the start time. The
  hero8 fixture moves by 0.1s because the GPS5 samples of one payload
  share a timestamp.
- Fall back to the creation time when the telemetry fails to parse,
  instead of failing to sample the video.
- Measure BlackVue point times from the first NMEA line, which is the
  start of the recording, instead of from the first valid fix. Until the
  receiver gets a fix the camera logs lines without positions, so every
  point, and with the previous commit every frame sampled by interval,
  was late by the time to first fix. Across 433 BlackVue sample videos
  7 took more than 5s to get a fix, one 60s.
- Distance sampling resolves the start time only when a sampled frame
  has no absolute GPS time of its own, instead of requiring a creation
  time up front for every video.
- Ignore a malformed creation_time with a warning instead of raising.

On top of mapillary#839 and mapillary#841: the start time raises a plain
MapillaryVideoError, which the sample summary reports, as mapillary#839's other
sampling errors do. mapillary#839's test of an unreadable start time patched
probe_video_start_time(), which this removes; it now covers interval
sampling through _extract_video_start_time(), and distance sampling
through a track without a GPS clock, which is when distance sampling
looks the start time up. mapillary#841's comment on why only --video_start_time
places a GPX gave creation_time - duration as the reason, which no longer
holds; the reason now is that a video without a GPS clock would get its
start time from its creation time, which is stamped at the start or the
end of the recording, some in local time.
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