Created
April 14, 2026 07:37
-
-
Save apermo/49659dd7b1383db6338381e0b1d7c4a3 to your computer and use it in GitHub Desktop.
Small python script for renaming photos for release for WordCamps
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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