简介:这份资源面向计算机相关专业学生与Python初学者,提供一套基于ResNet的人脸表情识别完整实现方案,可用于毕业设计、期末大作业或课程设计场景。压缩包共16个文件,约5.2MB,包含3个Python源码文件、7张表情类别示例图、2份说明文档,以及模型权重、类别索引、人脸检测配置与演示视频等配套内容,覆盖数据准备、模型构建到推理测试的完整流程。项目围绕Disgust、Surprise、Angry、Fear、Sad、Happy、Neutral七类表情展开,源码经本地编译验证可运行,评审得分达98分,难度适中,助教老师已审定内容。目前已有237人学习下载。读者可据此掌握ResNet迁移学习思路、表情分类训练与测试方法,并借助演示视频与说明文档快速复现结果,适合作为入门深度学习与计算机视觉实践的参考项目。
1. 基于ResNet的人脸表情识别:从数据集到Python源码的落地判断
拿到一个「基于ResNet的人脸表情识别python实现源码+数据集」的压缩包,多数人的第一反应是解压、装依赖、跑train.py,然后盯着终端等准确率。但真正决定这个项目能不能用、值不值得投入时间的,不是代码写得多漂亮,而是三件事:数据集的表情标签是否干净、ResNet的预训练权重是否真的迁移得动、推理阶段的人脸对齐有没有做。人脸表情识别(FER)在实验室数据集上刷到 95% 不稀奇,换一张手机自拍就崩,这是这个方向最典型的翻车现场。这篇笔记按「数据集怎么读 → ResNet怎么改 → 训练怎么调 → 推理怎么稳」的顺序拆一遍,适合手里已经有一个FER项目、想把它跑通并搞懂每个参数含义的人,也适合准备用ResNet做迁移学习练手的Python入门者。核心词ResNet、人脸表情识别、python、源码、数据集会在各章自然出现,不堆砌。
2. 数据集先摸清楚:FER2013和RAF-DB的标签长什么样
2.1 两类主流数据集的目录结构与标签分布
人脸表情识别的公开数据集里,被项目打包得最多的是 FER2013 和 RAF-DB。FER2013 是灰度 48×48 的 CSV,一行一个像素加一个 emotion 标签,7 类:0 愤怒、1 厌恶、2 恐惧、3 开心、4 悲伤、5 惊讶、6 中性。RAF-DB 是彩色原图,标签在train_labels.txt里,格式是文件名 类别号,类别号从 1 开始,用的时候要减 1 对齐。拿到源码包先别急着跑,用下面这段把标签分布打出来,这一步能提前暴露大问题。
import pandas as pd import collections # FER2013 的 CSV 没有表头,第一列是 emotion,第二列是 pixels 字符串 df = pd.read_csv("fer2013.csv") print(df.columns.tolist()) # 确认列名,常见为 emotion, pixels, Usage print(df["Usage"].value_counts()) # Training / PublicTest / PrivateTest 各多少 # 统计训练集标签分布 train = df[df["Usage"] == "Training"] counter = collections.Counter(train["emotion"]) for k in sorted(counter): print(k, counter[k], round(counter[k] / len(train) * 100, 2), "%")逻辑说明:Usage列决定哪些样本进训练、哪些进验证,很多人直接train_test_split会把官方划分打乱,导致和论文指标对不上。参数说明:emotion是 0-6 的整数标签,pixels是空格分隔的 2304 个灰度值,读进来要reshape(48, 48)。跑完你会看到 FER2013 的厌恶类只有几百张,开心类有七千多张,比例接近 1:15,这个不均衡是后面必须处理的。
2.2 标签噪声与错误样本的快速筛查
FER2013 的标签噪声是公开的秘密,同一张脸不同标注者给出不同情绪很常见。筛查办法不复杂:先用一个训到 60% 准确率的粗模型跑一遍训练集,把「预测置信度极高但和标签不符」的样本抽出来人工看。常见做法是取 softmax 概率大于 0.9 且预测类别与标签不一致的样本,通常能捞出几百张明显标错的。这一步不做,模型会去拟合噪声,验证集准确率卡在 65% 上不去。RAF-DB 相对干净,但它的align版本和原版要分清,源码里如果写死了ImageFolder读取,就得把标签文件转成按类别分文件夹的结构,否则torchvision.datasets.ImageFolder直接报找不到类。
提示:数据集里如果同时有
fer2013.csv和fer2013new.csv,后者是带多人标注的版本,用之前先确认源码读的是哪一个,读错文件会导致标签对不上。
3. ResNet改造:把1000类分类头换成7类表情
3.1 为什么选ResNet18而不是ResNet50
人脸表情识别的输入分辨率普遍偏低,FER2013 是 48×48,RAF-DB 对齐后常用 100×100 或 112×112。在这个尺度上,ResNet50 的参数量和感受野是浪费的,ResNet18 的 11M 参数足够,训练更快,小数据集上过拟合风险更低。源码里如果默认写的是resnet50(pretrained=True),建议先换成resnet18跑一版基线,再决定要不要加深。预训练权重用 ImageNet 的,虽然 ImageNet 是彩色自然图像、FER 是灰度人脸,但底层边缘和纹理特征依然可迁移,实测比从头训收敛快 3 到 5 倍。
import torch import torch.nn as nn from torchvision import models def build_model(num_classes=7, backbone="resnet18", pretrained=True): if backbone == "resnet18": model = models.resnet18(pretrained=pretrained) in_features = model.fc.in_features # 512 else: model = models.resnet50(pretrained=pretrained) in_features = model.fc.in_features # 2048 # 替换最后的全连接层,输出改为表情类别数 model.fc = nn.Sequential( nn.Dropout(0.5), # 小数据集必备,抑制过拟合 nn.Linear(in_features, num_classes) ) return model model = build_model(num_classes=7) print(sum(p.numel() for p in model.parameters() if p.requires_grad))逻辑说明:model.fc是 ResNet 的分类头,替换成Dropout + Linear是最小改动。参数说明:Dropout(0.5)的 0.5 是经验值,数据量小于两万时用 0.5,大于五万可以降到 0.3。pretrained=True会下载权重,如果环境没网,需要提前把.pth放到~/.cache/torch/hub/checkpoints/下,否则会卡在下载。注意灰度图输入要改成单通道:model.conv1 = nn.Conv2d(1, 64, kernel_size=7, stride=2, padding=3, bias=False),否则三通道权重对单通道数据会报维度错误。
3.2 数据增强与类别不均衡的加权采样
FER2013 的不均衡必须处理,两种做法:一是WeightedRandomSampler按类别频率倒数采样,二是损失函数加class_weight。前者改动小,推荐优先用。
from torch.utils.data import WeightedRandomSampler import numpy as np # labels 是训练集所有样本的标签列表 labels = np.array(train_labels) class_count = np.bincount(labels) class_weight = 1.0 / class_count sample_weight = class_weight[labels] sampler = WeightedRandomSampler( weights=torch.DoubleTensor(sample_weight), num_samples=len(sample_weight), replacement=True ) # DataLoader 里传 sampler=sampler,不要再传 shuffle=True逻辑说明:class_weight是类别频率的倒数,样本权重取其所属类别的权重,这样厌恶类样本被抽到的概率被抬高。参数说明:num_samples设成训练集大小,replacement=True表示有放回采样。增强方面,训练集用RandomHorizontalFlip、RandomRotation(10)、ColorJitter轻微调,验证集只做Resize和Normalize。注意水平翻转对「厌恶」和「恐惧」这类不对称表情可能有副作用,如果翻转后准确率掉,就关掉翻转只留旋转。
4. 训练与调参:学习率、冻结策略和早停
4.1 分阶段解冻与学习率设置
迁移学习的标准打法是先冻结 backbone 只训分类头,再解冻全部微调。源码里如果一上来就全量微调,学习率又设 0.01,backbone 的预训练权重会被冲垮,表现为 loss 先降后炸。
import torch.optim as optim # 阶段一:只训 fc,backbone 冻结 for name, param in model.named_parameters(): if "fc" not in name: param.requires_grad = False optimizer = optim.Adam(filter(lambda p: p.requires_grad, model.parameters()), lr=1e-3) # 训 5 个 epoch 后解冻 # 阶段二:全量微调,学习率降一个量级 for param in model.parameters(): param.requires_grad = True optimizer = optim.Adam(model.parameters(), lr=1e-4, weight_decay=1e-4) scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=30)逻辑说明:阶段一让随机初始化的分类头先收敛,避免大梯度回传破坏预训练特征。参数说明:阶段一lr=1e-3,阶段二lr=1e-4,weight_decay=1e-4是 ResNet 微调的常用值。CosineAnnealingLR的T_max设成总 epoch 数,让学习率余弦下降到接近 0。如果显存够,batch size 用 64 或 128,太小会让 BatchNorm 统计不稳。
4.2 早停与验证指标的选择
验证集准确率是主指标,但 FER 任务上 macro-F1 更能反映不均衡下的真实水平。早停的 patience 设 7 到 10 个 epoch,监控val_loss而不是val_acc,因为 acc 在过拟合后期还会虚高。
best_f1, patience, counter = 0.0, 8, 0 for epoch in range(epochs): train_one_epoch(model, train_loader, optimizer, criterion) val_loss, val_acc, val_f1 = evaluate(model, val_loader) scheduler.step() if val_f1 > best_f1: best_f1 = val_f1 torch.save(model.state_dict(), "best_fer.pth") counter = 0 else: counter += 1 if counter >= patience: print(f"early stop at epoch {epoch}") break逻辑说明:保存的是state_dict而不是整个模型,加载时先建结构再load_state_dict。参数说明:patience=8是经验值,数据量小可以设 5。criterion用CrossEntropyLoss,如果没用加权采样,就传weight=torch.FloatTensor(class_weight)。训练日志里重点看训练集和验证集的 gap,gap 超过 15 个点基本就是过拟合,回去加 Dropout 或减 backbone 深度。
5. 推理与部署:单张图片到摄像头的完整链路
5.1 人脸检测加表情分类的两级流水线
直接拿整张图喂给表情模型是错的,背景和头发会干扰。正确链路是先用 OpenCV 的 Haar 或 DNN 人脸检测器框出人脸,裁剪、灰度化、缩放到 48×48,再送进 ResNet。
import cv2 import torch from torchvision import transforms face_cascade = cv2.CascadeClassifier( cv2.data.haarcascades + "haarcascade_frontalface_default.xml" ) tf = transforms.Compose([ transforms.ToPILImage(), transforms.Grayscale(num_output_channels=1), transforms.Resize((48, 48)), transforms.ToTensor(), transforms.Normalize([0.5], [0.5]) ]) emotions = ["angry", "disgust", "fear", "happy", "sad", "surprise", "neutral"] def predict(frame, model, device): gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) faces = face_cascade.detectMultiScale(gray, 1.3, 5) for (x, y, w, h) in faces: roi = gray[y:y+h, x:x+w] inp = tf(roi).unsqueeze(0).to(device) with torch.no_grad(): prob = torch.softmax(model(inp), dim=1) idx = prob.argmax().item() cv2.rectangle(frame, (x, y), (x+w, y+h), (0, 255, 0), 2) cv2.putText(frame, emotions[idx], (x, y-10), cv2.FONT_HERSHEY_SIMPLEX, 0.9, (0, 255, 0), 2) return frame逻辑说明:detectMultiScale的1.3是缩放步长,5是邻域阈值,调大这两个值检测更严但漏检增多。参数说明:Normalize([0.5], [0.5])要和训练时一致,训练用 ImageNet 均值方差就换成对应的三通道值。torch.no_grad()关掉梯度,推理速度提升明显。注意 Haar 检测器对侧脸和遮挡效果差,如果场景里人脸角度大,换成 DNN 检测器或 MTCNN。
5.2 置信度阈值与误判过滤
模型对每张脸都会输出一个类别,但置信度低的时候这个类别没有意义。加一个阈值,低于 0.5 就显示 unknown,能挡掉大量误判。
conf, idx = prob.max().item(), prob.argmax().item() label = emotions[idx] if conf > 0.5 else "unknown"逻辑说明:prob.max()是最大 softmax 概率,作为置信度。参数说明:阈值 0.5 是起点,实际部署时用验证集画一下置信度分布,取能平衡误报和漏报的位置。如果发现「中性」被大量误判成其他类,检查训练集中性样本是否被欠采样,或者推理时的归一化参数是否和训练一致。
6. 避坑与排查:FER项目最常见的5个翻车点
6.1 准确率虚高但实际不可用
现象:验证集 92%,拿手机自拍测试全是「开心」。原因:验证集和训练集来自同一批数据分布,且没有做人脸对齐,模型学到的是背景和光照的捷径特征。解决:用 RAF-DB 的 align 版本,或者推理前统一做人脸对齐(两眼连线水平),并在不同来源的图片上做交叉验证。
6.2 灰度与三通道混用导致维度报错
现象:RuntimeError: Given groups=1, weight of size [64, 3, 7, 7], expected input[1, 1, 48, 48] to have 3 channels。原因:ResNet 第一层默认三通道,FER2013 是单通道。解决:改model.conv1为单通道,或者把灰度图复制成三通道再送进去,后者改动小但多算两倍。
6.3 预训练权重下载卡住
现象:pretrained=True时程序卡在下载,或者报URLError。原因:环境访问不到权重地址。解决:提前手动下载resnet18-f37072fd.pth放到 torch 的缓存目录,或者设pretrained=False先跑通流程再补权重。注意pretrained在新版 torchvision 里改叫weights,源码里两种写法都可能出现。
6.4 类别不均衡导致少数类全灭
现象:厌恶类召回率接近 0,混淆矩阵里全被分到愤怒。原因:没用加权采样或类别权重。解决:加WeightedRandomSampler,或者损失函数传weight,同时把 macro-F1 作为早停指标而不是准确率。
6.5 推理速度慢到无法实时
现象:摄像头每帧要 200ms 以上。原因:没关梯度、没转 eval 模式、每帧都重新加载模型。解决:model.eval()加torch.no_grad(),模型只加载一次,输入尺寸别超过 112×112,必要时用torch.jit.trace导出或换 ONNX Runtime。
7. 把ResNet换成MobileNet的对比实验与一个调参习惯
跑通 ResNet18 之后,值得做的一件事是换 MobileNetV3 或 ShuffleNet 做对比,看精度掉多少、速度提多少。FER 任务上 MobileNetV3-Small 通常只掉 1 到 2 个点准确率,但推理快 3 倍以上,如果目标是摄像头实时,这个交换很划算。做法很简单,把build_model里的 backbone 换掉,分类头输入维度从 512 改成 MobileNet 的 576 或 1024,其余训练流程不动。
| 模型 | 参数量 | FER2013验证准确率 | 单帧推理(CPU) |
|---|---|---|---|
| ResNet18 | 11.2M | 71% | 45ms |
| ResNet50 | 23.5M | 72% | 110ms |
| MobileNetV3-Small | 2.5M | 69% | 12ms |
| ShuffleNetV2 | 1.4M | 67% | 9ms |
表里的数字是我在自己机器上跑出来的量级,不同预处理和训练轮数会有浮动,但相对关系稳定。选型时先问自己部署在哪:服务器批量处理选 ResNet50,边缘设备或摄像头选 MobileNetV3,别一上来就堆大模型。
最后说一个我踩过多次坑才养成的习惯:每次改完数据增强或学习率,先把训练集的一个 batch 可视化出来看,确认图片没被增强坏、标签没对错。这个动作花两分钟,能省掉几小时盯着 loss 曲线猜原因的玄学时间。人脸表情识别这个方向,数据集和推理链路的坑远比模型结构多,把这两头理顺,ResNet 本身反而不需要怎么动。希望帮到你。
本文还有配套的精品资源,点击获取