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.
- mapillary_tools/__init__.py +1 -1
- mapillary_tools/api_v4.py +5 -4
- mapillary_tools/authenticate.py +9 -9
- mapillary_tools/blackvue_parser.py +79 -22
- mapillary_tools/camm/camm_parser.py +5 -5
- mapillary_tools/commands/__main__.py +1 -2
- mapillary_tools/config.py +41 -18
- mapillary_tools/constants.py +3 -2
- mapillary_tools/exceptions.py +1 -1
- mapillary_tools/exif_read.py +65 -65
- mapillary_tools/exif_write.py +7 -7
- mapillary_tools/exiftool_read.py +23 -46
- mapillary_tools/exiftool_read_video.py +88 -49
- mapillary_tools/exiftool_runner.py +4 -24
- mapillary_tools/ffmpeg.py +417 -242
- mapillary_tools/geo.py +4 -21
- mapillary_tools/geotag/__init__.py +0 -1
- mapillary_tools/geotag/{geotag_from_generic.py → base.py} +34 -50
- mapillary_tools/geotag/factory.py +105 -103
- mapillary_tools/geotag/geotag_images_from_exif.py +15 -51
- mapillary_tools/geotag/geotag_images_from_exiftool.py +118 -63
- mapillary_tools/geotag/geotag_images_from_gpx.py +33 -16
- mapillary_tools/geotag/geotag_images_from_gpx_file.py +2 -34
- mapillary_tools/geotag/geotag_images_from_nmea_file.py +0 -3
- mapillary_tools/geotag/geotag_images_from_video.py +51 -14
- mapillary_tools/geotag/geotag_videos_from_exiftool.py +123 -0
- mapillary_tools/geotag/geotag_videos_from_gpx.py +35 -123
- mapillary_tools/geotag/geotag_videos_from_video.py +14 -147
- mapillary_tools/geotag/image_extractors/base.py +18 -0
- mapillary_tools/geotag/image_extractors/exif.py +60 -0
- mapillary_tools/geotag/image_extractors/exiftool.py +18 -0
- mapillary_tools/geotag/options.py +26 -3
- mapillary_tools/geotag/utils.py +62 -0
- mapillary_tools/geotag/video_extractors/base.py +18 -0
- mapillary_tools/geotag/video_extractors/exiftool.py +70 -0
- mapillary_tools/geotag/video_extractors/gpx.py +116 -0
- mapillary_tools/geotag/video_extractors/native.py +135 -0
- mapillary_tools/gpmf/gpmf_parser.py +16 -16
- mapillary_tools/gpmf/gps_filter.py +5 -3
- mapillary_tools/history.py +8 -3
- mapillary_tools/mp4/construct_mp4_parser.py +9 -8
- mapillary_tools/mp4/mp4_sample_parser.py +27 -27
- mapillary_tools/mp4/simple_mp4_builder.py +10 -9
- mapillary_tools/mp4/simple_mp4_parser.py +13 -12
- mapillary_tools/process_geotag_properties.py +21 -15
- mapillary_tools/process_sequence_properties.py +49 -49
- mapillary_tools/sample_video.py +15 -14
- mapillary_tools/serializer/description.py +587 -0
- mapillary_tools/serializer/gpx.py +132 -0
- mapillary_tools/telemetry.py +6 -5
- mapillary_tools/types.py +64 -635
- mapillary_tools/upload.py +176 -197
- mapillary_tools/upload_api_v4.py +94 -51
- mapillary_tools/uploader.py +284 -138
- mapillary_tools/utils.py +16 -18
- {mapillary_tools-0.14.0a1.dist-info → mapillary_tools-0.14.0b1.dist-info}/METADATA +87 -31
- mapillary_tools-0.14.0b1.dist-info/RECORD +75 -0
- {mapillary_tools-0.14.0a1.dist-info → mapillary_tools-0.14.0b1.dist-info}/WHEEL +1 -1
- mapillary_tools/geotag/geotag_images_from_exiftool_both_image_and_video.py +0 -77
- mapillary_tools/geotag/geotag_videos_from_exiftool_video.py +0 -151
- mapillary_tools/video_data_extraction/cli_options.py +0 -22
- mapillary_tools/video_data_extraction/extract_video_data.py +0 -157
- mapillary_tools/video_data_extraction/extractors/base_parser.py +0 -75
- mapillary_tools/video_data_extraction/extractors/blackvue_parser.py +0 -49
- mapillary_tools/video_data_extraction/extractors/camm_parser.py +0 -62
- mapillary_tools/video_data_extraction/extractors/exiftool_runtime_parser.py +0 -74
- mapillary_tools/video_data_extraction/extractors/exiftool_xml_parser.py +0 -52
- mapillary_tools/video_data_extraction/extractors/generic_video_parser.py +0 -52
- mapillary_tools/video_data_extraction/extractors/gopro_parser.py +0 -58
- mapillary_tools/video_data_extraction/extractors/gpx_parser.py +0 -108
- mapillary_tools/video_data_extraction/extractors/nmea_parser.py +0 -24
- mapillary_tools/video_data_extraction/video_data_parser_factory.py +0 -39
- mapillary_tools-0.14.0a1.dist-info/RECORD +0 -78
- {mapillary_tools-0.14.0a1.dist-info → mapillary_tools-0.14.0b1.dist-info}/entry_points.txt +0 -0
- {mapillary_tools-0.14.0a1.dist-info → mapillary_tools-0.14.0b1.dist-info}/licenses/LICENSE +0 -0
- {mapillary_tools-0.14.0a1.dist-info → mapillary_tools-0.14.0b1.dist-info}/top_level.txt +0 -0
mapillary_tools/exiftool_read.py
CHANGED
|
@@ -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
|
|
9
|
+
from . import exif_read
|
|
10
10
|
|
|
11
11
|
|
|
12
|
-
EXIFTOOL_NAMESPACES:
|
|
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:
|
|
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) ->
|
|
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(
|
|
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) ->
|
|
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
|
-
) ->
|
|
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) ->
|
|
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) ->
|
|
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
|
-
) ->
|
|
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) ->
|
|
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) ->
|
|
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) ->
|
|
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) ->
|
|
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) ->
|
|
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
|
-
) ->
|
|
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) ->
|
|
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) ->
|
|
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) ->
|
|
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) ->
|
|
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:
|
|
451
|
-
) ->
|
|
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:
|
|
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:
|
|
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]) ->
|
|
41
|
-
texts_by_tag:
|
|
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:
|
|
52
|
+
texts_by_tag: dict[str, list[str]],
|
|
51
53
|
fields: T.Sequence[str],
|
|
52
54
|
field_type: T.Type[_FIELD_TYPE],
|
|
53
|
-
) ->
|
|
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:
|
|
85
|
-
time_tag:
|
|
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:
|
|
89
|
-
gps_time_tag:
|
|
90
|
-
direction_tag:
|
|
91
|
-
ground_speed_tag:
|
|
92
|
-
) ->
|
|
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:
|
|
144
|
-
) ->
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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[
|
|
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:
|
|
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[
|
|
269
|
+
sample_iterator: T.Iterable[tuple[float, float, list[ET.Element]]],
|
|
238
270
|
lon_tag: str,
|
|
239
271
|
lat_tag: str,
|
|
240
|
-
alt_tag:
|
|
241
|
-
gps_time_tag:
|
|
242
|
-
direction_tag:
|
|
243
|
-
ground_speed_tag:
|
|
244
|
-
gps_fix_tag:
|
|
245
|
-
gps_precision_tag:
|
|
246
|
-
) ->
|
|
247
|
-
track:
|
|
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
|
-
|
|
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) ->
|
|
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) ->
|
|
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) ->
|
|
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) ->
|
|
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) ->
|
|
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
|
-
|
|
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
|
-
) ->
|
|
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:
|
|
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
|
-
) ->
|
|
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,
|
|
16
|
-
|
|
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.
|
|
19
|
+
self.exiftool_executable,
|
|
20
|
+
"-fast",
|
|
41
21
|
"-q",
|
|
42
22
|
"-n", # Disable print conversion
|
|
43
23
|
"-X", # XML output
|