简介:这套基于深度学习与PyQt5的行人识别检测计数系统,是一份面向毕业设计场景的完整源码项目,适合计算机相关专业学生在毕设、课程设计或期末大作业中直接参考与二次开发。系统采用CenterNet等目标检测方法,以PyQt5搭建交互界面,从数据准备、模型预测到计数统计均有对应Python脚本实现,并已提供训练好的模型权重,可直接运行。压缩包中总计34个文件,以19个Python脚本为主体,覆盖UI、模型结构、训练与预测等环节;另有h5模型文件、csv标注数据、ttf字体和配置文档等辅助内容,整体包体约684MB,结构清晰,便于按模块查阅。该设计评审分99分,经导师把关,代码完整性高,目前已有110人学习下载,附带说明文档和数据样例,能帮助快速复现并理解整套行人计数流程,对需要高分毕设参考的学习者很有价值。
1. 这项目到底解决什么问题:界面、检测和计数怎么合成一个能演示的系统
答辩前一个星期,模型在终端里已经能框出人,导师要看的是能操作、能计数的界面,于是很多人开始找“基于深度学习+PyQt5的行人识别检测计数系统”这类源码。这道题的本质不是让你从零发明算法,而是把三件事串成一条能演示的链路:用深度学习模型(最常选 YOLO 系列)在每帧里检出行人,用跟踪和计数逻辑统计人数,再用 PyQt5 把画面、检测框、统计数字放进同一个桌面窗口。它对毕业设计、课程设计很友好,也适合想快速搭一个可视化 Demo 的工程入门者。难点不在某个环节,而在它们之间的衔接。
2. 核心链路:深度学习行人检测 + PyQt5 界面架构怎么串起来
我拿到这类需求时,第一步不是写代码,而是先把数据流画出来:摄像头或视频文件 -> OpenCV 解码成帧 -> 模型前向推理 -> 得到检测框 -> 跟踪器补 ID -> 计数逻辑更新统计 -> 最后把结果帧交给 PyQt5 显示。这样拆完之后,你自然会发现主线程和推理线程必须分开,否则界面会卡到像死机。下面把模型选型和线程模型一起讲清楚。
2.1 行人检测选型:为什么是 YOLOv8n 而不是自己训 Faster R-CNN
很多人的第一反应是“深度学习嘛,那我自己训一个模型才显得有工作量”。但在毕业设计这个时间盒里,自己从零训练目标检测模型意味着要处理数据标注、类别不平衡、训练收敛、验证集划分一大堆问题。更合理的常见做法是直接用已经在 COCO 上训练好的公开权重,再针对实际场景做少量微调。COCO 数据集的 80 个类别里就有 person 这一类,YOLO 系列权重天然知道“人长什么样”。
在 YOLO 系列里,我一般会选 YOLOv8n。这里的 n 是 nano,最轻量的一档。它和 YOLOv8s/m 相比精度稍微低一点,但前向速度明显更快,模型文件也小。在 PyQt5 桌面应用里,你要的是“用户双击打开就能看到实时画面”,不是跑 benchmark。如果显卡只是入门级,用 YOLOv8n 能把 FPS 拉上去,界面体验会好很多。你可能会搜到“yolov5训练自己的模型”“yolov11训练自己的模型”这些词,它们解决的是训练侧的问题,而“行人识别检测计数系统”这类标题一般更侧重于部署和界面侧。
选型时还要注意一组容易被忽略的参数:输入尺寸和置信度。常见配置是 imgsz=640,conf=0.5,iou=0.45。imgsz 越大,小目标检测效果越好,但推理时间增长不是线性的,640 是性价比最高的起点。conf 是保留检测框的最低置信度,0.5 对行人这类目标很稳,如果镜头远处的人经常被漏检,可以降到 0.3,但误检会变多。iou 是 NMS 阈值,默认 0.45,控制两个重叠框是否合并;行人之间一旦发生遮挡,iou 调低到 0.4 能减少框互相吞掉的情况。
再说“自己训 Faster R-CNN”为什么通常不划算。Faster R-CNN 精度上限不差,但它分成 RPN 和 RCNN 两段,前向计算比单阶段的 YOLO 重很多。在 PyQt5 里接实时摄像头时,FPS 一旦掉到个位数,演示效果会很难看。除非题目点名要求两阶段方法,否则我建议选 YOLOv8n。如果确实需要自己训练,可以走“yolov5训练自己的模型”那条路线:标注数据、准备 yaml、跑训练脚本,最后导出 best.pt。这个“训练好模型”的权重文件,就是整个系统最关键的输入。
2.2 PyQt5 界面与推理线程分离:QThread 加信号槽的标准做法
PyQt5 教程里反复强调一件事:不要在 UI 主线程里做耗时操作。模型推理一次少说几十毫秒,在 CPU 上甚至要几百毫秒。如果你在按钮的 clicked 槽里直接调用 model.predict,Qt 的事件循环会被卡住,窗口拖不动、按钮点不了,看起来就像死机。准确地说,不是死机,是主线程在等推理返回。
常见做法是把检测放到 QThread 里,通过信号把结果传回主线程。PyQt5 的信号槽是线程安全的,信号在子线程里 emit,主线程的槽函数会在事件循环里被调用,这就绕开了手动加锁传递数据的麻烦。我一般会写一个专门的 Worker 类:
import cv2 from PyQt5.QtCore import QThread, pyqtSignal from ultralytics import YOLO class DetectWorker(QThread): frame_ready = pyqtSignal(object) result_ready = pyqtSignal(object) def __init__(self, model_path, source, parent=None): super().__init__(parent) self.model = YOLO(model_path) self.source = source self._running = True def run(self): cap = cv2.VideoCapture(self.source) while self._running and cap.isOpened(): ok, frame = cap.read() if not ok: break self.frame_ready.emit(frame.copy()) # 不复制会踩到上一帧的内存 results = self.model.predict( frame, imgsz=640, conf=0.5, verbose=False ) self.result_ready.emit(results) cap.release() def stop(self): self._running = False这段代码的逻辑很直白:run() 是 QThread 启动后子线程里执行的入口;VideoCapture 打开 source,source 可以是摄像头索引 0,也可以是视频文件路径;每一帧先发一个 frame_ready 信号让界面先显示原始画面,再调用模型推理,把 results 通过 result_ready 发回去。frame.copy() 值得专门说,因为 OpenCV 的 read 可能会复用同一块内存,不复制就发出去,等主线程显示的时候这块内存可能已经被下一帧覆盖了,画面会闪或花屏。
参数上,source 为 0 时表示默认摄像头,笔记本多摄像头可能要试 1、2。model_path 指向 best.pt 这类训练好的权重。pyqtSignal(object) 里用 object 是因为要跨线程传 OpenCV 图像和 YOLO 的 results 对象,它们不是 PyQt 内置类型,object 是最省事的泛型槽。stop() 里把 _running 置为 False,循环会尽快退出;关闭窗口时一定要调用 stop() 再 wait(),不然线程还在跑,程序退出时可能报 “QThread: Destroyed while thread is still running”。
这里有一个取舍:我让每一帧都做推理,是图省事。实际项目里如果推理速度跟不上视频帧率,QThread 里会不断积压待处理帧,内存上涨,界面延迟变大。更稳的做法是只在“新帧准备好且上一帧推理已完成”时才处理下一帧,或者用队列控制最多缓存两帧。这个 Worker 是串行消费模型,不是并行流水线,但对毕业设计演示已经足够。
2.3 模型加载与推理:从 best.pt 到一帧检测结果的最小代码
当你拿到一个“源码+训练好模型”的压缩包时,优先确认的东西只有几个:权重文件在哪、类别序号是不是 person=0、推理代码在哪个文件。很多系统的入口文件叫 main.py 或 main_window.py,但代码结构无非就是这一步:用一个 YOLO 对象加载权重,然后反复调用 predict 或 track。下面是最小可运行的一段:
from ultralytics import YOLO model = YOLO(r"weights/best.pt") results = model.predict( frame, imgsz=640, # 输入分辨率,越大越慢 conf=0.5, # 置信度阈值,低于此值的框被丢弃 iou=0.45, # NMS 的 IoU 阈值 device="0", # "0" 表示第一块 GPU,"cpu" 表示 CPU half=True, # 半精度 FP16 推理,仅 GPU 可用 verbose=False ) boxes = results[0].boxes if boxes is not None and len(boxes) > 0: for box in boxes: cls = int(box.cls[0]) conf = float(box.conf[0]) x1, y1, x2, y2 = [int(v) for v in box.xyxy[0]] if cls == 0: pass # 这里就是你要的行人框这段代码先把 best.pt 加载成 YOLO 对象。predict 接收一帧 BGR 图像,返回一个 Results 列表。列表里第一个元素的 boxes 字段包含了检测框、置信度、类别。注意 boxes.xyxy 默认是像素坐标,直接可以画;如果使用 results[0].boxes.xyxyn 则是归一化坐标,画之前要乘回图像宽高。
device="0" 表示使用第一块 GPU,不写或者写 device="cpu" 就是纯 CPU 推理。half=True 打开 FP16 半精度,只有 GPU 才支持;在 CPU 上开 half,程序会直接报错。这是常见翻车点,我一般在代码里加一个判断,只有 torch.cuda.is_available() 时才传 half=True。imgsz=640 和 conf=0.5 与 2.1 节对应。如果显存不够,把 imgsz 降到 512 或 416 能明显减负,代价是小目标召回率下降。
提示:拿到 best.pt 后不要马上接界面,先用一张行人图片单独跑一次推理,打印出 cls 和 conf,确认权重没问题再接 UI。这个过程能过滤掉一半以上的“检测不到人”问题。
3. 让“计数”可用:检测 + 跟踪 + 计数逻辑的三层落地
检测解决“人在哪”,计数解决“有多少人 / 过了多少人”。这两者的差别很容易被忽略:如果只用每帧检测数量当统计数字,同一人走出画面再回来就会被重复计算。真正的计数系统要分三步:先做目标检测,再做目标跟踪,最后用跟踪 ID 去重并给出统计值。这套逻辑在商场客流统计、教室人数统计里都是同一个套路。
3.1 画面内计数与越线计数:两种需求对应两种实现
最简单的“画面内计数”就是当前帧里检测框的数量,用来回答“现在画面里有几个人”。这个不用跟踪器:
person_count = sum(1 for box in boxes if int(box.cls) == 0)一行代码就能算出来。但这种统计有明显弱点:检测一波动,计数就跟着抖。如果需求是“报告当前人数”,通常还需要一个时间窗口平滑,比如取最近 5 帧的中位数。而“越线计数”关心的是流量,比如一天有多少人从某个门口经过,这时必须建立跨帧的轨迹。
越线计数的经典做法是设定一条虚拟线(例如画面高度 480 像素处),然后判断跟踪目标的重心是否跨越了这条线。我会保存每个跟踪 ID 的上一帧重心坐标,当上一帧在线的上方、当前帧到了下方,就认为完成一次方向向下的穿越;反向类似。为了不重复计数,要维护一个 crossed_ids 集合,同一 ID 只计一次,只有当目标离开视线再重新出现时才允许下一次计数。实现如下:
def count_line_cross(tracks, line_y, crossed_ids, direction="down"): cross_count = 0 for track in tracks: track_id = track["id"] prev_y = track["prev_center"][1] curr_y = track["center"][1] if track_id in crossed_ids: continue # 判断重心是否从线的一侧到了另一侧 if direction == "down" and prev_y < line_y and curr_y >= line_y: crossed_ids.add(track_id) cross_count += 1 elif direction == "up" and prev_y > line_y and curr_y <= line_y: crossed_ids.add(track_id) cross_count += 1 return cross_count这段代码我在不同项目里写过很多遍,需要解释三个参数:line_y 是线的 y 坐标,可以固定写在配置里,更进阶的做法是在 PyQt5 窗口上用鼠标拖动一条横线,把位置保存下来。direction 区分计数方向,单人单向通行时只统计“进”,双向场景可以计两个数。crossed_ids 是 Python 的 set,去重的核心依据是跟踪 ID,不是检测框。如果没有跟踪器,同一人跨线的 10 帧会触发 10 次计数,这也是很多人说“计数系统不准”的最大原因。
还有一个细节:用重心判断跨线其实不是最准的。人走路时身体上下晃动,重心可能会在线上来回抖动,造成反复穿越。更稳的做法是用脚底点(框的下边沿 y2),或者要求连续 3 帧都满足穿越条件后再计数。很多开源代码用重心是为了实现简单,我自己的习惯是加一个连续帧阈值,把偶然抖动滤掉。
3.2 跟踪器接入:ByteTrack 与 DeepSort 的参数取舍
跟踪器的作用是给每个检测框分一个 ID,让同一人在连续帧里保持同一个 ID。不需要自己实现跟踪器,ultralytics 的 track 方法已经内置了,用起来比单独调库省事很多。常见的两种是 ByteTrack 和 DeepSort。ByteTrack 基于检测框的 IoU 关联,不需要额外的行人外观特征,速度快,对普通视频够用;DeepSort 额外引入 ReID 特征,在严重遮挡、目标长期消失再出现的场景里更鲁棒,但要额外下载一个特征权重,推理时间也更高。
在“行人识别检测计数系统”里,我会优先用 ByteTrack。原因是摄像头场景下行人目标相对大,遮挡虽然存在,但 ByteTrack 的 track_buffer 参数已经把短暂遮挡的情况考虑进去了。接入代码比 predict 只多两个参数:
results = model.track( frame, tracker="bytetrack.yaml", persist=True, # 跨帧保持跟踪状态,不能漏 conf=0.3, # 跟踪场景下 conf 可以调低 iou=0.5, imgsz=640, verbose=False, ) if results[0].boxes.id is not None: ids = results[0].boxes.id.cpu().tolist() xyxy = results[0].boxes.xyxy.cpu().tolist() clss = results[0].boxes.cls.cpu().tolist()这个代码块的意思:track 会在每帧检测后执行跟踪,给有把握的框分配 ID。persist=True 让跟踪状态在视频帧之间保持,绝对不能漏。返回值里 boxes.id 就是每个框的轨迹 ID,boxes.id 可能是 None,表示这一帧没有成功关联的目标,所以使用前必须判空。conf 调到 0.3 是为了让跟踪器能拿到更多低分检测框,ByteTrack 的特点是它会把低分框也纳入匹配,因此这里不能把 conf 卡得太死。
bytetrack.yaml 里真正影响行为的参数有三个:track_thresh、track_buffer、match_thresh。track_thresh 是激活跟踪的分数阈值,默认 0.5;track_buffer 是目标丢失后保留轨迹的帧数,默认 30,如果画面中有目标被完全遮挡几秒,可以调大到 60;match_thresh 是匹配时用的相似度门槛,默认 0.8,调小会让匹配更宽松,但容易串 ID。这三个参数没有绝对正确,我通常先按默认跑一遍,看计数结果再针对性调 track_buffer。跟踪器的坑多数不在理论,而在 ID 跳变,这是计数误差的直接来源。
如果坚持用 DeepSort,需要额外指定一个 ReID 权重路径,例如 tracker="deepsort.yaml",其中 model 字段指向 ReID 模型。DeepSort 对 CPU 更不友好,GPU 资源不够时会明显掉 FPS,所以除非场景里人挨着人、遮挡非常严重,否则 ByteTrack 是性价比更高的选择。
3.3 计数结果可视化:在 PyQt5 画面上叠加统计面板
检测和计数做完以后,要让评委一眼看懂,界面至少要有三样东西:实时画面、每帧检测框、当前计数或累计计数。画框可以在拿到 results 后用 OpenCV 直接画在帧上,再把帧转成 QImage 显示。另一路是单独放一个 QLabel 显示“当前人数: 5 / 累计通过: 23”,用 setText 更新即可。我一般会在 Worker 里维护一个计数器对象,每次检测完把最新统计放回 results 对象里,界面只管读。
把 OpenCV 图像显示到 QLabel 有一段固定代码,容易写错的是颜色通道和 bytesPerLine:
def frame_to_qimage(frame): rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) # OpenCV 是 BGR h, w, ch = rgb.shape bytes_per_line = ch * w # 必须等于 3 * width return QImage(rgb.data, w, h, bytes_per_line, QImage.Format_RGB888)OpenCV 默认通道顺序是 BGR,Qt 是 RGB,不转换的话画面会偏蓝。QImage 的第四个参数叫 bytesPerLine,必须等于通道数乘宽度,也就是 3 * width。如果只写 width,图像会按错误的步长解析,出现斜切或颜色错乱。第二种显示方式是把 OpenCV 的 Mat 直接转成 QPixmap,但转换前还是绕不开 BGR 到 RGB。最省心的是先转 RGB 再构造 QImage,再交给 QLabel.setPixmap(QPixmap.fromImage(qt_image))。
绘制检测框时,可以使用 OpenCV 的 rectangle 和 putText,颜色用 (0, 255, 0) 绿色,线宽 2。如果要显示人流方向和计数,可以在画面顶部分别画一上一下两个统计数字。文字建议用 FONT_HERSHEY_SIMPLEX,字体大小 1.0,颜色用白底红字或黑底白字,不然在复杂背景里看不清。这里补一个血泪经验:不要把计数逻辑写在 PyQt5 的槽函数里,槽函数只负责把收到的 results 数据取出来显示。逻辑全放 Worker 侧,界面侧只做 copy、转换和 setPixmap,否则槽函数里一旦跑重活,界面又会卡。
4. 避坑与常见问题:从 PyQt5 安装到检测不到人的 5 个典型翻车点
我在帮人排查这类项目时,出现频率最高的问题其实和模型关系不大,大多集中在环境和线程上。下面 5 条基本覆盖了“装不上、黑屏、花屏、卡死、计数乱跳”这几个症状。每一条我都按“现象 -> 原因 -> 解决”的顺序写,方便你直接对着排查。
4.1 PyQt5 装不上或装错版本:先看 Python 版本和 pip 源
现象:pip install PyQt5 报错,或者装完 import 失败,提示找不到 QtCore。原因:PyQt5 的某些版本对 Python 3.11/3.12 支持不好;或者是 pip 源下载速度太慢导致超时。解决:建议用 Python 3.8 到 3.10 搭配 PyQt5 5.15.9 或 5.15.10,这是最常见的稳定组合。安装命令用:
pip install PyQt5==5.15.9 -i https://pypi.tuna.tsinghua.edu.cn/simple装完一定要跑一下:
python -c "from PyQt5.QtCore import QThread; print('ok')"确认 import 没问题再做界面开发。这里还容易踩一个坑:机器上同时装了 PyQt5 和 PySide6,两个库的 Qt 插件会冲突,报 “could not find or load the Qt platform plugin”。建议只保留一个绑定库,卸载另一个。
4.2 摄像头画面黑屏或界面卡死:OpenCV 与 Qt 事件循环冲突
现象:程序启动后窗口出来了,但画面区黑屏,或者拖动窗口时界面无响应。原因:摄像头读取加模型推理都在主线程,VideoCapture.read() 会阻塞等待下一帧,Qt 事件循环无法刷新。解决:按 2.2 那样把读帧和推理放进 QThread,主线程只接收信号。另外注意 VideoCapture 的摄像头索引,有的笔记本把摄像头识别成 1,不是 0,建议枚举一下可用索引。还有一个容易忽略的点:如果代码里还调用了 cv2.waitKey(),它会抢 Qt 的事件循环,画面一样会假死。OpenCV 的 waitKey 在 PyQt5 程序里基本用不到,删掉即可。
4.3 画面颜色偏蓝或偏绿:RGB 和 BGR 通道顺序
现象:画面颜色明显不对,人的脸发蓝,环境色偏青。原因:OpenCV 读出来的帧是 BGR,Qt 的 QImage 默认按 RGB 解释,通道错位。解决:QImage 构造前先 cv2.cvtColor 成 RGB;或者 QImage 构造时用 Format_BGR888,但要注意 Qt 版本支持情况。我一般用转换后再构造,兼容性最好。另一个相关问题是 QImage 的数据生命周期:如果 rgb 变量在函数结束后被回收,QImage 持有的是悬空指针,画面会闪或者直接崩溃。解决方法是把 rgb 存成成员变量,或者在 QImage 上调用 copy() 持有像素数据。
4.4 GPU 显存不足或推理很慢:模型尺寸、半精度和设备选择
现象:第一次推理报 CUDA out of memory,或者在 CPU 笔记本上只有两三帧。原因:模型输入分辨率太大,默认 batch 可能大于 1,且没开 half。解决:predict 时把 imgsz 降到 640,甚至 512;确认 device 是 "0" 且 half=True;如果是 CPU 设备,换回 imgsz=416 并把 conf 调高到 0.6,减少后处理负担。不要同时开多个模型实例,PyQt5 界面里只保留一个 YOLO 对象。还有一个容易被忽略的点:torch 的 CUDA 版本和显卡驱动不匹配时,torch.cuda.is_available() 返回 False,代码会悄悄走 CPU 路径。先跑一句 python -c "import torch; print(torch.cuda.is_available())" 验证环境,再谈性能。
4.5 计数跳变或重复计数:没有跟踪器或跨线判定太敏感
现象:一个人过线被计了三次;或者计数一会儿多一会儿少。原因:用了每帧检测数当累计值,或者跨线判定用了重心和单帧判断。解决:先用 track() 拿到 ID,再说计不计数。跨线判定加连续几帧的确认,或用脚底点代替重心。修计数逻辑时先拿一段固定视频反复跑,不要一边调摄像头一边改代码。如果 ID 还是频繁跳变,回到 bytetrack.yaml 调大 track_buffer,同时确认 conf 没有高到让行人在中途丢失。
5. 验证与验收:用 mAP、FPS 和计数误差证明“高分”不是吹的
界面做完、计数能跑,还差最后一步:怎么向导师或评委证明结果可靠。我从三个维度做验收,这也是答辩时最容易被追问的点。
5.1 用验证集跑 mAP,确认模型没有过拟合
如果是拿别人训练好的 best.pt,也需要在验证集上跑一遍指标。常见命令是:
yolo val model=weights/best.pt data=coco.yaml imgsz=640重点看 mAP50 和 mAP50-95。mAP50 高但 mAP50-95 偏低,说明模型能框出目标但框不够精确,这是正常现象。答辩时说清楚验证集来源和指标含义,比报一个数字可信得多。
5.2 实测 FPS:区分 GPU 和 CPU 的验收标准
在 PyQt5 界面上测 FPS 不能只看模型前向时间,还要算上解码和显示。做法是统计 100 帧耗时取平均:
start = time.perf_counter() results = model.predict(frame, imgsz=640, conf=0.5, verbose=False) total += time.perf_counter() - start fps = frames / totalCPU 上 YOLOv8n 能到 10 FPS 就算能演示;GPU 上期望 30 FPS 以上。如果界面显示和推理不同步,要说明瓶颈在显示复制还是模型。
5.3 计数误差:与人工标注对比
取 3 段不同时长、不同人数的视频,人工数出通过人数,再和系统计数对比。记录误差率:
| 测试视频 | 人工计数 | 系统计数 | 误差率 |
|---|---|---|---|
| 视频1(约1分钟) | - | - | - |
| 视频2(约3分钟) | - | - | - |
| 视频3(约5分钟) | - | - | - |
误差率控制在 5% 以内,基本能支撑“系统可用”的结论。如果误差率偏高,回到 4.5 检查跟踪 ID 和跨线逻辑。我自己的习惯是先把界面按钮用假数据跑通,再接入模型,这样 PyQt5 的槽和线程问题不会和模型问题混在一起。有次我为了省事直接在 UI 线程里调 predict,拖动窗口时直接假死,被当成事故记到现在。先跑通 10 帧再上摄像头,能省掉一半排查时间。希望帮到你。
本文还有配套的精品资源,点击获取