DEV Community

Cover image for Renaming job-site photos by capture time with Python and EXIF
Alex Alwood
Alex Alwood

Posted on

Renaming job-site photos by capture time with Python and EXIF

Phones name photos IMG_4021.jpg. That works until three people shoot the same basement and the files land in one shared folder in upload order. Someone then has to sort them for the claim file, and the filename gives no help.

I lead a water damage crew in Denver, and order matters to us because a drying job is a timeline: wet readings on day one, equipment placed, readings again on day three. This post covers a short Python script that copies photos into one folder, names each by the time it was taken plus the room, and writes a manifest. It needs Pillow and nothing else.

Where the time lives in a JPEG

A phone JPEG carries EXIF tags, and two of them hold a time. DateTimeOriginal (tag 36867) records when the shutter fired. DateTime (tag 306) records when the file was last written, and editing apps overwrite it. The file's modification time is the third source and the weakest, because copying or syncing a file resets it.

The script reads them in that order and writes the source it used into the manifest, so a reader can see which times are weaker.

One Pillow detail is easy to miss. DateTimeOriginal sits in the Exif sub-directory, not the main one. im.getexif().get(36867) returns None, even when the tag is there. You have to ask for the sub-directory first with get_ifd(0x8769).

The script

It expects one folder per job, with one subfolder per room. The subfolder name goes into the new filename.

#!/usr/bin/env python3
"""Copy job photos into one flat folder, named by capture time and room."""
import csv
import hashlib
import shutil
import sys
from datetime import datetime
from pathlib import Path

from PIL import Image

EXIF_IFD = 0x8769          # pointer to the Exif sub-directory
GPS_IFD = 0x8825           # pointer to the GPS sub-directory
DATETIME_ORIGINAL = 36867  # when the shutter fired
DATETIME = 306             # when the file was last written


def taken_at(path):
    with Image.open(path) as im:
        exif = im.getexif()
        raw = exif.get_ifd(EXIF_IFD).get(DATETIME_ORIGINAL) or exif.get(DATETIME)
        has_gps = bool(exif.get_ifd(GPS_IFD))
    if raw:
        return datetime.strptime(raw, "%Y:%m:%d %H:%M:%S"), "exif", has_gps
    return datetime.fromtimestamp(path.stat().st_mtime), "mtime", has_gps


def sha256(path):
    return hashlib.sha256(path.read_bytes()).hexdigest()


def main(src, dst):
    src, dst = Path(src), Path(dst)
    dst.mkdir(parents=True, exist_ok=True)
    rows = []
    for room_dir in sorted(p for p in src.iterdir() if p.is_dir()):
        shots = []
        for f in sorted(room_dir.glob("*.jp*g")):
            when, source, gps = taken_at(f)
            shots.append((when, f, source, gps))
        for n, (when, f, source, gps) in enumerate(sorted(shots, key=lambda s: s[0]), 1):
            new = dst / f"{when:%Y-%m-%d_%H%M%S}_{room_dir.name}_{n:02d}.jpg"
            shutil.copy2(f, new)
            if sha256(f) != sha256(new):
                sys.exit(f"copy differs from original: {f}")
            rows.append([f.name, new.name, when.isoformat(" "), source, "yes" if gps else "no", sha256(new)[:12]])
    with open(dst / "manifest.csv", "w", newline="") as fh:
        w = csv.writer(fh)
        w.writerow(["original", "renamed", "taken", "time_source", "gps", "sha256_12"])
        w.writerows(rows)
    for r in rows:
        print(*r[:5], sep="  ")


if __name__ == "__main__":
    main(*sys.argv[1:3])
Enter fullscreen mode Exit fullscreen mode

shutil.copy2 copies the bytes, so the EXIF block survives untouched. The script does not re-encode anything. After each copy it hashes the original and the copy and stops if they differ. The manifest keeps the first 12 characters of the hash.

What it printed

I made five tiny test images with fixed timestamps. Two kitchen shots from the first afternoon, one from the next morning, and two basement shots. One basement file has no EXIF at all, like a photo that went through a messaging app. I set its modification time by hand.

$ python claimshots.py job_4417 out
IMG_0872.jpg  2026-03-14_145102_basement_01.jpg  2026-03-14 14:51:02  exif  yes
PXL_20260314_2003.jpg  2026-03-14_150320_basement_02.jpg  2026-03-14 15:03:20  mtime  no
IMG_4019.jpg  2026-03-14_142955_kitchen_01.jpg  2026-03-14 14:29:55  exif  yes
IMG_4021.jpg  2026-03-14_143210_kitchen_02.jpg  2026-03-14 14:32:10  exif  yes
IMG_4044.jpg  2026-03-15_090541_kitchen_03.jpg  2026-03-15 09:05:41  exif  no
Enter fullscreen mode Exit fullscreen mode

IMG_4019 and IMG_4021 swapped places compared with their upload order, and the next-morning kitchen frame sorts last. The basement file without EXIF is marked mtime in the fourth column. I treat every mtime row as a time I have not verified.

Limits

  • Only .jpg and .jpeg are read. iPhones can shoot HEIC, so set the camera to the most compatible format or convert first.
  • The time is the phone's own clock. Two phones can be minutes apart, and the script does not correct that.
  • The gps column tells you which photos carry a location. The script leaves the location in the copies and does not strip it.

Top comments (0)