简介:这份资源面向计算机视觉初学者与需要实现图像检索功能的开发者,提供了一套基于相似度计算的图片搜索程序,可用于内容推荐、版权检测或视觉识别等场景中查找与查询图相似的图片。压缩包共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', pretrained=True) 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_pickle=True) 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_size=32, num_workers=4, shuffle=False) 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(dim=1, keepdim=True) 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(space='cosine', dim=dim) index.init_index(max_elements=num_elements, ef_construction=200, M=16) index.add_items(data, np.arange(num_elements)) # 查询时 ef 越大越准越慢 index.set_ef(64) labels, distances = index.knn_query(query.astype('float32'), k=10) 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, k=100) # 精排:用局部特征对候选重新打分 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, key=lambda x: -x[1])[:10]逻辑说明:粗排负责「别漏」,精排负责「排准」。match_descriptors里通常用比值判别法过滤掉不可靠的匹配点,只保留强匹配。匹配点数量越多,说明两张图共享的局部结构越多。
参数说明:粗排的k设 100 是经验值,太小精排没得选,太大精排扛不住。精排阶段可以设一个最小匹配点数阈值,低于阈值直接判为不相似,减少误报。
5.2 向量量化:把内存占用砍到四分之一
百万级向量用 float32 存,光原始数据就 2048 乘 100 万乘 4 字节,接近 8GB,加上索引轻松破 15GB。用乘积量化能把每个向量压到几十字节。
乘积量化的思路是把高维向量切成若干段,每段用一个聚类中心编号表示。查询时用查表的方式算近似距离,速度也快。
| 方案 | 单向量占用 | 召回损失 | 适用场景 |
|---|---|---|---|
| float32 原始 | 8192 字节 | 无 | 十万级以内 |
| float16 | 4096 字节 | 极小 | 百万级,精度要求高 |
| 乘积量化 8 子段 | 约 8 字节 | 5% 到 15% | 千万级,可接受重排 |
选型建议:数据量在百万以内,直接 float16 就够,改动最小。上到千万级再考虑乘积量化,但一定要配重排,否则召回损失肉眼可见。
5.3 一个我坚持了很久的习惯
每次换模型或者改索引参数,我都会留一份固定的评测集:200 张查询图,每张人工标好正确答案。改完配置先跑评测集,看召回率和 P99 有没有退化,再决定要不要上线。这个习惯帮我挡掉过好几次「感觉更快了但其实召回掉了」的翻车。相似检索这东西,没有评测集就是盲调,早晚要还债。
希望帮到你。
本文还有配套的精品资源,点击获取