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
mapillary_tools/types.py CHANGED
@@ -1,65 +1,54 @@
1
+ from __future__ import annotations
2
+
3
+ import abc
1
4
  import dataclasses
2
- import datetime
3
5
  import enum
4
6
  import hashlib
5
- import json
6
- import os
7
7
  import typing as T
8
8
  import uuid
9
9
  from pathlib import Path
10
- from typing import Literal, TypedDict
11
-
12
- import jsonschema
13
-
14
- from . import exceptions, geo, utils
15
10
 
16
-
17
- # http://wiki.gis.com/wiki/index.php/Decimal_degrees
18
- # decimal places degrees distance
19
- # 0 1.0 111 km
20
- # 1 0.1 11.1 km
21
- # 2 0.01 1.11 km
22
- # 3 0.001 111 m
23
- # 4 0.0001 11.1 m
24
- # 5 0.00001 1.11 m
25
- # 6 0.000001 0.111 m
26
- # 7 0.0000001 1.11 cm
27
- # 8 0.00000001 1.11 mm
28
- _COORDINATES_PRECISION = 7
29
- _ALTITUDE_PRECISION = 3
30
- _ANGLE_PRECISION = 3
11
+ from . import geo, utils
31
12
 
32
13
 
33
14
  class FileType(enum.Enum):
15
+ IMAGE = "image"
16
+ ZIP = "zip"
17
+ # VIDEO is a superset of all NATIVE_VIDEO_FILETYPES below.
18
+ # It also contains the videos that external geotag source (e.g. exiftool) supports
19
+ VIDEO = "video"
34
20
  BLACKVUE = "blackvue"
35
21
  CAMM = "camm"
36
22
  GOPRO = "gopro"
37
- IMAGE = "image"
38
- VIDEO = "video"
39
- ZIP = "zip"
23
+
24
+
25
+ NATIVE_VIDEO_FILETYPES = {
26
+ FileType.BLACKVUE,
27
+ FileType.CAMM,
28
+ FileType.GOPRO,
29
+ }
40
30
 
41
31
 
42
32
  @dataclasses.dataclass
43
33
  class ImageMetadata(geo.Point):
44
34
  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:
35
+ # filetype should be always FileType.IMAGE
36
+ md5sum: str | None = None
37
+ width: int | None = None
38
+ height: int | None = None
39
+ filesize: int | None = None
40
+
41
+ # Fields starting with MAP* will be written to the image EXIF
42
+ MAPSequenceUUID: str | None = None
43
+ MAPDeviceMake: str | None = None
44
+ MAPDeviceModel: str | None = None
45
+ MAPGPSAccuracyMeters: float | None = None
46
+ MAPCameraUUID: str | None = None
47
+ MAPOrientation: int | None = None
48
+ MAPMetaTags: dict | None = None
49
+ MAPFilename: str | None = None
50
+
51
+ def update_md5sum(self, image_data: T.BinaryIO | None = None) -> None:
63
52
  if self.md5sum is None:
64
53
  if image_data is None:
65
54
  with self.filename.open("rb") as fp:
@@ -77,13 +66,12 @@ class ImageMetadata(geo.Point):
77
66
  @dataclasses.dataclass
78
67
  class VideoMetadata:
79
68
  filename: Path
80
- # if None or absent, it will be calculated
81
- md5sum: T.Optional[str]
82
69
  filetype: FileType
83
70
  points: T.Sequence[geo.Point]
84
- make: T.Optional[str] = None
85
- model: T.Optional[str] = None
86
- filesize: T.Optional[int] = None
71
+ md5sum: str | None = None
72
+ make: str | None = None
73
+ model: str | None = None
74
+ filesize: int | None = None
87
75
 
88
76
  def update_md5sum(self) -> None:
89
77
  if self.md5sum is None:
@@ -94,7 +82,7 @@ class VideoMetadata:
94
82
  @dataclasses.dataclass
95
83
  class ErrorMetadata:
96
84
  filename: Path
97
- filetype: T.Optional[FileType]
85
+ filetype: FileType
98
86
  error: Exception
99
87
 
100
88
 
@@ -104,592 +92,94 @@ Metadata = T.Union[ImageMetadata, VideoMetadata]
104
92
  MetadataOrError = T.Union[Metadata, ErrorMetadata]
105
93
 
106
94
 
107
- class UserItem(TypedDict, total=False):
108
- MAPOrganizationKey: T.Union[int, str]
109
- # Not in use. Keep here for back-compatibility
110
- MAPSettingsUsername: str
111
- MAPSettingsUserKey: str
112
- user_upload_token: str
113
-
114
-
115
- class _CompassHeading(TypedDict, total=True):
116
- TrueHeading: float
117
- MagneticHeading: float
118
-
119
-
120
- class _ImageRequired(TypedDict, total=True):
121
- MAPLatitude: float
122
- MAPLongitude: float
123
- MAPCaptureTime: str
95
+ class BaseSerializer(abc.ABC):
96
+ @classmethod
97
+ @abc.abstractmethod
98
+ def serialize(cls, metadatas: T.Sequence[MetadataOrError]) -> bytes:
99
+ raise NotImplementedError()
124
100
 
101
+ @classmethod
102
+ @abc.abstractmethod
103
+ def deserialize(cls, data: bytes) -> list[Metadata]:
104
+ raise NotImplementedError()
125
105
 
126
- class _Image(_ImageRequired, total=False):
127
- MAPAltitude: float
128
- MAPCompassHeading: _CompassHeading
106
+ @classmethod
107
+ def deserialize_stream(cls, data: T.IO[bytes]) -> list[Metadata]:
108
+ return cls.deserialize(data.read())
129
109
 
130
110
 
131
- class _SequenceOnly(TypedDict, total=False):
132
- MAPSequenceUUID: str
111
+ def combine_filetype_filters(
112
+ a: set[FileType] | None, b: set[FileType] | None
113
+ ) -> set[FileType] | None:
114
+ """
115
+ >>> combine_filetype_filters({FileType.CAMM}, {FileType.GOPRO})
116
+ set()
133
117
 
118
+ >>> combine_filetype_filters({FileType.CAMM}, {FileType.GOPRO, FileType.VIDEO})
119
+ {<FileType.CAMM: 'camm'>}
134
120
 
135
- class MetaProperties(TypedDict, total=False):
136
- MAPDeviceMake: str
137
- MAPDeviceModel: str
138
- MAPGPSAccuracyMeters: float
139
- MAPCameraUUID: str
140
- MAPOrientation: int
121
+ >>> combine_filetype_filters({FileType.GOPRO}, {FileType.GOPRO, FileType.VIDEO})
122
+ {<FileType.GOPRO: 'gopro'>}
141
123
 
124
+ >>> combine_filetype_filters({FileType.GOPRO}, {FileType.VIDEO})
125
+ {<FileType.GOPRO: 'gopro'>}
142
126
 
143
- class ImageDescription(_SequenceOnly, _Image, MetaProperties, total=True):
144
- # filename is required
145
- filename: str
146
- # if None or absent, it will be calculated
147
- md5sum: T.Optional[str]
148
- filetype: Literal["image"]
149
- filesize: T.Optional[int]
127
+ >>> expected = {FileType.CAMM, FileType.GOPRO}
128
+ >>> combine_filetype_filters({FileType.CAMM, FileType.GOPRO}, {FileType.VIDEO}) == expected
129
+ True
150
130
 
131
+ >>> expected = {FileType.CAMM, FileType.GOPRO, FileType.BLACKVUE, FileType.VIDEO}
132
+ >>> combine_filetype_filters({FileType.VIDEO}, {FileType.VIDEO}) == expected
133
+ True
134
+ """
151
135
 
152
- class _VideoDescriptionRequired(TypedDict, total=True):
153
- filename: str
154
- # if None or absent, it will be calculated
155
- md5sum: T.Optional[str]
156
- filetype: str
157
- MAPGPSTrack: T.List[T.Sequence[T.Union[float, int, None]]]
136
+ if a is None:
137
+ return b
158
138
 
139
+ if b is None:
140
+ return a
159
141
 
160
- class VideoDescription(_VideoDescriptionRequired, total=False):
161
- MAPDeviceMake: str
162
- MAPDeviceModel: str
163
- filesize: T.Optional[int]
142
+ # VIDEO is a superset of NATIVE_VIDEO_FILETYPES,
143
+ # so we add NATIVE_VIDEO_FILETYPES to each set for intersection later
164
144
 
145
+ if FileType.VIDEO in a:
146
+ a = a | NATIVE_VIDEO_FILETYPES
165
147
 
166
- class _ErrorDescription(TypedDict, total=False):
167
- # type and message are required
168
- type: str
169
- message: str
170
- # vars is optional
171
- vars: T.Dict
148
+ if FileType.VIDEO in b:
149
+ b = b | NATIVE_VIDEO_FILETYPES
172
150
 
151
+ return a.intersection(b)
173
152
 
174
- class _ImageDescriptionErrorRequired(TypedDict, total=True):
175
- filename: str
176
- error: _ErrorDescription
177
153
 
154
+ M = T.TypeVar("M")
178
155
 
179
- class ImageDescriptionError(_ImageDescriptionErrorRequired, total=False):
180
- filetype: str
181
156
 
157
+ def separate_errors(
158
+ metadatas: T.Iterable[M | ErrorMetadata],
159
+ ) -> tuple[list[M], list[ErrorMetadata]]:
160
+ good: list[M] = []
161
+ bad: list[ErrorMetadata] = []
182
162
 
183
- def _describe_error_desc(
184
- exc: Exception, filename: Path, filetype: T.Optional[FileType]
185
- ) -> ImageDescriptionError:
186
- err: _ErrorDescription = {
187
- "type": exc.__class__.__name__,
188
- "message": str(exc),
189
- }
190
-
191
- exc_vars = vars(exc)
192
-
193
- if exc_vars:
194
- # handle unserializable exceptions
195
- try:
196
- vars_json = json.dumps(exc_vars)
197
- except Exception:
198
- vars_json = ""
199
- if vars_json:
200
- err["vars"] = json.loads(vars_json)
201
-
202
- desc: ImageDescriptionError = {
203
- "error": err,
204
- "filename": str(filename.resolve()),
205
- }
206
- if filetype is not None:
207
- desc["filetype"] = filetype.value
163
+ for metadata in metadatas:
164
+ if isinstance(metadata, ErrorMetadata):
165
+ bad.append(metadata)
166
+ else:
167
+ good.append(metadata)
208
168
 
209
- return desc
169
+ return good, bad
210
170
 
211
171
 
212
172
  def describe_error_metadata(
213
- exc: Exception, filename: Path, filetype: T.Optional[FileType]
173
+ exc: Exception, filename: Path, filetype: FileType
214
174
  ) -> ErrorMetadata:
215
175
  return ErrorMetadata(filename=filename, filetype=filetype, error=exc)
216
176
 
217
177
 
218
- Description = T.Union[ImageDescription, VideoDescription]
219
- DescriptionOrError = T.Union[ImageDescription, VideoDescription, ImageDescriptionError]
220
-
221
-
222
- UserItemSchema = {
223
- "type": "object",
224
- "properties": {
225
- "MAPOrganizationKey": {"type": ["integer", "string"]},
226
- # Not in use. Keep here for back-compatibility
227
- "MAPSettingsUsername": {"type": "string"},
228
- "MAPSettingsUserKey": {"type": "string"},
229
- "user_upload_token": {"type": "string"},
230
- },
231
- "required": ["user_upload_token"],
232
- "additionalProperties": True,
233
- }
234
-
235
-
236
- ImageDescriptionEXIFSchema = {
237
- "type": "object",
238
- "properties": {
239
- "MAPLatitude": {
240
- "type": "number",
241
- "description": "Latitude of the image",
242
- "minimum": -90,
243
- "maximum": 90,
244
- },
245
- "MAPLongitude": {
246
- "type": "number",
247
- "description": "Longitude of the image",
248
- "minimum": -180,
249
- "maximum": 180,
250
- },
251
- "MAPAltitude": {
252
- "type": "number",
253
- "description": "Altitude of the image, in meters",
254
- },
255
- "MAPCaptureTime": {
256
- "type": "string",
257
- "description": "Capture time of the image",
258
- "pattern": "[0-9]{4}_[0-9]{2}_[0-9]{2}_[0-9]{2}_[0-9]{2}_[0-9]{2}_[0-9]+",
259
- },
260
- "MAPCompassHeading": {
261
- "type": "object",
262
- "properties": {
263
- "TrueHeading": {"type": "number"},
264
- "MagneticHeading": {"type": "number"},
265
- },
266
- "required": ["TrueHeading", "MagneticHeading"],
267
- "additionalProperties": False,
268
- "description": "Camera angle of the image, in degrees. If null, the angle will be interpolated",
269
- },
270
- "MAPSequenceUUID": {
271
- "type": "string",
272
- "description": "Arbitrary key for grouping images",
273
- "pattern": "[a-zA-Z0-9_-]+",
274
- },
275
- # deprecated since v0.10.0; keep here for compatibility
276
- "MAPMetaTags": {"type": "object"},
277
- "MAPDeviceMake": {"type": "string"},
278
- "MAPDeviceModel": {"type": "string"},
279
- "MAPGPSAccuracyMeters": {"type": "number"},
280
- "MAPCameraUUID": {"type": "string"},
281
- # deprecated since v0.10.0; keep here for compatibility
282
- "MAPFilename": {
283
- "type": "string",
284
- "description": "The base filename of the image",
285
- },
286
- "MAPOrientation": {"type": "integer"},
287
- },
288
- "required": [
289
- "MAPLatitude",
290
- "MAPLongitude",
291
- "MAPCaptureTime",
292
- ],
293
- "additionalProperties": False,
294
- }
295
-
296
- VideoDescriptionSchema = {
297
- "type": "object",
298
- "properties": {
299
- "MAPGPSTrack": {
300
- "type": "array",
301
- "items": {
302
- "type": "array",
303
- "description": "track point",
304
- "prefixItems": [
305
- {
306
- "type": "number",
307
- "description": "Time offset of the track point, in milliseconds, relative to the beginning of the video",
308
- },
309
- {
310
- "type": "number",
311
- "description": "Longitude of the track point",
312
- },
313
- {
314
- "type": "number",
315
- "description": "Latitude of the track point",
316
- },
317
- {
318
- "type": ["number", "null"],
319
- "description": "Altitude of the track point in meters",
320
- },
321
- {
322
- "type": ["number", "null"],
323
- "description": "Camera angle of the track point, in degrees. If null, the angle will be interpolated",
324
- },
325
- ],
326
- },
327
- },
328
- "MAPDeviceMake": {
329
- "type": "string",
330
- "description": "Device make, e.g. GoPro, BlackVue, Insta360",
331
- },
332
- "MAPDeviceModel": {
333
- "type": "string",
334
- "description": "Device model, e.g. HERO10 Black, DR900S-1CH, Insta360 Titan",
335
- },
336
- },
337
- "required": [
338
- "MAPGPSTrack",
339
- ],
340
- "additionalProperties": False,
341
- }
342
-
343
-
344
- def merge_schema(*schemas: T.Dict) -> T.Dict:
345
- for s in schemas:
346
- assert s.get("type") == "object", "must be all object schemas"
347
- properties = {}
348
- all_required = []
349
- additional_properties = True
350
- for s in schemas:
351
- properties.update(s.get("properties", {}))
352
- all_required += s.get("required", [])
353
- if "additionalProperties" in s:
354
- additional_properties = s["additionalProperties"]
355
- return {
356
- "type": "object",
357
- "properties": properties,
358
- "required": sorted(set(all_required)),
359
- "additionalProperties": additional_properties,
360
- }
361
-
362
-
363
- ImageDescriptionFileSchema = merge_schema(
364
- ImageDescriptionEXIFSchema,
365
- {
366
- "type": "object",
367
- "properties": {
368
- "filename": {
369
- "type": "string",
370
- "description": "Absolute path of the image",
371
- },
372
- "md5sum": {
373
- "type": ["string", "null"],
374
- "description": "MD5 checksum of the image content. If not provided, the uploader will compute it",
375
- },
376
- "filesize": {
377
- "type": ["number", "null"],
378
- "description": "File size",
379
- },
380
- "filetype": {
381
- "type": "string",
382
- "enum": [FileType.IMAGE.value],
383
- "description": "The image file type",
384
- },
385
- },
386
- "required": [
387
- "filename",
388
- "filetype",
389
- ],
390
- },
391
- )
392
-
393
- VideoDescriptionFileSchema = merge_schema(
394
- VideoDescriptionSchema,
395
- {
396
- "type": "object",
397
- "properties": {
398
- "filename": {
399
- "type": "string",
400
- "description": "Absolute path of the video",
401
- },
402
- "md5sum": {
403
- "type": ["string", "null"],
404
- "description": "MD5 checksum of the video content. If not provided, the uploader will compute it",
405
- },
406
- "filesize": {
407
- "type": ["number", "null"],
408
- "description": "File size",
409
- },
410
- "filetype": {
411
- "type": "string",
412
- "enum": [
413
- FileType.CAMM.value,
414
- FileType.GOPRO.value,
415
- FileType.BLACKVUE.value,
416
- FileType.VIDEO.value,
417
- ],
418
- "description": "The video file type",
419
- },
420
- },
421
- "required": [
422
- "filename",
423
- "filetype",
424
- ],
425
- },
426
- )
427
-
428
-
429
- ImageVideoDescriptionFileSchema = {
430
- "oneOf": [VideoDescriptionFileSchema, ImageDescriptionFileSchema]
431
- }
432
-
433
-
434
- def validate_image_desc(desc: T.Any) -> None:
435
- try:
436
- jsonschema.validate(instance=desc, schema=ImageDescriptionFileSchema)
437
- except jsonschema.ValidationError as ex:
438
- # do not use str(ex) which is more verbose
439
- raise exceptions.MapillaryMetadataValidationError(ex.message)
440
- try:
441
- map_capture_time_to_datetime(desc["MAPCaptureTime"])
442
- except ValueError as ex:
443
- raise exceptions.MapillaryMetadataValidationError(str(ex))
444
-
445
-
446
- def validate_video_desc(desc: T.Any) -> None:
447
- try:
448
- jsonschema.validate(instance=desc, schema=VideoDescriptionFileSchema)
449
- except jsonschema.ValidationError as ex:
450
- # do not use str(ex) which is more verbose
451
- raise exceptions.MapillaryMetadataValidationError(ex.message)
452
-
453
-
454
- def datetime_to_map_capture_time(time: T.Union[datetime.datetime, int, float]) -> str:
455
- if isinstance(time, (float, int)):
456
- dt = datetime.datetime.utcfromtimestamp(time)
457
- # otherwise it will be assumed to be in local time
458
- dt = dt.replace(tzinfo=datetime.timezone.utc)
459
- else:
460
- # otherwise it will be assumed to be in local time
461
- dt = time.astimezone(datetime.timezone.utc)
462
- return datetime.datetime.strftime(dt, "%Y_%m_%d_%H_%M_%S_%f")[:-3]
463
-
464
-
465
- def map_capture_time_to_datetime(time: str) -> datetime.datetime:
466
- dt = datetime.datetime.strptime(time, "%Y_%m_%d_%H_%M_%S_%f")
467
- dt = dt.replace(tzinfo=datetime.timezone.utc)
468
- return dt
469
-
470
-
471
- @T.overload
472
- def as_desc(metadata: ImageMetadata) -> ImageDescription: ...
473
-
474
-
475
- @T.overload
476
- def as_desc(metadata: ErrorMetadata) -> ImageDescriptionError: ...
477
-
478
-
479
- @T.overload
480
- def as_desc(metadata: VideoMetadata) -> VideoDescription: ...
481
-
482
-
483
- def as_desc(metadata):
484
- if isinstance(metadata, ErrorMetadata):
485
- return _describe_error_desc(
486
- metadata.error, metadata.filename, metadata.filetype
487
- )
488
- elif isinstance(metadata, VideoMetadata):
489
- return _as_video_desc(metadata)
490
- else:
491
- assert isinstance(metadata, ImageMetadata)
492
- return _as_image_desc(metadata)
493
-
494
-
495
- def _as_video_desc(metadata: VideoMetadata) -> VideoDescription:
496
- desc: VideoDescription = {
497
- "filename": str(metadata.filename.resolve()),
498
- "md5sum": metadata.md5sum,
499
- "filetype": metadata.filetype.value,
500
- "filesize": metadata.filesize,
501
- "MAPGPSTrack": [_encode_point(p) for p in metadata.points],
502
- }
503
- if metadata.make:
504
- desc["MAPDeviceMake"] = metadata.make
505
- if metadata.model:
506
- desc["MAPDeviceModel"] = metadata.model
507
- return desc
508
-
509
-
510
- def _as_image_desc(metadata: ImageMetadata) -> ImageDescription:
511
- desc: ImageDescription = {
512
- "filename": str(metadata.filename.resolve()),
513
- "md5sum": metadata.md5sum,
514
- "filesize": metadata.filesize,
515
- "filetype": FileType.IMAGE.value,
516
- "MAPLatitude": round(metadata.lat, _COORDINATES_PRECISION),
517
- "MAPLongitude": round(metadata.lon, _COORDINATES_PRECISION),
518
- "MAPCaptureTime": datetime_to_map_capture_time(metadata.time),
519
- }
520
- if metadata.alt is not None:
521
- desc["MAPAltitude"] = round(metadata.alt, _ALTITUDE_PRECISION)
522
- if metadata.angle is not None:
523
- desc["MAPCompassHeading"] = {
524
- "TrueHeading": round(metadata.angle, _ANGLE_PRECISION),
525
- "MagneticHeading": round(metadata.angle, _ANGLE_PRECISION),
526
- }
527
- fields = dataclasses.fields(metadata)
528
- for field in fields:
529
- if field.name.startswith("MAP"):
530
- value = getattr(metadata, field.name)
531
- if value is not None:
532
- # ignore error: TypedDict key must be a string literal;
533
- # expected one of ("MAPMetaTags", "MAPDeviceMake", "MAPDeviceModel", "MAPGPSAccuracyMeters", "MAPCameraUUID", ...)
534
- desc[field.name] = value # type: ignore
535
- return desc
536
-
537
-
538
- @T.overload
539
- def from_desc(metadata: ImageDescription) -> ImageMetadata: ...
540
-
541
-
542
- @T.overload
543
- def from_desc(metadata: VideoDescription) -> VideoMetadata: ...
544
-
545
-
546
- def from_desc(desc):
547
- assert "error" not in desc
548
- if desc["filetype"] == FileType.IMAGE.value:
549
- return _from_image_desc(desc)
550
- else:
551
- return _from_video_desc(desc)
552
-
553
-
554
- def _from_image_desc(desc) -> ImageMetadata:
555
- kwargs: T.Dict = {}
556
- for k, v in desc.items():
557
- if k not in [
558
- "filename",
559
- "md5sum",
560
- "filesize",
561
- "filetype",
562
- "MAPLatitude",
563
- "MAPLongitude",
564
- "MAPAltitude",
565
- "MAPCaptureTime",
566
- "MAPCompassHeading",
567
- ]:
568
- kwargs[k] = v
569
-
570
- return ImageMetadata(
571
- filename=Path(desc["filename"]),
572
- md5sum=desc.get("md5sum"),
573
- filesize=desc.get("filesize"),
574
- lat=desc["MAPLatitude"],
575
- lon=desc["MAPLongitude"],
576
- alt=desc.get("MAPAltitude"),
577
- time=geo.as_unix_time(map_capture_time_to_datetime(desc["MAPCaptureTime"])),
578
- angle=desc.get("MAPCompassHeading", {}).get("TrueHeading"),
579
- width=None,
580
- height=None,
581
- **kwargs,
582
- )
583
-
584
-
585
- def _encode_point(p: geo.Point) -> T.Sequence[T.Union[float, int, None]]:
586
- entry = [
587
- int(p.time * 1000),
588
- round(p.lon, _COORDINATES_PRECISION),
589
- round(p.lat, _COORDINATES_PRECISION),
590
- round(p.alt, _ALTITUDE_PRECISION) if p.alt is not None else None,
591
- round(p.angle, _ANGLE_PRECISION) if p.angle is not None else None,
592
- ]
593
- return entry
594
-
595
-
596
- def _decode_point(entry: T.Sequence[T.Any]) -> geo.Point:
597
- time_ms, lon, lat, alt, angle = entry
598
- return geo.Point(time=time_ms / 1000, lon=lon, lat=lat, alt=alt, angle=angle)
599
-
600
-
601
- def _from_video_desc(desc: VideoDescription) -> VideoMetadata:
602
- return VideoMetadata(
603
- filename=Path(desc["filename"]),
604
- md5sum=desc["md5sum"],
605
- filesize=desc["filesize"],
606
- filetype=FileType(desc["filetype"]),
607
- points=[_decode_point(entry) for entry in desc["MAPGPSTrack"]],
608
- make=desc.get("MAPDeviceMake"),
609
- model=desc.get("MAPDeviceModel"),
610
- )
611
-
612
-
613
- def validate_and_fail_desc(desc: DescriptionOrError) -> DescriptionOrError:
614
- if "error" in desc:
615
- return desc
616
-
617
- filetype = desc.get("filetype")
618
- try:
619
- if filetype == FileType.IMAGE.value:
620
- validate_image_desc(desc)
621
- else:
622
- validate_video_desc(desc)
623
- except exceptions.MapillaryMetadataValidationError as ex:
624
- return _describe_error_desc(
625
- ex,
626
- Path(desc["filename"]),
627
- filetype=FileType(filetype) if filetype else None,
628
- )
629
-
630
- if not os.path.isfile(desc["filename"]):
631
- return _describe_error_desc(
632
- exceptions.MapillaryMetadataValidationError(
633
- f"No such file {desc['filename']}"
634
- ),
635
- Path(desc["filename"]),
636
- filetype=FileType(filetype) if filetype else None,
637
- )
638
-
639
- return desc
640
-
641
-
642
- # Same as validate_and_fail_desc but for the metadata dataclass
643
- def validate_and_fail_metadata(metadata: MetadataOrError) -> MetadataOrError:
644
- if isinstance(metadata, ErrorMetadata):
645
- return metadata
646
-
647
- filetype: T.Optional[FileType] = None
648
- 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))
656
- except exceptions.MapillaryMetadataValidationError as ex:
657
- # rethrow because the original error is too verbose
658
- return describe_error_metadata(
659
- ex,
660
- metadata.filename,
661
- filetype=filetype,
662
- )
663
-
664
- if not metadata.filename.is_file():
665
- return describe_error_metadata(
666
- exceptions.MapillaryMetadataValidationError(
667
- f"No such file {metadata.filename}"
668
- ),
669
- metadata.filename,
670
- filetype=filetype,
671
- )
672
-
673
- return metadata
674
-
675
-
676
- def desc_file_to_exif(
677
- desc: ImageDescription,
678
- ) -> ImageDescription:
679
- not_needed = ["MAPSequenceUUID"]
680
- removed = {
681
- key: value
682
- for key, value in desc.items()
683
- if key.startswith("MAP") and key not in not_needed
684
- }
685
- return T.cast(ImageDescription, removed)
686
-
687
-
688
178
  def group_and_sort_images(
689
- metadatas: T.Sequence[ImageMetadata],
690
- ) -> T.Dict[str, T.List[ImageMetadata]]:
179
+ metadatas: T.Iterable[ImageMetadata],
180
+ ) -> dict[str, list[ImageMetadata]]:
691
181
  # group metadatas by uuid
692
- sequences_by_uuid: T.Dict[str, T.List[ImageMetadata]] = {}
182
+ sequences_by_uuid: dict[str, list[ImageMetadata]] = {}
693
183
  missing_sequence_uuid = str(uuid.uuid4())
694
184
  for metadata in metadatas:
695
185
  if metadata.MAPSequenceUUID is None:
@@ -709,13 +199,10 @@ def group_and_sort_images(
709
199
  return sorted_sequences_by_uuid
710
200
 
711
201
 
712
- def sequence_md5sum(sequence: T.Iterable[ImageMetadata]) -> str:
202
+ def update_sequence_md5sum(sequence: T.Iterable[ImageMetadata]) -> str:
713
203
  md5 = hashlib.md5()
714
204
  for metadata in sequence:
205
+ metadata.update_md5sum()
715
206
  assert isinstance(metadata.md5sum, str), "md5sum should be calculated"
716
207
  md5.update(metadata.md5sum.encode("utf-8"))
717
208
  return md5.hexdigest()
718
-
719
-
720
- if __name__ == "__main__":
721
- print(json.dumps(ImageVideoDescriptionFileSchema, indent=4))