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 +1,6 @@
1
- VERSION = "0.14.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.
5
+
6
+ VERSION = "0.14.6"
mapillary_tools/api_v4.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 enum
@@ -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 getpass
@@ -7,7 +12,6 @@ import sys
7
12
  import typing as T
8
13
 
9
14
  import jsonschema
10
-
11
15
  import requests
12
16
 
13
17
  from . import api_v4, config, constants, exceptions, http
@@ -1,7 +1,12 @@
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
4
-
9
+ import datetime
5
10
  import json
6
11
  import logging
7
12
  import re
@@ -9,7 +14,7 @@ import typing as T
9
14
 
10
15
  import pynmea2
11
16
 
12
- from . import geo
17
+ from . import telemetry
13
18
  from .mp4 import simple_mp4_parser as sparser
14
19
 
15
20
 
@@ -32,7 +37,7 @@ NMEA_LINE_REGEX = re.compile(
32
37
  class BlackVueInfo:
33
38
  # None and [] are equivalent here. Use None as default because:
34
39
  # ValueError: mutable default <class 'list'> for field gps is not allowed: use default_factory
35
- gps: list[geo.Point] | None = None
40
+ gps: list[telemetry.GPSPoint] | None = None
36
41
  make: str = "BlackVue"
37
42
  model: str = ""
38
43
 
@@ -50,9 +55,11 @@ def extract_blackvue_info(fp: T.BinaryIO) -> BlackVueInfo | None:
50
55
  points.sort(key=lambda p: p.time)
51
56
 
52
57
  if points:
58
+ # Convert the time field to relative time to the first point
59
+ # epoch_time stays as the original time in seconds
53
60
  first_point_time = points[0].time
54
61
  for p in points:
55
- p.time = (p.time - first_point_time) / 1000
62
+ p.time = p.time - first_point_time
56
63
 
57
64
  # Camera model
58
65
  try:
@@ -114,25 +121,64 @@ def _extract_camera_model_from_cprt(cprt_bytes: bytes) -> str:
114
121
  return ""
115
122
 
116
123
 
117
- def _parse_gps_box(gps_data: bytes) -> list[geo.Point]:
124
+ def _compute_timezone_offset_from_rmc(
125
+ epoch_sec: float, message: pynmea2.NMEASentence
126
+ ) -> float | None:
118
127
  """
119
- >>> list(_parse_gps_box(b"[1623057074211]$GPGGA,202530.00,5109.0262,N,11401.8407,W,5,40,0.5,1097.36,M,-17.00,M,18,TSTR*61"))
120
- [Point(time=1623057074211, lat=51.150436666666664, lon=-114.03067833333333, alt=1097.36, angle=None)]
128
+ Compute timezone offset from an RMC message which has full date+time.
121
129
 
122
- >>> list(_parse_gps_box(b"[1629874404069]$GNGGA,175322.00,3244.53126,N,11710.97811,W,1,12,0.84,17.4,M,-34.0,M,,*45"))
123
- [Point(time=1629874404069, lat=32.742187666666666, lon=-117.1829685, alt=17.4, angle=None)]
130
+ Returns the offset to add to camera epoch to get correct UTC time,
131
+ or None if this message doesn't have the required datetime.
132
+ """
133
+ if (
134
+ message.sentence_type != "RMC"
135
+ or not hasattr(message, "datetime")
136
+ or not message.datetime
137
+ ):
138
+ return None
124
139
 
125
- >>> list(_parse_gps_box(b"[1629874404069]$GNGLL,4404.14012,N,12118.85993,W,001037.00,A,A*67"))
126
- [Point(time=1629874404069, lat=44.069002, lon=-121.31433216666667, alt=None, angle=None)]
140
+ correct_epoch = message.datetime.replace(tzinfo=datetime.timezone.utc).timestamp()
141
+ # Rounding needed to avoid floating point precision issues
142
+ return round(correct_epoch - epoch_sec, 3)
127
143
 
128
- >>> list(_parse_gps_box(b"[1629874404069]$GNRMC,001031.00,A,4404.13993,N,12118.86023,W,0.146,,100117,,,A*7B"))
129
- [Point(time=1629874404069, lat=44.06899883333333, lon=-121.31433716666666, alt=None, angle=None)]
130
144
 
131
- >>> list(_parse_gps_box(b"[1623057074211]$GPVTG,,T,,M,0.078,N,0.144,K,D*28[1623057075215]"))
132
- []
145
+ def _compute_timezone_offset_from_time_only(
146
+ epoch_sec: float, message: pynmea2.NMEASentence
147
+ ) -> float | None:
148
+ """
149
+ Compute timezone offset from GGA/GLL which only have time (no date).
150
+
151
+ Uses the date from camera epoch and replaces the time with NMEA time assuming camera date is correct.
152
+ Handles day boundary when camera and GPS times differ by more than 12 hours.
133
153
  """
134
- points_by_sentence_type: dict[str, list[geo.Point]] = {}
154
+ if not hasattr(message, "timestamp") or not message.timestamp:
155
+ return None
135
156
 
157
+ camera_dt = datetime.datetime.fromtimestamp(epoch_sec, tz=datetime.timezone.utc)
158
+
159
+ nmea_time = message.timestamp
160
+ corrected_dt = camera_dt.replace(
161
+ hour=nmea_time.hour,
162
+ minute=nmea_time.minute,
163
+ second=nmea_time.second,
164
+ microsecond=getattr(nmea_time, "microsecond", 0),
165
+ )
166
+ # Handle day boundary e.g. camera time is 23:00, GPS time is 01:00 or vice versa
167
+ camera_secs = camera_dt.hour * 3600 + camera_dt.minute * 60 + camera_dt.second
168
+ nmea_secs = nmea_time.hour * 3600 + nmea_time.minute * 60 + nmea_time.second
169
+ if camera_secs - nmea_secs > 12 * 3600:
170
+ corrected_dt += datetime.timedelta(days=1)
171
+ elif nmea_secs - camera_secs > 12 * 3600:
172
+ corrected_dt -= datetime.timedelta(days=1)
173
+
174
+ # Rounding needed to avoid floating point precision issues
175
+ return round(corrected_dt.timestamp() - epoch_sec, 3)
176
+
177
+
178
+ def _parse_nmea_lines(
179
+ gps_data: bytes,
180
+ ) -> T.Iterator[tuple[int, pynmea2.NMEASentence]]:
181
+ """Parse NMEA lines from GPS data, yielding (epoch_ms, message) tuples."""
136
182
  for line_bytes in gps_data.splitlines():
137
183
  match = NMEA_LINE_REGEX.match(line_bytes)
138
184
  if match is None:
@@ -156,29 +202,101 @@ def _parse_gps_box(gps_data: bytes) -> list[geo.Point]:
156
202
  continue
157
203
 
158
204
  epoch_ms = int(match.group(1))
205
+ yield epoch_ms, message
206
+
207
+
208
+ def _parse_gps_box(gps_data: bytes) -> list[telemetry.GPSPoint]:
209
+ """
210
+ >>> list(_parse_gps_box(b"[1623057074211]$GPGGA,202530.00,5109.0262,N,11401.8407,W,5,40,0.5,1097.36,M,-17.00,M,18,TSTR*61"))
211
+ [GPSPoint(time=1623097530.0, lat=51.150436666666664, lon=-114.03067833333333, alt=1097.36, angle=None, epoch_time=1623097530.0, fix=<GPSFix.FIX_3D: 3>, precision=None, ground_speed=None)]
212
+
213
+ >>> list(_parse_gps_box(b"[1629874404069]$GNGGA,175322.00,3244.53126,N,11710.97811,W,1,12,0.84,17.4,M,-34.0,M,,*45"))
214
+ [GPSPoint(time=1629914002.0, lat=32.742187666666666, lon=-117.1829685, alt=17.4, angle=None, epoch_time=1629914002.0, fix=<GPSFix.FIX_3D: 3>, precision=None, ground_speed=None)]
215
+
216
+ >>> list(_parse_gps_box(b"[1629874404069]$GNGLL,4404.14012,N,12118.85993,W,001037.00,A,A*67"))
217
+ [GPSPoint(time=1629850237.0, lat=44.069002, lon=-121.31433216666667, alt=None, angle=None, epoch_time=1629850237.0, fix=None, precision=None, ground_speed=None)]
218
+
219
+ >>> list(_parse_gps_box(b"[1629874404069]$GNRMC,001031.00,A,4404.13993,N,12118.86023,W,0.146,,100117,,,A*7B"))
220
+ [GPSPoint(time=1484007031.0, lat=44.06899883333333, lon=-121.31433716666666, alt=None, angle=None, epoch_time=1484007031.0, fix=None, precision=None, ground_speed=None)]
221
+
222
+ >>> list(_parse_gps_box(b"[1623057074211]$GPVTG,,T,,M,0.078,N,0.144,K,D*28[1623057075215]"))
223
+ []
224
+ """
225
+ timezone_offset: float | None = None
226
+ parsed_lines: list[tuple[float, pynmea2.NMEASentence]] = []
227
+ first_valid_gga_gll: tuple[float, pynmea2.NMEASentence] | None = None
228
+
229
+ # First pass: collect parsed_lines and compute timezone offset from the first valid RMC message
230
+ for epoch_ms, message in _parse_nmea_lines(gps_data):
231
+ # Rounding needed to avoid floating point precision issues
232
+ epoch_sec = round(epoch_ms / 1000, 3)
233
+ parsed_lines.append((epoch_sec, message))
234
+ if timezone_offset is None and message.sentence_type == "RMC":
235
+ if hasattr(message, "is_valid") and message.is_valid:
236
+ timezone_offset = _compute_timezone_offset_from_rmc(epoch_sec, message)
237
+ if timezone_offset is not None:
238
+ LOG.debug(
239
+ "Computed timezone offset %.1fs from RMC (%s %s)",
240
+ timezone_offset,
241
+ message.datestamp,
242
+ message.timestamp,
243
+ )
244
+ # Track first valid GGA/GLL for fallback
245
+ if first_valid_gga_gll is None and message.sentence_type in ["GGA", "GLL"]:
246
+ if hasattr(message, "is_valid") and message.is_valid:
247
+ first_valid_gga_gll = (epoch_sec, message)
248
+
249
+ # Fallback: if no RMC found, try GGA/GLL (less reliable - no date info)
250
+ if timezone_offset is None and first_valid_gga_gll is not None:
251
+ epoch_sec, message = first_valid_gga_gll
252
+ timezone_offset = _compute_timezone_offset_from_time_only(epoch_sec, message)
253
+ if timezone_offset is not None:
254
+ LOG.debug(
255
+ "Computed timezone offset %.1fs from %s (fallback, no date info)",
256
+ timezone_offset,
257
+ message.sentence_type,
258
+ )
259
+
260
+ # If no offset could be determined, use 0 (camera clock assumed correct)
261
+ if timezone_offset is None:
262
+ timezone_offset = 0.0
263
+
264
+ points_by_sentence_type: dict[str, list[telemetry.GPSPoint]] = {}
265
+
266
+ # Second pass: apply offset to all GPS points
267
+ for epoch_sec, message in parsed_lines:
268
+ corrected_epoch = round(epoch_sec + timezone_offset, 3)
159
269
 
160
270
  # https://tavotech.com/gps-nmea-sentence-structure/
161
271
  if message.sentence_type in ["GGA"]:
162
272
  if not message.is_valid:
163
273
  continue
164
- point = geo.Point(
165
- time=epoch_ms,
274
+ point = telemetry.GPSPoint(
275
+ time=corrected_epoch,
166
276
  lat=message.latitude,
167
277
  lon=message.longitude,
168
278
  alt=message.altitude,
169
279
  angle=None,
280
+ epoch_time=corrected_epoch,
281
+ fix=telemetry.GPSFix.FIX_3D if message.gps_qual >= 1 else None,
282
+ precision=None,
283
+ ground_speed=None,
170
284
  )
171
285
  points_by_sentence_type.setdefault(message.sentence_type, []).append(point)
172
286
 
173
287
  elif message.sentence_type in ["RMC", "GLL"]:
174
288
  if not message.is_valid:
175
289
  continue
176
- point = geo.Point(
177
- time=epoch_ms,
290
+ point = telemetry.GPSPoint(
291
+ time=corrected_epoch,
178
292
  lat=message.latitude,
179
293
  lon=message.longitude,
180
294
  alt=None,
181
295
  angle=None,
296
+ epoch_time=corrected_epoch,
297
+ fix=None,
298
+ precision=None,
299
+ ground_speed=None,
182
300
  )
183
301
  points_by_sentence_type.setdefault(message.sentence_type, []).append(point)
184
302
 
@@ -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 io
@@ -9,7 +14,6 @@ from ..mp4 import (
9
14
  mp4_sample_parser as sample_parser,
10
15
  simple_mp4_builder as builder,
11
16
  )
12
-
13
17
  from . import camm_parser
14
18
 
15
19
 
@@ -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, 11, 16, 21, 24, 58]
2
7
  from __future__ import annotations
3
8
 
@@ -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
  # ruff: noqa: F401
2
7
  from . import (
3
8
  authenticate,
@@ -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
  import argparse
2
7
  import enum
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
  import argparse
2
7
  import inspect
3
8
 
@@ -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 argparse
@@ -203,6 +208,13 @@ class Command:
203
208
  default=constants.DUPLICATE_ANGLE,
204
209
  required=False,
205
210
  )
211
+ group_sequence.add_argument(
212
+ "--skip_zigzag_check",
213
+ help="Skip the GPS zig-zag pattern detection check.",
214
+ action="store_true",
215
+ default=False,
216
+ required=False,
217
+ )
206
218
 
207
219
  def run(self, vars_args: dict):
208
220
  metadatas = process_geotag_properties(
@@ -1,7 +1,11 @@
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
  import inspect
2
7
 
3
8
  from ..authenticate import fetch_user_items
4
-
5
9
  from .process import Command as ProcessCommand
6
10
  from .upload import Command as UploadCommand
7
11
 
@@ -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
  import argparse
2
7
  import inspect
3
8
  from pathlib import Path
@@ -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
  import inspect
2
7
 
3
8
  from .. import constants
@@ -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
  import logging
2
7
 
3
8
  from ..types import FileType
@@ -1,7 +1,11 @@
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
  import inspect
2
7
 
3
8
  from ..authenticate import fetch_user_items
4
-
5
9
  from .upload import Command as UploadCommand
6
10
  from .video_process import Command as VideoProcessCommand
7
11
 
@@ -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
  import argparse
2
7
  import inspect
3
8
  from pathlib import Path
mapillary_tools/config.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 configparser
@@ -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 functools
@@ -115,7 +120,7 @@ MAPILLARY__EXPERIMENTAL_ENABLE_IMU: bool = _yes_or_no(
115
120
  ###### SEQUENCE PROCESSING ######
116
121
  #################################
117
122
  # In meters
118
- CUTOFF_DISTANCE = float(os.getenv(_ENV_PREFIX + "CUTOFF_DISTANCE", 600))
123
+ CUTOFF_DISTANCE = float(os.getenv(_ENV_PREFIX + "CUTOFF_DISTANCE", 100))
119
124
  # In seconds
120
125
  CUTOFF_TIME = float(os.getenv(_ENV_PREFIX + "CUTOFF_TIME", 60))
121
126
  DUPLICATE_DISTANCE = float(os.getenv(_ENV_PREFIX + "DUPLICATE_DISTANCE", 0.1))
@@ -136,6 +141,13 @@ MAX_SEQUENCE_FILESIZE: int | None = _parse_filesize(
136
141
  MAX_SEQUENCE_PIXELS: int | None = _parse_pixels(
137
142
  os.getenv(_ENV_PREFIX + "MAX_SEQUENCE_PIXELS", "6G")
138
143
  )
144
+ # Zig-zag detection parameters
145
+ ZIGZAG_WINDOW_SIZE = int(os.getenv(_ENV_PREFIX + "ZIGZAG_WINDOW_SIZE", 5))
146
+ ZIGZAG_DEVIATION_THRESHOLD = float(
147
+ os.getenv(_ENV_PREFIX + "ZIGZAG_DEVIATION_THRESHOLD", 0.8)
148
+ )
149
+ ZIGZAG_MIN_DEVIATIONS = int(os.getenv(_ENV_PREFIX + "ZIGZAG_MIN_DEVIATIONS", 1))
150
+ ZIGZAG_MIN_DISTANCE = float(os.getenv(_ENV_PREFIX + "ZIGZAG_MIN_DISTANCE", 30))
139
151
 
140
152
 
141
153
  ##################
@@ -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 typing as T
@@ -107,6 +112,10 @@ class MapillaryNullIslandError(MapillaryDescriptionError):
107
112
  pass
108
113
 
109
114
 
115
+ class MapillaryZigZagError(MapillaryDescriptionError):
116
+ pass
117
+
118
+
110
119
  class MapillaryUploadConnectionError(MapillaryUserError):
111
120
  exit_code = 12
112
121
 
@@ -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
@@ -14,6 +19,8 @@ from pathlib import Path
14
19
  import exifread
15
20
  from exifread.utils import Ratio
16
21
 
22
+ from .utils import sanitize_serial
23
+
17
24
 
18
25
  LOG = logging.getLogger(__name__)
19
26
  XMP_NAMESPACES = {
@@ -24,6 +31,7 @@ XMP_NAMESPACES = {
24
31
  "rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
25
32
  "x": "adobe:ns:meta/",
26
33
  "GPano": "http://ns.google.com/photos/1.0/panorama/",
34
+ "aux": "http://ns.adobe.com/exif/1.0/aux/",
27
35
  }
28
36
  # https://github.com/ianare/exif-py/issues/167
29
37
  EXIFREAD_LOG = logging.getLogger("exifread")
@@ -329,6 +337,10 @@ class ExifReadABC(abc.ABC):
329
337
  def extract_orientation(self) -> int:
330
338
  raise NotImplementedError
331
339
 
340
+ @abc.abstractmethod
341
+ def extract_camera_uuid(self) -> str | None:
342
+ raise NotImplementedError
343
+
332
344
 
333
345
  class ExifReadFromXMP(ExifReadABC):
334
346
  def __init__(self, etree: et.ElementTree):
@@ -477,6 +489,39 @@ class ExifReadFromXMP(ExifReadABC):
477
489
  return 1
478
490
  return orientation
479
491
 
492
+ def extract_camera_uuid(self) -> str | None:
493
+ """
494
+ Extract camera unique identifier from serial number tags in XMP.
495
+ Builds a composite ID from body and lens serial numbers.
496
+ """
497
+ body_serial = self._extract_alternative_fields(
498
+ [
499
+ "exif:SerialNumber",
500
+ "exif:BodySerialNumber",
501
+ "exif:CameraSerialNumber",
502
+ "exifEX:SerialNumber",
503
+ "exifEX:BodySerialNumber",
504
+ "aux:SerialNumber",
505
+ ],
506
+ str,
507
+ )
508
+ lens_serial = self._extract_alternative_fields(
509
+ [
510
+ "exif:LensSerialNumber",
511
+ "exifEX:LensSerialNumber",
512
+ "aux:LensSerialNumber",
513
+ ],
514
+ str,
515
+ )
516
+
517
+ parts = [
518
+ s for s in [sanitize_serial(body_serial), sanitize_serial(lens_serial)] if s
519
+ ]
520
+
521
+ if parts:
522
+ return "_".join(parts)
523
+ return None
524
+
480
525
  def _extract_alternative_fields(
481
526
  self,
482
527
  fields: T.Iterable[str],
@@ -811,6 +856,38 @@ class ExifReadFromEXIF(ExifReadABC):
811
856
  return 1
812
857
  return orientation
813
858
 
859
+ def extract_camera_uuid(self) -> str | None:
860
+ """
861
+ Extract camera unique identifier from serial number EXIF tags.
862
+ Builds a composite ID from body and lens serial numbers.
863
+ """
864
+ body_serial = self._extract_alternative_fields(
865
+ [
866
+ "EXIF BodySerialNumber",
867
+ "EXIF SerialNumber",
868
+ "EXIF CameraSerialNumber",
869
+ "Image BodySerialNumber",
870
+ "MakerNote SerialNumber",
871
+ "MakerNote InternalSerialNumber",
872
+ ],
873
+ str,
874
+ )
875
+ lens_serial = self._extract_alternative_fields(
876
+ [
877
+ "EXIF LensSerialNumber",
878
+ "Image LensSerialNumber",
879
+ ],
880
+ str,
881
+ )
882
+
883
+ parts = [
884
+ s for s in [sanitize_serial(body_serial), sanitize_serial(lens_serial)] if s
885
+ ]
886
+
887
+ if parts:
888
+ return "_".join(parts)
889
+ return None
890
+
814
891
  def _extract_alternative_fields(
815
892
  self,
816
893
  fields: T.Iterable[str],
@@ -982,3 +1059,15 @@ class ExifRead(ExifReadFromEXIF):
982
1059
  if val is not None:
983
1060
  return val
984
1061
  return None
1062
+
1063
+ def extract_camera_uuid(self) -> str | None:
1064
+ val = super().extract_camera_uuid()
1065
+ if val is not None:
1066
+ return val
1067
+ xmp = self._xmp_with_reason("camera_uuid")
1068
+ if xmp is None:
1069
+ return None
1070
+ val = xmp.extract_camera_uuid()
1071
+ if val is not None:
1072
+ return val
1073
+ return None