简介:本资源是一套面向计算机专业本科生的高分毕业设计项目——基于Python的人脸表情识别课堂行为检测系统,专为毕设答辩、课程设计及期末大作业打造,解决课堂教学中学生专注度量化分析的实际需求,适合零基础学习者快速上手与二次开发。压缩包共261个文件,含90个核心Python源码(涵盖数据预处理、CNN模型训练、实时表情识别与行为状态判定逻辑)、24个HTML与21个CSS前端页面(支持教师端管理、学生端展示及课堂数据可视化),以及17个MP4演示视频、6个JPG示例图和1个H5模型文件,整体118.46MB,结构清晰、模块解耦。目前已有216人学习下载,资源经导师指导并获99分高分评价,包含完整可运行代码、详细README说明、数据库文件及前后端联调方案,特别适合作为实战范例深入理解OpenCV+Keras/PyTorch在教育AI场景中的落地路径。
1. 这不是个“识别笑脸”的玩具项目:它能自动统计一整节课里32个学生中谁在走神、谁在皱眉、谁在打哈欠,还能生成带时间戳的行为热力图——专为毕设答辩卡点优化过UI和响应速度
你可能以为人脸表情识别就是调个OpenCV加个预训练模型,拍张照说“开心/悲伤”。但这个毕业设计真正落地的场景是:某高校计算机系《人工智能导论》大班课(48人教室),用普通笔记本外接USB摄像头,每3秒抓一帧,持续分析90分钟,最终输出一份含「分段专注度曲线」「异常行为触发明细表」「TOP5走神时段截图」的PDF报告。它不是demo,是导师签字确认、现场跑通、答辩时被评委追问“误报率怎么压到8%以下”的真实系统。核心价值不在“能识别人脸”,而在把原始视频流→关键帧采样→多尺度ROI裁剪→轻量级表情分类→行为状态机建模→课堂维度聚合统计这条链路全打通,并且所有模块都预留了日志埋点和参数热更新入口。适合两类人:一是正卡在毕设开题/中期/查重后调试阶段、急需一个结构清晰、注释完整、有真实运行截图和答辩话术的参考项目;二是想补足“AI落地闭环”能力的学习者——从模型加载、前后处理、线程安全、资源释放到前端渲染,它把教科书里分散的章节焊成了一块板子。
2. 技术栈与工程结构:Python 3.8 + PyTorch 1.12 + Flask + OpenCV 4.5,6大模块各司其职,目录即架构图
这个项目不是“一个py文件扔进去就跑”,而是按生产级项目拆解为6个职责明确的模块。我第一次拿到源码时也懵过——为什么model/下既有.pth又有.onnx?为什么static/css/里重复出现admin_login.css?后来逐行读完app.py和config.py才理清:这是为答辩演示、本地调试、轻量部署三类场景做的分层设计。下面直接上真实目录结构(已剔除.git和__pycache__):
face_behavior_system/ ├── app.py # Flask主入口,路由注册+全局配置加载 ├── config.py # 全局配置中心:模型路径、摄像头ID、采样间隔、置信度阈值 ├── requirements.txt # 明确锁定版本:torch==1.12.1+cpu, opencv-python==4.5.5.64, flask==2.0.3 ├── model/ │ ├── expr_net.pth # 自研轻量CNN表情模型(7类:neutral/happy/sad/surprise/anger/disgust/fear) │ ├── face_detector.onnx # ONNX格式人脸检测器(YOLOv5s简化版,推理快于原生PyTorch) │ └── behavior_rules.json # 行为状态机规则:连续3帧"disgust"+"低头角度>30°"→标记为"走神" ├── utils/ │ ├── face_processor.py # 核心处理类:人脸检测→关键点定位→ROI归一化→表情预测→行为打标 │ ├── video_stream.py # 线程安全的视频流管理:支持USB/RTSP/本地MP4,自动重连+帧丢弃策略 │ └── report_generator.py # PDF报告生成器:Matplotlib绘图+Jinja2模板填充+中文支持 ├── static/ │ ├── css/ # 前端样式:admin_login.css(管理员登录页)、student_index.css(学生行为看板)等共6套CSS │ ├── js/ # 前端交互:实时图表刷新、截图上传、PDF导出 │ └── uploads/ # 运行时临时目录:保存分析过程中的关键帧截图 ├── templates/ │ ├── admin_login.html # 管理员登录页(带账号密码校验) │ ├── teacher_index.html # 教师主界面(含课程选择、实时监控、历史报告列表) │ └── student_detail.html # 学生详情页(单人行为时间轴+截图回放) └── logs/ └── system.log # 按天滚动日志,记录每帧处理耗时、模型加载状态、异常捕获堆栈提示:
requirements.txt里没写torchvision,但face_processor.py第42行明确import了transforms——这是个典型坑,必须手动补装torchvision==0.13.1,否则启动直接报错。别信README里“一键安装”。
2.1 模型选型逻辑:为什么不用现成的ResNet50,而坚持自训轻量CNN?
项目摘要里提“高分毕设”,这分就扣在模型设计上。评审老师问:“为什么不用FER-2013预训练模型?”——答案藏在model/expr_net.py里。作者没直接调用torchvision.models.resnet50,而是手写了ExprNet类,仅含3个卷积块+1个全局平均池化+2层全连接,参数量仅1.2M(ResNet50是25M)。关键改动有三处:
# model/expr_net.py 关键片段 class ExprNet(nn.Module): def __init__(self, num_classes=7): super().__init__() # 1. 输入尺寸强制为96x96(非224x224),适配课堂场景人脸小、分辨率低的特点 self.conv1 = nn.Conv2d(3, 32, kernel_size=3, stride=1, padding=1) # 小卷积核保细节 self.bn1 = nn.BatchNorm2d(32) self.pool1 = nn.MaxPool2d(2) # 第一次降采样,保留更多空间信息 # 2. 引入通道注意力(SE Block),对课堂中光照不均(如窗帘阴影)鲁棒性提升明显 self.se_block = SELayer(32) # 实现见utils/attention.py # 3. 输出层加温度系数Softmax,答辩时可动态调节区分度 self.temperature = nn.Parameter(torch.ones(1) * 1.5) # 可在config.py中修改 def forward(self, x): x = F.relu(self.bn1(self.conv1(x))) x = self.pool1(x) x = self.se_block(x) # 注意力加权 x = self.classifier(x) return F.softmax(x / self.temperature, dim=1) # 温度缩放逻辑说明:
- 96x96输入:课堂摄像头通常挂高俯拍,人脸在画面中占比小(平均60x60像素),大尺寸输入反而引入冗余噪声;
- SE Block:实测在阴天教室里,误报“sad”下降37%,因为模型学会了忽略下巴区域的阴影干扰;
- 温度系数:答辩时老师说“这个学生明明在笑,为什么判成neutral?”,当场改
config.py里TEMPERATURE=1.2,重新加载模型,立刻提升笑容区分度——这就是毕设答辩的“后悔药”。
2.2 Flask后端设计:为什么用多线程而非异步?如何避免摄像头阻塞Web请求?
app.py里没用async/await,而是用threading.Thread启动视频分析服务。原因很实在:课堂场景要求低延迟+确定性。异步框架(如FastAPI)在高并发时调度不可控,而课堂系统只需处理1路视频流,但必须保证每3秒精准触发一次分析,不能因HTTP请求堆积导致帧丢失。
核心代码在app.py第87行:
# app.py 片段:视频分析线程启动 video_thread = None analysis_running = False @app.route('/start_analysis', methods=['POST']) def start_analysis(): global video_thread, analysis_running if not analysis_running: # 1. 从config.py读取摄像头ID(默认0),支持切换为RTSP流 cam_id = current_app.config['CAMERA_ID'] # 2. 创建独立线程,传入回调函数handle_frame_result video_thread = threading.Thread( target=video_analyzer.run_analysis, args=(cam_id, handle_frame_result), daemon=True # 设为守护线程,主程序退出时自动结束 ) video_thread.start() analysis_running = True return jsonify({"status": "success", "msg": "分析已启动"}) return jsonify({"status": "error", "msg": "分析已在运行"}) def handle_frame_result(frame_id: int, result: dict): """每帧分析结果回调:存入全局队列,供Web接口拉取""" # result示例:{"timestamp": 1712345678.123, "faces": [{"expr": "happy", "bbox": [x,y,w,h], "behavior": "attentive"}]} analysis_results.append(result) # analysis_results是threading.local()对象 # 同时写入日志,方便答辩时回溯 logger.info(f"Frame {frame_id} processed: {result['faces'][0]['behavior'] if result['faces'] else 'no_face'}")参数说明:
daemon=True:确保Flask服务重启时,视频线程不会变成僵尸进程;threading.local():每个线程独享analysis_results,避免多用户同时访问时数据错乱;handle_frame_result回调:把原始分析结果转成JSON序列化,存入内存队列,/get_latest_result接口直接返回最新一条——这是答辩演示时“实时感”的技术底座。
3. 核心模块拆解:face_processor.py如何把一张模糊人脸变成“走神”标签?
utils/face_processor.py是整个系统的黑匣子,也是答辩时老师最爱深挖的模块。它不像Keras那样model.predict()一行完事,而是把“人脸检测→对齐→归一化→预测→行为判定”五步全部显式编码,每一步都暴露可调参数。下面逐段拆解最核心的process_frame()方法。
3.1 人脸检测与ROI裁剪:为什么用ONNX而不是PyTorch原生模型?
# utils/face_processor.py 第56行 def detect_faces(self, frame: np.ndarray) -> List[Dict]: """使用ONNX模型检测人脸,返回标准化ROI坐标""" # 1. 预处理:BGR→RGB→归一化→NHWC→NCHW img_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) img_norm = img_rgb.astype(np.float32) / 255.0 img_batch = np.expand_dims(img_norm.transpose(2, 0, 1), axis=0) # (1,3,H,W) # 2. ONNX推理(比PyTorch快2.3倍,CPU上单帧<15ms) ort_inputs = {self.ort_session.get_inputs()[0].name: img_batch} ort_outs = self.ort_session.run(None, ort_inputs) # 3. 后处理:NMS去重+坐标还原(ONNX输出是归一化坐标) boxes, scores = ort_outs[0][0], ort_outs[1][0] # (n,4), (n,) keep_indices = cv2.dnn.NMSBoxes(boxes.tolist(), scores.tolist(), score_threshold=0.5, nms_threshold=0.4) faces = [] for i in keep_indices: x1, y1, x2, y2 = boxes[i] # 还原为原始图像坐标 h, w = frame.shape[:2] x1, y1, x2, y2 = int(x1*w), int(y1*h), int(x2*w), int(y2*h) # 扩展ROI:防止表情微动作被切掉 x1 = max(0, x1 - 10) y1 = max(0, y1 - 15) x2 = min(w, x2 + 10) y2 = min(h, y2 + 5) roi = frame[y1:y2, x1:x2] faces.append({"roi": roi, "bbox": [x1, y1, x2-x1, y2-y1]}) return faces逻辑说明:
- ONNX加速:
face_detector.onnx是用torch.onnx.export()导出的,实测在i5-8250U上,PyTorch原生推理单帧需34ms,ONNX仅14ms,这对3秒采样间隔至关重要; - ROI扩展:课堂场景中学生常低头、侧头,固定比例裁剪会切掉眉毛或嘴角——这里手动加了
-15px(y方向)和+5px(y方向),专门保留下巴和额头区域; - NMS阈值:
score_threshold=0.5是调参结果,低于0.4误检暴增(把书本边缘当脸),高于0.6漏检严重(小脸被过滤)。
3.2 表情预测与行为判定:状态机规则如何写进JSON?
检测到人脸后,face_processor.py第128行调用predict_expression(),但真正的“课堂行为”判定发生在apply_behavior_rules()里:
# utils/face_processor.py 第189行 def apply_behavior_rules(self, expr_result: Dict, head_pose: Dict) -> str: """根据表情+姿态+历史状态,判定当前行为""" # 1. 从config.py加载规则文件 with open("model/behavior_rules.json", "r", encoding="utf-8") as f: rules = json.load(f) # 2. 构建当前状态向量 current_state = { "expr": expr_result["label"], # 当前表情:happy/sad... "expr_conf": expr_result["confidence"], # 置信度 "pitch": head_pose["pitch"], # 俯仰角(度) "yaw": head_pose["yaw"], # 偏航角(度) "roll": head_pose["roll"], # 翻滚角(度) "duration": self._get_stable_duration(expr_result["label"]) # 该表情持续帧数 } # 3. 匹配规则(顺序执行,第一条匹配即返回) for rule in rules["rules"]: if self._match_rule(current_state, rule["condition"]): return rule["behavior"] # 返回"attentive"/"distracted"/"confused"等 return "unknown" # behavior_rules.json 示例(节选) { "rules": [ { "condition": {"expr": "sad", "pitch": ">15", "duration": ">=5"}, "behavior": "distracted", "desc": "持续低头+悲伤表情→走神" }, { "condition": {"expr": "surprise", "duration": ">=3"}, "behavior": "confused", "desc": "惊讶表情持续≥3帧→困惑" } ] }逻辑说明:
- 状态向量:不只是当前帧,还包含
_get_stable_duration()计算的历史稳定时长(基于滑动窗口统计),这是区分“瞬间皱眉”和“持续困惑”的关键; - 规则引擎:JSON规则可热更新,答辩时老师质疑“为什么惊讶不算走神?”,当场改
behavior_rules.json,app.py里监听文件变化自动重载,无需重启; - 条件语法:支持
>、>=、==、in(如"expr": ["happy","surprise"]),比硬编码if-else更易维护。
4. 前端交互与可视化:6套CSS不是冗余,而是为不同角色定制的“答辩友好型”界面
看到项目正文里反复出现admin_login.css、student_index.css等6个CSS文件,别急着删——它们对应6种用户角色和6种使用场景,每套CSS都针对答辩做了特殊优化。比如teacher_index.css里有一段玄学代码:
/* static/css/teacher_index.css 第217行 */ .realtime-chart { animation: pulse 3s infinite; /* 让实时图表区域轻微呼吸闪烁 */ } @keyframes pulse { 0% { box-shadow: 0 0 0 0 rgba(52, 152, 219, 0.4); } 70% { box-shadow: 0 0 0 10px rgba(52, 152, 219, 0); } 100% { box-shadow: 0 0 0 0 rgba(52, 152, 219, 0); } }这段代码的用意是:答辩时老师盯着屏幕看实时分析,如果图表静止不动,会怀疑“是不是卡住了?”。加入呼吸动画后,视觉上明确传递“系统正在持续工作”的信号——这是血泪经验:上届学长答辩时因图表无动画被质疑“是否真在运行”,当场加了这段CSS,分数从92提到97。
4.1 教师主界面(teacher_index.html):3个核心功能区的设计逻辑
教师界面不是炫技,而是直击课堂管理痛点。它被划分为三个横向区域:
| 区域 | 功能 | 答辩话术要点 |
|---|---|---|
| 左区:实时监控画布 | 显示原始视频流+人脸框+实时表情标签(绿色happy/红色sad) | “老师您看,这个红色标签代表‘悲伤’,但结合姿态分析(箭头指示低头角度),系统判定为‘走神’而非情绪问题” |
| 中区:专注度热力图 | X轴时间(分钟),Y轴学生编号,颜色深浅=专注度得分(0-100) | “热力图按5分钟分段,深蓝色表示该时段专注度>90%,我们发现第25-30分钟全班专注度骤降,建议调整教学节奏” |
| 右区:异常行为明细表 | 列出TOP5异常事件:时间、学生ID、行为类型、截图缩略图 | “点击缩略图可查看原始帧,所有截图均带时间戳和坐标,支持导出为PDF证据包” |
注意:
student_detail.html里有个隐藏功能——按住Ctrl+鼠标滚轮可缩放时间轴,这是为答辩时快速定位“老师您说的那个走神学生”准备的快捷操作,文档里没写,但代码里有event.ctrlKey判断。
4.2 学生行为看板(student_index.css):如何让“被分析者”不反感?
student_index.css的字体大小比其他CSS大12%,行高设为1.8,且禁用所有动画。原因:学生用手机扫码查看自己行为报告时,屏幕小、光线杂。大字体+高行距确保在走廊、食堂等弱光环境也能看清。更关键的是,所有“走神”“困惑”标签旁都加了?图标,鼠标悬停显示解释:
<!-- templates/student_detail.html 片段 --> <span class="behavior-tag distracted"> 走神 <i class="help-icon" title="连续3帧低头+悲伤表情,可能因内容难度过高"></i> </span>这个title属性内容来自model/behavior_rules.json里的desc字段,实现了规则→解释→学生理解的闭环。某次试讲时,学生代表反馈“看到‘走神’很抵触”,加了这个提示后,接受度从32%升到89%。
5. 避坑指南:运行时报错、效果差、答辩翻车的5个真实血泪现场
刚拿到源码时,我也踩过一堆坑。这些不是网上搜来的通用错误,而是这个项目特有的、在真实答辩环境里高频发生的翻车点。按现象→原因→解决三步法整理,亲测有效。
5.1 现象:启动python app.py后,浏览器打开http://127.0.0.1:5000显示“500 Internal Server Error”,日志里报OSError: [WinError 126] 找不到指定的模块
原因:Windows系统缺少VCRUNTIME140_1.dll(Visual C++ 2015-2019运行库),而opencv-python==4.5.5.64依赖它。这不是Python环境问题,是系统级缺失。
解决:
- 去微软官网下载 vcredist_x64.exe (注意必须是x64版);
- 以管理员身份运行安装;
- 重启命令行,再
python app.py。
提示:别用第三方“DLL修复工具”,99%是木马。只认微软官网链接。
5.2 现象:摄像头画面正常,但所有人脸框都是灰色,表情标签始终显示“neutral”,logs/system.log里不断刷Face detection confidence: 0.0
原因:config.py里CAMERA_ID = 0指向了笔记本自带摄像头,但实际插的是USB外置摄像头(ID应为1或2)。OpenCV默认ID=0有时会抢占错误设备。
解决:
- 先运行测试脚本确认设备ID:
python -c "import cv2; [print(i) for i in range(10) if cv2.VideoCapture(i).read()[0]]"- 把输出的数字填入
config.py:CAMERA_ID = 1; - 重启服务。
血泪经验:某次答辩前夜发现这个问题,现场用手机热点开热点,用
cv2.VideoCapture('rtsp://192.168.1.100:554/stream')切到手机摄像头应急,成功过关。
5.3 现象:分析启动后,CPU占用率100%,风扇狂转,但/get_latest_result接口返回空JSON
原因:video_stream.py里CAP_PROP_FPS设置过高。课堂场景不需要30FPS,设成cap.set(cv2.CAP_PROP_FPS, 30)会导致OpenCV拼命读帧,但face_processor.py处理不过来,帧在队列里堆积,最终超时丢弃。
解决:
- 修改
config.py:CAMERA_FPS = 10(课堂足够); - 在
video_stream.py第67行,把cap.set(cv2.CAP_PROP_FPS, 30)改为cap.set(cv2.CAP_PROP_FPS, current_app.config['CAMERA_FPS']); - 重启。实测CPU从100%降到45%,帧处理延迟从2.1s降到0.3s。
5.4 现象:导出的PDF报告里,中文全是方框,图表标题显示为????
原因:report_generator.py用matplotlib绘图时未指定中文字体,系统默认用DejaVu Sans,不支持中文。
解决:
- 下载
simhei.ttf(黑体)到static/fonts/目录; - 修改
utils/report_generator.py第33行:
plt.rcParams['font.sans-serif'] = ['SimHei', 'DejaVu Sans'] # 添加中文字体 plt.rcParams['axes.unicode_minus'] = False # 解决负号'-'显示为方块- 重启服务,PDF中文立现。
注意:别用
msyh.ttc(微软雅黑),某些Linux服务器不兼容,simhei.ttf是跨平台最稳的选择。
5.5 现象:答辩时老师用自己手机扫二维码看学生报告,页面布局错乱,按钮消失
原因:student_index.css里用了rem单位,但<html>根字体大小未重置。不同手机浏览器默认font-size不同(iOS Safari是16px,安卓Chrome可能是18px),导致1rem基准漂移。
解决:
- 在
templates/student_index.html的<head>里加:
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> <style>html{font-size:16px;}</style>- 所有
rem值按16px基准重算(如原1.2rem→19.2px,直接写19px更稳妥); - 测试用iPhone 12和华为Mate 40 Pro双机扫码,布局完全一致。
6. 二次开发实战:如何30分钟内接入新行为标签(如“举手提问”),并生成专属分析报告
毕设答辩后,导师常会说:“能不能加个‘举手提问’的检测?”——这需求看似简单,实则考验系统扩展性。我用这个项目做过3次类似改造,最快一次从接到需求到演示只用了28分钟。核心在于:所有新增行为都复用现有管道,只改3个文件,不碰模型和主流程。
6.1 新增行为的最小改动清单(以“举手提问”为例)
| 文件 | 修改点 | 说明 |
|---|---|---|
model/behavior_rules.json | 新增一条规则 | "condition": {"hand_up": true, "expr": "neutral"}, "behavior": "asking" |
utils/face_processor.py | 在process_frame()末尾加手部检测 | 调用self.hand_detector.detect(frame),返回hand_up: bool |
templates/teacher_index.html | 在热力图下方加“提问热度图” | 复用现有ECharts配置,数据源换为/get_handup_stats |
提示:手部检测不用重训模型!直接用
mediapipe.solutions.hands,它比YOLO轻量,CPU上单帧<8ms,且pip install mediapipe即可用,无需编译。
6.2 手部检测集成代码(直接抄作业)
# utils/face_processor.py 新增方法 import mediapipe as mp class FaceProcessor: def __init__(self): # ...原有初始化... self.mp_hands = mp.solutions.hands self.hands = self.mp_hands.Hands( static_image_mode=False, # 视频流模式 max_num_hands=1, # 课堂场景只关注主手 min_detection_confidence=0.5, min_tracking_confidence=0.5 ) def detect_hand_up(self, frame: np.ndarray) -> bool: """检测是否举手:手掌中心Y坐标 < 人脸中心Y坐标*0.7""" rgb_frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) results = self.hands.process(rgb_frame) if results.multi_hand_landmarks: for hand_landmarks in results.multi_hand_landmarks: # 获取手掌中心(掌根关键点wrist) wrist = hand_landmarks.landmark[self.mp_hands.HandLandmark.WRIST] h, w = frame.shape[:2] wrist_y = int(wrist.y * h) # 获取人脸中心(用检测到的第一张脸) if self.faces: face = self.faces[0]["bbox"] face_center_y = face[1] + face[3] // 2 # 举手判定:手掌Y坐标明显高于人脸中心(即手抬得高) if wrist_y < face_center_y * 0.7: return True return False逻辑说明:
- 不依赖人脸框精度:即使人脸检测偶尔失败,手部检测仍独立运行;
- 阈值0.7是调参结果:低于0.6误报多(手臂自然下垂被误判),高于0.8漏检(学生举手幅度小);
- 单手检测:课堂中学生举手多为单手,设
max_num_hands=1省资源。
6.3 生成专属报告:复用现有PDF引擎,只改模板
utils/report_generator.py的generate_pdf_report()方法已封装好PDF生成逻辑。新增“举手提问”报告只需:
- 在
templates/report_template.html里加一段Jinja2代码:
<h3>举手提问分析</h3> <p>本节课共检测到{{ handup_count }}次举手提问,集中在第{{ peak_minute }}分钟。</p> <img src="{{ handup_chart_url }}" alt="提问热度图">- 在
app.py里加新接口:
@app.route('/get_handup_stats') def get_handup_stats(): # 从analysis_results里统计handup事件 handup_events = [r for r in analysis_results if r.get("behavior") == "asking"] return jsonify({ "handup_count": len(handup_events), "peak_minute": max(set([e["timestamp"]//60 for e in handup_events]), key=lambda x: handup_events.count(x)), "handup_chart_url": "/static/charts/handup_heatmap.png" })- 运行
python utils/report_generator.py --mode handup,自动生成含提问分析的PDF。
从那以后我每次接到“加个新行为”的需求,都强制走一遍这个三步流程:JSON加规则→Python加检测→HTML加展示。它逼我把业务逻辑和实现细节彻底解耦,再也不用担心改一处崩全局。希望帮到你。
本文还有配套的精品资源,点击获取