简介:这套源码实现了一个基于深度学习的高精度人脸表情识别系统,面向需要做人脸表情分析、情感交互研究或AI视觉应用开发的学生与工程师,可覆盖数据预处理、模型训练、测试评估到实时摄像头识别与GUI交互的完整链路。包内共57个文件,约26.1MB,核心是17个Python脚本,覆盖模型构建、训练、实时识别、摄像头调用与GUI界面;另有17张PNG图片用于表情样本、图标及训练过程可视化,8张JPG测试图片,2个TensorFlow Lite模型,以及Shell部署脚本、字体文件和说明文档,结构清晰便于按模块查阅。当前已有318人学习。除常规CNN模型外,还提供LBP、Gabor、BlazeFace等特征与检测方案,可对比不同方法在表情分类上的表现;源码附带示例图片、测试数据与环境配置要求,能够帮助读者理解训练流程、模型导出与端侧部署,适合作为课程设计、毕业设计或表情识别项目的参考实现。
1. 高精度人脸表情识别系统源码:从课程作业到可复现项目之间的那块拼图
很多人学完吴恩达的深度学习课,或者跟着动手深度学习把 MNIST、CIFAR 之类的示例敲了一遍,一回头发现自己还是拿不下一张真实的人脸图片。人脸表情识别就是这类真实任务中最典型的试金石:要处理灰度图、要做标签映射、要选 backbone、要调学习率、还要写脚本把训练和推理串起来,任何一步掉链子,系统就跑不通。这套基于深度学习的高精度人脸表情识别系统设计源码,解决的就是这个断层——从数据集预处理到七类表情分类模型,再到 Python 训练推理脚本和 Shell 一键启动,整条链路是完整的。适合正在做人脸识别课设或毕设的学生,也适合想把这套骨架迁移到其他图像分类任务的工程师。
2. 系统结构与模型选型:这套源码每个文件管什么
打开这份源码之前,最好先理解它为什么这么组织。人脸表情识别本质上是一个图像分类问题,输入是单张人脸灰度图,输出是 7 个表情类别之一。既然是分类问题,工程上就可以拆成数据层、模型层、训练层、推理层四块。这份源码把这四块对应到了 data、models、utils 三个目录,再加上 train.py、predict.py、evaluate.py 和 run.sh,结构非常直白,没有那种把一百个函数塞进一个 main.py 的写法。
2.1 数据与标签:七类表情映射表是关键中的关键
表情识别最常用的公开基准是 FER2013 风格的 48×48 灰度人脸图,标签一共 7 类。很多人拿到的图片文件名是数字编号,如果不懂这张映射表,训练和推理时的标签顺序对不上,模型学得再好也是白搭。这套源码里内置的表情类别映射是:
| index | label | 中文含义 |
|---|---|---|
| 0 | angry | 愤怒 |
| 1 | disgust | 厌恶 |
| 2 | fear | 恐惧 |
| 3 | happy | 开心 |
| 4 | sad | 悲伤 |
| 5 | surprise | 惊讶 |
| 6 | neutral | 中性 |
这张表在训练脚本、评估脚本、推理脚本里必须保持一致。我见过不止一次有人训练时用 alphabetical 排序、推理时用下载数据集自带的 index,最后预测结果整体错位,准确率看起来还有 85%,其实全靠样本分布撑着。所以拿到这份源码第一步不是跑训练,而是先检查三个脚本里的 labels 列表是否一致。
预处理这块,源码里统一走 utils/preprocess.py,不建议自己另写一套读图逻辑,原因在第五章会细说。核心函数长这样:
# utils/preprocess.py import cv2 import numpy as np LABELS = ["angry", "disgust", "fear", "happy", "sad", "surprise", "neutral"] def load_gray_face(img_path, target_size=(48, 48)): # OpenCV 读图默认是 BGR,这里直接以灰度模式读取 img = cv2.imread(img_path, cv2.IMREAD_GRAYSCALE) if img is None: raise ValueError(f"failed to read image: {img_path}") # 缩放时用 INTER_AREA,对缩小场景下的人脸保留更多纹理 img = cv2.resize(img, target_size, interpolation=cv2.INTER_AREA) # 归一化到 0~1,避免直接除以 127.5 后变成 -1~1 的分布 img = img.astype(np.float32) / 255.0 return img这里有两个参数值得注意。第一个是 target_size,默认 48×48,对齐数据集基准,如果后续要改成 64×64 或其他尺寸,需要同步修改网络输入层,否则会报尺寸不匹配。第二个是归一化方式,除以 255 得到 0~1 区间,这是最保守的做法;如果你想在 ImageNet 预训练权重上做 finetune,就得换成 mean=0.5、std=0.5 的标准化。这套源码的默认策略是 0~1 归一化,配合从零训练,效果稳定。
2.2 backbone 选什么:ResNet18 是稳妥起点,MobileNetV3 留给部署
模型选型直接决定训练时间和最终精度,这不是玄学,是可以量化对比的。这套源码默认用 ResNet18,理由有三个:参数量适中,在 48×48 这种小分辨率输入上不会过早过拟合;结构简单,源码里给出的是去掉了默认全连接层、替换成 7 分类头的简化版;训练稳定性好,不像某些轻量网络对学习率特别敏感。
如果你打算把模型部署到边缘设备,比如树莓派或者 Jetson Nano 上做实时识别,那 MobileNetV3-Small 会更合理。替换方式是在 models 目录下新增一个 mobilenet.py,然后把 train.py 里的 --backbone 参数从 resnet18 改成 mobilenetv3。源码预留了这个参数位,你不用改训练逻辑。
| 模型 | 参数量分级 | 训练速度 | 推理速度 | 适用场景 |
|---|---|---|---|---|
| ResNet18 | 小 | 较快 | 中等 | 默认选择,课设毕设首选 |
| ResNet50 | 中 | 慢 | 较慢 | 数据量大时尝试 |
| MobileNetV3-Small | 极小 | 快 | 快 | 边缘设备、实时推理 |
在这个项目场景下,我一般会建议先跑通 ResNet18 全流程,确认精度和速度都能接受,再考虑换轻量模型。一上来就换网络,遇到问题时你会分不清是数据问题还是网络结构问题,排查成本会翻倍。
2.3 工程目录拆解:文件和他的职责一一对应
源码包解压后,关键部分的结构大致如下:
project/ ├── data/ │ ├── train/ # 训练集图片,按类别分子目录 │ ├── val/ # 验证集图片 │ └── samples/ # 推理演示图片 ├── models/ │ └── resnet.py # ResNet18 + 7 分类头 ├── utils/ │ ├── dataset.py # Dataset 封装、数据增强 │ └── preprocess.py # 灰度图读取与归一化 ├── train.py # 训练入口 ├── evaluate.py # 验证集评估,输出混淆矩阵 ├── predict.py # 单张图片推理 ├── requirements.txt # Python 依赖清单 └── run.sh # Shell 一键跑通完整流程data 目录是典型的 ImageFolder 结构,train 和 val 下面按类别建子目录,每类放对应表情的人脸图。这个结构的好处是可以用 torchvision.datasets.ImageFolder 直接读取,不用自己写 dataset 解析逻辑。utils/dataset.py 里除了 Dataset 封装,还把训练期的数据增强和验证期的常规预处理分开,这个设计细节很实用,第四章会专门展开。
requirements.txt 是这份源码里最容易忽略但最重要的文件之一。拿到源码后第一件事是 pip install -r requirements.txt,把 PyTorch、torchvision、numpy、opencv-python 等依赖版本固定住。版本不一致导致的加载权重报错是第五章避坑的第一大坑,提前固定版本能省掉这个麻烦。
3. 训练、推理与 Shell 一键部署:参数怎么改成你自己的数据
看懂了工程结构,接下来就是真刀真枪把流程跑起来。这一章不做理论复述,直接按 train.py、predict.py、run.sh 三个入口把参数和改动点讲清楚。你会发现这套源码的设计思路是:默认参数可以跑通,但换成你自己的数据集时,要改的东西其实很集中,不需要动神经网络结构。
3.1 train.py 训练入口:先看懂参数再动手改
train.py 用 argparse 接收参数,这样做的好处是你不必为了换数据集或调超参去改代码,命令行传参就行。源码里的参数定义大概长这样:
# train.py(参数解析部分) import argparse def parse_args(): parser = argparse.ArgumentParser(description="Face Expression Recognition Training") parser.add_argument("--data", default="data/train", help="训练集目录,按类别分子目录") parser.add_argument("--val_data", default="data/val", help="验证集目录") parser.add_argument("--checkpoint", default="checkpoints/resnet18_best.pth", help="模型保存路径") parser.add_argument("--batch_size", type=int, default=64, help="批大小,显存不够时先降到 32") parser.add_argument("--epochs", type=int, default=60, help="最大训练轮数") parser.add_argument("--lr", type=float, default=1e-3, help="初始学习率") parser.add_argument("--weight_decay", type=float, default=5e-4, help="L2 正则化系数") parser.add_argument("--backbone", default="resnet18", help="backbone 名称:resnet18/mobilenetv3") parser.add_argument("--num_workers", type=int, default=4, help="数据加载线程数") return parser.parse_args()逐项说明最关键的几个参数。batch_size 是显存和训练稳定性的平衡点,默认 64 在 8GB 显存上跑 ResNet18 没问题,如果你是 6GB 显存或者 Windows 笔记本,先降到 32,训练速度会慢一点但至少不会 OOM。epochs 设 60 是给学习率衰减和早停留出空间,实际训练往往到不了 60 轮就停了。lr=1e-3 配合 Adam 是这套配置的安全区,换成 SGD 的话建议从 1e-2 起步并配上动量。weight_decay 是反过拟合的基础手段,表情数据量普遍不大,5e-4 起步是惯例。
第一次训练时我建议不改任何参数,先按默认值跑 5 到 10 个 epoch,确认 loss 在下降、训练和验证的准确率在同步上升。如果 loss 直接炸掉或者准确率原地不动,再回头检查数据和预处理,不要一上来就怀疑模型结构。
# train.py(训练主循环节选) for epoch in range(epochs): model.train() running_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() running_loss += loss.item() * images.size(0) val_loss, val_acc = validate(model, val_loader, criterion, device) scheduler.step(val_loss) if val_loss < best_loss: best_loss = val_loss torch.save(model.state_dict(), checkpoint)这段代码里有两个容易被忽略的细节。第一个是 scheduler.step(val_loss),传的是验证集 loss 而不是训练 loss,这是 ReduceLROnPlateau 这类调度器的正确用法,靠它判断过拟合比训练 loss 更敏感。第二个是保存 checkpoint 的条件用的是 val_loss 而不是 val_acc,因为 val_loss 更平稳,不容易在某个 epoch 的噪声点上误存。如果你发现保存下来的模型在测试集表现不如中间某个 epoch,可以把保存条件切换成 val_acc,但要做好准确率波动带来的模型震荡。
3.2 predict.py 推理入口:输入输出和前置条件
推理脚本负责把训练好的权重用起来,输入是一张图片路径,输出是表情类别和置信度。它复用了 utils/preprocess.py 的 load_gray_face,保证推理时的预处理和训练时完全一致。这一点非常重要,很多系统在训练和推理时各写一套预处理,结果训练准确率很高,一上线就拉胯,排查时先查这里。
# predict.py import argparse import torch from models.resnet import resnet18_7class from utils.preprocess import load_gray_face, LABELS def main(): parser = argparse.ArgumentParser() parser.add_argument("--image", required=True, help="输入人脸图片路径") parser.add_argument("--checkpoint", default="checkpoints/resnet18_best.pth") args = parser.parse_args() model = resnet18_7class() model.load_state_dict(torch.load(args.checkpoint, map_location="cpu")) model.eval() # load_gray_face 返回 (48, 48),这里补上 batch 和 channel 两个维度 x = load_gray_face(args.image) x = torch.from_numpy(x).unsqueeze(0).unsqueeze(0) # (1, 1, 48, 48) with torch.no_grad(): logits = model(x) probs = torch.softmax(logits, dim=1)[0] conf, idx = torch.max(probs, dim=0) print(f"prediction: {LABELS[idx]}, confidence: {conf.item():.4f}") if __name__ == "__main__": main()两个维度扩展的语义要讲清楚:load_gray_face 返回的是 numpy 数组,shape 是 48×48,而网络期望的输入是 N×C×H×W,所以先用 unsqueeze(0) 在 0 维上加 batch 维,再用 unsqueeze(0) 加 channel 维。很多人在这里直接 reshape,结果数据分布被打乱了,预测结果随机。softmax 那一行输出的 7 个概率和为 1,取最大值对应的索引就是最终类别。置信度低于 0.5 的预测需要警惕,这在第五章会展开。
如果你想把它接到摄像头实时画面,predict.py 的逻辑稍作改动就行:把 cv2.VideoCapture 读到的帧先做人脸检测,裁剪出人脸框,再传给 load_gray_face 走后续逻辑。源码没有内置人脸检测器,常见做法是配合 OpenCV 自带的 Haar Cascade 或 MTCNN。这块注意 BGR 转灰度的时机和缩放尺寸,第五章有对应的踩坑记录。
3.3 run.sh 一键跑通:Shell 脚本不只是敲三行命令
这份源码的另一个亮点是自带 Shell 启动脚本。它不是简单把三条命令堆在一起,而是做了两个关键处理:set -e 让脚本在前面任何一步失败时立即终止,避免你看到一个中间报错还继续往下跑,最后拿到一个不知道自己怎么来的模型;source activate 则确保命令跑在正确的 Python 虚拟环境里,不同机器的环境隔离问题在这一步就解决了。
# run.sh #!/bin/bash set -e source activate py38 # 替换成你自己的 conda 虚拟环境名 python train.py --batch_size 64 --epochs 60 --lr 1e-3 python evaluate.py --checkpoint checkpoints/resnet18_best.pth python predict.py --image data/samples/angry.jpg --checkpoint checkpoints/resnet18_best.pth注意 evaluate.py 放在了 predict.py 前面,先看模型在验证集上的整体表现,再拿单张图做直观的冒烟测试。这套顺序是一个合格的验证流程:训练完先评估,再推理。Windows 用户直接在 cmd 里跑这个脚本大概率报错,建议用 Git Bash 或 WSL 来执行,具体原因在第五章讲。
3.4 训练过程监控:先让 loss 正常下降再谈调优
跑起来之后,训练日志里你会看到每个 epoch 的 train loss、val loss、val acc 三项。我一般用这套判断标准判断训练是否正常:前 5 个 epoch train loss 应该明显下降,val acc 也应该同步往上走;如果 train loss 下降但 val acc 纹丝不动,先怀疑增强强度是否过大;如果 train loss 也卡住,优先检查学习率,而不是盲目加深网络。另一个需要看的是 val loss 是否在某个 epoch 开始反弹,反弹同时 val acc 掉头向下,就是过拟合,这时候早停比继续训练更有用。
源码里如果没有单独画曲线,我建议每 5 个 epoch 手动记录一次 val loss 和 val acc,训练结束后贴到表格里看一眼整体趋势。等你会看这三条曲线的形态,调参就不再是玄学了。
4. 高精度调优:数据增强、类别不平衡与学习率策略
源码能跑通只是起点,真正把准确率做上去、把易混类别区分开,靠的是这一章的几项调优操作。表情识别和普通分类有个本质区别:类别之间的视觉差异非常微妙,特别是“恐惧”和“惊讶”、“悲伤”和“中性”,人眼都容易看走眼。所以调优的方向不是单方面堆模型深度,而是从数据分布和训练策略两个方向同时下手。
4.1 数据增强组合:模拟偏头、光线和表情幅度变化
直接看源码里 utils/dataset.py 的训练期增强配置:
# utils/dataset.py(训练期增强) from torchvision import transforms transform_train = transforms.Compose([ transforms.RandomResizedCrop(48, scale=(0.8, 1.0)), # 随机裁剪模拟人脸偏移 transforms.RandomHorizontalFlip(p=0.5), # 水平翻转覆盖侧脸 transforms.ColorJitter(brightness=0.2, contrast=0.2), # 亮度/对比度扰动 transforms.ToTensor(), transforms.Normalize(mean=[0.5], std=[0.5]) ]) transform_val = transforms.Compose([ transforms.Resize(48), transforms.ToTensor(), ])每一行都要解释清楚为什么这样配。RandomResizedCrop 的 scale 参数限制在 0.8 到 1.0,意思是裁剪区域占原始人脸的 80% 到 100%,这样既模拟了人脸框轻微偏移,又不会把五官关键信息切掉。表情识别的标注框通常紧贴脸部,切太多会导致模型学到残缺的耳朵和下巴。RandomHorizontalFlip 对于不对称表情是双刃剑,比如嘴歪向一边的“厌恶”,翻转后可能改变表情语义,但在 FER2013 这类数据集上,翻转带来的多样性收益通常大于语义翻转风险,所以折中取了 0.5 的概率。ColorJitter 的两个系数选 0.2 而不是 0.5,是因为表情特征主要是肌肉纹理和轮廓,不是颜色,颜色扰动过猛会引入噪声。
如果你发现增强后训练 loss 降得慢了,不要慌,这是正常的,数据多样性变大了,模型要学的东西更多。判断增强是否有效的标准是验证集准确率是否跟着提升,而不是训练集 loss 是否快速下降。很多人在这里翻车:一看训练准确率上不去就把增强全删了,结果验证集也一起掉。
4.2 类别不平衡:用 Focal Loss 替代普通交叉熵
表情数据集几乎必然存在类别不平衡,最典型的是“厌恶”类别样本量远小于“开心”和“中性”。如果直接用 CrossEntropyLoss,模型会对样本量大的类别严重过拟合,小类别准确率掉到个位数都是常见的。解决思路有两种:加权交叉熵和 Focal Loss。这套源码支持后者,因为它对难易样本的权重区分更细。
# utils/loss.py import torch import torch.nn as nn import torch.nn.functional as F class FocalLoss(nn.Module): def __init__(self, gamma=2.0, alpha=None): super().__init__() self.gamma = gamma # 对难样本的关注强度 self.alpha = alpha # 每个类别的权重,传入形状为 (7,) 的张量 def forward(self, logits, targets): ce = F.cross_entropy(logits, targets, reduction="none") pt = torch.exp(-ce) # 预测置信度,pt 越大说明样本越简单 # gamma 越大,简单样本的 loss 被压得越低 loss = (1 - pt) ** self.gamma * ce if self.alpha is not None: # 按真实类别给每个样本乘权重 loss = loss * self.alpha[targets] return loss.mean()gamma 的直觉理解是:当 pt 接近 1 时,也就是模型已经很有把握的简单样本,乘数 (1 - pt)^gamma 趋近 0,模型不再为这些样本消耗大量梯度;当 pt 很小时,乘数接近 1,难分样本拿到完整 loss。gamma 设 2.0 是 Focal Loss 论文里的常用值,实际表情数据集上可以调到 1.5 更温和,减少过拟合风险。alpha 直接控制类别权重,我一般先按样本量倒数归一化出一个权重张量,再乘 0.9 的平滑系数,避免某个类别权重过大导致训练震荡。
4.3 学习率调度与 EMA:让验证集 loss 稳步下降
学习率是表情识别训练里最需要花时间观察的超参数。固定学习率很容易在 loss 降到一定程度后原地打转,进不了更优的区域。这套源码里用 ReduceLROnPlateau,patience 设 5,意思是验证集 loss 连续 5 个 epoch 不创新低,就把学习率乘 0.5;如果又过了 5 个 epoch 还不降,再乘 0.5,直到收敛。
# train.py(学习率调度与 EMA 示例) import copy import torch.optim as optim optimizer = optim.Adam(model.parameters(), lr=1e-3, weight_decay=5e-4) scheduler = optim.lr_scheduler.ReduceLROnPlateau( optimizer, mode="min", factor=0.5, patience=5 ) # 简单 EMA:指数移动平均的缓冲模型 ema_model = copy.deepcopy(model) ema_decay = 0.999 for param, ema_param in zip(model.parameters(), ema_model.parameters()): ema_param.data.mul_(ema_decay).add_((1 - ema_decay) * param.data)EMA 的思路是用训练过程中模型权重的滑动平均替代最终权重,这样得到的模型通常比最后一个 epoch 的权重更平滑、泛化更好。ema_decay 的衰减系数 0.999 在 60 个 epoch 的训练里衰减足够慢,能让滑动平均真正覆盖到训练后期的最佳区域。验证集评估和最终推理都用 ema_model 而不是 model,我测过好几套模型,这个操作基本每次都能稳定带来小幅提升。
4.4 评估指标:别只用准确率,混淆矩阵才是排查工具
准确率在表情识别这种不平衡数据集上的参考价值很有限。假设“中性”占了 35% 的样本,模型全预测中性也能拿 35% 准确率,但这显然不可用。evaluate.py 里除了准确率还会输出每类精确率、召回率和 F1,以及一张混淆矩阵。你要重点看的是“悲伤”和“中性”、“恐惧”和“惊讶”这两对对角线之外的值,它们是最容易互混的类别组合。如果某个类别召回率低于 40%,下一步就该回去数这个类别的训练样本数,大概率是样本量太少了。
调优的执行顺序我按这个来:先加数据增强跑一组,再换 Focal Loss 跑一组,最后叠加 EMA 和调度器跑一组。每次只改一个变量,记录验证集准确率和混淆矩阵的变化。一次改三个超参,出了问题你根本定位不到是谁导致的。
5. 部署与训练常见问题排查:五条踩坑记录
训练、调优、推理都跑通之后,真正折磨人的是各种环境类和边界问题。以下五条是我拿到这类源码后几乎每次都会遇到的状况,按现象、原因、解决的顺序记录,你自己复现的时候可以直接对号入座。
5.1 训练时 Loss 变成 NaN
现象:train.py 跑到某几个 epoch 时,loss 突然变成 NaN,后续所有 loss 都停在 NaN,训练进程没有崩溃,但权重已经废了,继续跑下去没有任何意义。
原因:最常见的是学习率过高导致梯度爆炸,或者预处理归一化没有生效,输入值里有极端异常值。少数情况是数据集中存在损坏图片,读出来是空数组,反向传播时计算了无效梯度。
解决:先看归一化是否真的执行了,load_gray_face 里的除以 255 是否被注释掉;再确认 lr 是否在 1e-3 以下,Adam 相对稳妥但也不能完全免疫;最后写一个小脚本遍历 data 目录,检查每张图片能否用 cv2.imread 读出来、尺寸是否为 48×48,损坏文件直接移出训练集。这个排查顺序十分钟内能完成,不要一上来就重装环境。
5.2 加载权重时报错 size mismatch
现象:执行 predict.py 或 evaluate.py 时,model.load_state_dict 抛出 size mismatch,提示某个全连接层的权重尺寸对不上。
原因:这份源码支持 resnet18 和 mobilenetv3 两种 backbone,如果训练时用的是其中一个,推理时参数指定了另一个,权重形状自然对不上。还有一种情况是你自己改了分类头的类别数,从 7 类改成 5 类,或者反向操作,最后一层全连接权重也会错位。
解决:先确认 --backbone 参数和训练时完全一致,再检查 checkpoint 文件名的标记。我习惯在保存 checkpoint 时把模型结构和类别数写进文件名,比如 resnet18_7class_best.pth,这样就不会拿错权重去加载。遇到 mismatch 时把 torch.load 出来的 state_dict 打印出来看 key,比对着 model 的 state_dict key 找差异,一眼就能发现问题。
5.3 Windows 下执行 run.sh 直接报错
现象:在 Windows 的 cmd 或 PowerShell 里执行 run.sh,提示 source 命令不存在,或者文件找不到。换到 Git Bash 之后,又报出 command not found 的诡异错误。
原因:.sh 脚本是为 Linux 和 macOS 设计的,cmd 不认 source;即使安装了 Git Bash,如果文件是从 Windows 编辑器保存的,换行符是 CRLF,bash 会把它当成命令的一部分来解析。
解决:在 Git Bash 或 WSL 里运行,不要用 cmd。报了换行符相关错误,就在 Git Bash 里执行 dos2unix run.sh 转换换行符,或者在 VS Code 右下角把文件的行尾序列改成 LF 再保存。另外我一般会把 run.sh 里的 source activate py38 改成 conda activate py38,因为新版 conda 在非交互环境下不加载 source 命令。
5.4 OpenCV 读图和网络输入维度对不上
现象:predict.py 或摄像头推理时报错 Expected 4D input,或者 shape 不匹配,图像数组的维度比预期多一维或少一维。
原因:cv2.imread 默认读成 H×W×C 的三维数组,如果直接把这个数组传给网络,而网络期待的是 N×C×H×W,维度顺序完全对不上。另外有些人把 cv2.resize 的宽高传参顺序写反,导致图片被转置,模型预测结果自然乱掉。
解决:一律走 utils/preprocess.py 的 load_gray_face,别在 predict.py 里自己写读图逻辑。如果必须手写,记住 BGR 转灰度用 cvtColor,不要直接取第一个通道。cv2.resize 的第二个参数是 (width, height),比如 (48, 48),如果你习惯写 (height, width) 而两者恰好不相等,图就被旋转了。最后 numpy 转 torch 张量时,float32 的 (48, 48) 要 unsqueeze 两次变成 (1, 1, 48, 48)。
5.5 验证集准确率不错,摄像头实测却频繁识别错误
现象:模型在离线验证集上准确率不错,接到摄像头实时画面上,侧脸、背光、模糊场景下预测结果频繁在几个表情之间跳,看起来模型完全不可用。
原因:验证集图片和真实摄像头画面的分布不一致。训练数据多为正面、光照均匀的静态人脸,而摄像头画面包含姿态变化、运动模糊、亮度波动,属于分布外样本。还有一个原因是帧间没有做平滑,单帧误判直接展示出来,观感上就是模型乱跳。
解决:在推理链路里加入帧间投票,连续 5 帧得票最多的表情作为最终输出,这个改动对体验提升非常明显。同时加强数据增强里的角度和亮度扰动,让模型见过更多样的输入。最后把置信度阈值从 0.5 提高到 0.7,置信度不足时回退到上一稳定结果。这三步做完再测,识别稳定性通常会有质的改变。
6. 用 CAM 热力图验证模型看的是脸不是背景
训练完成、推理正常、部署稳定之后,还有一个验证步骤不能跳:检查模型到底在依据什么做判断。表情识别特别容易学到捷径,比如数据集里“开心”样本的人脸往往更亮,模型可能学的是亮度而不是嘴部肌肉;或者“中性”样本多数来自证件照,模型学会用照片风格做分类。这类错误用准确率根本发现不了,因为测试集和训练集同分布,没有反例。CAM(Class Activation Mapping)能把最后一层卷积的输出特征按类别权重映射回原图,直观地告诉你模型做决策时注意力放在图像的哪个区域。
实现上先注册一个 forward hook 把 layer4 的输出特征抓回来,然后对特征通道做加权求和,把 7×7 的特征图 resize 回 48×48 再叠加到原图上。关键参数是 resize 的插值方法,放大时用 INTER_LINEAR,热力图边缘比较平滑;用 INTER_NEAREST 会出现锯齿,不容易看出注意力中心。叠加时我习惯用 0.4 的透明度,太透明看不出重心,太不透明又盖住五官细节。
# visualize_cam.py(GradCAM 简版) import cv2 import torch import numpy as np from models.resnet import resnet18_7class from utils.preprocess import load_gray_face model = resnet18_7class() model.load_state_dict(torch.load("checkpoints/resnet18_best.pth", map_location="cpu")) model.eval() # 拿到最后一层卷积的输出特征 features = {} def hook_fn(module, input, output): features["feat"] = output.detach() handle = model.layer4.register_forward_hook(hook_fn) x = load_gray_face("data/samples/happy.jpg") # (48, 48) x = torch.from_numpy(x).unsqueeze(0).unsqueeze(0) with torch.no_grad(): out = model(x) # 对特征通道取均值,得到 7x7 的注意力图 cam = torch.mean(features["feat"], dim=1)[0].numpy() cam = cv2.resize(cam, (48, 48), interpolation=cv2.INTER_LINEAR) cam = (cam - cam.min()) / (cam.max() - cam.min()) handle.remove()拿到热力图后,逐张检查三件事:高亮区域是否落在眼睛或嘴巴周围;是否同时覆盖左脸和右脸而不是只盯一边;背景区域有没有异常高亮。把验证集里预测错但置信度高的样本也拿出来对比,错误样本的注意力通常更分散,高亮落在下巴、头发甚至背景上。
| 巡检项 | 健康表现 | 可能问题 |
|---|---|---|
| 热力中心 | 集中于眼睛/嘴周 | 集中在额头/下巴边缘 |
| 对称性 | 左右脸都有响应 | 始终只有单侧,疑似学到姿态 |
| 背景响应 | 背景接近 0 | 背景高亮,学到照片风格捷径 |
| 置信度分布 | 高置信度对应清晰表情 | 模糊图也给 0.9,需要降阈值 |
检查结果有问题时,按“先增强、再查数据、最后换模型”的顺序排查,不要上来就换网络,那会把数据层面的问题掩盖掉。我的固定流程是训练完先跑一次 visualize_cam.py,从每个类别抽两张图看热力图,再结合误检样本一起判断模型是否健康。从那以后,我每次做完识别系统,都会强制走一遍误检图巡检,把验证集里置信度最高但预测错的图片捞出来看模型在看什么。这套检查十分钟不到,能救回不少上线后的翻车。希望帮到你。
本文还有配套的精品资源,点击获取