本地图片去重与相似检索:感知哈希+CLIP特征向量实践

发布时间:2026/10/3 19:12:33
本地图片去重与相似检索:感知哈希+CLIP特征向量实践 我最早做 paperclip 这个项目是因为硬盘里堆了两万多张素材图散落在不同的项目目录、截图文件夹和临时缓存里。里面有大量重复的、不同尺寸截断的、甚至只是调过色的版本。人眼去看根本看不完。正好那阵子我在折腾图像检索相关的东西就顺手写了一个本地工具把所有图片统一抽特征、建索引然后按相似度分组去重、按内容查找。名字之所以叫 paperclip原因是它的定位就像一枚回形针把一堆本该在一起却被扯散的图片轻轻夹起来。后来同事用它处理了一批电商素材我才意识到这东西对不少做设计、做运营、管素材的人都有用。先说明一下这里说的 paperclip 不是早年间那个知名的文件上传附件库而是一个面向图片素材管理的轻量级本地工具。它的核心能力只有三件事给一个目录里的图片建立索引找出重复或高度相似的图片输入一张图从库里返回内容最接近的候选图。不依赖云端、不传数据、不需要 GPU 也能跑普通办公电脑就能搞定。这篇文章就把我完整的思路、实现细节、参数调法和踩过的坑一次说清楚。1. 需求与设计paperclip 到底在解决什么问题1.1 一个真实到不能再真实的场景事情的起点很简单。我当时接手了一批历史项目的设计素材目录结构长这样甲乙方来回传输的压缩包解压后有三四层同名文件夹同一个文件反复被另存为“xxx_v2”“xxx_final2”“xxx_final_真的最终版”设计师为了适配不同渠道导出了 1x、2x、3x 多套尺寸还有一部分是从网页上整页截图下来的长图和其他图混在一起。在这种情况下传统的“按文件名找重复”完全失灵因为同名文件可能内容完全不同内容相同但文件名又五花八门。我需要按图片内容本身去判断而不是按元数据。这就是 paperclip 最初的核心驱动力把“内容”变成“可比较的指纹”用指纹之间的距离判断两张图像不像。我先后试过网上现成的软件但总有三个问题一是闭源的不知道它到底拿图片做什么二是批量能力弱处理两万张图要么卡死要么只能一次选两张对比三是不能自定义阈值。所以我决定自己做一个开源的、可命令行操作的、离线运行的工具。1.2 需求拆解功能边界先想清楚动手之前我把需求收敛成了几条需要满足的功能索引构建输入一个根目录递归扫描所有图片提取特征并写入本地数据库。重复检测计算两两相似度把相似度高于阈值的图片分组显示供人工确认。相似图查找指定一张图片返回库里最相似的 N 张图及相似度分数。去重辅助对重复分组自动给出推荐保留项把其余文件移动到隔离目录而不是直接物理删除。同时我明确砍掉了这些功能人脸识别、目标检测、OCR、自动分类打标。原因是它们会显著增加开发成本而且对“找重复、找相似”这个核心诉求没有直接贡献。早期版本一旦想多做一件事就很容易变成一个大杂烩反而失去重点。保持工具的单一定位用户才能依赖它。1.3 为什么走“图像哈希 特征向量”双通道纯哈希路线足够快但只适合完全相同的图片遇到裁剪、调色、压缩、带水印就失灵。纯深度特征路线精度高但全库两两向量比对在普通 CPU 上跑两万张图时间不可接受。所以 paperclip 采用了两层结构第一层感知哈希极其廉价地过滤出“一眼相同”的图比如无改动复制、转码、改尺寸。第二层特征向量把每张图映射成一个固定长度的向量再通过向量距离度量相似度。这就有点像先看一个人的名字再去看身份证号。名字快但可能重名身份证号慢但几乎唯一。两张图片先比哈希指纹指纹距离极近就直接判定为重复如果哈希距离中等就进入特征向量层做精细比较。这样一来准确率和性能都能兼顾。1.4 技术栈只选稳定且本地友好的组件我最终确认的技术栈如下Python 3.10Pillow 负责图片解码与缩略图OpenCV 仅用于部分边缘检测和颜色空间转换不参与全部流程感知哈希自实现不依赖第三方库保证行为可控CLIP 模型负责提取深度特征推理端使用 ONNX Runtime避免安装大体积 PyTorchFAISS CPU 版做向量索引SQLite 存元数据和去重报告命令行入口走 argparse另带一个可选的本地 Web 页面做可视化复核。为什么不用 PyTorch 直接跑我知道很多教程推荐这么做但实操中发现以下几点PyTorch 包体积太大在只有 CPU 的机器上照样占几个 GB而 ONNX 模型只需要几百 MB启动速度快也不影响推理结果。为什么用 FAISS因为当图片数量到十万级时暴力逐个比对向量已经是瓶颈FAISS 的 IndexFlatIP 在 CPU 上就能用多线程做矩阵乘快一个数量级。2. 核心模块与实现细节2.1 图片预处理别在这步偷懒很多人处理图片任务时不重视预处理实际上一大半误判都是从这里来的。paperclip 对每张图统一做这么几件事读取图片后先把 EXIF 里的方向信息转正否则手机拍的竖图会被横向读取转成 RGB去掉 Alpha 通道统一缩放成 256x256 的缩略图对非标准尺寸图做居中裁剪而不是拉伸避免长图的文字部分被压缩成一团噪声。有一个细节值得单独说生成缩略图时如果直接用resize原图的宽高比例会被破坏导致后续特征提取受影响。我对超宽图、长截图一律采用“先缩放较长边到目标尺寸再居中裁剪正方形”的策略。这样保留下来的中心区域通常是视觉信息最密集的部分。对于超大图比如单张 6000x4000 的摄影原片不要直接把它送进模型。两万张原图如果全部 1:1 读进来光解码时间就够吃一顿午饭。paperclip 在扫描阶段就只保留缩略图缓存原图只在最终人工确认时才会打开。2.2 感知哈希快而糙的第一道闸感知哈希的原理简单来说是先把图片缩小成固定尺寸转成灰度再计算邻接像素之间的梯度关系。常用算法有三种ahash、phash、dhash。它们的差异在于算法比较维度对缩放/裁剪对亮度变化计算速度ahash平均灰度一般差最快phashDCT 低频系数较强较好中等dhash相邻像素梯度较强较好快paperclip 默认用的是 dhash因为它对轻微调色和缩放更稳健代码也短。它的核心逻辑是将图像缩小到 9x8 像素然后对每一行比较第 i 个像素和第 i1 个像素的明暗前者亮则记 1否则记 0。这样每行产生 8 位一共 8 行形成 64 位的哈希串。下面是我实现的核心片段from PIL import Image def dhash(image: Image.Image, hash_size: int 8) - int: # 缩小到 (hash_size 1) x hash_size img image.convert(L).resize((hash_size 1, hash_size), Image.LANCZOS) pixels list(img.getdata()) value 0 for row in range(hash_size): row_start row * (hash_size 1) for col in range(hash_size): left pixels[row_start col] right pixels[row_start col 1] value (value 1) | (1 if left right else 0) return value def hamming_distance(a: int, b: int) - int: return bin(a ^ b).count(1)这里 hash_size 取 8表示 64 位指纹。汉明距离代表两个哈希串之间有多少位不一致。0 表示完全一致小于等于 4 通常视为同一张图超过 10 基本可以判定是不同内容。阈值定得太松会出现牵连定得太紧又失去筛选意义所以我在实际使用的默认值是 6也就是说距离小于 6 的图片直接进入重复候选。2.3 CLIP 特征向量细而慢的第二道闸感知哈希能抓住纯复制和改尺寸但抓不住下面这些情况图片裁掉 30%、加了很粗的边框、整体色调偏移、两个不同来源但是相同产品角度拍摄。这些就需要特征向量上场。我选择的特征提取器是 CLIP。为什么是它因为 CLIP 训练时做了图文对比它的特征对语义内容更敏感而不是对像素细节敏感。换句话说它更关注“画面里是什么”而不是“某个像素是什么颜色”。这对素材管理非常合适。实际推理时我使用的是 ONNX 版本的 CLIP ViT-B/32输入是 224x224 的 RGB 图像输出是 512 维向量。拿到向量后必须先做 L2 归一化否则后续算相似度时明亮的图和暗的图会因为向量长度不同而产生偏差。归一化之后向量点积就等于余弦相似度。一个容易踩的坑是CLIP 的预处理和普通图像输入不太一样。它要求把像素值归一化到 [-1, 1]并按照训练时的均值/标准差做标准化。如果直接拿 0-255 的像素值丢进模型特征质量会明显下降。我封装了一个 inference 函数统一处理这一步import cv2 import numpy as np import onnxruntime as ort MEAN np.array([0.48145466, 0.4578275, 0.40821073]) STD np.array([0.26862954, 0.26130258, 0.27577711]) def preprocess(image: np.ndarray) - np.ndarray: # 统一缩放到短边 224中心裁剪到 224x224 h, w image.shape[:2] scale 224 / min(h, w) image cv2.resize(image, (int(w * scale), int(h * scale))) image image[(image.shape[0] - 224) // 2: (image.shape[0] - 224) // 2 224, (image.shape[1] - 224) // 2: (image.shape[1] - 224) // 2 224] image image.astype(np.float32) / 255.0 image (image - MEAN) / STD # 转成 CHW 并加 batch 维 return image.transpose(2, 0, 1)[None, ...].astype(np.float32) def get_embedding(onnx_session, image: np.ndarray) - np.ndarray: tensor preprocess(image) outputs onnx_session.run(None, {pixel_values: tensor}) emb outputs[0][0] norm np.linalg.norm(emb) return emb / norm这一步做完每张图最终变成一个 512 维的单位向量。后续所有相似度都是在这个向量空间里计算的。2.4 向量检索与阈值判定CLIP 向量有了接下来就是怎么快速找相似。最直观的做法是两两计算点积但那是平方复杂度。两万张图的时候是 4 亿次点积还能接受二十万张图就是 400 亿次直接卡死。重点是我不想给普通人增加太多折腾成本所以把 FAISS 引入作为本地索引引擎。FAISS 的 IndexFlatIP 是暴力精确检索但它底层调了 BLAS在 CPU 上并行计算速度比普通 Python 循环快非常多。我这里选择它而不是 IndexIVFFlat 这种近似的索引原因是图片数量还没到百万量级精确检索带来的准确率提升更值钱。十万张图的向量库在普通笔记本上用 IndexFlatIP 做一次查询也就几十毫秒完全可以接受。相似度判定不能只看模型输出。我把阈值设计成可配置参数默认推荐 0.88。这个数值来源也解释一下0.88 对于“同一产品的不同角度”是临界区对“同一张图带不同水印”则在 0.95 以上。如果阈值设成 0.80误召回会突然增加因为 CLIP 对“同主题”和“同构图”的区分在 0.80 附近很模糊。我建议用户先按 0.88 跑一轮再根据结果微调。为了减少大量相似对造成的噪音paperclip 在 FAISS 返回 TopK 候选后还会做一次“去冗余配对”的操作如果 A 和 B 相似B 和 C 也相似那 A、B、C 应该归为一个集合而不是产生三对重复项。这个步骤用并查集就能做到代码并不复杂但体验提升很大。2.5 数据模型与存储设计索引数据我全部存在 SQLite 里这样单文件可备份、可迁移不用在系统里跑来跑去。表结构就三张images图片路径、dhash 值、文件大小、扫描时间embeddingsimages 的 id、向量分块存储512 个 float 拆成 8 行避免单行超长duplicates分组 ID、重复组内容、判定来源、人工确认状态。为什么不直接用向量文件因为 SQLite 很适合和小工具捆绑迁移时只需拷走一个.db文件。向量本身存在单独的表里构建索引时先读哈希做粗筛只有哈希距离在合理范围内的图片才进入向量比对这样可以减少大量无意义的嵌入计算。还有一个实际经验不要把图片的原始路径直接当作主键。素材目录经常移动路径一变就全部失效。我给每个文件算一个由 file size 修改时间 路径 组成的指纹路径变化时只要大小和时间没变还能重新对应上。这比单纯用路径稳妥得多。3. 从零跑通 paperclip我的完整实操记录3.1 环境准备与安装我在一台 Windows 11 笔记本上做的验证配置是 i5-1240P 16GB 内存没有独立显卡。安装步骤很简单先建虚拟环境防止污染系统 Pythonpython -m venv venv venv\Scripts\activate pip install pillow opencv-python onnxruntime faiss-cpu fastapi uvicornCLIP 的 ONNX 模型我是提前从官方仓库转换好的文件放在models/clip_vitb32_224.onnx。整个模型约 340MB属于合理范围。为了在无外网环境也能跑我设计了一个 cache 目录模型只检查一次后续启动不再重复加载。这一步最常见的坑是faiss-cpu 在 Windows 上如果装不上往往是需要先装 Microsoft C Build Tools。如果你只是用纯 Python 场景也可以暂时去掉 FAISS退化为 numpy 点积检索就是慢一点但功能一样。3.2 构建本地图片索引索引构建是第一步。命令如下python -m paperclip index --dir D:\material_2024 --db paperclip.db扫描过程中程序会打印每个目录的进度。我负责处理的那两万多张图第一次构建耗时大概是 18 分钟其中绝大部分时间都花在 CLIP 推理上。每个缩略图的感知哈希计算只需要几毫秒CLIP 则要 300 毫秒左右。扫描完成后数据库里有两万多条记录。此时我还会跑一个统计命令看看图片的类型分布、像素分布、是否有异常小图python -m paperclip stats --db paperclip.db这一步能提前暴露问题比如某些目录下全是 400x300 的略缩图这时候后续去重的策略就要调整。不要跳过这个检查否则你会在误判报告里花很多时间。3.3 去重执行先看报告再动手paperclip 的去重流程分成两步。第一步是生成报告python -m paperclip dedup --db paperclip.db --threshold 0.88 --dry-run--dry-run只做分析与分组不做任何文件移动。生成的分组报告是 HTML 格式每个分组里展示所有成员图的缩略图、路径、相似度分数。我用浏览器打开后肉眼扫一遍把明显误判的组标记为“忽略”。这一步大概花了半小时但值得因为没有任何算法可以完全替代人眼对视觉判断的信任。确认无误后执行真正的移动python -m paperclip dedup --db paperclip.db --threshold 0.88 --move-to D:\duplicates_trash它会按分组保留路径层级最深、文件最大的那张作为原始副本其余移动到指定目录并按分组的 ID 建子目录。移动而不是删除是因为我们做素材管理时宁可多占一点磁盘也不能因为自动判定造成不可逆损失。等所有组都确认过、并且业务方也认可后才手动清空回收目录。3.4 相似图检索与目录整理去重之外paperclip 的检索能力在工作中更常用。比如设计同事拿着某张历史素材图来问“这个效果以前是不是做过”我用一张图就能把相关结果全部捞出来python -m paperclip query --image D:\query\style_ref.png --db paperclip.db --topk 20输出会按相似度从高到低排列包括每个候选图的路径和分数。我在实际使用里发现CLIP 的检索非常擅长“找同类元素、同类构图、同类色调”。比如我用一张“清晨城市街道”的图去查返回结果里大部分都是城市街景哪怕亮度、角度完全不同。这说明语义层抓取确实比像素层更符合直觉。我后来还做了一个小的批量整理功能给定多个关键词标签比如“工业风、暗色调、木纹、玻璃幕墙”程序把向量库里与这些文本描述向量接近的图片全部整理到对应目录。这件事本质上就是零样本分类对大规模素材归类非常实用。3.5 性能实测一张表看清楚取舍为了让你对这套方案的开销有直观感受我把 10368 张混合图片摄影图、截图、UI 稿做了完整测试结果如下阶段耗时内存峰值说明扫描文件与缩略图2 分 10 秒320MB主要是磁盘 IO感知哈希计算1 分 05 秒400MB纯 CPU线性扩展CLIP 向量提取52 分 30 秒1.2GB单线程 ONNX最耗时索引构建与去重报告6 分钟1.6GBFAISS 构建 分组单张图查询 Top2040 毫秒1.6GB索引常驻内存可见最大瓶颈是 CLIP 向量提取。这也是为什么一定要先用哈希做粗筛如果两张图 dHash 距离为 0它们根本不需要进入向量比对阶段。在我的库里大约 18% 的图能被感知哈希直接判定为重复省下的向量提取时间相当可观。如果你机器上有 NVIDIA 显卡可以把 ONNX Runtime 换成 CUDA 版向量提取时间能压到 10 分钟以内。但我自己测试时发现 CPU 版本已经够用就没在驱动上多折腾。4. 常见问题与排查技巧实录4.1 阈值怎么调才不误判这是我最常被问到的问题。直接给结论找完全一样的重复图dHash 距离设 4 以内特征阈值设 0.96找带水印、轻微调色、加了边框的变体特征阈值设 0.92找同一素材的不同尺寸、不同裁剪特征阈值设 0.88想找同场景、同主题但不是同构图的图阈值会掉到 0.78 以下误判风险极高。我的建议是先用 0.92 跑一遍快速清理明显重复再用 0.85 跑一遍把大组单独导出人工扫一遍。两轮下来准确率最高也最不容易误删。一个很容易误判的类别是纯色背景图。比如两张白底产品图如果产品只占中心 10% 的面积特征向量会非常接近因为它们的大部分视觉信息是同一片白色。对这种图我会在预处理阶段把图像按中心区域裁得更大一些减少背景占比效果立竿见影。4.2 大批量构建索引时内存爆了怎么办我遇到过一万张图索引构建到一半内存飙到 4GB 的情况。排查后发现问题不在特征向量而在于我一次性把所有图片路径和缩略图都塞进内存做批处理。解决方法是分批每次只处理 512 张图提取完特征立刻写入数据库并释放对象引用。FAISS 方面IndexFlatIP 需要把整个向量库加载进内存。十六万张图的向量也就 512x160000x4 字节约 320MB其实不算大。如果你的图到了百万级就不要再继续用 IndexFlatIP改成 IndexIVFFlat 并训练聚类中心。这一步能显著降内存代价是召回率会有轻微损失。另外程序异常中断会导致数据库写入不完整。我在关键写入点加了事务每次批量提交 1000 条。即使中途崩掉already indexed 的图片不会重复处理下次启动会从断点继续。4.3 CLIP 模型升级后向量对不上有一个坑藏得很深如果你换了一个版本的 CLIP 模型提取出来的向量空间可能完全不兼容。比如我一开始用的是 ViT-B/32后来想换成 ViT-B/16结果检索效果变好了但新旧向量混在一起算相似度分数整个乱掉。解决方案其实很简单数据库里记录每个 embedding 对应的模型指纹即模型名 输入尺寸 特征维度 模型哈希。查询时如果模型指纹不匹配就提示用户重新提取向量或者直接迁移到新库。不要心存侥幸不同模型的向量不能混用。4.4 坏图、超大图、动图怎么处理素材库里永远有各种非标准图片。比如伪装成 JPG 的 PNG、0 字节的损坏文件、超过 200MB 的 PS 导出图、还有 GIF 动图。如果不特殊处理任何一个异常都可能导致扫描进程卡死或闪退。我的做法是三层防护根据扩展名初始化扫描但实际读取时用 Pillow 的verify()先检查文件头对超过 5000 万像素的图片强制按比例缩小后再进缩略图流程对 GIF 只取第一帧对 WebP 统一转成 RGB。加了这些逻辑之后整个扫描过程基本没有再因为坏图中断过。我还在日志里打了skipped.png列表留底可查。4.5 去重时文件权限和路径引用的坑最后记录一个桌面端用户很容易遇到的问题。当我把重复文件移动到隔离目录后某些设计软件里的旧引用会失效因为文件路径变了。所以我增加了两个选项一是移动前生成 CSV 映射文件原路径 - 新路径方便恢复二是支持“软链接模式”只在隔离目录建立一份硬链接或符号链接原路径保留不动。这个细节在团队协作时价值很大。设计师经常要回退到某个历史版本如果软链接还在文件打开路径就不受影响。虽然多占了一点点 inode但相比“找不回文件”的损失完全值得。从开始动手写 paperclip到真正用它把整个素材库整理干净我花了一周晚上和两个周末。最大的收获不只是省下了几十 GB 空间而是重新理解了工具设计的取舍要能批量处理也要保留人工确认的环节要自动也要可追溯。根据我的个人经验如果你也要整理自己的素材库最重要的一件事是先跑一次 dry-run生成报告在任何自动删除类操作发生之前先把报告发给所有会用这些文件的人看一遍。这个习惯帮我挡掉过至少三次不必要的文件损失。