
简介一份面向Python深度学习初学者的期末大作业资源基于ResNet实现人脸表情识别覆盖图像预处理、残差网络建模、数据集准备、模型训练与评估等完整流程。资源共16个文件以model.py、train.py、test.py三个脚本为核心分别负责网络结构定义、训练过程和模型测试另有7张Angry、Happy、Sad等不同表情示例图、混淆矩阵与训练曲线可视化代码、requirements.txt依赖清单、XML人脸检测器、JSON类别映射文件以及一段演示视频压缩包整体约5.2MB整体涵盖训练数据样例、模型定义、测试脚本与演示视频。目前已有235人学习下载适合高校学生完成课程设计或快速入门卷积神经网络。除主要代码外资源还包含项目说明文档和模型权重文件支持直接加载模型进行推理也可以通过可视化结果分析各类别识别效果是系统掌握残差网络在图像分类中应用的实用参考资料。1. 一个能跑的ResNet人脸表情识别项目期末作业里少见的全链路demo拿到“python期末大作业基于ResNet的人脸表情识别.zip”这个压缩包时我第一反应是这不像一份糊弄事的作业。里面不只有model.py和train.py还有一套完整的七分类表情识别链路OpenCV先用haarcascade把人脸从画面里框出来ResNet再判断这张脸是Angry、Disgust、Fear、Happy、Neutral、Sad还是Surprise。对正在做python期末大作业的人来说把这个项目跑通一遍等于把深度学习图像分类里“数据准备、模型搭建、训练调参、评估可视化、视频实测”五件事全接触了一遍。对于已经入行的python开发它也是一个拿来就能改的基线工程换数据集就能迁移到其他细粒度图像分类任务上。2. 项目文件拆解与数据准备七分类任务的完整骨架2.1 压缩包里每件东西的职责对照拿到zip先别急着跑把每个文件对应的角色弄清楚后面改代码才不迷路。这个项目的文件组织在同类作业里算规整的模型定义、测试脚本、类别映射、人脸检测器各司其职。文件/目录职责说明qqemo/七张表情示例图Angry、Disgust、Fear、Happy、Neutral、Sad、Surprise各一张用于快速验证dataset/训练与验证数据按类别分目录存放是训练脚本的数据来源model.pyResNet模型定义封装了残差网络结构对外提供可实例化的模型接口cousion_matrix.py / cousion_matrix_plot混淆矩阵计算与可视化评估模型在每个类别上的错分情况class_indices.json类别索引映射记录“类别名→数字标签”的对应关系haarcascade_frontalface_default.xml人脸检测器OpenCV级联分类器先框出人脸再交给ResNetmodel/模型权重文件训练好的权重test.py直接加载做推理requirements.txt依赖清单一次性装齐OpenCV、PyTorch等库test.py测试脚本支持单张图片和视频两种输入video/jntm.mp4视频测试样本测试视频识别流程是否顺畅这份清单透露出的设计思路很清晰人脸检测和表情分类是两段独立任务分别用传统CV和深度学习方案完成。实际工程里这么做的好处是解耦——检测器换成MTCNN或者YOLO-face分类器完全不用动。对期末作业来说这种“传统算法深度模型”的组合也容易讲清楚创新点在哪。2.2 类别映射与目录结构label是怎么对上的表情识别本质是图像分类模型输出的是数字标签必须有一个地方把0到6映射回“Angry、Disgust”这些可读名字。这个项目用class_indices.json来干这件事打开后结构大致是这样{ Angry: 0, Disgust: 1, Fear: 2, Happy: 3, Neutral: 4, Sad: 5, Surprise: 6 }读取它并反转成“索引→标签”的映射是测试阶段必须做的一步import json with open(class_indices.json, r, encodingutf-8) as f: class_indices json.load(f) # 反转为 {0: Angry, 1: Disgust, ...} idx_to_label {v: k for k, v in class_indices.items()} print(idx_to_label[3]) # 输出 Happy这段反转逻辑看起来简单但实际很容易漏。模型推理返回的是torch.argmax得到的最大概率索引如果不做反转直接打印数字使用者根本不知道预测结果对应什么表情。我习惯把idx_to_label放在test.py的全局位置加载模型权重之后紧接着初始化它这样后面所有预测结果都能直接转成可读文字。dataset目录的常规组织方式是train和val两个大目录下面再按七个类别各建一个子文件夹。PyTorch的ImageFolder写法能直接按目录名生成标签所以dataset/val/Angry里的图片会被自动标注为类别Angry。如果拿到手的dataset不是这个结构需要先写一个整理脚本把它归好类否则训练脚本会报错或者类别数对不上。2.3 人脸检测与预处理为什么需要两段式流程表情识别不能把整张图直接塞给ResNet因为背景噪声会严重干扰分类结果。这个项目用OpenCV的级联分类器先定位人脸再做裁剪和缩放把标准化的人脸区域交给模型。这种两段式设计是实际工程里最常见的方案。import cv2 face_cascade cv2.CascadeClassifier(haarcascade_frontalface_default.xml) img cv2.imread(qqemo/Happy.png) gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) faces face_cascade.detectMultiScale( gray, scaleFactor1.1, minNeighbors5, minSize(64, 64) ) for (x, y, w, h) in faces: face img[y:yh, x:xw] face cv2.resize(face, (224, 224))detectMultiScale的四个参数影响检测质量scaleFactor是每次缩放的比例1.1表示每次缩小10%越小检测越慢但召回越高minNeighbors是候选框需要被多少个邻近框确认才保留值越大误检越少但可能漏检minSize设置最小人脸尺寸这里设64x64是为了过滤掉太小的误检区域。实际用的时候如果发现视频里远处的人脸检不出来把minSize调小如果发现背景被频繁误检成人脸把minNeighbors调大。这套参数在不同光照和摄像头角度下都需要微调没有一个万能值。人脸裁剪出来之后还要做和训练时一致的预处理一般包括缩放到ResNet期望的224x224、转成RGB、归一化到0到1区间。如果预处理不一致哪怕模型权重是好的推断结果也会莫名其妙地偏到某个类别上这类问题排查起来特别耗时。3. 模型与训练model.py里的ResNet实现细节与参数调优3.1 残差块为什么能解决梯度消失ResNet在表情识别里表现好的核心原因是残差连接。普通卷积网络在层数加深时反向传播的梯度逐层相乘越来越小前面几层几乎学不到东西训练误差反而随深度增加而上升。残差块让输入x绕过两层卷积直接加到输出上网络只需要学习“残差部分”F(x)即x到目标映射之间的差值即使F(x)学不到东西恒等映射也能让梯度顺利回传。import torch.nn as nn class BasicBlock(nn.Module): def __init__(self, in_channels, out_channels, stride1): super().__init__() self.conv1 nn.Conv2d(in_channels, out_channels, kernel_size3, stridestride, padding1, biasFalse) self.bn1 nn.BatchNorm2d(out_channels) self.conv2 nn.Conv2d(out_channels, out_channels, kernel_size3, stride1, padding1, biasFalse) self.bn2 nn.BatchNorm2d(out_channels) self.shortcut nn.Sequential() if stride ! 1 or in_channels ! out_channels: self.shortcut nn.Sequential( nn.Conv2d(in_channels, out_channels, kernel_size1, stridestride, biasFalse), nn.BatchNorm2d(out_channels) ) def forward(self, x): identity self.shortcut(x) out nn.ReLU(inplaceTrue)(self.bn1(self.conv1(x))) out self.bn2(self.conv2(out)) out identity return nn.ReLU(inplaceTrue)(out)这个BasicBlock对应ResNet18和ResNet34使用的残差单元。注意最后一个加法操作先执行out identity再统一做ReLU这比先ReLU再加恒等映射更好实验证明能提升训练稳定性。shortcut分支只在输入输出通道数不一致或者空间尺寸减半时启用用1x1卷积完成维度对齐。在model.py里改造这个BasicBlock时可以改动两个关键点一是把conv1的stride参数暴露出来用于下采样二是把ReLU替换成其他激活函数做对比实验。常见做法是在期末作业里直接复用PyTorch官方torchvision的ResNet实现但自己手写一遍BasicBlock更容易应付答辩时的提问。3.2 预训练权重加载与最后一层替换表情识别数据集通常只有几万张图从头训练ResNet很容易过拟合。这个项目的常见做法是加载ImageNet预训练权重只替换全连接层输出为7类然后微调。原理是预训练模型已经学到了边缘、纹理、形状等通用视觉特征表情识别需要的特征在这之上做迁移即可。import torchvision.models as models import torch.nn as nn model models.resnet18(weightsmodels.ResNet18_Weights.IMAGENET1K_V1) num_features model.fc.in_features model.fc nn.Linear(num_features, 7)替换fc层之后模型最后输出的logits维度从1000变成7。这里有一个容易被新手忽略的细节加载预训练权重后新替换的fc层是随机初始化的而前面所有层的参数都带有ImageNet的语义信息。训练时通常给fc层设较大学习率给卷积层设较小学习率或者冻住前几层避免随机初始化的层把预训练特征冲坏。做法是把参数分组传入优化器optimizer torch.optim.Adam([ {params: model.conv1.parameters(), lr: 1e-4}, {params: model.bn1.parameters(), lr: 1e-4}, {params: model.layer1.parameters(), lr: 1e-4}, {params: model.layer2.parameters(), lr: 1e-4}, {params: model.layer3.parameters(), lr: 1e-4}, {params: model.layer4.parameters(), lr: 1e-4}, {params: model.fc.parameters(), lr: 1e-3}, ], weight_decay5e-4)前几层卷积学习率设成10的负4次方fc层用10的负3次方整体差10倍。这样做的原因是fc层从零开始学需要更大的更新步长卷积层已经在ImageNet上收敛过了只需要微调不要猛拉。如果机器的显存有限一个更省显存的办法是把conv1和bn1的requires_grad设为False让它彻底冻住只更新后面的层。3.3 损失函数、数据增强与训练主循环的常见配置七分类任务用交叉熵损失函数这是图像分类的默认选择。交叉熵对错误预测的惩罚是logarithmic的预测概率越低惩罚越大配合softmax输出天然适合多分类。优化器用Adam还是SGD在这个项目里都能收敛但如果追求期末答辩时拿出的曲线好看Adam配合StepLR学习率调度是省心组合。import torch criterion nn.CrossEntropyLoss() optimizer torch.optim.Adam(model.parameters(), lr1e-3) scheduler torch.optim.lr_scheduler.StepLR(optimizer, step_size10, gamma0.1) for epoch in range(30): model.train() running_loss 0.0 for inputs, labels in train_loader: optimizer.zero_grad() outputs model(inputs) loss criterion(outputs, labels) loss.backward() optimizer.step() running_loss loss.item() * inputs.size(0) scheduler.step() val_acc evaluate(model, val_loader) print(fEpoch {epoch1}, Loss: {running_loss/len(train_loader.dataset):.4f}, Val Acc: {val_acc:.4f})训练循环里有几个值得注意的参数step_size设为10表示每10个epoch学习率衰减到十分之一gamma设为0.1即乘以0.1。如果你的数据集较小30个epoch可能已经过拟合观察验证集准确率曲线如果连续5个epoch不再上升就可以early stopping。数据增强方面随机水平翻转、随机旋转正负15度、随机裁剪后缩放到224x224是表情识别里既简单又有效的三件套。像AffectNet这种带标注的数据集还可能做颜色抖动但Fer2013灰度图为主颜色抖动作用不大。训练时的batch size也直接影响收敛。常见做法是batch size设32或64如果显存不够就降到16。表情识别任务类别相对均衡时不需要特殊处理但如果发现某一类样本特别少可以给每个类别设置权重传给CrossEntropyLoss的weight参数让稀少类别的误判代价更高。4. 评估与避坑混淆矩阵与四个翻车现场4.1 混淆矩阵把七分类的错乱关系看得明明白白训练完模型不能只看准确率因为表情识别里Happy和Neutral之间、Fear和Surprise之间本来就容易混淆。混淆矩阵能展示每个真实类别被预测成了哪个类别对角线上的数字越大说明分类越可靠对角线以外的数字聚集处就是模型最容易犯错的地方。这个项目的cousion_matrix.py做的就是这个工作。from sklearn.metrics import confusion_matrix import seaborn as sns import matplotlib.pyplot as plt y_true, y_pred [], [] model.eval() with torch.no_grad(): for inputs, labels in val_loader: outputs model(inputs) _, preds torch.max(outputs, dim1) y_true.extend(labels.numpy()) y_pred.extend(preds.numpy()) cm confusion_matrix(y_true, y_pred) sns.heatmap(cm, annotTrue, fmtd, xticklabelslist(idx_to_label.values()), yticklabelslist(idx_to_label.values())) plt.xlabel(Predicted) plt.ylabel(True) plt.savefig(confusion_matrix.png, dpi150)带annotTrue和fmtd能让每个格子显示具体数量而不是一片颜色深浅。xticklabels和yticklabels传入七个表情名比默认的数字坐标直观得多。画出来之后重点看两类一是非对角线的高亮区域比如Fear被预测成Surprise有大量样本说明这两个类别在模型眼里特征接近二是某一整行几乎没有命中说明对应类别训练样本严重不足。4.2 四个容易翻车的细节现象、原因、解决踩坑记录一cv2.imread读取图片返回None。现象是图片路径检查无误但img的内容是None运行到cvtColor直接抛错。原因大概率是文件路径包含中文或者文件夹名带空格OpenCV的imread底层对中文路径兼容性差。解决方式有两种一个是把路径改成纯英文并保证不包含特殊字符另一个是用cv2.imdecode配合np.fromfile读取import numpy as np import cv2 data np.fromfile(数据集/测试图.png, dtypenp.uint8) img cv2.imdecode(data, cv2.IMREAD_COLOR)imdecode方式能绕开imread对中文路径的解析问题但要注意返回值也可能是None需要加个判断逻辑。踩坑记录二输入尺寸和预训练权重要求的尺寸不一致。现象是模型推理不报错但准确率几乎等于随机猜测。原因是ResNet在ImageNet预训练时输入是224x224如果人脸裁剪后resize成48x48直接送进去模型的全局池化层虽然能处理任意尺寸但特征分布已经完全偏离预训练时的尺度。解决方法是统一在数据加载和测试脚本里都先resize到224x224再做ImageNet通道的标准化。这个标准化步骤很多人忘记直接除以255就完事也会导致类似问题。踩坑记录三验证集和训练集混在一起准确率虚高。现象是训练时验证准确率轻松超过0.95但测试新图片效果很差。原因多半是数据准备阶段没有按文件夹严格切分同一张人脸的多个帧同时出现在训练集和验证集里模型其实是靠记忆而不是泛化。解决方法是确认dataset下train和val目录物理隔离并且按人脸ID划分而不是按图片文件划分避免同一个人的不同表情帧泄漏。表情识别比赛里常见做法是用留出法按subject划分保证同一人只出现在一边。踩坑记录四class_indices.json和模型输出顺序错位。现象是predict返回0到6的数字但映射到标签后总差一位。原因是训练时用的类别顺序和测试时读取的json不一致比如训练脚本内sorted的字典顺序和手动写死的json顺序不同。解决方法是每次训练完都从数据集的class_to_idx重新生成class_indices.json不要手写也不要复制旧文件。在test.py里加载模型后先打印一遍idx_to_label确认七类顺序再开始预测。5. test.py实战从单张图片到视频流识别的提速习惯5.1 单张图片识别的完整调用测试脚本的核心流程是加载模型、读取图片、人脸检测、预处理、推理、输出标签。建议把检测和预处理封装成两个独立函数方便在图片和视频两条路径里复用。import torch import cv2 model.eval() img cv2.imread(qqemo/Angry.png) gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) faces face_cascade.detectMultiScale(gray, 1.1, 5, minSize(64, 64)) for (x, y, w, h) in faces: face img[y:yh, x:xw] face cv2.cvtColor(face, cv2.COLOR_BGR2RGB) face cv2.resize(face, (224, 224)) face_tensor transform(face).unsqueeze(0) with torch.no_grad(): outputs model(face_tensor) pred torch.argmax(outputs, dim1).item() print(fPredicted: {idx_to_label[pred]})torch.no_grad()在推理时一定要加省显存且加速。unsqueeze(0)是把单张图补成batch维度因为模型期望输入形状是(1, 3, 224, 224)。5.2 视频识别提速跳帧检测比逐帧识别更实用视频识别最直接的写法是逐帧读取、逐帧检测但实际跑起来会发现视频中人脸位置变化是连续的没必要每帧都做人脸检测。一个有效且容易答辩讲清楚的优化是每三帧才做一次完整检测中间帧直接复用上一帧的人脸坐标只用ResNet做分类。这样整体耗时能下降一半以上。从那以后我每次做视频类识别任务都强制先把检测频率和分类频率分开设计检测可以慢分类必须跟上帧率。对于这类期末作业能跑通、能讲清楚、还能说出一个优化点就足够站稳了。希望这个项目的拆解思路帮到你动手跑一遍比读十遍说明书都管用。本文还有配套的精品资源点击获取