ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于深度学习的人脸情绪识别系统:从数据到部署的工程实践

基于深度学习的人脸情绪识别系统:从数据到部署的工程实践 简介这份资源是面向人工智能、深度学习方向的毕业设计与课程设计参考项目聚焦人脸情绪识别这一细分课题适合具备Python基础、希望理解CNN表情分类完整链路的本科或研究生使用。压缩包共11个文件约11.89MB包含3个py脚本分别承担训练、主逻辑与调用接口另有h5与json模型权重及结构文件、caffemodel与prototxt人脸检测模型、csv格式的FER-2013数据集、md说明文档及jpg示例图覆盖从数据到推理的完整环节。项目以卷积神经网络为核心结合YOLO类检测思路先定位人脸再裁剪归一化最终输出快乐、悲伤、愤怒、惊讶等情绪标签可帮助读者掌握数据预处理、模型训练、权重保存与接口封装的具体做法。目前已有60人学习下载适合作为入门情绪识别、复现实验与二次开发的起点。1. 人脸情绪识别系统从一张证件照到七类情绪的工程落地很多人第一次接触人脸情绪识别是拿一张证件照去测结果模型给出的情绪概率七类几乎平均分布最高的一类还不到 0.3。这不是模型坏了而是证件照本身没有情绪表达——中性脸在 FER2013 这类数据集里占比过高模型学到的先验就是「大部分脸都是中性」。基于深度学习的人脸情绪识别系统要解决的核心问题不是「能不能分类」而是「在真实场景下人脸检测、对齐、光照归一化、时序平滑这几步怎么串起来才能让输出稳定可用」。它适合两类人一类是想把情绪识别接进在线课堂状态检测、客服质检、互动装置的产品和算法工程师另一类是正在做深度学习实战项目案例、想找一个端到端可复现 pipeline 的学生。下面按「数据怎么准备 → 模型怎么选 → 训练怎么调 → 部署怎么稳」的顺序拆开讲中间会给出可直接抄的代码和参数。2. 数据管线从原始人脸图到可训练张量2.1 主流数据集选型与标签体系对齐人脸情绪识别常用的公开数据集有 FER2013、RAF-DB、AffectNet、CK。选哪个取决于你的目标场景做课堂状态检测学生脸小、遮挡多、光照杂AffectNet 的野外样本更接近做实验室 demoFER2013 的 48×48 灰度图足够跑通流程。标签体系上常见的是 Ekman 六类加中性共七类angry、disgust、fear、happy、sad、surprise、neutral。不同数据集的标签命名和数量不一致必须先做映射表否则训练时会出现某一类永远预测不到的情况。数据集样本量级分辨率标签数适用场景FER2013约 3.5 万48×48 灰度7快速验证、教学RAF-DB约 1.5 万100×100 彩色7精度对比AffectNet约 40 万原图8含 contempt野外场景、课堂检测CK约 1000 序列原图7时序建模验证我一般会先统一到七类把 contempt 合并进 neutral 或直接丢弃避免类别过细导致样本不足。映射逻辑写成字典放在数据加载脚本顶部后面所有数据集都走同一套映射。2.2 人脸检测与对齐的最小可跑代码原始图片不能直接送进情绪分类网络必须先检测人脸框并做关键点对齐。常见做法是用 MTCNN 或 RetinaFace 做检测再用五点关键点做仿射变换把眼睛和嘴巴对齐到固定位置。下面这段是本地跑通的最小命令依赖 facenet-pytorch 和 opencv。import cv2 import torch from facenet_pytorch import MTCNN from PIL import Image import numpy as np # 初始化检测器keep_allFalse 表示只保留置信度最高的一张脸 mtcnn MTCNN(keep_allFalse, devicecuda if torch.cuda.is_available() else cpu) def align_face(img_path, output_size224): img Image.open(img_path).convert(RGB) # 检测关键点返回 shape 为 (5,2) 的数组 boxes, probs, landmarks mtcnn.detect(img, landmarksTrue) if boxes is None: return None # 用双眼和嘴巴中心做仿射变换把脸摆正 left_eye, right_eye, nose, mouth_left, mouth_right landmarks[0] eye_center ((left_eye[0] right_eye[0]) / 2, (left_eye[1] right_eye[1]) / 2) dx right_eye[0] - left_eye[0] dy right_eye[1] - left_eye[1] angle np.degrees(np.arctan2(dy, dx)) # 以双眼中心为原点旋转 M cv2.getRotationMatrix2D(eye_center, angle, 1.0) rotated cv2.warpAffine(np.array(img), M, (img.width, img.height), flagscv2.INTER_CUBIC) # 按检测框裁剪并缩放到固定尺寸 x1, y1, x2, y2 boxes[0] face rotated[int(y1):int(y2), int(x1):int(x2)] face cv2.resize(face, (output_size, output_size)) return face if __name__ __main__: face align_face(test.jpg) if face is not None: cv2.imwrite(aligned.jpg, cv2.cvtColor(face, cv2.COLOR_RGB2BGR))逻辑说明MTCNN 的 detect 返回框和五点关键点先按双眼连线角度旋转整图再按框裁剪这样能消除头部倾斜带来的类内差异。参数上output_size 设 224 是为了接 ResNet 系列如果要用轻量模型如 MobileNetV3可以降到 112 或 96推理速度会明显提升。keep_all 设 False 是因为情绪识别通常只关心画面主体多人场景需要改成 True 再逐脸处理。2.3 数据增强的边界哪些增强会破坏情绪线索情绪识别和通用图像分类不同水平翻转要谨慎——左右脸对称性对情绪影响不大可以翻但垂直翻转、大角度旋转会破坏眉毛和嘴角的相对位置模型会学到错误特征。我一般只用随机水平翻转、±10 度以内旋转、亮度对比度微调。颜色抖动幅度要小因为肤色变化本身可能被模型误当成情绪线索。下面是一个可用的增强配置。from torchvision import transforms train_tf transforms.Compose([ transforms.RandomHorizontalFlip(p0.5), transforms.RandomRotation(degrees10), transforms.ColorJitter(brightness0.2, contrast0.2, saturation0.1), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) val_tf transforms.Compose([ transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ])注意 RandomRotation 的 degrees 不要超过 15ColorJitter 的 saturation 不要超过 0.2否则验证集准确率会掉 3 到 5 个点。这是血泪经验不是理论推导。3. 模型选型与训练CNN 还是 ViT参数怎么设3.1 骨干网络对比ResNet、MobileNet、ViT 的取舍情绪识别本质是细粒度分类类间差异小尤其是 fear 和 surprise、sad 和 neutral 容易混。ResNet-50 是稳妥的基线ImageNet 预训练权重能提供不错的底层特征。MobileNetV3 适合端侧部署参数量小但精度通常比 ResNet 低 2 到 4 个点。ViT 在数据量足够时表现更好但 FER2013 这种小数据集上容易过拟合需要强增强和较长 warmup。我一般先用 ResNet-50 跑通再根据部署约束换 MobileNetV3 或 EfficientNet-B0。import torch.nn as nn from torchvision import models def build_model(num_classes7, backboneresnet50, pretrainedTrue): if backbone resnet50: model models.resnet50(weightsmodels.ResNet50_Weights.IMAGENET1K_V2 if pretrained else None) # 替换最后一层全连接输出七类 model.fc nn.Linear(model.fc.in_features, num_classes) elif backbone mobilenet_v3: model models.mobilenet_v3_large(weightsmodels.MobileNet_V3_Large_Weights.IMAGENET1K_V2 if pretrained else None) model.classifier[-1] nn.Linear(model.classifier[-1].in_features, num_classes) return model参数说明pretrained 为 True 时加载 ImageNet 权重这是小数据集上最重要的技巧之一。num_classes 固定为 7如果做二分类正负情绪改成 2。替换分类头后建议前几个 epoch 冻结骨干只训分类头之后再解冻全网络微调学习率降到 1e-4 量级。3.2 训练循环与关键超参训练循环里最容易被忽视的是类别不平衡。FER2013 里 happy 和 neutral 样本多disgust 和 fear 少直接训练会导致少数类召回率极低。常见做法是加权交叉熵权重按类别频率的倒数计算。下面是一个可复现的训练片段。import torch from torch.utils.data import DataLoader from torch.optim import AdamW from torch.optim.lr_scheduler import CosineAnnealingLR def train_one_epoch(model, loader, optimizer, criterion, device): model.train() total_loss, correct, total 0, 0, 0 for imgs, labels in loader: imgs, labels imgs.to(device), labels.to(device) optimizer.zero_grad() logits model(imgs) loss criterion(logits, labels) loss.backward() optimizer.step() total_loss loss.item() * imgs.size(0) correct (logits.argmax(1) labels).sum().item() total imgs.size(0) return total_loss / total, correct / total # 类别权重按训练集频率倒数计算后归一化 class_weights torch.tensor([1.5, 3.0, 2.5, 0.8, 1.8, 2.2, 0.6]).to(device) criterion torch.nn.CrossEntropyLoss(weightclass_weights) optimizer AdamW(model.parameters(), lr3e-4, weight_decay1e-4) scheduler CosineAnnealingLR(optimizer, T_max30)逻辑说明class_weights 的顺序要和标签索引一致不能凭感觉写必须从训练集统计里算出来。AdamW 的 weight_decay 设 1e-4 是常用起点太大模型欠拟合太小过拟合。CosineAnnealingLR 的 T_max 设成总 epoch 数让学习率平滑降到接近零。batch size 在显存允许下尽量大32 或 64 都行太小会让 BatchNorm 统计不稳。3.3 验证指标准确率不够要看混淆矩阵和 F1情绪识别不能只看总体准确率。如果模型把所有样本都预测成 neutral在 FER2013 上也能拿到 30% 以上的准确率但完全不可用。必须看每类 F1 和混淆矩阵。常见做法是每个 epoch 结束后在验证集上算 macro-F1保存 F1 最高的 checkpoint而不是准确率最高的。下面这段是验证和混淆矩阵输出。from sklearn.metrics import f1_score, confusion_matrix import numpy as np def evaluate(model, loader, device): model.eval() preds, gts [], [] with torch.no_grad(): for imgs, labels in loader: imgs imgs.to(device) logits model(imgs) preds.extend(logits.argmax(1).cpu().numpy()) gts.extend(labels.numpy()) macro_f1 f1_score(gts, preds, averagemacro) cm confusion_matrix(gts, preds) return macro_f1, cm参数说明averagemacro 表示每类权重相同适合类别不平衡场景。混淆矩阵里重点看 fear 和 surprise 的交叉、sad 和 neutral 的交叉这两组是最容易翻车的地方。如果某一类召回率低于 0.4优先补该类样本或调高该类权重。4. 推理部署从单张图到视频流的稳定输出4.1 单张图推理与置信度过滤训练完的模型直接推理单张图常见问题是置信度分布很平最高类只有 0.4 左右。这时候不要硬取 argmax而是设一个阈值低于阈值输出「不确定」。下面是一个带过滤的推理函数。def predict_emotion(model, face_img, device, threshold0.5): model.eval() # face_img 是 BGR 的 numpy 数组转成 RGB 并归一化 img cv2.cvtColor(face_img, cv2.COLOR_BGR2RGB) img cv2.resize(img, (224, 224)) tensor torch.from_numpy(img).permute(2, 0, 1).float() / 255.0 tensor (tensor - torch.tensor([0.485, 0.456, 0.406]).view(3,1,1)) / torch.tensor([0.229, 0.224, 0.225]).view(3,1,1) tensor tensor.unsqueeze(0).to(device) with torch.no_grad(): probs torch.softmax(model(tensor), dim1)[0] max_prob, idx probs.max(0) if max_prob.item() threshold: return uncertain, max_prob.item() return idx.item(), max_prob.item()逻辑说明threshold 设 0.5 是经验值实际项目里可以根据业务容忍度调到 0.6 或 0.4。返回 uncertain 比强行给一个错误标签更安全尤其是在课堂状态检测里误判学生情绪可能带来不必要的干预。4.2 视频流时序平滑避免逐帧跳变视频里逐帧推理会出现情绪标签频繁跳变上一帧 happy 下一帧 sad体验很差。常见做法是维护一个长度为 N 的滑动窗口对概率做平均或投票。N 一般取 5 到 15太小平滑不够太大延迟明显。下面是一个滑动窗口平滑的实现。from collections import deque class EmotionSmoother: def __init__(self, window_size10): self.window deque(maxlenwindow_size) def update(self, prob_vector): self.window.append(prob_vector) avg np.mean(self.window, axis0) return int(np.argmax(avg)), float(np.max(avg))参数说明window_size 在 25fps 视频里设 10 大约对应 0.4 秒延迟适合实时互动。如果是离线分析可以设 15 到 20输出更稳。注意窗口内概率向量要来自同一张脸多人场景需要按人脸 ID 分别维护窗口。4.3 部署时的预处理一致性训练时用了归一化和对齐推理时必须完全一致否则精度会掉得莫名其妙。常见翻车点是推理时忘了减均值除方差或者对齐方式不同。建议把训练时的 val_tf 直接导出成配置推理脚本复用同一套。如果部署到端侧用 ONNX 导出时要把预处理也固化进去避免不同语言实现带来的偏差。# 导出 ONNX固定输入尺寸 1x3x224x224 python -c import torch from model import build_model model build_model().eval() dummy torch.randn(1, 3, 224, 224) torch.onnx.export(model, dummy, emotion.onnx, input_names[input], output_names[logits], opset_version11) 导出后建议用 onnxruntime 跑一遍验证对比 PyTorch 和 ONNX 的输出差异如果最大绝对误差超过 1e-3检查是否有不支持的自定义层。5. 避坑与排查那些让精度掉点的细节5.1 现象验证集准确率很高实际场景一塌糊涂原因训练集和实际场景的人脸分布不一致比如训练集多是正面清晰脸实际场景有侧脸、口罩、强逆光。解决在训练集里加入实际场景的样本或者用 RetinaFace 做更鲁棒的检测再对检测到的人脸做质量筛选模糊和过小的脸直接丢弃。5.2 现象fear 和 surprise 永远分不开原因这两类在静态图上确实高度相似眉毛抬高和嘴巴张开的组合接近。解决引入时序信息用 LSTM 或 3D CNN 对连续帧建模或者在单帧模型里加入关键点距离特征比如眉毛到眼睛的距离、嘴角张开角度作为辅助输入。5.3 现象训练 loss 震荡准确率不升原因学习率太大或者 batch size 太小导致 BatchNorm 统计不稳。解决把学习率降到 1e-4 再试batch size 至少 32如果显存不够用梯度累积模拟大 batch。另外检查数据加载有没有 shuffle验证集有没有误加增强。5.4 现象推理速度慢达不到实时原因骨干网络太重或者预处理在 CPU 上做成了瓶颈。解决换 MobileNetV3 或 EfficientNet-B0输入分辨率从 224 降到 112预处理用 GPU 做或者用 OpenCV 的 UMat 加速。ONNX Runtime 开 GPU 执行提供者通常比原生 PyTorch 快 1.5 到 2 倍。5.5 现象多人场景只检测到一张脸原因MTCNN 的 keep_all 设成了 False或者置信度阈值太高。解决keep_all 改 Truemin_face_size 调小到 40 左右同时后处理里按人脸框面积过滤掉过小的误检。多人场景还要注意给每个人脸分配稳定 ID否则平滑窗口会串。6. 进阶技巧用关键点辅助和知识蒸馏把 F1 再提几个点如果单帧 CNN 的 macro-F1 卡在 0.65 左右上不去可以试两个方向。第一个是关键点辅助分支在骨干网络之外用一个人脸关键点检测器提取 68 点计算眉毛高度、嘴角角度、眼睛开合度等几何特征和 CNN 特征拼接后分类。几何特征对 fear 和 surprise 的区分特别有效因为这两类的关键点构型差异比像素差异更明显。实现上可以用 MediaPipe FaceMesh 提取 468 点再降维到几十维几何特征。import mediapipe as mp mp_face_mesh mp.solutions.face_mesh face_mesh mp_face_mesh.FaceMesh(static_image_modeTrue, max_num_faces1) def extract_geo_features(face_img): results face_mesh.process(cv2.cvtColor(face_img, cv2.COLOR_BGR2RGB)) if not results.multi_face_landmarks: return None lm results.multi_face_landmarks[0].landmark # 取眉毛上缘和眼睛上缘的垂直距离归一化到人脸框高度 left_brow lm[105].y left_eye lm[159].y brow_eye_dist abs(left_brow - left_eye) # 嘴角张开度 mouth_open abs(lm[13].y - lm[14].y) return np.array([brow_eye_dist, mouth_open], dtypenp.float32)第二个方向是知识蒸馏用一个大模型比如在 AffectNet 上预训练的 ViT当教师输出软标签让小模型同时学硬标签和软标签。软标签里包含了类间相似度信息对 fear/surprise 这种难分对特别有用。温度 T 一般设 3 到 5蒸馏损失权重 0.5 到 0.7。我自己的习惯是先用大模型跑一遍训练集把 softmax 输出存下来再训小模型时直接读省得每次前向。验证蒸馏有没有效果不能只看总体准确率要看难分对的 F1 有没有提升。如果 fear 的 F1 从 0.45 提到 0.55即使总体准确率只涨了 1 个点这个方向也值得继续。最后提醒一句所有离线指标都要在真实场景的视频流上再验一遍我见过太多次验证集 0.72、实际视频里频繁跳变的案例。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进