mapillary-downloader 0.7.6__py3-none-any.whl → 0.7.8__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.
@@ -72,9 +72,6 @@ def write_exif_to_image(image_path, metadata):
72
72
  if "model" in metadata and metadata["model"]:
73
73
  exif_dict["0th"][piexif.ImageIFD.Model] = metadata["model"].encode("utf-8")
74
74
 
75
- if "exif_orientation" in metadata and metadata["exif_orientation"]:
76
- exif_dict["0th"][piexif.ImageIFD.Orientation] = metadata["exif_orientation"]
77
-
78
75
  if "width" in metadata and metadata["width"]:
79
76
  exif_dict["0th"][piexif.ImageIFD.ImageWidth] = metadata["width"]
80
77
 
@@ -88,6 +85,8 @@ def write_exif_to_image(image_path, metadata):
88
85
  exif_dict["0th"][piexif.ImageIFD.DateTime] = datetime_bytes
89
86
  exif_dict["Exif"][piexif.ExifIFD.DateTimeOriginal] = datetime_bytes
90
87
  exif_dict["Exif"][piexif.ExifIFD.DateTimeDigitized] = datetime_bytes
88
+ exif_dict["Exif"][piexif.ExifIFD.SubSecTimeOriginal] = ('000'+str(metadata["captured_at"] % 1000))[-3:]
89
+ exif_dict["Exif"][piexif.ExifIFD.SubSecTimeDigitized] = ('000'+str(metadata["captured_at"] % 1000))[-3:]
91
90
 
92
91
  # GPS data - prefer computed_geometry over geometry
93
92
  geometry = metadata.get("computed_geometry") or metadata.get("geometry")
@@ -77,7 +77,7 @@ def safe_json_save(file_path, data):
77
77
  temp_file.replace(file_path)
78
78
 
79
79
 
80
- def http_get_with_retry(url, params=None, max_retries=5, base_delay=1.0, timeout=60):
80
+ def http_get_with_retry(url, params=None, max_retries=5, base_delay=1.0, timeout=60, session=None):
81
81
  """HTTP GET with exponential backoff retry.
82
82
 
83
83
  Args:
@@ -86,6 +86,7 @@ def http_get_with_retry(url, params=None, max_retries=5, base_delay=1.0, timeout
86
86
  max_retries: Maximum retry attempts (default: 5)
87
87
  base_delay: Initial delay in seconds (default: 1.0)
88
88
  timeout: Request timeout in seconds (default: 60)
89
+ session: Optional requests.Session for connection pooling
89
90
 
90
91
  Returns:
91
92
  requests.Response object
@@ -93,9 +94,10 @@ def http_get_with_retry(url, params=None, max_retries=5, base_delay=1.0, timeout
93
94
  Raises:
94
95
  requests.RequestException: If all retries exhausted
95
96
  """
97
+ getter = session or requests
96
98
  for attempt in range(max_retries):
97
99
  try:
98
- response = requests.get(url, params=params, timeout=timeout)
100
+ response = getter.get(url, params=params, timeout=timeout)
99
101
  response.raise_for_status()
100
102
  return response
101
103
  except RequestException as e:
@@ -7,6 +7,7 @@ from datetime import datetime
7
7
  from pathlib import Path
8
8
  import requests
9
9
  from mapillary_downloader.exif_writer import write_exif_to_image
10
+ from mapillary_downloader.xmp_writer import write_xmp_to_image
10
11
  from mapillary_downloader.webp_converter import convert_to_webp
11
12
  from mapillary_downloader.utils import http_get_with_retry
12
13
 
@@ -105,7 +106,7 @@ def download_and_convert_image(image_data, output_dir, quality, convert_webp, se
105
106
 
106
107
  try:
107
108
  # Use retry logic with 3 attempts for image downloads
108
- response = http_get_with_retry(image_url, max_retries=3, base_delay=1.0, timeout=60)
109
+ response = http_get_with_retry(image_url, max_retries=3, base_delay=1.0, timeout=60, session=session)
109
110
 
110
111
  with open(jpg_path, "wb") as f:
111
112
  for chunk in response.iter_content(chunk_size=8192):
@@ -117,6 +118,9 @@ def download_and_convert_image(image_data, output_dir, quality, convert_webp, se
117
118
  # Write EXIF metadata
118
119
  write_exif_to_image(jpg_path, image_data)
119
120
 
121
+ # Write XMP metadata for panoramas
122
+ write_xmp_to_image(jpg_path, image_data)
123
+
120
124
  # Convert to WebP if requested
121
125
  if convert_webp:
122
126
  webp_path = convert_to_webp(jpg_path, output_path=final_path, delete_original=False)
@@ -0,0 +1,154 @@
1
+ """XMP metadata writer for panoramic Mapillary images."""
2
+
3
+ import logging
4
+
5
+ logger = logging.getLogger("mapillary_downloader")
6
+
7
+ # XMP namespace identifier for APP1 segment
8
+ XMP_NAMESPACE = b"http://ns.adobe.com/xap/1.0/\x00"
9
+
10
+ # XMP packet template for GPano metadata
11
+ XMP_TEMPLATE = """<?xpacket begin="\ufeff" id="W5M0MpCehiHzreSzNTczkc9d"?>
12
+ <x:xmpmeta xmlns:x="adobe:ns:meta/">
13
+ <rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
14
+ <rdf:Description rdf:about=""
15
+ xmlns:GPano="http://ns.google.com/photos/1.0/panorama/"
16
+ GPano:ProjectionType="equirectangular"
17
+ GPano:UsePanoramaViewer="True"
18
+ GPano:FullPanoWidthPixels="{width}"
19
+ GPano:FullPanoHeightPixels="{height}"
20
+ GPano:CroppedAreaImageWidthPixels="{width}"
21
+ GPano:CroppedAreaImageHeightPixels="{height}"
22
+ GPano:CroppedAreaLeftPixels="0"
23
+ GPano:CroppedAreaTopPixels="0"{pose_heading}/>
24
+ </rdf:RDF>
25
+ </x:xmpmeta>
26
+ <?xpacket end="w"?>"""
27
+
28
+
29
+ def build_xmp_packet(metadata):
30
+ """Build XMP packet with GPano metadata.
31
+
32
+ Args:
33
+ metadata: Dictionary with width, height, and optionally compass_angle
34
+
35
+ Returns:
36
+ XMP XML string
37
+ """
38
+ width = metadata.get("width", 0)
39
+ height = metadata.get("height", 0)
40
+
41
+ # Get compass angle (prefer computed)
42
+ compass = metadata.get("computed_compass_angle") or metadata.get("compass_angle")
43
+
44
+ # Build pose heading attribute if available
45
+ if compass is not None:
46
+ pose_heading = f'\n GPano:PoseHeadingDegrees="{compass:.1f}"'
47
+ else:
48
+ pose_heading = ""
49
+
50
+ return XMP_TEMPLATE.format(
51
+ width=width,
52
+ height=height,
53
+ pose_heading=pose_heading,
54
+ )
55
+
56
+
57
+ def write_xmp_to_image(image_path, metadata):
58
+ """Write XMP GPano metadata to a JPEG image for panoramas.
59
+
60
+ Only writes metadata if is_pano is True in the metadata dict.
61
+
62
+ Args:
63
+ image_path: Path to the JPEG image file
64
+ metadata: Dictionary of metadata from Mapillary API
65
+
66
+ Returns:
67
+ True if XMP was written, False if skipped or failed
68
+ """
69
+ # Only write XMP for panoramas
70
+ if not metadata.get("is_pano"):
71
+ return False
72
+
73
+ # Need dimensions to write meaningful GPano data
74
+ if not metadata.get("width") or not metadata.get("height"):
75
+ logger.warning(f"Skipping XMP for {image_path}: missing dimensions")
76
+ return False
77
+
78
+ try:
79
+ # Read the JPEG file
80
+ with open(image_path, "rb") as f:
81
+ data = f.read()
82
+
83
+ # Verify JPEG signature
84
+ if data[:2] != b"\xff\xd8":
85
+ logger.warning(f"Skipping XMP for {image_path}: not a valid JPEG")
86
+ return False
87
+
88
+ # Build XMP packet
89
+ xmp_xml = build_xmp_packet(metadata)
90
+ xmp_bytes = xmp_xml.encode("utf-8")
91
+
92
+ # Build APP1 segment with XMP namespace
93
+ xmp_segment = XMP_NAMESPACE + xmp_bytes
94
+ segment_length = len(xmp_segment) + 2 # +2 for length bytes
95
+
96
+ if segment_length > 65535:
97
+ logger.warning(f"Skipping XMP for {image_path}: XMP too large")
98
+ return False
99
+
100
+ # APP1 marker (0xFFE1) + length + data
101
+ app1_marker = b"\xff\xe1"
102
+ length_bytes = segment_length.to_bytes(2, byteorder="big")
103
+ full_segment = app1_marker + length_bytes + xmp_segment
104
+
105
+ # Find insertion point - after SOI (0xFFD8) and any existing APP0/APP1 segments
106
+ # We want to insert after EXIF APP1 but before other segments
107
+ pos = 2 # Skip SOI
108
+
109
+ while pos < len(data) - 1:
110
+ if data[pos] != 0xFF:
111
+ break
112
+
113
+ marker = data[pos + 1]
114
+
115
+ # Stop at SOS (start of scan) or non-marker data
116
+ if marker == 0xDA or marker == 0x00:
117
+ break
118
+
119
+ # Check if this is an APP1 with XMP namespace (skip if exists)
120
+ if marker == 0xE1: # APP1
121
+ seg_len = int.from_bytes(data[pos + 2 : pos + 4], byteorder="big")
122
+ seg_data = data[pos + 4 : pos + 2 + seg_len]
123
+ if seg_data.startswith(XMP_NAMESPACE):
124
+ # XMP already exists, replace it
125
+ new_data = data[:pos] + full_segment + data[pos + 2 + seg_len :]
126
+ with open(image_path, "wb") as f:
127
+ f.write(new_data)
128
+ logger.debug(f"Replaced XMP in {image_path}")
129
+ return True
130
+ # Skip this APP1 (probably EXIF)
131
+ pos += 2 + seg_len
132
+ continue
133
+
134
+ # Skip APP0 (JFIF) segments
135
+ if marker == 0xE0: # APP0
136
+ seg_len = int.from_bytes(data[pos + 2 : pos + 4], byteorder="big")
137
+ pos += 2 + seg_len
138
+ continue
139
+
140
+ # Found a different marker, insert XMP here
141
+ break
142
+
143
+ # Insert XMP segment at current position
144
+ new_data = data[:pos] + full_segment + data[pos:]
145
+
146
+ with open(image_path, "wb") as f:
147
+ f.write(new_data)
148
+
149
+ logger.debug(f"Wrote XMP GPano metadata to {image_path}")
150
+ return True
151
+
152
+ except Exception as e:
153
+ logger.warning(f"Failed to write XMP to {image_path}: {e}")
154
+ return False
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mapillary_downloader
3
- Version: 0.7.6
3
+ Version: 0.7.8
4
4
  Summary: Archive user data from Mapillary
5
5
  Author-email: Gareth Davidson <gaz@bitplane.net>
6
6
  Requires-Python: >=3.10
@@ -2,19 +2,20 @@ mapillary_downloader/__init__.py,sha256=KEjiBRghXDeA7E15RJeLBfQm-yNJkowZarL59QOh
2
2
  mapillary_downloader/__main__.py,sha256=iuDGZoFVu8q_dTvJuExSpj4Jx1x9xASSjUITRGwd0RA,4864
3
3
  mapillary_downloader/client.py,sha256=a5n43FLHP45EHodEjl0ieziBK-b6Ey-rZJwYB6EFhNI,4743
4
4
  mapillary_downloader/downloader.py,sha256=l6MT3dFOB-lZfoLEVVGIkioKSXcDu30Q9xc2MZ17iGI,18897
5
- mapillary_downloader/exif_writer.py,sha256=K_441EG1siWyNMmFGZSfnORUCjBThkeg4JFtbg9AOsA,5120
5
+ mapillary_downloader/exif_writer.py,sha256=_a70l9NS9pf8qTBAWVRdkh1edZHGOVjNDevYWrBoMoo,5187
6
6
  mapillary_downloader/ia_check.py,sha256=L2MEbG_KmlAd5NLmo2HQkO8HWvRN0brE5wXXoyNMbq8,1100
7
7
  mapillary_downloader/ia_meta.py,sha256=DTmFwIKN03aNgBaerQWF5x_hveDpjvrMBTdRAgHoFRk,6365
8
8
  mapillary_downloader/ia_stats.py,sha256=kjbNUVXtZziWxTx1yi2TLTZt_F0BWjrv1WWyy6ZeCLY,10678
9
9
  mapillary_downloader/logging_config.py,sha256=Z-wNq34nt7aIhJWdeKc1feTY46P9-Or7HtiX7eUFjEI,2324
10
10
  mapillary_downloader/metadata_reader.py,sha256=Re-HN0Vfc7Hs1eOut7uOoW7jWJ2PIbKoNzC7Ak3ah5o,4933
11
11
  mapillary_downloader/tar_sequences.py,sha256=hh77hfj0DFSPrPRfbGrOhvnZMctKESgO0gSpJXUxCCs,4886
12
- mapillary_downloader/utils.py,sha256=VgcwbC8yb2XlTGerTNwHBU42K2IN14VU7P-I52Vb01c,2947
12
+ mapillary_downloader/utils.py,sha256=qQ_ewhN0b0r4KLfBtf9tjwewF9PHVF1swLt71t8x9F0,3058
13
13
  mapillary_downloader/webp_converter.py,sha256=vYLLQxDmdnqRz0nm7wXwRUd4x9mQZNah-DrncpA8sNs,1901
14
- mapillary_downloader/worker.py,sha256=K2DkQgFzALKs20TsG1KibNUdFiWN_v8MtVnBX_0xVyc,5162
14
+ mapillary_downloader/worker.py,sha256=rMqeDj5pfLoEPwKOGN28R7yMZ_XDSzLayrL5ht0cqN0,5335
15
15
  mapillary_downloader/worker_pool.py,sha256=QnqYcPCi3GNu2e8GNG_qQ8v680PWzCZcGE5KeskqZxU,7868
16
- mapillary_downloader-0.7.6.dist-info/entry_points.txt,sha256=PdYtxOXHMJrUhmiPO4G-F98VuhUI4MN9D_T4KPrVZ5w,75
17
- mapillary_downloader-0.7.6.dist-info/licenses/LICENSE.md,sha256=7_BIuQ-veOrsF-WarH8kTkm0-xrCLvJ1PFE1C4Ebs64,146
18
- mapillary_downloader-0.7.6.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
19
- mapillary_downloader-0.7.6.dist-info/METADATA,sha256=waAPMcywcb2AjaSZg3wmK5HL9O-Xl_HBYKv_3H1aUUg,5540
20
- mapillary_downloader-0.7.6.dist-info/RECORD,,
16
+ mapillary_downloader/xmp_writer.py,sha256=6kjAP3JpqVnknDETgjd8Ze-P7c1kMbmvuQ14GF0dMoA,5163
17
+ mapillary_downloader-0.7.8.dist-info/entry_points.txt,sha256=PdYtxOXHMJrUhmiPO4G-F98VuhUI4MN9D_T4KPrVZ5w,75
18
+ mapillary_downloader-0.7.8.dist-info/licenses/LICENSE.md,sha256=7_BIuQ-veOrsF-WarH8kTkm0-xrCLvJ1PFE1C4Ebs64,146
19
+ mapillary_downloader-0.7.8.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
20
+ mapillary_downloader-0.7.8.dist-info/METADATA,sha256=R3KrmR3R8vhDHWqBmEmIpKaKmZpsJfuQj5ohHnLSrqA,5540
21
+ mapillary_downloader-0.7.8.dist-info/RECORD,,