简介:基于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_channels=1 并放弃该层预训练权重。多数「高分项目」的做法是复制通道,这个不影响最终效果。
类别严重不均衡: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),则需要写一个解析类。
我在本地跑这类项目时,第一步永远是把数据集目录结构打印出来,确认图片尺寸、通道数和类别文件夹数量。这里统计一下:
| 数据集 | 图片尺寸 | 通道 | 类别数 | 是否需要预训练 |
|---|---|---|---|---|
| FER2013 | 48x48 | 1 | 7 | 可选,但强烈建议 |
| RAF-DB | 100x100 左右 | 3 | 7 | 是 |
| CK+ | 640x490 或裁剪后 | 3 | 8 | 否(视频帧,规模更小) |
一句话:拿到 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 年之前写的,大概率用的是torch==1.8.0甚至更老,而你现在环境里装的可能是 torch 2.x。版本跨度大时,torchvision.models.resnet18(pretrained=True)这个 API 已经废弃,要改成weights=ResNet18_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_classes=7, pretrained=True): if pretrained: # torch 2.x 推荐写法;旧代码里 pretrained=True 已弃用 model = models.resnet18(weights=models.ResNet18_Weights.DEFAULT) else: model = models.resnet18(weights=None) # 拿到最后一层全连接输入的维度,mind that 不同 resnet 不一样 in_features = model.fc.in_features # 替换分类头:7 分类表情 model.fc = nn.Sequential( nn.Dropout(p=0.3), # 对表情识别很有效,后面避坑细说 nn.Linear(in_features, num_classes) ) return model逻辑说明:这个函数把 torchvision 内置的 ResNet18 分类头从 1000 类替换成 7 类,同时保留所有卷积层的预训练权重。fc.in_features在 ResNet18 里是 512,ResNet50 里是 2048;写成动态读取的好处是换骨干网络时不用改代码。
参数说明:Dropout 0.3是我在表情任务上常用的值,比默认不加 Dropout 能提升 2~3 个点验证集准确率。如果数据量极小(比如自定义采集了几千张),把 Dropout 提到 0.5。pretrained=False适合你想从零训练或加载自己的 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(p=0.5), transforms.RandomRotation(degrees=10), transforms.ToTensor(), normalize ]) val_transform = transforms.Compose([ transforms.Resize((64, 64)), transforms.ToTensor(), normalize ]) train_dataset = datasets.ImageFolder('dataset/train', transform=train_transform) val_dataset = datasets.ImageFolder('dataset/val', transform=val_transform) train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True, num_workers=4) val_loader = DataLoader(val_dataset, batch_size=64, shuffle=False, num_workers=4)逻辑说明:ImageFolder会自动按子文件夹名生成类别索引,比如angry/文件夹下的图都会被归为索引 0。数据集规模小时,RandomRotation的度数不要超过 15 度,人脸转多了语义就变了。Resize((64, 64))是个折中:FER2013 原图 48x48 信息量太少,直接输入 ResNet 效果不佳,放大到 64 再配合预训练权重,相当于让模型看到更完整的脸部纹理。
参数说明:batch_size=64在 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_norm=10.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(), f'checkpoints/best_epoch{epoch}_acc{val_acc:.4f}.pth') # 余弦退火或 StepLR 二选一 scheduler.step()逻辑说明:训练函数里多做了两件事:梯度裁剪和控制 best state 的深拷贝。copy.deepcopy在这里不是可有可无——如果直接用model.state_dict()赋值,后面的训练会原地修改同一个 dict,最后保存的模型其实是最后一轮,不是最优轮。README 里写“保存 best”但不深拷贝的代码,是这类项目里最隐蔽的坑。
参数说明:max_norm=10.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_location='cpu') print('Total keys in ckpt:', len(ckpt.keys())) print('fc.weight shape:', ckpt['fc.weight'].shape) # 应该是 [7, 512]如果fc.weight的 shape 不是[7, 512],说明这份权重对应的分类头类别数对不上。解决办法是strict=False加载,让 fc 层重新初始化:
model.load_state_dict(torch.load('checkpoints/best_model.pth'), strict=False)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_decay=1e-4) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR( optimizer, T_max=total_epochs, eta_min=1e-6 )参数说明:AdamW 里的weight_decay相当于 L2 正则,对防止 fc 层过拟合有效。eta_min=1e-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', classes=np.array(range(len(class_names))), y=np.array(label_list) ) weights = torch.tensor(weights, dtype=torch.float32).to(device) criterion = nn.CrossEntropyLoss(weight=weights)逻辑说明: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(p=0.5), transforms.RandomRotation(degrees=8), transforms.ToTensor(), normalize, RandomErasing(p=0.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 降到 16,num_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_norm=5.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_location='cpu') # 如果是 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, strict=True)严格模式先不要乱关,strict=False开的越早,越容易掩盖结构不匹配的问题。
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, dim=1) 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 结构,而是“读别人代码时先找坑”的习惯。把该验证的验证、该打印的打印,模型结构反而不是最容易出错的地方。希望这篇拆解能帮你少踩几个我踩过的坑,把这个项目真正做成自己的东西。
本文还有配套的精品资源,点击获取