简介:一份完整的基于深度学习的面部表情识别毕业设计项目,包含Python源码、论文、数据集与答辩PPT,评审得分98分,主要面向计算机相关专业正在完成毕业设计、课程设计或期末大作业的学生,也适合需要项目实战练习的深度学习初学者。整个zip包共36个文件,大小约446MB,核心为14个Python脚本和5个Jupyter Notebook,覆盖数据预处理、模型训练与对比实验;另含5个论文/说明文档、1个答辩PPT、1个演示视频以及预训练模型.pkl、人脸检测xml配置等,目录结构清晰,便于按需调用。目前已有276人学习浏览。内容提供CNN、VGG、ResNet多种模型实现,附带部署说明、代码注释与参考文献,可帮助读者完整复现人脸表情识别全流程,掌握从数据集构建、模型训练到界面展示的实战方法,并对论文写作和答辩陈述提供直接参考。
1. 这个表情识别项目:一个能直接用于毕设答辩的完整工程
答辩前一周,导师问我:如果训练集里的照片全部换成戴帽子的侧脸,你这套表情识别模型还能不能稳住准确率?当时我盯着自己的训练曲线卡了半天没接上话。很多毕设项目第一次被评委追问时都会在这里露馅——模型能跑,但换一份数据就崩,讲不清结构,也拿不出对比实验。而这套基于深度学习的面部表情识别系统恰好补上了这些短板:CNN、VGG、ResNet 三条路线的训练脚本齐全,数据集、预处理脚本、模型对比、论文文档和答辩 PPT 全部配套,属于打开压缩包就能照着走完一个完整课题的工程型资源。适合正在做毕业设计或课程设计的学生,也适合想通过一个实战项目把深度学习图像分类链路看透的开发者。下面按我拆这个项目的顺序来讲。
2. 项目结构拆解:数据、模型、文档如何组织成闭环
2.1 打开压缩包的第一件事:先读 README 与手册
拿到压缩包后不要急着找训练脚本,先看根目录下的 README 和手册.docx。README 会交代运行环境与文件职责,手册则是图文并茂的使用说明,里面写了每个脚本的用途和执行顺序。我习惯把 README 里的脚本名在文件列表里逐个过一遍,再对照手册确认哪些文件是训练用的、哪些是测试用的,这样能快速在脑子里画出一张项目地图。项目里还带了几份 docx 和 doc 格式的论文文档,以及一个答辩 PPT,意味着从代码实现到论文写作、再到现场答辩的材料都是自包含的,不需要再去别处拼凑。
2.2 目录地图:脚本、模型、数据、文档一表看清
从文件列表可以看出,这个项目的模块划分非常清晰,大致可以归为五类:
| 类别 | 文件 | 职责 |
|---|---|---|
| 数据预处理 | data_process.py、data_separation.py、data_view.py、image_emotion_mapping.py | 原始图片切分、样本统计、标签映射 |
| 人脸检测 | haarcascade_frontalface_default.xml | 从图片中框出人脸区域 |
| 模型训练 | model_CNN.py、model_VGG.py、model_ResNet.py | 三类网络的训练入口 |
| 模型测试与对比 | model_CNN_test.py、model_VGG_test.py、model_ResNet_test.py、model_All_Compare.py | 单模型推理与三模型效果对比 |
| 文档与演示 | 手册.docx、论文若干、答辩PPT、example_dsh.mp4 | 毕设答辩材料与演示视频 |
整个布局是沿着“数据处理 → 模型训练 → 模型评估 → 文档汇报”的顺序推进的,这正好也是毕业论文的标准章节顺序。face_images 是原始图片与示例图,dataset 是切分后的数据集目录,model 目录与 model_resnet.pkl 存放训练产物。
2.3 训练脚本、Notebook 模板与参考实现的配合关系
model_CNN.py 是卷积基线网络,model_VGG.py 与 model_ResNet.py 是两条深度网络路线。对应地,每个模型都有一个 _test.py 脚本,负责加载训练好的权重对单张图片做推理。另有一批 .ipynb 文件(CNN_01、CNN_02、CNN_03、ResNet、Facial-expressions)是逐段运行的 Notebook 模板,适合在 Jupyter 里边看输出边调参,比如修改卷积核数量、切换优化器这些操作在 Notebook 里改起来比改脚本直观得多。model_All_Compare.py 的作用是把三个模型放到同一份测试集上算准确率并做可视化对比,这是答辩时最能说明问题的一张图。
压缩包里另外几个 zip 是参考实现,Face-Annotation-Tool 是标注工具,Fer2013-pytorch-CNN-VGG-Resnet 和 FERNet 是这个方向常见的开源基线。它们不是必须运行的,作用是给你对照自己的实现有没有偏离主流做法。我拆项目时一般会先跑通自己的三份脚本,再去参考包里看别人怎么处理数据增强和类不平衡。
3. 数据管线实战:从原始图片到可训练样本的完整处理
3.1 data_separation.py:把原始图片按标签切分到三目录
表情识别是典型的图像分类任务,第一步不是写模型,而是把数据组织好。data_separation.py 做的事情很直接:读取 face_images 下按类别存放的原始图片,按比例随机划分到 dataset/train、dataset/val、dataset/test 三个目录,每个目录内部保留类别子目录。这样后续训练脚本就可以直接用 ImageFolder 风格的方式加载数据,不需要手动维护 CSV 映射关系。
import os import random import shutil random.seed(42) SRC_DIR = "face_images" # 原始图片根目录 OUT_DIR = "dataset" # 切分后的数据集根目录 SPLIT = {"train": 0.8, "val": 0.1, "test": 0.1} for label in os.listdir(SRC_DIR): label_dir = os.path.join(SRC_DIR, label) if not os.path.isdir(label_dir): continue images = [f for f in os.listdir(label_dir) if f.lower().endswith((".jpg", ".png", ".jpeg"))] random.shuffle(images) total = len(images) train_end = int(total * SPLIT["train"]) val_end = train_end + int(total * SPLIT["val"]) for split_name, part in zip( SPLIT.keys(), [images[:train_end], images[train_end:val_end], images[val_end:]]): target = os.path.join(OUT_DIR, split_name, label) os.makedirs(target, exist_ok=True) for img in part: shutil.copy(os.path.join(label_dir, img), os.path.join(target, img))代码逻辑不复杂,关键点有三个:random.seed(42) 保证每次切分结果一致,这对论文里的实验可复现性非常重要;0.8/0.1/0.1 的比例是图像分类任务里比较常用的默认值,如果某个类别图片特别少,可以把 val 和 test 适当调低到各 0.05;把三类划分写在一个循环里同步完成,避免同一张图片同时出现在训练集和测试集里。
3.2 data_view.py:训练前先确认类别分布
表情数据集的常见问题是不均衡,像 neutral 这类中性表情样本往往远多于 disgust,如果直接拿去训练,模型会把所有样本都往样本量大的类别上偏。data_view.py 的作用就是在训练前对各类别数量做统计并可视化,让你在跑模型之前就意识到这个问题。
import os from collections import Counter import matplotlib.pyplot as plt train_dir = "dataset/train" counter = Counter() for label in os.listdir(train_dir): label_path = os.path.join(train_dir, label) if os.path.isdir(label_path): counter[label] = len(os.listdir(label_path)) labels, counts = zip(*counter.most_common()) plt.bar(labels, counts) plt.xticks(rotation=45) plt.title("Train Set Class Distribution") plt.show()运行这一段之后,你会看到一张柱状图,每根柱子代表一个表情类别。如果发现某几类数量差异过大,常见做法是先用 data_process.py 做数据增强,对少样本类别做水平翻转、随机裁剪、亮度调整,而不是直接改网络结构。答辩时这张图也能用来说明你对数据分布做过分析,属于加分项。
3.3 人脸检测:haarcascade 在表情识别里的作用
表情信息主要集中在眼睛、眉毛、嘴巴这些局部区域,如果把整张背景图直接送进网络,模型会花大量参数去学习无关的背景特征。项目根目录放了 haarcascade_frontalface_default.xml,这就是用来做人脸框选的检测器。实际处理时先检测人脸框,再把框内区域裁剪出来缩放到统一尺寸,比如 48x48 或 64x64。
import cv2 face_cascade = cv2.CascadeClassifier("haarcascade_frontalface_default.xml") img = cv2.imread("example.jpg") gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) faces = face_cascade.detectMultiScale( gray, scaleFactor=1.1, minNeighbors=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)) cv2.imwrite("face_crop.jpg", face)这里 scaleFactor=1.1 表示每次检测窗口缩放 1.1 倍,值越小检测越精细但速度越慢;minNeighbors=5 表示一个候选框至少要有 5 个邻近检测结果才保留,调大可以减少误检但可能漏掉侧脸。这个检测器虽然老,但作为预处理工具完全够用,而且跨平台依赖少,比引入大型人脸检测模型省事。
3.4 标签映射:image_emotion_mapping.py 维护的情绪字典
模型输出的 raw logits 只是一个整数索引,需要映射回表情字符串才能让人看懂。image_emotion_mapping.py 维护的就是这种映射,常见做法是定义成字典:
EMOTIONS = { 0: "angry", 1: "disgust", 2: "fear", 3: "happy", 4: "neutral", 5: "sad", 6: "surprise", }这是 Fer2013 数据集的标准 7 分类约定。训练脚本里 label 是 int 类型,展示和答辩时用这个字典转回字符串。另外要注意,如果后续要套用 ResNet 这类预训练模型,它们的预训练权重通常是针对 ImageNet 的 3 通道输入,而 Fer2013 是灰度图,所以常见处理是把单通道灰度图复制堆叠成 3 通道再送入网络,这一步可以在 data_process.py 里完成。
4. 模型实现对比:CNN、VGG、ResNet 三条路线的核心差异
4.1 model_CNN.py:卷积基线网络怎么设计
三份训练脚本里,model_CNN.py 应该最先读,因为它最简单,定义了整个任务的下限。这个基线网络通常由三层卷积块加全连接层组成,卷积块内部是 Conv2d、BatchNorm2d、ReLU、MaxPool2d 的标准组合:
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.AdaptiveAvgPool2d((1, 1)), nn.Flatten(), nn.Linear(128, num_classes), ) def forward(self, x): return self.classifier(self.features(x))输入是 48x48 的单通道灰度图,第一层卷积输入通道为 1,和普通三通道预训练网络有所区别。卷积核统一用 3x3 加 padding=1,保证特征图尺寸按池化层减半,最后一层用 AdaptiveAvgPool2d 把特征压成 1x1,再接全连接输出 7 类。这里没有用 Dropout,如果训练集不大,建议在全连接前加一个 p=0.5 的 Dropout,能明显抑制过拟合。
4.2 model_VGG.py 与 model_ResNet.py:深度网络怎么搭
VGG 和 ResNet 的代码思路完全不同。VGG 是堆叠更多 3x3 卷积层,结构规整但参数量大;ResNet 引入了残差连接,让梯度可以跨层回传,所以网络可以做得更深而不容易退化。在这个项目里,两个模型通常都基于预训练权重做微调:
import torchvision.models as models import torch.nn as nn def build_resnet(num_classes=7): model = models.resnet18(weights=models.ResNet18_Weights.IMAGENET1K_V1) model.conv1 = nn.Conv2d(1, 64, kernel_size=7, stride=2, padding=3, bias=False) model.fc = nn.Linear(model.fc.in_features, num_classes) return model这里有个细节值得注意:原始 ResNet 的 conv1 接受 3 通道输入,而表情数据是灰度图,所以需要把第一层改成输入通道为 1。VGG 也是同样的改法,把 features 模块的第一层 Conv2d 的 in_channels 改成 1。常见做法是保留预训练权重里 3 通道对应的卷积核,然后把新 Conv2d 层的权重做均值初始化,这样即使输入通道数变了,前向传播仍然有合理的初始特征。如果你不想改第一层,也可以把灰度图堆成 3 通道输入,VGG 和 ResNet 都不用动结构。
4.3 训练循环:学习率、batch size 与损失函数
三份训练脚本的训练循环基本是同一个模板,差别主要在模型实例化和优化器选择上。损失函数用 CrossEntropyLoss 即可,优化器方面常见选择是 Adam,学习率从 0.001 起步,ResNet 这类预训练模型可以把特征层的学习率调低一些,比如 0.0001,只让最后的全连接层用 0.001 快速收敛:
import torch import torch.nn as nn import torch.optim as optim from torch.utils.data import DataLoader model = ExpressionCNN(num_classes=7) criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=0.001) scheduler = optim.lr_scheduler.StepLR(optimizer, step_size=20, gamma=0.1) for epoch in range(50): model.train() for images, labels in train_loader: optimizer.zero_grad() outputs = model(images) loss = criterion(outputs, labels) loss.backward() optimizer.step() if (epoch + 1) % 10 == 0: model.eval() val_acc = evaluate(model, val_loader) print(f"epoch {epoch+1}, val_acc {val_acc:.4f}") scheduler.step()batch size 一般取 64 或 128,取决于显存大小。训练 50 个 epoch,每 10 个 epoch 打印一次验证集准确率,StepLR 每 20 个 epoch 把学习率降为原来的 0.1,相当于后期做精调和收敛。如果发现 val loss 在某个 epoch 后开始反弹,优先检查是不是学习率太高或训练集太小,先做数据增强比调网络结构更有效。
4.4 三模型对比与推理测试
model_All_Compare.py 把三个模型放到同一份测试集上评估,输出准确率对比表格,通常还会配一张柱状图。答辩时这张图直接决定了评委对你实验完成度的第一印象。推理脚本的加载逻辑通用:
import torch model = ExpressionCNN(num_classes=7) state = torch.load("model/model_resnet.pkl", map_location="cpu") model.load_state_dict(state) model.eval() with torch.no_grad(): output = model(face_tensor) pred = output.argmax(dim=1).item() print(image_emotion_mapping.EMOTIONS[pred])model_resnet.pkl 是已经训练好的权重,可以直接加载做预测。注意 map_location="cpu" 这一步,如果在 GPU 机器上保存权重再到 CPU 机器加载,不加这个参数会报显存相关的错误。加载后先 model.eval() 再推理,否则 BatchNorm 和 Dropout 的行为会不一致,导致同样的输入每次输出都不一样。
5. 运行与避坑指南:让三套模型在本机真正跑起来
5.1 跑通项目的标准顺序
这个项目不需要复杂的部署流程,核心依赖是 PyTorch、OpenCV、NumPy、Matplotlib。我建议用 conda 创建一个干净环境,避免把实训机器上的环境搞乱:
conda create -n expression python=3.8 -y conda activate expression pip install torch opencv-python numpy matplotlib pandas jupyter python data_separation.py python data_view.py python model_CNN.py python model_CNN_test.py顺序是有讲究的:先跑 data_separation 切分数据,再跑 data_view 确认分布,然后才开始训练 CNN。如果你的目标是快速看到效果而不是复现完整实验,可以直接加载 model_resnet.pkl 跳过训练环节,用 model_ResNet_test.py 对 example_dsh.mp4 或任意一张人脸图片做推理。四个 ipynb 脚本也可以在 Jupyter 里逐个运行,运行前把文件放到项目根目录,保证相对路径不错位。
提示:如果机器没有 GPU,训练会慢很多,建议先用 CNN 脚本确认流程通了,再决定要不要跑 VGG 和 ResNet。
5.2 避坑记录:四个最容易让项目翻车的地方
第一条:运行训练脚本时报 FileNotFoundError,提示找不到 dataset/train。原因是脚本默认从相对路径读数据,但你直接把图片丢进 dataset 根目录没做切分。解决方法是先跑 data_separation.py,让它生成 train/val/test 三个子目录。我拆项目时习惯在首次运行前把 dataset 目录结构截个图留底,后面对比时方便排查。
第二条:GPU 机器上训练好的 pkl 在 CPU 机器上加载报错。原因是保存的 state_dict 里有 cuda 相关的张量信息,加载时没做映射。解决方法是 torch.load(path, map_location="cpu"),并且保证加载前已经实例化了结构完全一致的模型。如果报 key mismatch,说明网络结构对不上,检查你实例化的是 CNN 却加载了 ResNet 的权重这种低级错误。
第三条:验证集准确率不错,但换到摄像头或自己手机拍的照片就明显变差。原因是训练用的 Fer2013 数据大多是正脸、单光照、低分辨率,真实场景里有侧脸、遮挡、复杂背景。解决方法是预测前先做人脸检测与裁剪,然后灰度化、缩放到 48x48,很多同学还会加一步 cv2.equalizeHist 做直方图均衡,把光照差异压平。
第四条:训练过程中 val loss 降到一定值后反弹,准确率上不去。原因是学习率始终不变,模型在最优解附近震荡。解决方法是给优化器加 StepLR,或者用 ReduceLROnPlateau 在 val loss 连续几个 epoch 不降时自动把学习率乘 0.1。这个小改动通常能让测试准确率提升两个百分点以上,在答辩对比时很能说明你对训练流程有控制力。
6. 二次开发方向:把表情识别工程改造成自己的课设或毕设
6.1 接上摄像头做实时表情识别
原有脚本处理的是单张图片,改造实时视频流时只需要在循环里读取摄像头帧,把每一帧缩放到检测器能处理的尺寸,检测到人脸后裁剪、缩放、预测,再把结果画到画面上:
import cv2 import torch model = ExpressionCNN(num_classes=7) model.load_state_dict(torch.load("model/model_cnn.pkl", map_location="cpu")) model.eval() cap = cv2.VideoCapture(0) face_cascade = cv2.CascadeClassifier("haarcascade_frontalface_default.xml") 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)) face = torch.tensor(face, dtype=torch.float32).unsqueeze(0).unsqueeze(0) face = face / 255.0 pred = model(face).argmax(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", frame) if cv2.waitKey(1) & 0xFF == ord("q"): break注意把像素值除以 255 归一化,这一步漏掉会导致推理输出的概率分布离谱。如果视频流卡顿,可以把检测频率降低,比如每 5 帧检测一次人脸,中间几帧沿用上一次的结果,这个技巧能明显提升帧率。
6.2 换数据集与自定义情绪类别
如果是课程设计,通常会换数据集,比如自己采集几百张人脸图,或者改成四类情绪。改造点有两处:一是 image_emotion_mapping.py 里的字典重新定义类别,二是模型最后的全连接层输出个数改成新类别数。迁移学习时加载预训练权重,只需要替换最后一层:
model = ExpressionCNN(num_classes=7) state = torch.load("model/model_resnet.pkl", map_location="cpu") model.load_state_dict(state, strict=False) # 允许 fc 层缺失 model.fc = nn.Linear(model.fc.in_features, 4)strict=False 让加载时忽略不匹配的 layer,这样原来的特征提取层保留预训练信息,新的输出层从头学。跑新数据集之前,记得先看一眼 data_view 的柱状图,如果某个类别只有几十张图,先做数据增强再训练,否则准确率会很难看。
我从那次之后养成了一个习惯:拿到任何一份新数据集,都先强制跑一遍 data_view 看分布,再切分、再训练,这条流程已经变成了我接手表情识别项目的固定套路。希望这个项目的拆解能帮你在自己的毕设或课设上少走一段弯路。
本文还有配套的精品资源,点击获取