ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

细粒度图像检索系统源码详解:从SIFT词包到深度哈希

细粒度图像检索系统源码详解:从SIFT词包到深度哈希 简介这是一份基于Python实现的细粒度图像检索系统源码包面向图像检索方向的开发者、研究人员及需要输出技术汇报的工程师核心解决细粒度图像精确匹配、多标签分类和特征表示学习等问题。包内共79个文件整体约66.21MB以37个Python源码文件为主体覆盖图像处理、特征提取、相似度计算与完整检索流程另含15个文本说明、11篇PDF学术论文、4个PPT汇报演示文稿及3个pyc编译文件等可结合文献理解算法细节也能直接用于工作汇报或项目小结。资源涉及SIFT词包模型、三元组损失网络、细粒度属性学习与多标签学习等典型方案并配有CUB等公开数据集的评测代码目录模块清晰便于二次开发。已有348人学习、下载适合需要系统掌握图像检索项目框架、快速运行实验并沉淀汇报材料的读者。1. 从“看着像”到“就是它”细粒度图像检索系统源码包里藏着什么同一款车的不同年款、同一品种鸟的不同亚种这种“整体差不多、细节差一点”的检索需求就是细粒度图像检索要解决的问题。这套基于 Python 实现的细粒度图像检索系统源码包把常用解法一次给齐了基于 SIFT 特征的词包模型、三元组损失网络、多标签网络、细粒度属性学习外加深度哈希方向的代码与论文基本覆盖了从传统特征到深度表示的主流路线。对我这种经常要搭 baseline 的人来说最值钱的是四条检索链路都能独立跑通且配套了方法论文 PDF、汇报 PPT 和开题报告代码、文档、汇报三件套齐全。适合正在做图像检索课题、需要可复现基线来对比算法的从业者。2. SIFT 词包模型先跑通闭环searchFeatures.py 与 query.py 的检索链路2.1 视觉词典的构建特征层面先做一遍聚类词包模型Bag of Visual Words的思路和文本检索里的词袋几乎一样把图像拆成若干局部特征再把特征“分词”成视觉单词最后统计每张图里每个单词出现的次数。图像检索里用词包模型打底最大的好处是每一步都可解释——特征提出来了、聚类分好了、直方图能可视化代码跑出来不对能定位到具体环节不像深度模型出了问题像个黑匣子。searchFeatures.py承担的就是建库端的特征提取和视觉词典训练。核心流程是先用 SIFT 提取每张图的局部描述子再把所有描述子堆在一起做聚类聚类中心就是视觉单词表。实际操作中我的习惯是直接上MiniBatchKMeans这个包里数据规模在几千张图级别全量KMeans也能跑但每轮迭代都要算所有样本到所有中心的距离内存和时间都会被拖住。# build_vocab.py —— 提取训练集 SIFT 描述子并聚类成视觉词典 import cv2 import numpy as np import joblib from sklearn.cluster import MiniBatchKMeans import glob sift cv2.SIFT_create(nfeatures0, contrastThreshold0.03) descs [] for img_path in glob.glob(train_imgs/*.jpg): img cv2.imread(img_path) gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) _, des sift.detectAndCompute(gray, None) if des is not None: descs.append(des) all_descs np.vstack(descs).astype(np.float32) kmeans MiniBatchKMeans(n_clusters512, batch_size8192, random_state42) kmeans.fit(all_descs) np.save(vocab_512.npy, kmeans.cluster_centers_) joblib.dump(kmeans, kmeans_512.pkl)这段逻辑看起来简单里面有几个参数直接影响检索效果。nfeatures0表示不限制 SIFT 特征点数量词包模型依赖特征点的覆盖率默认 1000 的上限在纹理丰富的图上会丢失细节我一般放开限制。contrastThreshold0.03是特征点响应阈值调低到 0.02 能多出不少弱纹理区域的特征但也会把噪声带进来平衡点建议在 0.02 到 0.04 之间试。聚类数n_clusters512对应视觉词典大小这个值太小了区分度不够不同类的直方图长得太像太大了直方图稀疏相似度计算容易失真。几千张图的规模512 到 1024 是常见取值区间具体要看类别数和类内差异来调。这里还有一个容易忽略的细节聚类前描述子要做归一化吗SIFT 描述子本身在 OpenCV 内部做过归一化但不同图像提出来的特征点数量差异很大直方图统计时如果不做后续归一化特征点多的图会在相似度计算里天然占便宜。这个放到检索端一并处理。2.2 检索链路与相似度排序query.py 里藏着哪些可调参数词典训练好之后检索链路分三段库图逐张提特征 - 量化为直方图 - 查询图走同样流程后算相似度。query.py实现的就是查询端的完整流程。这里有一个我踩过的坑建库时图像是什么尺寸查询时也必须用同样的尺寸和预处理否则 SIFT 提出来的特征点分布完全不同直方图之间没有可比性。# query.py 片段 —— 查一张图返回 TopK 相似图像 id import cv2 import numpy as np import joblib kmeans joblib.load(kmeans_512.pkl) sift cv2.SIFT_create(nfeatures0, contrastThreshold0.03) def bow_histogram(img_path, kmeans, vocab_size512): img cv2.imread(img_path) gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 检索前统一缩放到与库图一致的分辨率直方图才有可比性 gray cv2.resize(gray, (448, 448)) _, des sift.detectAndCompute(gray, None) if des is None: return np.zeros(vocab_size, dtypenp.float32) labels kmeans.predict(des.astype(np.float32)) hist np.bincount(labels, minlengthvocab_size).astype(np.float32) # L2 归一化消除不同图像特征点数量差异带来的尺度偏差 hist / (np.linalg.norm(hist) 1e-8) return hist q_hist bow_histogram(query/001.jpg, kmeans) sims np.load(image_hists.npy) # 建库阶段一次性算好的 (N, 512) 直方图矩阵 scores sims q_hist # 两者都已 L2 归一化内积即余弦相似度 topk np.argsort(scores)[::-1][:10] for rank, idx in enumerate(topk, 1): print(frank {rank}: image_{idx:04d}.jpg score{scores[idx]:.4f})这段代码的核心是第 14 行的内积计算。所有库图直方图堆成矩阵后和查询直方图做矩阵乘法一次拿到所有相似度再argsort取 TopK。前提是直方图都做过 L2 归一化否则内积会被特征点数量主导。更讲究一点的做法是加 TF-IDF 权重把出现在大量图中、没有区分度的视觉单词降权让出现次数少、类别指向性强的单词主导排序。对词包检索来说TF-IDF 带来的提升通常比换相似度度量更明显代价只是多存一份词的文档频率。建库端的image_hists.npy建议单独做一步存盘不要每次查询都重新提一遍全库特征。它会成为后续所有检索实验的公共底料——我在踩坑总结时还会回到这个文件。包里的UI界面词包模型.py把这条链路包了一层可视化界面适合演示项目进度但调参数时还是直接跑脚本看中间输出更方便。3. 三元组损失网络用 margin 和困难样本把 embedding 训得更能打3.1 距离度量与三元组构造P×K 采样和 margin 怎么定词包模型的问题在于它没有语义概念只认局部纹理的统计分布。深度检索网络的目标则是学一个嵌入空间让同类图像距离近、异类图像距离远。三元组损失Triplet Loss是这类方法里最经典的监督信号它拿三张图一组做约束锚点图、正样本图和负样本图要求锚点到正样本的距离加上一个 margin 后仍然小于锚点到负样本的距离。train.py里三元组网络的核心参数有两个margin 和 batch 采样策略。margin 太大容易让训练一直震荡——模型无论如何也拉不开这么远的距离loss 降不下去margin 太小模型学到一点区分度就满足了embedding 的判别性不够。我一般从 0.2 起步观察验证集的检索 mAP 再往上调到 0.3 或 0.5不要一开始就上大 margin。采样策略比 margin 更影响最终效果。朴素的随机采样会得到大量“简单三元组”负样本和锚点差异巨大模型不需要学就能满足约束loss 趋近于零等于没学。常见做法是 P×K 采样——每个 batch 里随机选 P 个类别每个类别选 K 张图保证 batch 内既有足够的正样本对也有充足的负样本池。# triplet_batch.py —— 构造 P×K 三元组 batch import random def build_pk_batch(labels, p8, k4): classes sorted(set(labels)) selected random.sample(classes, p) batch_idx, batch_labels [], [] for c in selected: idxs [i for i, l in enumerate(labels) if l c] random.shuffle(idxs) for a_idx in idxs[:k]: batch_idx.append(a_idx) batch_labels.append(c) return batch_idx, batch_labels # 每个 batch 共 p*k 张图代码逻辑不复杂先按类别把样本索引分组每个 batch 抽取 P 个类别、每个类别 K 张图。这样 batch 内任意一个锚点的正样本一定来自同类的另外 K-1 张负样本则从其余 (P-1)×K 张里找。有了这个 batch 结构就可以做在线难负样本挖掘不再用预先固定好的三元组而是在每次前向传播后用当前 embedding 实时计算距离挑 batch 内距离最近、最容易混淆的负样本来算 loss。这也是包内三元组损失网络目录下训练脚本的实际做法训练效率比离线挖负样本高得多。3.2 训练脚本落地train.py 里的 backbone 替换与 checkpoint 恢复模型结构部分包内默认走向是拿 ImageNet 预训练的 CNN 做 backbone把最后的分类头换成 embedding 输出层。常见做法是去掉fc层和avgpool之后的全连接换成一层输出 256 或 512 维的线性层。embedding 输出后要做一次 L2 归一化这样训练时用内积近似余弦相似度检索时也可以直接用内积排序。# model.py 片段 —— embedding 网络定义 import torch import torch.nn as nn from torchvision.models import resnet50, ResNet50_Weights class EmbeddingNet(nn.Module): def __init__(self, embed_dim256): super().__init__() base resnet50(weightsResNet50_Weights.IMAGENET1K_V1) # 去掉最后的 avgpool 和 fc保留卷积特征提取部分 self.features nn.Sequential(*list(base.children())[:-2]) self.pool nn.AdaptiveAvgPool2d((1, 1)) self.fc nn.Linear(2048, embed_dim) def forward(self, x): feat self.pool(self.features(x)).flatten(1) emb nn.functional.normalize(self.fc(feat), dim1) return emb这段代码有两个细节。第一list(base.children())[:-2]切掉了avgpool和fc保留了到最后卷积层的全部模块这样在自定义pool时能拿到空间信息更完整的特征图如果只切[:-1]AdaptiveAvgPool2d会重复出现forward 时的输出维度会乱。第二weightsResNet50_Weights.IMAGENET1K_V1是当前 torchvision 版本的写法老代码里直接写pretrainedTrue在新版本会直接抛异常这一点在避坑章节还要重点说。对应的三元组损失函数是一个简单但容易写错点的模块# loss.py —— 三元组损失margin 为可调超参数 import torch import torch.nn.functional as F def triplet_loss(emb, labels, margin0.2): # emb: (N, D), labels: (N,) dist_mat torch.cdist(emb, emb, p2) # 所有样本两两距离 mask_pos labels[:, None] labels[None, :] # 同类为真 mask_neg labels[:, None] ! labels[None, :] # 异类为真 # 对每个锚点取最难的正样本距离最大和最难的负样本距离最小 hardest_pos (dist_mat * mask_pos.float() (~mask_pos).float() * 1e9).max(dim1).values hardest_neg (dist_mat * mask_neg.float() (~mask_neg).float() * 1e9).min(dim1).values loss F.relu(hardest_pos - hardest_neg margin).mean() return losstorch.cdist一次算出 batch 内所有样本对的欧氏距离然后通过mask_pos和mask_neg屏蔽掉非目标位置。hardest_pos取的是同类里距离最大的那个hardest_neg取异类里距离最小的那个这一步就是在做在线难样本挖掘。注意mask里对角线位置是样本自身和自身比较如果处理不当会把距离 0 当成正样本需要在对角线位置显式屏蔽。训练时配套的 checkpoint 恢复在包内cpk、ckp目录里加载时最容易翻车的点是单卡多卡状态字典的 key 前缀不一致建议torch.load后先打印一个 key 看看再决定要不要剥掉module.前缀。训练完成后检索阶段的做法是把每张库图过一遍网络保存 256 维的 embedding查询图同样提 embedding然后和库图矩阵做内积排序。到这一步就能拿到比词包模型语义上更准的检索结果了。4. 多标签与细粒度属性MAP.py 之外还有 CUB.py 和哈希4.1 多标签检索的 mAP 口径sigmoid 不能配 CrossEntropy细粒度图像经常不是单标签——一张鸟图同时有“白色羽毛”“翅膀条纹”“喙部黄色”多个属性这就是多标签学习的切入点。包内多标签学习以及评定指标目录下的train.py和MAP.py对应的就是多标签分类网络与其评测指标。多标签任务和单标签任务在输出层的区别是原则性的单标签用 softmax保证所有类别概率和为 1多标签每类独立判有无要用 sigmoid再配BCEWithLogitsLoss。# 多标签头sigmoid BCE 是标配不要用 softmax import torch.nn as nn loss_fn nn.BCEWithLogitsLoss() logits model(images) # (B, C)C 为标签数 loss loss_fn(logits, labels) # labels 为 0/1 多热点向量很多第一次写多标签的人会惯性用CrossEntropyLoss结果就是训练时 loss 在下降但每个样本的预测概率总和被强制约束成 1真实存在的多个标签互相竞争评测指标一路走低。这类问题在train.py里是已经处理好了的但如果你拿这份代码去改自己的数据集务必检查输出层的激活函数和损失函数是否匹配。评测侧MAP.py计算的是 mean Average Precision这也是图像检索最通用的指标。它会为每个查询图计算一个查询向量和全库的相似度排序按排序逐位置计算精确率再取平均# MAP.py 核心逻辑 —— 对每个 query 计算 AP 再取平均 import numpy as np def average_precision(score, gt): order np.argsort(score)[::-1] # 按相似度降序排列 gt_sorted gt[order] precisions, hits [], 0 for k, is_gt in enumerate(gt_sorted, 1): if is_gt: hits 1 precisions.append(hits / k) return np.mean(precisions) if hits else 0.0 def mean_average_precision(scores, gts): aps [average_precision(s, g) for s, g in zip(scores, gts)] return np.mean(aps)这段代码里gt是二值数组标注这张库图是否和查询属于同一类。注意在多标签场景下mAP 有两种口径图像级 mAP 只看整张图是否相关标签级 mAP 则把每个标签当作独立的查询维度。两种口径算出来的数字差异很大写报告前先想清楚用哪一种否则对比别人的结果时容易产生“我的 mAP 怎么低了十个点”的错觉。4.2 属性感知注意力CUB.py 里的细粒度表示学习细粒度检索最难的点在于类间差异极小而 OpenAI 的 CLIP 这类通用模型对这类“细节级差异”并不敏感。属性学习是另一个经典方向先让模型学会预测图像的语义属性再用属性向量做检索。包内细粒度的属性学习目录对应 CUB-200-2011 鸟类数据集这是细粒度检索最标准的 bench200 类鸟、每类约 60 张图。CUB.py和tools/cub_demo.py按这类包的命名习惯一个负责数据准备一个负责训练和演示入口。属性感知注意力模型的做法是双头结构主干网络提特征一个头输出属性概率——比如“翅膀是白色”“腹部有条纹”“喙型是尖的”另一个头输出用于检索的 embedding。训练时属性损失和检索损失联合优化同时注意力模块会根据属性关键词回传到特征图上的热点区域让网络关注鸟类最具区分度的局部部位而不是整张图的平均外观。结合包内论文Attribute-Aware Attention Model for Fine-grained Representation Learning这套思路当年在 CUB 上的检索精度比直接用分类特征高出一截。demo.py加载训练好的权重后输入一张鸟图输出的是属性概率向量和检索向量后续相似度计算和前面一样走内积排序。4.3 深度哈希把连续向量压成 16/32/64 位二值码如果检索库有几百万张图用 256 维 float 向量算内积内存和耗时都是问题。哈希检索把 embedding 压成 16 到 64 位的二值码存储和计算都能省一个量级。包内IDHN目录以及论文Improved Deep Hashing With Soft Pairwise Similarity for Multi-Label Image Retrieval讲的就是这个方向。深度哈希的难点在于二值化不可导——网络输出连续向量后取符号得到{-1, 1}码这个取符号操作没有梯度。常见做法是训练时用 tanh 做替身逼近符号函数加上量化正则项约束输出接近正负一# 深度哈希训练片段tanh 作为 sign 的替身二值化放到检索时再做 hash_code torch.tanh(model(images)) # (B, bits)输出连续值逼近二值 hash_loss soft_pairwise_loss(hash_code, labels) # 按论文构造 pairwise 相似度损失 quant_loss (hash_code.abs() - 1).pow(2).mean() # 量化正则把值拉向 {-1, 1} total_loss hash_loss 0.01 * quant_losssoft_pairwise_loss的思路是多标签场景下两个样本共享的标签越多它们越相似。论文用哈希码内积模拟这个相似度再和真实标签相似度放进一个交叉熵结构里优化。检索时对hash_code取符号得到二值码库图之间直接算汉明距离——异或后统计 1 的个数这一步在 Python 里可以用查表法加速。位宽越大检索精度越高但存储优势越小48 位在多数场景是精度和速度的折中点。5. 避坑与排查五类高频故障的现象、原因和处置5.1 排查前的基线与日志准备排查问题前先建立一个可复现的基线。我的习惯是固定随机种子、固定数据加载顺序把特征文件和评测结果落盘留底。不要一边调参一边凭感觉判断“好像涨了点”没有留底的结果在换了一版代码后根本说不清是谁造成的。# eval_baseline.py —— 固定 seed输出一份可对照的起点 import random import numpy as np import torch seed 42 random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) features np.load(embedding.npy) # 同一份特征文件后续所有改动都基于它对比 scores features features.T print(baseline mAP:, mean_average_precision(scores, gt)) np.save(scores_baseline.npy, scores)这份基线分数会在后续每次改模型、改参数之后重新跑一遍用它判断改动是正向的还是负向的。血的教训很多看似“模型不行”的问题最后定位到是数据顺序不一致导致的有了一份固定 seed 的基线这类问题一查便知。这里也建议把包内cpk、ckp的权重文件名、训练轮次、当时的 mAP 记到一个 txt 里这几个 checkpoint 文件命名相似没有记录很容易拿错权重。5.2 五类高频故障现象、原因、处置坑位 1检索结果相似度全为零或清一色一个类现象query.py跑完TopK 结果的 score 要么全部是 0.0要么全部指向同一个类别。原因库图像的直方图是零向量SIFT 在弱纹理或分辨率过低的图上没提到特征点des is None分支直接返回了全零向量或者nfeatures限制太死每张图只留了几十个特征点直方图稀疏到和任何查询都不匹配。解决所有图像统一缩放到 448 或 512 分辨率再提特征把contrastThreshold从 0.04 降到 0.02nfeatures放开或设为 0。如果特征点仍然稀少可以考虑 dense SIFT——在密集网格上提取局部描述子代价是特征数量翻几倍但直方图稳定性好很多。坑位 2CUB 数据标签与图像错位mAP 一直偏低现象训练 loss 正常下降但验证集 mAP 比随机高不了多少而且调参无效。原因CUB 官方的images.txt按图片 id 排序image_class_labels.txt按类别分组两者顺序不一致。如果用文件名的字典序或遍历顺序去对齐标签等于把大部分样本标成了错误类别模型学到的映射完全是乱的。解决以 image id 为唯一主键关联图像路径和类别标签合并时打印前 20 条确认对应关系。自己做 train/test 划分时还要按类别分组切避免同一只鸟的多张图同时出现在训练集和测试集——这种泄漏会让指标虚高换真实查询时立刻现原形。坑位 3三元组 loss 卡住不降或者直接变成 NaN现象训练到第 10 个 epoch 左右 loss 稳定在某个值附近不再下降另一种情况是 loss 在某个 batch 后直接变 NaN。原因loss 不降通常是 batch 里全是简单负样本模型学不到新的边界NaN 常见诱因是学习率过大或torch.cdist计算时遇到极小值导致的数值不稳定也可能是 BN 层统计量在小 batch 下爆炸。解决先检查采样策略是否用了 P×K随机采样大概率会卡住。然后把学习率从 1e-4 起调不要一上来就 1e-3NaN 出现时先冻结 BN 的 running stats 跑几个 step 定位再把 margin 从 0.2 降到 0.1 试试。坑位 4老代码直接跑报错torchvision 模型定义不兼容现象AttributeError: ResNet object has no attribute fc或pretrainedTrue抛异常。原因torchvision 从 0.13 之后移除了pretrained参数统一改成weights枚举同时部分老代码用list(children())[:-1]来切模块新版本里模块顺序变了切出来的结果和预期不一致。解决显式改成resnet50(weightsResNet50_Weights.IMAGENET1K_V1)torch.load加载 checkpoint 时加map_locationcpu先打印state_dict的 key 再决定是否需要剥掉module.前缀。坑位 5深度哈希检索效果反而不如连续向量现象哈希码 48 位、64 位都试了检索 mAP 低于直接用 256 维连续 embedding差距还不小。原因训练时直接对模型的输出做sign()梯度在这个位置断流哈希码实际没有参与优化或者量化正则项的系数设得太小二值码和连续 embedding 差异过大。解决训练阶段用tanh输出替换sign把量化误差写进损失函数系数从 0.01 起调检索阶段再取符号。还要提醒一点哈希码本来就在精度上有牺牲——16 位和 64 位的 mAP 差异可能有十个点一定要和同配置的连续向量做对比时心里有数这不是模型坏了是信息瓶颈本身。6. 两套特征揉在一起分数融合检索的加权与验证习惯词包模型看重局部纹理的统计分布深度 embedding 看重语义特征两者往往能在不同检索维度上互补。我在实际项目里最常用的进阶手法是把两套相似度分数融合起来而不是简简单单拼特征向量——拼特征需要对齐维度和尺度分数融合只需要两套分数文件结构上更解耦调起来也更快。# fuse.py —— 两种分数的融合排序在验证集上选融合权重 import numpy as np s_sift np.load(sift_scores.npy) # 词包模型余弦相似度 s_deep np.load(deep_scores.npy) # 深度 embedding 余弦相似度 # 先各自放缩到 [0, 1]消除量纲差异 s_sift (s_sift - s_sift.min()) / (s_sift.max() - s_sift.min() 1e-8) s_deep (s_deep - s_deep.min()) / (s_deep.max() - s_deep.min() 1e-8) for alpha in [0.3, 0.4, 0.5, 0.6, 0.7]: fused alpha * s_sift (1 - alpha) * s_deep print(falpha{alpha:.1f} mAP{compute_map(fused, gt):.4f})这里的alpha是在验证集上网格搜索出来的不要拍脑袋定。词包特征在纹理判别型数据集上权重高一些深度特征在语义差异主导的数据集上更可靠从 0.3 到 0.7 扫一遍选择验证集 mAP 最高的值。如果两套分数的分布差异过大也可以改用 RRF 融合——把每个候选按在两种排序里的 rank 取倒数求和它对量纲完全不敏感在跨特征类型融合时往往比加权更稳。从那以后我每次交检索结果前都会把sift_scores和deep_scores原样 dump 一份存盘调alpha只重算融合层不动特征省掉了大量重跑全量特征的时间。这个习惯让我在至少三个项目里少熬了夜希望帮到你。本文还有配套的精品资源点击获取
返回列表