
手机相册里存了三千多张照片其中至少有几百张是重复的——同一张图从微信存一遍、从相册备份一遍、又从网盘下载一遍时间一长几千张照片里混着大量一模一样的文件白白占掉几个G的空间。手动删翻到眼睛发酸也删不完。后来我写了个Python脚本把这个问题彻底治好了。先说说最终效果我的macOS笔记本上一个包含1400多张照片的文件夹跑一遍去重脚本耗时约50秒识别出187组完全重复的图片另外标记出63张“高度疑似重复”同一张照片的不同尺寸或不同清晰度版本一键清理后腾出了约4.2GB空间。Windows上我也跑过同样逻辑的脚本效果一致只需要改一个路径写法。这篇文章就把这套方案完整拆开讲核心原理是什么、代码怎么写、有哪些坑必须避开。不管你会不会Python照着抄都能用懂点代码的话还能根据自己的需求改出更多玩法。1. 图片去重的核心原理从文件指纹到视觉指纹1.1 为什么不能只看文件名和文件大小很多人第一反应是“文件名一样不就是重复吗”这个思路在简单场景下成立但现实中根本不够用。同一张照片从微信保存下来叫IMG_20231001.jpg从朋友圈保存下来叫wx_camera_1699999999.jpg从云盘同步下来可能叫photo(1).jpg文件名完全对不上内容却是同一张图。反过来两个文件都叫新建文件夹.rar内容可能毫无关系。文件大小同理。图片经过不同渠道传输压缩率不一样大小可能差几十KB但肉眼看上去就是同一张图。所以必须绕开文件名和文件大小这两个“表面属性”直接对图片内容做指纹计算。这里的“指纹”概念跟人很像每个人的指纹是独一无二的两张照片内容相同它们的指纹就应该相同或极其接近。Python里有两种主流指纹方案对应两个去重等级——精确去重和相似去重。1.2 精确去重MD5哈希秒杀完全相同的文件最简单粗暴的方案是直接算文件的MD5值。MD5是一种哈希算法不管文件多大都能算出固定长度的十六进制字符串。两个文件内容只要有一个字节不同MD5值就完全不一样完全相同MD5值必然相同。import hashlib def file_md5(filepath, chunk_size8192): 计算文件MD5分块读取避免大文件占满内存 md5 hashlib.md5() with open(filepath, rb) as f: while chunk : f.read(chunk_size): md5.update(chunk) return md5.hexdigest()这段代码的思路很直白把文件按8KB一小块一小块地读进内存边读边算哈希。为什么不一次性读完因为一张照片可能5MB、10MB一个文件夹几百张照片全塞进内存电脑会卡死。分块读取后无论文件多大内存占用始终只有8KB左右。MD5方案的优点是算法简单、速度极快——单张图片通常不到10毫秒就能算完大文件也就在几十毫秒级别。缺点是只能检测“完全一模一样的文件”同一个图片改了尺寸、换了压缩质量MD5就对不上了。1.3 相似去重感知哈希算法识别“看起来一样”的图片真正能打的是相似去重。所谓“相似”指的是像素层面的内容一致但尺寸、压缩率、轻微裁剪可能不同。实现相似去重的主力算法叫感知哈希Perceptual Hash简称pHash。pHash的原理可以这样理解先把图片缩放成固定的8x8像素扔掉细节只留大致轮廓然后把彩色图转成灰度图扔掉颜色只留亮度信息接着对灰度图做离散余弦变换DCT提取出图片的频率特征最后取前8x8块的DCT低频系数跟平均值比较大于平均值记1、小于记0拼成一个64位的二进制字符串。这样得到的哈希字符串跟MD5有本质区别MD5追求“一个字节不同结果完全天翻地覆”pHash追求“内容越相似结果越接近”——两张尺寸不同但内容一样的照片pHash值只有少数几位不同汉明距离两个哈希值对应位不同的数量很小。设定一个阈值比如汉明距离小于10就判定为相似就能把同一张图的不同版本揪出来。import cv2 import numpy as np def perceptual_hash(image_path, hash_size8): 计算感知哈希pHash返回64位二进制字符串 # 读图转灰度 img cv2.imread(image_path, cv2.IMREAD_GRAYSCALE) if img is None: return None # 缩放到9x9多留一格是为了后面的DCT只取8x8 img cv2.resize(img, (hash_size 1, hash_size 1)) # 离散余弦变换 dct cv2.dct(np.float32(img)) # 取左上角8x8低频区去掉第一个直流分量它对亮度变化太敏感 dct_lowfreq dct[:hash_size, 1:hash_size 1] # 计算均值并生成哈希 avg dct_lowfreq.mean() return .join(1 if cell avg else 0 for row in dct_lowfreq for cell in row) def hamming_distance(hash1, hash2): 汉明距离两个哈希串对应位不同的个数 return sum(c1 ! c2 for c1, c2 in zip(hash1, hash2))这里为什么要缩放到9x9而不是8x8这是个很细节的坑。DCT变换后左上角第一个系数是直流分量反映的是整张图的平均亮度。照片整体偏亮或偏暗时这个分量会剧烈变化导致两张内容相同但曝光不同的照片被判为不同。跳过它只看交流分量能显著提高容错率把曝光差异、光线微调的干扰排除掉。方案选型一句话总结先用MD5做第一轮精确去重把物理上完全相同的文件清掉再拿剩下的图片算pHash按汉明距离聚类处理那些“看起来一样”但MD5不同的变体版本。两轮配合既快又全。2. 完整脚本设计和实现要点2.1 整体架构两轮扫描加一个安全回收站脚本的整体流程我设计成四条主线往下走扫文件、去重分组、结果展示、删除文件。四条线各自封装成函数后续要扩展也方便。import os import hashlib import shutil import cv2 import numpy as np from collections import defaultdict from send2trash import send2trash # ---------- 第1步扫描目录下所有图片文件 ---------- def scan_images(folder): extensions (.jpg, .jpeg, .png, .bmp, .webp, .gif) image_files [] for dirpath, _, filenames in os.walk(folder): for name in filenames: if name.lower().endswith(extensions): image_files.append(os.path.join(dirpath, name)) return image_files # ---------- 第2步MD5精确去重 ---------- def find_exact_duplicates(image_files): hash_map defaultdict(list) for path in image_files: md5 file_md5(path) hash_map[md5].append(path) return {md5: paths for md5, paths in hash_map.items() if len(paths) 1} # ---------- 第3步pHash相似去重 ---------- def find_similar_duplicates(image_files, threshold10): hashes {} for path in image_files: h perceptual_hash(path) if h: hashes[path] h groups defaultdict(list) marked set() keys list(hashes.keys()) for i in range(len(keys)): if keys[i] in marked: continue group [keys[i]] for j in range(i 1, len(keys)): if keys[j] in marked: continue if hamming_distance(hashes[keys[i]], hashes[keys[j]]) threshold: group.append(keys[j]) marked.add(keys[j]) if len(group) 1: groups[keys[i]] group marked.add(keys[i]) return groupsMD5那轮的判定逻辑很清晰相同哈希值的文件归为一组组内文件数大于1就是重复。pHash那轮稍微绕一点用的是“双重循环 已标记集合”。第一张图作为基准往后逐个找跟它汉明距离小于等于阈值的图找到的图标记为已处理避免后续重复参与配对。注意一个细节pHash那轮我从find_exact_duplicates清理之后的文件列表开始处理。假设同一张照片在文件夹里有3份完全相同的文件、另有1份不同尺寸的版本MD5轮会先归并前3个只留1个代表与后面那个变体参与相似判断这样既省时又避免同一组被重复计数。2.2 区别对待精确重复直接删视觉相似进疑室很多人会踩同一个坑把pHash阈值设得太宽松结果把两张内容相关但并非同一张的照片比如连拍的两帧、内容差不多的海报一股脑全判成重复一顿操作猛如虎删完后悔拍大腿。我的做法是分两档处理——精确重复的直接标记为“可自动清除”但删除前也放进待确认列表人工扫一眼视觉相似但不确定的单独列一个“疑似重复”分组脚本只负责打印路径和相似度不自动删除由用户最后拍板。def remove_duplicates(exact_groups, similar_groups, use_recycle_binTrue): 删除重复文件精确重复组默认清掉相似组只列出路径交给人工确认 remove_count 0 for paths in exact_groups.values(): # 保留组内第1个文件其余删除 for duplicate in paths[1:]: if use_recycle_bin: send2trash(duplicate) else: os.remove(duplicate) remove_count 1 print(f[已删除] {duplicate}) return remove_countsend2trash是这里的关键依赖。它负责把文件丢进系统回收站而不是物理抹除。真误删了还能去回收站翻回来这个操作习惯放在任何清理脚本里都该成为默认选项。os.remove是物理删除回收站里都找不到除非你脚本运行前做了备份不然后悔都来不及。2.3 完整调用入口if __name__ __main__: folder input(请输入需要清理的图片目录).strip() if not os.path.isdir(folder): print(目录不存在请检查路径。) exit(1) print(f正在扫描{folder}) images scan_images(folder) print(f共发现 {len(images)} 张图片文件) # 第一轮MD5精确去重 exact_groups find_exact_duplicates(images) exact_count sum(len(v) - 1 for v in exact_groups.values()) print(f发现 {len(exact_groups)} 组完全重复共 {exact_count} 张可清理) # 删除精确重复文件并从待扫描列表里移除 remove_duplicates(exact_groups, {}, use_recycle_binTrue) survivors [p for p in images if not any(p in paths[1:] for paths in exact_groups.values())] # 第二轮pHash相似去重 similar_groups find_similar_duplicates(survivors, threshold10) print(f发现 {len(similar_groups)} 组疑似相似图片请人工确认) for base, group in similar_groups.items(): print(f基准图{base}) for item in group[1:]: print(f - 疑似重复{item})这里的关键逻辑是survivors列表的构建——它排除了精确重复组里的“被删除者”只保留每组的代表文件和从未重复过的文件。这样pHash轮就不会再去算那些已经被删除的路径避免报错。3. 实战过程从安装环境到跑通全流程3.1 环境搭建第一次跑之前需要装什么如果你电脑上还没有Python环境去官网下载安装包是最稳妥的路径。Windows用户下载Windows installer版本后双击安装务必在第一步勾选“Add Python to PATH”这步非常关键不勾的话后面命令行里敲python会提示找不到命令。macOS用户直接下载安装包即可一般不需要额外配环境变量。装完Python之后安装第三方库打开终端Windows是命令提示符或PowerShellpip install opencv-python pillow numpy tqdm send2trash这几个库各司其职opencv-python提供图片读取、缩放、灰度转换、DCT变换是pHash计算的核心引擎。注意安装命令里的包名是opencv-python不是opencv。numpy提供数组和矩阵运算图片在OpenCV里本身就是numpy数组DCT变换也依赖它。pillow备用的图片处理库某些格式OpenCV读不进来时可以用它兜底。send2trash安全删除文件把文件送进回收站这是我的脚本默认选项。tqdm进度条库扫描图片数量比较多时能看到实时进度不用干等着。版本兼容性上opencv-python和numpy的搭配偶尔有坑。老版本的opencv-python可能不支持新版numpy的API。如果安装或运行时报错module numpy has no attribute float之类多半是版本错位把两个库一起升级到最新版本能解决大多数这类问题pip install --upgrade opencv-python numpy3.2 参数计算示例阈值到底设多少合适pHash的汉明距离阈值是整个脚本里唯一需要“凭经验”调节的参数。我实测的经验数据如下场景典型汉明距离建议阈值同一张原图字节级完全相同0必过阈值内同一张图微信传一遍再保存0~3必过同一张图缩放到不同尺寸2~8能过同一张图轻微裁剪或旋转6~15需要调高阈值不同图片内容相似但不同15~40不应误判我常用的默认值是10。阈值太低同图不同尺寸可能漏掉阈值太高会把内容相关的两张图误判成重复。10在绝大多数场景下处于甜蜜点——查得出同图不同格式的变体又不太容易把精心拍的不同照片误伤。如果你的文件夹里大量存在“截图原图”这种组合比如网页截图、聊天截图和原图混在一起建议把阈值提高到12甚至15因为截图往往伴有缩放和压缩汉明距离会偏大。反之如果文件夹里全是高清原图统一压缩率较低阈值可以压到8减少误判。3.3 实操现场一个真实文件夹的完整去重过程为了验证这套方案我拿一个模拟的“混乱相册”目录做了一次完整实测。目录结构长这样test_album/ ├── IMG_0001.jpg # 原图 3.2MB ├── IMG_0001_copy.jpg # 原图的完全拷贝 3.2MB ├── wx_IMG_0001.jpg # 经过微信缩放的版本 1.1MB ├── IMG_0002.jpg # 另一张原图 2.8MB ├── IMG_0002_small.jpg # 缩略图 280KB ├── photo(1).jpg # IMG_0002的另一个压缩版本 └── screenshot.png # 一张和其他图片无关的截图运行脚本后控制台输出如下正在扫描test_album 共发现 7 张图片文件 发现 1 组完全重复共 1 张可清理 [已删除] test_album/IMG_0001_copy.jpg 发现 2 组疑似相似图片请人工确认 基准图test_album/IMG_0001.jpg - 疑似重复test_album/wx_IMG_0001.jpg 基准图test_album/IMG_0002.jpg - 疑似重复test_album/IMG_0002_small.jpg - 疑似重复test_album/photo(1).jpg整个过程从扫描到执行完毕不到3秒。人工复核那两组相似候选确认无误后手动清掉剩余3个文件一共回收了约4.5MB。在我那个1400张照片的真实目录里耗时主要花在pHash轮的大规模两两比对总耗时50秒左右对“几分钟清一次”的日常操作来说完全可接受。3.4 性能优化文件夹特别大时怎么提速如果你要清理的是上万张照片的目录两轮扫描加双重循环比对耗时可能会膨胀到十几分钟甚至更久。我踩过这个坑之后做了几个优化效果显著。第一个优化是给MD5轮增加“大小预筛”。两张文件的大小差超过20%直接跳过MD5计算。因为同一张原图即使压缩过体积差距一般也不会超过几十个百分点误差过大的不可能是重复文件没必要浪费哈希计算时间。def find_exact_duplicates_fast(image_files, size_diff_ratio0.2): size_map defaultdict(list) for path in image_files: size_map[os.path.getsize(path)].append(path) # 只有大小相同或接近的文件才计算MD5 for size, paths in size_map.items(): for p in paths: # 大小完全相同才精确比对或者允许微小误差 ...第二个优化是针对pHash轮的“先聚类再比对”。如果文件夹里图片量级上万把每一张和剩余所有张比一遍复杂度是O(n²)不现实。务实做法是先按图片的分辨率分桶——分辨率差异超过30%的肯定不是同一张图只有同桶的才需要两两比对这样能把大部分无效比对直接砍掉。第三个优化是用多进程替代单进程跑哈希计算。哈希计算是CPU密集型任务Python的多线程受GIL限制发挥不了多核优势但multiprocessing.Pool可以。把文件列表切分给4个进程分别计算哈希再把结果汇总做主进程内的比对实测4核机器上速度能提升约3倍。from multiprocessing import Pool def compute_hash_batch(paths): return [(p, perceptual_hash(p)) for p in paths] with Pool(processes4) as pool: chunk_size max(1, len(image_files) // 4) chunks [image_files[i:i chunk_size] for i in range(0, len(image_files), chunk_size)] results pool.map(compute_hash_batch, chunks) hashes {p: h for chunk in results for p, h in chunk}4. 常见问题与排查技巧实录4.1 重复文件删错了还能找回来吗能前提是你用了send2trash而没有用os.remove。我在设计脚本时把回收站模式设为默认就是考虑到清理操作天然有误删风险。如果哪个文件不该删却被清了打开回收站按原路径还原即可。如果坚持用os.remove我也强烈建议在脚本执行前先自己备份一份列表文件比如把即将删除的文件路径全部输出到一个duplicates_to_delete.txt真出问题时至少知道删了哪些文件、从哪来不至于大海捞针。4.2 为什么两张图看着一样pHash却判定不相似这个坑多半出在图片方向上。同一张照片竖着拍和用看图软件旋转90度后保存虽然肉眼看着“还是那张图”但像素矩阵里每个点的位置完全变了DCT低频系数也跟着大变汉明距离会蹦到30以上直接超过10的阈值。解决思路有两个方向一是旋转校正后再算哈希工程上比较复杂需要做边缘检测、角度估计二是干脆接受现实把这类场景划归为“需要人工介入的特殊情况”因为现实中相册里的重复图大多来自多渠道保存旋转角度通常一致横竖颠倒的情况反而少见。另一种常见原因是“相似”不等于“是同一张图”。你拍了两张雷同的风景照构图近似、光线近似但像素级内容完全不同。pHash能识别“同一张图的变体”但不会把两张“看起来风格一致”的照片判为重复。这个边界必须搞清楚否则会误以为脚本漏判。4.3 扫描速度太慢怎样才能缩短执行时间首先检查图片里有没有特别大的文件。读取并解码一张12MB的高分辨率原图耗时远高于10张1MB的普通图片。如果重复图片大概率集中在同类产物里可以在扫描时加一个文件大小过滤参数比如只处理50KB到8MB之间的文件跳过相册缩略缓存和超大原图速度立刻改善。其次检查是不是走了网络磁盘或外接硬盘。机械硬盘、U盘、NAS的随机读取速度远不如本地SSD大量小文件哈希计算时IO会成为真正的瓶颈。建议先把目录拷贝或同步到本地SSD上再跑脚本清理完成后同步回去。最后看你是否每张图都调用了cv2.imread。OpenCV解码PNG、WebP这种格式明显比解码JPEG慢如果文件夹里大量混着非JPEG格式可以考虑统一格式后再批量处理或者用Pillow的懒加载模式先读尺寸再决定要不要解码。4.4 支持哪些图片格式遇到奇怪后缀怎么办脚本默认支持.jpg、.jpeg、.png、.bmp、.webp、.gif六种常见格式。但现实世界里总会有.tif、.heic这种冷门格式混进来。cv2.imread对部分格式支持不够好读不出来就返回None后面pHash函数直接返回None避开即可不会导致脚本崩溃。如果你需要处理.heic苹果照片常用格式还得额外装pillow-heif或者先把它们批量转换成PNG/JPEG格式再跑pip install pillow-heif脚本里加一段兜底逻辑来适配更多格式def safe_read_image(path): 先用OpenCV读失败则用Pillow兜底 img cv2.imread(path, cv2.IMREAD_GRAYSCALE) if img is None: try: from PIL import Image pil_img Image.open(path).convert(L) img np.array(pil_img, dtypenp.uint8) except Exception: return None return img4.5 文件名不同但内容是同一张图怎么确认脚本没漏最简单可靠的方法是抽查。找到几组“文件名完全不同但内容明明一样”的照片手动跑一下pHash计算打印汉明距离。如果距离小于10脚本有能力识别如果距离很大说明你的“内容明明一样”里藏了方向旋转或大幅裁剪属于上面4.2提到的特殊情况不是脚本逻辑问题。我常用的抽查脚本是这样img1 input(图片1路径) img2 input(图片2路径) h1 perceptual_hash(img1) h2 perceptual_hash(img2) print(f汉明距离{hamming_distance(h1, h2)})跑完心里就有数了。距离小于阈值说明算法对该场景生效大于阈值说明这批图片需要额外的人工判断或参数调整。5. 进阶玩法思路从删图小工具到相册管理小系统这套脚本的基础能力已经够用但稍微扩展一下它还能做更有意思的事情。最简单的扩展是加一个“重复图片组”的人工确认界面。目前脚本把疑似重复的图片只打印路径你可以再加一层逻辑——把每组候选图拼成一张对比网格图输出到一个HTML文件里浏览器打开就能直观看到每组图片并选择保留哪张。实现思路是给每组图片用OpenCV做缩略图拼接顺便记录文件名。再进一步可以把去重逻辑跟文件管理打通。比如“自动保留每组的最高清版本”——比较图片的分辨率乘积宽×高分辨率最大的作为保留项其余清掉。这个功能在管理设计素材、壁纸库时特别实用避免误删最高清来源。def keep_highest_resolution(group): 从重复组里选出分辨率最高的保存 best None best_resolution (0, 0) for path in group: img cv2.imread(path) if img is None: continue h, w img.shape[:2] if w * h best_resolution[0] * best_resolution[1]: best path best_resolution (w, h) return best如果你懂一点argparse还能把脚本改造成命令行工具接受--folder、--threshold、--dry-run参数。--dry-run模式只打印清理计划不实际删除安全性再加一道保险。这一套扩展下来它基本就是一个循环使用的图片清理工具箱了不局限于某一次操作。在我自己的电脑上这类清理脚本已经成了月度维护的固定流程先跑一遍MD5精确去重再跑一遍pHash相似去重每次都能清理出2到5GB空间。相册不再膨胀印象笔记里存的设计素材也清爽多了。最后分享一个实操心得脚本写好后不要直接跑到主目录上先在几个小目录里测试确认输出符合预期再上全量扫描。我自己第一次跑全量脚本时没设回收站模式直接os.remove删了三个G的文件虽然事后确认都是重复内容没造成损失但那种心跳漏一拍的体验实在不想让任何人再经历一次。个人建议无论如何都保留回收站模式对自己好一点。