mapillary-tools 0.13.3a1__py3-none-any.whl → 0.14.0__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 (87) hide show
  1. mapillary_tools/__init__.py +1 -1
  2. mapillary_tools/api_v4.py +287 -22
  3. mapillary_tools/authenticate.py +326 -64
  4. mapillary_tools/blackvue_parser.py +195 -0
  5. mapillary_tools/camm/camm_builder.py +55 -97
  6. mapillary_tools/camm/camm_parser.py +429 -181
  7. mapillary_tools/commands/__main__.py +17 -8
  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 +44 -13
  13. mapillary_tools/commands/video_process_and_upload.py +19 -5
  14. mapillary_tools/config.py +65 -26
  15. mapillary_tools/constants.py +141 -18
  16. mapillary_tools/exceptions.py +37 -34
  17. mapillary_tools/exif_read.py +221 -116
  18. mapillary_tools/exif_write.py +10 -8
  19. mapillary_tools/exiftool_read.py +33 -42
  20. mapillary_tools/exiftool_read_video.py +97 -47
  21. mapillary_tools/exiftool_runner.py +57 -0
  22. mapillary_tools/ffmpeg.py +417 -242
  23. mapillary_tools/geo.py +158 -118
  24. mapillary_tools/geotag/__init__.py +0 -1
  25. mapillary_tools/geotag/base.py +147 -0
  26. mapillary_tools/geotag/factory.py +307 -0
  27. mapillary_tools/geotag/geotag_images_from_exif.py +14 -131
  28. mapillary_tools/geotag/geotag_images_from_exiftool.py +136 -85
  29. mapillary_tools/geotag/geotag_images_from_gpx.py +60 -124
  30. mapillary_tools/geotag/geotag_images_from_gpx_file.py +13 -126
  31. mapillary_tools/geotag/geotag_images_from_nmea_file.py +4 -5
  32. mapillary_tools/geotag/geotag_images_from_video.py +88 -51
  33. mapillary_tools/geotag/geotag_videos_from_exiftool.py +123 -0
  34. mapillary_tools/geotag/geotag_videos_from_gpx.py +52 -0
  35. mapillary_tools/geotag/geotag_videos_from_video.py +20 -185
  36. mapillary_tools/geotag/image_extractors/base.py +18 -0
  37. mapillary_tools/geotag/image_extractors/exif.py +60 -0
  38. mapillary_tools/geotag/image_extractors/exiftool.py +18 -0
  39. mapillary_tools/geotag/options.py +182 -0
  40. mapillary_tools/geotag/utils.py +52 -16
  41. mapillary_tools/geotag/video_extractors/base.py +18 -0
  42. mapillary_tools/geotag/video_extractors/exiftool.py +70 -0
  43. mapillary_tools/geotag/video_extractors/gpx.py +116 -0
  44. mapillary_tools/geotag/video_extractors/native.py +160 -0
  45. mapillary_tools/{geotag → gpmf}/gpmf_parser.py +205 -182
  46. mapillary_tools/{geotag → gpmf}/gps_filter.py +5 -3
  47. mapillary_tools/history.py +134 -20
  48. mapillary_tools/mp4/construct_mp4_parser.py +17 -10
  49. mapillary_tools/mp4/io_utils.py +0 -1
  50. mapillary_tools/mp4/mp4_sample_parser.py +36 -28
  51. mapillary_tools/mp4/simple_mp4_builder.py +10 -9
  52. mapillary_tools/mp4/simple_mp4_parser.py +13 -22
  53. mapillary_tools/process_geotag_properties.py +184 -414
  54. mapillary_tools/process_sequence_properties.py +594 -225
  55. mapillary_tools/sample_video.py +20 -26
  56. mapillary_tools/serializer/description.py +587 -0
  57. mapillary_tools/serializer/gpx.py +132 -0
  58. mapillary_tools/telemetry.py +26 -13
  59. mapillary_tools/types.py +98 -611
  60. mapillary_tools/upload.py +408 -416
  61. mapillary_tools/upload_api_v4.py +172 -174
  62. mapillary_tools/uploader.py +804 -284
  63. mapillary_tools/utils.py +49 -18
  64. {mapillary_tools-0.13.3a1.dist-info → mapillary_tools-0.14.0.dist-info}/METADATA +93 -35
  65. mapillary_tools-0.14.0.dist-info/RECORD +75 -0
  66. {mapillary_tools-0.13.3a1.dist-info → mapillary_tools-0.14.0.dist-info}/WHEEL +1 -1
  67. mapillary_tools/geotag/blackvue_parser.py +0 -118
  68. mapillary_tools/geotag/geotag_from_generic.py +0 -22
  69. mapillary_tools/geotag/geotag_images_from_exiftool_both_image_and_video.py +0 -93
  70. mapillary_tools/geotag/geotag_videos_from_exiftool_video.py +0 -145
  71. mapillary_tools/video_data_extraction/cli_options.py +0 -22
  72. mapillary_tools/video_data_extraction/extract_video_data.py +0 -176
  73. mapillary_tools/video_data_extraction/extractors/base_parser.py +0 -75
  74. mapillary_tools/video_data_extraction/extractors/blackvue_parser.py +0 -34
  75. mapillary_tools/video_data_extraction/extractors/camm_parser.py +0 -38
  76. mapillary_tools/video_data_extraction/extractors/exiftool_runtime_parser.py +0 -71
  77. mapillary_tools/video_data_extraction/extractors/exiftool_xml_parser.py +0 -53
  78. mapillary_tools/video_data_extraction/extractors/generic_video_parser.py +0 -52
  79. mapillary_tools/video_data_extraction/extractors/gopro_parser.py +0 -43
  80. mapillary_tools/video_data_extraction/extractors/gpx_parser.py +0 -108
  81. mapillary_tools/video_data_extraction/extractors/nmea_parser.py +0 -24
  82. mapillary_tools/video_data_extraction/video_data_parser_factory.py +0 -39
  83. mapillary_tools-0.13.3a1.dist-info/RECORD +0 -75
  84. /mapillary_tools/{geotag → gpmf}/gpmf_gps_filter.py +0 -0
  85. {mapillary_tools-0.13.3a1.dist-info → mapillary_tools-0.14.0.dist-info}/entry_points.txt +0 -0
  86. {mapillary_tools-0.13.3a1.dist-info → mapillary_tools-0.14.0.dist-info/licenses}/LICENSE +0 -0
  87. {mapillary_tools-0.13.3a1.dist-info → mapillary_tools-0.14.0.dist-info}/top_level.txt +0 -0
@@ -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))