mapillary-tools 0.13.3__py3-none-any.whl → 0.14.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mapillary_tools/__init__.py +1 -1
- mapillary_tools/api_v4.py +198 -55
- mapillary_tools/authenticate.py +326 -64
- mapillary_tools/blackvue_parser.py +195 -0
- mapillary_tools/camm/camm_builder.py +55 -97
- mapillary_tools/camm/camm_parser.py +429 -181
- mapillary_tools/commands/__main__.py +10 -6
- mapillary_tools/commands/authenticate.py +8 -1
- mapillary_tools/commands/process.py +27 -51
- mapillary_tools/commands/process_and_upload.py +18 -5
- mapillary_tools/commands/sample_video.py +2 -3
- mapillary_tools/commands/upload.py +44 -13
- mapillary_tools/commands/video_process_and_upload.py +19 -5
- mapillary_tools/config.py +65 -26
- mapillary_tools/constants.py +141 -18
- mapillary_tools/exceptions.py +37 -34
- mapillary_tools/exif_read.py +221 -116
- mapillary_tools/exif_write.py +10 -8
- mapillary_tools/exiftool_read.py +33 -42
- mapillary_tools/exiftool_read_video.py +97 -47
- mapillary_tools/exiftool_runner.py +57 -0
- mapillary_tools/ffmpeg.py +417 -242
- mapillary_tools/geo.py +158 -118
- mapillary_tools/geotag/__init__.py +0 -1
- mapillary_tools/geotag/base.py +147 -0
- mapillary_tools/geotag/factory.py +307 -0
- mapillary_tools/geotag/geotag_images_from_exif.py +14 -131
- mapillary_tools/geotag/geotag_images_from_exiftool.py +136 -85
- mapillary_tools/geotag/geotag_images_from_gpx.py +60 -124
- 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 +88 -51
- mapillary_tools/geotag/geotag_videos_from_exiftool.py +123 -0
- mapillary_tools/geotag/geotag_videos_from_gpx.py +52 -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 +182 -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/geotag/video_extractors/gpx.py +116 -0
- mapillary_tools/geotag/video_extractors/native.py +160 -0
- mapillary_tools/{geotag → gpmf}/gpmf_parser.py +205 -182
- mapillary_tools/{geotag → gpmf}/gps_filter.py +5 -3
- mapillary_tools/history.py +134 -20
- mapillary_tools/mp4/construct_mp4_parser.py +17 -10
- 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 +184 -414
- mapillary_tools/process_sequence_properties.py +594 -225
- mapillary_tools/sample_video.py +20 -26
- mapillary_tools/serializer/description.py +587 -0
- mapillary_tools/serializer/gpx.py +132 -0
- mapillary_tools/telemetry.py +26 -13
- mapillary_tools/types.py +98 -611
- mapillary_tools/upload.py +411 -387
- mapillary_tools/upload_api_v4.py +167 -142
- mapillary_tools/uploader.py +804 -284
- mapillary_tools/utils.py +49 -18
- {mapillary_tools-0.13.3.dist-info → mapillary_tools-0.14.0.dist-info}/METADATA +93 -35
- mapillary_tools-0.14.0.dist-info/RECORD +75 -0
- {mapillary_tools-0.13.3.dist-info → mapillary_tools-0.14.0.dist-info}/WHEEL +1 -1
- mapillary_tools/geotag/blackvue_parser.py +0 -118
- 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/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.13.3.dist-info/RECORD +0 -75
- /mapillary_tools/{geotag → gpmf}/gpmf_gps_filter.py +0 -0
- {mapillary_tools-0.13.3.dist-info → mapillary_tools-0.14.0.dist-info}/entry_points.txt +0 -0
- {mapillary_tools-0.13.3.dist-info → mapillary_tools-0.14.0.dist-info/licenses}/LICENSE +0 -0
- {mapillary_tools-0.13.3.dist-info → mapillary_tools-0.14.0.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,9 +40,11 @@ class ExifEdit:
|
|
|
40
40
|
|
|
41
41
|
return (deg, 1), (min, 1), (sec, precision)
|
|
42
42
|
|
|
43
|
-
def add_image_description(self, data:
|
|
43
|
+
def add_image_description(self, data: dict) -> None:
|
|
44
44
|
"""Add a dict to image description."""
|
|
45
|
-
self._ef["0th"][piexif.ImageIFD.ImageDescription] = json.dumps(
|
|
45
|
+
self._ef["0th"][piexif.ImageIFD.ImageDescription] = json.dumps(
|
|
46
|
+
data, sort_keys=True, separators=(",", ":")
|
|
47
|
+
)
|
|
46
48
|
|
|
47
49
|
def add_orientation(self, orientation: int) -> None:
|
|
48
50
|
"""Add image orientation to image."""
|
|
@@ -201,7 +203,7 @@ class ExifEdit:
|
|
|
201
203
|
piexif.insert(exif_bytes, self._filename_or_bytes, output)
|
|
202
204
|
return output.read()
|
|
203
205
|
|
|
204
|
-
def write(self, filename:
|
|
206
|
+
def write(self, filename: Path | None = None) -> None:
|
|
205
207
|
"""Save exif data to file."""
|
|
206
208
|
if filename is None:
|
|
207
209
|
if not isinstance(self._filename_or_bytes, str):
|
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:
|
|
@@ -80,15 +82,42 @@ def _extract_alternative_fields(
|
|
|
80
82
|
return None
|
|
81
83
|
|
|
82
84
|
|
|
85
|
+
def _same_gps_point(left: GPSPoint, right: GPSPoint) -> bool:
|
|
86
|
+
"""
|
|
87
|
+
>>> left = GPSPoint(time=56.0, lat=36.741385, lon=29.021274, alt=141.6, angle=1.54, epoch_time=None, fix=None, precision=None, ground_speed=None)
|
|
88
|
+
>>> right = GPSPoint(time=56.0, lat=36.741385, lon=29.021274, alt=142.4, angle=1.54, epoch_time=None, fix=None, precision=None, ground_speed=None)
|
|
89
|
+
>>> _same_gps_point(left, right)
|
|
90
|
+
True
|
|
91
|
+
"""
|
|
92
|
+
return (
|
|
93
|
+
left.time == right.time
|
|
94
|
+
and left.lon == right.lon
|
|
95
|
+
and left.lat == right.lat
|
|
96
|
+
and left.epoch_time == right.epoch_time
|
|
97
|
+
and left.angle == right.angle
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _deduplicate_gps_points(
|
|
102
|
+
track: list[GPSPoint], same_gps_point: T.Callable[[GPSPoint, GPSPoint], bool]
|
|
103
|
+
) -> list[GPSPoint]:
|
|
104
|
+
deduplicated_track: list[GPSPoint] = []
|
|
105
|
+
for point in track:
|
|
106
|
+
if not deduplicated_track or not same_gps_point(deduplicated_track[-1], point):
|
|
107
|
+
deduplicated_track.append(point)
|
|
108
|
+
return deduplicated_track
|
|
109
|
+
|
|
110
|
+
|
|
83
111
|
def _aggregate_gps_track(
|
|
84
|
-
texts_by_tag:
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
116
|
+
alt_tag: str | None = None,
|
|
117
|
+
gps_time_tag: str | None = None,
|
|
118
|
+
direction_tag: str | None = None,
|
|
119
|
+
ground_speed_tag: str | None = None,
|
|
120
|
+
) -> list[GPSPoint]:
|
|
92
121
|
"""
|
|
93
122
|
Aggregate all GPS data by the tags.
|
|
94
123
|
It requires lat, lon to be present, and their lengths must match.
|
|
@@ -139,8 +168,8 @@ def _aggregate_gps_track(
|
|
|
139
168
|
assert len(timestamps) == expected_length
|
|
140
169
|
|
|
141
170
|
def _aggregate_float_values_same_length(
|
|
142
|
-
tag:
|
|
143
|
-
) ->
|
|
171
|
+
tag: str | None,
|
|
172
|
+
) -> list[float | None]:
|
|
144
173
|
if tag is not None:
|
|
145
174
|
vals = [
|
|
146
175
|
_maybe_float(val)
|
|
@@ -161,8 +190,17 @@ def _aggregate_gps_track(
|
|
|
161
190
|
# aggregate speeds (optional)
|
|
162
191
|
ground_speeds = _aggregate_float_values_same_length(ground_speed_tag)
|
|
163
192
|
|
|
193
|
+
# GPS timestamp (optional)
|
|
194
|
+
epoch_time = None
|
|
195
|
+
if gps_time_tag is not None:
|
|
196
|
+
gps_time_text = _extract_alternative_fields(texts_by_tag, [gps_time_tag], str)
|
|
197
|
+
if gps_time_text is not None:
|
|
198
|
+
dt = exif_read.parse_gps_datetime(gps_time_text)
|
|
199
|
+
if dt is not None:
|
|
200
|
+
epoch_time = geo.as_unix_time(dt)
|
|
201
|
+
|
|
164
202
|
# build track
|
|
165
|
-
track = []
|
|
203
|
+
track: list[GPSPoint] = []
|
|
166
204
|
for timestamp, lon, lat, alt, direction, ground_speed in zip(
|
|
167
205
|
timestamps,
|
|
168
206
|
lons,
|
|
@@ -173,22 +211,26 @@ def _aggregate_gps_track(
|
|
|
173
211
|
):
|
|
174
212
|
if timestamp is None or lon is None or lat is None:
|
|
175
213
|
continue
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
)
|
|
214
|
+
|
|
215
|
+
point = GPSPoint(
|
|
216
|
+
time=timestamp,
|
|
217
|
+
lon=lon,
|
|
218
|
+
lat=lat,
|
|
219
|
+
alt=alt,
|
|
220
|
+
angle=direction,
|
|
221
|
+
epoch_time=epoch_time,
|
|
222
|
+
fix=None,
|
|
223
|
+
precision=None,
|
|
224
|
+
ground_speed=ground_speed,
|
|
188
225
|
)
|
|
189
226
|
|
|
227
|
+
if not track or not _same_gps_point(track[-1], point):
|
|
228
|
+
track.append(point)
|
|
229
|
+
|
|
190
230
|
track.sort(key=lambda point: point.time)
|
|
191
231
|
|
|
232
|
+
track = _deduplicate_gps_points(track, same_gps_point=_same_gps_point)
|
|
233
|
+
|
|
192
234
|
if time_tag is not None:
|
|
193
235
|
if track:
|
|
194
236
|
first_time = track[0].time
|
|
@@ -202,11 +244,11 @@ def _aggregate_samples(
|
|
|
202
244
|
elements: T.Iterable[ET.Element],
|
|
203
245
|
sample_time_tag: str,
|
|
204
246
|
sample_duration_tag: str,
|
|
205
|
-
) -> T.Generator[
|
|
247
|
+
) -> T.Generator[tuple[float, float, list[ET.Element]], None, None]:
|
|
206
248
|
expanded_sample_time_tag = expand_tag(sample_time_tag)
|
|
207
249
|
expanded_sample_duration_tag = expand_tag(sample_duration_tag)
|
|
208
250
|
|
|
209
|
-
accumulated_elements:
|
|
251
|
+
accumulated_elements: list[ET.Element] = []
|
|
210
252
|
sample_time = None
|
|
211
253
|
sample_duration = None
|
|
212
254
|
for element in elements:
|
|
@@ -224,16 +266,17 @@ def _aggregate_samples(
|
|
|
224
266
|
|
|
225
267
|
|
|
226
268
|
def _aggregate_gps_track_by_sample_time(
|
|
227
|
-
sample_iterator: T.Iterable[
|
|
269
|
+
sample_iterator: T.Iterable[tuple[float, float, list[ET.Element]]],
|
|
228
270
|
lon_tag: str,
|
|
229
271
|
lat_tag: str,
|
|
230
|
-
alt_tag:
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
272
|
+
alt_tag: str | None = None,
|
|
273
|
+
gps_time_tag: str | None = None,
|
|
274
|
+
direction_tag: str | None = None,
|
|
275
|
+
ground_speed_tag: str | None = None,
|
|
276
|
+
gps_fix_tag: str | None = None,
|
|
277
|
+
gps_precision_tag: str | None = None,
|
|
278
|
+
) -> list[GPSPoint]:
|
|
279
|
+
track: list[GPSPoint] = []
|
|
237
280
|
|
|
238
281
|
expanded_gps_fix_tag = None
|
|
239
282
|
if gps_fix_tag is not None:
|
|
@@ -297,10 +340,13 @@ class ExifToolReadVideo:
|
|
|
297
340
|
etree: ET.ElementTree,
|
|
298
341
|
) -> None:
|
|
299
342
|
self.etree = etree
|
|
300
|
-
|
|
343
|
+
root = self.etree.getroot()
|
|
344
|
+
if root is None:
|
|
345
|
+
raise ValueError("ElementTree root is None")
|
|
346
|
+
self._texts_by_tag = _index_text_by_tag(root)
|
|
301
347
|
self._all_tags = set(self._texts_by_tag.keys())
|
|
302
348
|
|
|
303
|
-
def extract_gps_track(self) ->
|
|
349
|
+
def extract_gps_track(self) -> list[geo.Point]:
|
|
304
350
|
# blackvue and many other cameras
|
|
305
351
|
track_with_fix = self._extract_gps_track_from_quicktime()
|
|
306
352
|
if track_with_fix:
|
|
@@ -318,7 +364,7 @@ class ExifToolReadVideo:
|
|
|
318
364
|
|
|
319
365
|
return []
|
|
320
366
|
|
|
321
|
-
def _extract_make_and_model(self) ->
|
|
367
|
+
def _extract_make_and_model(self) -> tuple[str | None, str | None]:
|
|
322
368
|
make = self._extract_alternative_fields(["GoPro:Make"], str)
|
|
323
369
|
model = self._extract_alternative_fields(["GoPro:Model"], str)
|
|
324
370
|
if model is not None:
|
|
@@ -349,15 +395,19 @@ class ExifToolReadVideo:
|
|
|
349
395
|
model = model.strip()
|
|
350
396
|
return make, model
|
|
351
397
|
|
|
352
|
-
def extract_make(self) ->
|
|
398
|
+
def extract_make(self) -> str | None:
|
|
353
399
|
make, _ = self._extract_make_and_model()
|
|
354
400
|
return make
|
|
355
401
|
|
|
356
|
-
def extract_model(self) ->
|
|
402
|
+
def extract_model(self) -> str | None:
|
|
357
403
|
_, model = self._extract_make_and_model()
|
|
358
404
|
return model
|
|
359
405
|
|
|
360
|
-
def _extract_gps_track_from_track(self) ->
|
|
406
|
+
def _extract_gps_track_from_track(self) -> list[GPSPoint]:
|
|
407
|
+
root = self.etree.getroot()
|
|
408
|
+
if root is None:
|
|
409
|
+
raise ValueError("ElementTree root is None")
|
|
410
|
+
|
|
361
411
|
for track_id in range(1, MAX_TRACK_ID + 1):
|
|
362
412
|
track_ns = f"Track{track_id}"
|
|
363
413
|
if self._all_tags_exists(
|
|
@@ -369,7 +419,7 @@ class ExifToolReadVideo:
|
|
|
369
419
|
}
|
|
370
420
|
):
|
|
371
421
|
sample_iterator = _aggregate_samples(
|
|
372
|
-
|
|
422
|
+
root,
|
|
373
423
|
f"{track_ns}:SampleTime",
|
|
374
424
|
f"{track_ns}:SampleDuration",
|
|
375
425
|
)
|
|
@@ -391,15 +441,15 @@ class ExifToolReadVideo:
|
|
|
391
441
|
self,
|
|
392
442
|
fields: T.Sequence[str],
|
|
393
443
|
field_type: T.Type[_FIELD_TYPE],
|
|
394
|
-
) ->
|
|
444
|
+
) -> _FIELD_TYPE | None:
|
|
395
445
|
return _extract_alternative_fields(self._texts_by_tag, fields, field_type)
|
|
396
446
|
|
|
397
|
-
def _all_tags_exists(self, tags:
|
|
447
|
+
def _all_tags_exists(self, tags: set[str]) -> bool:
|
|
398
448
|
return self._all_tags.issuperset(tags)
|
|
399
449
|
|
|
400
450
|
def _extract_gps_track_from_quicktime(
|
|
401
451
|
self, namespace: str = "QuickTime"
|
|
402
|
-
) ->
|
|
452
|
+
) -> list[GPSPoint]:
|
|
403
453
|
if not self._all_tags_exists(
|
|
404
454
|
{
|
|
405
455
|
expand_tag(f"{namespace}:GPSDateTime"),
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
import typing as T
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ExiftoolRunner:
|
|
9
|
+
"""
|
|
10
|
+
Wrapper around ExifTool to run it in a subprocess
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
def __init__(self, exiftool_executable: str = "exiftool", recursive: bool = False):
|
|
14
|
+
self.exiftool_executable = exiftool_executable
|
|
15
|
+
self.recursive = recursive
|
|
16
|
+
|
|
17
|
+
def _build_args_read_stdin(self) -> list[str]:
|
|
18
|
+
args: list[str] = [
|
|
19
|
+
self.exiftool_executable,
|
|
20
|
+
"-fast",
|
|
21
|
+
"-q",
|
|
22
|
+
"-n", # Disable print conversion
|
|
23
|
+
"-X", # XML output
|
|
24
|
+
"-ee",
|
|
25
|
+
*["-api", "LargeFileSupport=1"],
|
|
26
|
+
*["-charset", "filename=utf8"],
|
|
27
|
+
*["-@", "-"],
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
if self.recursive:
|
|
31
|
+
args.append("-r")
|
|
32
|
+
|
|
33
|
+
return args
|
|
34
|
+
|
|
35
|
+
def extract_xml(self, paths: T.Sequence[Path]) -> str:
|
|
36
|
+
if not paths:
|
|
37
|
+
# ExifTool will show its full manual if no files are provided
|
|
38
|
+
raise ValueError("No files provided to exiftool")
|
|
39
|
+
|
|
40
|
+
# To handle non-latin1 filenames under Windows, we pass the path
|
|
41
|
+
# via stdin. See https://exiftool.org/faq.html#Q18
|
|
42
|
+
stdin = "\n".join([str(p.resolve()) for p in paths])
|
|
43
|
+
|
|
44
|
+
args = self._build_args_read_stdin()
|
|
45
|
+
|
|
46
|
+
# Raise FileNotFoundError here if self.exiftool_path not found
|
|
47
|
+
process = subprocess.run(
|
|
48
|
+
args,
|
|
49
|
+
capture_output=True,
|
|
50
|
+
text=True,
|
|
51
|
+
input=stdin,
|
|
52
|
+
encoding="utf-8",
|
|
53
|
+
# Do not check exit status to allow some files not found
|
|
54
|
+
# check=True,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
return process.stdout
|