照片导出到电脑后,文件名常常是 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 读取时键名就是这些字符串。典型值如下:
- import exifread
- with open('IMG_1234.jpg', 'rb') as f:
- tags = exifread.process_file(f, details=False)
- 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 的顺序读取,全部失败再降级到文件修改时间:
- import os
- import exifread
- from datetime import datetime
- def get_photo_taken_time(file_path, timezone_offset_hours=0):
- try:
- with open(file_path, 'rb') as f:
- tags = exifread.process_file(f, details=False)
- for tag in ('EXIF DateTimeOriginal', 'EXIF DateTimeDigitized', 'Image DateTime'):
- if tag in tags:
- time_str = str(tags[tag])
- taken_time = datetime.strptime(time_str, '%Y:%m:%d %H:%M:%S')
- taken_time = taken_time.replace(
- hour=(taken_time.hour + timezone_offset_hours) % 24
- )
- return taken_time
- except Exception:
- pass
- mtime = os.path.getmtime(file_path)
- return datetime.fromtimestamp(mtime)
复制代码
这个写法体现了两个原则:批量处理时单张坏照片不应中断整个队列;解析失败就走降级路径。原文示例直接返回修改时间,所以主流程里的 taken_time is None 分支实际很少触发。如果你希望没有 EXIF 的文件全部进 unknown,应把降级分支改成 return None。
四、主流程:扫描、归类、防冲突
主流程先收集移动计划,再统一执行,避免边遍历边移动导致漏文件。支持扩展名覆盖常见 JPEG、PNG、TIFF、HEIC/HEIF 和相机 RAW:
- import os
- import shutil
- SUPPORTED_EXTS = {
- '.jpg', '.jpeg', '.tif', '.tiff', '.png',
- '.bmp', '.heic', '.heif', '.nef', '.cr2',
- '.arw', '.dng', '.orf', '.rw2'
- }
- def organize_photos(source_dir, target_dir, dry_run=True):
- move_plan = []
- for root, _, files in os.walk(source_dir):
- for filename in files:
- ext = os.path.splitext(filename)[1].lower()
- if ext not in SUPPORTED_EXTS:
- continue
- src_path = os.path.join(root, filename)
- taken_time = get_photo_taken_time(src_path)
- if taken_time is None:
- target_relpath = os.path.join('unknown', filename)
- else:
- date_folder = taken_time.strftime('%Y-%m-%d')
- timestamp = taken_time.strftime('%Y%m%d_%H%M%S')
- new_filename = f'{timestamp}_{ext}'
- target_relpath = os.path.join(date_folder, new_filename)
- dst_path = os.path.join(target_dir, target_relpath)
- counter = 1
- while os.path.exists(dst_path) and src_path != dst_path:
- name_part = dst_path.rsplit('.', 1)[0]
- new_filename = f'{timestamp}_{counter:02d}{ext}'
- dst_path = os.path.join(target_dir, date_folder, new_filename)
- counter += 1
- move_plan.append((src_path, dst_path))
- if dry_run:
- for src, dst in move_plan:
- print(f'[计划移动] {src} -> {dst}')
- print(f'共计划处理 {len(move_plan)} 个文件')
- return
- for src, dst in move_plan:
- os.makedirs(os.path.dirname(dst), exist_ok=True)
- shutil.move(src, dst)
- 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,时间不可靠时先怀疑数据源再改脚本。 |