ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

基于人脸表情识别的课堂行为检测实战:从数据到专注度评分

基于人脸表情识别的课堂行为检测实战:从数据到专注度评分 简介面向计算机相关专业毕业生的课堂行为检测系统毕业设计基于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、surpriseRAF-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_allFalse, thresholds[0.6, 0.7, 0.7], post_processFalse) def preprocess_frame(frame, target_size112): 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(weightsmodels.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()), lr1e-4, weight_decay5e-4 ) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max20) criterion nn.CrossEntropyLoss()这段代码的关键点有三个。第一weightsmodels.ResNet18_Weights.IMAGENET1K_V1是 torchvision 官方推荐的加载预训练权重方式比pretrainedTrue的旧接口语义更清晰。第二冻结 layer1、layer2 是因为低层特征边缘、纹理、颜色块是通用的不需要在表情数据上重新学layer3、layer4 和新的 fc 层需要解冻因为它们学的是高层语义特征。第三AdamW配合weight_decay5e-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, digits3)) 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_size30): # 30 帧按 3fps 采样约等于 10 秒 self.window deque(maxlenwindow_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_size30对应 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, labelStudent A) plt.plot(timestamps, focus_scores_student_b, labelStudent B) plt.axhline(y0.5, colorgray, linestyle--) plt.legend() plt.xlabel(time (s)) plt.ylabel(focus score)配合曲线再看视频里对应时刻的学生动作评委能立刻理解「分数高的时候学生在抬头看黑板、分数低的时候在低头」这层因果关系比念指标数字有说服力得多。我自己第一次做类似系统时就只盯着验证集准确率调参结果现场视频一放全是中性脸差点下不来台后来把验证方式改成「滑窗评分和人工观察的一致性」才算把系统调到真正能用的状态。希望这个习惯能帮你在答辩现场少一点紧张、多一分底气。本文还有配套的精品资源点击获取
返回列表