ARTICLE DETAIL

资讯详情

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

基于ResNet的人脸表情识别实战:从源码项目到模型调优指南

基于ResNet的人脸表情识别实战:从源码项目到模型调优指南 简介基于ResNet的人脸表情识别Python实现源码与配套数据集适合计算机视觉方向的毕业设计、期末大作业及课程实践。项目在本地编译通过并获评审高分涵盖7类基本表情愤怒、厌恶、恐惧、开心、难过、惊讶、中性的图片样本与完整训练/测试流程可直接用于模型复现与二次开发。资源共16个文件压缩包约5.2MB主要包括3个Python脚本模型定义、混淆矩阵绘制、测试、7张表情样例图、OpenCV人脸检测模型、类别索引、依赖清单及演示视频目录结构清晰便于对照学习。已有237人学习下载适合需要快速搭建人脸表情识别系统的学习者参考。通过模型定义脚本可查看ResNet结构利用测试脚本可结合实时摄像头或静态图片推理演示视频展示运行效果附带说明文档与混淆矩阵可视化脚本帮助理解分类效果和调优方向。1. 从 zip 到跑通一份 ResNet 表情识别项目到底在解决什么拿到一个名为「基于 ResNet 的人脸表情识别 python 实现源码数据集高分项目.zip」的压缩包多数人的第一反应是解压、找 train.py、然后跑起来看 acc。但这类项目的价值远不止“能跑”ResNet 作为分类骨架在 fer2013、RAF-DB 这类人脸表情数据集上解决的是从人脸图像到七类表情生气、厌恶、恐惧、开心、悲伤、惊讶、中性的映射问题。如果你的诉求是课程设计交付、算法入门练手或者把表情识别接进课堂专注度分析、驾驶员疲劳检测这类真实场景这个项目能给你一套从数据加载到模型部署的最小闭环。适合两类人想在一周内交付一个能演示的深度学习项目的人以及想把迁移学习、图像分类这些概念落到代码里的初学者。2. 为什么偏偏是 ResNet表情识别的选型逻辑与数据集陷阱2.1 表情识别的难点细微纹理比 ImageNet 分类更吃“残差”人脸表情识别不是普通的图像分类。ImageNet 上分类一个“猫”和一个“狗”差异在整体轮廓但“开心”和“惊讶”的区别可能只集中在嘴角上扬幅度、眼睛开合程度这几像素的区域。这意味着模型需要捕捉细粒度特征而非粗粒度形状。早期 VGG 这类平原网络堆到 16 层以上就开始出现退化——训练集 loss 不降反升这不是过拟合是深层网络优化困难。ResNet 的残差连接让梯度可以跨层回传保证深层网络至少不会比浅层更差这是表情识别这种“小差异、大噪声”任务最需要的性质。另一个选型理由是可迁移性。torchvision、keras 里都有现成的 ResNet 权重用迁移学习把 ImageNet 上学会的边缘、纹理基础特征拿过来再微调表情数据可以大幅缩短训练时间。表情数据集普遍不大——FER2013 约 3.5 万张灰度图RAF-DB 约 3 万张彩色图——从头训练深层网络不现实预训练模型是唯一可靠路径。2.2 ResNet18 还是 ResNet50参数量与数据量之间的取舍先给结论这一类“源码数据集”项目里最常见也最稳妥的选择是 ResNet18 或 ResNet34。原因有三表情数据集规模小ResNet50 以上几乎必然过拟合除非做很强的数据增强ResNet18 在单卡 GPU 上训练一轮的时间约是 ResNet50 的 1/3调试迭代更快推理阶段接入摄像头或 Web 服务时ResNet18 在 CPU 上也能跑出接近实时的速度。如果项目给了 ResNet50 的代码不要急着高兴。先看数据集规模小于 5 万张图就优先降级到 ResNet18。注意 ResNet50 的最后一层全连接输入是 2048 维ResNet18 是 512 维改分类头时不要带错参数。2.3 表情数据集的三个坑灰度、类别失衡、标签噪声以 FER2013 为例这是表情识别最常用的公开数据集但你用的时候要留意三件事灰度图通道FER2013 是 48x48 灰度图。如果用 torchvision 的 ResNet 预训练权重第一层卷积 expect 3 通道输入需要把灰度图复制成 3 通道或者修改网络第一层 conv1 的 in_channels1 并放弃该层预训练权重。多数「高分项目」的做法是复制通道这个不影响最终效果。类别严重不均衡FER2013 里“开心”类样本接近 9000 张“厌恶”只有约 400 张。不做任何处理模型对厌恶类的召回率可能低于 10%。后面会讲两类解法按类别加权 Loss 或对少数类做过采样。标签噪声表情标注天然带主观性同一张脸不同人看会有不同结论。FER2013 的标签错误率并不低。表现是训练集 acc 很高、验证集波动大。遇到这种情况不要加正则硬扛先去看错分样本到底是不是标注本身就有争议。2.4 数据集下载与组织直接决定能否复现这类 zip 项目里数据集文件组织通常是这样的dataset/ train/ # 按类别分文件夹 angry/ (xxx.jpg) happy/ (xxx.jpg) ... val/ angry/ happy/按 ImageFolder 方式组织的目录可以直接用torchvision.datasets.ImageFolder加载不需要手写自定义 Dataset。如果数据和标签是 CSV 格式FER2013 原始格式就是 CSV则需要写一个解析类。我在本地跑这类项目时第一步永远是把数据集目录结构打印出来确认图片尺寸、通道数和类别文件夹数量。这里统计一下数据集图片尺寸通道类别数是否需要预训练FER201348x4817可选但强烈建议RAF-DB100x100 左右37是CK640x490 或裁剪后38否视频帧规模更小一句话拿到 zip 先看数据集属于哪种格式再决定数据加载代码怎么改。3. 把 zip 里的事故现场变成可复现项目数据加载与模型构建3.1 目录结构先弄清每个文件在干嘛这类项目解压后典型结构如下project/ models/ resnet.py # ResNet 定义可能是手写复刻 train.py # 训练主脚本 test.py # 验证/测试脚本 utils/ dataset.py # 数据集加载与预处理 transforms.py # 数据增强 dataset/ train/ val/ checkpoints/ best_model.pth requirements.txt我拿到任何源码项目的第一件事不是直接运行python train.py而是打开requirements.txt看依赖版本。这个项目如果是 2022 年之前写的大概率用的是torch1.8.0甚至更老而你现在环境里装的可能是 torch 2.x。版本跨度大时torchvision.models.resnet18(pretrainedTrue)这个 API 已经废弃要改成weightsResNet18_Weights.DEFAULT。这个坑后面单独讲。3.2 手写 ResNet 与 torchvision 内置用哪个能少掉头发很多课程设计项目会手写 ResNet 代码以证明“理解原理”但手写版本最容易在BasicBlock的downsample分支上出错。如果 zip 里既有手写版本又有 torchvision 调用入口我的建议是优先用 torchvision 版本省掉一个变量维度的 Debug 时间。以下是 ResNet18 在 torch 2.x 下的标准写法import torch import torch.nn as nn from torchvision import models def create_resnet18(num_classes7, pretrainedTrue): if pretrained: # torch 2.x 推荐写法旧代码里 pretrainedTrue 已弃用 model models.resnet18(weightsmodels.ResNet18_Weights.DEFAULT) else: model models.resnet18(weightsNone) # 拿到最后一层全连接输入的维度mind that 不同 resnet 不一样 in_features model.fc.in_features # 替换分类头7 分类表情 model.fc nn.Sequential( nn.Dropout(p0.3), # 对表情识别很有效后面避坑细说 nn.Linear(in_features, num_classes) ) return model逻辑说明这个函数把 torchvision 内置的 ResNet18 分类头从 1000 类替换成 7 类同时保留所有卷积层的预训练权重。fc.in_features在 ResNet18 里是 512ResNet50 里是 2048写成动态读取的好处是换骨干网络时不用改代码。参数说明Dropout 0.3是我在表情任务上常用的值比默认不加 Dropout 能提升 2~3 个点验证集准确率。如果数据量极小比如自定义采集了几千张把 Dropout 提到 0.5。pretrainedFalse适合你想从零训练或加载自己的 checkpoint 时使用。3.3 数据加载ImageFolder 最小实现与 CSV 适配如果 zip 里的数据集是文件夹组织用 ImageFolder 三行搞定。这里的细节是transforms必须和预训练权重匹配——ResNet 预训练权重期望输入被 Normalize 到 ImageNet 的均值和标准差千万不能省from torchvision import datasets, transforms from torch.utils.data import DataLoader # ImageNet 的均值/标准差已经是行业惯例照抄即可 normalize transforms.Normalize( mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225] ) train_transform transforms.Compose([ transforms.Resize((64, 64)), # FER2013 原图 48x48先放大再增强 transforms.RandomHorizontalFlip(p0.5), transforms.RandomRotation(degrees10), transforms.ToTensor(), normalize ]) val_transform transforms.Compose([ transforms.Resize((64, 64)), transforms.ToTensor(), normalize ]) train_dataset datasets.ImageFolder(dataset/train, transformtrain_transform) val_dataset datasets.ImageFolder(dataset/val, transformval_transform) train_loader DataLoader(train_dataset, batch_size64, shuffleTrue, num_workers4) val_loader DataLoader(val_dataset, batch_size64, shuffleFalse, num_workers4)逻辑说明ImageFolder会自动按子文件夹名生成类别索引比如angry/文件夹下的图都会被归为索引 0。数据集规模小时RandomRotation的度数不要超过 15 度人脸转多了语义就变了。Resize((64, 64))是个折中FER2013 原图 48x48 信息量太少直接输入 ResNet 效果不佳放大到 64 再配合预训练权重相当于让模型看到更完整的脸部纹理。参数说明batch_size64在 6GB 显存的显卡上跑 ResNet18 是安全的。如果你用的是 4GB 老卡降 batch 到 32同时把num_workers降到 2否则可能触发内存错误。注意shuffle在验证集必须为False否则每次验证的样本顺序不同会导致 acc 曲线高频抖动。3.4 训练主循环把“能跑”变成“能稳定复现”一个能跑的训练脚本不代表它是正确的。很多 zip 里的训练代码只有训练 loop没有学习率调整、没有梯度裁剪、没有最优模型保存逻辑。以下是能直接替换进任何训练脚本的模型保存与早停逻辑import copy def train_one_epoch(model, loader, criterion, optimizer, device): model.train() total_loss 0.0 correct 0 total 0 for inputs, labels in loader: inputs, labels inputs.to(device), labels.to(device) optimizer.zero_grad() outputs model(inputs) loss criterion(outputs, labels) loss.backward() # 梯度裁剪防止个别 batch 出现极端梯度导致 loss 打穿 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm10.0) optimizer.step() total_loss loss.item() * inputs.size(0) _, predicted torch.max(outputs, 1) correct (predicted labels).sum().item() total labels.size(0) return total_loss / total, correct / total def validate(model, loader, criterion, device): model.eval() total_loss 0.0 correct 0 total 0 with torch.no_grad(): for inputs, labels in loader: inputs, labels inputs.to(device), labels.to(device) outputs model(inputs) loss criterion(outputs, labels) total_loss loss.item() * inputs.size(0) _, predicted torch.max(outputs, 1) correct (predicted labels).sum().item() total labels.size(0) return total_loss / total, correct / total best_acc 0.0 best_model_wts copy.deepcopy(model.state_dict()) for epoch in range(epochs): train_loss, train_acc train_one_epoch( model, train_loader, criterion, optimizer, device ) val_loss, val_acc validate(model, val_loader, criterion, device) if val_acc best_acc: best_acc val_acc best_model_wts copy.deepcopy(model.state_dict()) torch.save(model.state_dict(), fcheckpoints/best_epoch{epoch}_acc{val_acc:.4f}.pth) # 余弦退火或 StepLR 二选一 scheduler.step()逻辑说明训练函数里多做了两件事梯度裁剪和控制 best state 的深拷贝。copy.deepcopy在这里不是可有可无——如果直接用model.state_dict()赋值后面的训练会原地修改同一个 dict最后保存的模型其实是最后一轮不是最优轮。README 里写“保存 best”但不深拷贝的代码是这类项目里最隐蔽的坑。参数说明max_norm10.0是一个安全阈值。学习率设得偏高时某个 batch 的奇异样本可能让梯度范数爆到上百clip 到 10 能防止权重一步跳坏。4. 把验证集准确率从 70% 推到 85%4 个必调参数4.1 预训练权重用还是不用差别在 10 个点以上表情识别任务里预训练权重带来的收益比绝大多数数据增强都大。ImageNet 上学习到的边缘、眼睛、嘴巴这些基础特征可以直接迁移。实测在 FER2013 上同样的 ResNet18 结构用预训练权重做微调比从零训练验证集准确率高 8~12 个点。但注意如果这类 zip 项目里已经自带了一个.pth文件train 好的权重这个权重到底是在哪个数据集上训的、是不是完整的 ResNet18 结构需要先验证再运行。常见做法是# 加载前打印 key 长度确认权重来自同一结构 ckpt torch.load(checkpoints/best_model.pth, map_locationcpu) print(Total keys in ckpt:, len(ckpt.keys())) print(fc.weight shape:, ckpt[fc.weight].shape) # 应该是 [7, 512]如果fc.weight的 shape 不是[7, 512]说明这份权重对应的分类头类别数对不上。解决办法是strictFalse加载让 fc 层重新初始化model.load_state_dict(torch.load(checkpoints/best_model.pth), strictFalse)4.2 学习率1e-4 起步、warmup 3 个 epoch、余弦退火收尾表情识别数据量小主体是微调学习率不能沿用 ImageNet 全量训练的 0.1。经验值是骨干网络卷积层学习率 1e-4新替换的 fc 层学习率可以稍高到 1e-3。层间不同学习率的实现optimizer torch.optim.AdamW([ # 骨干层小学习率保住预训练特征 {params: [p for name, p in model.named_parameters() if fc not in name], lr: 1e-4}, # 分类头新初始化的可以学快一点 {params: model.fc.parameters(), lr: 1e-3} ], weight_decay1e-4) scheduler torch.optim.lr_scheduler.CosineAnnealingLR( optimizer, T_maxtotal_epochs, eta_min1e-6 )参数说明AdamW 里的weight_decay相当于 L2 正则对防止 fc 层过拟合有效。eta_min1e-6让学习率在最后一轮降到接近 0帮助收敛到更平滑的局部最优。T_max 与总 epoch 数保持一致不要拍脑袋写 50。4.3 类别不均衡三行代码解决“厌恶类永远测不准”如果直接打印模型在验证集上每个类别的准确率你会发现“厌恶”类往往是个位数。这是因为 FER2013 里“厌恶”只有几百张图模型学不到这类特征。最低成本的解法是给 Loss 加类别权重from sklearn.utils.class_weight import compute_class_weight # 拿到 ImageFolder 自动生成的标签列表 class_names train_dataset.classes label_list [train_dataset.targets[i] for i in range(len(train_dataset))] weights compute_class_weight( balanced, classesnp.array(range(len(class_names))), ynp.array(label_list) ) weights torch.tensor(weights, dtypetorch.float32).to(device) criterion nn.CrossEntropyLoss(weightweights)逻辑说明compute_class_weight会自动计算每个类别的权重样本越少的类别权重越大让 loss 对少数类的错误更敏感。对于 FER2013效果是“厌恶”类准确率能提升 15~20 个点同时总准确率不掉。如果这个项目里用了 MixUp 或 CutMix注意它们和类别权重是否兼容。MixUp 会把两张图的标签混合成软标签此时CrossEntropyLoss仍然可以配合使用。4.4 面部对齐与数据增强在不过拟合的前提下把数据翻一倍数据增强的边界不是看增强多样性而是看验证集 loss 是否开始回升。表情识别上我的常用组合是随机水平翻转、随机旋转 10 度、随机擦除。RandomErasing对表情识别有意外的好效果——它强迫模型从多个脸部区域综合判断而不是盯着某一个极端特征from torchvision.transforms import RandomErasing train_transform transforms.Compose([ transforms.Resize((64, 64)), transforms.RandomHorizontalFlip(p0.5), transforms.RandomRotation(degrees8), transforms.ToTensor(), normalize, RandomErasing(p0.25, scale(0.02, 0.1)) # 擦掉一点小区域 ])注意放的位置RandomErasing必须在ToTensor之后因为它的输入是张量。5. 跑到吐血的 5 个翻车现场现象、定位与解决5.1 路径里有中文解压即报错 FileNotFoundError现象压缩包解压到“D:\课程设计\表情识别”后ImageFolder报错找不到图片。原因Windows 中文路径加 Python 的 encoding 处理不当。此外如果数据集内部文件名是中文或包含空格opencv的imread可能读不出图它内部不是按 Unicode 处理路径。解决第一步“把所有文件移到纯英文路径”比如D:\Projects\fer_project。第二步确认ImageFolder读取时是否跳过空目录。我习惯先执行一行打印from torchvision import datasets ds datasets.ImageFolder(dataset/train) print(len(ds.classes), ds.classes, len(ds))如果len(ds)明显小于文件数说明有子文件夹为空或文件名不符合读取规则。5.2 显存不足OOM 不是加大显存就能解决的事现象CUDA out of memory训练在第 2 个 epoch 崩掉。原因除了 batch_size 过大还有可能是验证阶段把整个验证集一次性 load 进显存或者模型里冻结的 BatchNorm 层累积了过多的中间变量。解决按顺序做三件事。第一batch_size 降到 16num_workers降到 2 再看。第二确认验证时包了torch.no_grad()否则即使验证集只有 3000 张前向也会累积梯度图。第三在 epoch 之间调用torch.cuda.empty_cache()释放碎片显存这对长时间训练尤其有效。5.3 验证集准确率反复横跳loss 不降现象训练 5 个 epoch 后验证集 acc 在 60% 上下震荡最优模型是第 3 轮的后面越训越差。原因学习率过大。微调阶段学习率到 1e-3 以上时预训练权重会被快速破坏浅层卷积学到的通用边缘特征被覆盖成表情数据集特有的过拟合特征。解决把骨干学习率降到 3e-5 ~ 1e-4 区间重新训练。观察前 3 个 epoch 的训练 loss 是否下降速度变“稳重”验证集 acc 是否小步提升。如果验证集 acc 依然震荡叠加梯度裁剪max_norm5.0。5.4 预测结果全是一个类别现象训练完成模型准确率 75%但打印混淆矩阵发现超过 80% 的预测都是“开心”。原因类别不均衡 验证集分布偏斜。模型发现全预测“开心”就能拿到 60% 以上的准确率从而放弃学习其他类别。解决两个方案叠加。一是用第 4 章 4.3 里的 class_weight 方案二是打印验证集每个类别样本数确认验证集本身有没有抽样偏差。另外注意测试脚本里的model.eval()是否真的被调用了BatchNorm 在 train 模式下跑验证也会导致预测分布集中在某个类。5.5 torch.load 报安全警告或权重加载失败现象UnicodeDecodeError或RuntimeError: Unexpected key(s) in state_dict。原因最老的权重文件在torch.save时用了 Python 的 pickle且旧版本 torch 保存的文件可能包含module.前缀DataParallel 训练产物或包含完全相同的 key 但前多了module.名字。解决加载时把所有module.前缀过滤掉ckpt torch.load(checkpoints/best_model.pth, map_locationcpu) # 如果是 DataParallel 训练出的权重去掉 module. 前缀 new_state_dict {} for k, v in ckpt.items(): new_key k.replace(module., ) if k.startswith(module.) else k new_state_dict[new_key] v model.load_state_dict(new_state_dict, strictTrue)严格模式先不要乱关strictFalse开的越早越容易掩盖结构不匹配的问题。6. 把项目变成能用的模块推理脚本、ONNX 导出和部署习惯养成这个 zip 项目如果在训练上已经跑通下一步值得做的是把 off-line 的训练脚本变成一个 on-line 的推理模块。给一个可以接摄像头的推理函数骨架import cv2 import torch import torch.nn.functional as F def recognize_face(model, face_image_bgr, transform, device): # 输入为摄像头截取的 BGR 人脸图 rgb cv2.cvtColor(face_image_bgr, cv2.COLOR_BGR2RGB) img transform(rgb) # 复用训练时的预处理 img img.unsqueeze(0).to(device) # [1, C, H, W] model.eval() with torch.no_grad(): logits model(img) prob F.softmax(logits, dim1) conf, pred torch.max(prob, 1) return int(pred.item()), float(conf.item())推理时要保证transform与训练完全一致尤其是Resize的尺寸和Normalize的均值/方差。换一个尺寸训练、另一个尺寸推理是部署阶段最常见的静默故障。如果要把模型接到服务端或移动端用 ONNX 导出替代 PyTorch 加载体积和速度都好不少dummy_input torch.randn(1, 3, 64, 64).to(device) torch.onnx.export( model, dummy_input, resnet18_fer.onnx, input_names[input], output_names[output], dynamic_axes{input: {0: batch}, output: {0: batch}} )dynamic_axes声明 batch 维度可变方便服务端压测时一次带多张图。这类课程设计项目最容易被忽略的是效果验证。不要只打印一个总 acc把混淆矩阵和每个类别的召回率画出来。我的习惯是训练完固定跑一份类别准确率表格如果之后改数据增强或学习率就对照这份表格决定改动是否有效。说句老实话我从这类 zip 项目里学到最多的不是 ResNet 结构而是“读别人代码时先找坑”的习惯。把该验证的验证、该打印的打印模型结构反而不是最容易出错的地方。希望这篇拆解能帮你少踩几个我踩过的坑把这个项目真正做成自己的东西。本文还有配套的精品资源点击获取
返回列表