简介:这份资源是面向高校计算机相关专业学生与毕业设计选题者的完整项目包,聚焦基于Python的人脸表情识别与课堂行为检测系统,可用于毕业设计答辩、课程实践或二次开发。压缩包共261个文件,约118.46MB,以90个py源码文件为核心,辅以75个pyc编译文件、24个html与21个css前端页面、17个mp4演示视频,以及xml、jpg、db、txt等配置与数据文件,覆盖模型推理、界面展示与数据存储等模块。项目已获导师指导并通过评审,代码完整可直接运行,包含训练好的模型与数据库文件,便于读者快速复现表情识别与课堂行为分析流程,理解前后端交互与模型调用逻辑。目前已有582人学习下载,适合需要完整赛题方案、可运行源码与模型文件的毕业设计场景参考。
1. 从一张课堂截图说起:表情识别怎么变成可落地的行为检测
你手里可能有一段教室监控视频,或者一张学生上课的截图。你想知道:谁在走神、谁在打瞌睡、谁在积极互动。这件事听起来像是一个“情绪识别”问题,但真正落地时,它其实是一个课堂行为检测系统——输入是画面,输出是每个学生的行为标签,而人脸表情识别只是其中最关键的一环。
我做过好几个类似的项目,踩过最大的坑就是:把表情识别当成一个纯分类任务,拿 FER2013 训练一个 CNN,准确率跑到 65% 就以为能用了。结果一放到真实教室场景,光线不均、人脸遮挡、侧脸、低头,模型直接崩掉。后来才明白,基于 Python 开发人脸表情识别的课堂行为检测系统,核心不是模型有多深,而是整条链路能不能在真实约束下跑通:人脸检测要稳、表情分类要够快、行为映射要合理、部署要轻量。
这套方案适合谁?做毕业设计的学生、想快速验证教育场景 AI 落地的开发者、需要给现有录播系统加行为分析模块的工程师。你不需要从头训练一个 SOTA 模型,但需要知道每个环节的选型边界和参数怎么调。接下来我会按“先跑通再优化”的顺序,把源码结构、模型选型、训练脚本、推理部署和避坑经验一次讲清楚。
2. 课堂行为检测系统的技术拆解:从人脸框到行为标签
2.1 为什么不用端到端模型直接出行为标签
很多人第一反应是:我直接拿一个视频分类模型,输入一段课堂视频,输出“听课/走神/睡觉”不就行了?理论上可以,但实际落地时你会遇到三个硬伤。
第一,标注成本极高。端到端视频分类需要逐片段标注行为类别,一个 40 分钟的课堂视频,按 5 秒切片就是 480 个样本,标完一节课眼睛就废了。而人脸表情识别可以用公开数据集预训练,再少量微调就能迁移到课堂场景。
第二,可解释性差。老师或教务人员看到“这个学生被判定为走神”,第一反应是“凭什么”。如果系统能展示人脸框、表情概率、头部姿态角度,说服力完全不一样。端到端模型就是一个黑匣子,出了问题你连后悔药都没得吃。
第三,算力不友好。端到端视频模型参数量动辄几十 M,在教室边缘设备上跑不动。而“人脸检测 + 表情分类”两级流水线,检测用轻量级模型,分类用小型 CNN,整体可以压到 10M 以内,CPU 也能跑到 10 FPS 以上。
所以常见做法是:人脸检测负责定位,表情识别负责分类,行为映射负责业务逻辑。这三层解耦之后,每一层都可以独立替换和优化。
2.2 人脸检测选型:RetinaFace 还是 YOLOv8-face
人脸检测是整个系统的入口,漏检一个人脸,后面表情再准也没用。我对比过三种方案:
| 方案 | 模型大小 | CPU 单帧耗时 | 侧脸召回 | 遮挡鲁棒性 |
|---|---|---|---|---|
| Haar Cascade | <1MB | 15ms | 差 | 差 |
| RetinaFace-MobileNet | 1.7MB | 45ms | 好 | 中等 |
| YOLOv8n-face | 6MB | 30ms | 很好 | 好 |
Haar Cascade 只适合做 demo,教室场景直接放弃。RetinaFace 的精度很好,但 MobileNet 版本在 CPU 上跑 45ms,加上表情分类的耗时,整体帧率会掉到 8 FPS 以下。YOLOv8n-face 是我目前最推荐的:6MB 大小,CPU 单帧 30ms,侧脸和遮挡场景召回率明显优于 RetinaFace,而且 Ultralytics 的 Python 接口非常顺手。
安装命令很简单:
pip install ultralytics opencv-python numpy torch torchvision如果你要用 GPU 加速,把 torch 换成对应的 CUDA 版本。注意 YOLOv8n-face 不是官方预训练模型,需要从社区下载权重文件,常见做法是搜“yolov8n-face.pt”找到可用的权重,放到项目根目录的weights/文件夹下。
2.3 表情分类模型:轻量 CNN 还是迁移学习
表情分类是核心模块。公开数据集有 FER2013、RAF-DB、AffectNet。FER2013 有 3.5 万张 48x48 灰度图,7 类表情:愤怒、厌恶、恐惧、开心、悲伤、惊讶、中性。但课堂场景里,真正有用的只有四类:开心(积极互动)、中性(正常听课)、悲伤/疲惫(走神前兆)、愤怒/厌恶(异常行为)。
我一般会用一个轻量级 CNN 做基础分类,结构如下:
import torch import torch.nn as nn class EmotionCNN(nn.Module): def __init__(self, num_classes=7): super().__init__() self.features = nn.Sequential( nn.Conv2d(1, 32, 3, padding=1), nn.BatchNorm2d(32), nn.ReLU(), nn.MaxPool2d(2), # 48 -> 24 nn.Conv2d(32, 64, 3, padding=1), nn.BatchNorm2d(64), nn.ReLU(), nn.MaxPool2d(2), # 24 -> 12 nn.Conv2d(64, 128, 3, padding=1), nn.BatchNorm2d(128), nn.ReLU(), nn.MaxPool2d(2), # 12 -> 6 nn.Flatten(), nn.Linear(128 * 6 * 6, 256), nn.ReLU(), nn.Dropout(0.5), nn.Linear(256, num_classes) ) def forward(self, x): return self.features(x)这个模型参数量大约 1.2M,输入 48x48 灰度图,CPU 单帧推理 5ms 左右。训练时用 Adam 优化器,学习率 1e-3,batch size 64,跑 50 个 epoch 在 FER2013 上能到 65% 左右的准确率。别小看这个数字,FER2013 本身标注噪声很大,人类标注一致性也只有 65% 左右。
如果你想再提点精度,可以用迁移学习:拿 ResNet18 或 MobileNetV3 在 AffectNet 上预训练,然后冻结前面层,只微调最后两层。但模型大小会涨到 10M 以上,推理耗时翻倍。毕业设计场景下,轻量 CNN 足够写论文了。
2.4 行为映射逻辑:表情概率怎么变成课堂行为标签
表情分类输出的是 7 类概率,但课堂行为检测需要的是“听课/走神/睡觉/互动”这样的标签。中间需要一个映射层,我一般用规则引擎加时间窗口平滑。
规则示例:
def map_behavior(emotion_probs, head_pose, duration): # emotion_probs: [angry, disgust, fear, happy, sad, surprise, neutral] happy = emotion_probs[3] neutral = emotion_probs[6] sad = emotion_probs[4] # 头部姿态:pitch 低头角度,yaw 偏头角度 pitch, yaw = head_pose if pitch > 25 and duration > 3: return "低头走神" if sad > 0.5 and duration > 5: return "疲惫" if happy > 0.6: return "积极互动" if neutral > 0.5 and abs(yaw) < 15: return "正常听课" return "待观察"这里的关键参数是duration:单帧表情不可靠,必须连续多帧一致才判定。我一般设 3 到 5 秒的滑动窗口,窗口内取众数。头部姿态可以用 MediaPipe 或 PnP 算法估计,如果不想引入额外依赖,只用表情概率也能跑,但误报会多一些。
提示:行为映射规则没有标准答案,不同课堂场景差异很大。建议先用少量标注数据统计一下表情分布,再定阈值。
3. 源码结构拆解与训练脚本:从数据集到模型权重
3.1 项目目录怎么组织才不乱
一个能跑通的课堂行为检测系统,目录结构应该清晰到别人拿到就能复现。我常用的结构如下:
classroom_behavior/ ├── data/ │ ├── fer2013/ # 原始数据集 │ └── processed/ # 预处理后的图片 ├── weights/ │ ├── yolov8n-face.pt # 人脸检测权重 │ └── emotion_cnn.pth # 表情分类权重 ├── src/ │ ├── detect.py # 人脸检测模块 │ ├── emotion.py # 表情分类模块 │ ├── behavior.py # 行为映射模块 │ └── pipeline.py # 整体流水线 ├── train/ │ ├── dataset.py # 数据加载 │ └── train_emotion.py # 训练脚本 ├── configs/ │ └── default.yaml # 参数配置 └── requirements.txtdata/放原始数据,weights/放模型权重,src/放推理代码,train/放训练代码,configs/放配置文件。这样别人拿到源码后,只需要下载数据集和权重,改一下configs/default.yaml里的路径就能跑。
3.2 FER2013 数据预处理:三个容易翻车的点
FER2013 原始格式是 CSV,每行包含表情标签和 2304 个像素值(48x48)。预处理时最容易翻车的地方有三个:
第一,灰度归一化。像素值范围是 0-255,直接除以 255 就行,但要注意训练和推理必须用同样的归一化方式。我见过有人训练时用了(x - 127.5) / 127.5,推理时忘了,结果模型输出全是乱的。
第二,数据增强。FER2013 只有 3.5 万张,容易过拟合。我一般加随机水平翻转、随机旋转 ±10 度、随机裁剪。但注意:不要用颜色抖动,因为输入是灰度图,颜色增强没意义。
第三,类别不平衡。FER2013 里“厌恶”类只有 547 张,“开心”有 8989 张,差了 16 倍。直接训练会导致模型偏向多数类。常见做法是用 WeightedRandomSampler 或者给损失函数加类别权重。
from torch.utils.data import Dataset, DataLoader import pandas as pd import numpy as np class FER2013Dataset(Dataset): def __init__(self, csv_path, transform=None): self.data = pd.read_csv(csv_path) self.transform = transform def __len__(self): return len(self.data) def __getitem__(self, idx): row = self.data.iloc[idx] label = int(row['emotion']) pixels = np.array(row['pixels'].split(), dtype=np.float32) image = pixels.reshape(48, 48) / 255.0 # 归一化到 0-1 image = np.expand_dims(image, axis=0) # 加通道维 if self.transform: image = self.transform(image) return image, label这段代码的关键是pixels.reshape(48, 48) / 255.0,把字符串像素转成浮点数组并归一化。np.expand_dims加通道维是因为 PyTorch 卷积层要求输入是(C, H, W)格式。
3.3 训练脚本:学习率、批次大小和早停策略
训练脚本的核心是循环,但参数设置决定了你能不能收敛。我一般用这个配置:
import torch import torch.nn as nn import torch.optim as optim from torch.utils.data import DataLoader from dataset import FER2013Dataset from model import EmotionCNN # 设备选择 device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') # 数据加载 train_dataset = FER2013Dataset('data/fer2013/train.csv') val_dataset = FER2013Dataset('data/fer2013/val.csv') train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True, num_workers=4) val_loader = DataLoader(val_dataset, batch_size=64, shuffle=False, num_workers=4) # 模型、损失、优化器 model = EmotionCNN(num_classes=7).to(device) criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=1e-3, weight_decay=1e-4) scheduler = optim.lr_scheduler.ReduceLROnPlateau(optimizer, mode='min', patience=5, factor=0.5) # 早停 best_val_loss = float('inf') patience_counter = 0 early_stop_patience = 10 for epoch in range(50): model.train() train_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() train_loss += loss.item() # 验证 model.eval() val_loss = 0.0 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) loss = criterion(outputs, labels) val_loss += loss.item() _, predicted = torch.max(outputs, 1) total += labels.size(0) correct += (predicted == labels).sum().item() val_loss /= len(val_loader) val_acc = correct / total scheduler.step(val_loss) print(f"Epoch {epoch+1}: train_loss={train_loss/len(train_loader):.4f}, val_loss={val_loss:.4f}, val_acc={val_acc:.4f}") # 早停判断 if val_loss < best_val_loss: best_val_loss = val_loss patience_counter = 0 torch.save(model.state_dict(), 'weights/emotion_cnn.pth') else: patience_counter += 1 if patience_counter >= early_stop_patience: print("Early stopping triggered") break几个关键参数说明:lr=1e-3是 Adam 的常用初始学习率,如果 loss 震荡就降到 5e-4。weight_decay=1e-4是 L2 正则,防止过拟合。ReduceLROnPlateau在验证 loss 不降时自动降学习率,patience=5表示连续 5 个 epoch 不降就降一半。早停patience=10是防止训练过头。
训练完成后,weights/emotion_cnn.pth就是表情分类权重。整个训练过程在 GTX 1060 上大约 20 分钟,CPU 上大约 2 小时。
3.4 推理流水线:把检测、分类、映射串起来
推理流水线是最终交付的模块,输入一帧图像,输出每个学生的行为标签。核心代码如下:
import cv2 import torch from ultralytics import YOLO from emotion import EmotionCNN from behavior import map_behavior # 加载模型 face_model = YOLO('weights/yolov8n-face.pt') emotion_model = EmotionCNN(num_classes=7) emotion_model.load_state_dict(torch.load('weights/emotion_cnn.pth', map_location='cpu')) emotion_model.eval() def process_frame(frame): # 人脸检测 results = face_model(frame, verbose=False) behaviors = [] for box in results[0].boxes: x1, y1, x2, y2 = map(int, box.xyxy[0]) face = frame[y1:y2, x1:x2] if face.size == 0: continue # 预处理:灰度、缩放、归一化 gray = cv2.cvtColor(face, cv2.COLOR_BGR2GRAY) gray = cv2.resize(gray, (48, 48)) tensor = torch.tensor(gray, dtype=torch.float32).unsqueeze(0).unsqueeze(0) / 255.0 # 表情分类 with torch.no_grad(): probs = torch.softmax(emotion_model(tensor), dim=1)[0].numpy() # 行为映射(这里简化了头部姿态,实际项目需要额外估计) behavior = map_behavior(probs, head_pose=(0, 0), duration=1) behaviors.append({ 'bbox': (x1, y1, x2, y2), 'emotion_probs': probs.tolist(), 'behavior': behavior }) return behaviors这段代码里,face_model(frame)返回所有人脸框,然后逐个裁剪、灰度化、缩放、归一化,送入表情模型。torch.softmax把 logits 转成概率。map_behavior是上一章的规则函数。
实际部署时,duration参数需要维护一个时间窗口队列,记录每个人脸在过去 N 帧的表情概率。可以用简单的字典加 deque 实现,这里不展开。
注意:YOLOv8 的
results[0].boxes返回的是检测框,如果画面里人多,建议加conf=0.5过滤低置信度框,减少误检。
4. 避坑与排查:课堂场景下最容易翻车的五个问题
4.1 侧脸和低头导致人脸检测漏检
现象:教室后排学生侧脸或低头写字时,YOLOv8n-face 检测不到人脸,行为标签直接丢失。
原因:通用人脸检测模型对正脸训练充分,侧脸和俯仰角超过 30 度时召回率下降。YOLOv8n-face 虽然比 Haar 好很多,但极端角度仍然会漏。
解决:两个方向。一是降低检测阈值,conf=0.3甚至0.25,但会引入误检。二是加一个头部姿态估计模块,用 MediaPipe Face Mesh 或 PnP 算法估计头部角度,当检测不到人脸但姿态估计显示有人时,用姿态结果兜底。我一般用 MediaPipe,CPU 单帧 10ms 左右,可以接受。
4.2 表情分类在暗光环境下准确率暴跌
现象:教室后排光线不足,模型把“中性”误判为“悲伤”,把“开心”误判为“中性”。
原因:FER2013 数据集大多是正面、光照均匀的图片,模型没学过暗光增强。直接推理时,暗光人脸对比度低,卷积特征提取不到有效纹理。
解决:推理前加一个 CLAHE(限制对比度自适应直方图均衡化)预处理。OpenCV 一行代码:
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)) gray = clahe.apply(gray)clipLimit=2.0控制对比度增强幅度,太大噪声也会放大。tileGridSize=(8,8)是分块大小,48x48 的图用 8x8 比较合适。加了 CLAHE 之后,暗光场景准确率能提升 10 个百分点左右。
4.3 行为映射的时间窗口设得太短或太长
现象:窗口设 1 秒,行为标签频繁跳变,老师看着眼花。窗口设 30 秒,学生已经抬头了系统还显示“低头走神”。
原因:表情是瞬时的,行为是持续的。窗口太短没有平滑效果,太长则响应滞后。
解决:我一般设 3 到 5 秒。具体做法是维护一个 deque,长度等于窗口帧数(假设 15 FPS,5 秒就是 75 帧),每帧追加表情概率,取窗口内众数作为当前行为。如果某个行为连续出现超过窗口长度,才输出标签。这样既平滑又不滞后。
4.4 多人场景下 ID 跳变导致行为追踪断裂
现象:画面里学生走动或转头,人脸框 ID 突然变了,系统把同一个人当成两个人,行为记录断裂。
原因:YOLOv8 本身不做追踪,每帧独立检测。没有 ID 关联,就无法做时间窗口平滑。
解决:加一个简单的 IOU 追踪器,或者直接用 Ultralytics 的model.track()接口。model.track(frame, persist=True)会返回带 track_id 的框,同一人 ID 保持一致。注意persist=True要跨帧保持状态,否则每帧重置。
4.5 模型文件太大导致部署困难
现象:训练完发现emotion_cnn.pth有 50MB,加上 YOLO 权重,整个项目超过 100MB,放到边缘设备上存储不够。
原因:保存模型时用了torch.save(model, path)保存整个模型结构,而不是只保存state_dict()。整个模型包含优化器状态和计算图,体积翻倍。
解决:只保存model.state_dict(),加载时先实例化模型再load_state_dict()。另外可以用torch.quantization做动态量化,把 FP32 转成 INT8,模型大小压缩 4 倍,推理速度提升 2 到 3 倍,精度损失不到 2%。量化代码:
quantized_model = torch.quantization.quantize_dynamic( model, {nn.Linear, nn.Conv2d}, dtype=torch.qint8 ) torch.save(quantized_model.state_dict(), 'weights/emotion_cnn_quantized.pth')提示:量化后的模型在 CPU 上推理更快,但 GPU 上不一定。如果你的部署环境是 GPU,先别量化。
5. 进阶技巧:用 ONNX 导出和 TensorRT 加速把帧率翻三倍
前面跑通的流水线,在 CPU 上大概 8 到 10 FPS。如果教室有 30 个学生,每人脸检测加表情分类,帧率会掉到 3 FPS 以下,根本没法实时。这时候需要上推理加速。
我一般用 ONNX Runtime 或 TensorRT。ONNX 是跨平台的,TensorRT 是 NVIDIA 专用但更快。先讲 ONNX 导出,因为大部分毕业设计环境没有 NVIDIA GPU。
导出表情模型到 ONNX:
import torch from model import EmotionCNN model = EmotionCNN(num_classes=7) model.load_state_dict(torch.load('weights/emotion_cnn.pth', map_location='cpu')) model.eval() dummy_input = torch.randn(1, 1, 48, 48) torch.onnx.export( model, dummy_input, 'weights/emotion_cnn.onnx', input_names=['input'], output_names=['output'], dynamic_axes={'input': {0: 'batch'}, 'output': {0: 'batch'}}, opset_version=11 )dynamic_axes让 batch 维度可变,这样一次可以推理多张人脸。opset_version=11兼容性最好。
然后用 ONNX Runtime 推理:
import onnxruntime as ort import numpy as np session = ort.InferenceSession('weights/emotion_cnn.onnx') input_name = session.get_inputs()[0].name def predict_emotion(face_gray): tensor = face_gray.reshape(1, 1, 48, 48).astype(np.float32) / 255.0 outputs = session.run(None, {input_name: tensor}) probs = np.exp(outputs[0]) / np.exp(outputs[0]).sum() return probs[0]ONNX Runtime 在 CPU 上比 PyTorch 快 1.5 到 2 倍,因为做了算子融合和内存优化。如果换成 TensorRT,在 NVIDIA GPU 上能快 3 到 5 倍。
YOLOv8 也支持导出 ONNX:
yolo export model=weights/yolov8n-face.pt format=onnx opset=11 simplify=Truesimplify=True会做图优化,去掉冗余算子。导出后同样用 ONNX Runtime 加载。
实测数据:原始 PyTorch 流水线 CPU 8 FPS,ONNX Runtime 15 FPS,TensorRT FP16 25 FPS。如果教室场景只需要 5 FPS 做行为分析,ONNX 版本完全够用。
还有一个技巧是批处理。YOLO 检测到 N 个人脸后,不要逐个送表情模型,而是拼成一个 batch 一次推理。ONNX 的dynamic_axes已经支持可变 batch,把 N 张 48x48 的图 stack 成(N, 1, 48, 48)一次送入,GPU 利用率更高,CPU 上也能减少循环开销。
最后说一个我踩过的坑:ONNX 导出时如果模型里有Dropout层,记得先model.eval(),否则推理结果会随机。另外torch.onnx.export的opset_version不要低于 11,否则dynamic_axes可能不生效。
这套方案从数据预处理到训练到部署,整条链路我都跑过不止一遍。最深的教训是:别一上来就追求高精度模型,先把流水线跑通,再针对瓶颈优化。我见过太多人卡在训练一个 90% 准确率的模型上,结果部署时发现帧率只有 2 FPS,整个项目延期。先跑通,再加速,最后调精度,这个顺序不能反。希望帮到你。
本文还有配套的精品资源,点击获取