ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

课堂行为识别:YOLO+ResNet实现教学行为自动分析

课堂行为识别:YOLO+ResNet实现教学行为自动分析

简介:基于Python的深度学习课堂行为图像识别分类项目源码,是为毕业设计或期末大作业准备的完整可运行方案。项目以课堂监控或课堂录像中的学生行为为对象,通过卷积神经网络等深度学习模型对举手、睡觉、交流等状态进行图像分类识别,覆盖数据准备、模型搭建、训练评估到结果可视化的主要环节,也适合作为图像分类入门的学习范例。压缩包共1206个文件,其中1183张JPG图片组成分类数据集,14个Python脚本承担模型定义、训练、测试与预测等核心功能,搭配PNG图示、Markdown说明文档等辅助材料,整体大小约100.29MB。源码经过严格调试,评审得分达95分以上,具备较好的稳定性和规范性,便于直接运行复现或二次修改;文件中还保留了数据与代码的组织结构,可帮助读者快速理解深度学习项目的工程脉络。目前已有571人学习使用,适合需要快速搭建课堂行为识别系统的开发者、学生参考。

1. 课堂行为图像识别与分类:从一段课堂视频到一张行为报表

如果你接到一个“基于python的深度学习的课堂行为图像识别分类项目源码.zip”,大概率不是想研究论文,而是想搞清楚三件事:这东西能识别哪些行为、怎么训练出来、放到实际课堂里为什么偶尔不准。把题目拆开看,它本质上是一条“摄像头采集图像 → 学生个体检测 → 行为类别分类 → 输出统计报表”的流水线,属于计算机视觉里目标检测加图像分类的经典组合,不是玄学,也不神秘。

这类项目真正能为一线解决的问题很具体:教务想统计一节课里有多少次举手、有多少人趴在桌上、哪些时段学生低头写字,靠自己盯视频回放非常耗费时间;用深度学习模型做自动识别后,能输出一段时间内的行为占比和异常时段,给教研和督导做参考。适合谁来研究这套源码?一个是用 PyTorch 做课程设计的学生,一个是刚入门深度学习、准备接智慧教室项目的小团队。理解它的最短路径不是先去翻模型结构论文,而是先跑通“数据 → 训练 → 推理”这一段闭环,再回来调整分类策略。下面分享的这套做法,是围绕“检测人 + 分类行为”的主流工程方案展开的,这也是大多数课堂行为识别源码采用的技术基线。

2. 定行为类别、准备数据集:先让标注规范,再让模型做事

2.1 先选“场景级分类”还是“目标级分类”

课堂行为识别的第一刀,不是选模型,而是选任务拆分方式。常见做法有两种:一种是“场景级分类”,把整张教室图像直接喂给一个分类模型,输出“上课中”“学生举手”“学生睡觉”这样的整体状态,优点是数据制作快,不用画框,但缺点很明显——教室里几十个学生行为各异,整图分类结果只能代表图像主体,没法回答“谁在睡觉、谁在举手”这种个体问题。

另一种是“目标级分类”,先用车牌识别领域常用的目标检测模型把每个学生的人体框找出来,再对裁剪出的人体小图做行为分类。图片中同一时刻有成百上千个学生,必须带上检测再识别,所以实践里基本都走这条路。真正到实现层,建议用“YOLO 检测学生位置 + ResNet 分类学生行为”的双模型结构;如果视频机位固定、人数少,比如小班在线课,也可以只用整图分类勉强交差,但做教务统计一定会被追问“第几排第几个学生”,所以更可靠的目标级方案一开始就应该定下来。

2.2 行为类别定义:8 类是容易操作的上限

行为类别定得越多,模型越难学。课堂场景通常定 6 到 8 类,再多就会出现边界模糊。下表是一套被验证过的类别定义,前四类是“正向学习行为”,后四类是“疑似负向行为”,这套定义后来生成的统计口径也比较自然:

编号类别名说明最容易混淆的情况
0listening抬头面向讲台或屏幕低头看书但正在听讲
1hand_raising举手抬手摸头、伸懒腰
2writing低头且手部在桌面连续移动低头看手机
3reading低头或注视书本/屏幕与书写动作过渡态相似
4phone低头注视手机写字时手部遮挡手机
5sleeping趴在桌面或长时间闭眼不动低头且完全静止
6standing站立站起来又立刻坐下
7talking侧头与邻座交流低头捡东西、自言自语

定义类别时要给出一套“判别规则说明书”,而不是只给类名。一个常见坑是:标注人员在判断“低头”时标准不一,同样一张图,有人标 reading,有人标 phone,造成类别重叠。建议在标注前定一条规则:图像中看不到屏幕光斑或手机边缘时,一律不标 phone;有明显的伏案动作时优先标 sleeping。这类规则的颗粒度直接决定标注一致性和模型上限。

2.3 数据采集与转换:从视频抽帧到裁剪出个体训练集

课堂行为数据不会自带标签,通常从教室监控视频里抽帧再人工标注。抽帧最简单的方式是 ffmpeg 按 0.2 帧率,也就是每 5 秒取一帧:

ffmpeg -i course_01.mp4 -vf fps=0.2 -q:v 2 frames/lesson01_%04d.jpg

参数说明:fps=0.2表示每秒抽取 0.2 帧,即 5 秒一帧,这是行为识别里比较稳妥的密度;-q:v 2控制输出 JPEG 质量为高质量,避免压缩带来的模糊。如果相机帧率本身只有 15fps,抽帧间隔不改也没关系,关键是保证同一行为在连续时间段内出现足够多的样本。

抽完帧,用 LabelImg 或 labelme 打开图片,先画人体框(person),再给人体框标行为类别;导出格式建议选 YOLO txt 格式,每行是“类别编号 中心点x 中心点y 宽度 高度”,坐标都是归一化后的 0 到 1 的小数,加载训练时很方便。拿到 YOLO 标注后,还需要把它转成分类任务的数据集,做法是“按人体框裁剪原图,以类别名建文件夹存小图”。

import os from glob import glob from PIL import Image IMG_DIR = "./frames" TXT_DIR = "./labels_yolo" OUT_ROOT = "./behavior_dataset" CLS = ["listening", "hand_raising", "writing", "reading", "phone", "sleeping", "standing", "talking"] for txt_path in glob(os.path.join(TXT_DIR, "*.txt")): img_path = os.path.join(IMG_DIR, os.path.basename(txt_path)[:-4] + ".jpg") if not os.path.exists(img_path): continue image = Image.open(img_path) width, height = image.size with open(txt_path) as f: for line in f.readlines(): tokens = line.split() if len(tokens) != 5: continue cls_idx, x_center, y_center, box_w, box_h = map(float, tokens) left = int((x_center - box_w / 2.0) * width) top = int((y_center - box_h / 2.0) * height) right = int((x_center + box_w / 2.0) * width) bottom = int((y_center + box_h / 2.0) * height) left, top = max(0, left), max(0, top) right, bottom = min(width, right), min(height, bottom) crop = image.crop((left, top, right, bottom)) out_dir = os.path.join(OUT_ROOT, CLS[int(cls_idx)]) os.makedirs(out_dir, exist_ok=True) file_name = f"{os.path.basename(txt_path)[:-4]}_{left}_{top}.jpg" crop.save(os.path.join(out_dir, file_name))

这段脚本的逻辑不难:先读每一帧和对应的 txt 标注,把归一化框还原成像素坐标,再做边界裁剪防止越界,最后按类别存文件夹。有一点要注意,YOLO 的框可能是全身体框,裁剪出的图像可能包含邻座同学的半个身体,对分类是干扰项。好在我标注时只画学生上半身,或者脚本里裁剪后保留目标框的 1.2 倍范围,这样手部动作不太容易被切掉。

“只为分类模型造数据”这一步很容易被低估。通常每类至少准备 150 到 250 张裁剪图,8 类就是 2000 张左右;如果是从零训练,这个量还会翻倍。数据规模不足时,优先做的不是找更多标注员,而是提高每一帧的标注一致性,而且要利用同一帧的多人框来扩充样本数。

2.4 数据增强参数:小样本场景下最有效的几个设置

课堂图像的特殊性在于角度固定、背景单一,所以增强策略不能太激进,以免把“手部动作”破坏掉。一组稳妥的增强参数如下:

增强方式推荐参数作用
随机水平翻转p=0.5教室左右对称,不破坏语义
亮度扰动±0.2应对上午、下午不同采光
对比度扰动±0.2应付黑板反光和背光
随机缩放裁剪0.8 到 1.0模拟前排同学遮挡,增强抗遮挡能力
CutOut8 到 12 像素随机遮挡图像小块,防过拟合
MixUpalpha=0.2小样本分类时稳定边界

做增强时要跟着模型的表现走。比如刚训练完发现“writing”和“reading”混淆严重,说明手部细节特征不够,此时加大随机裁切的幅度反而不利;更好的做法是把这类样本的裁剪范围固定为框内 1.0 倍并去掉翻转。数据增强没有绝对标准,最终判断依据是混淆矩阵,不是训练损失的数值。

3. 模型选型与训练:分类头 + ResNet18 是性价比最高的组合

3.1 为什么图像分类模型先从轻量 CNN 起步

标题里写的是“深度学习图像识别分类”,对应到工程实现,优先选 resnet18 或者 efficientnet-b0 这类轻量 CNN,而不是一上来用 vision transformer。课堂上被判别的对象是人体小图,空间尺度不大,局部纹理特征比如手部位置、屏幕亮斑、头肩角度,ResNet 的卷积结构足够提取到;而 ViT 在小数据集上的强依赖性问题非常典型,几千张课堂图很难撑起 attention 训练,容易欠拟合。如果你看过“深度学习 cnn”相关的入门教程就应该明白,CNN 收敛快、依赖数据少、推理开销低,在嵌入式摄像头和普通 GPU 机器上都生活得比较好,作为这个项目的起点是最不容易翻车的。

具体选型上,主力用 ResNet18,backbone 加载 ImageNet 预训练权重,最后把全连接层改成 8 类输出。如果机器显存小或要部署到 Jetson,换成 EfficientNetB0 也可以,肉眼差异很小。整理成对照表更直观:

模型相对推理开销小样本表现建议
ResNet18中好首选基线
EfficientNetB0小中部署资源受限时选
ResNet50大中数据超过 1 万张再考虑
ViT-small大容易欠拟合数据充分后尝试

3.2 训练代码与核心参数:一套可以直接改的 PyTorch 脚本

模型结构确定后,训练流程用 PyTorch 实现不算长。下面这段代码覆盖了“加载数据 → 初始化模型 → 设置损失和优化器 → 训练一个 epoch”,适合第一次把项目跑通。

import torch import torch.nn as nn from torch.utils.data import DataLoader from torchvision import datasets, models, transforms from torch.optim.lr_scheduler import CosineAnnealingLR train_transform = transforms.Compose([ transforms.Resize((224, 224)), transforms.RandomHorizontalFlip(p=0.5), transforms.ColorJitter(brightness=0.2, contrast=0.2), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) train_dataset = datasets.ImageFolder("./behavior_dataset/train", train_transform) train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True, num_workers=4, pin_memory=True) model = models.resnet18(weights=models.ResNet18_Weights.IMAGENET1K_V1) model.fc = nn.Linear(model.fc.in_features, 8) criterion = nn.CrossEntropyLoss() optimizer = torch.optim.AdamW(model.parameters(), lr=1e-4, weight_decay=1e-4) scheduler = CosineAnnealingLR(optimizer, T_max=50) for epoch in range(50): model.train() running_loss = 0.0 for images, labels in train_loader: optimizer.zero_grad() outputs = model(images) loss = criterion(outputs, labels) loss.backward() optimizer.step() running_loss += loss.item() scheduler.step() print(f"epoch {epoch+1}, loss: {running_loss / len(train_loader):.4f}") torch.save(model.state_dict(), "./checkpoints/behavior_resnet18.pth")

代码中的几个关键参数直接决定训练效果。batch_size=64是本项目的最优区间,太大单卡容易显存不足,而且课堂数据里同一场景的相似帧多,大 batch 反而会加剧过拟合;lr=1e-4是迁移学习的标准起点,如果模型收敛太慢可以放大到 3e-4,但超过 1e-3 很容易在预训练权重上表现剧烈;weight_decay=1e-4用于抑制过拟合;CosineAnnealingLR把学习率从 1e-4 余弦降到接近 0,小样本训练时比固定学习率更利于收敛。训练到后期如果损失一直不降,检查一下model.fc是不是忘了从 512 维改成 8 维,这种低级报错经常被忽略。

3.3 类别不均衡:用 class weight 而不是盲目加数据

课堂行为数据天然不均衡。“听讲”占一半,“举手”“睡觉”可能只有 5% 以下。直接硬训会让模型偏向多数类,在小类别上的准确率几乎为 0。最简单有效的方法是用CrossEntropyLoss的weight参数,权重的计算方式为“总体样本数 / 类别数 / 该类别样本数”:

class_counts = torch.tensor([1200, 150, 380, 420, 90, 60, 110, 1000]) n_samples = int(class_counts.sum()) n_classes = len(class_counts) class_weights = n_samples / (n_classes * class_counts.float()) criterion = nn.CrossEntropyLoss(weight=class_weights)

要注意的是,class_weights要转为 float 再传给损失函数,否则在部分版本的 PyTorch 里会报类型不匹配。加了权重后少数类的 loss 惩罚会变大,训练曲线会出现轻微震荡,这是正常现象。如果权重导致的震荡太剧烈,可以把权重开根号后再用,也就是 “sqrt 平滑”技巧;也可以配合WeightedRandomSampler把少数类样本的采样概率提上来,两种方式都试过之后,优先保留下调class_weights,因为它实现简单而且不会让每个 epoch 的样本分布变化太大。

4. 推理落地:检测人、裁剪、滑动窗口平滑、然后上线

4.1 推理骨架:先检测人体框,再对裁剪图做分类

训练完成的分类模型还不能直接扔给视频用,因为一帧图像里有几十号人,得先有一段代码定位“每个学生在哪”,再让分类模型对每个裁剪框分别判断。用 YOLO 系列做检测是通用做法,我一般用 YOLOv8 的官方 Python 包,输出每个学生的人体框坐标,再按坐标裁图送入行为分类模型。核心流程如下:

import cv2 import torch from ultralytics import YOLO from torchvision import transforms det_model = YOLO("yolov8n.pt") cls_model = torch.load("behavior_resnet18.pth", map_location="cpu") transform = transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) frame = cv2.imread("frame_0001.jpg") frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) results = det_model.predict(frame_rgb, conf=0.4, verbose=False) for box in results[0].boxes: x1, y1, x2, y2 = [int(v) for v in box.xyxy[0].tolist()] crop_rgb = frame_rgb[y1:y2, x1:x2] if crop_rgb.size == 0: continue crop_img = transforms.ToPILImage()(crop_rgb) crop_tensor = transform(crop_img).unsqueeze(0) with torch.no_grad(): logits = cls_model(crop_tensor) pred = logits.argmax(dim=1).item() # pred 对应 8 类中的编号,再映射到类别名输出

注意这段代码里把 YOLO 的专用 object 转成了普通 BGR 帧里的坐标,很多新人栽在这:直接用results[0].boxes的坐标去切 RGB 图没问题,但如果原图是 BGR,切完后要转换颜色空间,否则图像颜色错乱会让分类结果下降好几个点。另一个容易被忽略的地方是pred是对每一帧每个框单独输出的,完全没有利用时间维度,所以接下来还要做时序平滑。

4.2 时间滑窗:把帧级预测变成行为片段

单帧推理容易受动作模糊和检测框抖动影响,比如手臂刚抬起来那 0.5 秒被裁成奇怪形状,分类器就会误判。课堂行为是连续性事件,一个行为至少持续 5 到 10 秒,所以对连续帧做时间投票能明显提升稳定性。用“5 帧一窗”的策略,窗口内每个类别投票,阈值超过 3 票才输出该行为,否则沿用上一窗口结果。

import numpy as np from collections import Counter WINDOW = 5 VOTE_THRESHOLD = 3 def smooth_predictions(predictions): smoothed = [] last_valid = predictions[0] for i in range(0, len(predictions), WINDOW): window = predictions[i:i + WINDOW] counter = Counter(window) pred, votes = counter.most_common(1)[0] if votes >= VOTE_THRESHOLD or not smoothed: last_valid = pred smoothed.append(pred) else: smoothed.append(last_valid) return smoothed

这里的参数解释:WINDOW=5配合前面 5 秒一帧的抽帧策略,代表约 25 秒的行为判定单位;VOTE_THRESHOLD=3表示窗口内至少有 3 帧投同一类别才接受新结果,低于阈值就沿用上个有效状态。这样做虽然会让切换行为的时间延迟 1 到 2 个窗口,但换来的好处是几乎消除了单帧跳变——“低头1秒”不会被误判为“sleeping”,课堂手势的突发性也能被压住。

4.3 低成本上线:导出 ONNX 并用 FastAPI 暴露接口

训练完成后如果还要交付给别的系统调用,建议把 PyTorch 模型导出成 ONNX,推理时用 onnxruntime 加载。这样可以摆脱训练代码中的各类 transforms 依赖,对不同 Python 版本的兼容性更好。导出时只导出分类模型,不需要再包含检测模型,因为视频抽帧后的检测逻辑可以在上层完成:

import torch from torchvision import models model = models.resnet18() model.fc = torch.nn.Linear(model.fc.in_features, 8) model.load_state_dict(torch.load("behavior_resnet18.pth")) model.eval() dummy_input = torch.randn(1, 3, 224, 224) torch.onnx.export(model, dummy_input, "behavior_resnet18.onnx", input_names=["image"], output_names=["logits"], dynamic_axes={"image": {0: "batch"}, "logits": {0: "batch"}})

拿到behavior_resnet18.onnx后,用 FastAPI 做一个极简接口,接收一张裁剪图,返回 8 类对应的概率。业务方拿到接口后,只需要把每一帧的检测结果发过来就能得到个体行为标签。部署形态不强求 GPU,CPU 上用 onnxruntime 推理单张 224x224 图耗时通常在 20 毫秒以内;视频并发路数多时再考虑 NVIDIA Triton 或 TensorRT,小团队没必要一上来就上重型推理框架。

5. 五个常见问题与避坑手册:为什么效果不好,以及怎么调

5.1 小样本过拟合:准确率有九成,但抽样一看全部预测成“听讲”

现象:训练集只有几百张图,训练 loss 降得很快,验证集准确率却上不去,打开预测结果发现几乎所有裁剪图都被判成“listening”。原因是数据量太少,模型用“是不是课堂场景”来判断行为类别,没有学到细节特征。解决方法是先做类别均衡采样,每类至少补到 150 张,再用增强扩展;同时把模型冻结前几层,只微调后面两个 stage,相当于强制模型关注课堂图像独有的细节。

5.2 类别不均衡:少数类识别率低迷到等于没识别

现象:睡觉、玩手机这两类样本稀少,F1 值接近 0,训练集里哪怕模型输出了这个类别,也大概率是错的。原因在于交叉熵损失被多数类主导。解决方式除了前面说的class_weights,我还会检查数据增强是否把少数类的关键特征破坏掉,比如“sleeping”的样本被随机裁剪切掉了手臂,模型就不再有计算依据;对少数类必须关闭比例过大的随机裁剪。

5.3 动态模糊与低帧率:低头动作被误判为睡觉

现象:学生不过是低头捡东西,或者侧头看桌面,模型频繁输出“sleeping”。原因是摄像头帧率低,快速动作在采样瞬间形成运动模糊,特征和静止低头的形态接近。解决思路是两层:一是图像预处理时对裁剪图做轻微锐化,常用cv2.filter2D加 3x3 锐化核;二是充分依赖前面的时间窗口投票,并提高VOTE_THRESHOLD到 4,宁可延迟识别,也不误报睡觉。

5.4 同框多人遮挡:人体框频繁变化,行为归因错乱

现象:前排同学站起来挡住后排,后排学生的人体框被检测模型丢掉或切掉半边,行为统计时归属到错误的学生。原因不是分类模型的问题,而是检测模型的漏检和框抖动。解决方法是先跑一个 ByteTrack 这类多目标跟踪器,给每个学生分配稳定的 ID;裁剪时不要只裁剪到精确边框,稍微外扩 10% 到 20% 像素,把被遮挡的手臂和书本包含进来,分类稳定度会显著提升。

5.5 夜间与背光场景:一整排学生被判为睡觉

现象:晚上补光不足,学生面部和桌面都是暗色,模型输出大量“sleeping”。原因不是模型学坏了,而是预训练权重见过的自然光图像更多,对低照度的课堂图像特征不敏感。解决的土办法是把图像亮度拉回正常区间,比如做一次亮度自适应:计算灰度图均值,如果低于 60,用 gamma 校正把暗部提亮;再在数据增强里把“亮度扰动”从 ±0.2 加到 ±0.3。仍然不行的话,要单独采一小段夜间场景数据做二次微调,这类数据量不需要很多,200 张就够把模型“拉回现实”。

6. 从“能识别”到“能汇报”:注意力曲线、预警时间轴与抽帧复核

模型稳定后,下一步不是继续调准确率,而是把行为识别变成可用报告。按时间窗口统计每个行为的累计时长和占比,公式很简单:兴奋度 = 举手占比 + 讨论占比,专注度 = 听讲占比 + 书写占比 + 阅读占比。<|assistant|>

6.1 行为统计与“专注度”指标怎么映射

一段 45 分钟的课堂视频,如果每 5 秒取一帧,总共约 540 个决策窗口。每个窗口输出一个行为类别,然后按“行为时长占比”做汇总,生成行级别的统计表。实际操作中,我习惯把“专注度”定义为聆听、书写、阅读三类行为的窗口数占比;“课堂活跃度”则定义为举手、发言讨论两类行为窗口数占比。这两个指标比单独看某一类行为更容易被非技术老师接受,也可以作为模型的评估展示层。

统计脚本不复杂,用 pandas 按时间段聚合即可,例如按分钟切分:“第 10 分钟的专注度 = 该分钟内行为窗口为正向行为的窗口数 / 该分钟内窗口总数”。这里有一个必须注意的选择:是否对每个学生独立统计。如果要输出“全班整体专注曲线”,对每帧的全体学生行为做聚合就可以;如果要输出“张三第几分钟走神”,就必须有稳定的学生 ID,背后要接多目标跟踪。在源码交付阶段,先做全班整体统计,单学生统计作为扩展功能。

6.2 预警时间轴:不需要逐帧汇报,只挑“异常片段”

把识别结果全部平铺成 JSON 没有意义,使用者真正关心的是“这节课哪个时段异常”。我的做法是设定一个行为阈值,例如“睡觉窗口占比连续超过 30%”,就记录一个预警片段,并把这段时间的视频抽成 GIF 或截取几帧图片,留给人工复核。预警时间轴可以和原始视频时间戳对应,这样督导老师能直接点击定位到录像位置,不需要来回拖动进度条。这一层很小但价值密度最高,因为它把模型的输出从“标签序列”变成了“可行动的线索”。

抽查复核也是最后一道防线。人工抽 5 段各 30 秒的视频,把模型输出和肉眼判断做对比,记录“误报片段”和“漏报片段”。我通常要求误报率不超过 15%,超过这个线就回到第 5 章的避坑项里排查原因。这个抽检习惯形成之后,整个项目的可信度比单纯看论文数据要高得多。

6.3 保留推理中间产物:给复核留后悔药

项目运行时,我会把关键中间结果写盘:每个检测框的裁剪图、模型 softmax 概率、预测类别、时间戳,统一存成 JSON 或数据库。这样当老师质疑某个判断“他明明只是在捡笔,怎么标成睡觉”时,可以把当时的裁剪图和概率分布调出来,直接看到模型为什么这么判。很多团队觉得存中间文件占空间,但它能在上线初期省下大量扯皮时间,这个习惯我一直保留着。

最后给阅读这份源码的你一句实在建议:先把“检测 + 分类 + 时间投票”这个主链路跑通,再回头优化模型结构和数据量,不要一开始就追新模型。课堂行为识别没有完美的模型,只有经得起抽检的工程方案。希望这套一线走过的流程,能帮你在自己的环境里少走一轮弯路。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进