Improve GPS reverse geocoding

This commit is contained in:
hbrain 2026-05-25 09:14:54 +00:00
parent bc935d2e0a
commit e35201496f
2 changed files with 181 additions and 16 deletions

View file

@ -23,7 +23,7 @@ sudo apt install ffmpeg
The default title/stamp styling uses these fonts: The default title/stamp styling uses these fonts:
- Bebas Neue for the opening title, date, and location - Bebas Neue for the opening title, date, and location
- Inter for the bottom-left/bottom-right stamps and per-file captions - Noto Sans SemiBold for the bottom-left/bottom-right stamps and per-file captions
Both fonts support common Danish and Croatian characters such as `æ ø å Æ Ø Å č ć ž š đ Č Ć Ž Š Đ`. Both fonts support common Danish and Croatian characters such as `æ ø å Æ Ø Å č ć ž š đ Č Ć Ž Š Đ`.
@ -33,8 +33,8 @@ Install them locally for the current user:
mkdir -p ~/.local/share/fonts/google mkdir -p ~/.local/share/fonts/google
curl -L -o ~/.local/share/fonts/google/BebasNeue-Regular.ttf \ curl -L -o ~/.local/share/fonts/google/BebasNeue-Regular.ttf \
'https://github.com/google/fonts/raw/main/ofl/bebasneue/BebasNeue-Regular.ttf' 'https://github.com/google/fonts/raw/main/ofl/bebasneue/BebasNeue-Regular.ttf'
curl -L -o ~/.local/share/fonts/google/Inter%5Bopsz,wght%5D.ttf \ curl -L -o ~/.local/share/fonts/google/NotoSans-SemiBold.ttf \
'https://github.com/google/fonts/raw/main/ofl/inter/Inter%5Bopsz,wght%5D.ttf' 'https://github.com/google/fonts/raw/main/ofl/notosans/NotoSans%5Bwdth,wght%5D.ttf'
fc-cache -f ~/.local/share/fonts/google fc-cache -f ~/.local/share/fonts/google
``` ```
@ -42,7 +42,7 @@ Check that fonts are available:
```bash ```bash
fc-match 'Bebas Neue' fc-match 'Bebas Neue'
fc-match 'Inter' fc-match 'Noto Sans'
``` ```
## Input folder ## Input folder
@ -98,6 +98,8 @@ If the date line is omitted, movmaker uses the date from the first picture/video
May 14th 2026 May 14th 2026
``` ```
If the location line is omitted and GPS coordinates are found in the first picture/video that has them, movmaker reverse geocodes them and uses that as the location. It tries to return only `city/town/village, country`; if no city/town/village is found, it uses just the country.
Key/value format also works: Key/value format also works:
```text ```text
@ -154,7 +156,7 @@ python3 movmaker.py input --preview
- fades music in over 2 seconds and out over 10 seconds by default - fades music in over 2 seconds and out over 10 seconds by default
- fades video in/out over 1.5 seconds by default - fades video in/out over 1.5 seconds by default
- overlays the opening title for the first 6 seconds: large title, then date/location on one line - overlays the opening title for the first 6 seconds: large title, then date/location on one line
- displays optional per-file captions at bottom center; use `|` to split caption lines - displays optional per-file captions at bottom center with a shadow; use `|` to split caption lines
- adds persistent bottom-left title/date/place and bottom-right `@bubulescu` stamps - adds persistent bottom-left title/date/place and bottom-right `@bubulescu` stamps
- preserves special characters in rendered overlays and metadata - preserves special characters in rendered overlays and metadata
- uses safe ASCII only for generated filenames, so special letters become readable equivalents like `Č` -> `C`, `å` -> `a`, `ø` -> `o` - uses safe ASCII only for generated filenames, so special letters become readable equivalents like `Č` -> `C`, `å` -> `a`, `ø` -> `o`

View file

@ -20,6 +20,8 @@ import subprocess
import sys import sys
import tempfile import tempfile
import unicodedata import unicodedata
import urllib.parse
import urllib.request
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
@ -117,12 +119,10 @@ def ffprobe_creation_date(path: Path) -> datetime | None:
return None return None
def exif_image_date(path: Path) -> datetime | None: def image_tiff_data(path: Path) -> bytes | None:
"""Read common JPEG/TIFF EXIF date tags using only the stdlib."""
data = path.read_bytes()[:1024 * 1024] data = path.read_bytes()[:1024 * 1024]
if data.startswith(b"\xff\xd8"): if data.startswith(b"\xff\xd8"):
pos = 2 pos = 2
tiff: bytes | None = None
while pos + 4 <= len(data): while pos + 4 <= len(data):
if data[pos] != 0xFF: if data[pos] != 0xFF:
break break
@ -136,12 +136,16 @@ def exif_image_date(path: Path) -> datetime | None:
segment = data[pos + 2:pos + size] segment = data[pos + 2:pos + size]
pos += size pos += size
if marker == 0xE1 and segment.startswith(b"Exif\x00\x00"): if marker == 0xE1 and segment.startswith(b"Exif\x00\x00"):
tiff = segment[6:] return segment[6:]
break return None
if tiff is None: return data
return None
else:
tiff = data def exif_image_date(path: Path) -> datetime | None:
"""Read common JPEG/TIFF EXIF date tags using only the stdlib."""
tiff = image_tiff_data(path)
if tiff is None:
return None
if tiff[:2] == b"II": if tiff[:2] == b"II":
endian = "little" endian = "little"
@ -193,6 +197,163 @@ def exif_image_date(path: Path) -> datetime | None:
return None return None
def exif_image_gps(path: Path) -> tuple[float, float] | None:
tiff = image_tiff_data(path)
if tiff is None:
return None
if tiff[:2] == b"II":
endian = "little"
elif tiff[:2] == b"MM":
endian = "big"
else:
return None
def u16(off: int) -> int:
return int.from_bytes(tiff[off:off + 2], endian)
def u32(off: int) -> int:
return int.from_bytes(tiff[off:off + 4], endian)
def rational(off: int) -> float:
num = u32(off)
den = u32(off + 4)
return num / den if den else 0.0
def ascii_value(value: int, count: int, entry_off: int) -> str:
raw = tiff[entry_off + 8:entry_off + 8 + count] if count <= 4 else tiff[value:value + count]
return raw.split(b"\x00", 1)[0].decode("ascii", "ignore").strip()
def rationals(value: int, count: int) -> list[float]:
return [rational(value + i * 8) for i in range(count)]
try:
if u16(2) != 42:
return None
ifd0 = u32(4)
entries = u16(ifd0)
gps_ifd = None
for i in range(entries):
off = ifd0 + 2 + i * 12
if off + 12 > len(tiff):
break
if u16(off) == 0x8825:
gps_ifd = u32(off + 8)
break
if gps_ifd is None or gps_ifd + 2 > len(tiff):
return None
gps_entries = u16(gps_ifd)
lat_ref = lon_ref = ""
lat = lon = None
for i in range(gps_entries):
off = gps_ifd + 2 + i * 12
if off + 12 > len(tiff):
break
tag = u16(off)
typ = u16(off + 2)
count = u32(off + 4)
value = u32(off + 8)
if tag == 1 and typ == 2:
lat_ref = ascii_value(value, count, off)
elif tag == 2 and typ == 5:
lat = rationals(value, count)
elif tag == 3 and typ == 2:
lon_ref = ascii_value(value, count, off)
elif tag == 4 and typ == 5:
lon = rationals(value, count)
if not lat or not lon or len(lat) < 3 or len(lon) < 3:
return None
lat_dec = lat[0] + lat[1] / 60 + lat[2] / 3600
lon_dec = lon[0] + lon[1] / 60 + lon[2] / 3600
if lat_ref.upper() == "S":
lat_dec = -lat_dec
if lon_ref.upper() == "W":
lon_dec = -lon_dec
return lat_dec, lon_dec
except Exception:
return None
def ffprobe_gps(path: Path) -> tuple[float, float] | None:
try:
out = capture([
tool_path(FFPROBE, "ffprobe"), "-v", "error",
"-show_format", "-show_streams",
"-of", "json",
str(path),
])
data = json.loads(out)
except Exception:
return None
values: list[str] = []
def collect(obj: object) -> None:
if isinstance(obj, dict):
tags = obj.get("tags")
if isinstance(tags, dict):
for key, value in tags.items():
if "location" in key.lower() or "gps" in key.lower():
values.append(str(value))
for value in obj.values():
collect(value)
elif isinstance(obj, list):
for value in obj:
collect(value)
collect(data)
for value in values:
match = re.search(r"([+-]\d+(?:\.\d+)?)([+-]\d+(?:\.\d+)?)", value)
if match:
return float(match.group(1)), float(match.group(2))
return None
def media_gps(path: Path) -> tuple[float, float] | None:
if path.suffix.lower() in IMAGE_EXTS:
gps = exif_image_gps(path)
if gps:
return gps
return ffprobe_gps(path)
def reverse_geocode_city_country(lat: float, lon: float) -> str | None:
last_country = ""
for zoom in range(18, 2, -1):
query = urllib.parse.urlencode({
"format": "jsonv2",
"lat": f"{lat:.8f}",
"lon": f"{lon:.8f}",
"zoom": str(zoom),
"addressdetails": "1",
})
request = urllib.request.Request(
f"https://nominatim.openstreetmap.org/reverse?{query}",
headers={"User-Agent": "movmaker/1.0 (reverse geocoding for local movie metadata)"},
)
try:
with urllib.request.urlopen(request, timeout=10) as response:
data = json.loads(response.read().decode("utf-8"))
address = data.get("address", {})
country = address.get("country", "")
last_country = country or last_country
city = next((address.get(key) for key in ("city", "town", "village") if address.get(key)), "")
if city:
return ", ".join(x for x in [city, country] if x)
except Exception:
continue
return last_country or None
def infer_place_from_gps(media: list[MediaItem]) -> str | None:
for item in media:
gps = media_gps(item.path)
if not gps:
continue
place = reverse_geocode_city_country(*gps)
if place:
print(f"Location from GPS: {place} ({gps[0]:.6f}, {gps[1]:.6f})")
return place
return None
def media_date(path: Path) -> datetime: def media_date(path: Path) -> datetime:
ext = path.suffix.lower() ext = path.suffix.lower()
dt = None dt = None
@ -441,8 +602,8 @@ def build_video(
final_label = "vout" final_label = "vout"
title_label = "titled" title_label = "titled"
title_font = str(Path.home() / ".local/share/fonts/google/BebasNeue-Regular.ttf") title_font = str(Path.home() / ".local/share/fonts/google/BebasNeue-Regular.ttf")
stamp_font = str(Path.home() / ".local/share/fonts/google/Inter%5Bopsz,wght%5D.ttf") stamp_font = str(Path.home() / ".local/share/fonts/google/NotoSans-SemiBold.ttf")
stamp_bold_font = str(Path.home() / ".local/share/fonts/google/Inter%5Bopsz,wght%5D.ttf") stamp_bold_font = str(Path.home() / ".local/share/fonts/google/NotoSans-SemiBold.ttf")
if title_lines: if title_lines:
title_font_size = max(60, height // 10) title_font_size = max(60, height // 10)
subtitle_font_size = max(40, height // 18) subtitle_font_size = max(40, height // 18)
@ -600,6 +761,8 @@ def main(argv: list[str] | None = None) -> int:
first_media_date = media_date(media[0].path) first_media_date = media_date(media[0].path)
if not data.date: if not data.date:
data.date = display_date(first_media_date) data.date = display_date(first_media_date)
if not data.place:
data.place = infer_place_from_gps(media) or ""
fade = min(args.fade, max(0.0, args.image_duration - 0.1)) fade = min(args.fade, max(0.0, args.image_duration - 0.1))
audio_fade = max(0.0, args.audio_fade) audio_fade = max(0.0, args.audio_fade)
audio_fade_in = max(0.0, args.audio_fade_in) audio_fade_in = max(0.0, args.audio_fade_in)