Skip to content

Commit 5d79955

Browse files
authored
feat: export descriptions as GPX (#759)
* move desc to description.py * move user items to config.py * refactor * add doctest * add Serializer * fix tests * rename: parse capture time * build capture time * move description.py to serializer/description.py * fix tests * git add mapillary_tools/serializer/description.py * handle .gpx * git add mapillary_tools/serializer/gpx.py * lint * validate descs in deserializer * fix tests * add test_upload_read_descs_from_stdin * fix
1 parent 96b5ba0 commit 5d79955

19 files changed

Lines changed: 1113 additions & 874 deletions

mapillary_tools/authenticate.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111

1212
import requests
1313

14-
from . import api_v4, config, constants, exceptions, types
14+
from . import api_v4, config, constants, exceptions
1515

1616

1717
LOG = logging.getLogger(__name__)
@@ -64,7 +64,7 @@ def authenticate(
6464
LOG.info('Creating new profile: "%s"', profile_name)
6565

6666
if jwt:
67-
user_items: types.UserItem = {"user_upload_token": jwt}
67+
user_items: config.UserItem = {"user_upload_token": jwt}
6868
user_items = _verify_user_auth(_validate_profile(user_items))
6969
else:
7070
user_items = _prompt_login(
@@ -89,7 +89,7 @@ def authenticate(
8989
def fetch_user_items(
9090
user_name: str | None = None,
9191
organization_key: str | None = None,
92-
) -> types.UserItem:
92+
) -> config.UserItem:
9393
"""
9494
Read user information from the config file,
9595
or prompt the user to authenticate if the specified profile does not exist
@@ -155,17 +155,17 @@ def _prompt(message: str) -> str:
155155
return input()
156156

157157

158-
def _validate_profile(user_items: types.UserItem) -> types.UserItem:
158+
def _validate_profile(user_items: config.UserItem) -> config.UserItem:
159159
try:
160-
jsonschema.validate(user_items, types.UserItemSchema)
160+
jsonschema.validate(user_items, config.UserItemSchema)
161161
except jsonschema.ValidationError as ex:
162162
raise exceptions.MapillaryBadParameterError(
163163
f"Invalid profile format: {ex.message}"
164164
)
165165
return user_items
166166

167167

168-
def _verify_user_auth(user_items: types.UserItem) -> types.UserItem:
168+
def _verify_user_auth(user_items: config.UserItem) -> config.UserItem:
169169
"""
170170
Verify that the user access token is valid
171171
"""
@@ -205,7 +205,7 @@ def _validate_profile_name(profile_name: str):
205205
)
206206

207207

208-
def _list_all_profiles(profiles: dict[str, types.UserItem]) -> None:
208+
def _list_all_profiles(profiles: dict[str, config.UserItem]) -> None:
209209
_echo("Existing Mapillary profiles:")
210210

211211
# Header
@@ -256,7 +256,7 @@ def _is_login_retryable(ex: requests.HTTPError) -> bool:
256256
def _prompt_login(
257257
user_email: str | None = None,
258258
user_password: str | None = None,
259-
) -> types.UserItem:
259+
) -> config.UserItem:
260260
_enabled = _prompt_enabled()
261261

262262
if user_email is None:
@@ -288,7 +288,7 @@ def _prompt_login(
288288

289289
data = resp.json()
290290

291-
user_items: types.UserItem = {
291+
user_items: config.UserItem = {
292292
"user_upload_token": str(data["access_token"]),
293293
"MAPSettingsUserKey": str(data["user_id"]),
294294
}

mapillary_tools/config.py

Lines changed: 38 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,31 +2,54 @@
22

33
import configparser
44
import os
5+
import sys
56
import typing as T
7+
from typing import TypedDict
68

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
813

14+
from . import api_v4
915

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-
)
1916

17+
DEFAULT_MAPILLARY_FOLDER = os.path.join(os.path.expanduser("~"), ".config", "mapillary")
2018
MAPILLARY_CONFIG_PATH = os.getenv(
2119
"MAPILLARY_CONFIG_PATH",
2220
os.path.join(
2321
DEFAULT_MAPILLARY_FOLDER,
2422
"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("|", "_"),
2625
),
2726
)
2827

2928

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+
3053
def _load_config(config_path: str) -> configparser.ConfigParser:
3154
config = configparser.ConfigParser()
3255
# Override to not change option names (by default it will lower them)
@@ -36,19 +59,17 @@ def _load_config(config_path: str) -> configparser.ConfigParser:
3659
return config
3760

3861

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:
4263
if config_path is None:
4364
config_path = MAPILLARY_CONFIG_PATH
4465
config = _load_config(config_path)
4566
if not config.has_section(profile_name):
4667
return None
4768
user_items = dict(config.items(profile_name))
48-
return T.cast(types.UserItem, user_items)
69+
return T.cast(UserItem, user_items)
4970

5071

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]:
5273
if config_path is None:
5374
config_path = MAPILLARY_CONFIG_PATH
5475
cp = _load_config(config_path)
@@ -60,7 +81,7 @@ def list_all_users(config_path: str | None = None) -> dict[str, types.UserItem]:
6081

6182

6283
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
6485
) -> None:
6586
if config_path is None:
6687
config_path = MAPILLARY_CONFIG_PATH

mapillary_tools/geotag/geotag_images_from_gpx.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from typing_extensions import override
1313

1414
from .. import exceptions, geo, types
15+
from ..serializer.description import build_capture_time
1516
from .base import GeotagImagesFromGeneric
1617
from .geotag_images_from_exif import ImageEXIFExtractor
1718

@@ -43,26 +44,26 @@ def _interpolate_image_metadata_along(
4344

4445
if image_metadata.time < sorted_points[0].time:
4546
delta = sorted_points[0].time - image_metadata.time
46-
gpx_start_time = types.datetime_to_map_capture_time(sorted_points[0].time)
47-
gpx_end_time = types.datetime_to_map_capture_time(sorted_points[-1].time)
47+
gpx_start_time = build_capture_time(sorted_points[0].time)
48+
gpx_end_time = build_capture_time(sorted_points[-1].time)
4849
# with the tolerance of 1ms
4950
if 0.001 < delta:
5051
raise exceptions.MapillaryOutsideGPXTrackError(
5152
f"The image date time is {round(delta, 3)} seconds behind the GPX start point",
52-
image_time=types.datetime_to_map_capture_time(image_metadata.time),
53+
image_time=build_capture_time(image_metadata.time),
5354
gpx_start_time=gpx_start_time,
5455
gpx_end_time=gpx_end_time,
5556
)
5657

5758
if sorted_points[-1].time < image_metadata.time:
5859
delta = image_metadata.time - sorted_points[-1].time
59-
gpx_start_time = types.datetime_to_map_capture_time(sorted_points[0].time)
60-
gpx_end_time = types.datetime_to_map_capture_time(sorted_points[-1].time)
60+
gpx_start_time = build_capture_time(sorted_points[0].time)
61+
gpx_end_time = build_capture_time(sorted_points[-1].time)
6162
# with the tolerance of 1ms
6263
if 0.001 < delta:
6364
raise exceptions.MapillaryOutsideGPXTrackError(
6465
f"The image time is {round(delta, 3)} seconds beyond the GPX end point",
65-
image_time=types.datetime_to_map_capture_time(image_metadata.time),
66+
image_time=build_capture_time(image_metadata.time),
6667
gpx_start_time=gpx_start_time,
6768
gpx_end_time=gpx_end_time,
6869
)

mapillary_tools/history.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from pathlib import Path
88

99
from . import constants, types
10+
from .serializer.description import DescriptionJSONSerializer
1011

1112
JSONDict = T.Dict[str, T.Union[str, int, float, None]]
1213

@@ -57,6 +58,8 @@ def write_history(
5758
"summary": summary,
5859
}
5960
if metadatas is not None:
60-
history["descs"] = [types.as_desc(metadata) for metadata in metadatas]
61+
history["descs"] = [
62+
DescriptionJSONSerializer.as_desc(metadata) for metadata in metadatas
63+
]
6164
with open(path, "w") as fp:
6265
fp.write(json.dumps(history))

mapillary_tools/process_geotag_properties.py

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
import collections
44
import datetime
5-
import json
65
import logging
76
import typing as T
87
from pathlib import Path
@@ -17,6 +16,11 @@
1716
SourcePathOption,
1817
SourceType,
1918
)
19+
from .serializer.description import (
20+
DescriptionJSONSerializer,
21+
validate_and_fail_metadata,
22+
)
23+
from .serializer.gpx import GPXSerializer
2024

2125
LOG = logging.getLogger(__name__)
2226
DEFAULT_GEOTAG_SOURCE_OPTIONS = [
@@ -200,12 +204,16 @@ def _write_metadatas(
200204
desc_path: str,
201205
) -> None:
202206
if desc_path == "-":
203-
descs = [types.as_desc(metadata) for metadata in metadatas]
204-
print(json.dumps(descs, indent=2))
207+
descs = DescriptionJSONSerializer.serialize(metadatas)
208+
print(descs.decode("utf-8"))
205209
else:
206-
descs = [types.as_desc(metadata) for metadata in metadatas]
207-
with open(desc_path, "w") as fp:
208-
json.dump(descs, fp)
210+
normalized_suffix = Path(desc_path).suffix.strip().lower()
211+
if normalized_suffix in [".gpx"]:
212+
descs = GPXSerializer.serialize(metadatas)
213+
else:
214+
descs = DescriptionJSONSerializer.serialize(metadatas)
215+
with open(desc_path, "wb") as fp:
216+
fp.write(descs)
209217
LOG.info("Check the description file for details: %s", desc_path)
210218

211219

@@ -293,7 +301,7 @@ def _validate_metadatas(
293301
# See https://stackoverflow.com/a/61432070
294302
good_metadatas, error_metadatas = types.separate_errors(metadatas)
295303
map_results = utils.mp_map_maybe(
296-
types.validate_and_fail_metadata,
304+
validate_and_fail_metadata,
297305
T.cast(T.Iterable[types.Metadata], good_metadatas),
298306
num_processes=num_processes,
299307
)

mapillary_tools/process_sequence_properties.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import typing as T
88

99
from . import constants, exceptions, geo, types, utils
10+
from .serializer.description import DescriptionJSONSerializer
1011

1112
LOG = logging.getLogger(__name__)
1213

@@ -106,7 +107,7 @@ def duplication_check(
106107
dup = types.describe_error_metadata(
107108
exceptions.MapillaryDuplicationError(
108109
msg,
109-
types.as_desc(cur),
110+
DescriptionJSONSerializer.as_desc(cur),
110111
distance=distance,
111112
angle_diff=angle_diff,
112113
),

mapillary_tools/sample_video.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from .exif_write import ExifEdit
1414
from .geotag import geotag_videos_from_video
1515
from .mp4 import mp4_sample_parser
16+
from .serializer.description import parse_capture_time
1617

1718
LOG = logging.getLogger(__name__)
1819

@@ -65,7 +66,7 @@ def sample_video(
6566
video_start_time_dt: datetime.datetime | None = None
6667
if video_start_time is not None:
6768
try:
68-
video_start_time_dt = types.map_capture_time_to_datetime(video_start_time)
69+
video_start_time_dt = parse_capture_time(video_start_time)
6970
except ValueError as ex:
7071
raise exceptions.MapillaryBadParameterError(str(ex))
7172

0 commit comments

Comments
 (0)