基于YOLO11与PyQt5的学生课堂行为检测系统实战指南
简介基于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.350.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记录过去 2530 帧的类别结果做多数投票。一个人连续 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 文件坐标全部归一化到 01。这一步转换脚本我每次都要写核心逻辑如下import json import os from glob import glob # labelme 导出的 json 文件目录 json_dir labels_json out_dir labels_txt os.makedirs(out_dir, exist_okTrue) # 类别映射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, encodingutf-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), encodingutf-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 \ modelyolo11n.pt \ datadataset/behavior.yaml \ epochs120 \ imgsz640 \ batch16 \ lr00.01 \ patience20用 yolo11n.pt 作为起点是让模型在 COCO 预训练权重的基础上做迁移学习收敛速度比从零训练快很多。首次运行会自动下载预训练权重下载超时的话手动把权重文件放到用户目录下的权重缓存目录里再重试。batch16 在 6G 显存显卡上跑 640 分辨率没问题如果显存不足 8G不要硬提 batch先降 imgsz 更划算。patience20 是早停参数连续 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, source0, conf0.35, parentNone): 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, imgsz640, confself.conf, verboseFalse) 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 后要做缩放再显示到 QLabelfrom 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 输入推理用 imgsz640画面清晰度和 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 pyqt55.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就能拿到跨帧的跟踪 IDresults model.track( frame, imgsz640, conf0.4, persistTrue, trackerbytetrack.yaml, verboseFalse, ) 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 在同一人在场期间保持不变persistTrue 表示跟踪器会把 ID 信息保留到下一帧tracker 参数可选 bytetrack.yaml 或 botsort.yaml课堂场景用 bytetrack 效果好一些。有了 track_id之前提到的 25 帧多数投票才有意义——先按 ID 分组再对每个 ID 的行为类别做投票。视频画面里有人被遮挡再出现时跟踪 ID 通常会变这是正常现象不要试图完全消除。6.2 一节课的行为时间线怎么生成我的做法是维护一个字典key 是 track_idvalue 是最近 25 帧的行为类别队列。每 125 帧约 5 秒做一次统计把每个 ID 的多数类别记下来from collections import Counter, deque state {} # track_id - deque(maxlen25) 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(maxlen25)) 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 置信度阈值和自定义类别让系统适配不同教室不同教室的摄像头位置、光线、学生密度差别很大。同一套 conf0.4 的参数在 A 教室表现正常在 B 教室可能误检暴增。把置信度阈值做成 QSlider 控件暴露在界面上现场调试时拖动滑块就能看到检测结果变化不需要重新训练模型。这比在代码里反复改参数再重启程序高效得多。想扩充行为类别时不要直接在原数据集上追加标注而是把新类别单独标一批图再合并到原数据集里重新训练。例如要增加“讨论”类别就把相邻学生侧身交谈的场景单独收集 300500 张图更新 data.yaml 的 names 列表重新跑训练。标注规则要提前定清楚“侧身但没说话”算不算讨论否则又是一轮无效标注。我自己交付这类项目的习惯是先拿 80 张验证集图做一次可视化检查确认标注框和类别文字都正确再开始训练训练结束后用一段 5 分钟的真实课堂视频做端到端验证而不是只看 mAP 数字。界面永远先跑通 CPU 推理再上 GPU避免一上来就被环境问题卡住。这套流程看起来土翻车率确实最低。希望帮到你。本文还有配套的精品资源点击获取