尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

图像情绪分析实战:基于ResNet18与FER2013的人脸表情识别

图像情绪分析实战:基于ResNet18与FER2013的人脸表情识别 简介图像情绪分析是计算机视觉中连接感知与认知的关键技术它通过解析人脸图像中的表情特征来识别喜怒哀乐等心理状态。其技术原理通常依托卷积神经网络利用残差结构如ResNet18有效捕捉面部肌肉纹理的细微变化而迁移学习则解决了小样本数据下的训练难题。在工程实践中数据增强与损失函数加权是提升模型泛化能力的重要手段。该技术已广泛应用于人机交互、教育评估、医疗辅助等场景。本文以FER2013和CK数据集为例系统梳理了从数据预处理、模型微调、调参避坑到实验评估的完整流程并提供了基于PyTorch的可复现方案可供入门计算机视觉项目参考。1. 为什么选图像情绪分析作为导论大作业人工智能导论这门课的期末大作业每年都是大家最头疼的事。选题太简单显得没诚意选题太难又容易把自己坑进去。我当时在选题阶段刷了一圈历届优秀作业发现图像情绪分析这个方向几乎年年有人做但真正做得像样的反而不多。这个题目看起来很经典——给一张人脸图片模型输出对应的情绪类别比如开心、悲伤、愤怒、惊讶、恐惧、厌恶、中性这七类。但真正动手做起来从数据处理到模型训练再到文档撰写每一步都藏着不少坑。先说为什么选它。情绪分析属于计算机视觉领域里“入门门槛适中但延展空间很大”的方向。它不像图像分类的猫狗识别那么烂大街也不像目标检测那样需要搞锚框和NMS这些繁琐的细节更不像图像生成那样对算力有硬性要求。核心任务就是把一张人脸图像映射到一个情绪类别上这本质上是一个图像分类问题正好能覆盖人工智能导论课程里讲到的图像表示、特征提取、神经网络、卷积网络这些核心知识点。更重要的是这个课题有真实的应用价值。情绪识别在人机交互、教育评估、医疗辅助、内容推荐这些场景里都有落地需求写文档和实验报告的时候可以从应用角度切入让整个项目的立意高不少。课程老师其实很看重这一点——你是不是只停留在“跑通了一个网络”还是能说清楚这个技术在实际中解决什么问题。从交付物的角度来说这个题目也很适合做成一套完整的大作业。我的最终交付包括可运行的源代码数据预处理脚本、模型定义、训练与评估脚本、单张图片预测脚本、一份详细的项目文档说明环境配置、代码结构、运行步骤、以及一份完整的实验报告问题定义、方法设计、实验过程、结果分析、总结反思。这三样东西互相支撑代码是实验报告的验证实验报告是代码的升华文档说明则是连接两者的桥梁。当时我给自己定的目标是这套东西哪怕一年后拿给别人看别人也能照着跑通并复现我的结果。讲真大作业这个东西做出来不难做好很难。很多人最后仓促交一个只跑过几十个epoch的resnet脚本准确率难看不说文档也就两页纸。我这次花了不少心思在方案设计和实验记录上最后拿到的结果和评价都不错所以想把整个过程整理出来给后面选这个题的人一个可以直接参考的路线图。2. 数据集的选型与预处理情绪分类的地基工程2.1 公开数据集怎么选FER2013和CK怎么配合使用做情绪分析数据集的选择基本决定了你最终的天花板。情绪数据集比普通图像分类数据集更麻烦的一点是情绪标注本身有很强的主观性——同一个人脸有人说这是悲伤有人觉得这是平静很难有绝对正确的标签。所以市面上的情绪数据集数量并不算多质量也参差不齐。我去翻了一圈之后锁定在两个最主流的数据集上FER2013和CK。FER2013是Kaggle上非常经典的人脸情绪识别数据集48x48的灰度图像35000多张图标注了7类情绪angry、disgust、fear、happy、sad、surprise、neutral。这个数据集的优势是数据量足够大类别覆盖完整而且普遍被学术界用作基准很多论文都在这个数据集上报告结果便于横向对比。缺点是图像质量参差不齐有些图是错位的、模糊的甚至有人怀疑部分标注本身就有问题。CKExtended Cohn-Kanade Dataset则是一个偏实验室环境的数据集只有几百个视频序列的人脸表情帧但标注质量非常高是很多情绪识别论文验证模型泛化能力的标配。它的缺点是数据量太小单独用来训练肯定不够。我当时采用的方案是以FER2013作为主训练集CK用来做额外的泛化验证。也就是说模型在FER2013上训练和调参训练完成后在CK的测试帧上跑一遍看看模型面对质量更高、和训练分布不同的数据时表现如何。这个思路在写实验报告的时候特别有价值——很多人的报告只报告一个测试集准确率而我多了一个跨数据集验证的维度体现出你考虑过模型的泛化能力。2.2 灰度图与尺寸的统一处理流程FER2013的原图是48x48的灰度图。这个分辨率在今天的视觉任务里算非常低的但情绪识别本身对高频纹理细节的要求不像物体识别那么高面部肌肉的形变模式在低分辨率下依然能保留。当然48x48对于现代网络来说输入分辨率有点小直接训练也不是不行但为了能迁移预训练模型很多预训练模型期望至少224x224的输入我选择了上采样策略。我的做法是先用OpenCV将48x48的灰度图resize到128x128然后用三通道复制的方式把灰度图转成RGB三通道。为什么是128而不是224因为我的实验环境只有一张老旧GTX 1060显卡6GB显存224的输入会让batch size被迫降到16以下反而影响BatchNorm的稳定性。128x128是一个不错的折中能保留足够的面部细节又不会撑爆显存。数据预处理的完整流程大概是import cv2 import numpy as np def load_fer2013_image(pixel_str, target_size(128, 128)): # FER2013 CSV里存储的是以空格分隔的灰度像素值 pixels np.array(pixel_str.split(), dtypenp.float32) # 还原为48x48灰度图 img pixels.reshape(48, 48) # 上采样到目标尺寸 img cv2.resize(img, target_size, interpolationcv2.INTER_CUBIC) # 转成三通道适配预训练模型的输入格式 img_rgb cv2.cvtColor(img, cv2.COLOR_GRAY2RGB) return img_rgb这里有个细节值得注意resize的插值算法。我对比过INTER_NEAREST、INTER_LINEAR和INTER_CUBIC三种方式发现INTER_NEAREST会产生明显的锯齿影响情绪特征提取INTER_CUBIC虽然计算量大一点但在这种低分辨率放大的场景下边缘更平滑最终准确率能高出1到2个百分点。这个细节写在实验报告里显得非常专业说明你对数据预处理做过对比实验。2.3 数据增强怎么用有限的FER2013变出更多有效样本数据增强是情绪分析里非常关键的一环。FER2013虽然有三万多张图但分配到七个类别之后每个类别平均只有5000张左右而且类别分布很不均衡——happy和neutral样本多disgust样本极少只有不到600张。如果不做增强模型很容易对样本量少的类别过拟合。我用的增强策略是随机水平翻转probability0.5情绪识别里常用的操作因为人脸左右翻转不改变情绪类别能有效增加样本多样性随机旋转范围-15度到15度轻微的头部倾斜是日常场景中非常常见的随机亮度调整0.8到1.2适应光照变化随机裁剪crop比例0.9-1.0然后resize回原尺寸模拟不同人脸框检测精度的差异这里我提个醒不要用太激烈的增强。比如旋转角度如果超过30度人脸已经明显变形反而会让模型学到错误的特征。亮度调整的系数也不要超过1.5倍否则部分图像的梯度信息会被破坏。当时我还做了一个实验对比用augmentation和不用augmentation同样训练30个epoch前者的验证集准确率高出约7个百分点。这个数据写进报告后就非常有说服力证明增强不是可有可无的锦上添花而是数据有限时的必需品。3. 模型架构设计从ResNet到微调策略3.1 为什么选ResNet18而不是自己搭一个简单CNN很多导论大作业的常规操作是拿PyTorch或TensorFlow搭一个三四层的卷积网络然后训练几十个epoch收工。这种做法不是不行但说实话很难拿到好看的准确率也很难在实验报告里写出有深度的分析。我当时给自己定的标准是要在FER2013上跑到60%以上的准确率这个数据集上人类的准确率大约是65%左右所以必须启用预训练模型而不是从零训练。常用的预训练模型有几个选择VGG16、ResNet18/50、MobileNetV2、EfficientNet。我对比之后选了ResNet18理由有三第一ResNet18的残差结构更适合情绪识别这类任务。情绪特征往往体现在面部的局部纹理变化上比如嘴角的弧度、眉毛的倾斜度。残差结构通过跳跃连接让梯度可以更顺畅地传播到浅层网络训练起来更稳定不容易出现梯度消失。第二ResNet18足够轻量。相比ResNet50和VGG16它的参数量只有约1100万在我的GTX 1060上训练一个epoch大概只需要40秒整套实验跑下来时间成本低很多。大作业毕竟不是论文需要在“模型复杂度和算力可承受度”之间找平衡。第三ResNet18的预训练权重在ImageNet上经过充分训练底层特征边缘、纹理、颜色块是通用的。虽然ImageNet是1000类自然图像没有专门的人脸数据但底层特征迁移到人脸表情识别上依然有效。这就是迁移学习的核心思想——底层通用特征直接复用高层特征针对目标任务重新训练。3.2 网络结构修改如何把通用分类器改成情绪分类器我用PyTorch的torchvision库加载ResNet18的预训练权重然后对最顶层的全连接层做替换。原始ResNet18的最后一层全连接输出1000类我需要改成7类。这个替换的代码很简单但背后的逻辑值得写成文档import torchvision.models as models import torch.nn as nn def create_model(num_classes7): # 加载预训练权重 model models.resnet18(pretrainedTrue) # 获取原全连接层的输入特征维度 in_features model.fc.in_features # 替换最后一层输出7个情绪类别 model.fc nn.Linear(in_features, num_classes) return model从迁移学习的角度来说这个替换隐含着一个重要策略前面所有层的参数都保留预训练权重的初始化值只有最后一层是随机初始化的。这意味着训练时最后一层的梯度更新幅度会明显大于前面的层因为随机初始化的输出层对损失函数的影响最大。我特意让前面的层使用较小的学习率大概是全新层的十分之一让最后一层快速学习情绪分类的决策边界而前面的层只做微调。还有一个实用技巧我在训练初期把骨干网络的所有参数冻结requires_gradFalse只训练最后一层全连接。这样做两三个epoch后新加的层已经能对输入特征做一个初步的合理映射然后再解冻所有层一起训练。这个“先冻结后解冻”策略能避免前期随机初始化层的大梯度冲击预训练权重。3.3 损失函数、优化器和评价指标的搭配逻辑情绪分类是标准的多分类问题损失函数我用的是交叉熵损失CrossEntropyLoss。这个选择没有悬念很多新手也能答上来但我想说的是怎么和前面的类别不均衡结合起来考虑。FER2013中disgust类别的样本特别少如果用普通的交叉熵模型会倾向于把所有样本都预测成样本量大的类别比如happy因为这样总损失最小。解决这个问题有两个常用方法一是对少数类别的样本进行过采样二是用加权交叉熵损失。我采用的是后者给每个类别的损失乘上权重权重是训练集中各类别样本数的倒数归一化结果import torch.nn as nn def compute_class_weights(labels): # labels是形状为[N]的类别标签数组 class_counts np.bincount(labels, minlength7) total class_counts.sum() weights total / (class_counts * 7) return torch.tensor(weights, dtypetorch.float32) criterion nn.CrossEntropyLoss(weightcompute_class_weights(train_labels))优化器方面我选了AdamW。相比传统的AdamAdamW把权重衰减L2正则和动量更新解耦训练时的泛化效果通常更好是目前的主流选择。学习率初始值设为0.0001这个值对微调预训练模型比较安全不会因为学习率太大而破坏已有的预训练特征。评价指标不能只看准确率。在类别不均衡的数据集上准确率会被大类别的样本主导导致模型在少数类别上表现极差时准确率依然很好看。所以我在实验报告里同时报告了每个类别的Precision、Recall和F1-score以及宏平均F1macro-F1。这套指标体系的完整程度是一个加分项答辩的时候老师很可能会追问。4. 训练中的关键调参与避坑记录4.1 显存不足的应对方案batch size与梯度累积我用的GTX 1060只有6GB显存128x128的输入batch size如果设置成64直接OOM。第一次跑的时候报错“CUDA out of memory”我第一反应是把batch size降到32但训练了两三个epoch后发现验证集准确率的波动很大。后来我意识到问题的根源是batch size太小导致BatchNorm层的均值方差估计不稳定。解决的方式不是继续压缩batch size而是用梯度累积gradient accumulation来模拟更大的batch。梯度累积的思路很简单不每个batch都更新一次参数而是累积几个batch的梯度攒够一个模拟大batch之后再更新一次。具体实现如下accumulation_steps 2 # 模拟batch size翻倍 optimizer.zero_grad() for i, (images, labels) in enumerate(train_loader): images, labels images.cuda(), labels.cuda() outputs model(images) loss criterion(outputs, labels) # 除以累积步数保持梯度量级一致 loss loss / accumulation_steps loss.backward() if (i 1) % accumulation_steps 0: optimizer.step() optimizer.zero_grad()实际batch size从32变成64之后训练曲线的稳定性明显改善。这里有个小坑别忘记把梯度除以累积步数否则梯度会比预期大一倍可能导致训练发散。4.2 过拟合的早期信号怎么判断情绪分析这种小数据集任务过拟合是头号问题。我训练到第15个epoch左右就开始观察到训练准确率持续上升接近95%但验证准确率在62%左右徘徊两个指标之间的差距越拉越大——这是过拟合的典型信号。我尝试的第一个方案是加大数据增强的强度把旋转角度从10度放宽到15度翻转概率从0.3提高到0.5。这个操作把过拟合的起点往后推了大概5个epoch但到第25个epoch还是出现了明显的过拟合。第二个方案是加早停early stopping监控验证集的宏平均F1如果连续8个epoch都没有提升就保存当前最佳模型并停止训练。早停看着简单实际执行起来有个细节——得额外保存一份“当前最佳模型”的拷贝而不是简单地在训练结束时保存最后一个模型。因为最后一个epoch的模型往往并不是验证集上表现最好的那个我用验证集F1最高的那个epoch第34个epoch作为最终模型准确率比最后第40个epoch高了约2个百分点。第三个方案是加大权重衰减weight decay从默认的1e-4调到5e-4。这相当于在损失函数中增加更大的惩罚项逼迫模型学习到简单、泛化能力更强的权重。这三招组合起来最终把验证集准确率稳定在了63%到64%之间。4.3 学习率调度怎么用余弦退火在末期再提1到2分训练初期我用的固定学习率0.0001但训练到中后期发现loss在某个平台期反复震荡无法继续下降。这个现象很常见——固定学习率在接近局部最优时会在这个最小值附近来回弹跳。我当时用的解决方案是余弦退火Cosine Annealing学习率调度。这个策略让学习率按照余弦曲线的形式从初始值平滑下降到接近0。具体含义是训练初期保持较高的学习率快速收敛随着训练进行学习率逐渐减小让更新步长越来越小从而更精细地逼近最优解。配合warmup前5个epoch从很小的学习率线性升到目标学习率整体的学习率策略是一个完整的温启动-高学习率-退火的过程。这个调度方法让我在最后10个epoch里把验证集准确率提升了大概1.5个百分点幅度不算大但在比赛和报告里“稳定提升”本身就有说服力。我把完整训练过程中的关键超参数整理成了一个表方便后来者直接参考超参数设置值说明输入尺寸128x128x3灰度图转为三通道batch size6432 x 2梯度累积适配显存同时保证BN稳定初始学习率1e-4微调预训练模型的安全值权重衰减5e-4缓解过拟合优化器AdamW解耦权重衰减泛化更好训练轮数50早停于34轮实际保存的最佳epoch数据增强翻转、旋转±15°、亮度、裁剪缓解过拟合5. 实验评估与结果分析数字之外的洞察5.1 FER2013测试集上的最终结果经过完整训练后我在FER2013的官方测试集上得到的最终结果如下类别PrecisionRecallF1-scoreangry0.580.520.55disgust0.610.440.51fear0.530.470.50happy0.820.880.85sad0.560.540.55surprise0.720.750.73neutral0.600.630.61宏平均0.630.600.61整体准确率65.3%宏平均F1是0.61在单模型、只使用预训练ResNet18的条件下这个结果在FER2013排行榜上处于中上水平接近人类的基准表现。有个值得注意的规律happy的precision和recall都最高disgust最低fear也不高。这反映的是数据量和类间相似度的问题。happy这个类别的面部特征和别的类别差异很大——嘴角大幅上扬、脸颊隆起、眼睛周围出现纹路这些模式和其他类别的面部形态差异巨大分类器很容易学到。而disgust的样本量本来就少它的面部特征又与angry有重叠都涉及鼻子附近的褶皱和嘴部动作模型难以区分这两者。5.2 混淆矩阵里藏着的核心问题我画了最终的混淆矩阵横纵轴都是7个情绪标签格子里的数字代表真实情绪为行标时预测为列标的比例。有几个非常直观的结论angry和disgust之间互相混淆的比例最高接近30%的disgust样本被预测成angry。这两个类别的面部动作单元AU确实有重叠——皱鼻子和上唇提升这两个动作在angry和disgust中都会出现。fear和surprise的混淆也很明显原因是夸张的恐惧表情和惊讶表情在嘴部和眼部形态上接近——眼睛睁大、眉毛提升。sad容易被预测为neutral这也是很自然的困惑轻度悲伤的表情本来就接近没有表情。这类分析是实验报告里最出彩的部分它把“哪里错了”升级成了“为什么错”。如果只是在报告里贴一张混淆矩阵图然后说“从图中可以看出angry和disgust容易混淆”那只是描述现象。我结合面部动作编码系统来解释混淆的根源并提出了后续的改进方向——比如引入面部关键点作为辅助特征或者用多任务学习同时预测动作单元让模型学到更细粒度的面部形态差异。我还跑了一组对照实验分别用t-SNE可视化了模型最后一个全连接层之前的特征向量把7类样本投影到二维平面。同样是happy和neutral中间有一条比较清晰的分界线而angry、disgust、fear三类的点却交叠在一起和混淆矩阵的结论呼应。这两组分析放在一起整个实验报告的逻辑链条就完整了。5.3 跨数据集验证在CK上的泛化表现为了验证模型不仅仅是死记硬背FER2013的标注风格我在CK数据集上做了额外测试。CK包含593个图像序列我从中抽取了每个序列的最后三帧表情峰值帧共约1200张图把它们resize到128x128并转成灰度后送入模型。模型在CK上的准确率是72.6%比在FER2013测试集上高出7个百分点。这个差距其实不意外——CK是实验室环境下的刻意表情表演表情强度高且图像质量好情绪类别更容易识别FER2013则是在互联网上抓取的“自然表情”光照、遮挡、姿势变化复杂得多。这个跨数据集实验的意义不仅是多了一个数字而是证明模型学到的情绪特征具有一定的泛化能力不是简单记住了某一批图像的风格。做完这个实验之后我顺手把所有测试脚本封装成了命令行工具输入一张图片路径就能输出7个类别的概率分布和Top-1预测结果。这成为了代码交付中最直观的Demo答辩现场给老师演示的时候效果非常好。6. 源代码、文档和实验报告的交付规范6.1 代码仓库的组织结构大作业代码最怕的就是一坨脚本塞在一个文件夹里别人拿到不知道先跑哪个。我按照工程化的方式组织代码最终目录结构如下emotion_recognition/ ├── README.md # 项目说明环境配置快速开始 ├── requirements.txt # 依赖包及版本 ├── src/ │ ├── data/ │ │ ├── dataset.py # 数据集加载与预处理 │ │ ├── augment.py # 数据增强策略 │ │ └── split_data.py # 训练/验证集划分 │ ├── models/ │ │ ├── resnet.py # 模型定义与修改 │ │ └── train.py # 训练主脚本 │ ├── eval/ │ │ ├── evaluate.py # 测试集评估输出指标 │ │ └── predict.py # 单张图片推理脚本 │ └── utils/ │ └── metrics.py # Precision/Recall/F1计算 ├── configs/ │ └── config.yaml # 超参数配置文件 ├── scripts/ │ ├── train.sh # 一键训练脚本 │ └── eval.sh # 一键评估脚本 ├── data/ # 数据存放目录不提交大文件 └── results/ # 训练日志、模型权重、可视化结果README.md是整个代码仓库的门面。我写的内容包括项目功能介绍、环境配置方法Python版本、CUDA版本、pip安装命令、数据集下载与处理步骤、训练和测试的运行命令、项目结构说明、以及实验结果摘要。整个README大概有两千字涵盖了一个新用户从零开始复现结果所需的全部信息。requirements.txt里我锁定了关键依赖的版本torch1.13.0、torchvision0.14.0、opencv-python、numpy、pandas、scikit-learn、matplotlib、tqdm、pyyaml。版本锁定这件事千万别偷懒否则过半年PyTorch升级个API别人的环境就跑不起来了。6.2 实验报告的写作策略实验报告是大作业评分权重最高的部分。我的报告结构不是简单的“摘要-方法-结果-结论”四段式而是按照“问题定义—方案设计—实验过程—结果分析—总结展望”的思路展开每个段落都围绕一个核心论点组织证据。重点说一下“方法选择”部分的写法。很多人写“我选择了ResNet18因为它在ImageNet上表现好”这样的表述太单薄。我是这么写的先列出一张表格对比候选模型ResNet18、VGG16、MobileNetV2的准确率、参数量、单epoch训练耗时然后给出选型理由——“ResNet18在精度和训练成本之间取得了最好的平衡残差结构更适合情绪这种局部特征敏感的任务VGG16精度相近但参数量是ResNet18的三倍训练速度慢50%MobileNetV2参数量最少但精度低了约2个百分点而且它的深度可分离卷积在微调时对学习率更敏感调参成本高。”这样的表述既客观又显得分析深入。实验中记录的每一条日志也都整理成了图表。我用了TensorBoard记录每个epoch的损失值和验证集指标训练结束后自动导出PNG图片。这些图表在报告里全部要配上大段的文字解读不只是“loss下降说明模型在收敛”而是要具体到某一段训练里出现的波动及其原因。6.3 遇到过的坑与别人可能会遇到的坑最后分享几个我在完成这个项目的过程中踩过的坑希望能帮后来者省点时间。第一个坑是数据读取的通道顺序问题。OpenCV读图默认是BGR格式而PyTorch预训练模型期望的是RGB。如果你用cv2.imread读图后直接送入model图像的红色和蓝色通道会被颠倒模型效果会出现断崖式下跌。排查起来很隐蔽因为模型还能训loss也在下降只是准确率上限一直上不去。正确做法是在预处理里加上cv2.cvtColor(img, cv2.COLOR_BGR2RGB)。这个坑几乎每个做图像任务的人都会踩一次。第二个坑是标签对齐问题。FER2013的CSV文件里的分类标签是用整数表示的需要和emotion_dict正确对应起来。如果你在数据加载时把标签索引弄错了比如把0对应的angry当成neutral那么模型会在一个错误的标签映射下学习最终测试时所有预测都看似合理实际上是全乱的。我的建议是写一个简单的数据可视化脚本把每个标签对应的图像批量打印出来人工核对一遍这件事值得花十分钟做。第三个坑是PyTorch版本兼容性。最新版的torchvision已经不再内置预训练ResNet18的权重下载接口新接口需要用weightsResNet18_Weights.IMAGENET1K_V1来指定。很多教程里写的老接口pretrainedTrue在新版本已经移除如果你照着旧教程写代码会直接报错。我的建议是在requirements.txt里锁定torch1.13.0和torchvision0.14.0这样经过验证的稳定版本组合避免版本差异带来的无谓调试。第四个坑是关于随机种子的。如果不设置全局随机种子Python的random、NumPy的random、PyTorch的manual_seed、CUDA的seed那么每次训练的结果都会有明显差异有时验证集准确率上下波动能到2到3个百分点。这意味着你的实验报告里写的那个算法准确率有可能在别人复现时得不到同等效果。我在所有训练脚本开头都固定了随机种子保证实验结果可复现这是做实验应该有的基本素养。从最初的选题、数据集处理到模型设计、训练调参再到最后的代码封装和报告撰写整个流程走下来最大的感受是课程大作业真正考查的其实是你能不能把一个AI问题当成一个完整的工程项目来对待。技术选型有取舍训练过程有分析交付文档有细节这比单独某个指标好看要重要得多。希望这篇总结能给准备做图像情绪分析大作业的同学节省一些走弯路的时间。本文还有配套的精品资源点击获取
返回列表