简介:这套基于YOLOv5目标检测的网课专注度检测系统,面向计算机相关专业毕业生与深度学习入门者,提供完整的Python源码、训练好的模型以及PyQt5图形界面,能够对网课学习状态进行实时分析,帮助识别专注程度与分心行为。压缩包共122个文件,整体大小约179.87MB,包含py/pyc源码、pt权重文件、onnx导出模型、yaml参数配置、界面相关ui文件、图标图像以及项目计划书和说明文档,人脸关键点检测数据与提示音频也一并打包,目录组织规整,便于按功能模块查阅。项目源自导师认可的高分毕业设计,评审分达99分,代码结构完整、依赖梳理清晰,基本可以达到直接运行或二次开发的程度。当前已有112人学习下载,特别适合需要快速搭建目标检测应用、熟悉YOLOv5与PyQt5集成流程的在校学生,也可作为课程设计或毕业答辩的参考方案。
1. 网课专注度检测不是玄学:YOLOv5+PyQt5技术栈为什么适合当毕设
网课专注度检测系统,听起来像玄学,拆开其实就两件事:用yolov5目标检测模型识别人脸、手机、低头状态,再用PyQt5把这些结果实时画到桌面窗口里。做这个方向的人诉求一般很直接——毕设要高分,又不想去啃冷门数据集。yolov5的成熟度能撑起整个检测链路,PyQt5又能补齐可视化工作量,两者拼起来,从摄像头取流到画框出报表,一个完整的功能闭环就出来了。适合零基础起步,也适合想两个月内跑通整套项目的同学。真正的风险点不在模型训练,而在界面线程和推理速度之间的配合——这篇文章就按我自己的排坑顺序来讲。
2. 先立目标再调模型:专注度怎么定义、YOLOv5用哪一版网络结构
2.1 专注度检测到底检测什么:三类标签设计与姿态判断逻辑
拿“专注度”这三个字去做目标检测,第一步不是选模型,而是把专注度变成一个能被标注框住的东西。检测模型只能输出“这里有什么、在哪个位置”,它不能直接告诉你“这个学生有没有在听”。所以常见做法是做一个中间层:给页面上的状态打标签,再用标签组合去推导专注度。
我一般会这样定义标签体系。第一类是“人”,完整检测学生躯干或头部,用于确认是否出现在摄像头范围里。第二类是“手机”,用于判断是否在低头玩手机。第三类可以加“书本/笔记”作为专注状态的正向证据。检测层的输出只是坐标框,真正计算专注度靠的是后续的状态机逻辑——比如检测到人但长时间检测不到手机,判定为专注;人离开了画面或手机框持续出现,判定为分心。
# focus_status.py:把检测结果转成专注度状态机的伪代码 class FocusState: def __init__(self, absent_frames=15, phone_frames=30): self.absent_frames = absent_frames self.phone_frames = phone_frames self.absent_count = 0 self.phone_count = 0 def update(self, detections, img_h, img_w): has_person = any(d.cls == 0 for d in detections) # 假设0类是人 has_phone = any(d.cls == 1 for d in detections) # 假设1类是手机 self.absent_count = self.absent_count + 1 if not has_person else 0 self.phone_count = self.phone_count + 1 if has_phone and has_person else 0 if self.absent_count > self.absent_frames: return "absent" # 人离开画面 if self.phone_count > self.phone_frames: return "distracted" # 长时间玩手机 return "focused"这段代码说明了专注度系统的核心:模型只负责“有什么”,状态机负责“发生了什么”。absent_frames和phone_frames要按视频帧率调,比如25fps的采集源,15帧就是0.6秒,太灵敏会导致误报。这里的detections是模型后处理完的结果数组,每个元素带cls和坐标框。
2.2 YOLOv5网络结构选型:s模型还是m模型,以及C3模块和检测头
yolov5有n/s/m/l/x几个尺寸,毕设场景我最推荐s或m。s模型参数最少,在CPU上也能勉强跑到每秒十几帧;m模型精度高一点,但需要一块显存不低于4GB的显卡。至于网络结构里具体的东西:Backbone的C3模块负责提取特征,Neck的FPN+PAN结构把多层特征融合,Head的三个检测头分别在80x80、40x40、20x20特征图上输出小目标、中目标和大目标。
专注度检测场景里,手机通常是画面里较小的目标,所以关键在小目标那一层。如果训练时发现手机经常漏检,优先看80x80这个检测头的loss是不是偏高。选型上还有个容易被忽略的细节:直接下载官方COCO预训练权重,里面本来就有person类和cell phone类,所以迁移学习的起点非常好。如果自己从头训练,小目标收敛会慢得多。
2.3 推理后处理:置信度阈值、NMS与专注度状态机之间的衔接
模型输出的是原始张量,必须经过后处理才能变成上面状态机能用的detections。后处理主要做两件事:按置信度阈值过滤掉低质量框,再做NMS去除重叠框。yolov5仓库里的non_max_suppression函数参数很关键,conf_thres我习惯设为0.25,iou_thres设为0.45,这两个值要在误报和漏检之间找平衡。
置信度阈值设太低,比如0.1,画面里会出现大量“幽灵框”,手机检测的误报会直接污染专注度状态机;设太高,比如0.6,手机小目标很容易被过滤掉。一个可靠的做法是先拿测试视频跑一遍,把检测框的可视化打开,看哪些框是错检,哪些框是漏检,再回头调阈值。后处理还有个性能细节:可以用half=True开启FP16推理,但CPU上不要开,有些老CPU的FP16指令集不全,推理反而变慢。
3. 把训练跑通:数据集处理、训练命令与3个必调超参数
3.1 VOC转YOLO格式:转换脚本与四个边界坑
无论是自己标注还是找公开数据集,最常见的是VOC格式的XML标注,而yolov5训练要的是YOLO格式的TXT,每行“类别ID 归一化中心x 归一化中心y 归一化宽 归一化高”。转换脚本需要遍历JPEGImages里的每张图,把XML里的坐标换算成归一化值,然后按同样的文件名写入labels目录。
# voc2yolo.py:VOC XML标注转YOLO TXT格式 import os import xml.etree.ElementTree as ET def voc_to_yolo(xml_path, out_dir, class_names): tree = ET.parse(xml_path) root = tree.getroot() img_w = int(root.find("size/width").text) img_h = int(root.find("size/height").text) lines = [] for obj in root.iter("object"): cls_name = obj.find("name").text if cls_name not in class_names: continue cls_id = class_names.index(cls_name) bndbox = obj.find("bndbox") x1 = float(bndbox.find("xmin").text) y1 = float(bndbox.find("ymin").text) x2 = float(bndbox.find("xmax").text) y2 = float(bndbox.find("ymax").text) # 边界框裁剪,避免归一化后坐标超出0~1 x1 = max(0, min(x1, img_w)) y1 = max(0, min(y1, img_h)) x2 = max(0, min(x2, img_w)) y2 = max(0, min(y2, img_h)) cx = ((x1 + x2) / 2) / img_w cy = ((y1 + y2) / 2) / img_h bw = (x2 - x1) / img_w bh = (y2 - y1) / img_h lines.append(f"{cls_id} {cx:.6f} {cy:.6f} {bw:.6f} {bh:.6f}") out_path = os.path.join(out_dir, os.path.basename(xml_path).replace(".xml", ".txt")) with open(out_path, "w") as f: f.write("\n".join(lines)) class_names = ["person", "phone"] # 与data.yaml里的nc顺序必须一致这里有个典型的坑:类别的顺序不能换。假如class_names是["phone", "person"],但训练配置里data.yaml写的是["person", "phone"],模型会一直把手机当成人来学,训练出来的loss低但实际检测完全错乱。第二个坑是边界框坐标超出图像范围,如果标注框紧贴图片边缘,坐标可能在图像外面,需要像我上面那样用max/min裁剪之后再归一化。第三个坑是数据集图片和标签文件名要完全一致,大小写都不能差。第四个坑是转换后一定要随机抽几张图,把框画回去看一眼,这一步能发现坐标横纵颠倒、除以的是宽度而不是高度这类低级错误。
3.2 训练命令与超参数:epochs、batch-size、imgsz怎么定
转换完数据之后,按yolov5的标准目录摆放数据集,然后直接跑训练脚本。目录结构是datasets/attention/images/train、datasets/attention/images/val,标签目录对应labels/train和labels/val,再写一个data.yaml声明路径、类别数量和类别名。
# 在yolov5仓库根目录下执行 python train.py \ --data datasets/attention/data.yaml \ --weights yolov5s.pt \ --epochs 100 \ --batch-size 16 \ --imgsz 640 \ --device 0 \ --workers 4 \ --project runs/attention这条命令的意思是:加载预训练的yolov5s.pt作为初始权重,训练100轮,每批16张图,输入分辨率640x640。前三个超参数我按重要程度排:epochs不要太少,50轮以下模型可能还没收敛,但也不是越多越好,100轮之后观察val/box_loss和val/cls_loss,连续10轮不再下降就可以停了;batch-size取决于显存,6GB显存跑s模型可以设16,跑m模型降到8;imgsz建议就640,想提升小目标精度可以试1280,但训练时间会翻倍,且PyQt5推理端也要跟着改成1280,否则精度直接打折。
训练过程中我习惯看三个指标:val/box_loss、val/cls_loss和mAP@0.5。这三个都在TensorBoard里,训练结束也可以打开runs/attention/exp/results.csv看指标。初期如果mAP@0.5一直上不去,先检查转换脚本输出的TXT文件里有没有空文件——空标签文件会导致训练时该图被跳过,数量多了等于白训。
3.3 评估与导出:val.py看指标,导出TorchScript方便PyQt加载
训练结束后要做评估,yolov5自带的val.py会输出每个类别的精确率、召回率和mAP。专注度场景里要特别看重手机的召回率,因为手机漏检直接导致分心状态判断失效。评估完导出模型,PyQt5界面端加载建议用TorchScript格式,这样推理代码不依赖yolov5仓库的Python环境,部署时干净很多。
# 先用val.py评估 python val.py --data datasets/attention/data.yaml --weights runs/attention/exp/weights/best.pt --imgsz 640 # 导出TorchScript,供PyQt5项目加载 python export.py --weights runs/attention/exp/weights/best.pt --include torchscript --imgsz 640导出之后会得到best.torchscript文件,PyQt5端直接torch.jit.load加载,不需要再实例化Detect模块,也绕开了模型版本对不上的问题。这里有个经验:导出前确认Pixel和标注用的类别顺序一致,export.py不会帮你重新映射类别。
4. PyQt5界面集成:实时检测线程与信号槽的落地写法
4.1 界面结构与线程划分:为什么推理必须丢进QThread
PyQt5界面集成的第一个关键决策,是把视频推理放进子线程。很多翻车案例都是把cv2.VideoCapture.read()和模型推理写在主窗口的事件循环里,结果画面一卡,窗口直接提示“未响应”,再严重点直接闪退。原因是推理是同步阻塞操作,一帧推理耗时可能达到50到100毫秒,阻塞期间界面无法刷新。
正确结构是三个线程:主线程只跑Qt事件循环,负责创建窗口、响应用户点击;QThread子线程里跑视频循环,取帧、推理、回传结果;如果要做统计报表,统计计算丢到第三个线程,避免和推理抢GIL。一个线程里的耗时操作越少,界面越流畅。
4.2 视频流循环与信号回传:核心代码与参数说明
QThread和界面通信靠信号槽。子线程检测到一帧就发一个信号,信号携带检测框、类别、置信度和当前画面的帧数据,主线程收到信号后重绘界面。信号里传numpy数组没问题,PyQt5的pyqtSignal支持object类型。
# detect_thread.py:PyQt5推理线程的骨架 import cv2 import torch import numpy as np from PyQt5.QtCore import QThread, pyqtSignal class DetectThread(QThread): frame_ready = pyqtSignal(object, object, int) # 帧数据、检测结果、状态 def __init__(self, model_path, source=0, conf_thres=0.25): super().__init__() self.model = torch.jit.load(model_path, map_location="cpu") self.model.eval() self.cap = cv2.VideoCapture(source) self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, 1280) self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 720) self.running = True def run(self): while self.running: ret, frame = self.cap.read() if not ret: break rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) img = torch.from_numpy(rgb).permute(2, 0, 1).float().div(255) img = img.unsqueeze(0) with torch.no_grad(): results = self.model(img) # 这里做后处理、NMS,得到boxes和labels self.frame_ready.emit(frame, (boxes, labels, scores), state) self.cap.release() def stop(self): self.running = False self.wait()这段代码的重点是信号定义和跑循环的方式。frame_ready信号每次带三个参数,主线程槽函数接收后把它们画到QLabel上。source可以是摄像头索引,也可以是视频文件路径,调试期间建议先拿视频文件测,摄像头打不开的情况排查起来更花时间。torch.jit.load出来的模型推理不需要torch.no_grad,但保险起见还是加上,它同时能减少内存占用。
4.3 画框与状态显示:QLabel实时刷新、表格记录与截图保存
主线程拿到检测结果后的处理逻辑,通常是把整帧画面转成QImage,再画上检测框和状态文字,最后设置到QLabel上。画框用QPainter完成,注意坐标换算——模型输入是640x640,但摄像头采集是1280x720,检测框坐标必须按比例映射回去。
# main_window.py:主线程里的绘制逻辑 def update_frame(self, frame, detections, state): rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) h, w, _ = rgb.shape for box, label, score in zip(*detections): x1, y1, x2, y2 = [int(v) for v in box] # 坐标从640分辨率映射到原图分辨率 x1 = int(x1 * w / 640) y1 = int(y1 * h / 640) x2 = int(x2 * w / 640) y2 = int(y2 * h / 640) cv2.rectangle(rgb, (x1, y1), (x2, y2), (0, 255, 0), 2) cv2.putText(rgb, f"{label} {score:.2f}", (x1, y1 - 6), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2) qimg = QImage(rgb.data, rgb.shape[1], rgb.shape[0], QImage.Format_RGB888) self.video_label.setPixmap(QPixmap.fromImage(qimg)) self.status_label.setText(f"当前状态: {state}")坐标映射这一步特别重要,不映射的话,框会偏到画面的左上角小区域。检测结果的展示不要只放一个画面,还要有状态标签和时间线记录。每次状态切换时记录时间点,之后做专注度报表就有基础数据了。
5. YOLOv5+PyQt5常见坑:卡顿、闪退、检测不到目标的排查
5.1 界面卡死、无响应
现象:点击开始检测后,窗口标题栏出现“未响应”,几秒后恢复,或者直接崩掉。 原因:推理代码和画面刷新都写在主线程里,一帧推理50毫秒以上,Qt事件循环被阻塞,系统判定窗口失去响应。 解决:把所有cap.read()和模型推理挪到QThread的run()里,主线程只做信号接收和绘制。还要注意子线程里不要创建任何QWidget,Qt的UI操作必须留在主线程。
5.2 摄像头黑屏或打不开
现象:cv2.VideoCapture(0)返回False,或者打开后画面全黑。 原因:笔记本摄像头索引不是0;部分设备需要额外的后端参数;摄像头被其他程序占用。 解决:用cv2.VideoCapture(0, cv2.CAP_DSHOW)强制指定DirectShow后端,Windows下有效。写一个简单的摄像头枚举脚本,循环试索引0到5,找出能正常读到帧的索引。调试阶段用视频文件替代摄像头,先把链路跑通再换摄像头。
5.3 检测框全部为空或标签错乱
现象:加载训练好的模型后,画面上一个框都没有,或者把手机框成“person”。 原因:data.yaml里的类别顺序和训练时的class_names不一致;置信度阈值设得过高;输入图像的尺寸和训练时不一致。 解决:以训练时的data.yaml为准,重新检查voc2yolo.py里的类别列表。把conf_thres降到0.15试试,如果还是没框,打印模型输出 tensor 的形状,确认后处理代码是否匹配导出模型。
5.4 显存不足导致训练中断
现象:训练跑到第几个epoch直接报CUDA out of memory。 原因:batch-size设太大,或imgsz设了1280,显存被中间特征图占满。 解决:把batch-size从16降到8或4,同时--workers降到2,减少数据加载进程占用的内存。还可以在train.py里加--amp参数开启混合精度训练,能把显存占用砍掉一半左右,速度反而更快。
5.5 模型加载慢、启动时间长
现象:点击“开始检测”后,等了十几秒窗口才有反应。 原因:torch.jit.load后没有做warming up,首次推理时模型会执行初始化算子,耗时是正常推理的好几倍。 解决:加载完模型后,用一张全零的假图先推理一次,把算子触发开销消耗掉,再进入真正的视频循环。代码就一行:self.model(torch.zeros(1, 3, 640, 640))。
6. 让毕设更耐看:专注度打分与统计报表的最后一招
很多专注度检测毕设卡在“检测出来了,但不会量化”。如果你想让项目档次明显高于平均水平,加一个“专注度评分”模块——每秒钟根据状态机输出一个专注/分心/离开的判定,然后用滑动窗口算出一分钟内的专注度百分比,最后把整个网课时段的评分画成曲线,并导出CSV。这一块代码不多,但答辩时非常出效果。
# score.py:滑动窗口专注度打分 import csv import time from collections import deque class FocusMeter: def __init__(self, window_size=60, fps=25): self.window = deque(maxlen=window_size * fps) self.start_time = time.time() self.records = [] def add_frame(self, state): self.window.append(1 if state == "focused" else 0) def score(self): if len(self.window) < 30: return None return round(100 * sum(self.window) / len(self.window), 1) def save_csv(self, path): elapsed = time.time() - self.start_time with open(path, "w", newline="") as f: writer = csv.writer(f) writer.writerow(["time_sec", "focus_score"]) writer.writerow([round(elapsed, 1), self.score()])这个模块的思路是把状态流变成一个0/1序列,再用队列做滑动窗口平均。窗口大小按分钟算,60秒窗口在25fps下就是1500帧,队列容量刚好够。保存CSV时建议每个评分周期存一行,不要只在退出时存一次,否则程序异常退出数据全丢。我自己的习惯是状态栏上同时显示“当前专注度”和“已检测时长”,曲线部分用pyqtgraph画,比matplotlib嵌Qt流畅得多。
最后再说一个血泪经验:demo演示前一定把摄像头驱动提前装好,现场装驱动是最容易翻车的环节。这套方案的真正价值在于它把目标检测、后处理、界面工程、数据统计串成了一条完整链路,你不需要在每个环节都做到极致,但每个环节都要能说出设计理由。希望帮到你,这比模型刷高那一个点的mAP值更有说服力。
本文还有配套的精品资源,点击获取