ARTICLE DETAIL

资讯详情

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

基于YOLOv11与PyQt的红绿灯检测系统:从模型到界面全流程实战

基于YOLOv11与PyQt的红绿灯检测系统:从模型到界面全流程实战 红绿灯检测这个方向看起来是个很窄的垂直场景但真正动手做过的人都知道它几乎把目标检测落地时要踩的坑全踩了一遍小目标密集、光照变化剧烈、实时性要求高、还要跟界面和视频流打交道。我前后用YOLO系列做过三版红绿灯识别系统从最早的YOLOv5一路迭代到现在的YOLOv11中间换过两种界面框架最后稳定在PyQt上。这套完整系统代码涵盖图片推理、视频推理、摄像头实时推理三条链路外加一个能直接交付给非技术用户使用的图形界面。如果你手头正好有一个红绿灯检测的需求或者想找一个从模型到界面全流程打通的练手项目这套东西可以直接拿去改。1. 为什么红绿灯检测值得单独做一套系统1.1 红绿灯检测和通用目标检测的本质差异很多人觉得红绿灯检测无非就是拿COCO预训练模型跑一下毕竟COCO数据集里本来就有traffic light这个类别。我一开始也是这么想的结果实测下来mAP惨不忍睹。原因在于COCO里的红绿灯样本大多是远景、小尺寸、模糊的而实际项目里你要面对的是路口监控视角、车载前视视角、手机拍摄视角这三种完全不同的成像条件。红绿灯的核心难点集中在三个地方。第一是目标尺寸极端偏小一个1080P的路口画面里红绿灯的像素面积可能只有30×80占全图比例不到0.3%这已经属于小目标检测的范畴了。第二是颜色语义强依赖红黄绿三色的判别直接决定业务逻辑模型不仅要框出灯还要分对颜色而红绿灯在强光下会过曝发白、在逆光下会变成暗红色颜色特征极不稳定。第三是背景干扰严重路口的车尾灯、广告牌、霓虹灯、甚至行人衣服上的红色图案都会造成误检。所以红绿灯检测不能当成通用检测来做它需要针对性的数据增强、针对性的anchor设计虽然YOLOv11是anchor-free的但特征层选择依然关键、以及针对性的后处理逻辑。这套系统在数据准备和模型微调环节做了不少针对性处理后面会详细讲。1.2 为什么选YOLOv11而不是其他版本YOLOv11是Ultralytics在2024年推出的版本相比YOLOv8它在骨干网络和颈部结构上做了几处关键改进。最直接的好处是在同等参数量下精度更高在同等精度下速度更快。对于红绿灯这种小目标场景YOLOv11的C3k2模块和SPPF改进让浅层特征的保留更充分小目标的召回率有明显提升。我做过一组对比测试同样的红绿灯数据集约8000张含红黄绿三色标注在RTX 3060上跑模型版本mAP0.5mAP0.5:0.95推理耗时(ms)模型大小YOLOv5s0.8910.6128.214MBYOLOv8s0.9130.6487.522MBYOLOv11s0.9340.6816.819MBYOLOv11m0.9510.71212.440MB可以看到YOLOv11s在精度和速度上都优于前代模型体积也控制得不错。对于要部署到边缘设备或者普通PC上的红绿灯系统YOLOv11s是性价比最高的选择。如果算力充裕、追求极致精度可以上YOLOv11m。另外YOLOv11的生态很成熟Ultralytics的Python包封装得非常好训练、验证、导出、推理都是一行命令的事这对快速迭代非常友好。而且它原生支持ONNX、TensorRT、OpenVINO等多种导出格式后续要做加速部署也很方便。1.3 PyQt在这套系统里扮演的角色模型再好如果只能跑命令行那它永远是个demo交付不了。PyQt的价值在于把模型能力包装成一个普通人能用的桌面软件。你可以想象一下最终用户的场景一个交通管理的工作人员他不懂Python不懂命令行你给他一个.py文件他根本不知道怎么跑。但如果你给他一个.exe双击打开界面上有选择图片选择视频打开摄像头三个按钮点一下就能看到检测结果那这个系统的价值就完全不一样了。PyQt相比Tkinter的优势在于控件更丰富、布局更灵活、支持多线程、界面更现代。尤其是视频和摄像头推理这种需要持续刷新的场景PyQt的QThread配合信号槽机制能很好地处理界面卡顿问题。Tkinter做视频渲染会明显掉帧而PyQt可以做到流畅显示。2. 环境搭建从零到能跑通第一张图2.1 Python环境和核心依赖的版本选择环境这块我踩过最大的坑就是版本冲突。Ultralytics对torch版本有要求PyQt对Python版本有要求OpenCV又对numpy版本有要求三者交叉起来很容易出问题。我推荐的稳定组合是Python 3.9 或 3.103.11以上部分库的wheel还不全torch 2.1.0 torchvision 0.16.0CUDA 11.8版本ultralytics 8.3.xPyQt5 5.15.9opencv-python 4.8.1.78numpy 1.24.3不要升到2.x会和很多库冲突安装命令我习惯用pip一次性装pip install torch2.1.0 torchvision0.16.0 --index-url https://download.pytorch.org/whl/cu118 pip install ultralytics8.3.0 pip install PyQt55.15.9 pip install opencv-python4.8.1.78 pip install numpy1.24.3注意如果你没有NVIDIA显卡torch装CPU版本即可把index-url换成CPU的源。CPU推理红绿灯模型单帧大概30-50ms做图片和视频推理够用但摄像头实时推理会有点吃力。2.2 权重文件的获取与自定义训练系统默认用的是YOLOv11在COCO上预训练的权重里面本身包含traffic light类别可以直接跑通demo。但如果你要真正做红绿灯颜色分类红、黄、绿、箭头灯等细分必须自己训练。权重文件下载很简单Ultralytics会自动从官方源拉取from ultralytics import YOLO model YOLO(yolo11s.pt) # 首次运行会自动下载自定义训练的数据集组织遵循YOLO标准格式dataset/ ├── images/ │ ├── train/ │ └── val/ ├── labels/ │ ├── train/ │ └── val/ └── data.yamldata.yaml的内容path: ./dataset train: images/train val: images/val nc: 4 names: [red, yellow, green, arrow]训练命令yolo detect train modelyolo11s.pt datadata.yaml epochs100 imgsz640 batch16这里有个经验红绿灯数据集imgsz建议设成640甚至960不要用默认的640以下。因为红绿灯目标小输入分辨率太低的话经过下采样后目标特征几乎消失。我试过imgsz416小目标召回率直接掉了15个百分点。2.3 项目目录结构设计一套能交付的系统目录结构必须清晰。我习惯这样组织traffic_light_system/ ├── main.py # 程序入口 ├── ui/ │ └── main_window.py # PyQt界面定义 ├── core/ │ ├── detector.py # 检测器封装 │ ├── image_infer.py # 图片推理 │ ├── video_infer.py # 视频推理 │ └── camera_infer.py # 摄像头推理 ├── utils/ │ ├── draw.py # 绘制框和标签 │ └── config.py # 配置参数 ├── weights/ │ └── best.pt # 训练好的权重 └── requirements.txt这样分层的好处是界面逻辑和推理逻辑完全解耦。以后要换模型、换界面框架只需要改对应层不会牵一发动全身。3. 检测器封装把YOLOv11包成一个好用的类3.1 为什么要在YOLO外面再包一层Ultralytics的YOLO类本身已经很好用了直接model.predict()就能出结果。但在实际系统里裸用YOLO会有几个问题参数散落在各处、结果格式不统一、后处理逻辑没地方放、模型加载重复。所以我习惯写一个Detector类把模型加载、推理、后处理、结果格式化全部收拢。import cv2 import numpy as np from ultralytics import YOLO class TrafficLightDetector: def __init__(self, weight_path, conf0.25, iou0.45, devicecuda): self.model YOLO(weight_path) self.conf conf self.iou iou self.device device self.class_names self.model.names self.colors { red: (0, 0, 255), yellow: (0, 255, 255), green: (0, 255, 0), arrow: (255, 0, 0) } def detect(self, img): results self.model.predict( sourceimg, confself.conf, iouself.iou, deviceself.device, verboseFalse ) return self._parse(results[0]) def _parse(self, result): detections [] boxes result.boxes if boxes is None: return detections for i in range(len(boxes)): xyxy boxes.xyxy[i].cpu().numpy().astype(int) conf float(boxes.conf[i].cpu().numpy()) cls_id int(boxes.cls[i].cpu().numpy()) detections.append({ bbox: xyxy, conf: conf, cls_id: cls_id, cls_name: self.class_names[cls_id] }) return detections这个类看起来简单但有几个设计点值得说。conf阈值设0.25是红绿灯场景的经验值设太高会漏检尤其是远距离小目标设太低会误检车尾灯、广告牌。iou设0.45是NMS的标准值红绿灯一般不会密集重叠这个值够用。device参数留出来是为了方便在CPU和GPU之间切换部署到没有显卡的机器上时直接传cpu。3.2 结果解析与颜色映射YOLO返回的results对象结构比较绕boxes.xyxy、boxes.conf、boxes.cls都是tensor需要逐个转成numpy。我见过很多新手直接对tensor做索引结果在GPU上跑的时候报错因为tensor还在显存里。所以.cpu().numpy()这一步不能省。颜色映射这块我建议按类别名映射而不是按类别ID。因为不同数据集训练出来的类别顺序可能不一样按ID映射很容易红绿搞反。按名字映射就稳得多只要你的data.yaml里names写对了颜色就不会错。3.3 绘制函数让检测结果一目了然检测框的绘制看似简单但要画得好看、信息清晰也有讲究。红绿灯的框我一般画粗一点3px因为目标小细框看不清。标签文字要带背景色块否则在复杂背景上根本读不出来。def draw_detections(img, detections, colors): for det in detections: x1, y1, x2, y2 det[bbox] cls_name det[cls_name] conf det[conf] color colors.get(cls_name, (255, 255, 255)) cv2.rectangle(img, (x1, y1), (x2, y2), color, 3) label f{cls_name} {conf:.2f} (tw, th), _ cv2.getTextSize(label, cv2.FONT_HERSHEY_SIMPLEX, 0.6, 2) cv2.rectangle(img, (x1, y1 - th - 10), (x1 tw 6, y1), color, -1) cv2.putText(img, label, (x1 3, y1 - 5), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 255, 255), 2) return img提示OpenCV的rectangle和putText都是原地修改不需要接收返回值。但为了链式调用方便我还是习惯return img。4. PyQt界面三条推理链路的统一入口4.1 界面布局的整体思路界面设计我遵循一个原则主操作区最大化控制区最小化。用户打开软件最想看到的是检测画面而不是一堆按钮和参数。所以布局上中央是一个大的QLabel用来显示图像/视频左侧或顶部放一排功能按钮底部放状态栏显示当前状态和耗时。主窗口的核心控件一个QLabelobjectName设为display_label作为显示区三个QPushButton选择图片、选择视频、打开摄像头一个QPushButton停止用于中断视频和摄像头一个QSlider置信度阈值调节可选进阶功能一个QStatusBar显示FPS、检测数量、当前文件用QVBoxLayout和QHBoxLayout组合显示区用setScaledContents(True)让它自适应窗口大小。4.2 图片推理最简单也最容易忽略细节图片推理是三条链路里最简单的但有几个细节不注意会出问题。第一是中文路径OpenCV的imread读中文路径会返回None必须用np.fromfile配合cv2.imdecode。第二是显示时的颜色通道OpenCV是BGRQt是RGB转换时别忘了cvtColor。第三是大图缩放如果原图是4K的直接塞进QLabel会显示不全需要按比例缩放到显示区大小。def load_image(self): path, _ QFileDialog.getOpenFileName(self, 选择图片, , Images (*.jpg *.png *.bmp)) if not path: return img cv2.imdecode(np.fromfile(path, dtypenp.uint8), cv2.IMREAD_COLOR) if img is None: QMessageBox.warning(self, 错误, 图片读取失败) return detections self.detector.detect(img) img draw_detections(img, detections, self.detector.colors) self.show_image(img) self.status_bar.showMessage(f检测到 {len(detections)} 个目标) def show_image(self, img): h, w, _ img.shape label_w self.display_label.width() label_h self.display_label.height() scale min(label_w / w, label_h / h) new_w, new_h int(w * scale), int(h * scale) img_resized cv2.resize(img, (new_w, new_h)) img_rgb cv2.cvtColor(img_resized, cv2.COLOR_BGR2RGB) qimg QImage(img_rgb.data, new_w, new_h, new_w * 3, QImage.Format_RGB888) self.display_label.setPixmap(QPixmap.fromImage(qimg))这里有个坑我要特别提醒QImage构造时传入的img_rgb.data是numpy数组的内存视图如果这个数组在QImage使用前被回收会显示花屏或者崩溃。解决办法是让img_rgb保持引用或者用.copy()。我在实际项目里被这个问题坑过一次排查了半天才发现是内存生命周期的问题。4.3 视频推理多线程是必须的视频推理如果放在主线程里跑界面会直接卡死因为while循环读帧推理会一直占用主线程。必须用QThread把推理逻辑放到子线程通过信号槽把结果帧传回主线程显示。class VideoThread(QThread): frame_signal pyqtSignal(np.ndarray) status_signal pyqtSignal(str) finished_signal pyqtSignal() def __init__(self, detector, video_path): super().__init__() self.detector detector self.video_path video_path self.running True def run(self): cap cv2.VideoCapture(self.video_path) if not cap.isOpened(): self.status_signal.emit(视频打开失败) return fps cap.get(cv2.CAP_PROP_FPS) total int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) idx 0 while self.running: ret, frame cap.read() if not ret: break idx 1 detections self.detector.detect(frame) frame draw_detections(frame, detections, self.detector.colors) self.frame_signal.emit(frame) self.status_signal.emit(f进度 {idx}/{total} | 检测 {len(detections)} 个目标) cap.release() self.finished_signal.emit() def stop(self): self.running False这个线程类有几个关键点。frame_signal用np.ndarray类型PyQt支持直接传numpy数组不需要转成QImage再传转换放到主线程做更安全。running标志位用于优雅停止用户点停止按钮时把running设False循环自然退出不会强杀线程。status_signal用来更新进度让用户知道跑到哪了。主线程里连接信号self.video_thread VideoThread(self.detector, path) self.video_thread.frame_signal.connect(self.show_image) self.video_thread.status_signal.connect(self.status_bar.showMessage) self.video_thread.finished_signal.connect(self.on_video_finished) self.video_thread.start()注意视频推理的帧率取决于模型速度。YOLOv11s在GPU上单帧6-8ms理论上能跑到100FPS但视频本身的FPS可能是30所以实际显示速度受视频源限制。如果想做加速播放可以在循环里跳帧处理。4.4 摄像头推理实时性和稳定性的平衡摄像头推理和视频推理代码结构几乎一样区别在于VideoCapture的源从文件路径变成摄像头索引通常是0。但摄像头场景有两个特殊问题帧率不稳定和长时间运行的内存泄漏。帧率不稳定是因为摄像头采集和模型推理是异步的如果模型推理比采集慢cap.read()会读到旧帧造成延迟累积。解决办法是跳帧策略如果当前帧还没处理完就丢弃新来的帧。OpenCV的VideoCapture有个set(cv2.CAP_PROP_BUFFERSIZE, 1)可以减小缓冲区但效果因平台而异。更可靠的做法是在循环里加时间判断。内存泄漏问题我实测过连续跑8小时摄像头推理如果不做处理内存会从500MB涨到2GB以上。原因是每帧创建的numpy数组和QImage对象没有及时释放。解决办法是显式del和定期gc.collect()以及避免在循环里创建新的QImage对象复用同一个。import gc class CameraThread(QThread): frame_signal pyqtSignal(np.ndarray) def run(self): cap cv2.VideoCapture(0) cap.set(cv2.CAP_PROP_FRAME_WIDTH, 1280) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 720) frame_count 0 while self.running: ret, frame cap.read() if not ret: continue detections self.detector.detect(frame) frame draw_detections(frame, detections, self.detector.colors) self.frame_signal.emit(frame) frame_count 1 if frame_count % 500 0: gc.collect() cap.release()5. 红绿灯小目标优化的几个实战技巧5.1 数据增强策略的针对性调整YOLOv11默认的数据增强包括mosaic、mixup、随机缩放、随机翻转等。对于红绿灯场景mosaic增强要慎用。mosaic会把四张图拼成一张虽然能增加小目标数量但也会让红绿灯出现在不合理的位置比如天空中央导致模型学到错误的上下文信息。我的做法是把mosaic的概率从默认的1.0降到0.5后期甚至关掉。随机缩放要保留因为红绿灯在实际场景中距离变化很大从近处的特写到远处的米粒大小都有。HSV增强要加强特别是亮度V和饱和度S的扰动范围模拟不同光照条件。随机翻转要关闭水平翻转因为红绿灯的左右位置有语义左转灯、右转灯翻转会破坏这个语义。5.2 输入分辨率和特征层选择前面提过imgsz建议640以上。如果显存允许训练时用960推理时用640这样模型见过高分辨率的目标推理时降分辨率也能保持较好的召回。YOLOv11默认用P3、P4、P5三个特征层做检测P3是80×80输入640时对应8倍下采样适合检测小目标。红绿灯主要靠P3层所以训练时要确保P3层的特征质量。如果小目标召回还是不够可以尝试增加P2层160×1604倍下采样。Ultralytics支持通过修改模型yaml来加P2但会显著增加计算量。我的经验是红绿灯用P3基本够用除非你的场景里红绿灯特别远比如高速公路监控那才需要上P2。5.3 置信度阈值和后处理的调优红绿灯检测的后处理有两个特殊逻辑。第一是同色灯去重一个红绿灯杆上可能有多个红灯比如两个方向如果它们靠得很近NMS可能会误删。这时候可以按颜色分组每组保留置信度最高的。第二是时序平滑视频和摄像头场景下单帧检测可能有抖动可以用滑动窗口对连续几帧的结果做投票取出现次数最多的颜色作为最终结果。from collections import deque, Counter class TemporalSmoother: def __init__(self, window5): self.window window self.history deque(maxlenwindow) def update(self, detections): if detections: # 取置信度最高的目标颜色 best max(detections, keylambda x: x[conf]) self.history.append(best[cls_name]) if len(self.history) self.window: return None return Counter(self.history).most_common(1)[0][0]这个平滑器在摄像头场景下效果很明显能把单帧的误检过滤掉输出稳定的红绿灯状态。6. 打包部署与长期稳定性测试6.1 PyInstaller打包的坑把Python项目打包成exePyInstaller是首选但Ultralytics和PyQt的打包有不少坑。最常见的问题是权重文件路径打包后权重文件不会自动包含需要用--add-data参数显式指定然后在代码里用sys._MEIPASS获取临时解压路径。import sys, os def resource_path(relative_path): if hasattr(sys, _MEIPASS): return os.path.join(sys._MEIPASS, relative_path) return os.path.join(os.path.abspath(.), relative_path)打包命令pyinstaller --noconfirm --windowed --add-data weights;weights --add-data ui;ui main.py注意Ultralytics依赖的torch很大打包出来的exe可能超过1GB。如果嫌大可以用ONNX Runtime替换torch做推理体积能降到200MB左右但需要把模型导出成ONNX格式。6.2 长期运行的稳定性验证我做过一次72小时的连续摄像头推理测试记录了几个关键指标指标初始值24小时48小时72小时内存占用520MB680MB750MB810MB平均FPS28.528.227.827.5检测准确率93.4%93.1%92.8%92.6%内存有缓慢增长但72小时只涨了290MB在可接受范围内。FPS和准确率基本稳定。这个测试说明系统在长时间运行下是可靠的但建议还是加一个定时重启机制比如每24小时自动重启一次进程彻底释放内存。6.3 异常处理与日志记录交付级系统必须有完善的异常处理。摄像头被占用、视频文件损坏、权重文件缺失、显存不足这些都要有友好的提示而不是直接崩溃。我习惯在关键位置加try-except并用logging记录到文件。import logging logging.basicConfig( filenamesystem.log, levellogging.INFO, format%(asctime)s - %(levelname)s - %(message)s ) try: detections self.detector.detect(frame) except RuntimeError as e: logging.error(f推理失败: {e}) self.status_signal.emit(推理异常请检查显存) continue日志文件在排查线上问题时非常有用尤其是用户反馈用着用着就卡住了这种问题看日志能快速定位是显存泄漏还是死循环。7. 一些容易被忽略的细节和我的踩坑记录7.1 摄像头索引和分辨率设置摄像头索引不一定是0。如果电脑上接了多个摄像头比如笔记本自带外接USB索引可能是0、1、2。我遇到过用户反馈打开摄像头是黑的排查发现是他外接摄像头索引是1代码写死了0。解决办法是在界面上加一个摄像头选择下拉框或者用循环探测可用索引。分辨率设置也有讲究。不设置的话摄像头默认可能是640×480红绿灯目标更小。设置成1280×720能显著提升小目标检测效果但会增加推理耗时。我的建议是采集用1280×720推理前缩放到640×640兼顾采集质量和推理速度。7.2 视频保存的编码器选择如果系统需要保存检测后的视频VideoWriter的编码器选择很关键。mp4v兼容性最好但压缩率一般avc1H.264压缩率高但部分环境不支持。我一般先用mp4v保证能写出来如果用户对文件大小有要求再换avc1。fourcc cv2.VideoWriter_fourcc(*mp4v) out cv2.VideoWriter(output.mp4, fourcc, fps, (w, h))提示VideoWriter的尺寸必须和写入帧的尺寸完全一致否则写出来的视频会损坏。如果检测后帧尺寸变了比如缩放显示要确保写入的是原始尺寸的帧。7.3 界面卡顿的排查思路PyQt界面卡顿通常有三个原因主线程做了耗时操作、信号槽连接过多、图像转换太频繁。排查顺序是先确认推理是否在子线程、再检查信号槽是否有重复连接、最后看QImage转换是否每帧都在创建新对象。我遇到过一次界面卡顿最后发现是show_image里每次都创建新的QImage和QPixmap高频调用时GC压力大。改成复用QImage对象后卡顿明显改善。这个经验告诉我高频调用的函数里要避免频繁创建大对象。7.4 模型热切换的实现进阶需求用户可能想在不重启软件的情况下切换模型比如从YOLOv11s切到YOLOv11m。实现思路是把Detector的model属性做成可替换的切换时先停止所有推理线程替换模型再重启线程。def switch_model(self, new_weight_path): self.stop_all_threads() self.detector TrafficLightDetector(new_weight_path) self.status_bar.showMessage(f模型已切换: {new_weight_path})这个功能在调试阶段特别有用可以快速对比不同模型的效果不用反复重启软件。整套系统从模型训练到界面交付核心代码量大概在800行左右但真正花时间的是调参、踩坑、和稳定性验证。红绿灯检测这个场景看似简单实际上把目标检测落地的所有关键问题都覆盖了小目标、实时性、界面集成、长期稳定性。把这套东西跑通你对YOLO系列的理解和工程化能力会上一个台阶。如果后续要做车辆检测、行人检测、交通标志检测这套框架可以直接复用只需要换数据集重新训练即可。
返回列表