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