From ce67d7ef0f9fa9a150a27e3a7ad27601b5df46fc Mon Sep 17 00:00:00 2001 From: lymkwi Date: Fri, 10 May 2024 00:02:02 +0200 Subject: [PATCH] Initial commit Signed-off-by: lymkwi --- .gitignore | 1 + README.md | 19 ++++++ korrel.py | 167 +++++++++++++++++++++++++++++++++++++++++++++++ requirements.txt | 2 + 4 files changed, 189 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100755 korrel.py create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bdaab25 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +env/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..81d2559 --- /dev/null +++ b/README.md @@ -0,0 +1,19 @@ +# Korrel + +A little python script I wrote to add GPS EXIF data to a batch of photographs using a GPX file. + +## How to Run + +Simply run the following: +```bash +python3 -m venv env +source env/bin/activate # depending on shell, this may change +pip3 install -r requirements.txt +./korrel.py +``` + +Photos in the target folder are written on the fly and sought recursively. + +## License + +I don't care enough about licensing random shitty tools I write: [ACSL](https://anticapitalist.software/). diff --git a/korrel.py b/korrel.py new file mode 100755 index 0000000..1c06d25 --- /dev/null +++ b/korrel.py @@ -0,0 +1,167 @@ +#!/usr/bin/python3 +""" +Python tool to tag photos along a GPX track. +""" + +import argparse +from datetime import datetime, timedelta, timezone +from decimal import Decimal +from pathlib import Path +import sys + +import gpx +import exif + + +def engrave_gpx_data( + exif_data: exif.Image, photopath: Path, wpt: gpx.waypoint.Waypoint +): + """Engrave the GPS data from a Waypoint into the image""" + # GPSVersionID + exif_data["gps_version_id"] = 2 + + lat = wpt.lat + # GPSLatitudeRef + exif_data["gps_latitude_ref"] = "N" if lat >= 0 else "S" + lat = abs(lat) + # GPSLatitude + intlat = int(lat) + declat = int((lat - intlat) * 60) + decdeclat = (lat - intlat - declat / Decimal(60)) * 3600 + exif_data["gps_latitude"] = (intlat, declat, decdeclat) + + long = wpt.lon + # GPSLongitudeRef + exif_data["gps_longitude_ref"] = "E" if long >= 0 else "W" + # GPSLongitude + long = abs(long) + intlong = int(long) + declong = int((long - intlong) * 60) + decdeclong = (long - intlong - declong / Decimal(60)) * 3600 + exif_data["gps_longitude"] = (intlong, declong, decdeclong) + + ele = wpt.ele + if ele is not None: + # GPSAltitudeRef + exif_data["gps_altitude_ref"] = 0 if ele >= 0 else 1 + # GPSAltitude + exif_data["gps_altitude"] = abs(ele) + + if wpt.hdop: + # GPSDOP + exif_data["gps_dop"] = wpt.hdop + + utctime = wpt.time.replace(tzinfo = timezone.utc) + # GPSTimeStamp + exif_data["gps_datestamp"] = utctime.strftime("%Y:%m:%d") + # GPSDateStamp + exif_data["gps_timestamp"] = (utctime.hour, utctime.minute, utctime.second) + + with photopath.open(mode="wb") as photo: + then = utctime.strftime("%Y:%m:%d %H:%M:%S") + print( + f"[{then}] {photopath.name}: {intlat}°{declat}'{decdeclat:02.6}\"N ({lat}) {intlong}°{declong}'{decdeclong:02.6}\"W ({long})" + ) + photo.write(exif_data.get_file()) + + +def run(gpxf: str, photodir: str): + """Run the program""" + # Get the file, potentially + gpxobj = gpx.GPX.from_file(gpxf) + # Open the directory + photos = Path(photodir) + if not photos.exists(): + print(f'Unable to open photographs folder "{photodir}" !', file=sys.stderr) + return + + # Get the time bounds of the GPX segments + gpx_time_bounds = compute_gpx_time_bounds(gpxobj) + + time_photo_dict = find_applicable_images(gpx_time_bounds, photos) + times_to_find = list(time_photo_dict.keys()) + times_to_find.sort() + + # Find the earliest point that is okay for all photos + last_point = None + for track in gpxobj.tracks: + for seg in track.segments: + for point in seg.points: + if last_point is None: + last_point = point + continue + + if len(times_to_find) == 0: + # Job's done + break + + while ( + len(times_to_find) > 0 + and last_point.time <= times_to_find[0] < point.time + ): + # Oh, `last_point` was what we wanted + engrave_gpx_data(*time_photo_dict[times_to_find[0]], last_point) + times_to_find.pop(0) + last_point = point + + +def find_applicable_images( + gpx_time_bounds: list[tuple[datetime, timedelta]], photos: Path +) -> dict[datetime, (exif.Image, Path)]: + """Find images in the photo path within the given GPX segment time bounds""" + result = {} + local_tz = datetime.now(timezone.utc).astimezone().tzinfo + for root, _, candidates in photos.walk( + top_down=True, + on_error=lambda x: print(f"Could not open {x}"), + follow_symlinks=True, + ): + for candidate in candidates: + with open(root / candidate, mode="rb") as candidate_file: + exif_data = exif.Image(candidate_file) + if len(exif_data.list_all()) == 0: + continue + candidate_time = datetime.strptime( + exif_data.datetime_original, "%Y:%m:%d %H:%M:%S" + ).replace(tzinfo=local_tz) + + # Find an applicable time bound + for bound_min, bound_dur in gpx_time_bounds: + if bound_min <= candidate_time < bound_min + bound_dur: + result[candidate_time] = (exif_data, Path(root / candidate)) + + print(f"Found {len(result)} candidate pictures") + + return result + + +def compute_gpx_time_bounds(gpxobj: gpx.gpx.GPX) -> list[tuple[datetime, timedelta]]: + """Compute the time bounds of GPX segments""" + return [ + (seg.points[0].time, seg.duration) + for track in gpxobj.tracks + for seg in track.segments + ] + + +def main(): + """The main function""" + args = parse_arguments() + run(args.gpx_file, args.photo_older) + + +def parse_arguments() -> argparse.Namespace: + """Parse arguments to the programs""" + parser = argparse.ArgumentParser( + prog="korrel", + description="Correlate and tag images using a GPX track", + usage="%(prog)s [-h] ", + ) + parser.add_argument("gpx_file") + parser.add_argument("photo_older") + + return parser.parse_args() + + +if __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..7286247 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +exif==1.6 +gpx==0.2