1. 从红绿灯检测说起:这套系统到底解决了什么问题
红绿灯识别这件事,听起来像是自动驾驶里的一个小模块,但真正动手做过的人都知道,它是个典型的"看起来简单、做起来全是坑"的任务。路口的光照变化、红绿灯本身的尺寸差异、远近视角下的尺度跳变、夜间过曝和欠曝、雨雾天气的散射,再加上红绿灯在整张图里往往只占几十个像素,这些因素叠在一起,让通用目标检测模型直接拿来用的时候,召回率经常惨不忍睹。
我这次做的这套系统,核心目标很明确:用 YOLOv11 作为检测骨干,配一套 PyQt 桌面界面,把图片推理、视频推理、摄像头实时推理三条链路全部打通,做成一个能直接跑起来、能演示、能二次开发的完整工程。它不是那种只给你一个detect.py脚本的玩具,而是包含了界面交互、模型加载、结果可视化、结果保存、线程管理的完整闭环。适合谁看?如果你是刚接触目标检测、想找一个能跑通的完整项目练手的学生,或者你是需要快速搭一个检测 Demo 给客户看的工程师,再或者你想在红绿灯这个具体场景上做优化,这套东西都能直接拿来当底座。
关键词里出现的 YOLOv11、PyQt、目标检测、摄像头推理、视频推理,基本就是这套系统的全部技术栈。我下面会从整体设计思路讲起,然后拆核心细节、实操流程、踩坑记录,尽量把每个"为什么这么选"讲清楚,而不是只丢一堆代码让你自己猜。
2. 整体架构设计与技术选型拆解
2.1 为什么是 YOLOv11 而不是别的检测器
选检测器这件事,我一般看三个维度:精度、速度、工程友好度。红绿灯检测对速度的要求其实挺高的,尤其是摄像头实时推理这条链路,你至少得跑到 25 FPS 以上,画面才不会卡成幻灯片。两阶段检测器像 Faster R-CNN 那一类,精度是不错,但推理速度在普通显卡上很难满足实时要求,而且部署链路更长。
YOLOv11 相比前几代,在骨干网络和特征融合部分做了调整,比较直观的感受是小目标的召回有提升,这对红绿灯这种小目标场景是实打实的利好。另外它挂在 Ultralytics 这套框架下,训练、验证、导出、推理的 API 高度统一,你换个模型基本不用改业务代码。工程友好度这一项,Ultralytics 是真的省心,model.predict()一行就能出结果,返回的 Results 对象里 boxes、conf、cls 都给你整理好了,省掉了大量后处理胶水代码。
提示:选 YOLOv11 不代表它一定比 YOLOv8 强很多,如果你的数据集很小、类别很简单,v8 和 v11 的差距可能就在一两个点。但如果你要做小目标优化、要长期维护,v11 的生态和后续支持更值得投入。
2.2 PyQt 作为界面层的取舍
界面这块,Python 桌面开发能选的无非就那几个:Tkinter、PyQt/PySide、wxPython、Kivy。Tkinter 上手快但控件丑、布局能力弱,做视频显示这种需要频繁刷新的场景会很难受。Kivy 更适合触屏和移动端风格。PyQt 的优势在于控件成熟、信号槽机制清晰、和 OpenCV 的 QImage 转换链路成熟,社区里现成的例子多,遇到问题好搜。
我选 PyQt5 而不是 PyQt6,主要是考虑兼容性。很多人的环境里 OpenCV、NumPy 的版本和 PyQt6 搭配时偶尔会有坑,PyQt5 相对稳。信号槽机制在这里特别关键:摄像头推理必须放在子线程里跑,主线程只负责刷新界面,否则界面直接假死。PyQt 的QThread加pyqtSignal就是干这个的,比你自己手写 threading 加队列要清爽得多。
2.3 三条推理链路的统一抽象
图片、视频、摄像头,看起来是三件事,但本质上都是"拿到一帧 → 送进模型 → 拿到结果 → 画框 → 显示或保存"。所以我在设计上做了一个统一抽象:不管来源是什么,最终都归一成一个frame(numpy 数组),然后走同一个推理和绘制函数。
这样做的好处是,模型加载只做一次,绘制逻辑只写一份,保存逻辑也只写一份。图片推理就是"读一张图当一帧",视频推理就是"循环读帧",摄像头推理就是"从 VideoCapture(0) 循环读帧"。区别只在于帧的来源和循环的控制方式,核心处理完全复用。这个思路能帮你省掉大量重复代码,后期改绘制样式也只改一个地方。
| 链路 | 帧来源 | 循环方式 | 线程要求 | 结果去向 |
|---|---|---|---|---|
| 图片推理 | cv2.imread | 单次 | 主线程可接受 | 界面显示 + 可选保存 |
| 视频推理 | cv2.VideoCapture(路径) | while 循环 | 建议子线程 | 界面显示 + 可选写出视频 |
| 摄像头推理 | cv2.VideoCapture(0) | while 循环 | 必须子线程 | 界面显示 + 可选截图保存 |
2.4 目录结构规划
一个能长期维护的项目,目录结构一开始就要想清楚。我习惯这样分:
project/ ├── models/ # 权重文件 │ └── best.pt ├── ui/ # 界面文件 │ └── main_window.ui ├── core/ │ ├── detector.py # 检测封装 │ └── drawer.py # 绘制封装 ├── workers/ │ ├── video_worker.py # 视频推理线程 │ └── camera_worker.py # 摄像头推理线程 ├── utils/ │ └── image_utils.py # 图像转换工具 ├── main.py # 入口 └── requirements.txt把检测、绘制、线程、工具各自拆开,好处是单元测试好写,出问题定位快。比如画面卡顿,你基本能确定是 worker 的问题;框画歪了,去 drawer 里找。别把所有东西塞进一个 main.py,几百行之后你自己都不想看。
3. 核心细节解析与实操要点
3.1 环境配置:版本对齐是第一步
环境这块,我踩过最多的坑就是版本冲突。Ultralytics 对 torch 的版本有要求,OpenCV 和 PyQt5 之间偶尔也会因为 Qt 插件打架。我实测比较稳的一套组合是:
pip install ultralytics==8.3.0 pip install torch==2.1.0 torchvision==0.16.0 pip install opencv-python==4.8.1.78 pip install PyQt5==5.15.9 pip install numpy==1.24.3这里有几个点要解释。torch 选 2.1.0 是因为它和 ultralytics 8.3.x 搭配时 CUDA 兼容性好,如果你用更新的 torch,有时候会遇到算子不支持的问题。opencv-python 选 4.8.1 是因为再往上的版本在某些系统上和 PyQt5 的 Qt 库会有符号冲突,表现为一开摄像头就崩。numpy 锁在 1.24 是因为 2.x 版本改了一些 API,部分老代码会报错。
注意:如果你没有独立显卡,装 CPU 版 torch 就行,
pip install torch --index-url https://download.pytorch.org/whl/cpu。CPU 推理红绿灯这种小模型,单帧大概 100-200ms,做图片和视频够用,摄像头实时会有点吃力。
3.2 模型加载与推理封装
检测器我封装成一个类,初始化时加载模型,推理时接收帧返回结果。核心逻辑大概是这样:
from ultralytics import YOLO import numpy as np class TrafficLightDetector: def __init__(self, weight_path, conf=0.25, iou=0.45, device='cuda'): self.model = YOLO(weight_path) self.conf = conf self.iou = iou self.device = device def detect(self, frame): results = self.model.predict( source=frame, conf=self.conf, iou=self.iou, device=self.device, verbose=False ) return results[0]conf和iou这两个参数是调优的重点。红绿灯检测里,conf设太高会漏检远处的小灯,设太低会误检一堆红色圆形物体。我一般从 0.25 起步,根据实际漏检和误检情况微调。iou控制 NMS 的合并阈值,红绿灯一般不会密集重叠,0.45 到 0.5 都行。
verbose=False这个参数别小看,默认情况下 Ultralytics 每推理一次就往控制台打一堆日志,摄像头实时推理时控制台会刷屏,拖慢速度。关掉之后清爽很多。
3.3 绘制逻辑:框、标签、置信度怎么画才清楚
检测结果画在图上,看起来简单,但要画得清楚有讲究。红绿灯本身很小,框如果画太粗会盖住灯,画太细又看不清。我的做法是框线宽根据图像尺寸动态计算:
import cv2 def draw_boxes(frame, boxes, names, color_map): h, w = frame.shape[:2] thickness = max(2, int(min(h, w) / 400)) font_scale = max(0.5, min(h, w) / 1000) for box in boxes: x1, y1, x2, y2 = map(int, box.xyxy[0]) cls_id = int(box.cls[0]) conf = float(box.conf[0]) label = f"{names[cls_id]} {conf:.2f}" color = color_map.get(cls_id, (0, 255, 0)) cv2.rectangle(frame, (x1, y1), (x2, y2), color, thickness) (tw, th), _ = cv2.getTextSize(label, cv2.FONT_HERSHEY_SIMPLEX, font_scale, 1) cv2.rectangle(frame, (x1, y1 - th - 8), (x1 + tw + 4, y1), color, -1) cv2.putText(frame, label, (x1 + 2, y1 - 4), cv2.FONT_HERSHEY_SIMPLEX, font_scale, (255, 255, 255), 1) return frame给不同类别配不同颜色,比如红灯红框、绿灯绿框、黄灯黄框,这样一眼就能分辨。标签背景填充色块再写白字,比直接写彩色字在复杂背景上清楚得多。这个细节在演示的时候特别加分。
3.4 图像格式转换:OpenCV 和 Qt 之间的桥
这是 PyQt 加 OpenCV 组合里最容易出问题的地方。OpenCV 读进来的是 BGR 格式的 numpy 数组,Qt 的 QImage 要的是 RGB,而且对内存连续性有要求。转换函数必须写对:
from PyQt5.QtGui import QImage, QPixmap import cv2 def cvimg_to_qpixmap(frame): rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) h, w, ch = rgb.shape bytes_per_line = ch * w qimg = QImage(rgb.data, w, h, bytes_per_line, QImage.Format_RGB888) return QPixmap.fromImage(qimg.copy())这里qimg.copy()那一步千万别省。QImage 构造时用的是 rgb.data 的指针,如果 rgb 这个 numpy 数组在函数返回后被回收,QImage 指向的就是野内存,界面会显示花屏或者直接崩。.copy()会深拷贝一份数据,虽然多一次内存拷贝,但稳定。我一开始就是省了这一步,调试了半天才发现是内存问题。
3.5 线程模型:为什么摄像头必须开子线程
Qt 的主线程负责事件循环和界面刷新,如果你在主线程里写一个while True读摄像头,界面直接卡死,按钮点不动,窗口拖不动。所以摄像头和视频推理必须放到 QThread 里。
我的做法是定义一个 Worker 类,继承 QThread,重写 run 方法,在里面循环读帧、推理、发信号:
from PyQt5.QtCore import QThread, pyqtSignal import cv2 class CameraWorker(QThread): frame_ready = pyqtSignal(object) error_occurred = pyqtSignal(str) def __init__(self, detector, camera_id=0): super().__init__() self.detector = detector self.camera_id = camera_id self.running = False def run(self): cap = cv2.VideoCapture(self.camera_id) if not cap.isOpened(): self.error_occurred.emit("摄像头打开失败") return self.running = True while self.running: ret, frame = cap.read() if not ret: break result = self.detector.detect(frame) annotated = draw_boxes(frame, result.boxes, result.names, COLOR_MAP) self.frame_ready.emit(annotated) cap.release() def stop(self): self.running = False self.wait()frame_ready信号把画好框的帧发给主线程,主线程收到后转成 QPixmap 显示。这样推理在子线程,显示在主线程,两边互不阻塞。stop()里先置 running 为 False 再 wait,保证线程干净退出,不然关窗口时容易残留进程。
4. 完整实操流程与关键环节实现
4.1 界面布局搭建
界面我用 Qt Designer 拖了一个基础布局,左边是显示区(一个 QLabel 占大头),右边是控制区(按钮、下拉框、滑块)。控制区包含:模型选择下拉框、置信度滑块、IoU 滑块、图片推理按钮、视频推理按钮、摄像头开关按钮、保存结果复选框。
用 .ui 文件的好处是改布局不用动代码,pyuic5 main_window.ui -o ui_main.py一转换就行。但要注意,每次改完 ui 都要重新转换,别改了 ui 忘了转,然后对着旧界面纳闷为什么没变化。我自己就干过这事。
置信度滑块我绑定了实时更新,拖动时把值传给 detector 的 conf 属性。这样用户可以在推理过程中动态调阈值,看不同阈值下的效果,演示时很实用。
4.2 图片推理链路实现
图片推理最简单,点按钮 → 弹文件选择框 → 读图 → 推理 → 显示 → 可选保存。
def on_image_infer(self): path, _ = QFileDialog.getOpenFileName(self, "选择图片", "", "Images (*.jpg *.png *.bmp)") if not path: return frame = cv2.imread(path) if frame is None: QMessageBox.warning(self, "错误", "图片读取失败") return result = self.detector.detect(frame) annotated = draw_boxes(frame, result.boxes, result.names, COLOR_MAP) self.show_frame(annotated) if self.save_checkbox.isChecked(): out_path = path.rsplit('.', 1)[0] + "_result.jpg" cv2.imwrite(out_path, annotated)保存路径我用原文件名加_result后缀,避免覆盖原图。这个习惯很重要,尤其是批量处理的时候,覆盖原图是不可逆的。
4.3 视频推理链路实现
视频推理和摄像头结构几乎一样,区别是帧来源是文件,而且要考虑播放速度。如果推理比视频原始帧率快,直接循环会播放得飞快,所以要加一个基于帧率的延时:
fps = cap.get(cv2.CAP_PROP_FPS) delay = max(1, int(1000 / fps)) if fps > 0 else 30每处理完一帧self.msleep(delay),让播放速度接近原视频。如果推理本身比视频慢,那就不用延时了,能跑多快跑多快。
视频保存这块,如果要写出带框的视频,得用cv2.VideoWriter,注意编码器和帧尺寸要和原视频一致:
fourcc = cv2.VideoWriter_fourcc(*'mp4v') writer = cv2.VideoWriter(out_path, fourcc, fps, (w, h))mp4v编码器兼容性好,但有些环境里写出来的视频播放器打不开,可以换avc1试试。帧尺寸必须和 writer 初始化时一致,否则写出来的视频是坏的。
4.4 摄像头推理链路实现
摄像头链路的关键点有三个:打开失败的处理、帧率控制、退出清理。
打开失败要给出明确提示,而不是静默失败。有些笔记本有多个摄像头,或者外接 USB 摄像头,camera_id 可能是 0、1、2,界面上最好给个下拉框让用户选。
帧率控制上,摄像头本身有采集帧率,你推理完直接读下一帧就行,不用额外延时。但如果推理特别快,读到的可能是重复帧,这时候加个很小的 sleep 反而更稳。
退出清理是最容易忘的。用户点关闭摄像头按钮或者直接关窗口时,必须确保cap.release()被调用,线程被 join。我在 closeEvent 里统一处理:
def closeEvent(self, event): if self.camera_worker and self.camera_worker.isRunning(): self.camera_worker.stop() event.accept()不写这个,程序关了摄像头灯还亮着,下次打开就报设备占用。
4.5 模型切换与热加载
界面上放模型下拉框,是为了方便对比不同权重。切换模型时重新实例化 detector 就行,但要注意如果摄像头正在跑,得先停掉再换,不然会出问题。
def on_model_changed(self, model_name): if self.camera_worker and self.camera_worker.isRunning(): self.camera_worker.stop() weight_path = os.path.join("models", model_name) self.detector = TrafficLightDetector(weight_path, device=self.device)热加载的耗时主要在模型初始化,YOLOv11 小模型加载大概一两秒,界面上给个"加载中"的提示体验会好很多。
5. 常见问题与排查技巧实录
5.1 推理速度慢怎么办
这是问得最多的问题。排查顺序我一般这样走:
| 现象 | 可能原因 | 排查方法 | 解决方向 |
|---|---|---|---|
| 单帧超过 500ms | 用了 CPU 推理 | 打印torch.cuda.is_available() | 装 GPU 版 torch |
| 单帧 100-300ms | 模型太大 | 看权重文件大小 | 换 n 或 s 版本 |
| 帧率忽高忽低 | 图像预处理耗时 | 计时各阶段 | 缩小输入尺寸 |
| 首帧特别慢 | 模型首次加载 | 看是否只在第一帧慢 | 预热一次推理 |
GPU 推理时,第一次推理会包含 CUDA 初始化,特别慢,之后才正常。所以我在 detector 初始化后会用一张空白图预热一次,避免用户点第一次推理时等半天。
输入尺寸也是个关键。YOLOv11 默认 640,如果你摄像头分辨率是 1080p,模型内部会缩放,但读帧和显示还是全尺寸,这部分开销不小。可以在读帧后先 resize 到 720p 再推理和显示,速度能提升不少。
5.2 检测框抖动严重
摄像头实时推理时,框会轻微抖动,这是因为每帧的检测结果有细微差异。如果只是演示,可以接受;如果要看起来稳,可以加一个简单的跟踪或者结果平滑。
最简单的平滑是维护一个历史框队列,对同一个目标的位置做滑动平均。但红绿灯场景里目标基本不动,其实更简单的做法是提高 conf 阈值,减少低置信度的抖动检测。我实测把 conf 从 0.25 提到 0.4,抖动明显减少,代价是偶尔漏检远处的灯。
5.3 界面卡顿或假死
界面卡顿九成是线程问题。检查两点:一是推理是不是在主线程跑的,二是信号发送频率是不是太高。
信号发送频率这块,如果摄像头 30 FPS,每帧都发信号,主线程每秒要处理 30 次图像转换和显示,压力不小。可以在 worker 里做个降频,比如每两帧发一次,显示 15 FPS,肉眼看起来也够流畅,主线程压力减半。
还有一种假死是模态对话框引起的。比如推理过程中弹了个 QMessageBox,主线程被阻塞,界面就不刷新了。所以推理过程中的提示尽量用状态栏文字,别用模态框。
5.4 摄像头打不开或黑屏
这个问题的排查链路比较长,我整理成清单:
- 检查 camera_id 是否正确,试试 0、1、2
- 检查摄像头是否被其他程序占用(比如刚关掉的自己)
- Linux 下检查
/dev/video*权限 - Windows 下检查隐私设置里是否允许应用访问摄像头
- 检查
cap.isOpened()返回值,别假设一定能打开
黑屏但 isOpened 为 True,通常是前几帧还没准备好,读到的空帧。可以在正式循环前先读几帧丢弃,等画面稳定。
5.5 保存的视频无法播放
写出视频打不开,八成是编码器或帧尺寸问题。帧尺寸必须和 VideoWriter 初始化时完全一致,如果中途 resize 了就会坏。编码器方面,mp4v最通用,avc1画质好但依赖系统编码器。还有个坑是 writer 忘了 release,文件头没写完,视频就是坏的。所以 writer 一定要在 finally 里 release。
提示:调试视频写出问题时,先用一个很短的视频测试,别拿长视频反复试,浪费时间。
6. 小目标优化与后续扩展方向
红绿灯检测的难点本质是小目标问题。YOLOv11 本身对小目标已经比前代友好,但如果你的场景里灯特别小,还有几个方向可以试。
第一个是提高输入分辨率。把 imgsz 从 640 提到 960 甚至 1280,小目标特征保留得更多,召回会提升,代价是速度下降。这个取舍要看你的硬件。
第二个是数据增强。训练时加 mosaic、mixup、随机缩放,能让模型见到更多尺度变化。特别是随机缩放,对小目标检测帮助明显。
第三个是切片推理。把大图切成小块分别推理再合并,能显著提升小目标召回,但速度会成倍下降,适合对精度要求极高、对速度不敏感的场景。
第四个是注意力机制的引入。关键词里提到的 hcanet 这类结构,思路就是在特征融合时加强小目标的响应。如果你要自己改网络结构,可以从这个方向入手,但要注意改了结构就得重新训练,不能直接用官方权重。
后续扩展上,这套系统还能往几个方向走。一是加多模态分析,把检测结果和交通场景理解结合,比如判断"红灯时是否有车闯线"。二是加结果统计,比如统计一段时间内红灯绿灯的时长比例。三是加远程推流,把推理结果推到网页端查看。这些都是在现有底座上做加法,核心检测和界面框架不用动。
我个人在实际操作中的体会是,这套系统真正的价值不在于模型有多先进,而在于它把工程链路打通了。很多人卡在的不是训练不出模型,而是训练出来之后不知道怎么变成一个能用的东西。把界面、线程、推理、保存这几块理顺,你后面换任何模型、加任何功能,都是在稳固的地基上盖楼。最后分享一个小技巧:调试阶段把每帧的推理耗时打印到状态栏,你能直观看到性能瓶颈在哪,比盲目优化有效得多。