尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

基于卷积神经网络的人脸表情识别实战

基于卷积神经网络的人脸表情识别实战 简介面向计算机相关专业毕业设计与人脸表情识别实战的深度学习项目基于卷积神经网络搭建提供完整源码、面部表情数据集、训练好的模型以及论文、答辩PPT、使用手册等资料适合正在准备毕设、大作业或需要项目练习的学习者。整个压缩包共36个文件约446MB涵盖Python源码与Jupyter Notebook、人脸数据集和模型压缩包、多篇论文与PPT文档、演示视频等目录按代码、文档、数据与模型分类便于定位。项目经导师指导并审定评分98分源码可运行集成CNN、VGG、ResNet等经典模型的实现与对比还附参考文献、操作说明和效果演示能帮助理解整个做人脸表情识别的流程与调试方法。已有55人学习下载对需要完整可扩展项目模板的同学来说很有参考价值。1. 人脸表情识别为什么从特征工程转向卷积神经网络教学楼入口的摄像头能统计到访人流但判断不了人的情绪状态网课上讲师看不到学生的实时反应心理测评问卷又太慢了。人脸面部表情识别要解决的正是这类问题从一张脸部的图像里判断出 angry、happy、surprise 等七类情绪状态。早些年的做法是 LBP、HOG 这类手工特征加 SVM 分类器特征设计依赖大量先验光照变化、头部姿态一改变准确率就崩。卷积神经网络把特征提取和分类合在一个端到端结构里天然的平移不变性在处理人脸这类位置相对固定的目标时优势很明显。一个反直觉的结论是在 FER2013 这种带标签噪声的数据集上三层卷积块的简易 CNN 就能到 70% 以上VGG 和 ResNet 可以推到 78% 附近真正的瓶颈往往在数据预处理和类别不均衡上。2. FER2013 数据解析与人脸对齐pixels 还原、划分与标注2.1 从 CSV 还原成 48x48 灰度图FER2013 数据集没有把图片直接存成 jpg而是把每张图的像素点拉平成一串空格分隔的数字放在 CSV 的pixels列里emotion列是对应的 0~6 类别标签。拿到data_process.py之后第一件事是把这串字符串还原成(48, 48)的矩阵再整理成四维张量喂给模型。import pandas as pd import numpy as np df pd.read_csv(fer2013.csv) pixels df[pixels].values labels df[emotion].values X np.empty((len(pixels), 48, 48, 1), dtypenp.float32) for i, p in enumerate(pixels): arr np.array(p.split(), dtypenp.int) X[i, :, :, 0] arr.reshape(48, 48) / 255.0 Y labels.astype(np.int64) np.save(X.npy, X) np.save(Y.npy, Y)这里p.split()按空格把字符串切成列表np.array(..., dtypenp.int)转成整数数组除以 255 把像素值从 0~255 压缩到 0~1 区间。归一化不是可有可无——不除 255 的话第一层卷积输出的梯度量级会被放大很多SGD 在相同学习率下更容易震荡。这份源码里的data_process.py处理逻辑和上面这段基本一致额外加了一个对空行和异常长度的容错判断。2.2 类别标签分布Fer2013-pytorch-CNN-VGG-Resnet--master.zip里附带的 FER2013 原始 CSV七类表情的样本量极不均匀emotion含义原始样本数0angry生气49531disgust厌恶5472fear恐惧51213happy开心89894sad悲伤60775surprise惊讶40026neutral中性6198disgust 只有五百多张和其他类别差了十倍以上。直接拿原始分布训练模型会把所有不敢确定的样本往 happy 或 neutral 上推验证集上准确率看着还行但画混淆矩阵就会发现 disgust 基本没被识别出来。这个问题放到第 4 章再展开怎么处理先记住这个数字就够。data_separation.py负责把还原好的 npy 按 train / valid / test 划分。我一般按 8:1:1 的比例先洗牌再切shuffle 的随机种子固定住保证每次跑出来的训练集一致否则对比模型结构时会引入不必要的变量。2.3 人脸检测与居中裁剪模型训练样本是已经对齐到 48x48 的人脸区域但摄像头拍回来或者视频里截取的帧不是人脸可能出现在画面左侧、占比很小或者带背景。直接用cv2.resize把整帧压到 48x48 会把表情细节全毁掉识别效果断崖式下跌。源码包根目录下的haarcascade_frontalface_default.xml是 OpenCV 自带的 Haar 级联人脸检测器不需要训练直接拿来用。流程是视频帧转灰度图 →detectMultiScale找到人脸矩形框 → 按中心裁剪并缩放成 48x48 → 输入模型。下面这段是预处理阶段会用到的人脸裁剪函数import cv2 def extract_face_roi(frame, cascade): gray cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) faces cascade.detectMultiScale(gray, scaleFactor1.1, minNeighbors5, minSize(48, 48)) if len(faces) 0: return cv2.resize(gray, (48, 48)) x, y, w, h faces[0] center_x, center_y x w // 2, y h // 2 side max(w, h) // 2 roi gray[center_y - side:center_y side, center_x - side:center_x side] return cv2.resize(roi, (48, 48))scaleFactor1.1控制每层金字塔缩小比例越小检测越慢但越不容易漏检minNeighbors5是候选框最少要几次命中才认为是人脸设太小会框出一堆噪声设太大则遮挡或多角度人脸直接检测不到。裁剪时按宽高中的大边取正方形区域避免 resize 的横纵拉伸变形。检测不到人脸时的兜底策略是灰度图直接压到 48x48——这种情况最终推理结果不可信但至少不会让程序崩溃。2.4 可视化与标注工具data_view.py用来抽查还原后的图像和标签是否对应。跑一条最简单的命令把X.npy的前 9 张图拼成网格显示不看一眼就开训的话等 100 个 epoch 跑完回头才发现标签错位浪费的时间按小时算。Face-Annotation-Tool-master.zip是一套图形化人脸框标注工具如果你要用自己的视频帧扩展数据集用它画框比手写坐标值高效得多。Facial-expression-recognition-main.zip和FERNet-master.zip是两套可以参考的完整工程实现里面的数据增强方式——随机水平翻转、±10° 旋转、±10% 平移——可以直接抄到自己的训练脚本里。3. CNN/VGG/ResNet 三种卷积神经网络结构实现与选型3.1 基线 CNN三层卷积块加两个全连接层model_CNN.py的基线结构是三组卷积块每组两个 3x3 卷积加一个最大池化通道数从 32 逐步涨到 64、128最后接两个全连接层输出 7 类 logits。代码核心部分如下import torch.nn as nn class EmotionCNN(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.Conv2d(32, 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.Conv2d(64, 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.Conv2d(128, 128, 3, padding1), nn.BatchNorm2d(128), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), ) self.classifier nn.Sequential( nn.Flatten(), nn.Linear(128 * 6 * 6, 256), nn.ReLU(inplaceTrue), nn.Dropout(0.5), nn.Linear(256, num_classes), ) def forward(self, x): return self.classifier(self.features(x))输入是单通道 48x48 灰度图三次MaxPool2d(2)把特征图依次压到 24x24、12x12、6x6所以全连接第一层的输入维度是128 * 6 * 6。这个数字不是拍脑袋定的改网络时如果动了池化次数或者卷积 stride这里必须跟着改否则nn.Linear会直接报维度不匹配的错误。padding1加上 3x3 卷积核会让特征图宽高保持不变这样每次池化减半的尺寸变化是确定性的好推算。BatchNorm2d放在卷积和 ReLU 之间是常见做法——先归一化再激活梯度分布更稳定允许你开更高的初始学习率。Dropout(0.5)加在最后一个全连接前面训练时随机屏蔽一半神经元缓解这个小数据集上的过拟合。需要说明的是BN 本身有正则化效果dropout 不是必须的但叠加使用在这个任务上效果并不差。3.2 VGG 风格结构model_VGG.py走了 VGG 的思路卷积核统一用 3x3不搞大卷积核靠堆叠层数来增加感受野。和基线 CNN 最大的区别是通道数增长更激进三个 stage 的通道数是 64、128、256每个 stage 里连续两个 3x3 卷积。3x3 连续堆两层等价于一个 5x5 的 receptive field但参数量更少非线性表达更强。代价是训练速度明显变慢model_VGG_test.py里能看到推理时要等一小会儿。如果你的机器是纯 CPU 环境VGG 结构不是最优选择训练一个 epoch 的时间大约是基线的两倍。3.3 ResNet 残差结构model_ResNet.py引入了残差连接核心是跳跃加和输入经过两个卷积块之后把原始输入再加回去再进 ReLU。改动很小但对梯度传播的意义很大——梯度可以跨层直接回传不会随着深度增加而消失。小规模数据上用 ResNet 往往比 VGG 更稳验证集 loss 尾段不会跳来跳去。残差块的实现如下import torch import torch.nn as nn class ResidualBlock(nn.Module): def __init__(self, in_channels, out_channels, stride1): super().__init__() self.conv1 nn.Conv2d(in_channels, out_channels, 3, stridestride, padding1, biasFalse) self.bn1 nn.BatchNorm2d(out_channels) self.conv2 nn.Conv2d(out_channels, out_channels, 3, padding1, biasFalse) self.bn2 nn.BatchNorm2d(out_channels) self.shortcut nn.Sequential() if stride ! 1 or in_channels ! out_channels: self.shortcut nn.Sequential( nn.Conv2d(in_channels, out_channels, 1, stridestride, biasFalse), nn.BatchNorm2d(out_channels), ) def forward(self, x): out torch.relu(self.bn1(self.conv1(x))) out self.bn2(self.conv2(out)) out self.shortcut(x) return torch.relu(out)shortcut分支的1x1卷积只在通道数变化或特征图减半时出现作用是把输入张量变换成和输出一样的形状去做加和。biasFalse是因为后面接了 BN偏置会被 BN 的均值平移消掉留着只会徒增参数。三个模型的横向对比在model_All_Compare.py里它把 MDL 的顶会分成三份以先到先得的方式把对比实验固化在一个入口里。网络结构参数量量级CPU 训练一个 epoch验证集表现基线 CNN约 200 万快验证集准确率浮动较大后期容易过拟合VGG 风格约 800 万慢准确率上限高但需要更长训练时间ResNet约 300 万中收敛平稳验证集 loss 波动小具体数字以你自己机器上跑出来的结果为准但这个排序和我在多组配置下的实测认知是一致的。3.4 模型加载方式model_resnet.pkl是 ResNet 训练完保存的权重文件。这个项目是用torch.save(model, path)保存的整个模型对象不是state_dict所以加载时要保证EmotionCNN或对应的ResNet类定义在当前命名空间里可见import torch import torch.nn as nn model torch.load(model_resnet.pkl, map_locationtorch.device(cpu), weights_onlyFalse) model.eval()map_location指定成 CPU 是因为如果权重是在 GPU 机器上存下来的直接 load 到无 GPU 环境会报 CUDA 相关错误model.eval()把 BN 和 dropout 切到推理模式不调用它的话同一个输入每次跑出来的结果可能不一样这一点经常被忽略。4. 训练对比与调参损失函数、学习率调度与类别不均衡4.1 统一训练入口model_All_Compare.py把 CNN、VGG、ResNet 三个模型放在同一个 pipeline 下对比共用一套数据加载、损失函数和优化器配置。这样横向对比才有说服力——如果每个模型用不同学习率、不同 batch size性能差异到底来自网络结构还是超参就没法判断了。核心训练循环import torch from torch.utils.data import DataLoader, TensorDataset train_ds TensorDataset(torch.from_numpy(X_train), torch.from_numpy(y_train)) val_ds TensorDataset(torch.from_numpy(X_val), torch.from_numpy(y_val)) train_loader DataLoader(train_ds, batch_size128, shuffleTrue, num_workers4, pin_memoryTrue) val_loader DataLoader(val_ds, batch_size256, shuffleFalse) def train_one_epoch(model, loader, optimizer, criterion, device): model.train() total_loss 0.0 for xb, yb in loader: xb, yb xb.to(device), yb.to(device) optimizer.zero_grad() loss criterion(model(xb), yb) loss.backward() optimizer.step() total_loss loss.item() * xb.size(0) return total_loss / len(loader.dataset)batch_size128对 48x48 灰度图来说很宽松GPU 显存占用不到 1GBnum_workers4开四个子进程预取数据但 Windows 上必须把这个逻辑放在if __name__ __main__块里否则多进程重放整个脚本会抛出运行时错误。这是跨平台跑这套源码最常见的坑之一。pin_memoryTrue只在使用 GPU 时提升数据传输效率纯 CPU 训练没有明显影响。4.2 损失函数与优化器选择分类任务直接用nn.CrossEntropyLoss()它在内部做了 log_softmax 和负对数似然所以模型的最后一层输出原始 logits 就好不要再手动接一层 softmax。七类情感互斥交叉熵天然合适。优化器我一般先用 SGD 加 momentum 来对比结构optimizer torch.optim.SGD(model.parameters(), lr0.01, momentum0.9, weight_decay1e-4) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max50)weight_decay1e-4是 L2 正则防止权重过大导致过拟合CosineAnnealingLR让学习率在 50 个 epoch 内从初始值余弦下降到接近 0比固定步长衰减更容易逼近局部最优。如果换成 Adam学习率要调低一到两个数量级一般取 1e-3 到 3e-4因为 Adam 有自适应动量SGD 的学习率直接拿过来大概率发散。4.3 类别不均衡的补救回到第 2 章的占比问题disgust 只有 547 张训练时模型基本学不到这个类别的有效特征。最简单的做法是用compute_class_weight算出每个类别的权重传给交叉熵from sklearn.utils.class_weight import compute_class_weight import numpy as np import torch class_weights compute_class_weight( balanced, classesnp.unique(y_train), yy_train ) loss_fn torch.nn.CrossEntropyLoss( weighttorch.from_numpy(class_weights).float() )balanced模式下的权重公式是总样本数除以类别数乘以该类样本数样本越少的类权重越大。加权重之后disgust 的 loss 占比被抬高梯度更新方向不再被 happy 主导。这个改动通常会在整体准确率上掉 1~2 个百分点副作用是模型不再偷懒地把所有模糊样本都归到多数类。要不要这个 trade-off取决于你的应用场景如果只是展示 demo原始分布就够用了要做心理学统计分析混淆矩阵里 disgust 一行全零是交不了差的。4.4 过拟合信号与训练日志分析训练脚本按 epoch 数跑典型配置是 100 轮。判断有没有过拟合盯两条曲线训练 loss 持续下降验证 loss 降一段开始回升那就是模型开始背训练集了验证 loss 长时间不动则可能是学习率太低或者 BN 层参数初始化有问题。100 轮训练日志里看到验证准确率到 77%~80% 区间就不动了这是小数据加带噪标签的正常水平不代表代码有 bug。用命令行参数区分不同实验比较实用python model_All_Compare.py --model resnet --lr 0.01 --batch_size 128 --epochs 100模型结构、学习率、batch size 都通过 argparse 传进去脚本内部把这三者的值拼到日志文件名里跑完直接用脚本比较不会混。这个文件里还顺带实现了最优 epoch 的模型保存逻辑验证 loss 连续 10 个 epoch 不下降就提前终止。5. 实时表情识别部署OpenCV 人脸检测与模型推理的工程细节5.1 单张图片的推理拿到一张含人脸的图片先过haarcascade_frontalface_default.xml检测人脸框再把框内区域缩放到 48x48 归一化最后喂给模型取 argmax。image_emotion_mapping.py里维护了类别索引到语义标签的映射0 对应 angry1 对应 disgust2 对应 fear3 对应 happy4 对应 sad5 对应 surprise6 对应 neutral。这个映射顺序必须和训练时data_process.py里的保持一致训练推理各用各的映射是常见低级错误。5.2 视频流实时识别example_dsh.mp4是打包好的演示视频跑通它就能验证整条链路。下面是完整可运行的视频推理代码import cv2 import torch import numpy as np cascade cv2.CascadeClassifier(haarcascade_frontalface_default.xml) model torch.load(model_resnet.pkl, map_locationtorch.device(cpu), weights_onlyFalse) model.eval() labels [angry, disgust, fear, happy, sad, surprise, neutral] cap cv2.VideoCapture(example_dsh.mp4) while cap.isOpened(): ret, frame cap.read() if not ret: break gray cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) faces cascade.detectMultiScale(gray, scaleFactor1.05, minNeighbors6, minSize(48, 48)) for (x, y, w, h) in faces: roi cv2.resize(gray[y:yh, x:xw], (48, 48), interpolationcv2.INTER_AREA) roi (roi.astype(np.float32) / 255.0).reshape(1, 1, 48, 48) with torch.no_grad(): logits model(torch.from_numpy(roi)) pred int(torch.argmax(logits, dim1).item()) cv2.rectangle(frame, (x, y), (x w, y h), (0, 255, 0), 2) cv2.putText(frame, labels[pred], (x, y - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.9, (0, 255, 0), 2) cap.release() cv2.destroyAllWindows()scaleFactor1.05让检测金字塔按 5% 比例缩小检测更精细代价是每帧耗时增加实际视频里可以放宽到 1.1。minNeighbors6在误检和漏检之间取平衡数值太小画面里会出现大量假人脸框太大则侧脸和低头帧全被跳过。minSize(48, 48)直接筛掉小于模型输入尺寸的候选框避免把极小的人脸区域硬 resize 成 48 后产生严重插值伪影。5.3 三个能立刻提升效果的技巧第一个小脸先放大再缩小。cv2.resize(roi, (96, 96), interpolationcv2.INTER_LINEAR)再缩回 48比直接从原尺寸缩放到 48 保留更多边缘梯度模型对人脸关键区域的空间结构更敏感。第二个置信度阈值加时序平滑。单帧单独预测会抖动典型表现是同一张脸在连续几帧里从 happy 跳成 neutral。取 softmax 概率低于阈值的帧不输出结果再用一个长度为 5 的滑动窗口取众数作为最终标签识别稳定性会好很多from collections import deque import torch buffer deque(maxlen5) soft torch.softmax(logits, dim1) conf, pred torch.max(soft, dim1) if conf.item() 0.6: buffer.append(int(pred.item())) if len(buffer) 5: final_label labels[max(set(buffer), keybuffer.count)]第三个限制检测分辨率。1080p 全帧做人脸检测每帧耗时接近 50ms把输入先缩放到 720p 再检测速度能到实时人脸框坐标按缩放比例映射回原图再画框视觉上几乎没有差异。CPU 机器上跑这套源码这三个技巧同时用上example_dsh.mp4就能接近流畅播放而不掉帧。本文还有配套的精品资源点击获取
返回列表