简介面向计算机相关专业毕设与大作业场景这套基于Python与卷积神经网络的人脸表情识别系统提供从模型构建、训练测试到可视化演示的完整流程。项目涵盖CNN、VGG、ResNet三种模型实现及对比脚本附带人脸检测级联分类器、数据集处理脚本及预训练权重适合需要快速搭建深度学习项目、完成论文写作与答辩准备的本科生及进阶学习者。资源为zip压缩包共36个文件主体为14个Python源码、5个Jupyter Notebook教程、5份Word/PDF论文文档及1份答辩PPT另有模型权重(pkl)、人脸检测XML、演示视频和参考工程压缩包等整体大小约446MB目录结构清晰便于直接运行和学习。已有75人学习使用。项目亮点在于含评分为98分的完整毕业论文与答辩PPT且源码均经本地编译调试通过配套的Notebook可帮助逐段理解模型原理视频演示则直观展示表情识别效果是一份覆盖“数据—训练—评估—论文—答辩”全链条的高质量毕业设计参考资料。1. 人脸表情识别项目看起来是图像分类真正卡人的是数据先给结论这套「Python 卷积神经网络」的人脸表情识别系统模型结构不是难点能上网搜到的CNN代码一抓一大把真正让绝大多数人卡住的是数据处理和训练细节——同样一份FER2013数据集有人能训到65%以上的验证准确率有人折腾一周还在50%上下晃悠差别不在网络多先进而在预处理、超参数和踩坑经验。这个项目的价值在于它覆盖了一条完整的落地链路从数据集解析、人脸检测对齐、CNN模型设计到训练调参、模型保存、摄像头实时推理外加论文和答辩PPT的整理思路。对做毕设、课设或者刚入门CV想练手的人来说它是少有的「一个项目打通全流程」的选择。对已经熟悉分类任务的开发者这套系统的可玩点则在工程层面——怎么把检测和分类串成实时链路怎么让模型在光照差、侧脸、遮挡时还能稳住。我接下来按我自己做这类项目的顺序来讲先讲清CNN在表情识别里该怎么选型和配参再给出一套能跑通的数据处理与训练代码最后把验证、排错和答辩展示的关键点补齐。2. 为什么选卷积神经网络来做表情识别结构、参数与一个基准网络2.1 CNN在表情识别里赢在哪局部纹理与平移不变性表情识别的输入是48x48的灰度人脸图比ImageNet那种224x224的彩色图小得多信息量也少得多。但这种小图恰恰是CNN的舒适区——人脸表情的判别依据集中在眼睛、眉毛、嘴巴这些局部区域比如高兴时嘴角上扬、惊讶时眼睛和嘴巴同时张大这些特征都是局部纹理模式。CNN的两个天然特性正好匹配这个需求第一卷积操作的局部感受野让网络先学会提取边缘、线条再组合成眼睛、嘴巴等部件级特征最后形成表情级别的抽象表达第二卷积和下采样的组合带来一定程度的平移不变性——人脸检测框即使有误差脸在框里偏移了几个像素网络的输出也不会剧烈变化。这一点是传统方法比如直接把像素拉平喂给SVM最头疼的问题。所以在表情识别这个任务上CNN不是「可选项」而是性价比最高的默认选项。它不需要你手动设计HOG、LBP这些特征端到端地把特征提取和分类一起学出来训练和部署链路都短。用Keras或者PyTorch都能实现我习惯用PyTorch后面代码也按PyTorch来写。2.2 卷积神经网络的汇聚层参数怎么设才不翻车汇聚层池化层在表情识别网络里承担两件事降分辨率和扩大感受野。常见的选择是最大池化MaxPooling它在每个2x2窗口里取最大值把特征图的宽高各缩一半。为什么不用平均池化因为表情的判别细节比如嘴角的弧度边缘往往对应较强的激活值最大池化能把这些强响应保留下来平均池化反而会把它们稀释掉。参数上我一般固定用kernel_size2、stride2的2x2最大池化这几乎是小型CNN的默认配置。要注意的是池化层不要和卷积层叠加得太密——每做一次2x2池化特征图分辨率就减半48x48的输入经过三次池化就变成6x6如果继续池化后面的全连接层能拿到的空间信息就太少了模型会明显掉点。另一个容易翻车的地方是「该池化时不池化」。有人为了让模型学得更细把池化层全部去掉只用stride2的卷积来下采样。这在数据量大的时候可以但FER2013总共才三万五千多张图纯卷积下采样会让参数量上升、训练变慢正则压力也更大。我的建议是池化层该用就用2x2最大池化在小数据集上是稳妥的默认项。2.3 一个能跑得动又不丢脸的基准网络参考LeNet-5的卷积池化交替思路但把通道数加宽、引入批归一化和Dropout我常用的一个基准结构是这样输入48x48单通道灰度图卷积块1两个3x3卷积64通道 批归一化 ReLU 2x2最大池化输出24x24卷积块2两个3x3卷积128通道 批归一化 ReLU 2x2最大池化输出12x12卷积块3两个3x3卷积256通道 批归一化 ReLU 2x2最大池化输出6x6分类器Dropout(0.5) → 全连接512 → ReLU → Dropout(0.5) → 全连接7为什么用两个3x3卷积叠一个块而不是直接上一个5x5卷积两个3x3卷积的感受野等于一个5x5但参数量更少、非线性更强这是现代CNN的通行做法。通道数从64涨到256是因为越往后特征图越小需要更多通道来承载高层语义信息。7个输出节点对应FER2013的七类表情Angry、Disgust、Fear、Happy、Sad、Surprise、Neutral。这个结构在有GPU的情况下训练30个epoch大约十几分钟CPU也能跑但会慢很多。作为毕业设计级别的项目它的表达能力和训练成本之间平衡得很好不会因为模型太大导致过拟合也不会因为太浅导致欠拟合。3. 数据处理这一步最容易被低估FER2013的读取、归一化与增强3.1 FER2013的数据结构CSV里藏着训练集、验证集和测试集先说一个很多新手会懵的点FER2013不是一个图片文件夹而是一个CSV文件。每一行有三列emotion0到6的整数标签、pixels48x482304个灰度值用空格分隔、Usage这一行属于Training、PublicTest还是PrivateTest。第一次接手这个数据集的人最容易犯的错是把整个CSV当成训练集直接读。实际标好的划分是Training约28709张用于训练PublicTest约3589张适合当验证集PrivateTest约3589张是真正的测试集。我在训练时的做法是只用Training部分做训练和验证PrivateTest留到最后测一次绝不提前碰它。下面代码实现了按Usage字段拆分的数据集类import csv import numpy as np import torch from torch.utils.data import Dataset class FER2013Dataset(Dataset): def __init__(self, csv_path, usageTraining, transformNone): self.images [] self.labels [] self.transform transform with open(csv_path, r) as f: reader csv.reader(f) next(reader) # 跳过表头: emotion,pixels,Usage for row in reader: if row[2] ! usage: # 只留指定划分的数据 continue pixels np.array([int(x) for x in row[1].split()], dtypenp.uint8) pixels pixels.reshape(48, 48, 1) self.images.append(pixels) self.labels.append(int(row[0])) self.images np.array(self.images) self.labels np.array(self.labels) def __len__(self): return len(self.labels) def __getitem__(self, idx): img self.images[idx] label self.labels[idx] if self.transform: img self.transform(img) return img, label这段代码的逻辑很简单初始化时遍历CSV按Usage字段筛出对应的行把pixels字符串按空格切分并转成48x48的灰度图标签直接取emotion列。类内部维护images和labels两个numpy数组__getitem__负责在取样时对单张图做增强变换。需要注意一个隐藏坑CSV里的pixels列的每个值读出来是字符串直接int()转换没问题但如果有人图省事用np.fromstring或者不做类型转换后面算归一化时会出现隐式类型问题轻则精度变差重则直接报错。所以我在读取时显式指定了dtypenp.uint8把类型问题掐死在源头。3.2 环境准备装好Python、CUDA版PyTorch和cv2拿到源码后第一步不是跑模型而是把环境弄干净。Python建议用3.8到3.11之间的版本太新的版本有时会遇到某些库还没跟上编译的问题。在VSCode里做Python开发的话记得先装好Python扩展然后按CtrlShiftP调出命令面板选择「Python: Select Interpreter」指定虚拟环境这一步经常有人忽略导致命令行和编辑器用的不是同一个环境报错报得莫名其妙。需要装的包不多核心三个PyTorch带CUDA的版本、OpenCV-Python、NumPy。如果你的机器有NVIDIA显卡先去NVIDIA官网确认显卡支持的CUDA版本再到PyTorch官网用对应命令安装没有独立显卡就用CPU版代码不用改就是训练慢。OpenCV的安装很直接pip install opencv-pythoncv2的导入名是cv2安装包名是opencv-python这个对应关系也常坑到新手——在VSCode里写import cv2提示找不到模块其实不是代码问题是装错包名。另外建议顺手装上scikit-learn后面做混淆矩阵要用matplotlib用来画训练曲线和可视化预测结果。这两个不在关键路径上但做论文和答辩展示时基本少不了。3.3 归一化与数据增强三个必调参数数据从CSV读出来是0到255的整数灰度值直接喂给网络有两个问题数值范围太大会让梯度更新不稳而且卷积层对输入的尺度敏感。标准做法是归一化到[-1, 1]或[0, 1]。我用的是(x - 128.0) / 128.0等价于mean0.5、std0.5理由是48x48的小图细节少过多的均值方差统计反而容易把分布绑死在训练集上。数据增强方面有三个参数我每次必调第一个是随机裁剪。对48x48的图随机裁剪成44x44再缩放回48x48等效于给模型看略微偏移的人脸缓解检测框不精准的问题。padding为4的RandomCrop是常见配法。第二个是水平翻转。人脸表情在水平方向上大致对称——向左笑和向右笑在语义上没区别但卷积核不是对称的翻转相当于把训练数据扩大一倍。注意不要做垂直翻转把人脸倒过来训练会引入错误先验。第三个是旋转。表情识别对旋转角度很敏感超过15度的旋转就会让五官位置关系失真我只rotate±10度。旋转角度设大会让模型看到大量扭曲的人脸验证集上反而更差。增强操作放在PyTorch的transforms里统一编排from torchvision import transforms train_transform transforms.Compose([ transforms.ToTensor(), # (48,48,1) - (1,48,48)像素缩放到[0,1] transforms.RandomCrop(44, padding4), # 随机裁剪44x44再在后面resize回48x48 transforms.Resize((48, 48)), transforms.RandomHorizontalFlip(), # 随机水平翻转 transforms.RandomRotation(10), # 随机旋转±10度 transforms.Normalize((0.5,), (0.5,)) # 归一化到[-1,1] ]) val_transform transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.5,), (0.5,)) ])注意验证集和测试集的变换里只有ToTensor和Normalize不做RandomCrop和RandomFlip。这是个原则性问题验证集代表模型的真实水平加了随机增强会让验证结果每次不一样你没法判断模型到底是变好了还是只是运气好。3.4 类别不均衡Disgust类只有别人的零头FER2013的七类表情样本数差异非常大大致分布是这样表情大约样本数全量Angry4900Disgust550Fear5100Happy8900Sad6000Surprise4000Neutral6200Disgust的样本数只有Happy的零头。如果直接按默认的CrossEntropyLoss训练模型会倾向于把Disgust错分成其他类因为全预测成Happy都能拿高准确率。处理办法有两个二选一即可第一种是在损失函数里给每个类加权重样本少的类权重高。PyTorch的CrossEntropyLoss直接支持weight参数传入一个长度为7的tensor。权重怎么算我习惯用总样本数 / (类别数 * 该类别样本数)这样所有类的权重和为1。第二种是用WeightedRandomSampler在采样时让每类的命中概率均衡相当于制造一个类别平衡的batch。这个方法对DataLoader的影响更直接但要注意drop_last要设成True否则最后一个batch可能全是重复样本。我一般优先用第一种改一行代码就能生效class_counts np.array([4953, 547, 5121, 8989, 6077, 4002, 6198]) weights class_counts.sum() / (len(class_counts) * class_counts.astype(np.float64)) class_weights torch.from_numpy(weights).float().to(device) criterion nn.CrossEntropyLoss(weightclass_weights)加了类别权重后Disgust的召回率会明显上升总准确率可能微降但论文里能画出更漂亮的混淆矩阵这个取舍是值得的。4. 用PyTorch把CNN搭起来训练代码、超参数与模型保存4.1 网络结构代码卷积、批归一化、Dropout的排布这是整套系统的核心代码。我按第2章定的基准结构来实现每一层都带注释说明设计意图import torch.nn as nn class ExpressionCNN(nn.Module): def __init__(self, num_classes7): super(ExpressionCNN, self).__init__() self.features nn.Sequential( # 卷积块1: 48x48x1 - 24x24x64 nn.Conv2d(1, 64, kernel_size3, padding1), nn.BatchNorm2d(64), nn.ReLU(inplaceTrue), nn.Conv2d(64, 64, kernel_size3, padding1), nn.BatchNorm2d(64), nn.ReLU(inplaceTrue), nn.MaxPool2d(kernel_size2, stride2), # 卷积块2: 24x24x64 - 12x12x128 nn.Conv2d(64, 128, kernel_size3, padding1), nn.BatchNorm2d(128), nn.ReLU(inplaceTrue), nn.Conv2d(128, 128, kernel_size3, padding1), nn.BatchNorm2d(128), nn.ReLU(inplaceTrue), nn.MaxPool2d(kernel_size2, stride2), # 卷积块3: 12x12x128 - 6x6x256 nn.Conv2d(128, 256, kernel_size3, padding1), nn.BatchNorm2d(256), nn.ReLU(inplaceTrue), nn.Conv2d(256, 256, kernel_size3, padding1), nn.BatchNorm2d(256), nn.ReLU(inplaceTrue), nn.MaxPool2d(kernel_size2, stride2), ) self.classifier nn.Sequential( nn.Dropout(0.5), nn.Linear(256 * 6 * 6, 512), nn.ReLU(inplaceTrue), nn.Dropout(0.5), nn.Linear(512, num_classes), ) def forward(self, x): x self.features(x) x x.view(x.size(0), -1) # 拉平成向量送入全连接层 x self.classifier(x) return x代码里的两个关键设计点每个卷积块内连续两个3x3卷积等价于一个5x5卷积的感受野但非线性更强、参数量更少每个ReLU前都接BatchNorm2d作用是让每层输入分布稳定允许你用更大的学习率而不容易发散。Dropout放在全连接层前面而不是卷积层后面这是通用实践——全连接层参数量占比最大最容易过拟合Dropout对它有最强的抑制效果。有个值得注意的细节inplaceTrue这个参数很多人看不懂它的意思是把ReLU的输出直接覆盖到原内存上省一次内存分配。训练时能省一点显存推理时没有影响。如果你在模型里做深入定制这个参数不可用时比如跟autograd冲突把它改成False就行不影响精度。4.2 训练脚本损失函数、优化器与学习率调度训练主流程用标准的三件套CrossEntropyLoss做损失、Adam做优化器、StepLR做学习率调度。完整代码可以直接复制运行import torch import torch.optim as optim from torch.optim.lr_scheduler import StepLR from torch.utils.data import DataLoader device torch.device(cuda if torch.cuda.is_available() else cpu) model ExpressionCNN(num_classes7).to(device) # 3.4节里给的类别权重在这里用 criterion nn.CrossEntropyLoss(weightclass_weights.to(device)) optimizer optim.Adam(model.parameters(), lr1e-3, weight_decay1e-4) scheduler StepLR(optimizer, step_size10, gamma0.1) train_loader DataLoader(train_dataset, batch_size64, shuffleTrue, num_workers2) val_loader DataLoader(val_dataset, batch_size64, shuffleFalse, num_workers2) best_acc 0.0 for epoch in range(30): model.train() running_loss 0.0 for images, labels in train_loader: images, labels images.to(device), labels.to(device) optimizer.zero_grad() outputs model(images) loss criterion(outputs, labels) loss.backward() optimizer.step() running_loss loss.item() # 每轮训练完做一次验证 model.eval() correct, total 0, 0 with torch.no_grad(): for images, labels in val_loader: images, labels images.to(device), labels.to(device) outputs model(images) _, predicted torch.max(outputs, 1) total labels.size(0) correct (predicted labels).sum().item() val_acc 100.0 * correct / total if val_acc best_acc: best_acc val_acc torch.save(model.state_dict(), best_model.pt) # 只保存权重不保存整个模型 scheduler.step() print(fEpoch {epoch1:02d} | Loss: {running_loss/len(train_loader):.4f} | Val Acc: {val_acc:.2f}% | Best: {best_acc:.2f}%)逻辑不复杂每个epoch先遍历整个训练集用梯度下降更新权重结束后在验证集上做一次前向推理统计准确率如果本轮验证准确率比历史最好值高就把当前权重保存到best_model.pt。学习率调度器每10轮把学习率乘以0.1让模型在后期用小步长精细收敛。这里有个「后悔药」机制一定要养成习惯只保存验证集上的最优权重而不是傻傻地等30轮跑完再手动挑。训练过程中模型很可能在第18轮就已经到最佳状态继续训练反而开始过拟合。有了best_model.pt哪怕后面训练烂了也能回滚。4.3 关键超参数batch大小、学习率、epoch与weight_decay这四个参数是训练成败的主控旋钮我逐个说我的配置理由。batch大小默认64GPU显存不够就降到32或16。batch越小梯度噪声越大模型收敛越不稳定batch太大128以上又会降低泛化能力。64对小数据集是个甜点值。如果显存连32都跑不动把网络里的通道数从64/128/256减半到32/64/128效果比硬撑要稳定得多。学习率默认1e-3搭配Adam优化器。这个组合对小型CNN几乎不会翻车。如果训练时loss一开始就飙升或者震荡剧烈把学习率降到3e-4再试反过来如果loss下降很慢可以试试2e-3但不要超过这个值。学习率调度用StepLR每10轮降为原来的0.130轮训练里相当于最后10轮在精修。epoch数设30。FER2013这个规模的数据30轮足够让模型收敛继续加轮数验证集准确率也不会再涨多少。真正该关注的不是epoch数量而是loss曲线如果训练loss还在稳定下降、验证loss拐头向上说明开始过拟合提前停掉就好。weight_decay我设1e-4它是L2正则化的强度。设太大1e-2会让权重被压缩得过小模型欠拟合设太小等于没有正则。1e-4对表情识别这种中型网络是个稳妥起点调参优先级低于学习率一般不用动。5. 人脸表情识别避坑指南五个常见翻车点这一章写的是我做这类项目反复踩过的坑每一条都是「现象 → 原因 → 解决」的结构新手上路前先看一遍能省好几天排查时间。5.1 训练loss不降反升现象前几个epoch的loss在1.9附近波动然后一路涨到2.3甚至更高验证准确率在20%以下徘徊。20%是什么概念七分类随机猜的准确率是14.3%20%约等于模型在瞎蒙。原因分三类最常见的是数据预处理出错——pixels没归一化或归一化参数写得不对梯度被大数值输入带偏第二类原因是学习率过大Adam虽然自适应但初始学习率1e-3对某些网络结构仍然偏激进第三类比较隐蔽是数据增强强度过大随机旋转加随机裁剪叠加后人脸特征被破坏到模型学不到有效模式。解决先打印一个batch的数据检查图像的像素范围是不是在[-1, 1]区间、标签是不是0到6的整数然后把增强暂时全关掉只用ToTensor和Normalize跑5个epoch如果loss正常下降问题就锁定在增强参数上把旋转角度从15度改回10度去掉RandomCrop再逐步加回来。5.2 验证准确率来回震荡现象训练loss在平滑下降但每个epoch验证的准确率像过山车这轮62%、下轮48%、再下轮58%完全看不出趋势。原因FER2013的PublicTest只有3589张图batch size设为64的话验证集总共才56个batch任何一批的分类结果变化都会让验证准确率波动好几个百分点。另外类别不均衡也会放大这种波动——Disgust在验证集里只有几十张某轮蒙对三张、某轮蒙对零张对整体准确率的影响能有1到2个百分点。解决不要用单次验证准确率判断模型好坏改成每5个epoch验证一次或者记录每个epoch的验证loss并画平滑曲线。同时在验证时统计「按类别的平均准确率」而不是全局准确率能更真实地反映模型在每个表情上的表现。5.3 摄像头推理延迟过高现象用训练好的模型接USB摄像头画面严重卡顿目测帧率只有5到8帧。你以为是模型推理太慢但其实可能不是。原因我排查过类似问题真正的瓶颈往往不是模型本身——一个48x48输入的小CNN单次推理只要几毫秒卡顿的根源在于「人脸检测 表情分类」串行执行时OpenCV的Haar级联检测在大尺寸帧上每帧都要扫一遍多尺度窗口这个开销比模型推理大得多。解决采用跳帧策略每处理2帧才跑一次完整检测加分类中间帧直接复用上一帧的结果检测前把帧缩小一半再送入detectMultiScale坐标按比例还原还可以用cv2.CAP_PROP_FPS把摄像头读取帧率限制在30以内避免缓冲区堆积。这些改动对展示效果几乎没有影响但能让帧率翻倍以上。5.4 训练结果复现不了现象同一个源码包同一份数据集跑两次得到的验证准确率不一样有时候差三到四个百分点。这在论文里写实验对比时很尴尬因为你没法证明某个改动是有效的。原因深度学习训练里有三处随机源——PyTorch模型参数的随机初始化、DataLoader的shuffle打乱顺序、以及CUDA的卷积算法选择。这三处不固定结果就不可能完全复现。解决在训练脚本开头固定所有随机种子并关闭CUDA的自动调优import random import numpy as np import torch def set_seed(seed42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) torch.backends.cudnn.deterministic True # 牺牲一点速度保证可复现 torch.backends.cudnn.benchmark False注意deterministicTrue会稍微拖慢训练速度但换来的是每次跑结果完全一致这在调参和写论文时价值极高。5.5 测试集准确率比验证集低一大截现象训练时验证准确率能做到66%但用PrivateTest真正没见过的测试集一测只有58%差了8个百分点。原因我在第3章说过训练过程中每轮都在用验证集挑最优模型这本身就是一种隐式的「过拟合验证集」。选模型时用的标准是「在PublicTest上准确率最高」相当于在验证集上做了多次假设检验模型的泛化能力被高估了。解决这是正常的不需要害怕。做法是训练阶段只看验证集选模型训完后用测试集测一次记录结果这个测试结果就是论文里要写的最终数字。如果测试集和验证集差距过大说明模型对数据分布的记忆过强优先做法是增大Dropout到0.6、调高weight_decay到5e-4而不是换一个更大的网络。6. 从跑通到能答辩验证模型、实时演示与展示思路6.1 用混淆矩阵确认模型的薄弱表情训练结束后第一件事不是急着做演示而是把模型在测试集上的混淆矩阵画出来。这一步能直观告诉你模型把哪些表情混在了一起——常见的规律是Fear和Sad互相认错、Neutral被大量误判成其他表情。有了这张图论文的实验分析章节才有内容可写。from sklearn.metrics import confusion_matrix, ConfusionMatrixDisplay import matplotlib.pyplot as plt emotion_labels [Angry, Disgust, Fear, Happy, Sad, Surprise, Neutral] # 在测试集上收集所有预测结果 model.eval() all_preds, all_labels [], [] with torch.no_grad(): for images, labels in test_loader: images images.to(device) outputs model(images) _, preds torch.max(outputs, 1) all_preds.extend(preds.cpu().numpy()) all_labels.extend(labels.numpy()) cm confusion_matrix(all_labels, all_preds) disp ConfusionMatrixDisplay(confusion_matrixcm, display_labelsemotion_labels) disp.plot(cmapBlues, xticks_rotationvertical) plt.savefig(confusion_matrix.png, dpi150, bbox_inchestight) plt.show()有的项目标题里带「源码」源码包通常已经包含了类似的可视化脚本但自己跑一遍才能在答辩时讲清楚每个数字的含义。色块深的对角线是模型的强项对角线外深色的格子就是典型的混淆对——把这两个格子拿出来分析为什么混淆比通篇背准确率数字有说服力得多。6.2 实时摄像头演示与答辩展示的串联方式答辩现场最出效果的是一个能跑的实时演示。流程并不复杂OpenCV读摄像头帧 → 转灰度 → Haar级联检测人脸框 → 裁剪人脸区域 → 缩放48x48 → 归一化 → 送入CNN → 把预测表情名称画在框上。核心代码量不大但要把「跳帧」加进去保证演示流畅import cv2 import torch from torchvision import transforms face_cascade cv2.CascadeClassifier(cv2.data.haarcascades haarcascade_frontalface_default.xml) model.eval() cap cv2.VideoCapture(0) preprocess transforms.Compose([ transforms.ToPILImage(), transforms.Resize((48, 48)), transforms.ToTensor(), transforms.Normalize((0.5,), (0.5,)) ]) frame_idx 0 result_text while True: ret, frame cap.read() if not ret: break frame_idx 1 gray cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) if frame_idx % 3 0: # 每3帧做一次检测和分类 faces face_cascade.detectMultiScale(gray, scaleFactor1.1, minNeighbors5, minSize(48, 48)) result_text for (x, y, w, h) in faces: face gray[y:yh, x:xw] # 裁剪人脸区域 tensor preprocess(face).unsqueeze(0).to(device) with torch.no_grad(): output model(tensor) pred_idx torch.argmax(output, dim1).item() result_text emotion_labels[pred_idx] cv2.rectangle(frame, (x, y), (xw, yh), (0, 255, 0), 2) cv2.putText(frame, result_text, (30, 30), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 0, 255), 2) cv2.imshow(FER Demo, frame) if cv2.waitKey(1) 0xFF ord(q): break cap.release() cv2.destroyAllWindows()这套演示代码有几个细节值得留意检测用的是OpenCV自带的Haar级联分类器不需要额外下载模型文件cv2.data.haarcascades路径下自带人脸区域直接灰度裁剪不转RGB因为模型输入本身就是单通道minSize(48, 48)限制了最小检测尺寸避免远处的小脸被送入网络时因信息不足产生误判。答辩展示时我建议按这个顺序串先花30秒讲清楚数据从哪来、长什么样、为什么分三类划分再讲网络结构为什么要这么设计卷积块加池化的动机、Dropout和BN的作用然后展示训练曲线的收敛趋势和混淆矩阵最后用实时摄像头演示收尾。源码、数据和PPT其实是同一件事的三面——数据是基础源码是实现PPT是说服力三者在答辩时互为印证。我自己的习惯是所有代码跑通后把关键实验截图训练loss曲线、混淆矩阵、实时演示效果按顺序存到项目目录的docs文件夹里写论文和做PPT时直接取用。这套系统从拿到数据到完成演示一个周末能跑通主流程但真正把它做好、讲透还需要在数据增强和调参上多花几个迭代。希望帮到你把这套项目做得比「能跑」更进一步。本文还有配套的精品资源点击获取