基于Python与CNN的人脸表情识别数据集及模型训练实战 📅 发布时间:2026/9/12 2:53:11 👁 浏览次数: 简介一套面向计算机专业毕业设计与人脸表情识别入门实践的完整项目包整合了Python源码、配套数据集、训练好的模型以及论文答辩材料可帮助完成课程设计、毕业设计或项目实战练习。包含卷积神经网络中多种经典模型VGG与ResNet等的训练和测试脚本以及PyTorch实现、OpenCV人脸检测、表情映射、数据划分与预处理代码覆盖数据读取、模型定义、训练循环、评估指标与可视化对比的完整流程。资源包共36个文件以Python脚本、Jupyter笔记、Word/PDF文档、答辩演示文稿、效果视频和训练好的模型文件为主压缩包约446MB另有数据集和多个参考开源工程目录清晰便于查阅。目前已有55人学习。项目经过本地运行调试源自导师认可的高分设计从模型对比、论文写作到答辩展示都有完整呈现能够节省大量环境配置与调参时间。1. 这个项目为什么总被评价为“高分”人脸表情识别是计算机视觉里最容易被低估的分类任务之一。很多人以为它和车牌识别、猫狗分类没区别但真正动手就会碰到两件麻烦事一是公开数据集的类别分布严重不均匀“开心”的样本可能比“厌恶”多出四五倍二是不同人的同一表情差异极大同一个人在不同光照下的同一表情也能被模型分到不同类别。标题里那套“Python 卷积神经网络 人脸表情识别 数据集 训练好的模型”组合本质上是一个完整课题的最小闭环数据从哪里来、模型长什么样、怎么把准确率做到能演示、答辩时怎么解释失败案例。它的价值不在代码量而在于把“训练跑通”和“效果可信”这两件事同时做完了这正是课程设计和毕业设计里拉开分数差距的地方。这个标题适合两类读者。一类是做课程设计、毕业设计的学生需要从零搭一套能演示、能答辩的完整流程另一类是刚接触 CNN 的工程师想复用一个人脸分类项目来理解数据增强、过拟合、类别不平衡这些实战问题。下文按我平时做这类项目的顺序展开先把数据讲透再给一个能解释清楚的 CNN 结构然后是训练参数的设置思路最后落到推理和答辩准备。2. 表情数据集选型与预处理数据决定了模型的上界2.1 常见公开表情识别数据集对比做表情识别数据集直接决定后续工作的上限。我见过不少项目在模型上花了很多功夫最后发现效果上不去是因为数据集本身有问题类别太少、图片分辨率过低、或者训练集和测试集分布不一致。以下是目前最常见的三个公开数据集及适用场景数据集样本规模类别数特点适用场景FER2013约 35000 张7 类48x48 灰度图来自谷歌图片搜索噪声大教学、论文对比、压力测试CK约 590 个序列7 类实验室环境人脸对齐良好序列帧验证算法上限RAF-DB约 30000 张7 类或 2 类真实场景有年龄/性别标注贴近实际应用的实验如果是做课程设计我会优先选 FER2013。它不需要自己爬数据规模适中7 个类别覆盖了“生气、厌恶、恐惧、开心、难过、惊讶、中性”而且分辨率低48x48对硬件要求不高普通笔记本用 CPU 也能完成一轮训练。CK 的样本更干净但数量少容易出现对训练集的过拟合RAF-DB 更接近真实场景但读取格式复杂不做微调的话效果不一定理想。需要注意FER2013 的测试集是从原始分布中切出来的里面本身包含大量难以辨认的样本。换个说法即使是一个正常人来标注 FER2013 的测试集也无法达到 100% 准确率。这是答辩时解释“为什么只有 70%”的底气来源。2.2 用 OpenCV Haar Cascade 从图片中裁出人脸表情识别和人脸检测是两件事。表情识别的输入是一张已经对齐的人脸而“从一张照片里找到人脸”属于检测任务。很多学生在答辩时把这两个概念混在一起讲会被老师追问得很惨。我的做法是训练和测试阶段都直接从 FER2013 的灰度图上裁出人脸区域该数据集本身已是人脸图片不需要再检测但第一次演示会使用摄像头拍摄的图片时才会调用人脸检测器。import cv2 cascade cv2.CascadeClassifier( cv2.data.haarcascades haarcascade_frontalface_default.xml ) def detect_face(img_gray, scaleFactor1.1, minNeighbors5): faces cascade.detectMultiScale( img_gray, scaleFactorscaleFactor, minNeighborsminNeighbors, minSize(48, 48), ) if len(faces) 0: return None x, y, w, h max(faces, keylambda f: f[2] * f[3]) return img_gray[y : y h, x : x w]首先将图片转为灰度图原因是表情识别对颜色信息不敏感灰度图可以降低计算量并减少过拟合风险。scaleFactor是每轮缩放图像的比例1.1表示每次缩小 10%值越小检测越慢但越精确minNeighbors至少要有 5 个邻近矩形框确认减少误检但不至于漏检这是自适应阈值的关键。返回的人脸区域再送入cv2.resize缩放到 48x48。如果检测不到人脸就不做任何处理跳过该样本。一个容易踩的坑是输入顺序必须在BGR - 灰度 - 检测 - 裁剪 - resize - 归一化很多人先 resize 再检测导致小尺寸人脸漏检。另一个坑是detectMultiScale返回的坐标是相对原图的不能直接当成统一尺寸的输入。2.3 FERPlus 标签噪声与类别权重FER2013 的一个隐藏问题是标签噪声严重。后来学术界发布的 FERPlus 版本让 10 个标注者投票决定每张图片的标签通过投票分布可以获得更可靠的标签。但部分项目仍然直接使用 FER2013 原始标签模型精度会比用 FERPlus 低约 5 个百分点。在实现层面更直接的问题是 7 个类别的样本数严重不均衡。以 FER2013 为例“开心”类别的样本量大约是“厌恶”类别的 6 倍。如果直接计算交叉熵损失模型会倾向于把所有样本都预测为“开心”因为这样整体损失最小。常见的解决方案是给CrossEntropyLoss传入weight参数import torch.nn as nn label_to_weight { 0: 1.0, # 生气 1: 2.0, # 厌恶 2: 1.2, # 恐惧 3: 1.0, # 开心 4: 1.5, # 难过 5: 1.0, # 惊讶 6: 1.2, # 中性 } weights torch.tensor( [label_to_weight[i] for i in range(7)], dtypetorch.float32 ) criterion nn.CrossEntropyLoss(weightweights)这段代码的核心是对样本少的类别给更大的损失权重惩罚模型“偷懒”地把它们预测成常见类别。权重的具体数值不是固定的我一般先用各类别样本量的倒数做归一化再手动微调。这里有一个经验权重不能拉得差距过大否则训练初期 loss 会剧烈震荡模型会优先拟合小类别而丢掉整体精度。2.4 数据增强训练集和验证集必须分开数据增强是用 FER2013 训练时要重点讨论的问题。48x48 的低分辨率灰度图非常容易过拟合如果不做增强训练集准确率很快到 95%验证集却卡在 55%。from torchvision import transforms train_transform transforms.Compose([ transforms.RandomAffine(degrees10, translate(0.05, 0.05)), transforms.RandomHorizontalFlip(p0.3), transforms.ToTensor(), transforms.Normalize(mean(0.5,), std(0.5,)), ]) val_transform transforms.Compose([ transforms.ToTensor(), transforms.Normalize(mean(0.5,), std(0.5,)), ])RandomAffine中的degrees10表示最多随机旋转 10 度translate表示最多平移 5%RandomHorizontalFlip以 30% 概率水平翻转。需要注意的是水平翻转对于“厌恶”“开心”这类表情没问题但对“文字方向敏感”的图像不适用而人脸总体是对称的可放心使用。验证集不能做任何随机增强。否则同一张图片在不同 epoch 验证时结果不同无法判断模型是真上升还是偶然波动。很多人的验证曲线是锯齿状起伏原因就在这里。另外Normalize(mean0.5, std0.5)对灰度图来说就是把像素范围从[0,1]映射到[-1,1]。这个操作的目的是让输入分布更接近标准正态有利于模型收敛。如果训练和推理时使用的归一化参数不一致模型的输出会出现系统性偏差这属于推理阶段最容易犯的低级错误。3. 用 6 层 CNN 搭出可解释的表情识别模型3.1 网络结构设计与每层的形状变化“卷积、池化、步长、核、填充”是 CNN 里最核心的一组概念很多项目直接用现成的 ResNet18 迁移学习反而讲不清楚每一层在做什么。我的建议是课程设计或毕业答辩场景下自己搭一个 6 层左右的小型 CNN比直接搬一个预训练模型更安全。因为老师一定会问“为什么选用这个结构”而“因为网上说 ResNet 效果好”并不是技术理由。下面是一个经过简化但完整可运行的结构import torch.nn as nn class EmotionCNN(nn.Module): def __init__(self, num_classes7, dropout0.5): super().__init__() self.features nn.Sequential( # 48x48 - 24x24 nn.Conv2d(1, 32, kernel_size3, padding1), nn.BatchNorm2d(32), nn.ReLU(inplaceTrue), nn.MaxPool2d(kernel_size2, stride2), # 24x24 - 12x12 nn.Conv2d(32, 64, kernel_size3, padding1), nn.BatchNorm2d(64), nn.ReLU(inplaceTrue), nn.MaxPool2d(kernel_size2, stride2), # 12x12 - 6x6 nn.Conv2d(64, 128, kernel_size3, padding1), nn.BatchNorm2d(128), nn.ReLU(inplaceTrue), nn.MaxPool2d(kernel_size2, stride2), ) self.classifier nn.Sequential( nn.Flatten(), nn.Linear(128 * 6 * 6, 512), nn.ReLU(inplaceTrue), nn.Dropout(pdropout), nn.Linear(512, num_classes), ) def forward(self, x): return self.classifier(self.features(x))这段网络的输入是(batch, 1, 48, 48)的灰度图。三个卷积块均采用3x3卷积核、padding1这样图片尺寸在卷积前后保持不变靠池化层完成降采样。每经过一个MaxPool2d(kernel_size2, stride2)宽高减半48 → 24 → 12 → 6。最后一个池化输出的特征图是128 x 6 x 6展平后变成 4608 维送入两层全连接分类器。两层全连接之间有一个Dropout(p0.5)它在训练时随机屏蔽一半神经元强制网络不依赖某个局部特征。p0.5是经验值p 太小抑制过拟合的效果不明显p 太大则会导致欠拟合。迁移到验证阶段时Dropout会自动关闭这是 PyTorch 通过model.eval()控制的。BatchNorm2d 放在卷积之后、ReLU 之前作用是让每层输入的分布稳定。小数据集上它的收益非常明显允许使用更大的学习率同时降低了对初始化权重的敏感度。3.2 卷积核尺寸、步长和填充的选择理由选择3x3卷积核而不是5x5或7x7理由有两个。结构上两个串联的3x3卷积拥有与一个5x5卷积相同的感受野看得到的区域大小但参数量更少2 x 9 x C x C对比25 x C x C非线性更强。对于 48x48 的低分辨率输入5x5和7x7会过早地缩小特征图的尺寸不利于保留表情细节。stride1是默认值用在卷积层池化层的stride2才是真正的降采样手段。padding1是为了让卷积不改变特征图尺寸这意味着边界像素也有机会被卷积核覆盖。如果padding0每过一层宽高减少 2三层后 48x48 会变成 42x42再经过三次池化会变成更小的非整数尺寸给展平后的全连接层计算带来麻烦。关于通道数32 → 64 → 128 是小型 CNN 的标准增长规律。通道数在每次空间降采样后翻倍这样既控制了计算量又不丢失信息表达。通道数翻倍到 256 以上对 48x48 输入没有明显收益反而容易在小数据集上过拟合。3.3 为什么不用预训练模型做迁移学习在这类场景里我通常不使用迁移学习原因很简单预训练模型如 ResNet18是为 224x224 的 ImageNet 图片设计的而表情数据集是 48x48 灰度图。强行调整输入尺寸到 224x224 会导致信息冗余和计算量暴增同时引入大量与表情无关的预训练偏置。另一个考虑是训练成本与收益的平衡。从零训练这个 6 层 CNN在 GTX 1060 上跑 30 个 epoch 只需要十几分钟而 ResNet18 在这个低分辨率任务上提升的准确率往往不超过 3 个百分点却把训练时间拉长了十几倍。对于课程设计来说“从零训练 结构能解释”比“微调 讲不清原理”的答辩效果好得多。如果最终要冲更高的指标可行的路径是先用 FER2013 从零训练再把学到的权重迁移到 CK 等小数据集上做微调。这属于进阶优化不作为第一版方案。4. 训练策略与调参让 loss 真正降下来4.1 训练循环的最小代码骨架模型精度的上限由数据决定但能否达到上限则由训练策略决定。从我的经验看让学生项目失败的往往不是网络结构而是训练流程里的低级错误梯度没有清零、模型没调成train模式、增强写到了验证集里。import torch from torch.utils.data import DataLoader def train_one_epoch(model, train_loader, criterion, optimizer, device): model.train() total_loss, correct, total 0.0, 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() total_loss loss.item() * images.size(0) preds outputs.argmax(dim1) correct (preds labels).sum().item() total labels.size(0) return total_loss / total, correct / total核心是四行optimizer.zero_grad()清空上一步的梯度否则梯度会跨 batch 累加loss.backward()反向传播计算梯度optimizer.step()更新参数。很多人漏掉第一行导致 loss 不减反增且越跑越不稳定。outputs.argmax(dim1)取每个样本得分最高的类别作为预测结果correct / total是这一轮 epoch 的训练准确率。验证阶段要加model.eval()并包裹torch.no_grad()让 BatchNorm 和 Dropout 切换到推理行为同时不再计算梯度减少显存占用。4.2 关键训练参数和它们的调整依据以下是一组在 FER2013 上表现稳定的初始参数参数推荐值说明输入尺寸48x48 灰度与 FER2013 一致不放大也不缩小batch_size64显存不足时优先降到 32优化器Adam默认betas(0.9, 0.999)适合小项目初始学习率0.001高于 0.003 通常无法收敛epoch30早停生效时不要硬跑满学习率调整step 每 10 epoch 降为 0.1 倍让后期训练更稳定损失函数CrossEntropyLoss 类别权重对应上面给的 label_to_weight如果更换为 SGD Momentum训练时间会显著变长但最后的泛化精度通常比 Adam 略好。一个务实的折中方案是前 10 个 epoch 用 Adam 快速下降之后切到 SGD(momentum0.9) 做精细调整。对于课程设计直接用 Adam 就够了。有个容易观察到的现象验证集 loss 先降后升但验证集准确率还能继续缓慢上涨。这并不矛盾loss 对错误分类的概率更敏感后期模型在边界样本上的置信度反复横跳准确率却没掉。判断是否过拟合不能只看 acc 变成多少而是看训练集和验证集的 gap。4.3 识别过拟合的三个信号过拟合在这个任务里几乎是必然出现的关键是要尽早发现。三个信号按出现顺序排列如下。第一训练损失持续下降但验证损失在第 5 到第 8 个 epoch 后开始反弹。这是最常见的信号说明模型开始记忆训练样本的噪声了。第二训练准确率超过 90% 而验证准确率在 60% 附近二者差值超过 20 个百分点。第三把某张训练样本单独拿出来预测模型能正确分类但只要对图片做任意轻微扰动比如平移 1 个像素预测结果立刻变化。这说明模型依赖了某些脆弱的局部纹理模式。针对这些信号系统的应对顺序是先增加 Dropout 的比例0.5 → 0.6再增加数据增强强度提高 rotate 角度到 15 度最后才是缩小网络容量。不要一开始就加正则项或调小模型那样会同时伤害训练集的拟合能力。4.4 混淆矩阵与类别召回率分析准确率是最直观的指标但对表情识别来说它不够。由于类别不平衡模型可能把“厌恶”几乎全部预测错但整体准确率依然有 70% 以上这个“还行”的数字会掩盖致命缺陷。我会在每个 epoch 结束时同时输出混淆矩阵。from sklearn.metrics import confusion_matrix import numpy as np def evaluate_confusion(model, val_loader, device): model.eval() all_preds, all_labels [], [] with torch.no_grad(): for images, labels in val_loader: images images.to(device) outputs model(images) preds outputs.argmax(dim1).cpu().numpy() all_preds.extend(preds) all_labels.extend(labels.numpy()) cm confusion_matrix(all_labels, all_preds) print(混淆矩阵:\n, cm) recall np.diag(cm) / np.maximum(cm.sum(axis1), 1) print(各类召回率:, dict(zip(range(7), np.round(recall, 3))))代码逻辑是在验证集上跑一遍完整的 forward收集预测结果和真实标签交给sklearn生成7x7的混淆矩阵。矩阵第i行第j列表示真实类别为i、被预测为j的样本数。recall是每类真实样本中被正确预测的比例比accuracy更能定位模型在哪几类上失效。从实践来看最容易混淆的三组类别恐惧和惊讶、难过和中性、生气和厌恶。这些混淆在人类标注上也是存在的。如果目标是答辩重点不是让混淆矩阵变成完美对角矩阵而是能解释清楚“为什么恐惧会被识别成惊讶”——因为它们在外观上共享了睁大眼睛和张嘴的区域性特征。5. 推理流程、视频稳定性处理与答辩提分技巧5.1 从训练好的模型到单张图片推理拿到标题里“训练好的模型”之后不能只会在训练脚本里调用它。最终交付物应该是一个独立的预测脚本加载.pth权重文件输入一张人脸图片输出表情类别和置信度。import cv2 import torch import torch.nn.functional as F def predict_emotion(model, img_gray, device, label_names): model.eval() img cv2.resize(img_gray, (48, 48)) img img / 255.0 tensor torch.tensor(img, dtypetorch.float32).unsqueeze(0).unsqueeze(0) tensor (tensor - 0.5) / 0.5 # 与训练时的 Normalize 保持一致 tensor tensor.to(device) with torch.no_grad(): logits model(tensor) probs F.softmax(logits, dim1) top1 probs.argmax(dim1).item() confidence probs[0][top1].item() return label_names[top1], confidence这里的重点与训练时完全对应灰度图缩放为 48x48、除以 255 归一化到[0,1]、再通过(x - 0.5) / 0.5映射到[-1,1]。这三步与transforms.Normalize(mean0.5, std0.5)等价不能在推理阶段省略或改顺序。用F.softmax把全连接层输出的 logits 转换成概率分布然后取 top-1 作为最终类别。model.eval()关闭 Dropout 和 BatchNorm 的随机行为。很多同学会把训练时的model.state_dict()保存成model.pt但加载时忘了model.load_state_dict(torch.load(...))结果预测结果随机。保存权重比较稳妥的写法是torch.save(model.state_dict(), model.pth)加载时先实例化模型再load_state_dict。5.2 视频实时表情识别的稳定性滑动窗口投票视频帧上的表情识别经常出现结果抖动某一帧是开心下一帧变成中性再下一帧又变回开心。逐帧独立预测的噪声会让演示效果大打折扣。常见做法是维护一个长度为N的预测队列每次输出队列中出现频率最高的类别。我一般取N5因为窗口太短平滑效果差太长则导致表情切换有明显延迟from collections import deque class SmoothPredictor: def __init__(self, window_size5): self.queue deque(maxlenwindow_size) def update(self, pred_label): self.queue.append(pred_label) return max(set(self.queue), keyself.queue.count)deque(maxlen5)让队列自动丢弃最老的预测保留最近 5 帧的类别。max(set(...), keycount)取出现次数最多的类别输出。这个技巧不需要重新训练任何网络但对演示效果提升明显。需要注意的是这个平滑器应该用在“检测到人脸”之后。如果某一帧人脸检测失败就直接跳过这一帧不把空值填入队列。5.3 答辩现场容易被追问的四个技术点结合往年的答辩经验老师不太会追问“你的代码有多少行”更常问下面四个方向的问题。提前准备好回答口径比临场编答案更稳妥。第一为什么不用更大更深的网络回答口径不是“跑不动”而是“48x48 的低分辨率限制了深层网络能提取的信息浅层网络的参数量与数据规模更匹配从零训练也能收敛”。这个回答同时展示了模型容量与数据量匹配的思考。第二准确率为什么不是 90% 以上回答口径是数据本身的噪声。FER2013 是通过图片搜索关键词采集的存在大量难以辨认的样本。可以提到 FERPlus 论文里标注者之间的一致率也只有约 70%说明任务本身的容错率就是有限的。第三如何处理类别不平衡如果使用了类别权重可以指出损失函数中每个类别的权重值并展示混淆矩阵里小类别的召回率。第四人脸检测和表情识别为什么分成两步前者是目标检测问题人脸位置在哪后者是图像分类问题这张脸是什么表情。合在一起做会引入额外的定位误差拆分后可以单独评估每个模块的效果。5.4 演示时最实用的一组提分技巧演示环节的稳定性直接关系到项目评价。三个细节值得特别留意。第一个细节是演示优先用静态图片而不是摄像头实时画面。提前准备几张在不同角度、不同光照下拍摄的人脸照片运行一次推理并显示结果。这样避免了现场环境光线差、人脸检测失效的风险。如果必须做实时演示先把摄像头对准一张照片测试通过再说。第二个细节是同时打印 top-3 概率而不是只输出一个类别。当模型预测失败时top-2 或 top-3 里往往有真实类别这说明模型并非“完全不认识”该表情只是置信度排序有偏差。在答辩现场这个细节能把一次失败演示变成一个关于混淆概率的讨论。第三个细节是训练过程可视化。保存训练和验证的 loss 曲线、准确率曲线图片并在答辩 PPT 里给出“第 10 个 epoch 出现验证 loss 回弹”的观察再对应说明采取的早停或调整策略。这比“我用 Adam 训练了 30 轮”更能体现对训练过程的理解。一条可选的小技巧尝试去除训练集里不能通过人脸检测器的图片。FER2013 里其实混入了少量没有人脸或人脸模糊的图片这些样本会在训练时向模型注入噪声。通过 2.2 节的人脸检测过滤后再训练通常能在验证集上获得 1% 到 3% 的提升。这种方法成本极低却能在答辩时讲出一个“数据清洗”的完整故事。本文还有配套的精品资源点击获取