简介:面向图像识别初学者与深度学习开发者,这份资源基于VGG16网络实现人脸表情识别,可精准区分愤怒、快乐、惊讶、厌恶、悲伤、恐惧六种表情,从数据集的整理与扩充、图像尺寸统一和归一化,到网络结构微调、训练参数配置与模型保存,覆盖完整项目链路,可直接用于课程设计、毕业设计或入门实战。压缩包共7个文件,含5个Python脚本、1个数据集压缩包和1个说明文档,整体大小59.23MB,目录组织清晰;脚本分别承担模型定义、数据集加载、训练、评估等职责,便于按需查看与修改。已有139人学习下载。下载后既能加载现成训练模型开展表情识别,也可参照脚本理解VGG16迁移学习的实现细节,包括数据增强、全连接层改造、超参数调整等关键操作,是快速上手深度学习图像分类任务的一份实用资源。
1. 用VGG16做人脸表情识别,先搞清楚要解决什么问题
人脸表情识别在安防、教育、医疗等领域有实际需求,比如课堂专注度分析、驾驶员疲劳监测。但表情识别不同于人脸识别,类内差异小、类间相似度高,像“生气”和“厌恶”的区分极依赖数据质量。常见做法是直接使用预训练的VGG16做迁移学习,而不是从头训练。VGG16在ImageNet上学习过边缘、纹理等通用特征,这些特征迁移到人脸表情任务上能显著减少训练时间,并且在小数据集上也能收敛。内容面向想把VGG16真正跑通的算法工程师和研究生,从数据预处理、模型改造、训练调参到实时推理,给出可复现的完整方案。
2. 人脸表情数据集与预处理:模型效果的上限由数据决定
2.1 常用数据集:FER2013、CK+ 和 AffectNet 怎么选
人脸表情识别的公开数据集有很多,但选型会直接影响 VGG16 的输入设计和评估指标。FER2013 有 35887 张 48x48 灰度图,7 类表情(angry, disgust, fear, happy, sad, surprise, neutral),适合做基准测试,但网上广为流传的 CSV 版本存在标签噪声。CK+ 是实验室录制的人脸序列,样本量小(约 593 个序列),但表情强度变化自然,适合做验证集。AffectNet 规模大,有超过 45 万张图像,包含 8 类表情和强度标注,但下载需要申请授权。
| 数据集 | 图像尺寸 | 样本量 | 类别 | 适用场景 |
|---|---|---|---|---|
| FER2013 | 48x48 灰度 | 约 3.5 万 | 7 | 基准对比、调参 |
| CK+ | 640x490 | 序列帧 | 7 | 精度验证、细粒度分析 |
| AffectNet | 256x256 | 约 45 万 | 8 | 大规模训练、迁移预训练 |
我一般会先用 FER2013 跑通流程,因为它的数据是现成的 CSV,加载速度快;等模型稳定后再用 AffectNet 扩充训练数据。注意 FER2013 的 48x48 分辨率对 VGG16 来说过低,VGG16 的输入通常是 224x224,因此需要上采样到 224x224,这一步会引入插值噪声,但实验表明对最终准确率影响不大。
2.2 人脸检测与对齐:用 OpenCV 的 DNN 模块做关键点对齐
如果数据来自真实场景,比如摄像头抓拍,那么原始图像里除了人脸还有背景。直接喂给 VGG16 会引入大量无关特征。常见做法是先做人脸检测,再根据关键点做仿射对齐。这里推荐 OpenCV 的 DNN 模块加载预训练的 Caffe 模型,不需要额外安装深度学习框架。
import cv2 import numpy as np # 加载人脸检测器与关键点检测器 face_net = cv2.dnn.readNetFromCaffe('deploy.prototxt', 'res10_300x300_ssd_iter_140000.caffemodel') landmark_net = cv2.dnn.readNetFromTensorflow('facial_landmark_model.pb') def align_face(img, margin=0.2): h, w = img.shape[:2] blob = cv2.dnn.blobFromImage(img, 1.0, (300, 300), (104.0, 177.0, 123.0)) face_net.setInput(blob) dets = face_net.forward() for i in range(dets.shape[2]): conf = dets[0, 0, i, 2] if conf > 0.7: box = dets[0, 0, i, 3:7] * np.array([w, h, w, h]) x1, y1, x2, y2 = box.astype(int) # 扩大边界,让眼睛和嘴巴对齐后仍然在框内 dx = int((x2 - x1) * margin) dy = int((y2 - y1) * margin) x1 = max(0, x1 - dx); y1 = max(0, y1 - dy) x2 = min(w, x2 + dx); y2 = min(h, y2 + dy) face = img[y1:y2, x1:x2] return face return img这段代码先用 SSD 做人脸框检测,再根据置信度过滤。关键点对齐可以进一步用 landmark 模型,这里省略了仿射变换矩阵计算。传递到 VGG16 之前的图像,建议先缩放到 224x224,再归一化到 [0,1] 或按 ImageNet 的 mean/std 处理。
2.3 数据增强:让 VGG16 不因为样本太少而过拟合
FER2013 虽然有 3 万张,但每个类别分布很不均匀,比如 disgust 只有约 600 张,而 happy 有近 9000 张。数据增强在这里不只是为了凑数,更是为了让模型对旋转、光照和裁剪鲁棒。常见选项包括随机水平翻转、小角度旋转(±10°)、亮度扰动和随机裁剪。
from torch.utils.data import Dataset from torchvision import transforms train_transform = transforms.Compose([ transforms.Resize((256, 256)), transforms.RandomCrop(224), transforms.RandomHorizontalFlip(p=0.5), transforms.RandomRotation(degrees=10), transforms.ColorJitter(brightness=0.2, contrast=0.2), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ])提示:验证集的 transform 只保留 Resize 和 Normalize,不要加入随机裁剪,否则评估指标会失真。
建议将训练集和验证集的增强分开,验证集只做 Resize 和 Normalize,不要加入随机裁剪,否则评估指标会失真。另外,PyTorch 的 RandomCrop 在输入尺寸小于 224 时会报错,所以先 Resize 到 256 再裁剪是常用手法。
3. 用 PyTorch 搭建 VGG16 迁移学习模型:替换分类头是关键
3.1 VGG16 的结构与迁移学习原理
VGG16 由 13 个卷积层和 3 个全连接层组成,卷积层使用 3x3 卷积核,激活函数为 ReLU。它通过连续的卷积和池化把 224x224 输入压缩成 512 通道的 7x7 特征图,再展平后送入全连接层。人脸表情识别任务中,我们想要的不是 1000 类 ImageNet 分类结果,而是 7 类表情,因此必须替换最后两层全连接。
迁移学习的逻辑是:卷积层学习到的是通用视觉特征,比如边缘、颜色和纹理,这些特征与任务无关。而 VGG16 在 ImageNet 上训练多年,参数非常成熟,直接用这些权重初始化我们的模型,比随机初始化收敛更快,最终准确率也更高。这就是为什么要在 VGG16 上做微调,而不是从零构造 CNN。
3.2 修正全连接层以适配 7 类表情
在 PyTorch 中,用models.vgg16(pretrained=True)加载预训练权重后,需要把classifier[6]替换成新的全连接层。VGG16 的原始分类器是Linear(4096, 1000),我们改成Linear(4096, 7)。
import torch import torch.nn as nn from torchvision import models def build_vgg16_fer(num_classes=7, freeze_conv=True): model = models.vgg16(weights=models.VGG16_Weights.IMAGENET1K_V1) # 替换最后一个全连接层 in_features = model.classifier[6].in_features model.classifier[6] = nn.Linear(in_features, num_classes) if freeze_conv: # 冻结所有卷积层参数,只训练分类器 for param in model.features.parameters(): param.requires_grad = False return model这里的in_features从原始全连接层读取,避免硬编码 4096。freeze_conv=True表示冻结卷积层,只训练新加的 7 类全连接层,这种做法适合小数据集;如果数据量达到几千张以上,可以冻结较浅层、解锁较深层进行微调。
3.3 冻结部分卷积层与特征提取参数说明
冻结层的选择需要实验验证。VGG16 前几层学习到的是基础边缘特征,对任何视觉任务都有用;后几层则逐渐偏向 ImageNet 特有的物体形状,与表情任务相关性较低。因此常见的微调策略有两种:
- 完全冻结卷积层,只训练分类器。此时模型相当于一个固定的特征提取器。训练速度快,但精度上限低。
- 冻结前面 10 层,解开后面 3 层卷积和全连接层。这样可以让高层特征针对表情任务重新调整,准确率通常能再提升 2~3 个百分点。
我一般采用第二种方案,因为 FER2013 的 3 万张数据足以支撑浅层微调。设置优化器时,注意为不同层设置不同学习率,比如解锁层的学习率为 1e-4,新全连接层的学习率为 1e-3。这个技巧能避免因学习率过大破坏预训练权重。
4. 训练配置与调参:让 VGG16 收敛的 5 个关键参数
4.1 损失函数与优化器选择
人脸表情识别本质上是一个多分类问题。对于 7 类表情,最常用的是交叉熵损失(CrossEntropyLoss)。考虑到类别不均衡,可以在损失函数中传入每个类别的权重,让模型更关注样本少的类别。设置权重时,可以按1 / 类别出现频率计算。
优化器选择上,Adam 和 SGD 都是常见选择。Adam 收敛快,适合快速实验;SGD 配合 momentum 和 cosine 学习率衰减,往往能达到更高的精度。因为 VGG16 参数量大(约 1.38 亿),直接用默认学习率很容易发散,需要优先确定合适的学习率范围。
from torch import optim # 按需冻结后过滤需要更新的参数 trainable_params = [p for p in model.parameters() if p.requires_grad] optimizer = optim.SGD(trainable_params, lr=0.001, momentum=0.9, weight_decay=1e-4) scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=30) criterion = nn.CrossEntropyLoss(weight=class_weights)学习率 0.001 只是起点,实际需要用小批量数据做学习率预热测试。weight_decay是 L2 正则,能抑制过拟合,但对全连接层影响较大,对卷积层作用有限。CosineAnnealingLR适合在 30 到 50 个 epoch 内从 0.001 降到接近 0,比固定步长衰减更平滑。
4.2 学习率、batch size、epoch 的经验值
我在 FER2013 上做过一组对比实验,以下参数组合能稳定达到 65% 到 70% 的验证准确率。如果你的数据是 CK+ 这类小数据集,需要降低学习率和 epoch 数量。
| 参数 | 推荐取值范围 | 说明 |
|---|---|---|
| 输入尺寸 | 224x224 | VGG16 默认输入 |
| batch size | 64 或 128 | 显存不够时先降 batch,再考虑梯度累积 |
| epoch | 30 到 50 | 观察验证集 loss,超过 50 通常过拟合 |
| 初始学习率 | 1e-3 到 1e-4 | 微调时全连接层用 1e-3,卷积层用 1e-4 |
| 动量 | 0.9 | SGD 的常用值 |
| 权重衰减 | 1e-4 到 5e-4 | 正则化强度,过大导致欠拟合 |
这里要特别说明 batch size 的影响。VGG16 的显存占用很高,在 8GB 显存下 batch size 很难达到 128,这时最简单的办法是把输入图像缩小到 112x112,但会损失细节。我更倾向于保留 224x224,然后用梯度累积模拟更大 batch,见下文代码。
4.3 训练脚本与日志记录
训练脚本的核心是训练循环、验证循环和模型保存。为了调试方便,我会用 TensorBoard 记录 loss 和准确率曲线。下面给出一个最小训练脚本,省略了数据加载的部分。
import torch from torch.utils.tensorboard import SummaryWriter writer = SummaryWriter('runs/fer_vgg16') accumulation_steps = 2 # 实际 batch 为 batch_size * accumulation_steps for epoch in range(epochs): model.train() total_loss = 0.0 optimizer.zero_grad() for i, (images, labels) in enumerate(train_loader): images = images.to(device) labels = labels.to(device) outputs = model(images) loss = criterion(outputs, labels) loss.backward() if (i + 1) % accumulation_steps == 0: optimizer.step() optimizer.zero_grad() total_loss += loss.item() # 验证 model.eval() correct, total = 0, 0 with torch.no_grad(): for images, labels in val_loader: images = images.to(device) labels = labels.to(device) outputs = model(images) _, predicted = torch.max(outputs, 1) total += labels.size(0) correct += (predicted == labels).sum().item() val_acc = correct / total scheduler.step() writer.add_scalar('val_acc', val_acc, epoch) torch.save(model.state_dict(), f'fer_vgg16_epoch{epoch}.pth')这段代码先判断是否达到累积步数,再更新参数。注意optimizer.zero_grad()的位置必须正确,否则梯度会累积到多个 batch 上。验证阶段要关闭梯度,用torch.no_grad()包裹。保存模型时建议同时保存 optimizer state dict,方便断点续训。
5. 评估模型与实时推理:从混淆矩阵到摄像头应用
5.1 用 train/val/test 划分并计算混淆矩阵
训练完成后,最直接的办法是输出测试集上的混淆矩阵,看看哪两个表情经常被混淆。FER2013 的默认划分是 train、val、test 三部分,其中 angry 和 fear 经常混淆,disgust 由于样本最少,recall 通常最低。
import numpy as np from sklearn.metrics import confusion_matrix, classification_report model.load_state_dict(torch.load('fer_vgg16_best.pth')) model.eval() all_preds, all_labels = [], [] with torch.no_grad(): for images, labels in test_loader: outputs = model(images.to(device)) _, preds = torch.max(outputs, 1) all_preds.extend(preds.cpu().numpy()) all_labels.extend(labels.cpu().numpy()) cm = confusion_matrix(all_labels, all_preds) print(classification_report(all_labels, all_preds, target_names=class_names))classification_report会输出每个类的 precision、recall 和 f1-score。分析混淆矩阵时,重点关注对角线以外的密集点,比如 surprise 被预测为 fear。如果这种情况严重,说明模型依赖的高级特征不够判别性,可能需要增强该类别的训练样本或调整损失权重。
5.2 加载权重对单张图片推理
实际部署时,推理代码和训练代码要分离。加载模型后,需要将输入图像做相同预处理,并检查模型是否处于 eval 模式。
from PIL import Image def predict_emotion(image_path, model, transform): img = Image.open(image_path).convert('RGB') img_tensor = transform(img).unsqueeze(0).to(device) model.eval() with torch.no_grad(): output = model(img_tensor) prob = torch.softmax(output, dim=1) top_prob, top_class = torch.topk(prob, 1) return class_names[top_class.item()], top_prob.item()这里的关键是unsqueeze(0)增加 batch 维度,因为 PyTorch 期望输入是四维张量。transform必须与验证集相同,即 Resize 到 224,然后 Normalize。很多推理结果错误是因为忘记做 Normalize,导致像素范围不匹配。
5.3 实时摄像头表情识别的最小实现
用 OpenCV 读取摄像头画面,把每帧传入detect_face和predict_emotion函数即可。为了流畅性,可以每 2 帧推理一次,并把结果绘制在画面上。
import cv2 cap = cv2.VideoCapture(0) while cap.isOpened(): ret, frame = cap.read() if not ret: break face = align_face(frame) if face is not None: face_rgb = cv2.cvtColor(face, cv2.COLOR_BGR2RGB) emotion, prob = predict_emotion_from_pil(face_rgb) cv2.putText(frame, f'{emotion}: {prob:.2f}', (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 255, 0), 2) cv2.imshow('FER', frame) if cv2.waitKey(1) & 0xFF == ord('q'): break cap.release() cv2.destroyAllWindows()实时推理时,人脸检测本身也会消耗算力。VGG16 的卷积层在 CPU 上每帧大约需要 50 到 100 毫秒,结合人脸检测,能在 10 FPS 左右运行。如果追求更高帧率,可以把输入尺寸从 224 降到 160,或者使用 ONNX Runtime 加速,这属于后续优化方向。
6. 进阶与踩坑:类别不平衡、过拟合和模型剪枝
6.1 类别不平衡:用加权损失和重采样解决
FER2013 中 disgust 和 fear 样本很少,直接训练出来的模型会偏向 happy 和 neutral。除了使用CrossEntropyLoss(weight=...)之外,还有一种更简单的做法:训练时对样本少的类别进行过采样。每次构造 batch 时,保证每个类别至少出现 4 张。
from torch.utils.data import WeightedRandomSampler labels = train_dataset.labels # 前提是 Dataset 里有 labels 属性 class_counts = np.bincount(labels) weights = 1.0 / class_counts[labels] sampler = WeightedRandomSampler(weights, num_samples=len(labels)) train_loader = DataLoader(train_dataset, batch_size=64, sampler=sampler)过采样会引入重复数据,加剧过拟合,因此配合数据增强必不可少。一般来说,加权损失更适合轻度不平衡,重采样更适合重度不平衡,两者可以结合使用。
6.2 过拟合:正则化、Dropout 与早停的实际用法
VGG16 的参数量巨大,在 FER2013 上很容易过拟合。常见做法是调整 VGG16 自带的 Dropout 概率。原始 VGG16 的 Dropout 为 0.5,但对于中等规模数据可以提高到 0.7,同时把新增全连接层前的 ReLU 后加一个 BatchNorm 层。
model.classifier[6] = nn.Sequential([ nn.Dropout(0.7), nn.Linear(4096, num_classes) ])早停是更直接的手段。记录验证集 loss,如果连续 5 个 epoch 没有下降,就恢复 best model 状态,停止训练。这个方式省心,也避免手动调整 epoch 数量。
6.3 VGG16 模型剪枝与轻量化
VGG16 模型体积约 500MB,不适合边缘部署。常见做法是用全局非结构剪枝把不重要的权重置零,再经过训练恢复精度。PyTorch 的torch.nn.utils.prune支持 L1 非结构化剪枝。
import torch.nn.utils.prune as prune for name, module in model.features.named_modules(): if isinstance(module, nn.Conv2d): prune.l1_unstructured(module, name='weight', amount=0.3)剪枝后模型大部分权重为零,但文件大小不变,需要配合稀疏存储或转成 ONNX 后进一步量化。实际工程中,我更推荐直接用 MobileNetV3 替换 VGG16 做表情识别,准确率略降但速度提升明显。这里提供剪枝思路是为了说明 VGG16 的优化边界,避免误以为 VGG16 只能原样使用。
最后说一个容易被忽略的技巧:无论训练还是推理,始终把模型的classifier中的 Dropout 状态处理好。PyTorch 的model.train()和model.eval()切换会影响 Dropout 行为,如果在推理时忘记切换,那么每次预测结果都会有随机性,这会让你误以为模型没有收敛。
本文还有配套的精品资源,点击获取