☰
Python筛选重复图片:感知哈希+并查集,精准去重实战
2026/10/30 5:54:31 网站建设 项目流程

图片素材越攒越多,硬盘里同一个画面存了三四份的情况几乎不可避免。微信保存一次、截图一次、相机导出一次,文件名各不相同,肉眼翻半天也未必找得全。Python筛选重复图片这件事,本质上就是让程序替你做两件事:判断两张图“是不是同一张”,以及决定“留哪张、删哪张”。它解决的是存储浪费和素材库混乱的问题,适合所有手里有几千上万张图的人——不管你是做设计的、写公众号的、搞数据集的,还是单纯手机相册爆了的普通用户。下面我按自己实际做过的几轮筛选脚本,把思路、原理、代码和踩过的坑一次讲清楚,你可以直接抄作业,也可以按需改造。

1. 整体方案设计与技术选型思路

1.1 先想清楚“重复”到底指什么

动手写代码之前,最容易被忽略的一步是定义“重复”。这个词在不同场景下含义完全不同,选错了判断方式,后面全白干。我一般把它分成三个层次:

  • 字节级重复:两个文件内容完全一致,只是文件名或路径不同。这种用文件哈希一算就知道,最省事。
  • 像素级重复:图片被重新保存过、压缩过,字节变了,但解码后的像素矩阵完全一样。这种要靠像素比对。
  • 视觉级重复:图片被缩放、裁剪、加水印、调了亮度,甚至格式从 PNG 转成了 JPG,人眼看着是同一张,但像素已经对不上了。这种必须用感知哈希或特征向量。

绝大多数人的真实需求落在第二层和第三层之间。比如手机相册里同一张照片的“原图”和“编辑后导出”,属于像素级;而从聊天软件里存下来的压缩版和原图,就属于视觉级。我的建议是:先用哈希做粗筛,再用感知哈希做精筛,两级流水线既快又准,单靠任何一级都有明显短板。

1.2 为什么不用“文件名+大小”这种土办法

很多人第一反应是比对文件名和文件大小。这个方案在极小规模下能用,但坑非常多。文件名可以随便改,大小相同也完全可能是两张不同的图——一张纯色图和另一张纯色图字节数撞车的概率并不低。更麻烦的是,大小相同但内容不同的情况在批量导出时经常出现,误判率会随着图片数量上升而快速放大。

我早期图省事用过这个办法,结果在一个两千张图的目录里误删了十几张不同的设计稿,因为它们的导出尺寸和压缩参数一致,文件大小恰好相同。从那以后我就定了个规矩:任何删除操作之前,判断依据必须是内容层面的,不能是元数据层面的。文件大小、修改时间、文件名这些只能用来辅助排序和展示,不能作为“是否重复”的唯一判据。

1.3 两级流水线的整体架构

整个方案的骨架是这样的:

  1. 遍历目录,收集所有图片路径,过滤掉非图片文件和过小的文件(比如小于 5KB 的图标、缩略图缓存)。
  2. 第一级:精确哈希。对每个文件算 SHA-256,哈希相同的直接判定为字节级重复,归为一组。
  3. 第二级:感知哈希。对第一级没归并的图片,统一缩放到固定尺寸、转灰度,计算差值哈希(dHash)或感知哈希(pHash),得到一串指纹。
  4. 相似度比对。两两比较指纹的汉明距离,距离小于阈值的判为视觉重复,用并查集把传递性关系合并成组。
  5. 输出报告。每组列出所有成员、文件大小、路径,并给出建议保留的那一张(通常是分辨率最高或文件最大的)。
  6. 人工确认后再删除。脚本只生成清单,删除动作单独执行,且默认走回收站或先移动到隔离目录。

这个架构的关键在于分级:第一级用精确哈希把最容易判断的干掉,剩下的才进入计算量更大的感知哈希环节。实测下来,在一个一万张图的目录里,精确哈希阶段通常能直接归并掉三到四成,感知哈希的计算量随之下降,整体耗时比一上来就全量算 pHash 少一半以上。

1.4 工具选型:为什么是 Pillow + imagehash

Python 生态里处理图片的库不少,我最终固定用Pillow做解码和缩放,用imagehash算感知哈希。理由很实在:

  • Pillow 是事实标准,格式支持最全,HEIC 之外的主流格式基本都能读,安装也简单。
  • imagehash 封装了 dHash、pHash、aHash、wHash 四种算法,接口统一,返回的对象直接支持减法运算得到汉明距离,省去自己写比对逻辑。
  • 两者都不依赖重量级的科学计算栈,装完就能跑,不像某些方案要拖进来一整套数值计算库。

如果图片量特别大(十万级以上),可以考虑引入 numpy 做向量化比对,但那是优化阶段的事,第一版没必要上。先把逻辑跑通,再谈性能。

2. 核心原理拆解与关键参数说明

2.1 精确哈希为什么能判断字节级重复

SHA-256 这类密码学哈希函数的特性是:输入差一个字节,输出就面目全非;输入完全相同,输出必然相同。所以两个文件的哈希值相等,基本可以断定内容一致(碰撞概率低到可以忽略)。用它做第一级筛选,计算成本低、判断绝对可靠,是流水线里最稳的一环。

这里有个细节值得说:读文件要分块读,不要一次性read()整个文件。大图动辄几十兆,一次性读进内存,批量处理时内存会飙得很高。我一般用 64KB 或 128KB 的块循环读取喂给哈希对象,内存占用平稳,速度也不受影响。

import hashlib def file_sha256(path, chunk_size=131072): h = hashlib.sha256() with open(path, "rb") as f: while True: chunk = f.read(chunk_size) if not chunk: break h.update(chunk) return h.hexdigest()

2.2 感知哈希到底在“感知”什么

感知哈希的思路很有意思,用生活化的说法解释:它不关心每个像素的具体数值,而是关心图像整体的明暗分布格局。就像你远远看一张照片,看不清细节,但能记住“左边亮右边暗”“中间有个深色块”这种大轮廓。感知哈希就是把这种大轮廓编码成一串 0 和 1。

以最常用的dHash(差值哈希)为例,流程是:

  1. 把图片缩放到 9×8 像素(宽 9 高 8),缩放过程本身就是在丢弃细节、保留整体明暗。
  2. 转成灰度图,每个像素变成一个亮度值。
  3. 对每一行,比较相邻两个像素的亮度:左边比右边亮记 1,否则记 0。
  4. 8 行每行产生 8 个比较结果,拼起来就是 64 位指纹。

两张图如果视觉上接近,缩放后的明暗格局就接近,64 位指纹里不同的位数(汉明距离)就少。汉明距离就是两个指纹逐位比较后不同位的个数,这个数值直接衡量了“视觉差异程度”。

2.3 阈值怎么定:0、5、10 的区别在哪

汉明距离的阈值是整个方案里最需要拿捏的参数。64 位指纹下,我的经验值是这样的:

阈值判定强度适用场景风险
0指纹完全一致像素级重复、无损转存漏掉轻微压缩的重复
1~5高度相似同图不同压缩、轻微调色误判率很低,推荐起点
6~10中等相似同图裁剪、加小水印开始出现误判,需人工复核
11~15宽松相似同场景连拍、相似构图误判明显增多
>15基本不可用无大量不同图被归为一组

我一般把阈值设在5作为默认值,跑完看报告再调整。如果发现漏判(明明是一张图没归到一起),往上调到 8 左右;如果发现误判(不同的图被归组),往下压到 3。没有放之四海皆准的阈值,只有针对你这批图最合适的阈值,所以报告和人工复核这一步不能省。

2.4 并查集:处理“A像B、B像C,但A不像C”的传递问题

相似关系不满足传递性。A 和 B 距离 4,B 和 C 距离 4,但 A 和 C 可能距离 8,超过阈值。如果简单地两两配对,就会出现同一张图的多个变体被拆成好几组的情况。

解决办法是用并查集(Union-Find):把每张图看成一个节点,只要两张图距离小于阈值,就把它们所在的集合合并。最后同一个集合里的所有图归为一组,不管它们两两之间是否都直接相似。这个数据结构实现起来就几十行,但能显著提升归组的完整性,是这类聚类问题的标准解法。

class UnionFind: def __init__(self, n): self.parent = list(range(n)) self.rank = [0] * n def find(self, x): while self.parent[x] != x: self.parent[x] = self.parent[self.parent[x]] x = self.parent[x] return x def union(self, a, b): ra, rb = self.find(a), self.find(b) if ra == rb: return if self.rank[ra] < self.rank[rb]: ra, rb = rb, ra self.parent[rb] = ra if self.rank[ra] == self.rank[rb]: self.rank[ra] += 1

路径压缩加按秩合并,几乎可以认为是常数时间,几万张图的合并毫无压力。

3. 完整实操流程与代码实现

3.1 环境准备与依赖安装

依赖就两个,装起来很快:

pip install pillow imagehash

如果你的图片里有 HEIC 格式(部分手机默认格式),Pillow 原生读不了,需要额外装pillow-heif并注册解码器。这一步很多人会漏,结果脚本跑到一半报“cannot identify image file”,排查半天以为是路径问题。我的做法是在脚本开头统一注册,读不了的格式直接跳过并记录到日志,不让单张坏图中断整个流程。

from PIL import Image try: import pillow_heif pillow_heif.register_heif_opener() except ImportError: pass # 没装就跳过 HEIC 支持

3.2 目录遍历与文件过滤

遍历用os.walk或pathlib.Path.rglob都行,我偏好后者,写法更清爽。过滤规则我固定这几条:

  • 扩展名白名单:.jpg .jpeg .png .bmp .gif .webp .tif .tiff,大小写都转小写再比。
  • 文件大小下限:小于 5KB 的直接跳过,这类多半是图标、占位图、缓存缩略图,参与比对只会制造噪音。
  • 跳过隐藏目录和系统缓存目录,比如以.开头的目录、__MACOSX之类。
from pathlib import Path IMG_EXTS = {".jpg", ".jpeg", ".png", ".bmp", ".gif", ".webp", ".tif", ".tiff"} MIN_SIZE = 5 * 1024 def collect_images(root): result = [] for p in Path(root).rglob("*"): if not p.is_file(): continue if p.suffix.lower() not in IMG_EXTS: continue if any(part.startswith(".") for part in p.parts): continue try: if p.stat().st_size < MIN_SIZE: continue except OSError: continue result.append(p) return result

这里用try/except包住stat()是有意为之:遍历过程中文件被其他程序占用或删除的情况真实存在,不包住就会整个脚本崩掉。批量处理脚本的第一原则是单点失败不能拖垮全局。

3.3 第一级:精确哈希分组

把上一步收集到的路径逐个算 SHA-256,用字典按哈希值分组。哈希值对应列表长度大于 1 的,就是字节级重复组。

from collections import defaultdict def group_by_hash(paths): buckets = defaultdict(list) for p in paths: try: buckets[file_sha256(p)].append(p) except OSError as e: print(f"[跳过] 读取失败 {p}: {e}") return {h: ps for h, ps in buckets.items() if len(ps) > 1}

这一步跑完,把重复组里的文件从后续感知哈希的候选列表里剔除——同一份字节内容没必要再算一遍指纹。注意剔除的是“组内除保留一张之外的其他成员”,保留的那张仍然要参与第二级,因为它可能和另一组视觉相似。

3.4 第二级:感知哈希计算与相似度比对

对剩余候选逐张算 dHash。计算时统一先转 RGB 再转灰度,避免带透明通道的 PNG 在转灰度时出现异常。算完得到(路径, 哈希对象)的列表,然后两两比对。

两两比对是 O(n²) 的复杂度,一万张图就是约五千万次比较。单次比较是 64 位整数的异或加位计数,速度很快,五千万次在普通机器上大概几十秒到一两分钟,可以接受。如果规模再大,就得考虑分块或索引优化,但那是另一个话题。

import imagehash from PIL import Image def compute_hashes(paths): items = [] for p in paths: try: with Image.open(p) as im: im = im.convert("RGB") h = imagehash.dhash(im, hash_size=8) items.append((p, h)) except Exception as e: print(f"[跳过] 无法解码 {p}: {e}") return items def group_by_similarity(items, threshold=5): n = len(items) uf = UnionFind(n) for i in range(n): for j in range(i + 1, n): if items[i][1] - items[j][1] <= threshold: uf.union(i, j) groups = defaultdict(list) for idx in range(n): groups[uf.find(idx)].append(items[idx][0]) return [g for g in groups.values() if len(g) > 1]

items[i][1] - items[j][1]这个写法是 imagehash 的特性,两个哈希对象相减直接返回汉明距离,比手动异或再数位清爽得多。

3.5 生成报告与建议保留策略

报告我一般输出成 CSV,字段包括:组号、文件路径、文件大小、分辨率、哈希值、是否建议保留。建议保留的规则按优先级排:

  1. 分辨率最高的优先(宽×高最大)。
  2. 分辨率相同看文件大小,大的优先(通常压缩更少、质量更高)。
  3. 再相同就看修改时间,早的优先(通常是原始版本)。
def pick_keeper(paths): def score(p): try: with Image.open(p) as im: w, h = im.size size = p.stat().st_size mtime = p.stat().st_mtime return (w * h, size, -mtime) except Exception: return (0, 0, 0) return max(paths, key=score)

用max配合元组比较,天然实现了多级优先级,代码短且不容易出错。注意-mtime取负值,因为max取最大,而我们要时间最早的。

3.6 安全删除:先隔离,后清理

这一步我要重点强调。脚本永远不要直接os.remove。我的做法是生成一个“待删除清单”,把清单里的文件移动到一个隔离目录(比如_duplicates_quarantine/),保留原目录结构。人工翻一遍隔离目录,确认没问题了,再手动清空。

import shutil def quarantine(paths, base, quarantine_dir): qdir = Path(quarantine_dir) for p in paths: rel = p.relative_to(base) target = qdir / rel target.parent.mkdir(parents=True, exist_ok=True) shutil.move(str(p), str(target))

移动而不是复制,省磁盘空间;保留相对路径,方便对照原位置;隔离目录独立于原目录,避免被下一次遍历扫到。这套流程我用了很久,从没出过不可挽回的事故。

4. 常见问题排查与实战避坑经验

4.1 为什么明明一样的图没被识别出来

这是最高频的问题,原因通常有三类。第一类是格式差异导致解码结果不同,比如一张是带透明通道的 PNG,一张是白底 JPG,转灰度后透明区域的处理方式不同,指纹就偏了。解决办法是在转灰度前统一铺白底:

def to_rgb_white_bg(im): if im.mode in ("RGBA", "LA", "P"): im = im.convert("RGBA") bg = Image.new("RGB", im.size, (255, 255, 255)) bg.paste(im, mask=im.split()[-1]) return bg return im.convert("RGB")

第二类是裁剪比例差异大,比如原图是 4:3,裁剪后是 1:1,缩放后明暗格局完全变了。这种 dHash 无能为力,得换 pHash 或者上特征向量方案。第三类是阈值设太严,把阈值从 5 调到 8 往往就能救回来。

4.2 误判:不同的图被归到一组怎么办

误判的典型场景是纯色图、渐变图、简单图标。这类图本身信息量低,缩放后明暗格局高度雷同,指纹自然接近。我的处理办法是在比对前加一道“信息量过滤”:计算缩放后图像的灰度标准差,标准差过低的(比如低于 10)直接标记为“低信息量”,不参与相似度归组,只参与精确哈希。

import numpy as np def is_low_information(im, std_threshold=10): small = im.convert("L").resize((32, 32)) arr = np.asarray(small, dtype=np.float32) return arr.std() < std_threshold

这个判断很便宜,但能挡掉大量误判。另一个办法是提高阈值严格度,但那是治标,信息量过滤才是治本。

4.3 速度太慢怎么优化

一万张图跑几分钟是正常的,但如果到了十几分钟,就得看看瓶颈在哪。我实测下来瓶颈几乎总在图片解码,而不是哈希计算或比对。优化手段按性价比排序:

  • 多进程解码:用concurrent.futures.ProcessPoolExecutor把解码和哈希计算分发到多核,通常能提速三到五倍。注意进程池里传路径而不是传 Image 对象,Image 对象不可序列化。
  • 先算精确哈希再算感知哈希:这个顺序不能反,精确哈希便宜得多,能提前砍掉一批。
  • 缩小比对规模:如果目录里有大量明显不同的图(比如照片和截图混在一起),可以先按分辨率或长宽比粗分桶,只在桶内比对。这个优化有前提,就是你对数据分布有把握,否则可能漏判。

4.4 常见问题速查表

现象可能原因排查方向解决手段
脚本报 cannot identify image格式不支持或文件损坏打印路径看扩展名装 pillow-heif,加异常捕获跳过
内存占用飙升一次性读大文件检查 read 调用改分块读取
相似图没归组阈值过严或格式差异手动算两张图的距离调阈值、统一背景色
不同图被归组低信息量图干扰看组内是否有纯色图加信息量过滤
跑得特别慢解码是瓶颈计时看各阶段耗时多进程、先精确后感知
删除后后悔直接删了原文件检查删除逻辑改隔离目录,人工确认

4.5 几个只有踩过才知道的细节

第一,GIF 只取第一帧。Pillow 打开 GIF 默认停在第一帧,算哈希时如果不注意,动图会被当成静态图处理,可能和它的某一帧静态截图误判为重复。我的做法是 GIF 单独标记,不参与视觉相似归组,只参与精确哈希。

第二,EXIF 方向要处理。手机拍的照片经常带旋转标记,Pillow 解码时默认不应用旋转,导致同一张照片“横着存”和“竖着存”算出来的指纹不同。用ImageOps.exif_transpose统一方向后再算哈希,能避免这类假阴性。

第三,路径要用绝对路径。相对路径在切换工作目录后容易出问题,尤其是移动文件那一步,路径算错可能把文件挪到意想不到的地方。我在收集阶段就统一resolve()成绝对路径,后面所有操作都基于绝对路径。

第四,日志要落盘。批量处理跑几十分钟,中途出错看终端输出容易漏。我习惯把跳过、异常、分组结果都写进一个日志文件,跑完翻日志比翻终端靠谱得多。

第五,先小批量试跑。任何新脚本第一次跑,先拿一个几百张图的子目录试,看分组结果是否符合预期,再上全量。这个习惯帮我省过好几次大麻烦——有一次阈值设错,全量跑完发现几乎所有图都被归成一组,幸好只是试跑。

5. 方案扩展与不同场景的适配调整

5.1 手机相册场景:按时间窗口分桶

手机相册的特点是量大、时间集中、连拍多。直接全量两两比对,连拍照片之间距离很近,容易把“同一场景的不同瞬间”误判为重复。我的调整办法是先按拍摄日期分桶,只在同一天内比对,同时把阈值压到 3 左右,宁可漏判不可误判。连拍照片虽然相似,但它们是不同的瞬间,删错了没法恢复,这个场景下保守比激进重要。

5.2 设计素材场景:分辨率优先保留

设计素材库里同一张图的不同尺寸版本很常见,比如一个图标有 16、32、64、128 四个尺寸。这种场景下“重复”的定义其实是“同一素材的不同规格”,筛选目的不是删除,而是归并整理。我的做法是把建议保留策略改成“保留最大尺寸”,其余移动到按素材名组织的子目录里,而不是隔离删除。报告里额外加一列“尺寸规格”,方便人工判断。

5.3 数据集清洗场景:需要可复现的清单

做图像数据集时,去重结果必须可复现、可审计。这种场景下我会把整个流程的中间结果都落盘:文件清单、哈希值表、相似度矩阵(或至少是超过阈值的配对列表)、最终分组。所有随机性来源(比如多进程的完成顺序)都要消除,保证同样的输入跑两次结果完全一致。阈值和参数写进配置文件,和结果一起存档,方便回溯。

5.4 大规模场景的性能取舍

图片数量上了十万级,O(n²) 的比对就顶不住了。这时候有几个方向:一是用 BK 树或局部敏感哈希做近邻检索,把比对从全量降到候选集;二是把 64 位指纹拆成四段 16 位,先按段建索引,只在段匹配的候选里做精确比对;三是引入向量数据库做近似检索。这些方案复杂度都不低,我的建议是先确认真的需要——大部分个人场景几千到几万张图,朴素方案完全够用,过早优化只会增加维护成本。

6. 我个人在实际操作中的几点体会

这套脚本我前后迭代了四五版,最大的感受是:判断逻辑的可靠性远比代码优雅重要。第一版我追求写得短,把精确哈希和感知哈希揉在一个循环里,结果调试时根本分不清某个分组是哪一级产生的,排查问题极其痛苦。后来拆成清晰的两级,每级输出独立报告,问题定位快了很多。

另一个体会是永远给删除留后路。隔离目录这个设计看起来多此一举,但它救过我两次——一次是阈值调错导致一批正常图被归组,一次是某张图的 EXIF 方向没处理导致误判。如果没有隔离,这两次都是不可逆的损失。批量处理脚本的容错设计,价值往往在出事那一刻才体现出来。

最后分享一个小技巧:跑完筛选后,别急着清理隔离目录,先隔一两天再回来看。刚跑完时你对结果有“确认偏误”,容易草率通过;放一放再复查,往往能发现当时没注意到的问题。这个习惯听起来很土,但确实管用。

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

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

立即咨询