整理照片这件事,我猜大多数人都有过同一种崩溃时刻:手机备份了三轮,电脑里还挂着两个旧相册文件夹,网盘又同步下来一份,等你想找某张照片时,眼前是四份几乎一样的笑脸。重复图片就是这么攒出来的——它们不是一次性堆进来的,而是每次换设备、每次备份、每次聊天保存图片时一点点沉积下来的。手动翻看删除,几百张还能忍,上万张基本等于劝退。这也是为什么“重复图片查找与删除”这类工具一直有人需要:它解决的核心问题很具体,就是用程序代替肉眼,把图库里内容雷同的文件找出来,再安全地删掉多余的那份。这里说的“重复”其实分两种,一种是文件完全一致,另一种是画面高度相似但分辨率、压缩率、水印位置不同,两者处理思路完全不一样。这篇文章适合手里有几千上万张照片、又不想把图库交给不明第三方服务的人,也适合想自己搭一套查重流程的开发爱好者。我会把原理、阈值、实操代码和踩过的坑一次讲清楚,跟着做基本能落地。
1. 从需求到方案:重复图片查重到底难在哪
1.1 重复照片的三种典型来源,决定了查重策略不能单一
我先把这个问题的来源拆开看,因为不同来源的重复,判定标准是不一样的。
第一种是完全相同的文件副本。手机连着电脑备份,同一张照片被复制到两个目录;或者从相册导出时勾选了“保留原文件”,结果多出一份。这类文件的字节内容一模一样,文件名可能不同,但用文件哈希一算就能对上,这是最容易处理的一类。
第二种是同一画面、不同编码参数。最典型的是聊天软件里保存下来的图片,原图被压缩过;或者相机拍完存了一份JPG,后期导出又存了一份质量不同的。像素尺寸可能变了、压缩率变了,但肉眼看就是同一张。这类靠文件哈希完全找不出来,必须比对画面内容。
第三种是连拍和近似场景。同一场景连拍十几张,构图几乎没差别,或者同一张照片加了不同滤镜。这类文件既不字节相同,画面也有细微差异,属于“相似但不重复”,是否删除完全取决于个人判断。
你看,这三种来源对算法的要求是递进的:从精确匹配到模糊匹配。所以一个能用的查重流程,不能上来就用一种方法扫全库,得分层过滤——先用最便宜、最严格的方法筛掉一大批,再对剩下的做更贵的相似度计算。这个思路贯穿全文,后面所有设计都是围绕它展开的。
1.2 手动、云相册、本地工具三条路,我为什么选本地自建
在动手之前,我对比过几种常见方案,结论是它们各有明显短板。
| 方案 | 优点 | 短板 | 适用场景 |
|---|---|---|---|
| 手动翻看对比 | 零成本、完全可控 | 图库过千后效率断崖式下降 | 几百张以内 |
| 相册软件自带查重 | 操作简单、无需配置 | 判定规则不透明,通常只能识别完全重复或简单相似 | 轻度整理 |
| 本地自建查重流程 | 规则透明、可调阈值、数据不出本机 | 需要一点动手能力 | 大批量、注重隐私 |
手动方案的问题不需要多解释,人眼对比在几百张之后就不可靠了,而且极度消耗耐心。相册软件自带的查重功能确实省事,但它的判定逻辑往往是黑盒——你不知道它把什么算作重复,也不敢全选删除,最后还是要一张张确认,省下的时间有限。更重要的是,很多在线服务需要把整库照片上传,这对于图库里有私人内容的人来说是个心理门槛。
本地自建这条路,代价是需要写点脚本或者配一套工具,但换来的是规则完全透明:什么算重复、相似度阈值卡在哪、保留哪一份,每一步都由你决定。对我这种想搞清楚“为什么这张被判定为重复”的人来说,这一点是决定性的。而且整个流程跑在本机,不联网也能用,没有数据外传的顾虑。
1.3 核心思路:两层过滤,先严后宽
整套流程的设计骨架就一句话:第一层做精确去重,第二层做相似去重。
第一层用文件内容哈希(比如 SHA-1 或 BLAKE2),把字节完全一致的文件归为一组。这一层计算量小、结果 100% 准确,几乎不会误判,能先把一眼就能确定的重复清理掉。实际图库里,这一层往往能解决三到五成的重复量。
第二层对第一层剩下的文件计算感知哈希(perceptual hash),把每张图压缩成一个短指纹,再比较指纹之间的汉明距离。距离越小,画面越像。这一层会产生“疑似重复”,需要设阈值,也需要人工复核,但它能抓住压缩、缩放、轻微调色这类变化。
两层的好处是计算资源花在刀刃上。假设图库有一万张,第一层算哈希很快,第二层才对去掉精确重复后的子集做图像解码。如果一上来就对全部图片做感知哈希,光是解码和缩放就够跑很久。这个“先便宜后贵”的顺序,是我在实际跑过大库之后最深的体会之一。
2. 技术原理拆解:哈希是怎么“认出”两张一样的图
2.1 文件哈希只认字节,但它是第一道最可靠的闸门
文件哈希的原理很朴素:把整个文件当成一串字节,跑一个散列函数,得到一个固定长度的摘要。两个文件只要有一个字节不同,摘要就几乎必然不同;反过来,摘要相同基本可以认定内容一致(碰撞概率在实用层面可以忽略)。
用 Python 实现非常简单,标准库hashlib就够:
import hashlib from pathlib import Path def file_hash(path, chunk_size=1 << 20): h = hashlib.blake2b(digest_size=16) # 比 md5 更稳,速度也够 with open(path, "rb") as f: while chunk := f.read(chunk_size): h.update(chunk) return h.hexdigest() def group_by_hash(root): groups = {} for p in Path(root).rglob("*"): if p.is_file() and p.suffix.lower() in {".jpg", ".jpeg", ".png", ".heic", ".webp"}: groups.setdefault(file_hash(p), []).append(p) return {k: v for k, v in groups.items() if len(v) > 1}这里有两个细节值得说。一是分块读取,不要一次性read()整个文件,图库里可能有几十兆的 RAW 或全景图,一次性读进内存容易把机器拖垮。二是哈希算法选择,MD5 和 SHA-1 在纯查重场景下其实够用,但既然 BLAKE2 在现代 CPU 上有硬件加速、速度更快,没有理由不用它。摘要长度取 16 字节(128 位)已经足够区分海量文件,没必要追求更长的输出。
注意:文件哈希只能判断“内容完全一致”,对改名、改后缀完全无效——这正好符合预期,因为改名不改内容。但它对“重新保存过的同一张图”无能为力,这就是第二层存在的理由。
2.2 感知哈希把图片压成指纹,靠的是“整体特征”而非像素
感知哈希(pHash)的思路和文件哈希完全不同。它不逐字节比对,而是先把图片缩小、转灰度、做离散余弦变换,取低频部分的系数,再和二值化阈值比较,生成一串 0/1 指纹。两张图如果整体亮度分布、结构走向接近,指纹就会很接近。
打个生活化的比方:文件哈希像是比对两份文档的每一个字,错一个标点就算不同;感知哈希像是让两个人远远看一眼两幅画,只要构图、明暗关系差不多,就认为“像”。前者严格,后者宽松,两者结合才完整。
实际实现里我不建议从零手写 DCT,用现成库更稳。imagehash这个库提供了 pHash、dHash、aHash 几种实现,搭配 Pillow 读取图片即可:
import imagehash from PIL import Image def perceptual_hash(path, hash_size=8): with Image.open(path) as img: img = img.convert("RGB") return imagehash.phash(img, hash_size=hash_size)hash_size=8会生成 64 位的指纹,够用且计算快;调到 16 会得到 256 位,对细微差异更敏感,但计算和比对成本也上去了。我的经验是先按 8 跑一遍,遇到漏判再局部调高,没必要一上来就上高精度。
还有一个容易被忽略的点:dHash 和 pHash 的适用场景略有差别。pHash 对整体色调和结构变化更宽容,适合找“同一张图的不同压缩版本”;dHash 对水平方向的梯度更敏感,在找“裁剪很小一块”的场景下有时表现更好。我的做法是两个都算,任一命中就进候选,宁可多给人工复核留一点余地,也不要漏掉。
2.3 汉明距离阈值:卡太严会漏,卡太松会误删
感知哈希之间用汉明距离衡量差异,也就是两个指纹有多少位不同。64 位指纹下,距离为 0 表示几乎一致,距离越大差异越明显。阈值怎么定,是这个流程里最需要经验的部分。
我参考过一些公开资料,也自己拿标注过的样本试过,大致规律如下:
| 汉明距离(64位) | 判定倾向 | 建议动作 |
|---|---|---|
| 0–2 | 极可能同一画面 | 可自动归组 |
| 3–6 | 高度相似,可能是压缩或轻微裁剪 | 归组后人工确认 |
| 7–10 | 存在相似元素,但可能是不同照片 | 仅作提示,不自动处理 |
| 11 以上 | 基本可视为不同图 | 忽略 |
这张表不是金科玉律,它和你的图库构成强相关。如果图库里大量是同一场景连拍,阈值可以收紧到 5 以内,避免把不同瞬间的照片混进来;如果是整理老照片、存在大量不同年代的扫描件,阈值放宽到 8 左右更合适。我踩过的坑是一开始把阈值设成 10,结果大量同色系的风景照被归成一组,差点误删,后来老老实实收到 6,配合人工复核才稳定下来。
提示:阈值调整一定要拿一小批你熟悉的照片做验证,别在全库上直接试。全库跑一次可能几十分钟,反复调参的时间成本很高。
2.4 为什么还要看 EXIF 和图片尺寸
光靠哈希有时候会给出反直觉的结果。比如一张 4K 原图和一张 200 像素的缩略图,感知哈希可能距离很小,但它们显然不该被同等对待——缩略图通常是原图的衍生品,删哪张要看你的用途。这时候 EXIF 和尺寸就派上用场了。
具体做法是:在相似组内部,按像素总量、文件大小、拍摄时间、是否有 EXIF 信息排序,优先保留分辨率最高、EXIF 完整、拍摄时间最早的那张。这样能把“原图保留、衍生图删除”的逻辑固化下来,而不是每遇到一组就靠人脑判断。
读取 EXIF 可以用 Pillow 的_getexif(),也可以上exifread,后者对不同厂商的字段兼容性更好一些。需要提醒的是,很多聊天软件保存的图片会把 EXIF 抹掉,所以“有 EXIF”只能作为加分项,不能作为唯一判据,更不能因为一张图没有 EXIF 就判定它是低价值副本。
3. 完整实操:从零跑通一套查重流程
3.1 环境准备与目录规划
先把环境搭起来。我习惯用虚拟环境隔离依赖,避免和系统里的其他 Python 项目打架:
python -m venv venv source venv/bin/activate # Windows 用 venv\Scripts\activate pip install pillow imagehash exifread tqdm依赖不多,各自职责很清晰:Pillow负责读图和缩放,imagehash算感知指纹,exifread读拍摄信息,tqdm只是给长时间扫描加个进度条——别小看这个进度条,扫上万张图的时候没有它你根本不知道程序是卡死了还是在跑。
目录上我建议分成三个区:源目录(只读,不直接动)、工作区(放中间结果和报告)、隔离区(疑似重复文件先移动到这里,而不是直接删)。这个三分法是我吃过亏之后定下来的规矩,后面第 4 节会详细讲为什么不能直接删。
3.2 第一遍扫描:用文件哈希做精确分组
第一遍的目标很单纯:把所有字节相同的文件归组。代码在 2.1 已经给过核心函数,这里补一个带进度和结果落盘的完整版本:
from pathlib import Path from collections import defaultdict import json from tqdm import tqdm def scan_exact(root): groups = defaultdict(list) files = [p for p in Path(root).rglob("*") if p.is_file()] for p in tqdm(files, desc="file hash"): try: groups[file_hash(p)].append(str(p)) except (OSError, PermissionError): continue dups = {h: paths for h, paths in groups.items() if len(paths) > 1} with open("exact_dups.json", "w", encoding="utf-8") as f: json.dump(dups, f, ensure_ascii=False, indent=2) return dups这段代码有三个实务要点。第一,异常要吞掉,真实图库里总有损坏文件、权限受限的目录、正在被占用的文件,一个异常不该让整轮扫描中断。第二,结果立刻落盘,扫描可能跑很久,中途崩了至少还能从 JSON 里恢复。第三,只处理图片后缀,把视频、文档排除掉,减少无效计算。
跑完之后你会得到一个exact_dups.json,里面每个键是一组完全相同的文件路径。这时候先别急着删,看一眼数量和分布——如果某一组里有几十个副本,通常意味着某次备份脚本出了问题,值得顺手查一下源头。
3.3 第二遍扫描:感知哈希找“看起来一样”
第二遍才是真正花时间的部分。我的做法是对去掉精确重复后的唯一文件集合计算感知哈希,然后两两比较或者用分桶加速。图库小的时候两两比较没问题,上了万级就要考虑优化,否则 O(n²) 的比对次数会很吓人。
import imagehash from PIL import Image from pathlib import Path def build_phash_index(paths, hash_size=8): index = [] for p in paths: try: with Image.open(p) as img: img = img.convert("RGB") h = imagehash.phash(img, hash_size=hash_size) index.append((str(p), h, img.size)) except Exception: continue return index def find_similar(index, max_dist=6): pairs = [] for i in range(len(index)): for j in range(i + 1, len(index)): d = index[i][1] - index[j][1] # imagehash 重载了减法,返回汉明距离 if d <= max_dist: pairs.append((index[i][0], index[j][0], d)) return pairs这里imagehash对象之间直接相减就得到汉明距离,用起来很顺手。但要注意,两两比较在几千张以上会明显变慢,我后来的做法是先按图片的平均颜色或者宽高比做粗分桶,只在桶内做细比对,能把比对次数降一个数量级。这个优化不是必须的,但如果你打算把这套流程做成定期任务,值得加上。
还有一点:透明背景的 PNG 要特殊处理。转 RGB 时透明区域会变成黑色或白色,导致两张视觉上不同的图算出很近的指纹。我的处理是先铺一层白底再转换:
def flatten_on_white(img): if img.mode in ("RGBA", "LA"): bg = Image.new("RGB", img.size, (255, 255, 255)) bg.paste(img, mask=img.split()[-1]) return bg return img.convert("RGB")这个细节不起眼,但在处理设计素材、图标类图库时能避免一大批误判。
3.4 相似组内择优:留哪张、删哪张要有明确规则
找到相似组之后,最危险的一步来了:决定保留谁。我的规则是按优先级打分,分数高的保留:
- 像素总量(宽×高)优先,原图通常最大;
- 像素相同则看文件大小,压缩损失小的通常更大;
- 再看是否有完整 EXIF,有拍摄信息的更可能是原始文件;
- 最后看拍摄时间,最早的通常是最初版本。
def pick_keeper(group): def score(item): path, size, has_exif, ts = item pixels = size[0] * size[1] return (pixels, has_exif, -ts) return max(group, key=score)这套规则不是绝对的。比如你做的是网页素材整理,可能需要保留体积最小、压缩最狠的那张;又比如你在整理扫描件,时间戳可能全是扫描时间,没有参考价值。规则要跟着用途走,这也是自建流程比黑盒工具强的地方——你可以随时改判定逻辑。
3.5 生成报告与人工复核
程序跑完不要直接动手删。先出一份可读的报告,我一般用 CSV,方便在表格软件里筛选排序:
import csv with open("similar_report.csv", "w", newline="", encoding="utf-8") as f: w = csv.writer(f) w.writerow(["group_id", "keep", "candidate", "distance", "keep_size", "cand_size"]) for gid, group in enumerate(groups): keeper = pick_keeper(group) for item in group: if item[0] != keeper[0]: w.writerow([gid, keeper[0], item[0], item[3], keeper[1], item[1]])报告的核心价值是让人能快速扫一遍异常。我的习惯是先按距离排序,距离最小的优先处理;再按组内数量排序,数量特别大的组单独看一眼,因为那往往是规则出了问题。人工复核这一步不要省,尤其第一次跑全库的时候,哪怕只是抽查前五十组,也能帮你发现阈值是否合适。
4. 删除策略与安全红线:宁可多留,不可错删
4.1 三级删除流程,把不可逆操作往后推
照片删除和普通文件删除不一样,它往往不可逆,而且很多图库同步工具会把删除动作同步到所有设备。所以我把删除拆成三级:
第一级,移动到隔离区。程序只负责把“候选删除”的文件移动到隔离目录,源目录保持不变。这一步是可逆的,也是唯一由程序自动执行的动作。
第二级,人工确认。在隔离区里再翻一遍,尤其是被标记为“相似”而非“完全相同”的文件。隔离区保留至少一周,期间随时可以捞回来。
第三级,彻底清理。确认无误后,再对隔离区执行删除,或者直接移到系统回收站,让它再待一阵。
这个流程看起来啰嗦,但它防的是最坏情况:阈值设错导致误删。我见过有人图省事写了个os.remove()直接删,结果发现哈希分组逻辑有 bug,一批原图没了。多花一周的存储空间,换一个后悔药,这笔账怎么算都值。
注意:如果图库参与了云同步,先在同步客户端里暂停同步,等隔离区确认完毕再恢复。否则移动操作会被同步到云端,等于绕过了你的隔离区。
4.2 保留规则的几个反直觉场景
规则不是死的,有几个场景我特别提醒一下。
截图和原图。很多时候一张截图和它截取的内容在感知哈希上距离很小,但它们用途完全不同。我的处理是按文件名模式把截图单独标记,不参与自动删除,只提示。
表情包和素材图。同一套表情包往往只有细微表情差异,哈希距离很近,但它们都是独立素材,删哪个都不合适。这类图我建议按目录整体排除。
文档翻拍和扫描件。同一页纸拍两次,光线略有不同,哈希距离可能在 4 到 6 之间。这种确实重复,但保留哪张取决于清晰度,光看分辨率不够,最好人工过一眼。
这些场景的共同点是:算法看到的是像素相似,人看到的是用途不同。所以自动化永远只能做到“归组”和“建议”,最终决定权要留给人。
4.3 回滚方案和日志
即便做了隔离区,我还是建议留一份操作日志:每个被移动的文件记录原路径、新路径、时间、所属组。这样万一要回滚,写个脚本按日志反向移动就行。
import json, shutil from pathlib import Path def rollback(log_path): ops = json.loads(Path(log_path).read_text(encoding="utf-8")) for op in reversed(ops): src, dst = Path(op["dst"]), Path(op["src"]) if src.exists() and not dst.exists(): dst.parent.mkdir(parents=True, exist_ok=True) shutil.move(str(src), str(dst))日志本身很小,但它把“不可逆”变成了“可逆”,这是整个流程里最值钱的一行保障。
5. 常见问题与排查实录
5.1 误判:为什么两张不同的图被归成一组
最常见的误判来源是纯色背景或低信息量图片。比如白底商品图、纯色背景的人像,低频信息很少,感知哈希容易算出很近的指纹。解决办法有两个:一是对低方差图片跳过相似判定,只保留精确哈希结果;二是把阈值临时收紧到 2 以内,只处理极高度相似的情况。
另一个来源是图片被大幅裁剪或加了大面积水印。这类情况哈希会偏,但通常表现为漏判而非误判,处理方式见下一节。
5.2 漏判:旋转、裁剪、滤镜导致的漏网
感知哈希对旋转不敏感度有限,图片旋转 90 度后 pHash 可能变化很大。如果你的图库里有大量手机拍摄后手动旋转过的照片,建议在计算哈希前先按 EXIF 的 Orientation 字段自动摆正,Pillow 可以用ImageOps.exif_transpose()一行搞定。
裁剪导致的漏判比较难完全解决。一个折中办法是对大图额外计算一次中心区域裁剪后的哈希,如果原图哈希不匹配但中心区域哈希匹配,也归入候选。这会增加计算量,但对整理截图、海报类图库很有效。
滤镜和调色对 pHash 的影响相对小,因为 pHash 主要看结构。如果你的图库里滤镜版本很多,可以再补一个基于颜色直方图的比对作为辅助信号。
5.3 性能:上万张图跑不动怎么办
全库扫描慢,通常慢在三个地方:图片解码、哈希计算、两两比对。对应的优化手段:
- 解码:只读缩略图而不是原图。很多格式支持快速读取缩略图,能省掉大量解码时间。
- 哈希:先算 dHash 这种更便宜的指纹做粗筛,只对粗筛命中的再算 pHash。
- 比对:分桶,按宽高比或平均颜色把图片分到不同桶里,只在桶内比对。
如果图库特别大,还可以把哈希结果缓存成文件,下次只对新增图片计算,增量处理。这一点对定期整理很关键,没必要每次都从头算。
5.4 问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 完全相同的文件没被分到一组 | 读取异常被吞、后缀不在白名单 | 检查异常日志、确认后缀过滤规则 |
| 大量不相关图片被判相似 | 阈值过松、纯色背景干扰 | 收紧阈值、增加方差过滤 |
| 旋转后的照片漏判 | EXIF 方向未处理 | 加入 exif_transpose |
| 扫描中途崩溃 | 内存不足、单文件过大 | 分块读取、限制单文件大小 |
| 隔离区文件被同步走 | 云同步未暂停 | 先暂停同步再操作 |
| 重复扫描耗时过长 | 未做增量缓存 | 落盘哈希结果,只算新增 |
这张表建议贴在流程文档旁边,出问题时先对照一遍,能省下不少瞎猜的时间。
最后说个我自己的习惯:每次跑全库查重之前,先拿一个几百张的小目录试跑,看一眼分组结果是否符合直觉。图库是会变化的,上一次调好的阈值,换一批新照片可能就不适用了。先小跑验证,再全库执行,这个顺序能挡掉绝大多数事故。隔离区那一步也别嫌麻烦,照片这种东西,删错了是真的找不回来,多等一周比事后追悔强得多。