mapillary-tools 0.13.3a1__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 (83) hide show
  1. mapillary_tools/__init__.py +1 -1
  2. mapillary_tools/api_v4.py +237 -16
  3. mapillary_tools/authenticate.py +325 -64
  4. mapillary_tools/{geotag/blackvue_parser.py → blackvue_parser.py} +74 -54
  5. mapillary_tools/camm/camm_builder.py +55 -97
  6. mapillary_tools/camm/camm_parser.py +429 -181
  7. mapillary_tools/commands/__main__.py +12 -6
  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 +18 -9
  13. mapillary_tools/commands/video_process_and_upload.py +19 -5
  14. mapillary_tools/config.py +31 -13
  15. mapillary_tools/constants.py +47 -6
  16. mapillary_tools/exceptions.py +34 -35
  17. mapillary_tools/exif_read.py +221 -116
  18. mapillary_tools/exif_write.py +7 -7
  19. mapillary_tools/exiftool_read.py +33 -42
  20. mapillary_tools/exiftool_read_video.py +46 -33
  21. mapillary_tools/exiftool_runner.py +77 -0
  22. mapillary_tools/ffmpeg.py +24 -23
  23. mapillary_tools/geo.py +144 -120
  24. mapillary_tools/geotag/base.py +147 -0
  25. mapillary_tools/geotag/factory.py +291 -0
  26. mapillary_tools/geotag/geotag_images_from_exif.py +14 -131
  27. mapillary_tools/geotag/geotag_images_from_exiftool.py +126 -82
  28. mapillary_tools/geotag/geotag_images_from_gpx.py +53 -118
  29. mapillary_tools/geotag/geotag_images_from_gpx_file.py +13 -126
  30. mapillary_tools/geotag/geotag_images_from_nmea_file.py +4 -5
  31. mapillary_tools/geotag/geotag_images_from_video.py +53 -51
  32. mapillary_tools/geotag/geotag_videos_from_exiftool.py +97 -0
  33. mapillary_tools/geotag/geotag_videos_from_gpx.py +39 -0
  34. mapillary_tools/geotag/geotag_videos_from_video.py +20 -185
  35. mapillary_tools/geotag/image_extractors/base.py +18 -0
  36. mapillary_tools/geotag/image_extractors/exif.py +60 -0
  37. mapillary_tools/geotag/image_extractors/exiftool.py +18 -0
  38. mapillary_tools/geotag/options.py +160 -0
  39. mapillary_tools/geotag/utils.py +52 -16
  40. mapillary_tools/geotag/video_extractors/base.py +18 -0
  41. mapillary_tools/geotag/video_extractors/exiftool.py +70 -0
  42. mapillary_tools/{video_data_extraction/extractors/gpx_parser.py → geotag/video_extractors/gpx.py} +57 -39
  43. mapillary_tools/geotag/video_extractors/native.py +157 -0
  44. mapillary_tools/{geotag → gpmf}/gpmf_parser.py +205 -182
  45. mapillary_tools/{geotag → gpmf}/gps_filter.py +5 -3
  46. mapillary_tools/history.py +7 -13
  47. mapillary_tools/mp4/construct_mp4_parser.py +9 -8
  48. mapillary_tools/mp4/io_utils.py +0 -1
  49. mapillary_tools/mp4/mp4_sample_parser.py +36 -28
  50. mapillary_tools/mp4/simple_mp4_builder.py +10 -9
  51. mapillary_tools/mp4/simple_mp4_parser.py +13 -22
  52. mapillary_tools/process_geotag_properties.py +155 -392
  53. mapillary_tools/process_sequence_properties.py +562 -208
  54. mapillary_tools/sample_video.py +13 -20
  55. mapillary_tools/telemetry.py +26 -13
  56. mapillary_tools/types.py +111 -58
  57. mapillary_tools/upload.py +316 -298
  58. mapillary_tools/upload_api_v4.py +55 -122
  59. mapillary_tools/uploader.py +396 -254
  60. mapillary_tools/utils.py +42 -18
  61. {mapillary_tools-0.13.3a1.dist-info → mapillary_tools-0.14.0a2.dist-info}/METADATA +3 -2
  62. mapillary_tools-0.14.0a2.dist-info/RECORD +72 -0
  63. {mapillary_tools-0.13.3a1.dist-info → mapillary_tools-0.14.0a2.dist-info}/WHEEL +1 -1
  64. mapillary_tools/geotag/__init__.py +0 -1
  65. mapillary_tools/geotag/geotag_from_generic.py +0 -22
  66. mapillary_tools/geotag/geotag_images_from_exiftool_both_image_and_video.py +0 -93
  67. mapillary_tools/geotag/geotag_videos_from_exiftool_video.py +0 -145
  68. mapillary_tools/video_data_extraction/cli_options.py +0 -22
  69. mapillary_tools/video_data_extraction/extract_video_data.py +0 -176
  70. mapillary_tools/video_data_extraction/extractors/base_parser.py +0 -75
  71. mapillary_tools/video_data_extraction/extractors/blackvue_parser.py +0 -34
  72. mapillary_tools/video_data_extraction/extractors/camm_parser.py +0 -38
  73. mapillary_tools/video_data_extraction/extractors/exiftool_runtime_parser.py +0 -71
  74. mapillary_tools/video_data_extraction/extractors/exiftool_xml_parser.py +0 -53
  75. mapillary_tools/video_data_extraction/extractors/generic_video_parser.py +0 -52
  76. mapillary_tools/video_data_extraction/extractors/gopro_parser.py +0 -43
  77. mapillary_tools/video_data_extraction/extractors/nmea_parser.py +0 -24
  78. mapillary_tools/video_data_extraction/video_data_parser_factory.py +0 -39
  79. mapillary_tools-0.13.3a1.dist-info/RECORD +0 -75
  80. /mapillary_tools/{geotag → gpmf}/gpmf_gps_filter.py +0 -0
  81. {mapillary_tools-0.13.3a1.dist-info → mapillary_tools-0.14.0a2.dist-info}/entry_points.txt +0 -0
  82. {mapillary_tools-0.13.3a1.dist-info → mapillary_tools-0.14.0a2.dist-info/licenses}/LICENSE +0 -0
  83. {mapillary_tools-0.13.3a1.dist-info → mapillary_tools-0.14.0a2.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,7 +40,7 @@ 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
45
  self._ef["0th"][piexif.ImageIFD.ImageDescription] = json.dumps(data)
46
46
 
@@ -201,7 +201,7 @@ class ExifEdit:
201
201
  piexif.insert(exif_bytes, self._filename_or_bytes, output)
202
202
  return output.read()
203
203
 
204
- def write(self, filename: T.Optional[Path] = None) -> None:
204
+ def write(self, filename: Path | None = None) -> None:
205
205
  """Save exif data to file."""
206
206
  if filename is None:
207
207
  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:
@@ -81,14 +83,15 @@ def _extract_alternative_fields(
81
83
 
82
84
 
83
85
  def _aggregate_gps_track(
84
- texts_by_tag: T.Dict[str, T.List[str]],
85
- time_tag: T.Optional[str],
86
+ texts_by_tag: dict[str, list[str]],
87
+ time_tag: str | None,
86
88
  lon_tag: str,
87
89
  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]:
90
+ alt_tag: str | None = None,
91
+ gps_time_tag: str | None = None,
92
+ direction_tag: str | None = None,
93
+ ground_speed_tag: str | None = None,
94
+ ) -> list[GPSPoint]:
92
95
  """
93
96
  Aggregate all GPS data by the tags.
94
97
  It requires lat, lon to be present, and their lengths must match.
@@ -139,8 +142,8 @@ def _aggregate_gps_track(
139
142
  assert len(timestamps) == expected_length
140
143
 
141
144
  def _aggregate_float_values_same_length(
142
- tag: T.Optional[str],
143
- ) -> T.List[T.Optional[float]]:
145
+ tag: str | None,
146
+ ) -> list[float | None]:
144
147
  if tag is not None:
145
148
  vals = [
146
149
  _maybe_float(val)
@@ -161,6 +164,15 @@ def _aggregate_gps_track(
161
164
  # aggregate speeds (optional)
162
165
  ground_speeds = _aggregate_float_values_same_length(ground_speed_tag)
163
166
 
167
+ # GPS timestamp (optional)
168
+ epoch_time = None
169
+ if gps_time_tag is not None:
170
+ gps_time_text = _extract_alternative_fields(texts_by_tag, [gps_time_tag], str)
171
+ if gps_time_text is not None:
172
+ dt = exif_read.parse_gps_datetime(gps_time_text)
173
+ if dt is not None:
174
+ epoch_time = geo.as_unix_time(dt)
175
+
164
176
  # build track
165
177
  track = []
166
178
  for timestamp, lon, lat, alt, direction, ground_speed in zip(
@@ -180,7 +192,7 @@ def _aggregate_gps_track(
180
192
  lat=lat,
181
193
  alt=alt,
182
194
  angle=direction,
183
- epoch_time=None,
195
+ epoch_time=epoch_time,
184
196
  fix=None,
185
197
  precision=None,
186
198
  ground_speed=ground_speed,
@@ -202,11 +214,11 @@ def _aggregate_samples(
202
214
  elements: T.Iterable[ET.Element],
203
215
  sample_time_tag: str,
204
216
  sample_duration_tag: str,
205
- ) -> T.Generator[T.Tuple[float, float, T.List[ET.Element]], None, None]:
217
+ ) -> T.Generator[tuple[float, float, list[ET.Element]], None, None]:
206
218
  expanded_sample_time_tag = expand_tag(sample_time_tag)
207
219
  expanded_sample_duration_tag = expand_tag(sample_duration_tag)
208
220
 
209
- accumulated_elements: T.List[ET.Element] = []
221
+ accumulated_elements: list[ET.Element] = []
210
222
  sample_time = None
211
223
  sample_duration = None
212
224
  for element in elements:
@@ -224,16 +236,17 @@ def _aggregate_samples(
224
236
 
225
237
 
226
238
  def _aggregate_gps_track_by_sample_time(
227
- sample_iterator: T.Iterable[T.Tuple[float, float, T.List[ET.Element]]],
239
+ sample_iterator: T.Iterable[tuple[float, float, list[ET.Element]]],
228
240
  lon_tag: str,
229
241
  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] = []
242
+ alt_tag: str | None = None,
243
+ gps_time_tag: str | None = None,
244
+ direction_tag: str | None = None,
245
+ ground_speed_tag: str | None = None,
246
+ gps_fix_tag: str | None = None,
247
+ gps_precision_tag: str | None = None,
248
+ ) -> list[GPSPoint]:
249
+ track: list[GPSPoint] = []
237
250
 
238
251
  expanded_gps_fix_tag = None
239
252
  if gps_fix_tag is not None:
@@ -300,7 +313,7 @@ class ExifToolReadVideo:
300
313
  self._texts_by_tag = _index_text_by_tag(self.etree.getroot())
301
314
  self._all_tags = set(self._texts_by_tag.keys())
302
315
 
303
- def extract_gps_track(self) -> T.List[geo.Point]:
316
+ def extract_gps_track(self) -> list[geo.Point]:
304
317
  # blackvue and many other cameras
305
318
  track_with_fix = self._extract_gps_track_from_quicktime()
306
319
  if track_with_fix:
@@ -318,7 +331,7 @@ class ExifToolReadVideo:
318
331
 
319
332
  return []
320
333
 
321
- def _extract_make_and_model(self) -> T.Tuple[T.Optional[str], T.Optional[str]]:
334
+ def _extract_make_and_model(self) -> tuple[str | None, str | None]:
322
335
  make = self._extract_alternative_fields(["GoPro:Make"], str)
323
336
  model = self._extract_alternative_fields(["GoPro:Model"], str)
324
337
  if model is not None:
@@ -349,15 +362,15 @@ class ExifToolReadVideo:
349
362
  model = model.strip()
350
363
  return make, model
351
364
 
352
- def extract_make(self) -> T.Optional[str]:
365
+ def extract_make(self) -> str | None:
353
366
  make, _ = self._extract_make_and_model()
354
367
  return make
355
368
 
356
- def extract_model(self) -> T.Optional[str]:
369
+ def extract_model(self) -> str | None:
357
370
  _, model = self._extract_make_and_model()
358
371
  return model
359
372
 
360
- def _extract_gps_track_from_track(self) -> T.List[GPSPoint]:
373
+ def _extract_gps_track_from_track(self) -> list[GPSPoint]:
361
374
  for track_id in range(1, MAX_TRACK_ID + 1):
362
375
  track_ns = f"Track{track_id}"
363
376
  if self._all_tags_exists(
@@ -391,15 +404,15 @@ class ExifToolReadVideo:
391
404
  self,
392
405
  fields: T.Sequence[str],
393
406
  field_type: T.Type[_FIELD_TYPE],
394
- ) -> T.Optional[_FIELD_TYPE]:
407
+ ) -> _FIELD_TYPE | None:
395
408
  return _extract_alternative_fields(self._texts_by_tag, fields, field_type)
396
409
 
397
- def _all_tags_exists(self, tags: T.Set[str]) -> bool:
410
+ def _all_tags_exists(self, tags: set[str]) -> bool:
398
411
  return self._all_tags.issuperset(tags)
399
412
 
400
413
  def _extract_gps_track_from_quicktime(
401
414
  self, namespace: str = "QuickTime"
402
- ) -> T.List[GPSPoint]:
415
+ ) -> list[GPSPoint]:
403
416
  if not self._all_tags_exists(
404
417
  {
405
418
  expand_tag(f"{namespace}:GPSDateTime"),
@@ -0,0 +1,77 @@
1
+ from __future__ import annotations
2
+
3
+ import platform
4
+ import shutil
5
+ import subprocess
6
+ import typing as T
7
+ from pathlib import Path
8
+
9
+
10
+ class ExiftoolRunner:
11
+ """
12
+ Wrapper around ExifTool to run it in a subprocess
13
+ """
14
+
15
+ def __init__(self, exiftool_path: str | None = None, recursive: bool = False):
16
+ if exiftool_path is None:
17
+ exiftool_path = self._search_preferred_exiftool_path()
18
+ self.exiftool_path = exiftool_path
19
+ self.recursive = recursive
20
+
21
+ def _search_preferred_exiftool_path(self) -> str:
22
+ system = platform.system()
23
+
24
+ if system and system.lower() == "windows":
25
+ exiftool_paths = ["exiftool.exe", "exiftool"]
26
+ else:
27
+ exiftool_paths = ["exiftool", "exiftool.exe"]
28
+
29
+ for path in exiftool_paths:
30
+ full_path = shutil.which(path)
31
+ if full_path:
32
+ return path
33
+
34
+ # Always return the prefered one, even if it is not found,
35
+ # and let the subprocess.run figure out the error later
36
+ return exiftool_paths[0]
37
+
38
+ def _build_args_read_stdin(self) -> list[str]:
39
+ args: list[str] = [
40
+ self.exiftool_path,
41
+ "-q",
42
+ "-n", # Disable print conversion
43
+ "-X", # XML output
44
+ "-ee",
45
+ *["-api", "LargeFileSupport=1"],
46
+ *["-charset", "filename=utf8"],
47
+ *["-@", "-"],
48
+ ]
49
+
50
+ if self.recursive:
51
+ args.append("-r")
52
+
53
+ return args
54
+
55
+ def extract_xml(self, paths: T.Sequence[Path]) -> str:
56
+ if not paths:
57
+ # ExifTool will show its full manual if no files are provided
58
+ raise ValueError("No files provided to exiftool")
59
+
60
+ # To handle non-latin1 filenames under Windows, we pass the path
61
+ # via stdin. See https://exiftool.org/faq.html#Q18
62
+ stdin = "\n".join([str(p.resolve()) for p in paths])
63
+
64
+ args = self._build_args_read_stdin()
65
+
66
+ # Raise FileNotFoundError here if self.exiftool_path not found
67
+ process = subprocess.run(
68
+ args,
69
+ capture_output=True,
70
+ text=True,
71
+ input=stdin,
72
+ encoding="utf-8",
73
+ # Do not check exit status to allow some files not found
74
+ # check=True,
75
+ )
76
+
77
+ return process.stdout