mirror of
https://git.vulpinecitrus.info/Lymkwi/korrel
synced 2024-11-23 02:46:34 +00:00
Initial commit
Signed-off-by: lymkwi <lymkwi@vulpinecitrus.info>
This commit is contained in:
commit
ce67d7ef0f
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
env/
|
19
README.md
Normal file
19
README.md
Normal file
|
@ -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 <path to GPX> <photo folder>
|
||||
```
|
||||
|
||||
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/).
|
167
korrel.py
Executable file
167
korrel.py
Executable file
|
@ -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] <gpx_file> <photo_folder>",
|
||||
)
|
||||
parser.add_argument("gpx_file")
|
||||
parser.add_argument("photo_older")
|
||||
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
2
requirements.txt
Normal file
2
requirements.txt
Normal file
|
@ -0,0 +1,2 @@
|
|||
exif==1.6
|
||||
gpx==0.2
|
Loading…
Reference in a new issue