
简介面向计算机相关专业毕业设计、课程设计与期末大作业的人脸表情识别课堂行为检测系统属于Python方向完整项目适合准备毕设答辩或希望从零跑通项目的学习者。压缩包共261个文件主体为90个Python源码文件配合24个HTML页面、21个CSS样式完成前端展示另含75个pyc编译文件、17个mp4演示视频、6个xml配置、6张jpg示例图片与3个db数据库整体约118.46MB目录结构保留完整便于直接运行和二次开发。该项目个人大四期间完成经导师指导并获99分评审属于高分毕业设计代码完整、依赖清晰附带模型与演示视频能够帮助学习者快速理解人脸表情识别在课堂行为检测场景中的调用链路。内容预览中可见登录、学生、课程、教师等页面样式配合源码可对照学习前端展示、后端逻辑、模型推理与数据库存储之间的衔接关系。已有216人学习下载对于正在选型或复现同类毕设题目的学生是一份可参考的完整项目基线。1. 人脸表情识别的课堂行为检测系统它解决的不只是“认脸”而是“看懂课堂状态”一个教室里装了摄像头系统不仅要判断镜头里“有没有人脸”还要持续输出“这个学生此刻是在专注听讲还是低头走神、趴桌犯困”。这就是人脸表情识别的课堂行为检测系统的核心任务。它不是单纯的表情分类Demo而是把表情、头部姿态和持续时间三个维度的信息合并合成课堂行为结论的一套完整工程。对做Python毕业设计的同学来说这套方向真正值钱的地方在于数据可获取、模型可训练、效果可演示、答辩时可讲清楚每一行代码为什么存在。很多同学一开始会把项目做成“单张人脸图片 → 输出七种表情”结果答辩时被老师问一句“你的课堂行为检测在哪”就卡壳。这套系统的正确做法是用OpenCV/MediaPipe做人脸检测与关键点提取用ResNet做表情分类再用一个状态机把连续帧的表情和头部姿态转换成行为结论。按这个思路你拿到的源码包里的模型和推理脚本才有实际说服力。2. 三段式系统架构人脸检测、表情分类、行为状态判定各管一段2.1 为什么不用动作识别框架而用人脸检测加规则判定如果你第一反应是“课堂行为检测是不是要用3D卷积或者ST-GCN这类动作识别模型”那会被这个题目带偏。课堂行为检测的输入是普通教室摄像头画面画面里有大量无关背景、多个学生、频繁遮挡很少有完整骨架信息。动作识别模型需要长视频切片和骨骼点标注课堂场景下数据根本凑不齐。常见做法是把任务拆成三段第一段做人脸检测和关键点定位第二段对人脸区域做表情分类第三段把表情结果和头部姿态角度输入一个规则状态机。这样每一段都可以单独替换和调优而且状态机的判定逻辑是可解释的答辩时老师问“为什么这个学生被判为分心”你能直接回答“因为他的低头角度超过阈值并且持续了5秒以上”而不是打开一个黑匣子说“模型给出的置信度是0.8”。三段式架构还有一个隐性好处模型的训练压力和部署压力都被拆小了。表情分类模型只需要处理112x112的齐人脸图像检测段只需要定位人脸框和关键点状态机完全不参与学习。这对毕业设计级别的计算资源和数据量来说是最稳妥的组合。2.2 各模块选型组合对比模块推荐方案备选方案选型理由人脸检测MediaPipe Face Detection / Face MeshOpenCV Haar Cascade不推荐、RetinaFace带关键点输出头姿可算CPU实时人脸对齐仿射变换到112x112模板直接resize精度差一点对齐能消除头部平面旋转干扰提高表情分类稳定性表情分类ResNet-18微调MobileNetV3、Swin-TResNet-18在学术数据集上成熟训练快且不易翻车行为判定规则状态机 时间窗口HMM、LSTM数据不够没必要规则可解释、零成本调试答辩好讲展示端Flask 简易Web界面PyQt桌面端、纯OpenCV窗口视频流推送到浏览器页面演示效果好2.3 环境准备与依赖安装这套系统对硬件要求不高CPU也能跑推理但训练阶段建议还是用GPU。Python版本我用的是3.8PyTorch对应选2.0.x。OpenCV和MediaPipe的版本锁定很关键版本乱装会导致MediaPipe的模型加载直接报错。# python 3.8 环境下的最小依赖集合 pip install opencv-python4.8.0.74 pip install mediapipe0.10.7 pip install torch2.0.0 torchvision0.15.0 --index-url https://download.pytorch.org/whl/cu118 pip install numpy1.24.3 matplotlib flask这段命令里最需要注意的是torch的--index-url参数。如果你的机器是NVIDIA显卡且已装好CUDA 11.8驱动用cu118的预编译包可以省去本机编译的等待时间。没有独立显卡的同学把最后那个index-url去掉安装CPU版torch即可。MediaPipe 0.10.7这个版本对Python 3.8到3.10兼容性最好不要为了追新去装0.11以上的版本我在0.11.x上遇到过anaconda环境里模型初始化卡死的问题。装完之后先跑一个最小验证用MediaPipe检测一张包含人脸的图片能打印出人脸框坐标和468个关键点坐标再继续往下做。别一上来就把全套代码跑起来依赖环境有问题时你会分不清是代码问题还是环境问题。3. 训练数据准备公开数据集与人脸对齐的完整流水线3.1 人脸检测、关键点提取与112x112对齐表情分类模型的输入不应该是原始图像中的人脸框直接resize的结果因为镜头里人脸会有左右旋转、俯仰角变化直接把不同角度的脸压缩到同一尺寸模型会把“头的旋转”和“表情变化”混在一起学。我常用的做法是先做关键点对齐检测到人脸后取左右眼中心、鼻尖、左右嘴角五个点再去拿一个标准人脸模板做仿射变换。import cv2 import numpy as np import mediapipe as mp # 主要用 FaceAlignment-FAN 论文里的五点多尺度模板112x112 对齐入口 TEMPLATE np.float32([ [38.2946, 51.6963], # 左眼中心 [73.5318, 51.5014], # 右眼中心 [56.0252, 71.7366], # 鼻尖 [41.5493, 92.3655], # 左嘴角 [70.7299, 92.2041], # 右嘴角 ]) def align_face(frame, face_rect, landmarks): # face_rect: (x, y, w, h)landmarks 是五个关键点坐标 x, y, w, h face_rect left_eye landmarks[0] right_eye landmarks[1] nose landmarks[2] left_mouth landmarks[3] right_mouth landmarks[4] src_points np.float32([left_eye, right_eye, nose, left_mouth, right_mouth]) # 仿射变换只要三组点这里取左眼、右眼、鼻尖 transform cv2.getAffineTransform(src_points[:3], TEMPLATE[:3]) aligned cv2.warpAffine(frame[y:yh, x:xw], transform, (112, 112)) return aligned上面这段代码里getAffineTransform只需要三组点所以取了左眼、右眼、鼻尖。注意src_points[:3]的顺序必须和TEMPLATE[:3]一一对应左眼对左眼、右眼对右眼顺序反了整张脸会镜像。112x112这个尺寸是表情识别领域的常见标准输入ResNet的全局池化层对尺寸不敏感你用96x96也行但112x112对齐后再训练的模型在摄像头实拍场景下表现最稳。对齐之后还要做归一化。很多人会随手把像素值除以255但表情识别场景我建议用mean和std的标准化方式均值0.5、标准差0.5这样能把肤色差异和光照差异的影响压低一点。这个预处理步骤在训练和推理时必须完全一致否则训练时准确率95%摄像头下一张陌生的脸进来直接分类错误后面避坑章节会专门讲这个。3.2 选择CK、RAF-DB还是FER2013表情识别公开数据集有三套主流选择每一套适合的课题不同。CK是实验室环境录制7到8类表情总量只有几百个序列适合快速跑通训练流程但容易过拟合FER2013是48x48灰度图有35000多张量大但噪声非常多很多标签是错的RAF-DB有12000多张真实场景人脸遮挡多、姿态杂训练难度高但和课堂实拍场景最接近。我给这个毕设系统推荐的组合是用FER2013做预训练再用RAF-DB的“中性、开心、难过、惊讶、生气、厌恶、恐惧”七类子集或CK做微调。如果你不想折腾两个数据集直接在RAF-DB全量上训练ResNet-18也能到85%左右。遇到极其重要的一个问题RAF-DB的官方划分是按图片划分的同一个人的不同表情图会同时出现在训练集和测试集里这对“测试集准确率”是虚高的。我自己的做法是重新按人脸ID划分保证同一个人的所有图片只出现在一边。import os import shutil import random # 数据集目录结构 # raf_db/raw/train_00001_angry.png 文件名里面带表情标签 # 这里示范按图片文件名前缀人ID重新划分 train/val/test all_files [f for f in os.listdir(raf_db/raw) if f.endswith(.png)] # 提取人物IDRAF-DB文件名以 train/valid 开头且每个人有唯一编号字段 person_ids sorted(list(set([f.split(_)[1] for f in all_files]))) random.seed(2024) random.shuffle(person_ids) train_ids person_ids[:int(len(person_ids) * 0.8)] val_ids person_ids[int(len(person_ids) * 0.8): int(len(person_ids) * 0.9)] test_ids person_ids[int(len(person_ids) * 0.9):] for f in all_files: pid f.split(_)[1] src os.path.join(raf_db/raw, f) if pid in train_ids: dst_dir raf_db/split/train elif pid in val_ids: dst_dir raf_db/split/val else: dst_dir raf_db/split/test shutil.copy(src, os.path.join(dst_dir, f))这段脚本的作用是按人物ID划分而不是按图片划分。如果不做这一步同一个人开心的表情在训练集里出现过测试集里又出现一张他闭着嘴的微笑图模型会“记住人脸”而不是“学会表情”测试准确率会比真实场景高很多。课堂上老师和学生是固定的但算法必须面对没见过的人脸所以这种防泄漏划分对毕设答辩特别重要。3.3 课堂场景的数据扩增策略数据扩增要克制。表情识别和通用物体分类不同左右翻转这个操作在表情任务里是双刃剑人脸肌肉有左右不对称性但多数情绪的两侧表达差异不明显所以左右翻转还算能用。然而在课堂场景里有个特殊情况学生写字时头会自然偏向左侧或右侧这种偏头不能当作表情变化来处理。我踩过这个坑之后扩增时只保留亮度扰动、小角度旋转±10度、随机遮挡三个操作不要做极端颜色变换。亮度扰动对课堂场景特别重要——教室里前排靠窗和后排靠墙的光照差异很大同一张表情在不同亮度下会被模型识别成不同类别。随机遮挡模拟的是手挡嘴、低头时头发遮眉、同桌探身挡住半边脸的情况。遮挡面积不要超过人脸框的15%否则模型会学到“看不清就是恐惧”这种错误映射。我一般用Cutout策略随机挖掉一块方形区域区域边长取人脸尺寸的10%到15%。4. 模型训练与行为判定逻辑从单个表情到课堂状态4.1 ResNet-18表情分类训练脚本网络结构没必要自己设计ResNet-18在七类表情分类任务上是成熟的基线方案。比它更轻的MobileNet也能做但48x48输入下的精度会掉2到3个百分点。我给出一个可直接改的训练脚本核心片段数据加载器、评估函数写配套的dataset.py里训练脚本只保留关键逻辑。import torch import torch.nn as nn from torchvision import models, transforms from torch.utils.data import DataLoader def build_model(num_classes7): model models.resnet18(weightsNone) # 最后一层全连接改成 7 类输出对应 angry/disgust/fear/happy/neutral/sad/surprise model.fc nn.Sequential( nn.Dropout(p0.2), nn.Linear(512, num_classes) ) return model def train_epoch(model, loader, optimizer, criterion, device): model.train() total_loss, correct 0, 0 for images, labels in loader: images, labels images.to(device), labels.to(device) optimizer.zero_grad() outputs model(images) loss criterion(outputs, labels) loss.backward() optimizer.step() total_loss loss.item() correct (outputs.argmax(1) labels).sum().item() return total_loss / len(loader), correct / len(loader.dataset) if __name__ __main__: # 训练参数batch 64、lr 1e-3、30 epoch用 CosineAnnealing 收尾 train_loader DataLoader(train_dataset, batch_size64, shuffleTrue, num_workers4) val_loader DataLoader(val_dataset, batch_size64, shuffleFalse, num_workers4) model build_model(7).to(cuda) criterion nn.CrossEntropyLoss(label_smoothing0.1) optimizer torch.optim.Adam(model.parameters(), lr1e-3, weight_decay1e-4) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max30) best_acc 0.0 for epoch in range(30): loss, acc train_epoch(model, train_loader, optimizer, criterion, cuda) scheduler.step() if epoch % 5 0: print(fepoch {epoch} | loss {loss:.3f} | acc {acc:.3f}) torch.save(model.state_dict(), models/expression_resnet18.pth)这里有一个关键参数容易被忽略label_smoothing0.1。表情数据集的标注本来就有主观性同一张脸有人觉得是惊讶有人觉得是恐惧硬标签会把模型学得过于自信导致摄像头下稍微变个角度就剧烈摇摆。标签平滑让模型对非目标类保留一点冗余置信度实拍场景下分类稳定性明显更好。Learning rate用1e-3配合CosineAnnealing衰减30个epoch内能收敛不需要更复杂的warmup策略。4.2 行为判定状态机从表情和头姿到课堂行为在监控帧里表情分类模型每帧吐出一个类别置信度向量但单帧结果不能直接当行为结论。学生低头看课本时可能被识别成“中性”或“悲伤”抬头看黑板时又变成“中性”模型结果来回跳。我一般用状态机加时间窗口来决策。class BehaviorStateMachine: def __init__(self): # 状态: FOCUS / LOW_HEAD / SLEEP / DISTRACT self.state FOCUS self.state_start_time 0.0 self.distract_expr_count 0 def update(self, expr, expr_prob, pitch, yaw, ts): # 表情优先级权重confusion 场景下 disgust/sadness 视为分心信号 distract_exprs [disgust, sad, fear] is_distract_expr expr in distract_exprs and expr_prob 0.6 is_head_down pitch -15.0 # 低头角度阈值单位度 is_head_turn abs(yaw) 30.0 # 左右偏头超过30度 if self.state FOCUS: if is_head_down and (ts - self.state_start_time) 3.0: self.state LOW_HEAD self.state_start_time ts elif is_distract_expr: self.distract_expr_count 1 if self.distract_expr_count 5: self.state DISTRACT self.state_start_time ts elif self.state LOW_HEAD: if (ts - self.state_start_time) 15.0: self.state SLEEP elif not is_head_down and (ts - self.state_start_time) 2.0: self.state FOCUS self.distract_expr_count 0 elif self.state DISTRACT: if not is_distract_expr and (ts - self.state_start_time) 5.0: self.state FOCUS self.distract_expr_count 0 return self.state这个状态机的核心逻辑是“连续异常才切状态”。单帧低头不切换、单帧分心表情不切换必须满足持续时间和连续计数两个条件。pitch -15度这个阈值是在摄像头装在讲台前方、斜向下拍摄的场景下调出来的如果你把摄像头装在教室侧面这个阈值要改到-25度左右因为透视角度本身就让头部看起来是低着的。distract_expr_count 5假设视频帧率是15FPS左右意味着约0.3秒内持续出现分心表情才触发切状态这个参数要根据实际推理帧率换算成秒。4.3 课堂行为统计输出一节课的专注度曲线毕业设计答辩时老师最想看到的是“有意义的结果输出”而不是实时视频里飘着的表情名称。我习惯在状态机外面套一层统计模块把一节课的时间切成若干个30秒窗口计算每个窗口内FOCUS状态的占比最后画出一条专注度曲线。import json import numpy as np def compute_attention_curve(state_log, window_sec30): # state_log: [(timestamp, state), ...]每秒记录一个状态 curve [] start_ts state_log[0][0] end_ts state_log[-1][0] current_window_start start_ts while current_window_start end_ts: window_end current_window_start window_sec window_states [s for ts, s in state_log if current_window_start ts window_end] focus_ratio window_states.count(FOCUS) / len(window_states) curve.append({ start_time: current_window_start, focus_ratio: round(focus_ratio, 3) }) current_window_start window_end total_focus sum(1 for _, s in state_log if s FOCUS) / len(state_log) result {curve: curve, overall_focus_rate: round(total_focus, 3)} with open(output/behavior_report.json, w, encodingutf-8) as fp: json.dump(result, fp, ensure_asciiFalse, indent2) return result输出JSON的好处是Flask展示端可以直接读取并渲染成图表不用重新跑一遍模型。overall_focus_rate这个单一数字很适合放在答辩PPT首页——它把一整个教室的复杂行为压缩成一个可比较的指标。然后曲线图展示某个时间段全班专注度的突然下降正好对应课堂里的难点讲解环节这个分析点能明显拉升答辩的评分。5. 课堂行为检测避坑指南数据、推理与展示层的 6 个排查记录5.1 训练准确率95%摄像头一开就全部识别错误这个现象几乎每个做表情识别的人都会遇到原因大概率是训练和推理的预处理不一致。训练时用了112x112对齐加mean/std标准化推理时直接拿OpenCV裁剪的原始人脸框resize后除以255传给模型输入分布完全变了。解决方法是把预处理封装成同一个函数训练和推理都调用它。我自己的教训是把这个函数放进utils/preprocess.py在代码里写注释标明“任何地方不得自行实现另一套预处理”后来团队同学复现就再没翻车过。5.2 多人教室场景人脸检测漏检严重课堂摄像头通常是广角镜头一个画面里有二十多个学生靠前排的学生人脸可能占100x100像素靠后排的只有30x30像素。MediaPipe的Face Detection在极小目标上召回率低。解决思路是提高推理分辨率把视频帧从720p升到1080p再送检测器或者隔帧进行一次检测、对未检测帧直接沿用上一帧的人脸框。检测和分类的频率还可以分开比如检测每5帧做一次分类每帧都做因为人脸框在短时间内的移动量很小这个策略能同时保住精度和推理速度。5.3 表情分类结果逐帧抖动状态机频繁切换典型现象是学生明明安静在写作业系统的行为状态却在FOCUS和DISTRACT之间来回跳。原因不是状态机写错而是表情分类单帧输出的噪声太大。我在章节3.3和4.2里分别用了数据扩增和状态机时间窗口但还有一个更直接的手段对表情类别做滑动窗口滤波取最近5帧中出现次数最多的类别作为当前表情而不是直接取模型置信度最大的类别。这个方案在工程里被称为滑动窗口滤波模型比改模型结构成本低得多效果却很明显。5.4 教室照明差人脸关键点漂移傍晚或阴天场景下MediaPipe的人脸关键点偶尔会跳到脸部轮廓外面导致对齐后的人脸图像里眼睛鼻子错位。解决方法是先做一次图像增强再进检测器用cv2.convertScaleAbs(frame, alpha1.2, beta30)线性提亮或者在检测前做一次CLAHE自适应直方图均衡。注意这个增强只应用于检测和对齐阶段不应用于表情分类阶段因为分类模型训练数据里没有这种风格的增强图强行改输入反而会掉点。5.5 视频推理只有2FPS答辩演示卡顿Raspberry Pi或者集成显卡上跑PyTorch模型推理确实慢但2FPS通常是代码问题而不是硬件问题。最常见的问题是每一帧都构建输入张量而没有开启推理模式。解决办法有三个用torch.no_grad()包裹推理、把模型切到model.eval()、用torch.cuda.synchronize()的时机来控制耗时统计。另外把视频帧从1920x1080先缩到960x540再做检测人脸检测的耗时能下降一半以上而1060x540下的人脸框质量对后续分类影响很小。5.6 自定义采集的课堂数据标注工作量失控有人想自己录一段真实课堂视频再做标注发现要标注几百个人脸框和表情类别一天只能标半小时视频。我的建议是不要自己从头标改用半自动标注流程先运行人脸检测模型自动生成人脸框再人工只修改错框表情类别用预训练模型给初标人工确认修正。这样一个人一天能标完几千张人脸。如果你连这个流程都不想做就直接用开源数据集训练把自定义数据只做测试集效果论证同样成立。6. 一个值得做的进阶方向表情时序平滑与专注度评分的可解释化6.1 用滑动窗口特征加回归模型生成0到100的专注度分数状态机输出的FOCUS/DISTRACT是离散标签可以用但不够细腻。课堂行为检测更实用的变现形式是一个连续分数这节课学生的投入程度是多少分。常见做法是把滑动窗口内的特征抽出来交给一个回归模型映射成分数。我用过效果不错的一组特征是窗口内中性表情占比、开心表情占比、低头帧占比、头部姿态角方差、状态切换次数。用LightGBM回归模型训练输入六个特征输出0到100分。import numpy as np import lightgbm as lgb # 每50帧滑动窗口统计一次特征 def extract_window_features(states, exprs, pitches, yaws): features np.array([ np.mean([1 if e neutral else 0 for e in exprs]), # 中性表情占比 np.mean([1 if e happy else 0 for e in exprs]), # 开心表情占比 np.mean([1 if p -15 else 0 for p in pitches]), # 低头帧占比 np.std(pitches), # 俯仰角标准差 np.std(yaws), # 偏航角标准差 len([1 for i in range(1, len(states)) if states[i] ! states[i-1]]), # 状态切换次数 ]) return features.reshape(1, -1) # 训练阶段收集历史窗口特征与人工评分训练回归模型 model lgb.LGBMRegressor(n_estimators200, learning_rate0.05, max_depth4) # model.fit(X_train, y_train) # y_train 是人工打分的0-100专注度特征工程比模型选择更影响回归效果。这里面的“低头帧占比”和“状态切换次数”是直接相关的一个学生频繁抬头低头状态切换次数高说明他在找东西或坐立不安专注度应该偏低。把这个分数和时间轴一起画成曲线能清晰看到哪位学生在哪个时间段分数掉下来。我自己做这个进阶方向时踩过一个真实的教训没有先定标准答案就去训练回归模型结果模型预测出来的分数忽高忽低完全没法解释。后来改成先定义打分规则——比如专注状态占比超过80%且切换次数低于5次才给85分以上——再用规则生成伪标签去训练回归模型效果一下子就稳定了。这个思路本质上是个知识蒸馏的变体答辩时能讲的东西也多了一层。最后想说的是这个方向要的不是建模比赛排名而是稳定可演示的课堂行为分析结论。优先把一套预处理、推理、状态机跑通再考虑更花哨的模型结构才是做好毕设的正路。希望帮到你。本文还有配套的精品资源点击获取