首页 / 资讯中心 / 文章详情

基于深度学习的人脸表情识别:CNN从零构建到调参实战

基于深度学习的人脸表情识别:CNN从零构建到调参实战 ★ FEATURED ARTICLE
做面部表情识别这个项目我前后折腾了大概三周。如果你正打算练手深度学习或者需要交一个能打的项目我强烈建议你试试这个方向。它听起来只是一个图像分类任务但真正跑通之后你会发现数据处理、模型设计、训练调参、评估部署整条流水线全走了一遍对CNN的理解也会上一个台阶。这篇文章我试着把从思路到实操的完整过程拆开讲包含我踩过的坑和调参心得希望能让你少走弯路。1. 为什么值得做从问题定义到技术选型1.1 表情识别到底在解决什么问题面部表情识别本质上是一个图像分类问题。输入一张人脸图片模型输出对应的情绪类别。目前学术和工业界比较常用的是七分类愤怒、厌恶、恐惧、开心、悲伤、惊讶、中性。也有做三分类的积极、消极、中性但七分类信息量更大也更能体现模型区分细微特征的能力。很多人以为表情识别就是“看嘴和眼睛”做完了才发现不是这么简单。同一个表情在不同人脸上差异极大有人开心只是嘴角微微上扬有人开心是咧嘴大笑光照变化、头部姿态、眼镜遮挡、胡子、皮肤颜色都会影响特征提取。这就决定了它不是一个“一眼就能分对”的任务模型需要学到的是一组相对鲁棒的表情特征而不是简单的像素模板。正因如此它非常适合做深度学习项目的入门级课题麻雀虽小五脏俱全。如果你准备做毕设或者个人项目这个主题还能自然扩展到实时摄像头检测、疲劳驾驶预警、课堂专注度分析等场景后续可玩性很高。1.2 为什么选深度学习而不是传统方法早些年做表情识别主流方案是先用人脸关键点检测定位眼睛、眉毛、嘴巴等区域再提取几何特征或纹理特征比如LBP、HOG最后接SVM或随机森林分类器。这套流程我试过有两个痛点第一特征工程非常依赖人工经验光照一旦变化明显特征分布就乱了第二关键点检测在侧脸、遮挡场景下经常失效整个识别链路就崩了。深度学习方案尤其是CNN把“特征提取”和“分类”两件事一起学出来了。你给它原始像素它自己会找到有用的纹理和局部结构不需要你手工设计特征。推理时也简单输入图像输出一个概率分布。从工程角度看这条链路更短、更稳定精度上限也更高。当然深度学习不是万能药。它需要大量标注数据、需要GPU训练或者耐心用CPU跑小模型、需要处理过拟合。但整体收益明显大于成本所以现在的表情识别系统基本都是基于CNN或其变体做的。1.3 技术路线选择从零搭建还是迁移学习这里通常有两条路线。第一条从零设计并训练一个小型CNN。好处是结构完全可控训练速度快对硬件要求低而且你能从底层理解卷积、池化、激活函数这些组件到底在干嘛。坏处是精度上限受模型容量限制在复杂数据集上可能只有60%到70%的准确率。第二条迁移学习。比如加载在ImageNet上预训练好的ResNet18、MobileNetV2或EfficientNet把最后一层全连接换成7分类然后微调全模型或只微调最后几层。好处是收敛快、精度高数据少也不容易过拟合坏处是你需要装预训练权重显存占用也会大一些。我的建议是两条路都走一遍先用自己的小模型跑通流程再切换迁移学习做对比。这样你既能看清CNN的底层逻辑又能知道真实项目中为什么大家偏爱预训练模型。做对比实验也是项目汇报里很加分的部分。后面我主要讲自制小CNN的方案迁移学习作为扩展方案给出来。2. 数据准备与预处理决定模型上限的第一关2.1 数据集选型与标注特点公开的表情识别数据集不算多常用的是FER2013约35000张48x48灰度人脸图、RAF-DB约30000张真实场景彩色图、AffectNet更大但有争议。如果你只是想快速跑通流程我建议先用FER2013因为它下载方便、格式统一、图片尺寸小训练起来快。而且它包含7个类别的标签正好匹配七分类任务。有一个事情你要提前知道FER2013的标签噪声偏高。有人做过统计清洗后的准确率会比原始标签高不少。也就是说不是你模型不行而是部分标签本身标错了。看到验证集准确率卡在65%左右上不去先不要急着调参可以考虑抽样看看到底是预测错了还是标签错了。这对初学者是个很实用的心态建设。RAF-DB的优势是更接近真实场景有遮挡、光照变化、夸张角度模型的鲁棒性会更好。缺点是彩色图像尺寸不一致预处理要多做一些工作。你可以在小模型稳定跑通之后再用RAF-DB做一轮训练作为对比。2.2 预处理细节表情识别和普通图像分类有个区别人脸区域占比很重要。如果整张图里人脸很小、背景很杂模型很容易学偏。所以预处理流程一般是人脸检测裁出人脸区域。缩放到统一尺寸比如48x48或64x64。灰度化FER2013本来就是灰度图但如果是自己采集的数据灰度化可以降低光照和肤色影响。归一化把像素值从[0, 255]映射到[0, 1]或标准正态分布。以PyTorch为例很多人习惯用transforms.Normalize((0.5,), (0.5,))这样会把像素归一化到[-1, 1]也可以。如果加载的是预训练模型要注意用模型要求的分辨率和归一化参数。数据增强是另一个重要环节。表情识别很容易过拟合因为同一个人在不同照片里的表情高度相似。如果不做增强模型会把“某个人”当作“某种表情”。我的增强组合是随机水平翻转概率0.5随机旋转在-10度到10度之间随机亮度、对比度扰动随机遮挡模拟眼镜、口罩用Cutout随机遮掉一小块区域这些操作都在线完成相当于每一轮训练都会看到略微不同的样本能明显提升泛化能力。但也别增强过度我试过把旋转调到30度结果模型把“惊讶”和“开心”都学混了因为角度变化会让嘴型失真。2.3 划分训练集与验证集的坑这个坑我必须单独拿出来说人脸数据一定要按人头划分不要按图片随机划分。什么意思如果你直接把所有图片随机打乱那么同一个人的多张照片会同时出现在训练集和验证集里。模型相当于见过这个人的“部分照片”再在验证集里看到他“另一张照片”这种信息泄露会让验证集准确率虚高而且到了真实场景会断崖式下跌。正确做法是拿到数据时先看每个身份subject有多少张图片按身份分出一部分作为验证集另一部分作为训练集。FER2013原始划分是已经固定好的不需要改如果你用RAF-DB或者自定义数据就要注意这一点。我当时用RAF-DB时吃足了亏训练集验证集准确率都到了八成换成摄像头实测只有五成多就是因为没按人头分。3. 模型设计与关键参数解析3.1 CNN基础结构设计一个适合表情识别的小型CNN不需要特别复杂。我的常用结构可以概括为卷积块堆叠 全局池化 全连接分类。一个卷积块通常是卷积Conv2d→ 批归一化BatchNorm2d→ ReLU激活 → 最大池化MaxPool2d。具体到表情识别我用过的一个稳定结构长这样第一块Conv2d(1, 32, kernel_size3, padding1)输出32通道特征图MaxPool缩小一半。第二块Conv2d(32, 64, kernel_size3, padding1)输出64通道MaxPool再缩小一半。第三块Conv2d(64, 128, kernel_size3, padding1)输出128通道MaxPool第三次缩小。最后接一个全局平均池化Global Average Pooling把特征图压缩成128维向量。全连接层分类输出7类。输入如果是48x48灰度图经过三次池化后变成6x6信息量刚刚好。如果你用64x64输入可以再加一块。核心思路是随着网络变深通道数增加空间分辨率下降让模型先从局部纹理慢慢抽象到整体表情语义。3.2 为什么要用批归一化和Dropout先讲批归一化。它的作用是在每个批次内对特征做归一化让每层输入分布尽量稳定。很多初学者发现模型loss降不下去加一个BN往往就顺了。因为BN缓解了梯度弥散允许你用相对较大的学习率收敛速度肉眼可见地变快。在表情识别这种小模型上BN基本是标配。Dropout的作用是防止过拟合。训练时按一定概率随机“丢弃”神经元相当于同时训练多个子网络推理时再合在一起能起到集成效果。我一般会在全连接层前加一个Dropout(0.5)如果发现过拟合严重就把概率提高到0.6甚至0.7。要注意Dropout和BN有微妙的冲突如果模型里每个卷积后面都接BN再在全连接前加Dropout效果通常是好的但如果卷积后面又加Dropout又加BN可能会互相抵消。我的习惯是BN放在卷积后、激活前Dropout只放在最后接入全连接之前。3.3 损失函数与优化器选择表情识别是个多分类问题损失函数首选交叉熵CrossEntropyLoss。它已经把Softmax和负对数似然合在一起实现了所以不需要在网络最后一层单独加Softmax直接输出7维logits即可。优化器我推荐AdamW相比经典Adam它的权重衰减更规范在小模型上表现稳定。初始学习率设1e-3配合余弦退火或StepLR衰减。如果训练到一半发现loss震荡可以把学习率降到3e-4甚至1e-4。还有一个参数容易被忽略Batch Size。FER2013单张图只有48x48灰度图很轻量所以我一般设置64或128。Batch Size太小比如8会导致梯度噪声大、不稳定太大比如512虽然稳但每个epoch更新次数少训练周期要拉长。如果显存不够减小Batch Size的同时建议稍微降低学习率。4. 训练流程与评估别只盯着准确率4.1 训练策略训练流程本身不复杂每一轮做三件事跑训练集前向传播、算loss、反向传播更新参数然后跑验证集算准确率和loss保存效果最好的模型参数。有几个策略我记得特别清楚早停法。每个epoch之后看验证集loss连续5个epoch没下降就提前结束训练。很多时候训练到20个epoch就过拟合了硬跑50个epoch纯属浪费算力。学习率衰减。我一般用StepLR每10个epoch把学习率乘0.1。先大步快跑再小步精调效果比固定学习率好不少。模型保存。保存时需要包括模型结构参数、优化器状态、当前epoch、最佳准确率。这样中途想换机器接着训练可以直接加载不会白跑。另外如果你用的是自制小CNN强烈建议先用少量数据比如每个类别100张过一遍流程确认代码没有bug再全量训练。我第一次作图时没这么做直接全量训练跑到第3个epoch才发现数据加载器有个归一化bug白白浪费了几个小时。4.2 评估指标准确率是最直观的指标但在表情识别上准确率会骗人。原因在于数据不均衡开心、中性这类样本占比高厌恶、恐惧这类占比低。模型哪怕把所有样本都预测成“开心”准确率也可能接近30%到40%看起来还行实际上一点用都没有。所以一定要看混淆矩阵和各类别的精确率Precision、召回率Recall、F1分数。我当时第一次训练完总体准确率78%但看混淆矩阵发现“恐惧”只有42%的召回率大量“恐惧”被分成了“悲伤”。这个信息是准确率看不出来的。针对类别不均衡有两个方法比较有效。一是给损失函数加类别权重让稀缺类别错分的惩罚更大权重可以按1/类别频率来算再归一化。二是用Focal Loss它让模型更关注难分样本适合表情这种类间有重叠的任务。二选一即可我自己的体验是Focal Loss调参稍微麻烦一些先用类别权重比较省事。4.3 可视化与调试训练过程中建议把loss曲线和准确率曲线画出来观察是收敛还是震荡。还有一个容易被忽略的调试方法把预测错误和预测正确但置信度低的样本打印出来看。你可能会发现有些“错误”其实是标签错了模型反而是对的有些错误是图像太模糊人眼都难以判断。这两种样本不应该成为优化目标非要模型在这些样本上得分只会导致过拟合到噪声上。5. 实操完整复现流程5.1 环境与依赖我的环境是Python 3.10 PyTorch 2.x OpenCV NumPy scikit-learn Matplotlib。用conda创建环境比较省心conda create -n facial-expression python3.10 conda activate facial-expression pip install torch torchvision opencv-python numpy scikit-learn matplotlibPyTorch的安装命令会因操作系统和CUDA版本而异如果你没有NVIDIA显卡直接装CPU版本也能训练这个小模型就是慢一些。为了快速迭代我建议至少先在小数据集上确认流程是对的就切全量。5.2 数据加载以FER2013的CSV格式为例每行是label,pixel0,pixel1,...pixel是48x48的灰度值。读取和转换可以用下面的代码import pandas as pd import numpy as np import torch from torch.utils.data import Dataset, DataLoader from torchvision import transforms from PIL import Image class FER2013Dataset(Dataset): def __init__(self, csv_path, trainTrue, transformNone): df pd.read_csv(csv_path) # FER2013自带Usage列Training / PublicTest / PrivateTest if train: df df[df[Usage] Training] else: df df[df[Usage] PublicTest] self.labels df[emotion].values.astype(np.int64) self.pixels df[pixels].values self.transform transform def __len__(self): return len(self.labels) def __getitem__(self, idx): pixel_string self.pixels[idx] arr np.fromstring(pixel_string, dtypeint, sep ) arr arr.reshape(48, 48).astype(np.uint8) img Image.fromarray(arr, modeL) if self.transform: img self.transform(img) return img, self.labels[idx] train_transform transforms.Compose([ transforms.RandomHorizontalFlip(), transforms.RandomRotation(10), transforms.ColorJitter(brightness0.2, contrast0.2), transforms.ToTensor(), transforms.Normalize((0.5,), (0.5,)), ]) val_transform transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.5,), (0.5,)), ]) train_dataset FER2013Dataset(fer2013.csv, trainTrue, transformtrain_transform) val_dataset FER2013Dataset(fer2013.csv, trainFalse, transformval_transform) train_loader DataLoader(train_dataset, batch_size128, shuffleTrue, num_workers4) val_loader DataLoader(val_dataset, batch_size128, shuffleFalse, num_workers4)需要注意np.fromstring在NumPy 2.x中可能被移除建议如果报错就改成np.fromstring(pixel_string, dtypeint, sep )换成np.array(list(map(int, pixel_string.split( ))))。5.3 模型定义下面是一个适合入门的小型CNN结构和前面讲的相符import torch.nn as nn class ExpressionCNN(nn.Module): def __init__(self, num_classes7): super().__init__() self.features nn.Sequential( # block1: 48x48 - 24x24 nn.Conv2d(1, 32, kernel_size3, padding1), nn.BatchNorm2d(32), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), # block2: 24x24 - 12x12 nn.Conv2d(32, 64, kernel_size3, padding1), nn.BatchNorm2d(64), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), # block3: 12x12 - 6x6 nn.Conv2d(64, 128, kernel_size3, padding1), nn.BatchNorm2d(128), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), ) self.global_pool nn.AdaptiveAvgPool2d(1) self.dropout nn.Dropout(0.5) self.classifier nn.Linear(128, num_classes) def forward(self, x): x self.features(x) x self.global_pool(x) x torch.flatten(x, 1) x self.dropout(x) x self.classifier(x) return x这里用AdaptiveAvgPool2d(1)做全局平均池化好处是不管输入分辨率是多少最后都能压成128维向量。如果你后续想切换到64x64输入这个模型不需要改结构。5.4 训练主循环训练代码的核心是一个双层循环加验证。下面是一个可运行的最小版本import torch.optim as optim import torch.nn as nn from sklearn.metrics import confusion_matrix, classification_report, accuracy_score torch.manual_seed(42) device torch.device(cuda if torch.cuda.is_available() else cpu) model ExpressionCNN(num_classes7).to(device) criterion nn.CrossEntropyLoss() optimizer optim.AdamW(model.parameters(), lr1e-3, weight_decay1e-4) scheduler optim.lr_scheduler.StepLR(optimizer, step_size10, gamma0.1) epochs 40 best_acc 0.0 for epoch in range(epochs): model.train() running_loss 0.0 for inputs, labels in train_loader: inputs, labels inputs.to(device), labels.to(device) optimizer.zero_grad() outputs model(inputs) loss criterion(outputs, labels) loss.backward() optimizer.step() running_loss loss.item() * inputs.size(0) model.eval() val_preds, val_labels [], [] with torch.no_grad(): for inputs, labels in val_loader: inputs, labels inputs.to(device), labels.to(device) outputs model(inputs) preds torch.argmax(outputs, dim1) val_preds.extend(preds.cpu().numpy()) val_labels.extend(labels.cpu().numpy()) val_acc accuracy_score(val_labels, val_preds) print(fEpoch {epoch1}/{epochs} | Loss: {running_loss/len(train_dataset):.4f} | Val Acc: {val_acc:.4f}) if val_acc best_acc: best_acc val_acc torch.save({ model_state_dict: model.state_dict(), optimizer_state_dict: optimizer.state_dict(), epoch: epoch, best_acc: best_acc, }, best_model.pt) scheduler.step()训练完成后加载最优权重打印混淆矩阵和分类报告checkpoint torch.load(best_model.pt, map_locationdevice) model.load_state_dict(checkpoint[model_state_dict]) cm confusion_matrix(val_labels, val_preds) print(cm) print(classification_report(val_labels, val_preds, digits4))我当时跑了40个epoch验证集准确率大约到了72%到78%之间。不算特别高但作为小型CNN在FER2013上的表现已经算合理水平。如果换成ResNet18微调同样的数据能到82%左右。5.5 扩展到实时摄像头识别训练完静态图片之后最自然的就是接摄像头做实时识别。流程是OpenCV读取视频帧 → 人脸检测器框出人脸 → 截取人脸区域 → 预处理成48x48灰度图 → 输入模型推理 → 把标签和置信度画到画面上。import cv2 face_cascade cv2.CascadeClassifier(cv2.data.haarcascades haarcascade_frontalface_default.xml) model.eval() cap cv2.VideoCapture(0) emotion_labels [Angry, Disgust, Fear, Happy, Sad, Surprise, 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: face gray[y:yh, x:xw] face cv2.resize(face, (48, 48)) face_tensor torch.from_numpy(face).float().unsqueeze(0).unsqueeze(0) face_tensor (face_tensor - 127.5) / 127.5 face_tensor face_tensor.to(device) with torch.no_grad(): output model(face_tensor) prob torch.softmax(output, dim1) pred_idx torch.argmax(prob, dim1).item() confidence prob[0][pred_idx].item() label f{emotion_labels[pred_idx]} {confidence:.2f} cv2.rectangle(frame, (x, y), (xw, yh), (0, 255, 0), 2) cv2.putText(frame, label, (x, y-10), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 255, 0), 2) cv2.imshow(Facial Expression Recognition, frame) if cv2.waitKey(1) 0xFF ord(q): break cap.release() cv2.destroyAllWindows()这里有一个重要的归一化细节如果训练时用transforms.Normalize((0.5,), (0.5,))等价于(像素值 / 255 - 0.5) / 0.5也就是像素值 * (1/255) * 2 - 1。所以上面代码里(face_tensor - 127.5) / 127.5在数值上基本等价。如果你用到OpenCV读出来的灰度图是0到255范围的uint8必须记住先转成float再归一化否则模型收到的是0到255的权重输出概率会乱成一片。6. 常见问题与排查技巧实录6.1 训练损失不下降先检查数据预处理。最常见的坑是像素值没归一化输入变成0到255、CrossEntropyLoss对超大logits输出的梯度极小网络几乎学不动。其次看学习率太高会在初始阶段震荡甚至loss爆炸太低则收敛极慢。我一般先用1e-3快速试跑100个batch看loss是否明显下降再决定调高或调低。6.2 过拟合症状是训练准确率逼近99%但验证准确率停滞在60%左右。这时候优先做的事不是加大模型而是加大Dropout比例、增加数据增强强度、加上早停。我试过一个有意思的现象强行加深模型反而更容易过拟合不如保持小模型加正则。对小项目来说一个简单可靠的小模型强数据增强往往比复杂大模型效果好。6.3 类别不均衡如果某个类别比如厌恶、恐惧准确率明显低可以先算类别频率分布。FER2013里“厌恶”样本只有几百张“开心”有近万张天然不均衡。一种快速解决方式是在DataLoader里用WeightedRandomSampler让稀有类别每个epoch被采样到更多次。我实际对比过加了这个采样器之后“厌恶”的召回率能从20%出头提升到40%左右。6.4 摄像头实时识别卡顿卡顿的主要原因不是模型太大而是人脸检测器在高分辨率帧上耗CPU。解决办法有两个一是把摄像头画面缩小后再做检测比如原始1280x720先缩一半到640x360检测速度会快很多二是降低推理频率比如每3帧才检测一次检测到后人脸位置用上一帧的结果平滑跟踪。亲测之后帧率能从每秒10帧提升到25帧以上。6.5 不同数据集之间迁移效果差在FER2013上训练好的模型拿摄像头实测往往效果打折扣。原因很简单数据集图像的采集环境和你摄像头实际环境不一样包括人脸姿态、光照、图像清晰度。实用建议是在部署前用摄像头采集50到100张自己的照片做一次轻量微调fine-tune。这个操作能大幅提升真实场景的使用体验。6.6 环境安装报错最常见的是NumPy版本和PyTorch冲突导致导入dataloader报错。我的建议是用conda统一管理环境不要用系统Python直接装另外优先装PyTorch官方源提供的wheel避免源码编译。如果你只有一个老显卡驱动和CUDA版本对不上最简单的办法就是装CPU版本小模型训练慢一点但完全够用。参数速查表参数项推荐值说明输入尺寸48x48 或 64x64灰度图CHW格式初始学习率1e-3AdamW配合时可以略大Batch Size64 ~ 128越小噪声越大越大训练越稳但更慢Epochs30 ~ 50配合早停使用Dropout0.5过拟合严重时调到0.6优化器AdamWweight_decay1e-4比经典Adam更稳损失函数CrossEntropyLoss类别不均衡时加class weight数据增强翻转、旋转10度、亮度对比度扰动不要过度旋转验证集划分按人头分组防止数据泄漏常见问题速查表现象可能原因排查方向训练和验证准确率都很低预处理错误或学习率不当检查归一化、试试1e-3学习率训练高验证低过拟合加Dropout、加增强、早停某一类准确率特别低数据不均衡WeightedRandomSampler或class weight验证高实测差数据分布不同或数据泄漏按人头划分微调模型摄像头卡顿人脸检测太耗时降低检测分辨率隔帧检测加载权重报错模型结构和保存时不一致检查num_classes和输入通道做完整套流程我最大的感受是表情识别这个项目并不需要多么天才的想法它考察的是你按部就班完成一个系统的能力。你从数据清洗开始到模型设计到训练调参到评估分析再到部署验证每一步都有清晰的反馈。哪怕最终准确率只有70%多你也亲手搭建了一个从输入到输出的完整系统这比背概念、刷题要实用得多。最后再分享一个我踩过的坑保存模型时一定要把优化器状态和当前epoch一起保存。我第一次只存了model.state_dict()后来想接着训练加载权重后学习率已经不重要了但优化器的动量信息全丢了前几个epoch的loss波动特别大白白浪费了不少时间。如果你计划中途休息或者换机器跑这个细节能省很多事。做项目的乐趣就在于一点点把坑填平。如果你也跑通了这个流程建议下一步试试对实时流做多人识别或者把模型导出成ONNX再部署到移动端那又是另一个值得折腾的世界。
阅读完成 · 觉得有帮助?
咨询建站