几百张照片按EXIF拍摄日期分文件夹归档:年/月两层一次理清

· 喵窝资源铺

相册导出几千张照片,想按「哪一年哪个月拍的」分文件夹,手动建目录拖文件点到眼花。这篇用 Python 读 EXIF DateTimeOriginal(按下快门那一刻的时间),把照片归档到 年/月 两层目录:无 EXIF 的自动回退文件修改时间并标注来源,损坏/锁文件/非图片逐条报告不中断,同名不覆盖,move/copy 双模式。代码实跑验证,真实输出贴在下面,坑也是实跑踩出来的。

与站内两篇的分工:按EXIF重命名照片是改文件名(文件还在原目录),本篇是分文件夹;按修改时间分层归档用的是 mtime(下载/复制时间不可信),本篇优先用 EXIF(拍摄的真实时间)。

脚本(60 行)

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""照片按EXIF拍摄日期归档到 年/月 两层文件夹:
EXIF优先,无EXIF回退修改时间;损坏/锁文件/非图片逐条报告;
同名文件进同一目标目录不覆盖;mode=move/copy 可选"""
import os, sys, shutil
from PIL import Image
from PIL import UnidentifiedImageError

SRC = sys.argv[1] if len(sys.argv) > 1 else "."
MODE = sys.argv[2] if len(sys.argv) > 2 else "move"      # move / copy
OUT = os.path.join(SRC, "by_exif_date")

done, skipped = [], []
for f in sorted(os.listdir(SRC)):
    p = os.path.join(SRC, f)
    if not os.path.isfile(p):
        continue                                    # 只扫顶层,输出目录也在此被跳过
    if f.startswith("~$"):
        skipped.append((f, "Office/临时锁文件")); continue
    if not f.lower().endswith((".jpg", ".jpeg", ".png", ".webp", ".heic")):
        skipped.append((f, "非图片")); continue
    try:
        im = Image.open(p)
        ex = im.getexif()
        raw = ex.get(36867) or ex.get(306)   # DateTimeOriginal / DateTime
        if raw:
            d = raw.split()[0].replace(":", "-")          # 只把日期部分的冒号换掉
            src_note = "EXIF"
        else:
            import datetime
            ts = os.path.getmtime(p)
            d = datetime.datetime.fromtimestamp(ts).strftime("%Y-%m-%d")
            src_note = "无EXIF,回退修改时间"
        year, month = d.split("-")[0], d.split("-")[1]
        dst_dir = os.path.join(OUT, year, month)
        dst = os.path.join(dst_dir, f)
        if os.path.exists(dst):
            skipped.append((f, "目标同名已存在,不覆盖")); continue
        os.makedirs(dst_dir, exist_ok=True)
        (shutil.move if MODE == "move" else shutil.copy2)(p, dst)
        done.append((f, f"{year}/{month}/", src_note))
    except (UnidentifiedImageError, OSError) as e:
        skipped.append((f, "读取失败: " + str(e)[:50]))

print(f"共归档 {len(done)} 个,跳过 {len(skipped)} 个(模式 {MODE})")
for f, to, note in done:   print(f"  [移] {f} -> {to}  ({note})" if MODE=="move" else f"  [拷] {f} -> {to}  ({note})")
for f, why in skipped:     print(f"  [跳] {f}: {why}")

用法:python3 exif_dir_archive.py 照片目录 move(或 copy 只拷贝不动源文件)。依赖 pip install pillow。

实跑结果

测试集 8 个文件:4 张带 EXIF 的图(2022/2023×2/2024 四个年月,PIL 写入 DateTimeOriginal)、1 张无 EXIF 的《截图01.png》、Office 锁文件 ~$tmp.jpg、3 字节坏的《坏图.jpg》(\xff\xd8notajpeg)、说明.txt。跑 python3 exif_dir_archive.py exif_dir_test move:

共归档 5 个,跳过 3 个(模式 move)
  [移] IMG_0001.jpg -> 2023/05/  (EXIF)
  [移] IMG_0002.jpg -> 2023/05/  (EXIF)
  [移] IMG_0003.jpg -> 2024/12/  (EXIF)
  [移] 微信图片.jpg -> 2022/01/  (EXIF)
  [移] 截图01.png -> 2026/10/  (无EXIF,回退修改时间)
  [跳] ~$tmp.jpg: Office/临时锁文件
  [跳] 坏图.jpg: 读取失败: cannot identify image file 'exif_dir_test/坏图.jpg'
  [跳] 说明.txt: 非图片

归档结果逐张核对:每个文件都落在 EXIF 对应的年/月目录,find exif_dir_test -type f 确认源文件已移走、5 个产物各就各位。再用 PIL 反读产物 EXIF:IMG_0001.jpg → 2023:05:01 10:30:00、微信图片.jpg → 2022:01:15 08:00:00——EXIF 随文件完整保留。

第二遍再跑:共归档 0 个,跳过 3 个——只剩锁文件/坏图/txt,幂等 ✅。copy 模式在新鲜测试集上同样 5 拷 3 跳全对,源文件原地保留 ✅。

同词干大小写变体专项:目录里放 IMG_1.jpg、IMG_1.JPG、IMG_1.png(EXIF 各不相同),归档后三个文件同名共存于 2023/05/,MD5 各不相同,反读 EXIF 各归各的拍摄时间——归档按原文件名落盘,不经过词干拼接,天然没有「产物同名互相覆盖」问题(对比 PDF 选页那篇的坑 1)。

实跑踩出的 4 个坑

  • 坑 1:EXIF 日期是冒号分隔的 2023:05:01 10:30:00,不能全局替换冒号。EXIF 标准里日期时间的分隔符就是 :,图省事 raw.replace(":", "-") 会把时间也改写成 10-30-00——本次侥幸只取年月没出错,但只要你想顺手用整串做目录名或文件名就直接炸,而且时间部分已不可逆。修复:先 split() 取日期部分,只对日期段做 replace(":", "-")。
  • 坑 2:无 EXIF 回退 mtime,归的是「到达时间」不是「拍摄时间」。截图、微信转发图普遍没有 DateTimeOriginal,直接跳过会漏掉一大半;但 mtime 是文件到达这台设备的时间——一张 2020 年拍的照片今年转发给你,会被归进今年。修复做不到凭空恢复真实日期,但可以把来源标注出来(本篇输出里的 (EXIF) / (无EXIF,回退修改时间)),让人一眼知道哪些目录的时间是推定值、需要人工复核。
  • 坑 3:坏图的异常要分开报,别全吞成「损坏」。3 字节的坏 JPG 抛 UnidentifiedImageError(信息是 cannot identify image file),而文件被占用/无权限抛的是普通 OSError——两类原因完全不同:前者这文件真没救,后者可能明天重跑就好了。如果图省事 except Exception 一律写「损坏」,会把权限问题也盖棺定论,用户永远不再尝试。修复:明确 catch 这两类,错误原文截断进报告。
  • 坑 4:输出目录在源目录里,靠 isfile 跳过,千万别换成 os.walk。本篇输出目录 by_exif_date/ 就建在源目录下,顶层 os.listdir + isfile 天然把它当目录跳过。但「改进」成 os.walk 递归扫全盘后,二次运行会把已归档的照片再归档一遍——by_exif_date/2023/05/ 里的文件被搬进 by_exif_date/2023/05/by_exif_date/... 无限套娃(和 mtime 归档那篇的坑 2 同源)。
  • 边界说明(如实)

  • 读的是 DateTimeOriginal (36867),回退 DateTime (306)(相机后来改写过的时间),最后回退 mtime;三级来源都在输出里标注。
  • HEIC 在部分 Pillow 版本需要装 pillow-heif 插件才能打开,打不开会带原因进跳过清单,不会中断批处理。
  • 只扫顶层不递归——照片分散在多层子目录的话先展平,或自行改造时记得排除输出目录(见坑 4)。
  • 虚拟物品不退款政策在所有对外文案中一致。
  • 不想自己跑脚本?喵窝资源铺提供人工代跑+交付服务(miaowoo.900890.xyz):把照片打包发来,交付按拍摄年月归档好的目录结构(附逐张来源核对清单)。由 AI 完成脚本与初验、人工复核后交付,9.9 元起。虚拟物品,交付后不退款,介意勿拍。

    ← 返回喵窝资源铺 · 相关:照片按EXIF拍摄日期批量重命名 · 相关:文件按修改时间分层归档