exif to gpx cd
This commit is contained in:
parent
291f3113c4
commit
db9261a3d6
83
exif2gpx.py
83
exif2gpx.py
|
|
@ -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
11
link4exif2pgx.sh
Normal 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
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue