ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Python以图找图类库实战:感知哈希与颜色特征实现相似图片检索

Python以图找图类库实战:感知哈希与颜色特征实现相似图片检索 简介这份资源是一套用 Python 实现的以图找图图像检索类库面向具备一定 Python 基础、希望快速搭建图片相似度比对能力的开发者可用于电商找同款、社交平台重复内容检测、图像库检索等场景。压缩包为 7z 格式共 4 个文件包含 2 个 py 源码、1 个 pyc 编译文件与 1 个 md 说明文档整体约 5KB体量轻巧核心逻辑集中在主程序与哈希特征提取模块中便于直接阅读与二次改造。目前已有 873 人学习下载。资源围绕特征提取、描述符匹配、图像距离度量与相似度计算等关键环节展开并借助 Cython 思路优化性能读者可据此理解以图找图的完整实现链路掌握对文件夹内图片批量比对、输出相似结果或记录日志的用法为构建自己的图像检索系统提供可复用的脚本与排错参考。1. 以图找图类库从一张截图反查原图的工程路径电商后台收到一张被裁掉水印的商品图运营想知道它来自哪个 SKU素材库里躺着三十万张设计稿设计师只记得主色是墨绿、构图偏左。这类需求落到代码里就是「以图找图」——给一张查询图在候选图库里按视觉相似度排序返回最像的若干张。Python 生态里做这件事的类库不少但真正能扛住几十万级图库、还能在普通开发机上跑通的组合并不多。这篇笔记不讲论文只讲我实际搭过的一套方案用感知哈希做粗筛、用颜色与纹理特征做精排封装成一个可复用的 Python 类库接口设计成add()和query()两个方法图库规模从几百张到几十万张都能平滑过渡。适合已经会写 Python、想给自己的工具链加一个「找相似图」能力的后端或算法同学也适合刚入门、想找一个能跑通全流程的实战项目练手的人。2. 选型为什么不用深度学习模型做第一版2.1 三种主流路线的成本对比以图找图在工程上大致有三条路一是直接用预训练 CNN 提特征向量再上向量检索二是用传统图像特征SIFT、ORB、颜色直方图、感知哈希三是混合方案粗筛用哈希、精排用深度特征。很多人一上来就想上 CLIP 或者 ResNet觉得「深度准」但真到落地阶段成本和收益要算清楚。路线单图特征提取耗时依赖图库 10 万张时的内存对硬件要求感知哈希 颜色特征15 ms仅 Pillow/OpenCV约 2050 MB任意机器预训练 CNN 特征2080 msCPUPyTorch/TF 权重文件约 400 MB1.6 GB建议有 GPU混合方案粗筛 2 ms 精排 30 ms两者都要中等中等我第一版选的是纯传统特征路线原因很直接图库是内部素材绝大多数查询是「找同一张图的不同版本」或者「找构图相近的图」不是「找语义相似的猫和狗」。感知哈希对缩放、轻微裁剪、亮度调整非常稳而深度特征反而容易在「同一张图被压缩过」这种场景下给出奇怪的排序。另一个现实原因是部署——传统特征方案不需要 GPU不需要下载几百 MB 的权重一个pip install就能跑这对内部工具来说太重要了。2.2 类库的接口设计原则既然是「类库」接口就不能写成一坨脚本。我定的原则是调用方只需要关心三件事——往库里加图、从库里查图、把结果存下来。其余全部封装。核心类叫ImageIndex对外暴露四个方法add(image_path, metaNone)加一张图meta 是任意附加信息比如 SKU、文件路径。add_batch(paths)批量加内部走多进程。query(image_path, top_k10)查最像的 top_k 张。save(path)/load(path)把索引持久化避免每次重启重算。这个设计的好处是调用方不需要知道底层用的是哈希还是 CNN将来换特征提取器接口不变。下面这张表是我在选特征时做的对比最终选了「dHash 颜色矩」的组合特征对缩放对裁剪对亮度变化对旋转计算成本aHash稳差差差极低dHash稳中中差极低pHash稳中稳差低颜色矩稳中中稳低ORB稳稳稳稳中dHash 负责抓结构颜色矩负责抓色调两者拼成一个 72 维向量64 位哈希 8 维颜色矩用汉明距离加欧氏距离的加权和做相似度。这个组合在内部素材库上跑出来的 top-10 命中率比单用 pHash 高了将近 20 个百分点。3. 动手把以图找图类库跑起来的最小实现3.1 环境准备与依赖安装先把环境弄干净。我习惯用 venv避免和系统 Python 打架。Python 版本建议 3.9 以上3.8 也能跑但有些新语法用不了。# 创建虚拟环境 python -m venv imgsearch-env # 激活Linux/macOS source imgsearch-env/bin/activate # 激活Windows imgsearch-env\Scripts\activate # 安装依赖只装必要的 pip install pillow numpy opencv-python-headless这里有个坑opencv-python带 GUI 依赖在服务器上装完可能报缺少 libGL。用opencv-python-headless就没这个问题功能一样只是不能imshow。如果你只是做特征提取和检索headless 版本足够。Pillow 用来读图和做基础变换numpy 负责向量运算三个包加起来不到 100 MB。3.2 特征提取dHash 与颜色矩的实现先写特征提取模块。dHash 的思路是把图缩成 9x8 的灰度图逐行比较相邻像素得到 64 位指纹。颜色矩则是把图缩成小图后统计每个通道的均值和标准差。import numpy as np from PIL import Image import cv2 def dhash(image, hash_size8): 差值哈希缩放到 (hash_size1, hash_size)逐行比较相邻像素 # 统一转灰度忽略色彩干扰 img image.convert(L).resize((hash_size 1, hash_size), Image.LANCZOS) pixels np.asarray(img, dtypenp.int16) # 每行相邻像素做差大于 0 记 1否则记 0 diff pixels[:, 1:] pixels[:, :-1] # 展平成 64 位打包成 uint64 方便存 bits np.packbits(diff.flatten()) return bits.tobytes() def color_moments(image, bins4): 颜色矩HSV 空间下每个通道的均值和标准差共 6 维 img image.convert(RGB).resize((64, 64), Image.LANCZOS) hsv cv2.cvtColor(np.asarray(img), cv2.COLOR_RGB2HSV) moments [] for i in range(3): channel hsv[:, :, i].astype(np.float32) / 255.0 moments.append(channel.mean()) moments.append(channel.std()) return np.array(moments, dtypenp.float32) def extract_features(image_path): 对外统一入口返回 (哈希字节, 颜色矩向量) img Image.open(image_path) # 处理 EXIF 旋转否则手机拍的图方向会乱 img ImageOps.exif_transpose(img) return dhash(img), color_moments(img)dhash里用LANCZOS重采样是为了保留更多高频信息换成NEAREST会让哈希对缩放过于敏感。color_moments把通道值归一化到 01避免不同位深图片8 位和 16 位算出来的矩量级不一致。extract_features里那句exif_transpose是血泪经验——不加的话手机竖拍的照片在库里全是横的查出来的结果排序会莫名其妙地差。3.3 索引构建与相似度查询有了特征接下来是索引。我用两个 numpy 数组存所有特征一个存哈希字节转成 uint8 数组一个存颜色矩。查询时先算汉明距离再算颜色矩的欧氏距离加权求和。import numpy as np class ImageIndex: def __init__(self, hash_weight0.7, color_weight0.3): self.hashes [] # 存 uint8 数组每个 8 字节 self.colors [] # 存 6 维 float32 self.metas [] # 存附加信息 self.hash_weight hash_weight self.color_weight color_weight def add(self, image_path, metaNone): h, c extract_features(image_path) self.hashes.append(np.frombuffer(h, dtypenp.uint8)) self.colors.append(c) self.metas.append(meta or {path: image_path}) def _hamming(self, a, b): 两个 uint8 数组的汉明距离用 XOR popcount xor np.bitwise_xor(a, b) return int(np.unpackbits(xor).sum()) def query(self, image_path, top_k10): qh, qc extract_features(image_path) qh np.frombuffer(qh, dtypenp.uint8) scores [] for i in range(len(self.hashes)): # 汉明距离归一化到 0~1越小越像 h_dist self._hamming(qh, self.hashes[i]) / 64.0 # 颜色矩欧氏距离除以经验常数 2.0 归一化 c_dist np.linalg.norm(qc - self.colors[i]) / 2.0 score self.hash_weight * h_dist self.color_weight * c_dist scores.append((score, i)) scores.sort(keylambda x: x[0]) return [(s, self.metas[i]) for s, i in scores[:top_k]]hash_weight和color_weight这两个参数是调优的关键。默认 0.7/0.3 适合「找同一张图的不同版本」如果你更在意色调相近把 color_weight 提到 0.5 试试。_hamming里用unpackbits展开再求和比逐位循环快一个数量级10 万张图的查询能在 200 ms 内出结果。query返回的是(score, meta)列表score 越小越像调用方自己决定阈值。3.4 批量入库与持久化单张add在几千张图时够用上到几万张就得批量。我用multiprocessing把特征提取并行化主进程只负责收集结果。from multiprocessing import Pool import pickle def _worker(path): try: return extract_features(path), path except Exception as e: return None, path # 坏图跳过不中断整个批次 def add_batch(self, paths, workers4): with Pool(workers) as p: for feat, path in p.imap_unordered(_worker, paths, chunksize32): if feat is None: continue h, c feat self.hashes.append(np.frombuffer(h, dtypenp.uint8)) self.colors.append(c) self.metas.append({path: path}) def save(self, path): with open(path, wb) as f: pickle.dump({ hashes: self.hashes, colors: self.colors, metas: self.metas, weights: (self.hash_weight, self.color_weight) }, f) def load(self, path): with open(path, rb) as f: data pickle.load(f) self.hashes data[hashes] self.colors data[colors] self.metas data[metas] self.hash_weight, self.color_weight data[weights]chunksize32是试出来的太小进程间通信开销大太大内存峰值高。_worker里用 try/except 包住遇到损坏的 JPEG 直接跳过不然一个坏文件能让整批入库挂掉。save用 pickle 存简单直接10 万张图的索引文件大概 30 MB加载不到一秒。如果你要跨语言用可以把哈希和颜色矩导出成 npy 或二进制格式但纯 Python 场景 pickle 最省事。4. 避坑以图找图类库落地时的五个真实翻车点4.1 现象查询结果里全是同一张图的缩略图原因图库里存在大量同一张图的不同尺寸版本dHash 对缩放不敏感导致它们互相之间距离极近把真正不同的图挤出了 top_k。解决入库时做去重汉明距离小于 3 的视为重复只保留分辨率最高的那张。或者在query后做一次后处理同一 meta 来源的只保留一条。4.2 现象手机拍的图查不到电脑上传的同款图能查到原因手机照片带 EXIF 旋转信息Pillow 默认不应用旋转导致特征提取时图是躺着的。解决在extract_features里加ImageOps.exif_transpose这一步必须在 resize 之前做否则旋转后的插值会引入额外噪声。4.3 现象颜色矩距离计算特别慢10 万张要好几秒原因np.linalg.norm在循环里逐对计算没有利用向量化。解决把所有颜色矩堆成一个(N, 6)的矩阵查询时用np.linalg.norm(matrix - qc, axis1)一次算完。这一步能把颜色距离的计算时间从秒级压到毫秒级。4.4 现象入库到一半内存爆了原因add_batch把所有特征先攒在列表里10 万张图的哈希加颜色矩虽然不大但 Python 列表的对象开销是实际数据的几倍。解决分批入库每 5000 张调一次save或者改用 numpy 预分配数组按索引写入。我一般会在add_batch里加一个flush_every参数默认 5000。4.5 现象换了台机器加载索引查询结果全乱原因pickle 序列化时依赖了 numpy 的版本不同版本的 numpy 对uint8数组的字节序处理可能不同。解决save时显式指定protocolpickle.HIGHEST_PROTOCOL并且在load后做一次np.asarray强制转换。更稳妥的做法是把哈希存成十六进制字符串颜色矩存成 JSON牺牲一点体积换跨平台稳定。5. 进阶把命中率再往上推一档的两个技巧第一版跑通之后我在内部素材库上做了两轮优化top-10 命中率从 68% 提到了 85% 左右。第一个技巧是「查询扩展」拿到查询图后先做一次轻微裁剪去掉边缘 5%再算一次特征两次结果取交集。这能有效对抗「查询图带边框、库图不带」的情况。第二个技巧是「加权投票」不只看单张图的距离而是看 top-20 里同一来源的图占了几张如果某个 SKU 在 top-20 里出现了 5 次以上就把它提到第一位。这个思路借鉴了推荐系统里的协同过滤在「一个 SKU 有多张角度图」的场景下特别管用。def query_with_expansion(self, image_path, top_k10): 查询扩展原图 中心裁剪图两次结果融合 img Image.open(image_path) w, h img.size # 裁掉边缘 5%聚焦主体 cropped img.crop((int(w*0.05), int(h*0.05), int(w*0.95), int(h*0.95))) tmp_path /tmp/_query_crop.jpg cropped.save(tmp_path) r1 self.query(image_path, top_ktop_k*2) r2 self.query(tmp_path, top_ktop_k*2) # 融合同一 meta 的分数取平均出现两次的加权 merged {} for score, meta in r1 r2: key meta.get(path, str(meta)) if key in merged: merged[key] (merged[key][0] score) / 2 * 0.9 # 重复出现降权 else: merged[key] (score, meta) ranked sorted(merged.values(), keylambda x: x[0]) return ranked[:top_k]这段代码里0.9那个降权系数是试出来的目的是让「两次都命中」的图稍微占优但不至于完全压过「只命中一次但距离极近」的图。/tmp/_query_crop.jpg这个临时文件在生产环境要记得清理或者直接用内存字节流传给特征提取函数避免磁盘 IO。验证方法上我一般会准备一个 200 张图的测试集每张图人工标注 3 个「应该被找到」的目标然后跑query看 top-10 里命中几个。这个测试集不用大但必须覆盖「缩放」「裁剪」「亮度变化」「旋转」四种情况。每次改完权重或特征跑一遍测试集看命中率是涨是跌。没有这个测试集调参就是玄学。最后说个习惯我每次上线新版本前会把索引文件备份一份命名带上日期和权重参数。因为以图找图的调参很容易「改一个参数整体排序全变」没有后悔药的话回滚都回不去。这个方案从第一版到现在跑了两年多图库从 3 万涨到 40 万核心代码没大改过靠的就是接口稳定和测试集兜底。希望帮到你。本文还有配套的精品资源点击获取
返回列表