简介:面向计算机专业学生,这是一份基于Pytorch构建的卷积神经网络面部表情识别毕业设计项目,评审得分高达98分,且已严格调试可运行。资源包内含2000个文件,其中有1992张JPG表情图像、6个Python源码、1个CSV标注文件及1个Markdown说明文档,整体大小53.69MB。图像样本覆盖快乐、悲伤、愤怒、恐惧、惊讶与中性等常见情绪,代码按数据加载、网络结构定义、模型训练与测试评估模块划分,完整呈现数据预处理、特征提取、反向传播优化到独立测试集验证的深度学习流程。通过该项目可深入理解卷积神经网络原理,掌握Pytorch图像分类与表情识别方法,并借助配套论文资料、参考文献及训练好的模型权重,大幅节省数据收集与模型搭建时间,快速产出高质量成果。目前已有44人学习浏览,尤其适合计算机专业学生用于毕业设计、课程设计或期末大作业,是一份兼具完整性与实战性的高分参考项目。
1. 手里有一份能跑通的表情识别代码,离能答辩的毕设还差什么
多数用卷积神经网络做面部表情识别的 PyTorch 项目,代码能跑,但答辩时撑不过三个问题:数据集是怎么加载的、损失函数为什么这么选、实时检测为什么这么卡。表情识别这件事的流程本身不长——数据管线、网络定义、训练循环、推理脚本,四段而已,但每一段都藏着毕业设计现场会被追问的细节。FER2013 是 CSV 而不是图片文件夹,48×48 灰度图要不要归一化,七分类的类别权重怎么算,OpenCV 调 PyTorch 模型时帧率为什么上不去。这篇按一个交得出手的毕设资源包来组织,从任务设定讲到调参,再讲到把模型接到摄像头上的落地方法。适合手里已有代码但讲不清楚细节的人,也适合准备从零搭一套的人。
2. 表情识别为什么用 CNN:任务设定、数据集与评价指标
2.1 从卷积结构说起:为什么全连接网络做不好这件事
面部表情识别本质上是图像分类,输入是一张人脸图,输出是情绪类别。传统的做法是把像素拉平后送进全连接网络,但全连接层对位置极其敏感,眼睛稍微偏移几个像素,激活值分布就变了,模型学到的不是"表情",而是"某个位置的像素组合"。卷积神经网络用局部感受野和权值共享解决了这个问题:一个卷积核只关心一个小邻域内的像素,同一个核滑过整张图,相当于在找"嘴角上扬""眉毛下压"这类局部纹理,而不是死记整张图的坐标。
在实际项目里,表情识别常用的是 48×48 或 64×64 的灰度图。灰度图意味着输入通道数是 1,模型第一层的in_channels=1,这比三通道 RGB 少算不少参数。图像尺寸小,意味着没必要上特别深的网络,LeNet-5 级别的结构就能跑,VGG 简化版也完全够用。这是表情识别和 ImageNet 分类最大的区别:数据量小、分辨率低、类别少(常见的是 7 类),深网络在这里只会更快过拟合。
2.2 数据集的选型:FER2013、CK+ 与标签体系
毕业设计里出现频率最高的是 FER2013,因为它是一个公开 CSV 文件,不需要爬数据,解压就能用。它的格式比较特殊:每一行有emotion、pixels、Usage三列,pixels是 2304 个以空格分隔的 0-255 灰度值,Usage标明了这一行属于 Training、PublicTest 还是 PrivateTest。训练集约 28709 张,公开测试集 3589 张,这个划分是固定的,做实验时要尊重它,不要自己重新随机切分,否则和公开结果没法对比。
CK+ 是实验室采集的 posed 表情序列,质量高但样本量小,只有几百个序列,而且很多人会不小心把同一被试的相邻帧同时放进训练集和测试集,造成数据泄漏。作为补充验证可以,作为主数据集不推荐。
| 数据集 | 样本规模 | 图像形式 | 类别数 | 划分方式 | 适合用途 |
|---|---|---|---|---|---|
| FER2013 | 约 3.5 万张 | 48×48 灰度 | 7 类 | 官方固定划分 | 主训练集、对比公开准确率 |
| CK+ | 约 593 个序列 | 不同尺寸灰度/彩色 | 7-8 类 | 需自行划分 | 交叉验证、小样本实验 |
| RAF-DB | 约 3 万张 | 彩色、有对齐版本 | 7 类基本表情 | 官方划分 | 想尝试更自然的样本 |
七分类的标签是 Angry、Disgust、Fear、Happy、Sad、Surprise、Neutral。有的项目会加一个 Contempt 变成八分类,但 FER2013 原始标注只有七类,用八分类意味着你要么换数据集,要么自己改标注,这在答辩时会引入额外的工作量和质疑点,不建议在毕业设计里另起炉灶。
2.3 评价指标:准确率之外还该看什么
表情数据集天然不均衡。FER2013 里 Happy 样本最多,Disgust 样本最少,后者大约只有前者的二十分之一。如果只盯着准确率,模型只要把多数类学好了,整体准确率就能到 65% 左右,但 Disgust 的召回率可能趋近于零。所以正确的做法是同时报告每个类别的 Precision、Recall、F1-score,以及加权平均 F1。
提示:答辩时如果被问"你的模型有没有偏向某一类",直接拿出按类别计算的混淆矩阵回答,比空口说"我做了数据增强"有说服力得多。混淆矩阵不仅是评价工具,也是你后续定位分类错误、决定要不要调类别权重的依据。
3. 用 PyTorch 搭一个可复现的表情识别训练流程
3.1 数据加载:把 FER2013 的 CSV 变成 PyTorch Dataset
FER2013 的pixels是字符串,不能直接给torchvision.datasets.ImageFolder用,需要写一个自定义 Dataset。常见做法是在__init__里把 CSV 读进来,按Usage列过滤出训练和测试子集,在__getitem__里解析像素串并转成张量。下面的代码是一个可以直接套用的实现:
import pandas as pd import torch from torch.utils.data import Dataset from PIL import Image import io class FER2013Dataset(Dataset): def __init__(self, csv_path, usage='Training', transform=None): df = pd.read_csv(csv_path) self.df = df[df['Usage'] == usage].reset_index(drop=True) self.transform = transform def __len__(self): return len(self.df) def __getitem__(self, idx): row = self.df.iloc[idx] label = row['emotion'] pixels = row['pixels'].split() # 2304 个像素对应 48x48 灰度图 img = torch.tensor([int(p) for p in pixels], dtype=torch.uint8) img = img.view(48, 48).numpy() img = Image.fromarray(img.astype('uint8'), mode='L') if self.transform: img = self.transform(img) return img, label这段代码有一个值得留意的点:先把像素字符串转成torch.uint8,再转成 PIL Image,最后交给transform处理。这样就统一了后续数据增强的入口,随机裁剪、翻转等操作都作用于 PIL 图像,符合torchvision.transforms的设计习惯。直接拿torch.Tensor做增强会绕过很多内置函数,后面会吃亏。
3.2 网络结构:一个够用且能讲清楚的轻量 CNN
表情识别不需要上 ResNet 或 VGG16 这种在 ImageNet 上预训练的深网络,模型太大反而容易在几万张图上过拟合。常见做法是一个三到四个卷积块的轻量结构,每个块里包含卷积、批归一化、ReLU 和最大池化。批归一化要加,它让训练稳定很多,尤其在你后面想尝试调大学习率时,BN 是兜底的那一层。
import torch.nn as nn class ExpressionCNN(nn.Module): def __init__(self, num_classes=7): super().__init__() self.features = nn.Sequential( nn.Conv2d(1, 32, kernel_size=3, padding=1), nn.BatchNorm2d(32), nn.ReLU(inplace=True), nn.MaxPool2d(2), nn.Conv2d(32, 64, kernel_size=3, padding=1), nn.BatchNorm2d(64), nn.ReLU(inplace=True), nn.MaxPool2d(2), nn.Conv2d(64, 128, kernel_size=3, padding=1), nn.BatchNorm2d(128), nn.ReLU(inplace=True), nn.MaxPool2d(2), ) self.classifier = nn.Sequential( nn.Dropout(0.5), nn.Linear(128 * 6 * 6, 256), nn.ReLU(inplace=True), nn.Dropout(0.3), nn.Linear(256, num_classes), ) def forward(self, x): x = self.features(x) x = x.view(x.size(0), -1) x = self.classifier(x) return x48×48 的输入经过三次 2 倍池化后变成 6×6,通道数是 128,所以全连接层的输入维度是128 × 6 × 6。这两个 dropout 的位置和比例是经验值:第一个放全连接之前,丢掉一半的高层特征;第二个放最后一层之前,比例降到 0.3。这里的kernel_size=3, padding=1保证了卷积不改变特征图尺寸,只由池化来降分辨率。答辩时讲这个结构只需要说清楚三句话:前三层负责提取局部纹理特征,池化扩大感受野并降低计算量,全连接层做分类决策。
3.3 训练脚本:优化器、损失函数与早停逻辑
训练循环本身不复杂,但有几个参数会影响收敛质量。损失函数用交叉熵nn.CrossEntropyLoss(),它内部已经包含 softmax,不需要在网络输出后再手动加一层。优化器我一般用 Adam,学习率从 1e-3 起步,配合 ReduceLROnPlateau 在验证损失停滞时把学习率降低一个数量级。weight_decay默认设 1e-4,给全连接层加一点 L2 约束。
import torch.optim as optim from torch.optim.lr_scheduler import ReduceLROnPlateau model = ExpressionCNN(num_classes=7) criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=1e-3, weight_decay=1e-4) scheduler = ReduceLROnPlateau(optimizer, mode='min', factor=0.5, patience=3) for epoch in range(epochs): model.train() for images, labels in train_loader: optimizer.zero_grad() outputs = model(images) loss = criterion(outputs, labels) loss.backward() optimizer.step() # 验证阶段 model.eval() val_loss = 0.0 with torch.no_grad(): for images, labels in val_loader: outputs = model(images) val_loss += criterion(outputs, labels).item() val_loss /= len(val_loader) scheduler.step(val_loss)早停不一定要写得很复杂,一个简单做法是记录验证集最优损失,连续 8 个 epoch 不下降就停止训练并保存最优模型。注意model.eval()和with torch.no_grad()必须同时出现,前者关掉 dropout 和 BN 的统计更新,后者关掉梯度计算和计算图存储。漏掉model.eval()会导致推理结果不稳定,因为 dropout 还在按概率随机丢弃神经元。
3.4 测试评估:混淆矩阵与类别 F1
训练结束后,测试集上的评估代码要生成三样东西:整体准确率、按类别的 F1-score、混淆矩阵。下面的代码输出一张 7×7 的混淆矩阵,并返回加权 F1:
from sklearn.metrics import confusion_matrix, classification_report import numpy as np def evaluate(model, test_loader, class_names): model.eval() all_preds = [] all_labels = [] with torch.no_grad(): for images, labels in test_loader: outputs = model(images) _, preds = torch.max(outputs, 1) all_preds.extend(preds.cpu().numpy()) all_labels.extend(labels.numpy()) print(classification_report(all_labels, all_preds, target_names=class_names, digits=3)) cm = confusion_matrix(all_labels, all_preds) print(cm) return cmclassification_report会输出每一类的 precision、recall、f1-score 和样本数,这些数字是你写毕业论文实验章节的直接素材。混淆矩阵数组可以np.save()存下来,后面画热力图时直接加载。这里有一个容易被忽略的坑:测试时也要把图像走一遍和训练时相同的预处理,至少包括ToTensor()和归一化。如果训练时对图像做了Normalize((0.5,), (0.5,)),测试时不加,输入分布不一致,准确率会掉好几个点。
4. 训练调参的实战经验:学习率、数据增强与过拟合边界
4.1 学习率与优化器参数:从 1e-3 起步,按验证集损失衰减
Adam 是表情识别项目里默认的优化器,它对学习率的敏感度比 SGD 低,初始值 1e-3 在大多数 CNN 结构上都能稳定收敛。但 Adam 不等于不用调学习率,配合ReduceLROnPlateau是最稳的组合。patience=3表示验证集损失连续 3 个 epoch 不下降就衰减一次,factor=0.5表示学习率减半。这两个参数决定训练节奏:patience 太小会过早衰减导致收敛不充分,太大则浪费训练时间。batch size 如果是 128,一般 30 个 epoch 内能看到完整的训练曲线。
| 参数 | 推荐值 | 调整方向与说明 |
|---|---|---|
| 初始学习率 | 1e-3 | 模型不收敛就降到 3e-4,不要从更大值硬试 |
| lr 衰减因子 | 0.5 | 每次乘以 0.5,衰减幅度太大会震荡 |
| lr 衰减 patience | 3-5 epoch | 验证损失连续不降时触发 |
| batch size | 64 / 128 | 48×48 小图,128 在 6GB 显存内可运行 |
| weight_decay | 1e-4 | 过拟合明显时可升到 5e-4 |
| dropout(分类层前) | 0.5 | 数据量小或网络偏大时升到 0.6 |
4.2 数据增强:一组既有效又不增加太多代码的组合
表情识别里的数据增强不用做太花哨,随机水平翻转、随机裁剪、随机旋转是性价比最高的三个。水平翻转对表情分类有效是因为情绪不受左右方向影响,这在语义上是安全的增强方式。随机裁剪在 48×48 的小图上要谨慎,裁得太狠会切掉眼睛或嘴巴这些关键部位。一种常见方案是先把图 padding 到 56×56,再随机裁剪回 48×48,这样既有了平移效果,又不会丢掉关键区域。
from torchvision import transforms train_transforms = transforms.Compose([ transforms.RandomHorizontalFlip(p=0.5), transforms.RandomAffine(degrees=10, translate=(0.1, 0.1)), transforms.ToTensor(), transforms.Normalize(mean=(0.5,), std=(0.5,)) ]) test_transforms = transforms.Compose([ transforms.ToTensor(), transforms.Normalize(mean=(0.5,), std=(0.5,)) ])4.3 过拟合的判断信号与处理手段
表情识别数据集小,过拟合现象出现得很早。最典型的信号是训练 loss 一路下降,验证 loss 在某个 epoch 后开始回升,或者训练准确率明显高于验证准确率。FER2013 上,如果训练准确率到了 90% 以上而验证集只有 60% 多,基本可以确定模型开始死记训练样本了。在增加数据增强之前,先确认模型容量是不是超出数据集规模——通道数 64 起步的轻量网络在 3 万张图上通常不需要进一步加大。
另一个实用的技巧是观察混淆矩阵里哪些类别互相混淆。Fear 和 Sadness 在 FER2013 里混淆率一直很高,这是数据集标注本身的问题,不是模型的错。答辩时主动说出这组混淆意味着什么,比逃避问题更有利。这里有一个常见误区:有些人会直接复制torchvision.models里的预训练模型并冻结骨干网络,但在 48×48 单通道输入下,这种做法既浪费计算量也没有明显收益,因为 ImageNet 预训练特征和灰度小脸图之间存在较大的分布偏移,在表情识别里,从头训练一个轻量网络是更合理的选择。
提示:调参不是逐个试,而是先固定结构、只动学习率和数据增强两组旋钮。每改动一个变量,记录验证集 loss,不要同时改两个以上,否则无法定位是哪个操作带来了增益。
5. 从静态图片到实时摄像头:模型落地与毕设展示
5.1 用 OpenCV 调 PyTorch 模型实现实时识别
训练好的模型最后要接进摄像头才像一个完整的毕设系统。核心流程是:OpenCV 读帧,用 Haar 级联检测人脸区域,裁剪后缩放到 48×48,转灰度图,经过和训练一致的预处理后送入模型,取输出概率最大的类别作为结果。下面是一个最小可运行的推理脚本:
import cv2 import torch import numpy as np from torchvision import transforms device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = ExpressionCNN(num_classes=7) model.load_state_dict(torch.load('best_model.pth', map_location=device)) model.to(device).eval() transform = transforms.Compose([ transforms.ToPILImage(), transforms.Resize((48, 48)), transforms.ToTensor(), transforms.Normalize(mean=(0.5,), std=(0.5,)) ]) face_cascade = cv2.CascadeClassifier( cv2.data.haarcascades + 'haarcascade_frontalface_default.xml') emotions = ['Angry', 'Disgust', 'Fear', 'Happy', 'Sad', 'Surprise', 'Neutral'] cap = cv2.VideoCapture(0) with torch.no_grad(): while True: ret, frame = cap.read() gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) faces = face_cascade.detectMultiScale(gray, 1.1, 5, minSize=(48, 48)) for (x, y, w, h) in faces: face = gray[y:y+h, x:x+w] face = cv2.resize(face, (48, 48)) tensor = transform(face).unsqueeze(0).to(device) output = model(tensor) pred = torch.argmax(output, dim=1).item() cv2.rectangle(frame, (x, y), (x+w, y+h), (0, 255, 0), 2) cv2.putText(frame, emotions[pred], (x, y-10), cv2.FONT_HERSHEY_SIMPLEX, 0.9, (0, 255, 0), 2) cv2.imshow('Expression Recognition', frame) if cv2.waitKey(1) & 0xFF == ord('q'): break cap.release() cv2.destroyAllWindows()model.load_state_dict(torch.load('best_model.pth', map_location=device))里的map_location=device是 CPU/GPU 切换的关键,训练时保存的模型若在 GPU 上,直接 CPU 加载会报设备不匹配的错误。torch.no_grad()放在循环外面比放在循环里面更优,因为每次进入和退出上下文都有微小开销,循环级复用一个上下文能让帧率更高。这里没有做对模型输出的软标签可视化,如果你想让演示更直观,可以把输出 logits 经过torch.softmax后,把最大概率值也画在框上,方便观察模型对当前帧是不是"不确定"。
5.2 帧率瓶颈与优化顺序
实时识别常见的问题是卡顿。卡顿有三个来源:人脸检测、图像缩放和模型推理。Haar 级联在 CPU 上做detectMultiScale已经很快了,但在 1080p 的帧上仍有耗时;模型推理虽然是轻量 CNN,但在 CPU 上单帧前向传播也要几十毫秒。优先做的优化是:把人脸检测的输入图像缩小一半(detectMultiScale支持传缩放后的图像),以及把cv2.putText的文本合成开销降到最低。还有一个容易忽略的点是minNeighbors=5这个参数,调大到 8 会减少误检但可能漏掉侧脸,调小到 3 帧率基本不变但画面会多出很多假脸框,需要根据你摄像头前的光照条件来折中。
提示:如果帧率还是很低,不要急着改模型,先用
time.time()分别统计检测耗时和推理耗时,定位瓶颈在哪一段再动。很多项目卡在cv2.resize和numpy转换上,和模型没有关系。
5.3 资源包的内容组织与答辩时怎么讲
一个完整的表情识别资源包,除了训练脚本,应该包含五样东西:数据加载模块、模型定义模块、训练脚本、测试评估脚本、实时推理脚本。数据放在单独目录但不能直接打进压缩包,FER2013 的 csv 文件有 40 多 M,答辩前要确认网络环境能下载,或者准备一个百度的备用链接。配置文件用 yaml 或者直接写成 Python 常量,把学习率、batch size、epoch 数放进去,答辩时可以说"所有超参数集中管理,复现时不需要改代码"。README 里要写清运行顺序、Python 环境和依赖清单,这是很多毕设被扣分的地方。最后一行最好停在演示环节的具体细节上:提前准备一张包含多个表情的面部合影,用静态图片模式跑一遍,再切换到摄像头模式,两种模式的切换按钮写成一个命令行参数,这比现场临时找角度对脸要稳定得多。
本文还有配套的精品资源,点击获取