简介这是一套基于OpenCV实现的人脸检测与表情识别完整工程面向计算机视觉初学者与希望快速上手表情识别项目的开发者。项目覆盖人脸定位、特征提取、分类识别全流程可识别喜、怒、哀、乐、惊喜等常见情感状态并附有可直接运行的源码与工程配置适合用于课程设计、毕业设计或算法入门实践。压缩包共151个文件大小约12.02MB主要包含C源文件.cpp/.h、VC工程相关文件.dsp/.dsw/.vcproj等、示例人脸图像.bmp以及编译生成的辅助文件整体结构较完整便于在Visual Studio环境下打开调试。已有2206人浏览学习具有一定参考价值。代码中内置Haar级联分类器进行人脸检测并结合LBP、HOG或CNN等特征提取思路与SVM等分类器完成表情判断读者可对照源码理解级联检测、特征工程与模型推理的实现细节还可依据自带参数调整检测路径和分类模型快速移植到人机交互、情感计算等实际场景。1. 人脸表情识别源代码我建议你先跑通实时推理再碰训练人脸表情识别这个方向很多人一上来就扎进训练脚本里调参结果两周过去模型在测试集上刷到了 85%摄像头一开却只有十几帧还经常把“中性”认成“厌恶”。我见过太多这样的翻车现场。这个标题里的“源代码”三个字真正值钱的不是那一堆训练代码而是从图片到实时视频流的完整链路人脸检测先把脸抠出来表情分类再把抠出来的脸映射到七类情绪上。检测不稳分类再准也白搭。这篇笔记沿着“选型 → 训练 → 推理 → 避坑”的顺序走一遍新手能照着复现熟手可以直接跳到第五章看踩坑记录和第六章的导出验证。2. 选型人脸检测与表情分类哪条路线最不容易翻车2.1 人脸检测级联分类器与深度学习检测器按场景选型做表情识别第一步永远是先把人脸从画面里框出来。这一步的选型直接决定后面推理的帧率和稳定性不太需要在“效果最好”和“跑得动”之间反复纠结。常见做法是二选一轻量级直接用开源视觉库自带的级联分类器也就是 Haar 特征的级联检测追求复杂背景和侧脸鲁棒性就用深度学习的检测模型。我的经验是在室内固定机位、背景比较干净的场景里级联分类器完全够用CPU 上单帧检测 3 毫秒左右几乎不占资源。但一旦场景里有人走动、光线变化大、或者人脸占比很小级联分类器就会开始漏检和误检这时候要换成深度检测器。选型时可以按这三个条件判断运行设备是树莓派、Jetson 这类低功耗板子优先级联分类器或轻量检测模型人脸在画面里占比超过 20%且背景单一级联分类器足够需要多人脸、侧脸、遮挡场景直接上深度学习检测器别浪费时间调 Haar 参数。检测框的质量直接影响表情分类。级联分类器给的框经常不够紧把额头、头发甚至背景都框进来分类网络输入的噪声就大。我一般会在检测框基础上向内收缩 10%15%让眼睛和嘴部区域占比更高这一个小改动经常比换模型还管用。import cv2 face_cascade cv2.CascadeClassifier( haarcascade_frontalface_default.xml ) def detect_face(image, shrink0.12): gray cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) faces face_cascade.detectMultiScale( gray, scaleFactor1.1, minNeighbors5, minSize(64, 64) ) if len(faces) 0: return None x, y, w, h faces[0] dx, dy int(w * shrink), int(h * shrink) return (x dx, y dy, w - 2 * dx, h - 2 * dy)scaleFactor1.1表示每次搜索窗口缩小 10%数值越接近 1.0 检测越慢但越不容易漏minNeighbors5是抑制误检的强度调大到 8 可以减少误检但可能漏掉被遮挡的脸。shrink0.12是我实验下来对 48×48 输入比较稳的收缩比例框缩太狠会把下巴切掉影响张嘴类表情比如惊讶的识别。2.2 表情分类网络从零训练一个轻量 CNN 需要哪些配置表情分类本质上是一个图像分类任务输入是检测器抠出来的人脸图。公开的七类表情数据集一般是 48×48 的灰度图标签为愤怒、厌恶、恐惧、开心、中性、悲伤、惊讶这七类。这个分辨率很低所以网络不需要很深堆到 20 层以上的 ResNet 结构在这个任务上基本是浪费算力。我通常用一个三层卷积加全连接的小网络参数量在 20 万左右CPU 上推理一张图 2 毫秒内。不要迷信预训练模型——ImageNet 预训练特征对表情这种细微纹理任务帮助有限而且灰度图输入和预训练的三通道输入本身就有适配成本。一个重要的选型理由是输入尺寸。如果把检测框直接缩放到 48×48信息量足够但如果你想把网络换成 112×112 的输入检测框的收缩比例就要重新调否则面部特征会被放大到失真的程度。这里的经验是先定输入尺寸再定检测框收缩比例两个参数一起调不要只改一个。2.3 数据集组织与框架选择目录结构决定你后面少踩多少坑训练代码本身的坑并不多真正让人头疼的是数据组织。我见过有人把所有图片打平成一个大数组丢给网络最后连哪张图属于哪一类都分不清调模型的时候完全抓瞎。正确的目录结构应该是这样的data/ emo/ train/ angry/ # 0 disgust/ # 1 fear/ # 2 happy/ # 3 neutral/ # 4 sad/ # 5 surprise/# 6 val/ angry/ ...框架选择上我一般用 PyTorch 搭配 torchvision 的ImageFolder因为它的标签排序就是按目录名的字母序目录命名从第一个字符就要规范。ImageFolder会自动把angry映射到 0disgust映射到 1这个顺序后续推理时要保持一致否则会出现“模型明明训练得很好部署后全预测错位”的尴尬情况。from torchvision import datasets, transforms data_transform transforms.Compose([ transforms.Grayscale(num_output_channels1), transforms.Resize((48, 48)), transforms.ToTensor(), transforms.Normalize(mean[0.5], std[0.5]), ]) train_data datasets.ImageFolder( data/emo/train, transformdata_transform ) val_data datasets.ImageFolder( data/emo/val, transformdata_transform )Normalize(mean[0.5], std[0.5])表示把像素值从 [0, 1] 映射到 [-1, 1]这是训练和推理必须完全一致的预处理。我踩过这个坑训练时用了归一化推理时忘了结果准确率直接从 80% 掉到 30%而且这个现象非常隐蔽模型看起来在跑输出概率也很平滑就是类别不对。3. 把训练跑起来一个可直接复现的 CNN 表情分类代码3.1 数据加载与预处理灰度归一化、数据增强的边界表情识别最容易犯的预处理错误是把数据增强做得太过。比如随机旋转 ±30 度、随机裁剪 30% 这类在通用分类任务上常用的增强用到表情任务上会直接把面部结构破坏掉——旋转 15 度以上嘴和眼睛的相对位置就失真了模型学到的成了“角度伪影”而不是表情本身。我一般只保留三个增强水平翻转、随机平移 ±2 像素、随机亮度扰动。其中水平翻转是安全的因为七类表情在镜像后语义不变除了极少数的非对称表情习惯。平移控制在 2 像素内相当于模拟检测框的轻微抖动。亮度扰动可以缓解后面要讲的光照泛化问题。train_transform transforms.Compose([ transforms.Grayscale(num_output_channels1), transforms.Resize((48, 48)), transforms.RandomHorizontalFlip(p0.5), transforms.RandomAffine(degrees0, translate(0.04, 0.04)), transforms.ColorJitter(brightness0.2), transforms.ToTensor(), transforms.Normalize(mean[0.5], std[0.5]), ])RandomAffine只设平移不设旋转是为了模拟检测框的位置抖动。translate(0.04, 0.04)表示在水平和垂直方向各随机平移输入尺寸的 4%48 像素的 4% 大约就是 2 个像素这个幅度既能增强鲁棒性又不会把关键部位移出画面。brightness0.2是亮度增益的幅度超过 0.3 会让暗光下的样本变得像曝光过度反而增加训练难度。3.2 最小训练脚本模型定义、损失函数与超参数七类表情分类的模型不需要花哨一个三层卷积网络加上全局平均池化就能跑出可用的结果。下面这个脚本是一个完整的最小实现我很多项目都是从这个结构起步的。它不追求刷榜追求的是“第一次训练就能稳定收敛”。import torch import torch.nn as nn from torch.utils.data import DataLoader class MiniExprNet(nn.Module): def __init__(self, num_classes7): super().__init__() self.features nn.Sequential( nn.Conv2d(1, 32, 3, padding1), nn.BatchNorm2d(32), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), nn.Conv2d(32, 64, 3, padding1), nn.BatchNorm2d(64), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), nn.Conv2d(64, 128, 3, padding1), nn.BatchNorm2d(128), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), ) self.classifier nn.Sequential( nn.AdaptiveAvgPool2d(1), nn.Flatten(), nn.Linear(128, num_classes), ) def forward(self, x): return self.classifier(self.features(x)) model MiniExprNet(num_classes7) criterion nn.CrossEntropyLoss() optimizer torch.optim.Adam(model.parameters(), lr1e-3) scheduler torch.optim.lr_scheduler.StepLR( optimizer, step_size10, gamma0.5 ) train_loader DataLoader( train_data, batch_size64, shuffleTrue, num_workers2 ) val_loader DataLoader( val_data, batch_size64, shuffleFalse, num_workers2 )BatchNorm2d在 48×48 这种小输入上非常重要它让每一层的输入分布保持稳定允许你用更高的学习率而不会发散。AdaptiveAvgPool2d(1)把最后一层特征图压成一个 128 维向量这样网络对输入尺寸的变化不那么敏感虽然我们用固定 48×48 输入但这一层为以后换成其他输入尺寸留了余地。step_size10, gamma0.5表示每 10 个 epoch 学习率减半如果发现第 20 个 epoch 后验证集准确率开始震荡可以把 step 改成 8。训练循环里有一个容易被忽略的细节验证阶段必须用torch.no_grad()而且要把模型切成eval()模式。eval()会关闭 BatchNorm 的统计更新否则验证时的 BN 统计还在被训练批次的分布影响导致验证曲线的噪声很大看起来像“玄学波动”。for epoch in range(30): model.train() for images, labels in train_loader: outputs model(images) loss criterion(outputs, labels) optimizer.zero_grad() loss.backward() optimizer.step() scheduler.step() model.eval() correct 0 total 0 with torch.no_grad(): for images, labels in val_loader: outputs model(images) preds outputs.argmax(dim1) correct (preds labels).sum().item() total labels.size(0) acc correct / total print(fepoch {epoch1}, val_acc {acc:.4f}) torch.save(model.state_dict(), expr_best.pth)batch_size64是折中值显存不够可以降到 32但不要低于 16否则 BatchNorm 在小批次上的统计不稳定。30 个 epoch 对这个模型足够我见过有人一跑就是 200 个 epoch最后的收益几乎为零还白白浪费时间。3.3 训练曲线怎么读loss 在降、accuracy 不动时怎么办训练过程中最让人焦虑的画面是loss 一路从 1.8 降到 0.6但验证准确率始终在 60% 上下不动。这种“loss 在降、accuracy 不动”的现象很常见它通常不是模型坏了而是训练已经进入了慢速爬坡阶段。一个直接的排查方法是看预测分布。如果某一类的预测概率始终很低说明这一类在训练集里样本太少模型把它当成了“不该存在的类别”。另一个常见原因是学习率衰减太快导致后期权重更新步长过小loss 在小范围内波动准确率自然上不去。我的处理方式是先检查类别样本数如果最少的类和最多的类差距超过 5 倍用加权交叉熵如果样本均衡就把学习率从 1e-3 调回 5e-3同时把 StepLR 的 gamma 从 0.5 改成 0.7给模型更长的冲刺空间。很多情况下准确率会在两三个 epoch 内突然跳升几个点这是正常现象不要因为中间有回落就过早终止训练。4. 打通摄像头实时推理从单张图片到视频流的落地改造4.1 单张图片推理的最小代码训练完的模型要先在单张图片上验证不要直接上摄像头。这一步的目的不是看准确率而是确认预处理链路和标签映射没有错。import cv2 import torch import numpy as np from torchvision import transforms model MiniExprNet(num_classes7) model.load_state_dict( torch.load(expr_best.pth, map_locationcpu) ) model.eval() infer_transform transforms.Compose([ transforms.ToPILImage(), transforms.Grayscale(num_output_channels1), transforms.Resize((48, 48)), transforms.ToTensor(), transforms.Normalize(mean[0.5], std[0.5]), ]) EMO_LABELS [ angry, disgust, fear, happy, neutral, sad, surprise, ] def infer_face(image_bgr, box): x, y, w, h box face image_bgr[y:yh, x:xw] tensor infer_transform(face).unsqueeze(0) with torch.no_grad(): logits model(tensor) prob torch.softmax(logits, dim1)[0] idx prob.argmax().item() return EMO_LABELS[idx], prob[idx].item()map_locationcpu在没装 GPU 的机器上能直接加载训练好的权重省去 CUDA 报错的麻烦。unsqueeze(0)给单张图片加一个批次维度因为网络的 forward 期望的输入是四维张量。这段代码跑通后你才真正拥有了一个“从图片到表情标签”的最小闭环。4.2 视频流加速丢帧策略、ROI 放大与概率平滑摄像头推理和单张图片的差别在于连续帧之间存在大量重复信息不需要每一帧都跑检测和分类。常见做法是检测器每隔 3 帧跑一次分类器用检测框裁剪后连续跑每一帧其实更省资源的策略是两者同时降频。我一般这样设计检测器每 5 帧跑一次中间的帧直接用上一帧的检测框做分类。这个策略的前提是摄像头帧率不低于 15 FPS如果低于 15 FPS眨眼和表情变化会在帧间丢失后续的概率平滑也救不回来。cap cv2.VideoCapture(0) frame_skip 5 frame_count 0 last_box None while True: ret, frame cap.read() if not ret: break frame_count 1 if frame_count % frame_skip 0: last_box detect_face(frame) if last_box is None: cv2.imshow(emo, frame) if cv2.waitKey(1) 0xFF ord(q): break continue label, conf infer_face(frame, last_box) x, y, w, h last_box cv2.rectangle(frame, (x, y), (xw, yh), (0, 255, 0), 2) cv2.putText(frame, f{label} {conf:.2f}, (x, y-10), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 255, 0), 2) cv2.imshow(emo, frame) if cv2.waitKey(1) 0xFF ord(q): breakframe_skip5意味着检测器每秒只跑 5 次左右在 30 FPS 的摄像头下这个频率足以跟上普通人脸的运动速度。如果发现检测框明显滞后于实际人脸把 skip 改回 3。这里有一个小优化点分类输入用的检测框不要直接取last_box最好把它做一次指数平滑。摄像头的检测框本身有抖动直接裁剪出来的画面会忽大忽小分类网络对这种输入非常敏感。平滑公式很简单box_smoothed 0.7 * box_smoothed 0.3 * box_current初始值用第一帧的检测框。4.3 输出工程化置信度阈值怎么定界面才不会乱跳模型输出的七个概率不能直接拿来显示。最典型的问题一个人面无表情地看屏幕模型可能以 0.35 的概率输出“中性”0.3 输出“悲伤”这两者在画面上来回横跳观感极差。处理方法是加两道关卡置信度阈值和回滞比较。置信度阈值的意思是最大概率低于这个值就显示“未知”或“检测中”而不是硬给一个标签。回滞比较的意思是只有当新的候选标签连续 3 帧都不同于当前显示标签时才切换显示。label, conf infer_face(frame, last_box) if conf 0.6: display_label unknown else: candidate label if candidate ! display_label: pending_count 1 if pending_count 3: display_label candidate pending_count 0 else: pending_count 0阈值 0.6 是一个比较稳的起点。数据集本身存在一些人工标注都不确定的模糊样本强行要求 0.8 会把很多有效识别挡在门外而低于 0.5 又会被误检干扰。回滞的 3 帧窗口可以根据真实 fps 调整15 FPS 的摄像头建议用 5 帧窗口也就是约 0.3 秒的确认时间既不会迟钝也不会闪烁。5. 人脸表情识别避坑实录五个真实翻车点与修复方案5.1 检测框抖动导致表情概率来回跳现象人在镜头前不动画面上的表情标签却在“中性”和“悲伤”之间来回切换而且概率值波动很大。原因级联分类器对每一帧的检测框位置有小幅抖动裁剪出来的脸在 23 像素的范围内漂移这个漂移传到分类网络后被放大了好几倍因为网络对边缘敏感。解决两步走。第一步对检测框做指数平滑公式我之前写过平滑系数 0.7 比较稳。第二步对输出概率也做 EMA 平滑窗口大小取 5 帧。两个平滑一起用画面基本稳定。不要只平滑检测框因为分类网络对框内像素的纹理变化同样敏感。5.2 训练集几乎全是正脸侧脸一来准确率腰斩现象训练时验证集准确率 85%一到实际使用侧脸角度超过 30 度准确率直接掉到 50% 附近。原因公开的七类表情数据集大部分是正脸或轻微偏头侧脸样本占比低模型没见过这种几何关系。解决我通常的做法是在推理端补救——检测框拿到侧脸后不做角度纠正因为轻量网络没有足够容量处理旋转而是在训练端增加角度增强。用RandomAffine(degrees(-20, 20))先试如果准确率没有下降逐步加到 30 度同时配合随机水平平移。角度超过 30 度的真实侧脸场景建议换用带角度标注的检测器单独把侧脸帧抽出来做模型微调而不是指望增强能解决。5.3 过曝与逆光好端端被认成“惊讶”现象室内靠窗位置人脸有一半在阴影里模型频繁输出“惊讶”换到均匀灯光下又恢复正常。原因曝光异常的图片里眼睛和嘴部的局部对比度被拉伸特征形状接近惊讶类别的“大眼张嘴”模式。解决训练时用ColorJitter(brightness0.25, contrast0.25)做数据增强这比任何推理端的光照归一化都有效。推理端不要做直方图均衡化因为它会把正常光照的图片也改掉分布导致训练和推理的输入不一致。如果光照条件实在恶劣一个取巧的办法是把输入灰度图做一次局部对比度归一化但在训练脚本里必须加入同样的预处理否则又是演化成“训练推理两张皮”的老问题。5.4 类别不均衡少数类被模型直接放弃现象训练完成后愤怒、厌恶两类在验证集上的召回率不到 20%其余类别都在 80% 以上。原因公开数据集中“厌恶”类的样本数量通常只有“开心”“中性”的五分之一左右普通的交叉熵损失会让模型把稀缺类别当成噪声直接全部预测为多数类。解决两个方案先用加权交叉熵权重按1 / 每类样本数计算再归一化。如果加权后少数类准确率上去了但多数类掉得厉害改用 Focal Loss它的机制是让模型聚焦难分样本而不是单纯调整类别权重。但注意 Focal Loss 有两个超参要调gamma 默认 2.0alpha 按类别逆频率设置这两个参数配合不好反而比加权交叉熵更差。5.5 过拟合训练集 95%验证集卡在 60% 上不去现象训练集准确率一路涨到 95%验证集停在第 20 个 epoch 后就在 60% 附近震荡怎么调学习率都没用。原因模型参数数量约 20 万对 3 万到 5 万张训练图片来说有过拟合空间加上 48×48 的低分辨率模型很容易记住训练集的纹理特征而不是表情语义。解决先试 Dropout在 classifier 的 Linear 层前加nn.Dropout(p0.5)大部分情况下验证集能涨 58 个点。如果还是不行把网络第一层的卷积核数量从 32 降到 16强制模型学更少的底层特征通常能再涨一点。数据增强不要继续加因为在低分辨率下过度增强会让训练集和验证集的分布差得越来越远。6. 进阶把模型导出到边缘设备前先做这几步验证6.1 用 ONNX 导出并对比精度训练完的 PyTorch 模型要部署到边缘设备第一步通常是导出成 ONNX 格式。导出动作本身很简单但有一个必须做的验证导出前后的推理结果必须完全一致。dummy_input torch.randn(1, 1, 48, 48) torch.onnx.export( model, dummy_input, expr_model.onnx, opset_version13, input_names[input], output_names[output], dynamic_axes{input: {0: batch}, output: {0: batch}}, )dynamic_axes设置为 batch 维度可变方便部署端一次处理多张脸。导出后用 ONNX Runtime 加载挑 100 张验证集图片对比 PyTorch 和 ONNX Runtime 的输出概率。两者最大误差超过 1e-4 就要检查是否有不支持的算子比较常见的是某些版本的 BatchNorm 导出后精度偏差偏大可以把 opset 版本升到 14 或 15 再试。6.2 用混淆矩阵定位真实短板准确率是一个会骗人的指标特别是类别不均衡时。一个模型整体准确率 80%可能只是“中性”和“开心”两类撑起来的。部署前先跑一批真实场景图片画出混淆矩阵重点关注两类错误相近情绪的互相混淆悲伤 vs 中性和稀有类被多数类吞掉厌恶 vs 愤怒。如果“悲伤”和“中性”互相混淆严重说明模型提取的特征里嘴部边缘和眼神信息不足。常见做法是在预处理阶段把眼睛和嘴部区域单独裁剪成小图作为第二个输入分支加入网络但这会让模型复杂度和推理耗时明显上升。更实用的做法是接受这个混淆在 UI 层把“悲伤”“中性”合并显示或者在采集时要求用户做更夸张的表情。6.3 量化后必查的三项指标模型导出并验证精度一致后下一步通常是量化成 INT8减少体积和提升推理速度。但量化对表情识别的影响比对一般分类任务更大因为表情特征本身很细微量化误差可能直接抹掉这些细节。量化后必查三项整体准确率下降不超过 3%“愤怒”和“厌恶”这两类的单类召回率下降不超过 5%同一张图片在量化前后的 softmax 概率分布形状不能发生“从集中到平坦”的变化。最后一项最容易被忽略如果量化后模型在所有类别上的概率都变得接近均匀分布说明量化过程破坏了特征表示此时不要强行部署回到训练脚本把模型的权重做一次谱归一化再导出。另外我有一个多年的习惯每次导出或量化完一定会在真实摄像头下跑一次完整的真人测试拿镜子里自己的脸当测试样本盯着输出概率看十秒钟。如果概率是稳定集中在某一类上而不是每帧都在几个类别间跳我才敢把这个模型交出去。模型在测试集上刷分是一回事在真实视频流里稳住是另一回事。这个习惯帮我挡掉了无数次线上翻车也希望帮到你。本文还有配套的精品资源点击获取