Skip to content

Instantly share code, notes, and snippets.

@apermo
Created April 14, 2026 07:37
Show Gist options
  • Select an option

  • Save apermo/49659dd7b1383db6338381e0b1d7c4a3 to your computer and use it in GitHub Desktop.

Select an option

Save apermo/49659dd7b1383db6338381e0b1d7c4a3 to your computer and use it in GitHub Desktop.
Small python script for renaming photos for release for WordCamps
#!/usr/bin/env python3
"""
Enrich all JPEG images with GPS location data and rename them
in chronological order.
GPS coordinates: 48.21718766749917, 16.35311927435226 (Vienna)
Usage:
pip install piexif Pillow
python3 process_images.py # dry run (default)
python3 process_images.py --run # actually process files
"""
import argparse
import os
import struct
import sys
from pathlib import Path
try:
import piexif
except ImportError:
sys.exit("piexif not installed. Run: pip install piexif")
from PIL import Image
DIRECTORY = Path(__file__).parent
LATITUDE = 48.21718766749917
LONGITUDE = 16.35311927435226
PREFIX = "WCVIE-Christoph-Daum"
EXTENSIONS = {".jpeg", ".jpg"}
# AstrHori 6mm f/2.8 Circular Fisheye — manual lens, no electronic coupling.
# Identified by: LensSpecification all zeros + body = NIKON Z6_3.
# Aperture was set to f/16 for all shots.
FISHEYE_LENS = {
piexif.ExifIFD.FNumber: (16, 1),
piexif.ExifIFD.LensSpecification: ((6, 1), (6, 1), (28, 10), (28, 10)),
piexif.ExifIFD.LensMake: b"AstrHori",
piexif.ExifIFD.LensModel: b"AstrHori 6mm f/2.8 Circular Fisheye",
}
def is_fisheye_image(exif_dict: dict) -> bool:
"""Detect fisheye images: Z6_3 body with no lens info (all-zero LensSpec)."""
model = exif_dict["0th"].get(piexif.ImageIFD.Model, b"")
if isinstance(model, bytes):
model = model.decode()
if "Z6_3" not in model:
return False
lens_spec = exif_dict["Exif"].get(piexif.ExifIFD.LensSpecification)
return lens_spec == ((0, 1), (0, 1), (0, 1), (0, 1))
def decimal_to_dms_rational(decimal_degrees: float):
"""Convert decimal degrees to EXIF-style rational DMS."""
degrees = int(decimal_degrees)
minutes_float = (decimal_degrees - degrees) * 60
minutes = int(minutes_float)
seconds_float = (minutes_float - minutes) * 60
# Store seconds with 10000x precision to avoid rounding issues
seconds_rational = (int(seconds_float * 10000), 10000)
return (
(degrees, 1),
(minutes, 1),
seconds_rational,
)
def build_gps_ifd(lat: float, lon: float) -> dict:
"""Build a piexif GPS IFD dict for the given coordinates."""
lat_ref = b"N" if lat >= 0 else b"S"
lon_ref = b"E" if lon >= 0 else b"W"
return {
piexif.GPSIFD.GPSVersionID: (2, 3, 0, 0),
piexif.GPSIFD.GPSLatitudeRef: lat_ref,
piexif.GPSIFD.GPSLatitude: decimal_to_dms_rational(abs(lat)),
piexif.GPSIFD.GPSLongitudeRef: lon_ref,
piexif.GPSIFD.GPSLongitude: decimal_to_dms_rational(abs(lon)),
}
def get_datetime_original(filepath: Path) -> str:
"""Read DateTimeOriginal from EXIF. Falls back to file mtime."""
try:
exif_dict = piexif.load(str(filepath))
dt = exif_dict["Exif"].get(piexif.ExifIFD.DateTimeOriginal)
if dt:
return dt.decode() if isinstance(dt, bytes) else dt
except Exception:
pass
# Fallback: use file modification time
mtime = os.path.getmtime(filepath)
from datetime import datetime
return datetime.fromtimestamp(mtime).strftime("%Y:%m:%d %H:%M:%S")
def collect_images(directory: Path) -> list[Path]:
"""Collect all JPEG files in the directory."""
return sorted(
(f for f in directory.iterdir()
if f.is_file() and f.suffix.lower() in EXTENSIONS),
key=lambda f: f.name,
)
def main():
parser = argparse.ArgumentParser(description="Geo-tag and rename images.")
parser.add_argument(
"--run", action="store_true",
help="Actually process files. Without this flag, only a dry run is performed.",
)
args = parser.parse_args()
dry_run = not args.run
if dry_run:
print("=== DRY RUN (pass --run to execute) ===\n")
images = collect_images(DIRECTORY)
if not images:
sys.exit(f"No JPEG files found in {DIRECTORY}")
print(f"Found {len(images)} images\n")
# Sort by DateTimeOriginal for chronological ordering
images_with_dt = []
for img_path in images:
dt = get_datetime_original(img_path)
images_with_dt.append((dt, img_path))
images_with_dt.sort(key=lambda x: x[0])
# Determine zero-padding width
total = len(images_with_dt)
width = max(3, len(str(total)))
gps_ifd = build_gps_ifd(LATITUDE, LONGITUDE)
rename_map = [] # (old_path, new_path) — applied after all GPS writes
for idx, (dt, img_path) in enumerate(images_with_dt, start=1):
seq = str(idx).zfill(width)
# dt is "YYYY:MM:DD HH:MM:SS" -> "YYYY-MM-DD-HHhMMm"
ts = dt[:10].replace(":", "-") + "-" + dt[11:13] + "h" + dt[14:16] + "m"
new_name = f"{PREFIX}-{seq}-{ts}.jpg"
new_path = img_path.parent / new_name
print(f"[{seq}/{total}] {img_path.name} ({dt}) -> {new_name}")
# Load EXIF (needed for both dry run reporting and actual writes)
try:
exif_dict = piexif.load(str(img_path))
except Exception:
exif_dict = {"0th": {}, "Exif": {}, "GPS": {}, "1st": {}}
fisheye = is_fisheye_image(exif_dict)
if not dry_run:
exif_dict["GPS"] = gps_ifd
if fisheye:
for tag, value in FISHEYE_LENS.items():
exif_dict["Exif"][tag] = value
exif_bytes = piexif.dump(exif_dict)
piexif.insert(exif_bytes, str(img_path))
else:
print(f" GPS: {LATITUDE}, {LONGITUDE}")
if fisheye:
print(f" Lens: AstrHori 6mm f/2.8 Fisheye, f/16")
rename_map.append((img_path, new_path))
# Rename pass — use temp names first to avoid collisions
if not dry_run:
print("\nRenaming files...")
# Phase 1: rename to temp names
temp_map = []
for old_path, new_path in rename_map:
temp_path = old_path.parent / (old_path.stem + ".tmp_rename" + old_path.suffix)
old_path.rename(temp_path)
temp_map.append((temp_path, new_path))
# Phase 2: rename temp to final names
for temp_path, new_path in temp_map:
temp_path.rename(new_path)
print(f"Done. Processed {total} images.")
else:
print(f"\nDry run complete. {total} images would be processed.")
print("Run with --run to apply changes.")
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment