fix for tuple

This commit is contained in:
Oskar Kapala 2025-09-02 10:48:23 +02:00
parent 5f371a1df7
commit 291f3113c4

View file

@ -1,11 +1,24 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""
exif2gpx.py generuje plik GPX z danych GPS w EXIF zdjęć (JPG/JPEG).
Użycie:
pip install Pillow
python exif2gpx.py /sciezka/do/folderu_lub_pliku output.gpx --tz +02:00
Opcje:
--tz Strefa czasowa, w której interpretować czasy EXIF (gdy EXIF nie ma TZ),
format +HH:MM lub -HH:MM (np. +02:00). Jeśli podasz, czasy zostaną zapisane
w GPX w UTC (z literą 'Z'). Jeśli nie podasz, <time> będzie pominięty.
"""
import argparse import argparse
import os import os
import sys
from pathlib import Path from pathlib import Path
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
from typing import Optional, Tuple, List from typing import Optional, Tuple, List
from numbers import Real
from PIL import Image, ExifTags from PIL import Image, ExifTags
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
@ -15,24 +28,60 @@ TAGS = {v: k for k, v in ExifTags.TAGS.items()}
GPSTAGS = {v: k for k, v in ExifTags.GPSTAGS.items()} GPSTAGS = {v: k for k, v in ExifTags.GPSTAGS.items()}
# Akceptowane rozszerzenia zdjęć # Akceptowane rozszerzenia zdjęć
IMAGE_EXTS = {".jpg", ".jpeg", ".JPG", ".JPEG"} IMAGE_EXTS = {".jpg", ".jpeg"}
def dms_to_decimal(dms, ref) -> Optional[float]: def rational_to_float(x) -> float:
""" """
Konwersja DMS (na ułamki) -> stopnie dziesiętne. Zamienia wartość EXIF na float.
dms: np. ((52,1), (13,1), (1234,100)) => 52° 13' 12.34" Obsługuje: liczby (int/float), IFDRational (numerator/denominator),
oraz krotki/listy (num, den).
"""
if x is None:
raise ValueError("Brak wartości rational")
# 1) już liczba
if isinstance(x, Real):
return float(x)
# 2) IFDRational (Pillow) albo coś z numerator/denominator
if hasattr(x, "numerator") and hasattr(x, "denominator"):
den = x.denominator
if den == 0:
raise ValueError("Mianownik równy zero w rational")
return float(x.numerator) / float(den)
# 3) krotka/lista (num, den)
if isinstance(x, (tuple, list)) and len(x) == 2:
num, den = x
den = float(den)
if den == 0:
raise ValueError("Mianownik równy zero w rational")
return float(num) / den
raise TypeError(f"Nieobsługiwany typ rational: {type(x)}")
def dms_to_decimal(dms, ref: str) -> float:
"""
Konwersja DMS -> stopnie dziesiętne.
dms: (deg, min, sec) każdy element może być float/int/IFDRational/(num, den)
ref: 'N'/'S' lub 'E'/'W' ref: 'N'/'S' lub 'E'/'W'
""" """
try: if not isinstance(dms, (tuple, list)) or len(dms) != 3:
deg = dms[0][0] / dms[0][1] raise ValueError(f"DMS musi być 3-elementową krotką/listą, dostałem: {dms!r}")
minutes = dms[1][0] / dms[1][1]
seconds = dms[2][0] / dms[2][1] deg = rational_to_float(dms[0])
dec = deg + minutes / 60.0 + seconds / 3600.0 minutes = rational_to_float(dms[1])
if ref in ("S", "W"): seconds = rational_to_float(dms[2])
dec = -dec
return dec if not isinstance(ref, str) or ref.upper() not in ("N", "S", "E", "W"):
except Exception: raise ValueError(f"Nieprawidłowy ref: {ref!r} (oczekiwano 'N'/'S'/'E'/'W')")
return None
dec = abs(float(deg)) + float(minutes) / 60.0 + float(seconds) / 3600.0
if ref.upper() in ("S", "W"):
dec = -dec
return dec
def parse_exif_datetime(raw_dt: Optional[str], subsec: Optional[str]) -> Optional[datetime]: def parse_exif_datetime(raw_dt: Optional[str], subsec: Optional[str]) -> Optional[datetime]:
""" """
@ -43,41 +92,49 @@ def parse_exif_datetime(raw_dt: Optional[str], subsec: Optional[str]) -> Optiona
return None return None
try: try:
base = datetime.strptime(raw_dt, "%Y:%m:%d %H:%M:%S") base = datetime.strptime(raw_dt, "%Y:%m:%d %H:%M:%S")
if subsec and subsec.isdigit(): # Subsekundy bywają różne: np. "123", "12", czasem z innymi znakami
# Subsec może mieć różną długość skracamy/padding do mikrosekund if subsec:
micro = int((subsec + "000000")[:6]) only_digits = "".join(ch for ch in str(subsec) if ch.isdigit())
base = base.replace(microsecond=micro) if only_digits:
micro = int((only_digits + "000000")[:6])
base = base.replace(microsecond=micro)
return base return base
except Exception: except Exception as e:
return None raise ValueError(f"Nie można sparsować daty EXIF '{raw_dt}' ({subsec=}): {e}")
def apply_tz_and_to_utc(dt: datetime, tz_offset: Optional[str]) -> Optional[datetime]: def apply_tz_and_to_utc(dt: datetime, tz_offset: Optional[str]) -> Optional[datetime]:
""" """
Jeśli podano --tz w formacie +HH:MM lub -HH:MM, interpretuj naive dt w tej strefie, Jeśli podano --tz w formacie +HH:MM lub -HH:MM, interpretuj naive dt w tej strefie,
a następnie zwróć dt w UTC. a następnie zwróć dt w UTC.
Jeśli --tz nie podano, zwróć None (żeby pominąć <time> albo zapisać bez gwarancji poprawnej TZ). Jeśli --tz nie podano, zwróć None (żeby pominąć <time>).
""" """
if tz_offset is None: if tz_offset is None:
return None return None
try: try:
if not (len(tz_offset) == 6 and tz_offset[0] in "+-" and tz_offset[3] == ":"):
raise ValueError("Błędny format strefy (oczekiwana postać +HH:MM lub -HH:MM)")
sign = 1 if tz_offset.startswith("+") else -1 sign = 1 if tz_offset.startswith("+") else -1
hh, mm = tz_offset[1:].split(":") hh = int(tz_offset[1:3])
offset = timedelta(hours=int(hh), minutes=int(mm)) mm = int(tz_offset[4:6])
offset = timedelta(hours=hh, minutes=mm)
tzinfo = timezone(sign * offset) tzinfo = timezone(sign * offset)
aware = dt.replace(tzinfo=tzinfo) aware = dt.replace(tzinfo=tzinfo)
return aware.astimezone(timezone.utc) return aware.astimezone(timezone.utc)
except Exception: except Exception as e:
return None raise ValueError(f"Nieprawidłowa strefa czasowa '{tz_offset}': {e}")
def extract_exif_gps_and_time(img_path: Path) -> Tuple[Optional[float], Optional[float], Optional[float], Optional[datetime], Optional[datetime]]:
def extract_exif_gps_and_time(img_path: Path) -> Tuple[Optional[float], Optional[float], Optional[float], Optional[datetime]]:
""" """
Zwraca: (lat, lon, ele, naive_time, utc_time_if_available) Zwraca: (lat, lon, ele, naive_time)
Rzuca wyjątek, jeśli plik nie jest czytelny lub ma wadliwe EXIF.
""" """
try: try:
with Image.open(img_path) as im: with Image.open(img_path) as im:
exif = im._getexif() or {} exif = im._getexif() or {}
except Exception: except Exception as e:
return None, None, None, None, None raise ValueError(f"Nie można otworzyć lub odczytać EXIF: {e}")
# Zamiana kluczy numerycznych na nazwy # Zamiana kluczy numerycznych na nazwy
exif_readable = {} exif_readable = {}
@ -88,30 +145,28 @@ def extract_exif_gps_and_time(img_path: Path) -> Tuple[Optional[float], Optional
gps_info = exif_readable.get("GPSInfo") gps_info = exif_readable.get("GPSInfo")
lat = lon = ele = None lat = lon = ele = None
if isinstance(gps_info, dict): if isinstance(gps_info, dict):
gps = {} gps = {ExifTags.GPSTAGS.get(k, k): v for k, v in gps_info.items()}
for k, v in gps_info.items():
gps_name = ExifTags.GPSTAGS.get(k, k)
gps[gps_name] = v
lat = dms_to_decimal(gps.get("GPSLatitude"), gps.get("GPSLatitudeRef", "N"))
lon = dms_to_decimal(gps.get("GPSLongitude"), gps.get("GPSLongitudeRef", "E"))
if "GPSLatitude" in gps and "GPSLatitudeRef" in gps and \
"GPSLongitude" in gps and "GPSLongitudeRef" in gps:
lat = dms_to_decimal(gps["GPSLatitude"], gps["GPSLatitudeRef"])
lon = dms_to_decimal(gps["GPSLongitude"], gps["GPSLongitudeRef"])
# Wysokość (opcjonalna) # Wysokość (opcjonalna)
if gps.get("GPSAltitude") and isinstance(gps["GPSAltitude"], tuple): if "GPSAltitude" in gps:
try: try:
ele = gps["GPSAltitude"][0] / gps["GPSAltitude"][1] ele = rational_to_float(gps["GPSAltitude"])
if gps.get("GPSAltitudeRef") == 1: if gps.get("GPSAltitudeRef") == 1:
ele = -ele ele = -ele
except Exception: except Exception as e:
ele = None raise ValueError(f"Nie można zinterpretować wysokości: {e}")
# Czas # Czas
raw_dt = exif_readable.get("DateTimeOriginal") or exif_readable.get("DateTime") or exif_readable.get("DateTimeDigitized") raw_dt = exif_readable.get("DateTimeOriginal") or exif_readable.get("DateTime") or exif_readable.get("DateTimeDigitized")
# Subsekundy bywają w różnych tagach: subsec = exif_readable.get("SubsecTimeOriginal") or exif_readable.get("SubSecTimeOriginal") or exif_readable.get("SubsecTime")
subsec = exif_readable.get("SubsecTimeOriginal") or exif_readable.get("SubsecTime") or exif_readable.get("SubSecTimeOriginal") naive_dt = parse_exif_datetime(raw_dt, subsec) if raw_dt else None
return lat, lon, ele, naive_dt
naive_dt = parse_exif_datetime(raw_dt, subsec)
return lat, lon, ele, naive_dt, None # utc_time wypełnimy później wg --tz
def collect_images(input_path: Path) -> List[Path]: def collect_images(input_path: Path) -> List[Path]:
files = [] files = []
@ -119,13 +174,14 @@ def collect_images(input_path: Path) -> List[Path]:
for root, _, fns in os.walk(input_path): for root, _, fns in os.walk(input_path):
for fn in fns: for fn in fns:
p = Path(root) / fn p = Path(root) / fn
if p.suffix in IMAGE_EXTS: if p.suffix.lower() in IMAGE_EXTS:
files.append(p) files.append(p)
else: else:
if input_path.suffix in IMAGE_EXTS: if input_path.suffix.lower() in IMAGE_EXTS:
files.append(input_path) files.append(input_path)
return files return files
def generate_gpx(points: List[dict], creator: str = "exif2gpx") -> ET.ElementTree: def generate_gpx(points: List[dict], creator: str = "exif2gpx") -> ET.ElementTree:
ET.register_namespace("", "http://www.topografix.com/GPX/1/1") ET.register_namespace("", "http://www.topografix.com/GPX/1/1")
ET.register_namespace("xsi", "http://www.w3.org/2001/XMLSchema-instance") ET.register_namespace("xsi", "http://www.w3.org/2001/XMLSchema-instance")
@ -152,13 +208,15 @@ def generate_gpx(points: List[dict], creator: str = "exif2gpx") -> ET.ElementTre
if p.get("time"): if p.get("time"):
# GPX wymaga ISO 8601 w UTC z 'Z' # GPX wymaga ISO 8601 w UTC z 'Z'
t = ET.SubElement(trkpt, "time") t = ET.SubElement(trkpt, "time")
t.text = p["time"].strftime("%Y-%m-%dT%H:%M:%S.%fZ").rstrip("0").rstrip(".") + "Z" iso = p["time"].strftime("%Y-%m-%dT%H:%M:%S.%f")
# Dodatkowo można dodać nazwę pliku jako <desc> iso = iso.rstrip("0").rstrip(".") + "Z"
t.text = iso
desc = ET.SubElement(trkpt, "desc") desc = ET.SubElement(trkpt, "desc")
desc.text = p.get("source", "") desc.text = p.get("source", "")
return ET.ElementTree(gpx) return ET.ElementTree(gpx)
def main(): def main():
ap = argparse.ArgumentParser(description="Z EXIF zdjęć (JPG) generuje GPX z trackiem.") ap = argparse.ArgumentParser(description="Z EXIF zdjęć (JPG) generuje GPX z trackiem.")
ap.add_argument("input", help="Plik JPG lub folder ze zdjęciami") ap.add_argument("input", help="Plik JPG lub folder ze zdjęciami")
@ -171,19 +229,28 @@ def main():
files = collect_images(src) files = collect_images(src)
if not files: if not files:
print("Brak zdjęć JPG/JPEG w podanej lokalizacji.") print("Brak zdjęć JPG/JPEG w podanej lokalizacji.", file=sys.stderr)
return return
points = [] points = []
for fp in files: for fp in files:
lat, lon, ele, naive_dt, _ = extract_exif_gps_and_time(fp) try:
lat, lon, ele, naive_dt = extract_exif_gps_and_time(fp)
except Exception as e:
print(f"Pomijam {fp}: {e}", file=sys.stderr)
continue
if lat is None or lon is None: if lat is None or lon is None:
print(f"Pomijam {fp}: brak prawidłowych współrzędnych GPS", file=sys.stderr)
continue continue
# Wyznacz czas w UTC jeśli podano --tz # Wyznacz czas w UTC jeśli podano --tz
time_utc = None time_utc = None
if naive_dt is not None and args.tz: if naive_dt is not None and args.tz:
time_utc = apply_tz_and_to_utc(naive_dt, args.tz) try:
time_utc = apply_tz_and_to_utc(naive_dt, args.tz)
except Exception as e:
print(f"Ostrzeżenie: nieprawidłowa strefa czasowa dla {fp.name}: {e}", file=sys.stderr)
points.append({ points.append({
"lat": lat, "lat": lat,
@ -195,17 +262,14 @@ def main():
}) })
if not points: if not points:
print("Nie znaleziono zdjęć z danymi GPS.") print("Nie znaleziono zdjęć z danymi GPS.", file=sys.stderr)
return return
# Sortowanie: najpierw po czasie jeśli dostępny; potem po nazwie pliku # Sortowanie: po czasie jeśli dostępny; potem po nazwie pliku
def sort_key(p): def sort_key(p):
# preferuj p['time'] (UTC) gdy jest; w przeciwnym razie naive_time; w ostateczności nazwa # preferuj p['time'] (UTC) gdy jest; w przeciwnym razie naive_time; w ostateczności nazwa
return ( primary = p["time"] if p["time"] is not None else p["naive_time"]
p["time"] if p["time"] is not None else return (primary or datetime.max, p["source"])
p["naive_time"] if p["naive_time"] is not None else
datetime.max
, p["source"])
points.sort(key=sort_key) points.sort(key=sort_key)