mapillary-tools 0.14.0a1__py3-none-any.whl → 0.14.0b1__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 (76) hide show
  1. mapillary_tools/__init__.py +1 -1
  2. mapillary_tools/api_v4.py +5 -4
  3. mapillary_tools/authenticate.py +9 -9
  4. mapillary_tools/blackvue_parser.py +79 -22
  5. mapillary_tools/camm/camm_parser.py +5 -5
  6. mapillary_tools/commands/__main__.py +1 -2
  7. mapillary_tools/config.py +41 -18
  8. mapillary_tools/constants.py +3 -2
  9. mapillary_tools/exceptions.py +1 -1
  10. mapillary_tools/exif_read.py +65 -65
  11. mapillary_tools/exif_write.py +7 -7
  12. mapillary_tools/exiftool_read.py +23 -46
  13. mapillary_tools/exiftool_read_video.py +88 -49
  14. mapillary_tools/exiftool_runner.py +4 -24
  15. mapillary_tools/ffmpeg.py +417 -242
  16. mapillary_tools/geo.py +4 -21
  17. mapillary_tools/geotag/__init__.py +0 -1
  18. mapillary_tools/geotag/{geotag_from_generic.py → base.py} +34 -50
  19. mapillary_tools/geotag/factory.py +105 -103
  20. mapillary_tools/geotag/geotag_images_from_exif.py +15 -51
  21. mapillary_tools/geotag/geotag_images_from_exiftool.py +118 -63
  22. mapillary_tools/geotag/geotag_images_from_gpx.py +33 -16
  23. mapillary_tools/geotag/geotag_images_from_gpx_file.py +2 -34
  24. mapillary_tools/geotag/geotag_images_from_nmea_file.py +0 -3
  25. mapillary_tools/geotag/geotag_images_from_video.py +51 -14
  26. mapillary_tools/geotag/geotag_videos_from_exiftool.py +123 -0
  27. mapillary_tools/geotag/geotag_videos_from_gpx.py +35 -123
  28. mapillary_tools/geotag/geotag_videos_from_video.py +14 -147
  29. mapillary_tools/geotag/image_extractors/base.py +18 -0
  30. mapillary_tools/geotag/image_extractors/exif.py +60 -0
  31. mapillary_tools/geotag/image_extractors/exiftool.py +18 -0
  32. mapillary_tools/geotag/options.py +26 -3
  33. mapillary_tools/geotag/utils.py +62 -0
  34. mapillary_tools/geotag/video_extractors/base.py +18 -0
  35. mapillary_tools/geotag/video_extractors/exiftool.py +70 -0
  36. mapillary_tools/geotag/video_extractors/gpx.py +116 -0
  37. mapillary_tools/geotag/video_extractors/native.py +135 -0
  38. mapillary_tools/gpmf/gpmf_parser.py +16 -16
  39. mapillary_tools/gpmf/gps_filter.py +5 -3
  40. mapillary_tools/history.py +8 -3
  41. mapillary_tools/mp4/construct_mp4_parser.py +9 -8
  42. mapillary_tools/mp4/mp4_sample_parser.py +27 -27
  43. mapillary_tools/mp4/simple_mp4_builder.py +10 -9
  44. mapillary_tools/mp4/simple_mp4_parser.py +13 -12
  45. mapillary_tools/process_geotag_properties.py +21 -15
  46. mapillary_tools/process_sequence_properties.py +49 -49
  47. mapillary_tools/sample_video.py +15 -14
  48. mapillary_tools/serializer/description.py +587 -0
  49. mapillary_tools/serializer/gpx.py +132 -0
  50. mapillary_tools/telemetry.py +6 -5
  51. mapillary_tools/types.py +64 -635
  52. mapillary_tools/upload.py +176 -197
  53. mapillary_tools/upload_api_v4.py +94 -51
  54. mapillary_tools/uploader.py +284 -138
  55. mapillary_tools/utils.py +16 -18
  56. {mapillary_tools-0.14.0a1.dist-info → mapillary_tools-0.14.0b1.dist-info}/METADATA +87 -31
  57. mapillary_tools-0.14.0b1.dist-info/RECORD +75 -0
  58. {mapillary_tools-0.14.0a1.dist-info → mapillary_tools-0.14.0b1.dist-info}/WHEEL +1 -1
  59. mapillary_tools/geotag/geotag_images_from_exiftool_both_image_and_video.py +0 -77
  60. mapillary_tools/geotag/geotag_videos_from_exiftool_video.py +0 -151
  61. mapillary_tools/video_data_extraction/cli_options.py +0 -22
  62. mapillary_tools/video_data_extraction/extract_video_data.py +0 -157
  63. mapillary_tools/video_data_extraction/extractors/base_parser.py +0 -75
  64. mapillary_tools/video_data_extraction/extractors/blackvue_parser.py +0 -49
  65. mapillary_tools/video_data_extraction/extractors/camm_parser.py +0 -62
  66. mapillary_tools/video_data_extraction/extractors/exiftool_runtime_parser.py +0 -74
  67. mapillary_tools/video_data_extraction/extractors/exiftool_xml_parser.py +0 -52
  68. mapillary_tools/video_data_extraction/extractors/generic_video_parser.py +0 -52
  69. mapillary_tools/video_data_extraction/extractors/gopro_parser.py +0 -58
  70. mapillary_tools/video_data_extraction/extractors/gpx_parser.py +0 -108
  71. mapillary_tools/video_data_extraction/extractors/nmea_parser.py +0 -24
  72. mapillary_tools/video_data_extraction/video_data_parser_factory.py +0 -39
  73. mapillary_tools-0.14.0a1.dist-info/RECORD +0 -78
  74. {mapillary_tools-0.14.0a1.dist-info → mapillary_tools-0.14.0b1.dist-info}/entry_points.txt +0 -0
  75. {mapillary_tools-0.14.0a1.dist-info → mapillary_tools-0.14.0b1.dist-info}/licenses/LICENSE +0 -0
  76. {mapillary_tools-0.14.0a1.dist-info → mapillary_tools-0.14.0b1.dist-info}/top_level.txt +0 -0
@@ -6,10 +6,10 @@ import typing as T
6
6
  import xml.etree.ElementTree as ET
7
7
  from pathlib import Path
8
8
 
9
- from . import exif_read, utils
9
+ from . import exif_read
10
10
 
11
11
 
12
- EXIFTOOL_NAMESPACES: T.Dict[str, str] = {
12
+ EXIFTOOL_NAMESPACES: dict[str, str] = {
13
13
  "Adobe": "http://ns.exiftool.org/APP14/Adobe/1.0/",
14
14
  "Apple": "http://ns.exiftool.org/MakerNotes/Apple/1.0/",
15
15
  "Composite": "http://ns.exiftool.org/Composite/1.0/",
@@ -53,11 +53,11 @@ EXIFTOOL_NAMESPACES: T.Dict[str, str] = {
53
53
 
54
54
 
55
55
  LOG = logging.getLogger(__name__)
56
+ DESCRIPTION_TAG = "rdf:Description"
56
57
  _FIELD_TYPE = T.TypeVar("_FIELD_TYPE", int, float, str)
57
- _DESCRIPTION_TAG = "rdf:Description"
58
58
 
59
59
 
60
- def expand_tag(ns_tag: str, namespaces: T.Dict[str, str]) -> str:
60
+ def expand_tag(ns_tag: str, namespaces: dict[str, str]) -> str:
61
61
  try:
62
62
  ns, tag = ns_tag.split(":", maxsplit=2)
63
63
  except ValueError:
@@ -72,42 +72,19 @@ def canonical_path(path: Path) -> str:
72
72
  return str(path.resolve().as_posix())
73
73
 
74
74
 
75
- def find_rdf_description_path(element: ET.Element) -> T.Optional[Path]:
75
+ def find_rdf_description_path(element: ET.Element) -> Path | None:
76
76
  about = element.get(_EXPANDED_ABOUT_TAG)
77
77
  if about is None:
78
78
  return None
79
79
  return Path(about)
80
80
 
81
81
 
82
- def index_rdf_description_by_path(
83
- xml_paths: T.Sequence[Path],
84
- ) -> T.Dict[str, ET.Element]:
85
- rdf_description_by_path: T.Dict[str, ET.Element] = {}
86
-
87
- for xml_path in utils.find_xml_files(xml_paths):
88
- try:
89
- etree = ET.parse(xml_path)
90
- except ET.ParseError as ex:
91
- verbose = LOG.getEffectiveLevel() <= logging.DEBUG
92
- if verbose:
93
- LOG.warning(f"Failed to parse {xml_path}", exc_info=verbose)
94
- else:
95
- LOG.warning(f"Failed to parse {xml_path}: {ex}", exc_info=verbose)
96
- continue
97
-
98
- rdf_description_by_path.update(
99
- index_rdf_description_by_path_from_xml_element(etree.getroot())
100
- )
101
-
102
- return rdf_description_by_path
103
-
104
-
105
82
  def index_rdf_description_by_path_from_xml_element(
106
83
  element: ET.Element,
107
84
  ) -> dict[str, ET.Element]:
108
85
  rdf_description_by_path: dict[str, ET.Element] = {}
109
86
 
110
- elements = element.iterfind(_DESCRIPTION_TAG, namespaces=EXIFTOOL_NAMESPACES)
87
+ elements = element.iterfind(DESCRIPTION_TAG, namespaces=EXIFTOOL_NAMESPACES)
111
88
  for element in elements:
112
89
  path = find_rdf_description_path(element)
113
90
  if path is not None:
@@ -127,7 +104,7 @@ class ExifToolRead(exif_read.ExifReadABC):
127
104
  ) -> None:
128
105
  self.etree = etree
129
106
 
130
- def extract_altitude(self) -> T.Optional[float]:
107
+ def extract_altitude(self) -> float | None:
131
108
  """
132
109
  Extract altitude
133
110
  """
@@ -143,7 +120,7 @@ class ExifToolRead(exif_read.ExifReadABC):
143
120
 
144
121
  def _extract_gps_datetime(
145
122
  self, date_tags: T.Sequence[str], time_tags: T.Sequence[str]
146
- ) -> T.Optional[datetime.datetime]:
123
+ ) -> datetime.datetime | None:
147
124
  """
148
125
  Extract timestamp from GPS field.
149
126
  """
@@ -157,13 +134,13 @@ class ExifToolRead(exif_read.ExifReadABC):
157
134
 
158
135
  return exif_read.parse_gps_datetime_separately(gpsdate, gpstimestamp)
159
136
 
160
- def extract_gps_datetime(self) -> T.Optional[datetime.datetime]:
137
+ def extract_gps_datetime(self) -> datetime.datetime | None:
161
138
  """
162
139
  Extract timestamp from GPS field.
163
140
  """
164
141
  return self._extract_gps_datetime(["GPS:GPSDateStamp"], ["GPS:GPSTimeStamp"])
165
142
 
166
- def extract_gps_datetime_from_xmp(self) -> T.Optional[datetime.datetime]:
143
+ def extract_gps_datetime_from_xmp(self) -> datetime.datetime | None:
167
144
  """
168
145
  Extract timestamp from XMP GPS field.
169
146
  """
@@ -180,7 +157,7 @@ class ExifToolRead(exif_read.ExifReadABC):
180
157
  dt_tags: T.Sequence[str],
181
158
  subsec_tags: T.Sequence[str],
182
159
  offset_tags: T.Sequence[str],
183
- ) -> T.Optional[datetime.datetime]:
160
+ ) -> datetime.datetime | None:
184
161
  dtstr = self._extract_alternative_fields(dt_tags, str)
185
162
  if dtstr is None:
186
163
  return None
@@ -195,7 +172,7 @@ class ExifToolRead(exif_read.ExifReadABC):
195
172
  return None
196
173
  return dt
197
174
 
198
- def extract_exif_datetime_from_xmp(self) -> T.Optional[datetime.datetime]:
175
+ def extract_exif_datetime_from_xmp(self) -> datetime.datetime | None:
199
176
  # EXIF DateTimeOriginal: 0x9003 (date/time when original image was taken)
200
177
  # EXIF SubSecTimeOriginal: 0x9291 (fractional seconds for DateTimeOriginal)
201
178
  # EXIF OffsetTimeOriginal: 0x9011 (time zone for DateTimeOriginal)
@@ -234,7 +211,7 @@ class ExifToolRead(exif_read.ExifReadABC):
234
211
 
235
212
  return None
236
213
 
237
- def extract_exif_datetime(self) -> T.Optional[datetime.datetime]:
214
+ def extract_exif_datetime(self) -> datetime.datetime | None:
238
215
  # EXIF DateTimeOriginal: 0x9003 (date/time when original image was taken)
239
216
  # EXIF SubSecTimeOriginal: 0x9291 (fractional seconds for DateTimeOriginal)
240
217
  # EXIF OffsetTimeOriginal: 0x9011 (time zone for DateTimeOriginal)
@@ -270,7 +247,7 @@ class ExifToolRead(exif_read.ExifReadABC):
270
247
 
271
248
  return None
272
249
 
273
- def extract_capture_time(self) -> T.Optional[datetime.datetime]:
250
+ def extract_capture_time(self) -> datetime.datetime | None:
274
251
  """
275
252
  Extract capture time from EXIF DateTime tags
276
253
  """
@@ -300,7 +277,7 @@ class ExifToolRead(exif_read.ExifReadABC):
300
277
 
301
278
  return None
302
279
 
303
- def extract_direction(self) -> T.Optional[float]:
280
+ def extract_direction(self) -> float | None:
304
281
  """
305
282
  Extract image direction (i.e. compass, heading, bearing)
306
283
  """
@@ -313,7 +290,7 @@ class ExifToolRead(exif_read.ExifReadABC):
313
290
  float,
314
291
  )
315
292
 
316
- def extract_lon_lat(self) -> T.Optional[T.Tuple[float, float]]:
293
+ def extract_lon_lat(self) -> tuple[float, float] | None:
317
294
  lon_lat = self._extract_lon_lat("GPS:GPSLongitude", "GPS:GPSLatitude")
318
295
  if lon_lat is not None:
319
296
  return lon_lat
@@ -332,7 +309,7 @@ class ExifToolRead(exif_read.ExifReadABC):
332
309
 
333
310
  def _extract_lon_lat(
334
311
  self, lon_tag: str, lat_tag: str
335
- ) -> T.Optional[T.Tuple[float, float]]:
312
+ ) -> tuple[float, float] | None:
336
313
  lon = self._extract_alternative_fields(
337
314
  [lon_tag],
338
315
  float,
@@ -355,7 +332,7 @@ class ExifToolRead(exif_read.ExifReadABC):
355
332
 
356
333
  return lon, lat
357
334
 
358
- def extract_make(self) -> T.Optional[str]:
335
+ def extract_make(self) -> str | None:
359
336
  """
360
337
  Extract camera make
361
338
  """
@@ -374,7 +351,7 @@ class ExifToolRead(exif_read.ExifReadABC):
374
351
  return None
375
352
  return make.strip()
376
353
 
377
- def extract_model(self) -> T.Optional[str]:
354
+ def extract_model(self) -> str | None:
378
355
  """
379
356
  Extract camera model
380
357
  """
@@ -394,7 +371,7 @@ class ExifToolRead(exif_read.ExifReadABC):
394
371
  return None
395
372
  return model.strip()
396
373
 
397
- def extract_width(self) -> T.Optional[int]:
374
+ def extract_width(self) -> int | None:
398
375
  """
399
376
  Extract image width in pixels
400
377
  """
@@ -409,7 +386,7 @@ class ExifToolRead(exif_read.ExifReadABC):
409
386
  int,
410
387
  )
411
388
 
412
- def extract_height(self) -> T.Optional[int]:
389
+ def extract_height(self) -> int | None:
413
390
  """
414
391
  Extract image height in pixels
415
392
  """
@@ -447,8 +424,8 @@ class ExifToolRead(exif_read.ExifReadABC):
447
424
  def _extract_alternative_fields(
448
425
  self,
449
426
  fields: T.Sequence[str],
450
- field_type: T.Type[_FIELD_TYPE],
451
- ) -> T.Optional[_FIELD_TYPE]:
427
+ field_type: type[_FIELD_TYPE],
428
+ ) -> _FIELD_TYPE | None:
452
429
  for field in fields:
453
430
  value = self.etree.findtext(field, namespaces=EXIFTOOL_NAMESPACES)
454
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,16 +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
- gps_time_tag: T.Optional[str] = None,
90
- direction_tag: T.Optional[str] = None,
91
- ground_speed_tag: T.Optional[str] = None,
92
- ) -> 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]:
93
121
  """
94
122
  Aggregate all GPS data by the tags.
95
123
  It requires lat, lon to be present, and their lengths must match.
@@ -140,8 +168,8 @@ def _aggregate_gps_track(
140
168
  assert len(timestamps) == expected_length
141
169
 
142
170
  def _aggregate_float_values_same_length(
143
- tag: T.Optional[str],
144
- ) -> T.List[T.Optional[float]]:
171
+ tag: str | None,
172
+ ) -> list[float | None]:
145
173
  if tag is not None:
146
174
  vals = [
147
175
  _maybe_float(val)
@@ -172,7 +200,7 @@ def _aggregate_gps_track(
172
200
  epoch_time = geo.as_unix_time(dt)
173
201
 
174
202
  # build track
175
- track = []
203
+ track: list[GPSPoint] = []
176
204
  for timestamp, lon, lat, alt, direction, ground_speed in zip(
177
205
  timestamps,
178
206
  lons,
@@ -183,22 +211,26 @@ def _aggregate_gps_track(
183
211
  ):
184
212
  if timestamp is None or lon is None or lat is None:
185
213
  continue
186
- track.append(
187
- GPSPoint(
188
- time=timestamp,
189
- lon=lon,
190
- lat=lat,
191
- alt=alt,
192
- angle=direction,
193
- epoch_time=epoch_time,
194
- fix=None,
195
- precision=None,
196
- ground_speed=ground_speed,
197
- )
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,
198
225
  )
199
226
 
227
+ if not track or not _same_gps_point(track[-1], point):
228
+ track.append(point)
229
+
200
230
  track.sort(key=lambda point: point.time)
201
231
 
232
+ track = _deduplicate_gps_points(track, same_gps_point=_same_gps_point)
233
+
202
234
  if time_tag is not None:
203
235
  if track:
204
236
  first_time = track[0].time
@@ -212,11 +244,11 @@ def _aggregate_samples(
212
244
  elements: T.Iterable[ET.Element],
213
245
  sample_time_tag: str,
214
246
  sample_duration_tag: str,
215
- ) -> T.Generator[T.Tuple[float, float, T.List[ET.Element]], None, None]:
247
+ ) -> T.Generator[tuple[float, float, list[ET.Element]], None, None]:
216
248
  expanded_sample_time_tag = expand_tag(sample_time_tag)
217
249
  expanded_sample_duration_tag = expand_tag(sample_duration_tag)
218
250
 
219
- accumulated_elements: T.List[ET.Element] = []
251
+ accumulated_elements: list[ET.Element] = []
220
252
  sample_time = None
221
253
  sample_duration = None
222
254
  for element in elements:
@@ -234,17 +266,17 @@ def _aggregate_samples(
234
266
 
235
267
 
236
268
  def _aggregate_gps_track_by_sample_time(
237
- sample_iterator: T.Iterable[T.Tuple[float, float, T.List[ET.Element]]],
269
+ sample_iterator: T.Iterable[tuple[float, float, list[ET.Element]]],
238
270
  lon_tag: str,
239
271
  lat_tag: str,
240
- alt_tag: T.Optional[str] = None,
241
- gps_time_tag: T.Optional[str] = None,
242
- direction_tag: T.Optional[str] = None,
243
- ground_speed_tag: T.Optional[str] = None,
244
- gps_fix_tag: T.Optional[str] = None,
245
- gps_precision_tag: T.Optional[str] = None,
246
- ) -> T.List[GPSPoint]:
247
- 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] = []
248
280
 
249
281
  expanded_gps_fix_tag = None
250
282
  if gps_fix_tag is not None:
@@ -308,10 +340,13 @@ class ExifToolReadVideo:
308
340
  etree: ET.ElementTree,
309
341
  ) -> None:
310
342
  self.etree = etree
311
- 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)
312
347
  self._all_tags = set(self._texts_by_tag.keys())
313
348
 
314
- def extract_gps_track(self) -> T.List[geo.Point]:
349
+ def extract_gps_track(self) -> list[geo.Point]:
315
350
  # blackvue and many other cameras
316
351
  track_with_fix = self._extract_gps_track_from_quicktime()
317
352
  if track_with_fix:
@@ -329,7 +364,7 @@ class ExifToolReadVideo:
329
364
 
330
365
  return []
331
366
 
332
- 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]:
333
368
  make = self._extract_alternative_fields(["GoPro:Make"], str)
334
369
  model = self._extract_alternative_fields(["GoPro:Model"], str)
335
370
  if model is not None:
@@ -360,15 +395,19 @@ class ExifToolReadVideo:
360
395
  model = model.strip()
361
396
  return make, model
362
397
 
363
- def extract_make(self) -> T.Optional[str]:
398
+ def extract_make(self) -> str | None:
364
399
  make, _ = self._extract_make_and_model()
365
400
  return make
366
401
 
367
- def extract_model(self) -> T.Optional[str]:
402
+ def extract_model(self) -> str | None:
368
403
  _, model = self._extract_make_and_model()
369
404
  return model
370
405
 
371
- 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
+
372
411
  for track_id in range(1, MAX_TRACK_ID + 1):
373
412
  track_ns = f"Track{track_id}"
374
413
  if self._all_tags_exists(
@@ -380,7 +419,7 @@ class ExifToolReadVideo:
380
419
  }
381
420
  ):
382
421
  sample_iterator = _aggregate_samples(
383
- self.etree.getroot(),
422
+ root,
384
423
  f"{track_ns}:SampleTime",
385
424
  f"{track_ns}:SampleDuration",
386
425
  )
@@ -402,15 +441,15 @@ class ExifToolReadVideo:
402
441
  self,
403
442
  fields: T.Sequence[str],
404
443
  field_type: T.Type[_FIELD_TYPE],
405
- ) -> T.Optional[_FIELD_TYPE]:
444
+ ) -> _FIELD_TYPE | None:
406
445
  return _extract_alternative_fields(self._texts_by_tag, fields, field_type)
407
446
 
408
- def _all_tags_exists(self, tags: T.Set[str]) -> bool:
447
+ def _all_tags_exists(self, tags: set[str]) -> bool:
409
448
  return self._all_tags.issuperset(tags)
410
449
 
411
450
  def _extract_gps_track_from_quicktime(
412
451
  self, namespace: str = "QuickTime"
413
- ) -> T.List[GPSPoint]:
452
+ ) -> list[GPSPoint]:
414
453
  if not self._all_tags_exists(
415
454
  {
416
455
  expand_tag(f"{namespace}:GPSDateTime"),
@@ -1,7 +1,5 @@
1
1
  from __future__ import annotations
2
2
 
3
- import platform
4
- import shutil
5
3
  import subprocess
6
4
  import typing as T
7
5
  from pathlib import Path
@@ -12,32 +10,14 @@ class ExiftoolRunner:
12
10
  Wrapper around ExifTool to run it in a subprocess
13
11
  """
14
12
 
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
13
+ def __init__(self, exiftool_executable: str = "exiftool", recursive: bool = False):
14
+ self.exiftool_executable = exiftool_executable
19
15
  self.recursive = recursive
20
16
 
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
17
  def _build_args_read_stdin(self) -> list[str]:
39
18
  args: list[str] = [
40
- self.exiftool_path,
19
+ self.exiftool_executable,
20
+ "-fast",
41
21
  "-q",
42
22
  "-n", # Disable print conversion
43
23
  "-X", # XML output