movmaker-webui/bin/gpsmap.py

641 lines
18 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-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_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_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()