fix for tuple
This commit is contained in:
parent
5f371a1df7
commit
291f3113c4
180
exif2gpx.py
180
exif2gpx.py
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue