
简介面向深度学习和计算机视觉初学者的完整人脸表情识别项目可作为毕业设计、课程设计或期末大作业的有力参考。项目按毕业设计标准组织资源基于Pytorch实现卷积神经网络完整覆盖数据加载与增强、可视化分析、模型搭建、训练评估及推理预测等环节可帮助读者掌握CNN原理、Pytorch框架使用以及多分类任务的实际落地方法。压缩包共2000个文件以1991张jpg表情图像为数据集主体另含6个Python脚本包括训练与预测、1个csv标签文件、1个说明txt及1个内置zip整体约128.93MB目录结构清晰便于按模块对照学习。目前已有1246人学习下载。完成该项目后既能系统巩固深度学习的基础知识也能积累从数据处理、特征可视化到模型调优的完整实战经验达到举一反三的效果。1. 从 48×48 灰度图到七个表情一个 PyTorch CNN 项目的完整拆解这个 zip 里的东西并不难懂。一个 dataset.csv两万多张灰度人脸图一套 PyTorch 训练的卷积神经网络目标是输出 7 个表情类别愤怒、厌恶、恐惧、开心、悲伤、惊讶、中性。这不是那种“只跑一次就扔”的教程代码而是一个能改、能拿去答辩、能扩展成实时识别 demo 的完整项目。如果你正在找毕业设计参考或者刚入门深度学习想找一份能看懂的 CNN 源码这份项目值得花半天时间拆开。读完之后你会清楚数据管线怎么搭、卷积层参数怎么算、训练循环该关注什么。2. 数据管线的起点解读 dataset.csv 并构建 PyTorch DataLoader2.1 表情识别为什么是 7 分类以及 CSV 里该有什么人脸表情识别在学术上被抽象成一个图像分类任务。输入是一张人脸区域图像输出是离散的表情标签。FER2013 是这类任务常用的基准划分了 7 类0Angry, 1Disgust, 2Fear, 3Happy, 4Sad, 5Surprise, 6Neutral。这个项目采用同样的标签体系所以源码里的num_classes是 7。项目里的dataset.csv是数据索引文件。常见做法是保留三列image_name对应图片文件名label是 0-6 的整数标签Usage标记属于 Training 还是 PublicTest。你也可以用train/val。下面是一个符合项目习惯的 CSV 样例image_namelabelUsage25769.jpg3Training27488.jpg5Training27523.jpg3PublicTest25850.jpg6Training如果 CSV 里没有Usage可以在读取后按比例拆分也可以在生成 Dataset 时根据文件名去匹配目录结构。这里我建议你在pd.read_csv之后先做一次标签分布统计因为表情数据集普遍不均衡后面训练时可能需要调整类别权重。2.2 从文件路径到张量自定义 Dataset 与 transformsPyTorch 处理图像数据通常不把所有像素一次性读进内存而是通过 Dataset 按需读取。这个项目里的图片是灰度 JPG文件夹里放了 25769.jpg、27488.jpg 这类文件。实现时我一般会自定义一个FaceDataset继承torch.utils.data.Dataset在__init__里读 CSV 并缓存文件列表在__getitem__里完成读图、预处理和标签返回。import pandas as pd import torch from torch.utils.data import Dataset from PIL import Image import torchvision.transforms as T class FaceDataset(Dataset): def __init__(self, csv_path, img_dir, transformNone): self.df pd.read_csv(csv_path) self.img_dir img_dir self.transform transform def __len__(self): return len(self.df) def __getitem__(self, idx): row self.df.iloc[idx] img_path f{self.img_dir}/{row[image_name]} image Image.open(img_path).convert(L) # 转成单通道灰度 label int(row[label]) if self.transform: image self.transform(image) return image, label这段代码的核心是__getitem__返回(image_tensor, label)的二元组。Image.open(...).convert(L)用来保证通道数是 1避免混入意外通道。row[label]直接读 CSV 里的整数标签PyTorch 的CrossEntropyLoss要求标签是 LongTensor所以后面在 collate 时要注意类型。2.3 归一化、尺寸对齐与 DataLoader 参数构建 DataLoader 之前必须统一图像尺寸和像素值范围。FER2013 原本是 48×48这个项目里的 JPG 应该也是 48×48 附近。如果源图尺寸不统一建议在 transform 里用Resize((48, 48))强行对齐。像素归一化用均值 0.5、标准差 0.5 是常见做法因为灰度图简单把 [0, 255] 映射到 [-1, 1] 收敛更稳定。from torch.utils.data import DataLoader transform_train T.Compose([ T.Resize((48, 48)), T.RandomHorizontalFlip(), T.ToTensor(), T.Normalize(mean[0.5], std[0.5]) ]) transform_val T.Compose([ T.Resize((48, 48)), T.ToTensor(), T.Normalize(mean[0.5], std[0.5]) ]) train_dataset FaceDataset(dataset.csv, face_images, transformtransform_train) train_loader DataLoader(train_dataset, batch_size128, shuffleTrue, num_workers2, pin_memoryTrue) val_dataset FaceDataset(dataset_val.csv, face_images, transformtransform_val) val_loader DataLoader(val_dataset, batch_size128, shuffleFalse, num_workers2, pin_memoryTrue)这里batch_size128是在显存允许范围内尽量选大的值因为表情类别间相似度高大 batch 能让梯度更稳定。shuffleTrue只对训练集开启验证集不需要打乱。num_workers2是数据读取的并行进程数Windows 上建议设为 0 或 1Linux 可以按 CPU 核数调大。pin_memoryTrue可以加速 CPU 到 GPU 的拷贝但对小数据集提升不明显。注意如果你按Usage列拆分就不要像我上面那样手动构造两个 CSV 文件。可以直接读取后过滤 DataFrame再分别传给FaceDataset这样更不容易出现文件对不上号的问题。还有个容易被忽略的细节如果 CSV 中的image_name和文件名存在大小写不一致Linux 下会报 FileNotFoundError。Windows 不区分大小写可能没问题但部署到 Linux 就会炸。我一般会把读取后的文件名和目录列表做一次 join 检查把找不到的样本单独打印出来避免训练到一半才发现数据缺失。3. 搭建 PyTorch 卷积神经网络从输入张量到 7 维输出3.1 为什么图像分类首选卷积层而不是全连接层一张 48×48 的灰度图如果摊平会变成 2304 维向量。用全连接层直接做分类第一层就要 2304×N 个参数N 稍大参数就爆炸。卷积层有两个优势局部连接和权值共享。一个 3×3 卷积核每次只看一个小邻域但通过滑动窗口覆盖全图既保留了空间结构又大幅减少了参数量。表情识别依赖的是眼睛、嘴巴等局部特征的组合卷积网路天然适合这种任务。这个项目里的 CNN 主体是由卷积块堆叠的。每个卷积块通常包含卷积层、BatchNorm、ReLU、MaxPool。卷积层负责提取特征BatchNorm 稳定激活分布ReLU 增加非线性MaxPool 降低分辨率并扩大感受野。理解这三者对调参很有帮助。3.2 定义模型两层卷积块加两个全连接层用 PyTorch 写一个适合 48×48 小图像的 CNN结构可以这样设计第一块输出 64 个特征图第二块输出 128 个特征图每块后面接 2×2 MaxPool。经过两次 pooling特征图从 48×48 降到 24×24 再到 12×12。展平后是 128×12×1218432 维接全连接层输出 256 维最后接 7。import torch.nn as nn class FaceCNN(nn.Module): def __init__(self, num_classes7): super().__init__() self.features nn.Sequential( nn.Conv2d(1, 64, kernel_size3, padding1), nn.BatchNorm2d(64), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), # 48 - 24 nn.Conv2d(64, 128, kernel_size3, padding1), nn.BatchNorm2d(128), nn.ReLU(inplaceTrue), nn.MaxPool2d(2) # 24 - 12 ) self.classifier nn.Sequential( nn.Flatten(), nn.Linear(128 * 12 * 12, 256), nn.ReLU(inplaceTrue), nn.Dropout(0.5), nn.Linear(256, num_classes) ) def forward(self, x): x self.features(x) x self.classifier(x) return xpadding1配合kernel_size3可以保持特征图宽高不变这样尺寸变化只发生在池化层。第一层Conv2d(1, 64, ...)中的 1 表示输入通道数对应灰度图。128 * 12 * 12是最后一个卷积块输出的特征图展平大小如果换成 64×64 的输入这里要重新计算。Dropout(0.5)只在训练时生效model.eval()后自动关闭。各层输出尺寸可以简单列出来方便后面核对维度层输出尺寸输入(1, 48, 48)Conv2d 64 BN ReLU(64, 48, 48)MaxPool(64, 24, 24)Conv2d 128 BN ReLU(128, 24, 24)MaxPool(128, 12, 12)Flatten18432Linear 256 ReLU Dropout256Linear 773.3 输出尺寸与参数量自查方法很多人改网络结构时会忘记调Linear的输入维度。一个简单的自查方式是打印每一层输出形状或者先跑一个 dummy tensor 看报错。我习惯在forward里临时加print(x.shape)但更靠谱的是直接把模型实例化后传入一个随机张量验证import torch model FaceCNN(num_classes7) dummy torch.randn(1, 1, 48, 48) out model(dummy) print(out.shape) # torch.Size([1, 7])torch.randn生成的张量形状是(batch1, channel1, height48, width48)和前向传播后的(1, 7)对应。如果这一步报mat1 and mat2 shapes cannot be multiplied说明Linear的输入维度和卷积展平后的维度不一致需要回去核对128 * 12 * 12。如果想要小模型以降低训练时间可以把第一层卷积的64改成32第二层改成64最后全连接输入变成64 * 12 * 12。表情识别是相对简单的分类任务小模型在 CPU 上也能训得动这个项目跑通后再放大模型更合理。4. 训练、验证与模型保存让 CNN 真正“认识”表情4.1 损失函数、优化器与学习率调度表情识别是 7 分类问题nn.CrossEntropyLoss是默认选择。它内部把 softmax 和交叉熵合在一起计算所以模型最后一层不需要额外加 softmax。优化器我用Adam初始学习率1e-3。相比 SGDAdam 在超参上更省心也适合刚入门的人。import torch.optim as optim device torch.device(cuda if torch.cuda.is_available() else cpu) model FaceCNN(num_classes7).to(device) criterion nn.CrossEntropyLoss() optimizer optim.Adam(model.parameters(), lr1e-3) scheduler optim.lr_scheduler.StepLR(optimizer, step_size8, gamma0.5)StepLR每 8 个 epoch 把学习率乘以 0.5防止后期 loss 震荡。如果你的显卡不支持 CUDA代码会自动落到 CPU此时建议把batch_size降到 32并把模型里的通道数调小。CrossEntropyLoss要求标签是 0 到 6 之间的整数且 dtype 为torch.longDataLoader 返回的label默认是 int64正好满足。4.2 训练循环batch 级 loss 与验证准确率训练循环是项目的主干部分。每个 epoch 里先model.train()遍历训练集计算 loss 和梯度再用model.eval()遍历验证集关闭梯度计算统计准确率。不要漏掉optimizer.zero_grad()否则梯度会累加这个坑新手很容易踩。def train_one_epoch(model, loader, criterion, optimizer, device): model.train() total_loss 0.0 correct 0 total 0 for images, labels in loader: images, labels images.to(device), labels.to(device) optimizer.zero_grad() outputs model(images) loss criterion(outputs, labels) loss.backward() optimizer.step() total_loss loss.item() * images.size(0) preds outputs.argmax(dim1) correct (preds labels).sum().item() total labels.size(0) avg_loss total_loss / total acc correct / total return avg_loss, acc torch.no_grad() def validate(model, loader, criterion, device): model.eval() total_loss 0.0 correct 0 total 0 for images, labels in loader: images, labels images.to(device), labels.to(device) outputs model(images) loss criterion(outputs, labels) total_loss loss.item() * images.size(0) preds outputs.argmax(dim1) correct (preds labels).sum().item() total labels.size(0) return total_loss / total, correct / totaloutputs.argmax(dim1)是取每个样本得分最高的类别索引dim1表示对类别维度操作。和preds labels做比较后sum()得到正确个数。torch.no_grad()装饰器让验证阶段跳过梯度计算既省显存又加速。注意验证时不要调用optimizer.step()也不需要loss.backward()。4.3 epoch 循环与检查点保存训练流程一般跑 30 到 50 个 epoch。每个 epoch 结束后打印训练损失和验证准确率并把最佳模型保存到磁盘。保存时用state_dict()而不是直接save(model)这样后续加载更灵活。best_acc 0.0 for epoch in range(1, 31): train_loss, train_acc train_one_epoch( model, train_loader, criterion, optimizer, device ) val_loss, val_acc validate(model, val_loader, criterion, device) scheduler.step() print(fEpoch {epoch:02d} | train_loss {train_loss:.4f} | ftrain_acc {train_acc:.4f} | val_loss {val_loss:.4f} | fval_acc {val_acc:.4f}) if val_acc best_acc: best_acc val_acc torch.save({ epoch: epoch, model_state_dict: model.state_dict(), optimizer_state_dict: optimizer.state_dict(), best_acc: best_acc, }, face_cnn_best.pt)保存 optimizer 的state_dict是为了以后断点续训。如果只做推理参数文件可以只保留model_state_dict。判断“最佳”的唯一标准是验证准确率而不是训练准确率因为训练集上过拟合的模型泛化能力差。4.4 可视化训练过程与常见误区训练完后可以用 matplotlib 画 loss 和 acc 两条曲线直观判断是否欠拟合或过拟合。常见做法是在每个 epoch 结束时把四项指标追加到列表里train_losses.append(train_loss) val_losses.append(val_loss) train_accs.append(train_acc) val_accs.append(val_acc)然后一次性画两张图。如果训练 loss 下降但验证 loss 上升说明模型过拟合优先增加 Dropout 或调大权重衰减weight_decay。如果两个 loss 都不降先检查数据是否有问题——比如 CSV 的 label 和图片错位或者 transform 里忘记归一化。重要的经验表情识别任务里 7 类不均衡时验证准确率偏高但少数类全错。监控val_acc的同时最好打印每类的recall。可以用sklearn.metrics.classification_report在每轮结束时快速看。但项目自带的模型通常对这个数据集已经调好直接跑能到 70% 以上就算正常。5. 用训练好的模型做单张图片推理并用 Grad-CAM 检查模型在看哪里最后一章我们聚焦两个实用技巧加载 checkpoint 做单张预测以及用 Grad-CAM 可视化卷积层关注区域。这两件事在答辩和项目展示时非常加分因为能把“黑盒模型”讲清楚。import torch import torchvision.transforms as T from PIL import Image checkpoint torch.load(face_cnn_best.pt, map_locationcpu) model FaceCNN(num_classes7) model.load_state_dict(checkpoint[model_state_dict]) model.eval() transform T.Compose([ T.Resize((48, 48)), T.ToTensor(), T.Normalize(mean[0.5], std[0.5]) ]) image Image.open(demo.jpg).convert(L) input_tensor transform(image).unsqueeze(0) with torch.no_grad(): logits model(input_tensor) prob torch.softmax(logits, dim1) pred_class torch.argmax(prob, dim1).item() labels [Angry, Disgust, Fear, Happy, Sad, Surprise, Neutral] print(f预测类别: {labels[pred_class]}, 置信度: {prob[0][pred_class].item():.4f})map_locationcpu能避免在无 GPU 机器上加载时出现显存错误。unsqueeze(0)是必需的因为模型要求输入有 batch 维度。推理时用torch.no_grad()避免构建计算图加快速度。Grad-CAM 的实现思路其实不复杂对最后一层卷积输出的每个通道求全局平均池化作为权重再对目标类别的梯度做加权求和最后过 ReLU 并归一化得到注意力热力图。用torch.nn.functional里的conv2d或hook都能实现。这里我建议用一个更省事的替代方案直接对输入图像做遮挡实验用滑动窗口遮住人脸不同区域观察预测概率变化。遮住眼睛区域时置信度大幅下降说明模型确实在看眼睛。下面是核心的 hook 代码片段适合在 jupyter 里调试feature_blobs [] def hook_fn(module, input, output): feature_blobs.append(output) model.features[-2].register_forward_hook(hook_fn) # 第二个 ReLU 层model.features[-2]是第二块卷积里的 ReLU 层前向传播时会在该层后拿到特征图。注册 forward hook 后前向传播会把这一层的输出缓存下来后续就可以配合反向梯度做 Grad-CAM 了。注意 hook 是在运行时调用的不要在多线程 DataLoader 里依赖它的执行顺序。最后一个小技巧如果 batch 中混入了不同尺寸的图片DataLoader默认会报错。统一用T.Resize((48, 48))处理后这个问题就消失了。如果你想把这个项目改造成实时摄像头识别只需要把摄像头每一帧裁出人脸区域然后走一遍上面相同的预处理和推理流程即可。本文还有配套的精品资源点击获取