diff --git a/.gitignore b/.gitignore index 5d381cc..fe9260d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +.idea + # ---> Python # Byte-compiled / optimized / DLL files __pycache__/ @@ -15,7 +17,6 @@ dist/ downloads/ eggs/ .eggs/ -lib/ lib64/ parts/ sdist/ diff --git a/README.md b/README.md index 300a0c2..10991e1 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,26 @@ # gpx-to-image-tags +_Geotag your photos from a GPX track._ -Read a GPX track and correlate that to the time photos were taken. \ No newline at end of file +This is a simple script used to add geotags (geographic metadata locating where the photo was taken) to images taken by +a non-GPS camera. + +It reads the time a photo was taken and then finds the closest point in the GPX track. + +## Install + +1. `pip install -r requirements.txt` + +## Use + +``` +python3 main.py +``` + +You might need the `--timezone` argument. Double check the EXIF times are correct via `identify -verbose`. + +
+ +Optional Arguments: + +- `--max-diff`: Maximum allowed difference in seconds between image timestamp and track point. Default: 300 (5 minutes). +- `--timezone`: Three letter timezone the photos are in (e.g. EST). Default: auto-detect your local timezone. \ No newline at end of file diff --git a/lib/__init__.py b/lib/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lib/geotags.py b/lib/geotags.py new file mode 100644 index 0000000..dbfa8a8 --- /dev/null +++ b/lib/geotags.py @@ -0,0 +1,76 @@ +import datetime +from fractions import Fraction +from typing import Union + +import piexif + +""" +Source: https://gist.github.com/c060604/8a51f8999be12fc2be498e9ca56adc72 +""" + + +def to_deg(value, loc): + """convert decimal coordinates into degrees, munutes and seconds tuple + + Keyword arguments: value is float gps-value, loc is direction list ["S", "N"] or ["W", "E"] + return: tuple like (25, 13, 48.343 ,'N') + """ + if value < 0: + loc_value = loc[0] + elif value > 0: + loc_value = loc[1] + else: + loc_value = "" + abs_value = abs(value) + deg = int(abs_value) + t1 = (abs_value - deg) * 60 + min = int(t1) + sec = round((t1 - min) * 60, 5) + return deg, min, sec, loc_value + + +def change_to_rational(number): + """convert a number to rantional + + Keyword arguments: number + return: tuple like (1, 2), (numerator, denominator) + """ + f = Fraction(str(number)) + return f.numerator, f.denominator + + +def generate_geotags(lat: float, lon: float, alt: Union[int, float], timestamp_obj: datetime.datetime, original_exif: dict): + lat_deg = to_deg(lat, ["S", "N"]) + lng_deg = to_deg(lon, ["W", "E"]) + + exiv_lat = (change_to_rational(lat_deg[0]), change_to_rational(lat_deg[1]), change_to_rational(lat_deg[2])) + exiv_lng = (change_to_rational(lng_deg[0]), change_to_rational(lng_deg[1]), change_to_rational(lng_deg[2])) + + # Convert date and time to the format required by EXIF + datestamp = timestamp_obj.strftime("%Y:%m:%d") + timestamp = (change_to_rational(timestamp_obj.hour), change_to_rational(timestamp_obj.minute), change_to_rational(timestamp_obj.second)) + + if alt < 0: + altitude_ref = 1 + altitude = abs(alt) + else: + altitude_ref = 0 + altitude = alt + + gps_ifd = { + piexif.GPSIFD.GPSVersionID: (2, 0, 0, 0), + piexif.GPSIFD.GPSAltitudeRef: altitude_ref, + piexif.GPSIFD.GPSAltitude: change_to_rational(round(altitude)), + piexif.GPSIFD.GPSLatitudeRef: lat_deg[3], + piexif.GPSIFD.GPSLatitude: exiv_lat, + piexif.GPSIFD.GPSLongitudeRef: lng_deg[3], + piexif.GPSIFD.GPSLongitude: exiv_lng, + piexif.GPSIFD.GPSDateStamp: datestamp, + piexif.GPSIFD.GPSTimeStamp: timestamp, + } + gps_exif = {"GPS": gps_ifd} + + # Update original exif data to include GPS tag. + original_exif.update(gps_exif) + + return original_exif diff --git a/lib/gpx.py b/lib/gpx.py new file mode 100644 index 0000000..582a98b --- /dev/null +++ b/lib/gpx.py @@ -0,0 +1,32 @@ +from datetime import timedelta + +import gpxpy +from gpxpy.gpx import GPXTrackPoint + + +def get_closest_point(gpx, timestamp, max_diff) -> GPXTrackPoint | None: + closest_point = None + closest_diff = timedelta.max + + for track in gpx.tracks: + for segment in track.segments: + for point in segment.points: + diff = abs(point.time - timestamp) + if diff < closest_diff and diff.total_seconds() <= max_diff: + closest_diff = diff + closest_point = point + + return closest_point + + +def get_gpx_data(file): + gpx_file = open(file, 'r') + gpx = gpxpy.parse(gpx_file) + + data = [] + for track in gpx.tracks: + for segment in track.segments: + for point in segment.points: + data.append((point.time, point.latitude, point.longitude)) + + return data diff --git a/lib/image.py b/lib/image.py new file mode 100644 index 0000000..c7dbe3c --- /dev/null +++ b/lib/image.py @@ -0,0 +1,23 @@ +from datetime import datetime + +import pytz +from PIL.ExifTags import TAGS + + +def get_image_timestamp(img, timezone): + exif = img._getexif() + if not exif: + raise ValueError("No EXIF metadata found") + + found_timestamp = None + + for (idx, tag) in TAGS.items(): + if tag == 'DateTime': + found_timestamp = exif.get(idx) + elif tag == 'DateTimeOriginal': + found_timestamp = exif.get(idx) + + if found_timestamp: + dt = datetime.strptime(found_timestamp, '%Y:%m:%d %H:%M:%S') + tz = pytz.timezone(timezone) + return tz.localize(dt) diff --git a/lib/time.py b/lib/time.py new file mode 100644 index 0000000..c06acf4 --- /dev/null +++ b/lib/time.py @@ -0,0 +1,7 @@ +from datetime import datetime + +from tzlocal import get_localzone + + +def get_local_timezone(): + return datetime.now(tz=get_localzone()).strftime('%Z') diff --git a/main.py b/main.py new file mode 100644 index 0000000..9c45a34 --- /dev/null +++ b/main.py @@ -0,0 +1,57 @@ +import argparse +from pathlib import Path + +import gpxpy +import piexif +import pytz +from PIL import Image + +from lib.geotags import generate_geotags +from lib.gpx import get_closest_point +from lib.image import get_image_timestamp +from lib.time import get_local_timezone + + +def main(args): + with open(args.gpx, 'r') as gpxfile: + gpx = gpxpy.parse(gpxfile) + + for file in args.photos.glob('*'): + if file.name.lower().endswith(('.jpg', '.jpeg')): + img = Image.open(file) + timestamp = get_image_timestamp(img, args.timezone) + del img + if not timestamp: + print(f'FAIL - "{file.name}" does not have a timestamp.') + continue + point = get_closest_point(gpx, timestamp, args.max_diff) + if not point: + point_forced = get_closest_point(gpx, timestamp, 99999999999999999999999999) + print(f'FAIL - Could not match image "{file.name}" @ {timestamp}. Closest point: {point_forced.latitude},{point_forced.longitude} @ {point_forced.time.astimezone(pytz.timezone(args.timezone))}') + continue + else: + original_exif = piexif.load(str(file)) + geotags = generate_geotags(point.latitude, point.longitude, point.elevation, point.time, original_exif) + exif_bytes = piexif.dump(geotags) + piexif.insert(exif_bytes, str(file)) + print('OKAY - ', file.name) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description='Add location tags to photos based on a GPX track.') + parser.add_argument('photos', help='Path to the directory containing the photos') + parser.add_argument('gpx', help='Path to the GPX file') + parser.add_argument('--max-diff', type=int, default=300, help='Maximum allowed difference in seconds between image timestamp and track point. Default: 300') + parser.add_argument('--timezone', default=get_local_timezone(), help='Three letter timezone the photos are in (e.g. EST). Default: auto-detect your local timezone') + args = parser.parse_args() + + args.photos = Path(args.photos).expanduser().absolute().resolve() + if args.photos.is_file() or not args.photos.exists(): + print('Invalid path to photos. Must be a directory.') + quit(1) + + args.gpx = Path(args.gpx).expanduser().absolute().resolve() + if args.gpx.is_dir() or not args.gpx.exists(): + print('Invalid path to GPX file.') + quit(1) + main(args) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..39cbe40 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +gpxpy==1.6.2 +pillow==10.2.0 +tzlocal==5.2 +pytz==2024.1 +piexif==1.1.3 \ No newline at end of file