mapillary-downloader 0.1.0__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_downloader/__init__.py +5 -0
- mapillary_downloader/__main__.py +65 -0
- mapillary_downloader/client.py +107 -0
- mapillary_downloader/downloader.py +119 -0
- mapillary_downloader/exif_writer.py +122 -0
- mapillary_downloader-0.1.0.dist-info/METADATA +54 -0
- mapillary_downloader-0.1.0.dist-info/RECORD +9 -0
- mapillary_downloader-0.1.0.dist-info/WHEEL +4 -0
- mapillary_downloader-0.1.0.dist-info/licenses/LICENSE.md +7 -0
@@ -0,0 +1,65 @@
|
|
1
|
+
"""CLI entry point."""
|
2
|
+
|
3
|
+
import argparse
|
4
|
+
import sys
|
5
|
+
from mapillary_downloader.client import MapillaryClient
|
6
|
+
from mapillary_downloader.downloader import MapillaryDownloader
|
7
|
+
|
8
|
+
|
9
|
+
def main():
|
10
|
+
"""Main CLI entry point."""
|
11
|
+
parser = argparse.ArgumentParser(
|
12
|
+
description="Download your Mapillary data before it's gone"
|
13
|
+
)
|
14
|
+
parser.add_argument(
|
15
|
+
"--token",
|
16
|
+
required=True,
|
17
|
+
help="Mapillary API access token"
|
18
|
+
)
|
19
|
+
parser.add_argument(
|
20
|
+
"--username",
|
21
|
+
required=True,
|
22
|
+
help="Your Mapillary username"
|
23
|
+
)
|
24
|
+
parser.add_argument(
|
25
|
+
"--output",
|
26
|
+
default="./mapillary_data",
|
27
|
+
help="Output directory (default: ./mapillary_data)"
|
28
|
+
)
|
29
|
+
parser.add_argument(
|
30
|
+
"--quality",
|
31
|
+
choices=["256", "1024", "2048", "original"],
|
32
|
+
default="original",
|
33
|
+
help="Image quality to download (default: original)"
|
34
|
+
)
|
35
|
+
parser.add_argument(
|
36
|
+
"--bbox",
|
37
|
+
help="Bounding box: west,south,east,north"
|
38
|
+
)
|
39
|
+
|
40
|
+
args = parser.parse_args()
|
41
|
+
|
42
|
+
bbox = None
|
43
|
+
if args.bbox:
|
44
|
+
try:
|
45
|
+
bbox = [float(x) for x in args.bbox.split(",")]
|
46
|
+
if len(bbox) != 4:
|
47
|
+
raise ValueError
|
48
|
+
except ValueError:
|
49
|
+
print("Error: bbox must be four comma-separated numbers")
|
50
|
+
sys.exit(1)
|
51
|
+
|
52
|
+
try:
|
53
|
+
client = MapillaryClient(args.token)
|
54
|
+
downloader = MapillaryDownloader(client, args.output)
|
55
|
+
downloader.download_user_data(args.username, args.quality, bbox)
|
56
|
+
except KeyboardInterrupt:
|
57
|
+
print("\nInterrupted by user")
|
58
|
+
sys.exit(1)
|
59
|
+
except Exception as e:
|
60
|
+
print(f"Error: {e}")
|
61
|
+
sys.exit(1)
|
62
|
+
|
63
|
+
|
64
|
+
if __name__ == "__main__":
|
65
|
+
main()
|
@@ -0,0 +1,107 @@
|
|
1
|
+
"""Mapillary API client."""
|
2
|
+
|
3
|
+
import time
|
4
|
+
import requests
|
5
|
+
|
6
|
+
|
7
|
+
class MapillaryClient:
|
8
|
+
"""Client for interacting with Mapillary API v4."""
|
9
|
+
|
10
|
+
def __init__(self, access_token):
|
11
|
+
"""Initialize the client with an access token.
|
12
|
+
|
13
|
+
Args:
|
14
|
+
access_token: Mapillary API access token
|
15
|
+
"""
|
16
|
+
self.access_token = access_token
|
17
|
+
self.base_url = "https://graph.mapillary.com"
|
18
|
+
self.session = requests.Session()
|
19
|
+
self.session.headers.update({"Authorization": f"OAuth {access_token}"})
|
20
|
+
|
21
|
+
def get_user_images(self, username, bbox=None, limit=2000):
|
22
|
+
"""Get images uploaded by a specific user.
|
23
|
+
|
24
|
+
Args:
|
25
|
+
username: Mapillary username
|
26
|
+
bbox: Optional bounding box [west, south, east, north]
|
27
|
+
limit: Number of results per page (max 2000)
|
28
|
+
|
29
|
+
Yields:
|
30
|
+
Image data dictionaries
|
31
|
+
"""
|
32
|
+
params = {
|
33
|
+
"creator_username": username,
|
34
|
+
"limit": limit,
|
35
|
+
"fields": ",".join(
|
36
|
+
[
|
37
|
+
"id",
|
38
|
+
"captured_at",
|
39
|
+
"compass_angle",
|
40
|
+
"computed_compass_angle",
|
41
|
+
"geometry",
|
42
|
+
"computed_geometry",
|
43
|
+
"altitude",
|
44
|
+
"computed_altitude",
|
45
|
+
"is_pano",
|
46
|
+
"sequence",
|
47
|
+
"camera_type",
|
48
|
+
"camera_parameters",
|
49
|
+
"make",
|
50
|
+
"model",
|
51
|
+
"exif_orientation",
|
52
|
+
"computed_rotation",
|
53
|
+
"height",
|
54
|
+
"width",
|
55
|
+
"thumb_256_url",
|
56
|
+
"thumb_1024_url",
|
57
|
+
"thumb_2048_url",
|
58
|
+
"thumb_original_url",
|
59
|
+
]
|
60
|
+
),
|
61
|
+
}
|
62
|
+
|
63
|
+
if bbox:
|
64
|
+
params["bbox"] = ",".join(map(str, bbox))
|
65
|
+
|
66
|
+
url = f"{self.base_url}/images"
|
67
|
+
|
68
|
+
while url:
|
69
|
+
response = self.session.get(url, params=params)
|
70
|
+
response.raise_for_status()
|
71
|
+
|
72
|
+
data = response.json()
|
73
|
+
|
74
|
+
for image in data.get("data", []):
|
75
|
+
yield image
|
76
|
+
|
77
|
+
# Get next page URL
|
78
|
+
url = data.get("paging", {}).get("next")
|
79
|
+
params = None # Don't send params on subsequent requests, URL has them
|
80
|
+
|
81
|
+
# Rate limiting: 10,000 requests/minute for search API
|
82
|
+
time.sleep(0.01)
|
83
|
+
|
84
|
+
def download_image(self, image_url, output_path):
|
85
|
+
"""Download an image from a URL.
|
86
|
+
|
87
|
+
Args:
|
88
|
+
image_url: URL of the image to download
|
89
|
+
output_path: Path to save the image
|
90
|
+
|
91
|
+
Returns:
|
92
|
+
Number of bytes downloaded if successful, 0 otherwise
|
93
|
+
"""
|
94
|
+
try:
|
95
|
+
response = self.session.get(image_url, stream=True)
|
96
|
+
response.raise_for_status()
|
97
|
+
|
98
|
+
total_bytes = 0
|
99
|
+
with open(output_path, "wb") as f:
|
100
|
+
for chunk in response.iter_content(chunk_size=8192):
|
101
|
+
f.write(chunk)
|
102
|
+
total_bytes += len(chunk)
|
103
|
+
|
104
|
+
return total_bytes
|
105
|
+
except Exception as e:
|
106
|
+
print(f"Error downloading {image_url}: {e}")
|
107
|
+
return 0
|
@@ -0,0 +1,119 @@
|
|
1
|
+
"""Main downloader logic."""
|
2
|
+
|
3
|
+
import json
|
4
|
+
import os
|
5
|
+
from pathlib import Path
|
6
|
+
from mapillary_downloader.exif_writer import write_exif_to_image
|
7
|
+
|
8
|
+
|
9
|
+
def format_bytes(bytes_count):
|
10
|
+
"""Format bytes as human-readable string."""
|
11
|
+
if bytes_count < 1024:
|
12
|
+
return f"{bytes_count} B"
|
13
|
+
if bytes_count < 1024 * 1024:
|
14
|
+
return f"{bytes_count / 1024:.3f} KB"
|
15
|
+
if bytes_count < 1024 * 1024 * 1024:
|
16
|
+
return f"{bytes_count / (1024 * 1024):.3f} MB"
|
17
|
+
return f"{bytes_count / (1024 * 1024 * 1024):.3f} GB"
|
18
|
+
|
19
|
+
|
20
|
+
class MapillaryDownloader:
|
21
|
+
"""Handles downloading Mapillary data for a user."""
|
22
|
+
|
23
|
+
def __init__(self, client, output_dir):
|
24
|
+
"""Initialize the downloader.
|
25
|
+
|
26
|
+
Args:
|
27
|
+
client: MapillaryClient instance
|
28
|
+
output_dir: Directory to save downloads
|
29
|
+
"""
|
30
|
+
self.client = client
|
31
|
+
self.output_dir = Path(output_dir)
|
32
|
+
self.output_dir.mkdir(parents=True, exist_ok=True)
|
33
|
+
|
34
|
+
self.metadata_file = self.output_dir / "metadata.jsonl"
|
35
|
+
self.progress_file = self.output_dir / "progress.json"
|
36
|
+
self.downloaded = self._load_progress()
|
37
|
+
|
38
|
+
def _load_progress(self):
|
39
|
+
"""Load previously downloaded image IDs."""
|
40
|
+
if self.progress_file.exists():
|
41
|
+
with open(self.progress_file) as f:
|
42
|
+
return set(json.load(f).get("downloaded", []))
|
43
|
+
return set()
|
44
|
+
|
45
|
+
def _save_progress(self):
|
46
|
+
"""Save progress to disk atomically."""
|
47
|
+
temp_file = self.progress_file.with_suffix(".json.tmp")
|
48
|
+
with open(temp_file, "w") as f:
|
49
|
+
json.dump({"downloaded": list(self.downloaded)}, f)
|
50
|
+
f.flush()
|
51
|
+
os.fsync(f.fileno())
|
52
|
+
temp_file.replace(self.progress_file)
|
53
|
+
|
54
|
+
def download_user_data(self, username, quality="original", bbox=None):
|
55
|
+
"""Download all images for a user.
|
56
|
+
|
57
|
+
Args:
|
58
|
+
username: Mapillary username
|
59
|
+
quality: Image quality to download (256, 1024, 2048, original)
|
60
|
+
bbox: Optional bounding box [west, south, east, north]
|
61
|
+
"""
|
62
|
+
quality_field = f"thumb_{quality}_url"
|
63
|
+
|
64
|
+
print(f"Downloading images for user: {username}")
|
65
|
+
print(f"Output directory: {self.output_dir}")
|
66
|
+
print(f"Quality: {quality}")
|
67
|
+
|
68
|
+
processed = 0
|
69
|
+
downloaded_count = 0
|
70
|
+
skipped = 0
|
71
|
+
total_bytes = 0
|
72
|
+
|
73
|
+
with open(self.metadata_file, "a") as meta_f:
|
74
|
+
for image in self.client.get_user_images(username, bbox=bbox):
|
75
|
+
image_id = image["id"]
|
76
|
+
processed += 1
|
77
|
+
|
78
|
+
if image_id in self.downloaded:
|
79
|
+
skipped += 1
|
80
|
+
continue
|
81
|
+
|
82
|
+
# Save metadata
|
83
|
+
meta_f.write(json.dumps(image) + "\n")
|
84
|
+
meta_f.flush()
|
85
|
+
|
86
|
+
# Download image
|
87
|
+
image_url = image.get(quality_field)
|
88
|
+
if not image_url:
|
89
|
+
print(f"No {quality} URL for image {image_id}")
|
90
|
+
continue
|
91
|
+
|
92
|
+
# Use sequence ID for organization
|
93
|
+
sequence_id = image.get("sequence")
|
94
|
+
if sequence_id:
|
95
|
+
img_dir = self.output_dir / sequence_id
|
96
|
+
img_dir.mkdir(exist_ok=True)
|
97
|
+
else:
|
98
|
+
img_dir = self.output_dir
|
99
|
+
|
100
|
+
output_path = img_dir / f"{image_id}.jpg"
|
101
|
+
|
102
|
+
bytes_downloaded = self.client.download_image(image_url, output_path)
|
103
|
+
if bytes_downloaded:
|
104
|
+
# Write EXIF metadata to the downloaded image
|
105
|
+
write_exif_to_image(output_path, image)
|
106
|
+
|
107
|
+
self.downloaded.add(image_id)
|
108
|
+
downloaded_count += 1
|
109
|
+
total_bytes += bytes_downloaded
|
110
|
+
print(f"Processed: {processed}, Downloaded: {downloaded_count} ({format_bytes(total_bytes)})")
|
111
|
+
|
112
|
+
# Save progress every 10 images
|
113
|
+
if downloaded_count % 10 == 0:
|
114
|
+
self._save_progress()
|
115
|
+
|
116
|
+
self._save_progress()
|
117
|
+
print(
|
118
|
+
f"\nComplete! Processed {processed} images, downloaded {downloaded_count} ({format_bytes(total_bytes)}), skipped {skipped}"
|
119
|
+
)
|
@@ -0,0 +1,122 @@
|
|
1
|
+
"""EXIF metadata writer for Mapillary images."""
|
2
|
+
|
3
|
+
import piexif
|
4
|
+
from datetime import datetime
|
5
|
+
|
6
|
+
|
7
|
+
def decimal_to_dms(decimal):
|
8
|
+
"""Convert decimal degrees to degrees, minutes, seconds format for EXIF.
|
9
|
+
|
10
|
+
Args:
|
11
|
+
decimal: Decimal degrees (can be negative)
|
12
|
+
|
13
|
+
Returns:
|
14
|
+
Tuple of ((degrees, 1), (minutes, 1), (seconds, 100)) as rational numbers
|
15
|
+
"""
|
16
|
+
decimal = abs(decimal)
|
17
|
+
degrees = int(decimal)
|
18
|
+
minutes_float = (decimal - degrees) * 60
|
19
|
+
minutes = int(minutes_float)
|
20
|
+
seconds = (minutes_float - minutes) * 60
|
21
|
+
seconds_rational = (int(seconds * 100), 100)
|
22
|
+
|
23
|
+
return ((degrees, 1), (minutes, 1), seconds_rational)
|
24
|
+
|
25
|
+
|
26
|
+
def timestamp_to_exif_datetime(timestamp):
|
27
|
+
"""Convert Unix timestamp to EXIF datetime string.
|
28
|
+
|
29
|
+
Args:
|
30
|
+
timestamp: Unix timestamp in milliseconds
|
31
|
+
|
32
|
+
Returns:
|
33
|
+
String in format "YYYY:MM:DD HH:MM:SS"
|
34
|
+
"""
|
35
|
+
dt = datetime.fromtimestamp(timestamp / 1000.0)
|
36
|
+
return dt.strftime("%Y:%m:%d %H:%M:%S")
|
37
|
+
|
38
|
+
|
39
|
+
def write_exif_to_image(image_path, metadata):
|
40
|
+
"""Write EXIF metadata from Mapillary API to downloaded image.
|
41
|
+
|
42
|
+
Args:
|
43
|
+
image_path: Path to the downloaded image file
|
44
|
+
metadata: Dictionary of metadata from Mapillary API
|
45
|
+
|
46
|
+
Returns:
|
47
|
+
True if successful, False otherwise
|
48
|
+
"""
|
49
|
+
try:
|
50
|
+
# Load existing EXIF data if any
|
51
|
+
try:
|
52
|
+
exif_dict = piexif.load(str(image_path))
|
53
|
+
except Exception:
|
54
|
+
# No existing EXIF data, start fresh
|
55
|
+
exif_dict = {"0th": {}, "Exif": {}, "GPS": {}, "1st": {}, "thumbnail": None}
|
56
|
+
|
57
|
+
# Ensure all IFDs exist
|
58
|
+
for ifd in ["0th", "Exif", "GPS", "1st"]:
|
59
|
+
if ifd not in exif_dict:
|
60
|
+
exif_dict[ifd] = {}
|
61
|
+
|
62
|
+
# Basic image info (0th IFD)
|
63
|
+
if "make" in metadata and metadata["make"]:
|
64
|
+
exif_dict["0th"][piexif.ImageIFD.Make] = metadata["make"].encode("utf-8")
|
65
|
+
|
66
|
+
if "model" in metadata and metadata["model"]:
|
67
|
+
exif_dict["0th"][piexif.ImageIFD.Model] = metadata["model"].encode("utf-8")
|
68
|
+
|
69
|
+
if "exif_orientation" in metadata and metadata["exif_orientation"]:
|
70
|
+
exif_dict["0th"][piexif.ImageIFD.Orientation] = metadata["exif_orientation"]
|
71
|
+
|
72
|
+
if "width" in metadata and metadata["width"]:
|
73
|
+
exif_dict["0th"][piexif.ImageIFD.ImageWidth] = metadata["width"]
|
74
|
+
|
75
|
+
if "height" in metadata and metadata["height"]:
|
76
|
+
exif_dict["0th"][piexif.ImageIFD.ImageLength] = metadata["height"]
|
77
|
+
|
78
|
+
# Datetime tags
|
79
|
+
if "captured_at" in metadata and metadata["captured_at"]:
|
80
|
+
datetime_str = timestamp_to_exif_datetime(metadata["captured_at"])
|
81
|
+
datetime_bytes = datetime_str.encode("utf-8")
|
82
|
+
exif_dict["0th"][piexif.ImageIFD.DateTime] = datetime_bytes
|
83
|
+
exif_dict["Exif"][piexif.ExifIFD.DateTimeOriginal] = datetime_bytes
|
84
|
+
exif_dict["Exif"][piexif.ExifIFD.DateTimeDigitized] = datetime_bytes
|
85
|
+
|
86
|
+
# GPS data - prefer computed_geometry over geometry
|
87
|
+
geometry = metadata.get("computed_geometry") or metadata.get("geometry")
|
88
|
+
if geometry and "coordinates" in geometry:
|
89
|
+
lon, lat = geometry["coordinates"]
|
90
|
+
|
91
|
+
# GPS Latitude
|
92
|
+
exif_dict["GPS"][piexif.GPSIFD.GPSLatitude] = decimal_to_dms(lat)
|
93
|
+
exif_dict["GPS"][piexif.GPSIFD.GPSLatitudeRef] = b"N" if lat >= 0 else b"S"
|
94
|
+
|
95
|
+
# GPS Longitude
|
96
|
+
exif_dict["GPS"][piexif.GPSIFD.GPSLongitude] = decimal_to_dms(lon)
|
97
|
+
exif_dict["GPS"][piexif.GPSIFD.GPSLongitudeRef] = b"E" if lon >= 0 else b"W"
|
98
|
+
|
99
|
+
# GPS Altitude - prefer computed_altitude over altitude
|
100
|
+
altitude = metadata.get("computed_altitude") or metadata.get("altitude")
|
101
|
+
if altitude is not None:
|
102
|
+
exif_dict["GPS"][piexif.GPSIFD.GPSAltitude] = (int(abs(altitude) * 100), 100)
|
103
|
+
exif_dict["GPS"][piexif.GPSIFD.GPSAltitudeRef] = 1 if altitude < 0 else 0
|
104
|
+
|
105
|
+
# GPS Compass direction
|
106
|
+
compass = metadata.get("computed_compass_angle") or metadata.get("compass_angle")
|
107
|
+
if compass is not None:
|
108
|
+
exif_dict["GPS"][piexif.GPSIFD.GPSImgDirection] = (int(compass * 100), 100)
|
109
|
+
exif_dict["GPS"][piexif.GPSIFD.GPSImgDirectionRef] = b"T" # True north
|
110
|
+
|
111
|
+
# GPS Version
|
112
|
+
exif_dict["GPS"][piexif.GPSIFD.GPSVersionID] = (2, 0, 0, 0)
|
113
|
+
|
114
|
+
# Dump and write EXIF data
|
115
|
+
exif_bytes = piexif.dump(exif_dict)
|
116
|
+
piexif.insert(exif_bytes, str(image_path))
|
117
|
+
|
118
|
+
return True
|
119
|
+
|
120
|
+
except Exception as e:
|
121
|
+
print(f"Warning: Failed to write EXIF data to {image_path}: {e}")
|
122
|
+
return False
|
@@ -0,0 +1,54 @@
|
|
1
|
+
Metadata-Version: 2.4
|
2
|
+
Name: mapillary_downloader
|
3
|
+
Version: 0.1.0
|
4
|
+
Summary: Download your Mapillary data before it's gone
|
5
|
+
Author-email: Gareth Davidson <gaz@bitplane.net>
|
6
|
+
Requires-Python: >=3.10
|
7
|
+
Description-Content-Type: text/markdown
|
8
|
+
License-File: LICENSE.md
|
9
|
+
Requires-Dist: requests>=2.31.0
|
10
|
+
Requires-Dist: piexif>=1.1.3
|
11
|
+
Requires-Dist: pre-commit ; extra == "dev"
|
12
|
+
Requires-Dist: pytest ; extra == "dev"
|
13
|
+
Requires-Dist: coverage ; extra == "dev"
|
14
|
+
Requires-Dist: pytest-cov ; extra == "dev"
|
15
|
+
Requires-Dist: build ; extra == "dev"
|
16
|
+
Requires-Dist: twine ; extra == "dev"
|
17
|
+
Requires-Dist: ruff ; extra == "dev"
|
18
|
+
Requires-Dist: mkdocs ; extra == "dev"
|
19
|
+
Requires-Dist: mkdocs-material ; extra == "dev"
|
20
|
+
Requires-Dist: pydoc-markdown ; extra == "dev"
|
21
|
+
Requires-Dist: Pillow ; extra == "dev"
|
22
|
+
Provides-Extra: dev
|
23
|
+
|
24
|
+
# Mapillary Downloader
|
25
|
+
|
26
|
+
Download your Mapillary data before it's gone.
|
27
|
+
|
28
|
+
## Installation
|
29
|
+
|
30
|
+
```bash
|
31
|
+
make install
|
32
|
+
```
|
33
|
+
|
34
|
+
## Usage
|
35
|
+
|
36
|
+
First, get your Mapillary API access token from https://www.mapillary.com/dashboard/developers
|
37
|
+
|
38
|
+
```bash
|
39
|
+
source .venv/bin/activate
|
40
|
+
python -m mapillary_downloader --token YOUR_TOKEN --username YOUR_USERNAME --output ./downloads
|
41
|
+
```
|
42
|
+
|
43
|
+
## Development
|
44
|
+
|
45
|
+
```bash
|
46
|
+
make dev # Setup dev environment
|
47
|
+
make test # Run tests
|
48
|
+
make coverage # Run tests with coverage
|
49
|
+
```
|
50
|
+
|
51
|
+
## License
|
52
|
+
|
53
|
+
WTFPL + Warranty (Don't blame me)
|
54
|
+
|
@@ -0,0 +1,9 @@
|
|
1
|
+
mapillary_downloader/__init__.py,sha256=KEjiBRghXDeA7E15RJeLBfQm-yNJkowZarL59QOh_1w,120
|
2
|
+
mapillary_downloader/__main__.py,sha256=xKYhamK0HYXqx98fGb5CVOEw0syURWgX7jnFIdsK5Ao,1720
|
3
|
+
mapillary_downloader/client.py,sha256=VTISi0VororaDei_heNhQpt2H5TDjMnNu7a2ynotUok,3256
|
4
|
+
mapillary_downloader/downloader.py,sha256=n5Y7aAoin3vBa_H3et9hpTNoPrEarbU_LdnHT619c5Y,4216
|
5
|
+
mapillary_downloader/exif_writer.py,sha256=Bn1u3QULfHtae86FnUGcqN450NccJwtwW9wVaSRyx9E,4615
|
6
|
+
mapillary_downloader-0.1.0.dist-info/licenses/LICENSE.md,sha256=7_BIuQ-veOrsF-WarH8kTkm0-xrCLvJ1PFE1C4Ebs64,146
|
7
|
+
mapillary_downloader-0.1.0.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
|
8
|
+
mapillary_downloader-0.1.0.dist-info/METADATA,sha256=04P8PB_Gg2xa3SVr-dNuCTH_UJR7oLbovzKgsbgm9Fw,1327
|
9
|
+
mapillary_downloader-0.1.0.dist-info/RECORD,,
|