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
# -*- 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 os
import sys
from pathlib import Path
from datetime import datetime, timezone, timedelta
from typing import Optional, Tuple, List
from numbers import Real
from PIL import Image, ExifTags
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()}
# 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.
dms: np. ((52,1), (13,1), (1234,100)) => 52° 13' 12.34"
Zamienia wartość EXIF na float.
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'
"""
try:
deg = dms[0][0] / dms[0][1]
minutes = dms[1][0] / dms[1][1]
seconds = dms[2][0] / dms[2][1]
dec = deg + minutes / 60.0 + seconds / 3600.0
if ref in ("S", "W"):
if not isinstance(dms, (tuple, list)) or len(dms) != 3:
raise ValueError(f"DMS musi być 3-elementową krotką/listą, dostałem: {dms!r}")
deg = rational_to_float(dms[0])
minutes = rational_to_float(dms[1])
seconds = rational_to_float(dms[2])
if not isinstance(ref, str) or ref.upper() not in ("N", "S", "E", "W"):
raise ValueError(f"Nieprawidłowy ref: {ref!r} (oczekiwano 'N'/'S'/'E'/'W')")
dec = abs(float(deg)) + float(minutes) / 60.0 + float(seconds) / 3600.0
if ref.upper() in ("S", "W"):
dec = -dec
return dec
except Exception:
return None
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
try:
base = datetime.strptime(raw_dt, "%Y:%m:%d %H:%M:%S")
if subsec and subsec.isdigit():
# Subsec może mieć różną długość skracamy/padding do mikrosekund
micro = int((subsec + "000000")[:6])
# Subsekundy bywają różne: np. "123", "12", czasem z innymi znakami
if subsec:
only_digits = "".join(ch for ch in str(subsec) if ch.isdigit())
if only_digits:
micro = int((only_digits + "000000")[:6])
base = base.replace(microsecond=micro)
return base
except Exception:
return None
except Exception as e:
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]:
"""
Jeśli podano --tz w formacie +HH:MM lub -HH:MM, interpretuj naive dt w tej strefie,
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:
return None
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
hh, mm = tz_offset[1:].split(":")
offset = timedelta(hours=int(hh), minutes=int(mm))
hh = int(tz_offset[1:3])
mm = int(tz_offset[4:6])
offset = timedelta(hours=hh, minutes=mm)
tzinfo = timezone(sign * offset)
aware = dt.replace(tzinfo=tzinfo)
return aware.astimezone(timezone.utc)
except Exception:
return None
except Exception as e:
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:
with Image.open(img_path) as im:
exif = im._getexif() or {}
except Exception:
return None, None, None, None, None
except Exception as e:
raise ValueError(f"Nie można otworzyć lub odczytać EXIF: {e}")
# Zamiana kluczy numerycznych na nazwy
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")
lat = lon = ele = None
if isinstance(gps_info, dict):
gps = {}
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"))
gps = {ExifTags.GPSTAGS.get(k, k): v for k, v in gps_info.items()}
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)
if gps.get("GPSAltitude") and isinstance(gps["GPSAltitude"], tuple):
if "GPSAltitude" in gps:
try:
ele = gps["GPSAltitude"][0] / gps["GPSAltitude"][1]
ele = rational_to_float(gps["GPSAltitude"])
if gps.get("GPSAltitudeRef") == 1:
ele = -ele
except Exception:
ele = None
except Exception as e:
raise ValueError(f"Nie można zinterpretować wysokości: {e}")
# Czas
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("SubsecTime") or exif_readable.get("SubSecTimeOriginal")
subsec = exif_readable.get("SubsecTimeOriginal") or exif_readable.get("SubSecTimeOriginal") or exif_readable.get("SubsecTime")
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]:
files = []
@ -119,13 +174,14 @@ def collect_images(input_path: Path) -> List[Path]:
for root, _, fns in os.walk(input_path):
for fn in fns:
p = Path(root) / fn
if p.suffix in IMAGE_EXTS:
if p.suffix.lower() in IMAGE_EXTS:
files.append(p)
else:
if input_path.suffix in IMAGE_EXTS:
if input_path.suffix.lower() in IMAGE_EXTS:
files.append(input_path)
return files
def generate_gpx(points: List[dict], creator: str = "exif2gpx") -> ET.ElementTree:
ET.register_namespace("", "http://www.topografix.com/GPX/1/1")
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"):
# GPX wymaga ISO 8601 w UTC z 'Z'
t = ET.SubElement(trkpt, "time")
t.text = p["time"].strftime("%Y-%m-%dT%H:%M:%S.%fZ").rstrip("0").rstrip(".") + "Z"
# Dodatkowo można dodać nazwę pliku jako <desc>
iso = p["time"].strftime("%Y-%m-%dT%H:%M:%S.%f")
iso = iso.rstrip("0").rstrip(".") + "Z"
t.text = iso
desc = ET.SubElement(trkpt, "desc")
desc.text = p.get("source", "")
return ET.ElementTree(gpx)
def main():
ap = argparse.ArgumentParser(description="Z EXIF zdjęć (JPG) generuje GPX z trackiem.")
ap.add_argument("input", help="Plik JPG lub folder ze zdjęciami")
@ -171,19 +229,28 @@ def main():
files = collect_images(src)
if not files:
print("Brak zdjęć JPG/JPEG w podanej lokalizacji.")
print("Brak zdjęć JPG/JPEG w podanej lokalizacji.", file=sys.stderr)
return
points = []
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:
print(f"Pomijam {fp}: brak prawidłowych współrzędnych GPS", file=sys.stderr)
continue
# Wyznacz czas w UTC jeśli podano --tz
time_utc = None
if naive_dt is not None and 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({
"lat": lat,
@ -195,17 +262,14 @@ def main():
})
if not points:
print("Nie znaleziono zdjęć z danymi GPS.")
print("Nie znaleziono zdjęć z danymi GPS.", file=sys.stderr)
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):
# preferuj p['time'] (UTC) gdy jest; w przeciwnym razie naive_time; w ostateczności nazwa
return (
p["time"] if p["time"] is not None else
p["naive_time"] if p["naive_time"] is not None else
datetime.max
, p["source"])
primary = p["time"] if p["time"] is not None else p["naive_time"]
return (primary or datetime.max, p["source"])
points.sort(key=sort_key)