简介:面向计算机相关专业毕设与大作业场景,这套基于Python与卷积神经网络的人脸表情识别系统,提供从模型构建、训练测试到可视化演示的完整流程。项目涵盖CNN、VGG、ResNet三种模型实现及对比脚本,附带人脸检测级联分类器、数据集处理脚本及预训练权重,适合需要快速搭建深度学习项目、完成论文写作与答辩准备的本科生及进阶学习者。资源为zip压缩包,共36个文件,主体为14个Python源码、5个Jupyter Notebook教程、5份Word/PDF论文文档及1份答辩PPT,另有模型权重(pkl)、人脸检测XML、演示视频和参考工程压缩包等,整体大小约446MB,目录结构清晰,便于直接运行和学习。已有75人学习使用。项目亮点在于含评分为98分的完整毕业论文与答辩PPT,且源码均经本地编译调试通过;配套的Notebook可帮助逐段理解模型原理,视频演示则直观展示表情识别效果,是一份覆盖“数据—训练—评估—论文—答辩”全链条的高质量毕业设计参考资料。
1. 人脸表情识别项目:看起来是图像分类,真正卡人的是数据
先给结论:这套「Python + 卷积神经网络」的人脸表情识别系统,模型结构不是难点,能上网搜到的CNN代码一抓一大把,真正让绝大多数人卡住的是数据处理和训练细节——同样一份FER2013数据集,有人能训到65%以上的验证准确率,有人折腾一周还在50%上下晃悠,差别不在网络多先进,而在预处理、超参数和踩坑经验。
这个项目的价值在于它覆盖了一条完整的落地链路:从数据集解析、人脸检测对齐、CNN模型设计,到训练调参、模型保存、摄像头实时推理,外加论文和答辩PPT的整理思路。对做毕设、课设或者刚入门CV想练手的人来说,它是少有的「一个项目打通全流程」的选择。对已经熟悉分类任务的开发者,这套系统的可玩点则在工程层面——怎么把检测和分类串成实时链路,怎么让模型在光照差、侧脸、遮挡时还能稳住。
我接下来按我自己做这类项目的顺序来讲:先讲清CNN在表情识别里该怎么选型和配参,再给出一套能跑通的数据处理与训练代码,最后把验证、排错和答辩展示的关键点补齐。
2. 为什么选卷积神经网络来做表情识别:结构、参数与一个基准网络
2.1 CNN在表情识别里赢在哪:局部纹理与平移不变性
表情识别的输入是48x48的灰度人脸图,比ImageNet那种224x224的彩色图小得多,信息量也少得多。但这种小图恰恰是CNN的舒适区——人脸表情的判别依据集中在眼睛、眉毛、嘴巴这些局部区域,比如高兴时嘴角上扬、惊讶时眼睛和嘴巴同时张大,这些特征都是局部纹理模式。
CNN的两个天然特性正好匹配这个需求:第一,卷积操作的局部感受野让网络先学会提取边缘、线条,再组合成眼睛、嘴巴等部件级特征,最后形成表情级别的抽象表达;第二,卷积和下采样的组合带来一定程度的平移不变性——人脸检测框即使有误差,脸在框里偏移了几个像素,网络的输出也不会剧烈变化。这一点是传统方法(比如直接把像素拉平喂给SVM)最头疼的问题。
所以在表情识别这个任务上,CNN不是「可选项」,而是性价比最高的默认选项。它不需要你手动设计HOG、LBP这些特征,端到端地把特征提取和分类一起学出来,训练和部署链路都短。用Keras或者PyTorch都能实现,我习惯用PyTorch,后面代码也按PyTorch来写。
2.2 卷积神经网络的汇聚层:参数怎么设才不翻车
汇聚层(池化层)在表情识别网络里承担两件事:降分辨率和扩大感受野。常见的选择是最大池化(MaxPooling),它在每个2x2窗口里取最大值,把特征图的宽高各缩一半。为什么不用平均池化?因为表情的判别细节(比如嘴角的弧度边缘)往往对应较强的激活值,最大池化能把这些强响应保留下来,平均池化反而会把它们稀释掉。
参数上我一般固定用kernel_size=2、stride=2的2x2最大池化,这几乎是小型CNN的默认配置。要注意的是池化层不要和卷积层叠加得太密——每做一次2x2池化,特征图分辨率就减半,48x48的输入经过三次池化就变成6x6,如果继续池化,后面的全连接层能拿到的空间信息就太少了,模型会明显掉点。
另一个容易翻车的地方是「该池化时不池化」。有人为了让模型学得更细,把池化层全部去掉,只用stride=2的卷积来下采样。这在数据量大的时候可以,但FER2013总共才三万五千多张图,纯卷积下采样会让参数量上升、训练变慢,正则压力也更大。我的建议是:池化层该用就用,2x2最大池化在小数据集上是稳妥的默认项。
2.3 一个能跑得动又不丢脸的基准网络
参考LeNet-5的卷积+池化交替思路,但把通道数加宽、引入批归一化和Dropout,我常用的一个基准结构是这样:
- 输入:48x48单通道灰度图
- 卷积块1:两个3x3卷积(64通道) + 批归一化 + ReLU + 2x2最大池化,输出24x24
- 卷积块2:两个3x3卷积(128通道) + 批归一化 + ReLU + 2x2最大池化,输出12x12
- 卷积块3:两个3x3卷积(256通道) + 批归一化 + ReLU + 2x2最大池化,输出6x6
- 分类器:Dropout(0.5) → 全连接512 → ReLU → Dropout(0.5) → 全连接7
为什么用两个3x3卷积叠一个块,而不是直接上一个5x5卷积?两个3x3卷积的感受野等于一个5x5,但参数量更少、非线性更强,这是现代CNN的通行做法。通道数从64涨到256,是因为越往后特征图越小,需要更多通道来承载高层语义信息。7个输出节点对应FER2013的七类表情:Angry、Disgust、Fear、Happy、Sad、Surprise、Neutral。
这个结构在有GPU的情况下,训练30个epoch大约十几分钟,CPU也能跑但会慢很多。作为毕业设计级别的项目,它的表达能力和训练成本之间平衡得很好,不会因为模型太大导致过拟合,也不会因为太浅导致欠拟合。
3. 数据处理这一步最容易被低估:FER2013的读取、归一化与增强
3.1 FER2013的数据结构:CSV里藏着训练集、验证集和测试集
先说一个很多新手会懵的点:FER2013不是一个图片文件夹,而是一个CSV文件。每一行有三列:emotion(0到6的整数标签)、pixels(48x48=2304个灰度值,用空格分隔)、Usage(这一行属于Training、PublicTest还是PrivateTest)。
第一次接手这个数据集的人,最容易犯的错是把整个CSV当成训练集直接读。实际标好的划分是:Training约28709张用于训练,PublicTest约3589张适合当验证集,PrivateTest约3589张是真正的测试集。我在训练时的做法是:只用Training部分做训练和验证,PrivateTest留到最后测一次,绝不提前碰它。
下面代码实现了按Usage字段拆分的数据集类:
import csv import numpy as np import torch from torch.utils.data import Dataset class FER2013Dataset(Dataset): def __init__(self, csv_path, usage='Training', transform=None): self.images = [] self.labels = [] self.transform = transform with open(csv_path, 'r') as f: reader = csv.reader(f) next(reader) # 跳过表头: emotion,pixels,Usage for row in reader: if row[2] != usage: # 只留指定划分的数据 continue pixels = np.array([int(x) for x in row[1].split()], dtype=np.uint8) pixels = pixels.reshape(48, 48, 1) self.images.append(pixels) self.labels.append(int(row[0])) self.images = np.array(self.images) self.labels = np.array(self.labels) def __len__(self): return len(self.labels) def __getitem__(self, idx): img = self.images[idx] label = self.labels[idx] if self.transform: img = self.transform(img) return img, label这段代码的逻辑很简单:初始化时遍历CSV,按Usage字段筛出对应的行,把pixels字符串按空格切分并转成48x48的灰度图,标签直接取emotion列。类内部维护images和labels两个numpy数组,__getitem__负责在取样时对单张图做增强变换。
需要注意一个隐藏坑:CSV里的pixels列的每个值读出来是字符串,直接int()转换没问题,但如果有人图省事用np.fromstring或者不做类型转换,后面算归一化时会出现隐式类型问题,轻则精度变差,重则直接报错。所以我在读取时显式指定了dtype=np.uint8,把类型问题掐死在源头。
3.2 环境准备:装好Python、CUDA版PyTorch和cv2
拿到源码后第一步不是跑模型,而是把环境弄干净。Python建议用3.8到3.11之间的版本,太新的版本有时会遇到某些库还没跟上编译的问题。在VSCode里做Python开发的话,记得先装好Python扩展,然后按Ctrl+Shift+P调出命令面板,选择「Python: Select Interpreter」指定虚拟环境,这一步经常有人忽略,导致命令行和编辑器用的不是同一个环境,报错报得莫名其妙。
需要装的包不多,核心三个:PyTorch(带CUDA的版本)、OpenCV-Python、NumPy。如果你的机器有NVIDIA显卡,先去NVIDIA官网确认显卡支持的CUDA版本,再到PyTorch官网用对应命令安装;没有独立显卡就用CPU版,代码不用改,就是训练慢。OpenCV的安装很直接:
pip install opencv-pythoncv2的导入名是cv2,安装包名是opencv-python,这个对应关系也常坑到新手——在VSCode里写import cv2提示找不到模块,其实不是代码问题,是装错包名。
另外建议顺手装上scikit-learn,后面做混淆矩阵要用;matplotlib用来画训练曲线和可视化预测结果。这两个不在关键路径上,但做论文和答辩展示时基本少不了。
3.3 归一化与数据增强:三个必调参数
数据从CSV读出来是0到255的整数灰度值,直接喂给网络有两个问题:数值范围太大会让梯度更新不稳,而且卷积层对输入的尺度敏感。标准做法是归一化到[-1, 1]或[0, 1]。我用的是(x - 128.0) / 128.0,等价于mean=0.5、std=0.5,理由是48x48的小图细节少,过多的均值方差统计反而容易把分布绑死在训练集上。
数据增强方面有三个参数我每次必调:
第一个是随机裁剪。对48x48的图,随机裁剪成44x44再缩放回48x48,等效于给模型看略微偏移的人脸,缓解检测框不精准的问题。padding为4的RandomCrop是常见配法。
第二个是水平翻转。人脸表情在水平方向上大致对称——向左笑和向右笑在语义上没区别,但卷积核不是对称的,翻转相当于把训练数据扩大一倍。注意不要做垂直翻转,把人脸倒过来训练会引入错误先验。
第三个是旋转。表情识别对旋转角度很敏感,超过15度的旋转就会让五官位置关系失真,我只rotate±10度。旋转角度设大会让模型看到大量扭曲的人脸,验证集上反而更差。
增强操作放在PyTorch的transforms里统一编排:
from torchvision import transforms train_transform = transforms.Compose([ transforms.ToTensor(), # (48,48,1) -> (1,48,48),像素缩放到[0,1] transforms.RandomCrop(44, padding=4), # 随机裁剪44x44,再在后面resize回48x48 transforms.Resize((48, 48)), transforms.RandomHorizontalFlip(), # 随机水平翻转 transforms.RandomRotation(10), # 随机旋转±10度 transforms.Normalize((0.5,), (0.5,)) # 归一化到[-1,1] ]) val_transform = transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.5,), (0.5,)) ])注意验证集和测试集的变换里只有ToTensor和Normalize,不做RandomCrop和RandomFlip。这是个原则性问题:验证集代表模型的真实水平,加了随机增强会让验证结果每次不一样,你没法判断模型到底是变好了还是只是运气好。
3.4 类别不均衡:Disgust类只有别人的零头
FER2013的七类表情样本数差异非常大,大致分布是这样:
| 表情 | 大约样本数(全量) |
|---|---|
| Angry | 4900 |
| Disgust | 550 |
| Fear | 5100 |
| Happy | 8900 |
| Sad | 6000 |
| Surprise | 4000 |
| Neutral | 6200 |
Disgust的样本数只有Happy的零头。如果直接按默认的CrossEntropyLoss训练,模型会倾向于把Disgust错分成其他类,因为全预测成Happy都能拿高准确率。处理办法有两个,二选一即可:
第一种是在损失函数里给每个类加权重,样本少的类权重高。PyTorch的CrossEntropyLoss直接支持weight参数,传入一个长度为7的tensor。权重怎么算?我习惯用总样本数 / (类别数 * 该类别样本数),这样所有类的权重和为1。
第二种是用WeightedRandomSampler,在采样时让每类的命中概率均衡,相当于制造一个类别平衡的batch。这个方法对DataLoader的影响更直接,但要注意drop_last要设成True,否则最后一个batch可能全是重复样本。
我一般优先用第一种,改一行代码就能生效:
class_counts = np.array([4953, 547, 5121, 8989, 6077, 4002, 6198]) weights = class_counts.sum() / (len(class_counts) * class_counts.astype(np.float64)) class_weights = torch.from_numpy(weights).float().to(device) criterion = nn.CrossEntropyLoss(weight=class_weights)加了类别权重后,Disgust的召回率会明显上升,总准确率可能微降,但论文里能画出更漂亮的混淆矩阵,这个取舍是值得的。
4. 用PyTorch把CNN搭起来训练:代码、超参数与模型保存
4.1 网络结构代码:卷积、批归一化、Dropout的排布
这是整套系统的核心代码。我按第2章定的基准结构来实现,每一层都带注释说明设计意图:
import torch.nn as nn class ExpressionCNN(nn.Module): def __init__(self, num_classes=7): super(ExpressionCNN, self).__init__() self.features = nn.Sequential( # 卷积块1: 48x48x1 -> 24x24x64 nn.Conv2d(1, 64, kernel_size=3, padding=1), nn.BatchNorm2d(64), nn.ReLU(inplace=True), nn.Conv2d(64, 64, kernel_size=3, padding=1), nn.BatchNorm2d(64), nn.ReLU(inplace=True), nn.MaxPool2d(kernel_size=2, stride=2), # 卷积块2: 24x24x64 -> 12x12x128 nn.Conv2d(64, 128, kernel_size=3, padding=1), nn.BatchNorm2d(128), nn.ReLU(inplace=True), nn.Conv2d(128, 128, kernel_size=3, padding=1), nn.BatchNorm2d(128), nn.ReLU(inplace=True), nn.MaxPool2d(kernel_size=2, stride=2), # 卷积块3: 12x12x128 -> 6x6x256 nn.Conv2d(128, 256, kernel_size=3, padding=1), nn.BatchNorm2d(256), nn.ReLU(inplace=True), nn.Conv2d(256, 256, kernel_size=3, padding=1), nn.BatchNorm2d(256), nn.ReLU(inplace=True), nn.MaxPool2d(kernel_size=2, stride=2), ) self.classifier = nn.Sequential( nn.Dropout(0.5), nn.Linear(256 * 6 * 6, 512), nn.ReLU(inplace=True), nn.Dropout(0.5), nn.Linear(512, num_classes), ) def forward(self, x): x = self.features(x) x = x.view(x.size(0), -1) # 拉平成向量,送入全连接层 x = self.classifier(x) return x代码里的两个关键设计点:每个卷积块内连续两个3x3卷积,等价于一个5x5卷积的感受野,但非线性更强、参数量更少;每个ReLU前都接BatchNorm2d,作用是让每层输入分布稳定,允许你用更大的学习率而不容易发散。Dropout放在全连接层前面而不是卷积层后面,这是通用实践——全连接层参数量占比最大,最容易过拟合,Dropout对它有最强的抑制效果。
有个值得注意的细节:inplace=True这个参数很多人看不懂,它的意思是把ReLU的输出直接覆盖到原内存上,省一次内存分配。训练时能省一点显存,推理时没有影响。如果你在模型里做深入定制,这个参数不可用时(比如跟autograd冲突),把它改成False就行,不影响精度。
4.2 训练脚本:损失函数、优化器与学习率调度
训练主流程用标准的三件套:CrossEntropyLoss做损失、Adam做优化器、StepLR做学习率调度。完整代码可以直接复制运行:
import torch import torch.optim as optim from torch.optim.lr_scheduler import StepLR from torch.utils.data import DataLoader device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = ExpressionCNN(num_classes=7).to(device) # 3.4节里给的类别权重在这里用 criterion = nn.CrossEntropyLoss(weight=class_weights.to(device)) optimizer = optim.Adam(model.parameters(), lr=1e-3, weight_decay=1e-4) scheduler = StepLR(optimizer, step_size=10, gamma=0.1) train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True, num_workers=2) val_loader = DataLoader(val_dataset, batch_size=64, shuffle=False, num_workers=2) best_acc = 0.0 for epoch in range(30): model.train() running_loss = 0.0 for images, labels in train_loader: images, labels = images.to(device), labels.to(device) optimizer.zero_grad() outputs = model(images) loss = criterion(outputs, labels) loss.backward() optimizer.step() running_loss += loss.item() # 每轮训练完做一次验证 model.eval() correct, total = 0, 0 with torch.no_grad(): for images, labels in val_loader: images, labels = images.to(device), labels.to(device) outputs = model(images) _, predicted = torch.max(outputs, 1) total += labels.size(0) correct += (predicted == labels).sum().item() val_acc = 100.0 * correct / total if val_acc > best_acc: best_acc = val_acc torch.save(model.state_dict(), 'best_model.pt') # 只保存权重,不保存整个模型 scheduler.step() print(f'Epoch {epoch+1:02d} | Loss: {running_loss/len(train_loader):.4f} | Val Acc: {val_acc:.2f}% | Best: {best_acc:.2f}%')逻辑不复杂:每个epoch先遍历整个训练集,用梯度下降更新权重;结束后在验证集上做一次前向推理,统计准确率;如果本轮验证准确率比历史最好值高,就把当前权重保存到best_model.pt。学习率调度器每10轮把学习率乘以0.1,让模型在后期用小步长精细收敛。
这里有个「后悔药」机制一定要养成习惯:只保存验证集上的最优权重,而不是傻傻地等30轮跑完再手动挑。训练过程中模型很可能在第18轮就已经到最佳状态,继续训练反而开始过拟合。有了best_model.pt,哪怕后面训练烂了也能回滚。
4.3 关键超参数:batch大小、学习率、epoch与weight_decay
这四个参数是训练成败的主控旋钮,我逐个说我的配置理由。
batch大小默认64,GPU显存不够就降到32或16。batch越小,梯度噪声越大,模型收敛越不稳定;batch太大(128以上)又会降低泛化能力。64对小数据集是个甜点值。如果显存连32都跑不动,把网络里的通道数从64/128/256减半到32/64/128,效果比硬撑要稳定得多。
学习率默认1e-3,搭配Adam优化器。这个组合对小型CNN几乎不会翻车。如果训练时loss一开始就飙升或者震荡剧烈,把学习率降到3e-4再试;反过来,如果loss下降很慢,可以试试2e-3,但不要超过这个值。学习率调度用StepLR,每10轮降为原来的0.1,30轮训练里相当于最后10轮在精修。
epoch数设30。FER2013这个规模的数据,30轮足够让模型收敛,继续加轮数验证集准确率也不会再涨多少。真正该关注的不是epoch数量,而是loss曲线:如果训练loss还在稳定下降、验证loss拐头向上,说明开始过拟合,提前停掉就好。
weight_decay我设1e-4,它是L2正则化的强度。设太大(1e-2)会让权重被压缩得过小,模型欠拟合;设太小等于没有正则。1e-4对表情识别这种中型网络是个稳妥起点,调参优先级低于学习率,一般不用动。
5. 人脸表情识别避坑指南:五个常见翻车点
这一章写的是我做这类项目反复踩过的坑,每一条都是「现象 → 原因 → 解决」的结构,新手上路前先看一遍能省好几天排查时间。
5.1 训练loss不降反升
现象:前几个epoch的loss在1.9附近波动,然后一路涨到2.3甚至更高,验证准确率在20%以下徘徊。20%是什么概念?七分类随机猜的准确率是14.3%,20%约等于模型在瞎蒙。
原因分三类:最常见的是数据预处理出错——pixels没归一化或归一化参数写得不对,梯度被大数值输入带偏;第二类原因是学习率过大,Adam虽然自适应,但初始学习率1e-3对某些网络结构仍然偏激进;第三类比较隐蔽,是数据增强强度过大,随机旋转加随机裁剪叠加后,人脸特征被破坏到模型学不到有效模式。
解决:先打印一个batch的数据,检查图像的像素范围是不是在[-1, 1]区间、标签是不是0到6的整数;然后把增强暂时全关掉,只用ToTensor和Normalize跑5个epoch,如果loss正常下降,问题就锁定在增强参数上,把旋转角度从15度改回10度,去掉RandomCrop再逐步加回来。
5.2 验证准确率来回震荡
现象:训练loss在平滑下降,但每个epoch验证的准确率像过山车,这轮62%、下轮48%、再下轮58%,完全看不出趋势。
原因:FER2013的PublicTest只有3589张图,batch size设为64的话,验证集总共才56个batch,任何一批的分类结果变化都会让验证准确率波动好几个百分点。另外,类别不均衡也会放大这种波动——Disgust在验证集里只有几十张,某轮蒙对三张、某轮蒙对零张,对整体准确率的影响能有1到2个百分点。
解决:不要用单次验证准确率判断模型好坏,改成每5个epoch验证一次,或者记录每个epoch的验证loss并画平滑曲线。同时在验证时统计「按类别的平均准确率」而不是全局准确率,能更真实地反映模型在每个表情上的表现。
5.3 摄像头推理延迟过高
现象:用训练好的模型接USB摄像头,画面严重卡顿,目测帧率只有5到8帧。你以为是模型推理太慢,但其实可能不是。
原因:我排查过类似问题,真正的瓶颈往往不是模型本身——一个48x48输入的小CNN单次推理只要几毫秒,卡顿的根源在于「人脸检测 + 表情分类」串行执行时,OpenCV的Haar级联检测在大尺寸帧上每帧都要扫一遍多尺度窗口,这个开销比模型推理大得多。
解决:采用跳帧策略,每处理2帧才跑一次完整检测加分类,中间帧直接复用上一帧的结果;检测前把帧缩小一半再送入detectMultiScale,坐标按比例还原;还可以用cv2.CAP_PROP_FPS把摄像头读取帧率限制在30以内,避免缓冲区堆积。这些改动对展示效果几乎没有影响,但能让帧率翻倍以上。
5.4 训练结果复现不了
现象:同一个源码包,同一份数据集,跑两次得到的验证准确率不一样,有时候差三到四个百分点。这在论文里写实验对比时很尴尬,因为你没法证明某个改动是有效的。
原因:深度学习训练里有三处随机源——PyTorch模型参数的随机初始化、DataLoader的shuffle打乱顺序、以及CUDA的卷积算法选择。这三处不固定,结果就不可能完全复现。
解决:在训练脚本开头固定所有随机种子,并关闭CUDA的自动调优:
import random import numpy as np import torch def set_seed(seed=42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) torch.backends.cudnn.deterministic = True # 牺牲一点速度,保证可复现 torch.backends.cudnn.benchmark = False注意deterministic=True会稍微拖慢训练速度,但换来的是每次跑结果完全一致,这在调参和写论文时价值极高。
5.5 测试集准确率比验证集低一大截
现象:训练时验证准确率能做到66%,但用PrivateTest(真正没见过的测试集)一测,只有58%,差了8个百分点。
原因:我在第3章说过,训练过程中每轮都在用验证集挑最优模型,这本身就是一种隐式的「过拟合验证集」。选模型时用的标准是「在PublicTest上准确率最高」,相当于在验证集上做了多次假设检验,模型的泛化能力被高估了。
解决:这是正常的,不需要害怕。做法是:训练阶段只看验证集选模型,训完后用测试集测一次记录结果,这个测试结果就是论文里要写的最终数字。如果测试集和验证集差距过大,说明模型对数据分布的记忆过强,优先做法是增大Dropout到0.6、调高weight_decay到5e-4,而不是换一个更大的网络。
6. 从跑通到能答辩:验证模型、实时演示与展示思路
6.1 用混淆矩阵确认模型的薄弱表情
训练结束后,第一件事不是急着做演示,而是把模型在测试集上的混淆矩阵画出来。这一步能直观告诉你模型把哪些表情混在了一起——常见的规律是Fear和Sad互相认错、Neutral被大量误判成其他表情。有了这张图,论文的实验分析章节才有内容可写。
from sklearn.metrics import confusion_matrix, ConfusionMatrixDisplay import matplotlib.pyplot as plt emotion_labels = ['Angry', 'Disgust', 'Fear', 'Happy', 'Sad', 'Surprise', 'Neutral'] # 在测试集上收集所有预测结果 model.eval() all_preds, all_labels = [], [] with torch.no_grad(): for images, labels in test_loader: images = images.to(device) outputs = model(images) _, preds = torch.max(outputs, 1) all_preds.extend(preds.cpu().numpy()) all_labels.extend(labels.numpy()) cm = confusion_matrix(all_labels, all_preds) disp = ConfusionMatrixDisplay(confusion_matrix=cm, display_labels=emotion_labels) disp.plot(cmap='Blues', xticks_rotation='vertical') plt.savefig('confusion_matrix.png', dpi=150, bbox_inches='tight') plt.show()有的项目标题里带「源码」,源码包通常已经包含了类似的可视化脚本,但自己跑一遍才能在答辩时讲清楚每个数字的含义。色块深的对角线是模型的强项,对角线外深色的格子就是典型的混淆对——把这两个格子拿出来分析为什么混淆,比通篇背准确率数字有说服力得多。
6.2 实时摄像头演示与答辩展示的串联方式
答辩现场最出效果的是一个能跑的实时演示。流程并不复杂:OpenCV读摄像头帧 → 转灰度 → Haar级联检测人脸框 → 裁剪人脸区域 → 缩放48x48 → 归一化 → 送入CNN → 把预测表情名称画在框上。核心代码量不大,但要把「跳帧」加进去,保证演示流畅:
import cv2 import torch from torchvision import transforms face_cascade = cv2.CascadeClassifier(cv2.data.haarcascades + 'haarcascade_frontalface_default.xml') model.eval() cap = cv2.VideoCapture(0) preprocess = transforms.Compose([ transforms.ToPILImage(), transforms.Resize((48, 48)), transforms.ToTensor(), transforms.Normalize((0.5,), (0.5,)) ]) frame_idx = 0 result_text = '' while True: ret, frame = cap.read() if not ret: break frame_idx += 1 gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) if frame_idx % 3 == 0: # 每3帧做一次检测和分类 faces = face_cascade.detectMultiScale(gray, scaleFactor=1.1, minNeighbors=5, minSize=(48, 48)) result_text = '' for (x, y, w, h) in faces: face = gray[y:y+h, x:x+w] # 裁剪人脸区域 tensor = preprocess(face).unsqueeze(0).to(device) with torch.no_grad(): output = model(tensor) pred_idx = torch.argmax(output, dim=1).item() result_text = emotion_labels[pred_idx] cv2.rectangle(frame, (x, y), (x+w, y+h), (0, 255, 0), 2) cv2.putText(frame, result_text, (30, 30), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 0, 255), 2) cv2.imshow('FER Demo', frame) if cv2.waitKey(1) & 0xFF == ord('q'): break cap.release() cv2.destroyAllWindows()这套演示代码有几个细节值得留意:检测用的是OpenCV自带的Haar级联分类器,不需要额外下载模型文件,cv2.data.haarcascades路径下自带;人脸区域直接灰度裁剪,不转RGB,因为模型输入本身就是单通道;minSize=(48, 48)限制了最小检测尺寸,避免远处的小脸被送入网络时因信息不足产生误判。
答辩展示时,我建议按这个顺序串:先花30秒讲清楚数据从哪来、长什么样、为什么分三类划分,再讲网络结构为什么要这么设计(卷积块加池化的动机、Dropout和BN的作用),然后展示训练曲线的收敛趋势和混淆矩阵,最后用实时摄像头演示收尾。源码、数据和PPT其实是同一件事的三面——数据是基础,源码是实现,PPT是说服力,三者在答辩时互为印证。
我自己的习惯是:所有代码跑通后,把关键实验截图(训练loss曲线、混淆矩阵、实时演示效果)按顺序存到项目目录的docs文件夹里,写论文和做PPT时直接取用。这套系统从拿到数据到完成演示,一个周末能跑通主流程,但真正把它做好、讲透,还需要在数据增强和调参上多花几个迭代。
希望帮到你把这套项目做得比「能跑」更进一步。
本文还有配套的精品资源,点击获取