mapillary-tools 0.13.3a1__py3-none-any.whl → 0.14.0__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 (87) hide show
  1. mapillary_tools/__init__.py +1 -1
  2. mapillary_tools/api_v4.py +287 -22
  3. mapillary_tools/authenticate.py +326 -64
  4. mapillary_tools/blackvue_parser.py +195 -0
  5. mapillary_tools/camm/camm_builder.py +55 -97
  6. mapillary_tools/camm/camm_parser.py +429 -181
  7. mapillary_tools/commands/__main__.py +17 -8
  8. mapillary_tools/commands/authenticate.py +8 -1
  9. mapillary_tools/commands/process.py +27 -51
  10. mapillary_tools/commands/process_and_upload.py +19 -5
  11. mapillary_tools/commands/sample_video.py +2 -3
  12. mapillary_tools/commands/upload.py +44 -13
  13. mapillary_tools/commands/video_process_and_upload.py +19 -5
  14. mapillary_tools/config.py +65 -26
  15. mapillary_tools/constants.py +141 -18
  16. mapillary_tools/exceptions.py +37 -34
  17. mapillary_tools/exif_read.py +221 -116
  18. mapillary_tools/exif_write.py +10 -8
  19. mapillary_tools/exiftool_read.py +33 -42
  20. mapillary_tools/exiftool_read_video.py +97 -47
  21. mapillary_tools/exiftool_runner.py +57 -0
  22. mapillary_tools/ffmpeg.py +417 -242
  23. mapillary_tools/geo.py +158 -118
  24. mapillary_tools/geotag/__init__.py +0 -1
  25. mapillary_tools/geotag/base.py +147 -0
  26. mapillary_tools/geotag/factory.py +307 -0
  27. mapillary_tools/geotag/geotag_images_from_exif.py +14 -131
  28. mapillary_tools/geotag/geotag_images_from_exiftool.py +136 -85
  29. mapillary_tools/geotag/geotag_images_from_gpx.py +60 -124
  30. mapillary_tools/geotag/geotag_images_from_gpx_file.py +13 -126
  31. mapillary_tools/geotag/geotag_images_from_nmea_file.py +4 -5
  32. mapillary_tools/geotag/geotag_images_from_video.py +88 -51
  33. mapillary_tools/geotag/geotag_videos_from_exiftool.py +123 -0
  34. mapillary_tools/geotag/geotag_videos_from_gpx.py +52 -0
  35. mapillary_tools/geotag/geotag_videos_from_video.py +20 -185
  36. mapillary_tools/geotag/image_extractors/base.py +18 -0
  37. mapillary_tools/geotag/image_extractors/exif.py +60 -0
  38. mapillary_tools/geotag/image_extractors/exiftool.py +18 -0
  39. mapillary_tools/geotag/options.py +182 -0
  40. mapillary_tools/geotag/utils.py +52 -16
  41. mapillary_tools/geotag/video_extractors/base.py +18 -0
  42. mapillary_tools/geotag/video_extractors/exiftool.py +70 -0
  43. mapillary_tools/geotag/video_extractors/gpx.py +116 -0
  44. mapillary_tools/geotag/video_extractors/native.py +160 -0
  45. mapillary_tools/{geotag → gpmf}/gpmf_parser.py +205 -182
  46. mapillary_tools/{geotag → gpmf}/gps_filter.py +5 -3
  47. mapillary_tools/history.py +134 -20
  48. mapillary_tools/mp4/construct_mp4_parser.py +17 -10
  49. mapillary_tools/mp4/io_utils.py +0 -1
  50. mapillary_tools/mp4/mp4_sample_parser.py +36 -28
  51. mapillary_tools/mp4/simple_mp4_builder.py +10 -9
  52. mapillary_tools/mp4/simple_mp4_parser.py +13 -22
  53. mapillary_tools/process_geotag_properties.py +184 -414
  54. mapillary_tools/process_sequence_properties.py +594 -225
  55. mapillary_tools/sample_video.py +20 -26
  56. mapillary_tools/serializer/description.py +587 -0
  57. mapillary_tools/serializer/gpx.py +132 -0
  58. mapillary_tools/telemetry.py +26 -13
  59. mapillary_tools/types.py +98 -611
  60. mapillary_tools/upload.py +408 -416
  61. mapillary_tools/upload_api_v4.py +172 -174
  62. mapillary_tools/uploader.py +804 -284
  63. mapillary_tools/utils.py +49 -18
  64. {mapillary_tools-0.13.3a1.dist-info → mapillary_tools-0.14.0.dist-info}/METADATA +93 -35
  65. mapillary_tools-0.14.0.dist-info/RECORD +75 -0
  66. {mapillary_tools-0.13.3a1.dist-info → mapillary_tools-0.14.0.dist-info}/WHEEL +1 -1
  67. mapillary_tools/geotag/blackvue_parser.py +0 -118
  68. mapillary_tools/geotag/geotag_from_generic.py +0 -22
  69. mapillary_tools/geotag/geotag_images_from_exiftool_both_image_and_video.py +0 -93
  70. mapillary_tools/geotag/geotag_videos_from_exiftool_video.py +0 -145
  71. mapillary_tools/video_data_extraction/cli_options.py +0 -22
  72. mapillary_tools/video_data_extraction/extract_video_data.py +0 -176
  73. mapillary_tools/video_data_extraction/extractors/base_parser.py +0 -75
  74. mapillary_tools/video_data_extraction/extractors/blackvue_parser.py +0 -34
  75. mapillary_tools/video_data_extraction/extractors/camm_parser.py +0 -38
  76. mapillary_tools/video_data_extraction/extractors/exiftool_runtime_parser.py +0 -71
  77. mapillary_tools/video_data_extraction/extractors/exiftool_xml_parser.py +0 -53
  78. mapillary_tools/video_data_extraction/extractors/generic_video_parser.py +0 -52
  79. mapillary_tools/video_data_extraction/extractors/gopro_parser.py +0 -43
  80. mapillary_tools/video_data_extraction/extractors/gpx_parser.py +0 -108
  81. mapillary_tools/video_data_extraction/extractors/nmea_parser.py +0 -24
  82. mapillary_tools/video_data_extraction/video_data_parser_factory.py +0 -39
  83. mapillary_tools-0.13.3a1.dist-info/RECORD +0 -75
  84. /mapillary_tools/{geotag → gpmf}/gpmf_gps_filter.py +0 -0
  85. {mapillary_tools-0.13.3a1.dist-info → mapillary_tools-0.14.0.dist-info}/entry_points.txt +0 -0
  86. {mapillary_tools-0.13.3a1.dist-info → mapillary_tools-0.14.0.dist-info/licenses}/LICENSE +0 -0
  87. {mapillary_tools-0.13.3a1.dist-info → mapillary_tools-0.14.0.dist-info}/top_level.txt +0 -0
@@ -1,11 +1,11 @@
1
1
  # pyre-ignore-all-errors[5, 21, 24]
2
+ from __future__ import annotations
2
3
 
3
4
  import datetime
4
5
  import io
5
6
  import json
6
7
  import logging
7
8
  import math
8
- import typing as T
9
9
  from pathlib import Path
10
10
 
11
11
  import piexif
@@ -15,9 +15,9 @@ LOG = logging.getLogger(__name__)
15
15
 
16
16
 
17
17
  class ExifEdit:
18
- _filename_or_bytes: T.Union[str, bytes]
18
+ _filename_or_bytes: str | bytes
19
19
 
20
- def __init__(self, filename_or_bytes: T.Union[Path, bytes]) -> None:
20
+ def __init__(self, filename_or_bytes: Path | bytes) -> None:
21
21
  """Initialize the object"""
22
22
  if isinstance(filename_or_bytes, Path):
23
23
  # make sure filename is resolved to avoid to be interpretted as bytes in piexif
@@ -25,12 +25,12 @@ class ExifEdit:
25
25
  self._filename_or_bytes = str(filename_or_bytes.resolve())
26
26
  else:
27
27
  self._filename_or_bytes = filename_or_bytes
28
- self._ef: T.Dict = piexif.load(self._filename_or_bytes)
28
+ self._ef: dict = piexif.load(self._filename_or_bytes)
29
29
 
30
30
  @staticmethod
31
31
  def decimal_to_dms(
32
32
  value: float, precision: int
33
- ) -> T.Tuple[T.Tuple[float, int], T.Tuple[float, int], T.Tuple[float, int]]:
33
+ ) -> tuple[tuple[float, int], tuple[float, int], tuple[float, int]]:
34
34
  """
35
35
  Convert decimal position to degrees, minutes, seconds in a fromat supported by EXIF
36
36
  """
@@ -40,9 +40,11 @@ class ExifEdit:
40
40
 
41
41
  return (deg, 1), (min, 1), (sec, precision)
42
42
 
43
- def add_image_description(self, data: T.Dict) -> None:
43
+ def add_image_description(self, data: dict) -> None:
44
44
  """Add a dict to image description."""
45
- self._ef["0th"][piexif.ImageIFD.ImageDescription] = json.dumps(data)
45
+ self._ef["0th"][piexif.ImageIFD.ImageDescription] = json.dumps(
46
+ data, sort_keys=True, separators=(",", ":")
47
+ )
46
48
 
47
49
  def add_orientation(self, orientation: int) -> None:
48
50
  """Add image orientation to image."""
@@ -201,7 +203,7 @@ class ExifEdit:
201
203
  piexif.insert(exif_bytes, self._filename_or_bytes, output)
202
204
  return output.read()
203
205
 
204
- def write(self, filename: T.Optional[Path] = None) -> None:
206
+ def write(self, filename: Path | None = None) -> None:
205
207
  """Save exif data to file."""
206
208
  if filename is None:
207
209
  if not isinstance(self._filename_or_bytes, str):
@@ -1,13 +1,15 @@
1
+ from __future__ import annotations
2
+
1
3
  import datetime
2
4
  import logging
3
5
  import typing as T
4
6
  import xml.etree.ElementTree as ET
5
7
  from pathlib import Path
6
8
 
7
- from . import exif_read, utils
9
+ from . import exif_read
8
10
 
9
11
 
10
- EXIFTOOL_NAMESPACES: T.Dict[str, str] = {
12
+ EXIFTOOL_NAMESPACES: dict[str, str] = {
11
13
  "Adobe": "http://ns.exiftool.org/APP14/Adobe/1.0/",
12
14
  "Apple": "http://ns.exiftool.org/MakerNotes/Apple/1.0/",
13
15
  "Composite": "http://ns.exiftool.org/Composite/1.0/",
@@ -51,11 +53,11 @@ EXIFTOOL_NAMESPACES: T.Dict[str, str] = {
51
53
 
52
54
 
53
55
  LOG = logging.getLogger(__name__)
56
+ DESCRIPTION_TAG = "rdf:Description"
54
57
  _FIELD_TYPE = T.TypeVar("_FIELD_TYPE", int, float, str)
55
- _DESCRIPTION_TAG = "rdf:Description"
56
58
 
57
59
 
58
- def expand_tag(ns_tag: str, namespaces: T.Dict[str, str]) -> str:
60
+ def expand_tag(ns_tag: str, namespaces: dict[str, str]) -> str:
59
61
  try:
60
62
  ns, tag = ns_tag.split(":", maxsplit=2)
61
63
  except ValueError:
@@ -70,34 +72,23 @@ def canonical_path(path: Path) -> str:
70
72
  return str(path.resolve().as_posix())
71
73
 
72
74
 
73
- def find_rdf_description_path(element: ET.Element) -> T.Optional[Path]:
75
+ def find_rdf_description_path(element: ET.Element) -> Path | None:
74
76
  about = element.get(_EXPANDED_ABOUT_TAG)
75
77
  if about is None:
76
78
  return None
77
79
  return Path(about)
78
80
 
79
81
 
80
- def index_rdf_description_by_path(
81
- xml_paths: T.Sequence[Path],
82
- ) -> T.Dict[str, ET.Element]:
83
- rdf_description_by_path: T.Dict[str, ET.Element] = {}
84
-
85
- for xml_path in utils.find_xml_files(xml_paths):
86
- try:
87
- etree = ET.parse(xml_path)
88
- except ET.ParseError as ex:
89
- verbose = LOG.getEffectiveLevel() <= logging.DEBUG
90
- if verbose:
91
- LOG.warning(f"Failed to parse {xml_path}", exc_info=verbose)
92
- else:
93
- LOG.warning(f"Failed to parse {xml_path}: {ex}", exc_info=verbose)
94
- continue
82
+ def index_rdf_description_by_path_from_xml_element(
83
+ element: ET.Element,
84
+ ) -> dict[str, ET.Element]:
85
+ rdf_description_by_path: dict[str, ET.Element] = {}
95
86
 
96
- elements = etree.iterfind(_DESCRIPTION_TAG, namespaces=EXIFTOOL_NAMESPACES)
97
- for element in elements:
98
- path = find_rdf_description_path(element)
99
- if path is not None:
100
- rdf_description_by_path[canonical_path(path)] = element
87
+ elements = element.iterfind(DESCRIPTION_TAG, namespaces=EXIFTOOL_NAMESPACES)
88
+ for element in elements:
89
+ path = find_rdf_description_path(element)
90
+ if path is not None:
91
+ rdf_description_by_path[canonical_path(path)] = element
101
92
 
102
93
  return rdf_description_by_path
103
94
 
@@ -113,7 +104,7 @@ class ExifToolRead(exif_read.ExifReadABC):
113
104
  ) -> None:
114
105
  self.etree = etree
115
106
 
116
- def extract_altitude(self) -> T.Optional[float]:
107
+ def extract_altitude(self) -> float | None:
117
108
  """
118
109
  Extract altitude
119
110
  """
@@ -129,7 +120,7 @@ class ExifToolRead(exif_read.ExifReadABC):
129
120
 
130
121
  def _extract_gps_datetime(
131
122
  self, date_tags: T.Sequence[str], time_tags: T.Sequence[str]
132
- ) -> T.Optional[datetime.datetime]:
123
+ ) -> datetime.datetime | None:
133
124
  """
134
125
  Extract timestamp from GPS field.
135
126
  """
@@ -143,13 +134,13 @@ class ExifToolRead(exif_read.ExifReadABC):
143
134
 
144
135
  return exif_read.parse_gps_datetime_separately(gpsdate, gpstimestamp)
145
136
 
146
- def extract_gps_datetime(self) -> T.Optional[datetime.datetime]:
137
+ def extract_gps_datetime(self) -> datetime.datetime | None:
147
138
  """
148
139
  Extract timestamp from GPS field.
149
140
  """
150
141
  return self._extract_gps_datetime(["GPS:GPSDateStamp"], ["GPS:GPSTimeStamp"])
151
142
 
152
- def extract_gps_datetime_from_xmp(self) -> T.Optional[datetime.datetime]:
143
+ def extract_gps_datetime_from_xmp(self) -> datetime.datetime | None:
153
144
  """
154
145
  Extract timestamp from XMP GPS field.
155
146
  """
@@ -166,7 +157,7 @@ class ExifToolRead(exif_read.ExifReadABC):
166
157
  dt_tags: T.Sequence[str],
167
158
  subsec_tags: T.Sequence[str],
168
159
  offset_tags: T.Sequence[str],
169
- ) -> T.Optional[datetime.datetime]:
160
+ ) -> datetime.datetime | None:
170
161
  dtstr = self._extract_alternative_fields(dt_tags, str)
171
162
  if dtstr is None:
172
163
  return None
@@ -181,7 +172,7 @@ class ExifToolRead(exif_read.ExifReadABC):
181
172
  return None
182
173
  return dt
183
174
 
184
- def extract_exif_datetime_from_xmp(self) -> T.Optional[datetime.datetime]:
175
+ def extract_exif_datetime_from_xmp(self) -> datetime.datetime | None:
185
176
  # EXIF DateTimeOriginal: 0x9003 (date/time when original image was taken)
186
177
  # EXIF SubSecTimeOriginal: 0x9291 (fractional seconds for DateTimeOriginal)
187
178
  # EXIF OffsetTimeOriginal: 0x9011 (time zone for DateTimeOriginal)
@@ -220,7 +211,7 @@ class ExifToolRead(exif_read.ExifReadABC):
220
211
 
221
212
  return None
222
213
 
223
- def extract_exif_datetime(self) -> T.Optional[datetime.datetime]:
214
+ def extract_exif_datetime(self) -> datetime.datetime | None:
224
215
  # EXIF DateTimeOriginal: 0x9003 (date/time when original image was taken)
225
216
  # EXIF SubSecTimeOriginal: 0x9291 (fractional seconds for DateTimeOriginal)
226
217
  # EXIF OffsetTimeOriginal: 0x9011 (time zone for DateTimeOriginal)
@@ -256,7 +247,7 @@ class ExifToolRead(exif_read.ExifReadABC):
256
247
 
257
248
  return None
258
249
 
259
- def extract_capture_time(self) -> T.Optional[datetime.datetime]:
250
+ def extract_capture_time(self) -> datetime.datetime | None:
260
251
  """
261
252
  Extract capture time from EXIF DateTime tags
262
253
  """
@@ -286,7 +277,7 @@ class ExifToolRead(exif_read.ExifReadABC):
286
277
 
287
278
  return None
288
279
 
289
- def extract_direction(self) -> T.Optional[float]:
280
+ def extract_direction(self) -> float | None:
290
281
  """
291
282
  Extract image direction (i.e. compass, heading, bearing)
292
283
  """
@@ -299,7 +290,7 @@ class ExifToolRead(exif_read.ExifReadABC):
299
290
  float,
300
291
  )
301
292
 
302
- def extract_lon_lat(self) -> T.Optional[T.Tuple[float, float]]:
293
+ def extract_lon_lat(self) -> tuple[float, float] | None:
303
294
  lon_lat = self._extract_lon_lat("GPS:GPSLongitude", "GPS:GPSLatitude")
304
295
  if lon_lat is not None:
305
296
  return lon_lat
@@ -318,7 +309,7 @@ class ExifToolRead(exif_read.ExifReadABC):
318
309
 
319
310
  def _extract_lon_lat(
320
311
  self, lon_tag: str, lat_tag: str
321
- ) -> T.Optional[T.Tuple[float, float]]:
312
+ ) -> tuple[float, float] | None:
322
313
  lon = self._extract_alternative_fields(
323
314
  [lon_tag],
324
315
  float,
@@ -341,7 +332,7 @@ class ExifToolRead(exif_read.ExifReadABC):
341
332
 
342
333
  return lon, lat
343
334
 
344
- def extract_make(self) -> T.Optional[str]:
335
+ def extract_make(self) -> str | None:
345
336
  """
346
337
  Extract camera make
347
338
  """
@@ -360,7 +351,7 @@ class ExifToolRead(exif_read.ExifReadABC):
360
351
  return None
361
352
  return make.strip()
362
353
 
363
- def extract_model(self) -> T.Optional[str]:
354
+ def extract_model(self) -> str | None:
364
355
  """
365
356
  Extract camera model
366
357
  """
@@ -380,7 +371,7 @@ class ExifToolRead(exif_read.ExifReadABC):
380
371
  return None
381
372
  return model.strip()
382
373
 
383
- def extract_width(self) -> T.Optional[int]:
374
+ def extract_width(self) -> int | None:
384
375
  """
385
376
  Extract image width in pixels
386
377
  """
@@ -395,7 +386,7 @@ class ExifToolRead(exif_read.ExifReadABC):
395
386
  int,
396
387
  )
397
388
 
398
- def extract_height(self) -> T.Optional[int]:
389
+ def extract_height(self) -> int | None:
399
390
  """
400
391
  Extract image height in pixels
401
392
  """
@@ -433,8 +424,8 @@ class ExifToolRead(exif_read.ExifReadABC):
433
424
  def _extract_alternative_fields(
434
425
  self,
435
426
  fields: T.Sequence[str],
436
- field_type: T.Type[_FIELD_TYPE],
437
- ) -> T.Optional[_FIELD_TYPE]:
427
+ field_type: type[_FIELD_TYPE],
428
+ ) -> _FIELD_TYPE | None:
438
429
  for field in fields:
439
430
  value = self.etree.findtext(field, namespaces=EXIFTOOL_NAMESPACES)
440
431
  if value is None:
@@ -1,3 +1,5 @@
1
+ from __future__ import annotations
2
+
1
3
  import dataclasses
2
4
  import functools
3
5
  import logging
@@ -9,7 +11,7 @@ from .telemetry import GPSFix, GPSPoint
9
11
 
10
12
 
11
13
  MAX_TRACK_ID = 10
12
- EXIFTOOL_NAMESPACES: T.Dict[str, str] = {
14
+ EXIFTOOL_NAMESPACES: dict[str, str] = {
13
15
  "Keys": "http://ns.exiftool.org/QuickTime/Keys/1.0/",
14
16
  "IFD0": "http://ns.exiftool.org/EXIF/IFD0/1.0/",
15
17
  "QuickTime": "http://ns.exiftool.org/QuickTime/QuickTime/1.0/",
@@ -28,7 +30,7 @@ _FIELD_TYPE = T.TypeVar("_FIELD_TYPE", int, float, str, T.List[str])
28
30
  expand_tag = functools.partial(exiftool_read.expand_tag, namespaces=EXIFTOOL_NAMESPACES)
29
31
 
30
32
 
31
- def _maybe_float(text: T.Optional[str]) -> T.Optional[float]:
33
+ def _maybe_float(text: str | None) -> float | None:
32
34
  if text is None:
33
35
  return None
34
36
  try:
@@ -37,8 +39,8 @@ def _maybe_float(text: T.Optional[str]) -> T.Optional[float]:
37
39
  return None
38
40
 
39
41
 
40
- def _index_text_by_tag(elements: T.Iterable[ET.Element]) -> T.Dict[str, T.List[str]]:
41
- texts_by_tag: T.Dict[str, T.List[str]] = {}
42
+ def _index_text_by_tag(elements: T.Iterable[ET.Element]) -> dict[str, list[str]]:
43
+ texts_by_tag: dict[str, list[str]] = {}
42
44
  for element in elements:
43
45
  tag = element.tag
44
46
  if element.text is not None:
@@ -47,10 +49,10 @@ def _index_text_by_tag(elements: T.Iterable[ET.Element]) -> T.Dict[str, T.List[s
47
49
 
48
50
 
49
51
  def _extract_alternative_fields(
50
- texts_by_tag: T.Dict[str, T.List[str]],
52
+ texts_by_tag: dict[str, list[str]],
51
53
  fields: T.Sequence[str],
52
54
  field_type: T.Type[_FIELD_TYPE],
53
- ) -> T.Optional[_FIELD_TYPE]:
55
+ ) -> _FIELD_TYPE | None:
54
56
  for field in fields:
55
57
  values = texts_by_tag.get(expand_tag(field))
56
58
  if values is None:
@@ -80,15 +82,42 @@ def _extract_alternative_fields(
80
82
  return None
81
83
 
82
84
 
85
+ def _same_gps_point(left: GPSPoint, right: GPSPoint) -> bool:
86
+ """
87
+ >>> left = GPSPoint(time=56.0, lat=36.741385, lon=29.021274, alt=141.6, angle=1.54, epoch_time=None, fix=None, precision=None, ground_speed=None)
88
+ >>> right = GPSPoint(time=56.0, lat=36.741385, lon=29.021274, alt=142.4, angle=1.54, epoch_time=None, fix=None, precision=None, ground_speed=None)
89
+ >>> _same_gps_point(left, right)
90
+ True
91
+ """
92
+ return (
93
+ left.time == right.time
94
+ and left.lon == right.lon
95
+ and left.lat == right.lat
96
+ and left.epoch_time == right.epoch_time
97
+ and left.angle == right.angle
98
+ )
99
+
100
+
101
+ def _deduplicate_gps_points(
102
+ track: list[GPSPoint], same_gps_point: T.Callable[[GPSPoint, GPSPoint], bool]
103
+ ) -> list[GPSPoint]:
104
+ deduplicated_track: list[GPSPoint] = []
105
+ for point in track:
106
+ if not deduplicated_track or not same_gps_point(deduplicated_track[-1], point):
107
+ deduplicated_track.append(point)
108
+ return deduplicated_track
109
+
110
+
83
111
  def _aggregate_gps_track(
84
- texts_by_tag: T.Dict[str, T.List[str]],
85
- time_tag: T.Optional[str],
112
+ texts_by_tag: dict[str, list[str]],
113
+ time_tag: str | None,
86
114
  lon_tag: str,
87
115
  lat_tag: str,
88
- alt_tag: T.Optional[str] = None,
89
- direction_tag: T.Optional[str] = None,
90
- ground_speed_tag: T.Optional[str] = None,
91
- ) -> T.List[GPSPoint]:
116
+ alt_tag: str | None = None,
117
+ gps_time_tag: str | None = None,
118
+ direction_tag: str | None = None,
119
+ ground_speed_tag: str | None = None,
120
+ ) -> list[GPSPoint]:
92
121
  """
93
122
  Aggregate all GPS data by the tags.
94
123
  It requires lat, lon to be present, and their lengths must match.
@@ -139,8 +168,8 @@ def _aggregate_gps_track(
139
168
  assert len(timestamps) == expected_length
140
169
 
141
170
  def _aggregate_float_values_same_length(
142
- tag: T.Optional[str],
143
- ) -> T.List[T.Optional[float]]:
171
+ tag: str | None,
172
+ ) -> list[float | None]:
144
173
  if tag is not None:
145
174
  vals = [
146
175
  _maybe_float(val)
@@ -161,8 +190,17 @@ def _aggregate_gps_track(
161
190
  # aggregate speeds (optional)
162
191
  ground_speeds = _aggregate_float_values_same_length(ground_speed_tag)
163
192
 
193
+ # GPS timestamp (optional)
194
+ epoch_time = None
195
+ if gps_time_tag is not None:
196
+ gps_time_text = _extract_alternative_fields(texts_by_tag, [gps_time_tag], str)
197
+ if gps_time_text is not None:
198
+ dt = exif_read.parse_gps_datetime(gps_time_text)
199
+ if dt is not None:
200
+ epoch_time = geo.as_unix_time(dt)
201
+
164
202
  # build track
165
- track = []
203
+ track: list[GPSPoint] = []
166
204
  for timestamp, lon, lat, alt, direction, ground_speed in zip(
167
205
  timestamps,
168
206
  lons,
@@ -173,22 +211,26 @@ def _aggregate_gps_track(
173
211
  ):
174
212
  if timestamp is None or lon is None or lat is None:
175
213
  continue
176
- track.append(
177
- GPSPoint(
178
- time=timestamp,
179
- lon=lon,
180
- lat=lat,
181
- alt=alt,
182
- angle=direction,
183
- epoch_time=None,
184
- fix=None,
185
- precision=None,
186
- ground_speed=ground_speed,
187
- )
214
+
215
+ point = GPSPoint(
216
+ time=timestamp,
217
+ lon=lon,
218
+ lat=lat,
219
+ alt=alt,
220
+ angle=direction,
221
+ epoch_time=epoch_time,
222
+ fix=None,
223
+ precision=None,
224
+ ground_speed=ground_speed,
188
225
  )
189
226
 
227
+ if not track or not _same_gps_point(track[-1], point):
228
+ track.append(point)
229
+
190
230
  track.sort(key=lambda point: point.time)
191
231
 
232
+ track = _deduplicate_gps_points(track, same_gps_point=_same_gps_point)
233
+
192
234
  if time_tag is not None:
193
235
  if track:
194
236
  first_time = track[0].time
@@ -202,11 +244,11 @@ def _aggregate_samples(
202
244
  elements: T.Iterable[ET.Element],
203
245
  sample_time_tag: str,
204
246
  sample_duration_tag: str,
205
- ) -> T.Generator[T.Tuple[float, float, T.List[ET.Element]], None, None]:
247
+ ) -> T.Generator[tuple[float, float, list[ET.Element]], None, None]:
206
248
  expanded_sample_time_tag = expand_tag(sample_time_tag)
207
249
  expanded_sample_duration_tag = expand_tag(sample_duration_tag)
208
250
 
209
- accumulated_elements: T.List[ET.Element] = []
251
+ accumulated_elements: list[ET.Element] = []
210
252
  sample_time = None
211
253
  sample_duration = None
212
254
  for element in elements:
@@ -224,16 +266,17 @@ def _aggregate_samples(
224
266
 
225
267
 
226
268
  def _aggregate_gps_track_by_sample_time(
227
- sample_iterator: T.Iterable[T.Tuple[float, float, T.List[ET.Element]]],
269
+ sample_iterator: T.Iterable[tuple[float, float, list[ET.Element]]],
228
270
  lon_tag: str,
229
271
  lat_tag: str,
230
- alt_tag: T.Optional[str] = None,
231
- direction_tag: T.Optional[str] = None,
232
- ground_speed_tag: T.Optional[str] = None,
233
- gps_fix_tag: T.Optional[str] = None,
234
- gps_precision_tag: T.Optional[str] = None,
235
- ) -> T.List[GPSPoint]:
236
- track: T.List[GPSPoint] = []
272
+ alt_tag: str | None = None,
273
+ gps_time_tag: str | None = None,
274
+ direction_tag: str | None = None,
275
+ ground_speed_tag: str | None = None,
276
+ gps_fix_tag: str | None = None,
277
+ gps_precision_tag: str | None = None,
278
+ ) -> list[GPSPoint]:
279
+ track: list[GPSPoint] = []
237
280
 
238
281
  expanded_gps_fix_tag = None
239
282
  if gps_fix_tag is not None:
@@ -297,10 +340,13 @@ class ExifToolReadVideo:
297
340
  etree: ET.ElementTree,
298
341
  ) -> None:
299
342
  self.etree = etree
300
- self._texts_by_tag = _index_text_by_tag(self.etree.getroot())
343
+ root = self.etree.getroot()
344
+ if root is None:
345
+ raise ValueError("ElementTree root is None")
346
+ self._texts_by_tag = _index_text_by_tag(root)
301
347
  self._all_tags = set(self._texts_by_tag.keys())
302
348
 
303
- def extract_gps_track(self) -> T.List[geo.Point]:
349
+ def extract_gps_track(self) -> list[geo.Point]:
304
350
  # blackvue and many other cameras
305
351
  track_with_fix = self._extract_gps_track_from_quicktime()
306
352
  if track_with_fix:
@@ -318,7 +364,7 @@ class ExifToolReadVideo:
318
364
 
319
365
  return []
320
366
 
321
- def _extract_make_and_model(self) -> T.Tuple[T.Optional[str], T.Optional[str]]:
367
+ def _extract_make_and_model(self) -> tuple[str | None, str | None]:
322
368
  make = self._extract_alternative_fields(["GoPro:Make"], str)
323
369
  model = self._extract_alternative_fields(["GoPro:Model"], str)
324
370
  if model is not None:
@@ -349,15 +395,19 @@ class ExifToolReadVideo:
349
395
  model = model.strip()
350
396
  return make, model
351
397
 
352
- def extract_make(self) -> T.Optional[str]:
398
+ def extract_make(self) -> str | None:
353
399
  make, _ = self._extract_make_and_model()
354
400
  return make
355
401
 
356
- def extract_model(self) -> T.Optional[str]:
402
+ def extract_model(self) -> str | None:
357
403
  _, model = self._extract_make_and_model()
358
404
  return model
359
405
 
360
- def _extract_gps_track_from_track(self) -> T.List[GPSPoint]:
406
+ def _extract_gps_track_from_track(self) -> list[GPSPoint]:
407
+ root = self.etree.getroot()
408
+ if root is None:
409
+ raise ValueError("ElementTree root is None")
410
+
361
411
  for track_id in range(1, MAX_TRACK_ID + 1):
362
412
  track_ns = f"Track{track_id}"
363
413
  if self._all_tags_exists(
@@ -369,7 +419,7 @@ class ExifToolReadVideo:
369
419
  }
370
420
  ):
371
421
  sample_iterator = _aggregate_samples(
372
- self.etree.getroot(),
422
+ root,
373
423
  f"{track_ns}:SampleTime",
374
424
  f"{track_ns}:SampleDuration",
375
425
  )
@@ -391,15 +441,15 @@ class ExifToolReadVideo:
391
441
  self,
392
442
  fields: T.Sequence[str],
393
443
  field_type: T.Type[_FIELD_TYPE],
394
- ) -> T.Optional[_FIELD_TYPE]:
444
+ ) -> _FIELD_TYPE | None:
395
445
  return _extract_alternative_fields(self._texts_by_tag, fields, field_type)
396
446
 
397
- def _all_tags_exists(self, tags: T.Set[str]) -> bool:
447
+ def _all_tags_exists(self, tags: set[str]) -> bool:
398
448
  return self._all_tags.issuperset(tags)
399
449
 
400
450
  def _extract_gps_track_from_quicktime(
401
451
  self, namespace: str = "QuickTime"
402
- ) -> T.List[GPSPoint]:
452
+ ) -> list[GPSPoint]:
403
453
  if not self._all_tags_exists(
404
454
  {
405
455
  expand_tag(f"{namespace}:GPSDateTime"),
@@ -0,0 +1,57 @@
1
+ from __future__ import annotations
2
+
3
+ import subprocess
4
+ import typing as T
5
+ from pathlib import Path
6
+
7
+
8
+ class ExiftoolRunner:
9
+ """
10
+ Wrapper around ExifTool to run it in a subprocess
11
+ """
12
+
13
+ def __init__(self, exiftool_executable: str = "exiftool", recursive: bool = False):
14
+ self.exiftool_executable = exiftool_executable
15
+ self.recursive = recursive
16
+
17
+ def _build_args_read_stdin(self) -> list[str]:
18
+ args: list[str] = [
19
+ self.exiftool_executable,
20
+ "-fast",
21
+ "-q",
22
+ "-n", # Disable print conversion
23
+ "-X", # XML output
24
+ "-ee",
25
+ *["-api", "LargeFileSupport=1"],
26
+ *["-charset", "filename=utf8"],
27
+ *["-@", "-"],
28
+ ]
29
+
30
+ if self.recursive:
31
+ args.append("-r")
32
+
33
+ return args
34
+
35
+ def extract_xml(self, paths: T.Sequence[Path]) -> str:
36
+ if not paths:
37
+ # ExifTool will show its full manual if no files are provided
38
+ raise ValueError("No files provided to exiftool")
39
+
40
+ # To handle non-latin1 filenames under Windows, we pass the path
41
+ # via stdin. See https://exiftool.org/faq.html#Q18
42
+ stdin = "\n".join([str(p.resolve()) for p in paths])
43
+
44
+ args = self._build_args_read_stdin()
45
+
46
+ # Raise FileNotFoundError here if self.exiftool_path not found
47
+ process = subprocess.run(
48
+ args,
49
+ capture_output=True,
50
+ text=True,
51
+ input=stdin,
52
+ encoding="utf-8",
53
+ # Do not check exit status to allow some files not found
54
+ # check=True,
55
+ )
56
+
57
+ return process.stdout