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.
Files changed (78) hide show
  1. mapillary_tools/__init__.py +6 -1
  2. mapillary_tools/api_v4.py +5 -0
  3. mapillary_tools/authenticate.py +5 -1
  4. mapillary_tools/blackvue_parser.py +138 -20
  5. mapillary_tools/camm/camm_builder.py +5 -1
  6. mapillary_tools/camm/camm_parser.py +5 -0
  7. mapillary_tools/commands/__init__.py +5 -0
  8. mapillary_tools/commands/__main__.py +5 -0
  9. mapillary_tools/commands/authenticate.py +5 -0
  10. mapillary_tools/commands/process.py +12 -0
  11. mapillary_tools/commands/process_and_upload.py +5 -1
  12. mapillary_tools/commands/sample_video.py +5 -0
  13. mapillary_tools/commands/upload.py +5 -0
  14. mapillary_tools/commands/video_process.py +5 -0
  15. mapillary_tools/commands/video_process_and_upload.py +5 -1
  16. mapillary_tools/commands/zip.py +5 -0
  17. mapillary_tools/config.py +5 -0
  18. mapillary_tools/constants.py +13 -1
  19. mapillary_tools/exceptions.py +9 -0
  20. mapillary_tools/exif_read.py +89 -0
  21. mapillary_tools/exif_write.py +75 -26
  22. mapillary_tools/exiftool_read.py +89 -0
  23. mapillary_tools/exiftool_read_video.py +56 -0
  24. mapillary_tools/exiftool_runner.py +5 -0
  25. mapillary_tools/ffmpeg.py +5 -0
  26. mapillary_tools/geo.py +91 -31
  27. mapillary_tools/geotag/__init__.py +4 -0
  28. mapillary_tools/geotag/base.py +5 -0
  29. mapillary_tools/geotag/factory.py +5 -0
  30. mapillary_tools/geotag/geotag_images_from_exif.py +5 -0
  31. mapillary_tools/geotag/geotag_images_from_exiftool.py +5 -0
  32. mapillary_tools/geotag/geotag_images_from_gpx.py +5 -0
  33. mapillary_tools/geotag/geotag_images_from_gpx_file.py +5 -0
  34. mapillary_tools/geotag/geotag_images_from_nmea_file.py +5 -0
  35. mapillary_tools/geotag/geotag_images_from_video.py +6 -0
  36. mapillary_tools/geotag/geotag_videos_from_exiftool.py +5 -0
  37. mapillary_tools/geotag/geotag_videos_from_gpx.py +5 -0
  38. mapillary_tools/geotag/geotag_videos_from_video.py +5 -0
  39. mapillary_tools/geotag/image_extractors/base.py +5 -0
  40. mapillary_tools/geotag/image_extractors/exif.py +6 -0
  41. mapillary_tools/geotag/image_extractors/exiftool.py +5 -0
  42. mapillary_tools/geotag/options.py +5 -0
  43. mapillary_tools/geotag/utils.py +5 -0
  44. mapillary_tools/geotag/video_extractors/base.py +5 -0
  45. mapillary_tools/geotag/video_extractors/exiftool.py +7 -0
  46. mapillary_tools/geotag/video_extractors/gpx.py +5 -0
  47. mapillary_tools/geotag/video_extractors/native.py +5 -0
  48. mapillary_tools/gpmf/gpmf_gps_filter.py +5 -0
  49. mapillary_tools/gpmf/gpmf_parser.py +5 -0
  50. mapillary_tools/gpmf/gps_filter.py +5 -0
  51. mapillary_tools/history.py +5 -0
  52. mapillary_tools/http.py +5 -1
  53. mapillary_tools/ipc.py +5 -0
  54. mapillary_tools/mp4/__init__.py +4 -0
  55. mapillary_tools/mp4/construct_mp4_parser.py +5 -0
  56. mapillary_tools/mp4/io_utils.py +5 -0
  57. mapillary_tools/mp4/mp4_sample_parser.py +20 -1
  58. mapillary_tools/mp4/simple_mp4_builder.py +5 -0
  59. mapillary_tools/mp4/simple_mp4_parser.py +5 -0
  60. mapillary_tools/process_geotag_properties.py +5 -0
  61. mapillary_tools/process_sequence_properties.py +213 -31
  62. mapillary_tools/sample_video.py +13 -1
  63. mapillary_tools/serializer/description.py +13 -0
  64. mapillary_tools/serializer/gpx.py +5 -1
  65. mapillary_tools/store.py +5 -0
  66. mapillary_tools/telemetry.py +108 -0
  67. mapillary_tools/types.py +6 -0
  68. mapillary_tools/upload.py +5 -0
  69. mapillary_tools/upload_api_v4.py +5 -0
  70. mapillary_tools/uploader.py +9 -0
  71. mapillary_tools/utils.py +17 -1
  72. {mapillary_tools-0.14.4.dist-info → mapillary_tools-0.14.6.dist-info}/METADATA +8 -1
  73. mapillary_tools-0.14.6.dist-info/RECORD +77 -0
  74. {mapillary_tools-0.14.4.dist-info → mapillary_tools-0.14.6.dist-info}/WHEEL +1 -1
  75. mapillary_tools-0.14.4.dist-info/RECORD +0 -77
  76. {mapillary_tools-0.14.4.dist-info → mapillary_tools-0.14.6.dist-info}/entry_points.txt +0 -0
  77. {mapillary_tools-0.14.4.dist-info → mapillary_tools-0.14.6.dist-info}/licenses/LICENSE +0 -0
  78. {mapillary_tools-0.14.4.dist-info → mapillary_tools-0.14.6.dist-info}/top_level.txt +0 -0
@@ -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, precision: int
33
- ) -> tuple[tuple[float, int], tuple[float, int], tuple[float, int]]:
34
- """
35
- Convert decimal position to degrees, minutes, seconds in a fromat supported by EXIF
36
- """
37
- deg = math.floor(value)
38
- min = math.floor((value - deg) * 60)
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 (deg, 1), (min, 1), (sec, precision)
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
- # num / den = (dt.second * 1e6 + dt.microsecond) / 1e6
87
- (int(dt.second * 1e6 + dt.microsecond), int(1e6)),
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, precision: float = 1e7) -> None:
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
- abs(lon), int(precision)
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
- int(abs(altitude) * precision),
106
- precision,
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 % 360.0
147
+ direction = math.fmod(direction, 360.0)
116
148
  self._ef["GPS"][piexif.GPSIFD.GPSImgDirection] = (
117
- int(abs(direction) * precision),
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:
@@ -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:
@@ -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 subprocess
mapillary_tools/ffmpeg.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, 24]
2
7
  from __future__ import annotations
3
8
 
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
- if sequence:
60
- time_diff = sequence[-1].time - sequence[0].time
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
- time_diff = 0.0
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("inf")
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[Point], t: float, lo: int = 0) -> Point:
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
- p = Point(time=t, lat=float("-inf"), lon=float("-inf"), alt=None, angle=None)
156
- idx = bisect.bisect_left(points, p, lo=lo)
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[Point], t: float, lo: int = 0) -> Point:
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[Point]]
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[Point]]):
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[Point], t: float, lo: int = 0, hi: int | None = None
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) -> Point:
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: Point | None = None
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: Point, end: Point, t: float) -> Point:
322
- try:
323
- weight = (t - start.time) / (end.time - start.time)
324
- except ZeroDivisionError:
325
- weight = 0.0
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(points: T.Sequence[Point], t: float, idx: int) -> Point:
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).
@@ -0,0 +1,4 @@
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.
@@ -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 abc
@@ -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 json
@@ -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 logging
@@ -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 logging
@@ -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
@@ -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 logging
@@ -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