看到这个项目名,你应该能猜到这是一次典型的算法落地尝试。NAS-FCOS、R50-Caffe、FPN、NASHead、GN-Head,这些词挤在一起,解决的是同一个问题:拥挤路段上怎么把车一辆一辆找出来,还得跑得动、训得稳。项目名结尾的“_1”说明这是第一轮稳定迭代,后面大概率还有“_2”“_3”。我自己做完这轮之后最大的感受是,NAS-FCOS在拥挤场景下的提升不是来自某个单点魔法,而是把搜索得到的检测头、组归一化这些细节全部拧到一起才有效果。这篇文章我会从方案选型、结构拆解、训练配置、踩坑记录四个方面完整复盘整个过程,适合正在做目标检测落地、尤其是被密集小目标折磨过的开发者参考。
1. 项目定位:拥挤路段车辆检测,真正难在哪
1.1 拥堵场景对检测器的三个特殊压力
做交通监控类项目的人应该都有体会,普通道路场景下车辆检测已经不算难题,但一旦进入“拥挤路段”这个限定词,模型的压力会立刻变大。这种压力不是简单的“目标多一点”,而是三个层面同时叠加。
第一个压力是尺度跨度极大。监控摄像头通常架在高处,近处车辆可能占满画面三分之一,远处排队等待通行的车可能只有十几个像素宽。一个检测器如果只按固定尺度设计锚框,很难同时覆盖近处的卡车和远处的小轿车。第二个压力是遮挡和重叠。拥堵状态下车辆间距很小,前车遮挡后车、车身互相交错是常态。大部分通用检测器在训练时会把两个重叠严重的框当作正负样本交替处理,结果就是模型对这类样本的响应非常不稳定。第三个压力是背景干扰。路面上的护栏、车道线阴影、树影、水渍都会产生大量类似车辆边缘的高频纹理,导致误检。
这三个压力加在一起,会直接把一些在常规数据集上表现不错的检测器打回原形。我在最初用通用检测框架跑这批数据时,AP50看着还凑合,但AP75掉得非常夸张,密集区域的漏检率也明显偏高。因此这个项目从一开始就不是“拿一个检测模型跑通”那么简单,而是必须找到一个能在高重叠、多尺度条件下保持稳定输出的方案。
1.2 方案选型:为什么是NAS-FCOS而不是其他
标题里把这套方案的骨干、融合结构、检测头、归一化方式都列出来了,可见选型是经过一番权衡的。我先用一张表把几个常见选项的对比列出来,后面再逐个说结论。
| 方案 | 锚框机制 | 拥挤场景表现 | 工程成本 | 灵活度 |
|---|---|---|---|---|
| 传统两阶段检测器 | 密集锚框 | 召回尚可但易重复检测 | 较高,需调锚框参数 | 低 |
| SSD类单阶段 | 固定锚框+多尺度预测 | 小目标弱,重叠易漏 | 中等 | 低 |
| 纯FCOS | 无锚框 | 优于锚框类,但head设计依赖经验 | 较低 | 中 |
| NAS-FCOS | 无锚框 | 通过搜索自动优化head,密集场景提升明显 | 中等,主导航已固定 | 高 |
先说为什么不用传统两阶段检测器。两阶段方法的候选框生成在密集场景下会产生大量重叠建议框,后面的NMS处理非常痛苦,阈值调高容易漏检,调低则出现大量重复框。虽然两阶段方法在小目标上通常有优势,但工程上的调参成本太高,尤其是在不同路口的摄像头视角差异很大的时候,几乎每个场景都要重新调一次锚框参数。
FCOS这类无锚框方法天然适合拥挤场景,因为它不依赖预设锚框尺寸,通过逐像素预测的方式直接把目标中心和边框回归出来,重叠目标之间至少不会因为锚框IoU分配而产生混乱。NAS-FCOS更进一步,它将原本需要人工设计的检测头通过架构搜索自动找出来,在交通场景下通常能搜到比手工堆叠卷积更好的特征融合方式。
那为什么骨干网络还是固定的ResNet-50?因为项目本质是落地而不是刷点。搜索整个backbone的计算成本太高,工业界也没有必要。把NAS的搜索空间限定在head和特征选择上,既能获得结构收益,又不会把训练时间拖到不可接受。至于选Caffe而不是其他更新框架,核心原因是部署链路里的自定义算子大多基于Caffe实现,而且现有推理加速模块对Caffe模型的兼容度更高。这部分我在后面部署排查章节还会细说。
1.3 这套组合解决了什么、妥协了什么
组合起来看,R50负责提供稳定的基础特征,FPN负责把多尺度信息拉齐,NASHead负责用搜出来的结构适配拥挤场景,GN-Head负责在小batch训练时稳住归一化统计量。这套方案解决的核心问题是:在不需要人工反复试head结构的前提下,让无锚框检测器在密集场景下达到可用的精度。
但它也有妥协。R50相对于更深的网络,在极小目标上的特征表达力有限,所以后期必须依赖多尺度训练和测试时增强来补。FPN的引入会带来额外显存占用,NASHead搜出来的结构往往比手工设计的head更深,推理速度会有所下降。GN虽然训练更稳定,但在Caffe里需要自定义层,增加了移植工作量。这些妥协不是缺陷,而是工程选型的必然结果,理解了它们,后面遇到问题时才不会慌。
2. 模型结构怎么搭:R50-Caffe-FPN-NASHead-GN-Head逐层解读
2.1 ResNet-50作为骨干:选它省心在哪
很多人看到R50-Caffe会下意识觉得这只是“换个框架实现同一个网络”,实际用起来完全不是这么回事。Caffe版本的ResNet-50在结构上有一个非常需要注意的地方:BatchNorm在Caffe里被拆成了BatchNorm层和Scale层两个操作,而不是像现在主流框架那样合成一个BN层。
BatchNorm层的核心作用是在每个通道上做标准化,它内部维护均值、方差和滑动平均系数。Caffe的BatchNorm层有一个关键的use_global_stats参数,训练时置为false,用当前batch的统计量;测试时置为true,用训练阶段累计的全局统计量。紧跟在后面的Scale层负责执行缩放和平移操作,它包含了两个可学习参数:缩放因子gamma和平移因子beta。
如果你拿着PyTorch或者别的框架上训好的ResNet-50权重往Caffe里灌,不能只做简单的名字映射,否则会乱套。需要先把BN的均值、方差转成Caffe的均值、方差,再把gamma和beta放到Scale层里,同时确认滑动平均的momentum是否一致。我在这里踩过一次很深的坑:直接用脚本转完权重后,训练loss能降,但验证时mAP一直上不去,后来排查发现是BN层滑动平均参数没有正确设置,导致测试阶段统计量混乱。
还有一个预处理差异。Caffe默认图像通道是BGR,而训练时很多数据增强库输出的是RGB。如果忘记在数据层或预处理阶段做颜色通道转换,模型看到的颜色分布完全不同,结果一定很差。这个看似幼稚的问题,在实际项目里出现的频率远比你想象的高。
2.2 FPN层融合对拥挤路段的关键价值
FPN特征金字塔在现在的检测器里几乎是标配,但在拥挤路段这个场景里,它的价值比一般场景更大。原因很简单:你要检测的目标尺度跨度实在太大,单层特征图不可能同时胜任。
FPN的基本思路是把骨干网络不同阶段的输出自顶向下融合,高层特征拥有强语义信息但分辨率低,低层特征分辨率高但语义信息弱。通过上采样和逐元素相加,每一层都能同时获得高分辨率和强语义。以ResNet-50为例,通常取C2到C5四个阶段的特征,再经过1x1卷积统一通道数,生成P2到P5,有些实现还会在P5基础上做降采样得到P6、P7。
拥挤路段的车辆,尤其是远处排队车辆,尺寸可能只有十几像素,这些目标的信息大量存在于P2或P3层。如果像早期YOLO那样只在最高层预测,这些小目标基本上没有活路。FPN让不同尺度的目标都能在最合适的特征层被检测,配合每层独立的回归范围,可以缓解尺度跨度带来的压力。
我在训练时重点关注了FPN相邻层之间的channel对齐。一般统一到256或512通道,但通道数并不是越大越好。通道太多会让计算量明显上升,而拥挤场景真正瓶颈往往不是通道表达力,而是遮挡样本的歧义性。还有一个容易被忽略的细节:FPN融合时上采样层如果直接用最近邻插值而不是双线性插值,精度会有所下降,我在Caffe里改成双线性后,小目标AP涨了一个点左右。
2.3 NASHead与GN-Head的实际理解
NASHead是这个项目里最有“算法含量”的部分,但理解它不需要把架构搜索的数学细节全搞清楚。NAS搜索空间里主要包含两类选择:一是检测头内部卷积层的堆叠方式,比如多少层、每层用什么kernel size、是否引入残差连接;二是特征金字塔层之间的连接模式,比如某一层特征是否要额外融合来自更高层的信息。
从实际结果看,NAS找出来的head往往比人工堆叠的结构更“反直觉”。比如我用的这份搜索结果中,head里存在多个不同尺寸卷积核的并联结构,有点类似Inception的思路,但分支配置完全不同。它没有遵循传统那种“前几层3x3、后几层1x1”的固定套路,而是把不同感受野的分支组合在一起,这对于同时捕捉车身边缘和车窗细节很有帮助。
GN-Head则是训练稳定性的关键。普通BN在小batch场景下会出问题:如果训练时每张卡只能塞下2到4张图,统计量抖动非常大,模型很容易出现loss震荡甚至发散。GN(Group Normalization)不依赖batch维度的统计量,而是把每个样本的通道分成若干组,在组内做归一化。这样无论batch多大,归一化结果都相对稳定。
Group数的选择有讲究。常用做法是固定group数为32,但当通道数较小时,32组会导致每组只有几个通道,统计量太少。我在检测头里用的通道数一般是256,除以8或16组比较合理,这样每组有16或32个通道,统计量足够稳定。GN层初始化时gamma一般设为1,beta设为0,但在某些实现里会把最后一个GN的gamma设为0,相当于让网络自己决定是否开始激活这一分支,这个细节在加载预训练参数时要格外注意。
2.4 匹配、回归与损失设计
NAS-FCOS属于无锚框检测器,它没有显式锚框,而是把每个正样本位置映射回原图的一个点,然后根据这个点落在哪个目标框内来决定正负。关键是如何定义“正样本”。FCOS的匹配方式比较简洁:对每个位置,如果它落在某个目标的ground truth框内,并且这个目标在当前特征层对应的回归范围内,就把它当作正样本。尺度不同的目标会在不同层被匹配到,这就是FPN分层预测的配套逻辑。
Centerness分支是FCOS家族的重要设计。它计算的是当前检测位置到目标中心的归一化距离,中心点越近,centerness越接近1。训练时这个分支用二值交叉熵监督,推理时把它乘到分类得分上。它做的事情看起来小,实际对抑制低质量检测框非常有效。拥挤路段上经常出现一个目标被相邻目标边界覆盖的情况,那些位于目标边缘的预测框虽然也能框住一部分车身,但centerness很低,乘上这个权重之后就会被过滤掉。
损失函数方面,分类分支采用Focal Loss来处理背景远多于车辆的正负样本不平衡问题。回归分支我用的是GIoU Loss而不是纯IoU Loss,因为纯IoU Loss在预测框与真值完全不重叠时梯度为零,模型学不到东西,而GIoU在不重叠时依然能提供一个下降方向的梯度。拥挤场景下初始预测框很多都和真值不重叠,用GIoU能让训练更顺滑。
3. 训练准备与完整参数记录
3.1 数据从采集到模型输入的完整链路
训练数据是这类项目的隐形决定因素。拥挤路段的车辆检测和常规目标检测数据集有一个显著区别:视频连续帧之间存在大量重复。如果直接拿连续帧训练,模型会对同一辆车的多个类似视角过拟合,泛化效果反而变差。
我采取的做法是每隔固定帧数抽一帧,保证相邻抽帧之间车辆位置有明显变化。具体间隔根据视频帧率调整,通常25fps的视频取每10帧一帧,这样既保留足够样本,又避免太多相似帧。抽完帧后做清洗,把模糊、过度曝光、目标被完全遮盖的帧丢弃。
标注环节比想象中费功夫。拥挤路段上大量车辆是互相遮挡的,标注时需要遵循一个原则:只要目标可见部分超过一定比例,比如30%,就标注完整车身框,而不是只标可见部分。这样做的好处是让模型学习车身完整范围,配合后面的centerness分支去处理遮挡。如果只标注可见部分,模型会学到一堆不完整的目标框,推理时输出的框也容易缺边少角。
另外,忽略区域(ignore region)的标注不能省。有些区域比如树荫下根本无法辨认的车辆、摄像头边缘的残影,如果强行标注或默认为背景,都会给训练注入噪声。把这类区域标为忽略区,让模型在这些区域不受loss约束,是提升精度的成本最低的招。
标注完成之后是格式转换。为了喂给Caffe的检测流程,我准备的是VOC风格的数据结构,每张图对应一个XML文件,再通过脚本生成训练列表。图像尺寸没有统一resize,而是记录原始尺寸,在训练时做随机resize,这样能保留更多真实尺度信息。
3.2 Caffe下的训练配置一步一步来
Caffe框架的训练配置以prototxt文件为主,看起来直观,但很多参数容易搞错。下面是我在实验中稳定下来的一个配置文件核心内容,可以直接作为参考。注意这里只贴关键部分,实际文件里还有数据层、锚框参数、输出层等细节需要按自己的数据调整。
layer { name: "data" type: "Data" top: "data" top: "label" data_param { source: "train_lmdb" batch_size: 8 backend: LMDB } transform_param { mean_value: 104.0 mean_value: 117.0 mean_value: 123.0 mirror: true } }solver配置
net: "train.prototxt" test_iter: 500 test_interval: 2000 base_lr: 0.02 lr_policy: "cosine" gamma: 0.0001 power: 0.9 display: 50 max_iter: 120000 momentum: 0.9 weight_decay: 0.0005 solver_mode: GPU这里有几个参数值得展开细说。
batch size是8,但实际训练时如果输入分辨率较高,比如短边800、长边1333,单卡可能放不下8张图。GN-Head的优势在这里体现出来了,即使把batch size降到4甚至2,GN也不会像BN那样剧烈抖动。Caffe的solver只配置了一个全局学习率,但不同模块我建议在prototxt里单独设置lr_mult。骨干网络前三层的Stem模块和FPN的倍数可以设低一点,比如lr_mult为0.1,而检测头和回归头的lr_mult为1.0,这样可以在训练初期防止骨干特征被破坏。
Base_lr我用了0.02这个稍大的值,配合cosine衰减。但要注意,如果不做warmup,这么大的学习率很容易在第一个iteration就发散。我另写了一个脚本,前500次迭代把学习率从0.002线性升高到0.02,这个warmup过程对稳定收敛帮助很大。weight_decay选了0.0005,因为GN层的参数相对BN无需太多正则化,但骨干部分还是按传统值来。
还有一个和Caffe强相关的坑是数据读取线程。Caffe的Data层默认几个prefetch线程,如果磁盘读取速度跟不上,训练会出现间歇性“等待”状态,GPU利用率忽高忽低。建议把queue容量调大一些,并确保训练数据是LMDB格式,而不是每次迭代重新读取图片文件。
3.3 评估与结果可视化
训练阶段的评估不能只盯着一个mAP。拥挤场景下我同时关注三个指标:AP50、AP75和小目标召回率。AP50反映的是“框个大概”的能力,AP75则更能体现定位精度,而对拥挤路段来说,小目标召回率直接决定项目能不能用。
实际验证时,我会把密集车流区域单独切成子集来统计召回率,而不是看全图的平均结果。全图平均指标很容易被大量简单场景稀释掉,导致你以为模型还行,真正到拥堵路段就露馅。
可视化方面,我除了画常规的PR曲线外,还会把NMS之前的原始输出拉出来查看。这一步非常管用,拥挤场景里有些目标是漏检还是被NMS抑制掉,在最终结果图里根本看不出来。把NMS前的高分框和NMS后的结果放在一起对比,才能定位问题出在分类头还是NMS策略。
如果推理时发现密集区域出现大量低置信度的重复框,核心问题往往是训练时centerness分支拟合不够好,或者NMS阈值设得过大。反过来,如果稀疏区域都出现漏检,那大概率是正样本匹配策略或回归范围设置有误。
4. 实操中踩到的坑与排查经验
4.1 训练发散:先查BN和通信库,再查数据
第一个坑就是训练中途loss突然变成NaN。发生这种情况时,我第一时间去查了Caffe的BatchNorm层设置。Caffe的BN层在训练和测试阶段需要切换use_global_stats,如果配置文件中该参数被错误设置为true,训练过程会直接使用未更新的全局统计量,很容易导致梯度爆炸。
排除掉BN之后,还要检查多卡通信。如果采用的是数据并行训练,梯度同步过程中出现NaN,经常是通信库版本不一致或梯度压缩导致的。一个比较直接的排查方法是将单卡训练和双卡训练各跑一小段,对比每一步loss曲线,如果单卡正常而双卡发散,优先检查梯度同步环节。
数据侧也不能放过。某些标注框可能出现坐标越界或宽高为负的情况,这类异常样本在常规迭代中不一定报错,但会悄悄污染梯度。我在训练前写了一个数据校验脚本,会把标注框超出图像边界的样本全部挑出来,要么裁剪要么删除。这样处理后,训练稳定度提升非常明显。
最后提一个与GN相关的细节。GN虽然不依赖batch统计量,但对初始参数很敏感。如果GN层的gamma初始化为0,对应的通道在初期是关停的,这本身没问题,但如果FPN层之后的GN也用了这一招,会出现某些特征层在前期完全无梯度流通的现象,表现为loss长时间不下降。排查方法是打印每个GN层输出的标准差,确认不是全零。
4.2 拥挤场景NMS不够用怎么办
用过FCOS类检测器的人应该都有一个共同体验:NMS是最大的瓶颈。拥挤场景里,同一辆车的多个预测框可能高度重合,同时不同车辆的预测框也可能因为遮挡而部分重合。传统贪心NMS按得分排序后抑制重叠框,换个角度说就是拿全局IoU做一刀切决策,在密集场景里经常出现误杀。
我试过方案:先把NMS阈值从常用0.5提高到0.6,直观结果是召回率上去了,但误检也跟着涨。之后尝试了Soft-NMS,用线性或高斯衰减代替直接置零,效果比普通NMS好了不少,密集区域漏检减少得挺明显。如果再进一步,可以用Matrix NMS这类并行实现,效果和Soft-NMS接近,但推理时能充分利用GPU并行能力。
实践中我得到的结论是:拥挤场景不能只靠调NMS阈值解决问题。最终保留的流程是,把分类得分和centerness相乘后作为排序得分,再配合Soft-NMS处理。这样处理后,车辆互相半遮挡的情况明显改善,而且因为centerness得分对边缘低质量框有抑制作用,重复框数量也少了很多。如果你在复现时发现密集区域还是乱成一团,先从输出得分设计上找原因,不要一上来就猛降NMS阈值。
4.3 模型转换与部署时的精度偏差
训练搞定之后,部署环节才是真正“渡劫”的开始。Caffe侧训练好的模型,要转到推理引擎或加速模块里,通常会遇到三类问题:算子不兼容、精度下降、图优化后行为不一致。
算子不兼容主要出现在GN层和NASHead里那些不常见结构上。有些推理引擎对GN层支持不全,最简单的办法是把它拆成若干个基础操作,比如分组卷积加标准化,但这会让计算图变大,推理速度受影响。更稳妥的做法是直接将训练好的GN参数融合进前面卷积层的偏置里,前提是你在部署时能保证输入分布和训练时一致。这个融合操作能省掉不少算子兼容的麻烦。
精度下降最常见的原因是通道数和宽高的对齐问题。Caffe模型在训练时可能接受任意输入尺寸,但部署时为了速度会把输入固定到某个较小尺寸,比如从800x1333降到640x640。这里等一下,尺寸变化不是简单的分辨率缩放,它改变了目标在特征图上的尺度分布,模型对尺度很敏感时,AP会明显下降。
所以部署前的精度验证不能只测一两个场景,至少要在训练集的一个子集和几个典型拥堵路段上分别跑一遍,对比训练时的评估指标。如果小目标AP掉得特别厉害,先尝试保持输入短边不变,削减长边;再做量化。在拥挤路段这种小目标密集的场景里,INT8量化带来的精度损失普遍比常规场景更严重,建议优先尝试FP16或保持FP32,只在压缩空间极度紧张时才考虑低比特量化。
4.4 显存与速度调优
训练时最容易碰到的就是显存不足。R50加上FPN、NASHead,输入分辨率一旦上去,显存占用非常可观。我试过在单卡上训练1024x1024输入,batch size只能设2,这时GN的优势就非常明显,因为GN不依赖batch统计量,即使batch为1也能正常训练,只是梯度会有些噪声。
要想在不牺牲太多精度的情况下提升训练吞吐,可以尝试更激进的裁剪策略。比如输入短边从800降到640,AP50一般只掉零点几个点,小目标AP可能会掉不少,但训练速度能提升近一倍。如果是要做快速验证,这个操作非常划算。另外也可以在训练末期冻结骨干网络,只训练检测头和FPN部分,这样能省一部分显存,同时因为骨干已经比较稳定,最终精度基本不受影响。
推理侧的速度优化则是另一个故事。NASHead搜索出来的结构通常有点“花”,分支多、并行多,直接按原样推理会很慢。我在实践中做了一部分结构简化:把3x3并联分支改成串联结构(如果训练时精度损失很小),或者把某些分支的通道数裁剪掉一半。这类简化最好放在训练之前决定,而不是训练完再硬改,否则权重迁移会非常麻烦。
如果部署平台支持半精度推理,建议先用半精度跑一遍基线。很多编译器优化的收益都建立在半精度基础上,Caffe下的FP32模型切换成FP16后速度基本能翻倍,而且在小目标场景下精度损失通常比INT8小得多。
最后再分享一点实际体会
这个项目做到后面,我逐渐意识到一个事实:NAS-FCOS这类方案,真正难的不是复现结构,而是让它在自己的数据上稳定运作。结构是公开的,搜索是一次性的,但GN的group数、FPN的通道数、NMS的处理方式、数据标注的细节,这些才是决定最终精度的东西。“_1”这个版本号对我来说是第一个能稳定输出高置信度结果的版本,但距离完整解决拥挤路段的检测问题还差不少。
如果非要给三条实际建议,我会这么说。第一,如果你在自己数据集上复现NAS-FCOS,先把数据标注和清洗做扎实,这一步至少影响5个点以上的AP。第二,GN的group数和初始化方法务必与训练结构一致,尤其是加载别人预训练模型时,不要想当然。第三,NMS和centerness的配合才是密集场景表现的核心,与其调各种花哨的后处理,不如先把这两者调对。后续如果继续做“_2”版本,我可能会尝试把输入分辨率策略改成动态变化,或者引入一些上下文信息辅助判断严重拥堵区域的车辆目标。这些就留待下一次迭代再展开了。