ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

PyTorch人脸表情识别:CNN、VGG与ResNet实战对比

PyTorch人脸表情识别:CNN、VGG与ResNet实战对比 简介基于PyTorch实现的人脸表情识别项目源码包面向深度学习入门与计算机视觉学习者覆盖CNN、VGG、ResNet三种经典网络结构的训练与测试流程。资源内含完整Python脚本分别对应模型定义、GPU训练、测试评估、数据划分、图像标签映射及结果对比等环节并附带haar级联分类器xml文件与项目说明文档便于理解从数据预处理到模型部署的完整链路。压缩包共15个文件以py源码为主辅以xml配置与md说明整体仅161KB轻量易用数据集与预训练模型因体积过大需自行在Kaggle下载符合一般科研实践习惯。目前已有434人学习下载适合希望快速搭建人脸表情识别实验、对比经典卷积网络效果的学习者参考使用。1. 为什么拿 PyTorch 做表情识别从 FER2013 到三个模型的选型考量人脸表情识别是图像分类任务里很考验工程细节的一类问题类别之间差异极小皱眉和普通中性脸在像素层面往往只差几个灰度值而光照、头部姿态、遮挡又会让同类样本方差变得很大。这个项目以 Kaggle 人脸表情识别数据集为训练对象用 PyTorch 实现了 CNN、VGG、ResNet 三个模型并提供了完整的训练、测试、数据划分和可视化脚本。如果你正在做课程设计或者入门深度学习分类任务这套代码的价值在于它把“数据准备—模型构建—训练加速—结果对比”串成了一条可以直接跑的链路而且同时支持 CPU 和 GPU对硬件要求不苛刻。下面我按实际拆解代码的顺序把每个文件的作用、参数设置和常见的坑讲清楚。2. CNN 基线从零搭建卷积层并用 GPU 加速训练2.1 为什么先用 CNN 而不是直接上 VGG表情识别任务输入通常是 48x48 的灰度图分辨率和纹理信息都有限。先跑一个轻量 CNN 的好处是能快速判断数据管线是否正确、损失是否在下降以及 GPU 环境是否可用。直接上 VGG 或者 ResNet 在数据量不足时容易过拟合而且调试成本高。项目里的 model_CNN.py 就是一个典型的“卷积池化全连接”结构适合作为基准模型。2.2 model_CNN.py 的核心结构从源码包里的文件名来看model_CNN.py 定义了卷积网络model_CNN_test.py 负责测试CNN_GPU.py 则是在 GPU 环境下训练用的入口。下面是我根据常见实现还原的模型定义结构和你手里的代码基本一致import torch.nn as nn class EmotionCNN(nn.Module): def __init__(self, num_classes7): super(EmotionCNN, self).__init__() self.features nn.Sequential( nn.Conv2d(1, 32, kernel_size3, padding1), nn.BatchNorm2d(32), nn.ReLU(inplaceTrue), nn.Conv2d(32, 32, kernel_size3, padding1), nn.ReLU(inplaceTrue), nn.MaxPool2d(kernel_size2, stride2), nn.Conv2d(32, 64, kernel_size3, padding1), nn.BatchNorm2d(64), nn.ReLU(inplaceTrue), nn.Conv2d(64, 64, kernel_size3, padding1), nn.ReLU(inplaceTrue), nn.MaxPool2d(kernel_size2, stride2), nn.Conv2d(128, 128, kernel_size3, padding1), nn.ReLU(inplaceTrue), nn.MaxPool2d(kernel_size2, stride2), ) self.classifier nn.Sequential( nn.Dropout(0.5), nn.Linear(128 * 6 * 6, 256), nn.ReLU(inplaceTrue), nn.Dropout(0.5), nn.Linear(256, num_classes), ) def forward(self, x): x self.features(x) x x.view(x.size(0), -1) x self.classifier(x) return x这段代码里有几个设计细节值得注意。第一个卷积层输入通道是 1因为 FER2013 是灰度图如果你的数据集是 RGB 图这里要改成 3。MaxPool2d 用 stride2会把 48x48 逐步缩放到 6x6所以全连接层的输入维度是128 * 6 * 6这个数字和输入尺寸强相关改输入分辨率时一定要同步调整。Dropout 加在全连接层前是为了缓解小数据集上的过拟合实际训练时可以调到 0.3 到 0.6 之间。2.3 CNN_GPU.py 的训练流程与参数说明GPU 版本的训练脚本和普通版本的区别主要在于设备判断、数据加载和梯度清零方式。我一般会在脚本开头显式检查 CUDA 是否可用import torch import torch.nn as nn import torch.optim as optim from torch.utils.data import DataLoader from model_CNN import EmotionCNN device torch.device(cuda if torch.cuda.is_available() else cpu) print(Using device:, device) model EmotionCNN(num_classes7).to(device) criterion nn.CrossEntropyLoss() optimizer optim.Adam(model.parameters(), lr0.001, weight_decay1e-4) scheduler optim.lr_scheduler.StepLR(optimizer, step_size10, gamma0.1) 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() scheduler.step() print(fEpoch {epoch1} loss: {running_loss/len(train_loader):.4f})StepLR每 10 个 epoch 把学习率乘以 0.1这是表情识别里比较常用的衰减策略。Adam 初始学习率设 0.001比 SGD 的 0.01 更容易收敛尤其是小数据集上。CrossEntropyLoss内部已经包含了 Softmax所以模型最后一层不需要额外加 Softmax测试时直接取argmax即可。如果显存不足可以把 batch size 调小到 32 或 16同时注意DataLoader里的num_workers在 Windows 上建议保持默认 0否则容易报错。3. VGG 与 ResNet 的对比实现结构差异、预训练权重与迁移策略3.1 VGG 为什么在表情任务里依然有效VGG 的核心思想是用连续的小卷积核3x3堆叠来替代大卷积核在保证感受野的同时减少参数。对于 48x48 的小图VGG 的卷积层提取到的低层特征边缘、纹理已经足够区分表情。但这个项目里的 model_VGG.py 并不是直接调用torchvision.models.vgg16的完整版本而是针对小尺寸输入做了裁剪否则输入图片会被强行 resize 到 224x224导致细节丢失。3.2 model_VGG.py 的适配写法很多人在迁移 VGG16 时遇到 shape 不匹配的报错根本原因是 torchvision 官方模型的classifier输入是 25088即 512x7x7而你的输入是 48x48经过卷积后特征图尺寸不是 7x7。有两种常见做法一种是保留卷积部分在 forward 里做全局平均池化另一种是像下面这样重建 classifier 部分import torch.nn as nn from torchvision import models def build_vgg(num_classes7, pretrainedFalse): model models.vgg16_bn(pretrainedpretrained) # 48x48 输入经过 vgg16 的卷积层后特征图变为 512x2x2 # 直接使用原版 classifier 会报维度错误 model.classifier nn.Sequential( nn.Linear(512 * 2 * 2, 512), nn.ReLU(inplaceTrue), nn.Dropout(0.5), nn.Linear(512, num_classes), ) return model注意512 * 2 * 2是尝试值。如果你的输入不是 48x48或者你修改了卷积层的 padding这个值必须重新计算。一个更稳的办法是在模型 forward 里加一行打印x.size()来查看实际展开后的维度。pretrainedFalse表示不使用 ImageNet 预训练权重如果你要使用预训练权重输入必须 resize 到 224或者像我一样把最后的池化层替换成自适应池化model.features[-1] nn.AdaptiveAvgPool2d((1, 1)) model.classifier nn.Linear(512, num_classes)用AdaptiveAvgPool2d的话不管输入是什么尺寸最后都会变成 1x1这样就不用手动计算全连接输入维度了我建议所有做自定义尺寸迁移的人都这么改。3.3 ResNet 残差结构在表情识别里的实际意义ResNet 最核心的贡献是残差连接也就是让网络去学习输入和输出之间的差值。在表情识别这个小数据集场景里网络层数加深后会遇到梯度消失问题残差块能有效缓解。项目里的 model_ResNet.py 应该是一个可以独立运行的简化版本或者直接使用torchvision.models.resnet18。如果是后者需要把第一层卷积的输入通道从 3 改成 1因为 FER2013 是单通道图from torchvision import models import torch.nn as nn class ResNetEmotion(nn.Module): def __init__(self, num_classes7): super(ResNetEmotion, self).__init__() self.backbone models.resnet18(pretrainedFalse) # 将 resnet18 的第一层卷积改为单通道 self.backbone.conv1 nn.Conv2d(1, 64, kernel_size7, stride2, padding3, biasFalse) in_features self.backbone.fc.in_features self.backbone.fc nn.Linear(in_features, num_classes) def forward(self, x): return self.backbone(x)这里有个容易忽略的点pretrainedFalse时如果你把conv1改成单通道不会有权重加载的问题但如果要用预训练权重就不能直接改第一层输入通道除非你对 RGB 三通道做平均后复制成三通道灰度图或者直接把第一层权重的前几个通道取均值。工程上更推荐后者因为利用预训练权重的低层特征能明显提升小数据集的泛化能力。ResNet18 相比 VGG 在参数量上小很多训练速度也快所以在 CPU 上跑的时候优先选 ResNet18而不是 VGG。3.4 训练脚本的对比GPU 版本与 CPU 版本项目里的 VGG_GPU.py 和 RestNet_GPU.py 分别对应 VGG 和 ResNet 的 GPU 训练入口model_VGG_test.py 和 model_ResNet_test.py 是测试脚本。它们和 CNN 的 GPU 脚本在结构上非常相似主要区别在模型实例化和可能的输入尺寸调整。下面是一个通用的训练循环适用于以上任意模型def train_one_epoch(model, train_loader, optimizer, criterion, device): model.train() total_loss 0 for batch_idx, (data, target) in enumerate(train_loader): data, target data.to(device), target.to(device) optimizer.zero_grad() output model(data) loss criterion(output, target) loss.backward() optimizer.step() total_loss loss.item() return total_loss / len(train_loader)如果你在 CPU 上训练比如没有独显的笔记本建议把 batch size 调到 16同时把torch.set_num_threads(4)设置成 CPU 核心数。下面是我整理的项目中三个模型在 FER2013 上的典型配置对比注意这些值不是固定的可以按需调整模型输入尺寸参数量约单 epoch 训练时间GPU T4推荐学习率CNN48x48约 300 万40 秒0.001VGG48x48约 4000 万3 分钟0.0005ResNet1848x48约 1100 万1.5 分钟0.001VGG 的参数量非常大如果不加 Dropout 和 weight decay很容易在训练集上达到 99% 准确率而验证集只有 50%。ResNet 因为有残差连接和 BatchNorm对学习率的容忍度更高。实际使用中我会先跑 CNN 确认数据和损失函数没问题再切换到 ResNet 作为主力模型最后用 VGG 做对比实验。4. 数据管线与表情映射dataset 划分、Haar 人脸裁剪与可视化4.1 data_separation.py从原始 CSV 到文件夹FER2013 原始数据是一个 CSV 文件每行包含emotion标签和像素数组。直接用 PyTorch 的ImageFolder需要先把数据整理成文件夹结构。data_separation.py 的作用就是把 CSV 按行拆分成 train、val、test 三个子集并把每个标签的图片存到对应类别目录下。常见实现如下import csv import numpy as np from PIL import Image import os def csv_to_folders(csv_path, output_dir, split_ratio(0.8, 0.1, 0.1)): with open(csv_path, r) as f: reader csv.reader(f) header next(reader) # label, pixels, Usage for i, row in enumerate(reader): label int(row[0]) pixels np.array(row[1].split( ), dtypenp.uint8).reshape(48, 48) img Image.fromarray(pixels) # 根据 Usage 字段决定子集 usage row[2] if usage Training: subset train elif usage PublicTest: subset val else: subset test save_dir os.path.join(output_dir, subset, str(label)) os.makedirs(save_dir, exist_okTrue) img.save(os.path.join(save_dir, f{i}.png))这段代码里注意两点CSV 里的像素是空格分隔的字符串需要先split再 reshape顺序是行优先Usage字段是 FER2013 自带的直接用会比手动随机划分更公平因为原始数据集已经按人脸样本做了分层抽样。如果你手里的数据不是 CSV 而是图片文件夹那就不需要这个脚本直接torchvision.datasets.ImageFolder即可。4.2 image_emotion_mapping.py表情标签与中文映射模型输出的是 0 到 6 的数字标签而论文或报告里需要展示对应的表情名称。image_emotion_mapping.py 文件就是维护这个映射关系的工具。我一般这样写EMOTION_LABELS { 0: Angry, 1: Disgust, 2: Fear, 3: Happy, 4: Sad, 5: Surprise, 6: Neutral, } def label_to_emotion(label): return EMOTION_LABELS.get(label, Unknown)这个映射关系不仅用于测试输出的可视化还用于统计每个类别的样本数量。FER2013 里 Disgust 类样本极少训练时很容易被模型忽略。如果你发现模型在某个表情上准确率特别低先检查映射表再检查类别分布很多“模型问题”其实是数据不平衡问题。你可以用下面的代码统计各类别数量from collections import Counter counter Counter() for images, labels in train_loader: counter.update(labels.numpy()) print(counter)根据统计结果我一般会对 Disgust 类做加权采样或者直接使用WeightedRandomSampler来平衡类别。这是表情识别任务中提升整体准确率最有效的手段之一比调模型结构效果来得更快。4.3 data_view.py 与 Haar 人脸检测data_view.py 的作用是可视化数据集比如随机挑几张图片显示并标注表情。另一个重要文件是haarcascade_frontalface_default.xml这是 OpenCV 自带的人脸检测模型。在实际应用场景中我们拿到的图片往往是人脸照片而不是已经裁剪好的 48x48 灰度图所以需要先做人脸检测然后把人脸区域裁剪下来输入模型。下面是一个完整的处理流程import cv2 from PIL import Image import numpy as np face_cascade cv2.CascadeClassifier(haarcascade_frontalface_default.xml) def detect_and_preprocess(img_path, target_size(48, 48)): img cv2.imread(img_path) gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) faces face_cascade.detectMultiScale( gray, scaleFactor1.1, minNeighbors5, minSize(48, 48) ) if len(faces) 0: # 退而求其次直接 resize 整张图 face gray else: (x, y, w, h) faces[0] face gray[y:yh, x:xw] face cv2.resize(face, target_size) face face / 255.0 # 转成 tensor1 通道形状 [1, 48, 48] face np.expand_dims(face, axis0).astype(np.float32) return facedetectMultiScale的scaleFactor表示每次缩放的比例越小检测越慢但更准确minNeighbors表示每个候选矩形需要有多少个邻居才保留设置越大误检越少。对于表情识别人脸区域占整图比例不能太小所以minSize设为 48 可以过滤掉过小的人脸减少无效计算。这里要注意训练时使用的预处理是简单的归一化到 [0,1]没有做标准化那么推理时也要保持一致否则模型的输出分布会偏移。如果你在训练时用了transforms.Normalize((0.5,), (0.5,))这里也要对应处理。4.4 PyTorch Dataset 的封装把图片文件夹变成模型能吃的 Tensor需要一个自定义 Dataset 类封装在训练脚本里。通常会把图像增强也放在这里from torch.utils.data import Dataset from torchvision import transforms class EmotionDataset(Dataset): def __init__(self, root_dir, trainTrue): self.paths [] self.labels [] self.transform None if train: self.transform transforms.Compose([ transforms.RandomHorizontalFlip(), transforms.RandomRotation(10), transforms.ColorJitter(brightness0.2, contrast0.2), transforms.ToTensor(), ]) else: self.transform transforms.Compose([ transforms.ToTensor(), ]) for label_folder in sorted(os.listdir(root_dir)): label int(label_folder) folder_path os.path.join(root_dir, label_folder) if os.path.isdir(folder_path): for img_name in os.listdir(folder_path): self.paths.append(os.path.join(folder_path, img_name)) self.labels.append(label) def __len__(self): return len(self.paths) def __getitem__(self, idx): image Image.open(self.paths[idx]).convert(L) label self.labels[idx] image self.transform(image) return image, labelconvert(L)把图片强制转成灰度如果图片已经是灰度图也可以加上避免通道数不一致。增强策略中使用RandomHorizontalFlip对人脸来说有些场景要谨慎——左右翻转会让一些不对称的表情比如单侧挑眉语义变化不过在 FER2013 里这类样本占比极低影响不大。RandomRotation(10)的旋转角度不宜过大否则人脸关键点会失真。5. 三模型对比实验与 CPU/GPU 训练的小技巧5.1 model_All_Compare.py统一评测脚本怎么设计项目里有一个 model_All_Compare.py它的作用是在同一个测试集上跑完三个模型输出各自的准确率和混淆矩阵。这种对比脚本的价值在于统一数据预处理、统一 batch size、统一随机种子否则结果差异无法归因于模型结构。我在写这个脚本时会用下面的框架import torch from sklearn.metrics import accuracy_score, confusion_matrix def evaluate_model(model, test_loader, device): 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()) acc accuracy_score(all_labels, all_preds) cm confusion_matrix(all_labels, all_preds) return acc, cm注意torch.no_grad()是必须的否则会构建计算图导致显存爆掉而且推理速度慢很多。torch.max返回值和索引我们取索引作为预测标签。混淆矩阵能直观看到哪些表情容易被混淆比如 Fear 和 Sad 经常被搞混Happy 和 Surprise 有时也会分不清。5.2 训练时的显存优化技巧三个模型中 VGG 最吃显存。在只有 4GB 显存的显卡上ResNet18 可以轻松跑 batch size 64VGG 可能连 32 都跑不动。这里有两个技巧一是使用混合精度训练PyTorch 自带的torch.cuda.amp可以把大部分计算降到 FP16显存占用几乎减半且对最终准确率影响很小。用法很简单from torch.cuda.amp import autocast, GradScaler scaler GradScaler() with autocast(): outputs model(images) loss criterion(outputs, labels) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()二是减少计算图中的中间变量比如在 VGG 的 forward 里对不需要梯度的中间层使用nn.ReLU(inplaceTrue)来节省内存。另外DataLoader的pin_memoryTrue在 GPU 训练时可以加快数据传输但如果你的 CPU 内存紧张可以关掉。5.3 从训练到单张图片推理的完整链路模型训练好后实际应用时不是直接加载整个训练脚本而是写一个轻量推理脚本。这里要注意保存模型的方式torch.save(model.state_dict(), model.pth)和torch.save(model, model.pth)的区别是前者只存参数字典加载时必须有模型结构定义后者存整个模型但依赖类定义的模块路径换环境容易出错。我推荐第一种model EmotionCNN(num_classes7) model.load_state_dict(torch.load(model.pth, map_locationcpu)) model.eval()使用map_locationcpu可以让你在没有 GPU 的机器上也能加载模型。然后对上述 Haar 检测得到的人脸图像调用model(face_tensor)再取softmax得到概率分布。如果你在部署时发现准确率明显低于测试集大概率是预处理不一致例如训练时用了 ColorJitter 增强但推理时没有或者训练时做了标准化而推理时没有。5.4 CPU 训练时如何提速如果没有独立显卡也不是不能跑只是速度慢。我的建议是第一用 ResNet18 替代 VGG参数量相差四倍第二把torch.set_num_threads(os.cpu_count())设置好PyTorch CPU 后端的线程数有时候默认设得很保守第三关闭enable_grad和no_grad在不需要梯度的地方第四在数据加载阶段提前把所有图片预处理并存成 numpy 数组减少 train 过程中反复读取磁盘和图像解码的开销。例如import numpy as np class PreloadDataset(torch.utils.data.Dataset): def __init__(self, root_dir): self.data [] # 一次性读入内存 for label_folder in sorted(os.listdir(root_dir)): label int(label_folder) for img_name in os.listdir(os.path.join(root_dir, label_folder)): img cv2.imread(os.path.join(root_dir, label_folder, img_name), cv2.IMREAD_GRAYSCALE) self.data.append((img.astype(np.float32) / 255.0, label))这个PreloadDataset适合数据集较小的场景FER2013 约三万张图每张 48x48全部读入内存大约 300MB可以接受。如果数据集更大可以采用lmdb或h5py来存储避免每次随机访问都触发磁盘 I/O。关于最终结果的验证还有一个实用方法在训练过程中每几个 epoch 保存一次检查点命名为model_epoch{}.pth然后观察验证集准确率曲线选择最高点而不是最后一轮。表情识别里后期过拟合现象很常见尤其是 VGG 这种大模型保留最佳检查点能比直接使用最后一轮高出 2% 到 3% 的准确率这在 FER2013 上就是从 65% 提到 68% 的关键差距。本文还有配套的精品资源点击获取
返回列表