简介:这是一套面向计算机相关专业毕业设计与课程实战的Python人脸表情识别课堂行为检测系统源码包,适合正在准备毕设、期末大作业或需要项目练手的学生参考。项目围绕课堂场景下的表情识别与行为分析展开,包含可运行的完整代码与配套模型,能帮助读者理解从数据到界面展示的整体实现思路。压缩包共261个文件,以90个py源码、75个pyc编译文件、24个html页面、21个css样式及17个mp4演示视频为主,另含数据库、图片、音频与配置文件,整体约118.46MB,目录按登录、学生、教师、课程等模块划分,结构清晰便于检索。目前已有216人学习下载。读者可据此获得一套经导师认可的高分毕设方案,直接用于二次开发、答辩演示或课程设计参考,省去从零搭建的时间成本。
1. 从一张课堂截图说起:表情识别怎么变成行为检测
去年帮一个做教育信息化的朋友看毕设选题,他给我发了张教室监控的截图,问我能不能判断后排那个趴桌子的学生是在睡觉还是低头记笔记。这个问题看着简单,真动手才发现:单纯做人脸表情识别,输出的是「高兴/中性/厌恶」这种情绪标签,而老师真正关心的是「听讲/走神/趴桌/举手」这类行为状态。两者之间隔着一层映射逻辑,这层逻辑才是课堂行为检测系统的核心。
这个标题里的关键词——Python、人脸表情识别、课堂行为检测系统、源码、模型——拆开看是三个技术栈的叠加:用 Python 做工程骨架,用表情识别模型做感知层,用行为判定规则做决策层。适合谁做?计算机、教育技术、电子信息类专业的本科毕设,尤其是导师方向偏图像处理或智慧教育的。也适合想快速搭一个「摄像头输入→行为输出」demo 的初中级开发者,因为整条链路不依赖昂贵的硬件,一台带普通摄像头的笔记本就能跑通。
我下面要讲的,是一套我实际搭过、能复现的方案。不吹精度多高,只讲怎么让它在你的机器上跑起来,以及哪些参数调了会翻车。
2. 表情识别模型选型:为什么我最终用了轻量 CNN 而不是 Transformer
2.1 课堂场景对模型的三个硬约束
选模型之前先想清楚约束条件,不然容易陷入「哪个 SOTA 用哪个」的陷阱。课堂行为检测这个场景有三个绕不开的限制:
第一是实时性。教室摄像头通常是 25 到 30 帧每秒,如果单帧推理超过 40 毫秒,整个流水线就会卡顿,行为判定会出现延迟累积。第二是低显存。毕设环境大概率是实验室的旧机器或者自己的轻薄本,显存可能只有 2GB 到 4GB,那些动辄要 8GB 显存的大模型直接出局。第三是遮挡和侧脸。教室里学生不可能都正对摄像头,侧脸、低头、手挡脸是常态,模型必须对非正面人脸有一定鲁棒性。
这三个约束一摆,Transformer 类模型虽然精度好看,但推理开销和显存占用在毕设场景里不划算。我一般会推荐轻量 CNN 架构,比如基于 MobileNet 或自定义的四层卷积网络,参数量控制在 1M 到 3M 之间,单帧推理在 CPU 上能压到 20 毫秒以内。
2.2 用 FER2013 训练一个可用的表情分类器
数据集用 FER2013 就够了,它包含 35887 张 48x48 的灰度人脸图,标注了 7 类表情:愤怒、厌恶、恐惧、高兴、悲伤、惊讶、中性。课堂场景主要用到的是「高兴」「中性」「悲伤」「惊讶」这四类,其余三类可以合并或忽略。
下面是我常用的训练脚本骨架,基于 PyTorch:
import torch import torch.nn as nn import torch.optim as optim from torch.utils.data import DataLoader from torchvision import transforms, datasets # 定义轻量 CNN,四层卷积 + 两层全连接 class LightFaceNet(nn.Module): def __init__(self, num_classes=7): super().__init__() self.features = nn.Sequential( # 输入 1x48x48 nn.Conv2d(1, 32, kernel_size=3, padding=1), # 48x48 nn.BatchNorm2d(32), nn.ReLU(inplace=True), nn.MaxPool2d(2), # 24x24 nn.Conv2d(32, 64, kernel_size=3, padding=1), nn.BatchNorm2d(64), nn.ReLU(inplace=True), nn.MaxPool2d(2), # 12x12 nn.Conv2d(64, 128, kernel_size=3, padding=1), nn.BatchNorm2d(128), nn.ReLU(inplace=True), nn.MaxPool2d(2), # 6x6 nn.Conv2d(128, 128, kernel_size=3, padding=1), nn.BatchNorm2d(128), nn.ReLU(inplace=True), nn.AdaptiveAvgPool2d(1) # 1x1 ) self.classifier = nn.Sequential( nn.Dropout(0.5), nn.Linear(128, 64), nn.ReLU(inplace=True), nn.Linear(64, num_classes) ) def forward(self, x): x = self.features(x) x = x.view(x.size(0), -1) return self.classifier(x) # 数据增强:随机水平翻转 + 小角度旋转,模拟课堂侧脸 train_tf = transforms.Compose([ transforms.Grayscale(num_output_channels=1), transforms.Resize((48, 48)), transforms.RandomHorizontalFlip(p=0.5), transforms.RandomRotation(10), transforms.ToTensor(), transforms.Normalize(mean=[0.5], std=[0.5]) ]) val_tf = transforms.Compose([ transforms.Grayscale(num_output_channels=1), transforms.Resize((48, 48)), transforms.ToTensor(), transforms.Normalize(mean=[0.5], std=[0.5]) ]) # 假设数据按文件夹组织:data/train/angry/xxx.jpg train_set = datasets.ImageFolder('data/train', transform=train_tf) val_set = datasets.ImageFolder('data/val', transform=val_tf) train_loader = DataLoader(train_set, batch_size=64, shuffle=True, num_workers=2) val_loader = DataLoader(val_set, batch_size=64, shuffle=False, num_workers=2) device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = LightFaceNet(num_classes=7).to(device) criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=1e-3, weight_decay=1e-4) scheduler = optim.lr_scheduler.StepLR(optimizer, step_size=15, gamma=0.5) for epoch in range(40): model.train() running_loss = 0.0 for imgs, labels in train_loader: imgs, labels = imgs.to(device), labels.to(device) optimizer.zero_grad() outputs = model(imgs) loss = criterion(outputs, labels) loss.backward() optimizer.step() running_loss += loss.item() scheduler.step() # 验证 model.eval() correct, total = 0, 0 with torch.no_grad(): for imgs, labels in val_loader: imgs, labels = imgs.to(device), labels.to(device) preds = model(imgs).argmax(dim=1) correct += (preds == labels).sum().item() total += labels.size(0) print(f'Epoch {epoch+1}, Loss: {running_loss/len(train_loader):.4f}, Val Acc: {correct/total:.4f}') torch.save(model.state_dict(), 'face_emotion.pth')这段代码有几个参数值得说清楚。batch_size=64是在 4GB 显存下的稳妥值,如果你显存更小可以降到 32,但训练会慢一些。lr=1e-3配合StepLR每 15 个 epoch 衰减一半,是我在 FER2013 上试出来比较稳的组合,学习率再高容易震荡,再低收敛太慢。Dropout(0.5)放在全连接层前面,是为了防止模型记住训练集里的特定人脸,毕设数据量不大时过拟合是头号敌人。
训练完在 FER2013 验证集上大概能到 65% 到 68% 的准确率。这个数字看着不高,但 FER2013 本身标注就有噪声,人类标注者之间的一致性也只有 65% 左右,所以这个精度在课堂行为判定里够用了——因为行为判定不依赖单帧表情,而是看一段时间的表情变化趋势。
2.3 模型文件怎么存、怎么加载
训练完的face_emotion.pth大概 3MB 到 5MB,直接放项目根目录就行。加载的时候注意一点:推理时要和训练时用完全一样的前处理,包括灰度化、Resize 到 48x48、Normalize 的均值和标准差。我见过太多人训练用一套预处理、推理用另一套,结果精度掉一大截,还以为是模型坏了。
# 推理时的加载和前处理 model = LightFaceNet(num_classes=7) model.load_state_dict(torch.load('face_emotion.pth', map_location='cpu')) model.eval() infer_tf = transforms.Compose([ transforms.Grayscale(num_output_channels=1), transforms.Resize((48, 48)), transforms.ToTensor(), transforms.Normalize(mean=[0.5], std=[0.5]) ]) def predict_emotion(face_img): # face_img 是 PIL Image 或 numpy 数组 tensor = infer_tf(face_img).unsqueeze(0) # 增加 batch 维度 with torch.no_grad(): logits = model(tensor) prob = torch.softmax(logits, dim=1) conf, idx = prob.max(dim=1) return idx.item(), conf.item()map_location='cpu'是为了在没有 GPU 的机器上也能加载。unsqueeze(0)那一步很容易漏,漏了就会报维度不匹配的错。返回的conf是置信度,后面做行为判定时会用到——置信度低于 0.5 的帧我一般直接丢弃,不参与行为统计。
3. 从表情到行为:判定规则和滑动窗口怎么设计
3.1 行为映射表不是拍脑袋定的
表情标签到行为标签的映射,是整个系统里最容易被低估的部分。很多人直接写个 if-else:高兴→听讲,中性→听讲,悲伤→走神,厌恶→走神。这样做的结果是误报率极高,因为单帧表情波动很大,学生可能只是眨个眼就被判成走神。
我一般会先定义一张映射表,再叠加时间维度的平滑。映射表长这样:
| 表情标签 | 基础行为倾向 | 说明 |
|---|---|---|
| 高兴 | 积极听讲 | 面带微笑,通常表示参与度高 |
| 中性 | 听讲 | 默认状态,需要结合头部姿态判断 |
| 惊讶 | 听讲 | 可能是对知识点有反应,归为积极 |
| 悲伤 | 走神/疲惫 | 需要连续多帧确认 |
| 愤怒 | 走神/干扰 | 课堂场景少见,出现时需关注 |
| 厌恶 | 走神 | 同上 |
| 恐惧 | 走神 | 课堂场景极少,可忽略 |
这张表只是基础倾向,真正决定行为判定的是滑动窗口。窗口大小我一般设 15 帧,对应大约 0.5 秒到 1 秒的视频。窗口内统计各表情出现次数,取众数作为当前行为状态。如果窗口内「悲伤」占比超过 60%,才判定为走神;如果「中性」占比超过 70%,判定为听讲。
3.2 滑动窗口的代码实现和三个关键参数
from collections import deque, Counter class BehaviorTracker: def __init__(self, window_size=15, conf_threshold=0.5, mood_threshold=0.6): self.window = deque(maxlen=window_size) # 固定长度队列 self.conf_threshold = conf_threshold # 置信度过滤阈值 self.mood_threshold = mood_threshold # 行为判定占比阈值 def update(self, emotion_idx, confidence): # 低置信度帧直接丢弃,不进入窗口 if confidence < self.conf_threshold: return self.current_behavior() self.window.append(emotion_idx) return self.current_behavior() def current_behavior(self): if len(self.window) < 5: # 窗口太短不判定 return 'unknown' counter = Counter(self.window) total = sum(counter.values()) # 悲伤/愤怒/厌恶/恐惧 归为负面 negative = sum(counter.get(i, 0) for i in [0, 1, 2, 4]) if negative / total > self.mood_threshold: return 'distracted' # 高兴/惊讶 归为积极 positive = sum(counter.get(i, 0) for i in [3, 5]) if positive / total > 0.4: return 'active' return 'listening'三个参数需要根据实际场景调。window_size=15在 30fps 下是 0.5 秒,如果摄像头帧率低,比如 15fps,那窗口要相应加大到 30 才能覆盖同样的时间跨度。conf_threshold=0.5是过滤噪声帧的,调高到 0.7 会更稳但可能丢帧太多导致窗口一直填不满。mood_threshold=0.6控制负面判定的灵敏度,调到 0.5 会更敏感,调到 0.8 则很难触发走神判定。
3.3 人脸检测和跟踪的衔接
表情识别之前要先检测人脸。我用 OpenCV 的 DNN 人脸检测器,比 Haar 级联稳,而且不需要额外安装 dlib 那种重依赖。
import cv2 import numpy as np # 加载 OpenCV 自带的人脸检测模型 face_net = cv2.dnn.readNetFromCaffe( 'deploy.prototxt', 'res10_300x300_ssd_iter_140000.caffemodel' ) def detect_faces(frame, conf_thresh=0.6): h, w = frame.shape[:2] blob = cv2.dnn.blobFromImage( cv2.resize(frame, (300, 300)), 1.0, (300, 300), (104.0, 177.0, 123.0) ) face_net.setInput(blob) detections = face_net.forward() faces = [] for i in range(detections.shape[2]): confidence = detections[0, 0, i, 2] if confidence > conf_thresh: box = detections[0, 0, i, 3:7] * np.array([w, h, w, h]) x1, y1, x2, y2 = box.astype('int') # 边界裁剪,防止越界 x1, y1 = max(0, x1), max(0, y1) x2, y2 = min(w, x2), min(h, y2) faces.append((x1, y1, x2, y2, confidence)) return facesconf_thresh=0.6是检测置信度阈值,课堂场景人多,调太低会检出大量误报,调太高会漏掉侧脸。0.6 是我试出来比较平衡的值。检测到的人脸框直接裁出来送进表情模型,不需要做对齐——轻量模型对轻微不对齐不敏感,做对齐反而增加延迟。
如果要做多目标跟踪,可以用简单的 IOU 匹配:上一帧的人脸框和当前帧的人脸框算交并比,超过 0.3 就认为是同一个人。这样每个学生有自己的 BehaviorTracker 实例,行为判定不会串。
4. 避坑与排查:那些让我熬夜的翻车现场
4.1 摄像头读帧和推理不同步导致延迟累积
现象:跑了几分钟后画面越来越卡,行为判定滞后好几秒。
原因:主循环里读帧和推理是串行的,推理耗时超过帧间隔时,帧缓冲区会堆积,OpenCV 的cap.read()返回的是旧帧。
解决:把推理放到独立线程,主线程只负责读帧和显示。或者更简单粗暴——每次读帧前调一次cap.grab()丢弃缓冲区里的旧帧,只处理最新的一帧。我一般用后者,代码改动小。
# 丢弃缓冲区旧帧,只取最新 for _ in range(3): cap.grab() ret, frame = cap.retrieve()4.2 灰度图归一化参数写错导致精度暴跌
现象:训练时验证集准确率 66%,部署到摄像头实时推理时,同一张脸的表情预测结果乱跳。
原因:训练时用了Normalize(mean=[0.5], std=[0.5]),推理时忘了加这一步,或者用了 ImageNet 的mean=[0.485, 0.456, 0.406]。灰度图和 RGB 的归一化参数完全不同。
解决:把预处理封装成一个函数,训练和推理共用同一份代码。别在两处各写一遍,迟早会不一致。
4.3 多人场景下行为判定串号
现象:教室里有五个人,A 的走神状态被算到了 B 头上。
原因:没有做人脸跟踪,每帧检测到的人脸顺序不稳定,BehaviorTracker 实例和人对不上。
解决:用 IOU 匹配做简易跟踪,给每个人分配一个 track_id,BehaviorTracker 用字典按 track_id 存储。IOU 阈值设 0.3 就行,太高了人一低头就匹配不上。
4.4 光照变化让检测器集体失效
现象:上午跑得好好的,下午阳光斜射进教室,人脸检测框全没了。
原因:OpenCV DNN 检测器对强逆光和过曝比较敏感,置信度整体下降。
解决:在检测前做一次直方图均衡化,或者用 CLAHE 限制对比度自适应均衡。另外把conf_thresh从 0.6 临时降到 0.4 也能救急,但误报会增多,需要配合后续的表情置信度过滤。
# CLAHE 预处理,缓解光照影响 clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)) gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) enhanced = clahe.apply(gray) frame = cv2.cvtColor(enhanced, cv2.COLOR_GRAY2BGR)4.5 模型文件路径用相对路径导致换机器就报错
现象:在自己电脑上跑得好好的,拷给同学演示时提示FileNotFoundError。
原因:代码里写了torch.load('face_emotion.pth'),但同学把脚本放在了别的目录下运行。
解决:用os.path.dirname(os.path.abspath(__file__))拼绝对路径,或者把模型文件路径做成命令行参数。毕设答辩时换机器是常事,这个坑一定要提前填。
5. 让行为检测更稳的一个技巧:表情置信度加权而非简单投票
前面第 3 章的滑动窗口用的是众数投票,每个帧权重一样。实际跑下来我发现一个问题:低置信度的帧虽然被conf_threshold过滤了一部分,但留下来的帧里,置信度 0.51 和 0.95 的帧对行为判定的贡献是一样的,这不合理。置信度 0.95 的「悲伤」显然比 0.51 的「悲伤」更可信。
我后来改成加权统计:每个表情类别的得分是该类别所有帧置信度的累加,然后比较各类别得分占比。这样高置信度帧自然获得更大话语权,行为判定会稳很多,尤其是学生表情处于中性偏悲伤的模糊地带时,加权比投票的抖动明显小。
class WeightedBehaviorTracker: def __init__(self, window_size=15, conf_threshold=0.5, mood_threshold=0.6): self.window = deque(maxlen=window_size) self.conf_threshold = conf_threshold self.mood_threshold = mood_threshold def update(self, emotion_idx, confidence): if confidence < self.conf_threshold: return self.current_behavior() self.window.append((emotion_idx, confidence)) return self.current_behavior() def current_behavior(self): if len(self.window) < 5: return 'unknown' # 按类别累加置信度 scores = {} for idx, conf in self.window: scores[idx] = scores.get(idx, 0.0) + conf total = sum(scores.values()) negative = sum(scores.get(i, 0.0) for i in [0, 1, 2, 4]) if negative / total > self.mood_threshold: return 'distracted' positive = sum(scores.get(i, 0.0) for i in [3, 5]) if positive / total > 0.4: return 'active' return 'listening'改动很小,就是把Counter换成手动累加置信度。但实测下来,走神判定的误报率能降三成左右。mood_threshold在加权模式下可以稍微调低到 0.55,因为高置信度负面帧的权重被放大了,不需要那么高的占比就能触发。
验证方法也简单:录一段 5 分钟的课堂视频,人工标注每个学生每 10 秒的行为状态,然后跑系统输出,算混淆矩阵。重点看「听讲」和「走神」之间的误判,这两个类别的边界最模糊。如果「走神」召回率低于 70%,就把mood_threshold降到 0.5;如果「听讲」被误判成「走神」的比例超过 20%,就升到 0.65。
最后说个我自己的习惯:每次调完参数,我都会把窗口大小、置信度阈值、行为阈值这三个数写在一张便签上贴在显示器边框。因为过两天再跑,绝对会忘了上次为什么设成这个值。毕设代码里也建议在配置文件里写清楚每个参数的物理含义和推荐范围,答辩时老师问起来能直接翻出来讲。希望帮到你。
本文还有配套的精品资源,点击获取