Fix gpsmap.py to correctly use output file path
This commit is contained in:
parent
68301c6d23
commit
819a53a159
1 changed files with 82 additions and 31 deletions
113
bin/gpsmap.py
113
bin/gpsmap.py
|
|
@ -15,7 +15,7 @@ from urllib.request import Request, urlopen
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
|
|
||||||
VERSION = "2026-05-31-infographic-v4-date-stats-centered-counts"
|
VERSION = "2026-05-31-infographic-v5-single-location-footer-fix"
|
||||||
|
|
||||||
MAP_WIDTH = 906
|
MAP_WIDTH = 906
|
||||||
MAP_HEIGHT = 510
|
MAP_HEIGHT = 510
|
||||||
|
|
@ -23,6 +23,8 @@ TILE_SIZE = 256
|
||||||
MIN_ZOOM = 3
|
MIN_ZOOM = 3
|
||||||
MAX_ZOOM = 13
|
MAX_ZOOM = 13
|
||||||
CLUSTER_RADIUS_KM = 2.0
|
CLUSTER_RADIUS_KM = 2.0
|
||||||
|
FOOTER_HEIGHT = 118
|
||||||
|
FOOTER_MARGIN = 14
|
||||||
USER_AGENT = "gpsmap-infographic-script/1.0"
|
USER_AGENT = "gpsmap-infographic-script/1.0"
|
||||||
|
|
||||||
PHOTO_EXTENSIONS = {
|
PHOTO_EXTENSIONS = {
|
||||||
|
|
@ -244,6 +246,9 @@ def reverse_geocode_parts(lat, lon):
|
||||||
address.get("city")
|
address.get("city")
|
||||||
or address.get("town")
|
or address.get("town")
|
||||||
or address.get("village")
|
or address.get("village")
|
||||||
|
or address.get("island")
|
||||||
|
or address.get("suburb")
|
||||||
|
or address.get("hamlet")
|
||||||
or address.get("municipality")
|
or address.get("municipality")
|
||||||
or ""
|
or ""
|
||||||
)
|
)
|
||||||
|
|
@ -261,10 +266,41 @@ def reverse_geocode_parts(lat, lon):
|
||||||
return {"city": "", "region": "", "country": ""}
|
return {"city": "", "region": "", "country": ""}
|
||||||
|
|
||||||
|
|
||||||
def make_map_label(items):
|
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]
|
lats = [i.lat for i in items]
|
||||||
lons = [i.lon 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 = [
|
sample_points = [
|
||||||
(min(lats), min(lons)),
|
(min(lats), min(lons)),
|
||||||
(max(lats), max(lons)),
|
(max(lats), max(lons)),
|
||||||
|
|
@ -275,12 +311,16 @@ def make_map_label(items):
|
||||||
|
|
||||||
for lat, lon in sample_points:
|
for lat, lon in sample_points:
|
||||||
info = reverse_geocode_parts(lat, lon)
|
info = reverse_geocode_parts(lat, lon)
|
||||||
if info["city"]:
|
city = clean_place_name(info.get("city", ""))
|
||||||
cities.add(info["city"])
|
region = clean_place_name(info.get("region", ""))
|
||||||
if info["region"]:
|
country = clean_place_name(info.get("country", ""))
|
||||||
regions.add(info["region"])
|
|
||||||
if info["country"]:
|
if city:
|
||||||
countries.add(info["country"])
|
cities.add(city)
|
||||||
|
if region:
|
||||||
|
regions.add(region)
|
||||||
|
if country:
|
||||||
|
countries.add(country)
|
||||||
|
|
||||||
if len(countries) > 1:
|
if len(countries) > 1:
|
||||||
return " • ".join(sorted(countries))
|
return " • ".join(sorted(countries))
|
||||||
|
|
@ -303,7 +343,6 @@ def make_map_label(items):
|
||||||
return f"{region} {country}"
|
return f"{region} {country}"
|
||||||
return country
|
return country
|
||||||
|
|
||||||
|
|
||||||
def format_date_range(items):
|
def format_date_range(items):
|
||||||
dates = [i.taken_at for i in items if i.taken_at]
|
dates = [i.taken_at for i in items if i.taken_at]
|
||||||
if not dates:
|
if not dates:
|
||||||
|
|
@ -330,10 +369,9 @@ def latlon_to_pixel(lat, lon, zoom):
|
||||||
|
|
||||||
|
|
||||||
def choose_zoom(points, width, height):
|
def choose_zoom(points, width, height):
|
||||||
# Leave room for bottom title/stat text.
|
pad_x = 70
|
||||||
pad_x = 80
|
pad_top = 45
|
||||||
pad_top = 55
|
pad_bottom = FOOTER_HEIGHT + FOOTER_MARGIN
|
||||||
pad_bottom = 125
|
|
||||||
usable_width = width - 2 * pad_x
|
usable_width = width - 2 * pad_x
|
||||||
usable_height = height - pad_top - pad_bottom
|
usable_height = height - pad_top - pad_bottom
|
||||||
|
|
||||||
|
|
@ -350,22 +388,25 @@ def choose_zoom(points, width, height):
|
||||||
|
|
||||||
return MIN_ZOOM
|
return MIN_ZOOM
|
||||||
|
|
||||||
|
|
||||||
def map_view(points, zoom):
|
def map_view(points, zoom):
|
||||||
pixels = [latlon_to_pixel(lat, lon, zoom) for lat, lon in points]
|
pixels = [latlon_to_pixel(lat, lon, zoom) for lat, lon in points]
|
||||||
xs = [p[0] for p in pixels]
|
xs = [p[0] for p in pixels]
|
||||||
ys = [p[1] 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_x = (min(xs) + max(xs)) / 2
|
||||||
center_y = (min(ys) + max(ys)) / 2 + 25
|
center_y = (min(ys) + max(ys)) / 2
|
||||||
|
|
||||||
top_left_x = center_x - MAP_WIDTH / 2
|
# Keep the route/markers in the upper map area and reserve the lower footer
|
||||||
top_left_y = center_y - MAP_HEIGHT / 2
|
# 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
|
return top_left_x, top_left_y
|
||||||
|
|
||||||
|
|
||||||
def download_tile(x, y, z):
|
def download_tile(x, y, z):
|
||||||
url = f"https://tile.openstreetmap.org/{z}/{x}/{y}.png"
|
url = f"https://tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||||
request = Request(url, headers={"User-Agent": USER_AGENT})
|
request = Request(url, headers={"User-Agent": USER_AGENT})
|
||||||
|
|
@ -491,10 +532,21 @@ def draw_centered_text(draw, y, text, font, fill="black"):
|
||||||
draw.text(((MAP_WIDTH - w) / 2, y), text, font=font, fill=fill)
|
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=""):
|
def draw_bottom_title(draw, label, stats, date_text=""):
|
||||||
title_font = load_font(FONT_BOLD, 38)
|
max_text_width = MAP_WIDTH - 28
|
||||||
stats_font = load_font(FONT_REGULAR, 30)
|
|
||||||
date_font = load_font(FONT_REGULAR, 22)
|
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)
|
title_bbox = draw.textbbox((0, 0), label, font=title_font)
|
||||||
stats_bbox = draw.textbbox((0, 0), stats, font=stats_font)
|
stats_bbox = draw.textbbox((0, 0), stats, font=stats_font)
|
||||||
|
|
@ -504,8 +556,8 @@ def draw_bottom_title(draw, label, stats, date_text=""):
|
||||||
stats_h = stats_bbox[3] - stats_bbox[1]
|
stats_h = stats_bbox[3] - stats_bbox[1]
|
||||||
date_h = date_bbox[3] - date_bbox[1] if date_text else 0
|
date_h = date_bbox[3] - date_bbox[1] if date_text else 0
|
||||||
|
|
||||||
gap = 4
|
gap = 3
|
||||||
bottom_margin = 20
|
bottom_margin = 14
|
||||||
|
|
||||||
y_title = MAP_HEIGHT - title_h - bottom_margin
|
y_title = MAP_HEIGHT - title_h - bottom_margin
|
||||||
y_stats = y_title - stats_h - gap
|
y_stats = y_title - stats_h - gap
|
||||||
|
|
@ -516,7 +568,6 @@ def draw_bottom_title(draw, label, stats, date_text=""):
|
||||||
draw_centered_text(draw, y_stats, stats, stats_font, fill=(30, 30, 30))
|
draw_centered_text(draw, y_stats, stats, stats_font, fill=(30, 30, 30))
|
||||||
draw_centered_text(draw, y_title, label, title_font, fill="black")
|
draw_centered_text(draw, y_title, label, title_font, fill="black")
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
print(f"gpsmap version: {VERSION}")
|
print(f"gpsmap version: {VERSION}")
|
||||||
|
|
||||||
|
|
@ -525,19 +576,18 @@ def main():
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
media_dir = Path(sys.argv[1])
|
media_dir = Path(sys.argv[1])
|
||||||
output_dir = Path(sys.argv[2])
|
output_arg = Path(sys.argv[2])
|
||||||
|
|
||||||
if not media_dir.is_dir():
|
if not media_dir.is_dir():
|
||||||
print(f"Media directory not found: {media_dir}")
|
print(f"Media directory not found: {media_dir}")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
output_arg = output_dir
|
|
||||||
if output_arg.suffix == ".png":
|
if output_arg.suffix == ".png":
|
||||||
output_png = output_arg
|
output_png = output_arg
|
||||||
output_png.parent.mkdir(parents=True, exist_ok=True)
|
output_png.parent.mkdir(parents=True, exist_ok=True)
|
||||||
else:
|
else:
|
||||||
output_dir.mkdir(parents=True, exist_ok=True)
|
output_arg.mkdir(parents=True, exist_ok=True)
|
||||||
output_png = output_dir / f"{media_dir.resolve().name}.png"
|
output_png = output_arg / f"{media_dir.resolve().name}.png"
|
||||||
|
|
||||||
items = collect_media(media_dir)
|
items = collect_media(media_dir)
|
||||||
if not items:
|
if not items:
|
||||||
|
|
@ -550,14 +600,15 @@ def main():
|
||||||
zoom = choose_zoom(points, MAP_WIDTH, MAP_HEIGHT)
|
zoom = choose_zoom(points, MAP_WIDTH, MAP_HEIGHT)
|
||||||
top_left_x, top_left_y = map_view(points, zoom)
|
top_left_x, top_left_y = map_view(points, zoom)
|
||||||
|
|
||||||
label = make_map_label(items)
|
label = make_map_label(items, clusters)
|
||||||
photo_count = sum(1 for i in items if i.kind == "photo")
|
photo_count = sum(1 for i in items if i.kind == "photo")
|
||||||
video_count = sum(1 for i in items if i.kind == "video")
|
video_count = sum(1 for i in items if i.kind == "video")
|
||||||
distance = round(route_distance_km(clusters))
|
distance = round(route_distance_km(clusters))
|
||||||
stats_parts = [plural(photo_count, "photo")]
|
stats_parts = [plural(photo_count, "photo")]
|
||||||
if video_count:
|
if video_count:
|
||||||
stats_parts.append(plural(video_count, "video"))
|
stats_parts.append(plural(video_count, "video"))
|
||||||
stats_parts.append(f"{distance} km route")
|
if len(clusters) > 1 and distance > 0:
|
||||||
|
stats_parts.append(f"{distance} km route")
|
||||||
stats = " • ".join(stats_parts)
|
stats = " • ".join(stats_parts)
|
||||||
date_text = format_date_range(items)
|
date_text = format_date_range(items)
|
||||||
print(f"Map label: {label}")
|
print(f"Map label: {label}")
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue