
简介这是一份基于Python实现的细粒度图像检索系统设计源码面向图像检索、机器学习方向的研究者、开发者以及需要整理项目汇报材料的工程人员。资源共68个文件包含29个Python源文件涵盖图像处理、特征提取、相似度计算与检索算法等核心模块、13个文本说明、11篇PDF文献涉及多标签学习、深度哈希、图卷积网络等主题、4个Markdown文档、4个PPT演示文稿以及少量编译文件、图片、表格和Word文档压缩包整体约66.2MB。项目围绕VOC2012、CUB等公开数据集实现词包模型、三元组损失网络、多任务多标签网络、细粒度属性学习等多个检索方案并提供UI界面演示便于研究者对比不同方法的检索效果。文档目录涵盖论文研读材料、项目汇报PPT和数据说明表格适合用于工作汇报、技术分享或课题小结。目前已有348人学习下载对想系统了解细粒度图像检索实现流程和代码组织的读者具有参考价值。1. 细粒度图像检索为什么难从“长得像”到“分得清”细粒度图像检索简单说就是“按图搜图”里最考验功夫的一档普通图像检索要回答“这是猫还是狗”细粒度检索要回答的是“这是白枕鹤还是丹顶鹤”“这是 2020 款还是 2021 款的宝马 3 系”。做电商同款识别、生物多样性监测、车型检索、文物比对的人都会撞上同一个问题——模型能把大类分开却在细微差异上翻车。标题里的这个 Python 实现核心不在于把模型堆得多大而在于把特征提取、度量学习和索引检索串成一条能落地的链路。这篇按实际做过的方案讲数据集怎么选、特征怎么抽、损失函数怎么调、检索接口怎么做以及新手最容易踩的几个坑。适合准备用 Python 做细粒度图像检索、想快速跑通并持续调优的人。2. 搭起最小可跑链路数据集、特征提取与 TopK 检索2.1 数据集怎么选CUB-200-2011 与分层抽样细粒度图像检索绕不开三个基准数据集CUB-200-2011 鸟类、Stanford Cars 车型、Oxford Flowers 花卉。其中 CUB 是使用最频繁的200 个类别、11788 张图片类别间的差异集中在喙形、翅膀纹路和颜色分布上正好就是细粒度检索要对付的“细小判别区域”。还有一个实际好处大量已发表论文都报告了在 CUB 上的 mAP 和 Recallk你在本地跑通后可以把数字和论文对照判断自己的实现有没有毛病。数据集不是下载完就能直接喂给模型。CUB 的官方划分是 train/test 按类别切分训练集和测试集的类别不重叠这一点和 ImageNet 的分类逻辑一致。实际做检索系统时常见做法是把 train 集作为检索库gallerytest 集作为查询集query这样评估出来的指标才反映“没见过这个个体但见过这个类别”的真实场景。# 下载 CUB-200-2011 并解压约 1.1 GB wget https://data.caltech.edu/records/65de6-vp158/files/CUB_200_2011.tgz tar -xzf CUB_200_2011.tgz解压后目录里有两个关键文件images.txt记录每个图片的文件名和编号image_class_labels.txt记录每张图的类别编号。写数据加载器时把它们读进来做一个映射表。一个小细节CUB 的图片尺寸参差不齐训练前统一 resize 到 224×224但测试阶段不要直接 resize等会儿在避坑章我会展开说这个玄学问题。2.2 特征提取ResNet50 加 GeM 池化最小代码主干网络选 ResNet50原因是“够用且省心”。它比 VGG16 轻得多比 ViT 更容易在小数据集上收敛而且 ImageNet 预训练权重覆盖面广对鸟类、车型、花卉这类自然图像都有不错的迁移起点。真正拉开差距的是池化层分类任务用的全局平均池化GAP会把空间信息全部压平细粒度任务恰恰需要保留“哪个位置最具有判别性”这个信息。GeMGeneralized Mean池化在 GAP 和全局最大池化之间插入了一个可学习的指数 pp 越大越偏向最大池化模型能自己学到“该多激进地挑选显著区域”。# model.py import torch import torch.nn as nn import torchvision.models as models class GeM(nn.Module): def __init__(self, p3.0, eps1e-6): super().__init__() # p 作为可学习参数初始值 3.0 self.p nn.Parameter(torch.ones(1) * p) self.eps eps def forward(self, x): # clamp 防止 0 值取对数/幂次时出 NaN return torch.nn.functional.avg_pool2d( x.clamp(self.eps).pow(self.p), (x.size(-2), x.size(-1)) ).pow(1.0 / self.p) class FineGrainedEncoder(nn.Module): def __init__(self, embed_dim2048, pretrainedTrue): super().__init__() backbone models.resnet50( weightsmodels.ResNet50_Weights.IMAGENET1K_V2 if pretrained else None ) # 去掉 ResNet50 最后的全局池化和全连接层 self.features nn.Sequential(*list(backbone.children())[:-2]) self.pool GeM(p3.0) self.embed_dim embed_dim def forward(self, x): x self.features(x) # (B, 2048, 7, 7) x self.pool(x).flatten(1) # (B, 2048) return torch.nn.functional.normalize(x, p2, dim1)这段代码里有两个关键决定。第一backbone.children()去掉最后两层后特征图是 7×7 的空间分辨率比分类任务常用的 1×1 保留了更多位置信息第二输出做了 L2 归一化让所有特征落在单位超球面上之后计算余弦相似度等价于点积检索速度和内存都更友好。p3.0是经验起点训练过程中模型会自己微调这个值不需要手工调。2.3 TopK 检索余弦相似度与索引缓存特征提取完之后检索本身是一个近邻搜索问题。数据量在十万级以内时用 NumPy 暴力算余弦相似度完全够用不需要一上来就上 FAISS 或 Milvus。核心逻辑是把检索库所有图片的特征拼成一个 (N, D) 的矩阵查询向量和这个矩阵做一次矩阵乘法得到 N 个相似度分数然后按分数倒序取前 K 个。# search.py import numpy as np def build_index(embedding_matrix): # 输入 (N, D)每行是 L2 归一化后的特征 # 转置成 (D, N)让一次矩阵乘法算完所有点积 return embedding_matrix.T def search(query_vec, index, gallery_labels, top_k10): # query_vec: (D,) 已经归一化 sims query_vec index # (N,) 余弦相似度 top_idx np.argsort(sims)[::-1][:top_k] return [(gallery_labels[i], float(sims[i])) for i in top_idx] # 使用示例 gallery_embs np.load(gallery_embs.npy) # (N, 2048) gallery_labels np.load(gallery_labels.npy) index build_index(gallery_embs) query_vec extract_feature(model, query_image) # 前向拿到 (2048,) results search(query_vec, index, gallery_labels, top_k5)build_index的转置是刻意为之目的是把计算交给 BLAS 的矩阵乘法而不是在 Python 层写 for 循环。当检索库超过五十万条时暴力检索的耗时开始变得不可接受那时候再换成 FAISS 的 IVF 索引或 HNSW 图索引特征不用重新提取只是索引结构变了。这里记住一条经验召回率下降时先怀疑特征质量再怀疑索引参数不要一上来就调 nprobe。3. 把检索精度提上去注意力机制、GeM 池化和度量学习的三个关键改动3.1 注意力机制为什么细粒度任务必须加注意力细粒度图像检索的难点在于两个类别的全局外观可能非常接近区别只集中在某几个局部区域——鸟的喙、车的进气格栅、花的蕊。普通卷积网络提取的特征是均匀分布的容易被大面积背景和相似纹理带偏。注意力机制解决的就是“让模型知道该往哪里看”。常见做法有两种。第一种是空间注意力典型代表是 CBAM它对特征图在通道维和空间维分别计算注意力权重第二种是自注意力对特征图的每个位置计算与其他位置的相关性捕捉长距离依赖。细粒度场景下我一般用轻量的 CBAM 而不是直接用 Transformer 那套原因是数据量通常不大万级自注意力容易过拟合到背景噪声上。一个改动就能看到 mAP 涨 2~3 个百分点。# attention.py import torch import torch.nn as nn class ChannelAttention(nn.Module): def __init__(self, channels, reduction16): super().__init__() self.mlp nn.Sequential( nn.AdaptiveAvgPool2d(1), nn.Flatten(), nn.Linear(channels, channels // reduction), nn.ReLU(inplaceTrue), nn.Linear(channels // reduction, channels), nn.Sigmoid() ) def forward(self, x): # x: (B, C, H, W) w self.mlp(x).unsqueeze(-1).unsqueeze(-1) # (B, C, 1, 1) return x * w class SpatialAttention(nn.Module): def __init__(self, kernel_size7): super().__init__() self.conv nn.Conv2d(2, 1, kernel_size, paddingkernel_size // 2) def forward(self, x): avg torch.mean(x, dim1, keepdimTrue) mx torch.max(x, dim1, keepdimTrue).values attn self.conv(torch.cat([avg, mx], dim1)) return x * torch.sigmoid(attn)把这两个模块串行插在 ResNet50 最后一层特征图之后就得到一个带注意力的编码器。channel attention 回答“什么特征重要”spatial attention 回答“哪里重要”。需要注意的一点注意力模块加在 layer4 之后效果最明显加在 layer1 之后反而可能干扰底层边缘特征的学习。我在 CUB 上对比过这个位置的影响layer4 后加 CBAM 比不加高 2.1 个点而 layer1 后加只高 0.4 个点。3.2 GeM 池化一个参数提升召回GeM 池化的实现前面已经给过完整代码这里重点说参数。p 初始值设为 3.0训练过程中作为 nn.Parameter 参与反向传播它会自己收敛到一个合适的值。实际训练结束后打印出来通常在 4~6 之间说明模型倾向于“更激进地挑选显著区域”。这里有个值得说的血泪经验不要在 ImageNet 预训练模型上直接改池化层而不重新训练。预训练权重是在 GAP 下学出来的突然换到 GeM特征分布会变直接提取特征做检索效果反而可能变差。正确做法是先把 GeM 接到模型上然后在目标数据集上微调至少 20 个 epoch让卷积核适应新的池化方式。如果只是想快速出 baseline可以先冻结 backbone 只训练池化和后面的分类头等稳定了再解冻全模型。3.3 度量学习损失从三元组到 Circle Loss分类任务用交叉熵检索任务必须用度量学习损失因为检索关心的是特征空间里的距离而不是分类边界。三元组损失Triplet Loss是最经典的方案锚点 a、正样本 p、负样本 n目标是让 a 和 p 的距离比 a 和 n 的距离近至少一个 margin。它的问题在于训练不稳定选到太简单的三元组时 loss 直接为 0模型学不到东西。实际做细粒度检索时Circle Loss 在大多数情况下比 Triplet Loss 更稳。它给每个相似度分数动态分配权重离优化目标越远的样本权重越大相当于内置了难样本挖掘。代码实现比 Triplet 稍长但效果值得。# losses.py import torch import torch.nn as nn import torch.nn.functional as F class CircleLoss(nn.Module): def __init__(self, m0.25, gamma256): super().__init__() self.m m # 边界 margin self.gamma gamma # 缩放因子控制梯度强度 def forward(self, features, labels): # features 已 L2 归一化(N, D) sim features features.T # (N, N) 余弦相似度矩阵 pos_mask labels[:, None] labels[None, :] neg_mask ~pos_mask pos_mask.fill_diagonal_(False) # 排除自身 # 分别计算正/负样本的权重系数 ap torch.clamp_min(pos_mask * sim 1 - self.m, 0) an torch.clamp_min(neg_mask * sim - self.m, 0) delta_p 1 - self.m delta_n self.m logit_p ap * (sim - delta_p) logit_n an * (sim - delta_n) # 每个锚点单独做 logsumexp再取平均 loss_p torch.logsumexp(logit_p, dim1) loss_n torch.logsumexp(logit_n, dim1) return ((loss_p loss_n) / self.gamma).mean()Circle Loss 里 gamma 的取值要花点心思理解。gamma 越大loss 对相似度差异越敏感梯度越集中到困难样本上gamma 太小会让模型“无差别攻击”连简单样本都使劲拉。CUB 这种万级数据量gamma256 是常用起点训练中发现 loss 震荡明显就降到 128。m 控制相似对的边界0.25 是原作者论文里的默认值一般不需要动。Circle Loss 和交叉熵不冲突。实际上我在训练时会把两个损失加权相加交叉熵保证训练初期收敛稳定Circle Loss 让特征空间的类间距离拉开。权重比例 1:1 起步交叉熵 loss 降到 1.0 以下后再把 Circle Loss 的权重提到 2mAP 还有约 1 个点的提升空间。4. 训练与评估用 mAP 和 Recallk 判断系统有没有变好4.1 PK 采样训练数据怎么配比度量学习训练最容易被忽视的是数据采样方式。如果每个 batch 完全随机采样很可能出现的情况是一个 batch 里同类别图片只有一两张甚至一张都没有Triplet 或 Circle Loss 根本构造成有意义的正样本对。解决办法是用 PK 采样每个 batch 随机选 P 个类别每个类别里随机选 K 张图batch 大小就是 P×K。这样保证每个 batch 里有 P 个类别的 K 张正样本和 P×(K−1) 张难负样本。# sampler.py import random import torch from torch.utils.data import Sampler class PKSampler(Sampler): def __init__(self, labels, batch_size, num_classes_per_batch4, images_per_class8): self.labels labels self.num_classes len(torch.unique(labels)) self.P num_classes_per_batch self.K images_per_class # 按类别整理索引方便每轮抽样 self.class_to_indices { c: (labels c).nonzero(as_tupleTrue)[0].tolist() for c in torch.unique(labels).tolist() } self.num_batches None # 由外部根据 epoch 长度设置 def __iter__(self): indices [] for _ in range(self.num_batches): classes random.sample(list(self.class_to_indices.keys()), self.P) for c in classes: sample random.sample(self.class_to_indices[c], self.K) indices.extend(sample) return iter(indices) def __len__(self): return self.num_batches * self.P * self.KP 和 K 的选择直接影响训练效果。P 太小每个 batch 里正样本对太少loss 方差大K 太小负样本不够难。CUB 上我用 P4、K8batch size 32显存占用和效果平衡得比较好。显卡只有 8GB 显存时可以把 K 降到 4batch size 降到 16但 P 尽量不要低于 4。这个配置是检索任务的通用起点换到 Stanford Cars 也可以直接复用。4.2 训练循环warmup、学习率与混合精度细粒度检索的微调和分类微调有个关键区别backbone 的预训练权重已经很好了一开始就用大学习率容易把判别性特征冲掉。常见做法是先给 backbone 一个较小的学习率1e-5给新增的池化层和损失头一个较大的学习率1e-4训练前 5 个 epoch 做线性 warmup把学习率从 0 慢慢升到目标值再用 cosine 退火衰减到接近 0。# train.py import torch from torch.cuda.amp import autocast, GradScaler def train_one_epoch(model, loader, optimizer, criterion_ce, criterion_circle, scaler): model.train() total_loss 0.0 for images, labels in loader: images, labels images.cuda(), labels.cuda() optimizer.zero_grad() with autocast(): embs model(images) # 检索特征 logits model.classifier(embs) # 分类头输出 loss_ce criterion_ce(logits, labels) loss_circle criterion_circle(embs, labels) loss loss_ce 2.0 * loss_circle scaler.scale(loss).backward() scaler.step(optimizer) scaler.update() total_loss loss.item() return total_loss / len(loader)这段代码里有两个值得注意的点。第一model.classifier是我在编码器后面额外加的一个轻量分类头一层全连接只在训练阶段用评估时丢掉。分类头的作用是让交叉熵损失有地方接同时它学到的类中心对特征也有正则作用。第二混合精度训练在这一步几乎是无脑加的GeM 池化里的 pow 运算在 fp16 下有数值溢出风险但 autocast 会自动保住关键 op 的精度不用手工干预。训练 60 个 epoch在单张 RTX 3090 上大约 40 分钟能跑完 CUB 全量。4.3 评估指标mAP 与 Recallk 的实现评估指标是检索系统的“仪表盘”。Recallk 关心“正确结果有没有出现在前 k 个里”mAPmean Average Precision更严格它同时关心正确结果的排序位置——排在第 1 和第 3 的得分差距应该在 mAP 中体现出来。这两个指标必须一起看Recall1 高但 mAP 低说明模型“偶尔能找到对的但排序不稳定”。# evaluate.py import numpy as np def compute_recall_at_k(sim_matrix, labels, k1): # sim_matrix: (Q, N) 查询与检索库的相似度 # labels: (Q,) 查询标签, gallery_labels: (N,) q, n sim_matrix.shape hits 0 for i in range(q): top_k np.argsort(sim_matrix[i])[::-1][:k] hits int(labels[i] in gallery_labels[top_k]) return hits / q def compute_map(sim_matrix, labels): q, n sim_matrix.shape aps [] for i in range(q): order np.argsort(sim_matrix[i])[::-1] sorted_labels gallery_labels[order] relevant (sorted_labels labels[i]) (np.arange(n) ! i) if not relevant.any(): continue tp np.cumsum(relevant) fp np.cumsum(~relevant) precision tp / (tp fp) recall tp / relevant.sum() # AP 精确率-召回率曲线下面积简化版 ap np.sum(precision * np.diff(np.concatenate(([0], recall)))) aps.append(ap) return np.mean(aps)这里有个容易搞错的细节计算指标时要去掉查询图片自身。如果查询图片本身也在检索库里相似度矩阵对角线必然最高把这一位排除了才能看到真实排序能力。所以上面代码里用np.arange(n) ! i把自身对应的位置从相关集中剔除。另外检索库里有同类别其他图片时相关集的定义是“类别相同”而不是“图片相同”这一点和新手常犯的“只查完全相同的图”有本质区别。5. 细粒度检索系统落地的 4 个避坑点数据噪声、特征漂移与索引更新5.1 训练 loss 在降mAP 却不涨跑第一版基线时最让人抓狂的情况是训练 loss 稳步下降交叉熵已经收敛到 0.3 以下但评估出来的 mAP 只有 20% 出头比随机好不了多少。查了代码、换了随机种子、调了学习率都没有起色。原因出在采样策略上。当时用的是随机采样每个 batch 里同类别图片太少Circle Loss 计算出的正样本对屈指可数模型实际上是在靠交叉熵硬学分类边界特征空间的类内聚合度很差。检索任务看的是特征距离分类 loss 再低也代表不了检索质量。解决方法是彻底换掉数据加载器改成 PK 采样并把 Circle Loss 的权重提高到交叉熵的两倍。改完后同一个模型在 10 个 epoch 内 mAP 从 21% 跳到 48%。这个案例说明一条铁律度量学习训练采样策略比损失函数更基础采样不对损失函数再好也白搭。5.2 模型没变检索结果却变了系统上线后遇到过一次诡异问题生产环境的模型文件没有动过特征库也没有重新构建但同样的查询图片返回的结果顺序和前一天不一样了。起初怀疑是随机性但连续复现了三次都不同。排查后发现是特征向量库在做增量更新时新插入的数据没有做 L2 归一化。前一天的系统里有一个预处理脚本会统一归一化某次发布时这个脚本被一个“优化版”替代而优化版漏掉了归一化步骤。单位超球面上的角度关系被破坏部分特征的模长偏大在点积计算里获得了不正当的优势。解决方法是把归一化逻辑写进特征提取函数内部而不是依赖外部脚本并且给每个特征库文件打上模型版本号和归一化标记。从这次之后我养成一个习惯任何特征向量落地之前先断言np.allclose(np.linalg.norm(embs, axis1), 1.0)用断言兜底比靠人肉检查可靠得多。5.3 resize 到 224 之后细节没了CUB 鸟类数据集里有些类别的差异集中在喙部的弯曲弧度上这个区域在原始图片里可能只占几十个像素。统一 resize 到 224×224 后这些判别性细节被压缩到不可辨认人眼都很难分清的图片模型自然也分不清。训练集 mAP 能到 70%测试集掉到 40%典型的过拟合加信息丢失双重问题。常见做法是两阶段方案。第一阶段先用 224×224 的输入把整体框架训练稳定第二阶段把输入分辨率提高到 320×320 微调 10~15 个 epoch。测试阶段更讲究不要只跑一个分辨率把图片分别缩放到 256 和 288各提取一次特征拼接后做 L2 归一化相当于让模型从两个尺度投票。这个 trick 不需要重新训练只增加推理时的计算量mAP 通常能再涨 1~2 个点。5.4 显存不够batch size 调不下去细粒度检索任务里 batch size 直接决定训练质量但 8GB 显存的卡跑 ResNet50 224×224 输入P4、K8 的配置刚好溢出。有人为了塞进显存把 batch size 砍半结果 mAP 掉了 5 个点因为 PK 采样失效了——一个 batch 里凑不齐足够的正负样本对。解决方法有三个层次。第一梯度累积维护一个虚拟 batch每 4 个真实 step 做一次参数更新相当于 batch size 不变但显存只占四分之一。第二把输入分辨率从 224 降到 192显存占用大约下降 30%精度损失一般不超过 1 个点。第三换更省显存的 backbone 如 ResNet18 或 MobileNetV3但这会牺牲特征容量属于最后手段。顺序上我一般先试梯度累积再降分辨率最后才动模型结构。6. 从脚本到服务把检索能力做成一个可用的接口6.1 特征索引的增量更新离线训练好的模型最终要变成线上服务核心问题是特征库的更新策略。全量重建最稳妥但耗时增量更新快但容易踩坑。常见做法是双索引内存里放一份 HNSW 图索引服务查询磁盘上存储完整特征矩阵每次新图片入库时先追加到磁盘矩阵再定期比如每天一次基于完整矩阵重建内存索引。查询走内存索引保证速度重建走后台任务不影响线上。# index_updater.py import numpy as np class FeatureIndex: def __init__(self, model_version): self.model_version model_version self.matrix_path ffeatures_{model_version}.npy self.label_path flabels_{model_version}.npy self._append_lock False def add_images(self, features, labels): # 增量追加先落盘再更新内存索引 if not self._append_lock: old np.load(self.matrix_path) if self._exists() else np.empty((0, features.shape[1])) new np.concatenate([old, features], axis0) np.save(self.matrix_path, new) np.save(self.label_path, np.concatenate([self._load_labels(), labels]))增量更新最大的坑是版本不一致模型升级后老特征和新特征的分布对不上混合使用会让检索质量断崖式下跌。应对方法是特征文件命名里带模型版本号升级时直接换新文件不回写老文件。同时给每条特征记录一个 timestamp支撑“只检索某时间之后入库的图片”这种业务过滤。6.2 把检索封装成服务FastAPI 最小接口有了特征索引最后一步是提供 HTTP 接口。FastAPI 是个趁手的工具数据校验、并发和文档都自带不需要额外配置。最小可用接口只需要两个端点上传图片返回 TopK 结果以及查询当前索引状态。# app.py from fastapi import FastAPI, UploadFile import numpy as np import torch from PIL import Image from torchvision import transforms app FastAPI() model load_encoder(checkpoint_best.pth).cuda().eval() index FeatureIndex(model_versionv1) gallery_labels index.load_labels() transform transforms.Compose([ transforms.Resize((256, 256)), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]), ]) app.post(/search) async def search(file: UploadFile): img Image.open(file.file).convert(RGB) x transform(img).unsqueeze(0).cuda() with torch.no_grad(): query_vec model(x).cpu().numpy().flatten() top search_topk(query_vec, index.matrix, gallery_labels, top_k10) return {results: [{label: int(l), score: float(s)} for l, s in top]}这里的search_topk就是第 2 章里的暴力检索函数数据量大到百万级时换成 FAISS 的IndexHNSWFlat接口签名保持不变对调用方无感。实际部署时记得加一个小技巧查询向量也要过 L2 归一化最好复用一个和训练时完全一致的 transform 流程不要因为“线上机器没有训练环境”就简化预处理——图像检索的精度往往就丢在预处理这一步的细微差别上。这套系统从数据到服务完整跑下来最长的时间往往不是模型设计而是特征质量的反复调试。我自己的习惯是每改一个关键参数就留一份特征文件存档用 mAP 曲线对比不同版本的特征质量而不是凭感觉调参。特征文件有了版本模型有了版本指标有了记录细粒度检索这个方向才能从“能跑”走到“可信”。希望帮到你。本文还有配套的精品资源点击获取