ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于Parser解析的车辆ReID:部件级特征与预训练权重实战

基于Parser解析的车辆ReID:部件级特征与预训练权重实战 简介这份资源面向计算机视觉方向的学习者与车辆重识别Vehicle ReID研究者提供一套基于Parser解析思路实现的车辆重识别Python工程可用于智能交通、安防监控等场景下的车辆检索与匹配任务。压缩包共59个文件以49个py源码文件为核心配合6个yml配置、1个json与1个md说明文档整体约2.07MB涵盖模型定义、损失函数、评价指标、数据加载与预处理等模块并附带预训练权重便于直接复现与二次开发。项目说明文档梳理了整体结构与使用方式examples与parsing相关目录展示了Parser解析与ReID流程的衔接逻辑requirements.txt与setup.py则方便快速搭建运行环境。目前已有41人学习下载适合希望深入理解车辆重识别算法实现、需要可运行代码与权重进行实验验证的读者参考。1. 车辆 ReID 为什么总在“同款车”上翻车从 Parser 解析说起做车辆重识别Vehicle ReID的同行大多有过这种体验行人 ReID 上跑得挺顺的模型换到车辆数据集mAP 直接掉一截尤其是同一车型、同一颜色、只差车牌和细微装饰的样本模型几乎是在瞎猜。原因不复杂——车辆之间的类间差异远比行人小同一款白色 SUV 在监控里可能同时出现几十辆模型如果只学到“白色 SUV”这个粗粒度特征检索时自然分不开。这个标题里的“基于 Parser 解析”指的正是把车辆图像先做结构化拆解把整车拆成车顶、车窗、车灯、车牌、保险杠等部件区域再让 ReID 网络在部件级别上提取判别性特征而不是把整张图当成一个黑匣子去端到端硬训。这套思路能解决的问题很具体跨摄像头检索时同款车靠局部差异贴纸、挂件、轮毂、天窗区分遮挡场景下整车特征失效但局部部件还在视角变化大时部件对齐比全局对齐更稳。适合谁已经跑通过基础 ReID 训练、手里有车辆数据集常见的是 VeRi-776、VehicleID 这类、想进一步提升 mAP 的从业者。如果你还在纠结 python 安装、python 环境变量配置这种前置问题建议先把 python 入门和 vscode python 环境配置过一遍再回来这篇的代码默认你能跑通 PyTorch 训练循环。下面从 Parser 到底解析什么、怎么和 ReID 主干拼起来、预训练权重怎么用一步步拆开讲。2. Parser 解析与 ReID 主干的拼接方式先想清楚特征从哪来2.1 车辆 Parser 到底输出什么为什么不能直接当分割用车辆 Parser 本质上是一个语义分割网络输入一张车辆图输出每个像素的部件类别。常见做法是基于 DeepLabV3 或 OCRNet 这类分割头在车辆分割数据集上训练类别一般包括背景、车顶、车窗、车头/车尾、车灯、车牌、车轮、车身侧面等 8 到 12 类。它和通用分割的区别在于车辆部件边界清晰但类间相似度高车灯和车窗在低分辨率下容易混所以 Parser 的训练数据标注质量直接决定后续 ReID 的上限。很多人第一反应是“那我直接把分割 mask 乘到特征图上不就行了”。实测下来这样做的收益很有限原因是硬 mask 会切断部件之间的上下文而车辆判别性往往来自部件组合关系比如车灯形状加进气格栅布局。更稳的做法是把 Parser 输出的概率图soft mask作为注意力权重或者把部件区域特征做池化后再拼接。下面这段代码演示如何从 Parser 输出得到部件级特征图注意这里用的是 soft 方式不是二值化硬切。import torch import torch.nn.functional as F def part_aware_pooling(feature_map, parse_logits, num_parts8): feature_map: [B, C, H, W] 主干输出的特征图 parse_logits: [B, num_parts1, H, W] Parser 输出的 logits第 0 类为背景 num_parts: 部件数量不含背景 返回: [B, C * num_parts] 的部件级特征 # 对 Parser 输出做 softmax得到每个像素属于各部件的概率 parse_prob F.softmax(parse_logits, dim1) # [B, P1, H, W] # 去掉背景类只保留部件 part_prob parse_prob[:, 1:, :, :] # [B, P, H, W] # 将特征图尺寸对齐到 parse 输出尺寸通常 Parser 输出是原图 1/4 或 1/8 if feature_map.shape[-2:] ! part_prob.shape[-2:]: feature_map F.interpolate(feature_map, sizepart_prob.shape[-2:], modebilinear, align_cornersFalse) B, C, H, W feature_map.shape P part_prob.shape[1] # 对每个部件用其概率图作为权重做加权池化 part_feats [] for p in range(P): weight part_prob[:, p, :, :].unsqueeze(1) # [B, 1, H, W] # 归一化权重避免部件面积差异导致数值尺度不一致 weight_sum weight.sum(dim(2, 3), keepdimTrue) 1e-6 weight weight / weight_sum pooled (feature_map * weight).sum(dim(2, 3)) # [B, C] part_feats.append(pooled) return torch.cat(part_feats, dim1) # [B, C * P]逻辑说明这段代码的核心是“用概率加权代替硬 mask”。parse_prob经过 softmax 后每个像素对每个部件都有一个 0 到 1 的归属概率加权池化相当于让特征图在每个部件区域内做软性聚合。参数上num_parts要和你的 Parser 类别数严格对应少一个多一个都会导致特征维度错位weight_sum加1e-6是防止某个部件在当前图中完全不存在时除零。实际训练时parse_logits可以来自一个冻结的预训练 Parser也可以和 ReID 主干联合微调后者显存占用会明显上升建议先冻结跑通再解冻。2.2 主干选型ResNet50-IBN 还是 ViT和 Parser 怎么对齐车辆 ReID 的主干选择上ResNet50-IBN 是性价比最高的起点IBN 模块对光照和颜色变化更鲁棒而车辆数据里跨摄像头色偏非常常见。ViT 类主干在车辆 ReID 上也有工作但对数据量要求高如果你只有 VeRi-776 这种几万张的量级ResNet50-IBN 收敛更稳。和 Parser 对齐时要注意分辨率Parser 通常在 256x256 或 384x384 上训练ReID 主干输入常见是 256x128车辆宽高比两者尺寸不一致时上面代码里的F.interpolate就是用来兜底的但插值会损失部件边界精度更好的做法是让 Parser 在 ReID 输入尺寸上做推理或者把 ReID 输入改成和 Parser 一致的正方形。预训练权重这块标题里提到的“预训练权重”一般指两类一是 ImageNet 上训的主干权重用来初始化 ReID 网络二是车辆 Parser 的分割权重用来提供部件先验。这两类权重不要混用加载时用strictFalse并打印缺失/多余键确认加载正确。常见坑是 Parser 权重的类别数和你的num_parts不一致加载后输出维度对不上训练时表现为 loss 不下降或直接报维度错误。2.3 训练目标ID loss、Triplet loss 和部件一致性 loss 怎么配基础 ReID 用 ID loss交叉熵 Triplet loss三元组就够了加上 Parser 之后建议再补一个部件一致性 loss同一辆车在不同摄像头下同一部件的特征应该接近不同车辆的同一部件应该远离。实现上可以对每个部件的池化特征单独算 Triplet再取平均。权重配比上ID loss 权重 1.0Triplet 权重 1.0部件一致性 loss 从 0.3 起步观察验证集 mAP 再调。如果部件 loss 权重给太大模型会过度关注局部而丢掉全局判别性表现为 mAP 先升后降这个拐点要盯紧。import torch.nn as nn class PartAwareReIDLoss(nn.Module): def __init__(self, num_classes, part_weight0.3, triplet_margin0.3): super().__init__() self.id_loss nn.CrossEntropyLoss() self.triplet nn.TripletMarginLoss(margintriplet_margin) self.part_weight part_weight self.classifier nn.Linear(2048 * 8, num_classes) # 假设主干输出2048维8个部件 def forward(self, global_feat, part_feats, labels, pos_idx, neg_idx): # global_feat: [B, C] 全局特征 # part_feats: [B, C*P] 部件拼接特征 logits self.classifier(part_feats) loss_id self.id_loss(logits, labels) # 全局 triplet loss_tri self.triplet(global_feat, global_feat[pos_idx], global_feat[neg_idx]) # 部件级 triplet对每个部件单独算 C global_feat.shape[1] P part_feats.shape[1] // C loss_part 0.0 for p in range(P): pf part_feats[:, p*C:(p1)*C] loss_part self.triplet(pf, pf[pos_idx], pf[neg_idx]) loss_part loss_part / P return loss_id loss_tri self.part_weight * loss_part参数说明part_weight控制部件一致性 loss 的强度从 0.3 开始试triplet_margin车辆 ReID 上常用 0.3 到 0.5太小则类间分不开太大则训练震荡。pos_idx和neg_idx是三元组采样得到的正负样本索引采样策略上建议用 PK 采样每个 batch 选 P 个 ID每个 ID 选 K 张图这样三元组才有意义。3. 从零跑通车辆 ReID数据准备、Parser 推理与训练命令3.1 数据集目录结构和常见格式转换车辆 ReID 常用数据集是 VeRi-776 和 VehicleID前者带摄像头和时间标注适合做跨摄像头评测。目录一般长这样images/下按车辆 ID 分文件夹train_test_split/下放训练/查询/库集的 ID 列表。如果你拿到的是 VOC 格式的标注需要转成 ReID 需要的 ID 列表格式。转换脚本核心逻辑是遍历标注文件按车辆 ID 聚合图像路径再按比例切分。import os import random from collections import defaultdict def voc_to_reid(voc_root, out_root, split_ratio0.7): voc_root: VOC 格式数据集根目录含 Annotations 和 JPEGImages out_root: 输出 ReID 格式的根目录 split_ratio: 训练集比例 id_to_imgs defaultdict(list) ann_dir os.path.join(voc_root, Annotations) for ann_file in os.listdir(ann_dir): # 这里假设文件名里包含车辆 ID实际按你的标注规则改 vid ann_file.split(_)[0] img_name ann_file.replace(.xml, .jpg) id_to_imgs[vid].append(img_name) all_ids list(id_to_imgs.keys()) random.shuffle(all_ids) split int(len(all_ids) * split_ratio) train_ids, test_ids all_ids[:split], all_ids[split:] # 写训练列表 with open(os.path.join(out_root, train.txt), w) as f: for vid in train_ids: for img in id_to_imgs[vid]: f.write(f{img} {vid}\n) # 测试集拆成 query 和 gallery每个 ID 取一张做 query with open(os.path.join(out_root, query.txt), w) as fq, \ open(os.path.join(out_root, gallery.txt), w) as fg: for vid in test_ids: imgs id_to_imgs[vid] fq.write(f{imgs[0]} {vid}\n) for img in imgs[1:]: fg.write(f{img} {vid}\n)逻辑说明这段脚本把 VOC 的 XML 标注聚合成 ReID 需要的 ID-图像列表。关键参数是split_ratio车辆 ReID 上训练集比例一般给 0.7 左右测试 ID 不能出现在训练集里否则 mAP 虚高。实际项目中车辆 ID 往往不在文件名里需要从 XML 的name字段或自定义字段读改vid的提取逻辑即可。3.2 Parser 推理批量生成部件概率图并缓存训练前先把 Parser 跑一遍把每张图的部件概率图缓存成.npy训练时直接读避免每个 epoch 重复推理。批量推理时注意归一化要和 Parser 训练时一致常见是 ImageNet 的 mean/std。缓存文件命名和原图一一对应放在parse_cache/下。import numpy as np import torch from PIL import Image from torchvision import transforms def cache_parse_maps(img_list, parser_model, out_dir, devicecuda): parser_model.eval().to(device) tf transforms.Compose([ transforms.Resize((256, 256)), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) os.makedirs(out_dir, exist_okTrue) with torch.no_grad(): for img_path in img_list: img Image.open(img_path).convert(RGB) inp tf(img).unsqueeze(0).to(device) logits parser_model(inp) # [1, P1, H, W] prob torch.softmax(logits, dim1).squeeze(0).cpu().numpy() # 保存为 float16 省空间精度损失可忽略 save_name os.path.basename(img_path).replace(.jpg, .npy) np.save(os.path.join(out_dir, save_name), prob.astype(np.float16))参数说明Resize((256, 256))要和 Parser 训练分辨率一致不一致会导致部件边界偏移保存成 float16 能把缓存体积减半车辆 ReID 数据量下这个优化很实在。如果显存够batch size 可以开到 16 或 32比逐张推理快很多。3.3 训练命令与关键超参训练入口一般是一个train.py核心超参包括batch size 用 PK 采样P16、K4总 batch 64初始学习率 3.5e-4用 cosine 衰减训练 60 到 120 epoch优化器用 Adam 或 SGDSGD 在 ReID 上更稳但收敛慢。启动命令示例python train.py \ --data-root ./data/VeRi \ --parse-cache ./parse_cache \ --backbone resnet50_ibn \ --num-parts 8 \ --part-weight 0.3 \ --batch-p 16 --batch-k 4 \ --lr 3.5e-4 --epochs 80 \ --output ./logs/exp1参数说明--num-parts必须和 Parser 类别数减一一致--part-weight是部件一致性 loss 权重先给 0.3--batch-p和--batch-k决定每个 batch 的 ID 数和每 ID 图像数显存不够就降 K。训练日志里重点看 mAP 和 Rank-1如果 mAP 在前 10 epoch 就冲到很高然后不动多半是学习率太大或数据泄漏。4. 避坑与排查车辆 ReID 训练里最容易翻车的 5 个点4.1 现象mAP 高得离谱Rank-1 接近 1.0原因测试 ID 泄漏进了训练集或者 query 和 gallery 里有完全相同的图。车辆 ReID 数据集如果切分脚本写错同一辆车的不同角度图可能同时出现在训练和测试里。解决检查 ID 列表交集确保训练 ID 和测试 ID 零重叠query 和 gallery 之间也要去重同一张图不能既做 query 又做 gallery。4.2 现象训练 loss 正常下降但验证 mAP 一直很低原因Parser 缓存和原图没对齐部件概率图错位导致部件特征全是噪声。常见于缓存时 Resize 尺寸和训练时输入尺寸不一致或者缓存文件名匹配错位。解决随机抽几张图把部件概率图叠加回原图可视化确认车灯、车牌区域对得上检查缓存文件名和原图 basename 是否严格一一对应。4.3 现象加了部件 loss 后 mAP 反而下降原因part_weight给太大模型过度关注局部全局判别性被削弱。车辆 ReID 里全局特征仍然重要部件是补充不是替代。解决把part_weight从 0.3 降到 0.1 甚至 0.05 再试观察 mAP 曲线同时确认部件 triplet 的采样和全局 triplet 用的是同一套 PK 采样不要各采各的。4.4 现象显存溢出batch size 上不去原因Parser 概率图和特征图同时驻留显存加上部件级 triplet 对每个部件单独算显存占用是普通 ReID 的好几倍。解决Parser 推理阶段用torch.no_grad()并缓存到磁盘训练时只读缓存部件级 triplet 可以隔几个 epoch 算一次或者只对判别性强的部件车灯、车牌算减少计算量。4.5 现象换摄像头后性能骤降原因模型过拟合了训练摄像头的背景和光照车辆本身的特征没学好。解决训练时加随机擦除和颜色抖动增强增强强度别太大车辆 ReID 上颜色是重要线索抖动过猛会破坏颜色特征另外可以引入摄像头对抗 loss让特征对摄像头身份不可分这个改动大建议先把基础增强做扎实。5. 进阶技巧用部件检索结果做重排序把 mAP 再抬一截基础模型跑通之后想再往上提重排序re-ranking是性价比最高的手段。车辆 ReID 上常用的是 k-reciprocal 重排序但它对部件特征不敏感。我的做法是把部件级特征也纳入重排序的距离计算全局特征算一个距离矩阵每个部件特征算一个距离矩阵加权融合后再做 k-reciprocal。权重上全局给 0.6部件平均给 0.4部件内部车灯和车牌权重可以再高一点因为这两个部件判别性最强。import numpy as np def part_aware_rerank(global_feats, part_feats, k120, k26, lambda_value0.3): global_feats: [N, C] 全局特征 part_feats: [N, P, C] 部件特征 返回: 重排序后的距离矩阵 [N, N] # 归一化 global_feats global_feats / (np.linalg.norm(global_feats, axis1, keepdimsTrue) 1e-6) part_feats part_feats / (np.linalg.norm(part_feats, axis2, keepdimsTrue) 1e-6) # 全局距离 dist_global 1 - global_feats global_feats.T # 部件距离对每个部件算再平均 P part_feats.shape[1] dist_part np.zeros_like(dist_global) for p in range(P): pf part_feats[:, p, :] dist_part 1 - pf pf.T dist_part / P # 融合距离 dist 0.6 * dist_global 0.4 * dist_part # 这里接标准 k-reciprocal 流程用 dist 作为初始距离 # 具体实现略核心是把 dist 替换掉原来的全局距离 return dist参数说明k1和k2是 k-reciprocal 的两个邻域参数车辆 ReID 上常用 k120、k26lambda_value控制原始距离和 Jaccard 距离的融合比例0.3 是常见起点。融合权重 0.6/0.4 不是固定的如果你的 Parser 质量高部件权重可以提到 0.5。重排序的计算量在 N 较大时明显gallery 上万张时建议先做一次粗排取 top-100 再重排。验证重排序是否有效看 mAP 提升幅度一般能涨 2 到 5 个点。如果涨了但 Rank-1 没动说明重排序主要改善了中段排序对头部影响小这属于正常现象。我自己的习惯是每次改完重排序参数固定随机种子跑三次取平均车辆 ReID 的评测波动比行人 ReID 大单次结果容易骗人。这套 Parser 加部件特征加重排序的组合在 VeRi-776 上从基础模型的 mAP 60 出头能推到 70 以上代价是训练和推理都更重值不值得做取决于你的业务对检索精度的容忍度。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进