mapillary-tools 0.13.3a1__py3-none-any.whl → 0.14.0a1__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 +235 -14
- mapillary_tools/authenticate.py +325 -64
- mapillary_tools/{geotag/blackvue_parser.py → blackvue_parser.py} +74 -54
- mapillary_tools/camm/camm_builder.py +55 -97
- mapillary_tools/camm/camm_parser.py +425 -177
- mapillary_tools/commands/__main__.py +11 -4
- mapillary_tools/commands/authenticate.py +8 -1
- mapillary_tools/commands/process.py +27 -51
- mapillary_tools/commands/process_and_upload.py +19 -5
- mapillary_tools/commands/sample_video.py +2 -3
- mapillary_tools/commands/upload.py +18 -9
- mapillary_tools/commands/video_process_and_upload.py +19 -5
- mapillary_tools/config.py +28 -12
- mapillary_tools/constants.py +46 -4
- mapillary_tools/exceptions.py +34 -35
- mapillary_tools/exif_read.py +158 -53
- mapillary_tools/exiftool_read.py +19 -5
- mapillary_tools/exiftool_read_video.py +12 -1
- mapillary_tools/exiftool_runner.py +77 -0
- mapillary_tools/geo.py +148 -107
- mapillary_tools/geotag/factory.py +298 -0
- mapillary_tools/geotag/geotag_from_generic.py +152 -11
- mapillary_tools/geotag/geotag_images_from_exif.py +43 -124
- mapillary_tools/geotag/geotag_images_from_exiftool.py +66 -70
- mapillary_tools/geotag/geotag_images_from_exiftool_both_image_and_video.py +32 -48
- mapillary_tools/geotag/geotag_images_from_gpx.py +41 -116
- mapillary_tools/geotag/geotag_images_from_gpx_file.py +15 -96
- mapillary_tools/geotag/geotag_images_from_nmea_file.py +4 -2
- mapillary_tools/geotag/geotag_images_from_video.py +46 -46
- mapillary_tools/geotag/geotag_videos_from_exiftool_video.py +98 -92
- mapillary_tools/geotag/geotag_videos_from_gpx.py +140 -0
- mapillary_tools/geotag/geotag_videos_from_video.py +149 -181
- mapillary_tools/geotag/options.py +159 -0
- mapillary_tools/{geotag → gpmf}/gpmf_parser.py +194 -171
- mapillary_tools/history.py +3 -11
- mapillary_tools/mp4/io_utils.py +0 -1
- mapillary_tools/mp4/mp4_sample_parser.py +11 -3
- mapillary_tools/mp4/simple_mp4_parser.py +0 -10
- mapillary_tools/process_geotag_properties.py +151 -386
- mapillary_tools/process_sequence_properties.py +554 -202
- mapillary_tools/sample_video.py +8 -15
- mapillary_tools/telemetry.py +24 -12
- mapillary_tools/types.py +80 -22
- mapillary_tools/upload.py +316 -298
- mapillary_tools/upload_api_v4.py +55 -122
- mapillary_tools/uploader.py +396 -254
- mapillary_tools/utils.py +26 -0
- mapillary_tools/video_data_extraction/extract_video_data.py +17 -36
- mapillary_tools/video_data_extraction/extractors/blackvue_parser.py +34 -19
- mapillary_tools/video_data_extraction/extractors/camm_parser.py +41 -17
- mapillary_tools/video_data_extraction/extractors/exiftool_runtime_parser.py +4 -1
- mapillary_tools/video_data_extraction/extractors/exiftool_xml_parser.py +1 -2
- mapillary_tools/video_data_extraction/extractors/gopro_parser.py +37 -22
- {mapillary_tools-0.13.3a1.dist-info → mapillary_tools-0.14.0a1.dist-info}/METADATA +3 -2
- mapillary_tools-0.14.0a1.dist-info/RECORD +78 -0
- {mapillary_tools-0.13.3a1.dist-info → mapillary_tools-0.14.0a1.dist-info}/WHEEL +1 -1
- mapillary_tools/geotag/utils.py +0 -26
- mapillary_tools-0.13.3a1.dist-info/RECORD +0 -75
- /mapillary_tools/{geotag → gpmf}/gpmf_gps_filter.py +0 -0
- /mapillary_tools/{geotag → gpmf}/gps_filter.py +0 -0
- {mapillary_tools-0.13.3a1.dist-info → mapillary_tools-0.14.0a1.dist-info}/entry_points.txt +0 -0
- {mapillary_tools-0.13.3a1.dist-info → mapillary_tools-0.14.0a1.dist-info/licenses}/LICENSE +0 -0
- {mapillary_tools-0.13.3a1.dist-info → mapillary_tools-0.14.0a1.dist-info}/top_level.txt +0 -0
mapillary_tools/authenticate.py
CHANGED
|
@@ -1,102 +1,363 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
import getpass
|
|
4
|
+
import json
|
|
2
5
|
import logging
|
|
6
|
+
import re
|
|
7
|
+
import sys
|
|
3
8
|
import typing as T
|
|
4
9
|
|
|
5
10
|
import jsonschema
|
|
11
|
+
|
|
6
12
|
import requests
|
|
7
13
|
|
|
8
|
-
from . import api_v4, config, types
|
|
14
|
+
from . import api_v4, config, constants, exceptions, types
|
|
9
15
|
|
|
10
16
|
|
|
11
17
|
LOG = logging.getLogger(__name__)
|
|
12
18
|
|
|
13
19
|
|
|
14
20
|
def authenticate(
|
|
15
|
-
user_name:
|
|
16
|
-
user_email:
|
|
17
|
-
user_password:
|
|
18
|
-
jwt:
|
|
21
|
+
user_name: str | None = None,
|
|
22
|
+
user_email: str | None = None,
|
|
23
|
+
user_password: str | None = None,
|
|
24
|
+
jwt: str | None = None,
|
|
25
|
+
delete: bool = False,
|
|
19
26
|
):
|
|
20
|
-
|
|
21
|
-
|
|
27
|
+
"""
|
|
28
|
+
Prompt for authentication information and save it to the config file
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
# We still have to accept --user_name for the back compatibility
|
|
32
|
+
profile_name = user_name
|
|
22
33
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
34
|
+
all_user_items = config.list_all_users()
|
|
35
|
+
if all_user_items:
|
|
36
|
+
_list_all_profiles(all_user_items)
|
|
37
|
+
else:
|
|
38
|
+
_welcome()
|
|
39
|
+
|
|
40
|
+
# Make sure profile name either validated or existed
|
|
41
|
+
if profile_name is not None:
|
|
42
|
+
profile_name = profile_name.strip()
|
|
43
|
+
else:
|
|
44
|
+
if not _prompt_enabled():
|
|
45
|
+
raise exceptions.MapillaryBadParameterError(
|
|
46
|
+
"Profile name is required, please specify one with --user_name"
|
|
47
|
+
)
|
|
48
|
+
profile_name = _prompt_choose_profile_name(
|
|
49
|
+
list(all_user_items.keys()), must_exist=delete
|
|
26
50
|
)
|
|
27
|
-
user_name = user_name.strip()
|
|
28
51
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
52
|
+
assert profile_name is not None, "profile_name should be set"
|
|
53
|
+
|
|
54
|
+
if delete:
|
|
55
|
+
config.remove_config(profile_name)
|
|
56
|
+
LOG.info('Profile "%s" deleted', profile_name)
|
|
57
|
+
else:
|
|
58
|
+
if profile_name in all_user_items:
|
|
59
|
+
LOG.warning(
|
|
60
|
+
'The profile "%s" already exists and will be overridden',
|
|
61
|
+
profile_name,
|
|
62
|
+
)
|
|
63
|
+
else:
|
|
64
|
+
LOG.info('Creating new profile: "%s"', profile_name)
|
|
65
|
+
|
|
66
|
+
if jwt:
|
|
67
|
+
user_items: types.UserItem = {"user_upload_token": jwt}
|
|
68
|
+
user_items = _verify_user_auth(_validate_profile(user_items))
|
|
69
|
+
else:
|
|
70
|
+
user_items = _prompt_login(
|
|
71
|
+
user_email=user_email, user_password=user_password
|
|
72
|
+
)
|
|
73
|
+
_validate_profile(user_items)
|
|
74
|
+
|
|
75
|
+
# Update the config with the new user items
|
|
76
|
+
config.update_config(profile_name, user_items)
|
|
77
|
+
|
|
78
|
+
# TODO: print more user information
|
|
79
|
+
if profile_name in all_user_items:
|
|
80
|
+
LOG.info(
|
|
81
|
+
'Profile "%s" updated: %s', profile_name, api_v4._sanitize(user_items)
|
|
82
|
+
)
|
|
83
|
+
else:
|
|
84
|
+
LOG.info(
|
|
85
|
+
'Profile "%s" created: %s', profile_name, api_v4._sanitize(user_items)
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def fetch_user_items(
|
|
90
|
+
user_name: str | None = None,
|
|
91
|
+
organization_key: str | None = None,
|
|
92
|
+
) -> types.UserItem:
|
|
93
|
+
"""
|
|
94
|
+
Read user information from the config file,
|
|
95
|
+
or prompt the user to authenticate if the specified profile does not exist
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
# we still have to accept --user_name for the back compatibility
|
|
99
|
+
profile_name = user_name
|
|
100
|
+
|
|
101
|
+
all_user_items = config.list_all_users()
|
|
102
|
+
if not all_user_items:
|
|
103
|
+
authenticate(user_name=profile_name)
|
|
104
|
+
|
|
105
|
+
# Fetch user information only here
|
|
106
|
+
all_user_items = config.list_all_users()
|
|
107
|
+
assert len(all_user_items) >= 1, "should have at least 1 profile"
|
|
108
|
+
if profile_name is None:
|
|
109
|
+
if len(all_user_items) > 1:
|
|
110
|
+
if not _prompt_enabled():
|
|
111
|
+
raise exceptions.MapillaryBadParameterError(
|
|
112
|
+
"Multiple user profiles found, please choose one with --user_name"
|
|
113
|
+
)
|
|
114
|
+
_list_all_profiles(all_user_items)
|
|
115
|
+
profile_name = _prompt_choose_profile_name(
|
|
116
|
+
list(all_user_items.keys()), must_exist=True
|
|
117
|
+
)
|
|
118
|
+
user_items = all_user_items[profile_name]
|
|
119
|
+
else:
|
|
120
|
+
profile_name, user_items = list(all_user_items.items())[0]
|
|
40
121
|
else:
|
|
41
|
-
|
|
122
|
+
if profile_name in all_user_items:
|
|
123
|
+
user_items = all_user_items[profile_name]
|
|
124
|
+
else:
|
|
125
|
+
_list_all_profiles(all_user_items)
|
|
126
|
+
raise exceptions.MapillaryBadParameterError(
|
|
127
|
+
f'Profile "{profile_name}" not found'
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
assert profile_name is not None, "profile_name should be set"
|
|
42
131
|
|
|
43
|
-
|
|
132
|
+
user_items = _verify_user_auth(_validate_profile(user_items))
|
|
44
133
|
|
|
134
|
+
LOG.info(
|
|
135
|
+
'Uploading to profile "%s": %s', profile_name, api_v4._sanitize(user_items)
|
|
136
|
+
)
|
|
45
137
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
138
|
+
if organization_key is not None:
|
|
139
|
+
resp = api_v4.fetch_organization(
|
|
140
|
+
user_items["user_upload_token"], organization_key
|
|
141
|
+
)
|
|
142
|
+
LOG.info("Uploading to Mapillary organization: %s", json.dumps(resp.json()))
|
|
143
|
+
user_items["MAPOrganizationKey"] = organization_key
|
|
50
144
|
|
|
145
|
+
return user_items
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _echo(*args, **kwargs):
|
|
149
|
+
print(*args, **kwargs, file=sys.stderr)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _prompt(message: str) -> str:
|
|
153
|
+
"""Display prompt on stderr and get input from stdin"""
|
|
154
|
+
print(message, end="", file=sys.stderr, flush=True)
|
|
155
|
+
return input()
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _validate_profile(user_items: types.UserItem) -> types.UserItem:
|
|
51
159
|
try:
|
|
52
|
-
|
|
160
|
+
jsonschema.validate(user_items, types.UserItemSchema)
|
|
161
|
+
except jsonschema.ValidationError as ex:
|
|
162
|
+
raise exceptions.MapillaryBadParameterError(
|
|
163
|
+
f"Invalid profile format: {ex.message}"
|
|
164
|
+
)
|
|
165
|
+
return user_items
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _verify_user_auth(user_items: types.UserItem) -> types.UserItem:
|
|
169
|
+
"""
|
|
170
|
+
Verify that the user access token is valid
|
|
171
|
+
"""
|
|
172
|
+
if constants._AUTH_VERIFICATION_DISABLED:
|
|
173
|
+
return user_items
|
|
174
|
+
|
|
175
|
+
try:
|
|
176
|
+
resp = api_v4.fetch_user_or_me(
|
|
177
|
+
user_access_token=user_items["user_upload_token"]
|
|
178
|
+
)
|
|
53
179
|
except requests.HTTPError as ex:
|
|
54
|
-
if (
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
and 400 <= ex.response.status_code < 500
|
|
58
|
-
):
|
|
59
|
-
r = ex.response.json()
|
|
60
|
-
subcode = r.get("error", {}).get("error_subcode")
|
|
61
|
-
if subcode in [1348028, 1348092, 3404005, 1348131]:
|
|
62
|
-
title = r.get("error", {}).get("error_user_title")
|
|
63
|
-
message = r.get("error", {}).get("error_user_msg")
|
|
64
|
-
LOG.error("%s: %s", title, message)
|
|
65
|
-
return prompt_user_for_user_items(user_name)
|
|
66
|
-
else:
|
|
67
|
-
raise ex
|
|
180
|
+
if api_v4.is_auth_error(ex.response):
|
|
181
|
+
message = api_v4.extract_auth_error_message(ex.response)
|
|
182
|
+
raise exceptions.MapillaryUploadUnauthorizedError(message)
|
|
68
183
|
else:
|
|
69
184
|
raise ex
|
|
70
185
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
186
|
+
user_json = resp.json()
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
**user_items,
|
|
190
|
+
"MAPSettingsUsername": user_json.get("username"),
|
|
191
|
+
"MAPSettingsUserKey": user_json.get("id"),
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _validate_profile_name(profile_name: str):
|
|
196
|
+
if not (2 <= len(profile_name) <= 32):
|
|
197
|
+
raise exceptions.MapillaryBadParameterError(
|
|
198
|
+
"Profile name must be between 2 and 32 characters long"
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
pattern = re.compile(r"^[a-zA-Z]+[a-zA-Z0-9_-]*$")
|
|
202
|
+
if not bool(pattern.match(profile_name)):
|
|
203
|
+
raise exceptions.MapillaryBadParameterError(
|
|
204
|
+
"Invalid profile name. Use only letters, numbers, hyphens and underscores"
|
|
77
205
|
)
|
|
78
206
|
|
|
79
|
-
if isinstance(user_key, int):
|
|
80
|
-
user_key = str(user_key)
|
|
81
207
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
208
|
+
def _list_all_profiles(profiles: dict[str, types.UserItem]) -> None:
|
|
209
|
+
_echo("Existing Mapillary profiles:")
|
|
210
|
+
|
|
211
|
+
# Header
|
|
212
|
+
_echo(f"{'':>5} {'Profile name':<32} {'User ID':>16} {'Username':>32}")
|
|
213
|
+
|
|
214
|
+
# List all profiles
|
|
215
|
+
for idx, name in enumerate(profiles, 1):
|
|
216
|
+
items = profiles[name]
|
|
217
|
+
user_id = items.get("MAPSettingsUserKey", "N/A")
|
|
218
|
+
username = items.get("MAPSettingsUsername", "N/A")
|
|
219
|
+
_echo(f"{idx:>5}. {name:<32} {user_id:>16} {username:>32}")
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _is_interactive():
|
|
223
|
+
# Check if stdout is connected to a terminal
|
|
224
|
+
stdout_interactive = sys.stdout.isatty() if hasattr(sys.stdout, "isatty") else False
|
|
225
|
+
|
|
226
|
+
# Optionally, also check stdin and stderr
|
|
227
|
+
stdin_interactive = sys.stdin.isatty() if hasattr(sys.stdin, "isatty") else False
|
|
228
|
+
stderr_interactive = sys.stderr.isatty() if hasattr(sys.stderr, "isatty") else False
|
|
229
|
+
|
|
230
|
+
# Return True if any stream is interactive
|
|
231
|
+
return stdout_interactive or stdin_interactive or stderr_interactive
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _prompt_enabled() -> bool:
|
|
235
|
+
if constants.PROMPT_DISABLED:
|
|
236
|
+
return False
|
|
237
|
+
|
|
238
|
+
if not _is_interactive():
|
|
239
|
+
return False
|
|
240
|
+
|
|
241
|
+
return True
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _is_login_retryable(ex: requests.HTTPError) -> bool:
|
|
245
|
+
if 400 <= ex.response.status_code < 500:
|
|
246
|
+
r = ex.response.json()
|
|
247
|
+
subcode = r.get("error", {}).get("error_subcode")
|
|
248
|
+
if subcode in [1348028, 1348092, 3404005, 1348131]:
|
|
249
|
+
title = r.get("error", {}).get("error_user_title")
|
|
250
|
+
message = r.get("error", {}).get("error_user_msg")
|
|
251
|
+
LOG.error("%s: %s", title, message)
|
|
252
|
+
return True
|
|
253
|
+
return False
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _prompt_login(
|
|
257
|
+
user_email: str | None = None,
|
|
258
|
+
user_password: str | None = None,
|
|
259
|
+
) -> types.UserItem:
|
|
260
|
+
_enabled = _prompt_enabled()
|
|
261
|
+
|
|
262
|
+
if user_email is None:
|
|
263
|
+
if not _enabled:
|
|
264
|
+
raise exceptions.MapillaryBadParameterError("user_email is required")
|
|
265
|
+
while not user_email:
|
|
266
|
+
user_email = _prompt("Enter Mapillary user email: ").strip()
|
|
267
|
+
else:
|
|
268
|
+
user_email = user_email.strip()
|
|
269
|
+
|
|
270
|
+
if user_password is None:
|
|
271
|
+
if not _enabled:
|
|
272
|
+
raise exceptions.MapillaryBadParameterError("user_password is required")
|
|
273
|
+
while True:
|
|
274
|
+
user_password = getpass.getpass("Enter Mapillary user password: ")
|
|
275
|
+
if user_password:
|
|
276
|
+
break
|
|
277
|
+
|
|
278
|
+
try:
|
|
279
|
+
resp = api_v4.get_upload_token(user_email, user_password)
|
|
280
|
+
except requests.HTTPError as ex:
|
|
281
|
+
if not _enabled:
|
|
282
|
+
raise ex
|
|
283
|
+
|
|
284
|
+
if _is_login_retryable(ex):
|
|
285
|
+
return _prompt_login()
|
|
286
|
+
|
|
287
|
+
raise ex
|
|
288
|
+
|
|
289
|
+
data = resp.json()
|
|
290
|
+
|
|
291
|
+
user_items: types.UserItem = {
|
|
292
|
+
"user_upload_token": str(data["access_token"]),
|
|
293
|
+
"MAPSettingsUserKey": str(data["user_id"]),
|
|
85
294
|
}
|
|
86
295
|
|
|
296
|
+
return user_items
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _prompt_choose_profile_name(
|
|
300
|
+
existing_profile_names: T.Sequence[str], must_exist: bool = False
|
|
301
|
+
) -> str:
|
|
302
|
+
assert _prompt_enabled(), "should not get here if prompting is disabled"
|
|
303
|
+
|
|
304
|
+
existed = set(existing_profile_names)
|
|
305
|
+
|
|
306
|
+
while True:
|
|
307
|
+
if must_exist:
|
|
308
|
+
prompt = "Enter an existing profile: "
|
|
309
|
+
else:
|
|
310
|
+
prompt = "Enter an existing profile or create a new one: "
|
|
311
|
+
|
|
312
|
+
profile_name = _prompt(prompt).strip()
|
|
87
313
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
314
|
+
if not profile_name:
|
|
315
|
+
continue
|
|
316
|
+
|
|
317
|
+
# Exit if it's found
|
|
318
|
+
if profile_name in existed:
|
|
319
|
+
break
|
|
320
|
+
|
|
321
|
+
# Try to find by index
|
|
91
322
|
try:
|
|
92
|
-
|
|
93
|
-
except
|
|
323
|
+
profile_name = existing_profile_names[int(profile_name) - 1]
|
|
324
|
+
except (ValueError, IndexError):
|
|
94
325
|
pass
|
|
95
326
|
else:
|
|
96
|
-
|
|
327
|
+
# Exit if it's found
|
|
328
|
+
break
|
|
97
329
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
330
|
+
assert profile_name not in existed, (
|
|
331
|
+
f"Profile {profile_name} must not exist here"
|
|
332
|
+
)
|
|
101
333
|
|
|
102
|
-
|
|
334
|
+
if must_exist:
|
|
335
|
+
LOG.error('Profile "%s" not found', profile_name)
|
|
336
|
+
else:
|
|
337
|
+
try:
|
|
338
|
+
_validate_profile_name(profile_name)
|
|
339
|
+
except exceptions.MapillaryBadParameterError as ex:
|
|
340
|
+
LOG.error("Error validating profile name: %s", ex)
|
|
341
|
+
profile_name = ""
|
|
342
|
+
else:
|
|
343
|
+
break
|
|
344
|
+
|
|
345
|
+
if must_exist:
|
|
346
|
+
assert profile_name in existed, f"Profile {profile_name} must exist"
|
|
347
|
+
|
|
348
|
+
return profile_name
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _welcome():
|
|
352
|
+
_echo(
|
|
353
|
+
"""
|
|
354
|
+
================================================================================
|
|
355
|
+
Welcome to Mapillary!
|
|
356
|
+
================================================================================
|
|
357
|
+
If you haven't registered yet, please visit https://www.mapillary.com/signup
|
|
358
|
+
to create your account first.
|
|
359
|
+
|
|
360
|
+
Once registered, proceed here to sign in.
|
|
361
|
+
================================================================================
|
|
362
|
+
"""
|
|
363
|
+
)
|
|
@@ -1,13 +1,16 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import dataclasses
|
|
4
|
+
|
|
1
5
|
import json
|
|
2
6
|
import logging
|
|
3
|
-
import pathlib
|
|
4
7
|
import re
|
|
5
8
|
import typing as T
|
|
6
9
|
|
|
7
10
|
import pynmea2
|
|
8
11
|
|
|
9
|
-
from
|
|
10
|
-
from
|
|
12
|
+
from . import geo
|
|
13
|
+
from .mp4 import simple_mp4_parser as sparser
|
|
11
14
|
|
|
12
15
|
|
|
13
16
|
LOG = logging.getLogger(__name__)
|
|
@@ -26,31 +29,45 @@ NMEA_LINE_REGEX = re.compile(
|
|
|
26
29
|
)
|
|
27
30
|
|
|
28
31
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
)
|
|
32
|
+
@dataclasses.dataclass
|
|
33
|
+
class BlackVueInfo:
|
|
34
|
+
# None and [] are equivalent here. Use None as default because:
|
|
35
|
+
# ValueError: mutable default <class 'list'> for field gps is not allowed: use default_factory
|
|
36
|
+
gps: list[geo.Point] | None = None
|
|
37
|
+
make: str = "BlackVue"
|
|
38
|
+
model: str = ""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def extract_blackvue_info(fp: T.BinaryIO) -> BlackVueInfo | None:
|
|
42
|
+
try:
|
|
43
|
+
gps_data = sparser.parse_mp4_data_first(fp, [b"free", b"gps "])
|
|
44
|
+
except sparser.ParsingError:
|
|
45
|
+
gps_data = None
|
|
46
|
+
|
|
47
|
+
if gps_data is None:
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
points = list(_parse_gps_box(gps_data))
|
|
51
|
+
points.sort(key=lambda p: p.time)
|
|
52
|
+
|
|
53
|
+
if points:
|
|
54
|
+
first_point_time = points[0].time
|
|
55
|
+
for p in points:
|
|
56
|
+
p.time = (p.time - first_point_time) / 1000
|
|
57
|
+
|
|
58
|
+
# Camera model
|
|
59
|
+
try:
|
|
60
|
+
cprt_bytes = sparser.parse_mp4_data_first(fp, [b"free", b"cprt"])
|
|
61
|
+
except sparser.ParsingError:
|
|
62
|
+
cprt_bytes = None
|
|
63
|
+
model = ""
|
|
64
|
+
|
|
65
|
+
if cprt_bytes is None:
|
|
66
|
+
model = ""
|
|
67
|
+
else:
|
|
68
|
+
model = _extract_camera_model_from_cprt(cprt_bytes)
|
|
69
|
+
|
|
70
|
+
return BlackVueInfo(model=model, gps=points)
|
|
54
71
|
|
|
55
72
|
|
|
56
73
|
def extract_camera_model(fp: T.BinaryIO) -> str:
|
|
@@ -62,6 +79,10 @@ def extract_camera_model(fp: T.BinaryIO) -> str:
|
|
|
62
79
|
if cprt_bytes is None:
|
|
63
80
|
return ""
|
|
64
81
|
|
|
82
|
+
return _extract_camera_model_from_cprt(cprt_bytes)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _extract_camera_model_from_cprt(cprt_bytes: bytes) -> str:
|
|
65
86
|
# examples: b' {"model":"DR900X Plus","ver":0.918,"lang":"English","direct":1,"psn":"","temp":34,"GPS":1}\x00'
|
|
66
87
|
# b' Pittasoft Co., Ltd.;DR900S-1CH;1.008;English;1;D90SS1HAE00661;T69;\x00'
|
|
67
88
|
cprt_bytes = cprt_bytes.strip().strip(b"\x00")
|
|
@@ -90,29 +111,28 @@ def extract_camera_model(fp: T.BinaryIO) -> str:
|
|
|
90
111
|
return ""
|
|
91
112
|
|
|
92
113
|
|
|
93
|
-
def
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
return points
|
|
114
|
+
def _parse_gps_box(gps_data: bytes) -> T.Generator[geo.Point, None, None]:
|
|
115
|
+
for line_bytes in gps_data.splitlines():
|
|
116
|
+
match = NMEA_LINE_REGEX.match(line_bytes)
|
|
117
|
+
if match is None:
|
|
118
|
+
continue
|
|
119
|
+
nmea_line_bytes = match.group(2)
|
|
120
|
+
if nmea_line_bytes.startswith(b"$GPGGA"):
|
|
121
|
+
try:
|
|
122
|
+
nmea_line = nmea_line_bytes.decode("utf8")
|
|
123
|
+
except UnicodeDecodeError:
|
|
124
|
+
continue
|
|
125
|
+
try:
|
|
126
|
+
nmea = pynmea2.parse(nmea_line)
|
|
127
|
+
except pynmea2.nmea.ParseError:
|
|
128
|
+
continue
|
|
129
|
+
if not nmea.is_valid:
|
|
130
|
+
continue
|
|
131
|
+
epoch_ms = int(match.group(1))
|
|
132
|
+
yield geo.Point(
|
|
133
|
+
time=epoch_ms,
|
|
134
|
+
lat=nmea.latitude,
|
|
135
|
+
lon=nmea.longitude,
|
|
136
|
+
alt=nmea.altitude,
|
|
137
|
+
angle=None,
|
|
138
|
+
)
|