mapillary-tools 0.13.3a1__py3-none-any.whl → 0.14.0a2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. mapillary_tools/__init__.py +1 -1
  2. mapillary_tools/api_v4.py +237 -16
  3. mapillary_tools/authenticate.py +325 -64
  4. mapillary_tools/{geotag/blackvue_parser.py → blackvue_parser.py} +74 -54
  5. mapillary_tools/camm/camm_builder.py +55 -97
  6. mapillary_tools/camm/camm_parser.py +429 -181
  7. mapillary_tools/commands/__main__.py +12 -6
  8. mapillary_tools/commands/authenticate.py +8 -1
  9. mapillary_tools/commands/process.py +27 -51
  10. mapillary_tools/commands/process_and_upload.py +19 -5
  11. mapillary_tools/commands/sample_video.py +2 -3
  12. mapillary_tools/commands/upload.py +18 -9
  13. mapillary_tools/commands/video_process_and_upload.py +19 -5
  14. mapillary_tools/config.py +31 -13
  15. mapillary_tools/constants.py +47 -6
  16. mapillary_tools/exceptions.py +34 -35
  17. mapillary_tools/exif_read.py +221 -116
  18. mapillary_tools/exif_write.py +7 -7
  19. mapillary_tools/exiftool_read.py +33 -42
  20. mapillary_tools/exiftool_read_video.py +46 -33
  21. mapillary_tools/exiftool_runner.py +77 -0
  22. mapillary_tools/ffmpeg.py +24 -23
  23. mapillary_tools/geo.py +144 -120
  24. mapillary_tools/geotag/base.py +147 -0
  25. mapillary_tools/geotag/factory.py +291 -0
  26. mapillary_tools/geotag/geotag_images_from_exif.py +14 -131
  27. mapillary_tools/geotag/geotag_images_from_exiftool.py +126 -82
  28. mapillary_tools/geotag/geotag_images_from_gpx.py +53 -118
  29. mapillary_tools/geotag/geotag_images_from_gpx_file.py +13 -126
  30. mapillary_tools/geotag/geotag_images_from_nmea_file.py +4 -5
  31. mapillary_tools/geotag/geotag_images_from_video.py +53 -51
  32. mapillary_tools/geotag/geotag_videos_from_exiftool.py +97 -0
  33. mapillary_tools/geotag/geotag_videos_from_gpx.py +39 -0
  34. mapillary_tools/geotag/geotag_videos_from_video.py +20 -185
  35. mapillary_tools/geotag/image_extractors/base.py +18 -0
  36. mapillary_tools/geotag/image_extractors/exif.py +60 -0
  37. mapillary_tools/geotag/image_extractors/exiftool.py +18 -0
  38. mapillary_tools/geotag/options.py +160 -0
  39. mapillary_tools/geotag/utils.py +52 -16
  40. mapillary_tools/geotag/video_extractors/base.py +18 -0
  41. mapillary_tools/geotag/video_extractors/exiftool.py +70 -0
  42. mapillary_tools/{video_data_extraction/extractors/gpx_parser.py → geotag/video_extractors/gpx.py} +57 -39
  43. mapillary_tools/geotag/video_extractors/native.py +157 -0
  44. mapillary_tools/{geotag → gpmf}/gpmf_parser.py +205 -182
  45. mapillary_tools/{geotag → gpmf}/gps_filter.py +5 -3
  46. mapillary_tools/history.py +7 -13
  47. mapillary_tools/mp4/construct_mp4_parser.py +9 -8
  48. mapillary_tools/mp4/io_utils.py +0 -1
  49. mapillary_tools/mp4/mp4_sample_parser.py +36 -28
  50. mapillary_tools/mp4/simple_mp4_builder.py +10 -9
  51. mapillary_tools/mp4/simple_mp4_parser.py +13 -22
  52. mapillary_tools/process_geotag_properties.py +155 -392
  53. mapillary_tools/process_sequence_properties.py +562 -208
  54. mapillary_tools/sample_video.py +13 -20
  55. mapillary_tools/telemetry.py +26 -13
  56. mapillary_tools/types.py +111 -58
  57. mapillary_tools/upload.py +316 -298
  58. mapillary_tools/upload_api_v4.py +55 -122
  59. mapillary_tools/uploader.py +396 -254
  60. mapillary_tools/utils.py +42 -18
  61. {mapillary_tools-0.13.3a1.dist-info → mapillary_tools-0.14.0a2.dist-info}/METADATA +3 -2
  62. mapillary_tools-0.14.0a2.dist-info/RECORD +72 -0
  63. {mapillary_tools-0.13.3a1.dist-info → mapillary_tools-0.14.0a2.dist-info}/WHEEL +1 -1
  64. mapillary_tools/geotag/__init__.py +0 -1
  65. mapillary_tools/geotag/geotag_from_generic.py +0 -22
  66. mapillary_tools/geotag/geotag_images_from_exiftool_both_image_and_video.py +0 -93
  67. mapillary_tools/geotag/geotag_videos_from_exiftool_video.py +0 -145
  68. mapillary_tools/video_data_extraction/cli_options.py +0 -22
  69. mapillary_tools/video_data_extraction/extract_video_data.py +0 -176
  70. mapillary_tools/video_data_extraction/extractors/base_parser.py +0 -75
  71. mapillary_tools/video_data_extraction/extractors/blackvue_parser.py +0 -34
  72. mapillary_tools/video_data_extraction/extractors/camm_parser.py +0 -38
  73. mapillary_tools/video_data_extraction/extractors/exiftool_runtime_parser.py +0 -71
  74. mapillary_tools/video_data_extraction/extractors/exiftool_xml_parser.py +0 -53
  75. mapillary_tools/video_data_extraction/extractors/generic_video_parser.py +0 -52
  76. mapillary_tools/video_data_extraction/extractors/gopro_parser.py +0 -43
  77. mapillary_tools/video_data_extraction/extractors/nmea_parser.py +0 -24
  78. mapillary_tools/video_data_extraction/video_data_parser_factory.py +0 -39
  79. mapillary_tools-0.13.3a1.dist-info/RECORD +0 -75
  80. /mapillary_tools/{geotag → gpmf}/gpmf_gps_filter.py +0 -0
  81. {mapillary_tools-0.13.3a1.dist-info → mapillary_tools-0.14.0a2.dist-info}/entry_points.txt +0 -0
  82. {mapillary_tools-0.13.3a1.dist-info → mapillary_tools-0.14.0a2.dist-info/licenses}/LICENSE +0 -0
  83. {mapillary_tools-0.13.3a1.dist-info → mapillary_tools-0.14.0a2.dist-info}/top_level.txt +0 -0
@@ -1,3 +1,5 @@
1
+ from __future__ import annotations
2
+
1
3
  import datetime
2
4
  import logging
3
5
  import os
@@ -11,14 +13,13 @@ from . import constants, exceptions, ffmpeg as ffmpeglib, geo, types, utils
11
13
  from .exif_write import ExifEdit
12
14
  from .geotag import geotag_videos_from_video
13
15
  from .mp4 import mp4_sample_parser
14
- from .process_geotag_properties import GeotagSource
15
16
 
16
17
  LOG = logging.getLogger(__name__)
17
18
 
18
19
 
19
20
  def _normalize_path(
20
21
  video_import_path: Path, skip_subfolders: bool
21
- ) -> T.Tuple[Path, T.List[Path]]:
22
+ ) -> tuple[Path, list[Path]]:
22
23
  if video_import_path.is_dir():
23
24
  video_list = utils.find_videos(
24
25
  [video_import_path], skip_subfolders=skip_subfolders
@@ -46,12 +47,11 @@ def sample_video(
46
47
  video_import_path: Path,
47
48
  import_path: Path,
48
49
  # None if called from the sample_video command
49
- geotag_source: T.Optional[GeotagSource] = None,
50
50
  skip_subfolders=False,
51
51
  video_sample_distance=constants.VIDEO_SAMPLE_DISTANCE,
52
52
  video_sample_interval=constants.VIDEO_SAMPLE_INTERVAL,
53
53
  video_duration_ratio=constants.VIDEO_DURATION_RATIO,
54
- video_start_time: T.Optional[str] = None,
54
+ video_start_time: str | None = None,
55
55
  skip_sample_errors: bool = False,
56
56
  rerun: bool = False,
57
57
  ) -> None:
@@ -62,7 +62,7 @@ def sample_video(
62
62
  f"Expect either non-negative video_sample_distance or positive video_sample_interval but got {video_sample_distance} and {video_sample_interval} respectively"
63
63
  )
64
64
 
65
- video_start_time_dt: T.Optional[datetime.datetime] = None
65
+ video_start_time_dt: datetime.datetime | None = None
66
66
  if video_start_time is not None:
67
67
  try:
68
68
  video_start_time_dt = types.map_capture_time_to_datetime(video_start_time)
@@ -86,16 +86,6 @@ def sample_video(
86
86
  elif sample_dir.is_file():
87
87
  os.remove(sample_dir)
88
88
 
89
- if geotag_source is None:
90
- geotag_source = "exif"
91
-
92
- # If it is not exif, then we use the legacy interval-based sample and geotag them in "process" for backward compatibility
93
- if geotag_source not in ["exif"]:
94
- if 0 <= video_sample_distance:
95
- raise exceptions.MapillaryBadParameterError(
96
- f'Geotagging from "{geotag_source}" works with the legacy interval-based sampling only. To switch back, rerun the command with "--video_sample_distance -1 --video_sample_interval 2"'
97
- )
98
-
99
89
  for video_path in video_list:
100
90
  # need to resolve video_path because video_dir might be absolute
101
91
  sample_dir = Path(import_path).joinpath(
@@ -189,7 +179,7 @@ def _sample_single_video_by_interval(
189
179
  sample_dir: Path,
190
180
  sample_interval: float,
191
181
  duration_ratio: float,
192
- start_time: T.Optional[datetime.datetime] = None,
182
+ start_time: datetime.datetime | None = None,
193
183
  ) -> None:
194
184
  ffmpeg = ffmpeglib.FFMPEG(constants.FFMPEG_PATH, constants.FFPROBE_PATH)
195
185
 
@@ -229,7 +219,7 @@ def _sample_video_stream_by_distance(
229
219
  points: T.Sequence[geo.Point],
230
220
  video_track_parser: mp4_sample_parser.TrackBoxParser,
231
221
  sample_distance: float,
232
- ) -> T.Dict[int, T.Tuple[mp4_sample_parser.Sample, geo.Point]]:
222
+ ) -> dict[int, tuple[mp4_sample_parser.Sample, geo.Point]]:
233
223
  """
234
224
  Locate video frames along the track (points), then resample them by the minimal sample_distance, and return the sparse frames.
235
225
  """
@@ -285,7 +275,7 @@ def _sample_single_video_by_distance(
285
275
  video_path: Path,
286
276
  sample_dir: Path,
287
277
  sample_distance: float,
288
- start_time: T.Optional[datetime.datetime] = None,
278
+ start_time: datetime.datetime | None = None,
289
279
  ) -> None:
290
280
  ffmpeg = ffmpeglib.FFMPEG(constants.FFMPEG_PATH, constants.FFPROBE_PATH)
291
281
 
@@ -299,9 +289,12 @@ def _sample_single_video_by_distance(
299
289
  )
300
290
 
301
291
  LOG.info("Extracting video metdata")
302
- video_metadata = geotag_videos_from_video.GeotagVideosFromVideo.geotag_video(
303
- video_path
292
+
293
+ video_metadatas = geotag_videos_from_video.GeotagVideosFromVideo().to_description(
294
+ [video_path]
304
295
  )
296
+ assert len(video_metadatas) == 1, "expect 1 video metadata"
297
+ video_metadata = video_metadatas[0]
305
298
  if isinstance(video_metadata, types.ErrorMetadata):
306
299
  LOG.warning(str(video_metadata.error))
307
300
  return
@@ -1,5 +1,6 @@
1
+ from __future__ import annotations
2
+
1
3
  import dataclasses
2
- import typing as T
3
4
  from enum import Enum, unique
4
5
 
5
6
  from .geo import Point
@@ -12,16 +13,8 @@ class GPSFix(Enum):
12
13
  FIX_3D = 3
13
14
 
14
15
 
15
- @dataclasses.dataclass
16
- class GPSPoint(Point):
17
- epoch_time: T.Optional[float]
18
- fix: T.Optional[GPSFix]
19
- precision: T.Optional[float]
20
- ground_speed: T.Optional[float]
21
-
22
-
23
16
  @dataclasses.dataclass(order=True)
24
- class TelemetryMeasurement:
17
+ class TimestampedMeasurement:
25
18
  """Base class for all telemetry measurements.
26
19
 
27
20
  All telemetry measurements must have a timestamp in seconds.
@@ -32,8 +25,28 @@ class TelemetryMeasurement:
32
25
  time: float
33
26
 
34
27
 
28
+ @dataclasses.dataclass
29
+ class GPSPoint(TimestampedMeasurement, Point):
30
+ epoch_time: float | None
31
+ fix: GPSFix | None
32
+ precision: float | None
33
+ ground_speed: float | None
34
+
35
+
36
+ @dataclasses.dataclass
37
+ class CAMMGPSPoint(TimestampedMeasurement, Point):
38
+ time_gps_epoch: float
39
+ gps_fix_type: int
40
+ horizontal_accuracy: float
41
+ vertical_accuracy: float
42
+ velocity_east: float
43
+ velocity_north: float
44
+ velocity_up: float
45
+ speed_accuracy: float
46
+
47
+
35
48
  @dataclasses.dataclass(order=True)
36
- class GyroscopeData(TelemetryMeasurement):
49
+ class GyroscopeData(TimestampedMeasurement):
37
50
  """Gyroscope signal in radians/seconds around XYZ axes of the camera."""
38
51
 
39
52
  x: float
@@ -42,7 +55,7 @@ class GyroscopeData(TelemetryMeasurement):
42
55
 
43
56
 
44
57
  @dataclasses.dataclass(order=True)
45
- class AccelerationData(TelemetryMeasurement):
58
+ class AccelerationData(TimestampedMeasurement):
46
59
  """Accelerometer reading in meters/second^2 along XYZ axes of the camera."""
47
60
 
48
61
  x: float
@@ -51,7 +64,7 @@ class AccelerationData(TelemetryMeasurement):
51
64
 
52
65
 
53
66
  @dataclasses.dataclass(order=True)
54
- class MagnetometerData(TelemetryMeasurement):
67
+ class MagnetometerData(TimestampedMeasurement):
55
68
  """Ambient magnetic field."""
56
69
 
57
70
  x: float
mapillary_tools/types.py CHANGED
@@ -1,3 +1,5 @@
1
+ from __future__ import annotations
2
+
1
3
  import dataclasses
2
4
  import datetime
3
5
  import enum
@@ -31,35 +33,40 @@ _ANGLE_PRECISION = 3
31
33
 
32
34
 
33
35
  class FileType(enum.Enum):
36
+ IMAGE = "image"
37
+ ZIP = "zip"
38
+ # VIDEO is a superset of all NATIVE_VIDEO_FILETYPES below.
39
+ # It also contains the videos that external geotag source (e.g. exiftool) supports
40
+ VIDEO = "video"
34
41
  BLACKVUE = "blackvue"
35
42
  CAMM = "camm"
36
43
  GOPRO = "gopro"
37
- IMAGE = "image"
38
- VIDEO = "video"
39
- ZIP = "zip"
44
+
45
+
46
+ NATIVE_VIDEO_FILETYPES = {
47
+ FileType.BLACKVUE,
48
+ FileType.CAMM,
49
+ FileType.GOPRO,
50
+ }
40
51
 
41
52
 
42
53
  @dataclasses.dataclass
43
54
  class ImageMetadata(geo.Point):
44
55
  filename: Path
45
- # if None or absent, it will be calculated
46
- md5sum: T.Optional[str]
47
- # filetype: is always FileType.IMAGE
48
- width: T.Optional[int] = None
49
- height: T.Optional[int] = None
50
- MAPSequenceUUID: T.Optional[str] = None
51
- MAPDeviceMake: T.Optional[str] = None
52
- MAPDeviceModel: T.Optional[str] = None
53
- MAPGPSAccuracyMeters: T.Optional[float] = None
54
- MAPCameraUUID: T.Optional[str] = None
55
- MAPOrientation: T.Optional[int] = None
56
- # deprecated since v0.10.0; keep here for compatibility
57
- MAPMetaTags: T.Optional[T.Dict] = None
58
- # deprecated since v0.10.0; keep here for compatibility
59
- MAPFilename: T.Optional[str] = None
60
- filesize: T.Optional[int] = None
61
-
62
- def update_md5sum(self, image_data: T.Optional[T.BinaryIO] = None) -> None:
56
+ md5sum: str | None = None
57
+ width: int | None = None
58
+ height: int | None = None
59
+ MAPSequenceUUID: str | None = None
60
+ MAPDeviceMake: str | None = None
61
+ MAPDeviceModel: str | None = None
62
+ MAPGPSAccuracyMeters: float | None = None
63
+ MAPCameraUUID: str | None = None
64
+ MAPOrientation: int | None = None
65
+ MAPMetaTags: dict | None = None
66
+ MAPFilename: str | None = None
67
+ filesize: int | None = None
68
+
69
+ def update_md5sum(self, image_data: T.BinaryIO | None = None) -> None:
63
70
  if self.md5sum is None:
64
71
  if image_data is None:
65
72
  with self.filename.open("rb") as fp:
@@ -77,13 +84,12 @@ class ImageMetadata(geo.Point):
77
84
  @dataclasses.dataclass
78
85
  class VideoMetadata:
79
86
  filename: Path
80
- # if None or absent, it will be calculated
81
- md5sum: T.Optional[str]
82
87
  filetype: FileType
83
88
  points: T.Sequence[geo.Point]
84
- make: T.Optional[str] = None
85
- model: T.Optional[str] = None
86
- filesize: T.Optional[int] = None
89
+ md5sum: str | None = None
90
+ make: str | None = None
91
+ model: str | None = None
92
+ filesize: int | None = None
87
93
 
88
94
  def update_md5sum(self) -> None:
89
95
  if self.md5sum is None:
@@ -94,7 +100,7 @@ class VideoMetadata:
94
100
  @dataclasses.dataclass
95
101
  class ErrorMetadata:
96
102
  filename: Path
97
- filetype: T.Optional[FileType]
103
+ filetype: FileType
98
104
  error: Exception
99
105
 
100
106
 
@@ -104,8 +110,37 @@ Metadata = T.Union[ImageMetadata, VideoMetadata]
104
110
  MetadataOrError = T.Union[Metadata, ErrorMetadata]
105
111
 
106
112
 
113
+ # Assume {GOPRO, VIDEO} are the NATIVE_VIDEO_FILETYPES:
114
+ # a | b = result
115
+ # {CAMM} | {GOPRO} = {}
116
+ # {CAMM} | {GOPRO, VIDEO} = {CAMM}
117
+ # {GOPRO} | {GOPRO, VIDEO} = {GOPRO}
118
+ # {GOPRO} | {VIDEO} = {GOPRO}
119
+ # {CAMM, GOPRO} | {VIDEO} = {CAMM, GOPRO}
120
+ # {VIDEO} | {VIDEO} = {CAMM, GOPRO, VIDEO}
121
+ def combine_filetype_filters(
122
+ a: set[FileType] | None, b: set[FileType] | None
123
+ ) -> set[FileType] | None:
124
+ if a is None:
125
+ return b
126
+
127
+ if b is None:
128
+ return a
129
+
130
+ # VIDEO is a superset of NATIVE_VIDEO_FILETYPES,
131
+ # so we add NATIVE_VIDEO_FILETYPES to each set for intersection later
132
+
133
+ if FileType.VIDEO in a:
134
+ a = a | NATIVE_VIDEO_FILETYPES
135
+
136
+ if FileType.VIDEO in b:
137
+ b = b | NATIVE_VIDEO_FILETYPES
138
+
139
+ return a.intersection(b)
140
+
141
+
107
142
  class UserItem(TypedDict, total=False):
108
- MAPOrganizationKey: T.Union[int, str]
143
+ MAPOrganizationKey: int | str
109
144
  # Not in use. Keep here for back-compatibility
110
145
  MAPSettingsUsername: str
111
146
  MAPSettingsUserKey: str
@@ -144,23 +179,22 @@ class ImageDescription(_SequenceOnly, _Image, MetaProperties, total=True):
144
179
  # filename is required
145
180
  filename: str
146
181
  # if None or absent, it will be calculated
147
- md5sum: T.Optional[str]
182
+ md5sum: str | None
148
183
  filetype: Literal["image"]
149
- filesize: T.Optional[int]
184
+ filesize: int | None
150
185
 
151
186
 
152
187
  class _VideoDescriptionRequired(TypedDict, total=True):
153
188
  filename: str
154
- # if None or absent, it will be calculated
155
- md5sum: T.Optional[str]
189
+ md5sum: str | None
156
190
  filetype: str
157
- MAPGPSTrack: T.List[T.Sequence[T.Union[float, int, None]]]
191
+ MAPGPSTrack: list[T.Sequence[float | int | None]]
158
192
 
159
193
 
160
194
  class VideoDescription(_VideoDescriptionRequired, total=False):
161
195
  MAPDeviceMake: str
162
196
  MAPDeviceModel: str
163
- filesize: T.Optional[int]
197
+ filesize: int | None
164
198
 
165
199
 
166
200
  class _ErrorDescription(TypedDict, total=False):
@@ -168,7 +202,7 @@ class _ErrorDescription(TypedDict, total=False):
168
202
  type: str
169
203
  message: str
170
204
  # vars is optional
171
- vars: T.Dict
205
+ vars: dict
172
206
 
173
207
 
174
208
  class _ImageDescriptionErrorRequired(TypedDict, total=True):
@@ -180,8 +214,26 @@ class ImageDescriptionError(_ImageDescriptionErrorRequired, total=False):
180
214
  filetype: str
181
215
 
182
216
 
217
+ M = T.TypeVar("M")
218
+
219
+
220
+ def separate_errors(
221
+ metadatas: T.Iterable[M | ErrorMetadata],
222
+ ) -> tuple[list[M], list[ErrorMetadata]]:
223
+ good: list[M] = []
224
+ bad: list[ErrorMetadata] = []
225
+
226
+ for metadata in metadatas:
227
+ if isinstance(metadata, ErrorMetadata):
228
+ bad.append(metadata)
229
+ else:
230
+ good.append(metadata)
231
+
232
+ return good, bad
233
+
234
+
183
235
  def _describe_error_desc(
184
- exc: Exception, filename: Path, filetype: T.Optional[FileType]
236
+ exc: Exception, filename: Path, filetype: FileType | None
185
237
  ) -> ImageDescriptionError:
186
238
  err: _ErrorDescription = {
187
239
  "type": exc.__class__.__name__,
@@ -210,7 +262,7 @@ def _describe_error_desc(
210
262
 
211
263
 
212
264
  def describe_error_metadata(
213
- exc: Exception, filename: Path, filetype: T.Optional[FileType]
265
+ exc: Exception, filename: Path, filetype: FileType
214
266
  ) -> ErrorMetadata:
215
267
  return ErrorMetadata(filename=filename, filetype=filetype, error=exc)
216
268
 
@@ -278,7 +330,6 @@ ImageDescriptionEXIFSchema = {
278
330
  "MAPDeviceModel": {"type": "string"},
279
331
  "MAPGPSAccuracyMeters": {"type": "number"},
280
332
  "MAPCameraUUID": {"type": "string"},
281
- # deprecated since v0.10.0; keep here for compatibility
282
333
  "MAPFilename": {
283
334
  "type": "string",
284
335
  "description": "The base filename of the image",
@@ -341,7 +392,7 @@ VideoDescriptionSchema = {
341
392
  }
342
393
 
343
394
 
344
- def merge_schema(*schemas: T.Dict) -> T.Dict:
395
+ def merge_schema(*schemas: dict) -> dict:
345
396
  for s in schemas:
346
397
  assert s.get("type") == "object", "must be all object schemas"
347
398
  properties = {}
@@ -436,11 +487,11 @@ def validate_image_desc(desc: T.Any) -> None:
436
487
  jsonschema.validate(instance=desc, schema=ImageDescriptionFileSchema)
437
488
  except jsonschema.ValidationError as ex:
438
489
  # do not use str(ex) which is more verbose
439
- raise exceptions.MapillaryMetadataValidationError(ex.message)
490
+ raise exceptions.MapillaryMetadataValidationError(ex.message) from ex
440
491
  try:
441
492
  map_capture_time_to_datetime(desc["MAPCaptureTime"])
442
493
  except ValueError as ex:
443
- raise exceptions.MapillaryMetadataValidationError(str(ex))
494
+ raise exceptions.MapillaryMetadataValidationError(str(ex)) from ex
444
495
 
445
496
 
446
497
  def validate_video_desc(desc: T.Any) -> None:
@@ -448,12 +499,12 @@ def validate_video_desc(desc: T.Any) -> None:
448
499
  jsonschema.validate(instance=desc, schema=VideoDescriptionFileSchema)
449
500
  except jsonschema.ValidationError as ex:
450
501
  # do not use str(ex) which is more verbose
451
- raise exceptions.MapillaryMetadataValidationError(ex.message)
502
+ raise exceptions.MapillaryMetadataValidationError(ex.message) from ex
452
503
 
453
504
 
454
- def datetime_to_map_capture_time(time: T.Union[datetime.datetime, int, float]) -> str:
505
+ def datetime_to_map_capture_time(time: datetime.datetime | int | float) -> str:
455
506
  if isinstance(time, (float, int)):
456
- dt = datetime.datetime.utcfromtimestamp(time)
507
+ dt = datetime.datetime.fromtimestamp(time, datetime.timezone.utc)
457
508
  # otherwise it will be assumed to be in local time
458
509
  dt = dt.replace(tzinfo=datetime.timezone.utc)
459
510
  else:
@@ -552,7 +603,7 @@ def from_desc(desc):
552
603
 
553
604
 
554
605
  def _from_image_desc(desc) -> ImageMetadata:
555
- kwargs: T.Dict = {}
606
+ kwargs: dict = {}
556
607
  for k, v in desc.items():
557
608
  if k not in [
558
609
  "filename",
@@ -582,7 +633,7 @@ def _from_image_desc(desc) -> ImageMetadata:
582
633
  )
583
634
 
584
635
 
585
- def _encode_point(p: geo.Point) -> T.Sequence[T.Union[float, int, None]]:
636
+ def _encode_point(p: geo.Point) -> T.Sequence[float | int | None]:
586
637
  entry = [
587
638
  int(p.time * 1000),
588
639
  round(p.lon, _COORDINATES_PRECISION),
@@ -644,15 +695,16 @@ def validate_and_fail_metadata(metadata: MetadataOrError) -> MetadataOrError:
644
695
  if isinstance(metadata, ErrorMetadata):
645
696
  return metadata
646
697
 
647
- filetype: T.Optional[FileType] = None
698
+ if isinstance(metadata, ImageMetadata):
699
+ filetype = FileType.IMAGE
700
+ validate = validate_image_desc
701
+ else:
702
+ assert isinstance(metadata, VideoMetadata)
703
+ filetype = metadata.filetype
704
+ validate = validate_video_desc
705
+
648
706
  try:
649
- if isinstance(metadata, ImageMetadata):
650
- filetype = FileType.IMAGE
651
- validate_image_desc(as_desc(metadata))
652
- else:
653
- assert isinstance(metadata, VideoMetadata)
654
- filetype = metadata.filetype
655
- validate_video_desc(as_desc(metadata))
707
+ validate(as_desc(metadata))
656
708
  except exceptions.MapillaryMetadataValidationError as ex:
657
709
  # rethrow because the original error is too verbose
658
710
  return describe_error_metadata(
@@ -686,10 +738,10 @@ def desc_file_to_exif(
686
738
 
687
739
 
688
740
  def group_and_sort_images(
689
- metadatas: T.Sequence[ImageMetadata],
690
- ) -> T.Dict[str, T.List[ImageMetadata]]:
741
+ metadatas: T.Iterable[ImageMetadata],
742
+ ) -> dict[str, list[ImageMetadata]]:
691
743
  # group metadatas by uuid
692
- sequences_by_uuid: T.Dict[str, T.List[ImageMetadata]] = {}
744
+ sequences_by_uuid: dict[str, list[ImageMetadata]] = {}
693
745
  missing_sequence_uuid = str(uuid.uuid4())
694
746
  for metadata in metadatas:
695
747
  if metadata.MAPSequenceUUID is None:
@@ -709,9 +761,10 @@ def group_and_sort_images(
709
761
  return sorted_sequences_by_uuid
710
762
 
711
763
 
712
- def sequence_md5sum(sequence: T.Iterable[ImageMetadata]) -> str:
764
+ def update_sequence_md5sum(sequence: T.Iterable[ImageMetadata]) -> str:
713
765
  md5 = hashlib.md5()
714
766
  for metadata in sequence:
767
+ metadata.update_md5sum()
715
768
  assert isinstance(metadata.md5sum, str), "md5sum should be calculated"
716
769
  md5.update(metadata.md5sum.encode("utf-8"))
717
770
  return md5.hexdigest()