做城市视觉识别这行久了,我慢慢发现一个有意思的现象:大家聊到目标检测,翻来覆去都是行人、车辆、车牌这些“标准任务”,但真正在项目里难倒人的,往往是那些不起眼却极度考验细节的场景——比如城市街角、社区围墙边、绿化带里成堆的打包垃圾。
我最近交付的Python项目,做的就是这件事:基于Faster R-CNN算法,在城市场景下自动检测打包垃圾。说得直白点,就是让算法在监控画面和巡检照片里,把“一袋一袋已经打好包的垃圾”准确框出来,而不是把落叶、纸箱、黑色塑料袋和普通杂物全混在一起。这个需求在智慧城市、市容巡检、物业保洁调度里非常常见,尤其在环卫考核逐步自动化的趋势下,靠人工一张张翻照片已经完全跟不上节奏了。
这篇文章把我从数据准备、模型搭建、训练调参到部署实测的完整过程梳理一遍,包括所有踩过的坑和最终的取舍。如果你刚接触目标检测,想拿一个真实场景练手;或者你已经在做检测相关项目,想把Faster R-CNN落到具体业务里,都可以直接参考这套思路,少走弯路。
1. 先理清任务形态:城市打包垃圾检测的难点在哪里
1.1 我们实际要检测的是什么
很多人一听到“垃圾检测”,下意识以为就是图像分类:判断画面里“有垃圾”还是“没垃圾”。实际业务完全不是这么回事。甲方要的不是一个笼统的判断,而是每个垃圾袋在画面里的具体位置、大小,最好还能区分出是单袋、成堆,还是散落在大件垃圾旁边。因为后续调度保洁车辆时,需要根据垃圾量决定派几个人、带什么工具。
我这次项目面对的场景以城市道路两侧、开放式小区围墙边、绿化带边缘为主。打包垃圾的形态非常统一:黑色或深色塑料袋居多,袋口系紧,有的单独一袋,有的三五袋堆在一起,偶尔混着纸箱、泡沫箱。这种目标有两个明显特征——第一,它没有固定尺寸,单袋可能只有拳头大小,成堆的却能占画面的三分之一;第二,它和背景的对比度往往很差,黑色垃圾袋放在柏油路边的阴影里,用肉眼都得仔细看两遍。
所以这个任务的本质是“小样本、单一类、强背景干扰”的检测。类少不代表简单,背景干扰和尺度差异才是真正的拦路虎。
1.2 这个任务和通用目标检测的差异在哪
通用目标检测数据集里,一个类别通常有大量标注框撑着,模型很容易学到稳定的类别特征。但打包垃圾这个类别,外观上几乎没有什么“强特征”——没有车灯那样的几何结构,没有人脸那样的纹理特征,没有车牌那样的规则文字。它就是一团轮廓,加上塑料袋特有的褶皱和反光。
这导致两个直接后果。一是模型容易把颜色相近、轮廓相似的东西误判成垃圾袋,比如黑色轮胎、遮阳网、树根处的阴影;二是模型对“袋口扎紧”这个细节不敏感,可能把敞口的垃圾箱、堆放的杂物也框进去。所以我在项目里反复跟团队强调:不要指望算法学会“垃圾的语义”,要让它学会“这一团东西在空间关系上像一袋垃圾”。
另一个差异是场景的时空变化。同一个点位,早上七点和晚上七点的光照完全不同;晴天和雨天的反光特性也不同;夏天树叶茂密时垃圾袋可能被遮挡一半,冬天落叶后同一位置完全暴露。这些在实际做数据集时都必须覆盖到,否则模型换个时间段直接失效。
1.3 技术选型:为什么在最终方案里押注Faster R-CNN
做目标检测,绕不开YOLO系列、SSD和Faster R-CNN这几条路线。我在项目初期做了个小对比,结论非常明确:这个场景要的是“准”而不是“快”。
| 方案 | 推理速度 | 小目标表现 | 误检率 | 训练调参成本 | 结论 |
|---|---|---|---|---|---|
| YOLO系列 | 极快 | 中等 | 偏高 | 较低 | 速度快,但误检处理成本高 |
| SSD | 快 | 一般 | 偏高 | 较低 | 精度和速度都比较尴尬 |
| Faster R-CNN | 较慢 | 好 | 低 | 中等 | 精度上限最高,适合此类任务 |
Faster R-CNN是典型的两阶段检测器:先用区域提议网络(RPN)快速筛出可能有目标的大致区域,再对这些区域做精细分类和边框回归。相当于先粗筛一轮,再精审一轮。用在打包垃圾这类“背景干扰强、类别特征弱”的目标上,多出来的这一步精审能显著压低误检。
当然,速度确实是Faster R-CNN的短板。我在部署阶段通过模型导出和推理加速做了处理,实测下来单张图片在边缘设备上能压到200毫秒左右,完全满足巡检场景的需求。这个后面专门讲。
2. 数据是这场仗的前半段:采集、标注、增强
2.1 数据采集的覆盖策略
数据质量决定了模型上限,这个道理在垃圾检测里体现得格外明显。我一开始偷懒,只从几个固定点位的监控截图里扒了800多张图,结果验证集上准确率还行,一到真实巡检就翻车——换了个小区,围墙颜色和绿化风格一变,模型立刻开始乱框。
后来我把采集策略重新梳理成三个维度。
第一个维度是场景覆盖。至少要包括:沥青路面、水泥路面、砖石路面、草地上、灌木丛旁、围墙根、垃圾桶周边、施工围挡旁边。每个场景类型不少于100张。这样做是逼着模型去学“垃圾袋本身”,而不是学“黑色区域”。
第二个维度是光照和天气覆盖。同一批点位,分别采晴天的上午、下午和傍晚,再加一部分阴天和雨后的画面。有条件的话,夜间的红外监控截图也要采一些。这个维度特别容易被忽略,但城市巡检的算法往往要求全天候运行。
第三个维度是形态覆盖。单袋、双袋、三五袋堆叠、和纸箱混放、半埋在落叶里、被树枝遮挡一半,这些情况都要单独标注。我统计过,成堆垃圾如果只标一个框,模型训练时会非常困惑——它不知道这个框里到底是一袋还是好几袋,最终回归出的边框会偏大或偏小。
数据总量方面,我的经验是单类别检测起步也要1500到2500张图,每张图平均1到3个目标。太少的话,即使有预训练权重,模型也容易过拟合到背景纹理上。
2.2 标注规范和格式转换
标注我用的是LabelImg这类开源工具,导出成VOC格式的XML文件。这里有一个非常容易踩的坑:标注框的划定标准必须统一。我当时的规范是“只框可见的袋体部分,被遮挡的区域不臆测”,并且“堆叠在一起的几袋垃圾,如果袋体之间有明显凹陷或边界,就分别标注;如果完全挤成一团无法区分,则标成一个框”。
这个规范听起来简单,标注员执行起来却很容易飘。有人在树丛遮挡严重时习惯把整个灌木丛框进去,有人会把纸箱和垃圾袋一起框。我后来加了一道抽检流程:随机抽20%的标注框叠加到原图上人工复核,低于95%合格率的标注批次直接打回。宁可前期慢一点,也不要让脏数据进训练集。
标注完成后需要把VOC格式转成训练框架需要的格式。我主要是转成COCO格式的JSON,因为后续做数据增强和数据划分时,COCO格式处理起来更顺手。转换脚本很简单,就是遍历XML,解析<object>节点,把xmin、ymin、xmax、ymax换算成bbox的xywh形式,同时维护好图片尺寸和类别映射表。
2.3 数据增强与难例挖掘
打包垃圾这个类别,颜色和纹理本来就弱,数据增强的作用比在常规任务里更大。我按以下组合做增强:水平翻转、随机缩放、随机亮度对比度扰动、轻微旋转。有一项增强我特意关掉了——随机裁剪。因为裁剪很容易把垃圾袋裁掉一半,反而制造出大量只有半个袋子的负样本,干扰训练。
难例挖掘是这次项目里最值得说的部分。第一次训练结束后,我把模型在验证集上所有误检和漏检的图片导出来,人工挑出典型的“易混淆案例”:黑色的轮胎、地上的黑色水管、堆叠的黑色沙袋、光影形成的深色团块。这些负样本被单独放进一个文件夹,和正样本一起重新参与训练。
这一步的效果极其明显。加入难例后,误检率从之前的每百帧约6次降到了每百帧不足1次。我后来总结:单类别检测器的性能瓶颈往往不在正样本不够,而在负样本不够“毒”。你给模型看的反例越接近正例,模型的边界就画得越准。
数据划分我采用7:2:1,训练、验证、测试完全按场景分组,保证同一个点位的画面不会同时出现在训练集和测试集里。如果随机划分,模型学到的是点位记忆而不是泛化能力,测试分数会虚高。
3. Faster R-CNN关键机制与Python工程实现
3.1 两阶段检测的核心流程,用大白话拆开
Faster R-CNN的名字里“Faster”是相对于上一代R-CNN和Fast R-CNN说的。它最大的贡献是把“区域提议”这件事也放进了神经网络里,不再靠Selective Search这类外部算法生成候选框,而是用一小段网络直接预测“哪些地方可能有目标”。
整个流程分成两大步。第一步是RPN在特征图上滑动,生成大量候选区域,每个候选区域输出两个结果:这个位置有没有目标(二分类),以及这个框应该怎么微调(四参数回归)。RPN生成的框会先经过NMS去重,留下一部分高分的作为候选。第二步是RoI Head,把这些候选框对应的特征区域池化成固定尺寸,送入分类器和边框回归器,输出最终的类别和坐标。
我习惯用一个类比来解释这两步的关系:RPN是保安在监控室里快速扫屏,看到可疑的轮廓就圈出来报给主管;RoI Head是主管拿着放大镜,对每个圈出来的区域仔细核对,决定它到底是不是垃圾袋。两段式的好处是,粗筛的召回率可以做得足够高,把真正的目标全部留进候选池,再由精审阶段把假阳性压下去。
3.2 骨干网络与特征提取的取舍
骨干网络我用了ResNet50。相比VGG16,ResNet50的参数更少,深度更深,特征表达能力强,而且有成熟的预训练权重,收敛速度明显更快。更深层的ResNet101我也试过,提升大概只有1到2个点的AP,但显存占用涨了三分之一,训练时间也拉长,对单类别任务来说性价比不高。
特征提取阶段有个关键概念叫“特征金字塔”(FPN)。简单说,就是把不同网络层输出的、不同分辨率的特征图融合起来,让浅层的高分辨率特征负责检测小目标,深层的低语义特征负责检测大目标。打包垃圾这个场景太需要这个能力了——我刚才说过,单袋垃圾可能只有几十个像素,成堆垃圾能占半幅画面,如果没有FPN,模型会在某一端严重失分。
3.3 工程框架选择和关键配置代码
工程实现上,我基于PyTorch生态,直接在开源的检测工具箱上改配置。选框架时主要看三点:能不能方便地改Anchor参数、支不支持FPN、文档和社区案例多不多。确定框架后,我把注意力集中在配置文件上,而不是自己从头写训练循环。
核心配置可以浓缩成下面这段逻辑,对应到具体框架里就是一份配置文件:
# 骨干网络:ResNet50,加载ImageNet预训练权重 backbone = dict( type='ResNet', depth=50, frozen_stages=1, norm_eval=True, ) # RPN:根据垃圾袋尺寸重新设计Anchor rpn_head = dict( type='RPNHead', anchor_generator=dict( strides=[4, 8, 16, 32, 64], ratios=[0.5, 1.0, 2.0], scales=[2, 4, 8, 16], ), loss_cls=dict(type='CrossEntropyLoss'), loss_bbox=dict(type='SmoothL1Loss', beta=1.0), ) # RoI Head:单类别二分类 + 边框回归 roi_head = dict( type='StandardRoIHead', bbox_roi_extractor=dict( type='SingleRoIExtractor', roi_layer=dict(type='RoIAlign', output_size=7, sampling_ratio=2), out_channels=256, ), bbox_head=dict( type='Shared2FCBBoxHead', num_classes=1, in_channels=256, fc_out_channels=1024, ), )这份配置最核心的改动是scales参数。默认的Anchor尺度是按COCO那种目标尺寸分布的,对垃圾袋场景来说偏小。我把尺度调成2、4、8、16之后,RPN对中等尺寸垃圾袋的召回率肉眼可见地提升。具体怎么调的,下一章细说。
3.4 损失函数与正负样本分配
Faster R-CNN的损失分两部分:RPN的分类损失和回归损失,加上RoI Head的分类损失和回归损失。分类损失用交叉熵,回归损失用Smooth L1。Smooth L1的特点是对离群点不那么敏感,避免个别标注偏差很大的样本把回归梯度带偏。
训练时还有一个容易被忽略的细节:正负样本的采样比例。默认情况下,RPN和RoI Head都会保证batch里正负样本比例在1:3左右。对于单类别任务,这很重要。因为打包垃圾在画面里的占比通常不大,绝大部分候选框都是背景,如果不控制比例,模型很快就会被“全是背景”的样本淹没,学成一个只会输出负样本的废模型。
4. 训练实录:调参过程与四类典型踩坑
4.1 第一次训练:血淋淋的起点
第一次训练我直接用默认配置跑,24个epoch下来,验证集mAP只有0.08,基本等于瞎猜。翻看预测结果,发现模型输出的框几乎全部集中在画面下半部分——那是因为训练数据里垃圾袋确实更喜欢出现在画面底部。模型学到的不是“垃圾袋长什么样”,而是“画面哪个位置容易出现深色块”。
这个教训很深刻。后来我做了两个调整:一是强制数据增强里加入一定比例的随机翻转,打破位置偏差;二是在训练集里刻意加入一批“垃圾袋出现在画面上方或角落”的样本,比如从高位监控摄像头拍到的画面。第二个调整见效特别快,mAP直接从0.08跳到了0.45。
另一个起点问题是学习率。默认的0.02对单类别小数据集来说太大了,训练到第10个epoch loss还在震荡。我改成学习率0.005,加500步的warmup,loss曲线终于变得平滑。这里我的建议是:不要迷信框架的默认学习率,先拿一个小验证集跑50个batch,看看loss的下降趋势,再决定要不要调低一个数量级。
4.2 Anchor尺度不对:垃圾袋“太大”导致的问题
这是本次项目里最典型、也最值得展开的问题。第一次训练时,我把验证集里所有真实标注框按面积做了个统计分析,发现中位数大约占据画面的6%到15%,换算到特征图上,对应的Anchor应该以中等偏大的尺度为主,而默认配置恰好偏向小尺度。RPN对中偏大的垃圾袋生成的候选框质量很低,要么只框住袋子的一部分,要么把整袋垃圾拆成了好几个碎片框。
调整方式就是我在第三节配置里写的:把Anchor的scales整体放大,并让不同特征层之间的Anchor尺度跨度更均匀。调整之后我再统计RPN的召回率,同样是每张图提议300个框,召回率从71%提升到了89%。这一步是全程收益最明显的单点优化。
顺带说一个判断Anchor是否合理的笨办法:把训练集里所有Ground Truth框和Anchor框做IoU匹配,如果大量真值框找不到IoU超过0.5的Anchor,那说明Anchor设计确实出了问题,而不是模型的问题。这个诊断手段不花一分钱,能节省好几天瞎调参的时间。
4.3 误检重灾区:树影、轮胎、纸箱和遮阳网
模型训练到后期,漏检已经不多了,剩下的问题全是误检。我把误检结果聚成几类,最大的几类如下:
| 误检对象 | 出现原因 | 处理手段 |
|---|---|---|
| 树根/灌木旁的阴影 | 深色块、形状和垃圾袋接近 | 加入更多阴影难例 |
| 黑色轮胎、水管 | 轮廓和系口垃圾袋相似 | 难例挖掘 + 提高置信度阈值 |
| 白色纸箱、泡沫箱 | 颜色亮、边缘清晰,易被当成浅色垃圾袋 | 补充浅色垃圾袋正样本 |
| 蓝色遮阳网 | 大面积深色纹理 | 难例加入负样本集 |
处理误检我分了两路。第一路是数据侧的难例挖掘,前面已经说过;第二路是策略侧的置信度阈值。单类别检测器经常在0.3阈值下疯狂误检,但把阈值抬到0.6,误检大幅下降,召回只损失不到3%,属于稳赚不赔的调整。我最后线上推理用的阈值就是0.6,实测效果最平衡。
4.4 训练不稳定与显存限制的处理
训练过程中还遇到两个工程问题。一个是显存溢出。我的显卡只有11G显存,Batch Size一开始设8直接OOM。解决方案是Batch Size降到4,用梯度累积模拟更大的batch。梯度累积是怎么做的?简单说就是每4个batch攒一次梯度再更新参数,效果等同于Batch Size 16,但显存占用不变。
另一个问题是训练中期loss突然跳高。排查之后发现是数据集里混入了几张标注框明显错误的图片——有个标注员把画面角落的消防栓也标成了垃圾袋。这种脏数据在训练到后期时,会让模型在几个batch上反复剧烈波动。解决方式是重新跑了一遍数据清洗,把所有标注框和原图叠加检查,剔除了40多张疑似错标的样本。数据清洗这件事,再多强调都不为过。
5. 实测评估:精度、速度与边界情况
5.1 评估指标怎么看
单类别目标检测,光看mAP很容易被表象骗过去。我同时关注Recall和Precision,以及F1分数。项目最终交付时的离线测试结果如下:
| 指标 | 数值 |
|---|---|
| AP@IoU=0.5 | 0.83 |
| AP@IoU=0.75 | 0.61 |
| Recall@100 | 0.86 |
| Precision@0.6阈值 | 0.91 |
| 单帧GPU推理耗时 | 约110ms |
AP@IoU=0.75比AP@IoU=0.5低了22个点,说明边框定位还不够精准,很多框虽然框中了目标但边界外扩明显。这个问题的根子在回归头的训练不充分,后续我会考虑增加回归损失权重、让模型多学几轮边框微调。
需要提醒的是,测试集和训练集的分组隔离非常关键。我按点位分组后,测试集mAP比随机划分时低了7个点。这7个点才是模型真实的泛化水平,别用随机划分的虚高分数骗自己。
5.2 不同光照与天气场景的表现
我把测试集按光照条件拆开统计,发现模型的表现确实有明显分层。
| 场景 | 召回率 | 误检率 | 典型问题 |
|---|---|---|---|
| 晴天上午 | 0.91 | 低 | 无明显问题 |
| 晴天傍晚 | 0.84 | 中 | 长阴影区域误检 |
| 阴天 | 0.88 | 低 | 无明显问题 |
| 雨后/积水 | 0.79 | 低 | 反光导致部分漏检 |
| 夜间红外 | 0.74 | 中 | 低照度下轮廓模糊 |
阴天和上午的表现最好,傍晚和夜间明显下降。这说明模型对纹理细节的依赖比对轮廓的依赖更强。针对夜间场景,我加了少量红外样本后的确有所改善,但受限于原始图像质量,夜间性能天花板就在那里。如果业务对夜间要求高,我的建议是优先提高摄像头补光条件,而不是一味堆训练数据。
5.3 检测速度与硬件要求
训练阶段的硬件要求不高,一张11G显存的卡就能跑通整个流程,只是时间略长。推理阶段,原版Faster R-CNN在GPU上单帧大约110毫秒,也就是9帧每秒。这个速度对实时视频流来说远远不够,但对巡检照片和定时抓拍完全够用。
如果要做实时视频分析,就必须走模型压缩和推理加速这条路。我下一篇会专门展开讲部署细节,这里先给一个结论:通过导出为ONNX、开启半精度推理、合并BN层,单帧耗时可以从110毫秒压到约55毫秒;再进一步换成TensorRT加速,30毫秒左右完全可行。在边缘盒子上用TensorRT跑这个模型,4路摄像头轮流抓帧分析,CPU占用也能压在40%以内。
6. 模型部署与后续扩展方向
6.1 部署链路与推理加速的实操记录
模型训练完成只是项目的一半,另一半是部署。我把模型的推理链路整理成标准流程:先把PyTorch权重导出为ONNX,再转到TensorRT引擎,然后在边缘设备上加载TensorRT引擎推理。
这里有一个容易踩的坑:导出ONNX时,动态输入尺寸没有配置好,导致TensorRT引擎只能接受固定尺寸的输入。垃圾袋检测的图片来源非常杂,有监控截图、无人机拍摄、手机随手拍,宽高比都不固定。我最终的方案是统一缩放到1280×736,长边不超过1280,短边适配到32的倍数。这个尺寸兼顾了小目标检测精度和推理速度。
我还做了个很土但很管用的优化:把预处理和后处理全改成批处理。原来的写法是一张图走一遍归一化、缩放、NMS,全部串行。改成统一预分配内存、整批做归一化、NMS在批量框集合上一次性执行之后,整体吞吐提升了约15%。这种优化不改变任何模型参数,纯粹是工程层面省出来的时间。
6.2 从单类检测到场景化的扩展思路
打包垃圾检测跑通之后,很自然的扩展方向是往“场景化”走。我目前在规划的方向有几个:一是增加“垃圾桶满溢”检测,判断垃圾桶周边的散落垃圾是否超出正常范围;二是结合目标跟踪,统计某个点位在一段时间内新增垃圾袋的数量,评估积压趋势;三是把检测结果和保洁工单系统打通,检测到垃圾袋超过一定滞留时间就自动生成清理任务。
这些扩展在算法层面都不需要推倒重来,核心还是Faster R-CNN这个检测器,只是把输出结果接入不同的后处理逻辑。我特别建议大家养成一个习惯:算法项目不要只交付一个“能跑出框”的模型,要把输出结果结构化,比如生成带时间戳、置信度、坐标、点位ID的JSON记录。这一步做好了,后面做任何业务逻辑都顺手很多。
6.3 持续优化:数据回流与定期重训
检测模型上线之后不是一劳永逸的。城市环境每季度都在变——新楼盘施工、绿化改造、道路翻新都会改变垃圾袋的呈现方式。我建议做三件事。
第一,把线上推理里所有置信度在0.3到0.6之间的模糊样本全部保存下来,由人工每周打一次标,归入训练集。第二,每两个月用新增数据做一次增量训练,不需要从零开始,加载已有权重再跑十几个epoch就行。第三,持续监控误检率,如果某类误检突然增加,先回看数据分布是不是发生了漂移,而不是急着调阈值。
我自己的体会是,数据回流机制看起来增加了一点人工成本,但它才是模型效果长期稳定的根本。很多团队花大量精力调参,最后模型几个月后照样退化,就是忽略了这一步。
最后再分享一个小经验:做这种单类别、单场景的检测项目,千万别一上来就追求最复杂的网络和最花哨的trick。先把Anchor尺度、数据难例、置信度阈值这三件事做到位,模型效果通常已经能到可用水平。我这次项目里所有的大幅提升都来自数据和Anchor设计,而不是网络结构上的创新。把基础功夫做扎实,往往比堆模型复杂度更出效果。