简介:面向计算机视觉入门与进阶开发者的OpenCV人脸表情识别工程,完整实现从人脸检测到表情分类的经典流程,覆盖喜、怒、哀、乐、惊喜等多种情绪状态,适合作为课程设计、算法实验或人机交互项目的参考起点。压缩包共151个文件,以C++源代码(16个cpp、17个h)为核心,配合56个bmp样本图像、可执行exe、模型文件及Visual Studio工程配置(sln/vcproj/dsp等),整体大小12.02MB,可在VS环境中直接打开编译,也可运行exe快速查看效果。目前已有2206人学习/下载,属于轻量级但结构完整的OpenCV视觉项目。代码包含Haar级联人脸检测器、LBP/HOG特征提取等传统视觉处理模块,并预留分类器替换接口,读者可结合SVM、随机森林或深度学习模型(如VGG、ResNet)进行扩展,以适配AffectNet、FER2013等不同表情数据集。内附bmp测试图片与相关文档说明,便于对照调试,整体能帮助开发者快速理解人脸检测与表情识别的技术链路。
1. 人脸表情识别源代码:不是跑通训练,是跑通整条链路
搜“人脸表情识别 源代码”的人,大多带着一个具体场景:给一张照片自动判断喜怒哀乐,或者在摄像头前实时输出情绪标签。但很多人下载源码后第一反应是懵——仓库里只有训练脚本,没有推理脚本;或者训练完在测试集上准确率有85%,对准自己脸一测,全是“neutral”,瞬间失去信心。这个标题背后真正要解决的,不是某个模型有多强,而是“人脸检测 + 表情分类 + 模型部署”这条链路的完整闭环。链路上任何一环断掉,源代码就跑不出理想效果。
这篇笔记按我实际做表情识别项目的顺序来讲:先选对人脸检测器,再训练一个分类器,最后把两者串起来做实时推理。会给出能直接跑的代码、必须调的关键参数,以及几处容易翻车的细节。适合刚入门想拿表情识别练手的开发者,也适合要做情绪分析Demo但不想在预处理上耗时间的从业者。目标很直接:让你下载任何一版开源代码后,知道从哪下手改、改什么、怎么验证。
2. 人脸检测器选择:表情识别的第一步,也是准确率的分水岭
表情识别的上游是人脸检测。检测框偏了半个脸,分类器再强也白搭。很多开源项目默认用OpenCV的Haar级联检测器,因为它零依赖、跑得快。但Haar在侧脸、暗光、遮挡场景下的召回率偏低,经常出现“人明明在画面里,就是没检测到脸”的情况。我建议按场景选检测器,不要无脑用Haar。
2.1 三种检测器对比:Haar、MTCNN、RetinaFace怎么选
| 检测器 | 速度(CPU) | 召回率 | 关键局限 | 适合场景 |
|---|---|---|---|---|
| Haar级联 | 极快,单帧<10ms | 低,侧脸/暗光易漏 | 对角度敏感,框不稳定 | 正向脸、光线稳定的Web Demo |
| MTCNN | 较快,单帧30-80ms | 中等,能处理轻度侧脸 | 小脸检测一般,需要依赖安装 | 大多数离线图片任务 |
| RetinaFace | 慢,需要GPU或优化 | 高,支持密集小脸 | 模型文件大,推理耗时 | 精度优先的离线批处理 |
我一般用MTCNN作为默认选项。它返回人脸框和5个关键点(双眼、鼻尖、嘴角),关键点可以用来做人脸对齐——把倾斜的脸摆正后再送进分类器,对表情识别稳定性的提升很明显。RetinaFace精度更高,但纯CPU跑实时视频流会比较吃力,适合离线分析。
2.2 用MTCNN做人脸检测与对齐的代码骨架
# 基于facenet-pytorch库,MTCNN同时完成检测和对齐 from facenet_pytorch import MTCNN import cv2 import numpy as np mtcnn = MTCNN( keep_all=False, # 只保留置信度最高的一张脸 select_largest=True, # 多人场景下取最大人脸 post_process=False # 返回原始图像范围,不做归一化 ) def extract_face(image, target_size=160): # image: BGR格式的numpy数组,cv2默认就是BGR rgb = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) # detect返回人脸框和关键点,没有检测到脸时返回None face, landmarks = mtcnn.detect(rgb, landmarks=True) if face is None: return None # 取第一个检测框,坐标格式 [x1, y1, x2, y2] x1, y1, x2, y2 = [int(v) for v in face[0]] # 向外扩展15%的边距,避免表情区域被切掉 h, w = rgb.shape[:2] mx1, my1 = max(0, int(x1 - 0.15 * (x2 - x1))), max(0, int(y1 - 0.15 * (y2 - y1))) mx2, my2 = min(w, int(x2 + 0.15 * (x2 - x1))), min(h, int(y2 + 0.15 * (y2 - y1))) face_region = rgb[my1:my2, mx1:mx2] return cv2.resize(face_region, (target_size, target_size))select_largest参数在多张脸场景下很关键。表情识别业务通常分析画幅中间或面积最大的主体,如果keep_all=True,后续还得自己写NMS或者按面积排序,徒增逻辑。post_process=False是我的习惯——让detect返回原始像素范围的坐标,方便自己控制裁剪边界。
注意一个细节:MTCNN内部会对输入做归一化,但我们已经手动把检测框坐标换算回原图了,因此这里不要用MTCNN返回的归一化坐标直接裁图,否则裁剪区域会偏移。
3. 表情分类模型训练:从FER2013数据到七分类识别器
检测器拿到人脸区域后,接下来是表情分类。表情识别领域最常用的数据集是FER2013,48x48灰度图,7类标签:angry、disgust、fear、happy、sad、surprise、neutral。开源代码里大多数都是基于这个数据集训练的,所以模型输入尺寸也默认配合它。
3.1 数据预处理:灰度图读取与标签映射
import pandas as pd import numpy as np from sklearn.model_selection import train_test_split from tensorflow.keras.utils import to_categorical # FER2013.csv 的典型结构:emotion, pixels, Usage # pixels 列是空格分隔的灰度像素值,长度=48*48 df = pd.read_csv('data/fer2013.csv', dtype={'pixels': str}) def pixels_to_image(pixel_str, size=48): arr = np.array([int(p) for p in pixel_str.split(' ')], dtype=np.uint8) return arr.reshape(size, size, 1) # 单通道灰度 X = np.stack([pixels_to_image(p) for p in df['pixels']]) y = df['emotion'].values # 归一化到[0,1],并做训练/验证切分 X = X.astype('float32') / 255.0 X_train, X_val, y_train, y_val = train_test_split( X, y, test_size=0.15, stratify=y, random_state=42 ) y_train = to_categorical(y_train, num_classes=7) y_val = to_categorical(y_val, num_classes=7)FER2013的pixels字符串长度固定为2304,正好是48x48。切分时用stratify=y保证每类表情在训练集和验证集里的比例一致——如果不做这个,遇到disgust类样本极少的数据集,验证集可能一个disgust样本都没有,混淆矩阵看起来会缺失一行。
3.2 轻量CNN模型:用MobileNetV2做特征提取
表情识别不需要像ImageNet那样的大模型。常见做法是用MobileNetV2迁移学习,把最后全连接层换成7分类输出。这样在CPU上也能跑出不错的实时性。
from tensorflow.keras.applications import MobileNetV2 from tensorflow.keras.layers import Dense, Dropout, GlobalAveragePooling2D from tensorflow.keras.models import Model # 输入按FER2013的48x48灰度设计,但MobileNetV2要求3通道,需要复制成3通道 base_model = MobileNetV2( input_shape=(48, 48, 3), include_top=False, weights='imagenet' ) model = Sequential([ Input(shape=(48, 48, 1)), Conv2D(3, (3,3), padding='same'), # 转成3通道,卷积核用xavier初始化 base_model, GlobalAveragePooling2D(), Dropout(0.5), Dense(7, activation='softmax') ])这里有个容易忽略的点:weights='imagenet'的预训练权重是针对3通道彩色图训练的,直接把单通道灰度图喂进去会破坏预训练分布。所以先用一个卷积层把1通道映射到3通道,再接base_model。这个映射层会让预训练权重在初期有一点适应成本,但总体收敛速度仍比从零训练快很多。
3.3 训练策略:数据增强与早停
from tensorflow.keras.callbacks import EarlyStopping, ReduceLROnPlateau from tensorflow.keras.optimizers import Adam from tensorflow.keras.preprocessing.image import ImageDataGenerator # 数据增强:只做轻微变换,避免表情特征被破坏 datagen = ImageDataGenerator( rotation_range=8, # 小角度旋转,超过10度会改变表情语义 width_shift_range=0.08, height_shift_range=0.08, zoom_range=0.08, horizontal_flip=True, fill_mode='nearest' ) model.compile( optimizer=Adam(learning_rate=1e-4), loss='categorical_crossentropy', metrics=['accuracy'] ) callbacks = [ EarlyStopping(monitor='val_accuracy', patience=15, restore_best_weights=True), ReduceLROnPlateau(monitor='val_accuracy', factor=0.5, patience=5, min_lr=1e-6) ] # batch_size=64,够了;FER2013总共只有3万多张图,batch太大容易过拟合 model.fit( datagen.flow(X_train, y_train, batch_size=64), validation_data=(X_val, y_val), epochs=80, callbacks=callbacks, verbose=1 )数据增强里最怕参数设置过猛。rotation_range=8是血泪教训:早期我试过15度,训练集损失降到很低,但真实摄像头下表情识别率反而下降。原因很简单——人脸表情对姿态变化极敏感,大笑变成侧脸15度,模型就把它当成neutral了。horizontal_flip 是安全的,因为表情具有水平对称性,但要注意FER2013里部分样本翻转后语义不成立(比如闭眼方向),实际使用中仍然可用。
4. 把模型串成实时推理:摄像头表情识别完整实现
训练好的模型只是仓库里的一个权重文件,要让源代码真正发挥作用,必须把它接到摄像头输入上,完成“帧捕获 → 人脸检测 → 表情分类 → 结果输出”的循环。这里给出一个可直接运行的实时推理脚本,这往往是开源代码里最缺失、却最需求旺盛的部分。
4.1 实时推理脚本:MTCNN + Keras模型联动
import cv2 import numpy as np from tensorflow.keras.models import load_model from facenet_pytorch import MTCNN # 加载训练好的模型 model = load_model('models/emotion_mobilenet_v2.h5') mtcnn = MTCNN(keep_all=False, select_largest=True, post_process=False) cap = cv2.VideoCapture(0) # 情绪中文标签,顺序需与训练时类别索引一致 emotion_labels = ['angry', 'disgust', 'fear', 'happy', 'sad', 'surprise', 'neutral'] # 缓存上一帧分类结果做简单平滑 prev_class_idx = 0 class_count = {} def predict_emotion(face_region, model): # face_region是RGB且尺寸为48*48 input_tensor = cv2.cvtColor(face_region, cv2.COLOR_RGB2GRAY) input_tensor = cv2.resize(input_tensor, (48, 48)).astype('float32') / 255.0 input_tensor = np.expand_dims(input_tensor, axis=(0, -1)) preds = model.predict(input_tensor, verbose=0)[0] return np.argmax(preds), np.max(preds) while True: ret, frame = cap.read() if not ret: break rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) face, landmarks = mtcnn.detect(rgb, landmarks=True) if face is not None: x1, y1, x2, y2 = [int(v) for v in face[0]] # 边距扩展,防止刘海或下巴切割丢失表情区域 h, w = frame.shape[:2] mx1, my1 = max(0, x1 - int(0.15 * (x2 - x1))), max(0, y1 - int(0.15 * (y2 - y1))) mx2, my2 = min(w, x2 + int(0.15 * (x2 - x1))), min(h, y2 + int(0.15 * (y2 - y1))) face_region = rgb[my1:my2, mx1:mx2] class_idx, confidence = predict_emotion(face_region, model) # 简单滑动窗口去抖:连续3帧同标签才更新 class_count[class_idx] = class_count.get(class_idx, 0) + 1 if class_count[class_idx] >= 3: prev_class_idx = class_idx class_count.clear() # 绘制检测框和识别结果 cv2.rectangle(frame, (mx1, my1), (mx2, my2), (0, 255, 0), 2) label = f"{emotion_labels[prev_class_idx]}: {confidence:.2f}" cv2.putText(frame, label, (mx1, my1 - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 255, 0), 2) cv2.imshow('Emotion Recognition', frame) if cv2.waitKey(1) & 0xFF == ord('q'): break cap.release() cv2.destroyAllWindows()这段脚本里有三个细节值得留意。第一,predict_emotion里先把人脸区域转灰度再缩放——不能直接用MTCNN裁剪出来的彩色图喂模型,因为训练时输入是单通道灰度。第二,类别标签顺序必须和训练时完全一致,否则识别结果会是错位的。第三,滑动窗口去抖是实时表情识别的刚需,裸输出置信度会导致情绪标签在相邻帧之间反复跳动,体验很差。
4.2 置信度阈值与未检测到人脸的处理
识别结果不能只看最大概率索引,还要看这个概率够不够高。FER2013训练出的模型对“neutral”类有天然偏好,很多非中性表情的置信度只有0.5左右。我在实际项目里会在3帧滑动窗口基础上叠加阈值逻辑:
if confidence > 0.45: class_count[class_idx] = class_count.get(class_idx, 0) + 1 else: class_count.clear()阈值太严(比如0.7)的表现是:画面里有表情但一直显示neutral,因为模型对部分表情的置信度普遍不够高。阈值太松(比如0.3)的表现是:随意一张背景都识别成某个情绪。0.45是我在FER2013迁移学习模型上测试多轮后的平衡点,不同数据集上可能需要微调。验证方法很简单——录制一段视频,逐帧打印置信度分布,看类间重叠区域在哪里。
未检测到人脸时,输出缓冲区和历史状态需要清空,否则会出现“人已经离开画面,屏幕上还停着happy”的尴尬情况。加一帧检测失败就清除状态:
if face is None: class_count.clear() cv2.putText(frame, "No face", (20, 40), cv2.FONT_HERSHEY_SIMPLEX, 1.0, (0, 0, 255), 2)5. 表情识别避坑指南:5个最容易翻车的细节
开源代码下载后跑不通,八成不是模型问题,而是数据流和预处理链路的隐性断层。以下每条都是我在实际调试中踩过的坑,按“现象 → 原因 → 解决”记录,希望能帮你省去几天排查时间。
5.1 训练准确率高、摄像头实测全变成neutral
- 现象:模型在验证集上准确率82%,但对着摄像头做表情,几乎全部识别为neutral。
- 原因:训练数据是中心裁剪的48x48灰度图,而推理时传入的人脸区域包含太多背景和颈部,或者人脸没有对齐。FER2013里人脸基本居中,模型根本没有见过“五官偏置”的输入分布。
- 解决:推理前必须做人脸对齐。用MTCNN返回的5个关键点,把双眼连线角旋正,再裁剪人脸区域。对齐能显著改变输入分布,比任何模型调参都有效。最小实现:算出左眼和右眼连线与水平线的夹角,用仿射变换旋转人脸区域。
def align_and_crop(rgb, landmarks, margin=0.2, size=48): left_eye, right_eye = landmarks[0][:2], landmarks[1][:2] dx = right_eye[0] - left_eye[0] dy = right_eye[1] - left_eye[1] angle = np.degrees(np.arctan2(dy, dx)) rotation_matrix = cv2.getRotationMatrix2D(center=left_eye, angle=angle, scale=1.0) aligned = cv2.warpAffine(rgb, rotation_matrix, (rgb.shape[1], rgb.shape[0])) # 再按原检测框裁剪并扩展 ...5.2 摄像头画面卡顿,FPS不到10
- 现象:实时推理帧率低,画面像幻灯片。MTCNN每帧都要在整张图上做前向传播。
- 原因:MTCNN在640x480的帧上检测耗时约50-80ms,同时表情分类模型每帧推理又占20ms,串行执行必然卡顿。最常见的优化空间不在模型,在调用方式。
- 解决:三管齐下。第一,把检测到的上一帧人脸区域中心作为下一帧搜索区域,缩小MTCNN的输入尺寸。第二,降低检测频率——每5帧做一次完整人脸检测,中间几帧沿用上一帧的检测框坐标。第三,表情分类模型输入从48x48提升到96x96会对小尺寸框更友好,但耗时也会增加,属于可选权衡。
5.3 训练时loss不降反升
- 现象:训练前几个epoch loss缓慢上升,然后突然掉下来,再上升,呈锯齿状。
- 原因:用的是ImageNet预训练的MobileNetV2,但前面的灰度转三通道卷积层是随机初始化的,梯度更新时把这个映射层震荡传导给了后续层。另一个可能是
lr=1e-4对迁移学习偏高。 - 解决:先把映射卷积层和base_model一起冻结,只训练最后的Dense层,跑10个epoch,再解冻所有层用
lr=1e-5微调。这种分阶段训练策略在表情识别上远比一次性端到端训练稳定。
5.4 类别不平衡:disgust识别率几乎为零
- 现象:验证集上disgust类recall为0,模型把所有样本都判成angry或neutral。
- 原因:FER2013中disgust样本量只有几百张,占比不到总体1%。softmax分类器天然偏向高频类别。
- 解决:给损失函数加类别权重,让少数类的错误对梯度的贡献更大。权重计算用
sklearn.utils.class_weight.compute_class_weight,在模型compile时通过class_weight参数传入,而不是手动改动数据集。
from sklearn.utils.class_weight import compute_class_weight class_weights = compute_class_weight( 'balanced', classes=np.unique(df['emotion']), y=df['emotion'] ) class_weight_dict = {i: w for i, w in enumerate(class_weights)} model.fit(..., class_weight=class_weight_dict)5.5 关键点坐标与裁剪框坐标系不一致
- 现象:MTCNN返回的landmarks与检测框位置对不上,对齐后人脸偏移严重。
- 原因:
post_process=True时MTCNN内部对坐标做缩放,返回的是相对于输入图像宽高的比例值;而keep_all模式下返回的坐标含义又有差异,混用两种模式就会出偏差。 - 解决:全链路统一用
post_process=False、keep_all=False的配置,让所有坐标都保持在原始输入图像的像素空间。凡是涉及MTCNN坐标的代码都复用同一组配置,不混搭。
6. 进阶:从离线识别到实时视频流的三点优化
代码能跑通只是第一步。如果要把这套表情识别真正用到客服情绪分析、课堂专注度统计之类的场景,离线单张图片的准确率远远不够,必须打通视频流处理链路。这里分享三个面向真实部署的优化技巧。
技巧一:用缓存池减少MTCNN调用次数。我的做法是维护一个上一帧的人脸框坐标池。新的一帧到达时,先在上次坐标周围扩大1.2倍区域做检测,只有区域内置信度低于阈值时,才退回全图检测。这个池化策略在固定摄像头场景下能省掉七成以上的检测计算量,帧率可以提升近一倍。如果摄像头可移动,建议改为每5帧全图检测一次,中间帧用目标跟踪算法(如OpenCV的TrackerKCF)跟住人脸框,成本比持续跑MTCNN低得多。
技巧二:把模型导出为ONNX或TensorRT格式。Keras的H5格式在部署时依赖TensorFlow运行时,启动慢且占用内存大。生产环境我习惯用tf2onnx.convert把模型转成ONNX,再用ONNX Runtime推理,依赖轻、部署路径干净。导出时务必固定输入尺寸和batch维,否则转换工具会生成动态shape,推理时莫名报错。转换完成后用同一张测试图对比H5和ONNX的输出张量,误差在1e-3以内才算通过。
技巧三:用情感概率时间序列替代单帧标签。单帧识别天然不稳定,相邻帧标签跳变非常影响用户体验和下游业务统计。我习惯保存最近30帧的7类概率向量,取平均后输出最高概率类别。这个滑动平均方法非常简单,却比滑窗投票效果更好——概率平均能保留“惊讶持续0.3秒但概率值高”这类短时强信号,而投票机制会直接丢弃这些帧。对视频流分析来说,30帧平均还附带一个实际好处:可以统计某个人在一段时间内的情绪分布,而不是只输出一个瞬时标签。
最后留个习惯:每次改完推理链路,我都用一段固定不变的测试视频做回归验证,确保新改动不破坏已有精度。这类项目翻车往往是“为了优化A功能毁了B功能”,回归测试是成本最低的后悔药。希望帮到你。
本文还有配套的精品资源,点击获取