简介:一套基于YOLOv3目标检测与PyQt5图形界面开发的交通路口智能监控系统完整源码包,面向计算机视觉开发者、Python后端工程师及智能交通方向学习者,提供从流媒体接入、目标检测到客户端展示的端到端技术方案。压缩包共113个文件(约54.48MB),包含32个Python源码、8个H5权重模型、4个YOLO配置文件、3个UI界面文件以及多份说明文档与测试视频,既有可复用的检测模型,也有完整的客户端交互逻辑。目前已有130人学习下载,适合具备一定深度学习基础、希望直接搭建或改造监控系统的读者。资源以SRS流媒体服务器、GPU服务器与本地客户端三层架构为核心,覆盖RTMP视频流传输、人/车/交通灯目标检测、并发连接处理等关键环节,并附带详细安装使用说明,可帮助高效跑通项目,也可用于毕业设计或工程参考。从源码结构、模型权重到部署文档一应俱全,便于按模块拆解学习。
1. 基于yolov3+pyqt5的交通路口智能监控系统到底在解决什么问题
一个路口的摄像头如果只做录像回放,那它的价值要等事故发生之后才体现。yolov3+pyqt5这套组合的思路完全不同:让普通PC或工控机一边读视频流,一边实时框出汽车、行人、公交车和两轮车,再把检测结果以桌面应用的形式直接呈现。YOLOv3负责目标位置和类别,PyQt5负责视频画面、按钮、表格、告警状态这些交互。整条链路不依赖云端服务,离线也能跑,尤其适合做毕设、产品原型或单位内部工具。对有一定Python基础但并不想重写深度学习推理代码的人来说,最有价值的地方在于:模型是现成的,核心代码可以复现,背后又留着足够的调优空间。下面按原理、环境、实现、调优和交付五个环节把它拆开讲清楚。
2. 系统结构与YOLOv3推理链路:从权重文件到PyQt5界面
2.1 为什么选择YOLOv3而不是直接上YOLOv8或Pytorch训练框架
YOLOv3虽然年代较早,但到今天仍大量出现在实际监控项目里:COCO 80类覆盖了车辆、行人和常见交通工具,几乎不用训练就能获得可用的识别效果。更重要的是,OpenCV从4.x开始内置了DNN模块,可以直接加载YOLOv3的权重和配置,不需要在目标机上安装PyTorch或Darknet运行时。这对桌面GUI项目的部署价值非常明显:文件体积小,运行时依赖少,换一台机器也不容易因为CUDA版本不一致而启动失败。很多开源代码选择用PyTorch重新实现YOLOv3,训练和调试方便,但到了交付阶段,torch库动辄几个GB,安装和打包都比OpenCV方案麻烦。这个系列源码采用OpenCV DNN路线,正是为了把主要精力放在业务逻辑和界面交互上。
2.2 YOLOv3的三个检测头与锚框设置
YOLOv3把输入图片缩放到固定尺寸后,会从网络尾部输出三个不同尺度的特征图。以416×416输入为例,三个输出层的尺寸分别是13×13、26×26和52×52,依次负责大目标、中等目标和小目标。每个格子慷慨预测三个锚框,因此每个输出层末尾是85维向量:x、y、w、h、置信度和80个类别概率。三个输出层的物理意义在交通路口场景中非常直接:13×13主要负责卡车和公交车这样的大目标,52×52主要负责远处行人、自行车这类小目标。
yolov3.cfg文件中YOLO层的anchors和mask参数是硬编码的,排列顺序只要改动一个值,检测框就会整体错位。很多初学者在换模型时只替换权重文件,却留下原cfg,最后得到的结果要么全空要么框位全乱。正确做法是 weights 和 cfg 必须配套使用。你不需要理解每个卷积层的权重怎么算,但要知道anchors是从训练数据聚类得到的先验尺寸,OpenCV会按cfg里的顺序把它和一个输出格对应起来。
2.3 PyQt5在这个系统里的角色:线程、信号与槽
PyQt5本身不会执行检测,它的职责是界面、事件循环和线程管理。摄像头读取和YOLOv3推理都是耗时操作,如果放在Qt主线程里,界面拖动会变成“胶片卡顿”,视频预览也会断断续续。常见做法是新建QThread子类,把视频循环放进run()中,检测完成后通过pyqtSignal把结果发回主线程的槽函数,再由槽函数更新QLabel或表格。信号槽的跨线程排队机制让数据传递变得安全,不需要像threading那样手动维护锁和条件变量。
这里给出这套UI线程模型里最常用的几个信号:
| 信号 | 携带数据 | 发出线程 | 主线程用途 |
|---|---|---|---|
frameReady | OpenCV图像对象 | 读取/推理线程 | 刷新视频画面 |
detectResult | 类别ID、框坐标、置信度 | 推理线程 | 表格、告警、日志 |
fpsUpdated | 浮点帧率 | 推理线程 | 状态栏显示 |
errorOccurred | 异常字符串 | 推理线程 | 弹窗或恢复操作 |
设计时不要把图像格式转换放在推理线程中。OpenCV的cv::Mat底层指针在跨线程传递时一般安全,但QImage对象如果在主线程绘制时原始数据被释放,会出现花屏或段错误。最合适的方式是推理线程只发原始帧和检测结果,图像到QImage的转换放在主线程槽函数里完成。
2.4 一套容易维护的源码目录结构
基于标题里“python源码”这几个字,实际项目不应把所有代码塞进一个main.py。推荐早期就按职责分成以下结构:
traffic_monitor/ ├── main.py # PyQt5入口,创建主窗口 ├── detector/ │ ├── __init__.py │ ├── yolo.py # YOLOv3检测器封装 │ └── draw.py # 绘制检测框、计数文字 ├── ui/ │ ├── main_window.py # QMainWindow子类 │ └── convert.py # cv图像与QImage互转 ├── threads/ │ └── video_thread.py # 读取/推理线程 ├── config/ │ ├── yolov3.cfg │ ├── yolov3.weights │ └── coco.names ├── resources/ │ └── alerts/ # 告警图片存放目录 └── requirements.txtdetector层不应该导入PyQt5,它的输入是一帧OpenCV图像,输出是检测结果列表,这样以后换成TensorRT或ONNX,只需要改yolo.py内部实现。threads层只负责调用detector并发射信号,不写业务逻辑。将来要加告警、统计、串口联动,都在detectResult信号的槽函数里接新的子模块,源码可读性和可维护性都会好很多。
3. 搭建pyqt5+yolov3环境:依赖安装、模型准备与首次检测
3.1 用venv隔离环境并安装PyQt5
无论是Windows还是Linux,都建议先建虚拟环境,避免多个Python项目互相污染。在项目根目录打开终端执行:
python -m venv yoloenv # Windows PowerShell 或 CMD yoloenv\Scripts\activate # Linux / macOS source yoloenv/bin/activate pip install --upgrade pip pip install pyqt5==5.15.10 pyqt5-tools==5.15.4.2 pip install opencv-python==4.8.1.78 numpy==1.24.4这里指定版本是有考虑的。PyQt5 5.15.10是最后一轮比较稳定的系列版本,pyqt5-tools提供Qt Designer,方便前期拖界面;opencv-python 4.8.x内置dnn模块且对YOLOv3支持完整;numpy选择1.24.4是为了和opencv-python的预编译二进制保持一致。如果在PyCharm或VSCode里运行,记得把项目解释器切换到刚刚创建的yoloenv文件夹。
装完验证一下:
python -c "import cv2, PyQt5; print(cv2.__version__)"能正常输出OpenCV版本号就说明基础环境没问题。注意PySide6和PyQt5的API并不完全一致,这套源码里用的是PyQt5.QtCore、PyQt5.QtWidgets和pyqtSignal,不要用pyside6替换,否则信号名和槽签名都要改。
3.2 yolov3模型文件清单与cfg关键参数
运行YOLOv3不需要准备训练集,只需要三个文件:网络结构文件yolov3.cfg、预训练权重yolov3.weights和类别名文件coco.names。权重是darknet的二进制格式,OpenCV dnn模块可以直接读取。这几个文件在常见开源YOLOv3仓库都能找到,下载时建议对照下表检查:
| 文件 | 典型特征 | 作用 |
|---|---|---|
| yolov3.cfg | 约9KB文本,包含Convolution层和3个YOLO层 | 定义网络结构、anchors、mask |
| yolov3.weights | 约248MB二进制文件 | 保存Darknet-53训练好的权重 |
| coco.names | 80行文本,每行一个类别名 | 把检测索引映射成person、car |
拿到cfg后可以打开检查内部是否有三处[yolo]节,每个节里都有一行anchors =,后面跟着9组坐标。还要看classes = 80和mask是否对应完整版模型。如果误把yolov3-tiny.cfg和完整版权重混用,OpenCV不会立即报错,但检测框会全部错乱。第一次调试建议固定使用416×416输入尺寸,如果改成608×608,小目标召回会好一点,但推理耗时近似变成两倍。
3.3 用OpenCV跑通最小YOLOv3检测脚本
在写PyQt5界面之前,先用一个最小脚本确认模型和OpenCV环境都正常。假设模型文件放在config目录下,再准备一张包含车辆和行人的街景图:
import cv2 net = cv2.dnn.readNet('config/yolov3.weights', 'config/yolov3.cfg') model = cv2.dnn_DetectionModel(net) model.setInputParams(size=(416, 416), scale=1/255.0, swapRB=True) model.setPreferableBackend(cv2.dnn.DNN_BACKEND_OPENCV) names = open('config/coco.names').read().strip().splitlines() img = cv2.imread('street.jpg') class_ids, scores, boxes = model.detect( img, confThreshold=0.45, nmsThreshold=0.4 ) for cid, score, box in zip(class_ids, scores, boxes): left, top, w, h = box cv2.rectangle(img, (left, top), (left + w, top + h), (0, 255, 0), 2) cv2.putText(img, f'{names[cid]} {score:.2f}', (left, top - 5), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 1) cv2.imwrite('result.jpg', img) print('检测到目标个数:', len(class_ids))这段代码有四个关键参数。size=(416,416)把任意尺寸图片转成网络输入尺寸;scale=1/255.0把像素值归一化到0到1;swapRB=True让OpenCV自动完成BGR到RGB的通道切换;confThreshold和nmsThreshold分别是类别置信度和NMS抑制阈值。如果同一辆车被框了好几层,把nmsThreshold调到0.3或0.35;如果远处行人被漏掉,先降低confThreshold,而不是直接把NMS关掉。
4. 实现交通路口监控GUI:多线程视频输入与实时检测框显示
4.1 主窗口布局:视频区、按钮和数据表格
主界面用QMainWindow承载,中央放一个用于视频预览的QLabel,右侧放控制面板:开始监测、停止、退出按钮,置信度阈值滑杆和检测结果表格。为了减少UI文件打包遗漏,界面代码可以直接写在Python类里。这个系统的核心逻辑是把视频线程和界面线程彻底分开:QLabel只负责显示,QTableWidget只负责记录最近检测到目标,不直接参与计算。
4.2 编写YoloV3Detector封装:输出解析与绘制分离
为了让代码可维护,把YOLOv3检测封装成一个类,文件放在detector/yolo.py。这个类不依赖PyQt5,可以单独运行和测试:
import cv2 import numpy as np class YoloV3Detector: def __init__(self, cfg_path, weights_path, names_path): self.net = cv2.dnn.readNet(weights_path, cfg_path) self.names = open(names_path, encoding='utf-8').read().strip().splitlines() # 获取三个无连接输出层:yolo_82 / yolo_94 / yolo_106 self.out_names = [layer.getOutputName() for layer in self.net.getUnconnectedOutLayers()] def detect(self, frame, conf_th=0.45, nms_th=0.4): h, w = frame.shape[:2] blob = cv2.dnn.blobFromImage(frame, 1/255.0, (416, 416), (0, 0, 0), swapRB=True, crop=False) self.net.setInput(blob) outputs = self.net.forward(self.out_names) boxes, scores, class_ids = [], [], [] for output in outputs: # output形状是(1,255,grid,grid),先转成NHWC再拆成每个anchor一行 grid_h, grid_w = output.shape[2], output.shape[3] detection = output[0].transpose(1, 2, 0).reshape(grid_h * grid_w, -1) for row in detection: confidence = float(row[4]) if confidence < conf_th: continue class_scores = row[5:] class_id = int(np.argmax(class_scores)) class_score = float(class_scores[class_id]) * confidence if class_score < conf_th: continue cx, cy = row[0] * w, row[1] * h box_w, box_h = row[2] * w, row[3] * h boxes.append([int(cx - box_w/2), int(cy - box_h/2), int(box_w), int(box_h)]) scores.append(class_score) class_ids.append(class_id) idxs = cv2.dnn.NMSBoxes(boxes, scores, conf_th, nms_th) result = [] if len(idxs) > 0: for i in idxs.flatten(): result.append((class_ids[i], scores[i], boxes[i])) return result这里forward(self.out_names)返回三个尺度的特征图,OpenCV已经把anchor解码成相对坐标。crop=False表示把整图缩放到416×416,而不是强制裁剪,因此坐标可以直接乘原始帧的宽高。NMS之前的class_score要乘上目标置信度row[4],因为当前输出既包含了“这里有没有目标”,又包含了“目标属于哪个类”。
4.3 把视频读取和推理放进QThread
视频线程类放在threads/video_thread.py,继承QThread。这个线程负责打开摄像头或视频文件,循环read,调用detector,再把结果通过信号发送出去:
from PyQt5.QtCore import QThread, pyqtSignal import cv2 class VideoProcessThread(QThread): frameReady = pyqtSignal(object) resultReady = pyqtSignal(object, list) def __init__(self, video_source, detector, parent=None): super().__init__(parent) self.video_source = video_source self.detector = detector self._running = True def stop(self): self._running = False def run(self): cap = cv2.VideoCapture(self.video_source) while self._running and cap.isOpened(): ok, frame = cap.read() if not ok: continue detections = self.detector.detect(frame) self.resultReady.emit(frame.copy(), detections) drawn = self.draw_detections(frame, detections) self.frameReady.emit(drawn) cap.release() def draw_detections(self, frame, detections): for class_id, score, box in detections: x, y, w, h = box label = f'{self.detector.names[class_id]} {score:.2f}' cv2.rectangle(frame, (x, y), (x + w, y + h), (0, 255, 0), 2) cv2.putText(frame, label, (x, y - 8), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2) return framestop()方法设置_running标志,线程会在下一次循环开始时退出。这里不能用thread.terminate()强制结束线程,否则cv2.VideoCapture内部状态可能被破坏,下一次程序运行时光头可能打不开。frameReady负责送画框后的图,resultReady把原始帧和检测列表一起传给主窗口,供后续统计和保存截图使用。
4.4 主窗口槽函数:BGR转QImage并显示
在主窗口类中连接信号:
from PyQt5.QtGui import QImage, QPixmap from PyQt5.QtWidgets import QMainWindow, QLabel from PyQt5.QtCore import Qt class MainWindow(QMainWindow): def __init__(self): super().__init__() self.videoLabel = QLabel() self.statusLabel = QLabel() # self.thread = VideoProcessThread(...) # self.thread.frameReady.connect(self.update_frame) # self.thread.resultReady.connect(self.on_result) def update_frame(self, cv_image): rgb = cv2.cvtColor(cv_image, cv2.COLOR_BGR2RGB) h, w, ch = rgb.shape qimg = QImage(rgb.data, w, h, ch * w, QImage.Format_RGB888) pixmap = QPixmap.fromImage(qimg) label_w = self.videoLabel.width() label_h = self.videoLabel.height() self.videoLabel.setPixmap(pixmap.scaled( label_w, label_h, Qt.IgnoreAspectRatio, Qt.SmoothTransformation)) def on_result(self, frame, detections): interest = {0: 'person', 1: 'bicycle', 2: 'car', 3: 'motorbike', 5: 'bus', 7: 'truck'} count = {} for cid, score, box in detections: if cid in interest: name = interest[cid] count[name] = count.get(name, 0) + 1 self.statusLabel.setText( f"行人 {count.get('person', 0)} | " f"自行车 {count.get('bicycle', 0)} | " f"汽车 {count.get('car', 0)}")update_frame里转换出来的QImage引用rgb.data,槽函数结束前QPixmap.fromImage会完成像素拷贝,所以画面上是安全的。如果需要长期保留qimg,建议在转换后加一句qimg = qimg.copy()。on_result中只统计交通路口关注的类别,COCO中大部分类别比如餐桌、沙发在这里没有任何意义,过滤后还能降低误报对统计结果的影响。
4.5 交通路口最常用的6类目标过滤
下面的表列的是COCO 80类中路口监控最常用的类别索引,实际代码里只需要保留这些ID:
| COCO ID | 英文类名 | 中文显示 | 典型尺寸 |
|---|---|---|---|
| 0 | person | 行人 | 小/中 |
| 1 | bicycle | 自行车 | 小 |
| 2 | car | 汽车 | 中/大 |
| 3 | motorcycle | 摩托车 | 小/中 |
| 5 | bus | 公交车 | 大 |
| 7 | truck | 卡车 | 大 |
在on_result里做过滤有个额外好处:可以把统计数据单独存一份,后续做倒计时或流量统计时不用重新解析原始检测结果。如果测试时发现汽车有时被识别成卡车,可以检查类别ID映射是否正确,COCO ID 7就是truck,不要按自己的想象随意改索引。
5. 调优与排错:帧率瓶颈、阈值策略和模型替换
5.1 用QElapsedTimer定位耗时段
监控系统最常见的抱怨是“画面卡顿”。不要一上来就换模型,先用计时器把每一段耗时拆开:
from PyQt5.QtCore import QElapsedTimer # 在VideoProcessThread.run中使用 timer = QElapsedTimer() while self._running: timer.start() ok, frame = cap.read() read_ms = timer.nsecsElapsed() / 1e6 timer.restart() detections = self.detector.detect(frame) detect_ms = timer.nsecsElapsed() / 1e6 timer.restart() drawn = self.draw_detections(frame, detections) draw_ms = timer.nsecsElapsed() / 1e6 # print(read_ms, detect_ms, draw_ms)如果read_ms经常超过40ms,问题通常在视频源或USB读取链路,不在YOLOv3;如果detect_ms占了大头,考虑缩小输入尺寸或换模型;如果draw_ms很高,往往是因为cv2.putText在绘制中文字体时失败或是在单帧里画了太多框。判断出瓶颈后调整才有意义。
5.2 路口场景的阈值设定策略
confThreshold和nmsThreshold应该分开调。先固定一个,动另一个,观察曲线变化。这里给出一组常见初始值:
| 场景 | confThreshold | nmsThreshold | 输入尺寸 | 预期效果 |
|---|---|---|---|---|
| 白天车流稀疏 | 0.5 | 0.4 | 416 | 误检少,漏检略多 |
| 早晚高峰密集 | 0.35 | 0.45 | 416 | 漏检降低,密集车辆会粘连 |
| 夜间监控 | 0.3 | 0.5 | 608 | 行人召回提升,帧率下降明显 |
| 高帧率模式 | 0.45 | 0.3 | 320 | 帧率提升一半,小目标易糊 |
夜间不建议简单把confThreshold降到0.25以下,因为低照度图片的虚影和车灯反光会被当成目标。比较有用的做法是先对视频帧做一次cv2.equalizeHist灰度均衡或CLAHE增强,再送入模型。还要留意cv2.dnn.NMSBoxes的第三个参数是conf,第四个是nms,很多误调都是因为参数顺序写反后一条框都出不来。
5.3 常见崩溃与现象排查
这套系统在实机运行中有几个高频问题,提前说明能省很多时间:
QThread: Destroyed while thread is still running:窗口关闭时线程没有退出。需要在主窗口closeEvent中调用self.thread.stop()和self.thread.wait(1000)。Assertion network.forward failed:weights和cfg不匹配,或者模型路径错误。检查文件名,不要混用tiny和完整版。- 视频画面黑屏但程序不崩溃:QImage的
bytesPerLine参数错误,确认ch * w和实际通道数一致。 - 第二次开始摄像头打不开:第一次停止时没有释放
cap.release(),同一设备句柄没有正常归还。 - 检测框明显偏到左上角:输入尺寸和坐标缩放不一致,检查
blobFromImage时的crop参数。
用VSCode或PyCharm调试时,把断点打在槽函数接收端,比打在emit行更容易看到真实传参内容。因为Qt信号槽是异步的,最终接收方拿到的对象可能在emit之后又经过了一次封装。
5.4 换成yolov3-tiny与后续ONNX扩展
如果CPU环境只能跑到5-8帧,最直接的方案是换成yolov3-tiny。模型文件替换后,检测代码不需要改动,只需要在初始化时更新两个路径:
self.net = cv2.dnn.readNet('config/yolov3-tiny.weights', 'config/yolov3-tiny.cfg')self.out_names会自动跟随cfg中的YOLO层数量变化,完整版三个,tiny两个。tiny对远处行人的漏检比完整版明显提升,所以更适用于主干路机动车流量检测。如果后续要迁移到ONNX,建议把detector定义成抽象接口,detect方法保持入参、出参不变,这样UI层、线程层一概不用动。
6. 进阶与交付:RTSP网络摄像头接入、告警截图与PyInstaller打包
6.1 接入RTSP网络摄像头
把线程初始化时的video_source参数从摄像头索引号直接换成RTSP地址即可:
rtsp_url = "rtsp://192.168.1.64:554/Streaming/Channels/101" thread = VideoProcessThread(rtsp_url, detector, self)网络摄像头首次连接容易长时间阻塞,打开前设置超时参数会更有保障:
cap = cv2.VideoCapture() cap.set(cv2.CAP_PROP_OPEN_TIMEOUT_MSEC, 3000) cap.set(cv2.CAP_PROP_BUFFERSIZE, 3) cap.open(rtsp_url)RTSP只需要摄像头和运行主机在同一局域网内即可,不依赖任何外部网络服务。如果画面延迟偏高,优先到摄像头管理页面把编码改成H.264 baseline,并降低码率。
6.2 记录告警截图与CSV日志
在on_result槽函数中增加保存逻辑,既保留结构化日志,也便于事后追溯:
now = QDateTime.currentDateTime() csv_row = now.toString("yyyy-MM-dd HH:mm:ss") for cid, score, box in detections: if cid in (0, 2, 3, 5, 7): csv_row += f",{self.detector.names[cid]},{box[0]},{box[1]},{score:.2f}" with open('alerts/event.csv', 'a') as f: f.write(csv_row + "\n") if count.get('person', 0) > 2: img_path = f'alerts/{now.toString("yyyyMMddHHmmsszzz")}.jpg' cv2.imwrite(img_path, frame)QDateTime.toString("zzz")生成毫秒级后缀,避免同秒写入多次时覆盖文件。cv2.imwrite会按扩展名自动选择JPEG编码,如果长期保存发现磁盘增长过快,可以使用cv2.IMWRITE_JPEG_QUALITY参数把质量从95降到85。
6.3 用PyInstaller打包成Windows可执行程序
调试完成后,用PyInstaller打出一个免安装程序:
pyinstaller --noconfirm --windowed --name TrafficMonitor ^ --hidden-import=PyQt5.QtXml --hidden-import=PyQt5.QtSvg ^ --add-data "config;config" --add-data "resources;resources" ^ main.py--windowed表示运行时不弹黑色控制台;--hidden-import强制保存QtXml和QtSvg两个容易被遗漏的模块;--add-data把模型和资源目录一起带入exe。Windows下分隔符使用分号,Linux下要改成冒号。真正要搬到另一台机器时,模型路径不能写成当前目录,而应该用getattr(sys, '_MEIPASS', os.path.dirname(__file__))定位到PyInstaller解包后的资源目录。只要打包时没有遗漏plugins/platforms,这个exe在未安装Python的机器上也能正常启动。
本文还有配套的精品资源,点击获取