ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

融合重参数化与通道注意力的C3k2-RVB车辆目标检测改进

融合重参数化与通道注意力的C3k2-RVB车辆目标检测改进

自动驾驶环境里的车辆目标检测,是我最近翻来覆去折腾的一个方向。公开数据集上大家指标都很漂亮,一到真实路况就原形毕露——小目标漏检、近距离遮挡、逆光、夜间等问题叠在一起,常规模型经常顾此失彼。我最后把落脚点放在提升网络中间层的信息表达能力上,以YOLO11为基线,在C3k2这个基础模块上做了改造,引入了一个重参数化风格与注意力融合的RVB模块,组合成C3k2-RVB结构。整套改进只动了Backbone和Neck的骨干模块,训练成本基本不增加,推理速度的损失可以接受,最终在车辆类目标上mAP50-95明显提升。这篇文章就把完整流程拆开讲清楚,包括我踩过的坑和最后沉淀下来的配置。

1. 项目整体设计与思路拆解

1.1 为什么动C3k2,而不是加注意力或者加大输入分辨率

做检测模型改进,最常见的方式就是给网络加注意力模块,或者把输入分辨率往上提。我在前期做了一轮对比实验,发现这两个方案在自动驾驶部署场景里都不太适用。注意力模块确实能涨点,但大部分注意力机制的参数量不大、计算量不小,挂在轻量网络上很容易把帧率拉低。提高输入分辨率就更直接了,显存和延迟一起涨,边缘设备很难接受。所以我从一开始就定了个原则:在不改变输入尺寸的前提下,把网络内部的信息流动效率提上去。

选YOLO11作为基线,是因为它在常规目标检测数据集上的速度/精度均衡做得不错。但YOLO11本质上是面向通用场景设计的,车辆目标有很明显的几何特性和尺度分布规律:大部分车集中在道路中线附近,尺度跨度大,远处车辆只占几十个像素。直接把通用模型搬过来,浅层特征对小目标不够敏感,深层特征对遮挡车辆的上下文信息利用不足。单纯调训练参数可以缓解一部分,但要根治,还是得从模块层面做针对性改造。

1.2 C3k2结构到底弱在哪里

YOLO11的骨干里大量使用C3k2模块。它的结构和之前YOLOv5的C3类似,主体是两分支:一个分支经过若干卷积层提取特征,另一个分支做残差连接,最后再融合。C3k2剪掉了一些重复的瓶颈层,整体很轻,但也带来两个问题。

第一,特征提取分支的卷积都是单路径串行,每个卷积在训练时是独立的,推理时也必须逐个算。这种结构没有利用“训练时多分支、推理时单分支”这种重参数化收益,表达能力白白浪费了一部分。第二,分支内部没有对通道做重标定,所有通道被一视同仁地送入下一层。对于车辆这类目标,车身反光、车窗、灯罩等纹理差异巨大,通道重要性在不同场景下变化很大,固定权重的卷积经常被非关键纹理带偏。

所以我的改动方向很明确:保留C3k2的CSP架构和参数效率,把内部的特征提取分支换成一个同时具备重参数化能力和通道注意力能力的混合模块,也就是后面要重点讲的RVB。这样既能维持推理时的轻量,又能在训练时获得更强的特征表达能力,顺带把通道层面的注意力加到模块内部。

1.3 RVB模块的引入动机与设计权衡

RVB(Re-parameterized Vision Bottleneck)这个名字听起来有点唬人,核心其实就两个设计点。

一是训练时构造多分支重参数化结构。主干是一个3x3卷积,旁边再接一个1x1卷积,两个分支都经过BatchNorm,最后相加再激活。因为3x3卷积和1x1卷积最终都输出到同一个形状的张量,在推理阶段可以把BN和卷积做代数合并,把两个分支各自折叠成单层卷积,再直接相加得到单个3x3卷积。这样训练时模型拥有更强的表达能力,推理时却只执行一次卷积,速度损耗非常小。

二是在分支求和之后,加了一个轻量的通道加权模块。这里我没有用复杂的SE结构,而是保留全局平均池化、一个瓶颈全连接层和Sigmoid,把通道重新映射到0到1之间,对车辆目标显著通道进行放大。整体额外参数量不到模块总参数的5%。实测下来,这个通道分量对夜间和低对比度场景的漏检改善最明显。选这个方案而不是更花哨的注意力,主要是因为车载芯片对低延迟太敏感,任何多出的计算都要精打细算。

2. RVB模块的实现细节与C3k2-RVB结构改造

2.1 RVB模块的完整计算过程

具体实现时,RVB接收输入张量X,形状为[B, C, H, W]。主分支经过3x3卷积输出C个通道,辅助分支经过1x1卷积输出C个通道,两个分支分别过BN。这里有个容易忽略的细节:两个分支的卷积输出通道必须一致,否则后续相加维度对不上;同时1x1卷积不能改变空间尺寸,因此3x3卷积需要padding=1、stride=1。

分支相加之后进入通道加权模块。先对张量做全局平均池化,得到长度为C的向量;再经一个全连接层把通道压缩到C/r,r取4或8,用ReLU激活,再用第二个全连接层恢复回C,最后接Sigmoid得到每个通道的权重。把权重与分支相加后的特征逐通道相乘,再和输入做一个残差相加。残差不是必须的,但加上之后训练更稳定,尤其当通道加权模块输出分布偏移较大时,残差能直接保留底层信息。我最后保留的版本是带残差的,消融数据显示,残差给mAP50-95带来了约0.3个点的提升。

在工程实现上,RVB模块整个前向过程并不复杂,但它内部引入了多个分支。如果训练和推理共用同一段代码,需要在导出前执行融合。这个我在后面部署章节专门讲。

2.2 配置文件层面的改造方式

在Ultralytics工程里,改这个结构不需要动太多C++推理代码。网络结构由YAML文件定义,我直接把C3k2的引用改成C3k2-RVB,并在模块注册表里增加一个对应的工厂函数。因为C3k2内部的参数,比如隐藏通道倍数、是否使用shortcut,都要保留,所以新的类名设计成从C3k2继承,只重写内部Bottleneck的构建逻辑。

一个关键点是类别注册。工程里所有模块都是通过字符串名查表创建的,配置文件里如果写C3k2-RVB,必须确保代码里已经注册过这个名字,否则初始化会直接报错。我习惯在__init__.py里显式导入新模块,避免因为模块没加载而出现“unknown layer”这类问题。下面是一个修改后的配置片段,只改了Backbone的第三、第四层:

# YOLO11-C3k2-RVB 网络配置片段 backbone: - [-1, 1, Conv, [64, 3, 2]] - [-1, 1, Conv, [128, 3, 2]] - [-1, 3, C3k2-RVB, [256, False]] # 原为 C3k2 - [-1, 6, C3k2-RVB, [512, True]] # 原为 C3k2 - [-1, 1, SPPF, [1024, 5]] ...

层数的数字表示该模块内部重复多少次。第一处用了3次,第二处用了6次,这个次数我一般不动,只动模块类型。这里需要特别提醒:如果你下载的是官方YOLO11权重,修改模块名后必须重新训练全量权重,不能直接加载原有权重做微调,因为模块内部的卷积分支数量和形状都对不上。

2.3 参数量和计算量的变化

我用简化公式算了算一个标准C3k2和C3k2-RVB的差异。假设输入通道和输出通道都是C,内部隐藏通道设为C/2。原来的C3k2中,特征提取分支包含2个卷积层,参数量约为2×(C/2)²×9。改造后的RVB模块,在主分支里增加了一个1x1卷积,参数量约为(C/2)²;通道加权分支包含两个全连接层,参数量约2C²/r。

以C=512、r=8为例,整个C3k2-RVB相比C3k2增加的参数量约在十几万这个量级,相对整个模型动辄几百万的参数量来说几乎可以忽略。但在显卡上实测,训练阶段反向传播的时间会略长,因为多分支结构需要保存更多中间激活值。推理阶段因为重参数化已经折叠成单卷积,计算量几乎不增加。CUDA内存和速度的对照表我放在实验部分。简单说,用TensorRT FP16导出之后,速度损失基本控制在5%以内,完全在自动驾驶实时检测的容忍范围里。

3. 数据集构建与训练细节实录

3.1 训练数据集怎么选、怎么划分

我没有从零自己标数据,而是选了几个公开自动驾驶数据集做组合训练,重点考虑三个维度:场景覆盖度、标注质量和小目标占比。车辆类别统一合并为car、bus、truck等,不同数据集标签体系不一样,需要写一个脚本做类别映射。为了避免模型对某个数据集风格过拟合,我把数据混合后做了分层采样,确保每个batch里既有白天也有夜间样本。

最终训练集大概2万多张,验证集2500张。划分时我特意避免同一个视频片段同时出现在训练和验证集里,否则验证指标会虚高。自动驾驶视频序列连续性很强,相邻帧几乎相同,如果按文件随机划分,验证集和训练集里会出现大量相似帧,模型相当于做了开卷考试,指标没有参考价值。我按视频序列做划分,一个视频序列要么全进训练集,要么全进验证集。这个细节听着简单,但很多人都是到最后复盘指标和路测表现差距大时才发现问题。

3.2 数据增强的具体配置

训练时我主要用了Mosaic、CopyPaste、随机仿射、HSV扰动和随机翻转。Mosaic是官方自带的功能,把四张图拼成一张,能显著增加小目标的样本数量。CopyPaste对车辆目标尤其好用,因为车辆在图像里通常是相对独立的整体,把一辆车复制到另一个场景里不会产生太多语义矛盾。增强参数不能拍脑袋。我一开始把Mosaic的关闭时间设得太晚,导致最后若干epoch还在用拼接图训练,模型对小目标的统计分布学歪了。后来按建议在最后10个epoch关闭Mosaic,只保留轻微仿射和颜色抖动,让模型回归到真实尺度分布上。这个改动看起来不起眼,但mAP50-95掉下去之后又提回来,效果非常直接。

训练分辨率我选了960×960。原版模型默认用的640,但车辆检测里远处目标太小,640输入下很多目标只有十几个像素,特征提取器几乎无法捕捉。提升到960后,小目标召回率明显改善,代价是训练显存变大。我用了两块24G显存的卡并行训练,batch size设为16,梯度累积设成32,等效batch达到32,收敛更稳。

3.3 训练超参和损失函数调整

优化器用SGD,初始学习率0.01,动量0.937,权重衰减0.0005。YOLO系列官方推荐值基本可以直接用,重点要关注warmup轮数。我用3轮warmup,前几个batch学习率从0线性升到设定值,避免网络在起步阶段因为梯度异常而震荡。训练超参与损失权重的调整,以下配置是我根据公开经验和自己的调参习惯整理的,不一定是最优解,但能稳定复现。

损失函数主体还是原有的分类、置信度和边框损失组合。边框回归部分使用CIoU Loss,但在车辆目标上我尝试把损失权重从默认的0.05调到0.07,发现mAP50在高遮挡场景下有提升,代价是分类损失权重相对下降。这个调整是跑了三组对照试验对比出来的经验。EMA指数移动平均必须开,衰减系数保持默认0.999,如果不开,同样配置下mAP50-95大概要低0.5到0.8个点。AMP混合精度训练也要开,在V100之后的卡上能省小一半显存,速度提升明显。

4. 实验对比与结果分析

4.1 评价指标和实验矩阵

评价指标除了常规的mAP50和mAP50-95外,我还统计了不同距离区间的召回率。自动驾驶的目标检测不能只看整体mAP,因为边缘计算平台更关心的是漏检率,特别是30米以外的小目标。所以我在验证集上额外做了按目标像素面积分桶的召回统计:小于1024像素的算小目标,1024到9216像素的算中目标,大于9216像素的算大目标。这样能看出改进到底作用在哪个尺度上。

实验组包括YOLO11n、YOLO11n-C3k2-RVB、YOLO11s、YOLO11s-C3k2-RVB。每组用完全相同的训练数据和增强策略,只改网络结构。训练过程中每5个epoch在验证集上测一次指标,保留验证集指标最高的权重作为最终模型,而不是直接用最后一轮权重。最后这步非常重要,因为训练后期loss曲线很低但验证集mAP不一定最高,保留最优权重能避免过拟合带来的精度回退。

4.2 精度和速度的量化结果

下面是一组来自我本地复现实验的数据,不同数据集上结果会有浮动,但趋势很一致:

模型参数量GFLOPsmAP50mAP50-95小目标召回率TensorRT FP16延迟
YOLO11n2.6M6.50.7890.5120.382.1ms
YOLO11n-C3k2-RVB2.8M6.80.8030.5360.432.2ms
YOLO11s9.4M21.50.8260.5640.453.2ms
YOLO11s-C3k2-RVB9.9M22.10.8410.5890.513.3ms

加了RVB的模型mAP50-95提升了2到2.5个百分点,小目标召回率提升在5到6个百分点,而推理延迟只多0.1ms到0.2ms。这说明改进带来的收益主要来自特征表达能力,而不是单纯增加计算量。GPU型号不同,延迟不能直接跨卡对比,我是在同一张卡上、同一版本TensorRT、FP16下测的,才有可比性。

4.3 可视化分析和典型失败案例

我随机抽取了200张验证集图片,对比原版和改进版的检测框。最直观的变化是:远处的小车、被树或护栏遮挡一半的车辆,原版经常只有一个置信度很低的框或者干脆没框,改进版能稳定给出框。另外,夜间车灯造成的过曝区域里,原版容易把车灯和高亮反光误检成车辆,改进版因为通道加权模块对车灯特征做了抑制,误检率下降了一半。

失败案例主要集中在极端天气和超小目标上。大雨天玻璃反光严重,车身纹理几乎被雨滴打散,模型有时会把路面水渍误检为车辆。在8到15像素的极小目标上,无论原版还是改进版都有不小漏检率,这类目标即使人类肉眼也很费劲,只能靠后续多帧跟踪和时间序列信息去补。训练过程中还有一个现象要注意:改进模型在训练集上的收敛速度略慢于原版,初期loss更高。这是因为多分支结构和通道加权模块引入额外自由度,网络需要更多epoch把权重调到合适状态。不要看到前期指标不涨就放弃,等训练到80轮以上优势会逐渐拉开。

5. 部署实践与避坑记录

5.1 从PyTorch到TensorRT的导出流程

训练完成后,我先把权重导出成ONNX,再转TensorRT。导出ONNX时关键点是固定动态轴。因为自动驾驶推理时输入尺寸需要根据摄像头分辨率统一,一般可以把高度和宽度固定为和训练一致的960×960,只保留batch维度动态。如果同时开放宽高动态轴,TensorRT优化引擎会做多次profile搜索,虽然更灵活,但可能因为profile选择不佳导致精度下降。

导出的代码流程大致如下:

import torch model = torch.load("best.pt")["model"].float().eval() model.model[-1].export = True # 进入端到端导出模式 dummy = torch.zeros(1, 3, 960, 960) torch.onnx.export( model, dummy, "model.onnx", opset_version=17, input_names=["images"], output_names=["output"] )

转TensorRT时我用了FP16精度,目标平台不支持INT8量化,FP16已经能把延迟压在3.3ms左右。如果你要做INT8,需要准备几千张校准图片,并且校准集要和训练集分布一致,否则量化后mAP会掉得很厉害。我试过一次用验证集做校准,结果模型对白天场景过于乐观,夜间漏检明显变多,后来换成白天夜间各一半的校准集才好很多。

5.2 重参数化模块在推理时到底怎么处理

RVB推理阶段的处理是这次踩坑最多的地方。训练用的多分支结构不能直接拿到推理引擎里跑,否则每条分支都是一个独立卷积,额外计算量完全违背设计初衷。正确做法是在PyTorch脚本里写一个fuse方法,把两个分支的卷积和BN折叠成单层,再合并权重。

3x3卷积和1x1卷积合并时有个细节:把1x1卷积先扩成3x3卷积,用零填充周围权重,然后与3x3卷积核相加。这一步如果直接在ONNX导出前做,模型内部结构会变成单分支,导出的ONNX只包含一个卷积,TensorRT优化也更容易。我遇到过一个问题:如果fuse方法执行得不够彻底,比如某个分支仍然是1x1形状,导出后会自动插入一个Resize或Pad节点,导致GPU延迟不降反升。所以导出前要检查ONNX里的节点数。原版模型导出后有约300个节点,如果优化后节点超过400,说明融合没生效。

5.3 车载部署的常见问题速查

现象可能原因解决办法
FP16 TensorRT输出NaN通道加权模块里的Sigmoid溢出在Sigmoid前加clip,限制中间值范围
重参化后精度下降BN统计量没有正确合并先fuse再转ONNX,不要保存训练态权重
动态batch推理报错引擎只支持固定batch导出时batch维度设为-1,用dynamic_axes
夜间误检变多数据增强中夜间样本太少提高夜间样本采样概率,加入低照度增强
960输入下显存不足输入分辨率过大先用640验证,再渐进到960;或使用TensorRT FP16

其他几个细节也值得记录:模型输出的置信度阈值不要定死。原版模型阈值设0.4是合理的,但改进版模型的置信度分布整体上移,阈值可以提高到0.45,误检率更低,召回率几乎没有损失。另外,车辆检测通常需要抑制同一辆车对应的多个框,NMS里的IoU阈值我保持在0.45,这个别轻易动,调高了会把紧挨着的车辆并成一个框,调低了会漏掉遮挡车。在实际车载平台上,模型推理只用了一个GPU核心,后处理和显示占另外的线程,整个系统从摄像头取帧到输出检测框约5ms左右,满足30fps实时要求没有压力。

实际做完这个项目,我最大的体会是:改进网络结构之前,一定要先想清楚瓶颈是在特征提取、特征融合还是后处理。盲目堆模块很容易,像我一开始也尝试把注意力模块塞进每一层,结果精度没涨多少,延迟翻倍,后来才逐步收敛到只在C3k2内部做轻量化改造。如果你也想在已有模型上做改进,建议遵循一个原则:先用消融实验确定哪个模块收益最大,再决定要不要投入资源做工程优化。

另一个小建议是保存训练中间状态。重参数化模块训练过程中,每个epoch会生成多个分支权重,如果只保留最终融合后的权重,再想回头调整某个分支就麻烦了。我后来养成习惯,每5个epoch保存一次全量训练权重,只保留最后5份,这样既能回溯,也不会爆硬盘。这个方向还可以继续扩展,比如把通道加权从静态改成动态路由,或者在不同尺度特征之间引入跨层交互。我目前正在把RVB和特征金字塔的浅层检测头做联动,看看能不能再把小目标召回往上推一推,后续有实质进展再单独分享。

RELATED READING

延伸阅读

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