Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -327,7 +327,10 @@ mapillary_tools upload MY_SAMPLES --desc_path=/tmp/mapillary_description.json
If you use external GPS devices for mapping, you will need to geotag your captures with the external GPS tracks.

To geotag images with a GPX file, the capture time (extracted from EXIF tag "Date/Time Original" or "GPS Date/Time") is minimally required.
It is used to locate the images along the GPS tracks.
It is used to locate the images along the GPS tracks. Images captured before the track starts, after it ends, or in a
gap of more than an hour between two of its points (such as between two trips in one GPX file) fall outside the track,
and are not geotagged from it. The same applies to videos. To allow longer gaps, set the environment variable
`MAPILLARY_TOOLS_MAX_GPX_GAP_SECONDS` (3600 by default).

```sh
mapillary_tools process MY_IMAGE_DIR --geotag_source "gpx" --geotag_source_path MY_EXTERNAL_GPS.gpx
Expand Down Expand Up @@ -462,6 +465,8 @@ with a GPX file called `foo.gpx`.
```sh
mapillary_tools process --video_geotag_source gpx VIDEO_DIR/
```
NMEA files work the same way with `--video_geotag_source nmea`, which associates `foo.mp4` with `foo.nmea`. What
follows about GPX tracks applies to NMEA tracks too.

#### Insta360 stitched videos

Expand Down
36 changes: 35 additions & 1 deletion mapillary_tools/commands/video_process.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@
from __future__ import annotations

import logging
from pathlib import Path

from .. import exceptions
from .. import constants, exceptions
from ..sample_video import find_sample_dirs
from ..types import FileType
from .process import Command as ProcessCommand
from .sample_video import Command as SampleCommand
Expand Down Expand Up @@ -43,7 +45,39 @@ def run(self, args: dict):
)
args[option] = {FileType.IMAGE}

if args["skip_subfolders"]:
_add_sample_dirs(args)

ProcessCommand().run(args)

if sample_error is not None:
raise sample_error


def _add_sample_dirs(args: dict) -> None:
"""
Add the folders that the videos were sampled into to the import paths.
--skip_subfolders chose the videos to sample, and hides the subfolders of
the import path from processing (and from uploading in
video_process_and_upload), which the samples of each video are in
"""
import_path: Path = args["import_path"]
sample_dirs = [
sample_dir
for sample_dir in find_sample_dirs(
args["video_import_path"], import_path, skip_subfolders=True
)
# Not a video that failed to sample
if sample_dir.is_dir()
]
if not sample_dirs:
return

if args.get("desc_path") is None:
# Where process writes it for the import path alone, rather than to
# STDOUT, as for several import paths
args["desc_path"] = str(
import_path.joinpath(constants.IMAGE_DESCRIPTION_FILENAME)
)

args["import_path"] = [import_path, *sample_dirs]
5 changes: 5 additions & 0 deletions mapillary_tools/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,11 @@ def _parse_scaled_integers(
###################
USER_DATA_DIR = appdirs.user_data_dir(appname="mapillary_tools", appauthor="Mapillary")
PROMPT_DISABLED: bool = _yes_or_no(os.getenv(_ENV_PREFIX + "PROMPT_DISABLED", "NO"))
# In seconds. Consecutive points of a GPX or NMEA track further apart than
# this leave a gap that no position is interpolated across, such as a GPS
# logger switched off between trips: the time inside it is outside the track,
# like the time before its start or after its end
MAX_GPX_GAP_SECONDS = float(os.getenv(_ENV_PREFIX + "MAX_GPX_GAP_SECONDS", 3600))


############################
Expand Down
126 changes: 114 additions & 12 deletions mapillary_tools/geotag/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
geotag_images_from_video,
geotag_videos_from_exiftool,
geotag_videos_from_gpx,
geotag_videos_from_nmea,
geotag_videos_from_video,
)
from .options import (
Expand All @@ -38,8 +39,7 @@
_ALL_VIDEO_TYPES = {types.FileType.VIDEO, *types.NATIVE_VIDEO_FILETYPES}

# Sources that read a GPS track from outside the video file, as opposed to
# re-reading the telemetry embedded in it. (NMEA cannot geotag videos yet: it
# skips them, and they keep the error they had)
# re-reading the telemetry embedded in it
EXTERNAL_GPS_SOURCES = frozenset({SourceType.GPX, SourceType.NMEA})


Expand Down Expand Up @@ -146,6 +146,8 @@ def can_geotag_videos(option: SourceOption) -> bool:
Examples:
>>> can_geotag_videos(SourceOption(SourceType.GPX))
True
>>> can_geotag_videos(SourceOption(SourceType.NMEA))
True
>>> can_geotag_videos(SourceOption(SourceType.EXIF))
False
>>> can_geotag_videos(
Expand All @@ -163,10 +165,14 @@ def process(
# Collection: ABC for sized iterable container classes
paths: T.Iterable[Path],
options: T.Collection[SourceOption],
# --filetypes, which the filetypes of every option are limited to already
filetypes: set[types.FileType] | None = None,
) -> list[types.MetadataOrError]:
if not options:
raise ValueError("No geotag options provided")

video_types = _video_types_asked_for(filetypes)

final_metadatas: list[types.MetadataOrError] = []

# Paths (image path or video path) that will be sent to the next geotag process
Expand Down Expand Up @@ -234,7 +240,20 @@ def process(
] = []

for metadata in image_metadata_or_errors + video_metadata_or_errors:
if isinstance(metadata, types.ErrorMetadata) and _is_reprocessable(
if video_types is not None and _is_of_other_type(
metadata, option, video_types
):
# Its type, known only once it is read, is not one asked for:
# leave it out, as the files of other kinds
LOG.debug(
"Skipping %s, which is not of the file types %s",
metadata.filename,
filetypes,
)
reprocessable_paths.remove(metadata.filename)
pending_errors.pop(metadata.filename, None)
gpx_rejections.pop(metadata.filename, None)
elif isinstance(metadata, types.ErrorMetadata) and _is_reprocessable(
metadata
):
# Leave what it is for the next geotag process
Expand Down Expand Up @@ -272,11 +291,14 @@ def process(
break

final_metadatas.extend(pending_errors.values())
final_metadatas.extend(
_describe_unread_video(path, sources)
for path, sources in unread_videos.items()
if path in reprocessable_paths and path not in pending_errors
)
# Limited to some types of video, a video that no source read may not be
# of any of them, and is left out as the videos found to be of other types
if video_types is None:
final_metadatas.extend(
_describe_unread_video(path, sources)
for path, sources in unread_videos.items()
if path in reprocessable_paths and path not in pending_errors
)

return final_metadatas

Expand Down Expand Up @@ -342,6 +364,81 @@ def _warn_geotagged_despite_gpx(
LOG.debug("Images outside %s: %s", gpx_track, [p.name for p in image_paths])


def _video_types_asked_for(
filetypes: set[types.FileType] | None,
) -> set[types.FileType] | None:
"""
The types of video that --filetypes limits processing to, or None if it
lets videos of every type through

>>> _video_types_asked_for({types.FileType.IMAGE, types.FileType.GOPRO})
{<FileType.GOPRO: 'gopro'>}
>>> _video_types_asked_for({types.FileType.GOPRO, types.FileType.VIDEO}) is None
True
>>> _video_types_asked_for(None) is None
True
"""
if filetypes is None or types.FileType.VIDEO in filetypes:
return None
return filetypes & types.NATIVE_VIDEO_FILETYPES


def _is_of_other_type(
metadata: types.MetadataOrError,
option: SourceOption,
video_types: set[types.FileType],
) -> bool:
"""
Whether the source **option** found a video to be of none of the types
**video_types** asked for, which _filter_images_and_videos() cannot tell by
its name: it read the video as another type, or it is the native reader,
which tries only the readers of the types it is given, and finds no GPS
data only in a video that none of them recognizes. A generic video is of a
type that is not known, and is kept.

>>> gopro = {types.FileType.GOPRO}
>>> video = types.VideoMetadata(Path("v.mp4"), types.FileType.VIDEO, points=[])
>>> _is_of_other_type(video, SourceOption(SourceType.GPX), gopro)
False
>>> blackvue = types.VideoMetadata(Path("v.mp4"), types.FileType.BLACKVUE, points=[])
>>> _is_of_other_type(blackvue, SourceOption(SourceType.EXIFTOOL_RUNTIME), gopro)
True
>>> not_found = types.describe_error_metadata(
... exceptions.MapillaryVideoGPSNotFoundError("No GPS data found from the video"),
... Path("v.mp4"),
... filetype=types.FileType.VIDEO,
... )
>>> _is_of_other_type(not_found, SourceOption(SourceType.NATIVE, filetypes=gopro), gopro)
True

Limited further by its own filetypes, a source found only that the video is
of none of those, and leaves it to the next source:

>>> _is_of_other_type(
... not_found,
... SourceOption(SourceType.NATIVE, filetypes=gopro),
... {types.FileType.GOPRO, types.FileType.BLACKVUE},
... )
False
"""
if isinstance(metadata, types.VideoMetadata):
return (
metadata.filetype in types.NATIVE_VIDEO_FILETYPES
and metadata.filetype not in video_types
)

if isinstance(metadata, types.ErrorMetadata):
return (
option.source is SourceType.NATIVE
and isinstance(metadata.error, exceptions.MapillaryVideoGPSNotFoundError)
and option.filetypes is not None
and types.FileType.VIDEO not in option.filetypes
and video_types <= option.filetypes
)

return False


def _is_reprocessable(metadata: types.MetadataOrError) -> bool:
if isinstance(metadata, types.ErrorMetadata):
if isinstance(
Expand Down Expand Up @@ -478,8 +575,9 @@ def _build_video_geotag(option: SourceOption) -> base.GeotagVideosFromGeneric |
'GeotagVideosFromGPX'

>>> opt = SourceOption(SourceType.NMEA, source_path=Path("/tmp/test.nmea"))
>>> _build_video_geotag(opt) is None
True
>>> geotagger = _build_video_geotag(opt)
>>> geotagger.__class__.__name__
'GeotagVideosFromNMEA'

>>> opt = SourceOption(SourceType.EXIF)
>>> _build_video_geotag(opt) is None
Expand Down Expand Up @@ -523,8 +621,12 @@ def _build_video_geotag(option: SourceOption) -> base.GeotagVideosFromGeneric |
)

elif option.source is SourceType.NMEA:
# TODO: geotag videos from NMEA
return None
return geotag_videos_from_nmea.GeotagVideosFromNMEA(
source_path=option.source_path,
num_processes=option.num_processes,
video_sync=option.video_sync,
interpolation=option.interpolation,
)

elif option.source is SourceType.EXIF:
# Legacy image-specific geotag types
Expand Down
40 changes: 38 additions & 2 deletions mapillary_tools/geotag/geotag_images_from_gpx.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@

from __future__ import annotations

import bisect
import dataclasses
import datetime
import logging
import sys
import typing as T
Expand All @@ -16,10 +18,11 @@
else:
from typing_extensions import override

from .. import exceptions, geo, types
from .. import constants, exceptions, geo, types
from ..serializer.description import build_capture_time
from .base import GeotagImagesFromGeneric
from .geotag_images_from_exif import ImageEXIFExtractor
from .video_extractors.gpx import describe_gap, track_gaps


LOG = logging.getLogger(__name__)
Expand All @@ -33,19 +36,24 @@ def __init__(
use_image_start_time: bool = False,
offset_time: float = 0.0,
num_processes: int | None = None,
# Whether an image in a gap of the track longer than
# constants.MAX_GPX_GAP_SECONDS is outside it
check_gaps: bool = True,
):
super().__init__(num_processes=num_processes)
self.points = points
self.use_gpx_start_time = use_gpx_start_time
self.use_image_start_time = use_image_start_time
self.offset_time = offset_time
self.check_gaps = check_gaps
# Why the track could not be read, reported for every image instead
self.track_error: Exception | None = None

def _interpolate_image_metadata_along(
self,
image_metadata: types.ImageMetadata,
sorted_points: T.Sequence[geo.Point],
gaps: T.Sequence[tuple[geo.Point, geo.Point]] = (),
) -> types.ImageMetadata:
assert sorted_points, "must have at least one point"

Expand Down Expand Up @@ -75,6 +83,28 @@ def _interpolate_image_metadata_along(
gpx_end_time=gpx_end_time,
)

# The last gap that starts at or before the image
idx = bisect.bisect_right(
[before.time for before, _ in gaps], image_metadata.time
)
if idx:
before, after = gaps[idx - 1]
delta = min(
image_metadata.time - before.time, after.time - image_metadata.time
)
# with the tolerance of 1ms, as at the ends of the track
if 0.001 < delta:
captured_at = datetime.datetime.fromtimestamp(
image_metadata.time, tz=datetime.timezone.utc
).isoformat()
track_gap = describe_gap(before, after, before.time, after.time)
raise exceptions.MapillaryOutsideGPXTrackError(
f"The GPX track has {track_gap}, which the image time {captured_at} falls in",
image_time=build_capture_time(image_metadata.time),
gpx_start_time=build_capture_time(sorted_points[0].time),
gpx_end_time=build_capture_time(sorted_points[-1].time),
)

interpolated = geo.interpolate(sorted_points, image_metadata.time)

return dataclasses.replace(
Expand Down Expand Up @@ -158,11 +188,17 @@ def to_description(
# TODO: this time modification seems to affect final capture times
image_metadata.time += image_time_offset

gaps = (
track_gaps(sorted_points, constants.MAX_GPX_GAP_SECONDS)
if self.check_gaps
else []
)

for image_metadata in sorted_image_metadatas:
try:
final_metadatas.append(
self._interpolate_image_metadata_along(
image_metadata, sorted_points
image_metadata, sorted_points, gaps
)
)
except exceptions.MapillaryOutsideGPXTrackError as ex:
Expand Down
Loading
Loading