简介:基于深度学习的面部表情识别系统完整毕业设计项目,面向计算机相关专业学生、正在进行课程设计或期末大作业的学习者,以及需要项目实战练习的入门级AI开发者。项目已通过导师指导并获98分评审,涵盖模型构建、训练、测试与部署全流程,可帮助读者理解CNN、VGG、ResNet等经典网络在图像分类任务中的应用。压缩包共36个文件,以Python源码(14个py)、Jupyter Notebook(5个ipynb)、论文文档(docx/doc/pdf)、答辩PPT、数据集zip及演示视频mp4为主,整体大小446.05MB,包含完整代码注释、部署说明和项目手册。已有276人学习下载,适合作为毕业论文设计或实战练习的参考模板。内容预览可见包含Face-Annotation-Tool、Fer2013等数据集与模型文件,并提供了CNN、VGG、ResNet多种模型实现和对比脚本,以及人脸检测、数据处理、表情映射等工具模块,便于读者按需查阅和二次开发。
1. 基于深度学习的面部表情识别:一个能直接答辩的完整工程项目
如果你正在找一份能跑通、能讲清楚、能写进毕业论文的 CV 方向项目,这套基于 FER2013 数据集的实时面部表情识别系统值得花一个周末拆开看。它不是那种只有一堆.py文件却跑不起来的“假资源”,而是把数据预处理、三种主流模型(CNN、VGG、ResNet)的训练与评估、OpenCV 人脸检测、Flask Web 页面、答辩 PPT 和论文全串在了一条线上。我拆完这份源码最大的感受是:它非常适合拿来当毕业设计骨架——模型不是黑匣子,每个环节都有对应的脚本和注释,改起来不需要推倒重来。这套项目的评审分是 98 分,属于典型的高分毕设结构,下一节我们直接从目录开始拆。
2. 整体架构:一条从 FER2013 原始 CSV 到网页实时识别的完整链路
2.1 拿到压缩包后先看什么:目录结构与模块职责
解压后第一件事不是急着装依赖,而是把顶层文件过一遍。这份资源的结构很清晰,训练、测试、数据、文档四类东西分得明明白白。按我的习惯,先列一张功能对照表再逐文件看代码:
| 文件 / 目录 | 职责 |
|---|---|
data_process.py | 读取 FER2013 的 CSV,把像素串转成 numpy 数组和标签 |
data_separation.py | 按比例划分训练集 / 验证集 / 测试集 |
data_view.py | 随机抽样绘制人脸图像,检查数据是否正常 |
image_emotion_mapping.py | 标签 id 与表情名称的映射字典 |
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 | 统一加载三个模型做对比评估 |
model_resnet.pkl | 已训练好的 ResNet 权重文件 |
templates/ | Flask 页面模板,说明项目自带了 Web 演示端 |
haarcascade_frontalface_default.xml | OpenCV 人脸检测器,用于从图片 / 摄像头画面中框出人脸 |
dataset/ | 原始数据集目录,按 FER2013 格式组织 |
建议先读README.md和手册.docx,里面写了环境版本和运行顺序。项目用的是 PyTorch,从.pkl权重、torch.save的用法和Fer2013-pytorch-CNN-VGG-Resnet--master.zip这个参考项目名基本可以判断。
2.2 端到端数据流:从一张图片到七类表情输出
整套系统最核心的不是某个模型,而是这条链路——我拆完代码后把它画成了一条流水线,理解这条线,你才能知道改哪里、断在哪:
输入(图片 / 摄像头帧 / 视频帧) ↓ OpenCV haarcascade 人脸检测,裁出人脸区域,灰度化 + resize 到 48×48 ↓ 像素值归一化到 [0,1] 区间,整理成 (1, 1, 48, 48) 张量 ↓ PyTorch 模型前向推理(CNN / VGG / ResNet 三选一) ↓ 输出 7 类概率分布 → 取 argmax → 映射为 angry / disgust / fear / happy / sad / surprise / neutral ↓ 在 Flask 页面或视频窗口上叠加显示表情标签和置信度这个流程里的关键参数是48×48。FER2013 数据集的原始图像分辨率就是 48×48 的灰度图,所以所有模型的第一层卷积输入通道都是 1,全连接层的输入维度也都按 48×48 推算。如果你换数据集(比如 CK+ 是 48×48 的灰度序列,RAF-DB 是 100×100 的彩色图),要改的不只是输入尺寸,还有归一化方式和通道数。
2.3 数据视图层:templates 与 Web 演示端
templates/目录的存在说明这套毕设不是纯命令行训练,而是带了一个 Flask Web 交互端。常见做法是:
# app.py 的典型路由结构,和项目 templates 目录配套 @app.route("/", methods=["GET", "POST"]) def upload_predict(): if request.method == "POST": # 读取上传图片 -> 转为灰度 -> 人脸检测 -> 交给模型推理 # 返回表情名称和置信度,渲染到 result.html pass return render_template("index.html")这里没有强制用哪种模板结构,三件套(index.html负责上传、result.html负责展示结果、style.css负责样式)是毕设项目里最常见的组织方式。你拿到手后如果 templates 里只有一两个文件,自己补一个结果页也很容易。Web 端是加分项,它让“深度学习表情识别”从抽象概念变成了可交互的实物,答辩时现场演示一段摄像头识别比只放训练曲线有说服力得多。
3. 数据工程:FER2013 的加载、划分与可视化
3.1 data_process.py:把 CSV 像素串变成可训练的张量
FER2013 数据集的原始格式是 CSV,每行由emotion标签(0 到 6)和pixels列(2304 个由空格分隔的灰度值)组成。我第一次看到这个格式时以为要写很复杂的解析器,其实核心data_process.py做的事就四步:
import pandas as pd import numpy as np def load_fer2013(csv_path): # 读取 FER2013 原始 CSV df = pd.read_csv(csv_path) # 把像素字符串拆成整数列表:split(' ') -> astype(np.uint8) pixel_values = df["pixels"].apply( lambda x: np.array(x.split(" "), dtype=np.uint8) ) # 每行 2304 个值,正好是 48 * 48 的灰度图像素数 images = np.stack(pixel_values.values).reshape(-1, 48, 48, 1) # 归一化到 [0,1],让网络训练更稳定 images = images.astype("float32") / 255.0 labels = df["emotion"].values.astype("int64") return images, labels # 使用示例:加载后查看形状,应为 (28709, 48, 48, 1) 和 (28709,) # images, labels = load_fer2013("dataset/fer2013.csv") # print(images.shape, labels.shape)逻辑说明:split(" ")按空格切分像素字符串,np.array(..., dtype=np.uint8)把像素值限制在 0~255 的灰度范围内,省内存也能避免数值异常;reshape(-1, 48, 48, 1)的-1是自动推断样本数,最后一维的1表示单通道灰度图。归一化是必须的——不除以 255,像素值在 0~255 范围,模型收敛速度会明显变慢,甚至会梯度震荡。这个文件是整个项目的地基,后面所有模型的数据加载都依赖它的输出格式。
3.2 data_separation.py:训练验证测试三集划分
FER2013 官方划分是训练集 28709 张、公开测试集 3589 张、私有测试集 3589 张,但很多复现项目为了自己控制比例,会重新划分。data_separation.py常见实现是手动切分:
from sklearn.model_selection import train_test_split def split_dataset(images, labels, val_ratio=0.1, test_ratio=0.1, random_seed=42): # 先分出测试集 X_trainval, X_test, y_trainval, y_test = train_test_split( images, labels, test_size=test_ratio, random_state=random_seed, stratify=labels ) # 再从剩余数据中分出验证集 X_train, X_val, y_train, y_val = train_test_split( X_trainval, y_trainval, test_size=val_ratio, random_state=random_seed, stratify=y_trainval ) return X_train, X_val, X_test, y_train, y_val, y_test逻辑说明:stratify=labels是按标签比例做分层采样——电脑懒得思考完整的清洗逻辑,因此这个参数很有用。为什么要分层?因为 FER2013 的类别严重不平衡,Disgust(厌恶)类只有几百张,Happy 类有近九千张,如果不分层随机切分,测试集里极有可能抽不到几张某类样本,模型的真实效果就会被高估。random_seed=42固定随机种子,保证每次运行划分结果一致,这在你调试模型对比实验结果时特别重要——否则同一个模型两次训练的验证集都不一样,谁高谁低根本无法判断。
3.3 可视化与标签映射:data_view.py 和 image_emotion_mapping.py
这两个文件一个负责看图,一个负责把数字标签翻译成人话。data_view.py通常用matplotlib随机抽 16 或 36 张图,按九宫格排版打印出来,同时在每张图上方标注真实标签。这一步在项目前期很有价值——你会发现 FER2013 里有不少标注错误的人脸,有些图连人都看不出来是什么表情。提前看过数据长什么样,后面模型出问题时排查会更有方向。
image_emotion_mapping.py是全局的“翻译官”,所有预测结果显示都依赖它:
emotion_dict = { 0: "angry", 1: "disgust", 2: "fear", 3: "happy", 4: "sad", 5: "surprise", 6: "neutral" }逻辑说明:这是 FER2013 官方定义的类别顺序,不能改变顺序。你训练时模型输出的argmax是一个 0~6 的整数,必须通过这个字典转成对应的英文标签。如果你自己换数据集,导致类别顺序不同,这里的映射要同步修改,否则会出现“把笑脸识别成 sad”这种看起来完全无法理解的 bug。顺手提醒一点:如果用中文标签做 Web 展示,建议单独做一个emotion_zh字典,不要改动这个基础映射文件。
4. 模型实现拆解:CNN、VGG、ResNet 三线并行的训练与评估
4.1 基线 CNN:小网络先跑通全流程
model_CNN.py对应的是基础卷积网络,它的作用不是刷精度,而是验证数据管线是否通畅。我拆完发现这类毕设里 CNN 模块的结构高度相似——几层Conv2d + BatchNorm + ReLU + MaxPool,最后接全连接层输出 7 类:
import torch.nn as nn class SimpleCNN(nn.Module): def __init__(self, num_classes=7): super(SimpleCNN, self).__init__() self.features = nn.Sequential( # 48x48 灰度图 -> 48x48 nn.Conv2d(1, 32, kernel_size=3, padding=1), nn.BatchNorm2d(32), nn.ReLU(inplace=True), nn.MaxPool2d(2, 2), # 24x24 nn.Conv2d(32, 64, kernel_size=3, padding=1), nn.BatchNorm2d(64), nn.ReLU(inplace=True), nn.MaxPool2d(2, 2), # 12x12 nn.Conv2d(64, 128, kernel_size=3, padding=1), nn.BatchNorm2d(128), nn.ReLU(inplace=True), nn.MaxPool2d(2, 2), # 6x6 ) self.classifier = nn.Sequential( nn.Flatten(), nn.Linear(128 * 6 * 6, 256), nn.ReLU(inplace=True), nn.Dropout(0.5), nn.Linear(256, num_classes) ) def forward(self, x): return self.classifier(self.features(x))逻辑说明:padding=1保持卷积后尺寸不变,三次MaxPool2d把 48×48 逐步压到 6×6,每次池化后通道数翻倍(32→64→128),这是图像分类网络最经典的设计范式。BatchNorm2d放卷积后面而不是激活函数后面,这是现代 CNN 的常见排列顺序,比传统的 Conv→ReLU→BN 收敛更快。全连接前的128 * 6 * 6是特征图展平后的维度,由前面卷积结构的输出尺寸决定——如果你改了输入分辨率或池化次数,这里的数字必须同步重算,这是新手最容易算错的地方。Dropout 0.5 只在全连接层生效,卷积层不做 Dropout。
4.2 VGG 路线:复用 torchvision 的 VGG11 结构改输入通道
VGG 的核心思想是用多个 3×3 小卷积堆叠替代大卷积核,感受野相同但参数更少、非线性更强。毕设项目里的 VGG 不太会从头写完整实现,常见做法是直接用torchvision.models.vgg11并改造第一层和最后一层:
import torchvision.models as models import torch.nn as nn def create_vgg11(num_classes=7): # 使用 vgg11 默认结构,加载 ImageNet 预训练权重的话后面会问题 model = models.vgg11(weights=None) # 原模型第一层输入是 3 通道 RGB,改成 1 通道灰度 model.features[0] = nn.Conv2d( 1, 64, kernel_size=3, padding=1 ) # 原模型分类层输出 1000 类,改成 7 类 model.classifier[6] = nn.Linear( in_features=4096, out_features=num_classes ) return model逻辑说明:model.features[0]是 VGG11 的输入卷积层(3→64),改成1→64后就能直接吃灰度图;model.classifier[6]是最后一个全连接层,原输出 1000 改成 7。这里有个关键选择:weights=None是从零训练,weights=ImageNet是加载预训练权重。对于 FER2013 这种小数据集,加载预训练权重理论上能加速收敛,但要注意两个问题:一是预训练权重第一层是 3 通道,改通道后这部分权重无法直接迁移,只能随机初始化;二是 FER2013 是灰度人脸图,和 ImageNet 的自然图像分布差异很大,迁移收益不一定明显。我建议先用weights=None跑通流程,再对比加载预训练权重的效果,把对比结果写进论文里反而更有说服力。
4.3 ResNet 路线:残差结构保精度,pkl 权重已可复现
ResNet 是这份源码里效果最好的模型,model_ResNet.py一般用 ResNet18 就足够应付 48×48 的低分辨率小图,更深反而容易过拟合。model_resnet.pkl就是训练好的 ResNet 权重,加载预测的代码在model_ResNet_test.py:
import torch import torchvision.models as models import torch.nn as nn def load_resnet_model(pkl_path, num_classes=7): # 创建和训练时完全相同的模型结构 model = models.resnet18(weights=None) 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) # 加载 pkl 权重,CPU 环境要加 map_location state_dict = torch.load(pkl_path, map_location="cpu") model.load_state_dict(state_dict) model.eval() return model # 单张图片预测示例 # model = load_resnet_model("model_resnet.pkl") # with torch.no_grad(): # output = model(torch.from_numpy(face_array).unsqueeze(0)) # pred = output.argmax(dim=1).item()逻辑说明:torch.load(..., map_location="cpu")是关键——如果权重是在 GPU 上训练的,直接加载到无 GPU 机器会报显存设备错误;这句话会自动把张量映射到 CPU。model.eval()必须调用,它会关闭 Dropout 和 BatchNorm 的 training 行为,否则同一个输入每次预测结果都不一样。ResNet 的残差连接(skip connection)让梯度可以跨层直接回传,所以网络更深也不容易出现梯度消失,这是它在表情识别这种细节纹理任务上优于普通 VGG 的原因。resnet18的第一层卷积核是 7×7、步长 2,对 48×48 小图来说其实有点激进,如果你追求更高精度,可以把conv1的stride改成 1,或者直接换成 3×3 小卷积。
4.4 统一评估:model_All_Compare.py 让三个模型同台竞技
这份资源比较难得的地方是给了model_All_Compare.py——一个把三个模型放在同一测试集、同一预处理流程下做横向对比的脚本。它的价值在于论文里可以直接引用对比结果作为消融实验,不用手动跑三遍再二次拼数据:
def evaluate_all_models(models_dict, test_loader): results = {} for name, model in models_dict.items(): model.eval() correct, total = 0, 0 all_preds, all_labels = [], [] with torch.no_grad(): for images, labels in test_loader: outputs = model(images) _, predicted = torch.max(outputs, 1) total += labels.size(0) correct += (predicted == labels).sum().item() all_preds.extend(predicted.cpu().numpy()) all_labels.extend(labels.cpu().numpy()) acc = correct / total # 计算每个类别的召回率、F1,保存混淆矩阵 results[name] = {"accuracy": acc, "preds": all_preds, "labels": all_labels} return results逻辑说明:评估脚本把三个已经加载好的模型放在同一个test_loader上跑,确保数据顺序和预处理完全一致,这样的对比才是公平的。准确率不是唯一指标,FER2013 的类别不平衡决定了你要同时看每个类的召回率。Disgust 样本最少,如果模型把所有样本都预测成 Neutral,准确率依然能到 60% 以上,但明显不可用。所以我建议在results里加上confusion_matrix和classification_report,答辩时这张混淆矩阵图比准确率数字更能体现你的工程深度。torch.max(outputs, 1)按行取最大值索引,是预测结果的解析方式;correct / total是整体正确率,配合per-class recall一起看才有意义。
5. 避坑与常见问题:训练表情识别模型的五个血泪经验
5.1 现象:split(" ")解析像素报错,训练直接中断
训练刚开始就报ValueError: setting an array element with a sequence,或者Split出来的像素数量不是 2304。原因:FER2013 CSV 的像素列是字符串,但部分行有前导空格或中间会有双空格(导出的 CSV 格式问题),直接split(" ")会产生空字符串,转float时失败,或者数据清洗时混入了非 48×48 的脏样本。解决:换成x.split(" ")后过滤空串,或用正则re.findall(r"\\d+", x)提取所有数字;更保险的做法是解析后检查len(values) == 2304,不满足的行直接丢弃。从那以后我每次处理像素类 CSV,都会先做数量和值域两个断言,不再信任原始 CSV 的格式。
5.2 现象:Disgust 类别召回率无限接近 0,准确率却一直涨
训练几十轮后整体准确率到了 60% 以上,但看混淆矩阵发现 Disgust 几乎全被分错。原因:类别严重不平衡。FER2013 的 Disgust 只有几百张样本,而 Happy 近 9000 张,模型为了最小化整体 loss,会把所有不确定样本倾向预测成大类,少数类直接被牺牲。解决:先用np.bincount(y_train)打印每类数量,确认不平衡比例;接着在CrossEntropyLoss里传weight参数,给少数类更高的权重;或者用WeightedRandomSampler在采样时多抽少数类样本。当时我两种都试过,WeightedRandomSampler效果更直接,因为等价于训练集中多次重复少数类样本,比单纯放大 loss 更好控制。
5.3 现象:torch.load报KeyError或模型大小不对,pkl 加载失败
下载的model_resnet.pkl加载进来直接报错。原因:.pkl是 PyTorch 的序列化文件,分两种——torch.save(model.state_dict())存的是权重字典,需要先构建模型再load_state_dict加载;而如果 save 的是整个模型对象,加载方式完全不同。本项目里 pkl 是state_dict格式,但毕设源码流传过程中 pkl 文件容易被替换或损坏。解决:先print(torch.load(pkl_path, map_location="cpu").keys())看第一层 key——如果有conv1.weight这类带层名的 key,说明是state_dict,走load_state_dict路线;如果只有一个model之类的大 key,可能整个模型被封装了。另一个常见问题是 PyTorch 版本不一致,旧版存的文件新版能读,新版存的文件旧版可能报_pickle.UnpicklingError,这时要升级环境。
5.4 现象:摄像头实时识别时人脸框乱跳,表情识别结果时对时错
Web 端跑通了,摄像头画面上人脸框忽大忽小,表情标签在几个人名之间乱闪。原因:haarcascade_frontalface_default.xml检测器对每帧独立检测,没有做时间平滑;光线变化和头部转动会导致框的尺寸和位置抖动,裁出的人脸区域每次都不一样,交给模型的输入也就不稳定。解决:第一个方案是加检测框的跟踪机制——用上一帧的框作为当前帧的先验,在当前帧的邻域内搜索人脸,避免每帧全图重新检测;第二个方案是把裁出的人脸固定到同一尺寸并做灰度直方图均衡化(cv2.equalizeHist),减弱光线影响;第三个方案是预测输出的标签用滑动窗口多数投票(近 5 帧中取出现次数最多的表情),能明显减少抖动。三个都做会稳很多,但如果只做其中一个,优先做多数投票。
5.5 现象:Web 端上传高清照片预测慢,CPU 上推理要好几秒
上传一张 4K 照片,模型推理已经很快,但接口迟迟不返回。原因:OpenCV 对整张原图做人脸检测时,detectMultiScale对高分辨率大图的计算量剧增,加上模型本身需要把多个检测到的人脸逐个推理,耗时成倍上升。解决:检测前先按比例缩小图像(比如最长边限制 800px),检测到人脸后记录缩放比例,把原图坐标映射回去再裁人脸;同时给每个检测到的人脸标注置信度,低于阈值的框直接丢弃,减少无效推理。很多毕设项目在这一步卡住,不是因为模型不行,而是因为没意识到人脸检测瓶颈在 CPU 上远比模型推理大,“先缩图再检测”是常规做法,改完以后响应时间能从秒级降到底下。
6. 二次开发:把训练脚本改成可部署的实时识别服务
拿到这套源码不要只停在“能跑通”这一步,那只是一个毕业设计的起跑线。真正加分的改动是把模型接进摄像头实时识别和 Web 服务两种场景。先说摄像头方案,核心步骤是:用cv2.VideoCapture(0)打开摄像头 → 每一帧调用detectMultiScale拿到人脸框 → 裁剪并缩放到 48×48 → 转成张量喂给模型 → 把表情文本画到帧上。注意每次从帧上裁图后要做和训练时完全一致的预处理——转灰度、归一化、维度从(48, 48)变(1, 1, 48, 48),漏掉任何一步预测结果都会失真。
更有工程价值的改造是把 PyTorch 模型导出成 ONNX,脱离 Python 训练环境做推理:
dummy_input = torch.randn(1, 1, 48, 48) torch.onnx.export(model, dummy_input, "resnet_fer.onnx", input_names=["input"], output_names=["output"], dynamic_axes={"input": {0: "batch"}, "output": {0: "batch"}})逻辑说明:dynamic_axes声明 batch 维度可变,这样导出的模型既能跑单张图,也能一次跑多张;ONNX 格式可以直接被 OpenCV 的dnn模块加载,或者在 Flask 服务里用onnxruntime推理。CPU 环境下 ONNX 推理通常比 PyTorch 的 eager 模式快 1.5~2 倍,因为图优化去掉了动态调度开销。导出后建议用onnxruntime跑一遍同一张测试图,和 PyTorch 的输出对比,误差在1e-5级别就说明导出成功——这一步不要省,很多人栽在这里。
如果你的改进重点在 Flask 页面,思路是:templates里加一个摄像头页(用浏览器原生getUserMedia拿视频流,每隔几百毫秒把画面 POST 到后端/predict接口),后端从request.files里取图片,跑完整链路后返回 JSON(表情名 + 置信度),前端用 JavaScript 把结果渲染到画面上。这样就摆脱了 OpenCV 依赖桌面环境的限制,老师在任何一台电脑上打开浏览器都能演示。我自己的习惯是:任何一次改造,都先写一个单测脚本验证输入输出张量的形状和数值范围,再动训练代码——这次拆这个项目时,我靠着这个习惯在半小时内定位出数据划分阶段类别比例失衡的问题。希望这份拆解能帮你跑通项目、顺利答辩。
本文还有配套的精品资源,点击获取