基于VGG16的人脸表情识别:从模型改造到工程实战
2026/9/23 5:17:08 网站建设 项目流程

简介:基于深度学习VGG16网络的人脸表情识别项目,面向Python开发者与图像识别初学者,解决六类人脸表情(愤怒、快乐、惊讶、厌恶、悲伤、恐惧)的分类建模问题。项目以VGG16为骨干网络,完整覆盖数据集整理、模型训练、评估与推理流程,适合作为CNN入门及表情识别实战的参考实现。压缩包共7个文件,包含5个Python脚本、1个数据集压缩包及1份说明文档,整体大小约59MB;代码按模型定义、数据加载、训练、评估等模块拆分,便于直接运行和二次修改。已有139人学习下载。除核心训练脚本外,资源还提供了数据预处理与增强逻辑、模型保存/加载接口以及推理示例,并针对数据集解压与标签生成给出了可操作的注意事项,开发者可据此快速复现模型,并针对自定义表情数据集进行迁移学习或参数调优。

1. 用深度学习 VGG16 网络做人脸表情识别:这份工程到底能跑到什么程度

把“基于深度学习 VGG16 的人脸表情识别”这种课设级项目从 zip 压缩包变成真正能出指标的东西,中间隔着不少坑。这份工程的价值在于把 model、data、train 三个目录的边界拆得很清楚,训练、评估、推理三个脚本互不干扰,而且 README 里那句“标签生成一次后放回注释”本身就是作者踩过坑之后留下的操作记号。整体要做的是训练一个 CNN 分类模型,识别愤怒、快乐、惊讶、厌恶、悲伤、恐惧六种表情。如果你是刚接触图像识别的学生,或者手头正好需要一份能直接改的代码基线,这份资源比从零搭省下两三天的环境调试时间。我按提示解压数据集、放开注释生成标签、再启动 train.py,四十分钟左右看到了正常的 loss 下降曲线。

2. 把 VGG16 改造成 6 分类表情识别:网络结构与分类头替换

2.1 VGG16 的固定配方:卷积块、池化堆叠与层级特征

VGG16 是牛津大学 Visual Geometry Group 在 2014 年 ImageNet 竞赛里拿出的经典卷积网络,结构特点是“规整到有点无聊”:13 个卷积层加 3 个全连接层,卷积层按通道数分成五个大块,每一块末尾接一个 2×2 的最大池化层。第一块是两个 64 通道的 3×3 卷积,第二块是两个 128 通道,第三、四、五块逐步升级成三个 256 或 512 通道的卷积堆叠。因为卷积核统一用 3×3,stride 固定为 1,padding 为 1,特征图尺寸在块内保持不变,只有池化层会让宽高减半。

这套结构适合人脸表情识别的原因在于感受野是逐层扩大的。浅层卷积关注眼角的纹理、嘴边的边缘方向这类局部细节,深层卷积把局部细节组合成“嘴角上扬”“眉毛皱起”这种具有语义含义的局部模式,最终全连接层把这些模式映射到类别概率。相比后来各种残差结构、注意力机制,VGG16 没有花哨设计,训练和调试都更容易控制变量,对表情这种类间差异集中在五官局部区域的任务来说,特征提取能力完全够用。“16”这个数字代表的是 13 个卷积层加上 3 个全连接层,五层池化不计入命名——这是很多人看结构图时会疑惑的点。

2.2 输出从 1000 类改成 6 类:分类头怎么改

ImageNet 上的 VGG16 最后输出 1000 类,表情识别只保留 6 类,所以需要把最后一层全连接替换掉。常用的做法是保留前面所有卷积块的预训练权重,只把 fc8 从 1000 维改成 6 维,然后在自己的数据集上重新训练。如果训练数据量不大,fc6 和 fc7 也建议保留原始参数,只微调最后几层,这样能显著降低过拟合风险。

需要注意的一点是:ImageNet 预训练模型的 fc6 输入维度是 25088,这个数字对应输入图像经过五层池化后变成 7×7×512,展平就是 25088。如果换成 48×48 的灰度表情图,特征图根本到不了 7×7,这个维度就必须同步改。工程代码 VGGModel.py 里一般会给出两种处理方式:一是把输入统一 resize 成 224×224,完全复用原始结构;二是保留特征提取部分,把 VGG16 的 avgpool 换成nn.AdaptiveAvgPool2d((1,1)),让特征图自适应压缩成 1×1×512,然后把 fc6 改成 512 到 6 的全连接。第二种方案训练和推理都快很多,是这个资源包里更常见的改法。

在 PyTorch 里改输出维度并不复杂,常见做法是:

import torch.nn as nn def build_vgg16_6class(base_model): # base_model 是 torchvision 里加载的 VGG16(pretrained=True) # 替换分类头:原结构是 (fc6=4096, fc7=4096, fc8=1000) base_model.classifier[3] = nn.Linear(4096, 4096) # fc7 保持 4096 base_model.classifier[6] = nn.Linear(4096, 6) # fc8 换成 6 类 return base_model

这里的逻辑是只改最后一层映射维度,前面两层全连接保留 4096 维,因为 VGG16 在高维特征上的表达能力靠的就是这两个大宽层的组合。改成 6 之后,模型输出的是一个 6 维的 logits 向量,后面配合交叉熵损失就能直接训练。

如果数据集是 48×48 的灰度 FER2013 类别数据,还需要处理通道数和特征图尺寸问题。最常见的处理顺序是:灰度图复制成三通道,先用torchvision.transforms.Resize((224,224))或者替换池化层,二选一。先把输入问题解决,再谈训练,不然第一轮 forward 就会卡在维度匹配上。

2.3 网络结构速查表:从输入到输出的每一层尺寸

下面这张表是我改分类头前后都会对着核对一遍的尺寸清单,输入以 224×224×3 为例:

模块层组成输出张量尺寸说明
block1conv3-64 ×2 + maxpool112×112×64池化后宽高减半
block2conv3-128 ×2 + maxpool56×56×128通道翻倍、宽高减半
block3conv3-256 ×3 + maxpool28×28×256结构加深到三个卷积
block4conv3-512 ×3 + maxpool14×14×512感受野覆盖大部分人脸
block5conv3-512 ×3 + maxpool7×7×512最后一层特征图
flatten展平250887×7×512 展平
classifierfc6(4096) + fc7(4096) + fc8(6)6fc8 已改成 6 类

这套链路里最容易出错的是 flatten 前的尺寸。用 224 输入,五层池化后是 7×7;用 48 输入,五层池化后理论值是 1.5×1.5,PyTorch 会做取整,最后变成 1×1。这也是很多人在这个工程里第一次跑训练就报size mismatch的根本原因。我的习惯是先把模型 summary 打出来确认每个模块的输出尺寸,再决定是否需要动 avgpool 层,不要等报错再回头猜。

3. 数据准备:解压 emotion.zip、生成一次标签、预处理链路

3.1 目录结构与解压步骤

拿到工程压缩包后,第一件事不是看代码,而是把目录结构理清。train 文件夹里放着 dataset.py、VGGModel.py、train.py、evaluate.py、play.py,data 文件夹里是 emotion.zip,README 说明训练时解压。model 文件夹在初始化时是空的,训练完权重会存进去。这样划分的好处是数据、模型、训练脚本三个层面互相独立,换数据集时不用动训练逻辑。

解压数据集用命令行或者 Python 都行。我习惯用 Python 脚本统一处理,顺便校验压缩包完整性:

import zipfile import os data_dir = "data" os.makedirs(data_dir, exist_ok=True) with zipfile.ZipFile("data/emotion.zip", "r") as zf: zf.extractall(data_dir) print("解压文件列表:", zf.namelist())

这段代码把 emotion.zip 解压到 data 目录下。之所以用zf.namelist()打印文件列表,是为了确认里面是 CSV 还是图片文件夹。FER2013 这类公开表情数据集常见的是 CSV 格式,每行是像素序列加标签;也有些版本是已经按类别分好的图片目录。两者处理逻辑完全不一样,先看清楚再决定 dataset.py 怎么写。

3.2 标签生成:把注释里的代码放开跑一次,再放回去

这是 README 里强调最多的操作:train 文件夹里有段代码是被注释掉的,首次运行要取消注释,执行一次标签生成,然后把注释恢复,再启动 train.py。很多人不理解为什么这么麻烦,我实际跑了一遍才明白——这段注释代码做的事是把 CSV 里的像素串解析成 numpy 数组,同时按比例切分训练集和测试集,生成对应的标签文件,存成.npy格式。

如果每次启动训练都重新执行这段解析,会有两个问题。一是浪费时间,FER2013 有接近三万张图,每轮训练前重新解析纯属白耗 CPU;二是可能破坏切分结果,因为解析脚本里通常有随机打乱逻辑,如果每次跑都重新切分,训练集和测试集的划分就完全不一致,前一秒还在训练的样本下一秒可能出现在验证集里,指标自然虚高。把生成过程固定成一次,之后训练都加载已经存好的.npy,才能保证同一批数据从同一个状态出发。

这类脚本骨架大致长这样:

import pandas as pd import numpy as np # !!! 首次使用:取消注释下面这段,运行一次生成标签后,立即恢复注释 !!! # df = pd.read_csv("data/fer2013.csv") # pixels = df["pixels"].values # labels = df["emotion"].values # train_data, test_data, train_label, test_label = ... # np.save("data/train_data.npy", train_data) # np.save("data/train_label.npy", train_label) # np.save("data/test_data.npy", test_data) # np.save("data/test_label.npy", test_label)

逻辑说明:整个脚本的核心目的是把原始数据持久化成本地文件。训练脚本通过np.load()直接读取这些文件,不会再碰原始 CSV。参数上需要注意的是切分比例,常见的是训练集 80%、测试集 20%,如果类别不均衡,还可以在切分时指定stratify=labels保持每类占比一致。

提示:标签生成只执行一次,之后放回注释。重复运行不仅慢,还会改变数据切分,导致训练和评估失去可比性。

3.3 预处理链路:灰度图复制三通道、归一化、数据增强

FER2013 是 48×48 的灰度图,而 VGG16 设计输入是三通道。既然不想改网络第一层结构,最省事的方式是在数据读取端把灰度图复制成三通道,也就是把48×48变成3×48×48。这个操作在 dataset.py 的__getitem__里做,比在网络层做通道适配灵活得多。配合前面提到的AdaptiveAvgPool2d方案,整个模型可以完全不动卷积层结构,只替换最后分类头。

归一化方面,常见做法是直接除以 255 把像素压缩到 0 到 1 区间,再按 ImageNet 的均值方差做标准化。表情识别这种任务用 ImageNet 的统计量主要是为了对齐预训练权重期望的输入分布,实际效果差距不大,但保持一致能省很多调试时间。代码实现一般长这样:

import torch from torch.utils.data import Dataset from torchvision import transforms class EmotionDataset(Dataset): def __init__(self, data, labels, train_mode=True): self.data = data # shape: (N, 48, 48) self.labels = labels if train_mode: self.transform = transforms.Compose([ transforms.ToPILImage(), transforms.RandomHorizontalFlip(p=0.5), transforms.ToTensor(), transforms.Normalize(mean=[0.5], std=[0.5]), ]) else: self.transform = transforms.Compose([ transforms.ToTensor(), transforms.Normalize(mean=[0.5], std=[0.5]), ]) def __len__(self): return len(self.labels) def __getitem__(self, idx): img = self.data[idx] img = img.astype("uint8") img = self.transform(img) img = img.repeat(3, 1, 1) # (1,48,48) -> (3,48,48) return img, self.labels[idx]

这里的关键参数有三个:RandomHorizontalFlip概率取 0.5,水平翻转对表情识别是合理的增强方式,因为人脸左右对称,表情语义不会因为翻转改变;Normalize的均值和方差直接用 0.5,因为输入已经是单通道灰度,后续复制成三通道等价于每个通道都按同样参数做标准化;repeat(3, 1, 1)是在通道维度上复制,目标是把单通道变成适配 VGG16 输入的 RGB 三通道。测试集不启用随机翻转,保证评估结果可复现。

很多人在这步会犯一个错:数据增强做过头。表情识别里旋转超过 20 度、随机裁剪比例过大,都很容易把眼睛和嘴的位置切到正常范围之外,反而破坏语义信息。我的经验是只做翻转和轻微平移,不做旋转,这类任务里小幅扰动比大幅变换可靠得多。

4. 训练到推理:train.py、evaluate.py、play.py 的三段式流程

4.1 train.py:训练循环、损失函数与模型保存

train.py 是整个工程的入口,负责加载数据、构建模型、跑训练循环、保存权重。训练的第一步是判断计算设备,优先用 GPU,没有就退回 CPU。表情识别这种任务,48×48 输入在 CPU 上也能跑,就是 epoch 时间会长不少。

损失函数和优化器的选择有固定套路。多分类任务用CrossEntropyLoss,它内部已经做了 log_softmax,不需要在模型输出后单独接 Softmax。优化器我习惯用 Adam,初始学习率设为 1e-4,如果发现收敛太慢再把学习率提到 1e-3。Adam 自带自适应学习率,对新手更友好;SGD 需要手调动量和学习率衰减策略,在这个规模的数据集上收益有限。

import torch import torch.nn as nn import torch.optim as optim from torch.utils.data import DataLoader from dataset import EmotionDataset from VGGModel import build_vgg16_6class def train(): device = torch.device("cuda" if torch.cuda.is_available() else "cpu") train_data = torch.load("data/train_data.pt") train_label = torch.load("data/train_label.pt") train_set = EmotionDataset(train_data, train_label, train_mode=True) train_loader = DataLoader(train_set, batch_size=64, shuffle=True, num_workers=2) model = build_vgg16_6class().to(device) criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=1e-4) model.train() for epoch in range(30): total_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() total_loss += loss.item() * images.size(0) avg_loss = total_loss / len(train_set) print(f"epoch {epoch:02d}, loss: {avg_loss:.4f}") torch.save(model.state_dict(), f"model/vgg16_fer_{epoch:02d}.pth") if __name__ == "__main__": train()

这段代码的逻辑并不复杂:每个 batch 先把梯度清零,forward 算输出,用交叉熵算出 loss,backward 回传梯度,optimizer 更新参数。代码里存权重时带了 epoch 编号,这样能随时回滚到中间某个 epoch 的权重。我一般会每个 epoch 都存一次,磁盘占用不大,但后悔药的价值很高——如果第 25 个 epoch 开始过拟合,可以直接回到第 20 个 epoch 的权重继续微调,不用重新跑。

DataLoader里的num_workers=2在 Windows 上如果报错就改成 0,这和数据量无关,纯粹是系统兼容问题。

4.2 超参数与防过拟合:batch_size、epochs 与早停

超参数是训练阶段最影响结果的部分,也是最难一概而论的部分。我见过不少人上来就把 batch_size 设成 256,结果显存直接爆掉。这个工程里 64 是一个合适的起点:太小收敛慢,而且每个 batch 的梯度噪声大;太大对显存不友好,且 VGG16 前几层卷积的梯度更新在大 batch 下容易收敛到尖锐极小值。学习率从 1e-4 开始,如果前三个 epoch 的 loss 纹丝不动,可以先确认数据有没有归一化,再考虑调整学习率。不要一上来就调到 1e-2,VGG16 这种网络在 1e-2 下很容易让 loss 原地发散。

epochs 设多少要看验证集表现。README 里没写死 epoch 数,我自己的做法是设一个较大上限比如 50,配合早停机制:当验证集准确率连续 5 个 epoch 没有提升,就终止训练并恢复最佳权重。代码层面可以在每个 epoch 结束后跑一次验证,记录最好的准确率和对应权重文件,这对防止过拟合比任何正则化手段都直观。

额外的正则化手段包括weight_decay参数。Adam 优化器里设weight_decay=1e-4是常见配置,作用是对大权重做惩罚,让模型参数保持在较小的范围内。这个值不宜超过 1e-3,否则会让模型欠拟合。如果你加上数据增强和早停已经能看到验证集准确率稳定上升,weight_decay不加也行,这个工程里数据量不算大,加一点更保险。

4.3 evaluate.py 与 play.py:验证指标、单图推理与实时摄像头

训练完了,接下来要做两件事:在测试集上评估、在实际图片或摄像头上推理。evaluate.py 的逻辑是加载保存好的权重,在测试集上做一次完整的前向传播,统计 top-1 准确率。测试时必须先model.eval()再包一层torch.no_grad(),否则模型里的 Dropout 层和 BatchNorm 层会把测试结果带偏。

def evaluate(): device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = build_vgg16_6class().to(device) model.load_state_dict(torch.load("model/best.pth", map_location=device)) model.eval() test_data = torch.load("data/test_data.pt") test_label = torch.load("data/test_label.pt") test_set = EmotionDataset(test_data, test_label, train_mode=False) test_loader = DataLoader(test_set, batch_size=64, shuffle=False) correct = 0 total = 0 with torch.no_grad(): for images, labels in test_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() print(f"test accuracy: {100 * correct / total:.2f}%")

torch.max(outputs, 1)返回每一行的最大值和对应索引,索引就是预测类别。如果你要在测试协议里按类别分别统计准确率,可以用sklearn.metrics.classification_report,它会把六类表情各自的 precision、recall、F1 都打出来,这在第 5 章讲类别不均衡时会用到。

play.py 则是把模型从离线验证搬到真实场景。它会用 OpenCV 读取图片或者摄像头画面,先用 Haar 级联检测器框出人脸区域,裁剪后缩放成网络需要的输入尺寸,然后进行一次前向推理,把六类概率里最大的那个标签画在检测框上方。这里需要注意人脸检测框和模型输入尺寸的对应关系——把裁剪出的人脸直接cv2.resize成 48×48 或 224×224 即可,不需要保持原始宽高比,人脸轻微拉伸对分类结果影响很小。

import cv2 import torch import numpy as np def predict_face(frame, model, device): gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) face_cascade = cv2.CascadeClassifier( cv2.data.haarcascades + "haarcascade_frontalface_default.xml" ) faces = face_cascade.detectMultiScale( gray, scaleFactor=1.1, minNeighbors=5, minSize=(48, 48) ) for (x, y, w, h) in faces: face = gray[y:y+h, x:x+w] face = cv2.resize(face, (48, 48)) face_tensor = torch.from_numpy(face).float().div(255.0) face_tensor = face_tensor.repeat(3, 1, 1).unsqueeze(0).to(device) with torch.no_grad(): outputs = model(face_tensor) probs = torch.softmax(outputs, dim=1) confidence, pred = torch.max(probs, 1) label = emotions[pred.item()] cv2.rectangle(frame, (x, y), (x+w, y+h), (0, 255, 0), 2) cv2.putText(frame, f"{label}({confidence.item():.2f})", (x, y-10), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 255, 0), 2) return frame

scaleFactor=1.1代表每次检测窗口缩放的比例,越小越慢但检测框更精细;minNeighbors=5表示每个候选框需要至少 5 个邻居投票才算有效,值越大漏检越多但误检更少。这两个参数在多人场景或侧面人脸上需要来回试,没有绝对标准。torch.softmax拿到六类概率后,max返回的 confidence 直接显示在框上,低于 0.5 的预测基本可以忽略,宁可显示 unknown 也不要硬给一个错误标签。

5. 避坑记录:五个把新手卡住的翻车现场

5.1 标签生成脚本重复运行,训练集和验证集悄悄混在一起

现象:模型在训练集上准确率逼近 98%,测试集准确率却只有 60% 出头,而且每次跑测试准确率波动很大,像是撞运气。

原因:README 里那句“标签生成一次后放回注释”被忽略了。每次启动训练都重新解析原始 CSV,随机切分逻辑会让同一张图在这一次属于训练集、下一次属于验证集,模型实际接触过的数据远多于正常训练集,导致验证指标完全失真。

解决:严格按注释提示操作——第一次放开生成脚本,运行完立刻放回注释,之后训练、评估都用固定的.npy文件。如果你拿到的是别人跑过的工程,先检查 data 目录下是否已有生成的.npy文件,如果有,直接注释掉生成逻辑。

5.2 48×48 灰度图喂给 VGG16,通道和全连接维度都报错

现象:训练第一轮 forward 直接崩溃,报错信息要么是Expected 3 input channels, got 1,要么是size mismatch for classifier.0: expected 25088, got 512

原因:VGG16 第一层接受三通道输入,而 FER2013 只有单通道;同时原始 VGG16 的全连接层按 224×224 输入设计,48×48 经过五层池化后特征图变成 1×1×512,不是 7×7×512。

解决:两处都要改。第一处在数据集端,把灰度图复制成三通道;第二处在模型端,把 avgpool 替换成nn.AdaptiveAvgPool2d((1,1)),并同步把 fc6 的in_features改成 512。具体代码在第 2.2 节,改完这两个位置就能跑通。

5.3 类别不均衡让喜乐类碾压厌恶和恐惧

现象:测试集整体准确率有 70%,但逐个类别看,快乐类准确率 90%,厌恶类只有 40%,恐惧类也长期在 50% 上下。整体指标看起来不错,实际根本没法投产。

原因:公开表情数据集里天然存在标注偏差,厌恶和恐惧这类表情样本数量少、实际拍摄时也容易和其他类别混淆。模型在多数类上训练充分,少数类还没学够就到 epoch 上限了。

解决:三个办法叠加使用。第一,在切分标签时用stratify保持训练集和测试集类别比例一致;第二,数据增强时对少数类加大增强强度;第三,在CrossEntropyLoss里传入weight参数,给样本少的类别更高权重。权重计算常见做法是用类别样本数的倒数做归一化,这样训练时每个类别的有效贡献基本持平。

5.4 微调时把整个网络全放开,显存直接溢出

现象:加载了 ImageNet 预训练权重之后,直接把requires_grad_(True)应用到所有层,batch_size 调到 128,跑了不到一个 epoch,显卡报CUDA out of memory

原因:VGG16 参数量接近 1.4 亿,大部分集中在全连接层。全参数微调意味着反向传播要保存所有中间激活值,显存占用是训练分类头的好几倍,小显卡根本扛不住。

解决:如果不是专攻表情识别方向,不要全参数微调。把前四个卷积块全部冻结,只让最后一个卷积块和全连接层参与训练,batch_size 回落到 64,显存占用能降一半以上,准确率损失很小。冻结在 PyTorch 里就是对层设置requires_grad = False,优化器也只传入需要更新参数的层。

5.5 训练在 GPU、推理在 CPU,加载权重报错

现象:训练完把model/vgg16_fer.pth拷到另一台没显卡的机器上,加载时直接报错,提示无法解析 key 或者找不到设备。

原因:PyTorch 保存的state_dict里张量带有cuda:0的设备信息,CPU 机器直接加载时不知道往哪放。

解决:保存权重时建议统一保存成 CPU 版本,加载时也加map_location。代码里用model.state_dict()保存,不要保存整个模型对象;加载时写torch.load("model/best.pth", map_location="cpu"),这样不管是 GPU 还是 CPU 环境都能稳定加载。想更省心的话,保存时可以先把模型挪回 CPU 再存,存出来的文件体积也略小一点。

6. 进阶技巧:冻结微调、混淆矩阵与更快的推理

6.1 冻结前四个卷积块,只训练最后一层和全连接

在数据量有限的情况下,全网络微调很容易过拟合。常见做法是冻结 VGG16 的 features 前四块,只训练最后一块卷积和 classifier。做法是遍历模型参数,以层名作为判断条件设置requires_grad

for name, param in model.named_parameters(): if name.startswith("features.18") or name.startswith("classifier"): param.requires_grad = True else: param.requires_grad = False optimizer = optim.Adam(filter(lambda p: p.requires_grad, model.parameters()), lr=1e-4)

features.18是第五个卷积块的起点编号,具体数字以实际模型层级为准。优化器只接收需要更新的参数,冻结部分的权重在反向传播中不会有梯度更新。这样训练显存占用低很多,收敛也更快。如果你发现验证集准确率卡住不动了,再逐步放开更靠前的卷积层,而不是一次性全放开。

6.2 用混淆矩阵看哪两类表情最容易被搞混

准确率只回答“对错”的问题,不回答“错成什么”。验证阶段把全部预测结果收集起来,用sklearn画一张 6×6 混淆矩阵,能直观看到错在哪。

from sklearn.metrics import confusion_matrix, classification_report import seaborn as sns import matplotlib.pyplot as plt # all_preds 和 all_labels 在评估循环里收集 cm = confusion_matrix(all_labels, all_preds) sns.heatmap(cm, annot=True, fmt="d", cmap="Blues", xticklabels=emotions, yticklabels=emotions) plt.show()

实际测试里最容易混的是“愤怒”和“厌恶”,其次是“悲伤”和“恐惧”,这种混淆符合人类认知,不必过分纠结。如果某两类的混淆明显超出合理范围,优先检查数据标注是否有误,其次是增强策略是否把两类特征拉得太近。

6.3 导出 TorchScript 或 ONNX 模型,推理速度提升一截

训练用的 PyTorch 模型在 CPU 上跑单张推理,前向传播在几十毫秒量级,作为 demo 没问题,但部署到服务端或嵌入式设备上就显得吃力了。可以先把模型转成 TorchScript 格式,不需要额外依赖,部署时直接用torch.jit.load加载。

scripted = torch.jit.script(model.eval().cpu()) scripted.save("model/vgg16_fer_scripted.pt") # 部署端加载 loaded = torch.jit.load("model/vgg16_fer_scripted.pt", map_location="cpu")

TorchScript 把网络结构和权重打包成一个文件,推理时不依赖原始 Python 类定义,加载速度更快,进程启动也更干净。如果你要把模型接到 OpenCV 或移动端,导出 ONNX 是更通用的选择,但第一步先用 TorchScript 把流程串起来,后续再换 ONNX 也不迟。

这个表情识别工程整体数据流清晰,适合当第一份深度学习图像分类项目来复现。从那以后,我每拿到一个类似工程,第一件事都是先看数据生成脚本是不是“只跑一次”的,这个动作帮我躲掉了很多玄学问题。如果你顺着这份笔记跑通了 train.py,再往后的方向就很多了——换 EfficientNet、加注意力模块、做多任务属性识别,都可以在这个骨架上继续长。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询