简介这是一份面向智慧课堂场景的YOLOv9学生上课状态识别检测系统完整资源包适合毕业设计、课程项目及深度学习目标检测入门者使用。资源基于YOLOv9-s模型平均准确率达99.6%并提供训练过程截图压缩包共176个文件整体约59.91MB涵盖83个Python源码脚本、30个YAML配置文件、3个已训练好的PT权重文件以及评估指标CSV、预测结果图片、reparameterization.ipynb等既能直接运行推理也支持按需重新训练。已有210人学习下载。除源码与模型外包内还包含详细的运行教程、训练数据集配置说明及检测效果示例可帮助用户理解从环境配置、数据准备到模型训练与测试的完整流程目录结构清晰训练好的best.pt等权重可直接用于线下课堂学生抬头、低头、玩手机等状态识别是快速搭建课堂行为分析系统的实用参考。1. 线下课堂学生状态识别YOLOv9为什么是当前最省事的工程方案把一个普通教室里的摄像头画面变成一份“谁在听课、谁在睡觉、谁在玩手机”的实时清单这件事最麻烦的不是算法本身而是小目标密集、遮挡频繁、光照变化大。基于YOLOv9实现的线下课堂学生上课状态识别检测系统正是冲着这个场景来的一套Python源码加训练好的权重文件加评估指标曲线既能从零开始训练自己的课堂状态检测器也能直接用现成模型做推理统计。适合三类人在高校实验室里做行为分析的算法方向学生、接教育信息化项目的集成商、以及想拿目标检测做毕设但不想只跑通一个DEMO的同学。2. YOLOv9的选型理由课堂场景为什么吃PGI和GELAN这一套2.1 可编程梯度信息给主分支配一根拐杖训练完就扔目标检测网络越深反向传播时梯度信息丢失越严重浅层网络拿不到足够细致的梯度小目标的轮廓和纹理就学不到位。YOLOv9把这个问题拆成一个很工程化的方案可编程梯度信息PGI。它在训练阶段额外加了一条辅助可逆分支让误差信号多一条路径传回浅层主分支的梯度被“兜住”等训练结束这条辅助分支直接从推理图里剪掉部署时还是那条干净的主干网络。这个设计对课堂状态识别来说是刚需。一个普通教室装一支摄像头画面里三四十个学生每个人头部在640x640输入下通常只有二三十像素属于标准的小目标密集分布。如果梯度传不到浅层模型学到的就只是“画面里有人”这种粗粒度特征根本分不清头部是抬着还是趴着。我一般会在这种场景下优先看模型对浅层特征的处理能力而不是只看参数量或榜单分数。补充一点PGI并不是把模型变大而是让训练过程更充分。它训练时多计算一条分支显存和训练时间会比YOLOv5高一些但推理时零额外开销。换句话说你为精度付的成本只在训练阶段发生一次部署端的紧张预算不受影响。2.2 GELAN特征聚合小目标密集场景下为什么更稳GELAN广义高效层聚合网络可以理解成把CSPNet的跨阶段拆分和ELAN的跨层聚合拼在一起。它把不同感受野的特征图做重复叠加和拼接让网络在同样的算力预算下拥有更强的特征复用能力。课堂场景里前排学生和后排学生尺度差异巨大——前排头部可能有60像素后排只有15像素——网络必须同时响应多个尺度的目标这恰好是GELAN比较擅长的区间。实际训练时我有个体感同样的数据和训练轮数GELAN做backbone的配置在“后排小头”上的召回率比旧一代结构高出一截而且不容易出现“前排全对、后排全漏”的偏科现象。另一个工程上的好处是GELAN对算力的利用率高训练时能把batch-size撑得更大收敛更快。这里要强调一个容易误读的点YOLOv9的精度优势并不是在所有数据集上都碾压YOLOv8。课堂状态这种类别少、目标小、场景固定的任务YOLOv9的收益主要来自梯度保持能力和特征复用结构如果你手里只有几十张图什么模型都救不了你先解决数据量问题。2.3 选型边界这几种场景建议你换掉YOLOv9不是所有课堂项目都适合上YOLOv9我踩过几次坑之后总结出三条边界。第一条如果你的部署端只有普通CPU而且要求实时出结果YOLOv9-c在FP32下的推理速度会让你很难受不如直接看轻量化方案或者做TensorRT量化。第二条如果任务只是统计教室里有多少人抬头不做细粒度状态分类为什么不直接用一个人头检测模型加一个简单的角度判断非要去训一个多分类检测器样本成本和标注成本都翻倍。第三条如果教室机位是手机从侧面拍的学生姿态以侧脸和全身为主那更值得考虑的是关键点方案或者带骨骼点输出的模型检测框信息量不够。我的习惯是先写一个两页纸的选型清单把部署硬件、帧率要求、类别数量、数据规模写清楚再决定用不用YOLOv9。模型选型这件事没有绝对的最优只有和场景匹配不匹配。做课堂项目硬件预算和摄像头机位往往比模型结构更能决定最终效果。3. 课堂数据的采集与标注4类状态怎么定、怎么标3.1 状态类别定义4类起步别一开始就上15分类课堂状态识别最容易犯的错是类别定义一上来就搞得很细。有人想区分“认真听讲”和“边听边记笔记”有人想加“举手”“喝水”“交头接耳”结果标注员先崩溃了——这些状态在二维画面里经常无法从视觉上区分。我做这个方向时用的是一套四分类方案normal代表正常听课sleep代表闭眼睡觉或头伏在桌上不动phone代表低头看着手部区域且手部有屏幕光源desk代表趴桌但没完全睡着的模糊态。为什么是四类而不是两类或八类两类听课/不听课会把很多中间态硬切成错误样本模型训练出来容易在“低头看书”和“玩手机”之间乱跳八类以上则会让标注一致性和样本量同时失控。四类正好卡在一个可以稳定标注的粒度上——标注员看一眼就能下判断不需要反复看视频确认。等数据量攒到一万张以上再考虑从normal里拆出handsup从desk里拆出其他状态。类别不平衡是课堂数据躲不开的问题。正常听课的样本通常占70%以上sleep和phone各占10%左右。这种分布会导致模型在训练时偏向多数类后面我会在第5章专门讲怎么处理。初期标注时我的建议是刻意多收sleep和phone的片段不必按真实课堂比例来训练时再通过采样策略调回来。3.2 采集与标注规范框头部还是框全身课堂场景的标注框选什么位置直接决定模型能不能学会状态区分。我早期试用全身框做过一轮实验结果模型经常把“坐直”与“趴桌”搞混因为全身框里桌子、书本、背景占了大量面积网络根本不知道重点看哪里。后来统一改成“头部肩部”框问题明显缓解。原因是低头、抬头、趴在桌上闭眼这些状态的区分信息高度集中在头部和肩颈的角度变化上。标注时具体规范是这样定的框从头顶开始下沿到肩线左右到耳朵外侧各留一点余量如果被前排遮挡到只剩半个头也照样框出来并标状态交给模型去处理遮挡。YOLO格式的每行是class,x_center,y_center,width,height坐标全部归一化到0到1之间。比如一张1920x1080的图里某个人头中心在x960、y400宽度260高度300那行标注就是“0 0.5 0.37 0.135 0.278”。标注工具我用过LabelImg和X-AnyLabeling前者轻量简单适合小批量后者带自动标注功能适合有初步模型时的辅助标注。无论用哪个导出格式一定要选YOLO的txt格式省得后面转来转去出乱子。标注完成后再花一点时间用脚本检查有没有空的txt、有没有坐标越界、有没有类别编号超出范围这些低级错误会在训练时以诡异的方式暴露出来。3.3 目录结构与数据集划分脚本数据集目录我习惯按YOLO惯例排列images和labels各分train、val、test三份。课堂项目通常图量在几千到几万张用不到test集做最终评测的话也可以只留train和val把val当评测集用。目录结构如下classroom/ ├── images/ │ ├── train/ │ ├── val/ │ └── test/ └── labels/ ├── train/ ├── val/ └── test/划分脚本用Python写很简单核心是保证图片和标签同名且随机划分时不要因为文件排序产生偏差。我一般会加一个种子参数保证每次划分结果一致方便复现。import os import random import shutil random.seed(42) src_images raw_images src_labels raw_labels train_ratio 0.7 val_ratio 0.2 # 先收集所有图片文件名不含扩展名 all_names [] for fname in os.listdir(src_images): if fname.lower().endswith((.jpg, .jpeg, .png)): all_names.append(os.path.splitext(fname)[0]) random.shuffle(all_names) train_n int(len(all_names) * train_ratio) val_n int(len(all_names) * val_ratio) def copy_files(name_list, dest_img_dir, dest_lbl_dir): os.makedirs(dest_img_dir, exist_okTrue) os.makedirs(dest_lbl_dir, exist_okTrue) for name in name_list: img_src os.path.join(src_images, name .jpg) lbl_src os.path.join(src_labels, name .txt) shutil.copy(img_src, dest_img_dir) shutil.copy(lbl_src, dest_lbl_dir) copy_files(all_names[:train_n], classroom/images/train, classroom/labels/train) copy_files(all_names[train_n:train_n val_n], classroom/images/val, classroom/labels/val) copy_files(all_names[train_n val_n:], classroom/images/test, classroom/labels/test)这段脚本的重点在两个地方。一是扩展名写死成jpg了如果你的素材有png要先改这一行二是源目录里如果存在没有对应txt的图片shutil.copy会直接报错这正好帮你在划分阶段就暴露脏数据比训练到一半再崩溃好受得多。名字带空格或中文的图片建议提前重命名Linux和Windows下的路径处理差异会带来不必要的麻烦。3.4 数据增强把教室的光照变化提前塞进训练样本课堂场景的光照是模型上线后翻车的高发区。靠窗座位和靠门座位亮度能差出好几个级别傍晚拉窗帘后整体偏暗投影仪开启时还会有局部高亮。这些变化如果训练集里没有模型在真实教室里的表现会直线下滑。数据增强就是最便宜的解决方案。YOLOv9训练时自带的增强已经包含马赛克、随机仿射、色彩抖动和翻转。我一般会在默认增强之外重点调两个参数hsv_h和hsv_s。hsv_h控制在0.01到0.02之间模拟不同时间段的色温偏移hsv_s保持在0.5左右让模型适应饱和度不同的摄像头。亮度方面教室场景建议在训练时有意加入随机暗化让夜间自习室的数据不至于完全依赖单独采集。另外提醒一个反直觉的点不要用上下翻转。课堂摄像头的安装位置固定在上方画面里的学生永远是头朝上上下翻转会生成现实中不存在的负样本干扰模型对头部朝向的判断。左右翻转是安全的因为课堂里左右位置掉换不影响状态语义。4. 训练配置与模型评估从预训练权重到best.pt4.1 环境对齐PyTorch版本与CUDA的组合YOLOv9的训练代码基于PyTorch环境对齐这件事比想象中重要。我见过不少同学卡在第一步代码拉下来、依赖装完、一跑就报算子不匹配。先说结论Python用3.8到3.11都行PyTorch用2.0及以上版本CUDA用11.8或12.1这个组合在目前的主流显卡驱动下最省心。装环境时我建议用conda建独立虚拟环境不要直接往基础环境里塞。依赖文件requirements.txt里列了opencv-python、matplotlib、numpy这些常用库直接用pip安装即可。最容易出问题的是torchvision和torch的版本必须配对用pip安装时不要分开装尽量用同一句命令同时装。conda create -n yolov9 python3.10 conda activate yolov9 pip install torch2.1.0 torchvision0.16.0 --index-url https://download.pytorch.org/whl/cu118 git clone yolov9源码仓库地址 cd yolov9 pip install -r requirements.txt装完后我习惯先跑一小组推理验证环境而不是直接开训练。找一张教室照片放到yolov9仓库目录下运行detect.py如果能正常出框就说明环境没问题。跳过这步直接训练的风险是花了半小时准备数据最后报错在某个底层库上排查成本高。训练前把环境验证做扎实后面遇到的坑会少很多。4.2 数据集YAML与训练命令YOLOv9训练时需要一个YAML文件告诉程序数据在哪、一共几类、类别名是什么。这个文件放在数据集的根目录下内容非常简单train: /data/classroom/images/train val: /data/classroom/images/val test: /data/classroom/images/test nc: 4 names: [normal, sleep, phone, desk]路径建议写绝对路径因为训练脚本执行时的当前目录未必是数据集所在目录相对路径会让日志里出现一堆FileNotFoundError。nc必须和names列表长度一致多一个少一个都会在解析时直接报错。类别名可以随意起但要和标注文件里的编号顺序严格对应第0行永远是normal。训练命令是整套流程里最关键的实操环节。我以一个8GB显存显卡能跑的配置举例python train.py \ --data /data/classroom/classroom.yaml \ --weights yolov9-c-converted.pt \ --cfg models/detect/yolov9-c.yaml \ --epochs 120 \ --batch-size 16 \ --imgsz 640 \ --device 0 \ --hyp hyp.scratch-high.yaml第一个要解释的是--weights。我建议从YOLOv9官方提供的预训练权重开始做迁移学习而不是随机初始化。课堂数据虽然场景特殊但底层的纹理、边缘、形状特征在COCO上已经学得很充分迁移学习可以省掉大量训练时间。第二个是--epochs课堂场景从预训练开始一般80到120轮就够收敛加太多反而容易过拟合。第三个是--batch-size这一项完全看显存24GB显卡可以开到328GB显卡用16会比较稳再小就考虑降到8并开启梯度累积。--imgsz默认640我建议保持这个值。教室原始画面如果是4K分辨率不要直接缩到640更好的做法是先按区域裁剪成多个画面再缩放。整图缩到640会让后排学生的人头只有几个像素标注时那些框在缩放后甚至小于一个像素模型根本没法学。4.3 评估输出怎么读P、R、mAP与混淆矩阵训练结束后去runs/train/expX目录下看结果。results.png是每轮的loss曲线和指标曲线汇总P、R、mAP0.5、mAP0.5:0.95都会画出来。课堂状态识别我重点关注两个指标mAP0.5和R召回率。mAP0.5代表IoU阈值0.5时的平均精度均值课堂场景不需要精确的像素级框框稍微偏一点不影响状态判断召回率则直接决定“有没有漏掉一个睡觉的学生”漏检比误检更致命。confusion_matrix.png是另一个必看的图。它显示每个真实类别被预测成了什么。课堂项目里最常见的问题出现在sleep和desk之间——这两个状态在画面上确实接近模型会把闭眼睡觉的人标成趴桌。看到这个第一步不是调模型而是回去看标注规范sleep的边界必须强调“闭眼或不动”desk则是“趴着但可能醒着”。如果标注时两个类别混在一起矩阵里这格就一定脏。4.4 训练结果导出best.pt与last.pt的正确用法训练完成后runs/train/expX/weights下有best.pt和last.pt两个文件。best.pt是验证集上mAP最高的一次权重last.pt是最后一轮的权重。部署和后续测试统一用best.pt这是基本常识。但我见过有人用last.pt做推理结果精度明显差一截——因为最后一轮已经可能过拟合验证集上的最优值才是泛化能力的代表。如果想用训练好的权重跑一下视频验证效果命令也很直接python detect.py \ --weights runs/train/expX/weights/best.pt \ --source classroom_test.mp4 \ --conf-thres 0.35 \ --iou-thres 0.45 \ --device 0--conf-thres低于0.35会导致大量误检教室里的书本、书包、后脑勺都可能被当成某个状态类别高于0.5又会漏检小目标。0.35到0.45是我在这个场景里体验比较舒服的区间。--iou-thres用于抑制重叠框教室场景人头密集如果设到0.7同一个头可能出多个框。5. 训练与部署避坑5条值得记录的踩坑记录5.1 loss收敛漂亮但mAP一直是0现象训练过程中loss曲线一路下降但每轮验证的mAP始终为零精确率和召回率也全是0。原因类别编号不匹配。标注文件里类别可能是从1开始编号的比如有人用工具导出时加了1而YAML里第0类才是normal于是模型学到的类别分布和标签对不上验证时所有预测都被判为错误。解决写个小脚本扫描labels目录输出每个txt里的类别编号最大值和最小值。如果发现最小值为1而YAML的nc为4说明标签从1开始把所有标签的class字段减1即可。顺带检查有没有空txt文件空标签在训练时会被当作背景数量多了会扭曲loss。5.2 全班人都被识别成normal现象模型训练完成推理时把画面里所有学生都框成normal即使有人明显趴在桌上。原因类别不平衡加置信度阈值设置不当。正常状态样本占70%以上模型天然倾向输出normal而detect.py的--conf-thres设得比较低时normal的低置信度预测也会被保留输出盖过了其他类别。解决两条路同时走。一是训练层面给sleep和phone加权重或者在数据加载时对少数类做重复采样二是推理层面把conf-thres提到0.4以上并且增加一个规则如果单个学生的normal置信度低于0.4且模型同时输出了desk或sleep的候选框优先保留后者。课堂场景里误报一次的代价远小于漏报一次这个方向不能省。5.3 显存溢出与训练中断现象train.py跑了不到10个batch就报CUDA out of memory或者训练中途被系统kill。原因YOLOv9训练时的PGI辅助分支会占用额外显存同样的batch-size下比YOLOv5更吃显存。另外马赛克增强会临时拼接多张图片显存峰值出现在增强阶段而不是前向阶段。解决直接把--batch-size减半比如从16降到8这是最立竿见影的操作。如果还想撑更大batch开启梯度累积在配置里设置accumulate2相当于用时间换显存。另一个经常被忽略的点关闭其他占用显存的应用比如浏览器、IDE的GPU加速因为这些和训练抢显存时不会报错只会在训练进行到一半时莫名其妙OOM。5.4 模型换个教室就崩现象训练时在A教室测试效果很好换到B教室后正常状态还能识别但sleep和phone的漏检率明显上升。原因A教室和B教室的摄像头角度、光照条件、课桌椅颜色差异太大。训练集只覆盖了单一环境模型学到的是“这个教室里的睡觉姿势”而不是泛化的“睡觉姿势”。解决数据层面采集时至少覆盖三个教室、两个时间段上午和下午、两种天气条件这是治本的做法。如果数据已经训完来不及补采应急方案是用训练好的模型在新教室先跑10分钟把漏检的截图收集起来做在线难例挖掘加进训练集重训一轮。部署层面尽量统一摄像头的安装高度和角度各教室之间保持一致能减少不少返工。5.5 推理速度不达标与模型加载报错现象两个常见问题的合并描述。一是模型在GPU上推理速度还行但在部署用的边缘设备上帧率只有个位数二是加载best.pt时报错提示权重文件结构不匹配。原因速度问题是浮点精度和输入尺寸双重影响的后果加载报错通常是训练环境和推理环境版本不一致比如训练时用PyTorch 2.1推理机上却是1.13。解决推理机上把PyTorch版本升到训练时一致。速度方面先用--imgsz 480跑推理看效果精度下降通常可接受速度能提升不少再上TensorRT导出FP16引擎比原生PyTorch推理快很多。初期的部署验证阶段我会先在录好的视频上跑离线分析避免把实时性压力和训练阶段纠缠在一起。6. 从best.pt到课堂参与度报表6.1 用detect.py批量推理课堂视频训练好的模型最终要落到“一节课45分钟的状态统计”上。我习惯先把整节课视频丢给detect.py做离线推理让模型把每一帧的检测结果输出成txt文件。命令里加一个--save-txt参数就行推理完的每个txt里每行是一个检测框格式是“class_id conf x1 y1 x2 y2”。批量跑视频时建议把detect.py的输出目录指向新文件夹并把--source指向视频文件。假设一段40分钟的视频在GPU上跑离线推理的耗时通常远小于视频时长可以接受。6.2 按分钟统计的课堂参与度报表得到每帧的检测结果后下一步是把帧级数据压缩成分钟级报表。核心思路是以一分钟为窗口统计这一分钟内每个学生被识别为各类别的帧数占比取占比最高的类别作为该学生这一分钟的状态主标签然后把所有学生综合成一个课堂参与度数值。参与度的定义可以有很多种我常用的是normal帧数占比 / 所有有效检测帧数。import os from collections import Counter, defaultdict det_dir runs/detect/expX/labels video_fps 25 # 根据实际视频修改 window_frames video_fps * 60 # 一分钟的帧数 # 按视频帧号聚合 frame_data defaultdict(list) # frame_id - [(class_id, conf), ...] for txt_name in os.listdir(det_dir): frame_id int(txt_name.split(_)[-1].split(.)[0]) with open(os.path.join(det_dir, txt_name)) as f: for line in f: parts line.strip().split() frame_data[frame_id].append((int(parts[0]), float(parts[1]))) # 按窗口统计参与度 total_frames max(frame_data.keys()) 1 num_windows total_frames // window_frames for win in range(num_windows): start win * window_frames end start window_frames normal_frames 0 total_frames_in_win 0 for fid in range(start, end): dets frame_data.get(fid, []) if not dets: continue total_frames_in_win len(dets) normal_frames sum(1 for cls, _ in dets if cls 0) participation normal_frames / total_frames_in_win if total_frames_in_win else 0 print(fminute {win1}: participation {participation:.2%})这段脚本读的是detect.py保存的txt结果所以不需要在统计阶段再加载模型速度很快。合理使用输出按分钟的参与度曲线后老师可以只看曲线上的低谷来定位课堂沉闷时段而不是盯着视频一帧一帧看。有个坑要提醒如果画面里经常同时出现多个学生total_frames_in_win统计的是检测框数量而不是学生人数这个数值本身只反映“被检测到的头部里正常姿态的占比”和严格的出勤人数无关。6.3 部署前的一点个人习惯每次训练完模型我都会在自己的测试视频上先跑一遍完整流程——包括导出txt和生成报表——再决定是否交付。因为训练指标好和实际教室表现好是两回事报表生成逻辑里的细节问题只有走到这一步才发现。另一个习惯是保存训练用的YAML文件和配置命令方便过两周回来看清当时用的参数。踩过“为了赶进度改了配置没记录结果复现不出来”的坑之后我就再也没偷懒省过这步。课堂状态识别这类系统最后交付的其实是一个可以持续迭代的数据闭环先靠训练好的模型跑出报表再从报表里找漏检样本补标注、微调、重新评估。希望这套流程能帮你在自己的数据上少走一段弯路把精力花在真正影响效果的地方。本文还有配套的精品资源点击获取