Skip to content

Instantly share code, notes, and snippets.

@djosix
Created April 21, 2026 17:11
Show Gist options
  • Select an option

  • Save djosix/79210f54d3a3c67fccf7e07923b2a305 to your computer and use it in GitHub Desktop.

Select an option

Save djosix/79210f54d3a3c67fccf7e07923b2a305 to your computer and use it in GitHub Desktop.
#!/usr/bin/env python3
"""
Lighten-mode stacker, preserves EXIF with earliest timestamp.
Usage: python3 photostacker.py FOLDER [-o out.jpg]
python3 -m pip install numpy Pillow piexif
"""
import sys
import argparse
import re
from pathlib import Path
from datetime import datetime
import numpy as np
from PIL import Image
import piexif
EXTS = {".jpg", ".jpeg", ".png", ".tif", ".tiff", ".webp"}
DT_RE = re.compile(r"^(\d{4}):(\d{2}):(\d{2}) (\d{2}):(\d{2}):(\d{2})")
def parse_dt(b):
if b is None:
return None
s = b.decode(errors="ignore") if isinstance(b, bytes) else b
m = DT_RE.match(s)
return datetime(*map(int, m.groups())) if m else None
def load(path):
"""Return (RGB array, piexif dict or None, DateTimeOriginal or None)."""
img = Image.open(path)
exif_bytes = img.info.get("exif")
exif_dict, dt = None, None
if exif_bytes:
try:
exif_dict = piexif.load(exif_bytes)
dt = parse_dt(exif_dict.get("Exif", {}).get(piexif.ExifIFD.DateTimeOriginal))
except Exception:
pass
arr = np.array(img.convert("RGB"))
return arr, exif_dict, dt
def main():
ap = argparse.ArgumentParser()
ap.add_argument("folder")
ap.add_argument("-o", "--output", default="stacked.jpg")
ap.add_argument("-q", "--quality", type=int, default=70)
args = ap.parse_args()
folder = Path(args.folder)
files = sorted(f for f in folder.iterdir() if f.suffix.lower() in EXTS)
if not files:
sys.exit(f"No images found in {folder}")
n = len(files)
print(f"Stacking {n} images")
# First image: seed the stack, inherit EXIF as template (camera/lens/GPS/ISO/f/shutter)
stack, base_exif, min_dt = load(files[0])
print(f"[1/{n}] {files[0].name}")
for i, f in enumerate(files[1:], 2):
arr, _, dt = load(f)
if arr.shape != stack.shape:
print(f"\n[{i}/{n}] SKIP (size mismatch): {f.name}")
continue
np.maximum(stack, arr, out=stack)
if dt and (min_dt is None or dt < min_dt):
min_dt = dt
print(f"\r[{i}/{n}] {f.name}", end="", flush=True)
print()
# Patch datetime to earliest; drop embedded thumbnail (stale)
if base_exif:
base_exif["thumbnail"] = None
base_exif["1st"] = {}
if min_dt:
dt_bytes = min_dt.strftime("%Y:%m:%d %H:%M:%S").encode()
base_exif.setdefault("0th", {})[piexif.ImageIFD.DateTime] = dt_bytes
base_exif.setdefault("Exif", {})[piexif.ExifIFD.DateTimeOriginal] = dt_bytes
base_exif["Exif"][piexif.ExifIFD.DateTimeDigitized] = dt_bytes
print(f"Earliest DateTime: {min_dt}")
out = Path(args.output)
save_kwargs = {}
if out.suffix.lower() in {".jpg", ".jpeg"}:
save_kwargs = {"quality": args.quality, "optimize": True}
if base_exif:
try:
save_kwargs["exif"] = piexif.dump(base_exif)
except Exception as e:
print(f"EXIF dump failed ({e}), saving without EXIF")
Image.fromarray(stack).save(out, **save_kwargs)
print(f"Saved: {out}")
if __name__ == "__main__":
main()
@djosix
Copy link
Copy Markdown
Author

djosix commented Apr 21, 2026

curl https://gist.githubusercontent.com/djosix/79210f54d3a3c67fccf7e07923b2a305/raw/4e97359bc2f574043041371f45f8d0a156fb8bb3/photostacker.py -o ~/.bin/photostacker
chmod +x ~/.bin/photostacker

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment