简介:面向计算机相关专业毕业设计与人脸表情识别实战的深度学习项目,基于卷积神经网络搭建,提供完整源码、面部表情数据集、训练好的模型,以及论文、答辩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), dtype=np.float32) for i, p in enumerate(pixels): arr = np.array(p.split(), dtype=np.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(..., dtype=np.int)转成整数数组,除以 255 把像素值从 0~255 压缩到 0~1 区间。归一化不是可有可无——不除 255 的话,第一层卷积输出的梯度量级会被放大很多,SGD 在相同学习率下更容易震荡。这份源码里的data_process.py处理逻辑和上面这段基本一致,额外加了一个对空行和异常长度的容错判断。
2.2 类别标签分布
Fer2013-pytorch-CNN-VGG-Resnet--master.zip里附带的 FER2013 原始 CSV,七类表情的样本量极不均匀:
| emotion | 含义 | 原始样本数 |
|---|---|---|
| 0 | angry(生气) | 4953 |
| 1 | disgust(厌恶) | 547 |
| 2 | fear(恐惧) | 5121 |
| 3 | happy(开心) | 8989 |
| 4 | sad(悲伤) | 6077 |
| 5 | surprise(惊讶) | 4002 |
| 6 | neutral(中性) | 6198 |
disgust 只有五百多张,和其他类别差了十倍以上。直接拿原始分布训练,模型会把所有不敢确定的样本往 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, scaleFactor=1.1, minNeighbors=5, 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))scaleFactor=1.1控制每层金字塔缩小比例,越小检测越慢但越不容易漏检;minNeighbors=5是候选框最少要几次命中才认为是人脸,设太小会框出一堆噪声,设太大则遮挡或多角度人脸直接检测不到。裁剪时按宽高中的大边取正方形区域,避免 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_classes=7): super().__init__() self.features = nn.Sequential( nn.Conv2d(1, 32, 3, padding=1), nn.BatchNorm2d(32), nn.ReLU(inplace=True), nn.Conv2d(32, 32, 3, padding=1), nn.BatchNorm2d(32), nn.ReLU(inplace=True), nn.MaxPool2d(2), nn.Conv2d(32, 64, 3, padding=1), nn.BatchNorm2d(64), nn.ReLU(inplace=True), nn.Conv2d(64, 64, 3, padding=1), nn.BatchNorm2d(64), nn.ReLU(inplace=True), nn.MaxPool2d(2), nn.Conv2d(64, 128, 3, padding=1), nn.BatchNorm2d(128), nn.ReLU(inplace=True), nn.Conv2d(128, 128, 3, padding=1), nn.BatchNorm2d(128), nn.ReLU(inplace=True), nn.MaxPool2d(2), ) self.classifier = nn.Sequential( nn.Flatten(), nn.Linear(128 * 6 * 6, 256), nn.ReLU(inplace=True), 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会直接报维度不匹配的错误。padding=1加上 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, stride=1): super().__init__() self.conv1 = nn.Conv2d(in_channels, out_channels, 3, stride=stride, padding=1, bias=False) self.bn1 = nn.BatchNorm2d(out_channels) self.conv2 = nn.Conv2d(out_channels, out_channels, 3, padding=1, bias=False) 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, stride=stride, bias=False), 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卷积只在通道数变化或特征图减半时出现,作用是把输入张量变换成和输出一样的形状去做加和。bias=False是因为后面接了 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_location=torch.device('cpu'), weights_only=False) 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_size=128, shuffle=True, num_workers=4, pin_memory=True) val_loader = DataLoader(val_ds, batch_size=256, shuffle=False) 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_size=128对 48x48 灰度图来说很宽松,GPU 显存占用不到 1GB;num_workers=4开四个子进程预取数据,但 Windows 上必须把这个逻辑放在if __name__ == '__main__'块里,否则多进程重放整个脚本会抛出运行时错误。这是跨平台跑这套源码最常见的坑之一。pin_memory=True只在使用 GPU 时提升数据传输效率,纯 CPU 训练没有明显影响。
4.2 损失函数与优化器选择
分类任务直接用nn.CrossEntropyLoss(),它在内部做了 log_softmax 和负对数似然,所以模型的最后一层输出原始 logits 就好,不要再手动接一层 softmax。七类情感互斥,交叉熵天然合适。
优化器我一般先用 SGD 加 momentum 来对比结构:
optimizer = torch.optim.SGD(model.parameters(), lr=0.01, momentum=0.9, weight_decay=1e-4) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=50)weight_decay=1e-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', classes=np.unique(y_train), y=y_train ) loss_fn = torch.nn.CrossEntropyLoss( weight=torch.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 对应 angry,1 对应 disgust,2 对应 fear,3 对应 happy,4 对应 sad,5 对应 surprise,6 对应 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_location=torch.device('cpu'), weights_only=False) 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, scaleFactor=1.05, minNeighbors=6, minSize=(48, 48)) for (x, y, w, h) in faces: roi = cv2.resize(gray[y:y+h, x:x+w], (48, 48), interpolation=cv2.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, dim=1).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()scaleFactor=1.05让检测金字塔按 5% 比例缩小,检测更精细,代价是每帧耗时增加,实际视频里可以放宽到 1.1。minNeighbors=6在误检和漏检之间取平衡,数值太小画面里会出现大量假人脸框,太大则侧脸和低头帧全被跳过。minSize=(48, 48)直接筛掉小于模型输入尺寸的候选框,避免把极小的人脸区域硬 resize 成 48 后产生严重插值伪影。
5.3 三个能立刻提升效果的技巧
第一个:小脸先放大再缩小。cv2.resize(roi, (96, 96), interpolation=cv2.INTER_LINEAR)再缩回 48,比直接从原尺寸缩放到 48 保留更多边缘梯度,模型对人脸关键区域的空间结构更敏感。
第二个:置信度阈值加时序平滑。单帧单独预测会抖动,典型表现是同一张脸在连续几帧里从 happy 跳成 neutral。取 softmax 概率低于阈值的帧不输出结果,再用一个长度为 5 的滑动窗口取众数作为最终标签,识别稳定性会好很多:
from collections import deque import torch buffer = deque(maxlen=5) soft = torch.softmax(logits, dim=1) conf, pred = torch.max(soft, dim=1) if conf.item() >= 0.6: buffer.append(int(pred.item())) if len(buffer) == 5: final_label = labels[max(set(buffer), key=buffer.count)]第三个:限制检测分辨率。1080p 全帧做人脸检测,每帧耗时接近 50ms,把输入先缩放到 720p 再检测,速度能到实时,人脸框坐标按缩放比例映射回原图再画框,视觉上几乎没有差异。CPU 机器上跑这套源码,这三个技巧同时用上,example_dsh.mp4就能接近流畅播放而不掉帧。
本文还有配套的精品资源,点击获取