简介:这份资源是一套基于深度学习与Python实现的课堂专注度行为识别系统,面向计算机、人工智能、自动化等专业的高校学生、教师及科研从业者,可用于毕业设计、课程设计、项目立项演示或自学进阶。压缩包共约2000个文件,整体114.36MB,涵盖Java、Vue、Python、C++、JavaScript、XML、Markdown等多种类型:Java与Vue支撑前后端交互界面,Python与C++负责模型推理与算子实现,Markdown与PDF提供项目说明和设计文档,结构完整、层次清晰。资源内含完整源码、模型权重与项目说明,代码经过测试可正常运行,读者可据此理解行为识别模型的训练与部署流程,掌握从数据到界面展示的完整链路,并在此基础上修改扩展功能。目前已有77人学习下载,适合需要快速上手深度学习实战或完成毕设课设的读者参考借鉴。
1. 课堂专注度行为识别:从一段课堂录像到可落地的深度学习方案
一间普通教室,四五十个学生,老师在上面讲课,下面有人抬头听讲、有人低头写字、有人趴桌子、有人扭头说话。过去要判断这节课的听课状态,只能靠老师的主观感受或者督导推门听一节。现在换个思路:架一台普通摄像头,把课堂录像喂给一个基于深度学习的行为识别系统,让它逐帧判断每个学生的行为类别,再聚合成整堂课的专注度曲线。这就是「基于深度学习python开发的课堂专注度行为识别系统」要解决的事。
它适合三类人:一是做教育信息化产品、想加行为分析模块的开发者;二是计算机视觉方向的学生,想找一个能跑通、能改、能写进简历的完整项目;三是学校或机构的教研人员,想用数据而不是感觉来评估课堂。核心链路并不复杂:视频抽帧、人体检测、行为分类、专注度打分、可视化输出。难点在于课堂场景本身很脏——遮挡、小目标、光照变化、多人重叠,这些才是决定系统能不能用的关键。下面按「先立住原理、再动手复现、最后讲坑」的顺序拆开讲。
2. 课堂行为识别的技术选型:为什么是检测加分类而不是端到端
2.1 课堂场景的三个硬约束决定了架构
课堂行为识别和通用动作识别(比如 UCF101 那种)不是一回事,它有三个绕不开的约束。第一是多人同时出现,一帧里可能有几十个目标,端到端视频分类模型(如 SlowFast、TimeSformer)通常输出的是「整段视频属于哪一类」,没法告诉你「第三排左边那个学生在干什么」。第二是行为粒度细,抬头听讲和低头写字在像素层面差别很小,主要靠头部姿态和手部位置区分。第三是实时性要求,一节课 45 分钟,如果处理速度慢于视频时长,就没法做在线分析。
所以主流做法是两阶段:先用目标检测把人框出来,再对每个人框做行为分类。检测负责「谁在哪」,分类负责「在干什么」。这样每个学生的行为是独立的,聚合起来就能算专注度。端到端方案不是不能用,而是标注成本高、可解释性差,出了问题你根本不知道是检测错了还是分类错了,排查起来就是黑匣子。
2.2 检测器选型:YOLO 系列为什么是课堂场景的默认答案
检测环节我一般直接用 YOLO 系列。原因很实际:课堂视频要抽帧,一节课按 1fps 抽也有 2700 帧,用两阶段检测器(Faster R-CNN 那类)速度扛不住。YOLO 单阶段、速度快、精度够用,而且 Python 生态成熟,ultralytics这个库几行就能跑起来。
选版本时看你的硬件。有独立显卡(哪怕 6G 显存)就上 YOLOv8n 或 YOLOv8s,n 版本在 1080p 课堂画面上对小目标召回一般,s 版本更稳。只有 CPU 或者低显存,就用 YOLOv8n 配合输入尺寸降到 640 甚至 480。这里有个血泪经验:课堂后排的学生在画面里可能只有 30 到 50 像素高,输入尺寸降太狠会直接漏检,宁可牺牲一点速度也别把分辨率压到 480 以下。
from ultralytics import YOLO # 加载预训练权重,课堂场景建议用 s 版本平衡精度和速度 model = YOLO("yolov8s.pt") # 对单帧做检测,conf 调低一点保证后排小目标不漏 results = model.predict( source="classroom_frame.jpg", conf=0.25, # 置信度阈值,课堂遮挡多,0.25 比默认 0.5 召回更好 iou=0.5, # NMS 的 IoU 阈值,人多重叠时别调太低 imgsz=960, # 输入尺寸,960 比 640 对后排小目标友好 classes=[0], # 只保留 person 类,COCO 里 person 的 id 是 0 verbose=False ) # 提取每个人框的坐标,供后续行为分类裁剪使用 boxes = results[0].boxes.xyxy.cpu().numpy() print(f"本帧检测到 {len(boxes)} 个人")这段代码的逻辑是:加载 YOLO 权重,对一帧画面做推理,只保留人这一类。参数里conf=0.25是关键,课堂里学生被桌椅、前排同学遮挡,默认 0.5 会漏掉很多人;imgsz=960是为了让后排小目标有足够像素;classes=[0]过滤掉桌椅、投影仪等无关目标,减少后续分类的干扰。跑完拿到的boxes就是每个学生的位置,下一步按框裁剪出小图送进行为分类模型。
2.3 行为分类:轻量 CNN 够用,别一上来就上 Transformer
行为分类这块,很多人第一反应是上 Transformer 或者 3D 卷积。我的建议是:先用轻量 2D CNN 把基线跑通。课堂行为类别通常就 5 到 8 类——听讲、写字、趴桌、扭头、举手、站立,这些类别的区分主要靠单帧的头部姿态和身体朝向,时序信息有帮助但不是决定性的。用 ResNet18 或 MobileNetV3 在裁剪出的人框上做分类,单帧准确率就能到 85% 以上,推理速度还快。
什么时候才需要时序模型?当你要区分「短暂低头看笔记」和「持续趴桌睡觉」这种需要看一段时间的类别时。这时候可以在 CNN 特征上接一个 LSTM 或者用滑动窗口把连续几帧的特征拼起来。但这是第二步优化,不是第一步。我见过太多人一上来就搭 3D 网络,结果数据量不够、训练不收敛、调参调到怀疑人生,最后连基线都没跑出来。
import torch import torch.nn as nn from torchvision import models # 用 ResNet18 做行为分类骨干,类别数按你的数据集改 class BehaviorClassifier(nn.Module): def __init__(self, num_classes=6): super().__init__() # 加载预训练权重,课堂数据通常不够从头训 self.backbone = models.resnet18(weights=models.ResNet18_Weights.DEFAULT) # 替换最后的全连接层,输出改成行为类别数 in_features = self.backbone.fc.in_features self.backbone.fc = nn.Linear(in_features, num_classes) def forward(self, x): return self.backbone(x) # 输入是裁剪后的人框,统一 resize 到 224x224 model = BehaviorClassifier(num_classes=6) dummy = torch.randn(8, 3, 224, 224) # batch=8 的示例输入 out = model(dummy) print(out.shape) # torch.Size([8, 6])这里用预训练 ResNet18 替换最后一层,是因为课堂行为数据集通常只有几千到几万张标注图,从头训练容易过拟合。num_classes=6对应你的行为类别数,改这个数字就行。输入统一到 224x224 是 ImageNet 的标准尺寸,和预训练权重匹配。实际训练时学习率要调小,一般 1e-4 到 1e-3,因为骨干网络已经学好了通用特征,只需要微调。
2.4 专注度打分:把行为类别映射成一个可解释的分数
检测和分类输出的是「谁在做什么」,但用户要的是「这节课专注度怎么样」。中间需要一个映射规则。常见做法是给每种行为赋一个权重分:听讲 1.0、举手 1.0、写字 0.8、扭头 0.3、趴桌 0.0,然后按人数加权平均。这个权重不是拍脑袋,最好结合教育学的课堂观察量表来定,但工程上先用一套合理默认值跑通,后面再根据实际反馈调。
打分公式可以写成:某时刻专注度 = Σ(每个学生的行为分) / 学生总数。再按时间轴画出来,就是一条专注度曲线。这条曲线比单帧判断有用得多——老师能直观看到哪个时间段学生状态下滑,是不是讲到难点卡住了,还是节奏太慢走神了。
3. 从零跑通系统:数据准备、训练和推理的完整步骤
3.1 数据集怎么来、怎么标、怎么划分
课堂行为识别最大的门槛不是模型,是数据。公开数据集里,SCB-Dataset 是专门做学生课堂行为的,包含举手、看书、写字、趴桌等类别,可以直接拿来用或者做预训练。但如果你要针对自己学校的场景,还是得自己标。
标注流程我一般这样走:先用 YOLO 把所有人框自动检测出来,导出成标注工具的预标注格式,人工只需要改类别标签,不用画框,能省一大半时间。标注工具用 LabelImg 或者 CVAT 都行,导出 YOLO 格式。类别定义要提前想清楚,别标到一半又加类别,返工成本很高。
数据划分按视频级别分,不能按帧随机分。同一节课的相邻帧长得几乎一样,如果随机分到训练集和验证集,验证集准确率会虚高,实际部署就翻车。正确做法是:训练集用 70% 的课,验证集 15%,测试集 15%,同一节课的帧只出现在一个集合里。
# 数据集目录结构,YOLO 检测和行为分类各一套 dataset/ ├── detection/ # 检测数据集 │ ├── images/ │ │ ├── train/ │ │ └── val/ │ └── labels/ │ ├── train/ │ └── val/ └── classification/ # 行为分类数据集,按类别分文件夹 ├── train/ │ ├── listen/ │ ├── write/ │ ├── lie/ │ └── turn/ └── val/ ├── listen/ └── ...这个结构是 YOLO 官方要求的格式,检测的标签是 txt,每行类别 x_center y_center width height,坐标都归一化到 0 到 1。分类数据集按文件夹分,每个文件夹一个类别,PyTorch 的ImageFolder能直接读。注意分类数据是从检测框裁剪出来的,裁剪时框往外扩 10% 到 20%,保留一点上下文,对区分扭头和听讲有帮助。
3.2 检测模型训练:关键参数和训练轮次
检测模型训练用ultralytics的命令行或者 Python 都行。课堂场景我一般训 100 到 150 轮,早停耐心设 30。学习率用默认的 0.01 配合余弦退火,batch size 看显存,8G 显存用 16,12G 用 32。
from ultralytics import YOLO model = YOLO("yolov8s.pt") model.train( data="dataset/detection/data.yaml", # 数据集配置文件路径 epochs=120, # 训练轮次,课堂数据一般 100 到 150 够 imgsz=960, # 训练输入尺寸,和推理保持一致 batch=16, # 批大小,按显存调 lr0=0.01, # 初始学习率 patience=30, # 30 轮没提升就早停 device=0, # 用第 0 号 GPU,CPU 写 "cpu" project="runs/classroom", name="yolov8s_960" )data.yaml里要写清楚训练和验证图片路径、类别数和类别名。imgsz=960和推理时保持一致,训练和推理尺寸不一致会导致精度下降。patience=30是防止过拟合,课堂数据量不大,训太久验证集 loss 会反弹。训练完看results.png里的 mAP50 曲线,如果验证集 mAP 明显低于训练集,说明过拟合了,要么加数据增强,要么减模型复杂度。
3.3 行为分类训练:迁移学习和数据增强的配合
分类模型训练比检测简单,核心是迁移学习加合适的数据增强。课堂行为分类的难点是类别不平衡——听讲和写字的样本远多于举手和站立。处理办法是在 loss 里加类别权重,或者对少样本类别做过采样。
import torch from torch.utils.data import DataLoader from torchvision import datasets, transforms # 训练集增强:随机裁剪、翻转、颜色抖动 train_tf = transforms.Compose([ transforms.Resize((224, 224)), transforms.RandomHorizontalFlip(p=0.5), # 左右翻转,行为类别不受方向影响 transforms.ColorJitter(0.2, 0.2, 0.2), # 应对教室光照变化 transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]) ]) # 验证集只做 resize 和归一化,不做增强 val_tf = transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]) ]) train_ds = datasets.ImageFolder("dataset/classification/train", transform=train_tf) val_ds = datasets.ImageFolder("dataset/classification/val", transform=val_tf) train_loader = DataLoader(train_ds, batch_size=32, shuffle=True, num_workers=4) val_loader = DataLoader(val_ds, batch_size=32, shuffle=False, num_workers=4) print(f"类别: {train_ds.classes}") print(f"训练样本: {len(train_ds)}, 验证样本: {len(val_ds)}")增强里RandomHorizontalFlip对行为分类是安全的,因为扭头向左和向右都算扭头,翻转不改变类别语义。ColorJitter是应对不同教室的灯光色温差异。归一化用的 ImageNet 均值方差,因为骨干是 ImageNet 预训练的。训练时用交叉熵损失,优化器选 AdamW,学习率 1e-4,训 30 到 50 轮基本收敛。
3.4 推理管线:把检测、分类、打分串起来
推理管线是整个系统的落地形态。输入一段课堂视频,按固定帧率抽帧,每帧过检测器,裁出人框,批量送分类器,得到每个人的行为,再算这一帧的专注度,最后按时间聚合。
import cv2 import numpy as np import torch from ultralytics import YOLO from torchvision import transforms from PIL import Image detector = YOLO("runs/classroom/yolov8s_960/weights/best.pt") classifier = BehaviorClassifier(num_classes=6) classifier.load_state_dict(torch.load("behavior_resnet18.pth", map_location="cpu")) classifier.eval() # 行为到专注度分数的映射,按你的教学场景调整 SCORE_MAP = {"listen": 1.0, "raise": 1.0, "write": 0.8, "turn": 0.3, "lie": 0.0, "stand": 0.5} CLASS_NAMES = ["listen", "write", "lie", "turn", "raise", "stand"] preprocess = transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]) ]) def process_frame(frame): results = detector.predict(frame, conf=0.25, imgsz=960, classes=[0], verbose=False) boxes = results[0].boxes.xyxy.cpu().numpy() if len(boxes) == 0: return 0.0, [] crops = [] for box in boxes: x1, y1, x2, y2 = map(int, box) # 框往外扩 15%,保留上下文 w, h = x2 - x1, y2 - y1 x1 = max(0, x1 - int(w * 0.15)) y1 = max(0, y1 - int(h * 0.15)) x2 = min(frame.shape[1], x2 + int(w * 0.15)) y2 = min(frame.shape[0], y2 + int(h * 0.15)) crop = frame[y1:y2, x1:x2] if crop.size == 0: continue crops.append(preprocess(Image.fromarray(crop[:, :, ::-1]))) if not crops: return 0.0, [] batch = torch.stack(crops) with torch.no_grad(): logits = classifier(batch) preds = logits.argmax(dim=1).cpu().numpy() scores = [SCORE_MAP[CLASS_NAMES[p]] for p in preds] frame_score = float(np.mean(scores)) return frame_score, [CLASS_NAMES[p] for p in preds] # 按 1fps 抽帧处理整段视频 cap = cv2.VideoCapture("classroom.mp4") fps = cap.get(cv2.CAP_PROP_FPS) interval = int(fps) # 每秒取一帧 timeline = [] idx = 0 while True: ret, frame = cap.read() if not ret: break if idx % interval == 0: score, behaviors = process_frame(frame) timeline.append((idx / fps, score, behaviors)) idx += 1 cap.release() # timeline 就是专注度曲线数据,可存 CSV 或画图 print(f"共处理 {len(timeline)} 个时间点")这段管线的逻辑:逐帧检测人,裁框时外扩 15% 保留上下文,批量送分类器,按映射表算分。interval = int(fps)是每秒抽一帧,45 分钟视频处理 2700 帧,GPU 上大概几分钟跑完。SCORE_MAP是专注度映射,你可以按实际教学场景调,比如把「站立」调高还是调低取决于你是想抓走神还是抓互动。输出的timeline就是专注度曲线,存成 CSV 后可以用 matplotlib 画出来。
4. 避坑与排查:课堂行为识别最容易翻车的五个地方
4.1 后排学生漏检严重
现象:检测结果里前排学生框得很准,后排几乎检测不到,专注度曲线偏高。
原因:后排学生在画面里像素太少,YOLO 默认输入 640 时,一个学生可能只有 20 到 30 像素高,低于模型的有效感受野。另外教室后排光照通常更暗,对比度低。
解决:把推理输入尺寸提到 960 甚至 1280,代价是速度下降,但召回提升明显。如果速度不能降,就在数据标注时专门多标后排样本,让模型学会关注小目标。还可以在抽帧前对画面做一次直方图均衡化,提升暗部对比度。
4.2 相邻帧行为跳变,曲线锯齿严重
现象:专注度曲线上下剧烈抖动,同一秒内相邻帧分数差很多。
原因:单帧分类本身有噪声,学生一个转头动作可能被连续几帧判成不同类别。另外检测框位置抖动也会导致裁剪出的图有差异。
解决:在时间维度做平滑。最简单的是滑动窗口平均,窗口大小 5 到 10 帧。更好的做法是对每个学生的行为做跟踪,用跟踪 ID 把同一个人的连续帧串起来,再对每个人的行为序列做多数投票或隐马尔可夫平滑。滑动窗口滤波在这里是性价比最高的方案,几行代码就能把曲线磨平。
4.3 类别不平衡导致举手、站立几乎测不出来
现象:模型在验证集上整体准确率 90%,但举手类别的召回只有 30%。
原因:课堂里举手和站立的样本天然少,听讲和写字占 80% 以上,模型倾向于全预测成多数类。
解决:训练时给交叉熵损失加类别权重,权重和类别频率成反比。或者用重采样,对少样本类别做过采样。评估时不能只看整体准确率,要看每个类别的召回和 F1,否则你根本发现不了这个问题。
4.4 训练集验证集随机划分导致指标虚高
现象:验证集准确率 95%,部署到新教室后掉到 60%。
原因:按帧随机划分,同一节课的相邻帧几乎一样,训练集和验证集高度相似,模型相当于在背答案。
解决:按视频或按课次划分数据集,确保同一节课的帧只出现在一个集合里。这个坑非常隐蔽,很多人跑出高指标就以为成了,实际一部署就露馅。
4.5 光照和摄像头角度变化导致泛化差
现象:在 A 教室训的模型,搬到 B 教室准确率大幅下降。
原因:不同教室的灯光色温、摄像头安装高度和角度都不同,模型学到了和场景相关的虚假特征。
解决:训练数据尽量覆盖多个教室、多种光照条件。数据增强里加颜色抖动和随机裁剪。如果目标教室和训练教室差异大,在目标教室采几百张图做微调,效果比重新训整个模型好得多。
5. 进阶技巧:用跟踪加时序平滑把专注度曲线做稳
前面讲的都是单帧管线,实际部署时最影响体验的不是分类准确率,而是曲线的稳定性。我踩过最深的坑就是:单帧准确率明明有 88%,但画出来的专注度曲线像心电图,老师根本没法看。后来加了两步优化,曲线才真正能用。
第一步是目标跟踪。用 ByteTrack 或者简单的 IoU 匹配,给每个学生分配一个跟踪 ID,把连续帧里的同一个人串起来。这样你拿到的不再是一堆孤立的分类结果,而是每个学生一条行为序列。跟踪本身不需要额外训练,ultralytics里直接支持model.track(),几行就能接上。
第二步是时序平滑。对每个跟踪 ID 的行为序列,用长度为 7 的滑动窗口做多数投票,窗口内出现次数最多的类别作为当前帧的最终行为。这样单帧的误判会被邻帧纠正。如果还想更稳,可以在投票前先做一次中值滤波,去掉孤立的跳变。
from collections import deque, Counter class BehaviorSmoother: def __init__(self, window=7): self.window = window self.buffers = {} # 每个跟踪 ID 一个缓冲区 def update(self, track_id, behavior): if track_id not in self.buffers: self.buffers[track_id] = deque(maxlen=self.window) self.buffers[track_id].append(behavior) # 多数投票,窗口内出现最多的类别胜出 most_common = Counter(self.buffers[track_id]).most_common(1)[0][0] return most_common # 使用示例:在跟踪循环里调用 smoother = BehaviorSmoother(window=7) # 假设 track_id=3 的当前帧分类结果是 "turn" stable_behavior = smoother.update(3, "turn")这个平滑器的逻辑是:每个跟踪 ID 维护一个长度为 7 的队列,每次新行为进来后做多数投票。窗口大小 7 是经验值,太小平滑不够,太大响应迟钝。实际用的时候,跟踪 ID 会断,比如学生被完全遮挡几帧后 ID 变了,这时候缓冲区要清理,否则新 ID 会继承旧 ID 的历史,导致行为判断滞后。处理办法是给每个缓冲区加一个时间戳,超过 2 秒没更新就删掉。
验证平滑效果的方法很直接:同一段视频,分别用平滑前和平滑后的管线跑一遍,把两条专注度曲线画在一起对比。平滑后的曲线应该明显更连续,同时整体趋势不变。如果平滑后曲线变得过于平坦,说明窗口开太大了,把真实的行为变化也磨掉了,这时候把窗口降到 5 试试。
还有一个容易被忽略的点:专注度分数的映射表要可配置。不同学科、不同课型对行为的容忍度不一样。比如讨论课里「扭头」可能是在交流,不该扣分;自习课里「扭头」就是走神。所以映射表不要写死在代码里,放到配置文件或者数据库里,让教研人员能自己调。这个设计看起来小,但决定了系统是「能用」还是「好用」。
我自己做这类项目的习惯是:先把单帧管线跑通,指标能看就行,别追求完美;然后立刻加跟踪和平滑,因为曲线稳定性对用户体验的影响远大于分类准确率从 85% 提到 90%;最后再回头优化分类模型和数据。顺序反了,很容易在模型调参上耗掉大半时间,结果做出来的东西还是没法演示。希望帮到你。
本文还有配套的精品资源,点击获取