mapillary-downloader 0.1.0__tar.gz

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.
@@ -0,0 +1,7 @@
1
+ # WTFPL + Warranty
2
+
3
+ Licensed under the WTFPL with one additional clause:
4
+
5
+ 1. Don't blame me.
6
+
7
+ Do whatever the fuck you want, just don't blame me.
@@ -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,30 @@
1
+ # Mapillary Downloader
2
+
3
+ Download your Mapillary data before it's gone.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ make install
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ First, get your Mapillary API access token from https://www.mapillary.com/dashboard/developers
14
+
15
+ ```bash
16
+ source .venv/bin/activate
17
+ python -m mapillary_downloader --token YOUR_TOKEN --username YOUR_USERNAME --output ./downloads
18
+ ```
19
+
20
+ ## Development
21
+
22
+ ```bash
23
+ make dev # Setup dev environment
24
+ make test # Run tests
25
+ make coverage # Run tests with coverage
26
+ ```
27
+
28
+ ## License
29
+
30
+ WTFPL + Warranty (Don't blame me)
@@ -0,0 +1,40 @@
1
+ [project]
2
+ name = "mapillary_downloader"
3
+ description = "Download your Mapillary data before it's gone"
4
+ version = "0.1.0"
5
+ authors = [
6
+ { name = "Gareth Davidson", email = "gaz@bitplane.net" }
7
+ ]
8
+ readme = "README.md"
9
+ requires-python = ">=3.10"
10
+
11
+ dependencies = [
12
+ "requests>=2.31.0",
13
+ "piexif>=1.1.3",
14
+ ]
15
+
16
+ [project.optional-dependencies]
17
+ dev = [
18
+ "pre-commit",
19
+ "pytest",
20
+ "coverage",
21
+ "pytest-cov",
22
+ "build",
23
+ "twine",
24
+ "ruff",
25
+ "mkdocs",
26
+ "mkdocs-material",
27
+ "pydoc-markdown",
28
+ "Pillow",
29
+ ]
30
+
31
+ [build-system]
32
+ build-backend = "flit_core.buildapi"
33
+ requires = ["flit_core >=3.2,<4"]
34
+
35
+ [tool.ruff]
36
+ line-length = 120
37
+ target-version = "py310"
38
+
39
+ [tool.ruff.format]
40
+ docstring-code-format = true
@@ -0,0 +1,5 @@
1
+ """Mapillary data downloader."""
2
+
3
+ from importlib.metadata import version
4
+
5
+ __version__ = version("mapillary_downloader")
@@ -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