exif to gpx cd

This commit is contained in:
oskar 2025-09-02 17:07:41 +02:00
parent 291f3113c4
commit db9261a3d6
3 changed files with 86 additions and 15 deletions

View file

@ -5,12 +5,14 @@ exif2gpx.py — generuje plik GPX z danych GPS w EXIF zdjęć (JPG/JPEG).
Użycie: Użycie:
pip install Pillow pip install Pillow
python exif2gpx.py /sciezka/do/folderu_lub_pliku output.gpx --tz +02:00 python exif2gpx.py /sciezka/do/folderu_lub_pliku output.gpx --tz +02:00 --from-date 2024-01-01 --to-date 2024-12-31
Opcje: Opcje:
--tz Strefa czasowa, w której interpretować czasy EXIF (gdy EXIF nie ma TZ), --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 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. w GPX w UTC (z literą 'Z'). Jeśli nie podasz, <time> będzie pominięty.
--from-date Uwzględniaj punkty od tej daty (włącznie). Format: YYYY-MM-DD[THH:MM[:SS]]
--to-date Uwzględniaj punkty do tej daty (włącznie). Format: YYYY-MM-DD[THH:MM[:SS]]
""" """
import argparse import argparse
import os import os
@ -39,18 +41,15 @@ def rational_to_float(x) -> float:
if x is None: if x is None:
raise ValueError("Brak wartości rational") raise ValueError("Brak wartości rational")
# 1) już liczba
if isinstance(x, Real): if isinstance(x, Real):
return float(x) return float(x)
# 2) IFDRational (Pillow) albo coś z numerator/denominator
if hasattr(x, "numerator") and hasattr(x, "denominator"): if hasattr(x, "numerator") and hasattr(x, "denominator"):
den = x.denominator den = x.denominator
if den == 0: if den == 0:
raise ValueError("Mianownik równy zero w rational") raise ValueError("Mianownik równy zero w rational")
return float(x.numerator) / float(den) return float(x.numerator) / float(den)
# 3) krotka/lista (num, den)
if isinstance(x, (tuple, list)) and len(x) == 2: if isinstance(x, (tuple, list)) and len(x) == 2:
num, den = x num, den = x
den = float(den) den = float(den)
@ -92,7 +91,6 @@ 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")
# Subsekundy bywają różne: np. "123", "12", czasem z innymi znakami
if subsec: if subsec:
only_digits = "".join(ch for ch in str(subsec) if ch.isdigit()) only_digits = "".join(ch for ch in str(subsec) if ch.isdigit())
if only_digits: if only_digits:
@ -103,6 +101,38 @@ def parse_exif_datetime(raw_dt: Optional[str], subsec: Optional[str]) -> Optiona
raise ValueError(f"Nie można sparsować daty EXIF '{raw_dt}' ({subsec=}): {e}") raise ValueError(f"Nie można sparsować daty EXIF '{raw_dt}' ({subsec=}): {e}")
def parse_cli_datetime(s: str) -> datetime:
"""
Parsuje datę z CLI. Akceptowane formaty (naive, bez strefy):
- YYYY-MM-DD
- YYYY-MM-DD HH:MM
- YYYY-MM-DD HH:MM:SS
- YYYY-MM-DDTHH:MM
- YYYY-MM-DDTHH:MM:SS
Zwraca naive datetime.
"""
if not s:
raise ValueError("Pusta data")
formats = [
"%Y-%m-%d",
"%Y-%m-%d %H:%M",
"%Y-%m-%d %H:%M:%S",
"%Y-%m-%dT%H:%M",
"%Y-%m-%dT%H:%M:%S",
]
last_err = None
for fmt in formats:
try:
dt = datetime.strptime(s, fmt)
if fmt == "%Y-%m-%d":
return dt.replace(hour=0, minute=0, second=0, microsecond=0)
return dt
except Exception as e:
last_err = e
continue
raise ValueError(f"Nie rozpoznano formatu daty: {s!r} ({last_err})")
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,
@ -136,7 +166,6 @@ def extract_exif_gps_and_time(img_path: Path) -> Tuple[Optional[float], Optional
except Exception as e: except Exception as e:
raise ValueError(f"Nie można otworzyć lub odczytać EXIF: {e}") raise ValueError(f"Nie można otworzyć lub odczytać EXIF: {e}")
# Zamiana kluczy numerycznych na nazwy
exif_readable = {} exif_readable = {}
for tag, val in exif.items(): for tag, val in exif.items():
name = ExifTags.TAGS.get(tag, tag) name = ExifTags.TAGS.get(tag, tag)
@ -151,7 +180,6 @@ def extract_exif_gps_and_time(img_path: Path) -> Tuple[Optional[float], Optional
"GPSLongitude" in gps and "GPSLongitudeRef" in gps: "GPSLongitude" in gps and "GPSLongitudeRef" in gps:
lat = dms_to_decimal(gps["GPSLatitude"], gps["GPSLatitudeRef"]) lat = dms_to_decimal(gps["GPSLatitude"], gps["GPSLatitudeRef"])
lon = dms_to_decimal(gps["GPSLongitude"], gps["GPSLongitudeRef"]) lon = dms_to_decimal(gps["GPSLongitude"], gps["GPSLongitudeRef"])
# Wysokość (opcjonalna)
if "GPSAltitude" in gps: if "GPSAltitude" in gps:
try: try:
ele = rational_to_float(gps["GPSAltitude"]) ele = rational_to_float(gps["GPSAltitude"])
@ -160,7 +188,6 @@ def extract_exif_gps_and_time(img_path: Path) -> Tuple[Optional[float], Optional
except Exception as e: except Exception as e:
raise ValueError(f"Nie można zinterpretować wysokości: {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") raw_dt = exif_readable.get("DateTimeOriginal") or exif_readable.get("DateTime") or exif_readable.get("DateTimeDigitized")
subsec = exif_readable.get("SubsecTimeOriginal") or exif_readable.get("SubSecTimeOriginal") or exif_readable.get("SubsecTime") 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 naive_dt = parse_exif_datetime(raw_dt, subsec) if raw_dt else None
@ -206,7 +233,6 @@ def generate_gpx(points: List[dict], creator: str = "exif2gpx") -> ET.ElementTre
ele = ET.SubElement(trkpt, "ele") ele = ET.SubElement(trkpt, "ele")
ele.text = f"{p['ele']:.2f}" ele.text = f"{p['ele']:.2f}"
if p.get("time"): if p.get("time"):
# GPX wymaga ISO 8601 w UTC z 'Z'
t = ET.SubElement(trkpt, "time") t = ET.SubElement(trkpt, "time")
iso = p["time"].strftime("%Y-%m-%dT%H:%M:%S.%f") iso = p["time"].strftime("%Y-%m-%dT%H:%M:%S.%f")
iso = iso.rstrip("0").rstrip(".") + "Z" iso = iso.rstrip("0").rstrip(".") + "Z"
@ -222,8 +248,29 @@ def main():
ap.add_argument("input", help="Plik JPG lub folder ze zdjęciami") ap.add_argument("input", help="Plik JPG lub folder ze zdjęciami")
ap.add_argument("output", help="Ścieżka wyjściowa pliku GPX") ap.add_argument("output", help="Ścieżka wyjściowa pliku GPX")
ap.add_argument("--tz", help="Strefa czasowa EXIF w formacie +HH:MM lub -HH:MM (np. +02:00)", default=None) ap.add_argument("--tz", help="Strefa czasowa EXIF w formacie +HH:MM lub -HH:MM (np. +02:00)", default=None)
ap.add_argument("--from-date", dest="from_date", help="Uwzględniaj punkty od tej daty (włącznie). Format: YYYY-MM-DD[THH:MM[:SS]]", default=None)
ap.add_argument("--to-date", dest="to_date", help="Uwzględniaj punkty do tej daty (włącznie). Format: YYYY-MM-DD[THH:MM[:SS]]", default=None)
args = ap.parse_args() args = ap.parse_args()
# Parsowanie filtrów daty
from_dt = to_dt = None
if args.from_date:
try:
from_dt = parse_cli_datetime(args.from_date)
if args.tz:
from_dt = apply_tz_and_to_utc(from_dt, args.tz)
except Exception as e:
print(f"Ostrzeżenie: nie można zinterpretować --from-date: {e}", file=sys.stderr)
from_dt = None
if args.to_date:
try:
to_dt = parse_cli_datetime(args.to_date)
if args.tz:
to_dt = apply_tz_and_to_utc(to_dt, args.tz)
except Exception as e:
print(f"Ostrzeżenie: nie można zinterpretować --to-date: {e}", file=sys.stderr)
to_dt = None
src = Path(args.input) src = Path(args.input)
out = Path(args.output) out = Path(args.output)
@ -252,6 +299,17 @@ def main():
except Exception as e: except Exception as e:
print(f"Ostrzeżenie: nieprawidłowa strefa czasowa dla {fp.name}: {e}", file=sys.stderr) print(f"Ostrzeżenie: nieprawidłowa strefa czasowa dla {fp.name}: {e}", file=sys.stderr)
# Filtrowanie po datach (inclusive). Jeśli filtry ustawione, wymagamy czasu w zdjęciu.
if args.from_date or args.to_date:
base_dt = time_utc if args.tz else naive_dt
if base_dt is None:
print(f"Pomijam {fp}: brak czasu przy aktywnych filtrach daty", file=sys.stderr)
continue
if from_dt and (base_dt < from_dt):
continue
if to_dt and (base_dt > to_dt):
continue
points.append({ points.append({
"lat": lat, "lat": lat,
"lon": lon, "lon": lon,
@ -267,7 +325,6 @@ def main():
# Sortowanie: 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
primary = p["time"] if p["time"] is not None else p["naive_time"] primary = p["time"] if p["time"] is not None else p["naive_time"]
return (primary or datetime.max, p["source"]) return (primary or datetime.max, p["source"])

11
link4exif2pgx.sh Normal file
View file

@ -0,0 +1,11 @@
#!/usr/bin/env bash
from=$1
to=$2
mkdir -p "$to"
# użycie find z -print0, żeby prawidłowo obsłużyć spacje i dziwne znaki
find "$from" -type f -print0 | while IFS= read -r -d '' i; do
ln -s "$i" "$to/$(basename "$i")"
done

View file

@ -47,3 +47,6 @@ Podsumowanie:
odwrotnie odwrotnie
python exif2gpx.py /ścieżka/do/folderu/ze/zdjęciami output.gpx --tz +02:00 python exif2gpx.py /ścieżka/do/folderu/ze/zdjęciami output.gpx --tz +02:00
for i in $(find /home/pi/immich/library/upload/7c9720a6-b22a-44bc-8e3c-f5ed92374732/ -type f); do ln -s $i; done