movmaker-webui/bin/gpsmap.py
2026-05-31 01:12:16 +02:00

590 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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]} <media_directory> <output_directory>")
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_arg = output_dir
if output_arg.suffix == ".png":
output_png = output_arg
output_png.parent.mkdir(parents=True, exist_ok=True)
else:
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()