简介:面向计算机专业毕业设计与深度学习初学者的完整人脸表情识别项目,以卷积神经网络为核心,覆盖数据预处理、模型搭建、训练评估与实时识别演示的完整流程,可直接用于课程作业、论文写作或实战练手。压缩包共36个文件,以Python脚本和Jupyter Notebook为主,辅以数据集、预训练模型、论文文档及答辩PPT,另含演示视频与使用手册,整体约446MB;目录中针对CNN、VGG、ResNet分别提供模板和测试代码,并包含数据划分、情感映射等工具模块。项目经导师指导并获评审98分,源码经过编译调试可稳定运行,自带的参考工程与模型文件也能帮助读者理解人脸检测、特征提取与表情分类等关键环节。目前已有75人学习,适合希望快速搭建人脸表情识别系统并输出论文、答辩等完整成果的学生参考。
1. 人脸表情识别不是玩具项目:从数据到部署一次走完的 CNN 实战
说到卷积神经网络,课设和毕设里最能落地的一个题目就是人脸表情识别:输入一张人脸,输出七类表情中的一类。这个题材好就好在它把数据清洗、模型设计、训练调参、推理部署全串在一起,做完一个项目,深度学习的完整链路基本都过了一遍。但这个项目有个特点,数据环节占的精力比模型环节还大,很多同学拿到手就在model_CNN.py里死磕结构,最后卡在数据切分、类别映射和摄像头演示上。这套源码把这些环节一次补齐:CNN、VGG、ResNet 三套可对照的模型、已训练好的model_resnet.pkl权重、Haar 级联人脸检测、web 演示页面,外加论文和答辩 PPT,难度正好,适合课程设计和本科毕设直接复现。
2. 数据管线先立住:把 fer2013 原始表变成能送进网络的表情张量
打开资源包,先别急着看模型。你会发现data_process.py、data_separation.py、data_view.py、image_emotion_mapping.py一字排开,数据脚本数量比模型脚本还多。这不是凑目录,是原作者把一半以上的时间花在了数据上。做表情识别,数据管线的优先级确实高于模型:同一个 CNN,喂干净的数据和喂错位的数据,准确率可能差出十个百分点。
2.1 data_process.py 与 data_separation.py:原始像素和标签是怎么被拆成三份的
Fer2013 格式的原始数据是一张 CSV 表,每一行由三部分组成:emotion标签、pixels灰度像素串、Usage字段。pixels是 48×48 的灰度值,排成一行 2304 个数字。data_process.py干的第一件事就是把这一串数字还原成二维矩阵,再除以 255 归一化。这一步不做的话,卷积层输出的特征值在一个 epoch 之后就很容易膨胀到溢出,表现就是 loss 跳到nan,这是新手最容易踩的第一个坑。
data_separation.py负责切分。常见做法是直接用 CSV 里自带的 Usage 字段区分 Training、PublicTest、PrivateTest 三份,如果手头数据没有这个字段,就按 8:1:1 的比例随机切。切分有一点要特别注意:尽量按人切,不要按样本切。同一个人的不同表情图片如果同时混进训练集和测试集,测试准确率会虚高,答辩时被问“模型是不是记住了这个人的长相”会很尴尬。这个资源默认走的是标准切分逻辑,跑完之后目录结构大致是这样的:
dataset/ ├── train/ │ ├── angry/ # 索引 0 │ ├── disgust/ # 索引 1 │ ├── fear/ # 索引 2 │ ├── happy/ # 索引 3 │ ├── sad/ # 索引 4 │ ├── surprise/ # 索引 5 │ └── neutral/ # 索引 6 ├── val/ └── test/切完以后顺手跑一遍data_view.py,把每个类别各抽一批样本拼成网格图看一眼。这一步看起来是浪费时间的,实际能发现两类问题:一类是标签错位,比如angry目录里混进了sad表情;另一类是图片过暗或人脸占比太小,这种样本在训练时几乎学不出有效特征,不如直接删掉。我一般把可视化脚本放在数据处理完、训练开始前强制跑一次,跑完再动手写模型。
2.2 用 haar 级联定位人脸,为什么它能决定模型上限
预训练模型是在裁剪后的人脸上训练的,所以推理阶段必须用同一个裁剪策略。资源包里带了haarcascade_frontalface_default.xml,这是 OpenCV 自带的经典人脸检测器,模型不大,但速度很快,适合在视频流里逐帧跑。典型用法如下:
import cv2 face_cascade = cv2.CascadeClassifier("haarcascade_frontalface_default.xml") img = cv2.imread("demo.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]这里有两个参数直接决定后续分类的上限。scaleFactor表示每轮缩放比例,设成 1.1 意味着窗口每次缩小 10%,值越小检测越精细,但速度越慢;minNeighbors控制一个候选框至少要被多少个相邻框确认才算人脸,设得越大漏检越多,但误检越少。真做实时 demo 时我会把minNeighbors调到 6 到 8,宁可偶尔漏检,也要避免把背景纹理当成人脸送进表情模型,因为表情模型对非人脸输入会输出一个完全没意义的概率分布。
另外要强调一句:训练数据如果是灰度图,推理阶段就必须也用灰度图。上面代码里先转gray再做检测和裁剪,这个顺序不能反。如果有人直接把彩色人脸塞给表情模型,会先遇到形状不匹配报错;更隐蔽的问题是,即使强行把三通道图喂进去,模型学的是灰度纹理特征,输入分布对不上,表现会明显变差。
2.3 image_emotion_mapping.py:类别映射和可视化校验
把模型输出的索引映射回文字标签,这一步同样容易出事。模型输出的不是字符串,而是 0 到 6 的索引;image_emotion_mapping.py里维护的就是这张对应表。
| 索引 | 标签 | 训练时容易遇到的问题 |
|---|---|---|
| 0 | angry | 和 sad 视觉上容易混淆 |
| 1 | disgust | 样本量少,容易学不动 |
| 2 | fear | 和 surprise 混淆度高 |
| 3 | happy | 最容易学的一类 |
| 4 | sad | 和 neutral 边界模糊 |
| 5 | surprise | 样本少,需要重点看曲线 |
| 6 | neutral | 容易压倒其他类别 |
我的血泪经验是:训练脚本、pkl 权重、web 页面三处的映射表必须用同一份拷贝。之前见过有人训练代码里定义的是[angry, fear, happy, ...],网页里又按[happy, sad, angry, ...]读,结果全部错位,而且显示出来的结果还“看着像那么回事”,排查了很久才发现是表的问题。复现项目时直接用资源里的映射文件就好,别自己重新排序。
3. 三个模型放在一起比着跑:CNN、VGG、ResNet 各自的优势与坑
模型定义集中在model_CNN.py、model_VGG.py、model_ResNet.py三个文件里,配套model_CNN_test.py、model_ResNet_test.py等测试脚本,以及一批*_model_template.py模板文件。这种结构明显是为毕业设计准备的:论文里要写“比较了三种网络结构”,代码里就得有对应的对比实验支撑。
3.1 model_CNN.py:先用最浅的基线把全流程跑通
model_CNN.py是最基础的两层卷积结构:输入单通道 48×48 灰度图,第一层卷积输出 32 个通道,经过池化变成 24×24;第二层卷积输出 64 个通道,经过池化变成 12×12;全连接层从64×12×12拉平,接一个 128 维的隐藏层,最后输出 7 类。这类实现和下面的结构等价:
class EmotionCNN(nn.Module): def __init__(self, num_classes=7): super().__init__() self.features = nn.Sequential( nn.Conv2d(1, 32, kernel_size=3, padding=1), nn.ReLU(inplace=True), nn.MaxPool2d(kernel_size=2, stride=2), nn.Conv2d(32, 64, kernel_size=3, padding=1), nn.ReLU(inplace=True), nn.MaxPool2d(kernel_size=2, stride=2), ) self.classifier = nn.Sequential( nn.Linear(64 * 12 * 12, 128), nn.ReLU(inplace=True), nn.Dropout(p=0.5), nn.Linear(128, num_classes), ) def forward(self, x): x = self.features(x) x = x.view(x.size(0), -1) return self.classifier(x)注意最后输出层用的是线性层,没有内置 Softmax。这不是漏写了,是因为 PyTorch 的CrossEntropyLoss会直接在 logits 上算损失,推理时再用softmax取概率就行。如果训练时既做 Softmax 又配CrossEntropyLoss,等价于在两个概率分布之间算交叉熵,梯度会被压得很平。
用这个浅网络跑完整条训练流程,最大的价值是验证数据管线通不通。我一般拿它先跑两三个 epoch,如果 loss 能正常降下来,再换 VGG、ResNet 做大实验。深模型一次训练成本高,拿浅网络探路能省很多时间。
3.2 model_VGG.py 与 model_ResNet.py:堆卷积和加残差是两种升级路径
VGG 的思路是全部用 3×3 小卷积核,靠层数换感受野。两个 3×3 卷积堆叠等效于一个 5×5 卷积,三个堆叠等效于一个 7×7 卷积,但参数量更少,非线性更强。model_VGG.py里通常就是conv 3x3 -> BN -> ReLU -> conv 3x3 -> BN -> ReLU -> pooling这样一组一组堆上去,到了后半段接全连接层。
ResNet 则是在每一组卷积旁边加一条 shortcut 跳连,把输入直接加到输出上。这个小改动解决的是深层网络里梯度消失的问题:即使层数很深,梯度也可以沿着 shortcut 直接回传。model_ResNet.py和ResNet_model_template.py里可以看到两层卷积和三层卷积两种基础残差块,模板文件比裸代码更好改,要调整通道数时只需要改nn.Sequential里的数字。
这里有两个实际选择问题。第一,fer2013 本身是静态人脸表情图,数据规模不大,ResNet 这类深模型很容易过拟合,所以资源里训练脚本一般会配合 BatchNorm 和 Dropout,用它们压制验证集上的震荡。第二,如果你改了num_classes,比如想只分 5 类表情,模型定义和 pkl 权重的输出层都要一起改,否则就会遇到第 5 章要讲的size mismatch报错。
3.3 model_All_Compare.py:一次跑通三个模型的对比
model_All_Compare.py的作用是把三个模型拉到同一套数据划分、同一套优化器和同样 epoch 数下跑一组对比,最后输出准确率和 loss 曲线。对比要以公平为前提:比如全部用同样的 batch size、同样的学习率策略,只改网络结构本身。资源里给了对比入口,你自己复现时可以把表格整理成下面这样:
| 对比维度 | CNN | VGG | ResNet |
|---|---|---|---|
| 训练速度 | 最快 | 中等 | 较慢 |
| 特征抽象能力 | 边缘、纹理 | 组合特征 | 深层次语义 |
| 过拟合风险 | 低 | 较高 | 中,残差有抑制作用 |
| 适合场景 | 快速验证流程 | 中等规模数据 | 数据充足时追求上限 |
写论文时,把这组对比放到实验章节,说明“浅网络用于验证流程,深网络用于提升上限”,逻辑上就完整了。答辩时如果被问“为什么选 ResNet”,可以从梯度流动和准确率两个角度回答,比单纯说“效果好”更有说服力。
4. 训练到推理:把 model_resnet.pkl 真正用起来
拿到这套资源,大部分人的目标是绕开漫长训练,直接用现成权重跑出识别效果。所以这一章把从加载数据到单张图片预测的全流程拆开说。
4.1 DataLoader 与 48×48 输入的设定
训练时dataset目录下每个子文件夹是一个类别,PyTorch 的ImageFolder可以直接读。入口处一般会接一个 transform 组合,把图片统一成 48×48 而且归一化:
from torch.utils.data import DataLoader from torchvision import datasets, transforms transform = transforms.Compose([ transforms.Resize((48, 48)), transforms.ToTensor(), transforms.Normalize([0.5], [0.5]), ]) train_dataset = datasets.ImageFolder("dataset/train", transform=transform) train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True, num_workers=2) for images, labels in train_loader: # images shape: (64, 1, 48, 48) break这里有两个参数值得留意。batch_size设为 64 是显存和训练速度的折中,如果你的显卡只有 4GB,降到 32 或 16;Normalize([0.5], [0.5])把 0 到 1 的像素值映射到 -1 到 1 区间,这个均值和方差必须和训练预处理一致。项目里data_process.py归一化过一遍,训练脚本再归一化一遍,不要中途改数值范围。
4.2 优化器、学习率与模型保存
fer2013 上我一般会先试 Adam,因为它起步快、不太挑学习率,适合跑通线;如果要刷高准确率,再切SGD + Momentum(0.9),配合余弦退火或者 StepLR,收敛更稳。训练脚本里常见的保存方式是每个 epoch 结束对比验证集准确率,只有比历史最好还高才覆盖保存:
best_acc = 0.0 for epoch in range(epochs): train_one_epoch(model, train_loader, optimizer, criterion) val_acc = evaluate(model, val_loader) if val_acc > best_acc: best_acc = val_acc torch.save(model.state_dict(), "model_resnet.pkl") print(f"epoch {epoch}: save best model, acc={val_acc:.4f}")按验证集准确率保存而不是按训练 loss 保存,可以避免把过拟合的中间结果存下来。很多情况下训练 loss 还在降,验证 loss 已经反弹了,如果保存的是最后一个 epoch 的权重,推理效果会明显变差。
4.3 加载预训练权重并推理单张图片
资源里已经带了model_resnet.pkl,你可以跳过训练直接加载。推理代码大概是下面这个模式:
import torch import torch.nn.functional as F model = build_resnet(num_classes=7) model.load_state_dict(torch.load("model_resnet.pkl", map_location="cpu")) model.eval() with torch.no_grad(): logits = model(face_tensor) # face_tensor shape: (1, 1, 48, 48) prob = F.softmax(logits, dim=1) idx = int(torch.argmax(prob, dim=1)) print(prob) # 七类概率,例如 [0.02, 0.01, 0.03, 0.88, 0.03, 0.02, 0.01]torch.load的map_location="cpu"很有用,服务器上训的权重通常带 CUDA 键,本地没显卡也能直接加载。model.eval()这一行不能省,否则 Dropout 和 BN 的推理行为不对,结果会有随机性。torch.no_grad()会关掉梯度计算,既省内存又让推理速度更快。
如果加载pkl时出现size mismatch,说明权重的结构和build_resnet定义不一致,最常见原因是训练时的num_classes不是 7。这一点在第 5 章专门讲。
4.4 用 templates 页面做交互演示
资源里带了templates目录,配合 Flask 之类的小服务可以做一个网页版 demo。流程是:用户上传一张图片到后端,后端先用 Haar 级联裁剪人脸,再缩放成 48×48 张量,喂给模型,把七类概率渲染成柱状图返回前端。答辩现场用摄像头截一张图传到这个页面,比在终端里打印一个数字要直观得多。
实际做的时候注意路径问题:haarcascade_frontalface_default.xml放在项目根目录,网页上传的临时图片放在独立的上传目录,模型权重用绝对路径或相对路径都行,但 Flask 的当前工作目录要确认对,否则会报找不到文件。
5. 表情识别项目避坑记录:最常翻车的五个环节
这套资源本身是能跑的,但跑的过程中还是有几个高频翻车点值得单独拿出来讲。每条按现象、原因、解决的顺序写,都是复现时容易遇到的真问题。
5.1 Haar 级联检测不到侧面和暗光场景
现象:正脸图片一切正常,侧脸或光线暗的图片直接返回空列表,后续裁剪代码报“找不到人脸”,演示中断。
原因:Haar 级联是基于正脸特征训练的,对侧脸、低头、逆光场景召回率很低。表情识别模型本身并没有问题,是前面的人脸检测环节把人脸漏掉了。
解决:先对图片做直方图均衡化提升对比度,再把minNeighbors从 5 降到 3,scaleFactor从 1.1 改到 1.05,让检测更敏感。如果确实需要处理侧脸,可以额外准备一张镜像图,把两次检测结果合并。批量跑前先抽样看检测率,不要直接拿整批数据跑训练。
5.2 灰度图和三通道图不一致导致的形状报错
现象:训练时图片能正常输入,到推理阶段报expected 4D input (got 3D)或者mat1 and mat2 shapes cannot be multiplied。
原因:训练数据走的是ImageFolder + transforms.ToTensor(),灰度图被自动处理成(1, 48, 48);推理时用 OpenCV 读图,cv2.imread默认返回三通道BGR,没转灰度就跟模型定义的第一层Conv2d(1, ...)对不上。
解决:推理入口强制加一行gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY),并且在 transform 里用transforms.Grayscale(num_output_channels=1)统一格式。每次喂给模型前打印一下face_tensor.shape,确认是(1, 1, 48, 48)再继续。
5.3 类别映射错位,识别结果张冠李戴
现象:训练 loss 正常下降,模型在训练集和测试集上的准确率也不难看,但是摄像头预测结果明显不对:把开心识别成愤怒,把平静识别成悲伤。
原因:模型输出的索引和页面展示的标签对应不上。ImageFolder是按文件夹名排序的,如果训练脚本里手动指定了类别顺序,而展示页面用的是另一套顺序,两者之间就错位了。
解决:固定一份映射表,训练、测试、展示全部引用同一个文件。资源里的image_emotion_mapping.py就是这个作用。另外在训练前打印train_dataset.class_to_idx,和推理代码里的列表一一对照,确认再开始。
5.4 深模型在小数据集上过拟合,验证集准确率飘
现象:训练 loss 降到很低,验证 loss 却持续升高,验证集准确率像过山车,最后保存出来的模型在真实图片上一塌糊涂。
原因:fer2013 只有几万张静态图像,类别间相似度高,ResNet 这类深模型参数量大,直接从头训练很容易记住训练样本的细节。
解决:训练时把数据增强打开,随机裁剪、水平翻转、旋转 10 度都加上;同时观察验证 loss,一旦出现连续几个 epoch 不降反升,就把学习率降一个量级或者早停。资源里data_process.py和训练脚本一般带了增强逻辑,复现时确认它被调用而不是被注释掉了。
5.5 加载 pkl 权重报 size mismatch
现象:执行model.load_state_dict(torch.load("model_resnet.pkl"))报错,提示size mismatch for classifier.3.weight之类。
原因:pkl 里保存的是训练好的state_dict,它的形状由训练时的网络结构决定。如果你本地定义的模型输出层是 5 类,而权重是 7 类训练出来的,最后一层权重自然对不上。
解决:先确认num_classes=7,再看模型定义里最后一层全连接输出。如果还不行,把 pkl 加载进来打印state_dict的键和形状,跟当前模型逐项对比:
python -c "import torch; s=torch.load('model_resnet.pkl', map_location='cpu'); [print(k, v.shape) for k,v in s.items()]"这一步能把“模型结构不一致”和“权重文件损坏”快速区分开。权重文件损坏时还会报unexpected key或EOFError,那时候就要考虑重新解压一次资源包。
6. 进阶:把摄像头变成实时表情演示和泛化验证
6.1 用 OpenCV 循环把检测和预测串起来
资源里的example_dsh.mp4是一个现成的视频示例,说明原作者就是按实时视频流来设计演示的。把前面的 Haar 级联、模型推理串到视频循环里,就是一个完整的实时表情 demo,核心代码很短:
import cv2 import torch emotion_map = ["angry", "disgust", "fear", "happy", "sad", "surprise", "neutral"] model = build_resnet(num_classes=7) model.load_state_dict(torch.load("model_resnet.pkl", map_location="cpu")) model.eval() face_cascade = cv2.CascadeClassifier("haarcascade_frontalface_default.xml") cap = cv2.VideoCapture(0) while True: ok, frame = cap.read() if not ok: break gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) faces = face_cascade.detectMultiScale(gray, 1.1, 5, minSize=(96, 96)) for x, y, w, h in faces: roi = cv2.resize(gray[y:y + h, x:x + w], (48, 48)) tensor = torch.from_numpy(roi).float().div(255) tensor = tensor.unsqueeze(0).unsqueeze(0) # (1, 1, 48, 48) with torch.no_grad(): idx = model(tensor).argmax(dim=1).item() cv2.rectangle(frame, (x, y), (x + w, y + h), (0, 255, 0), 2) cv2.putText(frame, emotion_map[idx], (x, y - 8), cv2.FONT_HERSHEY_SIMPLEX, 1.0, (0, 255, 0), 2) cv2.imshow("fer", frame) if cv2.waitKey(1) == 27: break cap.release() cv2.destroyAllWindows()minSize=(96, 96)是为了过滤掉距离太远的小脸,也避免模型对模糊小图做无意义预测。实时场景里缩放、归一化、unsqueeze 三步必须和训练时完全一致,半路改动数值范围会让准确率肉眼可见地下降。按 ESC 退出这习惯保留着,演示时临时要结束循环比较灵活。
6.2 验证模型是真学到了泛化规律还是背了样本
答辩前有一步我建议一定做:拿摄像头分别拍正常表情、夸张表情、戴眼镜、偏头、暗光五组画面,把每组画面跑一遍上面的循环,记录输出的概率分布。如果正常表情稳定,但稍微偏头就被判成另一类,说明模型对姿态敏感,大概率是人脸检测框不准,而不是表情识别的问题。
这种“直接喂真实场景”的验证方式比盯着测试集准确率更有说服力。测试集里的图片本质上和训练集同源,都比真实摄像头画面干净。只要在你的笔记本摄像头前连续识别 30 秒不飘,演示就翻不了车。
从那以后我每拿到一个表情识别项目,都会在复现时先跑一遍完整推理链路,再用摄像头自拍十几秒确认稳定性,最后才去看训练曲线的具体数值。模型权重能不能泛化,摄像头一照就知道。希望这套检查和调试思路也能帮到你。
本文还有配套的精品资源,点击获取