简介基于Python开发的人脸表情识别课堂行为检测系统是一套完整的高分毕业设计源码与模型资源面向计算机相关专业学生及人脸识别方向开发者。项目经过导师指导代码可直接运行适合用于毕业设计、课程实训或课题拓展。压缩包共261个文件大小约118.46MB核心为90个Python源码文件与75个编译后的pyc文件同时包含24个HTML页面、21个CSS样式、17个MP4演示视频以及XML配置、JPG图片、DB数据库、TXT说明等辅助内容。其中MP4演示视频可快速展示系统运行效果HTML/CSS覆盖学生、教师、课程等多角色管理界面README等文档提供部署与使用说明便于按目录模块快速梳理代码逻辑。目前已有582人学习系统完整度高覆盖模型预测、行为检测、数据存储与界面交互全流程可作为课堂行为分析项目的重要参考也便于在此基础上做二次开发与功能扩展。1. 人脸表情识别的课堂行为检测一套毕设源码到底在解决什么问题把摄像头架在讲台一角画面里二十多个学生有人低头记笔记有人托腮放空有人盯着屏幕偷笑。传统点名抽查只能看到“人来了”看不到“课堂状态”。人脸表情识别的课堂行为检测就是用 Python 把这一路视觉信息拆成三层先用检测模型把人脸定位出来再用分类模型把人脸表情映射成七类情绪最后把情绪按时间窗口折算成专注度、参与度这类行为指标。这套东西既能当作结课演示的亮点也能真的跑在教室机器上输出一节课的统计报表。适合正在做毕设、课设的同学也适合想快速验证 CV 落地流程的开发者。本文从模型选型讲到答辩现场的兜底技巧保证照着做能复现出一套可演示、可解释、不卡死的系统。2. 架构与选型为什么是 OpenCV DNN 轻量 CNN PyQt5毕设系统里最怕的不是算法难而是每一层都选错工具最后摄像头一开就卡、模型一加载就崩。常见做法是把系统拆成四个模块人脸检测、表情分类、行为判定、界面展示。这四个模块各用一套相对成熟的方案彼此之间通过内存队列解耦代码才不至于缠成一团。2.1 人脸检测用 OpenCV DNN 而不是 Haar很多人一上来就写cv2.CascadeClassifier(haarcascade_frontalface_default.xml)因为它代码最短。Haar 级联在侧脸、低头、光线暗的场景下漏检率高得离谱每帧漏掉四五个人脸后续的表情识别完全无从谈起。课堂场景恰恰是“低头记笔记”的高发场景Haar 的框经常掉在头顶或者干脆消失。更稳的替代方案是 OpenCV 自带的 DNN 人脸检测器它基于 SSD 架构权重文件是res10_300x300_ssd_iter_140000.caffemodel在 CPU 上跑一帧大约 30 到 50 毫秒完全够实时。import cv2 net cv2.dnn.readNetFromCaffe( deploy.prototxt, res10_300x300_ssd_iter_140000.caffemodel ) def detect_faces(frame, conf_threshold0.5): h, w frame.shape[:2] blob cv2.dnn.blobFromImage(frame, 1.0, (300, 300), (104.0, 177.0, 123.0)) net.setInput(blob) detections net.forward() boxes [] for i in range(detections.shape[2]): confidence detections[0, 0, i, 2] if confidence conf_threshold: box detections[0, 0, i, 3:7] * [w, h, w, h] x1, y1, x2, y2 box.astype(int) boxes.append((x1, y1, x2, y2, confidence)) return boxesblobFromImage中(104.0, 177.0, 123.0)是模型训练时的均值减除不能改成(0,0,0)否则检测会变差。conf_threshold是第一个必调参数课堂场景建议 0.5倒影、海报上的人像都会被过滤掉如果发现漏检严重降到 0.3 试试代价是偶尔多画一个假框。2.2 表情分类用轻量 CNN模型对比与选择表情识别属于图像分类任务最直接的方案是拿 ResNet 或 VGG 这类重模型来迁移学习。但课堂系统要在普通笔记本上跑实时摄像头模型推理一次不能超过 30 毫秒重模型在 CPU 上根本扛不住。业界在 FER2013 数据集上常用的是 Mini-Xception、MobileNet 或自定义 CNN结构上都是“卷积 深度可分离卷积 全局平均池化 全连接”。分类目标固定angry、disgust、fear、happy、sad、surprise、neutral 七类。模型参数量级FER2013 准确率范围CPU 单帧耗时自定义 4 层 CNN约 1M60% 左右5-10msMini-Xception约 2M66%-71%10-20msMobileNetV2约 3.5M65%-70%15-25ms毕设源码里最常见的是自定义 CNN 或 Mini-Xception因为这两者能在“训练时长”和“识别精度”之间取得平衡。如果完全没有训练条件直接加载训练好的emotion_model.h5跑推理即可如果想让结果更像自己的后文第 6 章有微调方案。注意一点FER2013 的标签人为主观性很强测试集上 60% 左右的准确率已经是常见水平别看到 60% 就以为模型坏了。2.3 行为判定层表情时序 头部姿态的取舍表情分类输出的单帧标签只是“这个瞬间的脸部状态”直接拿它评价课堂行为会产生大量误判比如学生低头看课本的瞬间摄像头拍到的是头顶表情分类器会随机输出一个标签。因此行为判定必须引入时间窗口以 20 到 30 秒为一段统计各表情出现的帧数占比。常见映射规则是neutral 和 happy 记为“专注”sad、angry、surprise、fear 记为“非专注”disgust 由于样本太少且区分度差通常不参与统计。头部姿态估计俯仰角、偏转角可以更精确地补充“是否在看屏幕”但需要额外的 landmarks 模型比如 MediaPipe FaceMesh 或 OpenCV 的facemark这会增加约 10 毫秒的推理时间。毕设足够用表情统计代替头部姿态因为表情统计已经能解释“为什么走神”而头部姿态只能解释“看没看”。追求演示稳定性的做法是先做表情统计把头部姿态作为可选加分项。2.4 界面层PyQt5 如何不卡摄像头界面要显示实时视频、人脸框、表情标签、专注度曲线很多人直接用while cap.read():循环再把cv2.imshow嵌进 PyQt5 的定时器里结果窗口一拖拽或者系统弹窗摄像头就黑屏。问题根源在于videoCapture 的读取和图像变换都不能放在 UI 线程。常见做法是开三个线程采集线程只负责cap.read()推理线程处理人脸检测和表情分类UI 线程只负责贴图。三个线程之间用queue.Queue传数据并设置队列最大长度防止摄像头线程积压旧帧。import queue import threading frame_queue queue.Queue(maxsize2) result_queue queue.Queue(maxsize1) def capture_loop(cap): while True: ok, frame cap.read() if ok: if frame_queue.full(): frame_queue.get_nowait() frame_queue.put(frame) def inference_loop(): while True: frame frame_queue.get() boxes, labels run_inference(frame) result_queue.put((frame, boxes, labels)) def ui_refresh(timer): result result_queue.get() show_frame(result)这里的关键是maxsize1UI 线程消费慢时直接丢弃旧结果永远只显示最新一帧。源码里如果看到cv2.VideoCapture和QTimer恢复上线混杂在一起基本可以断定会出现卡顿改造成上面的队列模式即可。3. 跑通最小系统人脸检测叠加表情标签的实时推理代码拿到源码包后第一步不是读全部代码而是先跑通一个最小闭环摄像头实时画面中每个人脸上出现矩形框和表情文字。这一步能验证环境、模型文件、依赖库三者是否匹配。3.1 环境准备与依赖安装毕设源码最常见的坑是 Python 版本和依赖库不匹配。实测稳定组合是 Python 3.8 到 3.10tensorflow-cpu2.10.0opencv-python4.8.0.74numpy1.24.3PyQt55.15.9。TensorFlow 2.10 是最后一个官方支持 Windows 原生 pip 安装的版本别盲目装最新版否则可能在load_model阶段就碰到算子不兼容的问题。python 安装教程里反复提到的“勾选 Add to PATH”在这套项目里同样适用否则命令行敲python会找不到解释器只能重装。# 新建虚拟环境避免污染全局 python 环境 python -m venv emotion_env emotion_env\Scripts\activate # 安装依赖按顺序装避免 pip 自动升级 tensorflow 版本 pip install tensorflow-cpu2.10.0 pip install opencv-python4.8.0.74 pip install numpy1.24.3 pip install PyQt55.15.9如果机器没有独显tensorflow-cpu就够了有 CUDA 环境再换tensorflow2.10.0但显卡驱动的坑比 CPU 版本多得多。源码包里如果有requirements.txt先打开看它锁没锁版本很多模板工程写的是tensorflow2.0这种约束等于没写请按上面的组合手动锁定。3.2 推理代码 face_expression_demo.py环境就绪后把下面的脚本保存成face_expression_demo.py放在模型文件emotion_model.h5同目录下运行。它实现人脸检测、表情识别、绘制结果的完整流程。import cv2 import numpy as np from tensorflow import keras EMOTIONS [angry, disgust, fear, happy, sad, surprise, neutral] face_net cv2.dnn.readNetFromCaffe( deploy.prototxt, res10_300x300_ssd_iter_140000.caffemodel ) emotion_model keras.models.load_model(emotion_model.h5) def preprocess_face(face_img, target_size48): gray cv2.cvtColor(face_img, cv2.COLOR_BGR2GRAY) resized cv2.resize(gray, (target_size, target_size)) normalized resized.astype(float32) / 255.0 # 模型输入形状(batch, height, width, channels)灰度图单通道 sample np.expand_dims(np.expand_dims(normalized, -1), 0) return sample cap cv2.VideoCapture(0) if not cap.isOpened(): raise RuntimeError(摄像头打开失败先确认没有其他程序占用) while True: ok, frame cap.read() if not ok: break h, w frame.shape[:2] blob cv2.dnn.blobFromImage(frame, 1.0, (300, 300), (104.0, 177.0, 123.0)) face_net.setInput(blob) detections face_net.forward() for i in range(detections.shape[2]): confidence detections[0, 0, i, 2] if confidence 0.5: continue box detections[0, 0, i, 3:7] * [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) if x2 - x1 20 or y2 - y1 20: continue face frame[y1:y2, x1:x2] input_tensor preprocess_face(face) preds emotion_model.predict(input_tensor, verbose0)[0] emotion EMOTIONS[int(np.argmax(preds))] score float(np.max(preds)) label f{emotion}:{score:.2f} cv2.rectangle(frame, (x1, y1), (x2, y2), (0, 255, 0), 2) cv2.putText(frame, label, (x1, y1 - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 0), 2) cv2.imshow(Emotion In Classroom, frame) if cv2.waitKey(1) 0xFF ord(q): break cap.release() cv2.destroyAllWindows()preprocess_face里的target_size48不是随便写的FER2013 数据集原始分辨率就是 48x48 灰度图模型输入层如果定义的是(48, 48, 1)传 64x64 或 RGB 图进去大概率报 shape 错误。另一个细节是emotion_model.predict(input_tensor, verbose0)verbose0省去推理日志刷屏每次预测只处理一个样本CPU 上大约 8 到 12 毫秒。score是模型对当前标签的置信度在 0.4 以下时建议显示为 unknown因为低置信度往往对应遮挡或侧脸。3.3 关键参数速查参数位置建议值作用conf_threshold人脸检测0.5低于此值的检测框丢弃降为 0.3 可提升召回率target_size表情预处理48必须与模型输入一致否则 predict 报错min_face_size检测后过滤20x20过滤距离镜头太远产生的误检score置信度表情输出0.4低于此值显示 unknown防止乱打标签waitKey(1)控制循环1 毫秒可调 5 毫秒减少 CPU 占用但帧率会下降这几个参数里最能影响演示效果的是conf_threshold和表情置信度阈值。教室后排人脸只有 30 像素宽0.5 的置信度可能全被滤掉此时画面上只有前两排有框台下看起来就像系统没识别到人。把阈值降到 0.3代价是海报上的人脸也会被框出来用min_face_size过滤即可。3.4 先别着急上摄像头用图片验证摄像头一开光线、角度、运动模糊全都叠加进来出了问题很难定位是人脸检测还是表情模型。更稳的做法是先用课堂照片或者在线图片验证。准备一张多人课堂图片test_classroom.jpg运行下面这段命令输出结果保存为标注图片。python -c import cv2 frame cv2.imread(test_classroom.jpg) # 复用 face_expression_demo.py 中的检测与识别逻辑 # 只处理单帧结果写入注解后的文件 cv2.imwrite(test_result.jpg, frame) 单张图片能稳定框出所有正脸并给出合理表情标签后再切换摄像头。源码里如果包含test_images/目录直接用它回归测试这是确认模型文件有没有损坏最快的方式。4. 行为判定与统计专注度窗口、举手检测和报表导出表情识别只能回答“这张脸是什么情绪”课堂行为检测系统要回答的是“这节课学生是否专注、举手几次、整体状态如何”。这部分是把单帧结果转化为统计结论的关键。4.1 基于滑动窗口的专注度判定算法统计逻辑不能做成“只要有一帧 happy 就记专注”要用一个固定长度的滑动窗口兜住最近 30 秒的预测结果窗口内专帧占比超过阈值才判定当前时刻为专注。下面这段代码实现了窗口队列可以直接嵌入实时推理线程。import collections class AttentionTracker: def __init__(self, window_seconds30, fps25, focus_ratio0.6, positive_labels(neutral, happy)): self.window_size window_seconds * fps self.focus_ratio focus_ratio self.positive_labels positive_labels self.queue collections.deque(maxlenself.window_size) def update(self, emotion_label): is_focus 1 if emotion_label in self.positive_labels else 0 self.queue.append(is_focus) property def focus_rate(self): if not self.queue: return 0.0 return sum(self.queue) / len(self.queue) property def is_focused(self): return self.focus_rate self.focus_ratiowindow_seconds30表示只看最近 30 秒fps25对应摄像头常见的 25 帧率maxlen自动丢弃最旧的数据。focus_ratio0.6是判断“当前是否专注”的阈值调低了学生偶尔低头也会被判专注调高了稍有动作就标成走神。实测课堂场景 0.6 到 0.7 之间比较合理。如果摄像头实际帧率只有 15记得把fps改成 15否则窗口覆盖的真实时间会缩短一半。positive_labels建议把 happy 算进去因为课堂互动时学生笑起来是正常参与行为不算走神。4.2 举手检测的两种实现路径迟到的提问抬头低头、点头可以被表情统计覆盖举手检测属于动作识别需要单独处理。源码里常见的做法有两种。第一种是基于肤色检测将 ROI 转 HSV 空间用inRange提取肤色区域再计算上下两个区域肤色面积差判断是否举手。第二种是基于姿态关键点用 MediaPipe Pose 或 YOLOv8 Pose识别手腕关节点坐标当手腕高于肩膀且手肘高于肩线时判定为举手。肤色检测对背景颜色敏感教室里白色墙壁容易把整片区域误判为肤色姿态关键点更稳但要额外安装mediapipe或ultralytics。课堂系统建议直接嵌 MediaPipe Pose它的pose_landmarks在 CPU 上跑大约 10 到 20 毫秒。关键点映射规则左腕landmark[15]左肩landmark[11]右腕landmark[16]右肩landmark[12]判断wrist_y shoulder_y且wrist_y elbow_y - 20即为举手。阈值 20 像素是防抖用的防止手在肩膀高度微微晃动就误判。# 伪代码片段单帧举手判定基于 MediaPipe Pose import mediapipe as mp mp_pose mp.solutions.pose pose mp_pose.Pose(static_image_modeFalse, min_detection_confidence0.5) def detect_raise_hand(landmarks, img_h, threshold_px20): left_wrist landmarks[mp_pose.PoseLandmark.LEFT_WRIST] left_shoulder landmarks[mp_pose.PoseLandmark.LEFT_SHOULDER] left_elbow landmarks[mp_pose.PoseLandmark.LEFT_ELBOW] right_wrist landmarks[mp_pose.PoseLandmark.RIGHT_WRIST] right_shoulder landmarks[mp_pose.PoseLandmark.RIGHT_SHOULDER] right_elbow landmarks[mp_pose.PoseLandmark.RIGHT_ELBOW] left_raised (left_wrist.y * img_h) (left_shoulder.y * img_h) - threshold_px right_raised (right_wrist.y * img_h) (right_shoulder.y * img_h) - threshold_px return left_raised or right_raisedthreshold_px20是按图像高度归一化后的像素数视频分辨率 720p 和 1080p 下这个阈值都适用因为已经乘了img_h。如果还是抖动把阈值调到 40。要注意举手判定是全屏的不是只对某个人脸框因此多人场景下还需要用人脸框坐标过滤取“人脸框中心点”附近的肢体关键点。4.3 把行为结果写进 CSV 报表系统只显示实时画面还不够毕设答辩需要成果物。实时专注度曲线、举手次数、表情分布统计最后都要落在一张 CSV 表上作为“课堂行为报告”的附件。每秒钟写一行记录人脸 ID、表情标签、专注状态、是否举手。import csv import time from collections import defaultdict class BehaviorRecorder: def __init__(self, output_pathbehavior_log.csv): self.output_path output_path self.buffer defaultdict(list) def log(self, face_id, emotion, focus, hand_raised): row { timestamp: time.strftime(%H:%M:%S), face_id: face_id, emotion: emotion, focus: focus, hand_raised: hand_raised, } with open(self.output_path, a, newline, encodingutf-8) as f: writer csv.DictWriter(f, fieldnameslist(row.keys())) if f.tell() 0: writer.writeheader() writer.writerow(row)这种“逐行打开追加写”的方式在每秒钟写入频率下没有性能问题比一次性攒内存再 flush 更抗断电程序中断也能保留已写出的记录。注意encodingutf-8否则用 Excel 打开会乱码。配合上面的AttentionTracker可以按face_id聚合出每个学生的专注时长、举手次数导出成summary.csv这就是课堂行为检测系统的交付物之一。4.4 多人场景的 ID 一致性维护表情统计和举手统计都依赖一个前提同一个人的框在不同帧里被认成同一个face_id。如果每帧检测都重新编号专注度统计就会混掉。最简单可靠的做法是用 IOU交并比跟踪计算当前帧每个人脸框和上一帧所有人脸框的 IOU最大 IOU 超过 0.3 则继承上一帧的 ID否则分配新 ID。帧脸 A 坐标脸 B 坐标继承 ID第 10 帧(100, 80, 150, 130)(300, 50, 360, 120)A1, B2第 11 帧(101, 81, 151, 131)(350, 52, 410, 122)最大 IOU 匹配IOU 阈值 0.3 比检测置信度阈值低不少因为相邻帧人脸框通常移动很小IOU 一般都大于 0.7低于 0.3 就说明人脸可能被遮挡或离开画面分配新 ID 是合理行为。不要用 OpenCV 自带的 tracker 或者深度学习重识别课堂场景会过拟合且重识别模型的推理时间不可控。5. 避坑清单模型加载、慢镜头和答辩现场的 5 个翻车点毕设系统每次翻车大概率都集中在环境版本、线程阻塞、模型输入不一致这三类问题上。这里直接把常见的坑列出来每一条都写清楚现象、原因和解决方式能省下一个星期的调试时间。5.1model.load_weights报错h5 文件与 keras 版本不兼容现象运行源码时load_model抛出Unknown layer: Functional或AttributeError: str object has no attribute decode。原因模型训练时的 Keras 版本和你当前环境的 TensorFlow Keras 版本不一致。常见的是训练时代码用的是keras 2.13你环境中是tensorflow 2.10或者反过来。解决卸载干净重装固定tensorflow-cpu2.10.0同时安装keras2.13中的适配版本。不要尝试用pip install -U keras升级这会破坏与 TensorFlow 的绑定关系。如果模型文件是 h5直接用keras.models.load_model加载如果报 decode 错误说明 h5 用的是 Python 2 序列化需要用h5py打补丁的方式加载但这个坑在 2.x 版本已经很少见。5.2 摄像头画面卡成幻灯片现象视频窗口能打开但帧率只有 5 到 8 FPS操作窗口时直接卡死。原因cv2.imshow和模型推理都在主线程执行模型推理一次 30 毫秒30 帧就是 900 毫秒每一帧都在阻塞 UI 事件循环。解决回到第 2.4 节的队列三线程模式。核心是result_queue的maxsize1旧的推理结果被丢弃保证 UI 永远有最新帧可显示。顺带检查cap.read()是否在独立线程摄像头读取和推理同时跑时read()的延迟会自己恢复但主线程可以不吃这个延迟。5.3 表情一直识别成 neutral现象无论学生做什么表情输出永远是 neutral。原因FER2013 数据集中 neutral 样本占比太高模型训练时学到捷径只要特征不强就倾向 neutral。另外 48x48 灰度图的信噪比低中性表情本身特征不明显。解决调整分类阈值不要用argmax改成当max(prob) 0.5时输出 unknown避免“不太像的表情都归到 neutral”。更好的做法是单独收集学生人脸数据用第 6 章的迁移学习微调把 neutral 的先验概率降下来。5.4 多人场景人脸框跳来跳去现象两个人并排坐时一个人的脸框偶尔跳到另一个人脸上专注度统计随之混乱。原因相邻帧没有做 IOU 追踪检测器对遮挡和角度变化敏感同一张脸被识别成两个检测框。解决用第 4.4 节的 IOU 追踪建立face_id。另外检测框的坐标要做一次平滑new_x 0.6 * current_x 0.4 * prev_x能有效减少摆动幅度。检测阈值也别太低0.5 以上否则一个脸会被重复检测成三个框。5.5 答辩现场摄像头突然失灵现象演示时打开程序cap.isOpened()返回 False或者画面全黑系里讲台的光线是逆光人脸全是黑影。原因笔记本摄像头被其他软件占用如钉钉会议、腾讯会议残留在后台或者采光条件极端。解决代码里强制释放摄像头并等待一秒重试如果失败则自动从demo_video.mp4读取视频流。准备一个预录的课堂片段作为 fallback是最稳妥的后悔药。逆光问题把cv2.convertScaleAbs(frame, alpha1.6, beta30)放在进入检测前能提亮暗部人脸这一步对人脸检测置信度提升非常明显。6. 进阶打法迁移学习微调模型与演示防翻车技巧如果系统已经能跑通下一步不是堆功能而是把识别精度和演示稳定性做扎实。对毕设系统来说提升表情识别精度性价比最高的手段是迁移学习微调而不是重新训练。6.1 冻结特征层替换分类头拿到预训练模型后加载模型冻结除最后两层之外的所有层只训练分类器和最后一层卷积。用你自己采集的 500 张课堂人脸表情图做微调。具体做法是model.load_model(emotion_model.h5)然后逐层设置layer.trainable False把最后一层Dense(7)换成Dense(7, softmax)编译并用ImageDataGenerator做数据增强后训练 5 个 epoch。微调后 neutral 的误判率通常能明显下降因为课堂上学生的中性表情和 FER2013 里的海报中性表情差异很大。自己采集数据时每类表情至少 80 张宁缺毋滥避免类别不平衡继续放大。6.2 模型瘦身h5 转 ONNX 提速如果追求演示帧率把 h5 模型转换成 ONNX在 CPU 上用 OpenCV 的cv2.dnn.readNetFromONNX加载。这样能摆脱 TensorFlow 在 CPU 上的初始化延迟模型加载时间从 5 秒降到 1 秒以内。转换工具用tf2onnx.convert需要先把模型model.compile后再转换否则输出节点名字对不上。转换后验证输入输出形状尤其注意灰度图(1, 48, 48, 1)的顺序是 NHWCONNX 模型通常也是 NHWC不需要额外转置。如果转换后精度暴跌检查预处理里的mean和std是否和训练时一致这个细节最容易出问题。6.3 演示现场的三件准备固定镜头、补光、预录视频走完整个项目后我最深的教训是毕设系统的功能完整度永远没有“现场演示顺利”重要。答辩当天有三件事比调参更值得提前做。第一把摄像头放到一个固定高度不要手持避免对焦反复拉风箱第二面向窗口时关掉逆光或者用白色 A4 纸垫高反射补光让脸部和背景亮度差小于一倍第三必须在代码里收藏好 fallback 视频方案用cap cv2.VideoCapture(demo.mp4)替换摄像头输入一秒钟就能切换。我在自己的课设里经历过摄像头驱动被系统更新打崩、现场只剩一张 USB 采集卡又没驱动的窘境后来把所有演示都改成预录视频加实时截图对照反而省出更多时间回答提问。逐条梳理完这套系统的结构、代码和坑点希望帮你在毕业设计上少走一段弯路。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?