☰
用Python按拍摄日期批量重命名照片并自动归档
2026/10/10 13:38:06 网站建设 项目流程

你有没有经历过这样一个瞬间:把手机、相机、存储卡里的照片一股脑导进电脑,满屏都是IMG_1234.jpg、DSC_0001.jpg、_MG_2837.heic,想找某一天的照片只能一张张翻。更麻烦的是,下次导出照片,文件名可能又从0001开始,两个文件夹里的IMG_0001根本不是同一张。时间一长,整个照片库就是一片混沌。

这篇文章讲的,就是用 Python 做一件非常具体的事:批量重命名照片,并按拍摄日期自动归类到对应的文件夹。我会从照片里拍摄日期到底存在哪里、怎么读出来开始讲,再给出一套可以直接复制运行的脚本,最后把我在实际批量整理中踩过的坑、总结的经验都摊开说。适合所有照片开始多到失控的人,也适合想用一个真实项目练手 Python 文件处理、元数据解析的读者。

1. 项目从哪来:一堆乱码照片的整理诉求

先说结论:照片的整理逻辑,应该围绕“拍摄时间”展开,而不是围绕文件名或下载时间。因为文件名是相机随机生成的,没有任何语义;下载时间取决于你哪天导出,不是照片真实产生的时间。只有拍摄时间是大批量照片自身携带、且能稳定识别的时间线索。

我最初需要做这个项目,是因为一次备份迁移:某次我把一块旧硬盘里的全部照片拷出来,发现同一个文件夹里既有按日期手工建好的目录,又有几百张散落在根目录、文件名完全不同规则的照片。人工整理了一下午,头都大了。当时我就在想,这个动作完全可以写成脚本,让电脑自己去读照片里的拍摄日期、自动建文件夹、自动改文件名。

1.1 核心需求:自动识别拍摄日期并归类

这个项目的核心需求其实只有三件事:

  1. 遍历一个目录下的所有照片文件;
  2. 从每张照片里读出拍摄时间;
  3. 以拍摄时间为依据,把照片移动到年-月-日这样的目录下,并给它一个新的、可排序的文件名。

听起来很简单,但真正落地时有一个关键问题:“拍摄时间”从哪里读?如果你只是用os.path.getmtime()拿文件的修改时间,那大概率会翻车。因为照片被导出、复制、发到微信再保存、用修图软件重新保存后,文件的修改时间会被系统改成“当前时间”或“保存时间”,根本代表不了拍摄时间。

所以必须回到照片本身,去读数码相机写入照片的 EXIF 信息。

1.2 为什么要用 Python,而不是现成软件

市面上确实存在很多照片管理软件可以自动整理,但它们的逻辑往往是黑盒:你不知道它依据什么时间归类,也不好自定义命名规则。对于我这种对文件管理有强迫症的人,更希望整理结果是“明明白白的一堆文件夹”,不依赖某个软件才能浏览。

Python 的优势在于:每一个动作都是透明的,可以控制,可以测试,可以回滚。拿exifread这类库读 EXIF,拿os.walk遍历目录,拿shutil.move移动文件,代码量不大,而且每一步你都能看到日志、知道发生了什么。就算出了问题,也能根据日志恢复。这种可控性,是图形化软件很难给的。

2. 原理先行:拍摄日期藏在照片哪里

很多人第一次写这个脚本时,会天真地以为“照片 + 文件修改时间”就够了。等你真正运行起来就会发现:同一批照片,有的修改时间是导入当天,有的是拍摄当天,有的是某次备份时被 touch 过。所以必须理解 EXIF。

2.1 EXIF 与 DateTimeOriginal,别被修改时间骗了

EXIF 是数码相机在拍照时写入照片文件头的一组元数据,里面包含相机品牌、型号、镜头参数、光圈、快门、ISO,当然也包含拍摄时间。其中和拍摄时间最相关的标签有三个:

  • Image DateTime:相机机身写入的最后修改时间,通常是拍照时间,但被相机处理过之后可能变化;
  • EXIF DateTimeOriginal:原始拍摄时间,这是最可靠的时间,绝大多数相机和手机都会写入;
  • EXIF DateTimeDigitized:照片被数字化/生成的时间,和原始拍摄时间通常一致。

在 Python 中,用exifread读取时,这三个标签对应的键名分别是Image DateTime、EXIF DateTimeOriginal、EXIF DateTimeDigitized。一个典型的 EXIF 时间字符串长这样:

2023:10:05 14:22:33

注意:中间的分隔符是冒号,不是短横线,所以解析时不能直接strptime成%Y-%m-%d,要写成%Y:%m:%d %H:%M:%S。这个细节我在第一次写脚本时就踩过,解析出来全报错。

读取 EXIF 的代码非常短:

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这样的值。如果照片来自某些手机App或截图,这个标签可能不存在,那么我会再去检查Image DateTime;如果都没有,才退而求其次使用文件修改时间。这个“三级降级策略”可以覆盖绝大多数照片。

2.2 重命名规则设计:文件名即时间轴

读取到拍摄时间之后,另一个关键决策是:新文件名到底叫什么?

我推荐的结构是:

YYYYMMDD_HHMMSS_序号.扩展名

例如:

20231005_142233_01.jpg 20231005_142233_02.jpg

这么命名的好处有两个:

  • 按文件名排序,就等于按拍摄时间排序;
  • 同一秒内多张连拍,用后面的两位序号区分,不会互相覆盖。

如果你拍摄的照片包含多个机位、多台设备,还可以在序号前追加相机型号的简化标识,比如:

20231005_142233_A7M4_01.jpg 20231005_142233_IPHONE_01.jpg

但我不建议把相机型号直接写进常用文件名里,因为型号字符串可能包含空格、斜杠等特殊字符,处理起来非常麻烦。真要区分设备,更好的办法是在文件目录上做文章,比如先按年份建目录,再按相机型号建子目录,最后是日期文件夹。

目录结构上,最简单的平铺方案是:

整理目录/ 2023-10-05/ 20231005_142233_01.jpg 2023-10-06/ 20231006_091023_00.jpg

更细的方案是嵌套目录年/月/日:

整理目录/ 2023/ 10/ 05/

平铺方案的优势是一眼能看到所有日期,适合照片总量不大、以“事件日”为检索单位的家庭相册;嵌套方案则在照片量达到数万张时,能明显减少单个文件夹里的文件数量,打开更快、备份更快。我在下面的脚本里先用平铺方案,因为逻辑最简单;后面扩展章节会讲怎么改成嵌套。

2.3 时区和时间格式:一张照片存在“两个时间”

还有一个容易被忽视的问题:EXIF 时间通常是相机本地时间,但文件系统里的修改时间可能是协调世界时,也可能是本地时间,取决于系统设置。如果相机的时间设置和电脑不一致,就会出现照片被归类到相差几个小时的文件夹里的情况。

更麻烦的是,部分安卓手机会在照片里写入“自拍时的时间”和“UTC时间”两套信息,但这两个标签不是标准的 EXIF 标签,exifread不一定读得到。所以我会在脚本里提供一个可选的timezone_offset_hours参数:如果发现整理结果里有整批照片都偏移了几个小时,只需要把这个参数设置为对应的值,脚本就会在解析完 EXIF 时间后统一做时间上的加减换算。

比如:

from datetime import timedelta taken_time = taken_time + timedelta(hours=timezone_offset_hours)

这个功能虽然简单,但处理海外旅行照片、跨境活动照片时,能救很多命。

3. 动手实现:完整脚本保姆级拆解

下面这套脚本,我尽量写得“开箱即用”。你只要把源码复制下来,把开头的SOURCE_DIR和TARGET_DIR改成你的真实目录,先以DRY_RUN = True运行一次,确认打印的整理方案没问题,再改成False执行。

3.1 环境准备与依赖安装

我默认你的机器上有 Python 3.6 以上的版本。其他依赖只需要一个exifread,用来读取 EXIF 信息。安装命令:

pip install exifread

如果你不想用第三方库,也可以选择Pillow来读取 EXIF:

from PIL import Image from PIL.ExifTags import TAGS img = Image.open('IMG_1234.jpg') exif_data = img._getexif()

但实测下来,Pillow 对某些相机 RAW 文件、特殊编码的 HEIC 照片支持并不好,而且exifread更轻量,不需要打开整个图像数据,只读元数据,速度快很多。所以我的首选是exifread。如果你需要处理 HEIC 格式,通常还需要额外安装pillow-heif之类的基础库,这一部分我在后面会单独说明。

3.2 读取拍摄时间的核心函数

先写一个函数,输入文件路径,输出一个datetime对象。逻辑是:逐级尝试读取 EXIF 标签,全部失败就读取文件修改时间:

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)

这里有两个细节要注意:

  • details=False让exifread不解析内嵌缩略图,速度更快;
  • 如果照片时间字符串格式不符合预期(有些早期相机写的秒数可能是00之外的奇怪值),strptime会抛异常,整个函数会走 except 分支,不会让主程序中断。

我认为这种“失败就降级”的策略比直接向用户抛错更合适。批量处理文件夹时,一张坏照片不应该卡住整个队列,它宁可被当成未知时间,放到一个unknown目录里,也不能让脚本崩溃。

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

接下来是核心主流程。遍历源目录下所有文件,过滤出照片扩展名,读取拍摄时间,算出目标文件夹和新文件名,然后移动。

import os import shutil from collections import defaultdict 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}')

这套逻辑的核心思路是:

  • 先收集所有“计划”,而不是边遍历边移动。因为在遍历过程中移动文件,会改变目录结构,可能导致漏文件;
  • 目标路径冲突时,用两位数序号递增,避免覆盖;
  • 如果目标路径和源路径是同一个文件(比如照片已经在正确的位置),直接跳过;
  • dry_run=True时只打印计划,这是一种保护机制,后面细讲。

safe_move的写法我用了shutil.move而不是os.rename,是因为当源文件和目标文件夹不在同一个磁盘分区时,os.rename会报跨设备错误,而shutil.move会先复制再删除,能自动处理跨盘移动。整理照片经常涉及“旧硬盘 -> 新硬盘”的场景,这个差异会在第4章详细说。

3.4 先模拟运行,再做真实修改

我现在整理任何一批文件,第一遍必然跑 dry-run。模拟运行的结果是一张计划表,而不是对文件执行任何写操作。你可以看到:

[计划移动] /old/IMG_0001.jpg -> /new/2023-10-05/20231005_142233_jpg.jpg [计划移动] /old/IMG_0002.jpg -> /new/2023-10-05/20231005_142233_01.jpg

打印出来后,先随机抽查几个文件,看看时间读得对不对。重点看:

  • 同一批照片的日期是否是拍摄当天的;
  • 相机内时间如果设置错了,是不是所有照片的时间都整体偏了;
  • 有没有大量文件落在unknown目录。

如果发现整体偏移,从 EXIF 读出来的时间是错的,那就调整timezone_offset_hours参数再重新跑 dry-run。这一步成本极低,却能把后面的意外风险降到最低。

4. 实战中必然会踩的坑

我自认为这套脚本的逻辑已经考虑得比较周全,但在真实照片库上跑的时候,还是遇到不少问题。这里挑几个最有代表性的写出来,希望你一次跳过所有坑。

4.1 读不到 EXIF:时间是空的怎么办

不是所有照片都有 EXIF 信息。最常见的情况包括:

  • 截图保存的 PNG;
  • 从社交平台、聊天工具里保存的图片(很多平台会剥离 EXIF);
  • 修图软件二次导出时没有勾选“保留元数据”;
  • 极老的照片扫描件。

这时,get_photo_taken_time()会走到os.path.getmtime()分支,用文件修改时间作为拍摄时间。这在大多数情况下是能接受的,但有一个例外:如果你批量复制照片时启用了“保留原文件时间”以外的方式,很多文件的修改时间会被统一切成复制那一刻,这会让照片全部堆到同一天。

解决办法是:把没有 EXIF 的文件单独路由到unknown文件夹,不要默认用修改时间,因为你很难判断这个修改时间可不可信。经过手工筛选后,再决定是手工归类,还是把修改时间当作参考时间。

我建议的配置是加一个开关USE_MTIME_FALLBACK:

USE_MTIME_FALLBACK = True

默认是True,但如果发现某一次整理结果高度可疑,就改成False,让没有 EXIF 的文件全部进unknown。

4.2 同一秒连拍导致的文件名冲突

相机连拍时,可能在同一个小时内出现大量拍摄时间完全相同的照片,尤其在持续时间极短的“一秒连拍”里。如果脚本只按时间戳.jpg生成文件名,第二张照片就会把第一张覆盖。

我在脚本里的防冲突逻辑,是在遇到目标文件已存在时追加_01、_02这样的序号。但有一个更隐蔽的问题:两个不同来源的照片,被复制到同一个日期文件夹时,如果时间巧合完全一致,追加序号会掩盖“这可能是同一张照片的副本”这个事实。

所以,如果整理的是从多个设备汇聚来的照片,建议在冲突时额外比较文件大小、文件哈希(MD5/SHA256),如果完全一致,可以选择保留一份并记录“重复文件,已跳过”,而不是生成_01。这个小小的差异,能让最终目录里的重复照片大幅减少。

伪代码思路:

if os.path.exists(dst_path): if sha256(src_path) == sha256(dst_path): print(f'[重复文件] {src_path} 与 {dst_path} 相同,跳过') continue

这样既不会丢内容,也不会产生一堆看不出差别的_01副本。

4.3 路径太长与跨磁盘移动

如果你用 Windows,而你的照片文件夹路径已经有三四层深,再套一层日期文件夹、新的长文件名,很容易触发 Windows 的路径长度限制(经典 260 字符限制)。遇到这种问题,脚本会中途报FileNotFoundError或者OSError。

我的建议:

  • 目标目录尽量放在盘的根层级附近,比如D:\PhotoArchive\;
  • 文件名不要塞入过多自定义信息,控制总长度;
  • 如果必须处理深层路径,可以在脚本开头启用 Windows 长路径支持,或者在 Python 中加前缀\\?\。但 Python 处理 UNC 路径和普通路径的差异比较隐蔽,不建议新手上来就用。

跨磁盘移动是另一个高频错误。很多整理脚本的初版会写os.rename(src, dst),在执行时遇到“不同盘符”直接报错。所以我在主流程里一直用shutil.move。这一点在代码注释里也写得非常清楚:shutil.move 是“跨设备安全”的,而 os.rename 不是。

4.4 意外修改后的回滚:用执行日志保命

整理 1000 张照片,就算 dry-run 检查过一遍,也难免有判断失误的时候。比如某批老照片的 EXIF 时间其实是相机内部电池没电后重置的时间,导致整批照片都被归到了 2009 年。这时候你需要的不是后悔,而是“撤销脚本”。

所以我在执行真实移动之前,会把所有移动计划写进一个 CSV 文件:

source,target,status /old/IMG_0001.jpg,/new/2023-10-05/20231005_142233_jpg.jpg,planned /old/IMG_0002.jpg,/new/2023-10-05/20231005_142233_01.jpg,planned

执行时再更新状态为moved。如果事后发现某个批次有问题,可以读回这个 CSV,把moved的target反着移动回source。这会带来两个好处:

  • 整理过程可审计;
  • 犯错后可以在几分钟内恢复原样。

我个人现在把 CSV 日志当作和照片本身同等重要的产物来对待,每次整理完都会留一份归档。

5. 让整理脚本适配更多真实场景

基础版本跑通之后,你会发现这个脚本的想象空间比想象中更大。按拍摄日期归类只是起点,真实场景里还有一堆奇奇怪怪的需求。

5.1 目录结构从平铺到嵌套:按年/月/品牌分组

如果照片量达到数万张,平铺的2023-10-05文件夹就会变得很多,浏览起来仍然费劲。这时可以把目录结构改成:

目标目录/ 2023/ 10月/ 05日/ ...

实现方式很简单:date_folder从taken_time.strftime('%Y-%m-%d')改成taken_time.strftime(os.path.join('%Y', '%m月', '%d日'))。如果还想按相机品牌分组,可以用exifread读Image Model或Image Make标签,生成子目录。

我实际使用中发现,按相机品牌分组的准确性取决于 EXIF 标签是否完整。有些修图软件会把Model字段改掉,导致同一台相机的照片散落在不同目录。如果遇到这种情况,最好手动维护一个“机型名缩写映射表”,不要直接把原始字符串当文件夹名。

5.2 视频、HEIC、RAW 混在一起怎么处理

现代人的照片库很少只有 JPEG。手机拍出的 HEIC、相机拍的 RAW、各种短视频,都会混在一起。基础脚本只处理图片扩展名,视频会全部留下。

处理视频的简单方案是:读取文件修改时间,按其归类;复杂方案是用ffprobe读取视频元数据里的创建时间。对大多数家庭归档需求,视频用修改时间归类就够了,因为手机导出视频时修改时间通常就是拍摄时间。

HEIC 照片在 Windows 上比较麻烦,exifread直接读 HEIC 可能拿不到完整元数据。我建议的处理方式是:

  • 能识别 EXIF 就尽量识别;
  • 识别不了,就按修改时间归到目标目录;
  • 保留原始扩展名,不强制转换成 JPG。

把照片从 HEIC 批量转换成 JPG 是另一个大工程,不建议和归类脚本混在一起做。先完成整理,再单独跑转换,问题定位会清晰很多。

5.3 用文件名猜时间(旧扫描件、网图)

有一类照片没有任何 EXIF,文件修改时间也完全不可信,比如老一辈用扫描仪扫出来的老照片。可是这些照片的文件名往往带有信息,例如1998-07-15_全家福.jpg,或者scan_1999_0001.jpg。

这时可以写一个guess_time_from_filename()函数,用正则表达式在文件名里寻找年份、月份、日期。如果找到,就直接用这个时间;如果没找到,再回退到修改时间。

我的经验是:这种“文件名猜时间”的方案,最好只作为一个辅助优先级排在 EXIF 之后、修改时间之前,因为文件名里的日期不一定准确,但通常比系统修改时间靠谱。你可以把这些照片单独归到一个guessed标记目录,后续手工核对。

5.4 把整理动作接到备份流水线里

整理照片不应该是一次性的。每月导出一次手机照片,之后跑一遍脚本自动归档,这个习惯能让你永远不需要面对“几千张乱文件”的绝望。你可以把主流程封装成一个函数,在每月备份后自动调用;或者设一个定时任务,只处理新增文件。

为了增量整理,你可以在 CSV 日志里保存已经处理过的文件哈希或源路径,下一次运行时先加载这个“已处理集合”,跳过已经归档过的文件。这样脚本越跑越快,也不会重复移动。

## 写到最后:一点实用的个人体会 这套脚本我实际跑过很多次,最大的体会是:**整理照片真正的难点不是文件操作,而是时间数据不可靠**。你永远要对“读出来的时间”保持怀疑,永远要先跑一遍 dry-run,永远要留日志。很多人学 Python 时喜欢做爬虫、做数据分析,但我觉得“批量重命名照片并按拍摄日期归类”才是最适合普通人的练手项目:需求明确、反馈直观、错误模式丰富,而且做完了真的有用。 最后再分享一个小技巧:第一次运行真实整理之前,建议先拷贝几十张不同来源的照片到一个临时目录,在那里面测试脚本。这样即便脚本把目录清空了,损失的也只是测试副本。等临时目录验证通过,再对正式照片库下手。这套“小样本验证,日志回滚,再全量执行”的流程,放到任何文件整理场景里都成立。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询