前阵子接了个项目,要在城市路口做一套实时的信号灯状态识别系统,视频流进去,信号灯状态出来,要求稳定、快速、能应对各种光照和天气。做这套系统之前我心里大概有底:用YOLO做检测,再用状态机做时序平滑,这是目前智能交通场景落地最主流的一条路线,但真到调试阶段才发现,跑通一个检测模型只是开始,后面还有数据、部署、边缘算力、现场环境一大堆坑等着你。
这篇文章把我从原理分析到完整实现的过程完整记录下来。如果你正在做智慧交通、辅助驾驶、路侧感知相关项目,或者只是好奇YOLO这类目标检测算法在真实场景里怎么落地,这几千字应该能帮你省下不少调研和踩坑的时间。
1. 为什么选YOLO做信号灯识别,而不是传统图像处理
1.1 信号灯识别到底难在哪
很多人以为识别红绿灯就是把红色通道抠出来,再加个阈值判断,实际上完全不是这样。城市路口的信号灯有三个天然麻烦:目标小、颜色易变、形态多。
先说目标小。路口摄像头通常固定在横臂或灯杆上,镜头到灯板距离几米到十几米,在常见的1080P画面里,一个信号灯满打满算也就几十乘几十像素,远一点的路口甚至只有十几个像素。小目标一直是目标检测的痛点,何况信号灯还是发光体,周围还有灯晕、反光、倒计时数字等干扰。
再说颜色。红灯在白天逆光下可能偏橙,绿灯在树荫下可能发青,夜间车灯、广告牌霓虹灯、建筑灯饰都会把环境色温搅得很乱。如果只靠颜色阈值,几乎不可能做到全天候稳定。
最后是形态。国内不同城市信号灯样式五花八门,有横排的、竖排的、全屏的、箭头组合的,还有和倒计时数字做在一个黑底灯板里的。传统方法针对某一类灯板写的规则,换一个路口就可能失效。
真实项目里遇到的往往不是“识别不了”,而是“这里识别得很好,换个路口就乱报”。这决定了方案选型必须走向端到端的深度学习检测。
1.2 YOLO的思路为什么更适合这个场景
YOLO这类单阶段检测器的核心思路,是把目标检测当成一个直接的回归问题:整张图喂进网络,特征图每个位置直接预测目标框和类别,一步到位出结果。它不像两阶段检测器那样先提候选区域再做二次分类,所以结构简单、管线短、部署方便,在算力受限的边缘设备上有天然优势。
用生活化的类比来说,传统图像处理方法像是拿着放大镜按照“哪里颜色像红灯”挨个找;而YOLO相当于直接让模型记住“红灯长什么样、绿箭头长什么样”,训练完成之后扫一眼全图就能同时说出位置和类型。
信号灯检测很适合YOLO的另一个原因是,它的目标类别之间界限相对清晰:红灯和绿灯虽然颜色不同,但语义明确,模型可以通过学习大量样本提取出颜色之外的形状、亮度、上下文特征。比如全屏红灯和圆饼红灯在形状上接近,箭头灯有方向信息,这些都可以被网络自动建模。再加上YOLO本身有预训练权重,可以用迁移学习的方式快速收敛,对于数据量有限的项目来说非常实用。
我在这个项目里最终采用的是YOLOv8作为基线模型。它的主干网络使用C2f结构,特征金字塔负责多尺度融合,解耦头把分类和回归分支分开,整体对小目标的适应性比之前版本有提升。更重要的是,官方给了不同规模的模型,从几兆的轻量版到几十兆的高精度版可以按算力选,对工程落地来说很友好。
1.3 系统整体架构:检测、决策、通信一条链
这套系统的目标不只是“看到信号灯”,而是要把“当前路口允许哪个方向通行”这个结构化信息实时送出去。所以整体架构分成三层。
第一层是采集和解码,通过RTSP拉取路口摄像头的视频流,把H.265/H.264码流解码成RGB帧。第二层是检测与决策,YOLO模型对当前帧做目标检测,输出所有信号灯发光区域的位置和类别,随后决策模块根据帧间一致性和状态机逻辑给出当前信号灯状态的最终判断。第三层是输出与通信,将判断结果封装成结构化数据,通过消息队列或HTTP接口发给下游系统。
这个架构的核心原则是“检测器解决‘看到什么’,决策层解决‘信不信、怎么报’”。只依赖单帧检测结果直接上抛,会被漏检和误检折腾死;引入时序决策之后,系统整体抗干扰能力会强很多。
下游对接的需求也影响架构设计。如果只是可视化展示,检测框直接叠加即可;如果要联动信号机控制或车辆诱导系统,则必须输出标准的灯色、方向、状态置信度和时间戳。因此我专门在后端增加了一个适配层,把内部检测结果统一转换为结构化信号格式,避免下游关心检测框坐标这些和业务无关的细节。
2. 数据准备与标注:这个环节决定模型上限
2.1 数据从哪来:公开数据打底,自采数据补盲区
训练数据是信号灯识别项目里最花时间、也最决定成败的部分。市面上的公开交通信号灯数据集能用,但普遍存在两个问题:一是场景集中在少数欧美城市路口,偏重横臂悬挂式灯头,和我们常见的单柱竖排灯板差异大;二是样本场景相对干净,缺少恶劣天气、逆光、夜间光晕这些极端情况。
我的做法是“公开数据打底,自采数据补盲区”。先拿公开数据集做预训练和模型结构与参数验证,再花两周时间跑现场采集。采集时有意覆盖不同时段:正午强光、黄昏背光、雨天夜晚、凌晨灯光混杂的路口。还有一个细节容易被忽视——一定要把摄像头安装高度、俯仰角调成和实际部署接近的状态,否则离线测试效果良好,往杆子上一挂就掉点。
采集之后要进行脱敏处理,路面上的车牌号和人脸必须做模糊。这不只是合规要求,也是工程习惯,处理过的数据后续共享和迭代都省心。
2.2 类别体系怎么设计:不是越少越好
信号灯类别设计直接影响模型决策和后续状态判断。我一开始想简化成red、green、yellow三类,很快发现不够用:如果只知道“是红灯”,不知道是左转红还是直行红,下游控制系统根本没法用。
最终我把类别定义为10类:red_circle、green_circle、yellow_circle、red_left、red_straight、red_right、red_straight_left、green_left、green_straight、green_right。箭头类别按方向和颜色组合,每个箭头都是一个独立检测目标。
这个设计有几个好处。第一,检测器直接输出“红灯+左转箭头”这种细粒度信息,决策层不需要再根据像素位置猜箭头方向。第二,模型可以学到“左转箭头绿灯和直行红灯同时亮”这种复杂组合的共存关系。第三,一个灯板上的多个灯可以被同时检测到,后续聚类逻辑更简单。
标注时要注意,YOLO格式是归一化的中心点坐标和宽高,每张图片对应一个txt文件。标注工具我用的常见标注软件,导出时选择YOLO格式即可。类别不平衡问题在这个任务里很突出:red_circle往往占大头,yellow_*因为持续时间短样本偏少。我在训练时对稀有类别做了加权,并把包含稀有类别的图片重复采样,才把各类别AP拉平。
2.3 数据增强的几个实战细节
信号灯识别的数据增强和其他目标检测任务最大的不同,在于“颜色”本身是重要语义。默认的HSV增强如果幅度开太大,红灯会被扭曲成紫灯,绿灯可能被转成黄绿,模型反而学坏。我实践下来比较稳的参数是:色调扰动hsv_h控制在0.01到0.02,饱和度扰动hsv_s在0.2到0.3,亮度扰动hsv_v可以到0.4到0.5。这样既能增强模型对不同光照的鲁棒性,又不至于破坏灯色语义。
mosaic增强和mixup增强可以开,能显著提升小目标泛化能力,尤其mosaic会把交通场景的目标缩小拼贴,等于免费增加了小目标样本。但要注意mosaic拼图会改变目标上下文,如果某个类别本身样本极少,被mosaic拼坏的比例会偏高,所以我对稀有类别做了保护,降低它们在mosaic合成时被裁剪掉的概率。
还有一个信号灯特有的增强:随机遮挡和模拟灯晕。在标注框周围叠加半透明高亮,或者用黑色块挡住部分灯面,能提高模型在真实遮挡场景下的表现。这些增强策略单独看都是小技巧,合在一起模型AP能差出2到3个百分点。
另外记得统一图片尺寸和分辨率。我一般把训练图缩放到640×640,同时保留采集原图作为后续升级到960分辨率的备选。训练前最好做一遍图片去畸变,因为很多路口摄像头有轻微鱼眼效应,边缘灯板会变形,去畸变能减少定位偏差。
3. 模型训练与关键参数调优
3.1 基础模型怎么选:轻量还是高精度
YOLOv8提供了n、s、m、l、x五个规模,参数量和精度递增。对于交通信号灯识别这种部署在边缘算力盒子上的任务,我对比了两个候选方案:n和s。
先说明选型逻辑。n模型体积最小,理论帧率最高,适合低算力设备,但小目标检测能力较弱,尤其在信号灯只有十几像素的场景里漏检明显。s模型参数量适中,精度更高,在边缘卡上通过TensorRT优化后也能跑到实时,是精度和速度的平衡点。最终我训练阶段用s作为主力,n作为快速验证模型。
具体参数对比我整理了一张表:
| 模型 | 参数量 | mAP@0.5:0.95 | 单帧耗时(640输入,边缘卡) | 结论 |
|---|---|---|---|---|
| YOLOv8n | 约3.2M | 0.79 | 约12ms | 快速验证用 |
| YOLOv8s | 约11.2M | 0.84 | 约22ms | 最终部署选型 |
这里mAP和耗时是基于我自己数据集的实测结果,不同数据集会波动,但趋势是明确的:s比n多花不到一倍的算力,换来了明显更稳的小目标召回率。如果现场摄像头距离灯板特别远,甚至可以考虑使用m模型配合更高分辨率输入,但边缘卡就要换更高算力的设备,工程成本上升。
3.2 训练参数的设置与计算逻辑
训练配置里最关键的是输入尺寸、批量大小、学习率和训练轮数。
输入尺寸我默认用640×640,但在信号灯小目标占比高的场景,我会建议同事把输入尺寸调到960×960试试。原因是信号灯在640分辨率下可能只有十几个像素,留给特征提取的信息太少;960分辨率把小目标撑到约1.5倍像素,mAP通常能提升一个点以上。代价是推理耗时上升,边缘部署时需要重新评估帧率。
批量大小依赖显卡显存,我用单卡24G显存跑s模型,batch设为16比较稳。batch太小会导致BN统计不稳定,训练波动大;batch太大又要降低学习率补偿,迭代效率反而不高。
学习率采用YOLO训练标准的余弦退火策略,初始学习率lr0设为0.01,最小学习率lrf设为0.01,前3个epoch做warmup。这个组合在多数数据集上都能稳定收敛,不需要额外调太多。训练轮数epochs我设置为200,配合早停策略,等验证集mAP连续20轮不涨就提前结束。
还有一个容易被忽略的参数是类别权重。因为yellow类、red_right这类样本少,我在损失函数中给稀有类别加了1.5倍权重,相当于让网络更重视这类样本。对yellow这一类,样本权重甚至可以调到2,毕竟它的持续时间短、样本天然稀缺。
3.3 训练过程怎么判断好坏:不要只看mAP
训练过程中我习惯每10个epoch保存一次权重,并且在验证集上单独计算每个类别的AP、精确率P和召回率R。只看总mAP容易掩盖单类别的问题:可能红圈灯AP很高,但绿箭头和红箭头混淆严重,总mAP依然过得去。
我需要重点看的是小目标部分的召回率。YOLO训练日志里给了不同尺寸目标的AP,重点关注small目标的AP。如果small AP明显低于medium,说明需要提高输入分辨率或者增加小目标样本。
另外一个实用习惯是把验证集按场景分组:白天、夜晚、雨天、逆光分别计算mAP,而不是把所有验证样本混在一起。这样能快速定位哪些天气条件下模型泛化弱。我的数据集里最先暴露问题的是逆光场景,红灯在阳光直射下偏白,模型把不少红灯漏掉了。后来针对性采集逆光样本增强了这部分数据,单个场景的AP才提上来。
训练结束后还要做一次混淆矩阵分析。样本少的方向类别之间即使总mAP不低,也常出现红左和红直行互相混淆的情况,这类问题在混淆矩阵里一眼就能看到。
4. 完整系统实现:从检测框到信号灯状态决策
4.1 图像解码、推理循环与资源管理
系统跑起来的第一件事是稳定拉流解码。用OpenCV直接连RTSP地址是最快的验证方式,但生产环境直接这么干容易踩坑:网络抖动时OpenCV的VideoCapture会阻塞,长时间不返回新帧,整个检测线程被拖死。
我的方案是单独开一个拉流解码进程,用FFmpeg负责RTSP拉流,解码后的原始帧通过消息管道交给检测进程。这样即使网络卡顿,检测进程拿到的也是最新可用帧,不会因为解码线程阻塞而丢掉整个系统的时间节奏。
检测循环的核心结构大概是这样的伪代码:
class SignalDetector: def __init__(self, model_path): # 加载训练好的YOLO权重 self.model = load_yolo_model(model_path) self.conf_thres = 0.45 self.iou_thres = 0.45 def run(self, frame_queue, result_queue): while True: frame = frame_queue.get_latest() if frame is None: continue detections = self.model.predict( frame, conf=self.conf_thres, iou=self.iou_thres, classes=LIGHT_CLASS_IDS ) result_queue.put((frame.timestamp, detections))注意置信度阈值不要设太高。信号灯小目标在远距离时特征弱,置信度往往只有0.4到0.6之间,阈值设到0.7会大量漏检。我最终定在0.45,配合决策层的时序过滤来压制误检,效果比单纯提高阈值更好。
推理进程要保持单例线程,避免多线程同时推理抢占GPU显存。CPU端的预处理、GPU端的推理、后处理尽量分离成独立阶段,方便用多进程流水线加速。
4.2 目标框聚类与灯板去重
YOLO输出的检测框经常出现多个重叠框指向同一盏灯的情况,需要做NMS去重。但仅仅NMS还不够,因为一块灯板上可能同时有红圈灯、绿箭头灯、倒计时数字,这些框在像素位置上非常接近甚至相交,但它们代表不同的灯,不能互相吃掉。
我的做法是先按检测框中心点做坐标聚类。灯板的发光区域空间分布相对固定,红圈可能在左上,绿箭头在中间,倒计时在右下,聚类后每个发光区域保留置信度最高的框。然后按类别合并,把同属于一个灯板的多个检测结果作为一个“灯组”交给决策层。
这个灯组概念很重要,因为决策层需要知道的是“当前灯板上有哪些灯在亮”,而不是散落的检测框序列。比如一个左转待转路口的灯板可能同时亮着全屏红灯和左转绿色箭头,检测层必须把这两个状态作为一个组合输出。
4.3 状态决策逻辑:状态机平滑与超时保护
检测器输出的是单帧检测结果,直接用它驱动下游会出两类问题:漏检导致信号灯状态频繁跳变,误检导致虚假状态一闪而过。状态机平滑是解决这两类问题的核心手段。
我实现的是一个基于状态确认计数的状态机。每个候选状态拿到一帧检测结果时计数加一,只有连续3帧都检测到同一状态才真正切换。反过来,状态离开也做平滑,连续2帧检测不到才允许离开当前状态。这样一段200毫秒的闪烁或遮挡不会导致状态翻转。
状态机还需要一个兜底机制:如果超过5秒没有检测到任何信号灯,说明可能出现了镜头朝向改变或者设备故障,这时立即输出UNKNOWN状态并触发告警,而不是继续沿用旧状态。旧状态一直保留也会误导下游,万一信号灯已经切换,下游还在按旧状态运行,后果很严重。
伪代码大致是这样的逻辑:
current_state = "UNKNOWN" confirm_count = 0 last_confirm_time = 0 while True: dets = detect(frame) suggested_state = aggregate_light_states(dets) if suggested_state == current_state: confirm_count += 1 else: confirm_count = 1 # 出现新候选状态 if confirm_count >= 3: if current_state != suggested_state: on_state_change(current_state, suggested_state) current_state = suggested_state这个状态机看起来简单,但它实际解决了“单帧检测置信度”和“多帧时序有效性”之间的鸿沟。系统运行稳定之后,状态跳变率从每分钟十几次降到几乎为零。
4.4 结构化输出与接口设计
状态机输出不能是孤立的字符串,下游系统需要知道状态来自哪个设备、什么时间、置信度多少。我定义了一个标准JSON结构:
{ "device_id": "node_003", "timestamp": "2025-06-18T14:23:56.120Z", "lights": [ {"target": "circle", "color": "red", "confidence": 0.97}, {"target": "left", "color": "green", "confidence": 0.93} ] }这个结构既表达“红灯亮、左转绿灯亮”的组合状态,也保留了每个灯单独的置信度。下游系统拿到之后,可以直接按target字段区分车道信号,不需要自己做图像语义解析。
传输层选择了消息队列做异步推送,每帧决策结果都带时间戳,消费者按自己的频率拉取最新状态。同时保留一个HTTP查询接口,方便调试时查看当前信号灯状态和历史状态列表。这里建议在接口里附带检测帧的缩略图,现场排障时能快速确认摄像头是否偏了、灯板是否被遮挡。
5. 部署优化:把精度变成实时性
5.1 模型导出:从PyTorch到TensorRT
训练好的模型是深度学习框架的格式,直接部署推理速度不够理想。我在部署阶段做了两步优化:先导出ONNX中间格式,再用TensorRT把ONNX编译成FP16精度的推理引擎。
FP16是精度和速度的甜点。我的实测结果显示,从FP32转到FP16,mAP@0.5:0.95只掉了0.1到0.2个百分点,但推理速度提升了接近一倍。INT8量化能把速度再翻一倍,但信号灯这种需要精细区分红绿颜色的小目标场景,校准数据不好时精度回落明显,我不建议默认使用INT8,除非你有充足的现场校准集。
导出时有一个细节:原始检测模型包含NMS后处理,TensorRT也支持NMS插件,但我在NMS这一步保留了自定义实现,方便按类别调整阈值。前端只导出网络部分的输出,后处理放在CPU上跑,看起来多了一步,实际上灵活性高很多。
5.2 部署架构:解码、推理、后处理三进程流水线
边缘部署的最大矛盾是算力有限,信号灯检测对延迟又敏感。优化方式是流水线化:拉流解码、GPU推理、后处理与决策分别放在三个进程里,用队列连接,各自用最大吞吐率跑,避免互相阻塞。
拉流解码进程只管从RTSP拿流,解码出的帧带时间戳放进环形缓冲。推理进程每次取最新帧,因为目标检测不需要逐帧推理,如果当前队列里积压了3帧,直接丢弃旧帧只处理最新帧,才能保证输出状态对应的实际时刻不过期。后处理进程负责NMS、灯组聚类和状态机,计算量不大但GPUK推理需要和CPU处理频率匹配。
部署时我还给推理进程设置了GPU内存预留,避免显存占用波动导致连续帧推理延迟突然升高。地温、风扇等现场环境问题也要考虑,边缘盒子要加温度监控,检测器在长稳测试中如果在高温下持续满负载跑,显存频率可能降频掉速,直接表现为检测帧率下降。
5.3 端到端性能实测数据
我在室内测试环境和现场实际部署环境分别记录了性能数据。以一块主流边缘GPU为例,输入分辨率640×640,batch为1,TensorRT FP16下的数据大致如下:
| 阶段 | 耗时 |
|---|---|
| 拉流解码一帧 | 6ms |
| 推理耗时 | 15ms |
| 后处理与状态机 | 4ms |
| 输出状态延迟 | 50ms以内 |
相比没有做TensorRT优化时的帧率,FP16优化让推理进程从约28帧升到约55帧,端到端状态输出延迟稳定控制在100ms以内。这个延迟对于信号灯识别来说完全够用,因为信号灯本身最短持续亮灯时间大约3秒,状态机还有帧数确认逻辑,最终输出的状态变化实时性远高于信号灯变化节奏。
这里我建议做一个长期稳定性验证:连续运行72小时,观察是否出现显存泄漏、状态机卡死、解码进程断流等问题。这类问题往往不在算法层面,而在工程细节层面,但对系统可用性影响最大。
6. 常见问题与排查实录
6.1 白天阳光直射下的误检
项目调试期间收到比较多的误检集中出现在正午和下午。阳光直射灯板时,红色灯罩被晒得发白,检测器把白色区域和车尾灯混淆,偶尔把反光的水渍也报成红灯。排查时先把误检样本保存下来,逐帧查看发现,误检框的置信度普遍在0.35到0.45之间,低于正常检测。
对策分两层。检测层:在训练数据里补充大量逆光和阳光直射样本,让模型学会识别“被晒白的红灯”周围的黑色灯板底板作为上下文。决策层:把状态机确认帧数从3帧增加到4帧,低置信度候选状态更不容易意外通过。效果上误报率从每10分钟一次降到每天一两次。
6.2 夜间灯晕与过曝
夜间问题是光晕。红灯亮起时灯罩周围一圈光晕连成一片,检测框定位偏大,有时把相邻的绿灯框也吞进去。更麻烦的是对面来车开远光灯,整个画面局部过曝,画面里出现白色团块,模型误检率上升。
我在摄像头端开启了HDR合成,再配合图像的局部直方图均衡,能有效抑制光晕扩散。算法侧针对夜间样本,专门标注了光晕边界,让模型学习“灯芯”和“光晕”的区分,检测框尽量贴合灯芯发光区域,而不是整个光晕。夜间的漏检率因此降了一半以上。
6.3 小目标漏检和状态跳变
离灯板较远的路口,信号灯在画面里不到20像素,模型经常间歇性漏检,状态机输出在正常状态和UNKNOWN之间反复横跳。这类问题用状态机硬扛没有意义,因为根本原因是模型看不到足够信息。
最后的解法是双路并行:一路整体画面做粗检测,另一路对灯板安装区域做局部裁切放大,再送入一个高精度模型细识别。两条路径的结果在决策层投票融合,小目标召回率有明显提升。如果不想引入双路模型,也可以把输入分辨率从640提到960,代价是帧率小幅下降,两种方案可现场权衡。
6.4 倒计时数字干扰
带倒计时数字的信号灯对模型是个陷阱。数码管数字和箭头形状接近,尤其是数字“3”和红箭头、数字“8”和红圆饼之间,特征图非常相似,模型偶尔把倒计时数字报成信号灯。
我尝试过在标注阶段把倒计时数字单独标成一个ignore类别,让模型知道这是背景,但效果一般。更可靠的方式是引入空间先验:同一个灯板上,信号灯发光区域和倒计时数字位置相对固定,我在决策层做了一个ROI屏蔽,只允许特定类别出现在灯板的特定半区。这个规则简单却非常有效,倒计时引发的误报几乎清零。
还有个小坑要提醒:有些路口信号灯带读秒声音,系统不需要处理音频,但摄像头采集的如果是音视频一体的RTSP流,解码进程如果启用音频解码会导致视频帧处理线程抖动,直接禁掉音频通道就对了。
7. 一点实战心得
整个项目做下来,我最深的体会是:训练一个高分模型只是系统的一小半,数据覆盖、时序决策、部署流畅度加起来占的工作量比模型训练还多。信号灯识别最大的敌人不是网络结构不够强,而是真实场景太复杂,阳光、灯晕、倒计时、摄像头抖动这些“非AI问题”才是最常见的掉链子原因。
最后分享一个小技巧:部署时在检测界面上叠加一个实时时间轴,把当前帧、检测框、状态机状态、置信度全部打点记录下来。这个日志在项目验收阶段帮我省了大量解释成本,也让现场运维团队能快速定位是摄像头脏了、灯板被遮挡了,还是模型真的判断错了。做智能交通项目,系统的可解释性和稳定性比单点精度更重要。