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