ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

基于ResNet的人脸表情识别实战:从FER2013训练到部署

基于ResNet的人脸表情识别实战:从FER2013训练到部署 简介面向Python期末大作业的人脸表情识别项目基于ResNet搭建覆盖图像读取、数据预处理、模型训练与效果评估的完整流程适合需要完成课程设计或入门深度学习的Python开发者参考。压缩包共103个文件约54.11MB核心为19个py源码文件32个png、14个jpg等图片构成表情样本集3个hdf5文件提供预训练权重可直接加载模型进行测试另有xml配置文件、mp4演示视频、md说明文档和gif效果图方便按需查阅。目前已有135人学习下载。源码经过本机调试可运行附带的说明文档能帮助理清数据集组织方式与训练流程训练好的权重文件让不熟悉训练细节的同学也能直接体验人脸表情识别效果而完整源码又适合深入分析ResNet各层结构与分类原理。整体难度适中既能支撑期末答辩也可作为K12阶段接触人工智能的实践素材。1. 基于ResNet的人脸表情识别为什么是期末大作业里最稳的选型每年期末都会看到一批人脸表情识别选题涌上来选型却总在第一步翻车有人拿VGG在48×48的小图上硬撑三天出不了能看的Loss有人自研CNN在验证集上过拟合到怀疑人生。基于ResNet做表情识别之所以稳是因为它把“深度”和“可训性”同时给了你——残差连接让梯度回传不再玄学ResNet18在小数据集上就能压得住数据量和算力都在一台普通笔记本可承受范围内。这套方案本身是一份期末大作业的完整打包源码、数据集加说明文档适合正在赶进度、想用最少踩坑把项目跑通的同学。下面按“数据处理→模型训练→部署推理→问题排查”的顺序拆开讲每个环节都给能直接复现的代码和参数。2. 把FER2013数据处理成ResNet能吃的格式读取、增强与标签映射2.1 FER2013的CSV结构一行一图一标签FER2013是表情识别最常见的公开数据集存储方式有点反直觉它不是一个图片文件夹而是一个CSV文件每一行是一张48×48的灰度图。第一列是emotion标签第二列是pixels第三列是Usage用来标记这张图属于训练集还是测试集。pixels这一列是把48×48灰度值按行展开成2304个数字用空格分隔。做这个项目时第一步就是先把这三列拆明白不然后面读数据全是坑。常见做法是先用Pandas读进来看一眼Training、PublicTest、PrivateTest各自的数量。需要特别留意的是FER2013默认切分是Training约28709张、PublicTest约3589张、PrivateTest约3589张。期末大作业里我一般用Training做训练、PrivateTest做验证PublicTest可以直接并入训练集让ResNet18多看到约12%的数据这个操作对最后两三个百分点的准确率是有实际帮助的。import pandas as pd df pd.read_csv(fer2013.csv) print(df[Usage].value_counts()) # 按 Usage 切分Training 训练PrivateTest 验证PublicTest 并入训练 train_df df[df[Usage] Training] val_df df[df[Usage] PrivateTest] extra_df df[df[Usage] PublicTest] train_df pd.concat([train_df, extra_df], ignore_indexTrue) print(train_df.shape, val_df.shape)这段代码的逻辑是先打印三个子集的样本数确认数据量然后分别筛出训练、验证和可扩充部分最后把PublicTest拼进训练集。参数说明ignore_indexTrue是让合并后的索引重新编号否则后面按索引取样本时train_df和val_df的索引会重叠容易在自定义Dataset里张冠李戴val_df保持原样不动是为了让验证集独立于任何训练数据衡量到的准确率才真实。切分完建议顺手打印一下每个类别的样本数FER2013的类别分布很不均匀这个信息后面做数据增强和类别加权时要用。2.2 数据增强参数翻转、裁剪与归一化表情识别一个容易被低估的点是数据增强。FER2013只有三万多张灰度小图ResNet18参数量一千多万直接硬训十有八九过拟合。我一般会在训练侧用RandomHorizontalFlip、RandomAffine和RandomCrop三件套验证侧只做归一化和缩放坚决不做随机变换——这是必须遵守的约定否则验证集就失去了评估意义。具体参数上有讲究。RandomHorizontalFlip的概率设0.5水平翻转不会改变表情语义能把数据多样性直接翻倍RandomAffine的degrees设10translate设0.1scale设0.9到1.1之间轻微旋转和缩放模拟拍摄角度差异。注意角度不要设太大旋转超过15度后人脸特征会明显失真表情识别不是对旋转很鲁棒的任务这个是我调参数调出来的血泪经验。from torchvision import transforms train_transform transforms.Compose([ transforms.ToPILImage(), transforms.RandomHorizontalFlip(p0.5), transforms.RandomAffine(degrees10, translate(0.1, 0.1), scale(0.9, 1.1)), transforms.RandomCrop(48, padding4), transforms.ToTensor(), transforms.Normalize(mean[0.5], std[0.5]) ]) val_transform transforms.Compose([ transforms.ToPILImage(), transforms.ToTensor(), transforms.Normalize(mean[0.5], std[0.5]) ])关键点在于Normalize的mean和std都填0.5。因为FER2013是单通道灰度图所以mean和std是长度为1的列表不是ImageNet预训练常用的三通道写法[0.485, 0.456, 0.406]。RandomCrop的padding4表示先把图扩边到52×52再随机裁回48×48等效于做了8像素范围内的平移增强这个操作对缓解过拟合很有效。如果你的数据集不是48×48需要把RandomCrop和模型输入尺寸一起改成目标值两者必须保持一致这也是新手最容易埋下的隐患。2.3 自定义Dataset类的完整代码FER2013的CSV格式决定了不能用torchvision的ImageFolder直接读必须写一个自定义Dataset。这个类要做三件事从DataFrame里取到pixels字符串、转成48×48的numpy数组、再套上对应的transform。代码本身不复杂但边界条件多写的时候容易漏。from torch.utils.data import Dataset import numpy as np class Fer2013Dataset(Dataset): def __init__(self, df, transformNone): self.df df.reset_index(dropTrue) self.transform transform self.label_map {0: angry, 1: disgust, 2: fear, 3: happy, 4: sad, 5: surprise, 6: neutral} def __len__(self): return len(self.df) def __getitem__(self, idx): row self.df.iloc[idx] label int(row[emotion]) pixels np.array(row[pixels].split(), dtypenp.uint8) image pixels.reshape(48, 48) if self.transform: image self.transform(image) return image, label核心逻辑在__getitem__里pixels列用split()按空格拆成字符串列表再转成uint8数组并reshape成48×48。dtype必须用np.uint8而不是默认的int64否则后面ToTensor会把输入值域理解错int64数组在部分PyTorch版本里甚至会直接报类型错误。label_map这个字典是给最终推理时把数字标签翻译成可读字符串用的训练阶段不参与计算但建议提前定义好后面画混淆矩阵和报告写分析都要用到。数据集这一层是整个流程的底座如果这里出了问题后面训练的模型全部白搭。建议在写训练代码之前单独跑一段验证取Dataset的前5个样本打印image.shape和label确认输出是torch.Size([1, 48, 48])和整型标签再继续。这一步只花两分钟能省掉后面排查半天数据加载问题的痛苦。3. ResNet18搭建与训练核心代码和参数设定3.1 为什么期末大作业选ResNet18而不是ResNet50选ResNet18而不是ResNet50核心不是玄学是成本和收益的权衡。ResNet18的参数量大约1120万ResNet50大约2550万后者在ImageNet这种千万级数据集上确实更强但表情识别只有三万多张训练图深度加深带来的收益会被过拟合抵消。更现实的是训练时间一台普通笔记本CPU训练ResNet18一个epoch大约3到4分钟ResNet50要翻倍期末大作业通常没有那么多时间反复调参。实际操作上不管用torchvision自带的resnet18还是自己实现都要做三处改动第一层卷积的in_channels从3改成1适配灰度图conv1的kernel_size从7×7改成3×3stride改成1因为48×48的输入图用stride2的7×7卷积会直接丢掉一半信息最后的全连接层输出改成7对应7类表情。这三处改完剩下的残差块结构原样保留。import torch.nn as nn from torchvision import models def build_resnet18(num_classes7): model models.resnet18(pretrainedFalse) model.conv1 nn.Conv2d(1, 64, kernel_size3, stride1, padding1, biasFalse) model.fc nn.Linear(model.fc.in_features, num_classes) return modelpretrainedFalse这里值得专门说。torchvision的resnet18默认带有ImageNet预训练权重输入是三通道RGB而我们的数据是单通道灰度图直接加载会出现第一层通道数不匹配。pretrainedFalse让模型从头初始化配合前面对conv1的修改训练时不会报维度错误。如果真想用迁移学习需要另外处理权重复制这个我在最后一部分单独讲。改完conv1之后模型的第一个卷积输出仍然是64个通道残差块不需要任何额外调整这个改动是侵入性最小的。3.2 训练主循环与超参数设定训练主循环是整套代码里最不值得自己造轮子的部分。常见做法就是标准PyTorch循环每个epoch遍历训练DataLoader计算Loss、反向传播、更新权重然后跑一遍验证集计算准确率。不需要自己写分布式、混合精度这些东西期末大作业用不上加了还容易出环境兼容问题。import torch import torch.nn as nn from torch.utils.data import DataLoader device torch.device(cuda if torch.cuda.is_available() else cpu) model build_resnet18().to(device) criterion nn.CrossEntropyLoss() optimizer torch.optim.Adam(model.parameters(), lr1e-3, weight_decay1e-4) train_dataset Fer2013Dataset(train_df, transformtrain_transform) val_dataset Fer2013Dataset(val_df, transformval_transform) train_loader DataLoader(train_dataset, batch_size128, shuffleTrue, num_workers2) val_loader DataLoader(val_dataset, batch_size128, shuffleFalse, num_workers2) for epoch in range(30): model.train() train_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() train_loss loss.item() * images.size(0) model.eval() correct 0 total 0 with torch.no_grad(): for images, labels in val_loader: images, labels images.to(device), labels.to(device) outputs model(images) _, predicted torch.max(outputs, 1) total labels.size(0) correct (predicted labels).sum().item() print(fEpoch {epoch1:02d} | Loss {train_loss / len(train_dataset):.4f} | Acc {correct / total:.4f})这里的超参数是权衡过的。batch_size设128是因为FER2013单张图只有48×48×1显存占用很小6GB显存的显卡毫无压力如果CPU训练建议降到32或64减少每批次计算峰值。Adam的学习率1e-3是经验默认值weight_decay设1e-4做L2正则对压制过拟合很关键。30个epoch是基线配置实际训练中盯着验证集准确率如果10个epoch后还在涨就继续连续4个epoch不再提升就该考虑早停或者降学习率了。参数取值说明batch_size128GPU可放宽CPU建议3264lr1e-3Adam默认经验值配合调度器动态调整weight_decay1e-4L2正则系数过拟合时优先调大到1e-3epochs30基线配置按验证集表现提前停或延长3.3 学习率调整与模型保存训练到后半程固定学习率容易在Loss曲面底部来回震荡。我一般会在第15个epoch之后把学习率降到原来的十分之一让权重在更小的步长下微调。常见做法是用ReduceLROnPlateau它会监控验证集Loss连续不下降就自动降学习率省去手动判断的麻烦。from torch.optim.lr_scheduler import ReduceLROnPlateau scheduler ReduceLROnPlateau(optimizer, modemin, factor0.1, patience3) # 每个epoch验证结束后调用 scheduler.step(val_loss)保存模型这里有个经典误区只保存state_dict而不是整个model对象。state_dict是权重的纯字典形式体积小、加载快、跨机器兼容性好整个model序列化会把类的定义路径也存进去换目录或者换环境立刻报错。保存时把验证集准确率最高的那份单独命名为best_model.pth覆盖保存这样训练后期即使过拟合加重手里永远握着最佳版本算是有后悔药。best_acc 0.0 # 每个epoch验证结束后 if acc best_acc: best_acc acc torch.save(model.state_dict(), best_model.pth)加载时用同一个build_resnet18函数先实例化模型再load_state_dict。注意要先把模型to(device)再加载否则CPU和GPU之间的权重device不匹配会报错。如果是训练中断后续训只加载权重还不够——optimizer的state_dict也要一起保存和加载因为Adam的动量信息丢失后重启训练前几个epoch的效果会明显变差。提示进程被杀或断电时best_model.pth里保存的是最近一次验证集最优的权重。重新启动直接加载这个文件继续调参不需要从头重跑能省下大量时间。4. 训练完怎么用图片推理与摄像头实时识别4.1 单张图片推理代码训练完成后最直接的验证方式是拿一张没见过的图片跑前向推理。这个流程每一步都有对应的坑读图→转灰度→缩放48×48→归一化→进模型→取softmax最大值对应的类别。OpenCV的imread默认读出来是BGR三通道必须先用cvtColor转成灰度否则通道数对不上模型输入。import cv2 import torch def predict_image(model, image_path, device): model.eval() img cv2.imread(image_path) gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) resized cv2.resize(gray, (48, 48)) tensor torch.from_numpy(resized).unsqueeze(0).unsqueeze(0).float() / 255.0 tensor (tensor - 0.5) / 0.5 tensor tensor.to(device) with torch.no_grad(): output model(tensor) prob torch.softmax(output, dim1) pred torch.argmax(prob, dim1).item() label_map {0: angry, 1: disgust, 2: fear, 3: happy, 4: sad, 5: surprise, 6: neutral} return label_map[pred], prob[0, pred].item()这里有一个容易翻车的细节训练时Normalize用的是mean0.5、std0.5推理时也必须做一模一样的归一化不能只除以255就进模型。很多人在这里忘了减均值除标准差导致推理结果和验证集准确率对不上还以为是模型坏了。tensor从H×W变成1×1×H×W第一个unsqueeze(0)模拟batch维度第二个模拟channel维度因为灰度图只有一个通道。softmax概率值同时返回方便在界面上显示置信度答辩展示时比只给一个标签更有说服力。4.2 用OpenCV做摄像头实时表情识别摄像头实时识别是答辩时最出效果的部分。核心是用OpenCV的VideoCapture读每一帧用Haar级联检测人脸并框出ROI把ROI缩放到48×48送进模型再把预测结果画在框上。人脸检测用的xml文件在OpenCV安装目录里自带不需要额外下载。import cv2 import torch model build_resnet18() model.load_state_dict(torch.load(best_model.pth, map_locationcpu)) model.eval() cap cv2.VideoCapture(0) face_cascade cv2.CascadeClassifier(cv2.data.haarcascades haarcascade_frontalface_default.xml) label_map {0: angry, 1: disgust, 2: fear, 3: happy, 4: sad, 5: surprise, 6: neutral} while True: ret, frame cap.read() if not ret: break gray cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) faces face_cascade.detectMultiScale(gray, scaleFactor1.1, minNeighbors5, minSize(48, 48)) for (x, y, w, h) in faces: roi gray[y:yh, x:xw] roi cv2.resize(roi, (48, 48)) tensor torch.from_numpy(roi).unsqueeze(0).unsqueeze(0).float() / 255.0 tensor (tensor - 0.5) / 0.5 with torch.no_grad(): output model(tensor) pred torch.argmax(output, dim1).item() prob torch.softmax(output, dim1)[0, pred].item() cv2.rectangle(frame, (x, y), (xw, yh), (0, 255, 0), 2) cv2.putText(frame, f{label_map[pred]} {prob:.2f}, (x, y-10), cv2.FONT_HERSHEY_SIMPLEX, 0.9, (0, 255, 0), 2) cv2.imshow(FER, frame) if cv2.waitKey(1) 0xFF ord(q): break cap.release() cv2.destroyAllWindows()detectMultiScale的三个核心参数直接决定检测灵敏度。scaleFactor1.1表示每次搜索窗口缩小10%越接近1越慢但越准minNeighbors5表示一个候选框至少要有5个邻近框确认才算人脸调小会出更多误检调大容易漏检测minSize(48, 48)直接过滤掉比训练图还小的框因为这种框即使检测到了缩放进模型后也丢光了细节。这一段的实际体验对答辩很关键建议提前在教室或实验室的光照条件下跑一遍确认能稳定识别而不是到答辩现场才第一次试。5. 避坑指南训练过程中的5个常见翻车现场5.1 过拟合Loss下降但验证集准确率不动现象训练集Loss每轮都在降验证集准确率在60%上下原地踏步甚至往下掉。原因FER2013只有三万多张图ResNet18的拟合能力太强模型开始背诵训练样本而不是学习表情的通用特征。这是小数据集配深网络最典型的症状。解决三步走。第一确认数据增强有没有实际作用到训练集很多人把增强写在transform里却在Dataset构造时忘了传进去。第二增大weight_decay从1e-4加到1e-3给权重更强的正则约束。第三在最后一层全连接前加一个nn.Dropout(p0.3)。我自己的经验是增大weight_decay比加Dropout效果来得更快同时训练Loss会略微抬高这是正常的说明过拟合被压住了。5.2 数据不平衡个别类别准确率低得离谱现象训练完成看分类报告angry和disgust的F1分数明显低于happy和neutral单独抽这几类样本测试准确率不到40%。原因FER2013类别分布本来就不均匀disgust类只有大约600张训练样本而happy有7000多张模型天然倾向预测占比大的类别少数类被牺牲掉。解决最有效的是在Loss层面做类别加权。torch.nn.CrossEntropyLoss自带weight参数传入与各类别样本数倒数成比例的权重向量即可。另一个办法是用WeightedRandomSampler做训练集采样让每个epoch中少数类被重复采样到接近多数类的数量这样不用改Loss就能让模型在训练时看到均衡的类别比例。这两种方法可以同时用期末作业里选用一种就能看到明显改善。5.3 灰度图和三通道维度不匹配现象DataLoader跑起来第一轮就报错RuntimeError提示channel维度不匹配expected 3 got 1或者反过来在推理时报expected 1 got 3。原因模型第一层卷积的输入通道写的还是3但FER2013的数据是单通道灰度图。反过来如果你推理时用cv2.imread直接读彩色图送进模型也会出现同样的维度冲突。解决模型搭建时就要统一把conv1.in_channels改成1这是第一步。第二个容易忽略的是训练和推理的预处理必须完全一致如果训练用灰度图推理就不能传彩色图。遇到这类错误优先检查模型定义和transform不要先怀疑数据本身。5.4 显存不足或者CPU训练慢到崩溃现象训练到一半进程直接崩掉提示CUDA out of memory或者CPU训练一个epoch要十几分钟整个流程根本跑不完。原因batch_size太大把显存挤爆或者num_workers设置不当导致内存开销过高。学生笔记本往往是4GB显存或干脆只有集显本来负担就重。解决batch_size降到32甚至16并确认模型和训练数据都在cuda上而没有跑偏。如果CPU训练num_workers设成0可以避免多进程加载带来的额外内存开销。更狠一点的办法是先把预处理后的48×48图存成npy数组训练时直接读npy省掉每次CSV字符串解析的耗时实测CPU训练能提速20%以上。5.5 加载模型时key不匹配现象加载best_model.pth时报错提示Missing key(s)和Unexpected key(s)比如fc.weight和conv1.weight对不上。原因两个常见来源。一是模型实例化时改了结构但保存的权重来自改结构之前的模型二是环境A和B里的模型类定义不一致比如一台机器用的全连接层输出是7类另一台改成了别的数值。解决先打印模型每一层名字和权重形状检查conv1和fc是否和构造时一致。保存和加载必须用同一个build_resnet18函数不要手动New一个结构后再load。另外torch.load时加map_locationcpu即使权重是在GPU上训练的也能在无GPU的机器上加载不会因为device不匹配报错这是最实用的一个技巧。6. 让准确率再进一步迁移学习与混淆矩阵分析6.1 用torchvision预训练权重做迁移学习如果验证集准确率卡在65%左右上不去一个可尝试的提升手段是用ImageNet预训练权重做迁移学习。但这里的坑很明确ImageNet是三通道彩色图FER2013是单通道灰度图直接加载会通道数不匹配。常见做法是把灰度图复制成三通道然后加载预训练模型再冻结前几层只微调后面几层。from torchvision import models model models.resnet18(pretrainedTrue) model.conv1 nn.Conv2d(1, 64, kernel_size3, stride1, padding1, biasFalse) # 复制权重新conv1每个输出通道取原权重在输入通道维度的均值 with torch.no_grad(): model.conv1.weight.copy_(model.conv1.weight.mean(dim1, keepdimTrue)) model.fc nn.Linear(512, 7)权重复制这里用取均值的方法原conv1权重形状是[64, 3, 7, 7]在输入通道维度求均值后变成[64, 1, 7, 7]keepdimTrue保持维度数不变。但说实话FER2013和ImageNet的域差距很大预训练权重的收益没有目标检测任务里那么明显我从65%提到68%左右提升有限。如果你时间紧先做好数据增强和正则也足够交差迁移学习是加分项而不是必选项。6.2 混淆矩阵的绘制与报告引用期末大作业的说明文档里准确率一个数字说服力有限混淆矩阵是答辩时最直观展示模型短板的东西。它一眼就能看出模型把难过误判成了中性还是把恐惧误判成了惊讶。绘制方法是在验证集上收集所有预测结果和真实标签用sklearn生成矩阵再用seaborn画成热力图。from sklearn.metrics import confusion_matrix import matplotlib.pyplot as plt import seaborn as sns y_true, y_pred [], [] model.eval() with torch.no_grad(): for images, labels in val_loader: images, labels images.to(device), labels.to(device) outputs model(images) _, predicted torch.max(outputs, 1) y_true.extend(labels.cpu().numpy()) y_pred.extend(predicted.cpu().numpy()) cm confusion_matrix(y_true, y_pred) labels [angry, disgust, fear, happy, sad, surprise, neutral] plt.figure(figsize(10, 8)) sns.heatmap(cm, annotTrue, fmtd, xticklabelslabels, yticklabelslabels) plt.xlabel(Predicted) plt.ylabel(True) plt.tight_layout() plt.savefig(confusion_matrix.png, dpi150)这段代码里y_true和y_pred要全部收集完再统一算一次混淆矩阵不能在每个batch里分别算再相加因为混淆矩阵本质是全量预测结果的计数。annotTrue会在每个格子显示数字fmtd表示用整数格式显示否则seaborn默认用科学计数法。混淆矩阵放进报告时记得在下方配一段简短分析指出哪两类最容易混淆比如sad和neutral再解释这个结果和人类认知的一致性。只贴图不解释的扣分概率很高这段文字是评分时的实际加分项。最后说说我的习惯。做这个项目时我在训练完成后会留着最后一版模型和第一版模型的准确率对比写报告时把每次调参改了什么、验证集涨了多少点逐条列出来这份过程记录比最后的准确率数字更能体现工作量。答辩时被问到“你怎么知道这是过拟合”直接把自己记录里训练Loss下降、验证Acc停滞的曲线拿出来讲比背概念有说服力得多。这个基于ResNet的表情识别项目真正值钱的不是那几行模型代码而是你有没有把数据、训练、推理这条链路上的每个环节都走通并且把原理说清楚。希望帮到你。本文还有配套的精品资源点击获取
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表