mapillary-tools 0.14.0a1__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.
- mapillary_tools/__init__.py +1 -1
- mapillary_tools/api_v4.py +5 -4
- mapillary_tools/authenticate.py +9 -9
- mapillary_tools/blackvue_parser.py +79 -22
- mapillary_tools/camm/camm_parser.py +5 -5
- mapillary_tools/commands/__main__.py +1 -2
- mapillary_tools/config.py +41 -18
- mapillary_tools/constants.py +3 -2
- mapillary_tools/exceptions.py +1 -1
- mapillary_tools/exif_read.py +65 -65
- mapillary_tools/exif_write.py +7 -7
- mapillary_tools/exiftool_read.py +23 -46
- mapillary_tools/exiftool_read_video.py +88 -49
- mapillary_tools/exiftool_runner.py +4 -24
- mapillary_tools/ffmpeg.py +417 -242
- mapillary_tools/geo.py +4 -21
- mapillary_tools/geotag/__init__.py +0 -1
- mapillary_tools/geotag/{geotag_from_generic.py → base.py} +34 -50
- mapillary_tools/geotag/factory.py +105 -103
- mapillary_tools/geotag/geotag_images_from_exif.py +15 -51
- mapillary_tools/geotag/geotag_images_from_exiftool.py +118 -63
- mapillary_tools/geotag/geotag_images_from_gpx.py +33 -16
- mapillary_tools/geotag/geotag_images_from_gpx_file.py +2 -34
- mapillary_tools/geotag/geotag_images_from_nmea_file.py +0 -3
- mapillary_tools/geotag/geotag_images_from_video.py +51 -14
- mapillary_tools/geotag/geotag_videos_from_exiftool.py +123 -0
- mapillary_tools/geotag/geotag_videos_from_gpx.py +35 -123
- mapillary_tools/geotag/geotag_videos_from_video.py +14 -147
- mapillary_tools/geotag/image_extractors/base.py +18 -0
- mapillary_tools/geotag/image_extractors/exif.py +60 -0
- mapillary_tools/geotag/image_extractors/exiftool.py +18 -0
- mapillary_tools/geotag/options.py +26 -3
- mapillary_tools/geotag/utils.py +62 -0
- mapillary_tools/geotag/video_extractors/base.py +18 -0
- mapillary_tools/geotag/video_extractors/exiftool.py +70 -0
- mapillary_tools/geotag/video_extractors/gpx.py +116 -0
- mapillary_tools/geotag/video_extractors/native.py +135 -0
- mapillary_tools/gpmf/gpmf_parser.py +16 -16
- mapillary_tools/gpmf/gps_filter.py +5 -3
- mapillary_tools/history.py +8 -3
- mapillary_tools/mp4/construct_mp4_parser.py +9 -8
- mapillary_tools/mp4/mp4_sample_parser.py +27 -27
- mapillary_tools/mp4/simple_mp4_builder.py +10 -9
- mapillary_tools/mp4/simple_mp4_parser.py +13 -12
- mapillary_tools/process_geotag_properties.py +21 -15
- mapillary_tools/process_sequence_properties.py +49 -49
- mapillary_tools/sample_video.py +15 -14
- mapillary_tools/serializer/description.py +587 -0
- mapillary_tools/serializer/gpx.py +132 -0
- mapillary_tools/telemetry.py +6 -5
- mapillary_tools/types.py +64 -635
- mapillary_tools/upload.py +176 -197
- mapillary_tools/upload_api_v4.py +94 -51
- mapillary_tools/uploader.py +284 -138
- mapillary_tools/utils.py +16 -18
- {mapillary_tools-0.14.0a1.dist-info → mapillary_tools-0.14.0b1.dist-info}/METADATA +87 -31
- mapillary_tools-0.14.0b1.dist-info/RECORD +75 -0
- {mapillary_tools-0.14.0a1.dist-info → mapillary_tools-0.14.0b1.dist-info}/WHEEL +1 -1
- mapillary_tools/geotag/geotag_images_from_exiftool_both_image_and_video.py +0 -77
- mapillary_tools/geotag/geotag_videos_from_exiftool_video.py +0 -151
- mapillary_tools/video_data_extraction/cli_options.py +0 -22
- mapillary_tools/video_data_extraction/extract_video_data.py +0 -157
- mapillary_tools/video_data_extraction/extractors/base_parser.py +0 -75
- mapillary_tools/video_data_extraction/extractors/blackvue_parser.py +0 -49
- mapillary_tools/video_data_extraction/extractors/camm_parser.py +0 -62
- mapillary_tools/video_data_extraction/extractors/exiftool_runtime_parser.py +0 -74
- mapillary_tools/video_data_extraction/extractors/exiftool_xml_parser.py +0 -52
- mapillary_tools/video_data_extraction/extractors/generic_video_parser.py +0 -52
- mapillary_tools/video_data_extraction/extractors/gopro_parser.py +0 -58
- mapillary_tools/video_data_extraction/extractors/gpx_parser.py +0 -108
- mapillary_tools/video_data_extraction/extractors/nmea_parser.py +0 -24
- mapillary_tools/video_data_extraction/video_data_parser_factory.py +0 -39
- mapillary_tools-0.14.0a1.dist-info/RECORD +0 -78
- {mapillary_tools-0.14.0a1.dist-info → mapillary_tools-0.14.0b1.dist-info}/entry_points.txt +0 -0
- {mapillary_tools-0.14.0a1.dist-info → mapillary_tools-0.14.0b1.dist-info}/licenses/LICENSE +0 -0
- {mapillary_tools-0.14.0a1.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))
|