资讯动态

图像相似检索实战:特征提取、向量索引与近似最近邻查询全解析

发布时间:2026/10/9 12:51:48 来源:尧图企业网站定制
简介这份资源面向计算机视觉初学者与需要实现图像检索功能的开发者提供了一套基于相似度计算的图片搜索程序可用于内容推荐、版权检测或视觉识别等场景中查找与查询图相似的图片。压缩包共30个文件约300KB以C源码为主体包含11个h头文件与5个cpp实现文件另有工程配置、资源脚本、说明文档及cximage.lib图像处理库整体结构紧凑便于在VC环境下编译调试。已有200人学习下载说明其在图像相似检索方向具有一定参考价值。程序覆盖图像预处理、特征提取、相似度度量与结果展示等环节读者可借此理解像素比较、色彩直方图或特征向量匹配的实现思路并参考检索文档与示例数据快速上手适合作为课程设计或小型检索系统的实践起点。1. 图像相似检索到底在解决什么问题你手里有几十万张商品图、设计稿或者监控截图想找出「和这张几乎一样」的那几张靠文件名搜索根本不现实。图像相似检索要解决的核心就是给每张图算出一个特征向量再用向量之间的距离衡量两张图有多像。它和「以图搜图」是同一件事的两种说法落地时通常拆成三步特征提取、向量入库、近邻查询。这套方案适合谁做电商去重、素材库管理、内容审核、工业质检的团队都用得上。新手可以先用现成模型跑通最小闭环熟手则要关心召回率、阈值和索引结构。下面我按「先跑通、再调优、最后避坑」的顺序把这条链路拆开讲清楚。2. 特征提取选对模型比调参更重要图像相似检索的效果八成取决于特征提取这一步。选错模型后面索引和阈值调得再细也是白费。这一章先把模型选型和最小可运行代码讲透。2.1 为什么全局特征和局部特征要分开选图像相似分两种场景一种是「整体看起来像」比如同一款商品的不同角度另一种是「局部有相同元素」比如两张海报里出现了同一个 logo。前者用全局特征后者用局部特征混用会翻车。全局特征常见做法是用预训练卷积网络或视觉 Transformer 的倒数第二层输出池化成一个固定长度向量比如 512 维或 768 维。它的优点是快、向量短、索引友好缺点是裁剪、旋转、加文字后容易失效。局部特征走的是关键点加描述子的路线一张图会产出几百到几千个向量。它抗裁剪、抗遮挡但存储和查询成本高一个量级。我的经验是素材库去重、商品同款这类需求先用全局特征只有全局特征召回明显不够时再上局部特征做重排。2.2 用预训练模型提取 512 维向量的最小代码下面这段代码用常见的开源视觉模型做特征提取输入一个图片目录输出每张图的归一化向量。注意向量一定要做 L2 归一化否则后面用余弦距离会算错。import os import numpy as np import torch from PIL import Image from torchvision import transforms # 加载预训练模型去掉最后的分类层只保留特征 model torch.hub.load(pytorch/vision, resnet50, pretrainedTrue) model torch.nn.Sequential(*list(model.children())[:-1]) # 去掉 fc 层 model.eval() # 标准预处理缩放、中心裁剪、归一化 preprocess transforms.Compose([ transforms.Resize(256), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]), ]) def extract(img_path): img Image.open(img_path).convert(RGB) tensor preprocess(img).unsqueeze(0) # 增加 batch 维度 with torch.no_grad(): feat model(tensor).squeeze() # 输出 2048 维 feat feat.numpy() feat feat / np.linalg.norm(feat) # L2 归一化关键 return feat if __name__ __main__: root ./images vectors {} for name in os.listdir(root): if name.lower().endswith((.jpg, .png, .jpeg)): vectors[name] extract(os.path.join(root, name)) np.save(features.npy, vectors, allow_pickleTrue) print(done, len(vectors))逻辑说明model.children()[:-1]去掉全连接分类层保留池化后的特征。torch.no_grad()关闭梯度省显存也更快。最后一步 L2 归一化是重点归一化后两个向量的点积就等于余弦相似度查询时直接算点积即可。参数说明Resize(256)加CenterCrop(224)是这套模型的标配输入尺寸换成别的模型要查它自己的预处理要求。输出维度这里是 2048如果嫌向量太长可以在后面接一个线性层降到 512但降维会损失一点精度需要自己权衡。2.3 批量提取时怎么避免显存爆掉单张提取没问题一上批量就容易 OOM。常见做法是分批推理每批 32 或 64 张同时把图片解码放到DataLoader的多进程里做。from torch.utils.data import DataLoader, Dataset class ImageFolder(Dataset): def __init__(self, root, transform): self.paths [os.path.join(root, f) for f in os.listdir(root) if f.lower().endswith((.jpg, .png))] self.transform transform def __len__(self): return len(self.paths) def __getitem__(self, idx): img Image.open(self.paths[idx]).convert(RGB) return self.transform(img), self.paths[idx] loader DataLoader(ImageFolder(./images, preprocess), batch_size32, num_workers4, shuffleFalse) all_feats, all_paths [], [] with torch.no_grad(): for batch, paths in loader: feat model(batch).squeeze(-1).squeeze(-1) # [B, 2048] feat feat / feat.norm(dim1, keepdimTrue) all_feats.append(feat.numpy()) all_paths.extend(paths)batch_size从 32 起调显存够就往上加。num_workers设成 CPU 核数的一半左右比较稳设太大反而因为进程切换变慢。这一步跑完你就得到了一个[N, 2048]的特征矩阵可以进入下一章的索引环节。3. 向量索引与相似度查询从暴力检索到近似最近邻特征有了接下来要解决「给定一张查询图怎么在几十万向量里快速找出最像的 K 张」。数据量小的时候暴力算就行数据量一大就必须上索引。3.1 余弦距离和欧氏距离到底用哪个很多人纠结这两个距离。结论是向量做过 L2 归一化之后余弦相似度和欧氏距离是等价的排序结果完全一致。所以选哪个不影响召回只影响你代码里怎么写。余弦相似度越大越像取值在 -1 到 1 之间欧氏距离越小越像。实际工程里我更习惯用内积因为归一化后内积就是余弦相似度而且很多向量库对內积有专门优化。import numpy as np # feats: [N, D] 已归一化; query: [D] 已归一化 scores feats query # 内积 余弦相似度 topk np.argsort(-scores)[:10] # 取相似度最高的 10 个 for i in topk: print(i, scores[i])这段就是暴力检索复杂度是 O(N*D)。N 在十万以内、D 是 512 时单次查询几十毫秒完全能用。超过百万级再考虑索引。3.2 用近似最近邻索引把查询压到毫秒级数据量上到百万暴力检索就顶不住了。常见做法是用基于图结构的近似最近邻索引比如 HNSW。它用多层跳表加邻居图查询时从粗到细逐层逼近牺牲一点点召回换几十倍的加速。import hnswlib import numpy as np dim 2048 num_elements len(all_feats) data np.vstack(all_feats).astype(float32) # 建索引M 控制图的连接度ef_construction 控制建索引时的搜索范围 index hnswlib.Index(spacecosine, dimdim) index.init_index(max_elementsnum_elements, ef_construction200, M16) index.add_items(data, np.arange(num_elements)) # 查询时 ef 越大越准越慢 index.set_ef(64) labels, distances index.knn_query(query.astype(float32), k10) print(labels, distances)参数说明M是每个节点的邻居数越大召回越高但内存和建索引时间也涨16 到 48 是常用区间。ef_construction建索引时的候选池大小200 起步。ef是查询时的候选池直接决定召回和延迟的平衡线上一般设 64 到 256。这三个参数是调优的主战场别一次全改固定两个调一个。3.3 阈值怎么定才不误伤检索出 Top-K 只是第一步业务上往往还要判断「到底算不算相似」。这时候需要一个相似度阈值。阈值定高了漏召回定低了满屏误报。我的做法是拿一批标注好的正负样本对画出相似度分布取正样本召回 95% 时对应的相似度作为初始阈值再根据业务对误报的容忍度微调。经验值上归一化后的余弦相似度同款商品通常在 0.9 以上同类别不同款在 0.7 到 0.85 之间跨类别基本低于 0.6。但这个数字强依赖模型换模型必须重新标定。提示阈值不要写死在代码里做成配置项方便按业务线分别调整。4. 避坑与排查相似检索上线后最容易翻车的五件事这一章是我踩过的坑按「现象 → 原因 → 解决」写能帮你省不少调试时间。4.1 明明很像的两张图相似度却很低现象同一款商品换个背景相似度从 0.95 掉到 0.6。原因全局特征对背景和构图敏感模型把背景也编码进去了。解决要么在预处理阶段做主体检测裁剪把背景去掉再提特征要么换用对背景更鲁棒的模型或者在训练时做背景增强。最省事的办法是先加一步目标检测裁出主体再走后面的流程。4.2 索引建好了查询结果和暴力检索对不上现象HNSW 返回的 Top-10 和暴力算出来的不完全一样。原因近似最近邻本来就是「近似」召回率不是 100%这是设计取舍不是 bug。解决先确认召回率是否在可接受范围。把ef调大能提升召回但延迟上升。如果业务要求必须精确那就别用近似索引老老实实暴力检索或者用倒排加量化做折中。4.3 批量入库时内存一路飙升最后被系统杀掉现象导入十万张图跑到一半进程被 OOM Killer 干掉。原因把所有特征一次性读进内存再建索引中间还复制了好几份。解决分批add_items每批几千条加完就释放临时数组。建索引前预估内存N 乘 D 乘 4 字节是原始数据HNSW 还要额外存图结构通常是原始数据的 1.5 到 2 倍。4.4 换了新模型老向量和新向量混在一起查现象新入库的图检索正常老图怎么都搜不出来。原因不同模型的特征空间不通用混用等于拿两套坐标系比距离。解决换模型必须全量重算特征并重建索引。工程上给特征加一个模型版本号字段查询时只比对同版本向量避免这种玄学问题。4.5 查询延迟忽高忽低P99 特别难看现象平均延迟 5 毫秒但 P99 偶尔飙到几百毫秒。原因索引没预热、并发查询抢锁或者单次查询的ef设得过大。解决服务启动时先用一批查询把索引预热查询走只读副本避免写锁把ef按业务分级非核心场景用小ef。监控上重点看 P99 而不是平均值平均值会骗人。5. 进阶技巧用重排和量化把效果与成本同时压下来跑通基础链路后真正拉开差距的是重排和存储优化。这一章讲两个我常用的技巧都是能直接落地的。5.1 两阶段检索粗排加精排单靠全局特征召回率往往卡在 80% 出头。我的做法是两阶段第一阶段用全局特征加 HNSW 快速召回 Top-100第二阶段用更重的模型对这 100 张做精排。精排模型可以用局部特征做匹配也可以用更强的跨模态模型算相似度。因为只处理 100 张延迟增加可控。实测这套组合能把召回率从 82% 提到 94% 左右代价是单次查询多花 20 到 30 毫秒。# 粗排HNSW 召回 Top-100 labels, _ index.knn_query(query, k100) # 精排用局部特征对候选重新打分 def rerank(query_img, candidate_paths): q_kp, q_desc local_extract(query_img) scored [] for path in candidate_paths: c_kp, c_desc local_extract(path) # 用描述子做最近邻匹配统计匹配点数量作为分数 matches match_descriptors(q_desc, c_desc) scored.append((path, len(matches))) return sorted(scored, keylambda x: -x[1])[:10]逻辑说明粗排负责「别漏」精排负责「排准」。match_descriptors里通常用比值判别法过滤掉不可靠的匹配点只保留强匹配。匹配点数量越多说明两张图共享的局部结构越多。参数说明粗排的k设 100 是经验值太小精排没得选太大精排扛不住。精排阶段可以设一个最小匹配点数阈值低于阈值直接判为不相似减少误报。5.2 向量量化把内存占用砍到四分之一百万级向量用 float32 存光原始数据就 2048 乘 100 万乘 4 字节接近 8GB加上索引轻松破 15GB。用乘积量化能把每个向量压到几十字节。乘积量化的思路是把高维向量切成若干段每段用一个聚类中心编号表示。查询时用查表的方式算近似距离速度也快。方案单向量占用召回损失适用场景float32 原始8192 字节无十万级以内float164096 字节极小百万级精度要求高乘积量化 8 子段约 8 字节5% 到 15%千万级可接受重排选型建议数据量在百万以内直接 float16 就够改动最小。上到千万级再考虑乘积量化但一定要配重排否则召回损失肉眼可见。5.3 一个我坚持了很久的习惯每次换模型或者改索引参数我都会留一份固定的评测集200 张查询图每张人工标好正确答案。改完配置先跑评测集看召回率和 P99 有没有退化再决定要不要上线。这个习惯帮我挡掉过好几次「感觉更快了但其实召回掉了」的翻车。相似检索这东西没有评测集就是盲调早晚要还债。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑