
简介这套基于深度学习的人脸表情识别系统以Python为核心实现集成训练好的模型与图形化交互界面面向计算机相关专业的毕设、课程设计及期末大作业场景。资源共43个文件压缩包约40MB涵盖Python源码、H5权重模型、UI界面文件、XML人脸检测配置以及多类表情样本图片从模型加载到实时识别均有完整代码支撑便于直接运行与二次开发。目前已有463人学习项目结构清晰包含主程序、相机线程、界面设计与需求说明等模块并附项目说明文档适合入门进阶或作为初期项目立项演示。借助预训练权重与GUI使用者可快速体验人脸表情识别流程也能在此基础上拓展新的算法或应用场景对理解深度学习落地与界面开发都有实际参考价值。1. 人脸表情识别系统打开压缩包之前先搞清楚你要交付什么如果你正在为毕设选题发愁大概率搜到过这种资源包深度学习、人脸表情识别、Python、源码、模型、GUI界面。这类项目最容易犯的错是下载后急着跑训练日志、盯着 loss 曲线却忘了真正拿去答辩的是一个“能当场演示”的系统而不是一张准确率截图。一个能落地的人脸表情识别系统至少要打通摄像头取帧、人脸检测、表情分类、界面显示这条完整链路。这篇笔记把链路拆成模型选型、数据与训练、GUI 线程、常见翻车现场四段来讲新手可以照着抄手上已有源码但想改造成自己项目的同学也能用同一套思路去拆。2. 先定模型与数据集表情识别的标签空间和选型逻辑人脸表情识别是一个 7 分类图像分类问题输入一张人脸区域图片输出一个表情标签。难点从来不在模型结构本身而在数据预处理、类别分布和工程链路的一致性。所以开篇先不写代码把几个决定成败的选型问题讲清楚。2.1 表情识别在学什么7 类标签、数据分布与“样本少”的现实最常用的公开数据集是 FER2013每张图都是 48×48 的灰度人脸标签分成 7 类anger、disgust、fear、happy、sad、surprise、neutral。它体积小、加载快、论文引用多因此成了毕设项目的默认选项。但 FER2013 有个非常现实的问题——类别分布严重不均happy、neutral 样本多disgust、fear 样本少。很多在测试集上准确率还不错的模型细看混淆矩阵fear 和 disgust 这两类几乎全是错的只是被整体准确率掩盖过去了。如果你手上的压缩包里就是 FER2013请先做一步统计用脚本统计训练目录下每个类别的图片数量把结果贴在你的设计文档里答辩时这是个很好的“发现问题”素材。如果数据源是摄像头自己拍的人脸数量通常只有几百张那就更依赖数据增强和迁移学习了这部分放到 2.3 展开。另外标签是中文字符还是英文字符并不重要重要的是训练、验证、GUI 推理三处的标签顺序必须完全一致这个坑我放在第 5 章专门说。2.2 为什么不从零搭 CNN迁移学习是毕设项目的默认选项新手最容易踩的误区是想自己写一个 CNN 从零训练。表情数据集通常只有两三万张还是灰度图堆一个 VGG 级别的网络进去结果是训练集 loss 很低验证集准确率上不去也就是典型的过拟合。常见做法是用 ImageNet 预训练的 ResNet18 或 MobileNetV2把最后一层全连接替换成 Dropout 加 7 分类的全连接然后整网微调。预训练权重是在大规模自然图像上学到的虽然 ImageNet 里没有人脸表情这个任务但它学到的边缘、纹理、形状特征对你这个任务来说已经是很好的起点。我一般会这样选如果答辩现场用笔记本 CPU 演示优先 MobileNetV2速度更快、打包体积更小如果训练时有 GPU、演示也可以用 GPU就选 ResNet18准确率通常更稳。自建小型 CNN 不是一个推荐选项除非数据量特别小且你明确想展示“自己搭网络”的过程。下面的表可以作为选型的参考模型CPU 推理表现训练成本适用场景MobileNetV2快低笔记本 CPU 演示、打包体积有要求ResNet18较快低有 GPU 或追求更稳效果自建小型 CNN快中想展示网络结构但调参玄学成分大迁移学习并不是“加载预训练权重就行”这么简单。加载后有两种做法一种是把骨干网络冻结、只训练新的分类头适合数据很少的情况另一种是全网络以较小的学习率微调效果一般更好但要注意早停否则照样过拟合。这部分参数怎么设下一步讲。2.3 训练参数的起手式学习率、batch、dropout 与早停为了不让“调参”变成玄学我习惯给项目固定一套起手参数在此基础上再做小范围调整。对 FER2013 ResNet18 这种组合比较稳的参数大致如下参数起手值说明输入尺寸224×224与预训练模型输入对齐原图 48×48 也要 resize优化器AdamW权重衰减设 1e-4比 SGD 更省心学习率1e-3微调用 3e-4先跑 5 个 epoch 看 loss 再决定batch_size32 或 64显存不够就降别偷偷改学习率epochs30~50主要靠早停而不是固定 epoch 数dropout0.3~0.5加在分类头缓解过拟合早停验证集 loss 连续 10 个 epoch 不降保存验证 loss 最低的那份权重数据增强也要跟着表情任务的语义走。水平翻转、随机裁剪、轻微平移是安全的但不要做上下翻转也不要做太大的随机旋转比如 15 度以上会把“情绪姿态”扰动得不自然模型学到的特征也会偏。另一个容易被忽略的是灰度与彩色的一致性FER2013 是灰度图但预训练模型默认吃三通道常见做法是在读取时把单通道复制成三通道或者干脆转成 RGB 后再做归一化。推理阶段必须用同一套预处理否则识别结果会莫名其妙地变差。3. 把训练流水线跑通目录、环境和 Python 脚本模型选型定了剩下的就是把代码跑通。这部分我按一个最小可行的工程结构来讲每一段代码都是可以直接复制到项目里的骨架不要直接硬套压缩包里的文件而是把它们移植到这个结构里。3.1 先定目录结构源码、数据、权重分开放拿到“源码模型GUI”这类压缩包第一件事不是双击运行而是把里面所有文件按照 源码、数据、权重、临时输出 四个区域重新归位。我一般会整理成下面这样fer/ ├── data/ │ └── fer/ │ ├── train/ │ │ ├── 0_angry/ │ │ ├── 1_disgust/ │ │ └── ... │ └── val/ ├── checkpoints/ │ ├── fer_resnet18.pt │ └── classes.txt ├── gui/ │ ├── main_window.py │ └── recognizer.py ├── model.py ├── train.py └── csv_to_images.py这样拆的好处很明显训练脚本只认data/fer和checkpoints两个目录GUI 脚本只认checkpoints、gui和model.py跑错了能立刻定位是哪一段的问题。真实项目里很多“代码能跑但效果不对”的诡异故障都是因为目录结构混乱导致的路径和标签问题。如果压缩包里的模型是.pth、.pt或.onnx先看清楚后缀后面加载方式完全不同。3.2 环境安装Python 版本、PyTorch 和两个坑环境安装是看起来简单、翻车率最高的环节。先创建一个干净的虚拟环境Python 版本选 3.9 或 3.10 比较稳太新的 Python 版本有时候会遇到 torch 轮子还没同步的问题conda create -n fer python3.9 -y conda activate fer pip install torch torchvision pip install opencv-python pyqt5 pandas numpy注意一个常见坑torch和torchvision的版本必须匹配直接pip install torch torchvision会自动解析但如果你的环境里先装了一个旧版本后面升级时容易把版本拆散所以最好在全新环境里装。第二个坑是 PyQt5 和 OpenCV 在同一台 Windows 机器上可能因为 Qt 动态链接库冲突出现import cv2或import PyQt5其中一个崩溃。常见解法是如果你的 GUI 内嵌图像显示根本不调用cv2.imshow就装opencv-python-headless它不绑定 Qt能和 PyQt5 和平共处。如果保留了 cv2 的显示窗口功能就装完整版opencv-python并在入口脚本最前面先import cv2再import PyQt5。还有一个小技巧模型训练时可以顺便打印一下torch.__version__和torchvision.__version__把这个版本号写进 README。后续换机器复现时很多莫名其妙的 tensor shape 报错都能从版本不一致里找到答案。3.3 训练脚本从数据加载到 checkpoint 保存如果数据集是 FER2013.csv 这种单文件格式先用下面这段脚本把它转成文件夹格式后面所有 PyTorch 数据加载代码都好写import os import cv2 import numpy as np import pandas as pd df pd.read_csv(data/fer2013/fer2013.csv) for idx, row in df.iterrows(): pixels np.array(row[pixels].split(), dtypenp.uint8).reshape(48, 48) usage row[Usage] # Training / PublicTest / PrivateTest label row[emotion] folder os.path.join(data/fer, train if usage Training else val, str(label)) os.makedirs(folder, exist_okTrue) cv2.imwrite(os.path.join(folder, f{idx:06d}.png), pixels)这段代码的逻辑是从 CSV 的pixels列还原 48×48 灰度图再按emotion数值建子目录。这里建议用0~6数字做目录名而不是直接写英文单词这样能避免 ImageFolder 按字母序自动排序时把angry、disgust、fear、happy、neutral、sad、surprise排成一套而 FER2013 原本的 emotion 编号是另一套两套顺序一旦错位模型学到的标签就全乱了。接下来是训练主脚本的骨架import argparse import os import torch import torch.nn as nn from torch.optim import AdamW from torch.utils.data import DataLoader from torchvision import datasets, models, transforms def build_model(num_classes7): weights models.ResNet18_Weights.DEFAULT model models.resnet18(weightsweights) in_features model.fc.in_features model.fc nn.Sequential( nn.Dropout(0.3), nn.Linear(in_features, num_classes) ) return model parser argparse.ArgumentParser() parser.add_argument(--data, defaultdata/fer) parser.add_argument(--epochs, typeint, default30) parser.add_argument(--batch-size, typeint, default64) parser.add_argument(--lr, typefloat, default3e-4) parser.add_argument(--ckpt, defaultcheckpoints/fer_resnet18.pt) args parser.parse_args() train_tf transforms.Compose([ transforms.Resize((224, 224)), transforms.RandomHorizontalFlip(), transforms.RandomCrop(220), transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]) ]) val_tf transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]) ]) train_ds datasets.ImageFolder(os.path.join(args.data, train), train_tf) val_ds datasets.ImageFolder(os.path.join(args.data, val), val_tf) train_loader DataLoader(train_ds, batch_sizeargs.batch_size, shuffleTrue, num_workers2) val_loader DataLoader(val_ds, batch_sizeargs.batch_size, shuffleFalse) model build_model() criterion nn.CrossEntropyLoss() optimizer AdamW(model.parameters(), lrargs.lr, weight_decay1e-4) best_loss float(inf) os.makedirs(os.path.dirname(args.ckpt), exist_okTrue) with open(os.path.join(os.path.dirname(args.ckpt), classes.txt), w) as f: f.write(\n.join(train_ds.classes)) for epoch in range(args.epochs): model.train() for x, y in train_loader: loss criterion(model(x), y) optimizer.zero_grad() loss.backward() optimizer.step() model.eval() val_loss, correct, total 0.0, 0, 0 with torch.no_grad(): for x, y in val_loader: out model(x) val_loss criterion(out, y).item() * x.size(0) correct (out.argmax(1) y).sum().item() total y.size(0) val_loss / total if val_loss best_loss: best_loss val_loss torch.save(model.state_dict(), args.ckpt) print(fepoch {epoch1}: saved, val_acc{(correct/total):.3f})这段代码有四个值得说的点。第一train_ds.classes是在数据加载时就确定好的类别顺序把它以纯文本形式保存到classes.txt后续 GUI 推理时直接读这个文件能保证训练和推理的标签顺序严格一致。第二RandomCrop(220)是从 224 的图上随机裁出 220×220相当于在保持尺寸的前提下加了平移扰动这是表情识别里比较安全的增强方式。第三early stopping 在这里是简化为“验证 loss 最低则保存”如果你想要更严格的早停就记录连续 10 个 epoch 没有突破best_loss的计数器然后 break。第四脚本里没有写学习率调度如果你想加常见的做法是每 10 个 epoch 把学习率降到原来的 0.1训练量不大的时候效果区别不大。跑完训练后checkpoints目录下会有两个文件.pt权重和classes.txt。这两个文件是“一对”的任何单独复制其中一个到别处都会留下标签顺序或权重结构不一致的隐患。如果你拿到的压缩包里已经带了一个预训练好的模型也请先看看同目录下有没有对应的标签文件没有的话就打开源码找LABELS [...]这样的定义把顺序抄下来放进classes.txt。4. GUI 界面摄像头、模型推理与 UI 线程的正确拆法模型训练完剩下的大头是把模型接进 GUI。很多毕设项目死在这一步不是因为模型差而是因为界面一启动就卡死或者一开摄像头就白屏。这一章把 GUI 的结构拆开讲清楚。4.1 界面选型为什么我推荐 PyQt5 而不是 TkinterGUI 框架上常见的两个选择是 Tkinter 和 PyQt5。Tkinter 是 Python 自带的依赖少、代码短但对“摄像头画面 实时标签 历史记录”这种组合来说布局和控件都比较原始想画一个好看的面板要费不少劲。PyQt5 的功能完整很多有 QThread、信号槽机制支持用 QSS 改样式放视频帧用的 QLabel 也可以直接 setPixmap。对毕设来说界面观感是答辩演示的加分项我更推荐 PyQt5。界面方案线程支持界面观感坑Tkinter弱需自己写 after 轮询朴素摄像头和按钮并发容易卡PyQt5QThread 信号槽成熟可定制与 OpenCV 的 Qt 库有潜在冲突PySide6同 PyQt5同 PyQt5API 基本一致协议更宽松PyQt5 的开源协议是 GPL对毕设来说没有问题如果你打算以后闭源商用这套代码就把from PyQt5改成from PySide6大部分代码不用动。另外注意安装的是PyQt5而不是pyqt5-tools后者主要是设计师工具不是必装项。4.2 摄像头推理不能放在主线程一个最小 QThread 骨架摄像头读取是持续的 IO 操作模型推理是持续的 CPU/GPU 计算这两个都不能放在界面主线程里跑否则鼠标挪一下窗口都要卡半秒。PyQt5 的成熟做法是开一个 QThread摄像头循环在run()里执行每一帧的结果通过信号发给主线程更新界面。from PyQt5.QtCore import QThread, pyqtSignal import cv2 class CameraThread(QThread): frame_ready pyqtSignal(object) # 传给界面显示的图像帧 result_ready pyqtSignal(str, float) # 表情标签、置信度 def __init__(self, recognizer): super().__init__() self.recognizer recognizer self.running True def run(self): cap cv2.VideoCapture(0) face_cascade cv2.CascadeClassifier( cv2.data.haarcascades haarcascade_frontalface_default.xml) while self.running: ok, frame cap.read() if not ok: continue gray cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) faces face_cascade.detectMultiScale( gray, scaleFactor1.1, minNeighbors5, minSize(48, 48)) pred, conf none, 0.0 for (x, y, w, h) in faces: face frame[y:yh, x:xw] pred, conf self.recognizer.predict(face) cv2.rectangle(frame, (x, y), (xw, yh), (0, 255, 0), 2) self.frame_ready.emit(frame) self.result_ready.emit(pred, conf) cap.release()这个线程模型的要点是frame_ready和result_ready是跨线程安全的信号主线程里用signal.connect(槽函数)更新界面worker 线程里绝不直接调用QLabel.setText这类 UI 操作。参数scaleFactor1.1表示每次缩放检测窗口的步长越小越慢但越准minNeighbors5表示一个候选区域至少被附近的几个窗口同时命中才判定为人脸数值越大误检越少但也可能漏检。如果你发现识别时漏检严重可以把minNeighbors降到 3。4.3 把模型封装成黑匣子输入 BGR 帧、输出标签与置信度GUI 线程里不应该出现模型结构代码我习惯把模型封装成一个独立的ExpressionRecognizer类只暴露一个predict(人脸BGR图片) - (标签, 置信度)的接口。这样训练脚本、验证脚本、GUI 三处复用的是同一个模型逻辑改一处就行。import os import cv2 import torch import torch.nn.functional as F from torchvision import transforms from model import build_model class ExpressionRecognizer: def __init__(self, ckpt_path, devicecpu): self.model build_model(num_classes7) self.model.load_state_dict(torch.load(ckpt_path, map_locationdevice)) self.model.to(device).eval() base_dir os.path.dirname(ckpt_path) with open(os.path.join(base_dir, classes.txt), r) as f: self.labels f.read().strip().splitlines() self.transform transforms.Compose([ transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]) ]) def predict(self, face_bgr): rgb cv2.cvtColor(face_bgr, cv2.COLOR_BGR2RGB) rgb cv2.resize(rgb, (224, 224)) img self.transform(rgb).unsqueeze(0) with torch.no_grad(): logits self.model(img) prob F.softmax(logits, dim1) conf, idx torch.max(prob, dim1) return self.labels[idx.item()], round(conf.item(), 3)这里最关键的是预处理完全对齐训练阶段BGR 转 RGB、resize 到 224、同一套 mean/std 归一化。任何一步不一致模型的输出概率都会整体偏移经常表现为“某个标签置信度特别高但结果明显不对”。classes.txt是从训练脚本自动保存下来的所以这个类里不需要再手动写标签列表也就不用担心标签顺序被改错。如果想让 CPU 推理更快可以在__init__里加一个torch.set_num_threads(4)或者在预测前把图像直接用cv2.resize压到 112×112 再送进去代价是准确率略有下降这属于典型的“用精度换帧率”取舍。在 GUI 主窗口里实例化 CameraThread 时把ExpressionRecognizer传进去然后连接两个信号到槽函数槽函数里一个更新 QLabel 的图像另一个更新下方的标签文本和置信度进度条。到这里一个能实时运行的人脸表情识别系统就算闭环了。5. 常见问题与避坑人脸表情识别系统的 5 个翻车现场这一章值回票价。下面的问题我基本都在真实项目里见过按“现象 → 原因 → 解决”的顺序写方便你对照排障。5.1 训练 loss 降、验证准确率停住不动先看过拟合还是标签错位现象训练集 loss 一路降到 0.1 以下验证集准确率却一直卡在 60% 附近甚至还在往下掉。原因最常见的是过拟合数据量不够、模型太深、没有增强或 dropout排在第二的是标签错位也就是训练目录顺序和模型输出顺序不一致验证集上的表现只是在瞎猜。判断方法很关键如果验证集 loss 在上升基本是过拟合如果验证集 loss 也在降但准确率不动那优先怀疑标签顺序错位。解决先做控制变量。把 epochs 降到 30分类头 dropout 加到 0.5增强里加RandomCrop(220)并把保存权重的条件改成“验证 loss 最低”而不是“最后一个 epoch”。如果验证 loss 和准确率都在正常范围再打印训练时的train_ds.classes和classes.txt对比推理端的标签列表看angry的位置是不是差了一位。经验做法是训练完立即用验证集里的一张图走一遍推理脚本如果输出标签和真实标签对不上基本就是错位而不是模型没学好。5.2 摄像头实时识别卡成 PPT先降分辨率再改线程模型现象界面能打开但摄像头画面一帧一帧跳移动鼠标都困难CPU 占用率接近 100%。原因把整帧 640×480 的图直接送去 224×224 的模型做推理还放在主线程里执行CPU 根本来不及。这类问题的本质是“检测 推理”总耗时超过了摄像头帧间隔而不是代码写错了。解决分三步处理。第一步确认是否线程问题按 4.2 的方式把摄像头和推理都挪进 QThread。第二步不要对整帧推理先用 Haar Cascade 框出人脸只把人脸区域 resize 后送进模型。第三步如果仍然卡降低推理频率常见做法是每 2 帧才推一次显示用最新一帧代码里用一个frame_count % 2 0的计数就能实现。CPU 上 MobileNetV2 每次推理大概几十毫秒配合 2 帧一次的节奏已经足够流畅。如果人脸检测本身很慢可以把scaleFactor从 1.1 调到 1.15检测更快但略降召回。5.3 GUI 启动后卡在“加载模型”路径问题与 map_location现象PyQt5 界面打开了但控制台报FileNotFoundError或RuntimeError程序停在加载模型那一步窗口白屏。原因很大概率是模型文件路径写死了相对路径或者模型是用 GPU 保存的而当前机器只有 CPU。相对路径在 IDE 里能跑换成在别的目录启动、打包成 exe 后就失效了。解决代码里一律用os.path.join(os.path.dirname(__file__), .., checkpoints, xxx.pt)来定位不要用./checkpoints/xxx.pt。加载权重时显式传map_locationcpu这样即使训练时用了 CUDA也能在无 GPU 的机器上加载。排障时先打印路径看看import os print(os.path.abspath(ckpt_path))发现路径不在预期位置就用os.makedirs补建目录或修正拼接逻辑。通常dirname(__file__)和os.getcwd()之差就是“IDE 里能跑、命令行不能跑”的根源。5.4 所有人脸都被识别成 neutral数据分布与预处理不一致现象摄像头一开不管做什么表情都显示 neutral或者某个标签概率几乎 100%切换表情毫无变化。原因两方面的叠加。第一训练集里 neutral 样本本来就占大头模型把“不确定”一律押给 neutral第二推理预处理和训练预处理不一致比如训练时输入是灰度三通道推理时用彩色图直接塞进去模型输出分布完全偏移softmax 出来的分数没有参考意义。解决先看训练集分布如果 neutral 真的很多换用加权采样或CrossEntropyLoss(weight...)把少数类权重调高代价是整体准确率略降但各类的召回会更均衡。预处理问题就按 4.3 的代码严格对齐灰度、尺寸、归一化三件事。最简单的验证方法是拿一张训练集里的图片在推理脚本上跑一次若输出的标签和训练时一致说明预处理链路是对的如果不一致就别再调模型了先修输入。5.5 打包成 exe 后模型反而加载不到PyInstaller 与临时目录现象在 IDE 里跑得好好的用 PyInstaller 打包成 exe 后启动报“找不到模型文件”或者界面能开但推理结果全错。原因PyInstaller 默认把程序解压到一个临时目录exe 旁边放的checkpoints不会被当作资源打包os.path.dirname(__file__)指向的也是临时目录导致路径失效。解决打包时用--add-data把权重和classes.txt作为资源带进去Windows 上用分号分隔Linux/macOS 上用冒号pyinstaller --windowed --add-data checkpoints;checkpoints main.py代码里用下面这个经典判断来获取真实路径import sys import os if getattr(sys, frozen, False): base_dir sys._MEIPASS else: base_dir os.path.dirname(os.path.abspath(__file__)) ckpt_path os.path.join(base_dir, checkpoints, fer_resnet18.pt)这条写法的本质是运行时先判断自己是不是在 PyInstaller 的打包环境里如果是就把资源根目录切到解压出来的临时目录sys._MEIPASS。另外提醒一句torch 打包出来的 exe 体积通常超过 1GB这是正常的不要试图把整个 torch 裁掉否则推理时会触发各种缺模块报错。如果要减小体积优先选 MobileNetV2 训练再配合打包器排除用不到的 torchvision 后端而不是在代码里动刀。6. 从能跑到能答辩验证脚本、混淆矩阵与最后的演示保底模型能跑、GUI 能开距离答辩还差最后一个环节把自己完整的验证结果拿出来。我建议至少准备两个东西——一个单图验证脚本一张混淆矩阵图。单图验证脚本很简单就是给一张带人脸的图片输出每个框的标签和置信度import cv2 from gui.recognizer import ExpressionRecognizer recognizer ExpressionRecognizer(checkpoints/fer_resnet18.pt) img cv2.imread(demo.jpg) cascade cv2.CascadeClassifier(cv2.data.haarcascades haarcascade_frontalface_default.xml) faces cascade.detectMultiScale(cv2.cvtColor(img, cv2.COLOR_BGR2GRAY), 1.1, 5) for (x, y, w, h) in faces: label, conf recognizer.predict(img[y:yh, x:xw]) print(f{x},{y},{w},{h} - {label} ({conf:.3f}))混淆矩阵用 sklearn 一行就能算出来关键是把它画出来并保存成图片演示 PPT 里放一张比贴一张 loss 曲线有说服力得多from sklearn.metrics import confusion_matrix, ConfusionMatrixDisplay import matplotlib.pyplot as plt # y_true 来自验证集真实标签y_pred 来自模型预测结果 cm confusion_matrix(y_true, y_pred) ConfusionMatrixDisplay(cm, display_labelslabels).plot() plt.savefig(confusion_matrix.png, dpi150)答辩演示的顺序我建议固定成三步先跑单张图片再切摄像头实时识别最后放一段提前录好的识别视频。这样做的原因是现场不可控——教室光线可能很暗、电脑可能没插电性能受限、摄像头可能在系统层面被占用提前录制视频作为兜底能避免现场翻车。我的一般做法是把视频文件也放进工程目录GUI 里加一个“打开视频”按钮演示时如果摄像头表现不好马上切换到视频流动作自然不会被看出来是预案。最后说一个我自己的血泪经验所有代码里只要涉及标签顺序、预处理尺寸、模型权重路径这三处一律写成从配置文件读取不要硬编码。把训练、验证、GUI 三处的代码各自跑通后再合起来做一次端到端自测也就是拿一张自己知道表情的图片从摄像头采集到界面显示走完整链路。这套流程顺手之后你拿到任何一份新的表情识别源码都能用同样的方式快速判断它的标签顺序和预处理到底对不对而不是被模型效果差的问题绕进去。希望帮到你。本文还有配套的精品资源点击获取