简介:面向计算机相关专业毕业生的课堂行为检测系统毕业设计,基于Python与人脸表情识别技术,可自动检测课堂中学生的表情与行为状态。项目经导师指导并获评审99分,代码完整可直接运行,适合毕设学生、项目实战学习者,也可用于课程设计和期末大作业。配套资源共261个文件,压缩包约118MB,包含90个Python源码、75个pyc编译文件、24个HTML页面与21个CSS样式,分别构成登录、学生、教师、课程等管理界面;另有17个MP4演示视频,以及H5模型文件、SQLite数据库、XML配置和说明文档,覆盖算法、前端、存储与演示各环节。已有216人学习下载,属较受欢迎的高分毕设项目。下载后可获得完整可运行工程、预训练模型、前端页面及操作录屏,既能对照源码理解人脸表情识别与课堂行为分析的实现逻辑,也能快速修改部署到自己的毕业设计或课程作业中。
1. 人脸表情识别的课堂行为检测:为什么这是 CV 毕设里最好落地的一个方向
教室摄像头拍到的是最密集、最持续的人脸信号,人脸表情识别恰好能直接回答「这个学生在不在状态」——比姿态估计省掉关键点标注成本,比语音方案少一套采集设备。这套课堂行为检测系统把一条视频流拆成「人脸检测 → 表情分类 → 时序聚合 → 行为标签」四段流水线,最后输出每个学生在某一时段的专注度曲线,Python 源码加训练好的模型就是一份完整且能现场演示的高分毕设交付物。它适合想用深度学习做毕业设计、手里只有一块带 GPU 的笔记本、但希望结果能直观展示的从业者。
2. 训练数据与人脸检测选型:FER2013、RAF-DA 怎么挑,预处理怎么搭
做课堂行为检测,第一步不是训练模型,而是把「数据从哪来、人脸怎么定位」这两个问题定死。我见过太多人直接下载一个表情识别模型就接进视频流,结果发现课堂这种多人数、多角度、光线杂乱的场景里,模型在实验室数据上的表现和真实现场完全不是一回事。所以先花半天把数据和检测器选对,后面能少走一周弯路。
2.1 数据集选型:FER2013、RAF-DA、AffectNet 的对比与取舍
表情识别领域常用的三个公开数据集,特点差异非常大,选错会让整个项目卡在精度上。我把它们的核心指标列成一张表:
| 数据集 | 样本规模 | 图像特点 | 标注质量 | 课堂场景适配度 |
|---|---|---|---|---|
| FER2013 | 约 3.6 万张 | 48×48 灰度,人脸已裁剪 | 单标签,噪声较多 | 低,图太小且是实验室灰度风格 |
| RAF-DA | 约 3 万张 | 真实场景彩色图,含对齐人脸 | 单标签 + 对齐框,质量较好 | 中高,光线和角度接近日常 |
| AffectNet | 约 40 万张 | 彩色,尺度不一 | 标签噪声较大,需二次清洗 | 中,数据量大但清洗成本高 |
常见的做法是先用 FER2013 跑通全流程,因为图小、加载快、迭代成本低,适合验证训练脚本有没有写错。等到需要提精度的时候再切到 RAF-DA 做最终模型。AffectNet 虽然大,但标签噪声会让新手在调试时完全摸不着头脑——模型明明在验证集上掉点,你却分不清是网络问题还是数据问题。我一般不会推荐毕设阶段直接上 AffectNet。
另外还有一个隐藏坑:这三个数据集的类别定义并不一致。FER2013 是 7 类(anger、disgust、fear、happy、neutral、sad、surprise),RAF-DA 的主任务也是 7 类但图像是彩色的,AffectNet 则有 8 类(多了 contempt)。如果你用了别人写好的训练脚本,一定先确认标签索引对应的是哪个数据集的类别顺序,否则训练时 loss 降得下去,推理时结果全乱。
2.2 人脸检测选型:Haar Cascade 快但脆,MTCNN 是性价比答案
课堂场景里人脸是「多、小、偏」,后排学生的人脸往往只有几十个像素。OpenCV 自带的 Haar Cascade 检测速度快、零依赖,但对侧脸、低头、戴眼镜的鲁棒性很差,经常把眼镜框当成人脸或者直接漏检。MTCNN 是更实用的选择,它分三个阶段从粗到细回归人脸框和五个关键点,CPU 上处理一帧约几百毫秒,精度远高于 Haar,而且输出的人脸关键点可以直接用来做对齐。RetinaFace 精度最高,但对毕设来说安装依赖繁琐、推理更慢,性价比并不高。
用 MTCNN 还有一个好处:它返回的人脸置信度分数可以用来过滤误检。课堂场景里如果一个人脸框的置信度低于 0.6,基本就是墙面纹理或书本图案误触发,直接丢掉比强行送入表情模型更可靠。
2.3 预处理流水线:从视频帧到模型输入的标准化代码
确定检测器后,就可以把「视频帧 → 人脸 → 模型输入」这条流水线固定下来了。下面这段是我常用的预处理函数,输入一帧 BGR 图像,输出一个可以直接喂给 ResNet18 的张量:
import cv2 import numpy as np from facenet_pytorch import MTCNN mtcnn = MTCNN(keep_all=False, thresholds=[0.6, 0.7, 0.7], post_process=False) def preprocess_frame(frame, target_size=112): rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) boxes, probs = mtcnn.detect(rgb) if boxes is None: return None # 课堂画面里有多个人脸,取置信度最高的一张 idx = int(np.argmax(probs)) x1, y1, x2, y2 = [int(v) for v in boxes[idx]] face = rgb[y1:y2, x1:x2] face = cv2.resize(face, (target_size, target_size)) # 按 ImageNet 的均值方差做归一化,匹配预训练权重 face = face.astype(np.float32) / 255.0 face = (face - np.array([0.485, 0.456, 0.406])) / np.array([0.229, 0.224, 0.225]) return face这段代码的逻辑是:先把 BGR 转 RGB,因为 MTCNN 和 torchvision 模型都按 RGB 训练;然后取置信度最高的人脸,避免课堂多人画面干扰后续分类;最后做和 ImageNet 预训练一致的归一化。target_size我建议用 112 而不是 48,因为 RAF-DA 的图就是这个尺寸,而且保留更多纹理细节对表情分类有明显帮助。
thresholds参数是 MTCNN 三个阶段的置信度阈值,0.6/0.7/0.7 是常用配置,调低会召回更多模糊人脸但误检也变多。如果你的课堂视频里后排人脸特别小,可以降到 0.5/0.6/0.6,但要做好误检率上升的准备。
2.4 类别重映射:7 类表情教不会模型,4 类行为标签才学得动
公开数据集标的是 7 类表情,但课堂行为检测真正关心的往往只有「专注、走神、困倦、积极」这几种状态。直接让模型学 7 类,会面临一个现实问题:fear 和 disgust 在课堂场景里几乎不出现,模型为这两个类别分配的参数量是浪费的,还会把 neutral 和 sad 的边界搞混。
我建议在训练前做一次类别重映射:把 7 类折叠成 4 类——happy 和 surprise 归为「积极/参与」,neutral 单独保留,sad、angry、disgust、fear 归为「消极/抵触」。这样一方面减少了模型要区分的类别数、提升小数据量下的精度,另一方面输出标签直接对接行为判定逻辑,不用再做一层翻译。重映射可以在数据加载阶段用一行字典映射完成,不要在训练完之后再做,因为那样会丢掉模型对中间类别的区分能力。
3. 用 ResNet18 微调表情识别模型:训练脚本、关键参数与评估方式
数据准备到位之后,进入模型训练环节。这个方向最忌讳的是自己从头搭一个 CNN——几万张图、几十个 epoch,从头训练出来的特征提取器远不如预训练模型好用。正确做法是拿 ImageNet 预训练的 ResNet18 做微调,把最后一层全连接换成自己的表情分类头,然后有选择地解冻部分层。
3.1 选型理由:小数据量下 ResNet18 比 ViT 更靠谱
很多新手一上来就想用 ViT 或最新的多模态大模型,但课堂行为检测的数据量只有几万张人脸图,ViT 在这种规模下很容易欠拟合,训练时间还长。ResNet18 有 1170 万参数,在单卡 GPU 上几个 epoch 就能收敛,推理速度也够处理视频流。如果硬件很弱,可以换 MobileNetV3 或 ShuffleNetV2,精度低一点但 CPU 也能跑得动。毕设评审看的是完整度和逻辑闭环,不是参数量大小,ResNet18 在这个场景里是投入产出比最高的选择。
3.2 微调脚本:冻结、替换全连接层与优化器配置
下面是一个可以直接跑的微调脚本核心部分,我用的是 7 类输出,如果你想做 4 类映射,把num_classes改成 4 并相应调整数据加载时的标签即可:
import torch import torch.nn as nn from torchvision import models num_classes = 7 # anger, disgust, fear, happy, neutral, sad, surprise model = models.resnet18(weights=models.ResNet18_Weights.IMAGENET1K_V1) model.fc = nn.Linear(model.fc.in_features, num_classes) # 冻结 layer1 和 layer2,只微调 layer3、layer4 和新的全连接层 for name, param in model.named_parameters(): if 'layer1' in name or 'layer2' in name: param.requires_grad = False optimizer = torch.optim.AdamW( filter(lambda p: p.requires_grad, model.parameters()), lr=1e-4, weight_decay=5e-4 ) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=20) criterion = nn.CrossEntropyLoss()这段代码的关键点有三个。第一,weights=models.ResNet18_Weights.IMAGENET1K_V1是 torchvision 官方推荐的加载预训练权重方式,比pretrained=True的旧接口语义更清晰。第二,冻结 layer1、layer2 是因为低层特征(边缘、纹理、颜色块)是通用的,不需要在表情数据上重新学;layer3、layer4 和新的 fc 层需要解冻,因为它们学的是高层语义特征。第三,AdamW配合weight_decay=5e-4是微调阶段比较稳的组合,比纯 SGD 收敛快,又比不带 weight decay 的 Adam 泛化好。
训练时的 batch size 建议 32 到 64,学习率从 1e-4 起步,每 5 个 epoch 观察一次验证集准确率。CosineAnnealing 的T_max是 20,意味着学习率会在 20 个 epoch 内从初始值余弦下降到接近 0,配合早停(patience 设 5)基本能覆盖大部分情况。
3.3 评估指标:准确率是黑匣子,混淆矩阵才是课堂场景的镜子
训练完看准确率是最直观的,但课堂场景有一个大坑:neutral 类别占比往往过半。如果模型把所有样本都预测成 neutral,准确率也能到 60% 以上,看起来不错,实际落地时所有学生都被判成「中性发呆」,系统等于废了。所以我每次训完都会打印混淆矩阵,重点看三件事:happy 和 surprise 的召回率;neutral 被误判成什么;sad 和 angry 能不能分开。
评估代码用 sklearn 一行就能出结果:
from sklearn.metrics import confusion_matrix, classification_report print(classification_report(y_true, y_pred, digits=3)) print(confusion_matrix(y_true, y_pred))如果发现 neutral 的样本大量吞噬了 happy,优先检查数据增强里的随机亮度扰动是不是过强,把表情差异抹平了。如果 sad 和 angry 分不开,可以考虑重映射成 4 类,让模型不再纠结这两个细分类。记住,在这个项目里「整体准确率」不重要,「每个行为类别能不能被稳定召回」才是关键。
4. 从表情到行为:课堂专注度评分与行为判定的滑窗逻辑
很多毕设做到这里就停了——模型能识别表情,但系统交付的是「这个学生现在是 happy」这种单帧结论,评委一问「这跟课堂行为有什么关系」就答不上来。真正的课堂行为检测,核心在于把连续的表情序列翻译成有业务含义的行为状态。这一章就是整个系统的点睛部分。
4.1 单帧表情不等于课堂行为:先建立状态映射表
学生在课堂上的表情是连续变化的,笑一下可能是听到了有趣的内容,也可能是和同桌聊天。所以不能拿一帧表情直接下结论,而是要设计一个映射规则,把表情类别对应到行为倾向:
| 表情类别 | 行为倾向 | 业务解释 |
|---|---|---|
| happy | 积极/参与 | 参与互动、听到有趣内容,权重高 |
| surprise | 积极/参与 | 被教学内容吸引,权重高 |
| neutral | 专注或发呆 | 可能是认真听讲,也可能是放空,权重中 |
| sad / angry / disgust / fear | 消极/抵触 | 情绪低落或抗拒课堂,权重低 |
这里的关键是 neutral 的语义模糊。一个学生全程 neutral,可能是学霸在专注思考,也可能是学渣在神游。解决思路是引入滑动窗口——如果 neutral 连续持续时间特别长,从概率上讲更可能是发呆;如果 neutral 中穿插了点头、微微表情变化,就更像思考。这属于后验概率判断,必须靠时序聚合实现。
4.2 滑窗聚合:用 python 的 deque 写一个 10 秒专注度评分器
时序聚合最直接的做法是维护一个固定长度的窗口,统计窗口内各表情的出现比例,再按映射表加权。Python 的collections.deque是现成的滑动窗口容器,窗口满时自动弹出最老的帧:
from collections import deque # 7 类表情索引与行为权重的映射 # 0:anger 1:disgust 2:fear 3:happy 4:neutral 5:sad 6:surprise weights = { 3: 1.0, # happy 6: 0.9, # surprise 4: 0.6, # neutral 0: 0.2, # anger 1: 0.2, # disgust 2: 0.2, # fear 5: 0.2, # sad } class FocusScorer: def __init__(self, window_size=30): # 30 帧按 3fps 采样约等于 10 秒 self.window = deque(maxlen=window_size) def update(self, label): self.window.append(label) if len(self.window) < self.window.maxlen: return None # 窗口未填满,不做判定 total = sum(weights[l] for l in self.window) max_possible = self.window.maxlen * 1.0 return total / max_possible这段代码把「时间窗内所有表情帧的权重和」除以「满窗全积极的理论最大权重」,得到一个 0 到 1 之间的专注度分数。window_size=30对应 10 秒窗口,这个时长是我反复试出来的折中——太短(如 5 帧)会把一次短暂抬头误判成积极,太长(如 5 分钟)则看不出课堂行为的起伏。这里的评分逻辑也可以直接用 Python 里更常见的dict结构来写,把每个学生的窗口统计存成结构化数据,方便后面画曲线,我这里用了类封装是为了把状态隔离清楚。
分数算出来后,还要做一次平滑。直接给相邻两帧的分数做指数移动平均,能明显减少摄像头抖动和检测器不稳定造成的毛刺:
ema_focus = 0.0 alpha = 0.7 def smooth(new_score): global ema_focus ema_focus = alpha * new_score + (1 - alpha) * ema_focus return ema_focusalpha越大,跟随越灵敏但越容易跳动;0.7 是兼顾灵敏度和连续性的常用值。如果发现行为标签切换过于频繁,把 alpha 调到 0.85,让系统更「迟钝」一些。
4.3 行为标签阈值:先定业务定义,再反推工程参数
专注度分数算出来之后,还要映射到「专注、走神、困倦、积极」这类行为标签。很多人卡在阈值怎么设——其实顺序应该反过来:先和任课老师或者你自己定一个业务标准,比如「连续 15 秒不面对黑板且无表情变化算走神」「5 秒内有两次以上积极表情算参与互动」,再根据这个标准去标定分数阈值。
我常用的基准是:专注度分数大于 0.75 判「积极」,0.5 到 0.75 判「专注」,0.3 到 0.5 判「走神」,低于 0.3 加上长时间低头判「困倦」。这几个数不是数学推导出来的,而是拿 10 分钟的课堂录屏人工标注后对齐出来的。每换一个教室环境,建议重新做一次小规模标注校准,因为光线和座位布局会影响表情识别的置信度分布。
5. 课堂行为检测系统避坑:真实课堂数据下的五个翻车现场
这一章写的是我在做类似系统时实际踩过的坑。每个坑都是「现象 → 原因 → 解决」的结构,希望能帮你省掉排查时间。
5.1 训练集涨点、课堂实测全判中性:分布差异是头号踩坑点
现象:模型在 RAF-DA 验证集上准确率到了 78%,一接到教室录屏,几乎所有人脸都被判成 neutral,偶尔出几个 happy,其他类别基本不见踪影。
原因:公开数据集里的表情是「表演出来的」,人面对镜头时表情幅度大、特征明显;课堂里的学生不会对着摄像头做表情,微表情在 112×112 的人脸图上特征极弱,网络直接把它们全归到了 neutral。
解决:准备 200 到 500 张课堂现场帧,用训练好的模型做伪标注,人工修正后混合进训练集做二次微调。这一步能让模型对「写实表情」的敏感度明显提升。注意伪标注只修正高置信度样本,低置信度的直接丢弃,不要硬标。
5.2 检测结果乱跳,行为标签像抽风
现象:同一个学生静止不动,专注度分数却在 0.3 和 0.8 之间来回跳,行为标签在「走神」和「积极」之间疯狂切换。
原因:单帧表情分类本身有噪声,MTCNN 裁剪的人脸框每帧有轻微偏移,导致输入图像抖动;表情模型对这些偏移非常敏感,输出概率分布剧烈波动。
解决:两件事,一是对 MTCNN 的框做时序平滑(用上一帧的位置限制当前帧的检测范围),二是对最终专注度分数做 EMA 平滑,alpha设 0.7 起步。先做框平滑再做分数平滑,不要只做其中一个。
5.3 戴眼镜、低头、侧脸全漏检
现象:后排戴眼镜的学生全程没有被检测到,低头看书的学生人脸框时有时无,侧脸朝向窗外的学生直接消失。
原因:MTCNN 对正脸和中等角度的脸效果好,但课堂里低头和侧脸比例极高,检测器置信度低于阈值被过滤掉了。
解决:把 MTCNN 的三阶段阈值整体下调一档(如 0.5/0.6/0.6),同时对低头场景引入关键点判断——如果检测到人脸但两只眼睛不可见,大概率是低头,这时单独给一个「低头」状态,比强行送入表情模型更合理。
5.4 6G 显存训练 OOM
现象:CUDA out of memory,训练脚本刚跑第一个 step 就被系统杀掉。
原因:ResNet18 本身不占显存,但 batch size 设 128、图像尺寸 224 加上 AdamW 的动量缓存,6G 显存直接爆掉。很多同学以为是模型太大,其实是超参数没算。
解决:batch size 降到 32,图像尺寸用 112,开torch.cuda.amp混合精度,梯度累积两步等效 64 的 batch。这三个操作组合起来,6G 显存能跑大部分微调任务。记住先降 batch size 再降分辨率,优先保证数据多样性。
5.5 实时处理跟不上帧率
现象:摄像头采集 30fps,但 MTCNN 加表情推理每帧要 200 到 400 毫秒,系统处理速度远跟不上输入,视频流堆积、延迟越来越大。
原因:逐帧做人脸检测和分类,计算量太大。课堂行为是慢变量,不需要 30fps 的帧率来捕捉。
解决:跳帧采样,每隔 10 帧处理一次,等效 3fps。30 帧的滑动窗口正好对应 10 秒的行为观察窗口。如果嫌 3fps 太稀疏,可以改成每 5 帧处理一次,但窗口长度要相应增加,保持 10 秒的语义时长不变。
6. 答辩前把「实时」改成「离线回放+标注」:一个让系统不翻车的演示习惯
到了这个阶段,系统已经能跑通,但答辩现场还有一个隐患——摄像头、光线、现场网络都是变量,实时演示一旦出问题,整个项目会显得不牢靠。我养成的习惯是:提前录一段 5 分钟的课堂视频,离线跑完整套流程,把结果保存成标注好的回放文件,答辩时直接播放。这不算作弊,因为处理逻辑和实时版完全一致,只是把输入从摄像头换成了视频文件。
6.1 离线回放比实时演示可靠得多
实时演示最怕的不是模型不行,而是环境干扰。现场灯光偏暖、摄像头自动曝光、观众走动遮挡,都会让 MTCNN 的检测置信度波动,表情分类跟着乱跳。离线处理的优势是你可以在录制时选光线、角度最好的片段,把系统的上限展示出来。录好的视频用同一套预处理函数跑一遍,把每个人脸框、表情标签、专注度分数都画在帧上,再用 OpenCV 写回视频文件。这样评委看到的是「带标注的课堂实况录屏」,视觉冲击力远大于一个光秃秃的摄像头画面。
6.2 用 python 画图把专注度曲线画给评委看
标注视频之外,我会用 python 画一张专注度曲线图,按时间轴列出两名学生的分数变化,配合视频同时展示。关键代码很短:
import matplotlib.pyplot as plt plt.plot(timestamps, focus_scores_student_a, label="Student A") plt.plot(timestamps, focus_scores_student_b, label="Student B") plt.axhline(y=0.5, color="gray", linestyle="--") plt.legend() plt.xlabel("time (s)") plt.ylabel("focus score")配合曲线再看视频里对应时刻的学生动作,评委能立刻理解「分数高的时候学生在抬头看黑板、分数低的时候在低头」这层因果关系,比念指标数字有说服力得多。我自己第一次做类似系统时就只盯着验证集准确率调参,结果现场视频一放全是中性脸,差点下不来台;后来把验证方式改成「滑窗评分和人工观察的一致性」,才算把系统调到真正能用的状态。希望这个习惯能帮你在答辩现场少一点紧张、多一分底气。
本文还有配套的精品资源,点击获取