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.
Files changed (64) hide show
  1. mapillary_tools/__init__.py +1 -1
  2. mapillary_tools/api_v4.py +235 -14
  3. mapillary_tools/authenticate.py +325 -64
  4. mapillary_tools/{geotag/blackvue_parser.py → blackvue_parser.py} +74 -54
  5. mapillary_tools/camm/camm_builder.py +55 -97
  6. mapillary_tools/camm/camm_parser.py +425 -177
  7. mapillary_tools/commands/__main__.py +11 -4
  8. mapillary_tools/commands/authenticate.py +8 -1
  9. mapillary_tools/commands/process.py +27 -51
  10. mapillary_tools/commands/process_and_upload.py +19 -5
  11. mapillary_tools/commands/sample_video.py +2 -3
  12. mapillary_tools/commands/upload.py +18 -9
  13. mapillary_tools/commands/video_process_and_upload.py +19 -5
  14. mapillary_tools/config.py +28 -12
  15. mapillary_tools/constants.py +46 -4
  16. mapillary_tools/exceptions.py +34 -35
  17. mapillary_tools/exif_read.py +158 -53
  18. mapillary_tools/exiftool_read.py +19 -5
  19. mapillary_tools/exiftool_read_video.py +12 -1
  20. mapillary_tools/exiftool_runner.py +77 -0
  21. mapillary_tools/geo.py +148 -107
  22. mapillary_tools/geotag/factory.py +298 -0
  23. mapillary_tools/geotag/geotag_from_generic.py +152 -11
  24. mapillary_tools/geotag/geotag_images_from_exif.py +43 -124
  25. mapillary_tools/geotag/geotag_images_from_exiftool.py +66 -70
  26. mapillary_tools/geotag/geotag_images_from_exiftool_both_image_and_video.py +32 -48
  27. mapillary_tools/geotag/geotag_images_from_gpx.py +41 -116
  28. mapillary_tools/geotag/geotag_images_from_gpx_file.py +15 -96
  29. mapillary_tools/geotag/geotag_images_from_nmea_file.py +4 -2
  30. mapillary_tools/geotag/geotag_images_from_video.py +46 -46
  31. mapillary_tools/geotag/geotag_videos_from_exiftool_video.py +98 -92
  32. mapillary_tools/geotag/geotag_videos_from_gpx.py +140 -0
  33. mapillary_tools/geotag/geotag_videos_from_video.py +149 -181
  34. mapillary_tools/geotag/options.py +159 -0
  35. mapillary_tools/{geotag → gpmf}/gpmf_parser.py +194 -171
  36. mapillary_tools/history.py +3 -11
  37. mapillary_tools/mp4/io_utils.py +0 -1
  38. mapillary_tools/mp4/mp4_sample_parser.py +11 -3
  39. mapillary_tools/mp4/simple_mp4_parser.py +0 -10
  40. mapillary_tools/process_geotag_properties.py +151 -386
  41. mapillary_tools/process_sequence_properties.py +554 -202
  42. mapillary_tools/sample_video.py +8 -15
  43. mapillary_tools/telemetry.py +24 -12
  44. mapillary_tools/types.py +80 -22
  45. mapillary_tools/upload.py +316 -298
  46. mapillary_tools/upload_api_v4.py +55 -122
  47. mapillary_tools/uploader.py +396 -254
  48. mapillary_tools/utils.py +26 -0
  49. mapillary_tools/video_data_extraction/extract_video_data.py +17 -36
  50. mapillary_tools/video_data_extraction/extractors/blackvue_parser.py +34 -19
  51. mapillary_tools/video_data_extraction/extractors/camm_parser.py +41 -17
  52. mapillary_tools/video_data_extraction/extractors/exiftool_runtime_parser.py +4 -1
  53. mapillary_tools/video_data_extraction/extractors/exiftool_xml_parser.py +1 -2
  54. mapillary_tools/video_data_extraction/extractors/gopro_parser.py +37 -22
  55. {mapillary_tools-0.13.3a1.dist-info → mapillary_tools-0.14.0a1.dist-info}/METADATA +3 -2
  56. mapillary_tools-0.14.0a1.dist-info/RECORD +78 -0
  57. {mapillary_tools-0.13.3a1.dist-info → mapillary_tools-0.14.0a1.dist-info}/WHEEL +1 -1
  58. mapillary_tools/geotag/utils.py +0 -26
  59. mapillary_tools-0.13.3a1.dist-info/RECORD +0 -75
  60. /mapillary_tools/{geotag → gpmf}/gpmf_gps_filter.py +0 -0
  61. /mapillary_tools/{geotag → gpmf}/gps_filter.py +0 -0
  62. {mapillary_tools-0.13.3a1.dist-info → mapillary_tools-0.14.0a1.dist-info}/entry_points.txt +0 -0
  63. {mapillary_tools-0.13.3a1.dist-info → mapillary_tools-0.14.0a1.dist-info/licenses}/LICENSE +0 -0
  64. {mapillary_tools-0.13.3a1.dist-info → mapillary_tools-0.14.0a1.dist-info}/top_level.txt +0 -0
@@ -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: T.Optional[str] = None,
16
- user_email: T.Optional[str] = None,
17
- user_password: T.Optional[str] = None,
18
- jwt: T.Optional[str] = None,
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
- if user_name:
21
- user_name = user_name.strip()
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
- while not user_name:
24
- user_name = input(
25
- "Enter the Mapillary username you would like to (re)authenticate: "
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
- 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
- }
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
- user_items = prompt_user_for_user_items(user_name)
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
- config.update_config(user_name, user_items)
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
- 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: ")
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
- resp = api_v4.get_upload_token(user_email, user_password)
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
- 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
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
- 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}"
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
- return {
83
- "MAPSettingsUserKey": user_key,
84
- "user_upload_token": upload_token,
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
- def authenticate_user(user_name: str) -> types.UserItem:
89
- user_items = config.load_user(user_name)
90
- if user_items is not None:
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
- jsonschema.validate(user_items, types.UserItemSchema)
93
- except jsonschema.ValidationError:
323
+ profile_name = existing_profile_names[int(profile_name) - 1]
324
+ except (ValueError, IndexError):
94
325
  pass
95
326
  else:
96
- return user_items
327
+ # Exit if it's found
328
+ break
97
329
 
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)
330
+ assert profile_name not in existed, (
331
+ f"Profile {profile_name} must not exist here"
332
+ )
101
333
 
102
- return user_items
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 .. import geo
10
- from ..mp4 import simple_mp4_parser as sparser
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
- def _parse_gps_box(gps_data: bytes) -> T.Generator[geo.Point, None, None]:
30
- for line_bytes in gps_data.splitlines():
31
- match = NMEA_LINE_REGEX.match(line_bytes)
32
- if match is None:
33
- continue
34
- nmea_line_bytes = match.group(2)
35
- if nmea_line_bytes.startswith(b"$GPGGA"):
36
- try:
37
- nmea_line = nmea_line_bytes.decode("utf8")
38
- except UnicodeDecodeError:
39
- continue
40
- try:
41
- nmea = pynmea2.parse(nmea_line)
42
- except pynmea2.nmea.ParseError:
43
- continue
44
- if not nmea.is_valid:
45
- continue
46
- epoch_ms = int(match.group(1))
47
- yield geo.Point(
48
- time=epoch_ms,
49
- lat=nmea.latitude,
50
- lon=nmea.longitude,
51
- alt=nmea.altitude,
52
- angle=None,
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 extract_points(fp: T.BinaryIO) -> T.Optional[T.List[geo.Point]]:
94
- gps_data = sparser.parse_mp4_data_first(fp, [b"free", b"gps "])
95
- if gps_data is None:
96
- return None
97
-
98
- points = list(_parse_gps_box(gps_data))
99
- if not points:
100
- return points
101
-
102
- points.sort(key=lambda p: p.time)
103
-
104
- first_point_time = points[0].time
105
- for p in points:
106
- p.time = (p.time - first_point_time) / 1000
107
-
108
- return points
109
-
110
-
111
- def parse_gps_points(path: pathlib.Path) -> T.List[geo.Point]:
112
- with path.open("rb") as fp:
113
- points = extract_points(fp)
114
-
115
- if points is None:
116
- return []
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
+ )