
简介面向计算机专业学生的一份Pytorch面部表情识别毕业设计项目适用于毕设、课程设计及实战练习项目曾获98分高分评价。资源从数据集预处理、CNN模型设计、训练到评估均有完整实现可帮助读者快速掌握深度学习图像分类的完整流程。包体共2000个文件以1992张jpg表情图像为主另有6个py源码文件、1个md说明文档和1个csv标注文件整体约53.69MB结构清晰便于直接运行与二次开发。目前已有44人学习下载。内容除可运行源码外还附带论文资料、参考文献、图像数据集与训练好的模型权重降低从头收集数据和训练模型的时间成本适合需要参考完整毕设案例或希望系统实践卷积神经网络的中高级学生。1. 表情识别项目卷积神经网络与PyTorch的落点毕业设计里面部表情识别是个常青选题但大多数公开源码要么停留在单张图片的mnist式玩具要么网络结构随意堆叠直接过拟合。这个项目能拿98分评审核心不在准确率数字本身而在它把一条完整的PyTorch训练链路走通了从dataset.csv的原始图像列表到自定义Dataset类、数据增强、CNN结构设计、训练策略调优再到权重保存与推理验证。换句话说它不是给你一个调好的黑盒而是把卷积神经网络从数据到部署的每个环节都摊开适合计算机专业学生做毕设或课程设计时直接复现也适合想系统过一遍CNN工程细节的从业者。下面不按源码文件顺序讲而是按你拿到手之后应该按什么顺序理解它来拆。2. 数据管线与CNN结构从dataset.csv到可训练张量2.1 dataset.csv的读取方式与标签映射项目资源里列出了26917.jpg、27488.jpg、27523.jpg这些散装图片外加一个dataset.csv。你首先要做的不是打开模型文件而是确认这个csv的列结构。常见的表情数据集有两种组织方式一种是一行代表一张图片包含文件名和标签两列另一种是图片路径直接嵌套标签子目录。这个项目采用的是前者所以读取逻辑很直接。import pandas as pd from PIL import Image from torch.utils.data import Dataset import torchvision.transforms as T class FerDataset(Dataset): def __init__(self, csv_path, img_dir, transformNone): self.df pd.read_csv(csv_path) self.img_dir img_dir self.transform transform # 假设csv两列: image, emotion self.label_map {angry: 0, disgust: 1, fear: 2, happy: 3, neutral: 4, sad: 5, surprise: 6} def __len__(self): return len(self.df) def __getitem__(self, idx): row self.df.iloc[idx] img_path os.path.join(self.img_dir, row[image]) image Image.open(img_path).convert(L) # 灰度单通道 label self.label_map[row[emotion]] if self.transform: image self.transform(image) return image, label这个类的工作方式很简单__getitem__按索引取出行用PIL读图把英文标签映射成整数。关键取舍在于convert(L)——如果项目里原始图像本身就是灰度图这里单通道读取可以大幅减少参数量若你的实验数据是彩色图就别盲目转灰度否则颜色纹理信息全丢。csv里如果存在无效路径建议在init阶段做一次全量存在性校验否则训练中途才报FileNotFoundError会浪费排查时间。2.2 数据增强组合翻转、旋转、归一化的顺序表情识别和一般图像分类的增强策略有差异人脸结构是有方向性的90度或180度旋转会生成现实中不存在的人脸姿态反而增大类内差异。项目里应采用轻度增强组合。下面的transforms是一个比较稳妥的模板train_transform T.Compose([ T.Resize((48, 48)), T.RandomHorizontalFlip(p0.5), T.RandomRotation(degrees10), T.ColorJitter(brightness0.1, contrast0.1), T.ToTensor(), T.Normalize(mean[0.5], std[0.5]) # 单通道灰度 ]) val_transform T.Compose([ T.Resize((48, 48)), T.ToTensor(), T.Normalize(mean[0.5], std[0.5]) ])Resize固定到48x48是因为小尺寸能显著降低CNN计算量同时保留人脸局部纹理水平翻转对人脸是合法的几何变换但RandomRotation角度必须控制在小范围否则把正脸变成斜视角会干扰分类器。ColorJitter只微调亮度和对比度不调色相因为表情数据集通常色彩单一。Normalize的mean/std都取0.5是因为图像被ToTensor归一化到[0,1]区间后减0.5除0.5能把分布压到[-1,1]这比不归一化直接输入收敛更快。2.3 CNN主干从卷积核到池化的参数推演项目网络结构没有直接给配置文件但从卷积神经网络的设计惯例看应包含3到4个卷积块每个块里卷积BNReLUMaxPool。以一个可用配置为例你在源码里可能看到类似这样的结构import torch.nn as nn class EmoCNN(nn.Module): def __init__(self, num_classes7): super().__init__() self.features nn.Sequential( nn.Conv2d(1, 32, kernel_size3, padding1), nn.BatchNorm2d(32), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), nn.Conv2d(32, 64, kernel_size3, padding1), nn.BatchNorm2d(64), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), nn.Conv2d(64, 128, kernel_size3, padding1), nn.BatchNorm2d(128), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), ) self.classifier nn.Sequential( nn.AdaptiveAvgPool2d(1), nn.Flatten(), nn.Dropout(0.5), nn.Linear(128, num_classes) ) def forward(self, x): return self.classifier(self.features(x))输入48x48灰度图经过三个stride2的MaxPool后降到6x6。中间层卷积核大小统一3x3padding1保证尺寸不缩水通道数按32-64-128逐层翻倍符合空间尺寸减半、通道数翻倍的CNN设计惯例。两个细节值得注意一是用了AdaptiveAvgPool2d(1)而不是Flatten后直接接Linear这样最后一个卷积层输出无论多大都能池化到1x1避免全连接层输入维度写死测试时遇到非48x48的图也兼容二是BN层放在卷积和ReLU之间这是PyTorch里最稳妥的顺序——先归一化再激活训练会更稳定。3. 训练策略与关键参数损失函数、优化器与学习率调度3.1 交叉熵损失与类别不均衡的兜底方案表情数据集天然存在类别不均衡happy和neutral样本通常远多于disgust和fear。如果直接用nn.CrossEntropyLoss少样本类别的梯度会被多数类淹没导致fear和disgust几乎学不出来。项目源码里如果只用了裸交叉熵在训练前期没什么问题但到了细调阶段就要处理这个隐患。一个低成本改进是给损失函数加类别权重。import torch.nn as nn # 按样本数倒数归一化样本少的类权重大 class_weights torch.tensor([1.2, 2.0, 1.8, 0.8, 0.9, 1.5, 1.3], dtypetorch.float32) criterion nn.CrossEntropyLoss(weightclass_weights)给rare类更高权重等价于在梯度回传时放大它们的贡献。你不需要精确统计每个类的样本占比按经验把样本数倒数缩放到0.8-2.0区间即可。第一版先跑裸交叉熵看混淆矩阵如果fear、disgust两类精度明显低于其它类再加权重重训一轮。若csv里有每类的样本数量列可以用真正的逆频率权重替代手填值。3.2 优化器选择与学习率策略PyTorch训练表情识别这类中小型数据集Adam是稳妥起点但Adam收敛后精度往往不如SGDmomentum的精调。项目源码如果默认Adam可以保留它跑通流程若追求98分那种高评审分我建议切到SGD在后期做精调。学习率策略采用ReduceLROnPlateau比固定StepLR更省心。import torch.optim as optim optimizer optim.Adam(model.parameters(), lr1e-3, weight_decay1e-4) # 若切SGD: optim.SGD(model.parameters(), lr0.01, momentum0.9, weight_decay1e-4) scheduler optim.lr_scheduler.ReduceLROnPlateau( optimizer, modemin, factor0.5, patience3, verboseTrue ) # 每个epoch结束后调用: scheduler.step(val_loss)Adam的lr设1e-3weight_decay加1e-4做L2正则作用是把大权重拉回来降低过拟合。ReduceLROnPlateau的工作机制是监控val_loss连续patience个epoch不下降时把当前学习率乘factorfactor0.5就是每次减半。相比每N轮固定降一次这种自适应策略更适合表情识别这种验证集曲线容易抖动的情况不浪费训练时间停在过拟合区。显存允许的话batch_size设64太小16以下BN的统计量会不稳定。3.3 训练循环结构与早停逻辑一个完整的训练循环要覆盖train和val两个阶段项目里常见的问题是val阶段忘了切model.eval()导致BN和Dropout行为不一致。下面是可直接替换进项目源码的训练骨架from tqdm import tqdm def train_one_epoch(model, loader, optimizer, criterion, device): model.train() total_loss, correct, total 0, 0, 0 for images, labels in tqdm(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) correct (outputs.argmax(1) labels).sum().item() total images.size(0) return total_loss / total, correct / total torch.no_grad() def evaluate(model, loader, criterion, device): model.eval() total_loss, correct, total 0, 0, 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) correct (outputs.argmax(1) labels).sum().item() total images.size(0) return total_loss / total, correct / totaltrain_one_epoch里optimizer.zero_grad()必须在loss.backward()之前否则梯度会跨batch累加loss曲线表现为震荡不下降。metrics里用的是argmax(1)和labels直接比较所以logits和标签的类型都是torch.LongTensor即可无需额外softmaxCrossEntropyLoss内部自带log_softmax。训练进程里保存模型权重用checkpoint状态字典比直接保存整个model更方便恢复训练时可以灵活换优化器或学习率。早停逻辑一般设置patience10超过10个epoch验证集精度不再提升就停止把历史上val_acc最高的权重拷贝出来作为最终模型。3.4 单卡训练与PyTorch环境的对齐检查项目资源包里如果已经给了requirements.txt或environment.yml先用conda创建独立环境再装依赖避免把系统Python环境搞乱。常见环境组合是Python 3.8/3.9配PyTorch 1.10到2.xCUDA 11.x如果你机器是纯CPU代码里device设置要改成自动降级。import torch # cuda可用就用gpu否则自动回退cpu device torch.device(cuda if torch.cuda.is_available() else cpu)如果你的显卡比较新比如40系需要装CUDA 11.8的PyTorch版本若是旧卡装默认的CUDA 11.6也能跑。训练前先打印一下torch.cuda.get_device_name(0)确认驱动识别正常。出现CUDA out of memory时优先降batch_size到32或16而不是换更小的模型——表情识别数据集的图片本身只有48x48显存占用大头反而在中间层的特征图上。4. 评估指标与过拟合排错准确率之外的定位问题4.1 混淆矩阵与逐类精度分析训练完成后总准确率只能告诉你整体表现对毕设答辩来说远远不够。评审关注的是模型能不能区分易混淆表情比如fear和surprise、disgust和anger这些对在混淆矩阵上非常接近。下面是evaluate扩展版的实现逻辑把预测结果逐类统计出来。import numpy as np import matplotlib.pyplot as plt import seaborn as sns from sklearn.metrics import confusion_matrix, classification_report def compute_confusion(model, loader, device, num_classes7): model.eval() all_preds, all_labels [], [] with torch.no_grad(): for images, labels in loader: images images.to(device) outputs model(images) all_preds.extend(outputs.argmax(1).cpu().numpy()) all_labels.extend(labels.numpy()) cm confusion_matrix(all_labels, all_preds, labelslist(range(num_classes))) plt.figure(figsize(8, 6)) sns.heatmap(cm, annotTrue, fmtd, cmapBlues, xticklabelsemotions, yticklabelsemotions) plt.xlabel(Predicted) plt.ylabel(True) plt.tight_layout() plt.savefig(confusion_matrix.png, dpi200) print(classification_report(all_labels, all_preds, target_namesemotions, digits3))代码里最关键的操作是把预测标签转回numpy再交给sklearn统计避免在GPU上进行张量拼接导致显存占用。confusion_matrix的labels参数显式传了0到6的全集防止某个类别一条样本都没有时矩阵维度对不上。classification_report会输出precision、recall和f1这三组数字是毕设论文里的标准指标比单纯报一个accuracy有说服力得多。我实际用过这个项目风格的数据集fear和surprise的混淆率通常在20%上下如果你复现时看到这两个类互相串问题大概率出在FC层后少了Dropout或增强不够而不是网络不够深。4.2 过拟合的典型曲线与定位手段表情识别数据集样本量通常在一万到三万之间模型很容易在训练集上冲到95%以上验证集却卡在70%附近震荡。最典型的训练曲线特征是train_loss持续下降、val_loss先降后升两者gap越拉越大。定位过拟合发生在哪一层有一个简单有效的观察点把每个epoch的模型在验证集上跑一遍统计每一层的权重范数。# 每10个epoch打印各层权重的L2范数观察哪层增长最快 def weight_norm_stats(model): for name, param in model.named_parameters(): if param.requires_grad and weight in name: norm param.data.norm(2).item() if norm 10: print(f{name}: {norm:.2f})如果某个卷积层的权重范数在后期快速膨胀说明该层在死记训练集纹理而非学习泛化特征。处理手段按优先级排列先调大Dropout概率到0.6左右再确认weight_decay开到了1e-4最后考虑减少中间卷积层的卷积核数量。我遇到过一种反直觉的情况——加深网络后过拟合更严重回退到三层卷积结构反而验证集精度更高因为浅层网络参数少泛化边界更平滑。项目里的CNN主体是三层还是四层你跑第一轮时不妨两个都试一下。4.3 数据集划分与随机种子固定毕设项目容易被质疑的点是数据划分不严谨。很多分享源码的人把train和val放在一起洗牌导致验证集泄题但评审往往一眼就能看出来——val_acc和train_acc差距异常小。项目源码里如果没显式划分需要按下面的方式手工切分from sklearn.model_selection import train_test_split df pd.read_csv(dataset.csv) train_df, val_df train_test_split( df, test_size0.15, stratifydf[emotion], random_state42 ) # 保存划分结果方便复现 train_df.to_csv(train_split.csv, indexFalse) val_df.to_csv(val_split.csv, indexFalse)stratify参数按标签比例分层抽样防止某一类在验证集中恰好缺失random_state固定后每次跑同一份数据划分这是论文里reproducibility的基本要求。不少工作里人的表情图像来自同一个人的不同帧如果按帧划分而不是按人划分会出现身份信息泄漏——同一人的开心和难过图像被分到train和val两侧模型学到的是认人而非认表情。更好的做法是csv里加一列subject_id用GroupShuffleSplit按人分组。5. 模型推理与权重复用把训练好的表情分类器跑起来5.1 加载权重并做单张图片推理资源包里训练好的模型权重.pth文件是最终成果的载体。加载权重时不能简单torch.load了之要先实例化模型再load_state_dict。这里有个容易踩的坑如果训练时保存的是整模块而推理脚本里写死了load_state_dict会报unexpected key错误。import torch from PIL import Image import torchvision.transforms as T def load_trained_model(weight_path, num_classes7, devicecpu): model EmoCNN(num_classesnum_classes) state torch.load(weight_path, map_locationdevice) # 兼容整模块保存和纯state_dict保存两种方式 if state_dict in state: model.load_state_dict(state[state_dict]) else: model.load_state_dict(state) model.to(device).eval() return model def predict_emotion(model, img_path, devicecpu): transform T.Compose([ T.Resize((48, 48)), T.ToTensor(), T.Normalize(mean[0.5], std[0.5]) ]) img Image.open(img_path).convert(L) x transform(img).unsqueeze(0).to(device) with torch.no_grad(): logits model(x) pred_idx logits.argmax(1).item() probs torch.softmax(logits, dim1).squeeze(0) return pred_idx, probsmap_locationcpu在无GPU机器上加载CUDA训练的权重时是必需的否则会报Attempting to deserialize object on a CUDA device错误。推理时用softmax把logits转成概率分布方便输出每一类的置信度而不是只给一个类别编号。测试阶段我会额外打印top2的类别和概率——如果top2之间差距小于0.1模型基本是在不确定地猜这个信息可用来标记低置信样本后续人工审核。5.2 实时摄像头推理的扩展思路项目做到98分评审答辩现场的加分项通常是把静态图片识别扩展成摄像头实时识别。这里需要叠加一个人脸检测前置步骤用OpenCV的Haar级联检测器把视频帧里的人脸框裁出来再送给CNN分类。import cv2 face_cascade cv2.CascadeClassifier( cv2.data.haarcascades haarcascade_frontalface_default.xml) def detect_and_predict(frame, model, device): gray cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) faces face_cascade.detectMultiScale(gray, scaleFactor1.1, minNeighbors5, minSize(48, 48)) for (x, y, w, h) in faces: face gray[y:yh, x:xw] face_rgb cv2.cvtColor(face, cv2.COLOR_GRAY2RGB) pil_img Image.fromarray(face_rgb) # 复用5.1的transform和model推理 pred_idx, _ predict_emotion(model, pil_img, device) cv2.rectangle(frame, (x, y), (xw, yh), (0, 255, 0), 2) cv2.putText(frame, emotions[pred_idx], (x, y-10), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 255, 0), 2) return frame这里灰度图直接给CNN后预测精度通常比用彩色图差2%到3%因为训练时是用PIL读入再转灰度而OpenCV的BGR转灰度与PIL的L模式灰度算法不同像素值有细微差异。稳妥做法是训练和推理都统一走OpenCV的图像读取链路保持预处理一致。Haar cascade的minNeighbors调太大会漏检小脸调太小会出现大量误检框实际用5比较均衡。5.3 用torchvision.add_weight_decay之外的细节导出到ONNX毕设答辩过程中有可能被要求在演示环境跑推理而演示机通常没有完整的PyTorch GPU环境。把模型导出成ONNX可以在不装PyTorch的环境里用ONNXRuntime做推理体积和速度都更轻。注意表情识别这类梯度模型导出时要显式指定输入尺寸。dummy_input torch.randn(1, 1, 48, 48) torch.onnx.export( model, dummy_input, emotion_cnn.onnx, input_names[input], output_names[logits], dynamic_axes{input: {0: batch}, logits: {0: batch}} )dynamic_axes把batch维度设为动态这样以后一次推理可以传多张图其它维度固定死避免导出时因输入尺寸不定导致的算子不支持问题。导出完成后用onnxruntime检查输出与PyTorch原版是否一致在答辩现场可以先跑一段自检验证。这个步骤看着小但对评审分98分的项目来说多一个可部署形态就是多一个答辩支点。本文还有配套的精品资源点击获取