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.
- mapillary_tools/__init__.py +1 -1
- mapillary_tools/api_v4.py +237 -16
- mapillary_tools/authenticate.py +325 -64
- mapillary_tools/{geotag/blackvue_parser.py → blackvue_parser.py} +74 -54
- mapillary_tools/camm/camm_builder.py +55 -97
- mapillary_tools/camm/camm_parser.py +429 -181
- mapillary_tools/commands/__main__.py +12 -6
- mapillary_tools/commands/authenticate.py +8 -1
- mapillary_tools/commands/process.py +27 -51
- mapillary_tools/commands/process_and_upload.py +19 -5
- mapillary_tools/commands/sample_video.py +2 -3
- mapillary_tools/commands/upload.py +18 -9
- mapillary_tools/commands/video_process_and_upload.py +19 -5
- mapillary_tools/config.py +31 -13
- mapillary_tools/constants.py +47 -6
- mapillary_tools/exceptions.py +34 -35
- mapillary_tools/exif_read.py +221 -116
- mapillary_tools/exif_write.py +7 -7
- mapillary_tools/exiftool_read.py +33 -42
- mapillary_tools/exiftool_read_video.py +46 -33
- mapillary_tools/exiftool_runner.py +77 -0
- mapillary_tools/ffmpeg.py +24 -23
- mapillary_tools/geo.py +144 -120
- mapillary_tools/geotag/base.py +147 -0
- mapillary_tools/geotag/factory.py +291 -0
- mapillary_tools/geotag/geotag_images_from_exif.py +14 -131
- mapillary_tools/geotag/geotag_images_from_exiftool.py +126 -82
- mapillary_tools/geotag/geotag_images_from_gpx.py +53 -118
- mapillary_tools/geotag/geotag_images_from_gpx_file.py +13 -126
- mapillary_tools/geotag/geotag_images_from_nmea_file.py +4 -5
- mapillary_tools/geotag/geotag_images_from_video.py +53 -51
- mapillary_tools/geotag/geotag_videos_from_exiftool.py +97 -0
- mapillary_tools/geotag/geotag_videos_from_gpx.py +39 -0
- mapillary_tools/geotag/geotag_videos_from_video.py +20 -185
- 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 +160 -0
- mapillary_tools/geotag/utils.py +52 -16
- mapillary_tools/geotag/video_extractors/base.py +18 -0
- mapillary_tools/geotag/video_extractors/exiftool.py +70 -0
- mapillary_tools/{video_data_extraction/extractors/gpx_parser.py → geotag/video_extractors/gpx.py} +57 -39
- mapillary_tools/geotag/video_extractors/native.py +157 -0
- mapillary_tools/{geotag → gpmf}/gpmf_parser.py +205 -182
- mapillary_tools/{geotag → gpmf}/gps_filter.py +5 -3
- mapillary_tools/history.py +7 -13
- mapillary_tools/mp4/construct_mp4_parser.py +9 -8
- mapillary_tools/mp4/io_utils.py +0 -1
- mapillary_tools/mp4/mp4_sample_parser.py +36 -28
- mapillary_tools/mp4/simple_mp4_builder.py +10 -9
- mapillary_tools/mp4/simple_mp4_parser.py +13 -22
- mapillary_tools/process_geotag_properties.py +155 -392
- mapillary_tools/process_sequence_properties.py +562 -208
- mapillary_tools/sample_video.py +13 -20
- mapillary_tools/telemetry.py +26 -13
- mapillary_tools/types.py +111 -58
- mapillary_tools/upload.py +316 -298
- mapillary_tools/upload_api_v4.py +55 -122
- mapillary_tools/uploader.py +396 -254
- mapillary_tools/utils.py +42 -18
- {mapillary_tools-0.13.3a1.dist-info → mapillary_tools-0.14.0a2.dist-info}/METADATA +3 -2
- mapillary_tools-0.14.0a2.dist-info/RECORD +72 -0
- {mapillary_tools-0.13.3a1.dist-info → mapillary_tools-0.14.0a2.dist-info}/WHEEL +1 -1
- mapillary_tools/geotag/__init__.py +0 -1
- mapillary_tools/geotag/geotag_from_generic.py +0 -22
- mapillary_tools/geotag/geotag_images_from_exiftool_both_image_and_video.py +0 -93
- mapillary_tools/geotag/geotag_videos_from_exiftool_video.py +0 -145
- mapillary_tools/video_data_extraction/cli_options.py +0 -22
- mapillary_tools/video_data_extraction/extract_video_data.py +0 -176
- mapillary_tools/video_data_extraction/extractors/base_parser.py +0 -75
- mapillary_tools/video_data_extraction/extractors/blackvue_parser.py +0 -34
- mapillary_tools/video_data_extraction/extractors/camm_parser.py +0 -38
- mapillary_tools/video_data_extraction/extractors/exiftool_runtime_parser.py +0 -71
- mapillary_tools/video_data_extraction/extractors/exiftool_xml_parser.py +0 -53
- mapillary_tools/video_data_extraction/extractors/generic_video_parser.py +0 -52
- mapillary_tools/video_data_extraction/extractors/gopro_parser.py +0 -43
- 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.13.3a1.dist-info/RECORD +0 -75
- /mapillary_tools/{geotag → gpmf}/gpmf_gps_filter.py +0 -0
- {mapillary_tools-0.13.3a1.dist-info → mapillary_tools-0.14.0a2.dist-info}/entry_points.txt +0 -0
- {mapillary_tools-0.13.3a1.dist-info → mapillary_tools-0.14.0a2.dist-info/licenses}/LICENSE +0 -0
- {mapillary_tools-0.13.3a1.dist-info → mapillary_tools-0.14.0a2.dist-info}/top_level.txt +0 -0
mapillary_tools/exif_write.py
CHANGED
|
@@ -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:
|
|
18
|
+
_filename_or_bytes: str | bytes
|
|
19
19
|
|
|
20
|
-
def __init__(self, filename_or_bytes:
|
|
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:
|
|
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
|
-
) ->
|
|
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:
|
|
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:
|
|
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):
|
mapillary_tools/exiftool_read.py
CHANGED
|
@@ -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
|
|
9
|
+
from . import exif_read
|
|
8
10
|
|
|
9
11
|
|
|
10
|
-
EXIFTOOL_NAMESPACES:
|
|
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:
|
|
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) ->
|
|
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
|
|
81
|
-
|
|
82
|
-
) ->
|
|
83
|
-
rdf_description_by_path:
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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) ->
|
|
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
|
-
) ->
|
|
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) ->
|
|
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) ->
|
|
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
|
-
) ->
|
|
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) ->
|
|
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) ->
|
|
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) ->
|
|
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) ->
|
|
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) ->
|
|
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
|
-
) ->
|
|
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) ->
|
|
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) ->
|
|
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) ->
|
|
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) ->
|
|
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:
|
|
437
|
-
) ->
|
|
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:
|
|
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:
|
|
@@ -81,14 +83,15 @@ def _extract_alternative_fields(
|
|
|
81
83
|
|
|
82
84
|
|
|
83
85
|
def _aggregate_gps_track(
|
|
84
|
-
texts_by_tag:
|
|
85
|
-
time_tag:
|
|
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:
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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:
|
|
143
|
-
) ->
|
|
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=
|
|
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[
|
|
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:
|
|
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[
|
|
239
|
+
sample_iterator: T.Iterable[tuple[float, float, list[ET.Element]]],
|
|
228
240
|
lon_tag: str,
|
|
229
241
|
lat_tag: str,
|
|
230
|
-
alt_tag:
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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) ->
|
|
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) ->
|
|
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) ->
|
|
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) ->
|
|
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) ->
|
|
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
|
-
) ->
|
|
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:
|
|
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
|
-
) ->
|
|
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
|