简介基于深度学习的面部表情识别系统完整毕业设计项目面向计算机相关专业学生、正在进行课程设计或期末大作业的学习者以及需要项目实战练习的入门级AI开发者。项目已通过导师指导并获98分评审涵盖模型构建、训练、测试与部署全流程可帮助读者理解CNN、VGG、ResNet等经典网络在图像分类任务中的应用。压缩包共36个文件以Python源码14个py、Jupyter Notebook5个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.xmlOpenCV 人脸检测器用于从图片 / 摄像头画面中框出人脸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( ), dtypenp.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(..., dtypenp.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_ratio0.1, test_ratio0.1, random_seed42): # 先分出测试集 X_trainval, X_test, y_trainval, y_test train_test_split( images, labels, test_sizetest_ratio, random_staterandom_seed, stratifylabels ) # 再从剩余数据中分出验证集 X_train, X_val, y_train, y_val train_test_split( X_trainval, y_trainval, test_sizeval_ratio, random_staterandom_seed, stratifyy_trainval ) return X_train, X_val, X_test, y_train, y_val, y_test逻辑说明stratifylabels是按标签比例做分层采样——电脑懒得思考完整的清洗逻辑因此这个参数很有用。为什么要分层因为 FER2013 的类别严重不平衡Disgust厌恶类只有几百张Happy 类有近九千张如果不分层随机切分测试集里极有可能抽不到几张某类样本模型的真实效果就会被高估。random_seed42固定随机种子保证每次运行划分结果一致这在你调试模型对比实验结果时特别重要——否则同一个模型两次训练的验证集都不一样谁高谁低根本无法判断。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_classes7): super(SimpleCNN, self).__init__() self.features nn.Sequential( # 48x48 灰度图 - 48x48 nn.Conv2d(1, 32, kernel_size3, padding1), nn.BatchNorm2d(32), nn.ReLU(inplaceTrue), nn.MaxPool2d(2, 2), # 24x24 nn.Conv2d(32, 64, kernel_size3, padding1), nn.BatchNorm2d(64), nn.ReLU(inplaceTrue), nn.MaxPool2d(2, 2), # 12x12 nn.Conv2d(64, 128, kernel_size3, padding1), nn.BatchNorm2d(128), nn.ReLU(inplaceTrue), nn.MaxPool2d(2, 2), # 6x6 ) self.classifier nn.Sequential( nn.Flatten(), nn.Linear(128 * 6 * 6, 256), nn.ReLU(inplaceTrue), nn.Dropout(0.5), nn.Linear(256, num_classes) ) def forward(self, x): return self.classifier(self.features(x))逻辑说明padding1保持卷积后尺寸不变三次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_classes7): # 使用 vgg11 默认结构加载 ImageNet 预训练权重的话后面会问题 model models.vgg11(weightsNone) # 原模型第一层输入是 3 通道 RGB改成 1 通道灰度 model.features[0] nn.Conv2d( 1, 64, kernel_size3, padding1 ) # 原模型分类层输出 1000 类改成 7 类 model.classifier[6] nn.Linear( in_features4096, out_featuresnum_classes ) return model逻辑说明model.features[0]是 VGG11 的输入卷积层3→64改成1→64后就能直接吃灰度图model.classifier[6]是最后一个全连接层原输出 1000 改成 7。这里有个关键选择weightsNone是从零训练weightsImageNet是加载预训练权重。对于 FER2013 这种小数据集加载预训练权重理论上能加速收敛但要注意两个问题一是预训练权重第一层是 3 通道改通道后这部分权重无法直接迁移只能随机初始化二是 FER2013 是灰度人脸图和 ImageNet 的自然图像分布差异很大迁移收益不一定明显。我建议先用weightsNone跑通流程再对比加载预训练权重的效果把对比结果写进论文里反而更有说服力。4.3 ResNet 路线残差结构保精度pkl 权重已可复现ResNet 是这份源码里效果最好的模型model_ResNet.py一般用 ResNet18 就足够应付 48×48 的低分辨率小图更深反而容易过拟合。model_resnet.pkl就是训练好的 ResNet 权重加载预测的代码在model_ResNet_test.pyimport torch import torchvision.models as models import torch.nn as nn def load_resnet_model(pkl_path, num_classes7): # 创建和训练时完全相同的模型结构 model models.resnet18(weightsNone) model.conv1 nn.Conv2d(1, 64, kernel_size7, stride2, padding3, biasFalse) model.fc nn.Linear(model.fc.in_features, num_classes) # 加载 pkl 权重CPU 环境要加 map_location state_dict torch.load(pkl_path, map_locationcpu) 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(dim1).item()逻辑说明torch.load(..., map_locationcpu)是关键——如果权重是在 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_locationcpu).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 依赖桌面环境的限制老师在任何一台电脑上打开浏览器都能演示。我自己的习惯是任何一次改造都先写一个单测脚本验证输入输出张量的形状和数值范围再动训练代码——这次拆这个项目时我靠着这个习惯在半小时内定位出数据划分阶段类别比例失衡的问题。希望这份拆解能帮你跑通项目、顺利答辩。本文还有配套的精品资源点击获取