diff --git a/README.md b/README.md index dc1534441..b702a5a6e 100644 --- a/README.md +++ b/README.md @@ -414,7 +414,7 @@ The new video processing is triggered with the `--video_geotag_source SOURCE` op In this case, each source will be tried in turn, until one returns good quality data. `SOURCE` can be: -1. the plain name of the source - one of `video, camm, gopro, blackvue, gpx, nmea, exiftool_xml, exiftool_runtime` +1. the plain name of the source - one of `native, video, camm, gopro, blackvue, gpx, nmea, exiftool_xml, exiftool_runtime` 2. a JSON object that includes optional parameters ```json5 { @@ -430,7 +430,8 @@ PATTERN specifies how the data source file is named, starting from the video fil Supported sources and their default pattern are: -* `video`: parse the video, in order, as `camm, gopro, blackvue`. Pattern: `%f` +* `native`, or `video`: parse the video looking for geolocation in any format that mapillary_tools reads: GoPro, CAMM, +BlackVue and others. Pattern: `%f` * `camm`: parse the video looking for a CAMM track. Pattern: `%f` * `gopro`: parse the video looking for geolocation in GoPro format. Pattern: `%f` * `blackvue`: parse the video looking for geolocation in BlackVue format. Pattern: `%f` diff --git a/mapillary_tools/exiftool_runner.py b/mapillary_tools/exiftool_runner.py index bb8a39de4..beb41bc3b 100644 --- a/mapillary_tools/exiftool_runner.py +++ b/mapillary_tools/exiftool_runner.py @@ -15,14 +15,22 @@ class ExiftoolRunner: Wrapper around ExifTool to run it in a subprocess """ - def __init__(self, exiftool_executable: str = "exiftool", recursive: bool = False): + def __init__( + self, + exiftool_executable: str = "exiftool", + recursive: bool = False, + fast: bool = True, + ): self.exiftool_executable = exiftool_executable self.recursive = recursive + # -fast skips metadata at the end of a file, such as the trailer that + # holds the GPS of an Insta360 original (.insv) + self.fast = fast def _build_args_read_stdin(self) -> list[str]: args: list[str] = [ self.exiftool_executable, - "-fast", + *(["-fast"] if self.fast else []), "-q", "-n", # Disable print conversion "-X", # XML output diff --git a/mapillary_tools/geotag/factory.py b/mapillary_tools/geotag/factory.py index bb7d20b3b..e8f2f6bc6 100644 --- a/mapillary_tools/geotag/factory.py +++ b/mapillary_tools/geotag/factory.py @@ -31,6 +31,7 @@ SourceOption, SourcePathOption, SourceType, + VIDEO_SOURCE_TYPE_ALIAS, ) @@ -38,12 +39,24 @@ _ALL_VIDEO_TYPES = {types.FileType.VIDEO, *types.NATIVE_VIDEO_FILETYPES} +# As a --video_geotag_source, each of the legacy sources that geotag images from +# videos reads the GPS of the videos of its type, as it did before v0.14 +_VIDEO_TYPE_OF_LEGACY_SOURCE = { + SourceType.GOPRO: types.FileType.GOPRO, + SourceType.BLACKVUE: types.FileType.BLACKVUE, + SourceType.CAMM: types.FileType.CAMM, +} + +# The XML that the exiftool_xml source reads for a file, without a source path +# or a pattern: the output of `exiftool -X` for it, named after it +_EXIFTOOL_XML_DEFAULT_PATTERN = "%g.xml" + # Sources that read a GPS track from outside the video file, as opposed to # re-reading the telemetry embedded in it EXTERNAL_GPS_SOURCES = frozenset({SourceType.GPX, SourceType.NMEA}) -def parse_source_option(source: str) -> list[SourceOption]: +def parse_source_option(source: str, video: bool = False) -> list[SourceOption]: """ Given a source string, parse it into a list of GeotagOptions objects. @@ -53,13 +66,17 @@ def parse_source_option(source: str) -> list[SourceOption]: "exif,gpx" -> [SourceOption(SourceType.EXIF), SourceOption(SourceType.GPX)] '{"source": "gpx"}' -> [SourceOption(SourceType.GPX)] + A --video_geotag_source (**video**) takes the names of + VIDEO_SOURCE_TYPE_ALIAS too. + Raise MapillaryBadParameterError for an unknown source type, and for a JSON object that does not match SourceOptionSchema. """ + aliases = {**SOURCE_TYPE_ALIAS, **VIDEO_SOURCE_TYPE_ALIAS} if video else None try: - source_type = SourceType(SOURCE_TYPE_ALIAS.get(source, source)) - except ValueError: + source_type = _parse_source_type(source, aliases) + except exceptions.MapillaryBadParameterError: pass else: return [SourceOption(source_type)] @@ -69,6 +86,10 @@ def parse_source_option(source: str) -> list[SourceOption]: except json.JSONDecodeError: pass else: + if aliases and isinstance(payload, dict): + name = payload.get("source") + if isinstance(name, str) and name in VIDEO_SOURCE_TYPE_ALIAS: + payload = {**payload, "source": VIDEO_SOURCE_TYPE_ALIAS[name].value} try: return [SourceOption.from_dict(payload)] except jsonschema.ValidationError as ex: @@ -78,17 +99,21 @@ def parse_source_option(source: str) -> list[SourceOption]: sources = source.split(",") - return [SourceOption(_parse_source_type(s)) for s in sources] + return [SourceOption(_parse_source_type(s, aliases)) for s in sources] -def _parse_source_type(source: str) -> SourceType: +def _parse_source_type( + source: str, aliases: T.Mapping[str, SourceType] | None = None +) -> SourceType: + if aliases is None: + aliases = SOURCE_TYPE_ALIAS try: - return SourceType(SOURCE_TYPE_ALIAS.get(source, source)) + return SourceType(aliases.get(source, source)) except ValueError: raise exceptions.MapillaryBadParameterError( f"Invalid geotag source {source!r}. Supported source types: " f"{', '.join(s.value for s in SourceType)} " - f"(aliases: {', '.join(SOURCE_TYPE_ALIAS)})" + f"(aliases: {', '.join(aliases)})" ) from None @@ -109,11 +134,8 @@ def parse_source_options( ) for s in video_geotag_source: - for video_option in parse_source_option(s): - video_option.filetypes = types.combine_filetype_filters( - video_option.filetypes, {types.FileType.VIDEO} - ) - parsed_options.append(video_option) + for video_option in parse_source_option(s, video=True): + parsed_options.append(_as_video_source(video_option)) for s in geotag_source: parsed_options.extend(parse_source_option(s)) @@ -137,6 +159,31 @@ def parse_source_options( return parsed_options +def _as_video_source(option: SourceOption) -> SourceOption: + """ + Limit a --video_geotag_source option to videos. The legacy gopro, blackvue + and camm sources, which otherwise geotag images from videos, read the + video's own GPS as the native source does, from the videos of their type + + >>> option = _as_video_source(SourceOption(SourceType.GOPRO)) + >>> option.source, option.filetypes + (, {}) + >>> option = _as_video_source(SourceOption(SourceType.GPX)) + >>> option.source, option.filetypes + (, {}) + """ + video_type = _VIDEO_TYPE_OF_LEGACY_SOURCE.get(option.source) + if video_type is not None: + option.source = SourceType.NATIVE + option.filetypes = types.combine_filetype_filters( + option.filetypes, {video_type} + ) + option.filetypes = types.combine_filetype_filters( + option.filetypes, {types.FileType.VIDEO} + ) + return option + + def can_geotag_videos(option: SourceOption) -> bool: """ Whether process() can read positions for videos from the source: it has a @@ -197,6 +244,9 @@ def process( # those sources, to report if no other source geotags them or fails to unread_videos: dict[Path, list[SourceType]] = {} + # Paths let through to any source + handed_paths: set[Path] = set() + for option in options: if option.source in EXTERNAL_GPS_SOURCES: option_paths = reprocessable_paths @@ -215,6 +265,7 @@ def process( image_videos, video_paths = _filter_images_and_videos( option_paths, option.filetypes ) + handed_paths.update(image_videos, video_paths) if image_videos: image_geotag = _build_image_geotag(option) @@ -291,18 +342,125 @@ def process( break final_metadatas.extend(pending_errors.values()) - # 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 + + unread_paths = [ + path + for path in unread_videos + if path in reprocessable_paths and path not in pending_errors + ] + + # Files that no source was let through, as the filetypes of every source + # leave them out (e.g. sources for images only, or a source for BlackVue + # videos with --filetypes gopro), but those that --filetypes leaves out + sourceless_images, sourceless_videos = _filter_images_and_videos( + sorted(reprocessable_paths - handed_paths), filetypes + ) + + if video_types is not None and (unread_paths or sourceless_videos): + # Limited to some types of video, a video that no source read may not + # be of any of them. Those of other types are left out, as the videos + # found to be of other types + kept_paths = set( + _videos_not_of_other_types( + unread_paths + sourceless_videos, + video_types, + # The same for every option (see process_geotag_properties) + num_processes=next(iter(options)).num_processes, + ) ) + unread_paths = [path for path in unread_paths if path in kept_paths] + sourceless_videos = [path for path in sourceless_videos if path in kept_paths] + + final_metadatas.extend( + _describe_unread_video(path, unread_videos[path]) for path in unread_paths + ) + final_metadatas.extend( + _describe_sourceless_file(path, options, filetypes) + for path in sourceless_images + sourceless_videos + ) return final_metadatas +def _videos_not_of_other_types( + video_paths: T.Sequence[Path], + video_types: set[types.FileType], + num_processes: int | None = None, +) -> list[Path]: + """ + The videos of **video_paths** that are of the types **video_types**, or of + a type that cannot be told: all but those in which the native reader, given + those types, recognizes none of them, as process() tells while geotagging + (see _is_of_other_type) + """ + LOG.info( + f"==> Reading the type of {len(video_paths)} videos that no source can geotag..." + ) + native = SourceOption( + SourceType.NATIVE, filetypes=video_types, num_processes=num_processes + ) + video_geotag = geotag_videos_from_video.GeotagVideosFromVideo( + filetypes=native.filetypes, num_processes=native.num_processes + ) + other_types = { + metadata.filename + for metadata in video_geotag.to_description(video_paths) + if _is_of_other_type(metadata, native, video_types) + } + return [path for path in video_paths if path not in other_types] + + +def _describe_sourceless_file( + path: Path, + options: T.Iterable[SourceOption], + filetypes: set[types.FileType] | None, +) -> types.ErrorMetadata: + """ + Describe a file that no source of **options** was let through, limited to + --filetypes **filetypes**, rather than leave it out of the results as if it + had not been there + + >>> video_only = SourceOption(SourceType.NATIVE, filetypes={types.FileType.VIDEO}) + >>> _describe_sourceless_file(Path("i.jpg"), [video_only], None).error + MapillaryGeoTaggingError('No geotag source is given for this image: the geotag source native is limited to other file types') + >>> # For BlackVue videos, limited to --filetypes gopro + >>> blackvue_only = SourceOption(SourceType.NATIVE, filetypes=set()) + >>> _describe_sourceless_file(Path("v.mp4"), [blackvue_only], {types.FileType.GOPRO}).error + MapillaryVideoGPSNotFoundError('--filetypes gopro leaves no geotag source for this video: the geotag source native is limited to other file types') + >>> images_only = [ + ... SourceOption(SourceType.NATIVE, filetypes={types.FileType.IMAGE}), + ... SourceOption(SourceType.EXIFTOOL_RUNTIME, filetypes={types.FileType.IMAGE}), + ... ] + >>> filetypes = {types.FileType.IMAGE, types.FileType.GOPRO} + >>> _describe_sourceless_file(Path("v.mp4"), images_only, filetypes).error + MapillaryVideoGPSNotFoundError('--filetypes gopro,image leaves no geotag source for this video: the geotag sources native, exiftool_runtime are limited to other file types') + """ + kind = "image" if utils.is_image_file(path) else "video" + if filetypes is None: + no_source = f"No geotag source is given for this {kind}" + else: + asked_for = ",".join(sorted(filetype.value for filetype in filetypes)) + no_source = f"--filetypes {asked_for} leaves no geotag source for this {kind}" + names = list(dict.fromkeys(option.source.value for option in options)) + sources = ( + f"the geotag sources {', '.join(names)} are" + if len(names) > 1 + else f"the geotag source {names[0]} is" + ) + message = f"{no_source}: {sources} limited to other file types" + if kind == "image": + return types.describe_error_metadata( + exceptions.MapillaryGeoTaggingError(message), + path, + filetype=types.FileType.IMAGE, + ) + return types.describe_error_metadata( + exceptions.MapillaryVideoGPSNotFoundError(message), + path, + filetype=types.FileType.VIDEO, + ) + + def _describe_unread_video( video_path: Path, sources: T.Sequence[SourceType] ) -> types.ErrorMetadata: @@ -489,6 +647,20 @@ def _ensure_source_path(option: SourceOption) -> Path: return option.source_path.source_path +def _exiftool_xml_source_path(option: SourceOption) -> SourcePathOption: + """ + Where the exiftool_xml source finds the XML of an image or a video: at its + source path or by its pattern, or by default, as the README documents, in + the XML named after the file beside it + + >>> _exiftool_xml_source_path(SourceOption(SourceType.EXIFTOOL_XML)) + SourcePathOption(pattern='%g.xml', source_path=None) + """ + if option.source_path is None: + return SourcePathOption(pattern=_EXIFTOOL_XML_DEFAULT_PATTERN) + return option.source_path + + def _build_image_geotag(option: SourceOption) -> base.GeotagImagesFromGeneric | None: """ Build a GeotagImagesFromGeneric object based on the provided option. @@ -509,14 +681,8 @@ def _build_image_geotag(option: SourceOption) -> base.GeotagImagesFromGeneric | ) elif option.source is SourceType.EXIFTOOL_XML: - # This is to ensure 'video_process --geotag={"source": "exiftool_xml", "source_path": "/tmp/xml_path"}' - # to work - if option.source_path is None: - raise exceptions.MapillaryBadParameterError( - "source_path must be provided for EXIFTOOL_XML source" - ) return geotag_images_from_exiftool.GeotagImagesFromExifToolWithSamples( - source_path=option.source_path, + source_path=_exiftool_xml_source_path(option), num_processes=option.num_processes, ) @@ -599,17 +765,17 @@ def _build_video_geotag(option: SourceOption) -> base.GeotagVideosFromGeneric | ) if option.source is SourceType.EXIFTOOL_RUNTIME: + # Only by its own pattern: the source path that --geotag_source_path, or + # the video import path in video_process, gives every source is not one + # for exiftool to read return geotag_videos_from_exiftool.GeotagVideosFromExifToolRunner( - num_processes=option.num_processes + pattern=None if option.source_path is None else option.source_path.pattern, + num_processes=option.num_processes, ) elif option.source is SourceType.EXIFTOOL_XML: - if option.source_path is None: - raise exceptions.MapillaryBadParameterError( - "source_path must be provided for EXIFTOOL_XML source" - ) return geotag_videos_from_exiftool.GeotagVideosFromExifToolXML( - source_path=option.source_path, + source_path=_exiftool_xml_source_path(option), ) elif option.source is SourceType.GPX: diff --git a/mapillary_tools/geotag/geotag_images_from_nmea_file.py b/mapillary_tools/geotag/geotag_images_from_nmea_file.py index eb779c736..7b9bb6844 100644 --- a/mapillary_tools/geotag/geotag_images_from_nmea_file.py +++ b/mapillary_tools/geotag/geotag_images_from_nmea_file.py @@ -77,8 +77,12 @@ def _read_nmea_points(source_path: Path) -> list[geo.Point]: def get_lat_lon_time_from_nmea(nmea_file: Path) -> list[geo.Point]: """ Read the fixes of an NMEA file: the time and position of each GGA - sentence, on the date of the last RMC sentence before it, or of the first - RMC sentence for the GGA sentences before any. + sentence, on the date of the RMC sentence of the same fix. Receivers + write the GGA sentence of a fix before or after its RMC sentence, so a + GGA sentence takes the date that puts it nearest the fix before it or the + RMC sentence after it, whichever it is nearer: the first fix after + midnight falls on the next day, also when the RMC sentence with that date + follows it. Receivers log sentences without a fix, such as before they acquire one: RMC sentences that are void or have no date or time, and GGA sentences @@ -98,11 +102,14 @@ def get_lat_lon_time_from_nmea(nmea_file: Path) -> list[geo.Point]: messages = [pynmea2.parse(line) for line in lines if _SENTENCE_RE.search(line)] - # Get initial date - dates = [_fix_date(m) for m in messages if isinstance(m, pynmea2.RMC)] - date = next((d for d in dates if d is not None), None) - if date is None: - fixes = [m for m in messages if isinstance(m, pynmea2.GGA) and _has_fix(m)] + # The RMC sentences with a date, and the GGA sentences with a fix + fixes = [ + m + for m in messages + if (isinstance(m, pynmea2.RMC) and _fix_date(m) is not None) + or (isinstance(m, pynmea2.GGA) and _has_fix(m)) + ] + if not any(isinstance(m, pynmea2.RMC) for m in fixes): if fixes: LOG.warning( "Ignoring the %d fixes in the NMEA file %s, as no RMC sentence gives their date", @@ -111,26 +118,74 @@ def get_lat_lon_time_from_nmea(nmea_file: Path) -> list[geo.Point]: ) return [] + # The datetime of the next RMC sentence after each fix + next_rmc: list[datetime.datetime | None] = [] + upcoming: datetime.datetime | None = None + for message in reversed(fixes): + next_rmc.append(upcoming) + if isinstance(message, pynmea2.RMC): + upcoming = _rmc_datetime(message) + next_rmc.reverse() + # Parse GPS trace points = [] times: set[float] = set() - for message in messages: + last: datetime.datetime | None = None + for message, after in zip(fixes, next_rmc): if isinstance(message, pynmea2.RMC): - date = _fix_date(message) or date + last = _rmc_datetime(message) + continue - elif isinstance(message, pynmea2.GGA) and _has_fix(message): - dt = datetime.datetime.combine(date, message.timestamp) - time = geo.as_unix_time(dt) - if time in times: - continue - times.add(time) - lat, lon, alt = message.latitude, message.longitude, message.altitude - points.append(geo.Point(time=time, lat=lat, lon=lon, alt=alt, angle=None)) + last = _gga_datetime(message.timestamp, last, after) + time = geo.as_unix_time(last) + if time in times: + continue + times.add(time) + lat, lon, alt = message.latitude, message.longitude, message.altitude + points.append(geo.Point(time=time, lat=lat, lon=lon, alt=alt, angle=None)) points.sort() return points +def _rmc_datetime(message: pynmea2.RMC) -> datetime.datetime: + return datetime.datetime.combine(message.datestamp, message.timestamp) + + +def _gga_datetime( + time: datetime.time, + before: datetime.datetime | None, + after: datetime.datetime | None, +) -> datetime.datetime: + """ + The datetime of a GGA sentence at **time** of day, after the fix **before** + it and before the RMC sentence **after** it: on the date, or the day before + or after it, that puts it nearest the one of them it is nearer + + >>> before = datetime.datetime(2022, 12, 21, 23, 59, 59) + >>> after = datetime.datetime(2022, 12, 22, 0, 0, 0) + >>> _gga_datetime(datetime.time(0, 0, 0), before, after) + datetime.datetime(2022, 12, 22, 0, 0) + >>> _gga_datetime(datetime.time(0, 0, 0), before, None) + datetime.datetime(2022, 12, 22, 0, 0) + >>> _gga_datetime(datetime.time(23, 59, 59), None, after) + datetime.datetime(2022, 12, 21, 23, 59, 59) + + The first fix of a log of another day, appended to the file: + + >>> after = datetime.datetime(2022, 12, 24, 9, 0, 0) + >>> _gga_datetime(datetime.time(9, 0, 0), before, after) + datetime.datetime(2022, 12, 24, 9, 0) + """ + candidates = [ + (datetime.datetime.combine(near.date() + datetime.timedelta(days), time), near) + for near in (before, after) + if near is not None + for days in (-1, 0, 1) + ] + return min(candidates, key=lambda candidate: abs(candidate[0] - candidate[1]))[0] + + def _fix_date(message: pynmea2.RMC) -> datetime.date | None: """The date of an RMC sentence, or None if it is void or has no date or time""" if message.status != "A" or message.timestamp is None: diff --git a/mapillary_tools/geotag/geotag_images_from_video.py b/mapillary_tools/geotag/geotag_images_from_video.py index 092fe2f42..6422a5c99 100644 --- a/mapillary_tools/geotag/geotag_images_from_video.py +++ b/mapillary_tools/geotag/geotag_images_from_video.py @@ -20,7 +20,7 @@ from .base import GeotagImagesFromGeneric from .geotag_images_from_gpx import GeotagImagesFromGPX from .geotag_videos_from_video import GeotagVideosFromVideo -from .video_extractors.gpx import GPSTimeline, GPXVideoExtractor +from .video_extractors.gpx import GPXVideoExtractor LOG = logging.getLogger(__name__) @@ -109,21 +109,15 @@ def _on_capture_timeline( ) -> T.Sequence[geo.Point]: """ Time the video's GPS points in GPS seconds from the start of the video, - like the samples, which are stamped with their capture times, as its - GPS timestamps tell: a timelapse packs several into each second of + like the samples, which are stamped with their capture times, at the + pace of its GPS clock: a timelapse packs several into each second of video, and some clocks gain on the video. """ clock = GPXVideoExtractor._read_video_clock(points) - if clock is None: + if clock is None or clock.rate == 1.0: return points - timeline = GPSTimeline.read(points, clock.rate) - assert timeline is not None, "a GPS clock is read from GPS timestamps" - start = timeline.to_unix_time(0.0) - return [ - dataclasses.replace(p, time=timeline.to_unix_time(p.time) - start) - for p in points - ] + return [dataclasses.replace(p, time=p.time * clock.rate) for p in points] class GeotagImageSamplesFromVideo(GeotagImagesFromGeneric): diff --git a/mapillary_tools/geotag/geotag_videos_from_exiftool.py b/mapillary_tools/geotag/geotag_videos_from_exiftool.py index 98af8ba4c..19e538d12 100644 --- a/mapillary_tools/geotag/geotag_videos_from_exiftool.py +++ b/mapillary_tools/geotag/geotag_videos_from_exiftool.py @@ -105,29 +105,65 @@ def find_rdf_by_path( class GeotagVideosFromExifToolRunner(GeotagVideosFromGeneric): + def __init__(self, pattern: str | None = None, num_processes: int | None = None): + super().__init__(num_processes=num_processes) + # Names the file to read for each video, after the video: by default + # the video itself, but the GPS of a stitched video can be in the + # original that it was stitched from (e.g. "../%g.insv") + self.source_path = ( + None if pattern is None else options.SourcePathOption(pattern=pattern) + ) + @override def _generate_video_extractors( self, video_paths: T.Sequence[Path] ) -> T.Sequence[VideoExifToolExtractor | types.ErrorMetadata]: + results: list[VideoExifToolExtractor | types.ErrorMetadata] = [] + + # The file to read for each video + read_paths: dict[Path, Path] = {} + for video_path in video_paths: + if self.source_path is None: + read_path = video_path + else: + read_path = self.source_path.resolve(video_path) + if read_path.is_file(): + read_paths[video_path] = read_path + else: + results.append( + types.describe_error_metadata( + exceptions.MapillaryVideoGPSNotFoundError( + f"File not found for video: {read_path}" + ), + video_path, + filetype=types.FileType.VIDEO, + ) + ) + + if not read_paths: + return results + + # Not -fast, which skips the trailer that holds the GPS of an Insta360 + # original if constants.EXIFTOOL_PATH is None: - runner = ExiftoolRunner() + runner = ExiftoolRunner(fast=False) else: - runner = ExiftoolRunner(constants.EXIFTOOL_PATH) + runner = ExiftoolRunner(constants.EXIFTOOL_PATH, fast=False) LOG.debug( "Extracting XML from %d videos with ExifTool command: %s", - len(video_paths), + len(read_paths), " ".join(runner._build_args_read_stdin()), ) try: - xml = runner.extract_xml(video_paths) + xml = runner.extract_xml(list(dict.fromkeys(read_paths.values()))) except FileNotFoundError as ex: exiftool_ex = exceptions.MapillaryExiftoolNotFoundError(ex) - return [ + return results + [ types.describe_error_metadata( exiftool_ex, video_path, filetype=types.FileType.VIDEO ) - for video_path in video_paths + for video_path in read_paths ] try: @@ -144,6 +180,13 @@ def _generate_video_extractors( xml_element ) - return GeotagVideosFromExifToolXML.build_video_extractors_from_etree( - rdf_by_path, video_paths + # Each video with the metadata of the file read for it + rdf_by_video_path = { + exiftool_read.canonical_path(video_path): rdf + for video_path, read_path in read_paths.items() + if (rdf := rdf_by_path.get(exiftool_read.canonical_path(read_path))) + is not None + } + return results + GeotagVideosFromExifToolXML.build_video_extractors_from_etree( + rdf_by_video_path, list(read_paths) ) diff --git a/mapillary_tools/geotag/options.py b/mapillary_tools/geotag/options.py index 896dab71d..a4ed25140 100644 --- a/mapillary_tools/geotag/options.py +++ b/mapillary_tools/geotag/options.py @@ -36,6 +36,12 @@ class SourceType(enum.Enum): "exiftool": SourceType.EXIFTOOL_RUNTIME, } +# The names that --video_geotag_source takes besides those above. Before v0.14, +# "video" read the video's own GPS in any format, as the native source does now +VIDEO_SOURCE_TYPE_ALIAS: dict[str, SourceType] = { + "video": SourceType.NATIVE, +} + @dataclasses.dataclass class SourceOption: diff --git a/mapillary_tools/geotag/video_extractors/gpx.py b/mapillary_tools/geotag/video_extractors/gpx.py index 02a0ebd1f..3144a6824 100644 --- a/mapillary_tools/geotag/video_extractors/gpx.py +++ b/mapillary_tools/geotag/video_extractors/gpx.py @@ -46,9 +46,10 @@ # a GPS logger left running after the video, but a log of several trips _MAX_RECORDING_SECONDS = 24 * 3600 -# A GPS clock that runs this much faster than the video clock is a timelapse. -# Normal videos stay well below it, although over a few seconds of GPS -# timestamped once per block the estimate can be 20% off +# A GPS clock that runs this much faster than the video clock is a timelapse, +# and the steps between its timestamps show it too. One timestamp far off the +# rest, or one step of the clock, can pull the least-squares slope of a normal +# video past it _MIN_TIMELAPSE_RATE = 1.5 # No GPS fix can be timestamped before the GPS epoch, 1980-01-06. GPX files @@ -70,6 +71,18 @@ # in steps of a second per half second, 2 minutes over a 14-minute video _STALE_STEP_RATE = 1.5 +# A GPS clock that runs within this fraction of the video's pace runs at it. +# Over 20 seconds or more, cameras that record in real time measure within +# 0.4% of 1x, or 1.6% for a GoPro MAX whose GPS glitches, while the clock of a +# LabPano runs 9% to 14% faster than its video +_MAX_CLOCK_RATE_ERROR = 0.05 + +# Nor can GPS timestamps measure a clock that departs from the video's pace by +# less than their own jitter over the video time they span: GoPro stamps blocks +# of points up to 1.7 seconds off the line through the rest, and clips a few +# seconds long measure up to 15% off 1x, if by no more than 0.3 seconds +_MAX_CLOCK_DRIFT_SECONDS = 2.0 + class SyncMode(enum.Enum): # Sync by video GPS timestamps if found, otherwise rebase @@ -83,7 +96,10 @@ class SyncMode(enum.Enum): @dataclasses.dataclass(frozen=True) class VideoClock: """ - Maps between the video time and the Unix time of the GPS clock in the video + Maps between the video time and the Unix time of the GPS clock in the video: + a straight line, as a camera records its frames at a steady pace, whether + in real time or as a timelapse. The GPS timestamps of the video jitter + about it >>> clock = VideoClock(video_time=2.0, unix_time=1000.0) >>> clock.to_video_time(1030.0) @@ -107,124 +123,6 @@ def to_unix_time(self, video_time: float) -> float: return self.unix_time + (video_time - self.video_time) * self.rate -@dataclasses.dataclass(frozen=True) -class GPSTimeline: - """ - The Unix time of the video's GPS clock along the video: interpolated - between its GPS timestamps, and extrapolated beyond them at the clock's - rate. A straight clock does not follow them: the clock of a timelapse - drifts from its rate over the video, the first timestamp of a GoPro MAX - timelapse can be seconds early, and some cameras gain on the video in - steps. - - >>> timeline = GPSTimeline((0.0, 1.0, 2.0), (100.0, 104.0, 114.0), rate=10.0) - >>> timeline.to_unix_time(0.5) - 102.0 - >>> timeline.to_unix_time(3.0) - 124.0 - >>> timeline.to_video_time(109.0) - 1.5 - >>> timeline.to_video_time(95.0) - -0.5 - >>> timeline.starting_at(1.0, 200.0).to_unix_time(2.0) - 210.0 - """ - - video_times: tuple[float, ...] - unix_times: tuple[float, ...] - # GPS seconds per video second beyond the timestamps - rate: float - - @classmethod - def read(cls, points: T.Sequence[geo.Point], rate: float) -> GPSTimeline | None: - """ - Read the timeline from the GPS timestamps of the points, or None if they - have none. Leave out the timestamps that the others show to be wrong: - those out of order, those before a jump of days (from a placeholder - date to the GPS time), and stale first ones. - """ - pairs: list[tuple[float, float]] = [] - for p in points: - unix_time = p.get_unix_time() - if unix_time is None: - continue - if pairs and unix_time == pairs[-1][1]: - # GoPro GPS5 stamps a whole block (~1s) of points with one - # timestamp. Keep the first point of the block - continue - if pairs and p.time <= pairs[-1][0]: - # Two timestamps at one video time - continue - pairs.append((p.time, unix_time)) - - # A timestamp out of order with the others is wrong, and could not be - # interpolated back to the video - pairs = _increasing_subsequence(pairs) - if not pairs: - return None - - # GoPro stamps a placeholder date until the GPS acquires the time - start = 0 - for idx in range(1, len(pairs)): - (v0, u0), (v1, u1) = pairs[idx - 1], pairs[idx] - if _IMPLAUSIBLE_OFFSET_SECONDS < (u1 - u0) - (v1 - v0) * rate: - start = idx - - # Stale first timestamps (see _STALE_STEP_RATE). Judged by the steps of - # the rest, not by the rate: that is 1 for timestamps over less than a - # second of video, as those of a short timelapse, too few to measure it - pace = GPXVideoExtractor._median_step_rate(pairs[start + 1 :]) - while pace is not None and start + 1 < len(pairs): - (v0, u0), (v1, u1) = pairs[start], pairs[start + 1] - if (u1 - u0) <= (v1 - v0) * pace * _STALE_STEP_RATE: - break - start += 1 - - return cls( - video_times=tuple(v for v, _ in pairs[start:]), - unix_times=tuple(u for _, u in pairs[start:]), - rate=rate, - ) - - def to_unix_time(self, video_time: float) -> float: - return _interpolate_line( - self.video_times, self.unix_times, video_time, self.rate - ) - - def to_video_time(self, unix_time: float) -> float: - return _interpolate_line( - self.unix_times, self.video_times, unix_time, 1 / self.rate - ) - - def starting_at(self, video_time: float, unix_time: float) -> GPSTimeline: - """ - The timeline shifted to be at **unix_time** at **video_time**: at the - pace of the GPS clock, from a start that the clock does not tell - """ - shift = unix_time - self.to_unix_time(video_time) - return dataclasses.replace( - self, unix_times=tuple(u + shift for u in self.unix_times) - ) - - -def _interpolate_line( - xs: T.Sequence[float], ys: T.Sequence[float], x: float, slope: float -) -> float: - """ - Interpolate y at x along the line through the points (xs, ys), xs in - increasing order, and extrapolate it beyond them at **slope** - """ - idx = bisect.bisect_right(xs, x) - if 0 < idx < len(xs): - x0, x1 = xs[idx - 1], xs[idx] - y0, y1 = ys[idx - 1], ys[idx] - return y0 + (y1 - y0) * (x - x0) / (x1 - x0) - - # Before the first point or after the last - anchor = 0 if idx == 0 else -1 - return ys[anchor] + (x - xs[anchor]) * slope - - def _increasing_subsequence( pairs: T.Sequence[tuple[float, float]], ) -> list[tuple[float, float]]: @@ -315,9 +213,7 @@ def extract(self) -> types.VideoMetadata: ) video_clock = self._read_video_clock(native_video_metadata.points) - clock: VideoClock | GPSTimeline | None = self._sync_clock( - video_clock, gpx_points, noisy - ) + clock = self._sync_clock(video_clock, gpx_points, noisy) if video_clock is not None and clock is None: LOG.warning( "The GPS clock of %s, whose GPS was rejected as noise, is %.1f days away from %s. " @@ -327,17 +223,10 @@ def extract(self) -> types.VideoMetadata: self.gpx_path, self.file_format, ) - if clock is not None and not noisy: - # Through the GPS timestamps of the video, which no straight clock - # follows. GPS rejected as noise gives the clock only - timeline = GPSTimeline.read(native_video_metadata.points, clock.rate) - assert timeline is not None, "a GPS clock is read from GPS timestamps" - clock = timeline if clock is not None and gpx_points and self.sync_mode is SyncMode.REBASE: - # At the pace of the GPS clock from the start of the track, which - # distance sampling, reading the frames by their composition times, - # puts at that of the first frame - clock = self._start_with_the_video(clock, gpx_points[0].time) + # Distance sampling reads the frames by their composition times: + # start the track at that of the first frame + clock = dataclasses.replace(clock, video_time=self._gpx_start_video_time()) if clock is None: # Its GPS has no clock to sync with, as if it had no GPS @@ -359,7 +248,7 @@ def extract(self) -> types.VideoMetadata: ) if clock.rate != 1.0: LOG.info( - "The GPS clock in %s runs %.1f times faster than the video (timelapse). Syncing %s accordingly", + "The GPS clock in %s runs %.3g times faster than the video. Syncing %s accordingly", self.video_path, clock.rate, self.gpx_path, @@ -424,7 +313,7 @@ def _parse_tracks(self) -> list[list[geo.Point]]: ) from ex def _clip_to_video( - self, gpx_points: list[geo.Point], clock: VideoClock | GPSTimeline | None + self, gpx_points: list[geo.Point], clock: VideoClock | None ) -> list[geo.Point]: """ Clip a GPX track to the video, from video time 0 to where the video @@ -486,20 +375,6 @@ def _place_without_video_gps( self._rebase_times(gpx_points, rate=ratio) return None - def _start_with_the_video( - self, clock: VideoClock | GPSTimeline, gpx_start_time: float - ) -> VideoClock | GPSTimeline: - """ - The **clock** moved to start the track, at **gpx_start_time**, with - the video, as --interpolation_use_gpx_start_time says it does - """ - video_time = self._gpx_start_video_time() - if isinstance(clock, GPSTimeline): - return clock.starting_at(video_time, gpx_start_time) - return VideoClock( - video_time=video_time, unix_time=gpx_start_time, rate=clock.rate - ) - def _gpx_start_video_time(self) -> float: """ The video time that --interpolation_use_gpx_start_time starts the GPX @@ -557,7 +432,7 @@ def _check_coverage( gpx_points: T.Sequence[geo.Point], gpx_unix_times: tuple[float, float] | None, video_gps_points: T.Sequence[geo.Point], - clock: VideoClock | GPSTimeline | None, + clock: VideoClock | None, rate: float, ) -> None: """ @@ -969,7 +844,12 @@ def _read_video_clock( cls, video_gps_points: T.Sequence[geo.Point] ) -> VideoClock | None: """ - Read the GPS clock from the video GPS points, or None if they have no timestamps + Read the GPS clock from the video GPS points, or None if they have no + timestamps: the straight line that their timestamps fit, at 1x unless + they measure a pace off it. The fit assumes that the clock runs at a + steady pace, as the camera records its frames: a step of the clock, or + a GPS dropout that it does not recover from, leaves the timestamps off + any line >>> GPXVideoExtractor._read_video_clock([]) is None True @@ -977,9 +857,54 @@ def _read_video_clock( >>> GPXVideoExtractor._read_video_clock([point]) is None True """ - # (video time, Unix time) pairs - pairs: list[tuple[float, float]] = [] + pairs = cls._read_timestamps(video_gps_points) + if not pairs: + return None + + rate = cls._estimate_clock_rate(pairs) + if rate is None or abs(rate - 1.0) <= max( + _MAX_CLOCK_RATE_ERROR, + _MAX_CLOCK_DRIFT_SECONDS / (pairs[-1][0] - pairs[0][0]), + ): + # See _MAX_CLOCK_RATE_ERROR and _MAX_CLOCK_DRIFT_SECONDS + rate = 1.0 + elif _MIN_TIMELAPSE_RATE < rate: + # A timestamp far off the rest, or a step of the clock, can pull the + # least-squares slope far off, so a timelapse must show in the + # median step between neighbouring pairs too. That is too coarse to + # measure the rate, but not to tell a timelapse from a normal video + step_rate = cls._median_step_rate(pairs) + if step_rate is None or step_rate <= _MIN_TIMELAPSE_RATE: + rate = 1.0 + + # Anchored at its own video time, which is not 0 when the video's GPS + # starts late (e.g. GoPro drops noisy fixes at the start of a recording), + # and at the median of all its timestamps mapped back to that time, not + # at the first alone: GPS clocks jitter by a second or so against the + # video, and a PANOX V2 stamps some fixes seconds late + video_time = pairs[0][0] + unix_time = statistics.median(u - (v - video_time) * rate for v, u in pairs) + return VideoClock(video_time=video_time, unix_time=unix_time, rate=rate) + + @classmethod + def _read_timestamps( + cls, video_gps_points: T.Sequence[geo.Point] + ) -> list[tuple[float, float]]: + """ + Read the (video time, Unix time) pairs of the GPS timestamps of the + points, leaving out those that the others show to be wrong: those out + of order, those before a jump of days (from a placeholder date to the + GPS time), and stale first ones + + >>> points = [ + ... telemetry.GPSPoint(time=v, lat=1, lon=1, alt=None, angle=None, epoch_time=u, fix=None, precision=None, ground_speed=None) + ... for v, u in [(0, 992.4), (0.4, 1000.4), (1.4, 1001.4), (1.8, 1001.4), (2.4, 1002.4)] + ... ] + >>> GPXVideoExtractor._read_timestamps(points) + [(0.4, 1000.4), (1.4, 1001.4), (2.4, 1002.4)] + """ + pairs: list[tuple[float, float]] = [] for p in video_gps_points: # Video GPS timestamps are stored in whatever epoch their container # uses (CAMM records GPS time, GoPro records Unix time), so go @@ -991,51 +916,50 @@ def _read_video_clock( # GoPro GPS5 stamps a whole block (~1s) of points with one # timestamp. Keep the first point of the block continue + if pairs and p.time <= pairs[-1][0]: + # Two timestamps at one video time + continue pairs.append((p.time, unix_time)) - if not pairs: - return None + # A timestamp out of order with the others is wrong + pairs = _increasing_subsequence(pairs) - rate = cls._estimate_clock_rate(pairs) - # One timestamp far off (e.g. a placeholder date before GPS time is - # acquired) can pull the least-squares slope far off, so a timelapse - # must show in the median step between neighbouring pairs too. That is - # too coarse to measure the rate, but not to tell a timelapse from a - # normal video - step_rate = cls._median_step_rate(pairs) - if not ( - rate is not None - and _MIN_TIMELAPSE_RATE < rate - and step_rate is not None - and _MIN_TIMELAPSE_RATE < step_rate - ): - rate = 1.0 + pace = cls._median_step_rate(pairs) + if pace is None: + return pairs - # Anchored at its own video time, which is not 0 when the video's GPS - # starts late (e.g. GoPro drops noisy fixes at the start of a recording), - # and at the median of all its timestamps mapped back to that time, not - # at the first alone: that can be seconds off the rest (the first GPS - # block of a GoPro timelapse, a stale first fix), and some GPS clocks - # wander by a second or so against the video - video_time = pairs[0][0] - unix_time = statistics.median(u - (v - video_time) * rate for v, u in pairs) + # GoPro stamps a placeholder date until the GPS acquires the time + start = 0 + for idx in range(1, len(pairs)): + (v0, u0), (v1, u1) = pairs[idx - 1], pairs[idx] + if _IMPLAUSIBLE_OFFSET_SECONDS < (u1 - u0) - (v1 - v0) * pace: + start = idx - return VideoClock(video_time=video_time, unix_time=unix_time, rate=rate) + # Stale first timestamps (see _STALE_STEP_RATE). Judged by the steps of + # the rest: the rate is fitted to the timestamps that are left + pace = cls._median_step_rate(pairs[start + 1 :]) + while pace is not None and start + 1 < len(pairs): + (v0, u0), (v1, u1) = pairs[start], pairs[start + 1] + if (u1 - u0) <= (v1 - v0) * pace * _STALE_STEP_RATE: + break + start += 1 + + return pairs[start:] @classmethod def _estimate_clock_rate( cls, pairs: T.Sequence[tuple[float, float]] ) -> float | None: """ - Least-squares slope of the Unix time over the video time, or None if the - pairs span less than a second of video + Least-squares slope of the Unix time over the video time, or None + without two pairs at different video times >>> GPXVideoExtractor._estimate_clock_rate([(0, 100), (1, 115), (2, 130)]) 15.0 - >>> GPXVideoExtractor._estimate_clock_rate([(0, 100), (0.5, 107.5)]) is None + >>> GPXVideoExtractor._estimate_clock_rate([(0, 100)]) is None True """ - if len(pairs) < 2 or pairs[-1][0] - pairs[0][0] < 1.0: + if len(pairs) < 2 or pairs[-1][0] <= pairs[0][0]: return None n = len(pairs) diff --git a/mapillary_tools/sample_video.py b/mapillary_tools/sample_video.py index 2ebcf24ce..17a47840c 100644 --- a/mapillary_tools/sample_video.py +++ b/mapillary_tools/sample_video.py @@ -36,7 +36,7 @@ SourceType, VideoSyncOption, ) -from .geotag.video_extractors.gpx import GPSTimeline, GPXVideoExtractor, VideoClock +from .geotag.video_extractors.gpx import GPXVideoExtractor, VideoClock from .geotag.video_extractors.native import NativeVideoExtractor from .geotag.video_extractors.nmea import NMEAVideoExtractor from .mp4 import mp4_sample_parser, simple_mp4_parser as sparser @@ -473,12 +473,12 @@ def _read_telemetry(video_path: Path) -> T.Sequence[geo.Point]: return [] -def _telemetry_clock_rate( +def _telemetry_clock( video_path: Path, telemetry_points: T.Sequence[geo.Point] -) -> float | None: +) -> VideoClock | None: """ - GPS seconds per video second of the video's own GPS clock: more than 1 if - the video is a timelapse, or None if it has no GPS clock + The video's own GPS clock, or None if it has none. It runs more than 1 GPS + second per video second if the video is a timelapse """ clock = GPXVideoExtractor._read_video_clock(telemetry_points) if clock is None: @@ -486,11 +486,11 @@ def _telemetry_clock_rate( if clock.rate != 1.0: LOG.info( - "The GPS clock in %s runs %.1f times faster than the video (timelapse)", + "The GPS clock in %s runs %.3g times faster than the video", video_path.name, clock.rate, ) - return clock.rate + return clock def _parse_filename_time(video_path: Path) -> datetime.datetime | None: @@ -770,48 +770,36 @@ def _sample_single_video_by_interval( telemetry_points = _read_telemetry(video_path) # Frames are stamped with their capture times, which a timelapse packs - # several seconds of into each second of video: as the video's own GPS - # timestamps tell, and for a video without a GPS clock, at - # --video_duration_ratio - clock_rate = _telemetry_clock_rate(video_path, telemetry_points) + # several seconds of into each second of video: by the video's own GPS + # clock, as GPX sync places a GPX and distance samples are stamped, and for + # a video without a GPS clock, at --video_duration_ratio + clock = _telemetry_clock(video_path, telemetry_points) # GPS rejected as noise gives no telemetry, but GPX sync reads its clock, # and the frames are stamped by it, where GPX sync places them along the # GPX, so that the geotagging stage finds them there - gpx_clock: VideoClock | None = None - if clock_rate is None: - gpx_clock = _gpx_sync_clock(video_path, geotag_options) - if gpx_clock is not None: - clock_rate = gpx_clock.rate - timeline: GPSTimeline | None = None - if clock_rate is None: + if clock is None: + clock = _gpx_sync_clock(video_path, geotag_options) + if clock is None: rate = duration_ratio else: - rate = clock_rate + rate = clock.rate if duration_ratio != 1.0: LOG.warning( "Ignoring --video_duration_ratio %g for %s, whose own GPS clock runs at %.3gx", duration_ratio, video_path.name, - clock_rate, + clock.rate, ) - if gpx_clock is None: - # As distance samples are stamped, and as the geotagging stage - # 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: + if clock is None and video_sync is not None: _check_gpx_start_by_interval(video_path, geotag_options, video_sync) probe = _probe_video(ffmpeg, video_path) if start_time is None: - if timeline is not None: + if clock is not None: start_time = datetime.datetime.fromtimestamp( - timeline.to_unix_time(0.0), tz=datetime.timezone.utc - ) - elif gpx_clock is not None: - start_time = datetime.datetime.fromtimestamp( - gpx_clock.to_unix_time(0.0), tz=datetime.timezone.utc + clock.to_unix_time(0.0), tz=datetime.timezone.utc ) else: start_time = _extract_video_start_time(video_path, probe, telemetry_points) @@ -843,11 +831,7 @@ def _sample_single_video_by_interval( continue # extract_frames() produces 1-based frame indices so we need to subtract 1 here video_time = (frame_idx_1based - 1) * sample_interval - if timeline is None: - seconds = video_time * rate - else: - seconds = timeline.to_unix_time(video_time) - timeline.to_unix_time(0.0) - timestamp = start_time + datetime.timedelta(seconds=seconds) + timestamp = start_time + datetime.timedelta(seconds=video_time * rate) exif_edit = ExifEdit(sample_paths[0]) exif_edit.add_date_time_original(timestamp) exif_edit.add_gps_datetime(timestamp) @@ -1057,11 +1041,11 @@ def _sample_single_video_by_distance( last_unix_time = max(track_unix_times, default=None) # Read from the video itself, a track with a GPS clock of its own paces the - # video, and stamps the frames along its timeline, as interval samples are. - # A GPX source warns of the options that its sync ignores itself, and its - # sync placed the track along the video's clock already: its timestamps - # stamp the frames as they are - track_timeline: GPSTimeline | None = None + # video, and its clock stamps the frames, as interval samples are. A GPX + # source warns of the options that its sync ignores itself, and its sync + # placed the track along the video's clock already: its timestamps stamp + # the frames as they are + track_clock: VideoClock | None = None if not any( option.source in factory.EXTERNAL_GPS_SOURCES for option in geotag_options ): @@ -1073,14 +1057,12 @@ def _sample_single_video_by_distance( start_time, ) track_clock = GPXVideoExtractor._read_video_clock(video_metadata.points) - if track_clock is not None: - track_timeline = GPSTimeline.read(video_metadata.points, track_clock.rate) - # Frames along the timeline, or at points with an absolute timestamp, are - # timestamped from it below, so only the others need the start time + # Frames stamped by the clock, or at points with an absolute timestamp, + # are timestamped below, so only the others need the start time if ( start_time is None - and track_timeline is None + and track_clock is None and any( interp.get_unix_time() is None for _, interp in sample_points_by_frame_idx.values() @@ -1130,8 +1112,8 @@ def _sample_single_video_by_distance( gps_unix_time = ( interp.get_unix_time() - if track_timeline is None - else track_timeline.to_unix_time(interp.time) + if track_clock is None + else track_clock.to_unix_time(interp.time) ) if gps_unix_time is not None: assert first_unix_time is not None and last_unix_time is not None diff --git a/tests/integration/test_gopro.py b/tests/integration/test_gopro.py index 115d79507..e7589e452 100644 --- a/tests/integration/test_gopro.py +++ b/tests/integration/test_gopro.py @@ -31,16 +31,17 @@ "MAPILLARY_TOOLS_GOPRO_GPS_PRECISION": "10000000", "MAPILLARY_TOOLS_MAX_CAPTURE_SPEED_KMH": "2000000", # km/h } -# The capture times come from the GPMF GPS timestamps (which put the start at -# 2019-11-18T23:42:10.009Z, without the first, 1.35 seconds stale), not from the -# container's creation time. This camera writes the creation time in local time, -# so reading the start time from there would timestamp every sample 8 hours off. +# The capture times come from the GPMF GPS clock (which puts the start at +# 2019-11-18T23:42:10.003Z, without the first timestamp, 1.36 seconds stale), +# not from the container's creation time. This camera writes the creation time +# in local time, so reading the start time from there would timestamp every +# sample 8 hours off. EXPECTED_DESCS: T.List[T.Any] = [ { "filename": "hero8.mp4/hero8_v_000001.jpg", "filetype": "image", "MAPAltitude": 9540.24, - "MAPCaptureTime": "2019_11_18_23_42_10_009", + "MAPCaptureTime": "2019_11_18_23_42_10_003", "MAPCompassHeading": { "TrueHeading": 123.93587938690177, "MagneticHeading": 123.93587938690177, @@ -55,7 +56,7 @@ "filename": "hero8.mp4/hero8_v_000002.jpg", "filetype": "image", "MAPAltitude": 7112.573717404068, - "MAPCaptureTime": "2019_11_18_23_42_12_052", + "MAPCaptureTime": "2019_11_18_23_42_12_003", "MAPCompassHeading": { "TrueHeading": 140.8665026186285, "MagneticHeading": 140.8665026186285, @@ -70,7 +71,7 @@ "filename": "hero8.mp4/hero8_v_000003.jpg", "filetype": "image", "MAPAltitude": 7463.642846094319, - "MAPCaptureTime": "2019_11_18_23_42_13_976", + "MAPCaptureTime": "2019_11_18_23_42_14_003", "MAPCompassHeading": { "TrueHeading": 138.44255851085705, "MagneticHeading": 138.44255851085705, @@ -85,7 +86,7 @@ "filename": "hero8.mp4/hero8_v_000004.jpg", "filetype": "image", "MAPAltitude": 6909.8168472111465, - "MAPCaptureTime": "2019_11_18_23_42_16_009", + "MAPCaptureTime": "2019_11_18_23_42_16_003", "MAPCompassHeading": { "TrueHeading": 142.23462669862568, "MagneticHeading": 142.23462669862568, @@ -100,7 +101,7 @@ "filename": "hero8.mp4/hero8_v_000005.jpg", "filetype": "image", "MAPAltitude": 7212.594480737465, - "MAPCaptureTime": "2019_11_18_23_42_17_992", + "MAPCaptureTime": "2019_11_18_23_42_18_003", "MAPCompassHeading": { "TrueHeading": 164.70819093235514, "MagneticHeading": 164.70819093235514, @@ -115,7 +116,7 @@ "filename": "hero8.mp4/hero8_v_000006.jpg", "filetype": "image", "MAPAltitude": 7274.361994963208, - "MAPCaptureTime": "2019_11_18_23_42_20_019", + "MAPCaptureTime": "2019_11_18_23_42_20_003", "MAPCompassHeading": { "TrueHeading": 139.71549328876722, "MagneticHeading": 139.71549328876722, diff --git a/tests/integration/test_process.py b/tests/integration/test_process.py index a14abe558..b16b982d7 100644 --- a/tests/integration/test_process.py +++ b/tests/integration/test_process.py @@ -17,6 +17,7 @@ assert_contains_image_descs, assert_descs_exact_equal, EXECUTABLE, + EXIFTOOL_EXECUTABLE, pytest_skip_if_not_exiftool_installed, run_command, run_exiftool_and_generate_geotag_args, @@ -663,6 +664,67 @@ def test_process_filetypes_of_videos(setup_data: py.path.local): ] +def test_process_filetypes_of_videos_no_source_can_read(setup_data: py.path.local): + """ + A video of a type asked for, given only sources that cannot geotag videos, + is failed as without --filetypes. It was left out of the results + """ + videos = [ + str(setup_data.join("gopro_data").join("max-360mode.mp4")), + str(setup_data.join("videos").join("sample-5s.mp4")), + ] + descs = run_process_for_descs( + ["--filetypes=gopro", "--geotag_source=exif", *videos] + ) + assert [(Path(d["filename"]).name, d["error"]["message"]) for d in descs] == [ + ("max-360mode.mp4", "The geotag source exif cannot geotag videos") + ] + + assert [] == run_process_for_descs( + ["--filetypes=blackvue", "--geotag_source=exif", *videos] + ) + + +def test_process_filetypes_no_source_is_given(setup_data: py.path.local): + """ + The filetypes of every source, limited to --filetypes if given, can leave + out a file, which --filetypes lets in. It was left out of the results + """ + videos = [ + str(setup_data.join("gopro_data").join("max-360mode.mp4")), + str(setup_data.join("videos").join("sample-5s.mp4")), + ] + image_source = ['--geotag_source={"source": "native", "filetypes": ["image"]}'] + descs = run_process_for_descs([*image_source, *videos]) + assert sorted((Path(d["filename"]).name, d["error"]["message"]) for d in descs) == [ + ( + name, + "No geotag source is given for this video: the geotag source native " + "is limited to other file types", + ) + for name in ["max-360mode.mp4", "sample-5s.mp4"] + ] + + blackvue_source = [ + "--video_geotag_source", + '{"source": "native", "filetypes": ["blackvue"]}', + ] + descs = run_process_for_descs(["--filetypes=gopro", *blackvue_source, *videos]) + assert [(Path(d["filename"]).name, d["error"]["message"]) for d in descs] == [ + ( + "max-360mode.mp4", + "--filetypes gopro leaves no geotag source for this video: the geotag " + "sources native, exiftool_runtime are limited to other file types", + ) + ] + + image_dir = setup_data.join("images") + video_source = ['--geotag_source={"source": "native", "filetypes": ["video"]}'] + descs = run_process_for_descs(["--filetypes=image", *video_source, str(image_dir)]) + assert descs + assert {d["error"]["type"] for d in descs} == {"MapillaryGeoTaggingError"} + + def test_process_video_geotag_source_with_gpx_specified(setup_data: py.path.local): video_path = setup_data.join("videos").join("sample-5s.mp4") gpx_file = setup_data.join("gpx").join("sf_30km_h.gpx") @@ -761,7 +823,7 @@ def test_process_video_geotag_source_with_gpx_outside_video( assert descs[0]["error"]["vars"] == { # Its first GPS timestamp is 23:44:42.590, a second before the rest # place the start of the video - "image_time": "2019_11_18_23_44_43_624", + "image_time": "2019_11_18_23_44_43_613", "gpx_start_time": "2025_03_14_07_00_00_000", "gpx_end_time": "2025_03_14_07_01_33_624", } @@ -775,7 +837,7 @@ def test_process_video_from_gpx_with_interpolation_options( images. They were ignored for videos. """ video_path = setup_data.join("gopro_data").join("max-360mode.mp4") - # 3.624 seconds before the GPS timestamps of the video place its start + # 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'' @@ -809,20 +871,19 @@ def video_time_of_recorded(*options: str) -> float: [point] = [p for p in describe(*options)["MAPGPSTrack"] if p[5] == recorded] return point[0] - # In ms of video, which the GPS timestamps of the video pace at a second - # per second, give or take their jitter of some 40 ms synced = video_time_of_recorded() - assert synced == pytest.approx(10000 - 3624, abs=50) + assert synced == pytest.approx(10000 - 3613, abs=1) - assert video_time_of_recorded("--interpolation_use_gpx_start_time") == ( - pytest.approx(10000, abs=50) + 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 ) - assert video_time_of_recorded( - "--interpolation_use_gpx_start_time", "--interpolation_offset_time=2.5" - ) == pytest.approx(7500, abs=50) shifted = video_time_of_recorded("--interpolation_offset_time=2") - assert shifted == pytest.approx(synced - 2000, abs=50) + 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 @@ -972,6 +1033,69 @@ def test_process_video_geotag_source_with_exiftool_runtime(setup_data: py.path.l assert_descs_exact_equal(exiftool_descs, native_descs) +def test_process_video_geotag_source_names_in_readme(setup_data: py.path.local): + """ + The README names the source that reads a video's own GPS "video", and those + that read one format of it "gopro", "camm" and "blackvue" + """ + video_path = str(setup_data.join("gopro_data").join("max-360mode.mp4")) + + native_descs = run_process_for_descs( + ["--video_geotag_source", "native", video_path] + ) + assert "error" not in native_descs[0] + + for source in ["video", "gopro"]: + descs = run_process_for_descs(["--video_geotag_source", source, video_path]) + assert_descs_exact_equal(native_descs, descs) + + # A GoPro video has none of the GPS of the other formats + for source in ["camm", "blackvue"]: + descs = run_process_for_descs(["--video_geotag_source", source, video_path]) + assert [d["error"]["type"] for d in descs] == ["MapillaryVideoGPSNotFoundError"] + + +def test_process_video_geotag_source_with_exiftool_runtime_pattern( + setup_data: py.path.local, +): + """ + As the README geotags a stitched video by the original it was stitched + from: exiftool_runtime reads the file that its pattern names after the video + """ + pytest_skip_if_not_exiftool_installed() + + original_path = setup_data.join("gopro_data").join("max-360mode.mp4") + stitched_dir = setup_data.join("gopro_data").mkdir("stitched") + # A video without GPS of its own, named after the original + stitched_path = stitched_dir.join("max-360mode.mp4") + setup_data.join("videos").join("sample-5s.mp4").copy(stitched_path) + + descs = run_process_for_descs( + [ + *[ + "--video_geotag_source", + json.dumps({"source": "exiftool_runtime", "pattern": "../%g.mp4"}), + ], + str(stitched_dir), + ] + ) + original_descs = run_process_for_descs( + [*["--video_geotag_source", "exiftool_runtime"], str(original_path)] + ) + + assert [Path(d["filename"]).name for d in descs] == ["max-360mode.mp4"] + assert Path(descs[0]["filename"]).parent.name == "stitched" + assert descs[0]["MAPDeviceModel"] == "GoPro Max" + assert len(descs[0]["MAPGPSTrack"]) > 0 + assert descs[0]["MAPGPSTrack"] == original_descs[0]["MAPGPSTrack"] + + # Without the pattern, the video's own metadata has no GPS + descs = run_process_for_descs( + [*["--video_geotag_source", "exiftool_runtime"], str(stitched_dir)] + ) + assert descs[0]["error"]["type"] == "MapillaryVideoGPSNotFoundError" + + def test_process_geotag_everything_with_exiftool_runtime(setup_data: py.path.local): pytest_skip_if_not_exiftool_installed() @@ -1090,6 +1214,55 @@ def test_process_geotag_with_exiftool_xml_pattern(setup_data: py.path.local): assert_descs_exact_equal(exiftool_descs, native_descs) +def test_process_geotag_with_exiftool_xml_default_pattern(setup_data: py.path.local): + """ + Without a source path or a pattern, exiftool_xml reads the XML named after + each image and video, beside it: %g.xml, as the README documents + """ + pytest_skip_if_not_exiftool_installed() + + paths = [ + str(setup_data.join("images")), + str(setup_data.join("gopro_data").join("max-360mode.mp4")), + ] + subprocess.run( + [ + EXIFTOOL_EXECUTABLE, + *["-fast", "-q", "-n", "-X", "-ee"], + *["-api", "LargeFileSupport=1"], + *["-w!", "%d%f.xml"], + *paths, + ], + check=True, + ) + assert setup_data.join("gopro_data").join("max-360mode.xml").check() + + native_descs = run_process_for_descs([*["--geotag_source", "native"], *paths]) + + descs = run_process_for_descs([*["--geotag_source", "exiftool_xml"], *paths]) + assert_descs_exact_equal(native_descs, descs) + + native_video_descs = run_process_for_descs( + [*["--video_geotag_source", "native"], paths[1]] + ) + video_descs = run_process_for_descs( + [*["--video_geotag_source", "exiftool_xml"], paths[1]] + ) + assert_descs_exact_equal(native_video_descs, video_descs) + + +def test_process_geotag_with_exiftool_xml_default_pattern_missing_file( + setup_data: py.path.local, +): + video_path = setup_data.join("videos").join("sample-5s.mp4") + descs = run_process_for_descs( + [*["--video_geotag_source", "exiftool_xml"], str(video_path)] + ) + + assert len(descs) == 1 + assert descs[0]["error"]["type"] == "MapillaryExifToolXMLNotFoundError" + + def test_process_geotag_with_exiftool_xml_pattern_missing_file( setup_data: py.path.local, ): diff --git a/tests/integration/test_upload.py b/tests/integration/test_upload.py index 1afe1bb45..3bfa1e357 100644 --- a/tests/integration/test_upload.py +++ b/tests/integration/test_upload.py @@ -10,7 +10,7 @@ import py.path import pytest -from mapillary_tools.geotag.video_extractors.gpx import GPSTimeline, GPXVideoExtractor +from mapillary_tools.geotag.video_extractors.gpx import GPXVideoExtractor from mapillary_tools.geotag.video_extractors.native import NativeVideoExtractor from .fixtures import ( @@ -52,9 +52,7 @@ def test_upload_video_with_gpx_running_past_both_ends( points = NativeVideoExtractor(Path(video_path)).extract().points clock = GPXVideoExtractor._read_video_clock(points) assert clock is not None - timeline = GPSTimeline.read(points, clock.rate) - assert timeline is not None - video_start = timeline.to_unix_time(0) + video_start = clock.to_unix_time(0) # From 12.5 seconds before the video to about 47 seconds after it trkpts = "".join( f'