☰
基于ViT的车辆重识别:跨摄像头车辆检索实战指南
2026/10/14 22:05:18 网站建设 项目流程

简介:基于ViT的车辆重识别系统项目源码,面向计算机视觉方向开发者与车辆识别研究者,解决跨摄像头场景下车辆身份匹配与检索问题。资源将Vision Transformer大模型迁移至纯视觉下游任务,完整覆盖车辆检测、多目标跟踪、特征提取与向量检索等核心流程,并在VeRi-776数据集上达到mAP 84.5、Rank-1 97.3的指标;同时对比VehicleID与VeRi-776不同训练数据下的性能差异,便于按场景选择模型。包内含3个文件,包括inscode工程入口、html交互页面与gitignore配置,压缩包仅7KB,轻量易部署。已有108人学习。读者可从中掌握ViT模型在车辆ReID任务中的优化细节,如ONNX转换、GUI设计及多线程处理思路,为复现实验或进一步研发智能交通、安防监控类应用提供可参考的源码框架。

1. 基于ViT的车辆重识别:换了个摄像头,同一辆车为什么像另一辆?

做园区车辆管理或卡口过车分析的开发者,大概都遇到过这种场景:一辆黑色SUV从入口正面抓拍,系统识别出“车辆A”;十分钟后它出现在出口,换成侧面俯拍角度,算法直接判成了另一辆车——因为在卷积网络眼里,车灯、轮毂、前脸这些局部特征全对不上了。这就是车辆重识别(Vehicle ReID)要解决的问题:不依赖车牌,只靠车身外观,让系统在跨摄像头、跨时间、跨视角的图片里认出“这是同一辆车”。传统CNN在这个任务上存在天然的视角短板,而基于ViT(Vision Transformer)的方案因为能把整辆车当作全局序列来建模,正在成为这条技术路线的主流。这篇文章适合已经有PyTorch基础、想从行人ReID迁移到车辆方向,或者正准备自建车辆检索系统的开发者。我会按一套不花哨、能落地的实现路径讲清楚:为什么选ViT、数据怎么组织、训练参数怎么设、以及最容易翻车的五个坑。

2. 为什么不是CNN:车辆重识别里的视角剧变与局部失效

2.1 CNN做车辆比对的三个死穴:遮挡、视角、同款车

行人重识别里CNN表现不错,是因为行人不是刚体,衣服纹理、背包、体态这些局部线索在大多数摄像头视角下能保持稳定。但车辆是刚体,一面是车头、一面是车尾、一面是侧面,视觉差异比行人跨视角大得多。CNN基于局部感受野做特征提取,默认依赖的是“哪些局部区域长得像”,这直接导致三个死穴:第一,遮挡。车头被绿化带挡掉一半,车灯特征丢失,CNN很容易把特征注意力浪费在树叶上。第二,视角剧变。正面图的进气格栅和背面图的尾灯在像素层面没有任何可对齐的局部结构,卷积核扫到的东西完全变了。第三,同款车。同品牌同颜色同配置的批量车辆,局部纹理几乎一样,CNN会把这辆和另一辆拉到极近的特征距离——而这恰恰是重识别最致命的错误。

所以在车辆ReID里,评判一个骨干网络好不好,关键不是它在ImageNet分类上多准,而是它能不能在丢掉局部对齐的情况下,仍然抓住“这是同一辆车”的全局证据。CNN的归纳偏置在这里反而成了限制:卷积的局部感受野决定了它天生更信任小块纹理,而不是跨区域的整体比例关系。这也是为什么早期车辆ReID方案普遍效果不稳,换一个数据集指标就大幅波动——本质是模型学到的判别依据不具备跨视角鲁棒性。

2.2 ViT的全局关联:把一辆车当一篇文章读

ViT的做法是把一张224×224的图切成196个16×16的patch,每个patch展平后映射成一个token,然后像处理句子一样送入Transformer编码器。self-attention让每个token在每一层都能和全图所有其他token计算关联权重,于是模型天然具备建模“车头与车尾的几何关系”“车窗位置与车身轮廓的比例”这类全局语义的能力。对车辆重识别来说,这意味着即使图像的局部区域变化剧烈(比如正面图到侧面图),注意力机制仍然可以追踪到“这辆车的轴距比例、车窗与车身的相对位置、整体轮廓线条”这些跨视角稳定的特征。

另一个关键优势是ViT对遮挡的容忍度。CNN遇到遮挡时,特征图上被遮挡区域直接“坏掉”,而Transformer可以通过注意力权重主动压低遮挡patch的贡献,从其他可见patch推断整体结构。实践中你会发现,ViT提取的车辆特征在query图和gallery图视角差异大时依然能保持较高的余弦相似度,而CNN特征往往断崖式下跌。当然,ViT也有代价:参数量大、训练对学习率更敏感、推理速度比同规格CNN慢。但这些代价在“识别准确率优先、推理可优化”的车辆ReID场景里是可接受的。

2.3 先用一行代码感受ViT特征:timm加载与推理

在动手训练之前,我强烈建议你先做一次“手感测试”:用timm加载一个预训练ViT,直接抽取两张车辆图像的特征,算一下余弦相似度。这一步能让你直观理解ViT输出的是什么,也能提前发现预处理上的问题。

import timm import torch from PIL import Image from torchvision import transforms # 加载 ViT-Base/16,去掉分类头,输出 768 维全局特征 model = timm.create_model('vit_base_patch16_224', pretrained=True, num_classes=0) model.eval() tf = transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ]) def get_feat(img_path): img = Image.open(img_path).convert('RGB') x = tf(img).unsqueeze(0) # 加 batch 维 with torch.no_grad(): feat = model(x) # 形状 [1, 768] return torch.nn.functional.normalize(feat, dim=1) # 同一辆车两个视角 vs 两辆不同车 feat_a = get_feat('car_front.jpg') feat_b = get_feat('car_side.jpg') feat_c = get_feat('other_car.jpg') print('same car cosine:', (feat_a * feat_b).sum().item()) print('diff car cosine:', (feat_a * feat_c).sum().item())

代码逻辑:num_classes=0让timm只返回特征、不返回分类概率;normalize做L2归一化,后面所有训练和检索都保持一致。参数说明:vit_base_patch16_224表示ViT-Base、patch size 16、输入分辨率224,输出维度768。如果同样的两张同车图片算出来的相似度反而不如不同车,优先检查预处理参数是否和预训练要求一致——这是最常见的手滑,跟你模型好坏没关系。这一小段代码也是后面整套系统的地基,训练、底库检索、评估全部复用同一个特征提取逻辑。

3. 从零搭车辆重识别系统:环境、数据组织与最小训练代码

3.1 环境与硬件:一张消费级显卡就能起步

车辆ReID的训练对硬件要求没有想象中高。ViT-Base加上224分辨率输入,单卡显存8GB以上就能跑得动;显存小就把batch size调小一点,或者把分辨率降到160,代价是精度会有几个点的损失。我比较推荐用conda创建一个独立环境,把依赖一次性装干净,避免后面被乱七八糟的版本问题打断。下面这份环境清单是我在多个项目上验证过的组合,不需要追求最新版本,稳定优先。

组件推荐选型说明
Python3.8+conda 环境隔离
PyTorch2.x训练主框架,动态图灵活
timm最新稳定版加载 ViT 预训练权重
torchvision与 PyTorch 匹配数据增强和图像变换
faiss-cpu最新稳定版评估和部署阶段检索用

装好环境后,先用第2.3节那段特征提取代码跑通一遍,确认timm能正常下载预训练权重、显卡驱动没问题,再进入数据集准备。这一步虽然简单,但能帮你把“环境问题”和“算法问题”两个变量分开——否则后面训练报错时,你根本分不清是环境坏了还是代码坏了。

3.2 数据组织:按“车ID+摄像头ID”划分而不是按文件夹划分

车辆重识别的数据组织方式直接决定评估指标的真假。常见的做法是准备三个目录:train用于训练,query是待检索的车辆图片,gallery是底库图片。query里的每辆车要在gallery里存在同ID的其他摄像头抓拍图,这样评估时才能算“跨摄像头识别率”。目录结构大致如下:

data/ ├── train/ │ ├── 0001_c1_001.jpg │ ├── 0001_c2_002.jpg │ ├── 0002_c1_003.jpg │ └── ... ├── query/ │ ├── 0012_c3_004.jpg │ └── ... └── gallery/ ├── 0012_c1_005.jpg └── ...

注意图片命名里的约定:前缀是车辆ID,中间是摄像头ID,后缀是帧序号。车辆ID是你要预测的目标,摄像头ID则用于后续的评估划分。这里有个很容易踩的坑:如果训练集和测试集来自同一个摄像头的邻近帧,模型其实在“记场景”而不是“认车辆”,指标会虚高到没法看。我一般会在数据准备阶段就按摄像头ID做一份映射表,确保train里出现过的摄像头画面不会同时出现在gallery里。做到这一点,后面评估出来的mAP才有参考价值。至于标注文件,直接用“图片名,车辆ID”的纯文本即可,不需要搞复杂的数据库。

3.3 最小可跑训练脚本:让ViT学会区分“车ID”

有了数据和预训练ViT,接下来要做的就是把分类头接到ViT特征上,让模型学会区分训练集里的每一辆车。下面是一个极简但完整的模型定义和数据加载代码,去掉了一切多余封装,方便你跑通后再逐步加功能:

import torch import torch.nn as nn from torch.utils.data import Dataset from torchvision import transforms from PIL import Image import timm class VehicleDataset(Dataset): def __init__(self, img_dir, ann_file): self.samples = [] with open(ann_file, 'r') as f: for line in f: img_name, vid = line.strip().split(',') self.samples.append((f'{img_dir}/{img_name}', int(vid))) self.tf = transforms.Compose([ transforms.Resize((224, 224)), transforms.RandomHorizontalFlip(), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ]) def __len__(self): return len(self.samples) def __getitem__(self, idx): path, vid = self.samples[idx] img = Image.open(path).convert('RGB') return self.tf(img), vid class ViTReID(nn.Module): def __init__(self, n_classes, backbone='vit_base_patch16_224'): super().__init__() # 预训练 ViT 作骨干,输出 768 维特征 self.backbone = timm.create_model(backbone, pretrained=True, num_classes=0) self.fc = nn.Linear(768, n_classes) # 车辆 ID 分类头 def forward(self, x, return_feat=False): feat = self.backbone(x) logits = self.fc(feat) if return_feat: return logits, feat return logits # 训练集有 100 个车辆 ID,按实际数据修改 model = ViTReID(n_classes=100)

代码逻辑:VehicleDataset从纯文本标注里读图,返回224×224的图像和车辆ID;ViTReID把预训练ViT当作特征提取器,后面接一个线性分类头。注意一个关键设计:训练时用logits算分类损失,但真正用于重识别检索的是feat,不是分类输出。参数说明:n_classes必须等于训练集里不同的车辆ID数量,不是图片数量;RandomHorizontalFlip对车辆ReID是安全的数据增强,车辆左右翻转不影响身份,但不要加RandomRotation,那会破坏车辆姿态的语义。

如果你的显卡显存小于8GB,把Resize改成(160, 160),同时vit_base_patch16_224要换成vit_base_patch16_224对应的timm.create_model参数里改img_size吗?不必,ViT的位置编码是预训练好的,强行改输入分辨率会导致位置编码失配。更简单的是就用224分辨率、batch size调到16甚至8。跑通之后再考虑提分辨率。

4. 训练配置与损失函数:让全局特征学会区分“同一辆车”

4.1 损失函数:ID分类 + 三元组损失的组合逻辑

车辆重识别训练的核心是让模型输出的特征满足两个性质:同一个车辆ID的图片特征要尽量近;不同车辆ID的特征要尽量远。单用分类交叉熵损失,模型会偏向“记住类别”,学出来的特征往往判别性不够;单用三元组损失,训练又容易震荡不收敛。所以实践中普遍的做法是把两者组合起来:分类头负责稳定收敛,三元组损失负责拉大特征间距。下面是基于Batch Hard策略的三元组损失实现,以及它在整个训练循环里的用法:

import torch import torch.nn.functional as F def batch_hard_triplet(feat, label, margin=0.3): # feat: [N, D] 已做 L2 归一化,label: [N] dist = torch.cdist(feat, feat, p=2) # 两两欧氏距离矩阵 same = (label[:, None] == label[None, :]).float() # 同车为 1 pos_dist = dist * same # 只保留正样本对 neg_dist = dist + (same * 1e6) # 盖掉正样本对 # 对每个 anchor 取最远的正样本和最近的负样本 hardest_pos = pos_dist.max(dim=1).values hardest_neg = neg_dist.min(dim=1).values loss = F.relu(hardest_pos - hardest_neg + margin).mean() return loss

代码逻辑:cdist算出batch内所有样本两两距离;same矩阵标记哪些样本对属于同一辆车;对每个anchor,找“最不像”的同类(hardest positive)和“最像”的异类(hardest negative),然后要求前者比后者近至少margin。参数说明:margin=0.3是常用起点。如果损失一直为0,说明样本对太容易区分或者margin过大,降到0.1试试;如果损失降不下去,说明margin太小或batch内每个ID的样本数太少。还有一个隐性要求:每个ID在同一个batch里至少要有2张图,所以dataloader的采样器要按ID分组采样,不能纯随机——这往往是新手写三元组损失不收敛的根源。

4.2 三个必调参数:patch size、学习率、re-ranking开关

第一个参数是patch size。ViT默认是16×16的patch划分,这个值是预训练时定死的,不要轻易改。在车辆重识别场景里更值得调的是输入分辨率:224是起步,显存够就上320或384,细粒度特征(车窗轮廓、后视镜形状)会有可见提升。patch改成32会丢失太多空间细节,在车辆这种细粒度任务上基本没人用。第二个参数是学习率。预训练骨干和随机初始化的分类头对学习率的耐受度完全不同,常见做法是把它们拆成两组参数分别设置:骨干3e-5到1e-4,分类头1e-3。这里的经验是:骨干学习率一旦超过1e-4,预训练特征很容易被冲垮,训练曲线表现为loss先降后暴升——这时候没有后悔药,只能重新加载预训练权重再来。

第三个参数容易被忽略:re-ranking。它指的是检索出Top-K候选后,利用候选列表之间的相互邻近关系做二次重排,能显著提升mAP。但新手在评估时要特别注意相关论文的指标是否开了re-ranking——因为开与不开,mAP可能差5到10个点。训练阶段不需要re-ranking;部署阶段如果对延迟不敏感可以考虑,但如果你要做实时检索,建议直接关掉,把精力放在特征本身的判别力上。这是我在项目里最常说的一句话:先别急着用re-ranking刷指标,想清楚你的系统到底哪一步是瓶颈。

4.3 训练循环与学习率调度:别让预训练权重被冲垮

下面是一段最小可用的训练循环,重点在于参数字典的分组和损失组合,其他细节(日志、模型保存)按你自己的工程习惯补全即可:

optimizer = torch.optim.AdamW([ {'params': model.backbone.parameters(), 'lr': 3e-5}, # 骨干小步微调 {'params': model.fc.parameters(), 'lr': 1e-3}, # 分类头正常训练 ]) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=60) for epoch in range(60): model.train() for x, label in dataloader: x, label = x.cuda(), label.cuda() logits, feat = model(x, return_feat=True) feat = F.normalize(feat, dim=1) ce = F.cross_entropy(logits, label) trip = batch_hard_triplet(feat, label, margin=0.3) loss = ce + 1.0 * trip optimizer.zero_grad() loss.backward() optimizer.step() scheduler.step()

代码逻辑:return_feat=True同时拿到分类logits和特征向量;feat在进三元组损失前做L2归一化,保证距离度量的一致性和稳定性;损失是两个loss直接相加,权重都是1.0。参数说明:T_max=60要和总epoch数一致,余弦退火会把学习率从初始值平滑降到0;如果你只打算训40轮,就把两个数值改成40。三元组损失的权重不是固定的,如果你的数据集每个ID样本很少(少于4张),三元组损失容易抖动,可以把权重降到0.5,用分类损失稳住大局。

训练曲线的判读也值得单独说:分类头的loss下降会很快,骨干的loss下降很慢甚至先涨后降,这是正常的。真正需要警惕的是验证集mAP在某个epoch后持续下降,或者骨干loss出现陡增——前者说明过拟合开始,后者说明学习率太大。还有一种情况是损失一直在降但检索指标纹丝不动,这通常意味着特征被分类头“带偏”了,模型学会了区分ID,却没有学会通用的车辆外观表征。遇到这种情况,把分类损失的权重降下来、三元组损失提上去,往往能救回来。

5. 车辆重识别训练与评估的五大翻车现场:排查清单

5.1 同一辆车不同视角被判成不同车

现象:query里正面拍的车辆,在gallery里同一辆车的侧面照排到了第50名开外,Top-10全是其他车。原因:训练数据里同一个车辆ID的图片大量来自同一个摄像头,模型学到的其实是“摄像头视角+车辆外观”的联合特征,换个摄像头就失效。解决:数据准备阶段先按摄像头ID统计每个车辆ID的样本覆盖情况,确保训练集里每个ID至少包含2个以上摄像头的图像;增强里加RandomErasing模拟遮挡;如果样本实在不够,把三元组损失的权重提高到1.5,让模型更强调跨样本的判别性。这个坑在公开数据集上不明显,因为数据集已经刻意做了视角平衡,但自建数据几乎必踩。

5.2 指标虚高:gallery里有训练见过的摄像头

现象:训练集上mAP到0.95,实测新场景只有0.4。原因:数据划分时没做摄像头隔离,gallery里混入了训练集的摄像头画面,模型靠“认背景”混到了高分。解决:划分数据时坚持“摄像头级隔离”——同一个摄像头的图像只能出现在train、query、gallery的其中一个集合里;评估阶段按摄像头维度拆开看指标,如果单摄像头场景指标远高于跨摄像头,基本可以判定数据划分有问题。另一个连带检查项是去重:同一辆车在同一天同一摄像头下的连续帧,视觉上几乎相同,如果同时出现在train和gallery,会制造“送分题”,必须按时间间隔抽帧去重。

5.3 ViT不收敛或越训越差:预训练权重被毁

现象:第一个epoch loss正常下降,第10个epoch开始loss陡增,特征向量之间几乎无法区分。原因:最常见的是骨干学习率设成了和分类头一样的1e-3。Transformer和CNN的微调耐受度不一样,ViT的预训练权重对学习率非常敏感,大步长更新会直接把学到的特征分布打乱。解决:骨干学习率从3e-5起步,最多到1e-4;外加前5个epoch做线性warmup,让学习率从0平滑升到目标值。如果已经发生了loss暴增,不要试图调低学习率救回来,直接重新加载预训练权重重新训练——这是省时间的最优解。

5.4 同品牌同色车辆互相误判

现象:检索白色同款SUV时,gallery里排前几名的经常是另一辆同款车,真正目标排到后面。原因:全局特征被颜色和车身轮廓主导,模型没有学到细粒度的个体差异。解决:提高输入分辨率到320或384,细小的划痕、贴纸、天线位置会进入特征;在数据增强里加ColorJitter和RandomGrayscale,强迫模型不依赖颜色;更进一步的做法是在ViT特征基础上拼接一个局部特征分支,比如把最后几层注意力图按车辆部件(车窗、车灯、保险杠)做分区加权。这个坑没有根治方案,只能做缓解。车辆重识别本来就比行人难在同类车之间的个体差异太小,合理预期是:同款车误排进Top-10可以接受,但必须排在同ID的后面。

5.5 模型评估mAP高,推理排队3秒一张

现象:离线评估指标不错,一上真实摄像头,单张图推理加上检索耗时严重超标。原因:ViT的自注意力计算量大,224分辨率的单张推理在普通GPU上约30到50毫秒;但真正的瓶颈在检索——gallery如果有10万张图,暴力计算余弦相似度在CPU上要做10万次向量乘法,耗时随底库线性增长。解决:模型推理阶段导出ONNX并用TensorRT加速;检索阶段用FAISS的IVFPQ索引代替暴力索引,把检索耗时从线性降到亚线性;底库特征提前预计算好,不要在线提取。评估指标和系统延迟是两回事,mAP高只能说明“离线排序正确”,不能说明“线上能用”,上线前必须做压测。

6. 进阶:用FAISS做百万级车辆检索的落地验证

6.1 把特征变成可检索的底库

训练完成后,你得到的是一个能输出768维特征向量的模型。接下来要把所有gallery图像的特征预先抽取出来,建成索引。FAISS是这一步的标配工具,下面的代码演示了最小可用的建库和检索流程:

import faiss import numpy as np # gallery_feats 形状 [M, 768],M 为底库图片数 faiss.normalize_L2(gallery_feats) # 先归一化,内积等价余弦相似度 index = faiss.IndexFlatIP(768) # 内积索引,简单精确 index.add(gallery_feats) # query_feat 形状 [1, 768],同样归一化 D, I = index.search(query_feat, k=10) # 返回距离和索引

代码逻辑:normalize_L2逐行归一化,让内积结果等于余弦相似度;IndexFlatIP是暴力索引,M在几万规模内精度最高。参数说明:k=10是检索返回数量,评测rank-1时k至少取10,实际应用你只需要关心前三名。当底库超过几十万张时,把IndexFlatIP换成IndexIVFPQ:先聚类再量化,检索速度提升一个数量级,代价是召回率下降几个点。注意:query和gallery抽取特征时,必须使用完全相同的预处理参数和模型权重,否则检索结果没有意义。

6.2 三个指标看系统能不能上线

车辆重识别系统的评估,我只看三个指标:mAP、rank-1、单摄像头与跨摄像头的差值。mAP衡量整体排序质量,rank-1衡量“第一张就命中”的概率。单独看rank-1会有误导性,因为如果同款车混在gallery里,rank-1可能是同款但不是同ID。跨摄像头差值是最容易被忽略的:把query按摄像头分组,分别计算每个组的rank-1,如果不同摄像头之间的指标波动超过10个百分点,说明模型对视角和场景的鲁棒性还不够,上线后会表现得很不稳定。按这个标准验证一遍,模型能不能进生产环境基本就有数了——不需要等部署后再来补救。

最后说一个我自己的习惯:每换一个数据集,第一件事不是刷mAP,而是随机挑20张query做Top-10可视化,用眼睛扫一遍。指标会骗人,可视化不会——如果你看到的结果大多是正确ID但混着同款车,说明模型已经可用,剩下的交给后处理过滤;如果看到大量错乱,那先回去查数据划分。这个习惯帮我少走了很多弯路。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询