mapillary-tools 0.14.0a2__py3-none-any.whl → 0.14.1__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 (49) hide show
  1. mapillary_tools/__init__.py +1 -1
  2. mapillary_tools/api_v4.py +66 -262
  3. mapillary_tools/authenticate.py +54 -46
  4. mapillary_tools/blackvue_parser.py +79 -22
  5. mapillary_tools/commands/__main__.py +15 -16
  6. mapillary_tools/commands/upload.py +33 -4
  7. mapillary_tools/config.py +38 -17
  8. mapillary_tools/constants.py +127 -43
  9. mapillary_tools/exceptions.py +4 -0
  10. mapillary_tools/exif_read.py +2 -1
  11. mapillary_tools/exif_write.py +3 -1
  12. mapillary_tools/exiftool_read_video.py +52 -15
  13. mapillary_tools/exiftool_runner.py +4 -24
  14. mapillary_tools/ffmpeg.py +406 -232
  15. mapillary_tools/geo.py +16 -0
  16. mapillary_tools/geotag/__init__.py +0 -0
  17. mapillary_tools/geotag/base.py +8 -4
  18. mapillary_tools/geotag/factory.py +106 -89
  19. mapillary_tools/geotag/geotag_images_from_exiftool.py +27 -20
  20. mapillary_tools/geotag/geotag_images_from_gpx.py +7 -6
  21. mapillary_tools/geotag/geotag_images_from_video.py +35 -0
  22. mapillary_tools/geotag/geotag_videos_from_exiftool.py +61 -14
  23. mapillary_tools/geotag/geotag_videos_from_gpx.py +22 -9
  24. mapillary_tools/geotag/options.py +25 -3
  25. mapillary_tools/geotag/utils.py +9 -12
  26. mapillary_tools/geotag/video_extractors/base.py +1 -1
  27. mapillary_tools/geotag/video_extractors/exiftool.py +1 -1
  28. mapillary_tools/geotag/video_extractors/gpx.py +61 -70
  29. mapillary_tools/geotag/video_extractors/native.py +34 -31
  30. mapillary_tools/history.py +128 -8
  31. mapillary_tools/http.py +211 -0
  32. mapillary_tools/mp4/construct_mp4_parser.py +8 -2
  33. mapillary_tools/process_geotag_properties.py +47 -35
  34. mapillary_tools/process_sequence_properties.py +340 -325
  35. mapillary_tools/sample_video.py +8 -8
  36. mapillary_tools/serializer/description.py +587 -0
  37. mapillary_tools/serializer/gpx.py +132 -0
  38. mapillary_tools/types.py +44 -610
  39. mapillary_tools/upload.py +327 -352
  40. mapillary_tools/upload_api_v4.py +125 -72
  41. mapillary_tools/uploader.py +797 -216
  42. mapillary_tools/utils.py +57 -5
  43. {mapillary_tools-0.14.0a2.dist-info → mapillary_tools-0.14.1.dist-info}/METADATA +91 -34
  44. mapillary_tools-0.14.1.dist-info/RECORD +76 -0
  45. {mapillary_tools-0.14.0a2.dist-info → mapillary_tools-0.14.1.dist-info}/WHEEL +1 -1
  46. mapillary_tools-0.14.0a2.dist-info/RECORD +0 -72
  47. {mapillary_tools-0.14.0a2.dist-info → mapillary_tools-0.14.1.dist-info}/entry_points.txt +0 -0
  48. {mapillary_tools-0.14.0a2.dist-info → mapillary_tools-0.14.1.dist-info}/licenses/LICENSE +0 -0
  49. {mapillary_tools-0.14.0a2.dist-info → mapillary_tools-0.14.1.dist-info}/top_level.txt +0 -0
@@ -13,6 +13,7 @@ from . import constants, exceptions, ffmpeg as ffmpeglib, geo, types, utils
13
13
  from .exif_write import ExifEdit
14
14
  from .geotag import geotag_videos_from_video
15
15
  from .mp4 import mp4_sample_parser
16
+ from .serializer.description import parse_capture_time
16
17
 
17
18
  LOG = logging.getLogger(__name__)
18
19
 
@@ -65,7 +66,7 @@ def sample_video(
65
66
  video_start_time_dt: datetime.datetime | None = None
66
67
  if video_start_time is not None:
67
68
  try:
68
- video_start_time_dt = types.map_capture_time_to_datetime(video_start_time)
69
+ video_start_time_dt = parse_capture_time(video_start_time)
69
70
  except ValueError as ex:
70
71
  raise exceptions.MapillaryBadParameterError(str(ex))
71
72
 
@@ -124,12 +125,11 @@ def sample_video(
124
125
 
125
126
  except Exception as ex:
126
127
  if skip_sample_errors:
127
- exc_info = LOG.getEffectiveLevel() <= logging.DEBUG
128
128
  LOG.warning(
129
129
  "Skipping the error sampling %s: %s",
130
130
  video_path,
131
131
  str(ex),
132
- exc_info=exc_info,
132
+ exc_info=LOG.isEnabledFor(logging.DEBUG),
133
133
  )
134
134
  else:
135
135
  raise
@@ -193,8 +193,8 @@ def _sample_single_video_by_interval(
193
193
  )
194
194
 
195
195
  with wip_dir_context(wip_sample_dir(sample_dir), sample_dir) as wip_dir:
196
- ffmpeg.extract_frames(video_path, wip_dir, sample_interval)
197
- frame_samples = ffmpeglib.sort_selected_samples(wip_dir, video_path, [None])
196
+ ffmpeg.extract_frames_by_interval(video_path, wip_dir, sample_interval)
197
+ frame_samples = ffmpeglib.FFMPEG.sort_selected_samples(wip_dir, video_path)
198
198
  for frame_idx_1based, sample_paths in frame_samples:
199
199
  assert len(sample_paths) == 1
200
200
  if sample_paths[0] is None:
@@ -321,11 +321,11 @@ def _sample_single_video_by_distance(
321
321
  video_path,
322
322
  wip_dir,
323
323
  frame_indices=set(sorted_sample_indices),
324
- stream_idx=video_stream_idx,
324
+ stream_specifier=str(video_stream_idx),
325
325
  )
326
326
 
327
- frame_samples = ffmpeglib.sort_selected_samples(
328
- wip_dir, video_path, [video_stream_idx]
327
+ frame_samples = ffmpeglib.FFMPEG.sort_selected_samples(
328
+ wip_dir, video_path, selected_stream_specifiers=[str(video_stream_idx)]
329
329
  )
330
330
  if len(frame_samples) != len(sorted_sample_indices):
331
331
  raise exceptions.MapillaryVideoError(
@@ -0,0 +1,587 @@
1
+ from __future__ import annotations
2
+
3
+ import dataclasses
4
+ import datetime
5
+ import json
6
+ import sys
7
+ import typing as T
8
+ from pathlib import Path
9
+ from typing import TypedDict
10
+
11
+ if sys.version_info >= (3, 11):
12
+ from typing import Required
13
+ else:
14
+ from typing_extensions import Required
15
+
16
+ if sys.version_info >= (3, 12):
17
+ from typing import override
18
+ else:
19
+ from typing_extensions import override
20
+
21
+ import jsonschema
22
+
23
+ from .. import exceptions, geo
24
+ from ..types import (
25
+ BaseSerializer,
26
+ describe_error_metadata,
27
+ ErrorMetadata,
28
+ FileType,
29
+ ImageMetadata,
30
+ Metadata,
31
+ MetadataOrError,
32
+ VideoMetadata,
33
+ )
34
+
35
+
36
+ # http://wiki.gis.com/wiki/index.php/Decimal_degrees
37
+ # decimal places degrees distance
38
+ # 0 1.0 111 km
39
+ # 1 0.1 11.1 km
40
+ # 2 0.01 1.11 km
41
+ # 3 0.001 111 m
42
+ # 4 0.0001 11.1 m
43
+ # 5 0.00001 1.11 m
44
+ # 6 0.000001 0.111 m
45
+ # 7 0.0000001 1.11 cm
46
+ # 8 0.00000001 1.11 mm
47
+ _COORDINATES_PRECISION = 7
48
+ _ALTITUDE_PRECISION = 3
49
+ _ANGLE_PRECISION = 3
50
+
51
+
52
+ class _CompassHeading(TypedDict, total=True):
53
+ TrueHeading: float
54
+ MagneticHeading: float
55
+
56
+
57
+ class _SharedDescription(TypedDict, total=False):
58
+ filename: Required[str]
59
+ filetype: Required[str]
60
+
61
+ # if None or absent, it will be calculated
62
+ md5sum: str | None
63
+ filesize: int | None
64
+
65
+
66
+ class ImageDescription(_SharedDescription, total=False):
67
+ MAPLatitude: Required[float]
68
+ MAPLongitude: Required[float]
69
+ MAPAltitude: float
70
+ MAPCaptureTime: Required[str]
71
+ MAPCompassHeading: _CompassHeading
72
+
73
+ MAPDeviceMake: str
74
+ MAPDeviceModel: str
75
+ MAPGPSAccuracyMeters: float
76
+ MAPCameraUUID: str
77
+ MAPOrientation: int
78
+
79
+ # For grouping images in a sequence
80
+ MAPSequenceUUID: str
81
+
82
+
83
+ class VideoDescription(_SharedDescription, total=False):
84
+ MAPGPSTrack: Required[list[T.Sequence[float | int | None]]]
85
+ MAPDeviceMake: str
86
+ MAPDeviceModel: str
87
+
88
+
89
+ class _ErrorObject(TypedDict, total=False):
90
+ type: Required[str]
91
+ message: Required[str]
92
+ vars: dict
93
+
94
+
95
+ class ErrorDescription(TypedDict, total=False):
96
+ filename: Required[str]
97
+ error: Required[_ErrorObject]
98
+ filetype: str
99
+
100
+
101
+ Description = T.Union[ImageDescription, VideoDescription]
102
+ DescriptionOrError = T.Union[ImageDescription, VideoDescription, ErrorDescription]
103
+
104
+
105
+ ImageDescriptionEXIFSchema = {
106
+ "type": "object",
107
+ "properties": {
108
+ "MAPLatitude": {
109
+ "type": "number",
110
+ "description": "Latitude of the image",
111
+ "minimum": -90,
112
+ "maximum": 90,
113
+ },
114
+ "MAPLongitude": {
115
+ "type": "number",
116
+ "description": "Longitude of the image",
117
+ "minimum": -180,
118
+ "maximum": 180,
119
+ },
120
+ "MAPAltitude": {
121
+ "type": "number",
122
+ "description": "Altitude of the image, in meters",
123
+ },
124
+ "MAPCaptureTime": {
125
+ "type": "string",
126
+ "description": "Capture time of the image",
127
+ "pattern": "[0-9]{4}_[0-9]{2}_[0-9]{2}_[0-9]{2}_[0-9]{2}_[0-9]{2}_[0-9]+",
128
+ },
129
+ "MAPCompassHeading": {
130
+ "type": "object",
131
+ "properties": {
132
+ "TrueHeading": {"type": "number"},
133
+ "MagneticHeading": {"type": "number"},
134
+ },
135
+ "required": ["TrueHeading", "MagneticHeading"],
136
+ "additionalProperties": False,
137
+ "description": "Camera angle of the image, in degrees. If null, the angle will be interpolated",
138
+ },
139
+ "MAPSequenceUUID": {
140
+ "type": "string",
141
+ "description": "Arbitrary key for grouping images",
142
+ "pattern": "[a-zA-Z0-9_-]+",
143
+ },
144
+ # deprecated since v0.10.0; keep here for compatibility
145
+ "MAPMetaTags": {"type": "object"},
146
+ "MAPDeviceMake": {"type": "string"},
147
+ "MAPDeviceModel": {"type": "string"},
148
+ "MAPGPSAccuracyMeters": {"type": "number"},
149
+ "MAPCameraUUID": {"type": "string"},
150
+ "MAPFilename": {
151
+ "type": "string",
152
+ "description": "The base filename of the image",
153
+ },
154
+ "MAPOrientation": {"type": "integer"},
155
+ },
156
+ "required": [
157
+ "MAPLatitude",
158
+ "MAPLongitude",
159
+ "MAPCaptureTime",
160
+ ],
161
+ "additionalProperties": False,
162
+ }
163
+
164
+ VideoDescriptionSchema = {
165
+ "type": "object",
166
+ "properties": {
167
+ "MAPGPSTrack": {
168
+ "type": "array",
169
+ "items": {
170
+ "type": "array",
171
+ "description": "track point",
172
+ "prefixItems": [
173
+ {
174
+ "type": "number",
175
+ "description": "Time offset of the track point, in milliseconds, relative to the beginning of the video",
176
+ },
177
+ {
178
+ "type": "number",
179
+ "description": "Longitude of the track point",
180
+ },
181
+ {
182
+ "type": "number",
183
+ "description": "Latitude of the track point",
184
+ },
185
+ {
186
+ "type": ["number", "null"],
187
+ "description": "Altitude of the track point in meters",
188
+ },
189
+ {
190
+ "type": ["number", "null"],
191
+ "description": "Camera angle of the track point, in degrees. If null, the angle will be interpolated",
192
+ },
193
+ ],
194
+ },
195
+ },
196
+ "MAPDeviceMake": {
197
+ "type": "string",
198
+ "description": "Device make, e.g. GoPro, BlackVue, Insta360",
199
+ },
200
+ "MAPDeviceModel": {
201
+ "type": "string",
202
+ "description": "Device model, e.g. HERO10 Black, DR900S-1CH, Insta360 Titan",
203
+ },
204
+ },
205
+ "required": [
206
+ "MAPGPSTrack",
207
+ ],
208
+ "additionalProperties": False,
209
+ }
210
+
211
+
212
+ def _merge_schema(*schemas: dict) -> dict:
213
+ for s in schemas:
214
+ assert s.get("type") == "object", "must be all object schemas"
215
+ properties = {}
216
+ all_required = []
217
+ additional_properties = True
218
+ for s in schemas:
219
+ properties.update(s.get("properties", {}))
220
+ all_required += s.get("required", [])
221
+ if "additionalProperties" in s:
222
+ additional_properties = s["additionalProperties"]
223
+ return {
224
+ "type": "object",
225
+ "properties": properties,
226
+ "required": sorted(set(all_required)),
227
+ "additionalProperties": additional_properties,
228
+ }
229
+
230
+
231
+ ImageDescriptionFileSchema = _merge_schema(
232
+ ImageDescriptionEXIFSchema,
233
+ {
234
+ "type": "object",
235
+ "properties": {
236
+ "filename": {
237
+ "type": "string",
238
+ "description": "Absolute path of the image",
239
+ },
240
+ "md5sum": {
241
+ "type": ["string", "null"],
242
+ "description": "MD5 checksum of the image content. If not provided, the uploader will compute it",
243
+ },
244
+ "filesize": {
245
+ "type": ["number", "null"],
246
+ "description": "File size",
247
+ },
248
+ "filetype": {
249
+ "type": "string",
250
+ "enum": [FileType.IMAGE.value],
251
+ "description": "The image file type",
252
+ },
253
+ },
254
+ "required": [
255
+ "filename",
256
+ "filetype",
257
+ ],
258
+ },
259
+ )
260
+
261
+
262
+ VideoDescriptionFileSchema = _merge_schema(
263
+ VideoDescriptionSchema,
264
+ {
265
+ "type": "object",
266
+ "properties": {
267
+ "filename": {
268
+ "type": "string",
269
+ "description": "Absolute path of the video",
270
+ },
271
+ "md5sum": {
272
+ "type": ["string", "null"],
273
+ "description": "MD5 checksum of the video content. If not provided, the uploader will compute it",
274
+ },
275
+ "filesize": {
276
+ "type": ["number", "null"],
277
+ "description": "File size",
278
+ },
279
+ "filetype": {
280
+ "type": "string",
281
+ "enum": [
282
+ FileType.CAMM.value,
283
+ FileType.GOPRO.value,
284
+ FileType.BLACKVUE.value,
285
+ FileType.VIDEO.value,
286
+ ],
287
+ "description": "The video file type",
288
+ },
289
+ },
290
+ "required": [
291
+ "filename",
292
+ "filetype",
293
+ ],
294
+ },
295
+ )
296
+
297
+
298
+ ImageVideoDescriptionFileSchema = {
299
+ "oneOf": [VideoDescriptionFileSchema, ImageDescriptionFileSchema]
300
+ }
301
+
302
+
303
+ class DescriptionJSONSerializer(BaseSerializer):
304
+ @override
305
+ @classmethod
306
+ def serialize(cls, metadatas: T.Sequence[MetadataOrError]) -> bytes:
307
+ descs = [cls.as_desc(m) for m in metadatas]
308
+ return json.dumps(descs, sort_keys=True, separators=(",", ":")).encode("utf-8")
309
+
310
+ @override
311
+ @classmethod
312
+ def deserialize(cls, data: bytes) -> list[Metadata]:
313
+ descs = json.loads(data)
314
+ return [cls.from_desc(desc) for desc in descs if "error" not in desc]
315
+
316
+ @override
317
+ @classmethod
318
+ def deserialize_stream(cls, data: T.IO[bytes]) -> list[Metadata]:
319
+ descs = json.load(data)
320
+ return [cls.from_desc(desc) for desc in descs if "error" not in desc]
321
+
322
+ @T.overload
323
+ @classmethod
324
+ def as_desc(cls, metadata: ImageMetadata) -> ImageDescription: ...
325
+
326
+ @T.overload
327
+ @classmethod
328
+ def as_desc(cls, metadata: ErrorMetadata) -> ErrorDescription: ...
329
+
330
+ @T.overload
331
+ @classmethod
332
+ def as_desc(cls, metadata: VideoMetadata) -> VideoDescription: ...
333
+
334
+ @classmethod
335
+ def as_desc(cls, metadata):
336
+ if isinstance(metadata, ErrorMetadata):
337
+ return cls._as_error_desc(
338
+ metadata.error, metadata.filename, metadata.filetype
339
+ )
340
+
341
+ elif isinstance(metadata, VideoMetadata):
342
+ return cls._as_video_desc(metadata)
343
+
344
+ else:
345
+ assert isinstance(metadata, ImageMetadata)
346
+ return cls._as_image_desc(metadata)
347
+
348
+ @classmethod
349
+ def _as_error_desc(
350
+ cls, exc: Exception, filename: Path, filetype: FileType | None
351
+ ) -> ErrorDescription:
352
+ err: _ErrorObject = {
353
+ "type": exc.__class__.__name__,
354
+ "message": str(exc),
355
+ }
356
+
357
+ exc_vars = vars(exc)
358
+
359
+ if exc_vars:
360
+ # handle unserializable exceptions
361
+ try:
362
+ vars_json = json.dumps(exc_vars, sort_keys=True, separators=(",", ":"))
363
+ except Exception:
364
+ vars_json = ""
365
+ if vars_json:
366
+ err["vars"] = json.loads(vars_json)
367
+
368
+ desc: ErrorDescription = {
369
+ "error": err,
370
+ "filename": str(filename.resolve()),
371
+ }
372
+ if filetype is not None:
373
+ desc["filetype"] = filetype.value
374
+
375
+ return desc
376
+
377
+ @classmethod
378
+ def _as_video_desc(cls, metadata: VideoMetadata) -> VideoDescription:
379
+ desc: VideoDescription = {
380
+ "filename": str(metadata.filename.resolve()),
381
+ "md5sum": metadata.md5sum,
382
+ "filetype": metadata.filetype.value,
383
+ "filesize": metadata.filesize,
384
+ "MAPGPSTrack": [PointEncoder.encode(p) for p in metadata.points],
385
+ }
386
+ if metadata.make:
387
+ desc["MAPDeviceMake"] = metadata.make
388
+ if metadata.model:
389
+ desc["MAPDeviceModel"] = metadata.model
390
+ return desc
391
+
392
+ @classmethod
393
+ def _as_image_desc(cls, metadata: ImageMetadata) -> ImageDescription:
394
+ desc: ImageDescription = {
395
+ "filename": str(metadata.filename.resolve()),
396
+ "md5sum": metadata.md5sum,
397
+ "filesize": metadata.filesize,
398
+ "filetype": FileType.IMAGE.value,
399
+ "MAPLatitude": round(metadata.lat, _COORDINATES_PRECISION),
400
+ "MAPLongitude": round(metadata.lon, _COORDINATES_PRECISION),
401
+ "MAPCaptureTime": build_capture_time(metadata.time),
402
+ }
403
+ if metadata.alt is not None:
404
+ desc["MAPAltitude"] = round(metadata.alt, _ALTITUDE_PRECISION)
405
+ if metadata.angle is not None:
406
+ desc["MAPCompassHeading"] = {
407
+ "TrueHeading": round(metadata.angle, _ANGLE_PRECISION),
408
+ "MagneticHeading": round(metadata.angle, _ANGLE_PRECISION),
409
+ }
410
+ fields = dataclasses.fields(metadata)
411
+ for field in fields:
412
+ if field.name.startswith("MAP"):
413
+ value = getattr(metadata, field.name)
414
+ if value is not None:
415
+ # ignore error: TypedDict key must be a string literal;
416
+ # expected one of ("MAPMetaTags", "MAPDeviceMake", "MAPDeviceModel", "MAPGPSAccuracyMeters", "MAPCameraUUID", ...)
417
+ desc[field.name] = value # type: ignore
418
+ return desc
419
+
420
+ @T.overload
421
+ @classmethod
422
+ def from_desc(cls, desc: ImageDescription) -> ImageMetadata: ...
423
+
424
+ @T.overload
425
+ @classmethod
426
+ def from_desc(cls, desc: VideoDescription) -> VideoMetadata: ...
427
+
428
+ @classmethod
429
+ def from_desc(cls, desc):
430
+ if "error" in desc:
431
+ raise ValueError("Cannot deserialize error description")
432
+
433
+ if desc["filetype"] == FileType.IMAGE.value:
434
+ return cls._from_image_desc(desc)
435
+ else:
436
+ return cls._from_video_desc(desc)
437
+
438
+ @classmethod
439
+ def _from_image_desc(cls, desc) -> ImageMetadata:
440
+ validate_image_desc(desc)
441
+
442
+ kwargs: dict = {}
443
+ for k, v in desc.items():
444
+ if k not in [
445
+ "filename",
446
+ "md5sum",
447
+ "filesize",
448
+ "filetype",
449
+ "MAPLatitude",
450
+ "MAPLongitude",
451
+ "MAPAltitude",
452
+ "MAPCaptureTime",
453
+ "MAPCompassHeading",
454
+ ]:
455
+ kwargs[k] = v
456
+
457
+ return ImageMetadata(
458
+ filename=Path(desc["filename"]),
459
+ md5sum=desc.get("md5sum"),
460
+ filesize=desc.get("filesize"),
461
+ lat=desc["MAPLatitude"],
462
+ lon=desc["MAPLongitude"],
463
+ alt=desc.get("MAPAltitude"),
464
+ time=geo.as_unix_time(parse_capture_time(desc["MAPCaptureTime"])),
465
+ angle=desc.get("MAPCompassHeading", {}).get("TrueHeading"),
466
+ width=None,
467
+ height=None,
468
+ **kwargs,
469
+ )
470
+
471
+ @classmethod
472
+ def _from_video_desc(cls, desc: VideoDescription) -> VideoMetadata:
473
+ validate_video_desc(desc)
474
+
475
+ return VideoMetadata(
476
+ filename=Path(desc["filename"]),
477
+ md5sum=desc.get("md5sum"),
478
+ filesize=desc.get("filesize"),
479
+ filetype=FileType(desc["filetype"]),
480
+ points=[PointEncoder.decode(entry) for entry in desc["MAPGPSTrack"]],
481
+ make=desc.get("MAPDeviceMake"),
482
+ model=desc.get("MAPDeviceModel"),
483
+ )
484
+
485
+
486
+ class PointEncoder:
487
+ @classmethod
488
+ def encode(cls, p: geo.Point) -> T.Sequence[float | int | None]:
489
+ entry = [
490
+ int(p.time * 1000),
491
+ round(p.lon, _COORDINATES_PRECISION),
492
+ round(p.lat, _COORDINATES_PRECISION),
493
+ round(p.alt, _ALTITUDE_PRECISION) if p.alt is not None else None,
494
+ round(p.angle, _ANGLE_PRECISION) if p.angle is not None else None,
495
+ ]
496
+ return entry
497
+
498
+ @classmethod
499
+ def decode(cls, entry: T.Sequence[T.Any]) -> geo.Point:
500
+ time_ms, lon, lat, alt, angle = entry
501
+ return geo.Point(time=time_ms / 1000, lon=lon, lat=lat, alt=alt, angle=angle)
502
+
503
+
504
+ def build_capture_time(time: datetime.datetime | int | float) -> str:
505
+ if isinstance(time, (float, int)):
506
+ dt = datetime.datetime.fromtimestamp(time, datetime.timezone.utc)
507
+ # otherwise it will be assumed to be in local time
508
+ dt = dt.replace(tzinfo=datetime.timezone.utc)
509
+ else:
510
+ # otherwise it will be assumed to be in local time
511
+ dt = time.astimezone(datetime.timezone.utc)
512
+ return datetime.datetime.strftime(dt, "%Y_%m_%d_%H_%M_%S_%f")[:-3]
513
+
514
+
515
+ def parse_capture_time(time: str) -> datetime.datetime:
516
+ dt = datetime.datetime.strptime(time, "%Y_%m_%d_%H_%M_%S_%f")
517
+ dt = dt.replace(tzinfo=datetime.timezone.utc)
518
+ return dt
519
+
520
+
521
+ def validate_image_desc(desc: T.Any) -> None:
522
+ try:
523
+ jsonschema.validate(instance=desc, schema=ImageDescriptionFileSchema)
524
+ except jsonschema.ValidationError as ex:
525
+ # do not use str(ex) which is more verbose
526
+ raise exceptions.MapillaryMetadataValidationError(ex.message) from ex
527
+
528
+ try:
529
+ parse_capture_time(desc["MAPCaptureTime"])
530
+ except ValueError as ex:
531
+ raise exceptions.MapillaryMetadataValidationError(str(ex)) from ex
532
+
533
+
534
+ def validate_video_desc(desc: T.Any) -> None:
535
+ try:
536
+ jsonschema.validate(instance=desc, schema=VideoDescriptionFileSchema)
537
+ except jsonschema.ValidationError as ex:
538
+ # do not use str(ex) which is more verbose
539
+ raise exceptions.MapillaryMetadataValidationError(ex.message) from ex
540
+
541
+
542
+ def validate_and_fail_metadata(metadata: MetadataOrError) -> MetadataOrError:
543
+ if isinstance(metadata, ErrorMetadata):
544
+ return metadata
545
+
546
+ if isinstance(metadata, ImageMetadata):
547
+ filetype = FileType.IMAGE
548
+ validate = validate_image_desc
549
+ else:
550
+ assert isinstance(metadata, VideoMetadata)
551
+ filetype = metadata.filetype
552
+ validate = validate_video_desc
553
+
554
+ try:
555
+ validate(DescriptionJSONSerializer.as_desc(metadata))
556
+ except exceptions.MapillaryMetadataValidationError as ex:
557
+ # rethrow because the original error is too verbose
558
+ return describe_error_metadata(
559
+ ex,
560
+ metadata.filename,
561
+ filetype=filetype,
562
+ )
563
+
564
+ if not metadata.filename.is_file():
565
+ return describe_error_metadata(
566
+ exceptions.MapillaryMetadataValidationError(
567
+ f"No such file {metadata.filename}"
568
+ ),
569
+ metadata.filename,
570
+ filetype=filetype,
571
+ )
572
+
573
+ return metadata
574
+
575
+
576
+ def desc_file_to_exif(desc: ImageDescription) -> ImageDescription:
577
+ not_needed = ["MAPSequenceUUID"]
578
+ removed = {
579
+ key: value
580
+ for key, value in desc.items()
581
+ if key.startswith("MAP") and key not in not_needed
582
+ }
583
+ return T.cast(ImageDescription, removed)
584
+
585
+
586
+ if __name__ == "__main__":
587
+ print(json.dumps(ImageVideoDescriptionFileSchema, indent=4))