☰
基于YOLO11与PyQt5的学生课堂行为检测系统实战指南
2026/9/27 23:09:49 网站建设 项目流程

简介:基于YOLO11深度学习的学生课堂行为检测系统,集成Pyqt5图形界面,面向计算机相关专业学生、教师及企业开发者,适用于毕业设计、课程设计、课堂行为分析实战项目。系统支持举手、阅读、书写、使用手机、低头、趴在桌子上共6类行为识别,模型训练完成,开箱即用。资源共2000个文件,包含Python源码(py)、数据集标注文件(txt/xml)、模型配置(yaml)及相关训练评估脚本,压缩包约603.51MB,内容完整。已有502人学习下载。包内附带安装使用教程、训练好的模型、评估指标曲线及演示图片视频,便于快速复现和二次开发;2000多张标注数据集可支撑进一步扩展行为类别或调整训练策略。代码已由作者训练测试通过,运行稳定,遇到问题可提供技术支持,适合新手学习进阶,也可直接用于项目演示与课程作业。

1. 学生课堂行为检测系统,先搞清楚它到底解决什么问题

YOLO11 做学生课堂行为检测系统,真正难的不是把模型跑起来,而是把“举手、低头、睡觉、玩手机”这些抽象概念变成模型和数据能理解的东西。一套开箱即用的系统,通常由三块组成:一份标注好的课堂行为数据集、一个用 YOLO11 训练出来的检测模型、一个用 PyQt5 搭出来的桌面 GUI。老师对着摄像头,实时看到画面里每个学生的行为类别;课后再看统计曲线,知道哪几分钟举手人数最多、哪几分钟低头人数突然增加。做这类系统的人,多半是在交课程设计、给学校做教务信息化试点,或者想验证深度学习在真实场景里的落地。下面这套流程,我从数据准备讲到 GUI 封装,照着走一遍,两三天能跑通。

2. 从 YOLO11 到行为判定:这套系统拆开是哪几层

2.1 YOLO11 网络结构里,哪几个参数直接决定检测效果

YOLO11 是 Ultralytics 在 YOLOv8 基础上迭代出来的检测模型,延续了 CSP 风格的 backbone 加 FPN/PAN neck 加解耦检测头的整体框架。backbone 里用 C3k2 模块替代了 v8 的 C2f,卷积核的搭配做了轻量化,整体对算力的要求更友好。检测头仍然是分类分支和回归分支分开的结构——分类分支判断这个框里是“举手”还是“睡觉”,回归分支负责把框的位置和大小压准。

真正影响课堂场景检测效果的,不是网络结构本身,而是下面几个参数:

backbone 的规格选择。YOLO11 提供 n/s/m/l/x 五个规格,n 最轻、x 最重。课堂行为检测的目标是“人 + 行为”,不是检测针尖大小的缺陷,用不着上 l 和 x。我的习惯是:先用 n 把流程跑通,再用 s 做最终训练。s 在速度和精度之间的平衡最舒服,1080p 摄像头输入、GTX 1660 级别显卡上能跑到 20 帧以上。

输入分辨率 imgsz。YOLO11 默认建议 640,但教室场景里学生坐得远,一个人可能只占画面几十个像素,640 容易漏检。我一般用 960,代价是显存占用接近翻倍、推理帧率下降。如果显卡只有 6G 显存,先 640 训练、推理时再用 960,效果会有提升但不稳定。这属于典型的“数据决定上限”场景。

置信度阈值 conf。推理时默认 0.25,对课堂来说太低,会出现大量“把背影当低头、把伸懒腰当举手”的误检。训练好的模型跑界面时,conf 收到 0.35~0.5 比较合适。阈值这件事,后面第 6 章还会细说。

YOLO11 和小型分割模型 SAM2/SAM3 这类工具经常被一起提起,但课堂行为检测用检测框就够,分割模型显存占用大、推理慢,交互式分割还需要人工点提示,不适合自动化课堂监控。

2.2 行为标签体系怎么定:先定类别再定标注规则

模型能学到什么,取决于你给了它什么标签。课堂行为检测的数据集标注,最忌讳的是类别定义含糊。标注的人觉得“低头”和“看书”差不多,训练出来的模型就会在这两类之间反复横跳。

我给课堂场景定义了一套 6 类的标签体系,实际项目里基本够用:

行为类别判定要点标注边界说明
sit_up(听讲)面向黑板方向,坐姿正常,没有明显低头侧身和同桌说话不单独建类,统一算听讲
head_down(低头)头部明显低于正常坐姿,视线落在桌面低头看书和低头写字不区分,只看头部姿态
hand_up(举手)手臂抬起,肘部超过肩部高度伸懒腰和举手的区别在于手臂是否伸直、是否保持
sleep(睡觉)趴桌、闭眼后仰靠椅背戴帽子遮脸、趴着但眼睛睁着,按姿态标注
stand(站立)人处于站立状态,目标框高宽比明显变化从座位上站起来挪动、走到过道,都算站立
phone(玩手机)手持手机且视线落在屏幕上手里拿手机但没低头看,不算玩手机

这套 6 类体系有两个原则:第一,类别之间边界必须能用姿态描述,不需要结合前后文判断;第二,不要超过 8 类,类别越多,类间混淆越严重,标注成本成倍上涨。标注规则定下来之后,要写成一页纸的说明发给每个标注的人,否则 2000 张图标注出来,同一行为会有三种不同标法。

2.3 检测加判定两段式:为什么这么设计

一个常见的误区是:让模型直接输出“这个学生在玩手机”,然后界面就显示一行字。单帧检测模型只看到一张图片,没有时间概念。学生低头拿手机的这一帧被判为 phone,下一帧手机放回桌面、人还低着头的姿态被判为 head_down,界面上就会出现“玩手机—低头—玩手机”来回跳的乱象。

所以架构上要拆成两段:

第一段,YOLO11 做逐帧检测,输出每个目标的类别和置信度。第二段,后处理逻辑做时序判定——给每个检测出来的学生分配一个跟踪 ID,记录过去 25~30 帧的类别结果,做多数投票。一个人连续 3 帧都被判为 hand_up,系统才认为他确实在举手;中间单帧跳成 sit_up,直接忽略。这套“检测 + 投票”的设计,代价是实时性有一帧左右的延迟,换来的是最终统计结果可靠得多。

还有一层考虑是训练成本。行为识别模型(比如基于骨骼关键点的动作识别)需要视频级别的标注和更复杂的网络结构,公开权重和工具链都不够成熟。而 YOLO11 可以直接加载官方预训练权重,2000 多张标注图就能训练出可用的模型,性价比明显高。

3. 用 2000 多张标注图喂出可用模型:数据整理与训练全流程

3.1 标注数据的统一:labelme 的 JSON 转成 YOLO 能读的 txt

常见做法是先用 labelme 做多边形标注,导出 JSON 文件。YOLO 系列训练需要的是每个目标一行“类别索引 x_center y_center width height”的 txt 文件,坐标全部归一化到 0~1。这一步转换脚本我每次都要写,核心逻辑如下:

import json import os from glob import glob # labelme 导出的 json 文件目录 json_dir = "labels_json" out_dir = "labels_txt" os.makedirs(out_dir, exist_ok=True) # 类别映射,key 必须和 labelme 里填写的标签名完全一致 class_map = { "sit_up": 0, "head_down": 1, "hand_up": 2, "sleep": 3, "stand": 4, "phone": 5, } for jf in glob(os.path.join(json_dir, "*.json")): with open(jf, "r", encoding="utf-8") as f: data = json.load(f) img_w = data["imageWidth"] img_h = data["imageHeight"] lines = [] for shape in data["shapes"]: label = shape["label"] if label not in class_map: continue points = shape["points"] # [[x1, y1], [x2, y2]] xs = [p[0] for p in points] ys = [p[1] for p in points] x1, x2 = min(xs), max(xs) y1, y2 = min(ys), max(ys) # 归一化到 [0, 1] x_center = (x1 + x2) / 2 / img_w y_center = (y1 + y2) / 2 / img_h box_w = (x2 - x1) / img_w box_h = (y2 - y1) / img_h lines.append(f"{class_map[label]} {x_center:.6f} {y_center:.6f} {box_w:.6f} {box_h:.6f}") base_name = os.path.basename(jf).replace(".json", "") with open(os.path.join(out_dir, base_name + ".txt"), "w") as g: g.write("\n".join(lines))

这段脚本有几个容易翻车的地方。labelme 的 points 是多边形顶点列表,这里直接取最小外接矩形,对行为检测场景足够——人这种目标用矩形框表达没有信息损失。class_map 里的 key 必须和标注时填写的标签一字不差,多了空格都会导致类别静默丢弃。转换完成后,跑一段统计脚本,确认没有越界索引:

import os label_index_set = set() for root, _, files in os.walk("labels_txt"): for f in files: if not f.endswith(".txt"): continue for line in open(os.path.join(root, f), encoding="utf-8"): label_index_set.add(int(line.split()[0])) print("出现的类别索引:", sorted(label_index_set)) print("最大索引 + 1 必须等于 data.yaml 里的类别数")

3.2 训练集的目录结构与一份能直接跑的训练配置

YOLO11 训练要求数据集按固定目录结构摆放。2000 多张图的规模不大,目录结构按下面的方式组织就够了:

dataset/ images/ train/ classroom_001.jpg classroom_002.jpg val/ classroom_101.jpg labels/ train/ classroom_001.txt val/ classroom_101.txt behavior.yaml

这里有一个关键坑:图片和 txt 必须同名,否则训练时图片会被静默跳过。我的做法是转换完之后写一个脚本,遍历 images 和 labels 目录,找出“有图无 txt”和“有 txt 无图”的文件,直接打印出来对照处理。

behavior.yaml 的内容:

path: dataset train: images/train val: images/val names: 0: sit_up 1: head_down 2: hand_up 3: sleep 4: stand 5: phone

如果 2000 多张图来自视频抽帧,切分训练集和验证集时不要随机打乱再切。同一个学生的连续帧会同时混进训练集和验证集,模型相当于开卷考试,验证指标虚高,一到真实课堂就翻车。正确做法是按时间顺序切分——前 80% 的视频帧进训练集,后 20% 进验证集。

3.3 跑通训练:命令、超参数与显卡选择的权衡

Windows 上安装 YOLO11 环境,核心就一条 pip 命令,依赖 torch 和 ultralytics 两个包。Python 版本建议 3.10 或 3.11,太新的 3.13 容易碰到依赖编译问题。训练命令我一般这么写:

yolo detect train \ model=yolo11n.pt \ data=dataset/behavior.yaml \ epochs=120 \ imgsz=640 \ batch=16 \ lr0=0.01 \ patience=20

用 yolo11n.pt 作为起点,是让模型在 COCO 预训练权重的基础上做迁移学习,收敛速度比从零训练快很多。首次运行会自动下载预训练权重,下载超时的话手动把权重文件放到用户目录下的权重缓存目录里再重试。batch=16 在 6G 显存显卡上跑 640 分辨率没问题,如果显存不足 8G,不要硬提 batch,先降 imgsz 更划算。patience=20 是早停参数,连续 20 个 epoch 验证集指标不提升就自动停止,能省下不少无用训练时间。

训练完成后,runs/detect/train 目录下会生成 weights/best.pt,这就是后面 GUI 要加载的模型文件。

3.4 评估指标怎么看:mAP50、mAP50-95 与混淆矩阵

训练结束不能只看 loss 曲线,重点看三个东西:results.png、混淆矩阵、验证集实测效果。results.png 里如果 train loss 持续下降但 val loss 掉头上升,说明过拟合,减少 epochs 或调大 pattience 重新训练。混淆矩阵关注易混淆类别——hand_up 和 sit_up、head_down 和 phone 是课堂场景里最容易出问题的两对。

指标含义课堂场景可接受范围
precision检出的行为里正确的比例0.85 以上
recall实际行为里被检出的比例0.80 以上
mAP50IoU 在 0.5 时的平均精度0.85 以上
mAP50-95严格 IoU 下的平均精度0.60 以上

mAP50 到 0.85 以上,模型就具备交付条件了。低于这个值,先别调模型结构,回到数据看一眼——是不是某一类样本太少,是不是标注框偏大或偏小,数据的问题占了八成的翻车原因。深度学习算法的可解释性本来就差,指标和可视化结果是唯一能相信的评估依据。

4. 用 PyQt5 把模型包成能双击运行的桌面程序

4.1 界面布局怎么设计:预览区、控制区、日志区的最小划分

PyQt5 做 GUI 的优势是上手快、界面风格原生,不需要额外的前端知识。用 pycharm 写界面代码和写普通 Python 没有区别,不需要装 designer 插件,手写布局反而更好改。

课堂行为检测程序的窗口,按三个区域划分就够:

上方是主预览区,用一个 QLabel 显示实时画面,QLabel 用 setScaledContents 会让画面拉伸变形,正确做法是把 QLabel 固定大小后按比例缩放图片。下方左侧是控制区,放四个 QPushButton:打开摄像头、打开图片、打开视频、退出;再加一个 QSlider 调节置信度阈值,一个 QComboBox 选择模型文件。下方右侧是日志区,用 QTextEdit 显示模型加载状态、当前 fps、检测到的人数,这些信息在调试阶段非常有用。

QMainWindow 作为主窗口,QVBoxLayout 做垂直布局,QHBoxLayout 做水平布局。界面代码本身不复杂,复杂的是怎么让推理不卡界面。

4.2 用 QThread 跑推理:不卡界面的最小实现

新手最容易犯的错,是在 Qt 主线程里直接写一个 while 循环读摄像头帧、跑推理、刷新画面,结果窗口一打开就“未响应”。Qt 的界面事件循环被循环体阻塞,画面自然刷新不了。解决方法是把摄像头读取和模型推理放进 QThread 子线程,通过信号把处理好的图像发回主线程。

import cv2 from PyQt5.QtCore import QThread, pyqtSignal from PyQt5.QtGui import QImage from ultralytics import YOLO class DetectThread(QThread): frame_ready = pyqtSignal(object) # 把 QImage 传给主线程 log = pyqtSignal(str) def __init__(self, model_path, source=0, conf=0.35, parent=None): super().__init__(parent) self.model_path = model_path self.source = source # 0 表示默认摄像头,也可以传视频文件路径 self.conf = conf self._running = True def stop(self): self._running = False def run(self): self.log.emit("模型加载中,请稍候...") model = YOLO(self.model_path) cap = cv2.VideoCapture(self.source) cap.set(cv2.CAP_PROP_FRAME_WIDTH, 1280) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 720) while self._running: ok, frame = cap.read() if not ok: self.log.emit("读取不到视频帧,检查摄像头或视频文件") break results = model.predict(frame, imgsz=640, conf=self.conf, verbose=False) annotated = results[0].plot() # 把检测框画到帧上 # OpenCV 是 BGR 顺序,Qt 显示需要 RGB rgb = cv2.cvtColor(annotated, cv2.COLOR_BGR2RGB) h, w, ch = rgb.shape qimg = QImage(rgb.data, w, h, ch * w, QImage.Format_RGB888).copy() self.frame_ready.emit(qimg) cap.release() self.log.emit("视频流已停止")

模型加载放在 run() 开头而不是init里,是为了避免程序启动时界面白屏等待。QThread 里不允许直接操作 UI 控件,所有反馈都通过信号发出去。frame_ready 信号把 QImage 对象发给主线程,主线程里的槽函数只做一件事:把图显示到 QLabel 上。这里最容易忽略的是 .copy()——QImage 构造时默认不持有图像数据的所有权,rgb 数组一旦被回收,界面显示就会花屏或崩溃。

4.3 OpenCV 帧与 QImage 的转换:一帧画面显示到界面上的完整路径

主线程接收 QImage 后,要做缩放再显示到 QLabel:

from PyQt5.QtCore import Qt from PyQt5.QtGui import QPixmap class MainWindow(QMainWindow): # 假设 self.video_label 是预览区的 QLabel def update_frame(self, qimg): pixmap = QPixmap.fromImage(qimg) scaled = pixmap.scaled( self.video_label.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation, ) self.video_label.setPixmap(scaled)

scaled 的第二个参数用 KeepAspectRatio,画面比例不会变形;用 SmoothTransformation 让缩放后的画面不出现严重锯齿。这里有一个容易忽略的点:update_frame 里的 qimg 来自子线程信号,Qt 的信号槽机制会保证它在主线程中执行,但 qimg 本身是线程间共享的,不要在槽函数里长时间持有它,显示完就释放引用。

摄像头分辨率不需要拉到 4K。1280×720 输入,推理用 imgsz=640,画面清晰度和 fps 的平衡最好。如果帧率还是不够,优先降摄像头分辨率而不是降 imgsz——分辨率降了用户感知不明显,imgsz 降了检测精度掉得直观。

5. 避坑:环境配置、界面卡死与检测翻车

这一章的坑,基本是我被反复折磨过的问题,按“现象 → 原因 → 解决”的格式写清楚。

5.1 现象:pip 安装 PyQt5 报错,或者 labelme 连带装不上 PyQt5

现象是 pip install pyqt5 时提示找不到满足要求的版本,或者装 labelme 时卡在 PyQt5 依赖上下载超时。很多人在这一步就放弃了整个项目。

原因是 pip 默认源在国外,PyQt5 的安装包体积大,下载容易超时;另一方面 Python 3.12 以上版本和某些 PyQt5 小版本存在 wheel 不匹配的问题。

解决方法是换国内 pip 源,并锁定 PyQt5 版本:

pip install -i https://pypi.tuna.tsinghua.edu.cn/simple pyqt5==5.15.10

同时项目 Python 环境用 3.10 或 3.11,不要用最新的 3.13。labelme 依赖 PyQt5,先把 PyQt5 装好,再装 labelme 就不会再触发这个坑。

5.2 现象:摄像头一开,界面立刻“未响应”

现象是点击“打开摄像头”按钮后窗口白屏,标题栏出现“未响应”,过几秒要么恢复要么彻底卡死。

原因是把视频循环和模型推理直接写在了主线程的按钮槽函数里。Qt 的事件循环被 while True 阻塞,界面重绘、鼠标响应全部排队等待。

解决方法是把推理逻辑整体移到 QThread 子线程,界面只通过信号接收结果。注意线程的停止不能直接调用 terminate(),那会导致摄像头资源没释放;用第 4 章的 _running 标志位,让循环自然退出。

5.3 现象:检测框乱跳,同一个人一会儿举手一会儿放下

现象是模型中 conf 调低之后,同一个人的检测框在坐姿和举手之间来回切换,界面上的状态文字跟着闪。

原因是单帧检测没有时序约束。一个人在举手动作的中间帧,手臂角度处于临界状态,模型这一帧判 sit_up、下一帧判 hand_up,完全取决于姿态在边界上的微小偏移。

解决方法是给每个目标加跟踪 ID,记录最近 25 帧的检测类别做多数投票;至少要连续 3 帧被判定为同一个类别,才更新界面上的行为状态。这个逻辑直接放在推理线程里,信号只发射投票后的结果。

5.4 现象:训练正常结束,mAP 却全是 0

现象是训练过程没报错、loss 在下降,但训练结束后 mAP50、mAP50-95 全部为 0,混淆矩阵里所有预测堆在某一类上。

原因是类别映射错位。标注阶段把 sit_up 排在第 0 位,但 data.yaml 里 names 的顺序把 head_down 排在了第 0 位,模型的预测和真实标签完全对不上。还有一种情况是 txt 标注文件里出现了超出 names 长度范围的索引,YOLO 会直接忽略这部分标注。

解决方法是训练前写统计脚本,打印所有 txt 里出现过的类别索引,和 data.yaml 的 names 逐一对照;再随机挑几张图做标注可视化,把标注框和类别文字画在图上人工确认。这个检查只要做一次,能省掉一整轮无效训练。

5.5 现象:用 PyInstaller 打包后模型加载失败

现象是开发环境里一切正常,打包成 exe 后双击运行直接闪退,或者提示找不到 best.pt 文件。

原因是 ultralytics 和 torch 使用了大量动态导入,PyInstaller 打包时收集不到全部依赖;相对路径的模型文件在 exe 工作目录里也不存在。

解决方法是打包命令里显式收集依赖:

pyinstaller --onefile --windowed \ --collect-all ultralytics --collect-all torch \ main_window.py

模型文件的加载路径不要写相对路径,用 os.path.dirname(sys.executable) 拼绝对路径。打包后的 exe 要放在模型文件同级目录下分发,交付说明里写清楚这一点。

6. 让“检测”变成“行为分析”:跟踪 ID、置信度阈值与课堂统计

6.1 用跟踪 ID 把人串起来:从“框”到“一个人的行为序列”

检测模型输出的是一帧一帧的框,行为分析需要的是“某个人在某个时间段干了什么”。把 YOLO11 的 predict 换成 track,就能拿到跨帧的跟踪 ID:

results = model.track( frame, imgsz=640, conf=0.4, persist=True, tracker="bytetrack.yaml", verbose=False, ) for box in results[0].boxes: cls_id = int(box.cls[0]) track_id = int(box.id[0]) if box.id is not None else -1 # track_id 在同一人在场期间保持不变

persist=True 表示跟踪器会把 ID 信息保留到下一帧;tracker 参数可选 bytetrack.yaml 或 botsort.yaml,课堂场景用 bytetrack 效果好一些。有了 track_id,之前提到的 25 帧多数投票才有意义——先按 ID 分组,再对每个 ID 的行为类别做投票。视频画面里有人被遮挡再出现时,跟踪 ID 通常会变,这是正常现象,不要试图完全消除。

6.2 一节课的行为时间线怎么生成

我的做法是维护一个字典,key 是 track_id,value 是最近 25 帧的行为类别队列。每 125 帧(约 5 秒)做一次统计,把每个 ID 的多数类别记下来:

from collections import Counter, deque state = {} # track_id -> deque(maxlen=25) timeline = [] # 每 5 秒一条记录 frame_count = 0 class_names = ["sit_up", "head_down", "hand_up", "sleep", "stand", "phone"] # 每帧推理完成后调用 for box in results[0].boxes: tid = int(box.id[0]) state.setdefault(tid, deque(maxlen=25)) state[tid].append(class_names[int(box.cls[0])]) frame_count += 1 if frame_count % 125 == 0: votes = {} for tid, history in state.items(): votes[tid] = Counter(history).most_common(1)[0][0] timeline.append({ "t_sec": frame_count // 25, "hand_up": list(votes.values()).count("hand_up"), "head_down": list(votes.values()).count("head_down"), "sleep": list(votes.values()).count("sleep"), })

timeline 列表可以导出成 CSV,再用 matplotlib 画一条“举手人数-时间”曲线。老师看这张图就知道哪十分钟课堂互动最好,哪个时段学生普遍走神,这比盯着实时画面有效得多。

6.3 置信度阈值和自定义类别:让系统适配不同教室

不同教室的摄像头位置、光线、学生密度差别很大。同一套 conf=0.4 的参数,在 A 教室表现正常,在 B 教室可能误检暴增。把置信度阈值做成 QSlider 控件暴露在界面上,现场调试时拖动滑块就能看到检测结果变化,不需要重新训练模型。这比在代码里反复改参数再重启程序高效得多。

想扩充行为类别时,不要直接在原数据集上追加标注,而是把新类别单独标一批图,再合并到原数据集里重新训练。例如要增加“讨论”类别,就把相邻学生侧身交谈的场景单独收集 300~500 张图,更新 data.yaml 的 names 列表,重新跑训练。标注规则要提前定清楚“侧身但没说话”算不算讨论,否则又是一轮无效标注。

我自己交付这类项目的习惯是:先拿 80 张验证集图做一次可视化检查,确认标注框和类别文字都正确再开始训练;训练结束后用一段 5 分钟的真实课堂视频做端到端验证,而不是只看 mAP 数字。界面永远先跑通 CPU 推理再上 GPU,避免一上来就被环境问题卡住。这套流程看起来土,翻车率确实最低。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询