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])
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
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
.jpgand.jpegare 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
gpscolumn tells you which photos carry a location. The script leaves the location in the copies and does not strip it.
Top comments (0)