ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于CNN的人脸表情识别系统:从期末大作业到摄像头实时推理的完整实现

基于CNN的人脸表情识别系统:从期末大作业到摄像头实时推理的完整实现

简介:这份资源是面向计算机相关专业学生的Python期末大作业完整源码包,主题为基于CNN的人脸表情识别系统,适合正在准备课程设计、需要项目实战练习的学习者参考使用。项目经导师指导并通过评审,难度适中,源码均经过本地编译与严格调试,可正常运行。压缩包共23个文件,约19.17MB,以10个Python脚本为核心,涵盖数据读取与写入、模型定义、训练与识别等模块,另含Keras训练脚本、tfrecord数据文件、界面渲染页面及说明文档,结构清晰便于按模块学习。目前已有144人学习下载。读者可从中获得一套完整的人脸表情识别实现方案,理解CNN模型构建、数据预处理、训练流程与摄像头及图片识别等关键环节,并借助README快速上手,适合作为大作业参考或深度学习入门实战案例。

1. 从一份期末大作业说起:CNN 人脸表情识别到底能跑出什么效果

每年期末季,总有人拿着「Python期末大作业基于CNN的人脸表情识别系统源码.zip」这个标题来找我,问的无非两件事:这东西能不能直接跑起来,以及答辩的时候老师会问什么。先说结论——能跑,而且只要环境装对,从解压到看到摄像头里自己咧嘴笑被识别成「happy」,快的话一个下午就够。但「能跑」和「跑得对」之间隔着一条不小的沟,这条沟里埋着数据集格式、OpenCV 读帧、模型输入尺寸、标签映射这几颗雷,踩中任何一颗,你看到的都是全程「neutral」或者干脆报错退出。

人脸表情识别(Facial Expression Recognition,FER)本质是一个图像分类任务:给一张人脸图,判断它属于哪一类情绪。主流数据集一般分 7 类——angry、disgust、fear、happy、sad、surprise、neutral。CNN 在这里干的事,就是从像素里一层层抽出边缘、纹理、五官形变这些特征,最后映射到情绪标签上。它不像人脸识别那样认「你是谁」,而是认「你现在什么状态」,所以对光照、角度、遮挡都更敏感,这也是为什么同一份源码在不同机器上表现能差出一大截。

这份大作业适合谁?如果你正在做课程设计、想找一个能讲清楚原理又能现场演示的题目,它很合适;如果你已经会写 PyTorch 训练循环,想拿它当练手项目补一补数据增强和推理部署的细节,也够用。但如果你指望它直接商用、或者拿它去跑真实监控场景,那得先做好心理准备——实验室数据集和真实场景之间的差距,比想象中大得多。下面我按「先立住原理、再动手复现、最后讲坑」的顺序,把这条路走一遍。

2. 拆开这份源码:CNN 表情识别的数据流与模型选型

2.1 一份典型源码里到底有哪几块

拿到压缩包解压后,目录结构通常长这样:一个data或dataset文件夹放图片,一个train.py负责训练,一个model.py定义网络,一个predict.py或camera.py做推理,外加requirements.txt和几个.pth权重文件。别急着python train.py,先花五分钟把每个文件扫一遍,重点看三处:数据是怎么读的、模型输入尺寸是多少、标签顺序是什么。这三处对不上,后面全是白忙。

数据读取这块,常见做法是用torchvision.datasets.ImageFolder,它要求目录按类别分好,比如data/train/happy/xxx.jpg。如果你的数据是 CSV 里存路径和标签,那就得自己写Dataset子类。模型输入尺寸一般在model.py或者train.py的transforms里写死,常见是 48×48 灰度图(FER2013 的风格)或者 224×224 彩色图(迁移学习的风格)。标签顺序更隐蔽,有的源码按字母序,有的按自定义列表,推理时如果顺序错了,happy 会被认成 angry,这种 bug 最难查,因为程序不报错,只是结果全乱。

2.2 为什么是 CNN,而不是全连接或者传统特征

有人会问,表情识别用 HOG 加 SVM 不也能做吗,为什么非要上 CNN。答案是:传统方法依赖人工设计特征,比如眼睛睁多大、嘴角上扬多少度,这些特征在正面清晰图上还行,一旦头部有偏转、光照变化,鲁棒性就崩了。CNN 的卷积核是学出来的,浅层学边缘和纹理,深层学五官组合和整体形变,对空间平移有一定不变性,这正是表情识别需要的。

具体到网络结构,这份大作业级别的源码一般不会上 ResNet 那么重,常见的是一个 4 到 6 层的自定义 CNN:两三层卷积加池化,后面接两三个全连接层,最后 softmax 输出 7 类。这种结构参数量小,在 FER2013 这种 3 万多张图的数据集上,CPU 也能在几十分钟内跑完一轮。如果你想提精度,可以换成预训练的 ResNet18 或 MobileNetV2,把最后一层改成 7 输出,冻结前面几层先训分类头,再解冻微调,这是常见做法,精度通常能比自定义 CNN 高几个百分点。

2.3 数据增强:小数据集能不能训好的关键

FER2013 这类数据集有个特点:类别不平衡,happy 和 neutral 的样本远多于 disgust 和 fear。直接训,模型会偏向多数类,disgust 几乎认不出来。解决办法有两个:一是加WeightedRandomSampler给少数类加权,二是在transforms里做增强,比如随机水平翻转、随机旋转 ±10 度、颜色抖动。注意水平翻转对表情识别是安全的,因为左右脸对称,但垂直翻转绝对不能用,倒过来的脸不是脸。

from torchvision import transforms train_transform = transforms.Compose([ transforms.Grayscale(num_output_channels=1), # FER2013 是灰度图,统一通道 transforms.Resize((48, 48)), # 和模型输入对齐,别改错 transforms.RandomHorizontalFlip(p=0.5), # 水平翻转,表情识别安全 transforms.RandomRotation(10), # 小角度旋转,模拟头部微偏 transforms.ColorJitter(brightness=0.2, contrast=0.2), # 光照鲁棒性 transforms.ToTensor(), transforms.Normalize(mean=[0.5], std=[0.5]) # 灰度图单通道归一化 ]) val_transform = transforms.Compose([ transforms.Grayscale(num_output_channels=1), transforms.Resize((48, 48)), transforms.ToTensor(), transforms.Normalize(mean=[0.5], std=[0.5]) ])

这段代码的关键在Resize和Normalize两处。Resize的尺寸必须和模型第一层卷积期望的输入一致,如果模型是按 48×48 写的,你喂 224×224 进去,全连接层的维度就对不上,报错信息通常是mat1 and mat2 shapes cannot be multiplied。Normalize的 mean 和 std 要和训练时一致,推理时如果忘了归一化或者用了不同的值,预测结果会整体偏移。灰度图用单通道,如果你用的是彩色数据集,把Grayscale去掉,Normalize改成三通道的均值方差。

3. 从零跑通训练:环境、数据、训练循环三步走

3.1 环境安装与依赖版本对齐

第一步永远是环境。Python 版本建议 3.8 到 3.10,太新的版本有些老源码里的库装不上。核心依赖就四个:torch、torchvision、opencv-python、numpy。安装命令如下:

pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu pip install opencv-python numpy matplotlib

如果你有 NVIDIA 显卡,把第一行的 cpu 换成对应的 cuda 版本,比如cu118。这里有个血泪经验:torch 和 torchvision 版本必须匹配,torch 2.0 配 torchvision 0.15,torch 1.13 配 torchvision 0.14,装错了会在import torchvision时直接报错。装完跑一句python -c "import torch; print(torch.__version__, torch.cuda.is_available())"确认,输出False说明用的是 CPU,训练会慢但能跑。

3.2 数据准备:ImageFolder 还是自定义 Dataset

如果你的数据已经是按类别分文件夹的,直接用ImageFolder最省事:

from torchvision.datasets import ImageFolder from torch.utils.data import DataLoader train_dataset = ImageFolder(root='data/train', transform=train_transform) val_dataset = ImageFolder(root='data/val', transform=val_transform) train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True, num_workers=2) val_loader = DataLoader(val_dataset, batch_size=64, shuffle=False, num_workers=2) print(train_dataset.classes) # 打印标签顺序,务必记下来

ImageFolder会按文件夹名的字母序生成classes列表,这个顺序就是模型输出的索引顺序。比如输出['angry', 'disgust', 'fear', 'happy', 'neutral', 'sad', 'surprise'],那模型输出第 3 个值最大就代表 happy。推理时如果自己另写了一个标签列表且顺序不同,结果就全错。batch_size根据显存调,CPU 训练建议 32 或 64,太大内存扛不住。num_workers在 Windows 上有时会出问题,设成 0 最稳。

3.3 训练循环与关键超参

训练循环是整份源码的核心,看懂这一段,答辩时老师问什么你都能接住:

import torch import torch.nn as nn import torch.optim as optim device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = SimpleCNN(num_classes=7).to(device) criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=1e-3, weight_decay=1e-4) scheduler = optim.lr_scheduler.StepLR(optimizer, step_size=10, gamma=0.5) for epoch in range(30): model.train() running_loss = 0.0 for imgs, labels in train_loader: imgs, labels = imgs.to(device), labels.to(device) optimizer.zero_grad() outputs = model(imgs) loss = criterion(outputs, labels) loss.backward() optimizer.step() running_loss += loss.item() scheduler.step() print(f'Epoch {epoch+1}, Loss: {running_loss/len(train_loader):.4f}') torch.save(model.state_dict(), 'best.pth')

几个参数值得说清楚。lr=1e-3是 Adam 的常用起点,如果 loss 震荡厉害就降到 1e-4。weight_decay=1e-4是 L2 正则,防止过拟合,小数据集上很有用。StepLR每 10 轮把学习率乘 0.5,让后期收敛更稳。CrossEntropyLoss内部已经带了 softmax,所以模型最后一层不要再加 softmax,加了反而会导致梯度问题。保存权重用state_dict(),别直接torch.save(model),后者依赖类定义,换环境加载容易出问题。

训练过程中要盯两个信号:训练 loss 持续下降但验证 loss 开始上升,说明过拟合了,该加 dropout 或者早停;两个 loss 都不降,可能是学习率太大或者数据标签有问题。验证集准确率能到 60% 以上就算及格,FER2013 上人类标注一致率也就 65% 左右,别指望冲到 90%。

4. 推理与摄像头实时识别:把模型用起来

4.1 单张图片推理的完整流程

训练完拿到.pth文件,下一步是推理。单张图片推理的流程是:读图、灰度化、缩放、归一化、转 tensor、加 batch 维度、送模型、取 argmax、映射标签。

import cv2 import torch import numpy as np from torchvision import transforms labels = ['angry', 'disgust', 'fear', 'happy', 'neutral', 'sad', 'surprise'] model.load_state_dict(torch.load('best.pth', map_location=device)) model.eval() img = cv2.imread('test.jpg') gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) face = cv2.resize(gray, (48, 48)) face = face.astype(np.float32) / 255.0 face = (face - 0.5) / 0.5 tensor = torch.from_numpy(face).unsqueeze(0).unsqueeze(0).to(device) with torch.no_grad(): output = model(tensor) pred = torch.argmax(output, dim=1).item() print(labels[pred])

注意unsqueeze(0)用了两次,第一次是把 (48,48) 变成 (1,48,48) 补通道维,第二次是变成 (1,1,48,48) 补 batch 维。少任何一个模型都会报维度错误。model.eval()必须调用,否则 dropout 和 batchnorm 还在训练模式,结果不稳定。torch.no_grad()关掉梯度计算,省显存也更快。

4.2 摄像头实时识别:OpenCV 加 Haar 人脸检测

实时识别比单张推理多一步人脸检测,常见做法是用 OpenCV 自带的 Haar 级联分类器先框出人脸,再把人脸区域送模型。这样能避免背景干扰,也能提高准确率。

face_cascade = cv2.CascadeClassifier(cv2.data.haarcascades + 'haarcascade_frontalface_default.xml') cap = cv2.VideoCapture(0) while True: ret, frame = cap.read() if not ret: break gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) faces = face_cascade.detectMultiScale(gray, scaleFactor=1.1, minNeighbors=5, minSize=(48, 48)) for (x, y, w, h) in faces: roi = gray[y:y+h, x:x+w] roi = cv2.resize(roi, (48, 48)).astype(np.float32) / 255.0 roi = (roi - 0.5) / 0.5 tensor = torch.from_numpy(roi).unsqueeze(0).unsqueeze(0).to(device) with torch.no_grad(): pred = torch.argmax(model(tensor), dim=1).item() cv2.rectangle(frame, (x, y), (x+w, y+h), (0, 255, 0), 2) cv2.putText(frame, labels[pred], (x, y-10), cv2.FONT_HERSHEY_SIMPLEX, 0.9, (0, 255, 0), 2) cv2.imshow('FER', frame) if cv2.waitKey(1) & 0xFF == ord('q'): break cap.release() cv2.destroyAllWindows()

detectMultiScale的三个参数直接影响体验:scaleFactor=1.1是每次缩放比例,越小检测越细但越慢;minNeighbors=5控制误检,调大能减少误框但可能漏检;minSize=(48,48)过滤太小的区域。摄像头场景下光照变化大,Haar 检测容易漏脸,如果效果差可以换 DNN 人脸检测器,OpenCV 自带cv2.dnn模块,精度更高但配置稍麻烦。

4.3 推理速度与准确率的平衡

实时场景下,每帧都跑一次模型可能掉帧。常见优化是隔几帧检测一次人脸,中间帧沿用上一次的框和标签,或者把模型换成 MobileNet 这类轻量网络。另外输入尺寸从 48×48 提到 96×96 通常能提一点精度,但推理时间翻倍,要自己权衡。如果只是答辩演示,48×48 加 Haar 足够,别过度优化。

5. 避坑与排查:那些让结果全错的细节

5.1 预测结果永远是同一类

现象:不管输入什么图,输出都是 neutral 或者 happy。原因通常是标签顺序错位,或者归一化参数和训练时不一致。先打印train_dataset.classes确认训练时的标签顺序,再检查推理时的labels列表是否一致。如果顺序对,检查归一化:训练用了mean=0.5, std=0.5,推理也必须一样,用mean=0, std=1会让输入分布偏移,模型输出全乱。

5.2 摄像头读不到帧或黑屏

现象:cap.read()返回False,或者窗口一片黑。原因一般是摄像头索引不对,或者被其他程序占用。cv2.VideoCapture(0)是默认摄像头,如果有多个摄像头可以试 1 或 2。Windows 上有时需要加cv2.CAP_DSHOW参数:cv2.VideoCapture(0, cv2.CAP_DSHOW)。另外确认没有其他程序(比如视频会议软件)占着摄像头。

5.3 训练 loss 不下降

现象:跑了几轮 loss 一直在 1.9 左右不动。原因可能是学习率太大导致震荡,或者数据标签全是乱的。先把学习率降到 1e-4 试试,如果还不行,手动打印几个 batch 的图片和标签,确认图片不是全黑、标签不是全 0。还有一种可能是模型最后一层加了 softmax,和CrossEntropyLoss冲突,去掉 softmax 即可。

5.4 显存不足或内存溢出

现象:训练到一半报CUDA out of memory或者程序被系统杀掉。解决办法是减小batch_size,从 64 降到 32 甚至 16。如果用的是 CPU,检查num_workers是不是设太大,Windows 上设 0 最稳。另外图片尺寸别设太大,48×48 的 batch 64 在 4G 显存上完全够用,224×224 就悬了。

5.5 换数据集后精度暴跌

现象:在 FER2013 上能到 60%,换成自己拍的图只有 20%。原因是域偏移——训练数据和真实数据的分布不一样。解决办法是用自己的数据微调几轮,或者做更激进的数据增强(随机裁剪、亮度对比度大幅抖动)。如果只是答辩演示,建议直接用和训练集风格接近的测试图,别拿手机自拍去测,容易翻车。

6. 进阶技巧:把这份大作业做出区分度

如果你想让这份作业在答辩时脱颖而出,有几个方向可以加。第一是加注意力机制,在卷积层后面接一个 SE 模块或者 CBAM,让模型聚焦到眼睛和嘴巴区域,代码量不大但效果提升明显,答辩时也有东西可讲。第二是做 Grad-CAM 可视化,把模型关注的区域画出来,直观展示它是不是在看脸,这个用pytorch-grad-cam库几行就能搞定。第三是加一个简单的 Web 界面,用 Flask 或 Gradio 包一层,上传图片返回表情,演示效果比命令行好得多。

import gradio as gr def predict_image(img): gray = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY) gray = cv2.resize(gray, (48, 48)).astype(np.float32) / 255.0 gray = (gray - 0.5) / 0.5 tensor = torch.from_numpy(gray).unsqueeze(0).unsqueeze(0).to(device) with torch.no_grad(): pred = torch.argmax(model(tensor), dim=1).item() return labels[pred] gr.Interface(fn=predict_image, inputs='image', outputs='text').launch()

这段 Gradio 代码十行不到就能起一个网页 demo,答辩时老师可以直接上传图片试,互动感强。注意输入图片是 RGB,要先转灰度再处理,别直接送模型。

最后说个我自己的习惯:每次跑通一个模型,我都会把标签顺序、归一化参数、输入尺寸这三个值写在一个config.py里,训练和推理都从这里读,绝不硬编码在两处。这个习惯帮我省了无数次「结果全错但找不到原因」的时间。表情识别这个方向,模型结构其实不是最难的,难的是数据管线和前后处理的一致性,把这块做扎实,剩下的就是调参的事。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进