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.
Files changed (49) hide show
  1. mapillary_tools/__init__.py +1 -1
  2. mapillary_tools/api_v4.py +66 -262
  3. mapillary_tools/authenticate.py +54 -46
  4. mapillary_tools/blackvue_parser.py +79 -22
  5. mapillary_tools/commands/__main__.py +15 -16
  6. mapillary_tools/commands/upload.py +33 -4
  7. mapillary_tools/config.py +38 -17
  8. mapillary_tools/constants.py +127 -43
  9. mapillary_tools/exceptions.py +4 -0
  10. mapillary_tools/exif_read.py +2 -1
  11. mapillary_tools/exif_write.py +3 -1
  12. mapillary_tools/exiftool_read_video.py +52 -15
  13. mapillary_tools/exiftool_runner.py +4 -24
  14. mapillary_tools/ffmpeg.py +406 -232
  15. mapillary_tools/geo.py +16 -0
  16. mapillary_tools/geotag/__init__.py +0 -0
  17. mapillary_tools/geotag/base.py +8 -4
  18. mapillary_tools/geotag/factory.py +106 -89
  19. mapillary_tools/geotag/geotag_images_from_exiftool.py +27 -20
  20. mapillary_tools/geotag/geotag_images_from_gpx.py +7 -6
  21. mapillary_tools/geotag/geotag_images_from_video.py +35 -0
  22. mapillary_tools/geotag/geotag_videos_from_exiftool.py +61 -14
  23. mapillary_tools/geotag/geotag_videos_from_gpx.py +22 -9
  24. mapillary_tools/geotag/options.py +25 -3
  25. mapillary_tools/geotag/utils.py +9 -12
  26. mapillary_tools/geotag/video_extractors/base.py +1 -1
  27. mapillary_tools/geotag/video_extractors/exiftool.py +1 -1
  28. mapillary_tools/geotag/video_extractors/gpx.py +61 -70
  29. mapillary_tools/geotag/video_extractors/native.py +34 -31
  30. mapillary_tools/history.py +128 -8
  31. mapillary_tools/http.py +211 -0
  32. mapillary_tools/mp4/construct_mp4_parser.py +8 -2
  33. mapillary_tools/process_geotag_properties.py +47 -35
  34. mapillary_tools/process_sequence_properties.py +340 -325
  35. mapillary_tools/sample_video.py +8 -8
  36. mapillary_tools/serializer/description.py +587 -0
  37. mapillary_tools/serializer/gpx.py +132 -0
  38. mapillary_tools/types.py +44 -610
  39. mapillary_tools/upload.py +327 -352
  40. mapillary_tools/upload_api_v4.py +125 -72
  41. mapillary_tools/uploader.py +797 -216
  42. mapillary_tools/utils.py +57 -5
  43. {mapillary_tools-0.14.0a2.dist-info → mapillary_tools-0.14.1.dist-info}/METADATA +91 -34
  44. mapillary_tools-0.14.1.dist-info/RECORD +76 -0
  45. {mapillary_tools-0.14.0a2.dist-info → mapillary_tools-0.14.1.dist-info}/WHEEL +1 -1
  46. mapillary_tools-0.14.0a2.dist-info/RECORD +0 -72
  47. {mapillary_tools-0.14.0a2.dist-info → mapillary_tools-0.14.1.dist-info}/entry_points.txt +0 -0
  48. {mapillary_tools-0.14.0a2.dist-info → mapillary_tools-0.14.1.dist-info}/licenses/LICENSE +0 -0
  49. {mapillary_tools-0.14.0a2.dist-info → mapillary_tools-0.14.1.dist-info}/top_level.txt +0 -0
@@ -14,15 +14,14 @@ from .mp4 import simple_mp4_parser as sparser
14
14
 
15
15
 
16
16
  LOG = logging.getLogger(__name__)
17
- # An example: [1623057074211]$GPVTG,,T,,M,0.078,N,0.144,K,D*28[1623057075215]
18
17
  NMEA_LINE_REGEX = re.compile(
19
18
  rb"""
20
19
  ^\s*
21
- \[(\d+)\] # timestamp
20
+ \[(\d+)\] # Timestamp
22
21
  \s*
23
- (\$\w{5}.*) # nmea line
22
+ (\$\w{5}.*) # NMEA message
24
23
  \s*
25
- (\[\d+\])? # strange timestamp
24
+ (\[\d+\])? # Strange timestamp
26
25
  \s*$
27
26
  """,
28
27
  re.X,
@@ -47,7 +46,7 @@ def extract_blackvue_info(fp: T.BinaryIO) -> BlackVueInfo | None:
47
46
  if gps_data is None:
48
47
  return None
49
48
 
50
- points = list(_parse_gps_box(gps_data))
49
+ points = _parse_gps_box(gps_data)
51
50
  points.sort(key=lambda p: p.time)
52
51
 
53
52
  if points:
@@ -83,8 +82,12 @@ def extract_camera_model(fp: T.BinaryIO) -> str:
83
82
 
84
83
 
85
84
  def _extract_camera_model_from_cprt(cprt_bytes: bytes) -> str:
86
- # examples: b' {"model":"DR900X Plus","ver":0.918,"lang":"English","direct":1,"psn":"","temp":34,"GPS":1}\x00'
87
- # b' Pittasoft Co., Ltd.;DR900S-1CH;1.008;English;1;D90SS1HAE00661;T69;\x00'
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
+ """
88
91
  cprt_bytes = cprt_bytes.strip().strip(b"\x00")
89
92
 
90
93
  try:
@@ -111,28 +114,82 @@ def _extract_camera_model_from_cprt(cprt_bytes: bytes) -> str:
111
114
  return ""
112
115
 
113
116
 
114
- def _parse_gps_box(gps_data: bytes) -> T.Generator[geo.Point, None, None]:
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
+
115
136
  for line_bytes in gps_data.splitlines():
116
137
  match = NMEA_LINE_REGEX.match(line_bytes)
117
138
  if match is None:
118
139
  continue
119
140
  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:
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:
128
163
  continue
129
- if not nmea.is_valid:
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:
130
175
  continue
131
- epoch_ms = int(match.group(1))
132
- yield geo.Point(
176
+ point = geo.Point(
133
177
  time=epoch_ms,
134
- lat=nmea.latitude,
135
- lon=nmea.longitude,
136
- alt=nmea.altitude,
178
+ lat=message.latitude,
179
+ lon=message.longitude,
180
+ alt=None,
137
181
  angle=None,
138
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 []
@@ -7,6 +7,8 @@ from pathlib import Path
7
7
  import requests
8
8
 
9
9
  from .. import api_v4, constants, exceptions, VERSION
10
+ from ..upload import log_exception
11
+ from ..utils import configure_logger, get_app_name
10
12
  from . import (
11
13
  authenticate,
12
14
  process,
@@ -30,8 +32,8 @@ mapillary_tools_commands = [
30
32
  ]
31
33
 
32
34
 
33
- # do not use __name__ here is because if you run tools as a module, __name__ will be "__main__"
34
- LOG = logging.getLogger("mapillary_tools")
35
+ # Root logger of mapillary_tools (not including third-party libraries)
36
+ LOG = logging.getLogger(get_app_name())
35
37
 
36
38
 
37
39
  # Handle shared arguments/options here
@@ -78,13 +80,6 @@ def add_general_arguments(parser, command):
78
80
  )
79
81
 
80
82
 
81
- def configure_logger(logger: logging.Logger, stream=None) -> None:
82
- formatter = logging.Formatter("%(asctime)s - %(levelname)-7s - %(message)s")
83
- handler = logging.StreamHandler(stream)
84
- handler.setFormatter(formatter)
85
- logger.addHandler(handler)
86
-
87
-
88
83
  def _log_params(argvars: dict) -> None:
89
84
  MAX_ENTRIES = 5
90
85
 
@@ -151,9 +146,7 @@ def main():
151
146
 
152
147
  args = parser.parse_args()
153
148
 
154
- log_level = logging.DEBUG if args.verbose else logging.INFO
155
- configure_logger(LOG, sys.stderr)
156
- LOG.setLevel(log_level)
149
+ configure_logger(LOG, level=logging.DEBUG if args.verbose else logging.INFO)
157
150
 
158
151
  LOG.debug("%s", version_text)
159
152
  argvars = vars(args)
@@ -162,16 +155,22 @@ def main():
162
155
  try:
163
156
  args.func(argvars)
164
157
  except requests.HTTPError as ex:
165
- LOG.error("%s: %s", ex.__class__.__name__, api_v4.readable_http_error(ex))
158
+ log_exception(ex)
166
159
  # TODO: standardize exit codes as exceptions.MapillaryUserError
167
160
  sys.exit(16)
168
161
 
162
+ except api_v4.HTTPContentError as ex:
163
+ log_exception(ex)
164
+ sys.exit(17)
165
+
169
166
  except exceptions.MapillaryUserError as ex:
170
- LOG.error(
171
- "%s: %s", ex.__class__.__name__, ex, exc_info=log_level == logging.DEBUG
172
- )
167
+ log_exception(ex)
173
168
  sys.exit(ex.exit_code)
174
169
 
170
+ except KeyboardInterrupt:
171
+ LOG.info("Interrupted by user...")
172
+ sys.exit(130)
173
+
175
174
 
176
175
  if __name__ == "__main__":
177
176
  main()
@@ -8,13 +8,13 @@ from .process import bold_text
8
8
 
9
9
  class Command:
10
10
  name = "upload"
11
- help = "upload images to Mapillary"
11
+ help = "Upload processed data to Mapillary"
12
12
 
13
13
  @staticmethod
14
14
  def add_common_upload_options(group):
15
15
  group.add_argument(
16
16
  "--user_name",
17
- help="The Mapillary user account to upload to. If you only have one account authorized, it will upload to that account by default.",
17
+ help="The Mapillary user account to upload to.",
18
18
  required=False,
19
19
  )
20
20
  group.add_argument(
@@ -23,9 +23,38 @@ class Command:
23
23
  default=None,
24
24
  required=False,
25
25
  )
26
+ group.add_argument(
27
+ "--num_upload_workers",
28
+ help="Number of concurrent upload workers for uploading images. [default: %(default)s]",
29
+ default=constants.MAX_IMAGE_UPLOAD_WORKERS,
30
+ type=int,
31
+ required=False,
32
+ )
33
+ group.add_argument(
34
+ "--reupload",
35
+ help="Re-upload data that has already been uploaded.",
36
+ action="store_true",
37
+ default=False,
38
+ required=False,
39
+ )
26
40
  group.add_argument(
27
41
  "--dry_run",
28
- help='Instead of uploading to the Mapillary server, simulate uploading to the local directory "mapillary_public_uploads" for debugging purposes.',
42
+ "--dryrun",
43
+ help="[DEVELOPMENT] Simulate upload by sending data to a local directory instead of Mapillary servers. Uses a temporary directory by default unless specified by MAPILLARY_UPLOAD_ENDPOINT environment variable.",
44
+ action="store_true",
45
+ default=False,
46
+ required=False,
47
+ )
48
+ group.add_argument(
49
+ "--nofinish",
50
+ help="[DEVELOPMENT] Upload data without finalizing. The data will NOT be stored permanently or appear on the Mapillary website.",
51
+ action="store_true",
52
+ default=False,
53
+ required=False,
54
+ )
55
+ group.add_argument(
56
+ "--noresume",
57
+ help="[DEVELOPMENT] Start upload from the beginning, ignoring any previously interrupted upload sessions.",
29
58
  action="store_true",
30
59
  default=False,
31
60
  required=False,
@@ -35,7 +64,7 @@ class Command:
35
64
  group = parser.add_argument_group(bold_text("UPLOAD OPTIONS"))
36
65
  group.add_argument(
37
66
  "--desc_path",
38
- help=f'Path to the description file generated by the process command. The hyphen "-" indicates STDIN. [default: {{IMPORT_PATH}}/{constants.IMAGE_DESCRIPTION_FILENAME}]',
67
+ help=f'Path to the description file with processed image and video metadata (from process command). Use "-" for STDIN. [default: {{IMPORT_PATH}}/{constants.IMAGE_DESCRIPTION_FILENAME}]',
39
68
  default=None,
40
69
  required=False,
41
70
  )
mapillary_tools/config.py CHANGED
@@ -2,31 +2,54 @@ from __future__ import annotations
2
2
 
3
3
  import configparser
4
4
  import os
5
+ import sys
5
6
  import typing as T
7
+ from typing import TypedDict
6
8
 
7
- from . import api_v4, types
9
+ if sys.version_info >= (3, 11):
10
+ from typing import Required
11
+ else:
12
+ from typing_extensions import Required
8
13
 
14
+ from . import api_v4
9
15
 
10
- _CLIENT_ID = api_v4.MAPILLARY_CLIENT_TOKEN
11
- # Windows is not happy with | so we convert MLY|ID|TOKEN to MLY_ID_TOKEN
12
- _CLIENT_ID = _CLIENT_ID.replace("|", "_", 2)
13
-
14
- DEFAULT_MAPILLARY_FOLDER = os.path.join(
15
- os.path.expanduser("~"),
16
- ".config",
17
- "mapillary",
18
- )
19
16
 
17
+ DEFAULT_MAPILLARY_FOLDER = os.path.join(os.path.expanduser("~"), ".config", "mapillary")
20
18
  MAPILLARY_CONFIG_PATH = os.getenv(
21
19
  "MAPILLARY_CONFIG_PATH",
22
20
  os.path.join(
23
21
  DEFAULT_MAPILLARY_FOLDER,
24
22
  "configs",
25
- _CLIENT_ID,
23
+ # Windows is not happy with | so we convert MLY|ID|TOKEN to MLY_ID_TOKEN
24
+ api_v4.MAPILLARY_CLIENT_TOKEN.replace("|", "_"),
26
25
  ),
27
26
  )
28
27
 
29
28
 
29
+ class UserItem(TypedDict, total=False):
30
+ MAPOrganizationKey: int | str
31
+ # Username
32
+ MAPSettingsUsername: str
33
+ # User ID
34
+ MAPSettingsUserKey: str
35
+ # User access token
36
+ user_upload_token: Required[str]
37
+
38
+
39
+ UserItemSchema = {
40
+ "type": "object",
41
+ "properties": {
42
+ "MAPOrganizationKey": {"type": ["integer", "string"]},
43
+ # Not in use. Keep here for back-compatibility
44
+ "MAPSettingsUsername": {"type": "string"},
45
+ "MAPSettingsUserKey": {"type": "string"},
46
+ "user_upload_token": {"type": "string"},
47
+ },
48
+ "required": ["user_upload_token"],
49
+ "additionalProperties": True,
50
+ }
51
+
52
+
30
53
  def _load_config(config_path: str) -> configparser.ConfigParser:
31
54
  config = configparser.ConfigParser()
32
55
  # Override to not change option names (by default it will lower them)
@@ -36,19 +59,17 @@ def _load_config(config_path: str) -> configparser.ConfigParser:
36
59
  return config
37
60
 
38
61
 
39
- def load_user(
40
- profile_name: str, config_path: str | None = None
41
- ) -> types.UserItem | None:
62
+ def load_user(profile_name: str, config_path: str | None = None) -> UserItem | None:
42
63
  if config_path is None:
43
64
  config_path = MAPILLARY_CONFIG_PATH
44
65
  config = _load_config(config_path)
45
66
  if not config.has_section(profile_name):
46
67
  return None
47
68
  user_items = dict(config.items(profile_name))
48
- return T.cast(types.UserItem, user_items)
69
+ return T.cast(UserItem, user_items)
49
70
 
50
71
 
51
- def list_all_users(config_path: str | None = None) -> dict[str, types.UserItem]:
72
+ def list_all_users(config_path: str | None = None) -> dict[str, UserItem]:
52
73
  if config_path is None:
53
74
  config_path = MAPILLARY_CONFIG_PATH
54
75
  cp = _load_config(config_path)
@@ -60,7 +81,7 @@ def list_all_users(config_path: str | None = None) -> dict[str, types.UserItem]:
60
81
 
61
82
 
62
83
  def update_config(
63
- profile_name: str, user_items: types.UserItem, config_path: str | None = None
84
+ profile_name: str, user_items: UserItem, config_path: str | None = None
64
85
  ) -> None:
65
86
  if config_path is None:
66
87
  config_path = MAPILLARY_CONFIG_PATH
@@ -1,6 +1,8 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import functools
3
4
  import os
5
+ import tempfile
4
6
 
5
7
  import appdirs
6
8
 
@@ -8,44 +10,92 @@ _ENV_PREFIX = "MAPILLARY_TOOLS_"
8
10
 
9
11
 
10
12
  def _yes_or_no(val: str) -> bool:
11
- return val.strip().upper() in [
12
- "1",
13
- "TRUE",
14
- "YES",
15
- ]
13
+ return val.strip().upper() in ["1", "TRUE", "YES"]
16
14
 
17
15
 
18
- # In meters
19
- CUTOFF_DISTANCE = float(os.getenv(_ENV_PREFIX + "CUTOFF_DISTANCE", 600))
16
+ def _parse_scaled_integers(
17
+ value: str, scale: dict[str, int] | None = None
18
+ ) -> int | None:
19
+ """
20
+ >>> scale = {"": 1, "b": 1, "K": 1024, "M": 1024 * 1024, "G": 1024 * 1024 * 1024}
21
+ >>> _parse_scaled_integers("0", scale=scale)
22
+ 0
23
+ >>> _parse_scaled_integers("10", scale=scale)
24
+ 10
25
+ >>> _parse_scaled_integers("100B", scale=scale)
26
+ 100
27
+ >>> _parse_scaled_integers("100k", scale=scale)
28
+ 102400
29
+ >>> _parse_scaled_integers("100t", scale=scale)
30
+ Traceback (most recent call last):
31
+ ValueError: Expect valid integer ends with , b, K, M, G, but got 100T
32
+ """
33
+
34
+ if scale is None:
35
+ scale = {"": 1}
36
+
37
+ value = value.strip().upper()
38
+
39
+ if value in ["INF", "INFINITY"]:
40
+ return None
41
+
42
+ try:
43
+ for k, v in scale.items():
44
+ k = k.upper()
45
+ if k and value.endswith(k):
46
+ return int(value[: -len(k)]) * v
47
+
48
+ if "" in scale:
49
+ return int(value) * scale[""]
50
+ except ValueError:
51
+ pass
52
+
53
+ raise ValueError(
54
+ f"Expect valid integer ends with {', '.join(scale.keys())}, but got {value}"
55
+ )
56
+
57
+
58
+ _parse_pixels = functools.partial(
59
+ _parse_scaled_integers,
60
+ scale={
61
+ "": 1,
62
+ "K": 1000,
63
+ "M": 1000 * 1000,
64
+ "MP": 1000 * 1000,
65
+ "G": 1000 * 1000 * 1000,
66
+ "GP": 1000 * 1000 * 1000,
67
+ },
68
+ )
69
+
70
+ _parse_filesize = functools.partial(
71
+ _parse_scaled_integers,
72
+ scale={"B": 1, "K": 1024, "M": 1024 * 1024, "G": 1024 * 1024 * 1024},
73
+ )
74
+
75
+ ###################
76
+ ##### GENERAL #####
77
+ ###################
78
+ USER_DATA_DIR = appdirs.user_data_dir(appname="mapillary_tools", appauthor="Mapillary")
79
+ PROMPT_DISABLED: bool = _yes_or_no(os.getenv(_ENV_PREFIX + "PROMPT_DISABLED", "NO"))
80
+
81
+
82
+ ############################
83
+ ##### VIDEO PROCESSING #####
84
+ ############################
20
85
  # In seconds
21
- CUTOFF_TIME = float(os.getenv(_ENV_PREFIX + "CUTOFF_TIME", 60))
22
- DUPLICATE_DISTANCE = float(os.getenv(_ENV_PREFIX + "DUPLICATE_DISTANCE", 0.1))
23
- DUPLICATE_ANGLE = float(os.getenv(_ENV_PREFIX + "DUPLICATE_ANGLE", 5))
24
- MAX_AVG_SPEED = float(
25
- os.getenv(_ENV_PREFIX + "MAX_AVG_SPEED", 400_000 / 3600)
26
- ) # 400 KM/h
27
- # in seconds
28
86
  VIDEO_SAMPLE_INTERVAL = float(os.getenv(_ENV_PREFIX + "VIDEO_SAMPLE_INTERVAL", -1))
29
- # in meters
87
+ # In meters
30
88
  VIDEO_SAMPLE_DISTANCE = float(os.getenv(_ENV_PREFIX + "VIDEO_SAMPLE_DISTANCE", 3))
31
89
  VIDEO_DURATION_RATIO = float(os.getenv(_ENV_PREFIX + "VIDEO_DURATION_RATIO", 1))
32
90
  FFPROBE_PATH: str = os.getenv(_ENV_PREFIX + "FFPROBE_PATH", "ffprobe")
33
91
  FFMPEG_PATH: str = os.getenv(_ENV_PREFIX + "FFMPEG_PATH", "ffmpeg")
34
- # When not set, MT will try to check both "exiftool" and "exiftool.exe" from $PATH
35
- EXIFTOOL_PATH: str | None = os.getenv(_ENV_PREFIX + "EXIFTOOL_PATH")
92
+ EXIFTOOL_PATH: str = os.getenv(_ENV_PREFIX + "EXIFTOOL_PATH", "exiftool")
36
93
  IMAGE_DESCRIPTION_FILENAME = os.getenv(
37
94
  _ENV_PREFIX + "IMAGE_DESCRIPTION_FILENAME", "mapillary_image_description.json"
38
95
  )
39
96
  SAMPLED_VIDEO_FRAMES_FILENAME = os.getenv(
40
97
  _ENV_PREFIX + "SAMPLED_VIDEO_FRAMES_FILENAME", "mapillary_sampled_video_frames"
41
98
  )
42
- USER_DATA_DIR = appdirs.user_data_dir(appname="mapillary_tools", appauthor="Mapillary")
43
- # The chunk size in MB (see chunked transfer encoding https://en.wikipedia.org/wiki/Chunked_transfer_encoding)
44
- # for uploading data to MLY upload service.
45
- # Changing this size does not change the number of requests nor affect upload performance,
46
- # but it affects the responsiveness of the upload progress bar
47
- UPLOAD_CHUNK_SIZE_MB = float(os.getenv(_ENV_PREFIX + "UPLOAD_CHUNK_SIZE_MB", 1))
48
-
49
99
  # DoP value, the lower the better
50
100
  # See https://github.com/gopro/gpmf-parser#hero5-black-with-gps-enabled-adds
51
101
  # It is used to filter out noisy points
@@ -54,38 +104,72 @@ GOPRO_MAX_DOP100 = int(os.getenv(_ENV_PREFIX + "GOPRO_MAX_DOP100", 1000))
54
104
  GOPRO_GPS_FIXES: set[int] = set(
55
105
  int(fix) for fix in os.getenv(_ENV_PREFIX + "GOPRO_GPS_FIXES", "2,3").split(",")
56
106
  )
57
- MAX_UPLOAD_RETRIES: int = int(os.getenv(_ENV_PREFIX + "MAX_UPLOAD_RETRIES", 200))
58
-
59
107
  # GPS precision, in meters, is used to filter outliers
60
108
  GOPRO_GPS_PRECISION = float(os.getenv(_ENV_PREFIX + "GOPRO_GPS_PRECISION", 15))
109
+ MAPILLARY__EXPERIMENTAL_ENABLE_IMU: bool = _yes_or_no(
110
+ os.getenv("MAPILLARY__EXPERIMENTAL_ENABLE_IMU", "NO")
111
+ )
112
+
61
113
 
114
+ #################################
115
+ ###### SEQUENCE PROCESSING ######
116
+ #################################
117
+ # In meters
118
+ CUTOFF_DISTANCE = float(os.getenv(_ENV_PREFIX + "CUTOFF_DISTANCE", 600))
119
+ # In seconds
120
+ CUTOFF_TIME = float(os.getenv(_ENV_PREFIX + "CUTOFF_TIME", 60))
121
+ DUPLICATE_DISTANCE = float(os.getenv(_ENV_PREFIX + "DUPLICATE_DISTANCE", 0.1))
122
+ DUPLICATE_ANGLE = float(os.getenv(_ENV_PREFIX + "DUPLICATE_ANGLE", 5))
123
+ MAX_CAPTURE_SPEED_KMH = float(
124
+ os.getenv(_ENV_PREFIX + "MAX_CAPTURE_SPEED_KMH", 400)
125
+ ) # 400 KM/h
62
126
  # WARNING: Changing the following envvars might result in failed uploads
63
127
  # Max number of images per sequence
64
- MAX_SEQUENCE_LENGTH = int(os.getenv(_ENV_PREFIX + "MAX_SEQUENCE_LENGTH", 1000))
128
+ MAX_SEQUENCE_LENGTH: int | None = _parse_scaled_integers(
129
+ os.getenv(_ENV_PREFIX + "MAX_SEQUENCE_LENGTH", "1000")
130
+ )
65
131
  # Max file size per sequence (sum of image filesizes in the sequence)
66
- MAX_SEQUENCE_FILESIZE: str = os.getenv(_ENV_PREFIX + "MAX_SEQUENCE_FILESIZE", "110G")
132
+ MAX_SEQUENCE_FILESIZE: int | None = _parse_filesize(
133
+ os.getenv(_ENV_PREFIX + "MAX_SEQUENCE_FILESIZE", "110G")
134
+ )
67
135
  # Max number of pixels per sequence (sum of image pixels in the sequence)
68
- MAX_SEQUENCE_PIXELS: str = os.getenv(_ENV_PREFIX + "MAX_SEQUENCE_PIXELS", "6G")
69
-
70
- PROMPT_DISABLED: bool = _yes_or_no(os.getenv(_ENV_PREFIX + "PROMPT_DISABLED", "NO"))
71
-
72
- _AUTH_VERIFICATION_DISABLED: bool = _yes_or_no(
73
- os.getenv(_ENV_PREFIX + "_AUTH_VERIFICATION_DISABLED", "NO")
136
+ MAX_SEQUENCE_PIXELS: int | None = _parse_pixels(
137
+ os.getenv(_ENV_PREFIX + "MAX_SEQUENCE_PIXELS", "6G")
74
138
  )
75
139
 
140
+
141
+ ##################
142
+ ##### UPLOAD #####
143
+ ##################
76
144
  MAPILLARY_DISABLE_API_LOGGING: bool = _yes_or_no(
77
145
  os.getenv("MAPILLARY_DISABLE_API_LOGGING", "NO")
78
146
  )
147
+ MAPILLARY_UPLOAD_HISTORY_PATH: str = os.getenv(
148
+ "MAPILLARY_UPLOAD_HISTORY_PATH", os.path.join(USER_DATA_DIR, "upload_history")
149
+ )
150
+ UPLOAD_CACHE_DIR: str = os.getenv(
151
+ _ENV_PREFIX + "UPLOAD_CACHE_DIR",
152
+ os.path.join(tempfile.gettempdir(), "mapillary_tools", "upload_cache"),
153
+ )
154
+ # The minimal upload speed is used to calculate the read timeout to avoid upload hanging:
155
+ # timeout = upload_size / MIN_UPLOAD_SPEED
156
+ MIN_UPLOAD_SPEED: int | None = _parse_filesize(
157
+ os.getenv(_ENV_PREFIX + "MIN_UPLOAD_SPEED", "50K") # 50 Kb/s
158
+ )
159
+ # Maximum number of parallel workers for uploading images within a single sequence.
160
+ # NOTE: Sequences themselves are uploaded sequentially, not in parallel.
161
+ MAX_IMAGE_UPLOAD_WORKERS: int = int(
162
+ os.getenv(_ENV_PREFIX + "MAX_IMAGE_UPLOAD_WORKERS", 4)
163
+ )
164
+ # The chunk size in MB (see chunked transfer encoding https://en.wikipedia.org/wiki/Chunked_transfer_encoding)
165
+ # for uploading data to MLY upload service.
166
+ # Changing this size does not change the number of requests nor affect upload performance,
167
+ # but it affects the responsiveness of the upload progress bar
168
+ UPLOAD_CHUNK_SIZE_MB: float = float(os.getenv(_ENV_PREFIX + "UPLOAD_CHUNK_SIZE_MB", 2))
169
+ MAX_UPLOAD_RETRIES: int = int(os.getenv(_ENV_PREFIX + "MAX_UPLOAD_RETRIES", 200))
79
170
  MAPILLARY__ENABLE_UPLOAD_HISTORY_FOR_DRY_RUN: bool = _yes_or_no(
80
171
  os.getenv("MAPILLARY__ENABLE_UPLOAD_HISTORY_FOR_DRY_RUN", "NO")
81
172
  )
82
- MAPILLARY__EXPERIMENTAL_ENABLE_IMU: bool = _yes_or_no(
83
- os.getenv("MAPILLARY__EXPERIMENTAL_ENABLE_IMU", "NO")
84
- )
85
- MAPILLARY_UPLOAD_HISTORY_PATH: str = os.getenv(
86
- "MAPILLARY_UPLOAD_HISTORY_PATH",
87
- os.path.join(
88
- USER_DATA_DIR,
89
- "upload_history",
90
- ),
173
+ _AUTH_VERIFICATION_DISABLED: bool = _yes_or_no(
174
+ os.getenv(_ENV_PREFIX + "_AUTH_VERIFICATION_DISABLED", "NO")
91
175
  )
@@ -51,6 +51,10 @@ class MapillaryVideoGPSNotFoundError(MapillaryDescriptionError):
51
51
  pass
52
52
 
53
53
 
54
+ class MapillaryInvalidVideoError(MapillaryDescriptionError):
55
+ pass
56
+
57
+
54
58
  class MapillaryGPXEmptyError(MapillaryDescriptionError):
55
59
  pass
56
60
 
@@ -871,7 +871,8 @@ class ExifRead(ExifReadFromEXIF):
871
871
 
872
872
  def _xmp_with_reason(self, reason: str) -> ExifReadFromXMP | None:
873
873
  if not self._xml_extracted:
874
- LOG.debug('Extracting XMP for "%s"', reason)
874
+ # TODO Disabled because too verbose but still useful to know
875
+ # LOG.debug('Extracting XMP for "%s"', reason)
875
876
  self._cached_xml = self._extract_xmp()
876
877
  self._xml_extracted = True
877
878
 
@@ -42,7 +42,9 @@ class ExifEdit:
42
42
 
43
43
  def add_image_description(self, data: dict) -> None:
44
44
  """Add a dict to image description."""
45
- self._ef["0th"][piexif.ImageIFD.ImageDescription] = json.dumps(data)
45
+ self._ef["0th"][piexif.ImageIFD.ImageDescription] = json.dumps(
46
+ data, sort_keys=True, separators=(",", ":")
47
+ )
46
48
 
47
49
  def add_orientation(self, orientation: int) -> None:
48
50
  """Add image orientation to image."""