简介:基于PyTorch实现的人脸表情识别项目源码包,面向深度学习初学者与计算机视觉开发者,覆盖CNN、VGG、ResNet三种经典网络从数据准备到训练的完整流程。压缩包共15个文件,包含13个Python脚本、1个XML与1个说明文档,整体大小仅161KB,体量精简但结构完整。脚本按功能拆分为模型定义、单卡训练、数据预览与划分、测试评估、多模型对比等模块,并针对GPU环境单独提供训练入口;XML为人脸检测必需的Haar级联配置。README.md给出数据集来源与运行说明,数据集需从Kaggle获取。资源标注浏览/学习人数为434人,适合希望参考PyTorch人脸识别工程结构、快速搭建训练评估流程的开发者,可作为课程设计或入门项目的参照实现。
1. 一张图看懂这个表情识别项目在做什么
人脸表情识别是计算机视觉里少有的「既好出成果、又难做到极致」的方向。它不像目标检测那样动辄几十个类别,通常只有 angry、disgust、fear、happy、neutral、sad、surprise 这 7 类,但类间差异小、类内差异大——同一个「笑」在不同人脸上可能相差十万八千里。这个项目用 PyTorch 把 CNN、VGG、ResNet 三条技术路线各跑了一遍,从零构建数据管线、训练脚本、评估脚本和推理脚本,最终能拿到一个可直接加载权重做实时预测的模型。对刚接触深度学习的工程师来说,它最大的价值不是「又调了一个模型」,而是把「数据怎么组织→模型怎么写→参数怎么调→结果怎么分析」这条链路完整串了起来,每一步都有代码可以对着抄。
2. 数据管线:先用 FER2013 把「图片路径→张量」这条链路打通
2.1 数据集选型与目录结构设计
常见做法是用 FER2013 作为起点,它包含约 35000 张 48×48 灰度人脸图,分为训练集、验证集、测试集三份 CSV,标签是 0~6 的整数,对应 7 种表情。CSV 的格式是emotion,pixels,Usage,其中pixels是 2304 个由空格分隔的灰度值。工程上建议先把 CSV 拆成按类别存放的目录结构,因为 PyTorch 的ImageFolder可以直接消费这种布局,不需要自己写 Dataset 的标签映射逻辑。
data/ train/ angry/00001.jpg disgust/00002.jpg ... val/ test/拆分的脚本逻辑很简单:读取 CSV,按Usage字段分桶,用numpy把像素字符串转为 48×48 的数组,再通过PIL存成 JPG。这里有一个容易被忽视的细节:FER2013 原始像素值的范围是 0~255,但 CSV 里是整数,直接 reshape 后矩阵的 dtype 是int64,存图前必须先转成numpy.uint8,否则 PIL 会报类型错误。
2.2 Dataset 子类与数据增强的配合方式
虽然ImageFolder能直接用,但自己写 Dataset 更可控——尤其是做数据增强时,你需要知道每一步操作发生在什么位置。常见做法是继承torch.utils.data.Dataset,在__getitem__里做三件事:读图、归一化、标签转 long 张量。
import torch from torch.utils.data import Dataset from PIL import Image import torchvision.transforms as T class FaceDataset(Dataset): def __init__(self, root_dir, transform=None): self.root_dir = root_dir self.classes = sorted(os.listdir(root_dir)) self.class_to_idx = {cls: i for i, cls in enumerate(self.classes)} self.samples = [] for cls in self.classes: cls_dir = os.path.join(root_dir, cls) for fname in os.listdir(cls_dir): self.samples.append((os.path.join(cls_dir, fname), self.class_to_idx[cls])) self.transform = transform def __len__(self): return len(self.samples) def __getitem__(self, idx): path, label = self.samples[idx] image = Image.open(path).convert('L') # 灰度图 if self.transform: image = self.transform(image) return image, torch.tensor(label, dtype=torch.long)这里convert('L')是关键——训练集是灰度图,但训练时如果直接输入单通道,ResNet 的conv1默认接受 3 通道输入,就会报维度不匹配。所以在 transform 里必须加T.Grayscale(num_output_channels=3)把单通道复制成三通道,或者修改模型第一个卷积层的in_channels=1。一般我会选择后者,因为灰度图强行复制成三通道不会带来额外信息,反而增加计算量。
训练集和验证集的 transform 要分开设计,这是图像增强 cnn 算法里最常见的实践。训练集需要随机扰动来抑制过拟合,验证集只做归一化,保证评估结果的稳定性。
train_transform = T.Compose([ T.RandomResizedCrop(48, scale=(0.8, 1.0)), T.RandomHorizontalFlip(p=0.5), T.RandomRotation(degrees=10), T.ToTensor(), T.Normalize(mean=[0.5], std=[0.5]) ]) val_transform = T.Compose([ T.Resize(48), T.ToTensor(), T.Normalize(mean=[0.5], std=[0.5]) ])RandomResizedCrop和RandomRotation是表情识别里最有效的两个增强手段——人脸在图像中的位置和朝向天然有偏移,这两种扰动恰好模拟了这种自然变化。Normalize(mean=[0.5], std=[0.5])是针对灰度图的简化归一化,等价于把像素值压到 [-1, 1] 区间,配合 Tanh 类激活函数时效果更好。
2.3 DataLoader 参数设置的三个要点
DataLoader 的参数不是随便填的,batch_size、num_workers、pin_memory三个参数直接决定训练速度和显存占用。batch_size一般取 32 或 64,FER2013 单张图才 48×48,显存压力不大,64 起步即可;num_workers在 Linux 上可以设成 CPU 核心数的一半,Windows 上建议设 0 或 2,否则容易报 DataLoader worker 相关的错误;pin_memory=True只有在 GPU 训练时才有意义,它能让数据传输更快,但会额外占用一部分锁页内存。
from torch.utils.data import DataLoader train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True, num_workers=4, pin_memory=True) val_loader = DataLoader(val_dataset, batch_size=64, shuffle=False, num_workers=4, pin_memory=True)注意验证集的shuffle=False,这保证了每次评估看到的数据顺序一致,混淆矩阵和准确率的计算才是可复现的。如果这个细节不做对,你会发现两次评估结果有细微差异,排查起来非常浪费时间。
3. 模型实现:CNN、VGG、ResNet 三条路线的 PyTorch 代码对比
3.1 从零搭一个轻量 CNN:几层、多少通道、池化放在哪
自定义 CNN 是本项目的基线模型。常见做法是 4 层卷积 + 全局平均池化 + 全连接层,而不是上来就堆 10 层。原因很简单:FER2013 的 48×48 输入分辨率很低,卷几层后特征图已经缩到很小,再加深没有意义。参考框架是[Conv-BN-ReLU] × 4,通道数从 32 翻倍到 256,最后接全局平均池化和一个 7 分类的全连接层。
import torch.nn as nn class SimpleCNN(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), nn.Conv2d(128, 256, kernel_size=3, padding=1), nn.BatchNorm2d(256), nn.ReLU(inplace=True), ) self.classifier = nn.Sequential( nn.AdaptiveAvgPool2d(1), nn.Flatten(), nn.Linear(256, num_classes) ) def forward(self, x): x = self.features(x) x = self.classifier(x) return x这段代码有两个设计要点。第一,padding=1配合kernel_size=3保证卷积不改变特征图尺寸,尺寸缩减只由池化完成,这样每一层的有效感受野可以明确推算;第二,BatchNorm 放在卷积和激活之间,能显著缓解梯度弥散,让训练初始阶段的学习率可以设置得更大。输入灰度图时nn.Conv2d(1, 32, ...)的in_channels=1必须写对,这是从零实现和移植预训练模型的最大区别。
3.2 把 VGG16 改造成 7 分类:替换分类头与参数冻结策略
VGG 的核心思想是「用连续的小卷积核替代大卷积核」,3×3 卷积堆叠拥有更大的有效感受野,同时参数量少于单个 5×5 卷积。在 PyTorch 中使用 VGG 做表情识别的常规做法是基于 torchvision 预训练的 vgg16,把最后一层全连接从 1000 类改成 7 类。但这里有一个常见的坑:VGG16 的全连接层参数量超过 1 亿,FER2013 只有 2 万多训练图,直接全量微调大概率过拟合。
import torchvision.models as models def get_vgg16(num_classes=7, freeze_features=True): model = models.vgg16(weights=models.VGG16_Weights.IMAGENET1K_V1) if freeze_features: for param in model.features.parameters(): param.requires_grad = False num_features = model.classifier[0].in_features model.classifier = nn.Sequential( nn.Linear(num_features, 512), nn.ReLU(inplace=True), nn.Dropout(0.5), nn.Linear(512, num_classes) ) return modelfreeze_features=True表示冻结特征提取层,只训练新的分类头。这种方式相当于把 VGG 当做一个固定的特征提取器,训练参数量从 1.3 亿降到了约 50 万,在数据量不足的情况下反而比全量微调更稳。如果数据量够大(比如超过 10 万张),可以放开features的最后几层做微调,通常做法是把model.features拆成两个子模块,只解冻后半段。
def get_vgg16_partial_finetune(num_classes=7): model = models.vgg16(weights=models.VGG16_Weights.IMAGENET1K_V1) for param in model.features[:20].parameters(): param.requires_grad = False # features[:20] 冻结,features[20:] 和 classifier 参与训练 return model这里的features[:20]是 VGG16 前 5 个 stage 中的前 4 个 stage(索引从 0 开始,第 20 层恰好是第 5 个 stage 的起点)。具体冻结到哪一层没有绝对标准,一般做消融实验——比较[:15]、[:20]、[:25]三种切分在验证集上的表现。
3.3 ResNet 的残差连接:为什么能比 VGG 少一半参数还更快收敛
ResNet 的核心是残差块,即F(x) + x这个恒等映射。它的意义在于:如果某一层学到的东西是负优化,网络可以直接把该层权重逼近 0,让梯度走恒等路径回传,避免深层网络的梯度消失问题。表情识别场景下,ResNet18 是性价比最高的选择——它在 ImageNet 上的表现就优于 VGG16,参数量只有 VGG16 的六分之一,推理速度也快得多。
使用 resnet 预训练模型时,需要修改conv1以适配灰度图。torchvision 的 resnet18 默认接受 3 通道输入,但表情数据集是单通道。常见做法是保留权重,把第一层卷积从 3 通道展开到 1 通道不可行,正确的做法是用nn.Conv2d(1, 64, kernel_size=7, stride=2, padding=3, bias=False)替换,然后用预训练权重中 3 个通道的平均值初始化它——这样既保留了预训练信息,又避免了随机初始化导致的训练震荡。
def get_resnet18(num_classes=7, use_pretrained=True): model = models.resnet18(weights=models.ResNet18_Weights.IMAGENET1K_V1 if use_pretrained else None) # 替换输入层以支持灰度图 original_conv1 = model.conv1 new_conv1 = nn.Conv2d(1, 64, kernel_size=7, stride=2, padding=3, bias=False) if use_pretrained: # 对预训练权重在通道维度取平均 with torch.no_grad(): new_conv1.weight.copy_(original_conv1.weight.mean(dim=1, keepdim=True)) model.conv1 = new_conv1 # 修改最后的全连接层 num_features = model.fc.in_features model.fc = nn.Linear(num_features, num_classes) return model这里weight.mean(dim=1, keepdim=True)把预训练权重从[64, 3, 7, 7]变成[64, 1, 7, 7],相当于把 RGB 三通道的信息做等权平均。这个操作的合理性在于:ImageNet 预训练模型的第一个卷积层学到的大多是边缘和颜色特征,平均后虽然丢失了颜色差异,但边缘特征仍然保留,对灰度图依然有效。实际跑下来,用平均初始化的 ResNet18 比完全随机初始化的 ResNet18 收敛速度快约 20% 的 epoch。
下面用一个表格对比三种模型在 FER2013 验证集上的典型表现(batch_size=64,Adam 优化器,初始学习率 1e-3,训练 30 epoch):
| 模型 | 参数量 | 单 epoch 耗时(RTX 3060) | 验证集准确率 | 是否使用预训练 |
|---|---|---|---|---|
| SimpleCNN | 约 30 万 | 8 秒 | 58%~62% | 否 |
| VGG16(冻结特征层) | 约 50 万 | 15 秒 | 63%~65% | 是 |
| ResNet18(灰度适配) | 约 1100 万 | 20 秒 | 68%~72% | 是 |
4. 训练与调参:准确率从 60% 到 70% 的五个关键设置
4.1 损失函数、优化器、学习率调度的选型
表情识别是标准的 7 分类问题,损失函数用交叉熵即可,但需要注意类别不平衡问题。FER2013 中disgust只有几百张,而happy有近万张,直接用CrossEntropyLoss会让模型对高频类过拟合。常见做法是给损失函数传入weight参数,权重取count_total / (count_class * num_classes),或者简单粗暴地做类别欠采样。
import torch.nn as nn class_counts = torch.tensor([3995, 436, 4097, 7215, 4830, 3171, 2488], dtype=torch.float32) total = class_counts.sum() weights = total / (class_counts * len(class_counts)) loss_fn = nn.CrossEntropyLoss(weight=weights.cuda())优化器方面,Adam 是默认选择,lr=1e-3,betas=(0.9, 0.999)。但 Adam 后期收敛不够细,常见做法是前 20 个 epoch 用 Adam,之后切换到 SGD + Momentum 继续精调,或者全程用 Adam 搭配CosineAnnealingLR。我一般用后者,因为它不需要手动切换优化器的重启步骤。
4.2 训练循环的标准模板:梯度清零、反向传播、梯度裁剪
训练循环的代码结构大同小异,但有三个容易出错的地方:optimizer.zero_grad()的位置必须在loss.backward()之前,否则梯度会累积;每个 epoch 结束后要切到model.eval()模式;with torch.no_grad()上下文管理器在验证时必须包住前向传播,否则每一步都会重新构建计算图,显存会被撑爆。
def train_one_epoch(model, loader, optimizer, loss_fn, device): model.train() total_loss, correct, total = 0.0, 0, 0 for images, labels in loader: images, labels = images.to(device), labels.to(device) optimizer.zero_grad() outputs = model(images) loss = loss_fn(outputs, labels) loss.backward() nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0) optimizer.step() total_loss += loss.item() * images.size(0) correct += (outputs.argmax(dim=1) == labels).sum().item() total += labels.size(0) return total_loss / total, correct / totalclip_grad_norm_(max_norm=5.0)是训练早期权重剧烈震荡时最有效的保险丝。它把梯度的 L2 范数裁到 5.0 以内,防止 BatchNorm 在数据不平衡时产生极端梯度。实际经验是:加了这个裁剪之后,ResNet18 在 FER2013 上的训练 loss 曲线会平滑很多,不会出现大幅跳变。
4.3 如何判断模型是否过拟合:训练/验证 loss 曲线分析
训练过程中需要实时监控两个数字:训练 loss 和验证 loss。如果训练 loss 持续下降但验证 loss 在第 15 个 epoch 后开始回升,说明模型开始「背诵」训练集而不是泛化,这就是过拟合的信号。应对手段按优先级依次是:增大 Dropout、加重数据增强、降低学习率、切换到冻结特征层的预训练模型。
# 在训练循环中加入验证逻辑 def evaluate(model, loader, loss_fn, device): model.eval() total_loss, correct, total = 0.0, 0, 0 all_preds, all_labels = [], [] with torch.no_grad(): for images, labels in loader: images, labels = images.to(device), labels.to(device) outputs = model(images) loss = loss_fn(outputs, labels) total_loss += loss.item() * images.size(0) correct += (outputs.argmax(dim=1) == labels).sum().item() total += labels.size(0) all_preds.extend(outputs.argmax(dim=1).cpu().tolist()) all_labels.extend(labels.cpu().tolist()) return total_loss / total, correct / total, all_preds, all_labels这里同时返回了预测值和真实标签,是为了后续画混淆矩阵做准备。验证模式下model.eval()必须调用,因为它会关闭 Dropout 的随机行为,并让 BatchNorm 使用训练阶段统计出的 running_mean 和 running_var。
4.4 数据集划分的两种策略:固定划分与 K 折交叉验证
FER2013 原生的train/val/test划分存在一个问题:测试集和训练集来自同一分布但不同个体,真实场景下泛化能力会被高估。如果项目说明要求更可信的评估数字,常见做法是 K 折交叉验证,K=5 在 2 万级别的数据集上比较合适。
from sklearn.model_selection import StratifiedKFold skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42) for fold, (train_idx, val_idx) in enumerate(skf.split(all_images, all_labels)): train_dataset = FaceDataset_from_memory(all_images[train_idx], all_labels[train_idx]) val_dataset = FaceDataset_from_memory(all_images[val_idx], all_labels[val_idx]) # 每折重新初始化模型和优化器K 折的代价是训练时间变为原来的 K 倍,但换来的是对模型真实能力的准确估计。实际项目里如果是打比赛或出论文,K 折几乎是必须的;如果只是工程验证,固定划分加上混淆矩阵分析已经够用。
5. 混淆矩阵与错误样本分析:定位模型学偏了哪里
5.1 用 sklearn 一行画出表情识别的混淆矩阵
训练结束后,不要只盯着准确率。在表情识别里,happy容易被误分成neutral、fear和surprise容易混淆,这些错误如果不上混淆矩阵根本看不出来。把上一阶段evaluate函数返回的all_preds和all_labels传进去,直接产出可视化结果。
import matplotlib.pyplot as plt import numpy as np from sklearn.metrics import confusion_matrix, ConfusionMatrixDisplay cm = confusion_matrix(all_labels, all_preds, labels=range(7)) disp = ConfusionMatrixDisplay(confusion_matrix=cm, display_labels=['angry', 'disgust', 'fear', 'happy', 'neutral', 'sad', 'surprise']) disp.plot(cmap='Blues', values_format='d') plt.title('FER2013 Confusion Matrix (ResNet18)') plt.show()读取混淆矩阵时关注对角线之外的高亮格子。比如disgust列几乎没有预测值,说明这个类别被完全忽略了——这是类别不平衡的直接后果,即便加了权重也可能不够,需要针对disgust单独做数据扩增(比如旋转角度增大到 ±20 度)。
5.2 错误样本的保存与人工复盘
对当前模型预测错误的样本,建议统一保存为缩略图拼版,人工扫一眼比任何指标都直观。方法是在验证循环里记录images, labels, preds三个张量,然后用torchvision.utils.make_grid拼图。
import torchvision.utils as vutils wrong_images = images[wrong_mask] wrong_labels = labels[wrong_mask] wrong_preds = preds[wrong_mask] grid = vutils.make_grid(wrong_images[:32].cpu(), nrow=8, normalize=True, value_range=(-1, 1)) plt.imshow(grid.permute(1, 2, 0)) plt.title('Misclassified samples: green=correct label, red=predicted label')人工看拼图的目的是找出「模型为什么错」的模式。如果大量surprise被分成fear,观察后会发现两类表情的眼睛和嘴部区域特征确实高度重叠,这时可以在数据增强中引入T.ColorJitter或对眼部区域做随机遮挡,促使模型学习更多的嘴部特征。
5.3 测试时增强:不加训练就把准确率再提 1~2 个点
测试时增强(TTA)是最后一个不用重新训练就能提点的技巧。它的思想是:推理时把同一张图做几种不同的变换,比如原始图、水平翻转、小角度旋转,分别输入模型,对输出的概率取平均,再取 argmax。这样做能平滑模型对输入微小变化的敏感度。
tta_transforms = [ T.Compose([T.Resize(48), T.ToTensor(), T.Normalize([0.5], [0.5])]), T.Compose([T.Resize(48), T.RandomHorizontalFlip(p=1.0), T.ToTensor(), T.Normalize([0.5], [0.5])]), T.Compose([T.Resize(48), T.RandomRotation(degrees=5), T.ToTensor(), T.Normalize([0.5], [0.5])]), ] model.eval() probs = [] with torch.no_grad(): for transform in tta_transforms: x = transform(image_pil).unsqueeze(0).to(device) probs.append(torch.softmax(model(x), dim=1)) final_probs = torch.stack(probs).mean(dim=0) pred = final_probs.argmax(dim=1).item()TTA 的收益对已经收敛的模型大约在 1~2 个准确率点之间,收益不大但胜在零成本。如果项目要求极高实时性,TTA 可以不加,直接删除这段逻辑;如果面对的是离线批量推理,加上它绝对值回票价。FP16 半精度推理用torch.autocast包住前向传播同样能提速,但确保显卡支持 FP16 再开启。
本文还有配套的精品资源,点击获取