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.
- mapillary_tools/__init__.py +1 -1
- mapillary_tools/api_v4.py +66 -262
- mapillary_tools/authenticate.py +54 -46
- mapillary_tools/blackvue_parser.py +79 -22
- mapillary_tools/commands/__main__.py +15 -16
- mapillary_tools/commands/upload.py +33 -4
- mapillary_tools/config.py +38 -17
- mapillary_tools/constants.py +127 -43
- mapillary_tools/exceptions.py +4 -0
- mapillary_tools/exif_read.py +2 -1
- mapillary_tools/exif_write.py +3 -1
- mapillary_tools/exiftool_read_video.py +52 -15
- mapillary_tools/exiftool_runner.py +4 -24
- mapillary_tools/ffmpeg.py +406 -232
- mapillary_tools/geo.py +16 -0
- mapillary_tools/geotag/__init__.py +0 -0
- mapillary_tools/geotag/base.py +8 -4
- mapillary_tools/geotag/factory.py +106 -89
- mapillary_tools/geotag/geotag_images_from_exiftool.py +27 -20
- mapillary_tools/geotag/geotag_images_from_gpx.py +7 -6
- mapillary_tools/geotag/geotag_images_from_video.py +35 -0
- mapillary_tools/geotag/geotag_videos_from_exiftool.py +61 -14
- mapillary_tools/geotag/geotag_videos_from_gpx.py +22 -9
- mapillary_tools/geotag/options.py +25 -3
- mapillary_tools/geotag/utils.py +9 -12
- mapillary_tools/geotag/video_extractors/base.py +1 -1
- mapillary_tools/geotag/video_extractors/exiftool.py +1 -1
- mapillary_tools/geotag/video_extractors/gpx.py +61 -70
- mapillary_tools/geotag/video_extractors/native.py +34 -31
- mapillary_tools/history.py +128 -8
- mapillary_tools/http.py +211 -0
- mapillary_tools/mp4/construct_mp4_parser.py +8 -2
- mapillary_tools/process_geotag_properties.py +47 -35
- mapillary_tools/process_sequence_properties.py +340 -325
- mapillary_tools/sample_video.py +8 -8
- mapillary_tools/serializer/description.py +587 -0
- mapillary_tools/serializer/gpx.py +132 -0
- mapillary_tools/types.py +44 -610
- mapillary_tools/upload.py +327 -352
- mapillary_tools/upload_api_v4.py +125 -72
- mapillary_tools/uploader.py +797 -216
- mapillary_tools/utils.py +57 -5
- {mapillary_tools-0.14.0a2.dist-info → mapillary_tools-0.14.1.dist-info}/METADATA +91 -34
- mapillary_tools-0.14.1.dist-info/RECORD +76 -0
- {mapillary_tools-0.14.0a2.dist-info → mapillary_tools-0.14.1.dist-info}/WHEEL +1 -1
- mapillary_tools-0.14.0a2.dist-info/RECORD +0 -72
- {mapillary_tools-0.14.0a2.dist-info → mapillary_tools-0.14.1.dist-info}/entry_points.txt +0 -0
- {mapillary_tools-0.14.0a2.dist-info → mapillary_tools-0.14.1.dist-info}/licenses/LICENSE +0 -0
- {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+)\] #
|
|
20
|
+
\[(\d+)\] # Timestamp
|
|
22
21
|
\s*
|
|
23
|
-
(\$\w{5}.*) #
|
|
22
|
+
(\$\w{5}.*) # NMEA message
|
|
24
23
|
\s*
|
|
25
|
-
(\[\d+\])? #
|
|
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 =
|
|
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
|
-
|
|
87
|
-
|
|
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) ->
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
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
|
-
|
|
132
|
-
yield geo.Point(
|
|
176
|
+
point = geo.Point(
|
|
133
177
|
time=epoch_ms,
|
|
134
|
-
lat=
|
|
135
|
-
lon=
|
|
136
|
-
alt=
|
|
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
|
-
#
|
|
34
|
-
LOG = logging.getLogger(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 = "
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
69
|
+
return T.cast(UserItem, user_items)
|
|
49
70
|
|
|
50
71
|
|
|
51
|
-
def list_all_users(config_path: str | None = None) -> dict[str,
|
|
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:
|
|
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
|
mapillary_tools/constants.py
CHANGED
|
@@ -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
|
-
|
|
19
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
|
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:
|
|
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:
|
|
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
|
-
|
|
83
|
-
os.getenv("
|
|
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
|
)
|
mapillary_tools/exceptions.py
CHANGED
mapillary_tools/exif_read.py
CHANGED
|
@@ -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
|
-
|
|
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
|
|
mapillary_tools/exif_write.py
CHANGED
|
@@ -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(
|
|
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."""
|