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