查看: 231|回复: 0

Python按EXIF拍摄时间批量重命名并归档照片脚本

[复制链接]
发表于 2 小时前 | 显示全部楼层 |阅读模式
照片导出到电脑后,文件名常常是 IMG_1234.jpg、DSC_0001.jpg、_MG_2837.heic,按文件名排序没有语义,按文件修改时间整理也不可靠。复制、导出、微信保存、修图软件二次导出都会改写修改时间,只有照片 EXIF 中的原始拍摄时间最适合作为归档依据。本文围绕 Python 脚本实践,讲清 exifread 读取 DateTimeOriginal、按 YYYYMMDD_HHMMSS 重命名、按日期目录移动照片,以及 dry-run、冲突处理、CSV 日志回滚和 HEIC/RAW/视频混排的处理方式。

一、拍摄时间从哪里读

EXIF 是相机写入照片文件头的元数据。与时间相关的常见标签有三个:EXIF DateTimeOriginal 是原始拍摄时间,最可靠;EXIF DateTimeDigitized 是数字化时间,通常与原始拍摄时间一致;Image DateTime 是相机机身写入的最后修改时间,可能变化。用 exifread 读取时键名就是这些字符串。典型值如下:
  1. import exifread
  2. with open('IMG_1234.jpg', 'rb') as f:
  3.     tags = exifread.process_file(f, details=False)
  4. print(tags.get('EXIF DateTimeOriginal'))
复制代码

输出类似 2023:10:05 14:22:33。注意中间是冒号,strptime 必须写成 '%Y:%m:%d %H:%M:%S',不能按常见的短横线格式解析。details=False 让 exifread 不解析内嵌缩略图,批量读取时更快。依赖安装只需要 pip install exifread。Pillow 也能读 EXIF,但对部分 RAW、HEIC 支持不完整,exifread 更轻量,只读元数据,不打开整幅图像。

二、命名规则与目录设计

推荐新文件名结构为 YYYYMMDD_HHMMSS_序号.扩展名,例如 20231005_142233_01.jpg、20231005_142233_02.jpg。这样按文件名排序就等于按拍摄时间排序,同一秒连拍再用两位序号区分,避免覆盖。目录可以先采用平铺的 2023-10-05,照片量到数万张后再改成 年/月/日 嵌套,减少单目录文件数量。EXIF 时间通常是相机本地时间,如果发现整批照片偏移几个小时,可以用 timezone_offset_hours 参数做修正,核心是 timedelta(hours=timezone_offset_hours)。

三、读取拍摄时间的函数

下面函数先按 EXIF DateTimeOriginal、EXIF DateTimeDigitized、Image DateTime 的顺序读取,全部失败再降级到文件修改时间:
  1. import os
  2. import exifread
  3. from datetime import datetime
  4. def get_photo_taken_time(file_path, timezone_offset_hours=0):
  5.     try:
  6.         with open(file_path, 'rb') as f:
  7.             tags = exifread.process_file(f, details=False)
  8.         for tag in ('EXIF DateTimeOriginal', 'EXIF DateTimeDigitized', 'Image DateTime'):
  9.             if tag in tags:
  10.                 time_str = str(tags[tag])
  11.                 taken_time = datetime.strptime(time_str, '%Y:%m:%d %H:%M:%S')
  12.                 taken_time = taken_time.replace(
  13.                     hour=(taken_time.hour + timezone_offset_hours) % 24
  14.                 )
  15.                 return taken_time
  16.     except Exception:
  17.         pass
  18.     mtime = os.path.getmtime(file_path)
  19.     return datetime.fromtimestamp(mtime)
复制代码

这个写法体现了两个原则:批量处理时单张坏照片不应中断整个队列;解析失败就走降级路径。原文示例直接返回修改时间,所以主流程里的 taken_time is None 分支实际很少触发。如果你希望没有 EXIF 的文件全部进 unknown,应把降级分支改成 return None。

四、主流程:扫描、归类、防冲突

主流程先收集移动计划,再统一执行,避免边遍历边移动导致漏文件。支持扩展名覆盖常见 JPEG、PNG、TIFF、HEIC/HEIF 和相机 RAW:
  1. import os
  2. import shutil
  3. SUPPORTED_EXTS = {
  4.     '.jpg', '.jpeg', '.tif', '.tiff', '.png',
  5.     '.bmp', '.heic', '.heif', '.nef', '.cr2',
  6.     '.arw', '.dng', '.orf', '.rw2'
  7. }
  8. def organize_photos(source_dir, target_dir, dry_run=True):
  9.     move_plan = []
  10.     for root, _, files in os.walk(source_dir):
  11.         for filename in files:
  12.             ext = os.path.splitext(filename)[1].lower()
  13.             if ext not in SUPPORTED_EXTS:
  14.                 continue
  15.             src_path = os.path.join(root, filename)
  16.             taken_time = get_photo_taken_time(src_path)
  17.             if taken_time is None:
  18.                 target_relpath = os.path.join('unknown', filename)
  19.             else:
  20.                 date_folder = taken_time.strftime('%Y-%m-%d')
  21.                 timestamp = taken_time.strftime('%Y%m%d_%H%M%S')
  22.                 new_filename = f'{timestamp}_{ext}'
  23.                 target_relpath = os.path.join(date_folder, new_filename)
  24.             dst_path = os.path.join(target_dir, target_relpath)
  25.             counter = 1
  26.             while os.path.exists(dst_path) and src_path != dst_path:
  27.                 name_part = dst_path.rsplit('.', 1)[0]
  28.                 new_filename = f'{timestamp}_{counter:02d}{ext}'
  29.                 dst_path = os.path.join(target_dir, date_folder, new_filename)
  30.                 counter += 1
  31.             move_plan.append((src_path, dst_path))
  32.     if dry_run:
  33.         for src, dst in move_plan:
  34.             print(f'[计划移动] {src} -> {dst}')
  35.         print(f'共计划处理 {len(move_plan)} 个文件')
  36.         return
  37.     for src, dst in move_plan:
  38.         os.makedirs(os.path.dirname(dst), exist_ok=True)
  39.         shutil.move(src, dst)
  40.         print(f'[已移动] {src} -> {dst}')
复制代码

关键点:move_plan 先收集再执行;目标文件已存在时追加 _01、_02 序号;源路径与目标路径相同则跳过;移动使用 shutil.move 而不是 os.rename,因为 os.rename 跨磁盘分区会报跨设备错误,shutil.move 会先复制再删除。原文示例里 new_filename 直接拼接了扩展名,实际落地时可按需要改成 f'{timestamp}{ext}' 或在冲突分支中再补序号;unknown 分支也需要自行补全,避免 date_folder 未定义。

五、先 dry-run,再用 CSV 日志回滚

整理照片前先设 dry_run=True,只打印计划,不动文件。重点检查:日期是否为拍摄当天;是否整批偏移;是否有大量文件进入 unknown。确认后改 False 执行。执行前建议把 source、target、status 写入 CSV,状态先是 planned,执行成功后改为 moved。如果发现某批照片被错误归到 2009 年,可以读回 CSV,把 target 反向移动到 source。日志让整理过程可审计,也让意外修改能在几分钟内恢复。

六、实战中的典型问题

没有 EXIF 的照片很常见:截图 PNG、微信或社交平台保存的图、二次导出未保留元数据的图、老照片扫描件。不要盲目信任文件修改时间,因为批量复制可能把所有文件的修改时间切成同一时刻。可以加 USE_MTIME_FALLBACK 开关,可疑时关闭,把无 EXIF 文件统一路由到 unknown 再人工处理。同一秒连拍会出现文件名冲突,除了追加序号,还可以比较 SHA256 或 MD5,内容相同就保留一份并记录重复跳过,减少副本。Windows 下深层目录加长文件名容易触发 260 字符路径限制,目标目录尽量放在 D:/PhotoArchive/ 这类根层级附近,控制文件名长度。HEIC 在 Windows 上可能读不到完整 EXIF,能识别就识别,识别不了就按修改时间归类并保留扩展名,不要和 HEIC 转 JPG 混在一个脚本里。视频混排时,简单方案是按修改时间归类,复杂方案用 ffprobe 读取创建时间。旧扫描件、网图可以用正则从文件名猜年月日,优先级放在 EXIF 之后、修改时间之前,并归入 guessed 标记目录。增量整理可把已处理源路径或哈希写入 CSV,下次先加载并跳过。

最后,小样本验证、dry-run、日志回滚、再全量执行,这套流程不仅适用于照片,也适用于任何批量文件整理脚本。原文给出的核心事实是:拍摄时间应优先来自 EXIF DateTimeOriginal,文件名应可排序,移动要用跨磁盘安全的 shutil.move,时间不可靠时先怀疑数据源再改脚本。
回复

使用道具 举报

您需要登录后才可以回帖 登录 | 注册

本版积分规则

指导单位

江苏省公安厅

江苏省通信管理局

浙江省台州刑侦支队

DEFCON GROUP 86025

Hacking Group 021A

旗下站点

态势感知中心

应急响应中心

红盟安全

联系我们

官方QQ群:112851260

官方邮箱:security#ihonker.org(#改成@)

官方核心成员

关注微信公众号

Archiver|手机版|小黑屋| ( 沪ICP备2021026908号 )

GMT+8, 2026-10-9 13:42 , Processed in 0.026402 second(s), 18 queries , Gzip On, Redis On.

Powered by ihonker.com

Copyright © 2015-现在.

  • 返回顶部