ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Python人脸表情识别课堂行为检测系统源码模型

Python人脸表情识别课堂行为检测系统源码模型 简介这是一套面向计算机相关专业毕业设计与课程实战的Python人脸表情识别课堂行为检测系统源码包适合正在准备毕设、期末大作业或需要项目练手的学生参考。项目围绕课堂场景下的表情识别与行为分析展开包含可运行的完整代码与配套模型能帮助读者理解从数据到界面展示的整体实现思路。压缩包共261个文件以90个py源码、75个pyc编译文件、24个html页面、21个css样式及17个mp4演示视频为主另含数据库、图片、音频与配置文件整体约118.46MB目录按登录、学生、教师、课程等模块划分结构清晰便于检索。目前已有216人学习下载。读者可据此获得一套经导师认可的高分毕设方案直接用于二次开发、答辩演示或课程设计参考省去从零搭建的时间成本。1. 从一张课堂截图说起表情识别怎么变成行为检测去年帮一个做教育信息化的朋友看毕设选题他给我发了张教室监控的截图问我能不能判断后排那个趴桌子的学生是在睡觉还是低头记笔记。这个问题看着简单真动手才发现单纯做人脸表情识别输出的是「高兴/中性/厌恶」这种情绪标签而老师真正关心的是「听讲/走神/趴桌/举手」这类行为状态。两者之间隔着一层映射逻辑这层逻辑才是课堂行为检测系统的核心。这个标题里的关键词——Python、人脸表情识别、课堂行为检测系统、源码、模型——拆开看是三个技术栈的叠加用 Python 做工程骨架用表情识别模型做感知层用行为判定规则做决策层。适合谁做计算机、教育技术、电子信息类专业的本科毕设尤其是导师方向偏图像处理或智慧教育的。也适合想快速搭一个「摄像头输入→行为输出」demo 的初中级开发者因为整条链路不依赖昂贵的硬件一台带普通摄像头的笔记本就能跑通。我下面要讲的是一套我实际搭过、能复现的方案。不吹精度多高只讲怎么让它在你的机器上跑起来以及哪些参数调了会翻车。2. 表情识别模型选型为什么我最终用了轻量 CNN 而不是 Transformer2.1 课堂场景对模型的三个硬约束选模型之前先想清楚约束条件不然容易陷入「哪个 SOTA 用哪个」的陷阱。课堂行为检测这个场景有三个绕不开的限制第一是实时性。教室摄像头通常是 25 到 30 帧每秒如果单帧推理超过 40 毫秒整个流水线就会卡顿行为判定会出现延迟累积。第二是低显存。毕设环境大概率是实验室的旧机器或者自己的轻薄本显存可能只有 2GB 到 4GB那些动辄要 8GB 显存的大模型直接出局。第三是遮挡和侧脸。教室里学生不可能都正对摄像头侧脸、低头、手挡脸是常态模型必须对非正面人脸有一定鲁棒性。这三个约束一摆Transformer 类模型虽然精度好看但推理开销和显存占用在毕设场景里不划算。我一般会推荐轻量 CNN 架构比如基于 MobileNet 或自定义的四层卷积网络参数量控制在 1M 到 3M 之间单帧推理在 CPU 上能压到 20 毫秒以内。2.2 用 FER2013 训练一个可用的表情分类器数据集用 FER2013 就够了它包含 35887 张 48x48 的灰度人脸图标注了 7 类表情愤怒、厌恶、恐惧、高兴、悲伤、惊讶、中性。课堂场景主要用到的是「高兴」「中性」「悲伤」「惊讶」这四类其余三类可以合并或忽略。下面是我常用的训练脚本骨架基于 PyTorchimport 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_classes7): super().__init__() self.features nn.Sequential( # 输入 1x48x48 nn.Conv2d(1, 32, kernel_size3, padding1), # 48x48 nn.BatchNorm2d(32), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), # 24x24 nn.Conv2d(32, 64, kernel_size3, padding1), nn.BatchNorm2d(64), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), # 12x12 nn.Conv2d(64, 128, kernel_size3, padding1), nn.BatchNorm2d(128), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), # 6x6 nn.Conv2d(128, 128, kernel_size3, padding1), nn.BatchNorm2d(128), nn.ReLU(inplaceTrue), nn.AdaptiveAvgPool2d(1) # 1x1 ) self.classifier nn.Sequential( nn.Dropout(0.5), nn.Linear(128, 64), nn.ReLU(inplaceTrue), 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_channels1), transforms.Resize((48, 48)), transforms.RandomHorizontalFlip(p0.5), transforms.RandomRotation(10), transforms.ToTensor(), transforms.Normalize(mean[0.5], std[0.5]) ]) val_tf transforms.Compose([ transforms.Grayscale(num_output_channels1), 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, transformtrain_tf) val_set datasets.ImageFolder(data/val, transformval_tf) train_loader DataLoader(train_set, batch_size64, shuffleTrue, num_workers2) val_loader DataLoader(val_set, batch_size64, shuffleFalse, num_workers2) device torch.device(cuda if torch.cuda.is_available() else cpu) model LightFaceNet(num_classes7).to(device) criterion nn.CrossEntropyLoss() optimizer optim.Adam(model.parameters(), lr1e-3, weight_decay1e-4) scheduler optim.lr_scheduler.StepLR(optimizer, step_size15, gamma0.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(dim1) correct (preds labels).sum().item() total labels.size(0) print(fEpoch {epoch1}, Loss: {running_loss/len(train_loader):.4f}, Val Acc: {correct/total:.4f}) torch.save(model.state_dict(), face_emotion.pth)这段代码有几个参数值得说清楚。batch_size64是在 4GB 显存下的稳妥值如果你显存更小可以降到 32但训练会慢一些。lr1e-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_classes7) model.load_state_dict(torch.load(face_emotion.pth, map_locationcpu)) model.eval() infer_tf transforms.Compose([ transforms.Grayscale(num_output_channels1), 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, dim1) conf, idx prob.max(dim1) return idx.item(), conf.item()map_locationcpu是为了在没有 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_size15, conf_threshold0.5, mood_threshold0.6): self.window deque(maxlenwindow_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_size15在 30fps 下是 0.5 秒如果摄像头帧率低比如 15fps那窗口要相应加大到 30 才能覆盖同样的时间跨度。conf_threshold0.5是过滤噪声帧的调高到 0.7 会更稳但可能丢帧太多导致窗口一直填不满。mood_threshold0.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_thresh0.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_thresh0.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_idBehaviorTracker 用字典按 track_id 存储。IOU 阈值设 0.3 就行太高了人一低头就匹配不上。4.4 光照变化让检测器集体失效现象上午跑得好好的下午阳光斜射进教室人脸检测框全没了。原因OpenCV DNN 检测器对强逆光和过曝比较敏感置信度整体下降。解决在检测前做一次直方图均衡化或者用 CLAHE 限制对比度自适应均衡。另外把conf_thresh从 0.6 临时降到 0.4 也能救急但误报会增多需要配合后续的表情置信度过滤。# CLAHE 预处理缓解光照影响 clahe cv2.createCLAHE(clipLimit2.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_size15, conf_threshold0.5, mood_threshold0.6): self.window deque(maxlenwindow_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。最后说个我自己的习惯每次调完参数我都会把窗口大小、置信度阈值、行为阈值这三个数写在一张便签上贴在显示器边框。因为过两天再跑绝对会忘了上次为什么设成这个值。毕设代码里也建议在配置文件里写清楚每个参数的物理含义和推荐范围答辩时老师问起来能直接翻出来讲。希望帮到你。本文还有配套的精品资源点击获取
返回列表