简介:这份Python期末大作业以ResNet残差网络为核心,完成人脸表情识别全流程,适合深度学习初学者或需要完整项目实战参考的学生。资源共17个文件,压缩包仅5.19MB,涵盖py源码、png表情数据集、json类别索引、xml人脸检测配置、模型文件、mp4演示视频及说明文档等,类型覆盖代码、数据、配置和成果展示。数据集按Angry、Happy、Sad等表情分类,代码包含数据加载与预处理、模型构建、训练与测试、混淆矩阵可视化等模块,并提供依赖环境说明,可快速搭建运行环境,已有211人学习下载。通过阅读数据集说明文档与项目源码,能掌握从图像预处理、残差块搭建到交叉熵损失训练与评估的完整思路,还包含模型保存和验证集调优等关键细节;附带的演示视频与示例模型,便于直观理解运行效果。整体目录结构清晰,适合用于课程设计答辩、项目复现或作为深入学习PyTorch/Keras的起点。
1. 为什么拿 ResNet 做期末人脸表情识别:先把四件事说清楚
人脸表情识别是 Python 期末大作业里性价比很高的一个方向:数据集现成、模型训练不要求顶级显卡、展示效果又直观。这个项目用的是 ResNet,而不是简单 CNN,核心原因只有一个——单靠堆卷积层,网络深了之后梯度在反向传播里会慢慢消失,训练集 loss 降不下去,ResNet 的残差块则用一条跳跃连接(skip connection)把信息直接传给后面的层,网络加深的同时梯度还能顺畅走回来。项目里除了一套完整代码,还带了数据集、训练好的模型文件和测试视频,属于拖下来就能跑的完整闭环。适合两类人:一类是期末需要交课设、要能现场演示效果的学生;另一类是刚学完深度学习基础、想拿真实项目练手,弄懂 ResNet 到底怎么落地而不是只看论文图的初学者。
2. 数据集与预处理:七类表情、目录读图和归一化的关键细节
2.1 项目里到底有哪些数据文件
这个项目根目录下有dataset/,里面的README.md记录了数据集的来源和目录组织方式。根目录还放了七张示例图:Angry.png、Disgust.png、Fear.png、Happy.png、Neutral.png、Sad.png、Surprise.png,这七张分别对应七个表情类别。这里要先确认一点:Face 表情识别常用数据集通常分两种组织方式,一种是每个类别一个文件夹,图片按类别存放;另一种是 CSV/JSON 标签文件,每行是图片路径和标签。这套项目走的是前一种,目录结构很直观。
class_indices.json是训练脚本自动生成的类别索引文件,内容类似下面这样:
{ "0": "Angry", "1": "Disgust", "2": "Fear", "3": "Happy", "4": "Neutral", "5": "Sad", "6": "Surprise" }这个文件在训练阶段由 Keras 的flow_from_directory自动产生,测试和推理阶段读它来完成“模型输出的索引号到真实表情名称”的映射。
类别数量是七个,而不是常见的六类或八类,这一点要在训练前就确认一致。很多学生在这个项目上翻车,就是因为中途删过个别样本,导致训练脚本里写死的类别数num_classes=7与实际目录数量对不上,训练时不出错,但验证集准确率始终停留在某一个固定值附近。
2.2 数据加载脚本:从目录里读图和归一化
不管是用 Keras 还是 PyTorch,第一步都是把图片读进来,转成张量,再做归一化。这套项目的数据读取逻辑可以用下面这份最小的 Keras 版来复现,项目里的加载模块就是这个思路:
from tensorflow.keras.preprocessing.image import ImageDataGenerator # 图像尺寸统一为 48x48,因为这种表情识别数据集里大多数原图就是 48x48 灰度图 IMG_SIZE = (48, 48) BATCH_SIZE = 64 # 只在训练集上做数据增强,验证集只做归一化 train_datagen = ImageDataGenerator( rescale=1.0 / 255.0, rotation_range=10, width_shift_range=0.1, height_shift_range=0.1, zoom_range=0.1, horizontal_flip=True, validation_split=0.2 ) train_generator = train_datagen.flow_from_directory( 'dataset', target_size=IMG_SIZE, batch_size=BATCH_SIZE, class_mode='categorical', subset='training', shuffle=True ) val_generator = train_datagen.flow_from_directory( 'dataset', target_size=IMG_SIZE, batch_size=BATCH_SIZE, class_mode='categorical', subset='validation', shuffle=False )rescale=1.0/255.0把像素值从[0, 255]缩放到[0, 1],这是 ResNet 输入层的基本要求,不做这一步模型收敛会明显变慢。rotation_range=10和zoom_range=0.1是轻度的几何扰动,人脸表情识别里旋转角度不宜过大,超过 15 度会让侧脸样本变得不真实,模型学到的是“歪头”而不是“表情”。validation_split=0.2是让flow_from_directory自动从每个类别目录里切出 20% 作为验证集,这个比例对期末项目的数据量来说是安全的。
要特别注意shuffle=False只出现在验证集上,这是为了后面画混淆矩阵时,预测结果的顺序能和真实标签一一对应,不至于对不上号。
2.3 为什么归一化和数据增强放在同一个生成器里
很多初学者会把数据增强和归一化分开写:先把图片读成数组、归一化,然后再手动翻转。这种做法在这类目录式数据集上会引入一个隐患——如果图片数据量不大(通常每个类别几百张),手动增强很容易在内存里撑爆,而 Keras 的ImageDataGenerator是在取批次的时候实时做变换的,内存占用稳定在一个 batch 的内存量级,不会随 epoch 增长。
这套项目选择把validation_split和增强放一起,还需要注意一个坑:flow_from_directory是懒加载的,它不做类别目录下所有图片一次性读入,而是每次按 batch 从磁盘读取。这意味着数据目录里如果混入了损坏的图片文件(比如某些表情包网站下载下来的人脸图片其实是 GIF 格式,后缀却是 .jpg),训练会在某个 epoch 中途突然报错,而且报错信息经常是模糊的Error decoding image或者根本不报错,只是那个 batch 的 loss 变成 NaN。
走路这一步最稳妥的验证方式是先跑一次train_generator.class_indices,打印出来确认七类表情的编号映射关系,再用下面的方式确认图片能正常解码:
import os from PIL import Image # 遍历所有图片,检查是否能被 PIL 正常解析 for root, dirs, files in os.walk('dataset'): for name in files: path = os.path.join(root, name) try: with Image.open(path) as im: im.load() except Exception as e: print('损坏文件:', path, e)这段代码会在训练前把所有图片过一遍,坏文件提前暴露出来。实际项目里出现过 GIF 伪装成的.jpg、零字节空文件、以及从聊天记录里保存下来的 .png 后缀但实际是 WebP 的图片,这几种情况 PIL 都能查出来。
3. 训练主流程:model.py 里的残差块、损失函数与回调保护
3.1 残差块到底在解决什么问题
ResNet 的核心思想要落到一个很具体的现象上:随着卷积网络层数增加,训练集准确率反而会下降——这不是过拟合,而是优化器很难拟合深层网络的恒等映射。普通卷积层学习的是y = F(x),而一个残差块学习的是y = F(x) + x,当某一层对输出没有帮助时,网络可以学出让F(x)全部置零,把信息直接跳过这一层。这个“允许网络把没用的层跳过”的特性,就是残差结构最直接的工程价值。
人脸表情识别不是 ImageNet 那种千分类任务,不需要 50 层以上的极深网络,但也不是随便两层卷积就能搞定的——表情差异往往集中在眼睛、嘴巴、眉毛这些局部区域,需要足够深的感受野。这个项目采用的做法是在基础卷积段后叠加残差块,把层数控制在能够提取局部纹理特征的范围内,避免过深反而难以收敛。
每层残差块内部由卷积层、批量归一化层和 ReLU 激活函数构成,卷积层提取特征,批量归一化把每层的输入分布拉回标准区间,ReLU 提供非线性。这三者的顺序有一点经验讲究:Conv -> BN -> ReLU是 ResNet 原论文里的配置,实际训练中 BN 放在 ReLU 前面比放在后面能更好抑制梯度消失。
3.2 model.py 的模型定义拆解
项目里的model.py构建了整个 ResNet 结构,下面是一份可以按照项目结构直接跑通的 Keras 实现,核心就是自己写残差块而不是直接调ResNet50——期末作业要能讲清楚实现原理,评审老师一般会针对残差块结构提问:
from tensorflow.keras import layers, models from tensorflow.keras.regularizers import l2 def residual_block(x, filters, stride=1): # 第一个 3x3 卷积,可能做下采样(stride>1) shortcut = x x = layers.Conv2D(filters, (3, 3), strides=stride, padding='same', kernel_regularizer=l2(1e-4))(x) x = layers.BatchNormalization()(x) x = layers.ReLU()(x) # 第二个 3x3 卷积,只做特征提取,不再降采样 x = layers.Conv2D(filters, (3, 3), strides=1, padding='same', kernel_regularizer=l2(1e-4))(x) x = layers.BatchNormalization()(x) # 当原通道数和残差块输出通道数不一致时,shortcut 也要做一次 1x1 卷积对齐 if shortcut.shape[-1] != filters or stride != 1: shortcut = layers.Conv2D(filters, (1, 1), strides=stride)(shortcut) shortcut = layers.BatchNormalization()(shortcut) x = layers.Add()([x, shortcut]) x = layers.ReLU()(x) return x def build_resnet(input_shape=(48, 48, 1), num_classes=7): inputs = layers.Input(shape=input_shape) # 输入段:普通卷积 + BN + ReLU,不进入残差块 x = layers.Conv2D(64, (3, 3), padding='same')(inputs) x = layers.BatchNormalization()(x) x = layers.ReLU()(x) # 堆叠残差块,通道数逐层增加,空间尺寸逐层减半 x = residual_block(x, 64) x = residual_block(x, 64) x = residual_block(x, 128, stride=2) x = residual_block(x, 128) x = residual_block(x, 256, stride=2) x = residual_block(x, 256) # 全局平均池化代替 Flatten,参数量小而且不易过拟合 x = layers.GlobalAveragePooling2D()(x) x = layers.Dropout(0.5)(x) x = layers.Dense(num_classes, activation='softmax')(x) model = models.Model(inputs=inputs, outputs=x) return model注意residual_block里的stride=2出现在通道数翻倍的那一层,这一层同时完成空间下采样和通道扩张,是 ResNet 的经典设计。l2(1e-4)对卷积核做轻微权重衰减,防止小数据集上过拟合。shortcut的对齐逻辑很容易写漏——当上一层的输出通道数是 64、当前残差块输出是 128 时,Add层要求两个分支形状完全一致,所以要用(1, 1)卷积给 shortcut 升维。
输入用了(48, 48, 1)单通道灰度图,这与前面数据集原图尺寸保持一致。也有人用(48, 48, 3)三通道输入,那需要把灰度图复制成三通道,多一倍计算量,准确率提升有限,这套项目按单通道处理是合理选择。
3.3 训练参数怎么设置才不容易翻车
训练核心参数在训练模块里定义,常规设置如下:
from tensorflow.keras.optimizers import Adam from tensorflow.keras.callbacks import EarlyStopping, ModelCheckpoint, ReduceLROnPlateau model = build_resnet() model.compile( optimizer=Adam(learning_rate=1e-3), loss='categorical_crossentropy', metrics=['accuracy'] ) # 只在验证准确率提升时保存权重,防止把过拟合模型当最优解 checkpoint = ModelCheckpoint( 'model/best_model.h5', monitor='val_accuracy', save_best_only=True, mode='max' ) # 验证集连续 8 轮不提升就提前结束,避免无效训练 early_stop = EarlyStopping( monitor='val_loss', patience=8, restore_best_weights=True ) # 训练停滞时把学习率减半,多数情况下能救回 loss 震荡 reduce_lr = ReduceLROnPlateau( monitor='val_loss', factor=0.5, patience=4 ) history = model.fit( train_generator, validation_data=val_generator, epochs=50, callbacks=[checkpoint, early_stop, reduce_lr] )Adam(learning_rate=1e-3)在大多数小型图像分类任务里是稳妥起点,如果 loss 前几个 epoch 完全不动,可以调到3e-4再跑。loss='categorical_crossentropy'对应flow_from_directory的class_mode='categorical',两者必须匹配,标签如果换成了sparse的整数形式,损失函数就要改成sparse_categorical_crossentropy。patience=8的意思是验证集 loss 连续 8 个 epoch 没有改善就停止训练,粒度可以根据训练时间调整,数据量小时可以放到 10。
ModelCheckpoint里save_best_only=True保存的是验证准确率最高那轮的权重,而不是最后一轮的,这在期末答辩时很重要——如果不保存最佳模型,遇到验证准确率在后半段下滑,演示时用的就会是个退化的权重。
训练结束后history对象里记录了每个 epoch 的loss和accuracy。项目里配套的可视化模块会读取 history 画两条曲线,一条是训练 loss 和验证 loss 的对比,一条是训练准确率和验证准确率的对比。两张图同时看才有效果:如果训练 loss 一直降但验证 loss 拐头向上,是过拟合;如果两者都高居不下,才是网络结构或数据预处理的问题。
4. 避坑排查:训练不收敛、内存爆掉和准确率虚高的四个出路
4.1 现象:训练集准确率 90% 以上,验证集一直卡在 40% 上下
原因:最典型的过拟合。表情数据集通常每个类别只有几千张甚至几百张图片,而 ResNet 的参数量在百万级别,从第一轮开始网络就在记忆训练图片的细节,而不是提取真正通用的表情特征。另一个常见原因是增强做得太轻,只加了翻转和缩放,光照变化和遮挡没有模拟到。
解决:先把Dropout从 0.5 提到 0.6,再看验证集准确率有没有抬升。同时把l2正则化系数从1e-4改成1e-3,损失函数里正则项占比加大,模型会更保守。如果这两步做完改善不明显,建议直接换成预训练模型做迁移学习——用ResNet50的 ImageNet 预训练权重,冻结前几十层只训练最后几层,期末场景下这个方案比从零训练稳定得多。
提示:验证集准确率首次超过 70% 时,立刻把最好那轮的权重单独保存一份,很多项目后面越调越差,最后才发现最好的结果出现在训练中段。
4.2 现象:训练到一半进程被杀,报错信息里有Killed或者Out of Memory
原因:flow_from_directory虽然按 batch 读取,但如果运行脚本的机器内存本来就不大(比如 8GB 的 Windows 笔记本),数据增强时rotation_range这类变换会把每个 batch 的临时张量放大若干倍,加上多个 callback 都要在 epoch 结束时做验证集预测,峰值内存就爆掉了。
解决:把BATCH_SIZE从 64 降到 32,多数情况能直接解决。再不行就把validation_freq调大,让验证不是每个 epoch 都做;验证集预测本身是在整个验证集上跑一遍模型,内存占用是训练单 batch 的十几倍。另外要注意 Windows 上model.save()有时候会触发系统临时文件写满,确保model/目录下没有残留的.h5临时文件堆积。
还有一条隐藏规则:不要在 Anaconda Prompt 里跑完训练立刻用同一个进程加载模型做推理,Keras 的显存分配策略会占住显卡不释放,推理端和训练端放到两个进程里跑更稳。
4.3 现象:准确率虚高,分类报告里某几个类别准确率接近 100%,另外几个类几乎全错
原因:类别不平衡。这套项目的七类表情里,Happy 和 Neutral 通常占绝大多数,Fear 和 Disgust 样本明显偏少。模型学到的最优策略是“把所有样本都预测为 Happy”,整体准确率照样能到 70% 以上,但答辩时一看混淆矩阵就穿帮。
解决:先看train_generator.class_indices确认每个类别的样本数,然后做类别加权。Keras 里最简单的方式是计算一个class_weight字典传给model.fit():样本多的类别权重调低(比如 0.5),样本少的类别权重调高到 2~3。计算权重可以直接复用 Python 的collections.Counter统计样本数量,然后用total_samples / (num_classes * class_samples[cls])得到每个类别的权重,这是标准做法。
4.4 现象:测试阶段加载模型后,所有输入图片都预测为同一个类别
原因:权重文件没保存对。常见的有两种:一是在model.fit结束之后又跑了新的代码,模型结构被重新初始化,而.h5文件里存的是重新初始化后的随机权重;二是load_model加载时没有compile标志,导致优化器状态缺失,预测阶段仍能跑,但输出分布异常。
解决:训练代码里best_model.h5必须是通过ModelCheckpoint在训练过程中保存的,而不是训练完手动调model.save()。如果确定权重没问题,那就检查输入预处理是否一致——训练时用的是rescale=1.0/255.0,测试时单独写脚本加载图片经常会漏掉除以 255,或者图片读取后是[0, 255]范围、模型的输入期望[0, 1],这种不匹配的表现就是预测概率输出几乎均匀分布在所有类别上。把测试脚本里的图片预处理改成和训练生成器完全一致:灰度、尺寸 48x48、除以 255。
5. 把模型跑进摄像头:test.py 实测、混淆矩阵与进阶调优
项目里test.py和video/目录支撑的是推理演示部分。测试流程分两步:先用haarcascade_frontalface_default.xml做人脸检测,把画面里的人脸区域框出来并裁成正方形,再缩放成 48x48 的灰度图送入模型,模型输出七类概率,取最大值对应的索引查class_indices.json得到表情名称,最后用 OpenCV 把名字画在框上方。
核心代码逻辑是这样的:
import cv2 import numpy as np from tensorflow.keras.models import load_model model = load_model('model/best_model.h5') face_cascade = cv2.CascadeClassifier('haarcascade_frontalface_default.xml') def predict_expression(face_roi): # 人脸区域缩放为 48x48 灰度图,归一化到 [0,1],加 batch 维 face_roi = cv2.cvtColor(face_roi, cv2.COLOR_BGR2GRAY) face_roi = cv2.resize(face_roi, (48, 48)) face_roi = face_roi.astype('float32') / 255.0 face_roi = np.expand_dims(face_roi, axis=0) face_roi = np.expand_dims(face_roi, axis=-1) probs = model.predict(face_roi, verbose=0) idx = np.argmax(probs) return idx, round(float(probs[0][idx]), 3)np.expand_dims的两步操作分别加的 batch 维和通道维,最终张量形状是(1, 48, 48, 1)。这里最容易写错的就是通道维的位置,(1, 48, 48, 3)和(1, 48, 48, 1)差一个数字,模型加载后预测出的索引完全不同。face_cascade用的是 OpenCV 自带的正脸检测器,对侧脸和低头检测效果弱一些,但如果要演示摄像头实时表情识别,它是最省事的方案,检测速度比 MTCNN 快一倍以上,CPU 上也能跑 15 帧左右。
实时演示要提前准备的几件事:摄像头要能正常出图,运行前检查cv2.VideoCapture(0)是否成功;video/目录里的测试视频可以直接用来录屏演示,避免现场摄像头光线不好影响效果;jntm.mp4是一段录好的测试视频,代码里加个参数切换视频源就行。
进阶调优方面,cousion_matrix.py这个脚本值得单独跑一遍,它会输出七类表情的混淆矩阵热图,训练结束后第一件事就是看这个图,判断哪些类别容易被混淆。常见的混淆对是 Fear 和 Surprise——两者都有嘴巴张大的特征;Sad 和 Neutral 容易混淆——平静和悲伤的嘴角弧度在 48x48 的小图上区分度很低。
想进一步提准确率,优先级最高的改动是替换人脸检测器:Haar Cascade 的边框经常不在包含前额和下巴的合适位置,导致裁掉关键特征区域,改成 Dlib 或 MediaPipe 的 68 点人脸关键点对齐,让眼睛位置对准归一化坐标,再送进模型,通常能涨 3~5 个百分点。其次是训练阶段把输入尺寸从 48 改成 64,用相同模型结构重训一遍,表情的细节纹理在小图上丢失太多了。
这套项目作为期末作业来说,结构完整性已经覆盖了数据、训练、评估、推理四个环节,我能给的建议是:拿到资源后先不要急着跑完整训练,先跑test.py加载现成的模型权重看推理效果,确认环境没问题后再从头训练,这样万一训练翻车至少还有一份可用的演示权重保底。我之前接过一个大作业项目,学生把训练脚本和测试脚本放在同一个进程里连续跑,结果训练结束自动保存的模型被model.py重新 import 时覆盖初始化了,演示现场模型变成一个随机权重版本——后来又写了 5 个文件用的都是from tensorflow.keras.models import load_model而不是from model import build_resnet,血泪教训。从那以后我每次跑这类项目都强制把训练流程和推理流程拆到两个独立脚本里,训练保存路径和测试加载路径写成两个变量,训练完先看混淆矩阵再决定要不要动权重。希望帮到你。
本文还有配套的精品资源,点击获取