diff --git a/bin/gpsmap.py b/bin/gpsmap.py new file mode 100644 index 0000000..6565b40 --- /dev/null +++ b/bin/gpsmap.py @@ -0,0 +1,585 @@ +#!/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() diff --git a/index.php b/index.php index 8346d11..99e60de 100644 --- a/index.php +++ b/index.php @@ -17,13 +17,11 @@ function hidden_video_outputs($config){ function video_metadata($path){ $base = pathinfo($path, PATHINFO_FILENAME); $meta = ['title' => nice_title($base), 'date' => date('Y-m-d', filemtime($path)), 'sort_date' => date('Y-m-d', filemtime($path)), 'location' => '', 'description' => '']; - if (preg_match('/^(\d{4})(\d{2})(\d{2})[_-](.+)$/', $base, $m)) { $meta['date'] = "$m[1]-$m[2]-$m[3]"; $meta['sort_date'] = "$m[1]-$m[2]-$m[3]"; $meta['title'] = nice_title($m[4]); } - foreach ([dirname($path).'/'.$base.'.txt', dirname($path).'/'.$base.'.data.txt'] as $sidecar) { if (!is_file($sidecar)) continue; foreach (file($sidecar, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) { @@ -33,7 +31,6 @@ function video_metadata($path){ if (in_array($k, ['title','date','location','place','description'], true)) $meta[$k === 'place' ? 'location' : $k] = $v; } } - $ffprobe = trim((string)shell_exec('command -v ffprobe 2>/dev/null')); if ($ffprobe !== '') { $json = shell_exec(escapeshellarg($ffprobe).' -v quiet -print_format json -show_entries format_tags '.escapeshellarg($path).' 2>/dev/null'); @@ -42,9 +39,7 @@ function video_metadata($path){ $lower = []; foreach ($tags as $k => $v) $lower[strtolower($k)] = $v; if (!empty($lower['title'])) $meta['title'] = $lower['title']; - if (!empty($lower['date'])) { - $meta['date'] = $lower['date']; - } + if (!empty($lower['date'])) $meta['date'] = $lower['date']; if (!empty($lower['creation_time'])) { $meta['sort_date'] = substr($lower['creation_time'], 0, 10); if (empty($lower['date'])) $meta['date'] = $meta['sort_date']; @@ -65,7 +60,6 @@ function load_video_cache($videos){ $items = $cache['items'] ?? []; $newItems = []; $changed = false; - foreach ($videos as $path) { $key = basename($path); $mtime = filemtime($path); @@ -77,7 +71,6 @@ function load_video_cache($videos){ } $newItems[$key] = $cached; } - if (array_diff(array_keys($items), array_keys($newItems))) $changed = true; if ($changed) { file_put_contents($cacheFile, json_encode(['version' => 2, 'generated_at' => date('c'), 'items' => $newItems], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT), LOCK_EX); @@ -85,7 +78,7 @@ function load_video_cache($videos){ return $newItems; } $hiddenOutputs = hidden_video_outputs($config); -$videos = array_values(array_filter(glob($config['videos_dir'].'/*.{mp4,webm,mov,m4v}', GLOB_BRACE) ?: [], fn($v)=>empty($hiddenOutputs[basename($v)]) && !preg_match('/_preview\.(mp4|webm|mov|m4v)$/i', basename($v)) && !preg_match('/_preview\.(mp4|webm|mov|m4v)$/i', basename($v)))); +$videos = array_values(array_filter(glob($config['videos_dir'].'/*.{mp4,webm,mov,m4v}', GLOB_BRACE) ?: [], fn($v)=>empty($hiddenOutputs[basename($v)]) && !preg_match('/_preview\.(mp4|webm|mov|m4v)$/i', basename($v)))); $videoCache = load_video_cache($videos); usort($videos, function($a, $b) use ($videoCache) { $am = $videoCache[basename($a)] ?? []; @@ -100,7 +93,17 @@ $page=max(1,(int)($_GET['page']??1)); $per=$config['items_per_page']; $total=cou

No videos yet.

-

+
+
+ + +" alt="Map"> + +
+

Bubulescu.Org