#!/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-v4-date-stats-centered-counts" MAP_WIDTH = 906 MAP_HEIGHT = 510 TILE_SIZE = 256 MIN_ZOOM = 3 MAX_ZOOM = 13 CLUSTER_RADIUS_KM = 2.0 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("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 make_map_label(items): lats = [i.lat for i in items] lons = [i.lon for i in items] 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) if info["city"]: cities.add(info["city"]) if info["region"]: regions.add(info["region"]) if info["country"]: countries.add(info["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): # Leave room for bottom title/stat text. pad_x = 80 pad_top = 55 pad_bottom = 125 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 route slightly above image center so title/stat text has room at bottom. center_x = (min(xs) + max(xs)) / 2 center_y = (min(ys) + max(ys)) / 2 + 25 top_left_x = center_x - MAP_WIDTH / 2 top_left_y = center_y - MAP_HEIGHT / 2 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 draw_bottom_title(draw, label, stats, date_text=""): title_font = load_font(FONT_BOLD, 38) stats_font = load_font(FONT_REGULAR, 30) date_font = load_font(FONT_REGULAR, 22) 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 = 4 bottom_margin = 20 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_date, 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_dir = Path(sys.argv[2]) if not media_dir.is_dir(): print(f"Media directory not found: {media_dir}") sys.exit(1) output_dir.mkdir(parents=True, exist_ok=True) output_png = output_dir / 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) 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")) 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()