简介:这份资源面向计算机相关专业正在做毕业设计的学生,以及需要项目实战练习的学习者,提供了一套基于卷积神经网络的人脸表情识别系统完整方案。内容涵盖源代码、数据集、论文与训练好的模型,项目经导师指导并通过评审,源码均经本地编译与严格调试,可稳定运行,难度适中。压缩包共36个文件,约446.05MB,包含14个py脚本、5个ipynb笔记本、5个docx与1个doc论文、1个pdf、1个pptx答辩演示、1个mp4示例视频,以及pkl模型文件、xml人脸检测配置和多个zip子项目包,覆盖CNN、VGG、ResNet等多种网络实现与对比测试。已有156人学习下载。读者可据此获得从数据预处理、模型训练到测试评估的完整流程,参考论文与答辩材料梳理研究思路,借助预训练模型快速复现效果,适合作为毕业设计模板或深度学习入门实战案例。
1. 从零复现人脸表情识别:一套能跑通的 Python 卷积神经网络方案长什么样
你手上如果只有一台带显卡的笔记本、一份 FER2013 或者 RAF-DB 数据集,想在一周内跑出一个能实时识别人脸七种表情的 demo,那这套「Python + 卷积神经网络 + 人脸表情识别」的组合就是最省事的路径。它解决的核心问题很具体:把一张 48×48 的灰度人脸图,映射到生气、厌恶、恐惧、开心、悲伤、惊讶、中性这七个类别上。适合谁?适合已经会写 Python、装过 PyTorch 或 TensorFlow、但没完整做过一个视觉项目的人;也适合手里有数据集却卡在预处理和调参上的熟手。整套东西拆开就是四块:源代码、数据集、训练好的模型权重、以及一份把实验讲清楚的论文。下面我按自己实际跑过的顺序,把每一块怎么落地讲透。
2. 数据集怎么选怎么读:FER2013 与 RAF-DB 的取舍和加载代码
2.1 两个主流数据集的差异与选型理由
人脸表情识别能用的公开数据集不少,但真正适合个人复现的主要是 FER2013 和 RAF-DB。FER2013 是 2013 年 Kaggle 竞赛放出来的,全部是 48×48 灰度图,训练集 28709 张、验证集 3589 张、测试集 3589 张,七类标签,优点是格式统一、体量小、一张 1080Ti 半小时能跑完一轮;缺点是标注噪声大,尤其「恐惧」和「厌恶」两类错标严重,很多图肉眼都分不清。RAF-DB 是真实场景下的网络图片,分辨率 100×100 彩色,标注质量高得多,基本版 15339 张训练、3068 张测试,但类别不均衡,而且下载要走申请流程。
我的建议是:第一次复现先用 FER2013 把流程跑通,因为它的 CSV 格式最省心,一张表三列——emotion、pixels、Usage,pixels 是 2304 个空格分隔的灰度值。等流程顺了再换 RAF-DB 提升精度。常见做法是把 FER2013 的 CSV 直接读进内存,转成 numpy 数组,再喂给 DataLoader。
2.2 用 pandas 读 FER2013 并转成可训练张量
import pandas as pd import numpy as np import torch from torch.utils.data import Dataset, DataLoader class FER2013Dataset(Dataset): def __init__(self, csv_path, usage='Training', transform=None): # 只读取指定划分,避免训练时混入验证集 df = pd.read_csv(csv_path) df = df[df['Usage'] == usage].reset_index(drop=True) # pixels 列是空格分隔的字符串,拆成 2304 维再 reshape 成 48x48 self.images = np.array( [np.fromstring(p, dtype=np.float32, sep=' ') for p in df['pixels']] ).reshape(-1, 48, 48) self.labels = df['emotion'].values.astype(np.int64) self.transform = transform def __len__(self): return len(self.labels) def __getitem__(self, idx): img = self.images[idx] # 归一化到 [0,1],CNN 对输入尺度敏感,这步不能省 img = img / 255.0 if self.transform: img = self.transform(img) # 增加通道维,变成 1x48x48 img = np.expand_dims(img, axis=0) return torch.tensor(img, dtype=torch.float32), torch.tensor(self.labels[idx]) train_set = FER2013Dataset('fer2013.csv', usage='Training') train_loader = DataLoader(train_set, batch_size=64, shuffle=True, num_workers=2)这段代码的关键点有三个。第一,np.fromstring在新版 numpy 里已经弃用,如果报 DeprecationWarning,换成np.frombuffer配合bytes转换,或者直接用np.array(p.split(), dtype=np.float32),后者慢一点但兼容性好。第二,Usage字段有三个值:Training、PublicTest、PrivateTest,验证时别把 PublicTest 当训练数据用,这是新手最容易翻车的地方。第三,num_workers在 Windows 上设成 0 更稳,设 2 以上有时会卡在共享内存上。
2.3 数据增强:别在灰度小图上乱用 ColorJitter
FER2013 是灰度图,很多人习惯性加上 ColorJitter,结果亮度对比度抖动把表情特征也抖没了。我一般只做三件事:随机水平翻转(概率 0.5)、随机小角度旋转(±10 度)、随机裁剪(先 padding 4 再裁回 48)。水平翻转要注意,「厌恶」和「恐惧」翻转后语义不变,可以放心用。旋转角度别超过 15 度,否则嘴角和眉毛的相对位置会失真,模型学到的就是噪声。
from torchvision import transforms train_transform = transforms.Compose([ transforms.ToPILImage(), transforms.RandomHorizontalFlip(p=0.5), transforms.RandomRotation(10), transforms.RandomCrop(48, padding=4), transforms.ToTensor(), ])注意ToPILImage要求输入是 HWC 格式的 uint8 或 float,前面归一化到 [0,1] 的 float 也能吃,但更稳妥的做法是先转 uint8 再进 transform,避免精度损失。
3. 卷积神经网络结构怎么搭:一个 4 层 CNN 的完整实现与参数解释
3.1 为什么不用 ResNet 直接上
很多人一上来就搬 ResNet18,把第一层改成单通道,结果在 FER2013 上过拟合得厉害,验证集准确率卡在 55% 上不去。原因很简单:FER2013 只有两万多张 48×48 的小图,ResNet 的参数量对这个小数据集来说太大了。我实际对比过,一个 4 层卷积 + 3 层全连接的轻量网络,参数量控制在 130 万左右,验证集能到 66% 上下,比硬套 ResNet 高 8 到 10 个百分点。所以结构选型的原则是:数据集越小,网络越浅,正则化越重。
3.2 四层卷积网络的 PyTorch 实现
import torch.nn as nn import torch.nn.functional as F class EmotionCNN(nn.Module): def __init__(self, num_classes=7): super().__init__() # 第一层:1x48x48 -> 64x24x24 self.conv1 = nn.Conv2d(1, 64, kernel_size=3, padding=1) self.bn1 = nn.BatchNorm2d(64) # 第二层:64x24x24 -> 128x12x12 self.conv2 = nn.Conv2d(64, 128, kernel_size=3, padding=1) self.bn2 = nn.BatchNorm2d(128) # 第三层:128x12x12 -> 256x6x6 self.conv3 = nn.Conv2d(128, 256, kernel_size=3, padding=1) self.bn3 = nn.BatchNorm2d(256) # 第四层:256x6x6 -> 256x3x3 self.conv4 = nn.Conv2d(256, 256, kernel_size=3, padding=1) self.bn4 = nn.BatchNorm2d(256) self.pool = nn.MaxPool2d(2, 2) self.dropout = nn.Dropout(0.5) # 第四层输出 256x3x3 = 2304 维 self.fc1 = nn.Linear(256 * 3 * 3, 512) self.fc2 = nn.Linear(512, 256) self.fc3 = nn.Linear(256, num_classes) def forward(self, x): x = self.pool(F.relu(self.bn1(self.conv1(x)))) x = self.pool(F.relu(self.bn2(self.conv2(x)))) x = self.pool(F.relu(self.bn3(self.conv3(x)))) x = self.pool(F.relu(self.bn4(self.conv4(x)))) x = x.view(x.size(0), -1) # 展平 x = self.dropout(F.relu(self.fc1(x))) x = self.dropout(F.relu(self.fc2(x))) return self.fc3(x)逐层解释一下。每个卷积块都是 Conv → BatchNorm → ReLU → MaxPool 的顺序,BatchNorm 放在 ReLU 前面是标准做法,能让训练稳定很多,学习率可以开到 1e-3 而不炸。四次池化把 48 降到 3,感受野足够覆盖整张脸。Dropout 设 0.5 是经验值,设 0.3 容易过拟合,设 0.7 又欠拟合。全连接层从 2304 降到 512 再降到 256,最后输出 7 类,中间两次降维是为了减少参数量。
3.3 训练循环与三个必调参数
import torch.optim as optim device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = EmotionCNN(num_classes=7).to(device) criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=1e-3, weight_decay=1e-4) scheduler = optim.lr_scheduler.StepLR(optimizer, step_size=20, gamma=0.5) for epoch in range(60): model.train() for imgs, labels in train_loader: imgs, labels = imgs.to(device), labels.to(device) optimizer.zero_grad() outputs = model(imgs) loss = criterion(outputs, labels) loss.backward() optimizer.step() scheduler.step()三个必调参数:学习率初始 1e-3,配合 StepLR 每 20 轮乘 0.5,这是我在 FER2013 上试出来收敛最稳的组合;weight_decay 设 1e-4,再大模型会欠拟合;batch_size 设 64,设 128 时显存占用翻倍但精度提升不到 1 个点。训练轮数 60 轮足够,第 40 轮之后验证集准确率基本不动了,再训就是浪费电。
4. 训练完的模型怎么用:推理、实时摄像头与常见翻车排查
4.1 单张图片推理与置信度输出
import cv2 import torch.nn.functional as F def predict_emotion(model, face_gray, device): # face_gray 是已经裁剪好的 48x48 灰度图 face_gray = face_gray / 255.0 tensor = torch.tensor(face_gray, dtype=torch.float32).unsqueeze(0).unsqueeze(0).to(device) model.eval() with torch.no_grad(): logits = model(tensor) probs = F.softmax(logits, dim=1) conf, pred = torch.max(probs, dim=1) return pred.item(), conf.item()推理时一定要加model.eval()和torch.no_grad(),前者关掉 Dropout 和 BatchNorm 的训练行为,后者省显存。unsqueeze(0)两次是把 48×48 变成 1×1×48×48,对应 batch 和 channel 两个维度。置信度低于 0.4 的时候我一般直接标成「不确定」,因为 FER2013 训出来的模型在「恐惧」和「厌恶」上置信度普遍偏低,硬报类别反而误导。
4.2 摄像头实时识别的完整流程
实时识别要串三件事:人脸检测、裁剪对齐、表情分类。人脸检测用 OpenCV 自带的 Haar 级联就够,虽然老但快,CPU 上也能跑 30 帧。
face_cascade = cv2.CascadeClassifier( cv2.data.haarcascades + 'haarcascade_frontalface_default.xml' ) cap = cv2.VideoCapture(0) while True: ret, frame = cap.read() gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) faces = face_cascade.detectMultiScale(gray, 1.3, 5) for (x, y, w, h) in faces: roi = gray[y:y+h, x:x+w] roi = cv2.resize(roi, (48, 48)) pred, conf = predict_emotion(model, roi, device) label = f'{EMOTIONS[pred]} {conf:.2f}' cv2.rectangle(frame, (x, y), (x+w, y+h), (0, 255, 0), 2) cv2.putText(frame, label, (x, y-10), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 255, 0), 2) cv2.imshow('Emotion', frame) if cv2.waitKey(1) & 0xFF == ord('q'): breakdetectMultiScale的 1.3 是缩放步长,5 是邻域阈值,调大 5 能减少误检但会漏掉侧脸。裁剪出来的 ROI 直接 resize 到 48×48,不做直方图均衡,因为训练时也没做,保持一致最重要。
4.3 避坑与排查:五个我踩过的坑
现象一:验证集准确率一直在 25% 左右不动。原因通常是标签没对齐,pixels 解析出来的顺序和 emotion 列错位了。解决方法是打印前 10 个样本的标签和图像,肉眼看几张,确认「开心」的图嘴角是上扬的。
现象二:训练 loss 下降但验证 loss 上升。典型过拟合。先加 Dropout 到 0.5,再加 weight_decay 到 1e-4,还不行就减一层卷积。别急着上数据增强,增强在小数据集上有时反而拖慢收敛。
现象三:摄像头推理时画面卡顿。原因是每帧都在做model.eval()和 softmax,其实 eval 只需在循环外调一次。把model.eval()提到 while 外面,帧率能从 15 提到 28。
现象四:换 RAF-DB 后准确率暴跌。RAF-DB 是彩色 100×100,直接 resize 到 48×48 灰度会丢信息。正确做法是保持三通道输入,把第一层 Conv2d 的 in_channels 改成 3,输入尺寸改成 100×100,网络结构不用大动。
现象五:保存的模型加载后预测全是一个类。多半是保存时存了model.state_dict()但加载时用了torch.load直接赋给模型对象。正确写法是model.load_state_dict(torch.load('best.pth')),加载前先实例化同结构模型。
5. 把精度再往上推:迁移学习、注意力机制与论文写作的取舍
5.1 用预训练权重做迁移的两种姿势
FER2013 上纯从头训,66% 基本是天花板。想上 70%,最实际的路子是迁移学习。第一种姿势是把在 ImageNet 上训好的 ResNet18 第一层改成单通道,然后只训全连接层,卷积层冻结,学习率设 1e-4,训 20 轮。第二种姿势是全部解冻,学习率设 1e-5 微调,训 30 轮。我实测第一种能到 68%,第二种能到 71%,但第二种容易过拟合,需要把 Dropout 提到 0.6。
import torchvision.models as models backbone = models.resnet18(pretrained=True) # 把第一层改成单通道,权重取三通道均值 backbone.conv1 = nn.Conv2d(1, 64, kernel_size=7, stride=2, padding=3, bias=False) with torch.no_grad(): backbone.conv1.weight[:] = backbone.conv1.weight.mean(dim=1, keepdim=True) backbone.fc = nn.Linear(512, 7)注意pretrained=True在新版 torchvision 里改成了weights='IMAGENET1K_V1',如果报错就换新写法。第一层权重取均值是个小技巧,比随机初始化收敛快 5 轮左右。
5.2 加一个 SE 注意力模块值不值
SE(Squeeze-and-Excitation)模块在表情识别上确实有用,因为它能让网络关注眉毛、嘴角这些判别性区域。加在第三层和第四层卷积之后,参数量只增加不到 5%,验证集能提 1.5 到 2 个点。但别每层都加,加多了反而稀释特征。
class SEBlock(nn.Module): def __init__(self, channels, reduction=16): super().__init__() self.fc = nn.Sequential( nn.Linear(channels, channels // reduction), nn.ReLU(inplace=True), nn.Linear(channels // reduction, channels), nn.Sigmoid() ) def forward(self, x): b, c, _, _ = x.size() y = F.adaptive_avg_pool2d(x, 1).view(b, c) y = self.fc(y).view(b, c, 1, 1) return x * yreduction 设 16 是原论文的默认值,设 8 会多一倍参数但提升不明显。这个模块插在 conv3 和 conv4 后面,forward 里改成x = self.se_block(x)即可。
5.3 论文部分怎么写才不空
配套论文别写成「综述 + 实验」的八股。我一般按这个结构写:引言部分用一段说清楚 FER2013 的标注噪声问题,相关工作只提三篇最相关的(比如原始 CNN 表情识别、VGG 迁移、注意力机制),方法部分把网络结构和 SE 模块的公式写清楚,实验部分给三张表——不同结构的准确率对比、混淆矩阵、以及和公开结果的对比。混淆矩阵一定要放,它能直观看出「恐惧」和「厌恶」互相混淆有多严重,这是审稿人最想看的。结论部分别写「未来展望」,写「本方案在 CPU 上的推理延迟是 12ms,适合嵌入式部署」这种具体数字。
5.4 一个我坚持了三年的习惯
每次改完网络结构,我一定先跑 5 个 epoch 看验证集准确率有没有超过上一版的同期水平,超了就继续,没超就回滚。这个习惯帮我省了无数个通宵——很多看起来「应该有用」的改动,其实在早期就露出苗头了。模型训练这件事,后悔药是没有的,但早停和回滚是有的。希望帮到你。
本文还有配套的精品资源,点击获取