ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

YOLOv11红绿灯检测系统实战:从数据集构建到PyQt界面集成

YOLOv11红绿灯检测系统实战:从数据集构建到PyQt界面集成 红绿灯检测这个方向看起来简单实际上手才知道坑有多密。我最早做这个项目的时候想的是不就是把红绿灯框出来吗YOLO 跑一下不就行了结果从数据标注到 PyQt 界面再到摄像头实时推理每一步都有让人挠头的地方。红绿灯目标本身尺寸小、背景复杂、光照变化剧烈白天逆光、夜间过曝、雨雾天模糊这些场景下模型的召回率会掉得很厉害。再加上要做一个完整的系统——不只是跑个推理脚本而是要有界面、要能切换图片/视频/摄像头三种输入源、要能保存结果——这就不是调个库那么简单了。这篇内容适合几类人看一是刚接触目标检测想拿红绿灯这个场景练手的同学二是已经会跑 YOLO 推理但不知道怎么把它包装成一个带界面的完整系统的开发者三是做智能交通、辅助驾驶相关项目需要快速搭一个可演示原型的工程师。我会把整个系统从环境配置、数据准备、模型训练、推理封装到 PyQt 界面集成的完整链路讲清楚重点放在那些文档里不会写、但实际做的时候一定会遇到的问题上。1. 为什么红绿灯检测值得单独做一个系统1.1 红绿灯检测和通用目标检测的本质差异很多人觉得红绿灯检测就是目标检测的一个子集拿 COCO 预训练模型微调一下就行。但实际做下来你会发现红绿灯检测有几个非常特殊的性质决定了它不能简单套用通用检测的思路。第一是目标尺寸极小。在一张 1920x1080 的行车记录仪画面里远处的红绿灯可能只有 15x30 像素占整张图的比例不到万分之五。YOLO 默认的 640 输入尺寸下这个目标经过下采样后可能只剩几个像素的特征很容易在 neck 部分就被丢掉了。第二是类别语义特殊红绿灯不只是一个灯它有红、黄、绿三种状态而且箭头灯、圆灯、倒计时数字屏又是不同的形态。如果你只检测traffic light一个类那检测出来也没法用因为你需要知道它当前是什么颜色。第三是背景干扰极强车尾灯、霓虹灯招牌、路灯、反光这些都会造成误检。所以一个真正能用的红绿灯检测系统至少需要区分 red、yellow、green 三个类别有时候还要加 off 或者 unknown并且在训练阶段就要针对小目标做优化。这也是为什么我在这个项目里没有直接用 COCO 的 traffic light 类别而是自己构建了红绿灯专用数据集。1.2 三种输入源背后的实际需求项目要求支持图片、视频、摄像头三种推理方式这不是为了凑功能而是对应了三个真实的使用场景。图片推理对应的是离线分析和调试。你在训练完模型之后需要快速看一批测试图的效果找出误检和漏检的规律这时候图片模式最方便。视频推理对应的是离线视频分析比如分析一段路口监控录像统计某个时间段内红绿灯的变化周期或者验证模型在连续帧上的稳定性。摄像头推理对应的是实时场景比如车载辅助提醒或者路口实时监控这时候延迟和帧率就是关键指标。这三种模式在代码实现上的差异其实比想象中大。图片是一次性加载、一次性推理视频要处理帧读取、帧率控制、结果写入摄像头要处理设备打开、缓冲队列、实时显示。如果架构设计得不好三套逻辑会写得非常冗余。我在项目里用了一个统一的推理接口把输入源抽象成帧生产者后面的检测和绘制逻辑完全复用这样代码量少了将近一半。1.3 完整系统的组成拆解一个能拿得出手的红绿灯检测系统拆开来看包含这几个模块模型推理模块加载 YOLOv11 权重对输入帧做预处理、推理、后处理NMS输出检测框和类别。输入源管理模块负责图片读取、视频解码、摄像头采集统一输出 BGR 格式的帧。结果绘制模块把检测框、类别标签、置信度画到帧上红绿灯还需要用对应颜色标注。界面交互模块PyQt 界面包含输入源选择、文件选择、开始/停止按钮、结果显示区域、参数调节控件。结果保存模块图片保存、视频写入、检测日志导出。这五个模块里模型推理是核心但相对成熟真正花时间的是界面和输入源的联动以及各种边界情况的处理。下面我按实际开发顺序从环境配置开始一步步讲。2. 环境配置里那些容易翻车的地方2.1 Python 版本和依赖的版本匹配YOLOv11 通过 ultralytics 库使用这个库对版本比较敏感。我实测下来Python 3.9 到 3.11 是最稳的区间3.12 在部分环境下 torch 的 wheel 会有兼容问题。PyQt 这边建议用 PyQt5虽然 PyQt6 也成熟了但 PyQt5 的教程和示例更多遇到问题好查。安装顺序很重要不要一股脑 pip install。正确的顺序是先装 torch再装 ultralytics最后装 PyQt 和其他辅助库。因为 ultralytics 安装时会检查 torch如果 torch 没装好它会尝试装一个 CPU 版本后面你想用 GPU 还得重装。# 先确认 CUDA 版本用 nvidia-smi 查看 # 假设是 CUDA 11.8安装对应 torch pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118 # 再装 ultralytics pip install ultralytics # 最后装界面和图像处理相关 pip install PyQt5 opencv-python numpy这里有个坑opencv-python 和 opencv-python-headless 不要同时装。PyQt 环境下有时候会依赖 headless 版本但如果你两个都装了cv2.imshow 之类的函数会报错。做 PyQt 界面的话其实不需要 cv2 的窗口功能用 headless 版本反而更干净但要注意 headless 版本没有 GUI 相关的函数。2.2 GPU 环境验证不能只看 torch.cuda.is_available()很多人装完 torch 跑一句torch.cuda.is_available()返回 True 就以为万事大吉了。但实际上这只说明 torch 能找到 CUDA不代表 ultralytics 推理时真的在用 GPU。我遇到过好几次明明 GPU 可用但推理速度跟 CPU 一样的情况最后发现是 ultralytics 加载模型时默认用了 CPU。验证方法要更彻底一点import torch from ultralytics import YOLO print(CUDA available:, torch.cuda.is_available()) print(CUDA device:, torch.cuda.get_device_name(0) if torch.cuda.is_available() else None) model YOLO(yolo11n.pt) # 显式指定 device results model.predict(test.jpg, device0) # 推理时用 nvidia-smi 观察显存占用如果有波动说明确实在用 GPU提示在 PyQt 界面里做推理时一定要显式传device0或devicecuda不要依赖自动检测。因为 PyQt 的事件循环和 CUDA 上下文有时候会互相干扰显式指定能避免很多玄学问题。2.3 PyQt5 在推理线程中的信号槽陷阱这是我在这个项目里踩得最深的一个坑。PyQt 的界面更新必须在主线程做推理如果放在主线程会卡死界面。所以推理必须放到 QThread 里然后通过信号槽把结果传回主线程更新界面。但问题是OpenCV 的 VideoCapture 和 QThread 配合时如果不在线程结束时正确释放资源会导致摄像头无法再次打开。我一开始的写法是线程 run 方法里打开摄像头循环读帧收到停止信号就 break。结果第二次点开始按钮时摄像头报 device busy。原因是 VideoCapture 没有 release而且线程对象没有正确销毁。正确的做法是在线程的 run 方法里用 try/finally 确保 release并且在线程外部等待线程真正结束后再允许重新启动class InferenceThread(QThread): frame_ready pyqtSignal(object) finished_signal pyqtSignal() def __init__(self, source, model, conf): super().__init__() self.source source self.model model self.conf conf self._running True def run(self): cap cv2.VideoCapture(self.source) try: while self._running and cap.isOpened(): ret, frame cap.read() if not ret: break results self.model.predict(frame, confself.conf, device0, verboseFalse) annotated results[0].plot() self.frame_ready.emit(annotated) finally: cap.release() self.finished_signal.emit() def stop(self): self._running False然后在主窗口里点击停止按钮时调用thread.stop()并且用thread.wait()等待线程真正退出后再把按钮状态复位。这个细节看起来小但不处理的话用户体验会非常差。3. 红绿灯数据集构建决定上限的关键一步3.1 数据来源和采集策略模型效果的上限是数据决定的这句话在红绿灯检测上体现得淋漓尽致。我建议的数据来源分三块第一块是公开数据集。比较常用的有 Bosch Small Traffic Lights Dataset、LISA Traffic Light Dataset这些数据集标注质量不错但场景偏国外路况红绿灯形态和国内有差异。可以拿来预训练或者补充样本。第二块是自己采集。如果项目有明确的部署场景比如某个固定路口那最好直接采集那个路口的视频抽帧标注。采集时要注意覆盖不同时段早高峰、中午强光、傍晚逆光、夜间。不同天气也要覆盖晴天、阴天、雨天。我自己的经验是至少采集 3 个时段、2 种天气的数据否则模型上线后很容易在没见过的光照条件下崩掉。第三块是数据增强补充。对于小目标检测Mosaic 和 MixUp 增强非常有效ultralytics 默认就开了。但红绿灯有个特殊点颜色是核心特征所以不要用 HSV 色调偏移类的增强否则红色可能被增强成橙色标签就错了。亮度、对比度调整可以用但幅度要控制。3.2 标注规范类别定义和边界框粒度标注规范如果不统一后面训练出来的模型会非常混乱。红绿灯标注我建议这样定义类别类别 ID类别名说明0red红色圆灯或箭头灯1yellow黄色灯2green绿色灯3red_left红色左转箭头可选4green_left绿色左转箭头可选如果项目不需要区分箭头方向用前三个类就够了。类别越多每个类的样本越少训练难度越大。我建议先从三类开始跑通整个流程后再考虑细分。边界框的粒度也很关键。红绿灯的框应该只框亮着的灯本身不要把整个灯箱框进去。因为灯箱里可能同时有红黄绿三个灯位只有一个是亮的如果框整个灯箱模型就学不到颜色和位置的对应关系。另外对于倒计时数字屏如果项目不需要识别数字建议单独标一个类或者直接忽略不要混在红绿黄里。3.3 小目标标注的实操技巧红绿灯目标小标注时容易出问题。用 LabelImg 或者 CVAT 标注时放大到 200% 以上再画框确保框紧贴灯的边缘。我见过有人标注时框比灯大了一圈包含了很多背景像素这种标注会让模型学到错误的特征。还有一个技巧对于特别小的目标小于 10x10 像素考虑是否值得标注。如果人眼在原始分辨率下都很难分辨颜色那模型大概率也学不会强行标注反而引入噪声。我的做法是设一个最小尺寸阈值比如 8 像素小于这个尺寸的目标直接跳过。标注完成后一定要做一次标注质量抽检。随机抽 50 张图用脚本把标注框画出来人眼过一遍看看有没有漏标、错标、框不准的情况。这一步花不了多少时间但能避免后面训练时莫名其妙的效果差。4. YOLOv11 训练红绿灯模型的参数调优4.1 模型选型n/s/m 怎么选YOLOv11 提供了 n、s、m、l、x 五个尺寸。红绿灯检测这个任务我的建议是优先用 s 或 m。n 虽然快但特征提取能力有限小目标召回率会明显偏低。l 和 x 精度高但推理慢如果部署在边缘设备上不划算。具体选哪个取决于你的部署环境。如果是在服务器上跑用 m 甚至 l 都没问题。如果要在 Jetson 或者普通笔记本上实时跑s 是比较平衡的选择。我实测下来yolo11s 在 640 输入下单张 1080p 图片推理约 15msRTX 3060视频 30fps 实时处理无压力。4.2 输入尺寸小目标检测的关键参数默认的 640 输入对红绿灯来说偏小。如果显存允许建议把 imgsz 提到 960 甚至 1280。输入尺寸增大对小目标的召回率提升非常明显。我做过对比实验同一个数据集640 输入下 mAP0.5 是 0.72960 输入下能到 0.81提升接近 10 个百分点。但增大输入尺寸的代价是推理速度下降和显存占用增加。960 输入的推理时间大约是 640 的 2.2 倍。所以这里要权衡如果是离线视频分析用 960 没问题如果是实时摄像头可能要在速度和精度之间找平衡点。还有一个折中方案训练时用大尺寸推理时用中等尺寸。因为训练时大尺寸能让模型学到更丰富的特征推理时适当缩小虽然会损失一点精度但速度能回来。不过这个方案需要验证不是所有任务都适用。4.3 训练参数配置和常见问题下面是我在红绿灯数据集上比较稳定的一套训练配置from ultralytics import YOLO model YOLO(yolo11s.pt) results model.train( datatraffic_light.yaml, epochs150, imgsz960, batch8, device0, workers4, optimizerAdamW, lr00.001, lrf0.01, momentum0.937, weight_decay0.0005, warmup_epochs3, cos_lrTrue, close_mosaic15, patience30, augmentTrue, hsv_h0.0, # 关键关闭色调增强 hsv_s0.5, hsv_v0.4, degrees0.0, # 红绿灯一般不会大角度旋转 translate0.1, scale0.5, fliplr0.5, mosaic1.0, mixup0.1, )这里有几个参数需要特别说明。hsv_h 设为 0是我反复强调的因为色调变化会破坏红绿灯的颜色语义。close_mosaic 设为 15表示最后 15 个 epoch 关闭 Mosaic 增强让模型在接近真实分布的数据上做微调这对最终精度有提升。degrees 设为 0是因为红绿灯在画面中基本是正立的旋转增强反而引入不真实的样本。训练过程中最常见的问题是过拟合。红绿灯数据集如果只有几千张图很容易在 50 个 epoch 后就开始过拟合验证集 loss 上升。这时候可以加大 weight_decay或者减少模型尺寸或者补充更多数据。另一个问题是类别不平衡红灯和绿灯样本多黄灯样本少导致黄灯检测效果差。解决办法是在数据集层面补充黄灯样本或者用类别权重。5. 推理封装从单张图片到实时摄像头5.1 统一推理接口的设计思路前面提到三种输入源要复用推理逻辑具体怎么设计我的做法是定义一个FrameSource抽象图片、视频、摄像头都实现这个接口对外只暴露read()和release()两个方法。class FrameSource: def read(self): raise NotImplementedError def release(self): raise NotImplementedError class ImageSource(FrameSource): def __init__(self, path): self.frame cv2.imread(path) self.done False def read(self): if self.done: return False, None self.done True return True, self.frame def release(self): pass class VideoSource(FrameSource): def __init__(self, path): self.cap cv2.VideoCapture(path) def read(self): return self.cap.read() def release(self): self.cap.release() class CameraSource(FrameSource): def __init__(self, index0): self.cap cv2.VideoCapture(index) self.cap.set(cv2.CAP_PROP_BUFFERSIZE, 1) def read(self): return self.cap.read() def release(self): self.cap.release()这样上层的推理循环完全不用关心输入源是什么代码非常干净。摄像头那里设置BUFFERSIZE1是个实用技巧能减少画面延迟否则摄像头会缓冲好几帧导致显示的画面比实际慢半秒以上。5.2 推理后处理和结果绘制YOLOv11 的results[0].plot()已经能画出不错的检测框但红绿灯场景我建议自定义绘制逻辑因为默认的颜色映射不一定符合直觉。比如你希望红灯的框是红色绿灯的框是绿色这样一眼就能看出来。COLOR_MAP { red: (0, 0, 255), yellow: (0, 255, 255), green: (0, 255, 0), } def draw_detections(frame, boxes, names): for box in boxes: cls_id int(box.cls[0]) conf float(box.conf[0]) label names[cls_id] color COLOR_MAP.get(label, (255, 255, 255)) x1, y1, x2, y2 map(int, box.xyxy[0]) cv2.rectangle(frame, (x1, y1), (x2, y2), color, 2) text f{label} {conf:.2f} cv2.putText(frame, text, (x1, y1 - 5), cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, 1) return frame这里有个细节置信度阈值不要设太低。红绿灯检测中低置信度的框很多是误检比如把车尾灯识别成红灯。我一般把 conf 设在 0.4 到 0.5 之间宁可漏检也不要误检因为误检在实际应用中会造成更严重的后果。5.3 视频推理的帧率控制和结果保存视频推理有个容易被忽略的问题处理速度和视频帧率不匹配。如果模型推理一帧需要 30ms而视频是 30fps每帧 33ms那基本能实时处理。但如果推理需要 50ms就会越来越慢最后内存里堆积大量帧。解决办法是跳帧处理或者异步读取。跳帧最简单每处理一帧就跳过若干帧适合对时间连续性要求不高的场景。异步读取是用一个单独的线程专门读帧推理线程从队列里取这样读帧和推理并行能提高吞吐。结果保存方面视频写入要用cv2.VideoWriter注意编码器和帧率要和输入匹配fourcc cv2.VideoWriter_fourcc(*mp4v) out cv2.VideoWriter(output.mp4, fourcc, fps, (width, height))注意mp4v编码器在部分环境下会报错如果遇到问题可以换成XVID配合.avi后缀。另外 VideoWriter 的尺寸必须和写入帧的尺寸完全一致否则写出来的视频会损坏。6. PyQt 界面集成让系统真正可用6.1 界面布局和控件设计PyQt 界面的设计原则是功能分区清晰操作路径短。我的布局是这样的左侧是控制面板包含输入源选择三个单选按钮、文件路径显示和选择按钮、置信度滑块、开始/停止按钮右侧是显示区域用一个 QLabel 承载视频帧底部是状态栏显示当前帧率、检测到的目标数量。控件选择上输入源用 QRadioButton 组文件选择用 QPushButton 触发 QFileDialog置信度用 QSlider范围 0-100映射到 0.0-1.0显示区域用 QLabel 配合 setScaledContents 或者手动缩放。class MainWindow(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle(红绿灯检测系统) self.resize(1200, 800) central QWidget() self.setCentralWidget(central) layout QHBoxLayout(central) # 左侧控制面板 control_panel QVBoxLayout() self.radio_image QRadioButton(图片) self.radio_video QRadioButton(视频) self.radio_camera QRadioButton(摄像头) self.radio_image.setChecked(True) # ... 添加其他控件 control_panel.addWidget(self.radio_image) # ... # 右侧显示 self.display_label QLabel() self.display_label.setMinimumSize(800, 600) self.display_label.setStyleSheet(background-color: #222;) layout.addLayout(control_panel, 1) layout.addWidget(self.display_label, 4)显示区域有个性能问题每帧都调用 setPixmap 会有开销。如果帧率很高界面会卡。优化方法是用 QTimer 控制刷新频率比如推理线程以 60fps 产出帧但界面只以 30fps 刷新中间丢弃一些帧。这样既保证了推理的连续性又不会让界面成为瓶颈。6.2 信号槽的线程安全实践PyQt 里跨线程更新界面必须用信号槽不能直接在线程里操作控件。这个规则大家都知道但实际写的时候容易犯的错是信号传递的对象类型不对。比如把 numpy 数组通过信号传递如果信号声明的是pyqtSignal(object)就没问题但如果声明成具体类型可能会出错。另一个坑是信号发送频率过高导致事件队列堆积。如果推理线程每 10ms 发一次信号而主线程处理不过来事件队列会越来越长界面延迟越来越大。解决办法是在信号槽连接时用Qt.QueuedConnection并且在槽函数里做节流或者用前面说的 QTimer 控制刷新。self.thread.frame_ready.connect(self.update_display, Qt.QueuedConnection) def update_display(self, frame): # 转成 QImage 再转 QPixmap rgb cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) h, w, ch rgb.shape qimg QImage(rgb.data, w, h, ch * w, QImage.Format_RGB888) pixmap QPixmap.fromImage(qimg) self.display_label.setPixmap(pixmap.scaled( self.display_label.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation))这里QImage构造时传入rgb.data有个隐患如果 rgb 数组在 QImage 使用前被回收会显示花屏。稳妥的做法是qimg qimg.copy()或者保持对 rgb 的引用。6.3 长期运行的稳定性处理如果这个系统要长时间运行比如路口监控跑一整天稳定性就是核心问题。我做过一个 72 小时的连续运行测试发现几个必须处理的点内存泄漏。PyQt 和 OpenCV 混合使用时如果不注意对象释放内存会缓慢增长。主要来源是 QImage/QPixmap 的频繁创建。解决办法是复用 QImage 缓冲区或者定期手动触发 gc。摄像头断连。USB 摄像头长时间运行可能会掉线VideoCapture.read() 返回 False。这时候要能自动重连而不是直接退出。我的做法是在读帧失败时计数连续失败超过阈值就 release 然后重新 open重试几次还不行才报错。显存增长。PyTorch 推理时如果不禁用梯度计算显存会持续增长。ultralytics 的 predict 默认是torch.no_grad()的但如果你自己写了后处理逻辑记得加with torch.no_grad():。日志和异常捕获。长时间运行一定要有日志记录每小时的帧率、检测数量、异常信息。用 Python 的 logging 模块写到文件出问题时能回溯。7. 实测中遇到的典型问题和解决思路7.1 夜间过曝和逆光场景的漏检这是红绿灯检测最头疼的问题。夜间摄像头自动增益拉高红绿灯周围形成光晕灯的实际颜色被冲淡模型容易漏检或者误判颜色。我试过几种方案第一种是在数据层面补充夜间样本这是最根本的。但夜间样本标注难度大光晕边界模糊标注一致性差。第二种是预处理做自适应直方图均衡CLAHE能改善对比度但对光晕本身没帮助。第三种是在推理时对高亮区域做特殊处理比如检测到过曝区域时降低该区域的置信度阈值。实测下来数据补充效果最明显CLAHE 有一定帮助但要调参第三种方案实现复杂收益有限。我的建议是优先做好数据别指望后处理能救回来。7.2 小目标在视频中的闪烁问题视频推理时同一个红绿灯在连续帧上可能一会儿检测到一会儿检测不到造成框的闪烁。这在演示时很难看。解决办法是引入帧间跟踪或者结果平滑。最简单的平滑是维护一个最近 N 帧的检测结果队列对同一个位置的目标做投票连续 3 帧中至少 2 帧检测到才显示。复杂一点可以用 ByteTrack 或者 SORT 做跟踪给每个目标分配 ID跟踪丢失几帧内仍然保留框。我用的是简化版投票机制实现简单效果也不错。关键是要根据目标位置做匹配不能简单按类别投票否则画面里多个红绿灯会混在一起。7.3 模型在不同分辨率视频上的表现差异训练时用的 960 输入推理时如果视频是 4K 的直接 resize 到 960 会丢失很多细节小目标更难检测。我的处理方式是先裁剪感兴趣区域再推理。红绿灯一般在画面上半部分可以裁掉下半部分这样同样的输入尺寸下红绿灯占的像素更多。如果不想裁剪可以分块推理把大图切成几块分别推理再合并。但这会成倍增加推理时间实时场景下不划算。所以最实用的还是根据场景做 ROI 裁剪这需要针对具体部署环境调整。8. 系统扩展方向和一些个人建议这套系统跑通之后可以往几个方向扩展。一是加入红绿灯状态时序分析不只是检测单帧而是分析红绿灯的变化周期预测下一次变灯时间这对辅助驾驶很有价值。二是多路视频并行处理一个路口多个摄像头需要做多线程或者多进程调度。三是模型量化和加速用 TensorRT 或者 ONNX Runtime 替换 PyTorch 推理速度能提升 2-3 倍适合边缘部署。我个人在实际操作中的体会是红绿灯检测这个任务数据质量的重要性远大于模型选择。我见过有人用 yolo11n 配合高质量数据集效果比用 yolo11l 配合脏数据好得多。所以如果你刚开始做别急着换模型、调参数先把数据集的覆盖度和标注质量做好。另外PyQt 界面这块不要一开始就追求功能大而全。先把图片推理跑通界面能显示结果然后再加视频和摄像头。每加一个功能就测试一遍确保没有引入回归问题。我一开始想一次性把三个输入源都写完结果调试时问题混在一起排查了很久。分步走每步验证看起来慢实际上快得多。最后分享一个小技巧在开发阶段把推理的中间结果原始帧、检测框坐标、置信度都写到日志里出问题时可以离线复现。尤其是视频和摄像头场景现场调试很不方便有了日志就能在本地慢慢分析。这个习惯帮我省了很多来回跑现场的时间。
返回列表