简介:基于深度学习的人脸表情识别系统源码包面向 Python 毕业设计、课程设计与期末大作业场景,目标用户清晰:无论是刚接触深度学习的学生,还是需要快速搭建演示项目的开发者,都能通过内含的详细注释把握核心实现。压缩包共 103 个文件,大小约 20.98MB,主要文件类型包括 .py 源代码、预训练好的 .hdf5 模型权重(ResNet、Xception 与 Mini_XCEPTION)、.docx 设计文档、表情样本图片与 GIF 动图、XML 配置以及少量 mp4 展示视频;代码附带注释,下载后简单部署即可运行。已有 424 人浏览学习。资源不仅提供可用的识别系统,还覆盖数据预处理、模型训练、评估与界面展示等环节,配合手册文档能帮助理解残差网络与 CNN 在表情分类中的实际应用,适合作为高完成度的毕设参考范本。
1. 深度学习人脸表情识别:一份可跑通的Python毕设工程
人脸表情识别这六个字听起来像算法论文里的词,放到毕业设计里其实就是「给一张人脸照片,让程序告诉你这个人现在是高兴还是惊讶」。这套基于深度学习的Python工程,核心是两套可交替使用的模型——ResNet和CNN,另外附带Xception权重文件,不重新训练也能直接出预测结果。
项目东西很全:训练好的hdf5权重、测试图片、Word文档说明,代码里带注释,目录里还能看到EmotionRecognition.iml这种PyCharm/IDEA的工程配置文件,说明是正经从IDE里开发调试过的项目,不是临时拼凑的脚本堆。对准备毕设或课程设计的同学来说,最直接的价值是不用从零调参,搭好环境跑一遍预测,再照着训练脚本重训一轮,整个流程就通了。
适合用过Python、见过numpy和matplotlib、但不一定亲手写过神经网络的同学。下面从模型原理开始拆,然后讲部署、重训和排错。
2. 模型选型与原理:ResNet和CNN在表情识别上的分工
2.1 为什么是ResNet:残差块解决深层网络的梯度问题
ResNet(残差网络)的核心是残差块。一个残差块里通常有两到三个卷积层,输入x经过卷积、批归一化、激活函数之后得到一个中间输出F(x),然后把这个输出和原始输入x直接相加:y = F(x) + x。这个跨层相加的操作就是恒等映射,落实到代码上就是一个Add层。
加了这个跳跃连接之后,梯度可以沿着shortcut路径直接往回传,网络深度从十几层扩展到几十层训练也不容易崩。表情识别里有一个实际情况:像「惊讶」这种表情,眼部和嘴部的形变范围大,需要中高层特征才能捕捉到,所以深一点的网络理论上效果更好。压缩包里那个resnet.hdf5权重,就是在这条技术路上训练出来的。
常见的ResNet变体有ResNet18、ResNet50,区别在网络层数和每个残差块内卷积核个数。训练这类模型时通常搭配Adam优化器和交叉熵损失函数,输入图像归一化到0到1之间的浮点数。这里有一个实操注意点:ResNet对输入尺寸比较敏感,用64×64训练的权重,推理时最好也用64×64,否则模型输入层节点的shape对不上,加载权重时会报错。
| 对比维度 | ResNet | 简单CNN |
|---|---|---|
| 网络深度 | 几十层,靠残差块堆叠 | 通常3~5个卷积块 |
| 梯度稳定性 | 跳跃连接兜底 | 过深容易梯度消失 |
| 训练成本 | 需要较长训练时间 | CPU上也能快速迭代 |
| 表情细节捕捉 | 中高层特征好 | 对形变鲁棒性一般 |
2.2 简单CNN为什么还能打:卷积池化全连接的老三样
CNN路线走的是另一个极端:用几层卷积和池化把特征图逐步缩小,最后接全连接层做分类。代码实现上就是一个Sequential容器,加Conv2D、MaxPooling2D、Flatten、Dense。计算量小,几十秒就能在CPU上跑一个epoch,这在毕设答辩时反而是优点——你可以现场改参数、现场重训,不用干等。
CNN在表情识别上的短板也明显:只堆卷积不加残差连接,超过十层就容易出梯度问题,所以CNN模型控制在3到5个卷积块比较稳。另一个短板是对表情本身的形变鲁棒性不够,图像里人脸倾斜或者比例不对,准确率会掉。补法靠数据增强,训练时随机旋转、平移、水平翻转,让模型见过更多「歪着的人脸」。
2.3 hdf5权重文件里装了什么:三个模型的分工
项目压缩包里能看到三个hdf5文件,这里逐个说清楚。hdf5是Keras训练后保存权重的标准格式,里面存的是每一层的名字、权重张量、优化器状态,load_model一行就能把结构和权重一起恢复回来。
resnet.hdf5:ResNet训练好的完整权重,骨干是残差结构,适合作为主力模型。推理时优先加载它,准确率相对稳。
Xeception.hdf5和_mini_XCEPTION.102-0.66.hdf5:Xception是Inception结构的升级版,用深度可分离卷积替换普通卷积,参数量比ResNet小。注意压缩包里第一个文件名拼写是Xeception,少了一个r,加载时保持原样别手滑改掉。mini版本是面向单通道灰度图设计的轻量版,文件名里的0.66可以理解为验证准确率约66%,在FER2013这种公开表情数据集上已经是中上水平。
这三个hdf5不是互相替代的关系,而是同一套系统的三个候选模型。训练脚本会把当前最优权重保存成hdf5,推理脚本再加载回去。如果做断点续训,这个文件就是你的后悔药,训练中途挂了只要load回来接着跑,不需要推倒重来。
3. 本地部署与第一次推理:环境搭配、文件拆解、预测脚本
3.1 环境版本怎么搭配:TensorFlow、Keras、OpenCV
先讲结论:这套代码最稳的是Python 3.8或3.9搭配TensorFlow 2.10。TensorFlow 2.10是最后一个原生支持Windows GPU训练的版本,后面版本把GPU支持切到了WSL方案,很多同学不想折腾WSL就卡在环境上跑不动了。Python 3.10以上装opencv-python和numpy时容易碰到二进制包不兼容的坑。
安装之前先建一个虚拟环境,不要直接怼进base环境里,这个习惯能帮你省掉后面一堆依赖冲突:
python -m venv venv # Windows激活 venv\Scripts\activate # Linux/macOS激活 source venv/bin/activate激活后安装依赖:
pip install tensorflow==2.10.0 pip install opencv-python==4.8.0.74 pip install numpy==1.24.3 matplotlib scikit-learn这里几个版本号是搭配好的。tensorflow 2.10内部兼容numpy 1.24.x,opencv 4.8.0.74在Python 3.8和3.9下都有预编译轮子,不需要本机装Visual Studio去现场编译。如果电脑没有NVIDIA显卡,把tensorflow==2.10.0换成tensorflow-cpu==2.10.0,训练慢一些但能跑。装完后在Python里执行import tensorflow as tf确认不报错,再往下走。
3.2 项目文件逐个拆:每个文件管哪块
下载解压后建议先别急着跑代码,把文件过一遍。整理后的目录作用如下表:
| 文件/目录 | 类型 | 作用 |
|---|---|---|
| resnet.hdf5 | 模型权重 | ResNet表情识别模型 |
| Xeception.hdf5 | 模型权重 | Xception结构权重 |
| _mini_XCEPTION.102-0.66.hdf5 | 模型权重 | 轻量版Xception权重 |
| 手册.1.docx | 文档 | 使用说明和技术文档 |
| happy1.jpeg | 测试图 | 开心表情推理测试 |
| surprised2.jpeg | 测试图 | 惊讶表情推理测试 |
| scan.gif、miaomiao.gif、justgogif.jpeg | 测试素材 | 界面演示用的动图和图片 |
| EmotionRecognition.iml | 工程配置 | PyCharm/IDEA的模块配置 |
那个.iml文件值得说一句。它是IntelliJ系IDE的模块描述文件,里面会记录项目SDK版本、框架类型、源代码目录,说明这个项目是在IDE里完整开发调试过的,不是网上那种只有脚本没有工程的半成品。用PyCharm打开项目根目录时,它会被自动识别为一个模块,编译和运行配置基本不用手动建。
3.3 第一个预测脚本:加载resnet.hdf5识别一张表情
环境装好、文件认识之后,写一个最简单的推理脚本来验证整条链路通不通。新建predict.py,内容如下:
import cv2 import numpy as np from tensorflow.keras.models import load_model # 标签顺序必须和训练时完全一致,否则输出全错位 EMOTIONS = ['angry', 'disgust', 'fear', 'happy', 'sad', 'surprise', 'neutral'] model = load_model('resnet.hdf5') # 打印模型输入要求,省得自己猜尺寸 print('模型输入要求:', model.inputs[0].shape) def preprocess(img_path, size=None): # OpenCV直接读图,中文路径容易返回None,路径尽量用英文 img = cv2.imread(img_path) if img is None: raise ValueError(f'图片读不出来: {img_path}') # 转灰度图,人脸表情识别在灰度图上信息量足够 gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) if size is None: # 从模型输入里取空间尺寸,形状是 (1, H, W, C) size = (model.inputs[0].shape[1], model.inputs[0].shape[2]) gray = cv2.resize(gray, size) gray = gray.astype('float32') / 255.0 # 增加通道维和batch维,变成 (1, H, W, 1) return np.expand_dims(gray, axis=-1)[np.newaxis, ...] x = preprocess('happy1.jpeg') pred = model.predict(x, verbose=0)[0] label = EMOTIONS[int(np.argmax(pred))] print(f'预测结果: {label}, 置信度: {pred[np.argmax(pred)]:.2%}')这段代码做了三件事:加载模型、预处理图片、输出预测结果。preprocess函数先从模型输入里自动取目标尺寸,这样换模型时不用改代码;转灰度是因为项目里的三个hdf5都是用单通道灰度图训练的,硬塞RGB三通道进去会在模型输入层直接报形状不匹配。归一化除以255属于常规操作,让输入值落在0到1之间,和训练时的预处理对齐。
跑通这个脚本之后,整个链路就算验证完了。如果这一步报错,大概率是版本问题,可以直接跳到第5章对照排查。
4. 训练自己的表情识别模型:数据增强、compile与回调参数详解
4.1 数据准备:FER2013的目录结构与标签映射
表情识别领域最常用的公开数据集是FER2013,Kaggle上可以下到原始CSV,里面每一行是一张48×48的灰度图,像素值按空格分隔,最后一列是表情标签。原始CSV用起来不方便,常见做法是先转成目录结构再喂给Keras的ImageDataGenerator。
我一般会准备train和val两个根目录,下面按7个表情类别分子目录,7类分别是angry、disgust、fear、happy、sad、surprise、neutral。目录名就是标签名,Keras的flow_from_directory会自动扫描子目录名生成类别索引,不需要手动写标签文件。
data/ train/ angry/ disgust/ fear/ happy/ sad/ surprise/ neutral/ val/ angry/ ...转格式时要注意CSV里的标签是0到6的数字,和子目录名的对应关系要查清楚,不同版本的数据集这个映射可能不一样。一个稳妥做法是转换时把CSV里的label打印一份,和目录列表对照,确认0对应angry、1对应disgust再继续。
4.2 数据增强与归一化:ImageDataGenerator关键参数
表情数据集普遍存在两个问题:类别不均衡(happy样本远多于disgust)和样本量少。直接拿原始图训练,模型容易过拟合,val_accuracy卡在55%上下不去。解决思路是数据增强+类别权重。
from tensorflow.keras.preprocessing.image import ImageDataGenerator datagen = ImageDataGenerator( rescale=1.0/255.0, rotation_range=15, # 随机旋转正负15度 width_shift_range=0.1, # 水平平移10% height_shift_range=0.1, # 垂直平移10% zoom_range=0.1, # 随机缩放10% horizontal_flip=True, # 水平翻转 validation_split=0.2 ) train_gen = datagen.flow_from_directory( 'data/train', target_size=(64, 64), color_mode='grayscale', batch_size=64, class_mode='categorical', subset='training', shuffle=True ) val_gen = datagen.flow_from_directory( 'data/train', target_size=(64, 64), color_mode='grayscale', batch_size=64, class_mode='categorical', subset='validation', shuffle=False )几个参数要解释清楚。validation_split=0.2表示从data/train里随机抽20%当验证集,所以val_gen的目录还是填data/train,只是subset不同。rotation_range和shift_range是表情识别里效果最明显的两个增强项,人脸的轻微倾斜和偏移在真实场景里太常见了。horizontal_flip要慎用:对于「生气」和「惊讶」这类左右对称的表情没问题,但如果数据集里混入了带有左右语义的样本,翻转会引入噪声。
4.3 CNN与ResNet训练脚本对比:compile参数与回调函数
数据准备好之后,模型构建和训练是核心。简单CNN直接用Sequential写,ResNet如果不想从零定义残差块,可以直接load项目自带的resnet.hdf5再微调,其实更省事。两种方式我都给你列出来。
from tensorflow.keras.models import Sequential from tensorflow.keras.layers import Conv2D, MaxPooling2D, Flatten, Dense, Dropout model = Sequential([ Conv2D(32, (3, 3), activation='relu', input_shape=(64, 64, 1)), MaxPooling2D((2, 2)), Conv2D(64, (3, 3), activation='relu'), MaxPooling2D((2, 2)), Conv2D(128, (3, 3), activation='relu'), MaxPooling2D((2, 2)), Flatten(), Dropout(0.5), Dense(128, activation='relu'), Dense(7, activation='softmax') ]) model.compile( optimizer='adam', loss='categorical_crossentropy', metrics=['accuracy'] )这个CNN结构是表情识别里非常经典的组合:3个卷积块逐步把特征图从64×64缩到8×8,通道数从32涨到128,最后接Dropout防过拟合再接全连接。softmax输出7个类别的概率分布。input_shape必须是(64, 64, 1),如果数据增强里target_size改了,这里要同步改。
训练时加上回调函数,能省掉很多手盯训练过程的精力:
from tensorflow.keras.callbacks import EarlyStopping, ModelCheckpoint, ReduceLROnPlateau callbacks = [ ModelCheckpoint('best_model.hdf5', monitor='val_accuracy', save_best_only=True), EarlyStopping(monitor='val_loss', patience=10, restore_best_weights=True), ReduceLROnPlateau(monitor='val_loss', factor=0.5, patience=5, min_lr=1e-6) ] history = model.fit( train_gen, validation_data=val_gen, epochs=80, callbacks=callbacks )| 回调函数 | 关键参数 | 作用 |
|---|---|---|
| ModelCheckpoint | monitor='val_accuracy', save_best_only=True | 只在验证准确率变高时保存权重 |
| EarlyStopping | patience=10, restore_best_weights=True | 连续10轮不改善就停,回到最优权重 |
| ReduceLROnPlateau | factor=0.5, patience=5, min_lr=1e-6 | 验证损失5轮不降,学习率减半 |
用resnet.hdf5做微调走的是另一条路。加载你刚跑通的那个模型,把最后一层替换掉(因为原权重很可能就是在7类上训的,其实不用换),然后冻结前面大部分层,只训练最后几层,这在数据量不够时是提分最快的做法。
4.4 训练日志怎么看:三种典型的loss曲线
训练跑起来之后要学会看日志,别光盯着epoch进度条发呆。三种典型的曲线直接决定你接下来改什么:
第一种是训练loss和验证loss同步下降,最后都收敛在一个平台期,这是理想状态。如果准确率不再涨了,可以考虑把ReduceLROnPlateau的factor调小到0.3,让学习率更细腻地搜索最优解。
第二种是训练loss一直降,验证loss降一点后反弹升高,这是标准过拟合。应对方式按优先级排列:加Dropout比例(从0.5调到0.6)、增强数据里的rotation_range、减小模型容量(把128通道改回64)。
第三种是训练loss和验证loss都降不下去,从一开始就横盘。这种情况先检查预处理:灰度图有没有做归一化、标签和类别顺序对不对、batch里是不是混进了空白图。在表情识别里,第三种80%是数据目录建错了,模型根本没学到有效特征。
5. 避坑记录:五个让表情识别翻车的常见错误
5.1 hdf5加载失败:Keras版本与权重编码不匹配
现象:load_model('resnet.hdf5')报错,提示Unknown layer: Xx或Unable to load weights saved in HDF5 format。
原因:这个权重大概率是用Keras 2.x保存的,而你现在环境是Keras 3.x,layer类名和权重组的解析逻辑变了。
解决:先看keras版本,低于2.15就升到2.15,高于3.0就降到2.15:
pip install keras==2.15.0 tensorflow==2.15.0注意keras和tensorflow要一起固定版本,只单独降keras会导致运行时自动用tf内置的keras,版本插入混乱。装完再print(keras.version)确认一下。
5.2 中文路径和GIF图片让人脸读不出来
现象:cv2.imread('测试图片.jpg')不报错,但返回的img是None,程序到cvtColor时报错。
原因:OpenCV老版本的imread对中文路径支持不好,直接返回空。另外项目里的scan.gif和miaomiao.gif是GIF动图,cv2.imread根本读不了GIF格式。
解决:路径全部改成英文,或者用PIL先读再转numpy数组。GIF这种动图要用imageio来读:
import imageio.v3 as iio import numpy as np frames = iio.imread('scan.gif', plugin='PIL') frame = np.array(frames[0]) # 取第一帧如果就是想在代码里统一处理,建议全部统一成PIL读取,再转成numpy喂给模型。interface上显示动图没问题,但喂给模型的一定是单帧静态图。
5.3 标签从0开始:class_indices对不上
现象:训练时准确率很高,验证集也正常,但推理阶段把「开心」预测成「生气」,结果张冠李戴。
原因:flow_from_directory自动生成的class_indices,字典顺序可能和你手工写的EMOTIONS列表顺序不一致。比如它可能把'angry'标成0,'disgust'标成1,而你推理脚本里EMOTIONS[0]写的是'angry',但模型输出下标0对应的实际是'neutral'。
解决:训练完之后立刻打印model.class_indices,直接把这个字典复制到推理脚本里,别自己重新敲一遍:
print(train_gen.class_indices) # 期望输出: {'angry': 0, 'disgust': 1, 'fear': 2, ...}推理端用同一个字典去反查标签名,从根源上消除顺序错位的可能。
5.4 训练被kill掉或显存不足
现象:训练跑到第20个epoch,进程直接消失,控制台提示Killed;或TensorFlow报ResourceExhaustedError。
原因:batch_size设太大,显存或者内存被打满。batched=64、图片尺寸如果还是256×256,一个batch的激活值就非常可观了。另外ImageDataGenerator默认会把整个数据集加载进内存做预处理,数据量大照样崩。
解决:先把batch_size降到32或16,再不行降图片分辨率到48×48。FER2013本身就是48×48的灰度图,用64×64训练已经很宽裕,没必要用大图。如果内存还涨,给flow_from_directory加一个workers参数控制预取线程数,或者改成tf.data管线流式读取。
5.5 推理卡顿与界面假死
现象:点了界面上的「识别」按钮,窗口白屏转圈,过几秒才弹结果,点第二次直接未响应。
原因:GUI是Tkinter或PyQt实现时,把model.predict放在了主线程里。预测时CPU/GPU高占用,GUI事件循环被阻塞,界面自然假死。
解决:把推理放进子线程,结果用队列传回主线程更新显示。最简单的一种写法:
import threading import queue result_queue = queue.Queue() def predict_worker(img_path): x = preprocess(img_path) pred = model.predict(x, verbose=0)[0] result_queue.put(pred) def on_click(img_path): threading.Thread(target=predict_worker, args=(img_path,), daemon=True).start() # 主线程用after/定时器检查result_queue,取到结果再刷新UI这个改法不涉及算法层面,就是工程习惯问题,但能让界面体验上一个档次。答辩演示时没人愿意看转圈的白屏。
6. 进阶技巧:多模型投票融合把准确率往上一顶
如果单模型准确率卡在60%多上不去,最快的提分方式不是换更强的网络,而是把三个hdf5都用起来做投票融合。这就是经典的集成学习思路,用几个能力相当的模型互补,往往比调参更见效。
import numpy as np from tensorflow.keras.models import load_model models = [ load_model('resnet.hdf5'), load_model('Xeception.hdf5'), load_model('_mini_XCEPTION.102-0.66.hdf5') ] def ensemble_predict(x): votes = np.zeros(7, dtype=float) for m in models: pred = m.predict(x, verbose=0)[0] votes += pred # 软投票:概率直接相加 # votes[np.argmax(pred)] += 1 # 硬投票:只有最高分得票 return votes / len(models) x = preprocess('surprised2.jpeg') pred = ensemble_predict(x) label = EMOTIONS[int(np.argmax(pred))] print(f'集成预测结果: {label}, 置信度: {pred[np.argmax(pred)]:.2%}')原理不复杂:三个模型对同一张图的预测概率分布做平均,相当于让它们互相纠错。resnet擅长抓深层语义特征,Xception参数量小但泛化性好,mini版对灰度图的预处理差异不敏感,三者同时犯同一个错误的概率比单个模型低得多。软投票比硬投票稳妥,因为概率平均保留了置信度信息,不会因为某一次恰好argmax偏了就丢掉一票。
如果想做得更细致,可以统计每个模型在验证集上的单独准确率,按准确率加权再平均,而不是简单等权相加。用一个简单的循环在val集上跑一遍,记录每个模型每类的F1值,再把这些F1当成权重乘到对应模型的输出上,效果通常还能再涨一到两个点。
这套工程我拆过不止一次,踩坑最多的地方永远是环境版本而不是模型本身。从那以后我每次拿到这种带hdf5权重的项目,第一步一定是先看keras历史版本和文件保存时间对不对得上,再谈跑通预测——这个习惯救了我很多次,希望帮到你。
本文还有配套的精品资源,点击获取