#!/usr/bin/env python3 import io import json import math import subprocess import sys import time from dataclasses import dataclass, field from datetime import datetime from pathlib import Path from urllib.parse import urlencode from urllib.request import Request, urlopen from PIL import Image, ImageDraw, ImageFont VERSION = "2026-05-31-infographic-v5-single-location-footer-fix" MAP_WIDTH = 906 MAP_HEIGHT = 510 TILE_SIZE = 256 MIN_ZOOM = 3 MAX_ZOOM = 13 CLUSTER_RADIUS_KM = 2.0 FOOTER_HEIGHT = 118 FOOTER_MARGIN = 14 USER_AGENT = "gpsmap-infographic-script/1.0" PHOTO_EXTENSIONS = { ".jpg", ".jpeg", ".png", ".heic", ".heif", ".webp", ".tif", ".tiff" } VIDEO_EXTENSIONS = { ".mp4", ".mov", ".m4v", ".avi", ".mkv", ".3gp" } FONT_BOLD = "/usr/share/fonts/truetype/noto/NotoSans-Bold.ttf" FONT_REGULAR = "/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf" @dataclass class MediaItem: path: Path lat: float lon: float kind: str taken_at: datetime | None = None @dataclass class Cluster: items: list[MediaItem] = field(default_factory=list) @property def lat(self): return sum(i.lat for i in self.items) / len(self.items) @property def lon(self): return sum(i.lon for i in self.items) / len(self.items) @property def photos(self): return sum(1 for i in self.items if i.kind == "photo") @property def videos(self): return sum(1 for i in self.items if i.kind == "video") @property def first_sort_key(self): return min(sort_key(i) for i in self.items) def load_font(path, size): try: return ImageFont.truetype(path, size) except Exception: return ImageFont.load_default() def parse_exif_date(value): if not value: return None # Common exiftool format: 2025:07:19 16:47:12 for fmt in ( "%Y:%m:%d %H:%M:%S", "%Y:%m:%d %H:%M:%S%z", "%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M:%S%z", ): try: return datetime.strptime(str(value).split(".")[0], fmt) except Exception: pass return None def media_kind(path): ext = path.suffix.lower() if ext in PHOTO_EXTENSIONS: return "photo" if ext in VIDEO_EXTENSIONS: return "video" return "other" def get_metadata(file_path): try: result = subprocess.run( [ "exiftool", "-json", "-n", "-GPSLatitude", "-GPSLongitude", "-DateTimeOriginal", "-CreateDate", "-MediaCreateDate", "-TrackCreateDate", str(file_path), ], capture_output=True, text=True, check=True, ) data = json.loads(result.stdout)[0] lat = data.get("GPSLatitude") lon = data.get("GPSLongitude") if lat is None or lon is None: return None taken_at = None for key in ("DateTimeOriginal", "CreateDate", "MediaCreateDate", "TrackCreateDate"): taken_at = parse_exif_date(data.get(key)) if taken_at: break return float(lat), float(lon), taken_at except Exception: return None def sort_key(item): # EXIF date first; filename fallback keeps your 001_, 003_, 005_ order stable. return (item.taken_at or datetime.max, item.path.name) def haversine_km(lat1, lon1, lat2, lon2): r = 6371.0 p1 = math.radians(lat1) p2 = math.radians(lat2) dp = math.radians(lat2 - lat1) dl = math.radians(lon2 - lon1) a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2 return 2 * r * math.atan2(math.sqrt(a), math.sqrt(1 - a)) def collect_media(media_dir): items = [] for file_path in media_dir.rglob("*"): if not file_path.is_file(): continue kind = media_kind(file_path) if kind == "other": continue meta = get_metadata(file_path) if not meta: continue lat, lon, taken_at = meta item = MediaItem(file_path, lat, lon, kind, taken_at) items.append(item) print(f"GPS: {file_path.name} -> ({lat}, {lon}) [{kind}]") return sorted(items, key=sort_key) def make_clusters(items): clusters = [] for item in items: best_cluster = None best_distance = None for cluster in clusters: dist = haversine_km(item.lat, item.lon, cluster.lat, cluster.lon) if dist <= CLUSTER_RADIUS_KM and (best_distance is None or dist < best_distance): best_cluster = cluster best_distance = dist if best_cluster: best_cluster.items.append(item) else: clusters.append(Cluster([item])) return sorted(clusters, key=lambda c: c.first_sort_key) def country_language(country_code): return { "dk": "da", "de": "de", "at": "de", "ch": "de", "hr": "hr", "se": "sv", "no": "no", "fi": "fi", "pl": "pl", "cz": "cs", "sk": "sk", "fr": "fr", "it": "it", "es": "es", "pt": "pt", "nl": "nl", "be": "nl", "ba": "bs", "rs": "sr", "si": "sl", "hu": "hu", "gb": "en", "uk": "en", "ie": "en", }.get(country_code.lower(), "en") def nominatim_reverse(lat, lon, language): params = urlencode({ "format": "jsonv2", "lat": lat, "lon": lon, "addressdetails": 1, "accept-language": language, }) url = f"https://nominatim.openstreetmap.org/reverse?{params}" request = Request(url, headers={"User-Agent": USER_AGENT}) with urlopen(request, timeout=20) as response: return json.loads(response.read().decode("utf-8")) def reverse_geocode_parts(lat, lon): try: first = nominatim_reverse(lat, lon, "en") country_code = first.get("address", {}).get("country_code", "en") language = country_language(country_code) time.sleep(1) data = nominatim_reverse(lat, lon, language) address = data.get("address", {}) city = ( address.get("city") or address.get("town") or address.get("village") or address.get("island") or address.get("suburb") or address.get("hamlet") or address.get("municipality") or "" ) region = ( address.get("state_district") or address.get("region") or address.get("state") or address.get("county") or "" ) country = address.get("country", "") return {"city": city, "region": region, "country": country.upper() if country else ""} except Exception: return {"city": "", "region": "", "country": ""} def clean_place_name(value): value = (value or "").strip() # Shorter Danish names look much better in the footer. replacements = { "Region Syddanmark": "Syddanmark", "Region Midtjylland": "Midtjylland", "Region Nordjylland": "Nordjylland", "Region Sjælland": "Sjælland", "Region Hovedstaden": "Hovedstaden", } return replacements.get(value, value) def make_map_label(items, clusters=None): lats = [i.lat for i in items] lons = [i.lon for i in items] # If this is a one-location map, use the center point and keep the label short. # Example: Rømø DANMARK instead of Tønder Kommune Region Syddanmark DANMARK. if clusters is not None and len(clusters) == 1: lat = sum(lats) / len(lats) lon = sum(lons) / len(lons) info = reverse_geocode_parts(lat, lon) city = clean_place_name(info.get("city", "")) region = clean_place_name(info.get("region", "")) country = clean_place_name(info.get("country", "")) if city and country: return f"{city} {country}" if region and country: return f"{region} {country}" return country sample_points = [ (min(lats), min(lons)), (max(lats), max(lons)), (sum(lats) / len(lats), sum(lons) / len(lons)), ] cities, regions, countries = set(), set(), set() for lat, lon in sample_points: info = reverse_geocode_parts(lat, lon) city = clean_place_name(info.get("city", "")) region = clean_place_name(info.get("region", "")) country = clean_place_name(info.get("country", "")) if city: cities.add(city) if region: regions.add(region) if country: countries.add(country) if len(countries) > 1: return " • ".join(sorted(countries)) country = next(iter(countries), "") if len(regions) > 1: return country region = next(iter(regions), "") if len(cities) > 1: return f"{region} {country}".strip() city = next(iter(cities), "") if city and region and country: return f"{city}, {region} {country}" if region and country: return f"{region} {country}" return country def format_date_range(items): dates = [i.taken_at for i in items if i.taken_at] if not dates: return "" start = min(dates) end = max(dates) if start.date() == end.date(): return start.strftime("%-d %b %Y") if start.year == end.year and start.month == end.month: return f"{start.strftime('%-d')} – {end.strftime('%-d %b %Y')}" if start.year == end.year: return f"{start.strftime('%-d %b')} – {end.strftime('%-d %b %Y')}" return f"{start.strftime('%-d %b %Y')} – {end.strftime('%-d %b %Y')}" def latlon_to_pixel(lat, lon, zoom): sin_lat = math.sin(math.radians(lat)) scale = TILE_SIZE * 2**zoom x = (lon + 180.0) / 360.0 * scale y = (0.5 - math.log((1 + sin_lat) / (1 - sin_lat)) / (4 * math.pi)) * scale return x, y def choose_zoom(points, width, height): pad_x = 70 pad_top = 45 pad_bottom = FOOTER_HEIGHT + FOOTER_MARGIN usable_width = width - 2 * pad_x usable_height = height - pad_top - pad_bottom if len(points) <= 1: return 11 for zoom in range(MAX_ZOOM, MIN_ZOOM - 1, -1): pixels = [latlon_to_pixel(lat, lon, zoom) for lat, lon in points] xs = [p[0] for p in pixels] ys = [p[1] for p in pixels] if max(xs) - min(xs) <= usable_width and max(ys) - min(ys) <= usable_height: return zoom return MIN_ZOOM def map_view(points, zoom): pixels = [latlon_to_pixel(lat, lon, zoom) for lat, lon in points] xs = [p[0] for p in pixels] ys = [p[1] for p in pixels] center_x = (min(xs) + max(xs)) / 2 center_y = (min(ys) + max(ys)) / 2 # Keep the route/markers in the upper map area and reserve the lower footer # for date, statistics and location. This prevents single-location maps from # having the marker collide with the footer. usable_center_x = MAP_WIDTH / 2 usable_center_y = (MAP_HEIGHT - FOOTER_HEIGHT) / 2 top_left_x = center_x - usable_center_x top_left_y = center_y - usable_center_y return top_left_x, top_left_y def download_tile(x, y, z): url = f"https://tile.openstreetmap.org/{z}/{x}/{y}.png" request = Request(url, headers={"User-Agent": USER_AGENT}) with urlopen(request, timeout=20) as response: return Image.open(io.BytesIO(response.read())).convert("RGB") def render_tiles(top_left_x, top_left_y, zoom): first_tile_x = int(top_left_x // TILE_SIZE) first_tile_y = int(top_left_y // TILE_SIZE) last_tile_x = int((top_left_x + MAP_WIDTH) // TILE_SIZE) last_tile_y = int((top_left_y + MAP_HEIGHT) // TILE_SIZE) image = Image.new("RGB", (MAP_WIDTH, MAP_HEIGHT), "white") max_tile = 2**zoom for tile_x in range(first_tile_x, last_tile_x + 1): for tile_y in range(first_tile_y, last_tile_y + 1): wrapped_x = tile_x % max_tile if tile_y < 0 or tile_y >= max_tile: continue try: tile = download_tile(wrapped_x, tile_y, zoom) time.sleep(0.1) except Exception as e: print(f"Failed tile {zoom}/{wrapped_x}/{tile_y}: {e}") continue paste_x = int(tile_x * TILE_SIZE - top_left_x) paste_y = int(tile_y * TILE_SIZE - top_left_y) image.paste(tile, (paste_x, paste_y)) return image def screen_xy(lat, lon, zoom, top_left_x, top_left_y): px, py = latlon_to_pixel(lat, lon, zoom) return int(px - top_left_x), int(py - top_left_y) def draw_arrow(draw, x1, y1, x2, y2, fill): angle = math.atan2(y2 - y1, x2 - x1) size = 13 cx = x1 + (x2 - x1) * 0.62 cy = y1 + (y2 - y1) * 0.62 p1 = (cx + math.cos(angle) * size, cy + math.sin(angle) * size) p2 = (cx + math.cos(angle + 2.55) * size, cy + math.sin(angle + 2.55) * size) p3 = (cx + math.cos(angle - 2.55) * size, cy + math.sin(angle - 2.55) * size) draw.polygon([p1, p2, p3], fill=fill, outline="white") def draw_route(draw, route_points): if len(route_points) < 2: return # White underlay makes the route visible on busy map tiles. draw.line(route_points, fill="white", width=7, joint="curve") draw.line(route_points, fill="red", width=4, joint="curve") for (x1, y1), (x2, y2) in zip(route_points, route_points[1:]): if math.hypot(x2 - x1, y2 - y1) > 35: draw_arrow(draw, x1, y1, x2, y2, "red") def marker_text(cluster): parts = [] if cluster.photos: parts.append(f"{cluster.photos} photo" + ("s" if cluster.photos != 1 else "")) if cluster.videos: parts.append(f"{cluster.videos} video" + ("s" if cluster.videos != 1 else "")) return parts def draw_cluster_marker(draw, x, y, cluster): font = load_font(FONT_BOLD, 22) total = cluster.photos + cluster.videos text = str(total) bbox = draw.textbbox((0, 0), text, font=font) tw = bbox[2] - bbox[0] th = bbox[3] - bbox[1] # Smaller marker, with the number truly centered in the circle. r = max(14, int(max(tw, th) / 2) + 8) draw.ellipse( (x - r, y - r, x + r, y + r), fill="white", outline="red", width=2, ) text_x = x - (bbox[0] + bbox[2]) / 2 text_y = y - (bbox[1] + bbox[3]) / 2 draw.text( (text_x, text_y), text, font=font, fill="black", ) def plural(n, singular, plural_word=None): if n == 1: return f"{n} {singular}" return f"{n} {plural_word or singular + 's'}" def route_distance_km(clusters): total = 0.0 for a, b in zip(clusters, clusters[1:]): total += haversine_km(a.lat, a.lon, b.lat, b.lon) return total def draw_centered_text(draw, y, text, font, fill="black"): bbox = draw.textbbox((0, 0), text, font=font) w = bbox[2] - bbox[0] draw.text(((MAP_WIDTH - w) / 2, y), text, font=font, fill=fill) def fit_font(draw, text, font_path, start_size, max_width, min_size=12): for size in range(start_size, min_size - 1, -1): font = load_font(font_path, size) bbox = draw.textbbox((0, 0), text, font=font) if bbox[2] - bbox[0] <= max_width: return font return load_font(font_path, min_size) def draw_bottom_title(draw, label, stats, date_text=""): max_text_width = MAP_WIDTH - 28 title_font = fit_font(draw, label, FONT_BOLD, 26, max_text_width, min_size=20) stats_font = fit_font(draw, stats, FONT_REGULAR, 26, max_text_width, min_size=16) date_font = fit_font(draw, date_text, FONT_REGULAR, 22, max_text_width, min_size=14) if date_text else None title_bbox = draw.textbbox((0, 0), label, font=title_font) stats_bbox = draw.textbbox((0, 0), stats, font=stats_font) date_bbox = draw.textbbox((0, 0), date_text, font=date_font) if date_text else (0, 0, 0, 0) title_h = title_bbox[3] - title_bbox[1] stats_h = stats_bbox[3] - stats_bbox[1] date_h = date_bbox[3] - date_bbox[1] if date_text else 0 gap = 3 bottom_margin = 14 y_title = MAP_HEIGHT - title_h - bottom_margin y_stats = y_title - stats_h - gap y_date = y_stats - date_h - gap if date_text else None if date_text: draw_centered_text(draw, y_stats, date_text, date_font, fill=(40, 40, 40)) # draw_centered_text(draw, y_stats, stats, stats_font, fill=(30, 30, 30)) draw_centered_text(draw, y_title, label, title_font, fill="black") def main(): print(f"gpsmap version: {VERSION}") if len(sys.argv) != 3: print(f"Usage: {sys.argv[0]} ") sys.exit(1) media_dir = Path(sys.argv[1]) output_arg = Path(sys.argv[2]) if not media_dir.is_dir(): print(f"Media directory not found: {media_dir}") sys.exit(1) if output_arg.suffix == ".png": output_png = output_arg output_png.parent.mkdir(parents=True, exist_ok=True) else: output_arg.mkdir(parents=True, exist_ok=True) output_png = output_arg / f"{media_dir.resolve().name}.png" items = collect_media(media_dir) if not items: print("No GPS coordinates found.") sys.exit(2) clusters = make_clusters(items) points = [(c.lat, c.lon) for c in clusters] zoom = choose_zoom(points, MAP_WIDTH, MAP_HEIGHT) top_left_x, top_left_y = map_view(points, zoom) label = make_map_label(items, clusters) photo_count = sum(1 for i in items if i.kind == "photo") video_count = sum(1 for i in items if i.kind == "video") distance = round(route_distance_km(clusters)) stats_parts = [plural(photo_count, "photo")] if video_count: stats_parts.append(plural(video_count, "video")) if len(clusters) > 1 and distance > 0: stats_parts.append(f"{distance} km route") stats = " • ".join(stats_parts) date_text = format_date_range(items) print(f"Map label: {label}") if date_text: print(f"Date: {date_text}") print(f"Stats: {stats}") image = render_tiles(top_left_x, top_left_y, zoom) draw = ImageDraw.Draw(image) route_points = [screen_xy(c.lat, c.lon, zoom, top_left_x, top_left_y) for c in clusters] draw_route(draw, route_points) for cluster, (x, y) in zip(clusters, route_points): draw_cluster_marker(draw, x, y, cluster) draw_bottom_title(draw, label, stats, date_text) image.save(output_png) print() print(f"Created: {output_png}") print(f"Photos: {photo_count}") print(f"Videos: {video_count}") print(f"Locations: {len(clusters)}") print(f"Zoom: {zoom}") if __name__ == "__main__": main()