ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

从零搭建CNN实现人脸表情识别:基于JAFFE数据集的全流程实践

从零搭建CNN实现人脸表情识别:基于JAFFE数据集的全流程实践 不夸张地说人脸表情识别是我做过最适合拿来入门CNN的项目。它不像目标检测那样需要先搞懂anchor和NMS也不像图像分割那样要处理像素级的密集输出本质上就是一个七分类的图像分类任务。把这个任务完整跑通卷积、池化、全连接这些名词就不再是教科书里的抽象定义而是你自己搭出来的网络结构里每一个真实存在的组件。数据集我选了JAFFEJapanese Female Facial Expression。很多人第一反应是这个库才213张图、10个人也太小了吧我的看法恰恰相反JAFFE这种小规模数据集做学习项目再合适不过。首先它下载容易一个压缩包就搞定其次因为数据量少训练速度快几分钟就能跑完一个完整的epoch你能在短时间内对比不同超参数的实际效果。最关键的一点是数据少意味着模型的短板暴露得也快过拟合、类别不均衡、同人数据泄漏这些问题都会原形毕露而这些恰恰是真实项目里最常见的坑。这篇文章我不打算只给一段能跑通的代码而是把整个项目的决策链讲清楚为什么选JAFFE、网络结构怎么一步步设计、数据预处理哪些地方会偷走你的精度、训练时loss曲线到底该怎么看、最后的评估报告里哪些数字是骗人的。不管你是交课程作业还是想认真学CNN按这套思路走一遍收获会比复制粘贴一个ResNet大得多。1. 为什么拿JAFFE练手数据集的真实成色与隐藏门槛1.1 先说清楚JAFFE到底长什么样JAFFE全称是Japanese Female Facial Expression database由日本九州大学心理学系在1998年前后构建。它包含10位日本女性模特每人做出7种表情每种表情采集2到4张总共213张256×256的灰度图。七类表情分别是生气Angry、厌恶Disgust、恐惧Fear、开心Happy、中性Neutral、悲伤Sad、惊讶Surprise。这种规模放在今天的深度学习语境下完全是“迷你”级别但入门阶段它有几个谁用谁知道的好处。一是标注质量高每一张图都由多位心理学背景的标注者独立打分确认过表情类别标签噪声小。二是类别人数均衡因为每类表情约30张左右不需要做复杂的类别加权。三是图像内容干净背景基本是灰色或深色幕布人脸占据画面主体省去了大量定位和去背景的工作。如果你用的是自己随手拍的照片当数据集光是清理无效样本、统一裁剪、去除重复帧这些杂活就能消耗掉大量热情。JAFFE相当于把最难搞的数据清洗环节替你做掉了你只需要专注在最重要的模型设计上。1.2 一个容易被忽略的问题训练集和测试集不能按图片随机切分这句话我建议直接刻在脑门上。JAFFE只有10个人如果按图片级别随机划分训练集和测试集同一个人微笑的照片可能同时出现在训练集和测试集里。CNN的记忆能力远比你想的强它会“记住”这个人的脸部特征然后在测试集上认出同一张脸从而把表情识别问题悄悄变成人脸识别问题。最终测试集准确率可能快90%但换一批人的照片立刻崩盘。正确做法是按人物划分subject-level split。例如选择8个人的图片作为训练集剩下2个人不参与训练只用于测试。这样模型在训练阶段从未见过测试者的长相测试精度才是真实的人脸表情泛化能力。在JAFFE这种只有10个人的库上一般建议做留一法leave-one-subject-out交叉验证每次拿9个人训练、1个人测试跑10轮取平均。虽然训练成本高一点但结果可信度远远好于单次切分。我在实际项目中通常先按8:2分出一个验证集用来调参和观察过拟合趋势最后再用全部数据做几轮留一法交叉验证这样可以同时拿到“开发过程中能快速反馈的验证集”和“最终可信的评估指标”。要注意的是不管怎么切测试集里不能混入训练集出现过的人这条规则在你以后遇到更大的表情数据集时同样成立。2. 网络结构设计从零手搭轻量CNN而不是无脑套预训练模型2.1 为什么JAFFE上不推荐直接上ResNet50、EfficientNet很多人一上来就想用ImageNet上预训练好的ResNet50做微调觉得大模型一定更好。这个思路在大数据集上没错但在JAFFE这种213张图的小库上会有两个实际问题。第一预训练模型是为1000类ImageNet设计的最后的全连接层参数和你的7分类完全不匹配改最后一层虽然不难但前面几百层冻结还是微调、怎么调学习率都是额外的工作。第二JAFFE是灰度图预训练模型几乎都是在三通道彩色图上训练的你得把灰度图复制成三通道这相当于把输入维度人为放大但没有增加任何信息量。更重要的是大模型在这个任务上并不会带来质的提升。表情识别的关键特征集中在眼睛、嘴角、眉毛这些局部区域的纹理变化上一个精心设计的浅层CNN完全有能力提取这些特征。实测下来手搭的5层卷积网络在JAFFE上的表现可以逼近甚至超过ResNet50微调的结果训练时间却只要几分钟。所以我建议第一版根本不要碰迁移学习就从零搭一个小而美的CNN。把网络结构变简单之后每一层的输出尺寸、特征的语义层次、过拟合的来源你都看得一清二楚。等这个流程完全跑通想换深一点的模型那时候再迁移也不迟。2.2 卷积、池化、步长、核、填充这次用表情识别给你讲透既然项目标题里突出的是卷积神经网络这几个基础概念必须在这里彻底讲明白。网上讲这些概念的教程太多了但大多用猫狗图片举例看完还是不知道跟自己项目有什么关系。这里我用表情识别里的实际场景重新讲一遍。卷积核可以理解成一个小型“观察窗口”。假设输入是一张48×48的灰度图你拿一个3×3的核也就是一个3×3的权重矩阵从图像左上角开始把窗口覆盖的9个像素值分别与对应位置的权重相乘再求和得到输出特征图上的一个像素值。这个操作就是在提取一种局部特征比如某个方向的边缘、某个区域的光照突变。一张人脸本质上就是由无数个这样的局部特征拼出来的眉毛的倾斜角度、眼睛的轮廓弧度、嘴角的上扬或下垂每个特征都由特定的卷积核来响应。**填充padding**的作用是解决边缘像素被“冷落”的问题。如果不做填充一个3×3的卷积核只能从图像第2行第2列开始滑图像最外圈的像素几乎不会被核的中心覆盖到边缘位置的纹理信息就丢了。解决方案是在图像四周补一圈0让卷积核可以从真正的边缘开始计算。你可能觉得一圈0没什么意义但表情识别里嘴角、眼眶边缘这些关键点的特征恰恰大量分布在图像边缘区域附近。**步长stride**是窗口每次滑动的距离。步长为1输出特征图跟输入尺寸差不多步长为2输出尺寸直接减半。步长变大相当于主动对特征图“抽稀”减少计算量的同时也会丢掉部分空间细节。**池化pooling**做的事情更粗暴——取一个2×2小区域内的最大值最大池化或者平均值平均池化把4个像素变成1个像素。这样既缩小了特征图尺寸又给网络带来了微小位移不变性只要特征还在这个小区域内池化都能捕捉到。在表情识别里这意味着只要眼睛、嘴巴在画面中的位置有轻微偏移池化层也能容忍。看公式可能会更清楚。假设输入尺寸为I卷积核大小为K填充为P步长为S输出尺寸O的计算公式是O (I - K 2P) / S 1拿48×48灰度图举例如果第一层使用3×3卷积核、padding1、stride1则输出尺寸为(48 - 3 2)/1 1 48空间尺寸不变只是通道数变了。后面接一个2×2最大池化、步长为2尺寸就变成24×24。2.3 我搭的基线网络结构从48×48到7类输出的完整通路下面给出我自己在JAFFE上用的基线网络。这个结构参考了LeNet-5的设计思想但做了一些面向表情识别任务的调整——输入尺寸、卷积核数量、Dropout位置都是反复调过的。第一层是卷积层3×3卷积核、16个输出通道、padding1后面接ReLU激活函数。输入是48×48的灰度图单通道输出变成48×48×16。这里用了16个卷积核意味着同时检测16种不同的局部纹理特征。第二层是2×2最大池化stride2输出变为24×24×16。第三层是卷积层3×3卷积核、32个输出通道、padding1接ReLU激活函数输出24×24×32。第四层是2×2最大池化输出12×12×32。第五层再卷积3×3卷积核、64个输出通道、padding1接ReLU输出12×12×64。第六层继续池化输出6×6×64。之后把6×6×64的特征图展平得到2304维的向量接一个全连接层映射到128维再接ReLU和Dropout比例为0.5只在训练时生效最后再经过一个全连接层输出7维向量对应7种表情的得分后面接Softmax或者直接用CrossEntropyLoss。整个网络参数量在几十万这个量级CPU上跑一个epoch也就十几秒。结构上你可以看出一个趋势特征图的空间尺寸不断减半48→24→12→6通道数不断增加16→32→64这就是CNN最常见的“空间压缩、语义浓缩”思路。前面的卷积层提取边缘、线条等局部特征中间的层慢慢组合出眼睛、嘴巴这样的部件级特征最后面的全连接层负责把这些部件特征综合成“开心”“惊讶”这样的全局判断。3. 数据预处理与人脸对齐几个偷走精度的隐形因素3.1 不做人脸检测直接整图训练会怎样有的教程图省事把JAFFE原始图片直接resize到48×48就扔进网络。JAFFE的照片里人脸占据了主体区域但每张图的人脸位置并不完全一致头发、耳朵、脖子以及背景也会一并被resize进来。这会导致同一个人的同一表情在不同照片里眼睛和嘴巴落在了不同的像素位置CNN得花大量参数去学习这种位置差异而不是专注学表情本身。所以正确做法是先用OpenCV自带的Haar Cascade人脸检测器把每张图里的人脸区域框出来。虽然是老技术但对JAFFE这种背景干净、光照均匀的图片来说检出率接近百分之百而且完全不需要GPUCPU上毫秒级就能跑完。检测到人脸框后按框的边缘各往外扩10%左右再裁剪这样能把发际线和下巴附近的纹理保留下来防止脸被切掉一部分。有了人脸框数据之后用OpenCV的相似变换affine transform把人脸对齐到标准尺寸48×48。对齐的方法是固定两只眼睛的位置——左眼到右眼连线保持水平两眼间距缩放到固定像素这样模型看到的所有人脸上眼睛都在同一个位置附近嘴巴也对应在差不多的高度上。这一步带来的精度提升通常比换一个更复杂的网络结构还明显因为CNN要学的事情变简单了。3.2 灰度图不只是为了省算力JAFFE原始图本身就是灰度图但很多人会习惯性地转换成三通道彩色图交给模型。这在预训练模型场景下可以理解但从零训练的小CNN上没必要。表情识别主要依赖的是肌肉纹理变化和形状结构颜色信息几乎不提供任何判别力硬要把单通道重复成三通道只是让网络多学了三份相同的特征计算量上去了精度不会有任何提升。直接用单通道灰度图训练是最理性的选择网络第一层的输入通道数就等于1。3.3 数据增强怎么扩、扩多少、哪些地方不能扩JAFFE只有213张图即使按9:1划分训练集也就190张左右。这么点数据喂给CNN模型会把训练集里的噪声当成特征记住出现严重的过拟合。数据增强是缓解这个问题最直接的手段。实践中我用的是随机水平翻转、随机旋转-10度到10度、随机亮度扰动正负10%、随机平移加缩放。为什么不用随机裁剪和更大幅度的旋转因为人脸是高度结构化的目标裁掉脸部关键部件或者把脸转到90度以上训练出的特征就完全变味了这不叫增强叫数据破坏。增强操作需要放在训练循环里对每个batch实时做而不是提前生成好几倍的图片存到硬盘上。因为实时增强每次给模型看到的都是不一样的数据等效于一个无限大的训练集而且实现起来也就几行代码的事。验证集和测试集绝对不能做任何增强必须用原始图像否则你评估出来的精度是虚高的。还有一个值得注意的细节如果你的数据增强包含了随机平移或旋转测试时你对齐好的48×48人脸框也会因为这种随机扰动而有轻微的边缘差异。所以增强的幅度宁小勿大尤其是旋转角度超过15度之后模型会把并不存在的脸部姿态变化当成表情特征来学。4. 模型训练实战loss曲线怎么读、过拟合怎么掐、超参数怎么调4.1 优化器选择Adam起步SGD收尾训练CNN第一步就是选优化器。我的建议是初版模型直接用Adam学习率设为3e-4beta值用PyTorch默认的(0.9, 0.999)weight_decay设置5e-4。Adam的好处在于自带自适应学习率调节对初始学习率的敏感度低不需要花太多时间在调参上很适合快速验证网络结构是否正确。Adam跑通之后如果想在最终精度上再抠一点可以换成带动量的SGDmomentum0.9学习率从0.01开始配合余弦退火学习率调度器。SGD的训练过程更“稳”收敛曲线更平滑最终精度往往优于Adam。这在人脸表情识别这种小数据集上体现得尤其明显因为Adam的自适应步长在训练后期容易在极小值附近来回震荡导致精度停滞。每次训练跑多少个epoch也值得商榷。我一般设一个上限比如80个epoch配合早停机制每跑完一个epoch看验证集loss如果连续15个epoch没有下降就直接终止训练然后保存验证集表现最好那一个epoch的模型权重。早停不光省时间它本质上是一种正则化手段防止模型在训练集上死磕到底导致过拟合。4.2 过拟合是把刀Dropout、权重衰减、增强要一起上JAFFE这种小数据集上最大的敌人就是过拟合表现是训练集loss一路狂跌验证集loss先降后涨最终验证集准确率远低于训练集。应对策略不是单一手段而是多管齐下。第一是Dropout。我在全连接层之前加了比例为0.5的Dropout训练时随机让一半神经元失活迫使网络学习更鲁棒的特征表示而不是依赖某几个特定神经元的组合。你也可以在卷积层之间加SpatialDropout2d比例设小一点比如0.2效果因人而异我的经验是0.5的普通Dropout在JAFFE上表现已经很稳定。第二是权重衰减weight_decay。本质上是对大权重加惩罚让模型权重分布更平滑不至于为了拟合某几个极端样本而变得过大。经验值是5e-4到1e-3太大容易欠拟合太小没效果。第三就是前面说的数据增强这是最温和也最有效的一个正则化手段。把增强幅度控制好之后你会发现验证集loss跟训练集loss的差距明显缩小这就是增强起作用的直接表现。4.3 我实际跑出来的loss曲线和训练日志解读拿我自己的实验举例。第一次跑不加任何正则化的基线网络训练集loss降到0.05以下验证集loss却停在1.4附近下不去验证集准确率只有58%。这就是典型的过拟合信号模型把这213张图背下来了但没学到表情的通用特征。加上Dropout和增强之后训练集loss不会再掉得那么夸张大概稳定在0.2左右验证集loss终于能压到0.8附近准确率升到78%。再把优化器换成SGD、加上余弦退火最后验证集准确率到了87%左右。对这个结果我比较满意了因为能明确看到每一步改动带来的提升而不是“玄学调参”。日志里我最关注两个指标每一轮验证集best accuracy对应的epoch位置以及验证集loss和训练集loss的差值。差值小于0.3说明正则化力度到位大于0.5说明过拟合严重需要增强Dropout或数据增强。这里没有放之四海而皆准的固定值但差值趋势一定是你每轮实验记录的必需品。5. 测试评估与结果解读不要把单个准确率数字当成全部5.1 混淆矩阵才是真正的照妖镜准确率只能告诉你总体水平混淆矩阵能告诉你在缺什么。用留一法跑完10轮后把所有预测结果汇总成一个7×7的混淆矩阵然后逐行逐列分析。我在JAFFE上最常见的混淆出现在“恐惧”和“悲伤”这两类之间。这非常合理因为从面部动作编码系统FACS的角度看恐惧和悲伤的激活单元高度重叠眉毛上抬、嘴角下拉等动作在两种表情里都有出现区别只在肌肉运动的幅度和组合方式上。另一个常见的问题是“中性”的召回率偏低原因是中性表情缺乏强烈的局部特征容易被模型判定成轻微的悲伤或惊讶。这些都是用模型结构本身很难彻底解决的问题除非引入人脸关键点之间的几何关系作为辅助信息或者增加训练数据。5.2 换一批真实照片测试才是检验模型的终极办法JAFFE数据集是在实验室环境下拍摄的光照均匀、正脸、无遮挡、无化妆夸张干扰。拿它训练的模型放到咖啡馆随手拍的照片上精度跌个二三十个百分点是常态。别把这种落差当成模型失败因为训练数据的分布跟你测试环境差太远了。真实场景里的影响因素有侧脸、光线、眼镜框、头发遮挡、运动模糊、手机HDR对皮肤纹理的重塑等等。所以做完这个项目之后我强烈建议你用自己的摄像头实时抓几张照片做定性测试让朋友做各种表情把图片预处理成灰度48×48后过一遍模型看看预测结果和真实表情是否吻合。这一步能让你直观感受到“Train on JAFFE, test on the wild”的残酷现实。如果能在训练数据里加入少量真实拍摄的照片做混合训练效果会好不少这也是把项目往实际应用推进的正确方向。5.3 后续可以怎么扩展如果你做完这个项目还有余力有几个方向值得试一试。第一个是把网络加深或加宽观察精度和训练时间如何trade-off。第二个是引入更复杂的数据增强方法例如cutout随机擦除、mixup混合输入。第三个是在输出层之前接入ArcFace这类度量学习损失让模型学到的特征空间里同类表情更聚合、不同类更分开。第四个也是最实用的一条路——用OpenCV的dlib或MediaPipe把人脸关键点检测出来把关键点坐标和CNN的全局特征拼接在一起做分类通常能在代价很小的情况下把精度再往上推几个点。每条路都会带来新的问题和新的经验这本身就是做AI项目最有意思的部分。
返回列表