前阵子做智慧环卫方向的项目,核心需求其实就一句话:从路口和垃圾中转站的监控画面里,把垃圾车从一堆货车、客车、面包车里精确地分出来。实验做了一圈之后,最终落地的是标题里这套组合——Grid R-CNN + ResNet-101 + FPN + GN head,用 COCO 预训练权重按2x训练策略做微调。整条链路从数据标注、配置改写、训练收敛到部署加速都走通了,验证集 mAP@0.5 稳定在 88% 上下。这篇算是一次完整复盘,把每一步的选择逻辑和实际踩到的坑都写出来,适合正在做特种车辆检测、或者想在 mmdetection 里把 Grid R-CNN 跑通的人参考。
1. 需求拆解:垃圾车检测为什么不能直接抄 COCO 的 truck 类
1.1 应用场景与真实痛点
当时接到的项目场景是这样的:在一批固定视角的路口监控和环卫站进出口相机上,需要自动识别经过的环卫作业车辆,尤其是垃圾运输车,用来做清运频次统计、作业轨迹回放和违规倾倒预警。这类需求看起来就是普通的目标检测,但有个很微妙的地方——我们不是要数车流,而是要把"垃圾车"这个细分类从所有卡车里捞出来。
垃圾车的形态本身就很杂。后压缩式、侧装式、钩臂式,车体颜色从白到绿到橙黄都有,和普通厢式货车、渣土车、集装箱卡车在远看轮廓上高度相似。如果只是在通用模型上把"truck"这个类拉出来用,几乎没办法区分哪辆是环卫垃圾车、哪辆是普通社会货车。实际抽测第一版通用模型时,误检率到了没法看的地步,路口一棵树的阴影、一辆深色面包车都能被当成货车框出来,垃圾车本身的召回倒还行,问题是下游系统没法基于这种框做业务判断。
1.2 为什么不拿通用模型直接凑合
很多人会问:COCO 不是有 truck 类吗,直接微调不就行了?我一开始也是这么想的,后来发现这条路有两个绕不开的问题。
第一个问题是 COCO 的标注口径。truck 类把所有卡车混在一起,半挂、集装箱、厢式货车全都算,垃圾车的数量在训练集里占比极低,模型对这种子类的判别力自然不足。第二个问题是通用模型对具体场景的先验分布完全不了解。固定机位、特定角度、特定光照下的监控画面,和 COCO 里五花八门的网络图片是两个分布,直接泛化过来,边界框飘、置信度上下乱跳都是常态。
所以方案定为:拿 COCO 预训练的网格检测模型做初始化,用自己的垃圾车数据集做微调。这本质上不是"从零训练"也不是"拿来就用",而是在一个已经很会看通用物体的模型基础上,教会它区分"垃圾车"和"看起来像垃圾车的车"。
1.3 模型选型的评估标准
选模型之前我先定了三条硬指标:单类检测 mAP@0.5 不低于 85%;部署形态是服务器上的 GPU 视频流服务,单帧推理控制在 200ms 以内;训练和复现要稳,不能靠某个实验室的特殊 trick。候选方案当时有 Faster R-CNN、Cascade R-CNN、Grid R-CNN、FCOS 和 YOLOv5 这一批,最终选了 Grid R-CNN,理由后面细说,但核心就一句:它在两阶段框架里把定位精度往上推了一档,而且 mmdetection 里有现成的 R101 + FPN + GN head + 2x 配置,改数据路径就能训,省去大量调框架的时间。
2. Grid R-CNN 的定位原理:网格点在回归之外多干了一件事
2.1 从 Faster R-CNN 的短板说起
两阶段模型的定位流程大家都很熟:RPN 先提候选框,RoIHead 对每个候选框做一次分类和回归。这里的回归通常只预测四个值——中心点偏移和宽高偏移,本质是在"把框往正确的方向挪一挪"。
这个设计对大多数目标够用,但对边界敏感的细分类任务有个隐患:回归对遮挡、截断、大姿态变化的目标容易跑偏。垃圾车在监控里经常被截断,车头和车厢颜色反差又大,如果只用四个偏移量去拟合,框经常往里缩一点或者往外飘一点——单独看还行,但要拿这个框去关联车牌、判断车辆是否驶入指定区域,误差就放大了。
2.2 网格点回归到底怎么工作
Grid R-CNN 的核心思路是:在回归之外,再预测目标内部一批网格点的位置,用这些点反过来约束最终框。默认配置是 3×3 的九个点,它们在目标上的位置相对固定——车头中线、车尾中线、车侧边缘这些关键位置。训练时用真实框切出网格点做监督,推理时把网格点坐标和回归框做融合,最终得到比纯回归更贴边的结果。
我的理解里,这东西可以类比成搭帐篷:回归框相当于先大概圈出帐篷的占地范围,九个网格点就是九根固定桩,桩位置对了,帐篷布的边界自然就绷准了。对于垃圾车这种车体规整、边角清晰的刚性目标,网格点能卡在车厢和车头的几何特征上,定位质量比纯回归稳定得多。
2.3 和几个主流候选方案对比
选型时我列了一张对比表,直接对着需求逐项打分:
| 方案 | 定位机制 | 精度潜力 | 训练/部署复杂度 | 我当时权衡 |
|---|---|---|---|---|
| Faster R-CNN | 四参数回归 | 中 | 低 | 定位细度不够我的下游用 |
| Cascade R-CNN | 多级回归 | 高 | 中 | 精度高但推理更慢,收益不明确 |
| Grid R-CNN | 回归 + 9 点网格 | 高 | 中 | 最终选择 |
| FCOS / ATSS | 逐像素预测 | 中高 | 低 | 边框质量不如网格约束 |
| YOLOv5 | 单阶段 anchor | 中 | 低 | 快,但遮挡和细分类一般 |
实际训练之后的体感是:Grid R-CNN 在"框贴不贴边"这件事上确实比 Faster R-CNN 好一截,尤其在目标占画面比例较大的路口机位,效果差距能直接量化到 mAP@0.75 上。代价是多了一个网格头的计算量和推理时的网格融合逻辑,这个后面部署章节再具体讲。
3. 配置逐个解读:R101、FPN、GN-head、2x 都不是白给的
3.1 主干网络与特征金字塔
这个配置里每一项后缀都有明确作用,不是拍脑袋组合出来的。先看 R101 和 FPN。
ResNet-101 比 ResNet-50 深一截,对"垃圾车 vs 普通卡车"这种细分类来说,深层特征能捕捉到更多外观细节,比如车斗结构、液压臂、车厢后盖的形态差异。代价是训练和推理都变慢,但对服务器部署来说可以接受。FPN 是特征金字塔,它解决的问题是尺度:垃圾车在监控里可能占满半幅画面,也可能只有几十像素,FPN 把不同深度的特征图拉成多尺度输出,让 RPN 和 RoI 提取层都能在不同尺度上找到对应特征。
提示:如果数据里小目标特别多,优先保证输入分辨率而不是盲目加深网络。垃圾车这类中大型车辆,R101 的收益主要体现在细分类上。
3.2 GN head 解决的是训练稳定性问题
GN 是 Group Normalization,也就是分组归一化。它和 BatchNorm 最大的区别是:BN 依赖 batch 内的统计量,而 GN 把通道分成若干组,在组内做归一化,完全不依赖 batch size。
检测模型的 head 部分天然不适合 BN。因为 R-CNN 系列是对采样出来的 RoI 做运算,一个 batch 里的 RoI 数量受正负样本比例影响,统计量抖动很大;而且训练时如果只有一两张卡、每卡 2 张图,BN 的均值和方差几乎不可用。GN 按通道分组做归一化,单张图也能算,训练过程稳得多。Grid R-CNN 的网格头里全是卷积分支,通道数不低,GN 在这里几乎是标配。
3.3 2x 训练策略的含义
2x 是 mmdetection 里的训练节奏约定:总共训 24 个 epoch,在第 16 和 22 个 epoch 把学习率乘以 0.1。相比 1x 的 12 轮,2x 给了模型更长的收敛时间和更充分的学习率衰减过程。
在 COCO 这种十万级图片的数据集上,2x 是标准配置。但在自建的小数据集上就要留个心眼:几千张图训 24 轮大概率会过拟合。我的实际做法是先用 2x 跑一遍观察验证集曲线的拐点,如果第 14 轮之后训练损失还在降、验证 mAP 开始横盘,就说明后面的轮次基本在浪费时间。
3.4 具体配置文件与训练命令
我用的 mmdetection 版本是 2.x 系列,配置文件直接基于模型库里的grid_rcnn_r101_fpn_gn-head_2x_coco改,只动了数据路径和类别数。下面是核心部分:
_base_ = [ '../_base_/models/grid_rcnn_r101_fpn_gn-head_2x_coco.py', '../_base_/datasets/coco_detection.py', '../_base_/schedules/schedule_2x.py', '../_base_/default_runtime.py' ] model = dict( pretrained='torchvision://resnet101', roi_head=dict( bbox_head=dict( num_classes=1, loss_cls=dict(type='CrossEntropyLoss', use_sigmoid=False, loss_weight=1.0), loss_bbox=dict(type='SmoothL1Loss', beta=1.0, loss_weight=1.0)), ), test_cfg=dict( rcnn=dict( score_thr=0.05, nms=dict(type='nms', iou_threshold=0.5), max_per_img=100, grid=dict(use_grid=True, grid_points=9)))) dataset_type = 'CocoDataset' data_root = 'data/garbage_truck/' classes = ('garbage_truck', ) data = dict( train=dict( type=dataset_type, ann_file=data_root + 'annotations/train.json', img_prefix=data_root + 'train/', classes=classes), val=dict( type=dataset_type, ann_file=data_root + 'annotations/val.json', img_prefix=data_root + 'val/', classes=classes), test=dict( type=dataset_type, ann_file=data_root + 'annotations/val.json', img_prefix=data_root + 'val/', classes=classes))注意一点:bbox_head 的num_classes改成 1 之后,加载预训练权重时必须把分类层的参数剥掉,这个坑我放到训练章节详细说。训练命令很简单:
python tools/train.py configs/garbage/grid_rcnn_r101_fpn_gn-head_2x_garbage.py \ --work-dir work_dirs/garbage_grid/如果只有单卡,学习率建议从默认的 0.002 线性缩放。默认 0.002 是八卡、每卡两张图的设置,单卡配 2 张图时我一般直接降到 0.00025,或者不做缩放但把 batch 拉大,否则 loss 在前几百步会抖得厉害。
4. 数据集构建:垃圾车数据怎么标、怎么筛、怎么扩
4.1 数据来源与采样策略
数据来源主要是两类:固定机位的监控录像和随车记录仪画面。固定机位视角稳定,但点位少、场景单调;随车画面场景丰富,但目标角度变化大、难例也多。我的策略是两者混着用,比例大约 7:3。
采样时最容易犯的错误是相邻帧大量重复。连续视频按每秒抽几帧,抽出来的两千张图里可能有 60% 是几乎一样的画面,模型等于在反复看同一批车,验证集又恰好是同一机位的其他时段,导致验证指标虚高。后来我用感知哈希对帧做了去重,再按机位分组划分训练集和验证集,确保同一个机位不会同时出现在两边。
另外专门收集了一批难例:环卫清扫车、洒水车、城管执法车、各类厢式货车,以及在树荫、雨天、夜间条件下的普通车辆。这批难例不参与训练也可以,单独放一个文件夹,每次评估的时候混进验证集,专门看模型在"它该拒绝的东西"上表现如何。
4.2 标注规范与 COCO 格式转换
类别设计这块我纠结过一阵:要不要把垃圾车按子类型拆成三类?比如后压缩式、侧装式、钩臂式。后来验证下来发现子类型之间外观差异大,样本数量又不均,拆类反而拉低整体精度,最后统一成一个garbage_truck类,子类型差异留给模型自己学习。
标注规范里有一条要提前约定好:目标被遮挡时,人工只标可见部分还是按可推断的完整轮廓标?我最终定的是"完整轮廓",因为下游要做轨迹还原和区域判断,需要的是整车位置。转换 COCO 格式时我建议注解框直接从多边形算包围盒,不要手动拉矩形,手拉矩形很容易带进背景,网格头的监督信号会被污染。
标注完成之后我做了两轮质检:第一轮看框和目标的贴合度,第二轮专门查漏标——把模型初检结果叠到图片上,人工看哪些目标没被框出来。第二轮通常能捡回 2%-5% 的漏标,这对指标的影响比换模型结构还大。
4.3 数据统计对超参数的影响
标注完了先别急着训,做一次数据统计能省很多弯路。我统计下来,垃圾车目标在 1333×800 输入下的边长大多落在 64~512 像素,宽高比集中在 1.2~2.2,和我们用的默认 anchor 配置基本匹配,所以不用额外调 anchor。如果统计发现大量目标小于 32 像素,那就要考虑提高输入分辨率或者多尺度训练,而不是硬调 anchor 尺度。
单类别检测还有一个隐性收益:RPN 阶段的目标/背景判断几乎不会出现类别不平衡,因为垃圾车是有明确外观的一类,不是那种"可能是也可能不是"的模糊类。主要的风险反而是"类内差异大、难例不够",这要靠持续收集失败样本来修正。
5. 训练实施与调优过程
5.1 环境与预训练权重
环境没什么特别的:PyTorch 1.8 左右、对应版本的 CUDA、mmdetection 2.x,按官方文档装就行。预训练权重从模型库下载grid_rcnn_r101_fpn_gn-head_2x_coco的 checkpoint,这个权重在 COCO 上有大概 41 的 box AP,是一个已经收敛得很好的初始化点。
5.2 加载预训练模型的正确姿势
这里有个容易踩的坑。COCO checkpoint 的分类层是全类别 80 类加背景,一共 81 个类,而我们要训的是 1 个类加背景,只有 2 个类。直接load_from会让分类层的 shape 对不上,轻则警告跳层,重则直接加载失败。正确做法是加载前先把分类层参数剥掉:
import torch ckpt = torch.load('checkpoints/grid_rcnn_r101_fpn_gn-head_2x_coco.pth') state = ckpt['state_dict'] for k in list(state.keys()): if 'roi_head.bbox_head.fc_cls' in k: state.pop(k) torch.save({'state_dict': state}, 'checkpoints/grid_garbage_pretrained.pth')为什么要动fc_cls而不动fc_reg?因为回归分支的输出是固定的四个坐标量,和类别数无关,可以直接复用;分类分支的输出维度跟类别数绑定,类别数量变了就必须丢弃,等模型自己重新学。RPN 头里的分类输出是背景和前景二分类,和类别数也没关系,可以放心保留。我把这个处理写进训练脚本里,后续不管换多少次数据集,这一套逻辑都是通用的。
5.3 训练曲线与超参调整
训练过程比预期顺。前几个 epoch,RPN 的损失和回归损失下降很快,第 8 轮左右验证 mAP@0.5 就能到 78% 前后;第 16 轮学习率第一次衰减之后,指标明显再跳一档,最终 24 轮跑到 88% 上下。
中间我做过几轮小调整。第一次是想加速收敛,把pos_fraction从 0.25 降到 0.2,目的是减少正样本数量,防止单类别场景下 RoIHead 正样本过多导致训练偏向。实际效果是过拟合确实轻了一点,验证集和训练集的差距缩小了,但 mAP 本身没有明显变化。后来又把学习率从 0.002 降到 0.001 试了一轮,发现收敛更稳,最终精度基本持平,所以我没把它留在最终配置里。
还有一个值得记录的观察:GN head 在这个任务里从没出现过 NaN 或者 loss 跳变,这比我在其他 BN head 项目里的体验好太多。如果你在小 batch 训练时遇到 head 部分 loss 不稳定,优先检查是不是 BN 统计量在作怪。
5.4 验证结果与测试时增强
最终验证集的结果大概是这样(我自己的数据上的数字,仅供参考,不同机位分布差异会很大):
| 指标 | 数值 |
|---|---|
| mAP@0.5 | 88.4 |
| mAP@0.75 | 74.6 |
| mAP@0.5:0.95 | 58.3 |
| AR@100 | 91.9 |
| 小目标 AP(<64px) | 61.8 |
| 中目标 AP(64~256px) | 88.5 |
| 大目标 AP(>256px) | 91.2 |
mAP@0.75 能到 74 是让我比较惊喜的,这个指标对框的贴合度非常敏感,间接说明网格定位在起作用。测试时增强方面,我试了水平翻转加多尺度(缩放 0.8 和 1.2 共三种尺度),mAP@0.5 能再涨 0.8 到 1 个点,但推理耗时翻了将近三倍,部署阶段果断没上。
6. 实测中的失败案例:漏检和误检都在哪里
6.1 夜间与低对比度
训练集里夜间样本约占 15%,但夜间漏检率还是明显高于白天。最容易漏的场景是:路灯背光下深灰色车厢和道路混在一起,车灯强光过曝让车头轮廓消失。针对这类样本,我把夜间段的视频单独抽了一个月的数据补进训练集,同时做了一个对比实验,在预处理里加了 CLAHE 对比度增强,结果夜间 AP 提升了约 3 个点,白天 AP 几乎不受影响。
6.2 相似车型的误检
误检大头不是背景,而是"看起来像垃圾车的车"。打扫得很干净的环卫清扫车、白色厢式货车、深色的公交车,在特定角度下都有可能拿到超过 0.5 的置信度。这个问题的解法不是调阈值,阈值调高了只会把真正的垃圾车也一起砍掉。
我的对策是持续收集误检样本,尤其是那些模型给出高置信度的难例,混入训练集重训。两轮之后,打扫车和普通厢式货车的误检明显下降。另外在工程层面做了兜底:通过车辆运行轨迹和作业区域做规则过滤,检测框必须出现在环卫作业路段或者垃圾站进出口周边才会进入业务判定流程,相当于给模型加了一层业务先验。
6.3 小目标与遮挡
远处的垃圾车只有几十像素的时候,漏检率随之上升。提升输入分辨率是最直接的手段,我从 1333×800 提到 1600×1200 试过,小目标 AP 从 61.8 涨到 67 左右,但推理时间多了将近 40%,最后因为延迟指标,服务端还是退回 1333×800。遮挡场景则是两辆垃圾车并排时 NMS 容易把后车吞掉,我试验过把 NMS 的 iou_threshold 从 0.5 调到 0.45,多辆车近距离并排的场景召回有所改善,单场景又没有明显变差,是一个性价比很高的小改动。
7. 部署加速的过程记录
7.1 网格预测在部署时的麻烦
训练收敛之后,部署阶段遇到一个文档里讲得很少的问题:Grid R-CNN 推理时的网格融合逻辑不太好导出。网格头预测出九个点的分布之后,还需要一个解码步骤把网格点坐标和回归框做迭代融合,这部分在 ONNX 导出时容易碰到不支持的算子。
如果你也卡在这,最省事的方案是把test_cfg.rcnn.grid.use_grid改成False,让模型在推理时只走回归分支,不再做网格融合。代价是定位精度略微下降。我在测试集上对比过,关掉网格融合 mAP@0.5 只掉了 0.6 个点,mAP@0.75 掉了 1.2 个点左右。对于纯部署场景,这个精度损失完全能接受,换取的是导出和推理链路的极大简化。
7.2 加速方案与实测耗时
我先后试过三条路径:原生 PyTorch fp32、ONNX Runtime、TensorRT FP16。在消费级显卡上的实测单帧耗时如下:
| 推理方式 | 单帧耗时 |
|---|---|
| PyTorch fp32(开网格) | 约 180ms |
| PyTorch fp32(关网格) | 约 165ms |
| ONNX Runtime | 约 140ms |
| TensorRT FP16(关网格) | 约 40ms |
TRT 的 FP16 加速非常可观,四倍多的收益让线上服务的吞吐彻底不是问题。导出命令基本是标准的:
python tools/deployment/pytorch2onnx.py \ configs/garbage/grid_rcnn_r101_fpn_gn-head_2x_garbage.py \ work_dirs/garbage_grid/latest.pth \ --input-img demo.jpg \ --shape 1333 800拿到 ONNX 之后再用 TensorRT 做精度序列化,注意 mmdetection 导出的模型里预处理部分的均值方差是内嵌的,部署端不要再重复做一次归一化。
7.3 工程化注意事项
部署阶段还有个经常被忽略的点:输入尺寸。mmdetection 的 pipeline 是按比例缩放并 pad 到 1333×800,如果把预处理换成简单缩放到固定尺寸,宽高比变了,检测框坐标换算也会跟着错。我在接入视频流时把 resize 逻辑改成了和训练时一致的等比缩放加灰色填充,并且统一了坐标还原公式,才把线上检测框和画面中实际车辆对齐。
帧间稳定性方面,我没有上跟踪模型,而是对检测框做了轻量级的时序平滑:连续多帧里同一辆车(通过 IOU 关联)的置信度取滑动平均,只有连续两帧及以上都被检测到才输出稳定的车辆事件。这一层在工程上基本把单帧漏检造成的业务抖动压下去了,代价几乎为零。
8. 复盘:哪些地方我重来一遍会改
8.1 数据投入的优先级
如果重来一次,我会把更多时间放到数据上,而不是模型结构上。回头看,最终精度提升贡献最大的是两件事:难例样本的持续补充和标注的二次质检,这两块的投入产出比远高于调学习率或者换骨干网络。建议一开始就把"持续收集失败样本、定期重训"纳入流程,而不是等到上线被退回才补。
8.2 训练策略上的简化
我自己的数据量只有几千张,24 轮训练的方案偏奢侈了。实际从验证曲线上看,第 16 轮学习率衰减之后模型的提升已经很小,后面六个 epoch 基本在烧 GPU。换个角度想,如果当时直接用 1x 也就是 12 轮,辅以学习率衰减和早停,能省下接近一半的训练时间,最终 mAP 大概只差 0.5 个点以内。数据量小的场景,不必迷信模型仓库里的默认配置。
8.3 模型选择的后见之明
Grid R-CNN 在这个项目里是站得住脚的:精度达标,训练稳定,代码链路完整。但目前看来,它的主要成本集中在部署环节的网格解码算子。如果你的部署环境对自定义算子很不友好,Cascade R-CNN 或 FCOS 这类导出更顺的方案可能是更稳妥的选择。反过来,如果你和我一样做精细定位的单类检测、又有标准 GPU 服务,Grid R-CNN 依然是值得先试的那一个。
最后再分享一个实操细节:我在评估use_grid=True和use_grid=False的差距时,意外发现纯回归分支的表现比预期好很多,这说明标注质量已经把框的上限拉得很高。以后再做类似单类特种车辆检测,我会优先把标注质量、难例数量和机位分布做好,模型结构反而不需要太纠结。这套流程里写的排查思路和代码都是可以直接拿走的,但具体的精度数字,换一批数据就会有变化——指标可以参考,踩坑的经验才比较稳定。