资讯详情

资讯详情

PyTorch人脸表情识别实战:从CNN训练到OpenCV实时部署

简介基于 PyTorch 的卷积神经网络人脸面部表情识别项目面向深度学习和计算机视觉初学者及实战开发者覆盖人脸检测、表情分类到模型训练评估完整流程。利用 PyTorch 动态图优势结合数据增强与可视化工具便于灵活调参和监测训练。压缩包共 8 个文件、317.76MB包括 3 个预训练模型pkl覆盖 CNN、VGG、ResNet 等结构、2 张示例图片、1 个 Haar 级联人脸检测 XML、1 个 Windows 一键运行脚本和 1 份 README 文档。模型与数据已配套可跳过训练直接推理已有 1597 人学习下载。对需要快速开展表情识别实验、复现深度学习方法或二次开发新模型的读者而言这套资源集源码、数据集、权重和说明于一体可直接运行查看效果也可在现有权重上微调适合课程设计、毕业课题和技术入门。1. 人脸表情识别三件套源码、数据集和训练好的权重怎么串起来用表情识别这个方向最大的坎不是模型结构而是数据预处理和训练/推理不一致。我见过不少从零开始做卷积神经网络表情识别的同学模型在验证集上能跑出 80% 以上的准确率换到摄像头或新图片上直接掉到一半最后查下来十有八九是灰度图、尺寸、归一化这些细节对不上。这个工程恰好把三件事打包齐了PyTorch 人脸表情识别源码、带标签的表情数据集、训练好的模型权重。解压之后既能直接跑训练也能加载权重做单张图片和摄像头实时推理。不管你是刚入门深度学习想找一个能完整跑通的练手项目还是课程设计需要一套能出结果的流程或者想在这个基础上往上接识别场景这套代码都能用。2. 为什么是 CNN PyTorch从数据管线到网络选型的三个关键决定2.1 任务拆解人脸检测、对齐和分类各管一段别指望一个模型全包人脸表情识别严格说是三步任务的串联先检测出人脸位置再对人脸区域做裁剪或对齐最后把裁剪结果送进分类网络。很多人把这三步混在一谈觉得搞一个模型就能同时输出表情实际工程里几乎都是分开做的。第一步人脸检测最省事的方案是 OpenCV 自带的 Haarcascade。它在正面人脸场景下足够用缺点是大角度侧脸会漏检后半程部署时我会专门讲它的参数调整。第二步对齐严谨做法是用人脸关键点检测出眼睛位置做仿射变换把眼睛拉平这一步能显著提升表情识别精度。但这个工程作为入门级项目一般会省略对齐直接用检测框裁剪只要求推理和训练用同一套裁剪逻辑否则模型一上摄像头就翻车。第三步才是 CNN 分类网络它只负责接收 48×48 或 224×224 的图片输出情绪的类别概率。把这三步在脑子里拆清楚之后后面调参和排错都会顺很多。特别是当准确率忽高忽低时你要能第一时间判断问题出在检测框没框住脸、裁剪尺寸不对还是分类网络本身训练不充分。2.2 网络选型轻量 CNN 够用预训练模型不是必需品很多从图像分类入门的人一上来就默认要上 ResNet50 或者更深的网络但表情识别这个任务有它的特殊性。表情特征集中在眉毛、眼睛、嘴角这些局部区域用几层卷积就足够提取了。我在实际拆包时看到这类工程最常用的骨干网络是 4 到 6 层的小型 CNN参数量在几百万量级CPU 上也能跑得动推理。卷积层为什么适合这个任务核心在于局部感受野和权值共享。低层卷积学会的是边缘、纹理这些基础模式高层卷积把这些模式组合成“眉毛下压”“嘴角上翘”这类表情相关的高层特征。相比之下全连接网络把每个像素都当作独立特征参数量大还容易过拟合对位置变化也敏感。如果你拿到的是 ResNet 这类预训练模型权重迁移到表情数据集时有一点必须注意最后一层全连接输出节点数必须和你的表情类别数一致常见是 7 类或 8 类。预训练模型之前在 ImageNet 上做的是 1000 类分类直接加载权重会报 size mismatch需要把最后一层换掉再重新训练。后面的避坑章节我会给出具体处理代码。2.3 PyTorch 数据管线Dataset 和 DataLoader 的正确打开方式不管数据集是散落在目录里还是打包在 CSV 文件中到了 PyTorch 里都要先转成 Dataset再用 DataLoader 批量喂给模型。以最常见的目录结构为例训练集根目录下按类别分好了子文件夹你的 Dataset 可以这样写import os from PIL import Image from torch.utils.data import Dataset class EmotionDataset(Dataset): def __init__(self, root_dir, transformNone): self.root_dir root_dir self.transform transform self.classes sorted(os.listdir(root_dir)) # 每个子目录是一个类别 self.samples [] for label_id, cls_name in enumerate(self.classes): cls_dir os.path.join(self.root_dir, cls_name) for fname in os.listdir(cls_dir): if fname.lower().endswith((.jpg, .png, .jpeg)): self.samples.append((os.path.join(cls_dir, fname), label_id)) def __len__(self): return len(self.samples) def __getitem__(self, index): path, label self.samples[index] image Image.open(path).convert(L) # 转成单通道灰度 if self.transform: image self.transform(image) return image, label这段代码里有两个关键点。第一self.classes sorted(os.listdir(root_dir))决定了类别 ID 的映射顺序它是按文件夹名字母序排的比如 angry、disgust、fear、happy……分别对应 0、1、2、3。这个顺序一旦定了就不能再改否则训练好模型之后推理时类别对不上。第二Image.open(path).convert(L)把图片统一转成单通道灰度图这一步要和训练时的预处理保持一致后面我会反复强调。有了 Dataset还要配一个 transform 流水线以及 DataLoader我一般这样配置from torch.utils.data import DataLoader from torchvision import transforms train_transform transforms.Compose([ transforms.Resize((48, 48)), transforms.RandomHorizontalFlip(p0.5), transforms.RandomRotation(degrees10), transforms.ToTensor(), transforms.Normalize(mean[0.5], std[0.5]) ]) train_loader DataLoader( dataset, batch_size64, shuffleTrue, num_workers4, pin_memoryTrue )这里Resize把图片统一到 48×48这是 FER2013 这类经典表情数据集的常见尺寸。RandomHorizontalFlip和RandomRotation是数据增强只放在训练集验证集和测试集不要加。Normalize的均值和标准差这里取 0.5它会把像素值从 [0,1] 映射到 [-1,1]推理时也要用同样的数值不然后面加载权重的人会一脸懵。3. 训练流程落地超参设置、早停与 checkpoints照着配置就能跑3.1 目录结构与标签映射先确认这四件事再碰 training loop拿到工程包之后先不要急着跑训练先把数据组织方式搞清楚。我一般会执行下面这几条命令确认目录层级和样本数量。unzip emotion_dataset.zip tree -L 2 ./emotion_dataset正常情况应该是训练集、验证集、测试集各一个顶级目录每个目录下面再按表情类别分子文件夹。这时先打印一遍类别映射确认类别 ID 和文件夹名的对应关系。import os train_dir ./emotion_dataset/train classes sorted(os.listdir(train_dir)) for i, c in enumerate(classes): print(i, c)这一步的价值在于把“黑匣子”提前打开。很多工程里类别 ID 不是从 0 开始的或者文件夹排完序之后和你脑子里的顺序不一样。如果你迷迷糊糊地跳过这一步等模型训练完调用模型时才会发现标签全错血泪经验。提示同一个数据集在训练、验证、测试三个阶段使用的类别映射必须完全一致推荐把classes列表保存到 JSON 文件训练完和权重放在一起。3.2 关键超参batch size、learning rate、weight decay、epochs表情识别数据集样本量通常不大几万张算是多的几千到一万张也很常见。这种规模下模型很容易过拟合所以超参设置要偏保守。我在类似工程上常用的配置如下参数建议值说明batch_size64小数据集用 32 或 64 都行显存不够就降到 32learning_rate1e-3用 Adam 优化器时常见起点SGD 需要更大weight_decay1e-4相当于 L2 正则化能抑制过拟合epochs40配合早停使用不设固定最大轮数patience8连续 8 轮验证集准确率不涨就停weight_decay在 PyTorch 里直接传给优化器即可它做的就是在损失函数里加上权重的 L2 范数惩罚防止模型把某些特征的权重学得过大。这在表情数据集上尤其重要因为本身样本量有限不做正则化的话训练集准确率很快冲到 95% 以上验证集却停滞在 70% 左右。我习惯先把学习率定在 1e-3 跑 5 个 epoch观察训练 loss 的下降速度。如果前两个 epoch loss 纹丝不动说明学习率偏小如果 loss 直接跳成 NaN说明学习率偏大。确定一个稳定下降的学习率之后再挂上 ReduceLROnPlateau 调度器配合早停。3.3 训练循环、早停与 checkpoints哪些状态必须进保存文件训练循环本身不复杂但一个能用于生产的训练脚本至少要包含四个动作前向传播、反向传播、验证集评估、checkpoint 保存。我给出一个精简但完整的核心循环best_val_acc 0.0 patience_counter 0 for epoch in range(num_epochs): 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() val_correct 0 val_total 0 with torch.no_grad(): for images, labels in val_loader: images, labels images.to(device), labels.to(device) outputs model(images) preds outputs.argmax(dim1) val_correct (preds labels).sum().item() val_total labels.size(0) val_acc val_correct / val_total if val_acc best_val_acc: best_val_acc val_acc patience_counter 0 torch.save({ epoch: epoch, model_state_dict: model.state_dict(), optimizer_state_dict: optimizer.state_dict(), best_val_acc: best_val_acc, classes: class_names, }, checkpoints/best.ckpt) else: patience_counter 1 if patience_counter patience: print(fEarly stop at epoch {epoch}) break这段逻辑里值得注意的有三点。第一model.train()和model.eval()切换不能漏它们控制 Dropout 和 BatchNorm 在训练与推理时的不同行为。第二保存 checkpoint 用的是整体字典而不是单独的状态张量因为optimizer_state_dict可以让训练在中断后原样恢复classes保证推理时类别 ID 不会错。第三早停判断条件只看验证集准确率不看 lossloss 下降不等于模型真正变好了尤其在数据类别不均衡时更容易骗人。对于权重文件的加载推理脚本里通常会写成ckpt torch.load(checkpoints/best.ckpt, map_locationcpu) model.load_state_dict(ckpt[model_state_dict])map_locationcpu这个参数在跨设备推理时必须写。训练用 GPU推理用 CPU 时如果没有它PyTorch 会试图把张量加载到 CUDA 设备直接报错。4. 从训练到部署把 Haar 人脸检测和表情模型串成一条推理管线4.1 用 OpenCV Haar 做人脸检测检测器选哪个参数怎么调OpenCV 自带多个 Haarcascade 模型文件表情识别场景最常用的是haarcascade_frontalface_default.xml它在正面人脸和轻微偏转场景下表现够用。如果发现检测框经常框不住整张脸可以换更严格的haarcascade_frontalface_alt2.xml误报少一些但漏检也会增加。初始化检测器只需要两行import cv2 detector cv2.CascadeClassifier( cv2.data.haarcascades haarcascade_frontalface_default.xml )真正需要调的是detectMultiScale的几个参数。我常用的配置是scaleFactor1.1、minNeighbors5、minSize(48, 48)。scaleFactor控制每次缩放的比例1.1 表示每次缩小 10%值越小检测越精细但越慢minNeighbors控制一个候选框需要被多少个邻近框确认才会被接受值越大漏检越多但误报越少minSize直接过滤掉小于 48×48 的区域太小的人脸即便框出来了送进模型也基本分不准。这三个参数在摄像头现场容易翻车后面避坑章我会再展开。4.2 给模型喂数据前的最后一步灰度、尺寸、归一化必须与训练对齐模型的准确率能不能在推理时保住几乎全看预处理流程和训练时是否完全一致。灰度图是关键中的关键训练时用.convert(L)转成了单通道推理时如果直接用三通道 BGR 图喂进去模型第一层卷积的输入维度就对不上会直接报数据维度错误。推理时的预处理我建议用一个独立函数封装训练和推理共用一份逻辑from torchvision import transforms def preprocess_face(face_gray, input_size48, mean0.5, std0.5): face_resized cv2.resize(face_gray, (input_size, input_size)) face_normalized face_resized / 255.0 tensor torch.tensor(face_normalized, dtypetorch.float32) tensor (tensor - mean) / std tensor tensor.unsqueeze(0).unsqueeze(0) # (1, 1, H, W) return tensor这里有两个容易写错的点。第一cv2.imread加cv2.IMREAD_GRAYSCALE读出来的图是(H, W)的二维数组unsqueeze(0)两次之后才是(1, 1, H, W)对应(batch, channel, height, width)四维张量。第二归一化的公式必须和训练时一样这里(x - mean) / std对应训练时的Normalize(mean[0.5], std[0.5])如果你训练用的是mean[0.485, 0.456, 0.406]那套 ImageNet 参数这里也要同步修改。不要看它简单大半翻车都出在这一步。4.3 单图、批量目录和摄像头三种推理入口的取舍工程包里通常至少提供两种推理方式单张图片和摄像头。我做推理脚本时会把三种入口都封进去参数用命令行控制这样调试期和测试期不用反复改代码。# 单张图片推理 python infer.py --image_path test.jpg --checkpoint checkpoints/best.ckpt # 摄像头实时推理 python infer.py --camera 0 --checkpoint checkpoints/best.ckpt # 批量目录推理 python infer.py --folder ./test_images --checkpoint checkpoints/best.ckpt摄像头推理的核心循环长这样cap cv2.VideoCapture(0) model.eval() while True: ret, frame cap.read() if not ret: break gray cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) faces detector.detectMultiScale( gray, scaleFactor1.1, minNeighbors5, minSize(48, 48) ) for (x, y, w, h) in faces: face_gray gray[y:yh, x:xw] tensor preprocess_face(face_gray, input_sizeINPUT_SIZE) with torch.no_grad(): logits model(tensor) prob torch.softmax(logits, dim1) label_idx torch.argmax(prob).item() label_name class_names[label_idx] cv2.rectangle(frame, (x, y), (xw, yh), (0, 255, 0), 2) cv2.putText(frame, f{label_name}: {prob[0][label_idx]:.2f}, (x, y - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 0), 2) cv2.imshow(Emotion, frame) if cv2.waitKey(1) 0xFF ord(q): break这段代码里INPUT_SIZE必须从训练配置里读取不要写死。如果训练你用的是 48×48推理时 resize 到 224×224模型一样能跑但准确率会明显下降因为模型没见过那么大尺寸下的特征分布。torch.no_grad()在推理时是必须的它能减少反向传播图的构建开销摄像头场景下对帧率影响非常大。5. 避坑记录人脸表情识别里我反复踩过的五个翻车现场5.1 训练集 92%、验证集 88%线上却不到 50%预处理没对上现象训练好好的准确率高到能发论文模型一接到摄像头或者新图片上立刻打回原形。原因训练时做了灰度转换、尺寸缩放、归一化推理脚本里单独写了一套预处理逻辑两边参数稍微差一点模型面对的特征分布就完全不同。灰度和灰度之间看不出差距但模型感知偏差非常大。解决把预处理逻辑收敛成同一个函数训练脚本和推理脚本都引用它不要复制粘贴两份。我后来习惯把preprocess_face单独放到utils.py文件里所有脚本统一from utils import preprocess_face。5.2 loss 在前几个 epoch 缓慢下降然后卡住学习率策略出了问题现象loss 从 2.0 降到 1.9 之后再也不动了验证集准确率始终徘徊在 30%~40%看起来像模型没有学习能力。原因固定学习率训练时模型前期权重更新较猛后期在损失平面的某个局部区域附近震荡lr1e-3这个量级在后期偏大了导致无法进入更平坦的谷底。解决加上学习率调度器。我习惯用ReduceLROnPlateau验证集准确率连续 3 轮不上升时把学习率乘以 0.1。scheduler torch.optim.lr_scheduler.ReduceLROnPlateau( optimizer, modemax, factor0.1, patience3, verboseTrue )注意modemax表示监控的目标指标越高越好如果用验证集 loss 作为监控目标这里就要改成modemin。5.3 整体准确率 80%但某一类表情完全分不出来类别不均衡现象混淆矩阵里 happy、neutral 的召回率很高但 disgust 的召回率只有 20% 左右偶尔连 fear 也会被误分到 surprise。原因数据集中 disgust 的样本明显少于其他类别模型训练时对它的梯度贡献不足网络学会了牺牲少数类换取整体准确率最大化。解决给损失函数加类别权重样本越少的类权重越大。class_weights torch.tensor([1.0, 2.5, 1.2, 1.0, 1.0, 1.0, 1.3]) criterion nn.CrossEntropyLoss(weightclass_weights.to(device))权重怎么算最省事的做法是统计每个类别的样本数用总样本数除以各类别样本数再归一化。这样可以不做任何数据改动只改损失函数就能明显提升少数类的召回率。5.4 加载训练好的模型时报 size mismatch最后一层输出节点不对现象推理脚本加载权重时报RuntimeError: Error(s) in loading state_dict for ResNet: size mismatch for fc.weight。原因权重文件来自一个在 1000 类数据集上预训练的模型你的表情识别任务是 7 类分类全连接层的输出维度不一致。这是迁移学习场景最常见的翻车点。解决加载时指定strictFalse然后重新初始化最后一层。model build_model(num_classes7) ckpt torch.load(checkpoints/best.ckpt, map_locationcpu) model.load_state_dict(ckpt[model_state_dict], strictFalse) # 重新初始化最后一层全连接 model.fc.reset_parameters()strictFalse会让 PyTorch 忽略权重文件中多出来的键和缺失的键只加载能匹配上的部分。但这里有个隐患如果模型的骨干网络定义和权重文件不是同一个架构即使strictFalse也救不回来最多只会默默丢层正确做法是打开模型定义文件确认卷积层和全连接层的名字。5.5 摄像头推理只有个位数帧率瓶颈几乎都在检测器现象摄像头画面一卡一卡的FPS 掉到 3 到 5表情识别完全谈不上实时。原因detectMultiScale在高分辨率帧上做多尺度滑动窗口检测这个步骤本身就慢再加上每帧都调用直接拖垮整个循环。模型推理在 CPU 上也要几十毫秒叠加之后帧率惨不忍睹。解决先缩小帧再检测对检测到的人脸区域做放大后送进模型。具体做法是把原始帧宽度缩放到 640 像素detectMultiScale的耗时能减少一半以上同时每 3 帧才做一次人脸检测检测结果在后续两帧直接复用因为摄像头视角内人脸位置不会瞬间跳变。这个小技巧能让帧率从个位数提到 20 以上。6. 验证与调优用混淆矩阵把“能用”变成“好用”6.1 混淆矩阵回答“哪两类老是分不清”准确率是一个片面的指标尤其在表情识别这种类别不均衡的任务里。我每次训练完的第一个动作不是看准确率而是生成混淆矩阵和分类报告。from sklearn.metrics import classification_report, confusion_matrix preds_all [] labels_all [] model.eval() with torch.no_grad(): for images, labels in test_loader: images images.to(device) outputs model(images) preds outputs.argmax(dim1) preds_all.extend(preds.cpu().tolist()) labels_all.extend(labels.tolist()) print(classification_report(labels_all, preds_all, target_namesclass_names))重点看macro avg的 F1 分数它把每个类别单独计算后取平均比整体准确率更能反映模型对少数类的真实表现。如果happy和neutral经常混在一起通常意味着这两类表情在数据里的区分度本身就不够或者说标注本身就存在主观性。6.2 把错误样本可视化出来问题往往不在网络数字只能告诉你哪里错了不能告诉你为什么错。我会从测试集里把预测错误且置信度高于 0.7 的样本挑出来保存成图片标注真实标签和预测标签。看几十张之后你会发现很多错误的根源不在模型而是人脸检测框偏了、头顶被裁掉、或者样本本身标注就有争议。把这些找出来反馈到数据清洗环节比盲目调模型效果好得多。6.3 小预算提高准确率数据增强、类别加权和换骨干如果确认数据和标签没问题改进方向我会按性价比排序。第一是加强数据增强随机亮度调整、随机仿射变换、随机遮挡都值得试但注意不要增强过度把真实分布扭曲掉。第二是给损失函数加类别权重这个改动最小见效也最快。第三是换更大的骨干网络从 4 层小 CNN 换到 ResNet18再配合预训练权重把前几层冻住只训练后几层这样能避免在小数据集上重新训练大网络的过拟合风险。这套检查流程我现在已经固定下来了不管哪个目录下的训练任务跑完必出混淆矩阵、分类报告和错误样本图先确认这三样再谈能不能部署上线。这个习惯让我后来少踩了很多坑也希望这份工程笔记能帮到你。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →