☰
课堂行为检测系统源码全解析:Haar+CNN实现实时表情识别
2026/10/1 4:33:51 网站建设 项目流程

简介:一套基于表情识别的课堂行为检测系统源代码,面向Python开发者、计算机视觉爱好者及教育信息化研究人员,可用于理解如何借助面部表情分析学生课堂参与度与专注度。代码覆盖表情识别、深度学习模型构建、视频流处理、人脸关键点检测、行为分析等关键技术,并包含教师端与学生端界面模块。资源共46个文件,以.py源代码为主,辅以.xml配置文件、.h5模型权重、.md说明文档、.mp4演示视频及.gitignore等,整包约78.39MB,结构清晰便于按模块拆解学习。目前已有328人学习下载,适合需要参考完整课堂行为检测方案、复用预训练模型或在此基础上做二次开发的读者。通过该项目可快速掌握从数据预处理、模型训练到实时识别与界面集成的全流程实现思路,并可直接运行验证效果。

1. 课堂行为检测系统不是黑匣子:这份源码包里到底有什么

提到课堂行为检测系统,多数人先想到论文概念图,但做过课设的人清楚:问题不是算法不够炫,而是代码跑不起来。这份 Python 源码包把表情识别、视频流、人机界面做成了一条能跑通的完整链路,还自带训练好的 CNN 权重 cnn3_best_weights.h5,跳过训练也能先跑推理。

系统思路一句话说清:摄像头采集画面,Haar 级联框住人脸,CNN 判断框内表情属于 happy、sad、surprise 中哪一类,结果汇总到教师端界面;学生端负责识别,教师端负责展示,靠结构化记录衔接。

它适合三类人:课设、毕设要交一套可演示系统的人;想换数据集做二次开发的人;想看懂人脸登录到实时识别再到聚合整条链路的人。解决的问题很具体:给一段课堂视频,输出情绪分布与参与度趋势,而不是停在检测出人脸就结束的 demo。

2. 双端架构先立住:学生端采集、教师端汇总这条链路怎么走

2.1 学生端:从 face_login.py 到 recognition_camera.py 的数据流

拿到源码包,最容易被目录结构误导。我之前习惯性先翻模型文件,结果绕了大半天。这份工程把代码按"教师端"和"学生端"分成两个独立模块,含义很清楚:学生端是数据产生的地方,教师端是数据消费的地方。根目录的 README.md 和 README.en.md 分别写了中英文的启动说明,先读它比先看代码省钱。

学生端里几个文件的分工,我按顺序给你拆开:

  • face_login.py:人脸登录。启动摄像头,用 Haar 级联检测到人脸后放行,解决"谁在镜头前"的问题。
  • recognition_camera.py:识别主程序。持续取帧,每一帧做人脸检测、人脸区域裁剪、表情分类,把结果输出。
  • utils.py:公用工具,比如图像预处理、结果格式化、时间戳写入。
  • model/ 目录:模型结构定义和权重。
  • player/ 子目录:播放录制好的课堂视频,相当于离线模式,不接摄像头也能完整跑一遍识别链路。

整个数据流用一段伪代码就能讲清楚,项目里 recognition_camera.py 的主循环逻辑也基本是这个顺序:

import cv2 import numpy as np cascade = cv2.CascadeClassifier('haarcascade_frontalface_alt.xml') cap = cv2.VideoCapture(0) # 0 是摄像头索引,笔记本一般从 0 开始 while cap.isOpened(): ok, frame = cap.read() if not ok: break gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) # Haar 检测器吃灰度图 faces = cascade.detectMultiScale(gray, scaleFactor=1.1, minNeighbors=5, minSize=(48, 48)) for (x, y, w, h) in faces: roi_gray = gray[y:y+h, x:x+w] # 人脸 ROI emotion_label = predict_from_model(roi_gray) # 交给 CNN 分类 cv2.rectangle(frame, (x, y), (x+w, y+h), (0, 255, 0), 2) cv2.putText(frame, emotion_label, (x, y-8), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 255, 0), 2) cv2.imshow('classroom', frame) if cv2.waitKey(1) & 0xFF == ord('q'): break

这段逻辑是判断一个系统是不是"真能跑"的关键。很多教程只画检测框就收工,这里检测到 ROI 之后必须继续喂给 CNN 做表情分类。几个参数的实际含义值得记一下:

  • scaleFactor=1.1:每次缩放检测窗口的倍率,1.1 表示逐级缩小 10%。越小越不容易漏检,但计算量越大。
  • minNeighbors=5:候选框至少要经过 5 个邻近框确认才保留。调大能压掉误检,但侧脸容易被当成噪声丢掉。
  • minSize=(48, 48):小于 48×48 的候选框直接丢弃。这个尺寸和模型输入配套,不要随手改大,否则人脸足够大时识别还可以,人一旦离镜头远就全被过滤了。

我一般建议第一次跑项目先别接摄像头,直接用 player/ 子目录读一段录制好的课堂视频。视频是确定性的,同一帧每次跑出来结果一致,方便你确认是模型问题还是摄像头采集问题。等离线链路验证稳定了,再切到 VideoCapture 实时模式。这个习惯在排错阶段能省掉大量无意义猜测。

目录里那些 .keep 文件是 git 占位符,作用是让空目录能提交进仓库,不是垃圾文件,删目录时别顺手删掉。.idea/ 是 PyCharm 的工程配置,你换 IDE 直接无视就行。

2.2 教师端:UI 与 tools 目录里的数据落点

学生端产生的是"每一帧、每个学生"的原始情绪标签,这种粒度给老师看没有任何意义。教师端 ui/ 目录里放的是界面代码,tools/ 目录里放的是数据整理脚本,作用是把原始标签聚合成老师能直接阅读的指标。

教师端界面最常见的三个展示视图,也是这份工程里 tools/ 脚本输出数据的三种落点:

  • 实时状态板:当前镜头里每个人的表情标签实时刷新,带检测框和人名。
  • 情绪占比图:统计一个时间段内各类表情的出现次数占比,常用柱状图或饼图展示。
  • 参与度趋势:把积极情绪和中性情绪视为参与,把走神类情绪视为不参与,画出随时间变化的曲线。

这里的设计思路值得抄进你自己的项目:识别系统和展示系统解耦。识别端只管输出带时间戳、学生标识、情绪标签的结构化记录;展示端只消费这些记录,完全不知道识别端内部用了什么模型。两个端之间靠文件或简单数据库传递数据,改动界面不会碰识别主流程,替换模型权重也不会动界面逻辑。

我接手这个项目时先确认了一件事:教师端能不能在完全没有学生端的情况下,用一份历史识别记录跑起来。如果能,说明数据接口定义得干净;如果不能,说明两个端耦合过紧,后面每次改需求都要两头改。这个判断你拿到代码后可以自己验证一遍。

3. 表情识别模型:Haar 负责找人脸,CNN 负责读表情

3.1 为什么选 haarcascade_frontalface_alt.xml 而不是 MTCNN

人脸检测方案很多,工程里选 Haar 级联是有明确取舍的。MTCNN、dlib 的准确率更高,但代价是推理时间。课堂场景里摄像头可能同时出现好几张脸,每帧都要检测,如果检测耗掉 50ms,识别再耗掉 30ms,帧率直接掉到 12 左右,界面就是幻灯片效果。

Haar 级联是 OpenCV 自带的传统方法,CPU 上检测一张脸只需要几毫秒。短板是大角度侧脸、强逆光容易漏检,但课堂摄像头的机位是固定的,学生基本正对镜头,正好落在 Haar 的舒适区。而且这个 xml 文件随工程一起打包了,不需要额外下载。部署时最怕代码里写着"请下载某模型放到某目录",跑一半发现文件名对不上,这个包把这一步省掉了。

方案CPU 单帧耗时参考大角度/侧脸部署成本
项目自带 Haar约 5~15ms差一个 xml 文件
MTCNN约 50~100ms好需下载模型文件
dlib HOG约 20~40ms一般需额外安装 dlib

各有各的场景:追求准确率、能接受帧率下降,就换 MTCNN;要保实时性、机位固定,Haar 反而是更稳的选择。这套代码默认 Haar,我在实测里也是先保留默认跑通,再考虑替换。

3.2 model.py 的 CNN 结构与 cnn3_best_weights.h5 的加载方式

model.py 定义的是标准卷积分类网络,套路是:卷积层提取纹理特征,池化层降维,全连接层做分类,最后 softmax 输出每个类别的概率。工程里输入是灰度图,输出对应七个基本表情:angry、disgust、fear、happy、sad、surprise、neutral。

加载权重用 Keras 的老接口在 TensorFlow 2.x 下容易踩序列化兼容问题的坑,我这边惯用的写法是:

from tensorflow.keras.models import load_model import cv2 import numpy as np model = load_model('model/cnn3_best_weights.h5', compile=False) emotion_map = ['angry', 'disgust', 'fear', 'happy', 'sad', 'surprise', 'neutral'] def predict_emotion(gray_roi): roi = cv2.resize(gray_roi, (48, 48)) # 缩到模型输入尺寸 roi = roi.astype('float32') / 255.0 # 归一化到 [0, 1] roi = roi.reshape(1, 48, 48, 1) # (batch, h, w, channels) proba = model.predict(roi, verbose=0)[0] idx = int(np.argmax(proba)) return emotion_map[idx], proba[idx]

三个点最容易在这段代码上翻车,我逐个说。

第一,reshape 的通道顺序。OpenCV 读灰度图返回二维数组,必须补成 (1, 48, 48, 1)。如果原模型是三通道输入,这里是 (1, 48, 48, 3)。通道数不对,predict 直接报维度不匹配,这个错倒是显性的,好排查。

第二,归一化必须和训练时一致。训练时用了 min-max 归一化,推理时忘了做,或者反过来,都不会报错,但准确率会静默掉一截。最保险的办法是回去翻 train_model.py 里训练数据怎么处理的,推理代码照抄同一套操作。

第三,emotion_map 的顺序要和训练时一致。训练时类别按 alphabetical 排序,推理时字典却按自己习惯排,那输出的标签全是错位的。这种错位在 Confusion_matrix.py 里一眼就能看出来,后面会讲。

3.3 推理时的预处理顺序不能乱

预处理顺序直接影响识别稳定度,我的固定顺序是:灰度化 → 裁剪人脸 ROI → resize 到模型输入尺寸 → 归一化 → 送网络。

有人习惯先 resize 整帧再裁剪,有人先归一化再 resize,这些顺序在数值上都有偏差。resize 插值会改变像素分布,所以裁剪必须在 resize 前面;灰度化要在裁剪之前做,因为 Haar 检测器本身处理的就是灰度图。

还有一个细节容易被忽略:课堂 ROI 通常不是正方形,但模型输入是正方形。直接 resize 会把脸拉变形。常见做法是先把人脸区域切成正方形再缩放,比如取 (x, y) 到 (x+max(w,h), y+max(w,h)),超界部分填充黑边。这个处理能让识别稳定不少,尤其是侧脸和低头的时候。

4. 训练与评估链路:mat 读取、归一化到混淆矩阵

4.1 mat_file_reader_4096_4_1024_normalization.py 的预处理逻辑

这个文件名很长,但信息量都在名字里。4096 是输入特征维度,正好等于 64×64 灰度图展开后的长度;4 和 1024 我在拿到文件后翻了几行注释确认,按命名习惯看多半是样本组织方式和归一化相关常量。想改数据集,第一件事就是搞清楚脚本输出给下游的到底是什么形状的张量。

.mat 文件在 Python 里用 scipy.io.loadmat 读取,核心逻辑一般长这样:

import scipy.io as sio import numpy as np data = sio.loadmat('your_dataset.mat') X = data['X'] # 原始特征矩阵 y = data['y'].ravel() # 标签向量,ravel 把二维压成一维 X = X.astype('float32') X = (X - X.mean()) / (X.std() + 1e-7) # 标准化,不是简单除以 255 X = X.reshape(-1, 64, 64, 1) # 还原成图像形状

这种均值方差标准化和前面推理时用的"除以 255"要分清楚。标准化用的均值、方差来自训练集统计值,推理时必须用同一套统计量,而不是用当前这帧的均值。这是整个项目里最容易出现"训练 90%、推理 70%"的根源。

reshape 这段也值得单独提醒。很多.mat 文件里图像数据是"H × W × N"的存储方式,每列是一张图,loadmat 出来是 (4096, N) 的形状。必须先转置再 reshape,不然画出来的图像全是转置的。文件名里的 4096 直接告诉你这是 64×64 展开的,不需要再猜输入尺寸。

提示:训练脚本里的归一化参数,推理时必须用同一套,这是整个项目中最隐蔽的准确率损耗来源。

4.2 train_model.py 的训练配置与权重保存

train_model.py 走的是标准流程:读数据 → 划分训练/验证集 → 构建模型 → 训练 → 保存 h5。关键配置部分我按一般工程习惯给你标注好,方便你对着目录里的代码核对:

from tensorflow.keras.optimizers import Adam from tensorflow.keras.callbacks import ModelCheckpoint, ReduceLROnPlateau model.compile( optimizer=Adam(learning_rate=1e-3), loss='categorical_crossentropy', metrics=['accuracy'] ) checkpoint = ModelCheckpoint( 'cnn3_best_weights.h5', save_best_only=True, # 只在验证集指标更好时覆盖保存 save_weights_only=False, # 保存完整模型,加载时不用重建网络 monitor='val_accuracy', mode='max' ) reduce_lr = ReduceLROnPlateau( monitor='val_loss', factor=0.5, patience=3, min_lr=1e-6 ) model.fit( train_gen, validation_data=val_gen, epochs=50, batch_size=32, callbacks=[checkpoint, reduce_lr] )

几个参数按我的经验给你标注:

  • save_best_only=True 配合 monitor='val_accuracy',只会留下验证集准确率最高的那一轮权重,而不是最后一轮。命名里的 best 就是这么来的。
  • save_weights_only=False 保存完整模型。加载时直接 load_model 就行,不用先执行一遍 model.py 重建结构。缺点是文件比纯权重大一些。
  • ReduceLROnPlateau:验证损失连续 3 轮不降,学习率减半,最低降到 1e-6,防止后期震荡。
  • batch_size=32:显存小于 8GB 建议降到 16 或 8,慢一点但不会 OOM。

如果打算用自己的课堂数据集重训,先统计类别数。FER2013 系数据一般是 7 类,你自己标注的数据也许只有 4 类(比如只分专注、困倦、开心、疑惑),那 model.py 最后一层输出维度必须改成 4,否则保存和加载都会出问题。改完输出维度,推理代码里的 emotion_map 也要同步换成你自己的类别名称。

4.3 Confusion_matrix.py:用混淆矩阵找出模型的真实短板

准确率是容易骗人的指标。如果课堂数据里 70% 是 neutral,一个"永远输出 neutral"的模型准确率也有 70%。混淆矩阵的价值在于告诉你每个类别到底有没有被学出来,Confusion_matrix.py 就是干这个的。

它一般会加载测试集,跑一轮预测,再用 sklearn 的 confusion_matrix 生成矩阵,配合热力图把结果可视化:

from sklearn.metrics import confusion_matrix, classification_report import seaborn as sns import matplotlib.pyplot as plt y_true = test_labels.argmax(axis=1) # 真实标签 y_pred = model.predict(test_X).argmax(axis=1) # 预测标签 cm = confusion_matrix(y_true, y_pred) sns.heatmap(cm, annot=True, fmt='d', cmap='Blues', xticklabels=emotion_names, yticklabels=emotion_names) plt.savefig('confusion_matrix.png', dpi=150) print(classification_report(y_true, y_pred, target_names=emotion_names))

看矩阵时我只看两个位置:主对角线上的值是不是明显大于同行其他值;disgust 和 fear 这两类是不是几乎不被预测出来。后者在 FER2013 上几乎是必然出现的,因为这两类样本少、神态表达模糊。fear 和 surprise 互相混淆是最常见的现象,根源在于训练样本本身接近,这时候优先做数据增强或类别加权,而不是急着堆模型深度。如果 confusion matrix 里连 happy、sad 这类强表达类别都预测不准,那问题大概率出在预处理不一致,回 4.1 检查归一化。

5. 部署避坑手册:这五个坑我至少踩过两遍

5.1 摄像头打不开,cv2.VideoCapture 返回无效

现象:程序启动后窗口全黑,cap.isOpened() 返回 False,有时候直接抛 AttributeError。原因:摄像头索引不对,笔记本自带摄像头通常是 0,外接 USB 摄像头可能是 1 或 2;另外 Windows 上 OpenCV 对部分摄像头驱动不兼容,open 成功但读帧全是 None。解决:先暴力探测所有索引:

import cv2 for i in range(4): cap = cv2.VideoCapture(i) ok, frame = cap.read() if ok: print('可用索引:', i, '尺寸:', frame.shape) break cap.release()

如果所有索引都打不开,检查摄像头是不是被其他进程占用。Zoom、企业微信、OBS 这类软件一旦占住摄像头,OpenCV 抢不到设备。调试期直接用 player/ 子目录读视频文件,问题绕开,识别逻辑照样验证。

5.2 检测框乱跳、人脸时有时无

现象:同一段画面里检测框位置上下抖动,头稍微偏一点就完全检测不到。原因:Haar 对光照和姿态敏感,参数配太松候选框太多导致不稳定,配太紧又漏检。解决:优先调 scaleFactor 和 minNeighbors,1.1 配 5 压不住就换 1.05 配 6;教室侧光导致脸一半亮一半暗时,先做直方图均衡化再检测,这一行往往直接解决问题:

gray = cv2.equalizeHist(cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY))

另外,默认检测框会把额头和背景都包进去,直接送 CNN 会引入噪声。可以只取 y+0.1h 到 y+0.9h 的区域,把头发和下巴背景切掉,识别稳定性会明显提升。

5.3 h5 权重加载报错,KeyError 或 Unknown layer

现象:load_model 抛 KeyError、ValueError,或者提示 Unknown optimizer / Unknown layer。原因:h5 文件是某个特定 TensorFlow/Keras 版本保存的,新版序列化格式变了,或者自定义层在当前环境里找不到对应类。解决:纯推理场景一律用 compile=False 加载;如果模型里有自定义层,加载时把自定义层类传进去:

from tensorflow.keras.models import load_model model = load_model('cnn3_best_weights.h5', compile=False, custom_objects={'MyLayer': MyLayer})

还报错的话,用 h5py 打开文件看层级结构,逐层对照 model.py 里的层名。我自己踩过两次,最后发现根本不是环境问题,而是改过 model.py 层名导致和 h5 记录对不上。

5.4 OpenCV 与 numpy 版本冲突

现象:import cv2 报 numpy.core.multiarray failed to import,或者运行到某一帧直接 Segmentation Fault。原因:OpenCV 是预编译二进制包,绑定特定 numpy ABI,pip 单独升级 numpy 大版本后旧 OpenCV 不兼容。解决:锁定稳定组合,别动不动就 -U 升级。我常用这套:

组件推荐版本
opencv-python4.6.0.66
numpy1.23.0
tensorflow2.10~2.12

多项目并存时一定用虚拟环境隔离,不要依赖全局环境:

python -m venv venv python -m pip install opencv-python==4.6.0.66 numpy==1.23.0

5.5 实时识别卡顿,帧率跌到个位数

现象:画面明显延迟,CPU 风扇狂转,情绪标签几秒才刷新一次。原因:主循环里每帧都做完整推理,Haar 检测加 CNN 预测在 CPU 上单帧耗掉几百毫秒,整个循环被拖死。解决:抽帧,而不是每帧都识别:

frame_count = 0 while cap.isOpened(): ok, frame = cap.read() frame_count += 1 if frame_count % 5 != 0: # 每 5 帧只处理 1 帧 continue # 后续检测和识别逻辑

还嫌慢的话,把采集和识别拆到两个线程,采集线程负责读帧,识别线程消费队列。改动量大约二十行,帧率能从个位数提到 15 以上,代价是标签滞后一两帧,对课堂统计这个场景完全可接受。

6. 进阶用法:把单帧表情变成可汇报的课堂专注度曲线

6.1 时间窗口统计与参与度指标

单帧情绪标签噪声很大,一个人低头再抬头,半秒内标签就变两三次。把实时标签直接甩给老师看,没人会信。我最后落地的做法是滑窗聚合:每 30 秒一个窗口,统计窗口内各情绪出现次数,再按规则映射成参与度指标:

window = [] window_size = 30 * 5 # 按 200ms 一帧估算,30 秒约 150 帧 engaged_emotions = {'happy', 'neutral', 'surprise'} def append_label(emotion): window.append(emotion) if len(window) > window_size: window.pop(0) if len(window) == window_size: engaged = sum(1 for e in window if e in engaged_emotions) return engaged / window_size # 返回参与度 return None

这个函数返回的值可以直接喂给教师端画曲线。对比单帧标签,滑窗输出平滑、可解释,老师看到的是"过去 30 秒参与比例 82%",而不是"刚才那一帧是 happy"。窗口大小按你的抽帧策略调整:如果每 5 帧处理 1 帧,实际帧率约 5fps,30 秒窗口就不是 150 帧,按真实帧率算。

6.2 扩展方向与我的边界判断

这套代码的边界必须提前讲清楚:它判断的是表情,不是真正的注意力。面无表情盯着黑板在模型眼里是 neutral,未必代表走神;笑着和同桌说话反而可能被判成参与。所以输出命名尽量用"情绪分布""参与度估计",不要写成"纪律评分"。想在课堂场景里加低头、趴桌检测,可以不动模型,在学生端把 ROI 的 y 坐标变化或人脸面积变化作为一个轻量特征接进去,先做规则判断,效果不够再考虑换骨架模型。

从那以后,我每次拿到新的识别项目,都会强制走一遍"人脸检测 → 预处理 → 推理 → 聚合"四步链路,拿一段确定性的视频把每步的输入输出形状 log 出来,确认环节对得上再谈优化。这个习惯帮我少做了至少三次无用功——每次排查到最后都发现是预处理不一致,而不是模型坏了。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询