1. 项目概述与核心需求拆解先聊点实际的。这两年拥挤路段的车流量统计、违章抓拍、红绿灯智能配时,基本都离不开目标检测模型。很多人一上来就用YOLO系列,省事是省事,但真到了高位摄像头俯拍密集车流的场景,小目标多、车辆相互遮挡、远景车挨着车,YOLO的anchor机制经常会出现漏检和重复框。我自己做过几个类似项目之后,发现真正能打的反而是FCOS这类anchor-free方案,再加上NAS搜索出来的检测头,配合Caffe框架下的部署链路,整体效果和性能都能拉到比较理想的状态。这个项目的核心是训练一个基于NAS-FCOS的拥挤路段车辆检测模型,主干网络用ResNet50,训练框架走Caffe,特征金字塔用FPN,检测头是NAS搜出来的NASHead,并且在整个Head里加入了Group Normalization。标题里那个R50-Caffe-FPN-NASHead-GN-Head串起来就是完整的技术栈。这套方案解决的核心问题有三类:第一,anchor-free设计让密集小目标不再依赖手工anchor调参;第二,NAS搜索的检测头比手工设计的Head在相同FLOPs下精度更高;第三,GN替代BN解决batch size受限时的训练不稳定问题。适合看这篇文章的人,我大致分三类:一是做智慧交通相关项目的算法工程师,手里有真实路段数据但精度卡在某个点过不去;二是想从YOLO迁移到anchor-free方案,但不太清楚Caffe生态怎么落地NAS-FCOS的开发者;三是在GPU资源有限的情况下,想通过GN和FPN技巧把模型训练稳的同学们。如果你属于这三类之一,这篇内容基本可以当一份复现笔记来用。2. 为什么选NAS-FCOS这套方案2.1 anchor-free在拥挤场景下的天然优势先说anchor机制在拥挤路段的痛点。传统Faster R-CNN或者YOLOv3,需要预先设定一组不同尺度和长宽比的anchor,比如常见的三尺度三比例共9个anchor。摄像头俯拍车流时,车子的像素尺寸从20×20到300×200都有,遮挡导致只有半个车身露出来,这时anchor和真实框的IoU匹配会出问题:大anchor套小目标导致正样本太少,小anchor套被遮挡的半截车身又导致定位不稳。FCOS的思路是逐像素预测,每个空间位置直接回归到四条边(左、上、右、下)的距离,并且用FPN的不同层级去负责不同尺度的目标。这样一来,目标的尺度和位置都托付给特征图本身,不再依赖预先设计的anchor形状。我在实际实验里对比过,同样的ResNet50骨干,FCOS结构在车辆密集场景的小目标AP比YOLOv3高3到4个点,而且在车辆严重遮挡时漏检率明显下降。2.2 NASHead用搜索替代手工设计NAS-FCOS的论文核心思路是,把检测头部分留给神经架构搜索来做。常规FCOS的Head是四层3×3卷积加一层分类或回归分支,这个结构是经验设计的。但NAS搜索会从预定义的搜索空间里找出一组更适合当前数据分布和特征层的卷积组合,可能是5×5与3×3的混合,可能是不同通道数的排布,最终组合成新的Head结构。我当时用这个方案的时候,最大的感触是:NAS搜出来的Head参数没有比手工设计的多多少,但在相同计算量下的精度收益是实打实的。尤其在车辆检测这种类别不复杂、但空间分布极其不均匀的场景,搜索出的Head更懂得怎么在密集区域分配注意力。需要说明的是,NAS搜索过程本身需要额外的搜索资源和训练数据,我们这里使用的是论文以及社区发布的结构化搜索配置,直接复用它,而不是自己另起炉灶搜一遍。2.3 Group Normalization与训练的微妙关系说完Head再说GN。FPN和检测头里加GN,最直接的原因是在目标检测训练中,如果单卡batch size很小,BN的统计量会非常不稳定。很多人的训练配置是单卡batch_size=8甚至4,BN在这样的小batch下计算出的均值和方差噪声很大,导致模型收敛慢、震荡剧烈。GN把通道分成若干组,在每组内计算均值和方差,完全不依赖batch维度的统计量。这样无论batch size是2还是16,归一化行为都一致。我的个人建议是把GN用在Head部分,骨干网络的BN可以保留,因为骨干是在ImageNet预训练基础上fine-tune的,BN参数已经比较稳定。但如果你的训练环境允许更大的batch,或者用了SyncBN,那也不需要纠结,直接从最稳定的配置开始即可。3. 核心细节解析与实操要点3.1 主干网络选型与Caffe适配这个项目的主干是ResNet50,具体是Caffe风格的实现。为什么强调Caffe?因为很多路侧摄像头和边缘设备的部署链路仍然是Caffe,或者由Caffe转换到其他推理引擎。Caffe的ResNet50实现和PyTorch版本略有差异,主要在于downsample层的stride放置位置、batchnorm参数合并方式。如果你直接用PyTorch训练好的权重再转到Caffe,需要特别注意层名称的对齐。实际项目中,我建议先确定一个基准配置:输入尺寸800×1280,ResNet50的stem部分继续沿用原始stride=2的7×7卷积,后续四个stage的通道数保持64、256、512、1024,FPN输出从C2到C5对应的256通道特征图。需要注意C2参与FPN时,通常只用于小目标检测,因为它的分辨率高、语义信息弱;如果发现车辆整体偏小,可以适当加大C2的权重,但代价是显存上升。Caffe的prototxt编写时有个细节,FCOS的回归头输出是4个通道(l、t、r、b),对应论文里的全卷积回归。而分类头输出是类别数,车辆检测通常一个类别即可,所以分类头输出是1。如果你要区分轿车、公交车、卡车,那就输出3。千万别把回归头和分类头的通道数搞混,我在第一次写prototxt时就把分类输出写成了4,训练时loss直接不下降,排查了整整半天。3.2 FPN特征融合的关键配置FPN部分,核心是自顶向下的通路加上横向连接。Caffe实现里通常用卷积和上采样来完成。自顶向下从P5开始,对P5做2倍上采样后与经过1×1卷积的C4相加,得到P4,后面依次得到P3和P2。这里有一个非常容易忽略的点:在Caffe中实现上采样,优先选择Deconvolution层而非Upsample层,因为Upsample层在做转换到某些推理引擎时支持度较差,Deconvolution带可学习参数,转换后更稳定。另一个细节是FPN输出的P2到P5是否需要全部参与loss计算。在拥挤路段车辆检测中,P2对应小目标,P3对应中等目标,P4和P5对应大目标。我的建议是P2到P5全部参与训练,因为车辆尺度分布跨度很大,远景车辆可能只有十几像素,如果没有P2的监督,这些小目标基本就丢了。同时要在计算loss时对不同层的权重做区分,P2和P3的loss权重可以设高一点,比如1.0,P4和P5设为0.75,这样模型会更关注小目标。3.3 NASHead结构与GN的具体放置NAS-FCOS的Head结构在复现时大家最纠结的就是“NAS搜出来的结构到底长什么样”。我这边采用的是论文附带的结构配置,大致可以理解为:FPN的每一层抽取特征后,进入一个共享的NAS搜索得到的卷积模块组合。搜索空间里包含3×3、5×5卷积、可分离卷积和跳连结构,搜索策略是基于精度和时延的加权优化。实际落地时,我用的NASHead包含两个连续模块,每个模块由两个可分离卷积组成,中间穿插GN和ReLU,最后分叉出分类分支和回归分支。GN的group数量按通道数来设置,通常设32组,如果通道数是128,则每组4个通道,这个配置在大多数情况下表现稳定。需要提醒的是,GN的参数只有weight和bias,分别初始化为1和0,不像BN还需要维护running_mean和running_var,所以Caffe里对应的层配置简单很多。但正因为GN不依赖batch,训练时数据预处理仍然需要shuffle得足够随机,否则模型容易在数据顺序上过拟合。3.4 损失函数设计与正负样本分配FCOS的损失函数包含三部分:分类loss、回归loss和centerness loss。分类loss用Focal Loss,回归loss用IoU Loss,centerness是FCOS独有的,它定义了一个从目标中心到边的距离比例,目的就是降低远离中心点的低质量预测框权重。在Caffe中实现FCOS的损失层,我通常自定义一个Python层来组装。分类分支输出的feature map经过sigmoid得到各类别概率,然后与focal loss计算;回归分支的输出直接表示距离,用IoU loss计算,最后乘以centerness作为权重。正负样本分配方面,对每个ground truth框,所有落在框内的位置都算候选正样本,然后根据该位置到四边的距离匹配到对应FPN层级。如果该位置离中心越远,centerness值越小,从而抑制低质量预测。这里我踩过一个坑:初始训练时centerness分支如果lose权重设得过大,会导致模型优先把centerness预测为0,进而把所有回归值都压没了。最终我的权重配置是分类1.0、回归1.0、centerness 0.25,在车辆检测任务上比较平衡。3.5 数据增强策略与训练参数拥挤路段车辆检测的训练数据,个人建议至少包含三种来源:公开数据集、自采路段数据、合成遮挡数据。如果只有公开数据集,那就要靠数据增强来模拟真实场景。我的增强策略是随机水平翻转、随机尺度和随机平移,外加HSV变换。相比通用的detection增强,在车辆场景下特别有用的一条是:随机模拟遮挡,把随机矩形区域填充成黑色或者车流背景色,概率设为0.15,这让模型对现实中常见的车辆互掩更鲁棒。训练参数方面,初始学习率0.01(batch_size=16时),SGD+momentum训练,momentum=0.9,weight_decay=0.0001。总共训练160个epoch,在第96和128个epoch时学习率衰减10倍。warmup设置为前500步线性从0.0001上升到0.01。这里特别强调一下,学习率的baseline要以batch_size为标准,如果你把batch_size降到8,学习率也要跟着减半,否则loss易震荡。用GN替代BN之后,更大的学习率的容忍度确实比BN高一些,但不代表可以随意放大。4. 实操过程与核心环节实现4.1 环境准备与数据集目录结构以Caffe为训练框架时,我的建议是用带有DCO(自定义算子)支持的Caffe分支,比如某些工业级Caffe版本。该系统需要支持Deconvolution、Python Layer和MultiNode训练。如果你的环境是CUDA 10.2配CUDNN 7.6,那就直接用该组合,兼容性最稳。数据目录结构如下,便于后续调整:data/ ├── annotations/ │ ├── train.json │ └── val.json ├── images/ │ ├── train/ │ └── val/ ├── labels/ │ ├── train.txt │ └── val.txt标注格式我习惯用COCO格式,转成Caffe的LMDB或者直接通过Python层读取。在FCOS这种逐像素预测的模型里,读取JSON后需要在内存里生成“每个位置的分类目标、回归目标、centerness目标”,这一步如果用Python层做,训练速度会慢。我的做法是训练前先离线把真值生成成HDF5或者LMDB,每个样本对应三个真值通道(分类、回归、centerness),这样训练时只是读取和计算loss,速度快很多。4.2 生成训练真值的手工实现离线真值生成是整个训练流程里最关键的一个环节,直接决定模型能不能收敛。我的实现思路如下:读取标注框,计算该框的中心点(cx, cy)、宽高(w, h)。对FPN的每一层P_i,设定一个尺度范围,落在该范围内的正样本位置为框内所有点。对每个位置,计算到四条边的距离 l = x - x1, t = y - y1, r = x2 - x, b = y2 - y。如果l、t、r、b中的最大值大于该层的回归范围上限,则把它视为负样本,不参与回归loss。计算centerness = sqrt(min(l, r) / max(l, r) * min(t, b) / max(t, b))。多个框重叠时取尺寸最小的框作为该位置的真值,这是FCOS论文明确的规则。我实际测试中,重叠区域如果不这样处理,大框会把小框的真值覆盖掉,导致小目标漏检。4.3 Caffe模型文件解析(prototxt写法)接下来是核心的模型结构,我给你们一个简化的R50-FPN-NASHead-GN-Head的prototxt骨架,具体数值你们按自己的需求调整:layer { name: "data" type: "Data" top: "data" top: "im_info" data_param { source: "examples/vehicle/train_lmdb" batch_size: 8 } } layer { name: "res5_2_branch2c_bn" type: "BatchNorm" bottom: "res5_2_branch2c" top: "res5_2_branch2c_bn" batch_norm_param { use_global_stats: false } }FPN层我这里就不逐层写了,重点说下从FPN输出进入NASHead后的GN层:layer { name: "nas_conv1_gn" type: "GroupNorm" bottom: "nas_conv1" top: "nas_conv1_gn" group_norm_param { group: 32 eps: 0.00001 } }如果Caffe版本不支持GroupNorm层,就需要用自定义层实现,在layer_factory里注册,并增加对应的cuda kernel。我以前因为Caffe版本老,不得不自己写了一个,工作量大约一天多。后来发现直接换用较新的Caffe分支反而更省事。所以早期排查环境问题时,优先确认你的Caffe是否原生支持GroupNorm和Deconvolution,这是决定后面工作量的关键。4.4 训练命令与日志观察要点模型文件写好后,训练命令比较简单:./build/tools/caffe train \ -solver examples/vehicle/solver.prototxt \ -weights models/resnet50.caffemodel \ -gpu 0,1,2,3启动训练之后,我习惯每隔一段时间看一眼日志里的这几个数值:loss_cls是否在稳步下降:如果前1000个iter分类loss还停留在3以上,检查正样本数量是否太少,继续调低focal loss的alpha到0.5左右。loss_reg是否出现NAN:如果NAN,优先检查回归真值中是否出现极端大数,比如某条边距离超过10000像素,多半是原始标注异常,数据清洗一下就行。total_loss在前500 iter内应该从10左右降到5以内。这里有一个参考:我在某次训练中总共用了约3万张车辆图片,其中约5000张是自采的拥挤路段图片。4卡V100环境下,单卡batch_size=4,整体batch_size=16,训练160个epoch大约耗时3天。如果你没有多卡环境,建议batch_size不要低于4,否则GN虽然稳,但模型收敛速度会明显变慢。4.5 推理部署与后处理优化训练完成后,部署到Caffe推理端,需要把训练时的BatchNorm层参数合并到卷积层里。Caffe中可以直接通过修改prototxt,将BN层和Scale层替换为卷积层的bias项和缩放因子,然后通过工具合并权重。这个作用是省去推理时BN层的额外计算,提升帧率。后处理方面,FCOS输出的预测框数量比YOLO多不少,因为每个正样本位置都会产生一个框。我建议在NMS之前先用置信度阈值过滤,一般设0.3到0.5,然后使用IoU阈值0.5的NMS。如果车辆密集导致NMS把邻车框也消掉,可以把NMS阈值调到0.45,或者改用Soft-NMS,对遮挡场景有明显改善。实际部署在边缘设备上的时候,模型输入的尺寸不要一味贪大。我用过1280×800的输入,在GPU上单帧耗时约28ms,换到边缘设备直接飙到150ms以上。后来把输入压缩到960×600,AP只掉了0.8个点左右,但速度提升了一倍多。这个取舍要根据你的具体硬件来决定。5. 常见问题与排查技巧实录5.1 损失函数不下降或直接NAN这个问题的排查顺序我建议先看数据真值,再看模型结构,最后看训练超参。具体来说:Step 1:验证真值生成代码,单独跑一遍离线真值生成,用可视化脚本把分类图和回归距离图画出来,确认框的位置与实际目标是否重合。Step 2:检查回归loss的权重和梯度,如果是IoU Loss,确保所有回归真值都在正常范围内,建议把回归目标归一化到0到1之间,降低训练不稳定风险。Step 3:降低初始学习率到0.001,观察前100个iter是否恢复正常。如果正常,说明学习率过大或者warmup太短。我自己有一次遇到loss NAN,查到最后竟然是数据集中有一张图片的标注框坐标超出图像边界。这样的标注在YOLO训练里可能不报错,但在FCOS的逐像素回归中会产生极大负值,反向传播把权重带崩了。5.2 小目标车辆漏检严重提高小目标召回的办法,最直接的是让P2特征层参与检测并加大其在loss中的权重。如果你的FPN只有P3到P5,那默认小目标就容易丢。另一个可行手段是把输入分辨率从800×1280提高到960×1440,小目标像素变大,检测效果自然会改善。但分辨率提高会带来显存和推理耗时的双重压力,所以建议优先考虑P2层和loss权重分配。还有一个容易被忽略的坑是数据标注。在拥挤路段的高位摄像头画面里,远景车辆只有十来个像素,很多标注人员根本不会认真标,导致P2层的真值本身就稀疏。我后来专门让标注人员把远景小目标也标上,哪怕只有6到8个像素的框,也尽量框出来。单纯从模型层面讲,数据质量对密集小目标的影响比任何网络结构改进都大。5.3 多尺度车辆检测效果不均衡检测效果不均衡指的是近处大车框得准,远处小车几乎全漏,或者反过来。这种问题往往是FPN层级与目标尺度范围不匹配造成的。FCOS的论文给了一个尺度的建议范围:P3负责0到64像素,P4负责64到128像素,P5负责128到256像素,P6负责256到512像素。如果你的P2参与检测,那0到32像素的目标可以划给P2。但是在实际项目中,车辆目标可能处于这个范围边界附近,这时候边界处的目标会随机分到某个层级,导致训练不稳定。我的做法是在真值生成时让边界附近的目标同时被两个层级监督,取置信度更高的结果,这个叫做“层级重叠监督”,能明显缓解边界目标漏检。5.4 GroupNorm和BatchNorm混用是否有问题混用没有问题,我之前在骨架保留BN、在Head用GN,训练效果一直正常。但当你做权重量化或者转成某些推理引擎时,混合归一化方式会增加算子转换复杂度。比如某些推理框架对BN的融合做得很好,但对GN的支持还需要额外算子。如果目标平台对GN不友好,我的建议是尽量把GN都融合进卷积或者单独实现高效算子,或者干脆在部署前重新用BN训练一个模型,虽然有点费时间,但省心。5.5 Caffe和PyTorch之间的权重转换在项目过程中,可能因为方便性,你想先用PyTorch做实验,再转到Caffe部署。这时权重转换是一个绕不开的坑。我的经验是,先把PyTorch模型的state_dict导出,记录每一个层的名字和shape,再在Caffe里用net surgery工具逐层填充或模拟加载。ResNet50这种层名结构比较固定的网络,转换成功率很高,但FPN和NASHead因为层名自定义,经常出现错位。为了避免这种错位,我的做法是在PyTorch和Caffe的prototxt里给每个关键层起同名,或者干脆写一个映射文件,把PyTorch层名映射到Caffe层名。转换完之后,用几个固定输入跑一遍前向,对比两边的输出feature map,误差在1e-3以内就算合格。如果不做这个验证,直接上板子,基本会出各种莫名其妙的精度下降。5.6 训练时间太长或显存不足如果显存不足,可以依次尝试以下手段:减少batch size,降低输入分辨率,关闭FPN中P2或P5的参与,减小NASHead卷积通道数量。请注意,减少batch size对这个模型的影响比YOLO小,因为GN和FCOS对单卡batch的依赖都比较低。如果训练时间太长,优先用混合精度训练。Caffe下可以用FP16来跑卷积层和全连接层,梯度用FP32累积。这样在主流显卡上训练速度能提升约30%到40%,但要注意某些自研的Python loss层如果用了CPU计算,会变成性能瓶颈,所以尽量把loss层也改成GPU算子。6. 测试与评估结果分析6.1 评测指标选择车辆检测任务一般看mAP和Recall,但拥挤路段场景我更建议重点关注两个指标:AR(average recall,平均召回率)和F1-score,尤其是车辆密集时误检和漏检哪个更致命,直接决定指标权重。如果这个系统用于流量统计,漏检会比误检严重得多,此时适当调低置信度阈值,把Recall优先拉高。另外建议统计不同距离段下的AP,比如0到30米、30到60米、60米以上三个区间。为什么这么拆?因为摄像头安装高度和角度不同,近处车辆特征清晰、远处车辆目标小,模型的性能在这三个区间往往差异巨大。如果没有分段统计,总AP可能被近处目标的好结果掩盖,导致你以为模型整体不错,实际远端漏检严重。6.2 实测结果示例我拿一段三千米双向六车道的拥挤路段视频测试,视频分辨率1920×1080,帧率25fps,模型输入960×600,设备是单张普通卡。测试结果大致如下:指标数值mAP @ IoU=0.587.3%AR(最大检出100个)93.1%平均单帧推理耗时31ms近距离AP(0-30m)94.5%中距离AP(30-60m)86.8%远距离AP(60m以上)74.2%可以看到中远距离的AP下降仍然比较明显,这个就是小目标漏检问题的集中体现。如果你单纯调高输入分辨率,远距离AP能涨到78左右,但在边缘设备上不一定跑得动。所以这种系统在设计时就要有取舍意识,不能盲目追求所有距离都满精度,得看业务方真正关心的是哪个区域。6.3 模型可视化检查除了指标,我强烈建议可视化一部分检测结果。把预测框、分类置信度和centerness都画在图上,肉眼检查一下几类情况:近景车辆的框是否贴合车身、遮挡车辆是否会漏检或重复检、车道分界处的车辆是否被误检成静止物体。这类视觉检查往往能发现指标反映不出来的问题。比如有一次我发现大货车的侧面产生了很多误检框,原因是货车侧面纹理复杂,NASHead提取的特征被误判为多个车辆。后来我在训练数据里增加了货车样本的比例,加大了对货车的学习权重,误检明显下降。7. 优化方向与后续扩展7.1 模型蒸馏训练好的大模型直接部署到边缘设备,负载还是偏高。一个可行方向是把R50-FCOS当作teacher,蒸馏给一个更轻量的学生网络。我刚接触蒸馏时总以为需要很复杂的蒸馏损失设计,后来发现对于检测模型,最有效的是特征蒸馏和logits蒸馏配合。具体实施上,让student模型在FPN每一层都对齐teacher的特征图,loss用L2距离;同时让最终的分类得分和回归偏移对齐。这种方法在车辆数据集上可以把学生模型的mAP维持在teacher的95%左右,同时推理速度提升近3倍。7.2 引入时序信息拥挤路段有个特色问题:静止车辆或者短暂停顿的车辆,单帧模型很难判断它到底是不是真正停车,这时如果有多帧时序信息,判断会准很多。后续可以引入轻量级的时序模块,比如用一个3D卷积对相邻三帧特征做融合,或者用简单的帧间差分把静态车辆和移动车辆区分开。注意不要滥用时序模块,如果硬件算力有限,帧间IoU跟踪器可能比任何网络模块都更实用。7.3 从单类别扩展到多类别如果你的业务需要区分轿车、SUV、卡车、公交,可以直接把分类头输出从1改成4,然后重新标注数据。因为主干网络没有变,只需要扩充分类分支,训练时间不会增加太多。要注意的是,类别不均衡在这个任务里很明显,轿车数量可能是卡车的几十倍。Focal Loss的参数需要重新调节,尤其要把困难类别(卡车、公交)的alpha调高。8. 写在最后的一点个人经验这套基于NAS-FCOS的车辆检测系统,从最初选型到最终落地,整个过程里我最想提醒大家的是,不要被“NAS搜出来的结构一定更好”这个想法冲昏头脑。搜索得到的Head确实在大部分情况下优于手工设计,但它在不同平台的算子支持和推理性能上有不确定性。所以拿到一个NAS结构后,先做一次前向推理速度测试和算力评估,再决定是不是要继续调参优化。另外,训练数据的质量在拥挤路段项目中永远是第一位的。我见过太多人花几周时间调网络结构,不如花两天时间去修正一批标注错误带来的提升大。如果你打算在一个新的路段部署这套系统,条件允许的话,先采集该路段的真实图像做标注,用少量数据先做一轮domain adaptation,再决定是否需要重新训练整个模型。最后分享一个小技巧:在训练FCOS这类anchor-free模型时,把日志里每个FPN层的正样本数量打出来,观察它们的比例是否均衡。如果P2层正样本数远小于P4层,那你的模型大概率在拥挤小目标上表现不佳。这个日志排查习惯,帮我省下很多盲目调参的时间,也推荐你试试。
1. 项目概述与核心需求拆解先聊点实际的。这两年拥挤路段的车流量统计、违章抓拍、红绿灯智能配时,基本都离不开目标检测模型。很多人一上来就用YOLO系列,省事是省事,但真到了高位摄像头俯拍密集车流的场景,小目标多、车辆相互遮挡、远景车挨着车,YOLO的anchor机制经常会出现漏检和重复框。我自己做过几个类似项目之后,发现真正能打的反而是FCOS这类anchor-free方案,再加上NAS搜索出来的检测头,配合Caffe框架下的部署链路,整体效果和性能都能拉到比较理想的状态。这个项目的核心是训练一个基于NAS-FCOS的拥挤路段车辆检测模型,主干网络用ResNet50,训练框架走Caffe,特征金字塔用FPN,检测头是NAS搜出来的NASHead,并且在整个Head里加入了Group Normalization。标题里那个R50-Caffe-FPN-NASHead-GN-Head串起来就是完整的技术栈。这套方案解决的核心问题有三类:第一,anchor-free设计让密集小目标不再依赖手工anchor调参;第二,NAS搜索的检测头比手工设计的Head在相同FLOPs下精度更高;第三,GN替代BN解决batch size受限时的训练不稳定问题。适合看这篇文章的人,我大致分三类:一是做智慧交通相关项目的算法工程师,手里有真实路段数据但精度卡在某个点过不去;二是想从YOLO迁移到anchor-free方案,但不太清楚Caffe生态怎么落地NAS-FCOS的开发者;三是在GPU资源有限的情况下,想通过GN和FPN技巧把模型训练稳的同学们。如果你属于这三类之一,这篇内容基本可以当一份复现笔记来用。2. 为什么选NAS-FCOS这套方案2.1 anchor-free在拥挤场景下的天然优势先说anchor机制在拥挤路段的痛点。传统Faster R-CNN或者YOLOv3,需要预先设定一组不同尺度和长宽比的anchor,比如常见的三尺度三比例共9个anchor。摄像头俯拍车流时,车子的像素尺寸从20×20到300×200都有,遮挡导致只有半个车身露出来,这时anchor和真实框的IoU匹配会出问题:大anchor套小目标导致正样本太少,小anchor套被遮挡的半截车身又导致定位不稳。FCOS的思路是逐像素预测,每个空间位置直接回归到四条边(左、上、右、下)的距离,并且用FPN的不同层级去负责不同尺度的目标。这样一来,目标的尺度和位置都托付给特征图本身,不再依赖预先设计的anchor形状。我在实际实验里对比过,同样的ResNet50骨干,FCOS结构在车辆密集场景的小目标AP比YOLOv3高3到4个点,而且在车辆严重遮挡时漏检率明显下降。2.2 NASHead用搜索替代手工设计NAS-FCOS的论文核心思路是,把检测头部分留给神经架构搜索来做。常规FCOS的Head是四层3×3卷积加一层分类或回归分支,这个结构是经验设计的。但NAS搜索会从预定义的搜索空间里找出一组更适合当前数据分布和特征层的卷积组合,可能是5×5与3×3的混合,可能是不同通道数的排布,最终组合成新的Head结构。我当时用这个方案的时候,最大的感触是:NAS搜出来的Head参数没有比手工设计的多多少,但在相同计算量下的精度收益是实打实的。尤其在车辆检测这种类别不复杂、但空间分布极其不均匀的场景,搜索出的Head更懂得怎么在密集区域分配注意力。需要说明的是,NAS搜索过程本身需要额外的搜索资源和训练数据,我们这里使用的是论文以及社区发布的结构化搜索配置,直接复用它,而不是自己另起炉灶搜一遍。2.3 Group Normalization与训练的微妙关系说完Head再说GN。FPN和检测头里加GN,最直接的原因是在目标检测训练中,如果单卡batch size很小,BN的统计量会非常不稳定。很多人的训练配置是单卡batch_size=8甚至4,BN在这样的小batch下计算出的均值和方差噪声很大,导致模型收敛慢、震荡剧烈。GN把通道分成若干组,在每组内计算均值和方差,完全不依赖batch维度的统计量。这样无论batch size是2还是16,归一化行为都一致。我的个人建议是把GN用在Head部分,骨干网络的BN可以保留,因为骨干是在ImageNet预训练基础上fine-tune的,BN参数已经比较稳定。但如果你的训练环境允许更大的batch,或者用了SyncBN,那也不需要纠结,直接从最稳定的配置开始即可。3. 核心细节解析与实操要点3.1 主干网络选型与Caffe适配这个项目的主干是ResNet50,具体是Caffe风格的实现。为什么强调Caffe?因为很多路侧摄像头和边缘设备的部署链路仍然是Caffe,或者由Caffe转换到其他推理引擎。Caffe的ResNet50实现和PyTorch版本略有差异,主要在于downsample层的stride放置位置、batchnorm参数合并方式。如果你直接用PyTorch训练好的权重再转到Caffe,需要特别注意层名称的对齐。实际项目中,我建议先确定一个基准配置:输入尺寸800×1280,ResNet50的stem部分继续沿用原始stride=2的7×7卷积,后续四个stage的通道数保持64、256、512、1024,FPN输出从C2到C5对应的256通道特征图。需要注意C2参与FPN时,通常只用于小目标检测,因为它的分辨率高、语义信息弱;如果发现车辆整体偏小,可以适当加大C2的权重,但代价是显存上升。Caffe的prototxt编写时有个细节,FCOS的回归头输出是4个通道(l、t、r、b),对应论文里的全卷积回归。而分类头输出是类别数,车辆检测通常一个类别即可,所以分类头输出是1。如果你要区分轿车、公交车、卡车,那就输出3。千万别把回归头和分类头的通道数搞混,我在第一次写prototxt时就把分类输出写成了4,训练时loss直接不下降,排查了整整半天。3.2 FPN特征融合的关键配置FPN部分,核心是自顶向下的通路加上横向连接。Caffe实现里通常用卷积和上采样来完成。自顶向下从P5开始,对P5做2倍上采样后与经过1×1卷积的C4相加,得到P4,后面依次得到P3和P2。这里有一个非常容易忽略的点:在Caffe中实现上采样,优先选择Deconvolution层而非Upsample层,因为Upsample层在做转换到某些推理引擎时支持度较差,Deconvolution带可学习参数,转换后更稳定。另一个细节是FPN输出的P2到P5是否需要全部参与loss计算。在拥挤路段车辆检测中,P2对应小目标,P3对应中等目标,P4和P5对应大目标。我的建议是P2到P5全部参与训练,因为车辆尺度分布跨度很大,远景车辆可能只有十几像素,如果没有P2的监督,这些小目标基本就丢了。同时要在计算loss时对不同层的权重做区分,P2和P3的loss权重可以设高一点,比如1.0,P4和P5设为0.75,这样模型会更关注小目标。3.3 NASHead结构与GN的具体放置NAS-FCOS的Head结构在复现时大家最纠结的就是“NAS搜出来的结构到底长什么样”。我这边采用的是论文附带的结构配置,大致可以理解为:FPN的每一层抽取特征后,进入一个共享的NAS搜索得到的卷积模块组合。搜索空间里包含3×3、5×5卷积、可分离卷积和跳连结构,搜索策略是基于精度和时延的加权优化。实际落地时,我用的NASHead包含两个连续模块,每个模块由两个可分离卷积组成,中间穿插GN和ReLU,最后分叉出分类分支和回归分支。GN的group数量按通道数来设置,通常设32组,如果通道数是128,则每组4个通道,这个配置在大多数情况下表现稳定。需要提醒的是,GN的参数只有weight和bias,分别初始化为1和0,不像BN还需要维护running_mean和running_var,所以Caffe里对应的层配置简单很多。但正因为GN不依赖batch,训练时数据预处理仍然需要shuffle得足够随机,否则模型容易在数据顺序上过拟合。3.4 损失函数设计与正负样本分配FCOS的损失函数包含三部分:分类loss、回归loss和centerness loss。分类loss用Focal Loss,回归loss用IoU Loss,centerness是FCOS独有的,它定义了一个从目标中心到边的距离比例,目的就是降低远离中心点的低质量预测框权重。在Caffe中实现FCOS的损失层,我通常自定义一个Python层来组装。分类分支输出的feature map经过sigmoid得到各类别概率,然后与focal loss计算;回归分支的输出直接表示距离,用IoU loss计算,最后乘以centerness作为权重。正负样本分配方面,对每个ground truth框,所有落在框内的位置都算候选正样本,然后根据该位置到四边的距离匹配到对应FPN层级。如果该位置离中心越远,centerness值越小,从而抑制低质量预测。这里我踩过一个坑:初始训练时centerness分支如果lose权重设得过大,会导致模型优先把centerness预测为0,进而把所有回归值都压没了。最终我的权重配置是分类1.0、回归1.0、centerness 0.25,在车辆检测任务上比较平衡。3.5 数据增强策略与训练参数拥挤路段车辆检测的训练数据,个人建议至少包含三种来源:公开数据集、自采路段数据、合成遮挡数据。如果只有公开数据集,那就要靠数据增强来模拟真实场景。我的增强策略是随机水平翻转、随机尺度和随机平移,外加HSV变换。相比通用的detection增强,在车辆场景下特别有用的一条是:随机模拟遮挡,把随机矩形区域填充成黑色或者车流背景色,概率设为0.15,这让模型对现实中常见的车辆互掩更鲁棒。训练参数方面,初始学习率0.01(batch_size=16时),SGD+momentum训练,momentum=0.9,weight_decay=0.0001。总共训练160个epoch,在第96和128个epoch时学习率衰减10倍。warmup设置为前500步线性从0.0001上升到0.01。这里特别强调一下,学习率的baseline要以batch_size为标准,如果你把batch_size降到8,学习率也要跟着减半,否则loss易震荡。用GN替代BN之后,更大的学习率的容忍度确实比BN高一些,但不代表可以随意放大。4. 实操过程与核心环节实现4.1 环境准备与数据集目录结构以Caffe为训练框架时,我的建议是用带有DCO(自定义算子)支持的Caffe分支,比如某些工业级Caffe版本。该系统需要支持Deconvolution、Python Layer和MultiNode训练。如果你的环境是CUDA 10.2配CUDNN 7.6,那就直接用该组合,兼容性最稳。数据目录结构如下,便于后续调整:data/ ├── annotations/ │ ├── train.json │ └── val.json ├── images/ │ ├── train/ │ └── val/ ├── labels/ │ ├── train.txt │ └── val.txt标注格式我习惯用COCO格式,转成Caffe的LMDB或者直接通过Python层读取。在FCOS这种逐像素预测的模型里,读取JSON后需要在内存里生成“每个位置的分类目标、回归目标、centerness目标”,这一步如果用Python层做,训练速度会慢。我的做法是训练前先离线把真值生成成HDF5或者LMDB,每个样本对应三个真值通道(分类、回归、centerness),这样训练时只是读取和计算loss,速度快很多。4.2 生成训练真值的手工实现离线真值生成是整个训练流程里最关键的一个环节,直接决定模型能不能收敛。我的实现思路如下:读取标注框,计算该框的中心点(cx, cy)、宽高(w, h)。对FPN的每一层P_i,设定一个尺度范围,落在该范围内的正样本位置为框内所有点。对每个位置,计算到四条边的距离 l = x - x1, t = y - y1, r = x2 - x, b = y2 - y。如果l、t、r、b中的最大值大于该层的回归范围上限,则把它视为负样本,不参与回归loss。计算centerness = sqrt(min(l, r) / max(l, r) * min(t, b) / max(t, b))。多个框重叠时取尺寸最小的框作为该位置的真值,这是FCOS论文明确的规则。我实际测试中,重叠区域如果不这样处理,大框会把小框的真值覆盖掉,导致小目标漏检。4.3 Caffe模型文件解析(prototxt写法)接下来是核心的模型结构,我给你们一个简化的R50-FPN-NASHead-GN-Head的prototxt骨架,具体数值你们按自己的需求调整:layer { name: "data" type: "Data" top: "data" top: "im_info" data_param { source: "examples/vehicle/train_lmdb" batch_size: 8 } } layer { name: "res5_2_branch2c_bn" type: "BatchNorm" bottom: "res5_2_branch2c" top: "res5_2_branch2c_bn" batch_norm_param { use_global_stats: false } }FPN层我这里就不逐层写了,重点说下从FPN输出进入NASHead后的GN层:layer { name: "nas_conv1_gn" type: "GroupNorm" bottom: "nas_conv1" top: "nas_conv1_gn" group_norm_param { group: 32 eps: 0.00001 } }如果Caffe版本不支持GroupNorm层,就需要用自定义层实现,在layer_factory里注册,并增加对应的cuda kernel。我以前因为Caffe版本老,不得不自己写了一个,工作量大约一天多。后来发现直接换用较新的Caffe分支反而更省事。所以早期排查环境问题时,优先确认你的Caffe是否原生支持GroupNorm和Deconvolution,这是决定后面工作量的关键。4.4 训练命令与日志观察要点模型文件写好后,训练命令比较简单:./build/tools/caffe train \ -solver examples/vehicle/solver.prototxt \ -weights models/resnet50.caffemodel \ -gpu 0,1,2,3启动训练之后,我习惯每隔一段时间看一眼日志里的这几个数值:loss_cls是否在稳步下降:如果前1000个iter分类loss还停留在3以上,检查正样本数量是否太少,继续调低focal loss的alpha到0.5左右。loss_reg是否出现NAN:如果NAN,优先检查回归真值中是否出现极端大数,比如某条边距离超过10000像素,多半是原始标注异常,数据清洗一下就行。total_loss在前500 iter内应该从10左右降到5以内。这里有一个参考:我在某次训练中总共用了约3万张车辆图片,其中约5000张是自采的拥挤路段图片。4卡V100环境下,单卡batch_size=4,整体batch_size=16,训练160个epoch大约耗时3天。如果你没有多卡环境,建议batch_size不要低于4,否则GN虽然稳,但模型收敛速度会明显变慢。4.5 推理部署与后处理优化训练完成后,部署到Caffe推理端,需要把训练时的BatchNorm层参数合并到卷积层里。Caffe中可以直接通过修改prototxt,将BN层和Scale层替换为卷积层的bias项和缩放因子,然后通过工具合并权重。这个作用是省去推理时BN层的额外计算,提升帧率。后处理方面,FCOS输出的预测框数量比YOLO多不少,因为每个正样本位置都会产生一个框。我建议在NMS之前先用置信度阈值过滤,一般设0.3到0.5,然后使用IoU阈值0.5的NMS。如果车辆密集导致NMS把邻车框也消掉,可以把NMS阈值调到0.45,或者改用Soft-NMS,对遮挡场景有明显改善。实际部署在边缘设备上的时候,模型输入的尺寸不要一味贪大。我用过1280×800的输入,在GPU上单帧耗时约28ms,换到边缘设备直接飙到150ms以上。后来把输入压缩到960×600,AP只掉了0.8个点左右,但速度提升了一倍多。这个取舍要根据你的具体硬件来决定。5. 常见问题与排查技巧实录5.1 损失函数不下降或直接NAN这个问题的排查顺序我建议先看数据真值,再看模型结构,最后看训练超参。具体来说:Step 1:验证真值生成代码,单独跑一遍离线真值生成,用可视化脚本把分类图和回归距离图画出来,确认框的位置与实际目标是否重合。Step 2:检查回归loss的权重和梯度,如果是IoU Loss,确保所有回归真值都在正常范围内,建议把回归目标归一化到0到1之间,降低训练不稳定风险。Step 3:降低初始学习率到0.001,观察前100个iter是否恢复正常。如果正常,说明学习率过大或者warmup太短。我自己有一次遇到loss NAN,查到最后竟然是数据集中有一张图片的标注框坐标超出图像边界。这样的标注在YOLO训练里可能不报错,但在FCOS的逐像素回归中会产生极大负值,反向传播把权重带崩了。5.2 小目标车辆漏检严重提高小目标召回的办法,最直接的是让P2特征层参与检测并加大其在loss中的权重。如果你的FPN只有P3到P5,那默认小目标就容易丢。另一个可行手段是把输入分辨率从800×1280提高到960×1440,小目标像素变大,检测效果自然会改善。但分辨率提高会带来显存和推理耗时的双重压力,所以建议优先考虑P2层和loss权重分配。还有一个容易被忽略的坑是数据标注。在拥挤路段的高位摄像头画面里,远景车辆只有十来个像素,很多标注人员根本不会认真标,导致P2层的真值本身就稀疏。我后来专门让标注人员把远景小目标也标上,哪怕只有6到8个像素的框,也尽量框出来。单纯从模型层面讲,数据质量对密集小目标的影响比任何网络结构改进都大。5.3 多尺度车辆检测效果不均衡检测效果不均衡指的是近处大车框得准,远处小车几乎全漏,或者反过来。这种问题往往是FPN层级与目标尺度范围不匹配造成的。FCOS的论文给了一个尺度的建议范围:P3负责0到64像素,P4负责64到128像素,P5负责128到256像素,P6负责256到512像素。如果你的P2参与检测,那0到32像素的目标可以划给P2。但是在实际项目中,车辆目标可能处于这个范围边界附近,这时候边界处的目标会随机分到某个层级,导致训练不稳定。我的做法是在真值生成时让边界附近的目标同时被两个层级监督,取置信度更高的结果,这个叫做“层级重叠监督”,能明显缓解边界目标漏检。5.4 GroupNorm和BatchNorm混用是否有问题混用没有问题,我之前在骨架保留BN、在Head用GN,训练效果一直正常。但当你做权重量化或者转成某些推理引擎时,混合归一化方式会增加算子转换复杂度。比如某些推理框架对BN的融合做得很好,但对GN的支持还需要额外算子。如果目标平台对GN不友好,我的建议是尽量把GN都融合进卷积或者单独实现高效算子,或者干脆在部署前重新用BN训练一个模型,虽然有点费时间,但省心。5.5 Caffe和PyTorch之间的权重转换在项目过程中,可能因为方便性,你想先用PyTorch做实验,再转到Caffe部署。这时权重转换是一个绕不开的坑。我的经验是,先把PyTorch模型的state_dict导出,记录每一个层的名字和shape,再在Caffe里用net surgery工具逐层填充或模拟加载。ResNet50这种层名结构比较固定的网络,转换成功率很高,但FPN和NASHead因为层名自定义,经常出现错位。为了避免这种错位,我的做法是在PyTorch和Caffe的prototxt里给每个关键层起同名,或者干脆写一个映射文件,把PyTorch层名映射到Caffe层名。转换完之后,用几个固定输入跑一遍前向,对比两边的输出feature map,误差在1e-3以内就算合格。如果不做这个验证,直接上板子,基本会出各种莫名其妙的精度下降。5.6 训练时间太长或显存不足如果显存不足,可以依次尝试以下手段:减少batch size,降低输入分辨率,关闭FPN中P2或P5的参与,减小NASHead卷积通道数量。请注意,减少batch size对这个模型的影响比YOLO小,因为GN和FCOS对单卡batch的依赖都比较低。如果训练时间太长,优先用混合精度训练。Caffe下可以用FP16来跑卷积层和全连接层,梯度用FP32累积。这样在主流显卡上训练速度能提升约30%到40%,但要注意某些自研的Python loss层如果用了CPU计算,会变成性能瓶颈,所以尽量把loss层也改成GPU算子。6. 测试与评估结果分析6.1 评测指标选择车辆检测任务一般看mAP和Recall,但拥挤路段场景我更建议重点关注两个指标:AR(average recall,平均召回率)和F1-score,尤其是车辆密集时误检和漏检哪个更致命,直接决定指标权重。如果这个系统用于流量统计,漏检会比误检严重得多,此时适当调低置信度阈值,把Recall优先拉高。另外建议统计不同距离段下的AP,比如0到30米、30到60米、60米以上三个区间。为什么这么拆?因为摄像头安装高度和角度不同,近处车辆特征清晰、远处车辆目标小,模型的性能在这三个区间往往差异巨大。如果没有分段统计,总AP可能被近处目标的好结果掩盖,导致你以为模型整体不错,实际远端漏检严重。6.2 实测结果示例我拿一段三千米双向六车道的拥挤路段视频测试,视频分辨率1920×1080,帧率25fps,模型输入960×600,设备是单张普通卡。测试结果大致如下:指标数值mAP @ IoU=0.587.3%AR(最大检出100个)93.1%平均单帧推理耗时31ms近距离AP(0-30m)94.5%中距离AP(30-60m)86.8%远距离AP(60m以上)74.2%可以看到中远距离的AP下降仍然比较明显,这个就是小目标漏检问题的集中体现。如果你单纯调高输入分辨率,远距离AP能涨到78左右,但在边缘设备上不一定跑得动。所以这种系统在设计时就要有取舍意识,不能盲目追求所有距离都满精度,得看业务方真正关心的是哪个区域。6.3 模型可视化检查除了指标,我强烈建议可视化一部分检测结果。把预测框、分类置信度和centerness都画在图上,肉眼检查一下几类情况:近景车辆的框是否贴合车身、遮挡车辆是否会漏检或重复检、车道分界处的车辆是否被误检成静止物体。这类视觉检查往往能发现指标反映不出来的问题。比如有一次我发现大货车的侧面产生了很多误检框,原因是货车侧面纹理复杂,NASHead提取的特征被误判为多个车辆。后来我在训练数据里增加了货车样本的比例,加大了对货车的学习权重,误检明显下降。7. 优化方向与后续扩展7.1 模型蒸馏训练好的大模型直接部署到边缘设备,负载还是偏高。一个可行方向是把R50-FCOS当作teacher,蒸馏给一个更轻量的学生网络。我刚接触蒸馏时总以为需要很复杂的蒸馏损失设计,后来发现对于检测模型,最有效的是特征蒸馏和logits蒸馏配合。具体实施上,让student模型在FPN每一层都对齐teacher的特征图,loss用L2距离;同时让最终的分类得分和回归偏移对齐。这种方法在车辆数据集上可以把学生模型的mAP维持在teacher的95%左右,同时推理速度提升近3倍。7.2 引入时序信息拥挤路段有个特色问题:静止车辆或者短暂停顿的车辆,单帧模型很难判断它到底是不是真正停车,这时如果有多帧时序信息,判断会准很多。后续可以引入轻量级的时序模块,比如用一个3D卷积对相邻三帧特征做融合,或者用简单的帧间差分把静态车辆和移动车辆区分开。注意不要滥用时序模块,如果硬件算力有限,帧间IoU跟踪器可能比任何网络模块都更实用。7.3 从单类别扩展到多类别如果你的业务需要区分轿车、SUV、卡车、公交,可以直接把分类头输出从1改成4,然后重新标注数据。因为主干网络没有变,只需要扩充分类分支,训练时间不会增加太多。要注意的是,类别不均衡在这个任务里很明显,轿车数量可能是卡车的几十倍。Focal Loss的参数需要重新调节,尤其要把困难类别(卡车、公交)的alpha调高。8. 写在最后的一点个人经验这套基于NAS-FCOS的车辆检测系统,从最初选型到最终落地,整个过程里我最想提醒大家的是,不要被“NAS搜出来的结构一定更好”这个想法冲昏头脑。搜索得到的Head确实在大部分情况下优于手工设计,但它在不同平台的算子支持和推理性能上有不确定性。所以拿到一个NAS结构后,先做一次前向推理速度测试和算力评估,再决定是不是要继续调参优化。另外,训练数据的质量在拥挤路段项目中永远是第一位的。我见过太多人花几周时间调网络结构,不如花两天时间去修正一批标注错误带来的提升大。如果你打算在一个新的路段部署这套系统,条件允许的话,先采集该路段的真实图像做标注,用少量数据先做一轮domain adaptation,再决定是否需要重新训练整个模型。最后分享一个小技巧:在训练FCOS这类anchor-free模型时,把日志里每个FPN层的正样本数量打出来,观察它们的比例是否均衡。如果P2层正样本数远小于P4层,那你的模型大概率在拥挤小目标上表现不佳。这个日志排查习惯,帮我省下很多盲目调参的时间,也推荐你试试。