mapillary-tools 0.14.0a1__py3-none-any.whl → 0.14.0a2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. mapillary_tools/__init__.py +1 -1
  2. mapillary_tools/api_v4.py +4 -4
  3. mapillary_tools/camm/camm_parser.py +5 -5
  4. mapillary_tools/commands/__main__.py +1 -2
  5. mapillary_tools/config.py +7 -5
  6. mapillary_tools/constants.py +1 -2
  7. mapillary_tools/exceptions.py +1 -1
  8. mapillary_tools/exif_read.py +65 -65
  9. mapillary_tools/exif_write.py +7 -7
  10. mapillary_tools/exiftool_read.py +23 -46
  11. mapillary_tools/exiftool_read_video.py +36 -34
  12. mapillary_tools/ffmpeg.py +24 -23
  13. mapillary_tools/geo.py +4 -21
  14. mapillary_tools/geotag/{geotag_from_generic.py → base.py} +32 -48
  15. mapillary_tools/geotag/factory.py +27 -34
  16. mapillary_tools/geotag/geotag_images_from_exif.py +15 -51
  17. mapillary_tools/geotag/geotag_images_from_exiftool.py +107 -59
  18. mapillary_tools/geotag/geotag_images_from_gpx.py +20 -10
  19. mapillary_tools/geotag/geotag_images_from_gpx_file.py +2 -34
  20. mapillary_tools/geotag/geotag_images_from_nmea_file.py +0 -3
  21. mapillary_tools/geotag/geotag_images_from_video.py +16 -14
  22. mapillary_tools/geotag/geotag_videos_from_exiftool.py +97 -0
  23. mapillary_tools/geotag/geotag_videos_from_gpx.py +14 -115
  24. mapillary_tools/geotag/geotag_videos_from_video.py +14 -147
  25. mapillary_tools/geotag/image_extractors/base.py +18 -0
  26. mapillary_tools/geotag/image_extractors/exif.py +60 -0
  27. mapillary_tools/geotag/image_extractors/exiftool.py +18 -0
  28. mapillary_tools/geotag/options.py +1 -0
  29. mapillary_tools/geotag/utils.py +62 -0
  30. mapillary_tools/geotag/video_extractors/base.py +18 -0
  31. mapillary_tools/geotag/video_extractors/exiftool.py +70 -0
  32. mapillary_tools/{video_data_extraction/extractors/gpx_parser.py → geotag/video_extractors/gpx.py} +57 -39
  33. mapillary_tools/geotag/video_extractors/native.py +157 -0
  34. mapillary_tools/gpmf/gpmf_parser.py +16 -16
  35. mapillary_tools/gpmf/gps_filter.py +5 -3
  36. mapillary_tools/history.py +4 -2
  37. mapillary_tools/mp4/construct_mp4_parser.py +9 -8
  38. mapillary_tools/mp4/mp4_sample_parser.py +27 -27
  39. mapillary_tools/mp4/simple_mp4_builder.py +10 -9
  40. mapillary_tools/mp4/simple_mp4_parser.py +13 -12
  41. mapillary_tools/process_geotag_properties.py +5 -7
  42. mapillary_tools/process_sequence_properties.py +40 -38
  43. mapillary_tools/sample_video.py +8 -8
  44. mapillary_tools/telemetry.py +6 -5
  45. mapillary_tools/types.py +33 -38
  46. mapillary_tools/utils.py +16 -18
  47. {mapillary_tools-0.14.0a1.dist-info → mapillary_tools-0.14.0a2.dist-info}/METADATA +1 -1
  48. mapillary_tools-0.14.0a2.dist-info/RECORD +72 -0
  49. mapillary_tools/geotag/__init__.py +0 -1
  50. mapillary_tools/geotag/geotag_images_from_exiftool_both_image_and_video.py +0 -77
  51. mapillary_tools/geotag/geotag_videos_from_exiftool_video.py +0 -151
  52. mapillary_tools/video_data_extraction/cli_options.py +0 -22
  53. mapillary_tools/video_data_extraction/extract_video_data.py +0 -157
  54. mapillary_tools/video_data_extraction/extractors/base_parser.py +0 -75
  55. mapillary_tools/video_data_extraction/extractors/blackvue_parser.py +0 -49
  56. mapillary_tools/video_data_extraction/extractors/camm_parser.py +0 -62
  57. mapillary_tools/video_data_extraction/extractors/exiftool_runtime_parser.py +0 -74
  58. mapillary_tools/video_data_extraction/extractors/exiftool_xml_parser.py +0 -52
  59. mapillary_tools/video_data_extraction/extractors/generic_video_parser.py +0 -52
  60. mapillary_tools/video_data_extraction/extractors/gopro_parser.py +0 -58
  61. mapillary_tools/video_data_extraction/extractors/nmea_parser.py +0 -24
  62. mapillary_tools/video_data_extraction/video_data_parser_factory.py +0 -39
  63. mapillary_tools-0.14.0a1.dist-info/RECORD +0 -78
  64. {mapillary_tools-0.14.0a1.dist-info → mapillary_tools-0.14.0a2.dist-info}/WHEEL +0 -0
  65. {mapillary_tools-0.14.0a1.dist-info → mapillary_tools-0.14.0a2.dist-info}/entry_points.txt +0 -0
  66. {mapillary_tools-0.14.0a1.dist-info → mapillary_tools-0.14.0a2.dist-info}/licenses/LICENSE +0 -0
  67. {mapillary_tools-0.14.0a1.dist-info → mapillary_tools-0.14.0a2.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,18 @@
1
+ from __future__ import annotations
2
+
3
+ import abc
4
+ from pathlib import Path
5
+
6
+ from ... import types
7
+
8
+
9
+ class BaseImageExtractor(abc.ABC):
10
+ """
11
+ Extracts metadata from an image file.
12
+ """
13
+
14
+ def __init__(self, image_path: Path):
15
+ self.image_path = image_path
16
+
17
+ def extract(self) -> types.ImageMetadataOrError:
18
+ raise NotImplementedError
@@ -0,0 +1,60 @@
1
+ from __future__ import annotations
2
+
3
+ import contextlib
4
+ import sys
5
+ import typing as T
6
+ from pathlib import Path
7
+
8
+ if sys.version_info >= (3, 12):
9
+ from typing import override
10
+ else:
11
+ from typing_extensions import override
12
+
13
+ from ... import exceptions, exif_read, geo, types, utils
14
+ from .base import BaseImageExtractor
15
+
16
+
17
+ class ImageEXIFExtractor(BaseImageExtractor):
18
+ def __init__(self, image_path: Path, skip_lonlat_error: bool = False):
19
+ super().__init__(image_path)
20
+ self.skip_lonlat_error = skip_lonlat_error
21
+
22
+ @contextlib.contextmanager
23
+ def _exif_context(self) -> T.Generator[exif_read.ExifReadABC, None, None]:
24
+ with self.image_path.open("rb") as fp:
25
+ yield exif_read.ExifRead(fp)
26
+
27
+ @override
28
+ def extract(self) -> types.ImageMetadata:
29
+ with self._exif_context() as exif:
30
+ lonlat = exif.extract_lon_lat()
31
+ if lonlat is None:
32
+ if not self.skip_lonlat_error:
33
+ raise exceptions.MapillaryGeoTaggingError(
34
+ "Unable to extract GPS Longitude or GPS Latitude from the image"
35
+ )
36
+ lonlat = (0.0, 0.0)
37
+ lon, lat = lonlat
38
+
39
+ capture_time = exif.extract_capture_time()
40
+ if capture_time is None:
41
+ raise exceptions.MapillaryGeoTaggingError(
42
+ "Unable to extract timestamp from the image"
43
+ )
44
+
45
+ image_metadata = types.ImageMetadata(
46
+ filename=self.image_path,
47
+ filesize=utils.get_file_size(self.image_path),
48
+ time=geo.as_unix_time(capture_time),
49
+ lat=lat,
50
+ lon=lon,
51
+ alt=exif.extract_altitude(),
52
+ angle=exif.extract_direction(),
53
+ width=exif.extract_width(),
54
+ height=exif.extract_height(),
55
+ MAPOrientation=exif.extract_orientation(),
56
+ MAPDeviceMake=exif.extract_make(),
57
+ MAPDeviceModel=exif.extract_model(),
58
+ )
59
+
60
+ return image_metadata
@@ -0,0 +1,18 @@
1
+ from __future__ import annotations
2
+
3
+ import contextlib
4
+ import xml.etree.ElementTree as ET
5
+ from pathlib import Path
6
+
7
+ from ... import exiftool_read
8
+ from .exif import ImageEXIFExtractor
9
+
10
+
11
+ class ImageExifToolExtractor(ImageEXIFExtractor):
12
+ def __init__(self, image_path: Path, element: ET.Element):
13
+ super().__init__(image_path)
14
+ self.element = element
15
+
16
+ @contextlib.contextmanager
17
+ def _exif_context(self):
18
+ yield exiftool_read.ExifToolRead(ET.ElementTree(self.element))
@@ -28,6 +28,7 @@ class SourceType(enum.Enum):
28
28
  SOURCE_TYPE_ALIAS: dict[str, SourceType] = {
29
29
  "blackvue_videos": SourceType.BLACKVUE,
30
30
  "gopro_videos": SourceType.GOPRO,
31
+ "exiftool": SourceType.EXIFTOOL_RUNTIME,
31
32
  }
32
33
 
33
34
 
@@ -0,0 +1,62 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import typing as T
5
+ import xml.etree.ElementTree as ET
6
+ from pathlib import Path
7
+
8
+ import gpxpy
9
+
10
+ from .. import exiftool_read, geo, utils
11
+
12
+ Track = T.List[geo.Point]
13
+ LOG = logging.getLogger(__name__)
14
+
15
+
16
+ def parse_gpx(gpx_file: Path) -> list[Track]:
17
+ with gpx_file.open("r") as f:
18
+ gpx = gpxpy.parse(f)
19
+
20
+ tracks: list[Track] = []
21
+
22
+ for track in gpx.tracks:
23
+ for segment in track.segments:
24
+ tracks.append([])
25
+ for point in segment.points:
26
+ if point.time is not None:
27
+ tracks[-1].append(
28
+ geo.Point(
29
+ time=geo.as_unix_time(point.time),
30
+ lat=point.latitude,
31
+ lon=point.longitude,
32
+ alt=point.elevation,
33
+ angle=None,
34
+ )
35
+ )
36
+
37
+ return tracks
38
+
39
+
40
+ def index_rdf_description_by_path(
41
+ xml_paths: T.Sequence[Path],
42
+ ) -> dict[str, ET.Element]:
43
+ rdf_description_by_path: dict[str, ET.Element] = {}
44
+
45
+ for xml_path in utils.find_xml_files(xml_paths):
46
+ try:
47
+ etree = ET.parse(xml_path)
48
+ except ET.ParseError as ex:
49
+ verbose = LOG.getEffectiveLevel() <= logging.DEBUG
50
+ if verbose:
51
+ LOG.warning("Failed to parse %s", xml_path, exc_info=True)
52
+ else:
53
+ LOG.warning("Failed to parse %s: %s", xml_path, ex)
54
+ continue
55
+
56
+ rdf_description_by_path.update(
57
+ exiftool_read.index_rdf_description_by_path_from_xml_element(
58
+ etree.getroot()
59
+ )
60
+ )
61
+
62
+ return rdf_description_by_path
@@ -0,0 +1,18 @@
1
+ from __future__ import annotations
2
+
3
+ import abc
4
+ from pathlib import Path
5
+
6
+ from ... import types
7
+
8
+
9
+ class BaseVideoExtractor(abc.ABC):
10
+ """
11
+ Extracts metadata from a video file.
12
+ """
13
+
14
+ def __init__(self, video_path: Path):
15
+ self.video_path = video_path
16
+
17
+ def extract(self) -> types.VideoMetadataOrError:
18
+ raise NotImplementedError
@@ -0,0 +1,70 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ import typing as T
5
+ from pathlib import Path
6
+ from xml.etree import ElementTree as ET
7
+
8
+ if sys.version_info >= (3, 12):
9
+ from typing import override
10
+ else:
11
+ from typing_extensions import override
12
+
13
+ from ... import exceptions, exiftool_read_video, geo, telemetry, types, utils
14
+ from ...gpmf import gpmf_gps_filter
15
+ from .base import BaseVideoExtractor
16
+
17
+
18
+ class VideoExifToolExtractor(BaseVideoExtractor):
19
+ def __init__(self, video_path: Path, element: ET.Element):
20
+ super().__init__(video_path)
21
+ self.element = element
22
+
23
+ @override
24
+ def extract(self) -> types.VideoMetadataOrError:
25
+ exif = exiftool_read_video.ExifToolReadVideo(ET.ElementTree(self.element))
26
+
27
+ make = exif.extract_make()
28
+ model = exif.extract_model()
29
+
30
+ is_gopro = make is not None and make.upper() in ["GOPRO"]
31
+
32
+ points = exif.extract_gps_track()
33
+
34
+ # ExifTool has no idea if GPS is not found or found but empty
35
+ if is_gopro:
36
+ if not points:
37
+ raise exceptions.MapillaryGPXEmptyError("Empty GPS data found")
38
+
39
+ # ExifTool (since 13.04) converts GPSSpeed for GoPro to km/h, so here we convert it back to m/s
40
+ for p in points:
41
+ if isinstance(p, telemetry.GPSPoint) and p.ground_speed is not None:
42
+ p.ground_speed = p.ground_speed / 3.6
43
+
44
+ if isinstance(points[0], telemetry.GPSPoint):
45
+ points = T.cast(
46
+ T.List[geo.Point],
47
+ gpmf_gps_filter.remove_noisy_points(
48
+ T.cast(T.List[telemetry.GPSPoint], points)
49
+ ),
50
+ )
51
+ if not points:
52
+ raise exceptions.MapillaryGPSNoiseError("GPS is too noisy")
53
+
54
+ if not points:
55
+ raise exceptions.MapillaryVideoGPSNotFoundError(
56
+ "No GPS data found from the video"
57
+ )
58
+
59
+ filetype = types.FileType.GOPRO if is_gopro else types.FileType.VIDEO
60
+
61
+ video_metadata = types.VideoMetadata(
62
+ self.video_path,
63
+ filesize=utils.get_file_size(self.video_path),
64
+ filetype=filetype,
65
+ points=points,
66
+ make=make,
67
+ model=model,
68
+ )
69
+
70
+ return video_metadata
@@ -1,51 +1,84 @@
1
+ from __future__ import annotations
2
+
3
+ import dataclasses
1
4
  import datetime
2
5
  import logging
6
+ import sys
3
7
  import typing as T
8
+ from pathlib import Path
4
9
 
5
- from ... import geo, telemetry
6
- from ...geotag import geotag_images_from_gpx_file
7
- from .base_parser import BaseParser
8
- from .generic_video_parser import GenericVideoParser
10
+ if sys.version_info >= (3, 12):
11
+ from typing import override
12
+ else:
13
+ from typing_extensions import override
9
14
 
15
+ from ... import geo, telemetry, types
16
+ from ..utils import parse_gpx
17
+ from .base import BaseVideoExtractor
18
+ from .native import NativeVideoExtractor
10
19
 
11
- LOG = logging.getLogger(__name__)
12
20
 
21
+ LOG = logging.getLogger(__name__)
13
22
 
14
- class GpxParser(BaseParser):
15
- default_source_pattern = "%g.gpx"
16
- parser_label = "gpx"
17
23
 
18
- def extract_points(self) -> T.Sequence[geo.Point]:
19
- path = self.geotag_source_path
20
- if not path:
21
- return []
24
+ class GPXVideoExtractor(BaseVideoExtractor):
25
+ def __init__(self, video_path: Path, gpx_path: Path):
26
+ self.video_path = video_path
27
+ self.gpx_path = gpx_path
22
28
 
29
+ @override
30
+ def extract(self) -> types.VideoMetadataOrError:
23
31
  try:
24
- gpx_tracks = geotag_images_from_gpx_file.parse_gpx(path)
32
+ gpx_tracks = parse_gpx(self.gpx_path)
25
33
  except Exception as ex:
26
34
  raise RuntimeError(
27
- f"Error parsing GPX {path}: {ex.__class__.__name__}: {ex}"
35
+ f"Error parsing GPX {self.gpx_path}: {ex.__class__.__name__}: {ex}"
28
36
  )
29
37
 
30
38
  if 1 < len(gpx_tracks):
31
39
  LOG.warning(
32
40
  "Found %s tracks in the GPX file %s. Will merge points in all the tracks as a single track for interpolation",
33
41
  len(gpx_tracks),
34
- self.videoPath,
42
+ self.gpx_path,
35
43
  )
36
44
 
37
45
  gpx_points: T.Sequence[geo.Point] = sum(gpx_tracks, [])
38
- if not gpx_points:
39
- return gpx_points
40
46
 
41
- offset = self._synx_gpx_by_first_gps_timestamp(gpx_points)
47
+ native_extractor = NativeVideoExtractor(self.video_path)
48
+
49
+ video_metadata_or_error = native_extractor.extract()
50
+
51
+ if isinstance(video_metadata_or_error, types.ErrorMetadata):
52
+ self._rebase_times(gpx_points)
53
+ return types.VideoMetadata(
54
+ filename=video_metadata_or_error.filename,
55
+ filetype=video_metadata_or_error.filetype or types.FileType.VIDEO,
56
+ points=gpx_points,
57
+ )
58
+
59
+ video_metadata = video_metadata_or_error
60
+
61
+ offset = self._synx_gpx_by_first_gps_timestamp(
62
+ gpx_points, video_metadata.points
63
+ )
42
64
 
43
65
  self._rebase_times(gpx_points, offset=offset)
44
66
 
45
- return gpx_points
67
+ return dataclasses.replace(video_metadata_or_error, points=gpx_points)
68
+
69
+ @staticmethod
70
+ def _rebase_times(points: T.Sequence[geo.Point], offset: float = 0.0):
71
+ """
72
+ Make point times start from 0
73
+ """
74
+ if points:
75
+ first_timestamp = points[0].time
76
+ for p in points:
77
+ p.time = (p.time - first_timestamp) + offset
78
+ return points
46
79
 
47
80
  def _synx_gpx_by_first_gps_timestamp(
48
- self, gpx_points: T.Sequence[geo.Point]
81
+ self, gpx_points: T.Sequence[geo.Point], video_gps_points: T.Sequence[geo.Point]
49
82
  ) -> float:
50
83
  offset: float = 0.0
51
84
 
@@ -57,19 +90,14 @@ class GpxParser(BaseParser):
57
90
  )
58
91
  LOG.info("First GPX timestamp: %s", first_gpx_dt)
59
92
 
60
- # Extract first GPS timestamp (if found) for synchronization
61
- # Use an empty dictionary to force video parsers to extract make/model from the video metadata itself
62
- parser = GenericVideoParser(self.videoPath, self.options, {})
63
- gps_points = parser.extract_points()
64
-
65
- if not gps_points:
93
+ if not video_gps_points:
66
94
  LOG.warning(
67
95
  "Skip GPX synchronization because no GPS found in video %s",
68
- self.videoPath,
96
+ self.video_path,
69
97
  )
70
98
  return offset
71
99
 
72
- first_gps_point = gps_points[0]
100
+ first_gps_point = video_gps_points[0]
73
101
  if isinstance(first_gps_point, telemetry.GPSPoint):
74
102
  if first_gps_point.epoch_time is not None:
75
103
  first_gps_dt = datetime.datetime.fromtimestamp(
@@ -92,17 +120,7 @@ class GpxParser(BaseParser):
92
120
  else:
93
121
  LOG.warning(
94
122
  "Skip GPX synchronization because no GPS epoch time found in video %s",
95
- self.videoPath,
123
+ self.video_path,
96
124
  )
97
125
 
98
126
  return offset
99
-
100
- def extract_make(self) -> T.Optional[str]:
101
- # Use an empty dictionary to force video parsers to extract make/model from the video metadata itself
102
- parser = GenericVideoParser(self.videoPath, self.options, {})
103
- return parser.extract_make()
104
-
105
- def extract_model(self) -> T.Optional[str]:
106
- # Use an empty dictionary to force video parsers to extract make/model from the video metadata itself
107
- parser = GenericVideoParser(self.videoPath, self.options, {})
108
- return parser.extract_model()
@@ -0,0 +1,157 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ import typing as T
5
+ from pathlib import Path
6
+
7
+ if sys.version_info >= (3, 12):
8
+ from typing import override
9
+ else:
10
+ from typing_extensions import override
11
+
12
+ from ... import blackvue_parser, exceptions, geo, telemetry, types, utils
13
+ from ...camm import camm_parser
14
+ from ...gpmf import gpmf_gps_filter, gpmf_parser
15
+ from .base import BaseVideoExtractor
16
+
17
+
18
+ class GoProVideoExtractor(BaseVideoExtractor):
19
+ @override
20
+ def extract(self) -> types.VideoMetadataOrError:
21
+ with self.video_path.open("rb") as fp:
22
+ gopro_info = gpmf_parser.extract_gopro_info(fp)
23
+
24
+ if gopro_info is None:
25
+ raise exceptions.MapillaryVideoGPSNotFoundError(
26
+ "No GPS data found from the video"
27
+ )
28
+
29
+ gps_points = gopro_info.gps
30
+ assert gps_points is not None, "must have GPS data extracted"
31
+ if not gps_points:
32
+ # Instead of raising an exception, return error metadata to tell the file type
33
+ ex: exceptions.MapillaryDescriptionError = (
34
+ exceptions.MapillaryGPXEmptyError("Empty GPS data found")
35
+ )
36
+ return types.describe_error_metadata(
37
+ ex, self.video_path, filetype=types.FileType.GOPRO
38
+ )
39
+
40
+ gps_points = T.cast(
41
+ T.List[telemetry.GPSPoint], gpmf_gps_filter.remove_noisy_points(gps_points)
42
+ )
43
+ if not gps_points:
44
+ # Instead of raising an exception, return error metadata to tell the file type
45
+ ex = exceptions.MapillaryGPSNoiseError("GPS is too noisy")
46
+ return types.describe_error_metadata(
47
+ ex, self.video_path, filetype=types.FileType.GOPRO
48
+ )
49
+
50
+ video_metadata = types.VideoMetadata(
51
+ filename=self.video_path,
52
+ filesize=utils.get_file_size(self.video_path),
53
+ filetype=types.FileType.GOPRO,
54
+ points=T.cast(T.List[geo.Point], gps_points),
55
+ make=gopro_info.make,
56
+ model=gopro_info.model,
57
+ )
58
+
59
+ return video_metadata
60
+
61
+
62
+ class CAMMVideoExtractor(BaseVideoExtractor):
63
+ @override
64
+ def extract(self) -> types.VideoMetadataOrError:
65
+ with self.video_path.open("rb") as fp:
66
+ camm_info = camm_parser.extract_camm_info(fp)
67
+
68
+ if camm_info is None:
69
+ raise exceptions.MapillaryVideoGPSNotFoundError(
70
+ "No GPS data found from the video"
71
+ )
72
+
73
+ if not camm_info.gps and not camm_info.mini_gps:
74
+ # Instead of raising an exception, return error metadata to tell the file type
75
+ ex: exceptions.MapillaryDescriptionError = (
76
+ exceptions.MapillaryGPXEmptyError("Empty GPS data found")
77
+ )
78
+ return types.describe_error_metadata(
79
+ ex, self.video_path, filetype=types.FileType.CAMM
80
+ )
81
+
82
+ return types.VideoMetadata(
83
+ filename=self.video_path,
84
+ filesize=utils.get_file_size(self.video_path),
85
+ filetype=types.FileType.CAMM,
86
+ points=T.cast(T.List[geo.Point], camm_info.gps or camm_info.mini_gps),
87
+ make=camm_info.make,
88
+ model=camm_info.model,
89
+ )
90
+
91
+
92
+ class BlackVueVideoExtractor(BaseVideoExtractor):
93
+ @override
94
+ def extract(self) -> types.VideoMetadataOrError:
95
+ with self.video_path.open("rb") as fp:
96
+ blackvue_info = blackvue_parser.extract_blackvue_info(fp)
97
+
98
+ if blackvue_info is None:
99
+ raise exceptions.MapillaryVideoGPSNotFoundError(
100
+ "No GPS data found from the video"
101
+ )
102
+
103
+ if not blackvue_info.gps:
104
+ # Instead of raising an exception, return error metadata to tell the file type
105
+ ex: exceptions.MapillaryDescriptionError = (
106
+ exceptions.MapillaryGPXEmptyError("Empty GPS data found")
107
+ )
108
+ return types.describe_error_metadata(
109
+ ex, self.video_path, filetype=types.FileType.BLACKVUE
110
+ )
111
+
112
+ video_metadata = types.VideoMetadata(
113
+ filename=self.video_path,
114
+ filesize=utils.get_file_size(self.video_path),
115
+ filetype=types.FileType.BLACKVUE,
116
+ points=blackvue_info.gps or [],
117
+ make=blackvue_info.make,
118
+ model=blackvue_info.model,
119
+ )
120
+
121
+ return video_metadata
122
+
123
+
124
+ class NativeVideoExtractor(BaseVideoExtractor):
125
+ def __init__(self, video_path: Path, filetypes: set[types.FileType] | None = None):
126
+ super().__init__(video_path)
127
+ self.filetypes = filetypes
128
+
129
+ @override
130
+ def extract(self) -> types.VideoMetadataOrError:
131
+ ft = self.filetypes
132
+ extractor: BaseVideoExtractor
133
+
134
+ if ft is None or types.FileType.VIDEO in ft or types.FileType.GOPRO in ft:
135
+ extractor = GoProVideoExtractor(self.video_path)
136
+ try:
137
+ return extractor.extract()
138
+ except exceptions.MapillaryVideoGPSNotFoundError:
139
+ pass
140
+
141
+ if ft is None or types.FileType.VIDEO in ft or types.FileType.CAMM in ft:
142
+ extractor = CAMMVideoExtractor(self.video_path)
143
+ try:
144
+ return extractor.extract()
145
+ except exceptions.MapillaryVideoGPSNotFoundError:
146
+ pass
147
+
148
+ if ft is None or types.FileType.VIDEO in ft or types.FileType.BLACKVUE in ft:
149
+ extractor = BlackVueVideoExtractor(self.video_path)
150
+ try:
151
+ return extractor.extract()
152
+ except exceptions.MapillaryVideoGPSNotFoundError:
153
+ pass
154
+
155
+ raise exceptions.MapillaryVideoGPSNotFoundError(
156
+ "No GPS data found from the video"
157
+ )
@@ -39,7 +39,7 @@ class KLVDict(T.TypedDict):
39
39
  type: bytes
40
40
  structure_size: int
41
41
  repeat: int
42
- data: T.List[T.Any]
42
+ data: list[T.Any]
43
43
 
44
44
 
45
45
  GPMFSampleData: C.GreedyRange
@@ -143,7 +143,7 @@ class GoProInfo:
143
143
 
144
144
  def extract_gopro_info(
145
145
  fp: T.BinaryIO, telemetry_only: bool = False
146
- ) -> T.Optional[GoProInfo]:
146
+ ) -> GoProInfo | None:
147
147
  """
148
148
  Return the GoProInfo object if found. None indicates it's not a valid GoPro video.
149
149
  """
@@ -276,7 +276,7 @@ def _gps5_timestamp_to_epoch_time(dtstr: str):
276
276
  def _gps5_from_stream(
277
277
  stream: T.Sequence[KLVDict],
278
278
  ) -> T.Generator[telemetry.GPSPoint, None, None]:
279
- indexed: T.Dict[bytes, T.List[T.List[T.Any]]] = {
279
+ indexed: dict[bytes, list[list[T.Any]]] = {
280
280
  klv["key"]: klv["data"] for klv in stream
281
281
  }
282
282
 
@@ -362,7 +362,7 @@ def _gps9_from_stream(
362
362
  ) -> T.Generator[telemetry.GPSPoint, None, None]:
363
363
  NUM_VALUES = 9
364
364
 
365
- indexed: T.Dict[bytes, T.List[T.List[T.Any]]] = {
365
+ indexed: dict[bytes, list[list[T.Any]]] = {
366
366
  klv["key"]: klv["data"] for klv in stream
367
367
  }
368
368
 
@@ -444,8 +444,8 @@ def _find_first_device_id(stream: T.Sequence[KLVDict]) -> int:
444
444
  return device_id
445
445
 
446
446
 
447
- def _find_first_gps_stream(stream: T.Sequence[KLVDict]) -> T.List[telemetry.GPSPoint]:
448
- sample_points: T.List[telemetry.GPSPoint] = []
447
+ def _find_first_gps_stream(stream: T.Sequence[KLVDict]) -> list[telemetry.GPSPoint]:
448
+ sample_points: list[telemetry.GPSPoint] = []
449
449
 
450
450
  for klv in stream:
451
451
  if klv["key"] == b"STRM":
@@ -469,7 +469,7 @@ def _is_matrix_calibration(matrix: T.Sequence[float]) -> bool:
469
469
 
470
470
 
471
471
  def _build_matrix(
472
- orin: T.Union[bytes, T.Sequence[int]], orio: T.Union[bytes, T.Sequence[int]]
472
+ orin: bytes | T.Sequence[int], orio: bytes | T.Sequence[int]
473
473
  ) -> T.Sequence[float]:
474
474
  matrix = []
475
475
 
@@ -503,14 +503,14 @@ def _apply_matrix(
503
503
  yield sum(matrix[row_start + x] * values[x] for x in range(size))
504
504
 
505
505
 
506
- def _flatten(nested: T.Sequence[T.Sequence[float]]) -> T.List[float]:
507
- output: T.List[float] = []
506
+ def _flatten(nested: T.Sequence[T.Sequence[float]]) -> list[float]:
507
+ output: list[float] = []
508
508
  for row in nested:
509
509
  output.extend(row)
510
510
  return output
511
511
 
512
512
 
513
- def _get_matrix(klv: T.Dict[bytes, KLVDict]) -> T.Optional[T.Sequence[float]]:
513
+ def _get_matrix(klv: dict[bytes, KLVDict]) -> T.Sequence[float] | None:
514
514
  mtrx = klv.get(b"MTRX")
515
515
  if mtrx is not None:
516
516
  matrix: T.Sequence[float] = _flatten(mtrx["data"])
@@ -530,7 +530,7 @@ def _get_matrix(klv: T.Dict[bytes, KLVDict]) -> T.Optional[T.Sequence[float]]:
530
530
  def _scale_and_calibrate(
531
531
  stream: T.Sequence[KLVDict], key: bytes
532
532
  ) -> T.Generator[T.Sequence[float], None, None]:
533
- indexed: T.Dict[bytes, KLVDict] = {klv["key"]: klv for klv in stream}
533
+ indexed: dict[bytes, KLVDict] = {klv["key"]: klv for klv in stream}
534
534
 
535
535
  klv = indexed.get(key)
536
536
  if klv is None:
@@ -561,7 +561,7 @@ def _scale_and_calibrate(
561
561
 
562
562
 
563
563
  def _find_first_telemetry_stream(stream: T.Sequence[KLVDict], key: bytes):
564
- values: T.List[T.Sequence[float]] = []
564
+ values: list[T.Sequence[float]] = []
565
565
 
566
566
  for klv in stream:
567
567
  if klv["key"] == b"STRM":
@@ -684,7 +684,7 @@ def _load_telemetry_from_samples(
684
684
  return device_found
685
685
 
686
686
 
687
- def _is_gpmd_description(description: T.Dict) -> bool:
687
+ def _is_gpmd_description(description: dict) -> bool:
688
688
  return description["format"] == b"gpmd"
689
689
 
690
690
 
@@ -699,11 +699,11 @@ def _filter_gpmd_samples(track: TrackBoxParser) -> T.Generator[Sample, None, Non
699
699
  yield sample
700
700
 
701
701
 
702
- def _extract_camera_model_from_devices(device_names: T.Dict[int, bytes]) -> str:
702
+ def _extract_camera_model_from_devices(device_names: dict[int, bytes]) -> str:
703
703
  if not device_names:
704
704
  return ""
705
705
 
706
- unicode_names: T.List[str] = []
706
+ unicode_names: list[str] = []
707
707
  for name in device_names.values():
708
708
  try:
709
709
  unicode_names.append(name.decode("utf-8"))
@@ -730,7 +730,7 @@ def _extract_camera_model_from_devices(device_names: T.Dict[int, bytes]) -> str:
730
730
 
731
731
  def _iterate_read_sample_data(
732
732
  fp: T.BinaryIO, samples: T.Iterable[Sample]
733
- ) -> T.Generator[T.Tuple[Sample, bytes], None, None]:
733
+ ) -> T.Generator[tuple[Sample, bytes], None, None]:
734
734
  for sample in samples:
735
735
  fp.seek(sample.raw_sample.offset, io.SEEK_SET)
736
736
  yield (sample, fp.read(sample.raw_sample.size))