简介:一份面向课堂行为识别的深度学习图像分类项目源码,基于Python实现,适合毕业设计、期末大作业或图像识别入门实践。项目涵盖数据预处理、模型训练、分类评估等环节,配套大量课堂场景图片数据集,可直接运行调试,帮助理解深度学习图像分类的完整流程。包内共1206个文件,包含1183张jpg图片样本、14个py脚本、3张png说明图、2个md文档,以及配置文件、license等,整体约100.29MB。py脚本为主要实现代码,md文档可辅助阅读项目结构,图片数据则用于模型训练与验证。目前已有569人学习下载,代码经过个人严格调试,评审分达95分以上,具备较好的参考价值。下载后可以快速复现课堂行为识别分类流程,根据实际数据调整模型参数,并借鉴其数据组织与工程目录方式,为相关课题或作业提供可用范本。
1. 课堂行为图像识别项目:Python 深度学习要做的事
如果教室里装了一颗固定视角的摄像头,你想自动判断这堂课学生是在举手、翻书、写笔记、趴桌子,还是低头玩手机,本质上是给每一帧画面里的每个学生贴一个行为类别标签。这个基于 Python 的深度学习课堂行为图像识别分类项目,做的就是这件事:把监控画面拆成单张图像,用卷积神经网络把学生行为划分到预定义的类别里。
它解决的问题不是“这个学生是谁”,而是“这个学生在干什么”。身份识别或目标跟踪另算一套活儿,这里只关心分类。适合的读者,是手里已经有一批课堂视频或图像,想尽快搭出一个能跑通、能出实验指标的分类模块的工程师和数据科学初学者。最终交付的源码里,数据准备、模型训练、评估和推理调用应该是一条完整的链路。
2. 模型选型与整体架构:为什么课堂行为先按图像分类来做
2.1 把问题拆成图像分类,而不是直接上时序模型
课堂行为的原始信号是视频,但视频建模的开销比单图大一个量级:3D CNN 或时序 Transformer 需要长时间窗口、对齐采样,以及比单图多 10 倍以上的标注量。而大部分教学管理场景里,真正有判别力的信息就是单帧的姿态和位置关系——举手是一个手臂抬起的瞬间,趴桌是一个头朝下的姿态,玩手机是一个低头和反光特征。因此,可靠的做法是:视频先抽帧,再对每一帧里的学生做目标检测和裁剪,最后把裁剪出的单人图像交给分类模型。这样数据流简单,分类也容易独立调试和替换。
把问题拆成“图像分类”还有个好处:分类模型的成熟度远高于视频模型,预训练权重丰富,训练技巧和评估工具链都很现成。即便后续要升级成带时序信息的模型,也可以把当前的分类结果作为视频层模型的输入特征,不必推翻重做。做这类项目,先跑通单帧分类,再考虑时序建模,是性价比最高的演进路线。
2.2 ResNet 打底、MobileNet 备选:backbone 怎么选
模型选择上,ResNet 是这类任务里最不容易出错的基地。ResNet18 只有 11M 参数,在 GPU 上推理一张 224×224 的图像不到 10ms,对一个实时课堂系统来说是够用的;ResNet50 精度更高,但显存和延迟都翻倍。另一个候选是 MobileNetV3,它在嵌入式摄像头、树莓派这类端侧设备上更合适,代价是分类的鲁棒性略差。项目源码如果只有一个模型入口,我通常会把 backbone 抽出来,运行时由配置文件指定是哪有模型,兼顾实验室和部署环境。
| 骨干网络 | 参数量 | GPU 推理单张延迟 | 适用场景 |
|---|---|---|---|
| ResNet18 | 约 11M | 约 5ms | 实验室、服务器端,默认首选 |
| ResNet50 | 约 25M | 约 10ms | 类别多、图像质量差时提精度 |
| MobileNetV3-Small | 约 2.5M | 约 2ms | 树莓派、嵌入式摄像头 |
为什么默认不选更轻的网络?课堂监控的分辨率往往不高,坐在教室后排的学生裁剪出来后只有几十像素宽,轻量网络在这种低分辨率输入下很容易把“举手”和“伸懒腰”混成同一类。ResNet18 是精度和速度的平衡点,预算允许时先上这个,跑通后再用更小的模型做压缩,避免一上来就卡在效果不达标的泥潭里。
2.3 四层架构与 config.py:源码按什么组织、参数写在哪
PyTorch 是这类项目最常用的框架,原因有三。第一,torchvision 里预训练权重一行代码就能拉,做迁移学习非常顺手;第二,训练循环完全显式,笔误和调试都比封装过度的框架更容易定位;第三,部署时用 TorchScript 或 ONNX 导出,能和 C++/Java 端对接,遇到性能瓶颈不需要推倒重来。整体架构就四层:数据层(抽帧、裁剪、标注)、数据加载层(Dataset/DataLoader 与增强)、模型层(CNN backbone 加分类头)、评估与导出层(指标计算加 ONNX 导出)。把数据加载层和模型层分开,是项目能复用的关键,很多人喜欢把所有逻辑写进一个大脚本,换数据集时非常痛苦。
# config.py import torch CONFIG = { "data_root": "./data/classroom_behavior", "class_names": ["hand_up", "read", "write", "sleep", "phone", "normal"], "train_ratio": 0.8, "img_size": 224, "batch_size": 32, "epochs": 30, "lr": 0.001, "backbone": "resnet18", # 或 "mobilenet_v3_small" "device": "cuda" if torch.cuda.is_available() else "cpu", }这段配置没什么高深的东西,但它是源码里被引用最多的地方。路径、类别名、训练比例这四项,决定了你换一个教室、换一批摄像头数据时要动哪些地方。常见的问题是路径写死,换机器就跑不起来,逐层排查很浪费时间。另一个容易踩的坑是 ImageNet 预训练的分类头和课堂行为类别不同,加载时必须把最后一层全连接换掉,否则输出维度对不上。如果是夜间红外摄像头拍的,和 ImageNet 的自然图像差异大,要不要冻结 backbone 前几层需要实验验证,白天可见光摄像头通常直接微调所有层效果最好。
3. 数据准备:从课堂视频抽帧到分类数据集的完整流程
这是整个项目里最吃时间的一步,也是上下限差异最大的一步。模型效果不好还能换,数据不对就全白费了。
3.1 视频抽帧:帧间距怎么定
原始素材是课堂录像,画面里一个班可能有 30 到 50 人,每个学生的行为在画面里只占一小块。第一步是抽帧,把视频变成一批图片,供后续检测和裁剪使用。
import cv2 import os def extract_frames(video_path, output_dir, target_fps=2): cap = cv2.VideoCapture(video_path) fps = cap.get(cv2.CAP_PROP_FPS) interval = max(1, int(fps / target_fps)) frame_id = 0 saved = 0 os.makedirs(output_dir, exist_ok=True) while True: ret, frame = cap.read() if not ret: break if frame_id % interval == 0: cv2.imwrite( os.path.join(output_dir, f"frame_{saved:06d}.jpg"), frame, [cv2.IMWRITE_JPEG_QUALITY, 95] ) saved += 1 frame_id += 1 cap.release() print(f"saved {saved} frames from {video_path}")这里target_fps设成 2,意思是每 0.5 秒取一帧。课堂行为的动作速度不快,举手这个动作从开始到放下来往往持续好几秒,翻页也是秒级事件,2 帧每秒足够捕捉到状态,又不至于让相邻帧几乎重复。抽得太密,训练集里会出现大量高度相似的图像,相当于把重复样本算了好几遍;抽得太稀,又可能刚好错过瞬间动作。我一般会抽完后随机挑几十帧人工扫一眼,确认每个行为类别都有清晰的姿态。
| 参数 | 推荐值 | 设计理由 |
|---|---|---|
| target_fps | 2 | 再密相邻帧重复度高,再稀会漏掉举手等瞬间动作 |
| interval | int(fps / target_fps) | 保证抽帧时间间隔均匀 |
| 保存格式 | JPEG 质量 95 | 画面细节保留够,文件体积合理 |
3.2 从全景图到单人样本:目录结构就是标注
抽出来的帧是全班场景,不能直接喂给 CNN。常见做法是先用一个目标检测模型把每个人框出来,再按框裁剪。这一步用现成的 YOLOv8s 性价比最高,推理也快。裁剪出的单人图尺寸统一处理到 224×224 附近,然后按类别手动分桶。目录结构最好是下面这种,train和val两个文件夹下按类名展开,PyTorch 的ImageFolder可以直接加载:
data/classroom_behavior/ ├── train/ │ ├── hand_up/ (1420张) │ ├── read/ (2350张) │ ├── write/ (1980张) │ ├── sleep/ (860张) │ ├── phone/ (310张) │ └── normal/ (3200张) └── val/ ├── hand_up/ (318张) ├── read/ (520张) ├── write/ (430张) ├── sleep/ (190张) ├── phone/ (75张) └── normal/ (700张)按类别文件夹摆放的好处有两个。一是数据量一目了然,开个统计脚本就知道哪个类别样本少;二是torchvision.datasets.ImageFolder直接按目录名生成标签,省去手工写 label 文件。有人喜欢用 CSV 或 JSON 存路径和标签,但从可复现角度看,文件夹本身就是可移植的标注结果。划分训练集和验证集时注意按人划分,也就是同一个学生的照片不能同时出现在训练集和验证集里,否则模型记住的是人脸,验证分数虚高,实际课堂里换一批学生就掉点。
3.3 数据增强:小数据集的正确打开方式
课堂行为类别之间的差异不大,“写字”和“看书”可能只有手部位置不一样,“玩手机”和“低头”在低分辨率下几乎同款。数据集不大的时候,不加增强,模型会很快过拟合。给出一套比较稳的增强方案:
from torchvision import transforms train_transform = transforms.Compose([ transforms.RandomResizedCrop( 224, scale=(0.8, 1.0), ratio=(0.75, 1.333) ), transforms.RandomHorizontalFlip(), transforms.ColorJitter( brightness=0.2, contrast=0.2, saturation=0.1 ), transforms.ToTensor(), transforms.Normalize( mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225] ), ]) val_transform = transforms.Compose([ transforms.Resize(256), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize( mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225] ), ])RandomResizedCrop的scale范围设成 0.8 到 1.0,而不是默认的 0.08 到 1.0。默认范围对 ImageNet 那种目标居中的图没问题,但课堂单人图的目标通常已经占满画面,裁到 8% 会把目标裁没了。ColorJitter里亮度抖动 0.2,可以模拟不同教室照明条件的差异,对比度和饱和度抖动幅度设小一点,保持行为本身的可辨识度。Normalize用 ImageNet 的均值和标准差,因为迁移学习里 backbone 的预训练就是这样归一化的,数据分布不能和预训练时差太远。验证集不增强,只做缩放和中心裁剪,评估时不会被随机变换干扰。
4. 用 PyTorch 训练课堂行为分类模型:关键代码与参数
4.1 迁移学习入口:加载预训练权重并替换分类头
torchvision 提供了一批现成的预训练模型,加载后替换最后的全连接层即可。新版 torchvision 里pretrained=True参数已经弃用,要用weights指定预训练版本。这里给出主训练脚本的模型部分:
import torch import torch.nn as nn from torchvision import models, datasets, transforms from torch.utils.data import DataLoader from config import CONFIG device = torch.device(CONFIG["device"]) if CONFIG["backbone"] == "resnet18": model = models.resnet18(weights=models.ResNet18_Weights.IMAGENET1K_V1) model.fc = nn.Linear(model.fc.in_features, len(CONFIG["class_names"])) elif CONFIG["backbone"] == "mobilenet_v3_small": model = models.mobilenet_v3_small( weights=models.MobileNet_V3_Small_Weights.IMAGENET1K_V1 ) model.classifier[3] = nn.Linear( model.classifier[3].in_features, len(CONFIG["class_names"]) ) model = model.to(device)两个分支的差异在分类头位置。ResNet 的最后一层叫fc,MobileNet 的分类头在classifier列表的第四个元素。加载预训练权重后如果忘了替换分类头,前向传播会在输出维度直接报错。拿到项目源码,第一件事就检查这里,确认输出类别数和自己的行为类别一致。
4.2 训练循环与验证逻辑
训练循环本身并不复杂。优化器用带动量的 SGD 在小数据集上往往比 Adam 稳,尤其在类别不平衡时不会过早收敛到一个次优解。学习率调度用固定步长衰减,每 10 个 epoch 降为原来的 0.1,是相对保守的配置。
criterion = nn.CrossEntropyLoss() optimizer = torch.optim.SGD( model.parameters(), lr=CONFIG["lr"], momentum=0.9, weight_decay=1e-4 ) scheduler = torch.optim.lr_scheduler.StepLR( optimizer, step_size=10, gamma=0.1 ) # Dataset/DataLoader 按 ImageFolder 方式加载 train_dataset = datasets.ImageFolder(CONFIG["data_root"] + "/train", train_transform) val_dataset = datasets.ImageFolder(CONFIG["data_root"] + "/val", val_transform) train_loader = DataLoader( train_dataset, batch_size=CONFIG["batch_size"], shuffle=True, num_workers=4, pin_memory=True ) val_loader = DataLoader( val_dataset, batch_size=CONFIG["batch_size"], shuffle=False, num_workers=4, pin_memory=True ) for epoch in range(CONFIG["epochs"]): model.train() running_loss = 0.0 for images, labels in train_loader: images, labels = images.to(device), labels.to(device) optimizer.zero_grad() outputs = model(images) loss = criterion(outputs, labels) loss.backward() optimizer.step() running_loss += loss.item() * images.size(0) train_loss = running_loss / len(train_loader.dataset) model.eval() correct = 0 total = 0 with torch.no_grad(): for images, labels in val_loader: images, labels = images.to(device), labels.to(device) outputs = model(images) _, predicted = torch.max(outputs, 1) total += labels.size(0) correct += (predicted == labels).sum().item() val_acc = correct / total print(f"Epoch {epoch+1:02d} | loss {train_loss:.4f} | val_acc {val_acc:.4f}") scheduler.step() torch.save(model.state_dict(), "classroom_behavior_resnet18.pth")running_loss累加的是每个 batch 的 loss 乘上 batch 大小,最后除以数据集大小,得到整个 epoch 的平均 loss。验证时model.eval()会关闭 Dropout 和 BatchNorm 的参数更新,这个一定要写,漏了验证指标会忽高忽低。有人因此反复调学习率,最后发现只是没切评估模式。
| 超参数 | 推荐值 | 调整原则 |
|---|---|---|
| lr | SGD 0.01,Adam 0.001 | 迁移学习不建议超过 0.01,会把预训练特征冲掉 |
| batch_size | 32 | 显存不够降到 16,梯度估计仍然稳定 |
| epochs | 25 到 40 | 看 val_acc 连续 5 轮不涨就早停 |
| weight_decay | 1e-4 | 超过 1e-3 可能把模型压得太简单 |
4.3 分类评估:混淆矩阵与每类 F1
课堂行为分类的类别比例失衡很常见,“正常听课”可能占一半,“举手”“玩手机”很少。准确率在这种情况下参考价值有限——把所有样本都预测成“正常”的模型准确率也能到 50% 以上。所以评估时我一般会看混淆矩阵和每类 F1,尤其是少数类的召回率。
from sklearn.metrics import classification_report, confusion_matrix model.eval() all_preds, all_labels = [], [] with torch.no_grad(): for images, labels in val_loader: images, labels = images.to(device), labels.to(device) outputs = model(images) _, preds = torch.max(outputs, 1) all_preds.extend(preds.cpu().numpy()) all_labels.extend(labels.cpu().numpy()) print(classification_report( all_labels, all_preds, target_names=CONFIG["class_names"] )) cm = confusion_matrix(all_labels, all_preds)读这份报告时,最先看“玩手机”和“趴桌”这类少数类的 recall。如果“举手”的 recall 只有 0.3,说明一半以上的举手被当成了别的动作,模型在实际课堂里没法用。遇到这种情况,第一反应不是换模型,而是回到数据看类别样本是不是姿态差异太大,或者到底被误分到哪个类别。混淆矩阵里相邻类别的误分规律往往比单看准确率更有提示作用。
4.4 类别不平衡的常规解法
处理类别不平衡,按优先级做三件事:清理多数类重复样本、给少数类补几轮数据、给损失函数加类别权重。第三件事代码上最直接:
class_weights = torch.tensor( [1.0, 2.0, 1.0, 1.5, 3.0, 0.7], dtype=torch.float32 ).to(device) criterion = nn.CrossEntropyLoss(weight=class_weights)class_weights的取值可以按各类别样本数反比归一化,也可以按业务重要程度手工调。核心是让 loss 对少数类的惩罚抬高。这个源码里如果有这个参数,说明作者被类别不平衡坑过;没有的话,自己加上的改动成本也很低。加了类别权重后,准确率可能略微下降,但少数类的召回会明显提升,这才是课堂行为识别真正关心的指标。
5. 模型落地:推理脚本与置信度阈值的处理
模型跑通 val_acc 之后,离“能用”还有一段距离。推理脚本的输入是一张裁剪好的单人图像,输出类别名和置信度:
import cv2 import torch from torchvision import models, transforms from PIL import Image class BehaviorClassifier: def __init__(self, model_path, class_names, backbone="resnet18", conf_threshold=0.6): self.device = torch.device( "cuda" if torch.cuda.is_available() else "cpu" ) if backbone == "resnet18": self.model = models.resnet18() self.model.fc = torch.nn.Linear( self.model.fc.in_features, len(class_names) ) self.model.load_state_dict( torch.load(model_path, map_location=self.device) ) self.model.to(self.device).eval() self.class_names = class_names self.conf_threshold = conf_threshold self.transform = transforms.Compose([ transforms.Resize(256), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize( mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225] ), ]) def predict(self, bgr_image): rgb = cv2.cvtColor(bgr_image, cv2.COLOR_BGR2RGB) img = Image.fromarray(rgb) tensor = self.transform(img).unsqueeze(0).to(self.device) with torch.no_grad(): prob = torch.softmax(self.model(tensor), dim=1) conf, idx = torch.max(prob, 1) cls = self.class_names[idx.item()] if conf.item() < self.conf_threshold: return "unknown", conf.item() return cls, conf.item()置信度阈值的处理容易被忽略。课堂里模型没见过的情况非常多——学生蹲下捡东西、和同桌说话、站在座位上伸展身体。这些行为不在预设类别里,模型硬要给一个输出。置信度低于 0.6 直接标记为unknown,宁可漏检也不乱报,实际使用中往往比硬分类更可靠。
最后一个调试技巧:在验证集上按类别画出置信度分布。比如“写字”的高置信度样本和“看书”的区间大量重叠,说明这两个类在特征层面就分不开,调阈值意义不大,回看标注是不是有问题反而更有效——很多标注把“转笔”“托腮”算进“写字”,干扰了特征学习。课堂行为识别的坑一半在标注、一半在类别定义,置信度分布图能帮你快速定位到底属于哪一半。
本文还有配套的精品资源,点击获取