Created
April 21, 2026 17:11
-
-
Save djosix/79210f54d3a3c67fccf7e07923b2a305 to your computer and use it in GitHub Desktop.
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 | |
| """ | |
| 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() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.