mapillary-tools 0.13.3a1__py3-none-any.whl → 0.14.0a2__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 (83) hide show
  1. mapillary_tools/__init__.py +1 -1
  2. mapillary_tools/api_v4.py +237 -16
  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 +429 -181
  7. mapillary_tools/commands/__main__.py +12 -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 +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 +31 -13
  15. mapillary_tools/constants.py +47 -6
  16. mapillary_tools/exceptions.py +34 -35
  17. mapillary_tools/exif_read.py +221 -116
  18. mapillary_tools/exif_write.py +7 -7
  19. mapillary_tools/exiftool_read.py +33 -42
  20. mapillary_tools/exiftool_read_video.py +46 -33
  21. mapillary_tools/exiftool_runner.py +77 -0
  22. mapillary_tools/ffmpeg.py +24 -23
  23. mapillary_tools/geo.py +144 -120
  24. mapillary_tools/geotag/base.py +147 -0
  25. mapillary_tools/geotag/factory.py +291 -0
  26. mapillary_tools/geotag/geotag_images_from_exif.py +14 -131
  27. mapillary_tools/geotag/geotag_images_from_exiftool.py +126 -82
  28. mapillary_tools/geotag/geotag_images_from_gpx.py +53 -118
  29. mapillary_tools/geotag/geotag_images_from_gpx_file.py +13 -126
  30. mapillary_tools/geotag/geotag_images_from_nmea_file.py +4 -5
  31. mapillary_tools/geotag/geotag_images_from_video.py +53 -51
  32. mapillary_tools/geotag/geotag_videos_from_exiftool.py +97 -0
  33. mapillary_tools/geotag/geotag_videos_from_gpx.py +39 -0
  34. mapillary_tools/geotag/geotag_videos_from_video.py +20 -185
  35. mapillary_tools/geotag/image_extractors/base.py +18 -0
  36. mapillary_tools/geotag/image_extractors/exif.py +60 -0
  37. mapillary_tools/geotag/image_extractors/exiftool.py +18 -0
  38. mapillary_tools/geotag/options.py +160 -0
  39. mapillary_tools/geotag/utils.py +52 -16
  40. mapillary_tools/geotag/video_extractors/base.py +18 -0
  41. mapillary_tools/geotag/video_extractors/exiftool.py +70 -0
  42. mapillary_tools/{video_data_extraction/extractors/gpx_parser.py → geotag/video_extractors/gpx.py} +57 -39
  43. mapillary_tools/geotag/video_extractors/native.py +157 -0
  44. mapillary_tools/{geotag → gpmf}/gpmf_parser.py +205 -182
  45. mapillary_tools/{geotag → gpmf}/gps_filter.py +5 -3
  46. mapillary_tools/history.py +7 -13
  47. mapillary_tools/mp4/construct_mp4_parser.py +9 -8
  48. mapillary_tools/mp4/io_utils.py +0 -1
  49. mapillary_tools/mp4/mp4_sample_parser.py +36 -28
  50. mapillary_tools/mp4/simple_mp4_builder.py +10 -9
  51. mapillary_tools/mp4/simple_mp4_parser.py +13 -22
  52. mapillary_tools/process_geotag_properties.py +155 -392
  53. mapillary_tools/process_sequence_properties.py +562 -208
  54. mapillary_tools/sample_video.py +13 -20
  55. mapillary_tools/telemetry.py +26 -13
  56. mapillary_tools/types.py +111 -58
  57. mapillary_tools/upload.py +316 -298
  58. mapillary_tools/upload_api_v4.py +55 -122
  59. mapillary_tools/uploader.py +396 -254
  60. mapillary_tools/utils.py +42 -18
  61. {mapillary_tools-0.13.3a1.dist-info → mapillary_tools-0.14.0a2.dist-info}/METADATA +3 -2
  62. mapillary_tools-0.14.0a2.dist-info/RECORD +72 -0
  63. {mapillary_tools-0.13.3a1.dist-info → mapillary_tools-0.14.0a2.dist-info}/WHEEL +1 -1
  64. mapillary_tools/geotag/__init__.py +0 -1
  65. mapillary_tools/geotag/geotag_from_generic.py +0 -22
  66. mapillary_tools/geotag/geotag_images_from_exiftool_both_image_and_video.py +0 -93
  67. mapillary_tools/geotag/geotag_videos_from_exiftool_video.py +0 -145
  68. mapillary_tools/video_data_extraction/cli_options.py +0 -22
  69. mapillary_tools/video_data_extraction/extract_video_data.py +0 -176
  70. mapillary_tools/video_data_extraction/extractors/base_parser.py +0 -75
  71. mapillary_tools/video_data_extraction/extractors/blackvue_parser.py +0 -34
  72. mapillary_tools/video_data_extraction/extractors/camm_parser.py +0 -38
  73. mapillary_tools/video_data_extraction/extractors/exiftool_runtime_parser.py +0 -71
  74. mapillary_tools/video_data_extraction/extractors/exiftool_xml_parser.py +0 -53
  75. mapillary_tools/video_data_extraction/extractors/generic_video_parser.py +0 -52
  76. mapillary_tools/video_data_extraction/extractors/gopro_parser.py +0 -43
  77. mapillary_tools/video_data_extraction/extractors/nmea_parser.py +0 -24
  78. mapillary_tools/video_data_extraction/video_data_parser_factory.py +0 -39
  79. mapillary_tools-0.13.3a1.dist-info/RECORD +0 -75
  80. /mapillary_tools/{geotag → gpmf}/gpmf_gps_filter.py +0 -0
  81. {mapillary_tools-0.13.3a1.dist-info → mapillary_tools-0.14.0a2.dist-info}/entry_points.txt +0 -0
  82. {mapillary_tools-0.13.3a1.dist-info → mapillary_tools-0.14.0a2.dist-info/licenses}/LICENSE +0 -0
  83. {mapillary_tools-0.13.3a1.dist-info → mapillary_tools-0.14.0a2.dist-info}/top_level.txt +0 -0
@@ -1,68 +1,39 @@
1
- import enum
1
+ from __future__ import annotations
2
+
2
3
  import io
3
- import json
4
- import logging
5
4
  import os
6
5
  import random
6
+ import sys
7
7
  import typing as T
8
8
  import uuid
9
9
 
10
+ if sys.version_info >= (3, 12):
11
+ from typing import override
12
+ else:
13
+ from typing_extensions import override
14
+
10
15
  import requests
11
16
 
12
- from .api_v4 import MAPILLARY_GRAPH_API_ENDPOINT, request_get, request_post
17
+ from .api_v4 import ClusterFileType, request_get, request_post, REQUESTS_TIMEOUT
13
18
 
14
- LOG = logging.getLogger(__name__)
15
19
  MAPILLARY_UPLOAD_ENDPOINT = os.getenv(
16
20
  "MAPILLARY_UPLOAD_ENDPOINT", "https://rupload.facebook.com/mapillary_public_uploads"
17
21
  )
18
- DEFAULT_CHUNK_SIZE = 1024 * 1024 * 16 # 16MB
19
22
  # According to the docs, UPLOAD_REQUESTS_TIMEOUT can be a tuple of
20
23
  # (connection_timeout, read_timeout): https://requests.readthedocs.io/en/latest/user/advanced/#timeouts
21
24
  # In my test, however, the connection_timeout rules both connection timeout and read timeout.
22
25
  # i.e. if your the server does not respond within this timeout, it will throw:
23
26
  # ConnectionError: ('Connection aborted.', timeout('The write operation timed out'))
24
27
  # So let us make sure the largest possible chunks can be uploaded before this timeout for now,
25
- REQUESTS_TIMEOUT = (20, 20) # 20 seconds
26
28
  UPLOAD_REQUESTS_TIMEOUT = (30 * 60, 30 * 60) # 30 minutes
27
29
 
28
30
 
29
- class ClusterFileType(enum.Enum):
30
- ZIP = "zip"
31
- BLACKVUE = "mly_blackvue_video"
32
- CAMM = "mly_camm_video"
33
-
34
-
35
- def _sanitize_headers(headers: T.Dict):
36
- return {
37
- k: v
38
- for k, v in headers.items()
39
- if k.lower() not in ["authorization", "cookie", "x-fb-access-token"]
40
- }
41
-
42
-
43
- _S = T.TypeVar("_S", str, bytes)
44
-
45
-
46
- def _truncate_end(s: _S) -> _S:
47
- MAX_LENGTH = 512
48
- if MAX_LENGTH < len(s):
49
- if isinstance(s, bytes):
50
- return s[:MAX_LENGTH] + b"..."
51
- else:
52
- return str(s[:MAX_LENGTH]) + "..."
53
- else:
54
- return s
55
-
56
-
57
31
  class UploadService:
58
32
  user_access_token: str
59
33
  session_key: str
60
- callbacks: T.List[T.Callable[[bytes, T.Optional[requests.Response]], None]]
61
34
  cluster_filetype: ClusterFileType
62
- organization_id: T.Optional[T.Union[str, int]]
63
- chunk_size: int
64
35
 
65
- MIME_BY_CLUSTER_TYPE: T.Dict[ClusterFileType, str] = {
36
+ MIME_BY_CLUSTER_TYPE: dict[ClusterFileType, str] = {
66
37
  ClusterFileType.ZIP: "application/zip",
67
38
  ClusterFileType.BLACKVUE: "video/mp4",
68
39
  ClusterFileType.CAMM: "video/mp4",
@@ -72,55 +43,40 @@ class UploadService:
72
43
  self,
73
44
  user_access_token: str,
74
45
  session_key: str,
75
- organization_id: T.Optional[T.Union[str, int]] = None,
76
- cluster_filetype: ClusterFileType = ClusterFileType.ZIP,
77
- chunk_size: int = DEFAULT_CHUNK_SIZE,
46
+ cluster_filetype: ClusterFileType,
78
47
  ):
79
- if chunk_size <= 0:
80
- raise ValueError("Expect positive chunk size")
81
-
82
48
  self.user_access_token = user_access_token
83
49
  self.session_key = session_key
84
- self.organization_id = organization_id
85
- # validate the input
86
- self.cluster_filetype = ClusterFileType(cluster_filetype)
87
- self.callbacks = []
88
- self.chunk_size = chunk_size
50
+ # Validate the input
51
+ self.cluster_filetype = cluster_filetype
89
52
 
90
53
  def fetch_offset(self) -> int:
91
54
  headers = {
92
55
  "Authorization": f"OAuth {self.user_access_token}",
93
56
  }
94
57
  url = f"{MAPILLARY_UPLOAD_ENDPOINT}/{self.session_key}"
95
- LOG.debug("GET %s", url)
96
58
  resp = request_get(
97
59
  url,
98
60
  headers=headers,
99
61
  timeout=REQUESTS_TIMEOUT,
100
62
  )
101
- LOG.debug("HTTP response %s: %s", resp.status_code, resp.content)
102
63
  resp.raise_for_status()
103
64
  data = resp.json()
104
65
  return data["offset"]
105
66
 
106
- def upload(
107
- self,
108
- data: T.IO[bytes],
109
- offset: T.Optional[int] = None,
110
- ) -> str:
111
- chunks = self._chunkize_byte_stream(data)
112
- return self.upload_chunks(chunks, offset=offset)
113
-
114
- def _chunkize_byte_stream(
115
- self, stream: T.IO[bytes]
67
+ @classmethod
68
+ def chunkize_byte_stream(
69
+ cls, stream: T.IO[bytes], chunk_size: int
116
70
  ) -> T.Generator[bytes, None, None]:
71
+ if chunk_size <= 0:
72
+ raise ValueError("Expect positive chunk size")
117
73
  while True:
118
- data = stream.read(self.chunk_size)
74
+ data = stream.read(chunk_size)
119
75
  if not data:
120
76
  break
121
77
  yield data
122
78
 
123
- def _offset_chunks(
79
+ def shift_chunks(
124
80
  self, chunks: T.Iterable[bytes], offset: int
125
81
  ) -> T.Generator[bytes, None, None]:
126
82
  assert offset >= 0, f"Expect non-negative offset but got {offset}"
@@ -135,23 +91,34 @@ class UploadService:
135
91
  else:
136
92
  yield chunk
137
93
 
138
- def _attach_callbacks(
139
- self, chunks: T.Iterable[bytes]
140
- ) -> T.Generator[bytes, None, None]:
141
- for chunk in chunks:
142
- yield chunk
143
- for callback in self.callbacks:
144
- callback(chunk, None)
94
+ def upload_byte_stream(
95
+ self,
96
+ stream: T.IO[bytes],
97
+ offset: int | None = None,
98
+ chunk_size: int = 2 * 1024 * 1024, # 2MB
99
+ ) -> str:
100
+ if offset is None:
101
+ offset = self.fetch_offset()
102
+ return self.upload_chunks(self.chunkize_byte_stream(stream, chunk_size), offset)
145
103
 
146
104
  def upload_chunks(
147
105
  self,
148
106
  chunks: T.Iterable[bytes],
149
- offset: T.Optional[int] = None,
107
+ offset: int | None = None,
150
108
  ) -> str:
151
109
  if offset is None:
152
110
  offset = self.fetch_offset()
111
+ shifted_chunks = self.shift_chunks(chunks, offset)
112
+ return self.upload_shifted_chunks(shifted_chunks, offset)
153
113
 
154
- chunks = self._attach_callbacks(self._offset_chunks(chunks, offset))
114
+ def upload_shifted_chunks(
115
+ self,
116
+ shifted_chunks: T.Iterable[bytes],
117
+ offset: int,
118
+ ) -> str:
119
+ """
120
+ Upload the chunks that must already be shifted by the offset (e.g. fp.seek(begin_offset, io.SEEK_SET))
121
+ """
155
122
 
156
123
  headers = {
157
124
  "Authorization": f"OAuth {self.user_access_token}",
@@ -160,14 +127,14 @@ class UploadService:
160
127
  "X-Entity-Type": self.MIME_BY_CLUSTER_TYPE[self.cluster_filetype],
161
128
  }
162
129
  url = f"{MAPILLARY_UPLOAD_ENDPOINT}/{self.session_key}"
163
- LOG.debug("POST %s HEADERS %s", url, json.dumps(_sanitize_headers(headers)))
164
130
  resp = request_post(
165
131
  url,
166
132
  headers=headers,
167
- data=chunks,
133
+ data=shifted_chunks,
168
134
  timeout=UPLOAD_REQUESTS_TIMEOUT,
169
135
  )
170
- LOG.debug("HTTP response %s: %s", resp.status_code, _truncate_end(resp.content))
136
+
137
+ resp.raise_for_status()
171
138
 
172
139
  payload = resp.json()
173
140
  try:
@@ -177,40 +144,6 @@ class UploadService:
177
144
  f"Upload server error: File handle not found in the upload response {resp.text}"
178
145
  )
179
146
 
180
- def finish(self, file_handle: str) -> str:
181
- headers = {
182
- "Authorization": f"OAuth {self.user_access_token}",
183
- }
184
- data: T.Dict[str, T.Union[str, int]] = {
185
- "file_handle": file_handle,
186
- "file_type": self.cluster_filetype.value,
187
- }
188
- if self.organization_id is not None:
189
- data["organization_id"] = self.organization_id
190
-
191
- url = f"{MAPILLARY_GRAPH_API_ENDPOINT}/finish_upload"
192
-
193
- LOG.debug("POST %s HEADERS %s", url, json.dumps(_sanitize_headers(headers)))
194
- resp = request_post(
195
- url,
196
- headers=headers,
197
- json=data,
198
- timeout=REQUESTS_TIMEOUT,
199
- )
200
- LOG.debug("HTTP response %s: %s", resp.status_code, _truncate_end(resp.content))
201
-
202
- resp.raise_for_status()
203
-
204
- data = resp.json()
205
-
206
- cluster_id = data.get("cluster_id")
207
- if cluster_id is None:
208
- raise RuntimeError(
209
- f"Upload server error: failed to create the cluster {resp.text}"
210
- )
211
-
212
- return T.cast(str, cluster_id)
213
-
214
147
 
215
148
  # A mock class for testing only
216
149
  class FakeUploadService(UploadService):
@@ -219,22 +152,24 @@ class FakeUploadService(UploadService):
219
152
  self._upload_path = os.getenv(
220
153
  "MAPILLARY_UPLOAD_PATH", "mapillary_public_uploads"
221
154
  )
222
- self._error_ratio = 0.1
155
+ self._error_ratio = 0.02
223
156
 
224
- def upload_chunks(
157
+ @override
158
+ def upload_shifted_chunks(
225
159
  self,
226
- chunks: T.Iterable[bytes],
227
- offset: T.Optional[int] = None,
160
+ shifted_chunks: T.Iterable[bytes],
161
+ offset: int,
228
162
  ) -> str:
229
- if offset is None:
230
- offset = self.fetch_offset()
231
-
232
- chunks = self._attach_callbacks(self._offset_chunks(chunks, offset))
163
+ expected_offset = self.fetch_offset()
164
+ if offset != expected_offset:
165
+ raise ValueError(
166
+ f"Expect offset {expected_offset} but got {offset} for session {self.session_key}"
167
+ )
233
168
 
234
169
  os.makedirs(self._upload_path, exist_ok=True)
235
170
  filename = os.path.join(self._upload_path, self.session_key)
236
171
  with open(filename, "ab") as fp:
237
- for chunk in chunks:
172
+ for chunk in shifted_chunks:
238
173
  if random.random() <= self._error_ratio:
239
174
  raise requests.ConnectionError(
240
175
  f"TEST ONLY: Failed to upload with error ratio {self._error_ratio}"
@@ -246,9 +181,7 @@ class FakeUploadService(UploadService):
246
181
  )
247
182
  return uuid.uuid4().hex
248
183
 
249
- def finish(self, _: str) -> str:
250
- return "0"
251
-
184
+ @override
252
185
  def fetch_offset(self) -> int:
253
186
  if random.random() <= self._error_ratio:
254
187
  raise requests.ConnectionError(