mapillary-tools 0.14.4__py3-none-any.whl → 0.14.6__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 +6 -1
- mapillary_tools/api_v4.py +5 -0
- mapillary_tools/authenticate.py +5 -1
- mapillary_tools/blackvue_parser.py +138 -20
- mapillary_tools/camm/camm_builder.py +5 -1
- mapillary_tools/camm/camm_parser.py +5 -0
- mapillary_tools/commands/__init__.py +5 -0
- mapillary_tools/commands/__main__.py +5 -0
- mapillary_tools/commands/authenticate.py +5 -0
- mapillary_tools/commands/process.py +12 -0
- mapillary_tools/commands/process_and_upload.py +5 -1
- mapillary_tools/commands/sample_video.py +5 -0
- mapillary_tools/commands/upload.py +5 -0
- mapillary_tools/commands/video_process.py +5 -0
- mapillary_tools/commands/video_process_and_upload.py +5 -1
- mapillary_tools/commands/zip.py +5 -0
- mapillary_tools/config.py +5 -0
- mapillary_tools/constants.py +13 -1
- mapillary_tools/exceptions.py +9 -0
- mapillary_tools/exif_read.py +89 -0
- mapillary_tools/exif_write.py +75 -26
- mapillary_tools/exiftool_read.py +89 -0
- mapillary_tools/exiftool_read_video.py +56 -0
- mapillary_tools/exiftool_runner.py +5 -0
- mapillary_tools/ffmpeg.py +5 -0
- mapillary_tools/geo.py +91 -31
- mapillary_tools/geotag/__init__.py +4 -0
- mapillary_tools/geotag/base.py +5 -0
- mapillary_tools/geotag/factory.py +5 -0
- mapillary_tools/geotag/geotag_images_from_exif.py +5 -0
- mapillary_tools/geotag/geotag_images_from_exiftool.py +5 -0
- mapillary_tools/geotag/geotag_images_from_gpx.py +5 -0
- mapillary_tools/geotag/geotag_images_from_gpx_file.py +5 -0
- mapillary_tools/geotag/geotag_images_from_nmea_file.py +5 -0
- mapillary_tools/geotag/geotag_images_from_video.py +6 -0
- mapillary_tools/geotag/geotag_videos_from_exiftool.py +5 -0
- mapillary_tools/geotag/geotag_videos_from_gpx.py +5 -0
- mapillary_tools/geotag/geotag_videos_from_video.py +5 -0
- mapillary_tools/geotag/image_extractors/base.py +5 -0
- mapillary_tools/geotag/image_extractors/exif.py +6 -0
- mapillary_tools/geotag/image_extractors/exiftool.py +5 -0
- mapillary_tools/geotag/options.py +5 -0
- mapillary_tools/geotag/utils.py +5 -0
- mapillary_tools/geotag/video_extractors/base.py +5 -0
- mapillary_tools/geotag/video_extractors/exiftool.py +7 -0
- mapillary_tools/geotag/video_extractors/gpx.py +5 -0
- mapillary_tools/geotag/video_extractors/native.py +5 -0
- mapillary_tools/gpmf/gpmf_gps_filter.py +5 -0
- mapillary_tools/gpmf/gpmf_parser.py +5 -0
- mapillary_tools/gpmf/gps_filter.py +5 -0
- mapillary_tools/history.py +5 -0
- mapillary_tools/http.py +5 -1
- mapillary_tools/ipc.py +5 -0
- mapillary_tools/mp4/__init__.py +4 -0
- mapillary_tools/mp4/construct_mp4_parser.py +5 -0
- mapillary_tools/mp4/io_utils.py +5 -0
- mapillary_tools/mp4/mp4_sample_parser.py +20 -1
- mapillary_tools/mp4/simple_mp4_builder.py +5 -0
- mapillary_tools/mp4/simple_mp4_parser.py +5 -0
- mapillary_tools/process_geotag_properties.py +5 -0
- mapillary_tools/process_sequence_properties.py +213 -31
- mapillary_tools/sample_video.py +13 -1
- mapillary_tools/serializer/description.py +13 -0
- mapillary_tools/serializer/gpx.py +5 -1
- mapillary_tools/store.py +5 -0
- mapillary_tools/telemetry.py +108 -0
- mapillary_tools/types.py +6 -0
- mapillary_tools/upload.py +5 -0
- mapillary_tools/upload_api_v4.py +5 -0
- mapillary_tools/uploader.py +9 -0
- mapillary_tools/utils.py +17 -1
- {mapillary_tools-0.14.4.dist-info → mapillary_tools-0.14.6.dist-info}/METADATA +8 -1
- mapillary_tools-0.14.6.dist-info/RECORD +77 -0
- {mapillary_tools-0.14.4.dist-info → mapillary_tools-0.14.6.dist-info}/WHEEL +1 -1
- mapillary_tools-0.14.4.dist-info/RECORD +0 -77
- {mapillary_tools-0.14.4.dist-info → mapillary_tools-0.14.6.dist-info}/entry_points.txt +0 -0
- {mapillary_tools-0.14.4.dist-info → mapillary_tools-0.14.6.dist-info}/licenses/LICENSE +0 -0
- {mapillary_tools-0.14.4.dist-info → mapillary_tools-0.14.6.dist-info}/top_level.txt +0 -0
mapillary_tools/exif_write.py
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
# Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
#
|
|
3
|
+
# This source code is licensed under the BSD license found in the
|
|
4
|
+
# LICENSE file in the root directory of this source tree.
|
|
5
|
+
|
|
1
6
|
# pyre-ignore-all-errors[5, 21, 24]
|
|
2
7
|
from __future__ import annotations
|
|
3
8
|
|
|
@@ -6,6 +11,7 @@ import io
|
|
|
6
11
|
import json
|
|
7
12
|
import logging
|
|
8
13
|
import math
|
|
14
|
+
from fractions import Fraction
|
|
9
15
|
from pathlib import Path
|
|
10
16
|
|
|
11
17
|
import piexif
|
|
@@ -29,16 +35,19 @@ class ExifEdit:
|
|
|
29
35
|
|
|
30
36
|
@staticmethod
|
|
31
37
|
def decimal_to_dms(
|
|
32
|
-
value: float,
|
|
33
|
-
) -> tuple[tuple[
|
|
34
|
-
"""
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
sec = math.floor((value - deg - min / 60) * 3600 * precision)
|
|
38
|
+
value: float,
|
|
39
|
+
) -> tuple[tuple[int, int], tuple[int, int], tuple[int, int]]:
|
|
40
|
+
"""Convert decimal position to Exif degrees, minutes, and seconds rationals"""
|
|
41
|
+
|
|
42
|
+
deg: int = int(value)
|
|
43
|
+
min: int = int(value := (value - deg) * 60)
|
|
44
|
+
sec: float = (value - min) * 60
|
|
40
45
|
|
|
41
|
-
return (
|
|
46
|
+
return (
|
|
47
|
+
(deg, 1),
|
|
48
|
+
(min, 1),
|
|
49
|
+
(Fraction.from_float(sec).limit_denominator().as_integer_ratio()),
|
|
50
|
+
)
|
|
42
51
|
|
|
43
52
|
def add_image_description(self, data: dict) -> None:
|
|
44
53
|
"""Add a dict to image description."""
|
|
@@ -83,41 +92,69 @@ class ExifEdit:
|
|
|
83
92
|
self._ef["GPS"][piexif.GPSIFD.GPSTimeStamp] = (
|
|
84
93
|
(dt.hour, 1),
|
|
85
94
|
(dt.minute, 1),
|
|
86
|
-
|
|
87
|
-
|
|
95
|
+
(
|
|
96
|
+
Fraction.from_float(dt.second + dt.microsecond / 1e6)
|
|
97
|
+
.limit_denominator()
|
|
98
|
+
.as_integer_ratio()
|
|
99
|
+
),
|
|
88
100
|
)
|
|
101
|
+
if LOG.isEnabledFor(logging.DEBUG):
|
|
102
|
+
LOG.debug(
|
|
103
|
+
'GPSDateStamp: "%s"\tGPSTimeStamp: %s',
|
|
104
|
+
self._ef["GPS"][piexif.GPSIFD.GPSDateStamp],
|
|
105
|
+
self._ef["GPS"][piexif.GPSIFD.GPSTimeStamp],
|
|
106
|
+
)
|
|
89
107
|
|
|
90
|
-
def add_lat_lon(self, lat: float, lon: float
|
|
108
|
+
def add_lat_lon(self, lat: float, lon: float) -> None:
|
|
91
109
|
"""Add lat, lon to gps (lat, lon in float)."""
|
|
110
|
+
|
|
92
111
|
self._ef["GPS"][piexif.GPSIFD.GPSLatitudeRef] = "N" if lat > 0 else "S"
|
|
112
|
+
self._ef["GPS"][piexif.GPSIFD.GPSLatitude] = ExifEdit.decimal_to_dms(
|
|
113
|
+
math.fabs(lat)
|
|
114
|
+
)
|
|
93
115
|
self._ef["GPS"][piexif.GPSIFD.GPSLongitudeRef] = "E" if lon > 0 else "W"
|
|
94
116
|
self._ef["GPS"][piexif.GPSIFD.GPSLongitude] = ExifEdit.decimal_to_dms(
|
|
95
|
-
|
|
96
|
-
)
|
|
97
|
-
self._ef["GPS"][piexif.GPSIFD.GPSLatitude] = ExifEdit.decimal_to_dms(
|
|
98
|
-
abs(lat), int(precision)
|
|
117
|
+
math.fabs(lon)
|
|
99
118
|
)
|
|
119
|
+
if LOG.isEnabledFor(logging.DEBUG):
|
|
120
|
+
LOG.debug(
|
|
121
|
+
"GPSLatitude: %s\tGPSLongitude: %s",
|
|
122
|
+
self._ef["GPS"][piexif.GPSIFD.GPSLatitude],
|
|
123
|
+
self._ef["GPS"][piexif.GPSIFD.GPSLongitude],
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
def add_altitude(self, altitude: float) -> None:
|
|
127
|
+
"""Add altitude."""
|
|
100
128
|
|
|
101
|
-
def add_altitude(self, altitude: float, precision: int = 100) -> None:
|
|
102
|
-
"""Add altitude (pre is the precision)."""
|
|
103
129
|
ref = 0 if altitude > 0 else 1
|
|
104
130
|
self._ef["GPS"][piexif.GPSIFD.GPSAltitude] = (
|
|
105
|
-
|
|
106
|
-
|
|
131
|
+
Fraction.from_float(math.fabs(altitude))
|
|
132
|
+
.limit_denominator()
|
|
133
|
+
.as_integer_ratio()
|
|
107
134
|
)
|
|
108
135
|
self._ef["GPS"][piexif.GPSIFD.GPSAltitudeRef] = ref
|
|
136
|
+
if LOG.isEnabledFor(logging.DEBUG):
|
|
137
|
+
LOG.debug(
|
|
138
|
+
'GPSAltitudeRef: "%s"\tGPSAltitude: %s',
|
|
139
|
+
self._ef["GPS"][piexif.GPSIFD.GPSAltitudeRef],
|
|
140
|
+
self._ef["GPS"][piexif.GPSIFD.GPSAltitude],
|
|
141
|
+
)
|
|
109
142
|
|
|
110
|
-
def add_direction(
|
|
111
|
-
self, direction: float, ref: str = "T", precision: int = 100
|
|
112
|
-
) -> None:
|
|
143
|
+
def add_direction(self, direction: float, ref: str = "T") -> None:
|
|
113
144
|
"""Add image direction."""
|
|
145
|
+
|
|
114
146
|
# normalize direction
|
|
115
|
-
direction = direction
|
|
147
|
+
direction = math.fmod(direction, 360.0)
|
|
116
148
|
self._ef["GPS"][piexif.GPSIFD.GPSImgDirection] = (
|
|
117
|
-
|
|
118
|
-
precision,
|
|
149
|
+
Fraction.from_float(direction).limit_denominator().as_integer_ratio()
|
|
119
150
|
)
|
|
120
151
|
self._ef["GPS"][piexif.GPSIFD.GPSImgDirectionRef] = ref
|
|
152
|
+
if LOG.isEnabledFor(logging.DEBUG):
|
|
153
|
+
LOG.debug(
|
|
154
|
+
'GPSImgDirectionRef: "%s"\tGPSImgDirection: %s',
|
|
155
|
+
self._ef["GPS"][piexif.GPSIFD.GPSImgDirectionRef],
|
|
156
|
+
self._ef["GPS"][piexif.GPSIFD.GPSImgDirection],
|
|
157
|
+
)
|
|
121
158
|
|
|
122
159
|
def add_make(self, make: str) -> None:
|
|
123
160
|
if not make:
|
|
@@ -182,6 +219,18 @@ class ExifEdit:
|
|
|
182
219
|
else:
|
|
183
220
|
del self._ef[ifd][tag]
|
|
184
221
|
# retry later
|
|
222
|
+
elif "thumbnail is too large" in message.lower():
|
|
223
|
+
# Handle oversized thumbnails (max 64kB per EXIF spec)
|
|
224
|
+
if thumbnail_removed:
|
|
225
|
+
raise exc
|
|
226
|
+
LOG.debug(
|
|
227
|
+
"Thumbnail too large (max 64kB) -- removing thumbnail and 1st: %s",
|
|
228
|
+
exc,
|
|
229
|
+
)
|
|
230
|
+
del self._ef["thumbnail"]
|
|
231
|
+
del self._ef["1st"]
|
|
232
|
+
thumbnail_removed = True
|
|
233
|
+
# retry later
|
|
185
234
|
else:
|
|
186
235
|
raise exc
|
|
187
236
|
except Exception as exc:
|
mapillary_tools/exiftool_read.py
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
# Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
#
|
|
3
|
+
# This source code is licensed under the BSD license found in the
|
|
4
|
+
# LICENSE file in the root directory of this source tree.
|
|
5
|
+
|
|
1
6
|
from __future__ import annotations
|
|
2
7
|
|
|
3
8
|
import datetime
|
|
@@ -7,15 +12,19 @@ import xml.etree.ElementTree as ET
|
|
|
7
12
|
from pathlib import Path
|
|
8
13
|
|
|
9
14
|
from . import exif_read
|
|
15
|
+
from .utils import sanitize_serial
|
|
10
16
|
|
|
11
17
|
|
|
12
18
|
EXIFTOOL_NAMESPACES: dict[str, str] = {
|
|
13
19
|
"Adobe": "http://ns.exiftool.org/APP14/Adobe/1.0/",
|
|
14
20
|
"Apple": "http://ns.exiftool.org/MakerNotes/Apple/1.0/",
|
|
21
|
+
"Canon": "http://ns.exiftool.org/MakerNotes/Canon/1.0/",
|
|
15
22
|
"Composite": "http://ns.exiftool.org/Composite/1.0/",
|
|
16
23
|
"ExifIFD": "http://ns.exiftool.org/EXIF/ExifIFD/1.0/",
|
|
17
24
|
"ExifTool": "http://ns.exiftool.org/ExifTool/1.0/",
|
|
18
25
|
"File": "http://ns.exiftool.org/File/1.0/",
|
|
26
|
+
"FLIR": "http://ns.exiftool.org/APP1/FLIR/1.0/",
|
|
27
|
+
"FujiFilm": "http://ns.exiftool.org/MakerNotes/FujiFilm/1.0/",
|
|
19
28
|
"GPS": "http://ns.exiftool.org/EXIF/GPS/1.0/",
|
|
20
29
|
"GoPro": "http://ns.exiftool.org/APP6/GoPro/1.0/",
|
|
21
30
|
"ICC-chrm": "http://ns.exiftool.org/ICC_Profile/ICC-chrm/1.0/",
|
|
@@ -28,11 +37,20 @@ EXIFTOOL_NAMESPACES: dict[str, str] = {
|
|
|
28
37
|
"IPTC": "http://ns.exiftool.org/IPTC/IPTC/1.0/",
|
|
29
38
|
"InteropIFD": "http://ns.exiftool.org/EXIF/InteropIFD/1.0/",
|
|
30
39
|
"JFIF": "http://ns.exiftool.org/JFIF/JFIF/1.0/",
|
|
40
|
+
"Kodak": "http://ns.exiftool.org/MakerNotes/Kodak/1.0/",
|
|
41
|
+
"Leica": "http://ns.exiftool.org/MakerNotes/Leica/1.0/",
|
|
31
42
|
"MPF0": "http://ns.exiftool.org/MPF/MPF0/1.0/",
|
|
32
43
|
"MPImage1": "http://ns.exiftool.org/MPF/MPImage1/1.0/",
|
|
33
44
|
"MPImage2": "http://ns.exiftool.org/MPF/MPImage2/1.0/",
|
|
45
|
+
"Nikon": "http://ns.exiftool.org/MakerNotes/Nikon/1.0/",
|
|
46
|
+
"Olympus": "http://ns.exiftool.org/MakerNotes/Olympus/1.0/",
|
|
47
|
+
"Panasonic": "http://ns.exiftool.org/MakerNotes/Panasonic/1.0/",
|
|
48
|
+
"Pentax": "http://ns.exiftool.org/MakerNotes/Pentax/1.0/",
|
|
34
49
|
"Photoshop": "http://ns.exiftool.org/Photoshop/Photoshop/1.0/",
|
|
50
|
+
"Ricoh": "http://ns.exiftool.org/MakerNotes/Ricoh/1.0/",
|
|
35
51
|
"Samsung": "http://ns.exiftool.org/MakerNotes/Samsung/1.0/",
|
|
52
|
+
"Sigma": "http://ns.exiftool.org/MakerNotes/Sigma/1.0/",
|
|
53
|
+
"Sony": "http://ns.exiftool.org/MakerNotes/Sony/1.0/",
|
|
36
54
|
"System": "http://ns.exiftool.org/File/System/1.0/",
|
|
37
55
|
"XMP-GAudio": "http://ns.exiftool.org/XMP/XMP-GAudio/1.0/",
|
|
38
56
|
"XMP-GImage": "http://ns.exiftool.org/XMP/XMP-GImage/1.0/",
|
|
@@ -48,6 +66,8 @@ EXIFTOOL_NAMESPACES: dict[str, str] = {
|
|
|
48
66
|
"XMP-xmp": "http://ns.exiftool.org/XMP/XMP-xmp/1.0/",
|
|
49
67
|
"XMP-xmpMM": "http://ns.exiftool.org/XMP/XMP-xmpMM/1.0/",
|
|
50
68
|
"XMP-xmpNote": "http://ns.exiftool.org/XMP/XMP-xmpNote/1.0/",
|
|
69
|
+
"XMP-drone-dji": "http://ns.exiftool.org/XMP/XMP-drone-dji/1.0/",
|
|
70
|
+
"DJI": "http://ns.exiftool.org/MakerNotes/DJI/1.0/",
|
|
51
71
|
"rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
|
|
52
72
|
}
|
|
53
73
|
|
|
@@ -421,6 +441,75 @@ class ExifToolRead(exif_read.ExifReadABC):
|
|
|
421
441
|
return 1
|
|
422
442
|
return orientation
|
|
423
443
|
|
|
444
|
+
def extract_camera_uuid(self) -> str | None:
|
|
445
|
+
"""
|
|
446
|
+
Extract camera UUID from serial numbers.
|
|
447
|
+
Returns a composite ID from body serial and lens serial if available.
|
|
448
|
+
"""
|
|
449
|
+
# Try body serial number from various sources
|
|
450
|
+
body_serial = self._extract_alternative_fields(
|
|
451
|
+
[
|
|
452
|
+
# Standard EXIF tags (BodySerialNumber has priority over generic SerialNumber)
|
|
453
|
+
"ExifIFD:BodySerialNumber",
|
|
454
|
+
"ExifIFD:SerialNumber",
|
|
455
|
+
"IFD0:CameraSerialNumber",
|
|
456
|
+
"IFD0:SerialNumber",
|
|
457
|
+
# MakerNotes - camera specific
|
|
458
|
+
"Canon:SerialNumber",
|
|
459
|
+
"Canon:InternalSerialNumber",
|
|
460
|
+
"DJI:SerialNumber",
|
|
461
|
+
"XMP-drone-dji:CameraSerialNumber",
|
|
462
|
+
"XMP-drone-dji:DroneSerialNumber",
|
|
463
|
+
"FLIR:CameraSerialNumber",
|
|
464
|
+
"FujiFilm:InternalSerialNumber",
|
|
465
|
+
"GoPro:CameraSerialNumber",
|
|
466
|
+
"Kodak:SerialNumber",
|
|
467
|
+
"Leica:SerialNumber",
|
|
468
|
+
"Leica:InternalSerialNumber",
|
|
469
|
+
"Nikon:SerialNumber",
|
|
470
|
+
"Olympus:SerialNumber",
|
|
471
|
+
"Olympus:InternalSerialNumber",
|
|
472
|
+
"Panasonic:InternalSerialNumber",
|
|
473
|
+
"Pentax:SerialNumber",
|
|
474
|
+
"Pentax:InternalSerialNumber",
|
|
475
|
+
"Ricoh:SerialNumber",
|
|
476
|
+
"Ricoh:InternalSerialNumber",
|
|
477
|
+
"Ricoh:BodySerialNumber",
|
|
478
|
+
"Sigma:SerialNumber",
|
|
479
|
+
"Sony:InternalSerialNumber",
|
|
480
|
+
# XMP equivalents
|
|
481
|
+
"XMP-exif:SerialNumber",
|
|
482
|
+
"XMP-exif:BodySerialNumber",
|
|
483
|
+
"XMP-exifEX:SerialNumber",
|
|
484
|
+
"XMP-exif:CameraSerialNumber",
|
|
485
|
+
"XMP-exifEX:BodySerialNumber",
|
|
486
|
+
"XMP-aux:SerialNumber",
|
|
487
|
+
],
|
|
488
|
+
str,
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
# Try lens serial number
|
|
492
|
+
lens_serial = self._extract_alternative_fields(
|
|
493
|
+
[
|
|
494
|
+
"ExifIFD:LensSerialNumber",
|
|
495
|
+
"FLIR:LensSerialNumber",
|
|
496
|
+
"Olympus:LensSerialNumber",
|
|
497
|
+
"Panasonic:LensSerialNumber",
|
|
498
|
+
"Ricoh:LensSerialNumber",
|
|
499
|
+
"XMP-exifEX:LensSerialNumber",
|
|
500
|
+
"XMP-aux:LensSerialNumber",
|
|
501
|
+
],
|
|
502
|
+
str,
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
parts = [
|
|
506
|
+
s for s in [sanitize_serial(body_serial), sanitize_serial(lens_serial)] if s
|
|
507
|
+
]
|
|
508
|
+
|
|
509
|
+
if parts:
|
|
510
|
+
return "_".join(parts)
|
|
511
|
+
return None
|
|
512
|
+
|
|
424
513
|
def _extract_alternative_fields(
|
|
425
514
|
self,
|
|
426
515
|
fields: T.Sequence[str],
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
# Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
#
|
|
3
|
+
# This source code is licensed under the BSD license found in the
|
|
4
|
+
# LICENSE file in the root directory of this source tree.
|
|
5
|
+
|
|
1
6
|
from __future__ import annotations
|
|
2
7
|
|
|
3
8
|
import dataclasses
|
|
@@ -8,16 +13,23 @@ import xml.etree.ElementTree as ET
|
|
|
8
13
|
|
|
9
14
|
from . import exif_read, exiftool_read, geo
|
|
10
15
|
from .telemetry import GPSFix, GPSPoint
|
|
16
|
+
from .utils import sanitize_serial
|
|
11
17
|
|
|
12
18
|
|
|
13
19
|
MAX_TRACK_ID = 10
|
|
14
20
|
EXIFTOOL_NAMESPACES: dict[str, str] = {
|
|
15
21
|
"Keys": "http://ns.exiftool.org/QuickTime/Keys/1.0/",
|
|
16
22
|
"IFD0": "http://ns.exiftool.org/EXIF/IFD0/1.0/",
|
|
23
|
+
"ExifIFD": "http://ns.exiftool.org/EXIF/ExifIFD/1.0/",
|
|
17
24
|
"QuickTime": "http://ns.exiftool.org/QuickTime/QuickTime/1.0/",
|
|
18
25
|
"UserData": "http://ns.exiftool.org/QuickTime/UserData/1.0/",
|
|
19
26
|
"Insta360": "http://ns.exiftool.org/Trailer/Insta360/1.0/",
|
|
20
27
|
"GoPro": "http://ns.exiftool.org/QuickTime/GoPro/1.0/",
|
|
28
|
+
"Ricoh": "http://ns.exiftool.org/MakerNotes/Ricoh/1.0/",
|
|
29
|
+
"XMP-GSpherical": "http://ns.exiftool.org/XMP/XMP-GSpherical/1.0/",
|
|
30
|
+
"XMP-aux": "http://ns.exiftool.org/XMP/XMP-aux/1.0/",
|
|
31
|
+
"DJI": "http://ns.exiftool.org/MakerNotes/DJI/1.0/",
|
|
32
|
+
"XMP-drone-dji": "http://ns.exiftool.org/XMP/XMP-drone-dji/1.0/",
|
|
21
33
|
**{
|
|
22
34
|
f"Track{track_id}": f"http://ns.exiftool.org/QuickTime/Track{track_id}/1.0/"
|
|
23
35
|
for track_id in range(1, MAX_TRACK_ID + 1)
|
|
@@ -403,6 +415,50 @@ class ExifToolReadVideo:
|
|
|
403
415
|
_, model = self._extract_make_and_model()
|
|
404
416
|
return model
|
|
405
417
|
|
|
418
|
+
def extract_camera_uuid(self) -> str | None:
|
|
419
|
+
"""
|
|
420
|
+
Extract camera unique identifier from serial number tags in video metadata.
|
|
421
|
+
Builds a composite ID from body and lens serial numbers.
|
|
422
|
+
"""
|
|
423
|
+
# Try camera-specific serial numbers first
|
|
424
|
+
body_serial = self._extract_alternative_fields(
|
|
425
|
+
[
|
|
426
|
+
# Camera-specific tags
|
|
427
|
+
"GoPro:SerialNumber",
|
|
428
|
+
"GoPro:CameraSerialNumber",
|
|
429
|
+
"Ricoh:SerialNumber",
|
|
430
|
+
"XMP-GSpherical:PiDeviceSN", # Labpano cameras
|
|
431
|
+
"Insta360:SerialNumber",
|
|
432
|
+
"DJI:SerialNumber",
|
|
433
|
+
"XMP-drone-dji:CameraSerialNumber",
|
|
434
|
+
"XMP-drone-dji:DroneSerialNumber",
|
|
435
|
+
# Generic tags
|
|
436
|
+
"ExifIFD:BodySerialNumber",
|
|
437
|
+
"ExifIFD:SerialNumber",
|
|
438
|
+
"IFD0:SerialNumber",
|
|
439
|
+
"UserData:SerialNumber",
|
|
440
|
+
"XMP-aux:SerialNumber",
|
|
441
|
+
"UserData:SerialNumberHash",
|
|
442
|
+
],
|
|
443
|
+
str,
|
|
444
|
+
)
|
|
445
|
+
lens_serial = self._extract_alternative_fields(
|
|
446
|
+
[
|
|
447
|
+
"UserData:LensSerialNumber",
|
|
448
|
+
"ExifIFD:LensSerialNumber",
|
|
449
|
+
"XMP-aux:LensSerialNumber",
|
|
450
|
+
],
|
|
451
|
+
str,
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
parts = [
|
|
455
|
+
s for s in [sanitize_serial(body_serial), sanitize_serial(lens_serial)] if s
|
|
456
|
+
]
|
|
457
|
+
|
|
458
|
+
if parts:
|
|
459
|
+
return "_".join(parts)
|
|
460
|
+
return None
|
|
461
|
+
|
|
406
462
|
def _extract_gps_track_from_track(self) -> list[GPSPoint]:
|
|
407
463
|
root = self.etree.getroot()
|
|
408
464
|
if root is None:
|
mapillary_tools/ffmpeg.py
CHANGED
mapillary_tools/geo.py
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
# Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
#
|
|
3
|
+
# This source code is licensed under the BSD license found in the
|
|
4
|
+
# LICENSE file in the root directory of this source tree.
|
|
5
|
+
|
|
1
6
|
# pyre-ignore-all-errors[4]
|
|
2
7
|
from __future__ import annotations
|
|
3
8
|
|
|
@@ -32,6 +37,47 @@ class Point:
|
|
|
32
37
|
alt: float | None
|
|
33
38
|
angle: float | None
|
|
34
39
|
|
|
40
|
+
def get_gps_epoch_time(self) -> float | None:
|
|
41
|
+
"""
|
|
42
|
+
Return the GPS epoch time for this point.
|
|
43
|
+
Base Point class returns None, subclasses can override.
|
|
44
|
+
"""
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
def _calculate_weight_for_interpolation(self, other: "Point", t: float) -> float:
|
|
48
|
+
"""
|
|
49
|
+
Calculate interpolation weight between self and other at time t.
|
|
50
|
+
Returns 0 if both points have the same time regardless of t.
|
|
51
|
+
"""
|
|
52
|
+
if self.time == other.time:
|
|
53
|
+
return 0.0
|
|
54
|
+
return (t - self.time) / (other.time - self.time)
|
|
55
|
+
|
|
56
|
+
def interpolate_with(self, other: "Point", t: float) -> "Point":
|
|
57
|
+
"""
|
|
58
|
+
Create a new interpolated point between self and other at time t.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
other: The end point to interpolate towards
|
|
62
|
+
t: The target timestamp
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
A new Point with interpolated values
|
|
66
|
+
"""
|
|
67
|
+
weight = self._calculate_weight_for_interpolation(other, t)
|
|
68
|
+
|
|
69
|
+
lat = self.lat + (other.lat - self.lat) * weight
|
|
70
|
+
lon = self.lon + (other.lon - self.lon) * weight
|
|
71
|
+
angle = compute_bearing((self.lat, self.lon), (other.lat, other.lon))
|
|
72
|
+
|
|
73
|
+
alt: float | None
|
|
74
|
+
if self.alt is not None and other.alt is not None:
|
|
75
|
+
alt = self.alt + (other.alt - self.alt) * weight
|
|
76
|
+
else:
|
|
77
|
+
alt = None
|
|
78
|
+
|
|
79
|
+
return Point(time=t, lat=lat, lon=lon, alt=alt, angle=angle)
|
|
80
|
+
|
|
35
81
|
|
|
36
82
|
PointLike = T.TypeVar("PointLike", bound=Point)
|
|
37
83
|
|
|
@@ -52,17 +98,36 @@ def gps_distance(latlon_1: tuple[float, float], latlon_2: tuple[float, float]) -
|
|
|
52
98
|
|
|
53
99
|
|
|
54
100
|
def avg_speed(sequence: T.Sequence[PointLike]) -> float:
|
|
101
|
+
"""
|
|
102
|
+
Calculate average speed over a sequence of points.
|
|
103
|
+
Uses GPS epoch time when available (via get_gps_epoch_time()),
|
|
104
|
+
otherwise falls back to the time field.
|
|
105
|
+
Returns 0.0 for empty or single-element sequences.
|
|
106
|
+
Returns NaN if time difference is zero (undefined speed).
|
|
107
|
+
"""
|
|
108
|
+
# Need at least 2 points to calculate speed
|
|
109
|
+
if len(sequence) < 2:
|
|
110
|
+
return 0.0
|
|
111
|
+
|
|
55
112
|
total_distance = 0.0
|
|
56
113
|
for cur, nxt in pairwise(sequence):
|
|
57
114
|
total_distance += gps_distance((cur.lat, cur.lon), (nxt.lat, nxt.lon))
|
|
58
115
|
|
|
59
|
-
|
|
60
|
-
|
|
116
|
+
first = sequence[0]
|
|
117
|
+
last = sequence[-1]
|
|
118
|
+
|
|
119
|
+
# Try to use GPS epoch time if available (via polymorphic method)
|
|
120
|
+
first_gps_time = first.get_gps_epoch_time()
|
|
121
|
+
last_gps_time = last.get_gps_epoch_time()
|
|
122
|
+
|
|
123
|
+
if first_gps_time is not None and last_gps_time is not None:
|
|
124
|
+
time_diff = last_gps_time - first_gps_time
|
|
61
125
|
else:
|
|
62
|
-
|
|
126
|
+
# Fall back to time field if GPS epoch time not available
|
|
127
|
+
time_diff = last.time - first.time
|
|
63
128
|
|
|
64
129
|
if time_diff == 0.0:
|
|
65
|
-
return float("
|
|
130
|
+
return float("nan")
|
|
66
131
|
|
|
67
132
|
return total_distance / time_diff
|
|
68
133
|
|
|
@@ -141,7 +206,7 @@ def as_unix_time(dt: datetime.datetime | int | float) -> float:
|
|
|
141
206
|
|
|
142
207
|
if sys.version_info < (3, 10):
|
|
143
208
|
|
|
144
|
-
def interpolate(points: T.Sequence[
|
|
209
|
+
def interpolate(points: T.Sequence[PointLike], t: float, lo: int = 0) -> PointLike:
|
|
145
210
|
"""
|
|
146
211
|
Interpolate or extrapolate the point at time t along the sequence of points (sorted by time).
|
|
147
212
|
"""
|
|
@@ -152,12 +217,14 @@ if sys.version_info < (3, 10):
|
|
|
152
217
|
# for cur, nex in pairwise(points):
|
|
153
218
|
# assert cur.time <= nex.time, "Points not sorted"
|
|
154
219
|
|
|
155
|
-
|
|
156
|
-
|
|
220
|
+
times = [p.time for p in points]
|
|
221
|
+
# Use bisect_left on the times list
|
|
222
|
+
idx = bisect.bisect_left(times, t, lo=lo)
|
|
223
|
+
|
|
157
224
|
return _interpolate_at_segment_idx(points, t, idx)
|
|
158
225
|
else:
|
|
159
226
|
|
|
160
|
-
def interpolate(points: T.Sequence[
|
|
227
|
+
def interpolate(points: T.Sequence[PointLike], t: float, lo: int = 0) -> PointLike:
|
|
161
228
|
"""
|
|
162
229
|
Interpolate or extrapolate the point at time t along the sequence of points (sorted by time).
|
|
163
230
|
"""
|
|
@@ -172,18 +239,19 @@ else:
|
|
|
172
239
|
return _interpolate_at_segment_idx(points, t, idx)
|
|
173
240
|
|
|
174
241
|
|
|
175
|
-
class Interpolator:
|
|
242
|
+
class Interpolator(T.Generic[PointLike]):
|
|
176
243
|
"""
|
|
177
244
|
Interpolator for interpolating a sequence of timestamps incrementally.
|
|
245
|
+
Preserves the type of input points (Point, GPSPoint, or CAMMGPSPoint).
|
|
178
246
|
"""
|
|
179
247
|
|
|
180
|
-
tracks: T.Sequence[T.Sequence[
|
|
248
|
+
tracks: T.Sequence[T.Sequence[PointLike]]
|
|
181
249
|
track_idx: int
|
|
182
250
|
# interpolation starts from the lower bound point index in the current track
|
|
183
251
|
lo: int
|
|
184
252
|
prev_time: float | None
|
|
185
253
|
|
|
186
|
-
def __init__(self, tracks: T.Sequence[T.Sequence[
|
|
254
|
+
def __init__(self, tracks: T.Sequence[T.Sequence[PointLike]]):
|
|
187
255
|
# Remove empty tracks
|
|
188
256
|
self.tracks = [track for track in tracks if track]
|
|
189
257
|
|
|
@@ -204,7 +272,7 @@ class Interpolator:
|
|
|
204
272
|
|
|
205
273
|
@staticmethod
|
|
206
274
|
def _lsearch_left(
|
|
207
|
-
track: T.Sequence[
|
|
275
|
+
track: T.Sequence[PointLike], t: float, lo: int = 0, hi: int | None = None
|
|
208
276
|
) -> int:
|
|
209
277
|
"""
|
|
210
278
|
similar to bisect.bisect_left, but faster in the incremental search case
|
|
@@ -221,14 +289,14 @@ class Interpolator:
|
|
|
221
289
|
# assert track[lo - 1].time < t <= track[lo].time
|
|
222
290
|
return lo
|
|
223
291
|
|
|
224
|
-
def interpolate(self, t: float) ->
|
|
292
|
+
def interpolate(self, t: float) -> PointLike:
|
|
225
293
|
if self.prev_time is not None:
|
|
226
294
|
if not (self.prev_time <= t):
|
|
227
295
|
raise ValueError(
|
|
228
296
|
f"Require times to be monotonically increasing, but got {self.prev_time} then {t}"
|
|
229
297
|
)
|
|
230
298
|
|
|
231
|
-
interpolated:
|
|
299
|
+
interpolated: PointLike | None = None
|
|
232
300
|
|
|
233
301
|
while self.track_idx < len(self.tracks):
|
|
234
302
|
track = self.tracks[self.track_idx]
|
|
@@ -318,25 +386,17 @@ def _ecef_from_lla2(lat: float, lon: float) -> tuple[float, float, float]:
|
|
|
318
386
|
return x, y, z
|
|
319
387
|
|
|
320
388
|
|
|
321
|
-
def _interpolate_segment(start:
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
lat = start.lat + (end.lat - start.lat) * weight
|
|
328
|
-
lon = start.lon + (end.lon - start.lon) * weight
|
|
329
|
-
angle = compute_bearing((start.lat, start.lon), (end.lat, end.lon))
|
|
330
|
-
alt: float | None
|
|
331
|
-
if start.alt is not None and end.alt is not None:
|
|
332
|
-
alt = start.alt + (end.alt - start.alt) * weight
|
|
333
|
-
else:
|
|
334
|
-
alt = None
|
|
335
|
-
|
|
336
|
-
return Point(time=t, lat=lat, lon=lon, alt=alt, angle=angle)
|
|
389
|
+
def _interpolate_segment(start: PointLike, end: PointLike, t: float) -> PointLike:
|
|
390
|
+
"""
|
|
391
|
+
Interpolate between two points at time t, preserving the type of the input points.
|
|
392
|
+
Uses the polymorphic interpolate_with() method to support subclasses.
|
|
393
|
+
"""
|
|
394
|
+
return T.cast(PointLike, start.interpolate_with(end, t))
|
|
337
395
|
|
|
338
396
|
|
|
339
|
-
def _interpolate_at_segment_idx(
|
|
397
|
+
def _interpolate_at_segment_idx(
|
|
398
|
+
points: T.Sequence[PointLike], t: float, idx: int
|
|
399
|
+
) -> PointLike:
|
|
340
400
|
"""
|
|
341
401
|
Interpolate time t along the segment between idx - 1 and idx.
|
|
342
402
|
If idx is out of range, extrapolate it to the nearest segment (first or last).
|
mapillary_tools/geotag/base.py
CHANGED