简介:这份资源是面向高校学生与深度学习入门者的面部表情识别毕设项目完整包,基于PyTorch与卷积神经网络实现,覆盖CNN、VGG、ResNet等多种模型架构,可用于课程设计、毕业设计或计算机视觉入门实践。包内共36个文件,包含14个Python源码、5个Jupyter Notebook实验文件、5个docx与1个pdf论文文档、1个pptx答辩演示、1个mp4演示视频,以及数据集、模型权重与Haar级联分类器文件,压缩包约446MB。源码均经本地编译验证可运行,评审分达95分以上,难度适中,并配有部署说明与代码注释,便于快速上手。已有288人学习下载。读者可获得从数据处理、模型训练到测试对比的完整流程代码,以及小组论文、答辩PPT与参考文献,适合直接参考或二次开发,帮助理清项目结构与实验思路。
1. 从一份 zip 说起:面部表情识别系统到底交付了什么
很多人第一次拿到「基于深度学习的面部表情识别系统python源码+部署说明+代码注释+论文」这类压缩包时,第一反应是解压、找main.py、直接python main.py,然后被一堆ModuleNotFoundError和路径错误劝退。我当年做毕设也是这么翻车的。这个标题背后其实是一套完整的工程交付物:一个用卷积神经网络做七类表情分类的 Python 项目,配套环境依赖、推理脚本、训练脚本、注释和一份能写进论文的方法描述。它解决的核心问题是——给定一张人脸图或一段摄像头画面,输出「愤怒/厌恶/恐惧/开心/悲伤/惊讶/中性」的概率分布。适合三类人:正在做深度学习毕设选题的学生、想快速跑通一个 CV 落地 demo 的初学者、以及需要把表情识别嵌进自己系统(比如课堂专注度分析、客服情绪质检)的工程师。下面我按「先跑通、再讲原理、再调参、最后避坑」的顺序,把这条链路拆开讲清楚。
2. 环境与数据:把 zip 跑起来前必须搞定的两件事
2.1 用 conda 建一个不污染全局的 Python 环境
面部表情识别项目通常依赖 PyTorch 或 TensorFlow,加上 OpenCV、NumPy、Pillow、tqdm、scikit-learn 这一串。直接装在系统 Python 里,十有八九会和已有的包打架。我一般用 conda 隔离:
# 创建独立环境,python 版本按项目 requirements 来,常见是 3.8 或 3.9 conda create -n fer python=3.9 -y conda activate fer # 安装深度学习框架,CPU 版先跑通,有显卡再换 cuda 版 pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu # 其余依赖 pip install opencv-python numpy pillow tqdm scikit-learn matplotlib逻辑说明:conda create的-n fer是环境名,随便起但别用中文;python=3.9是因为很多老毕设代码在 3.10+ 上会遇到np.float被移除之类的兼容问题。参数上,--index-url指向 PyTorch 官方 wheel 源,CPU 版体积小、装得快,适合先验证代码能不能跑。如果你有 NVIDIA 显卡,把cpu换成对应的cu118或cu121,具体版本要和你驱动匹配,装错了会报CUDA error: no kernel image is available。
提示:不要一上来就
pip install -r requirements.txt,先打开文件看一眼版本号。很多毕设的 requirements 是作者半年前 freeze 的,里面可能锁死了numpy==1.19,在新 Python 上根本装不上。
2.2 数据集长什么样,标签怎么对齐
表情识别最常用的公开数据集是 FER2013 和 RAF-DB。FER2013 是 48x48 灰度图,七类,训练集约 28709 张,验证集 3589 张,测试集 3589 张。它的目录结构通常是按类别分文件夹:
data/ train/ angry/ disgust/ fear/ happy/ neutral/ sad/ surprise/ test/ angry/ ...代码里读数据一般用torchvision.datasets.ImageFolder,它会自动按文件夹名排序生成类别索引。这里有个血泪经验:ImageFolder的类别顺序是按文件夹名字母序,不是你以为的「愤怒第一」。如果你在推理时手动写了一个classes = ['angry','disgust',...],而训练时用的是ImageFolder.classes,两者顺序不一致,结果就是「开心」被识别成「厌恶」,模型准确率看着挺高但全是错的。
from torchvision import datasets, transforms # 训练集做增强,测试集只做归一化 train_tf = transforms.Compose([ transforms.Grayscale(num_output_channels=1), # FER2013 是灰度图 transforms.RandomHorizontalFlip(), # 表情左右翻转不改变语义 transforms.RandomRotation(10), # 小角度旋转增强鲁棒性 transforms.ToTensor(), transforms.Normalize([0.5], [0.5]) # 单通道均值方差 ]) train_set = datasets.ImageFolder('data/train', transform=train_tf) print(train_set.classes) # 一定要打印出来,记下这个顺序 print(train_set.class_to_idx)逻辑说明:Grayscale(1)把三通道压成单通道,和 FER2013 原始格式对齐;RandomHorizontalFlip对表情是安全的增强,因为左右脸对称;RandomRotation(10)控制在 ±10 度,再大就可能把「惊讶」的嘴型转成「中性」。Normalize([0.5],[0.5])把像素从 [0,1] 映射到 [-1,1],这是很多 CNN 训练脚本的默认假设,换成别的均值方差要同步改模型输入预处理,否则推理时分布对不上,准确率掉得莫名其妙。
注意:
class_to_idx打印出来的映射关系,建议直接写进一个labels.json,推理脚本读这个文件,而不是硬编码。这是避免「训练/推理标签错位」最省事的办法。
3. 模型与训练:CNN 结构怎么选、损失函数怎么设
3.1 一个够用的 CNN 长什么样
毕设级别的表情识别,不需要上 ResNet50 这种大模型。FER2013 只有 48x48,参数量太大反而过拟合。常见做法是堆 4 个卷积块,每块「卷积 + BN + ReLU + 池化」,最后接全连接分类头。下面是我常用的一个结构:
import torch import torch.nn as nn class FERNet(nn.Module): def __init__(self, num_classes=7): super().__init__() def block(in_c, out_c): return nn.Sequential( nn.Conv2d(in_c, out_c, 3, padding=1), nn.BatchNorm2d(out_c), nn.ReLU(inplace=True), nn.MaxPool2d(2) ) self.features = nn.Sequential( block(1, 32), # 48 -> 24 block(32, 64), # 24 -> 12 block(64, 128), # 12 -> 6 block(128, 256) # 6 -> 3 ) self.classifier = nn.Sequential( nn.AdaptiveAvgPool2d(1), # 3x3 -> 1x1,省去手动算尺寸 nn.Flatten(), nn.Dropout(0.5), nn.Linear(256, num_classes) ) def forward(self, x): return self.classifier(self.features(x))逻辑说明:padding=1保证卷积后尺寸不变,尺寸缩减全靠MaxPool2d(2),这样每块输出尺寸清晰可追踪。BatchNorm2d放在卷积后、激活前,能明显加快收敛,毕设训练轮数少的时候尤其重要。AdaptiveAvgPool2d(1)是偷懒但可靠的做法,不管你输入是 48 还是 64,它都能压成 1x1,避免全连接层输入维度写死。Dropout(0.5)是防过拟合的关键,FER2013 数据量不大,不加 dropout 训练集准确率能到 99% 而验证集卡在 60% 出头。
参数上,num_classes=7对应七类表情;如果你只做三分类(正面/中性/负面),改成 3 即可,但最后一层输出维度变了,预训练权重就不能直接加载了。
3.2 训练循环里那几个必调参数
训练脚本的核心就几行,但每行都有讲究:
import torch.optim as optim from torch.utils.data import DataLoader device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = FERNet(num_classes=7).to(device) # 交叉熵损失,表情分类是互斥单标签,用它最合适 criterion = nn.CrossEntropyLoss() # Adam 学习率 1e-3 是稳妥起点 optimizer = optim.Adam(model.parameters(), lr=1e-3, weight_decay=1e-4) train_loader = DataLoader(train_set, batch_size=64, shuffle=True, num_workers=2) for epoch in range(50): model.train() for imgs, labels in train_loader: imgs, labels = imgs.to(device), labels.to(device) optimizer.zero_grad() out = model(imgs) loss = criterion(out, labels) loss.backward() optimizer.step()逻辑说明:CrossEntropyLoss内部已经包含 softmax,所以模型最后一层不要加 softmax,否则等于做了两次,梯度会变小、收敛变慢,这是新手最常见的翻车点之一。lr=1e-3配 Adam 是通用起点,如果 loss 震荡厉害就降到 5e-4;weight_decay=1e-4是 L2 正则,配合 dropout 一起压过拟合。batch_size=64在 8G 显存上跑这个模型绰绰有余,显存小就降到 32。shuffle=True必须开,不然每个 epoch 喂进去的类别顺序固定,BN 的统计量会偏。
提示:如果验证集准确率连续 5 个 epoch 不涨,别硬等,直接
optimizer.param_groups[0]['lr'] *= 0.5手动降学习率,比死磕强。
4. 推理与部署:从单张图到摄像头实时识别
4.1 单张图片推理的最小脚本
训练完保存model.state_dict(),推理时重建结构再加载:
import cv2 import torch import numpy as np from PIL import Image CLASSES = ['angry','disgust','fear','happy','neutral','sad','surprise'] model = FERNet(num_classes=7) model.load_state_dict(torch.load('best.pth', map_location='cpu')) model.eval() def predict(img_path): # 用 PIL 读,保证和训练时的通道顺序一致 img = Image.open(img_path).convert('L').resize((48, 48)) x = np.array(img, dtype=np.float32) / 255.0 x = (x - 0.5) / 0.5 # 和训练 Normalize 对齐 x = torch.from_numpy(x).unsqueeze(0).unsqueeze(0) # NCHW with torch.no_grad(): logits = model(x) prob = torch.softmax(logits, dim=1) idx = prob.argmax(1).item() return CLASSES[idx], prob[0][idx].item()逻辑说明:convert('L')转灰度,resize((48,48))对齐训练尺寸,这两步漏一个都会让输入分布偏移。(x-0.5)/0.5是手动复现Normalize([0.5],[0.5]),如果你训练时用了别的均值方差,这里必须同步改。unsqueeze(0)两次分别加 batch 维和 channel 维,得到[1,1,48,48]。model.eval()不能省,它会让 dropout 和 BN 切到推理模式,否则同一张图两次推理结果都不一样。
4.2 摄像头实时识别:人脸检测 + 表情分类串联
实时场景不能对整帧做表情分类,得先用 Haar 或 DNN 人脸检测器框出人脸,再送进模型:
cap = cv2.VideoCapture(0) face_cascade = cv2.CascadeClassifier( cv2.data.haarcascades + 'haarcascade_frontalface_default.xml') while True: ok, frame = cap.read() if not ok: break gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) faces = face_cascade.detectMultiScale(gray, 1.3, 5) for (x, y, w, h) in faces: roi = gray[y:y+h, x:x+w] roi = cv2.resize(roi, (48, 48)).astype(np.float32) / 255.0 roi = (roi - 0.5) / 0.5 tensor = torch.from_numpy(roi).unsqueeze(0).unsqueeze(0) with torch.no_grad(): prob = torch.softmax(model(tensor), dim=1) label = CLASSES[prob.argmax(1).item()] cv2.rectangle(frame, (x, y), (x+w, y+h), (0, 255, 0), 2) cv2.putText(frame, label, (x, y-10), cv2.FONT_HERSHEY_SIMPLEX, 0.9, (0, 255, 0), 2) cv2.imshow('FER', frame) if cv2.waitKey(1) & 0xFF == ord('q'): break cap.release() cv2.destroyAllWindows()逻辑说明:detectMultiScale(gray, 1.3, 5)里1.3是每次缩放比例,越小检测越慢但越全;5是邻域阈值,越大越严格、误检越少。实时场景建议把这两个值调成1.2, 6平衡速度和准确率。ROI 裁剪后必须重新 resize 到 48x48,因为人脸框大小不固定。整个循环里模型推理是瓶颈,CPU 上大概 10-15 FPS,想更流畅可以每两帧检测一次人脸、中间帧复用框。
注意:Haar 检测器对侧脸和遮挡很敏感,如果做产品级应用,换成
cv2.dnn加载的 SSD 人脸检测器,召回率会高不少,代价是模型文件多几 MB。
5. 避坑与排查:那些让准确率凭空消失的细节
5.1 训练准确率 99%、验证集 60%:过拟合的三种解法
现象:训练 loss 一路降到 0.05,验证集准确率卡在 60% 不动。原因:FER2013 本身标注噪声大,加上模型容量相对数据量偏大。解决:第一,把 dropout 从 0.5 提到 0.6,并在每个卷积块后加Dropout2d(0.2);第二,加数据增强,除了翻转旋转,再加RandomAffine的平移和缩放;第三,用早停,验证集 8 个 epoch 不涨就停,保存验证集最优权重而不是最后一个 epoch。
5.2 推理结果全是同一类:标签顺序和归一化对不上
现象:不管输入什么图,输出永远是「中性」或「开心」。原因:九成是CLASSES列表顺序和训练时ImageFolder.classes不一致,或者推理时忘了做(x-0.5)/0.5归一化。解决:打印训练时的class_to_idx存成 json,推理脚本读它;归一化参数写成一个常量,训练和推理共用同一个变量,别两处各写一遍。
5.3 CUDA out of memory:显存不够的排查顺序
现象:训练到第二个 epoch 报RuntimeError: CUDA out of memory。原因:batch_size 太大、或者验证阶段没加torch.no_grad()导致计算图累积。解决:先把 batch_size 减半;再检查验证循环有没有with torch.no_grad():;还不行就在验证前torch.cuda.empty_cache()。如果都不行,说明模型本身太大,把 256 通道那层砍到 128。
5.4 摄像头画面卡顿:推理和采集抢资源
现象:cv2.VideoCapture读帧正常,但加了模型推理后画面延迟两三秒。原因:模型推理在主线程阻塞了读帧。解决:把推理放到单独线程,主线程只负责读帧和画框,用一个队列传 ROI;或者降分辨率,把摄像头设成 640x480 而不是 1080p,cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640)。
5.5 换数据集后准确率暴跌:输入尺寸和通道没改
现象:在 FER2013 上 65%,换 RAF-DB 后掉到 30%。原因:RAF-DB 是彩色图、尺寸也不一样,代码里Grayscale(1)和resize((48,48))没改。解决:要么统一转灰度 resize 到 48,要么改模型第一层Conv2d(3, 32, ...)接受三通道,并把Normalize改成三通道的均值方差。改完记得重新训练,不能直接加载旧权重。
6. 进阶技巧:把毕设做成能写进简历的样子
跑通七分类只是及格线。想让这个项目在毕设答辩或面试里站得住,我一般会加两件事。第一件是置信度阈值 + 未知类拒识:softmax 最大概率低于 0.6 时输出「不确定」,而不是硬塞一个类别。这在真实场景里非常实用,因为人脸检测框可能框到非人脸区域。实现上就是在推理后加一个判断:
prob = torch.softmax(logits, dim=1) conf, idx = prob.max(1) if conf.item() < 0.6: label = 'uncertain' else: label = CLASSES[idx.item()]第二件是用 Grad-CAM 可视化模型到底在看哪里。答辩时老师最爱问「你怎么知道模型学的是表情而不是背景」,一张热力图比十句话管用。做法是 hook 住最后一个卷积层的输出,对目标类别的梯度做全局平均池化,叠加回原图。代码不长,但能直观看出模型关注的是眉毛、嘴角这些区域,而不是头发或衣领。
再往上走,可以把模型导出成 ONNX,用onnxruntime推理,速度比 PyTorch 原生快 20%-30%,也方便嵌到 C++ 或 Java 系统里。导出命令是torch.onnx.export(model, dummy_input, 'fer.onnx', input_names=['input'], output_names=['output']),注意dummy_input的 shape 要和实际推理一致,动态 batch 要显式指定dynamic_axes。
我自己的习惯是:每改一次预处理或模型结构,先拿 10 张固定图片跑一遍,把输出概率打印出来存档,下次改动后对比。这样一旦准确率掉了,能立刻定位是数据问题还是模型问题,比盲目重训省太多时间。表情识别这个方向,模型结构早就不是瓶颈,真正拉开差距的是数据清洗、标签对齐和推理链路的工程细节。希望帮到你。
本文还有配套的精品资源,点击获取