
简介这是一份基于深度学习的人脸表情识别完整项目含Python源码、预训练模型权重及GUI界面面向计算机、人工智能、数据科学等专业学生适用于毕业设计、课程设计、期末大作业及二次开发场景。系统通过摄像头实时采集人脸图像利用模型完成表情分类可识别平静、开心、惊讶、悲伤、生气等基本情绪并在界面上以趣味表情图标同步展示结果。资源包共43个文件约40MB以Python源码.py和表情素材.png为主另含UI界面文件.ui、模型权重.h5、人脸检测配置.xml、项目说明及依赖清单等目录结构清晰便于按模块查阅。目前已有463人学习浏览该资源。项目功能经验证稳定可靠源码模块划分明确涵盖主程序、摄像头线程处理、界面布局等关键部分可直接作为毕设或课程设计演示也便于扩展算法与功能适合深度学习视觉入门实践。1. 人脸表情识别毕设系统难点不在算法在于从数据集到 GUI 的一致性拿到“基于深度学习实现的人脸表情识别系统 python 源码模型GUI 界面”这套东西先别急着调包。表情识别跟人脸识别是两码事人脸识别回答“你是谁”表情识别回答“你现在什么情绪”同类目之间的差异极小最容易出现训练集 90 分、测试集 60 分的翻车场景。这个标题背后是一条完整链路数据清洗、模型训练、权重导出、GUI 界面推理。适合谁适合预算有限、电脑没有顶配 GPU、还要在答辩现场做实时演示的人。跑通代码只是第一步真正决定成绩的是你能否让训练好的模型在 GUI 里重新端起来之后准确率依然不掉。2. 数据与预处理表情数据集怎么选、怎么读、怎么把脏数据洗干净表情识别常用公开数据集就那几个FER2013、RAF-DB、CK。毕设从零做绝大多数人拿 FER2013 打底因为它只有一个 CSV 文件48×48 灰度图不用解压一堆图片入门成本最低。RAF-DB 是真实场景表情样本质量高但下载和整理繁琐CK 是实验室摆拍序列数量少但干净适合做最终验证。我的建议是主线用 FER2013答辩时用 CK 录一段视频做补充验证比只用单个数据集更有说服力。2.1 数据集选型FER2013 做底RAF-DB 提上限先看三个候选集的实际情况再做决定。数据集样本量图像尺寸场景主要问题FER2013约 3500048×48 灰度网络收集头部姿态杂标签噪声明显部分样本是错误标注RAF-DB约 30000100×100 左右真实场景类别不均衡部分类别样本极少CK约 593 个序列640×490 灰度实验室摆拍数据量小人物固定泛化性有限FER2013 的优点是 CSV 里直接带 Usage 列官方已经划好 Training / PublicTest / PrivateTest省去自己划分的麻烦。缺点是“脏”得明显有些图人脸裁得不完整有些标注跟表情明显对不上。做毕设不要追求洗得完美但至少要过滤掉像素值全为零的空图以及人脸区域占比过小的样本。我一般会先读 CSV按 Usage 列拆好三个集合再做一次可视化随机抽 100 张训练图拼成网格看一遍。这一步花十分钟能让你对数据脏到什么程度有直观概念比直接开训省下几个小时排错时间。2.2 PyTorch Dataset 写法读取、归一化和数据增强一步到位FER2013 的像素列是字符串空格分隔长度 2304对应 48×48。写 Dataset 时把“读字符串→转数组→reshape→增强”封装在一个类里训练和测试共用只通过 train 参数控制是否做随机增强。import torch import pandas as pd import numpy as np from PIL import Image from torch.utils.data import Dataset import torchvision.transforms as T class FERDataset(Dataset): def __init__(self, csv_path, splitTraining): df pd.read_csv(csv_path) self.df df[df[Usage] split].reset_index(dropTrue) # FER2013 原始标签0Angry 1Disgust 2Fear 3Happy # 4Sad 5Surprise 6Neutral self.train (split Training) if self.train: self.transform T.Compose([ T.RandomResizedCrop(48, scale(0.8, 1.0)), T.RandomHorizontalFlip(p0.5), T.ToTensor(), T.Normalize(mean[0.5], std[0.5]) ]) else: self.transform T.Compose([ T.ToTensor(), T.Normalize(mean[0.5], std[0.5]) ]) def __len__(self): return len(self.df) def __getitem__(self, idx): row self.df.iloc[idx] pixels np.array( [int(x) for x in row[pixels].split()], dtypenp.uint8 ).reshape(48, 48) img Image.fromarray(pixels, modeL) label int(row[emotion]) return self.transform(img), label这个类有两个关键点。第一是RandomResizedCrop(48, scale(0.8, 1.0))它会在 48×48 内部随机裁剪出一块再放大回 48模拟人脸轻微位移和尺度变化代价是可能裁掉部分五官但这种扰动对表情识别是有益的。第二是测试分支绝对不做RandomResizedCrop和HorizontalFlip否则同一张图多次推理结果会抖动后期接 GUI 时会很难排查。Normalize(mean[0.5], std[0.5])是灰度图最常用的归一化把像素值从 [0,1] 映射到 [-1,1]。这里要注意训练和推理必须共用同一组 mean/std很多毕设翻车就是因为在 GUI 里自己写了一套img / 255没有做标准化导致推理结果一片混乱。2.3 训练集与测试集要同分布打乱前先想清楚划分规则FER2013 官方已经划分好多数人直接拿来用没问题。如果是自己收集的人脸数据千万别用random_split对整库乱切。同一个人不同照片被同时分进训练集和测试集模型会退化成人脸识别而不是表情识别测试成绩虚高答辩现场换一个人立刻现原形。正确做法是“按人分组”划分。先给每个人一个唯一 ID聚类出所有相同 ID 的图片再按组切分保证同一个人的脸只出现在一个集合里。人少时可以用 8:1:1 的比例切训练、验证、测试人多了调成 9:0.5:0.5 也无妨。如果遇到某个表情类别样本特别少比如 FER2013 里的 Disgust 只有几百张可以考虑在训练时用WeightedRandomSampler给少数类提高采样概率而不是简单复制图片做重复样本。复制图片只是让模型背下来换张真实照片仍然会挂。后面第三章训练脚本里我会给出具体接法。3. 模型训练轻量 CNN 而不是大模型CPU 也能跑的调参节奏表情识别输入只有 48×48类别只有 7 类根本不需要上 ResNet50 或 ViT。模型越大训练越慢过拟合越快打包后体积越大答辩机器还带不动。常见做法是自建 4 层卷积网络或者 MobileNetV2。如果你电脑有独显可以试试 ResNet18没有独显就老老实实用轻量网络CPU 也能半小时内训完一个 epoch。3.1 模型结构怎么搭先花半小时选主干网络自建 CNN 的优点是可控性强答辩时能清晰讲出每一层的作用。我会用一个四层卷积主干每个卷积块都带 BN 和 ReLU。import torch.nn as nn class SimpleEmotionCNN(nn.Module): def __init__(self, num_classes7): super().__init__() self.features nn.Sequential( nn.Conv2d(1, 64, kernel_size3, padding1), nn.BatchNorm2d(64), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), nn.Conv2d(64, 128, kernel_size3, padding1), nn.BatchNorm2d(128), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), nn.Conv2d(128, 256, kernel_size3, padding1), nn.BatchNorm2d(256), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), ) self.classifier nn.Sequential( nn.AdaptiveAvgPool2d(1), nn.Flatten(), nn.Dropout(0.5), nn.Linear(256, num_classes) ) def forward(self, x): return self.classifier(self.features(x))输入是 1 通道灰度图经过三次 MaxPool 后空间尺寸从 48 变成 6最后用AdaptiveAvgPool2d(1)把特征压成向量这一步能保证输入尺寸即使不是 48网络也不会报错。Dropout 0.5 放在全连接前是防止小数据量下过拟合的关键。如果你的机器实在拉胯可以把第一层卷积从 64 改成 32参数量直接减半准确率通常只掉 12 个点训练速度快一截。这个取舍在毕设场景很划算。3.2 训练脚本与关键参数batch、学习率与 EarlyStopping 的取舍训练脚本是最容易照抄出错的部分。这里给一个完整可跑的训练循环包含验证和早停。import torch import torch.nn as nn from torch.utils.data import DataLoader, WeightedRandomSampler from torch.utils.tensorboard import SummaryWriter model SimpleEmotionCNN() dataset FERDataset(fer2013.csv, splitTraining) valid_dataset FERDataset(fer2013.csv, splitPublicTest) # 给少数类更高采样概率缓解类别不均衡 labels [row[emotion] for _, row in dataset.df.iterrows()] class_counts torch.bincount(torch.tensor(labels), minlength7).float() weights 1.0 / class_counts[labels] sampler WeightedRandomSampler(weights, num_sampleslen(weights), replacementTrue) train_loader DataLoader(dataset, batch_size64, samplersampler, num_workers2) valid_loader DataLoader(valid_dataset, batch_size128, num_workers2) optimizer torch.optim.AdamW(model.parameters(), lr1e-3, weight_decay1e-4) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max30) criterion nn.CrossEntropyLoss() best_acc 0.0 patience 0 for epoch in range(30): model.train() for images, targets in train_loader: optimizer.zero_grad() outputs model(images) loss criterion(outputs, targets) loss.backward() optimizer.step() model.eval() correct, total 0, 0 with torch.no_grad(): for images, targets in valid_loader: preds model(images).argmax(dim1) correct (preds targets).sum().item() total targets.size(0) acc correct / total print(fepoch {epoch1} acc {acc:.4f}) if acc best_acc: best_acc acc patience 0 torch.save({ state_dict: model.state_dict(), label_map: [Angry, Disgust, Fear, Happy, Sad, Surprise, Neutral], }, emotion_model.pt) else: patience 1 if patience 5: break scheduler.step()这里的WeightedRandomSampler解决了 Disgust、Fear 这类样本少的问题代价是每个 epoch 少数类会被重复采样训练时间略增。AdamW配weight_decay1e-4是当前框架下的常见组合比裸 Adam 更容易收敛。保存 checkpoint 时我用了一个 dict把权重和标签映射一起保存。这个习惯很重要因为 GUI 推理时如果不知道索引对应的表情名预测结果毫无意义。很多人只存model.state_dict()回头就忘了标签顺序。3.3 训练过程看什么loss 曲线和混淆矩阵比准确率重要准确率只是最终结论训练过程真正要看的是验证准确率随 epoch 的波动和混淆矩阵。每次验证时顺便生成分类报告from sklearn.metrics import classification_report, confusion_matrix # 验证阶段收集所有预测和真实标签后 report classification_report(all_targets, all_preds, target_names[Angry, Disgust, Fear, Happy, Sad, Surprise, Neutral]) print(report)看分类报告时重点看每类别的召回率而不是只看整体准确率。FER2013 上 Happy 和 Neutral 通常很高Disgust 和 Fear 偏低这是数据本身的问题不是模型“变笨”。如果 Happy 的召回率长期在 95% 上下而 Fear 只有 40%加再多模型层数也没用回数据层去补样本才是正路。loss 曲线不要只看训练 loss 一路下降就觉得万事大吉。训练 loss 降到 0.3 以下、验证 loss 反而抬头这就是过拟合信号EarlyStopping 会自动停在第 5 个连续不升点停止后再手动降低学习率继续训练意义不大不如直接去调数据增强强度。4. 从模型到 GUI推理链路与界面线程的完整拼接模型训好后最难的环节来了把权重塞进 GUI。这一步的问题不在深度学习而在工程习惯。训练时你用的是 PIL 读图推理时你用 OpenCV 读图两者通道顺序、归一化方式不同模型表现就可能从 85% 掉到 50%。我一般会专门写一个predict.py模块GUI 只调用模块接口不做任何图像处理。4.1 固定推理预处理训练怎么归一化推理就怎么归一化推理预处理必须和训练测试分支保持一致。OpenCV 读出来是 BGR 三通道需要先转灰度再缩放再转成 PyTorch 张量。import cv2 import numpy as np import torch import torchvision.transforms as T transform T.Compose([ T.ToTensor(), T.Normalize(mean[0.5], std[0.5]) ]) def preprocess(frame): gray cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) # 直接用 INTER_AREA 做缩小保留更多表情细节 resized cv2.resize(gray, (48, 48), interpolationcv2.INTER_AREA) # 转成 float32 再进 transform避免 uint8 被 ToTensor 重复归一化 tensor_img torch.from_numpy(resized.astype(np.float32) / 255.0).unsqueeze(0) tensor_img transform(tensor_img).unsqueeze(0) return tensor_img这里我把灰度图先除以 255再交给ToTensor等价于训练时的img / 255只是顺序上提前了。注意INTER_AREA对缩小图像更友好不会像INTER_LINEAR那样产生明显锯齿表情边缘保留得更好。to(device)这一步放到 GUI 初始化时只做一次不要在每次推理时调用。模型参数一直留在 GPU 或 CPU 上不动推理速度能快不少。4.2 摄像头实时识别处理帧率、缓冲和画面抖动接摄像头后最常见的体验是画质不差但画面卡或者表情标签在几个类别之间疯狂跳动。前者是每帧都推理后者是单帧误判。常见做法是跳帧推理加指数平滑。cap cv2.VideoCapture(0) frame_count 0 ema_result None alpha 0.6 while True: ok, frame cap.read() if not ok: break frame_count 1 if frame_count % 3 ! 0: continue tensor preprocess(frame) with torch.no_grad(): probs torch.softmax(model(tensor), dim1).flatten() label_idx probs.argmax().item() confidence probs[label_idx].item() label_name label_map[label_idx] if ema_result is None: ema_result probs else: # 用指数移动平均压住单帧误判 ema_result alpha * ema_result (1 - alpha) * probs label_idx ema_result.argmax().item() confidence ema_result[label_idx].item()跳帧参数设成 3表示每 3 帧推理一次对 30fps 摄像头来说每秒推理 10 次人眼感知不到延迟。alpha0.6时旧概率占六成新概率占四成单个误判帧很难把结果带偏。如果希望反应更灵敏可以把alpha降到 0.4但画面会开始闪烁。4.3 GUI 用线程封装推理别让界面卡成“未响应”tkinter 是 Python 自带的 GUI 库毕设用最合适不需要额外安装。但 tkinter 的事件循环是单线程的你要是直接在按钮回调里读摄像头、跑推理界面立刻卡死。标准做法是推理放子线程结果通过queue.Queue传回主线程再用after()周期更新标签。import queue import threading import tkinter as tk class EmotionApp: def __init__(self): self.result_queue queue.Queue() self.running True self.root tk.Tk() self.root.title(人脸表情识别) self.label tk.Label(self.root, text等待摄像头...) self.label.pack() tk.Button(self.root, text开始识别, commandself.start).pack() threading.Thread(targetself.worker, daemonTrue).start() self.poll() self.root.mainloop() def worker(self): cap cv2.VideoCapture(0) while self.running: ok, frame cap.read() if ok: self.result_queue.put(self.predict(frame)) cap.release() def predict(self, frame): tensor preprocess(frame) with torch.no_grad(): probs torch.softmax(model(tensor), dim1).flatten() idx probs.argmax().item() return f{label_map[idx]} {probs[idx].item():.2f} def poll(self): try: while True: msg self.result_queue.get_nowait() self.label.config(textmsg) except queue.Empty: pass self.root.after(100, self.poll) def start(self): self.running Trueworker里的死循环放到daemonTrue的线程中程序关闭时线程自动结束不会残留僵尸进程。poll每 100 毫秒从队列里取一次结果界面刷新足够顺畅也避免了多线程直接操纵 tkinter 控件的冲突问题。代码里predict方法没有处理 GPU 设备号如果用的是 GPU 推理记得在主线程初始化时把模型先放到 GPU 上子线程里直接调用即可。5. 毕设落地避坑指南5 个最容易翻车的问题与排查下面是这个方向里我踩过、也见别人踩过的高频坑。每条都按现象、原因、解决三步给出来做毕设前先过一遍能省出至少半天排错时间。5.1 训练集 90% 测试集 60%先检查数据划分现象训练收敛得很好验证集准确率很高一测测试集直接掉下来。原因大概率是同类数据在不同集合里重叠或者验证集和测试集分得不干净。FER2013 官方已经分好但很多人自己采集数据时会犯这个错。解决如果是自己采集的数据按人分组划分确保同一人的脸不同时出现在训练和测试。如果用的 FER2013 还出现这个现象检查是不是在读取时把 Usage 列过滤错了比如把 PublicTest 和 PrivateTest 混到了一起。我见过有人直接把整个 CSV 按行随机切 8:2等于把官方划分全部打乱结果测试集里全是训练脸。5.2 模型加载成功但 GUI 直接“未响应”现象点击“开始识别”按钮后窗口标题栏出现“未响应”几秒后系统弹出强制关闭提示。原因摄像头读取和模型推理全在主线程里执行堵住了 tkinter 的事件循环鼠标点击消息没有线程去处理系统就判定程序卡死。解决把摄像头循环放进threading.Thread结果通过queue传回主线程主线程只负责每隔 100 毫秒读队列更新标签。如果对线程不熟还有一个退路用canvas.after做定时器每 100 毫秒让 GUI 主动处理一次事件但跳帧和摄像头读取仍然建议放线程里。5.3 侧脸、遮挡、大角度转头就识别失败现象正脸识别正常稍微侧头或戴上口罩输出就乱跳表情变成随机乱猜。原因训练数据里正脸占多数数据增强里又没有包含角度和遮挡扰动模型学到的只是一张正脸长什么样而不是“嘴在往下撇就是 Sad”这类本质特征。解决在训练增强里加T.RandomRotation(degrees15)和T.RandomErasing(p0.3)。前者模拟头部小幅转动后者模拟遮挡。加了这两项后PublicTest 准确率可能短时间内略有下降但真实场景的鲁棒性会明显提升。注意RandomRotation角度别超过 20 度否则会把眼睛鼻子扭到不正常的位置模型反而学会歪脸。5.4 checkpoint 文件过大、依赖库版本冲突带崩答辩演示现象在自己电脑跑得好好的拷到答辩教室的机器上一加载模型就报错不是缺 torch 就是缺 numpy 版本。原因训练环境装了最新 PyTorch答辩机器装的是旧版本state_dict权重格式不兼容或者整个环境里有多个 Python 版本pip 装到了别的解释器上。解决保存权重时只存model.state_dict()和label_map不要用torch.save(model)这种保存完整模型的方式后者对 Python 版本和类定义顺序极其敏感。额外做一个 ONNX 导出做备胎答辩前在别人机器上验证一次。ONNX Runtime 安装体积小CPU 推理速度快即使没装 PyTorch 也能跑起来是缓解环境冲突最有效的后手。5.5 验证集当测试集用评估结果失真还不自知现象论文里的准确率写 88%答辩时现场演示一塌糊涂面试老师随口问一个简单样本都能答错。原因把验证集的结果当成了测试集结果或者验证集参与过权重选择评估数字被“看过答案”污染了。很多人为了省事选了表现最好的 epoch 之后又拿同一批验证集去算最终准确率这个数字天然偏高。解决严格区分验证集和测试集。训练阶段只能在验证集上调参模型定稿后一次性拿从未参与训练的测试集跑最终评估。如果做了早停早停依据的验证集成绩只是中间过程不能写进论文。养成这个纪律答辩时心里才踏实。6. 答辩前值得做的三件事验证置信度、可视化特征、打包单文件拿到一个能跑的模型后还有三件事能让答辩从“能运行”变成“有深度”。第一给 GUI 加置信度提示。把 softmax 输出的概率最大值显示在界面里低于 0.6 时就显示“低置信度请靠近摄像头”。这个功能不复杂但能体现你理解模型的不确定性不是只会调库。第二用 Grad-CAM 可视化模型关注区域。在最后一层卷积上注册 forward hook取目标类别的梯度对特征图加权求和得到热力图叠加在输入图像上。Happy 样本的热力图集中在嘴角和眼睛说明模型学到了正确的表情线索如果热力图集中在背景或头发说明你的模型在背样本答辩时这反而是加分点因为你能主动说出问题在哪。第三打包成单文件。PyInstaller 打包 tkinter 程序基本零配置pyinstaller --onefile --windowed --add-data emotion_model.pt:. gui_main.py--windowed隐藏命令行黑框--add-data把权重包进去。打包后务必在另一台没有 Python 的机器上跑一遍只在自己电脑上成功不算数。我当年做类似系统时吃过一个亏模型在验证集上非常好打包成 exe 后才发现 OpenCV 视频流在部分笔记本上打不开最后在视频读取失败时加了一张默认占位图才稳住演示。提前想一步“如果摄像头坏了怎么办”比追求准确率多两个点更值。希望这些经验帮你在答辩时少踩两个坑。本文还有配套的精品资源点击获取