
简介针对Python期末大作业与课程设计场景这份资源提供了一套基于ResNet的人脸表情识别完整项目由源码、数据集和模型共同组成。代码注释清晰、部署简单系统功能完善、操作简单且界面直观适合需要快速获得高分方案或初步接触深度学习项目的开发者参考。压缩包共16个文件、约5.2MB以Python脚本、七类表情示例图、说明文档和演示视频为主脚本覆盖模型定义、混淆矩阵分析与测试推理图片对应Happy、Sad、Angry等常见表情另有依赖配置、类别映射和Haar级联人脸检测器等辅助文件便于直接运行或二次修改。已有190人学习。借助这份资源可以系统了解ResNet在表情识别任务中的数据处理、模型构建、混淆矩阵分析与评估流程同时获得可直接运行的期末大作业方案快速验证模型效果有效减少从零搭建和调试的时间。1. 基于ResNet的人脸表情识别期末大作业为什么照着就能跑通每年到期末季总能看到有人抱着“基于ResNet的人脸表情识别”这类题目在群里问模型怎么搭、数据集去哪找、训练出来准确率为什么只有六成。这份资源我拆完之后的感觉是它把三个最容易卡住期末大作业的环节都提前处理好了带注释的ResNet模型代码、整理好的7类表情数据集、以及训练评估和图片检测的完整脚本。也就是说你不用从零写网络结构不用自己爬图做标签解压之后按顺序跑训练和测试就能产出一个能演示的完整项目。它的适用人群很明确正在做Python期末大作业或课程设计的学生以及对CNN分类还停留在“能用就行”阶段的入门者。项目核心代码有注释数据规模也控制在普通笔记本电脑能跑的范围。这篇笔记我会按“数据集结构 → 模型实现 → 训练评估 → 检测推理 → 踩坑记录 → 提分技巧”的顺序拆每一步都给出实际可抄的代码和参数说明。2. 先摸清工程结构数据集怎么组织、依赖装到什么程度2.1 压缩包内的文件布局每个文件是干什么的拿到压缩包后先不要急着运行把根目录过一遍。这个工程的根目录下有model.py、test.py、requirements.txt这几个直接相关的文件还有dataset文件夹、class_indices.json、cousion_matrix_plot文件夹和haarcascade_frontalface_default.xml。model.py定义的是ResNet网络结构test.py是推理脚本cousion_matrix.py在cousion_matrix_plot目录里用来画混淆矩阵。dataset目录里能看到Angry.png、Disgust.png、Surprise.png这一组表情缩略图它们是对应类别的示意样本方便你直观确认标签和图片内容是否对齐。真正参与训练的图片应该在dataset的子目录里按表情类别分文件夹存放。class_indices.json记录的是类别名到数字标签的映射训练时用数字标签预测时靠它把数字翻译回表情名称。解压之后我建议你先把README.md从头看一遍特别是数据集目录结构说明和运行顺序。常见做法是先用tree命令或直接在文件管理器里确认 dataset 下有几个子文件夹正常应该是7个分别对应 Anger、Disgust、Fear、Happy、Neutral、Sad、Surprise这也是表情识别最常用的标签体系。# 在工程根目录执行查看 dataset 下的类别子目录 tree dataset -d # 预期输出7个子目录每个对应一个表情类别这里tree -d只显示目录不列出文件目的是快速确认类别数对不对。如果发现少于7个文件夹说明数据集在拷贝过程中丢了子目录那后续训练会因为找不到某个类别的图片而报错或出现类别数不匹配。接下来读取class_indices.json确认映射关系用一条 Python 命令就能完成import json with open(class_indices.json, r, encodingutf-8) as f: class_indices json.load(f) print(class_indices) # 输出类似 {Angry: 0, Disgust: 1, Fear: 2, Happy: 3, Neutral: 4, Sad: 5, Surprise: 6}这个文件的作用不只是给你看训练脚本和数据加载器也要依赖这个映射。我一般会先跑这一段确认编码没问题因为如果你在 Windows 下用记事本改过这个 JSON很可能变成 GBK 编码后面json.load直接抛UnicodeDecodeError。2.2 requirements.txt 与运行环境版本卡在哪几个包requirements.txt列出了项目依赖核心是torch、torchvision、opencv-python、numpy、matplotlib、scikit-learn这些。装环境时最容易翻车的不是 torch 装不上而是 torch 和 torchvision 版本不匹配导致torchvision.models.resnet50导入失败。建议直接用带 CUDA 的版本对应关系安装哪怕是 CPU 版也要保证这两个包来自同一发布版本。# 创建虚拟环境并安装依赖CPU 版示例 python -m venv venv source venv/bin/activate # Windows 下是 venv\Scripts\activate pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu pip install -r requirements.txt思路是先装 torch 系再装 requirements 里剩余项避免 pip 解析依赖时把 torch 又换成一个不兼容版本。如果你本机没有 NVIDIA GPU不要碰nvidia相关后缀的安装命令直接 CPU 版就够这个数据集规模用。这里有一条实际经验opencv-python的版本不要追求最新4.5 到 4.8 之间都稳定。新版 OpenCV 在某些平台上对cv2.imshow的 GUI 支持有变动而test.py里要画人脸框和显示结果GUI 出问题会直接影响演示效果。3. 拆 model.pyResNet 的残差块和 7 分类输出怎么改3.1 残差块实现shortcut 分支怎么处理通道数变化model.py里实现的不是 torchvision 里那个现成的resnet50而是把核心残差块逻辑铺开写的方便你答辩时讲清楚结构。一个标准的残差块包含两个卷积层输入先过conv1 → bn1 → relu → conv2 → bn2然后和 shortcut 分支相加最后过一个 relu。关键点在 shortcut当输入输出通道数不一致时需要加一个1x1卷积来对齐维度。import torch.nn as nn import torch.nn.functional as F class BasicBlock(nn.Module): def __init__(self, in_channels, out_channels, stride1): super(BasicBlock, self).__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) # 通道数不变时直接用恒等映射变化时用 1x1 卷积对齐 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 F.relu(self.bn1(self.conv1(x))) out self.bn2(self.conv2(out)) out identity return F.relu(out)in_channels是上一层传进来的通道数out_channels是本层要输出的通道数。stride1时特征图尺寸不变stride2时特征图尺寸减半此时 shortcut 也必须做步长为 2 的卷积否则相加时尺寸对不上。这段代码里唯一需要你理解透的就是 shortcut 分支答辩老师最爱问“为什么残差能缓解梯度消失”答案就在于out identity这条路径让梯度可以原路回流。3.2 输出层的改动从 1000 类到 7 类原版 ResNet 最后是一个 1000 类的全连接层用于 ImageNet 分类。表情识别任务只有 7 类所以代码把最后的fc改成nn.Linear(512, 7)。这还不是最关键的更重要的是前面的conv1是否针对小尺寸人脸图做了调整。如果你用的是 224x224 输入conv1保持7x7卷积没问题但如果你想把训练加速把输入缩到 64x64 或 48x48通常会把conv1的步长改成 1并去掉第一个maxpool。class ResNet(nn.Module): def __init__(self, num_classes7): super(ResNet, self).__init__() self.conv1 nn.Conv2d(3, 64, kernel_size7, stride2, padding3, biasFalse) self.bn1 nn.BatchNorm2d(64) self.relu nn.ReLU(inplaceTrue) self.maxpool nn.MaxPool2d(kernel_size3, stride2, padding1) # 按 ResNet18/34 的层数配置堆叠残差块 self.layer1 self._make_layer(64, 64, 2, stride1) self.layer2 self._make_layer(64, 128, 2, stride2) self.layer3 self._make_layer(128, 256, 2, stride2) self.layer4 self._make_layer(256, 512, 2, stride2) self.avgpool nn.AdaptiveAvgPool2d((1, 1)) self.fc nn.Linear(512, num_classes) def _make_layer(self, in_channels, out_channels, blocks, stride): layers [] layers.append(BasicBlock(in_channels, out_channels, stride)) for _ in range(1, blocks): layers.append(BasicBlock(out_channels, out_channels, stride1)) return nn.Sequential(*layers)这里的num_classes7就是改动的落点。AdaptiveAvgPool2d((1, 1))的作用是把任意尺寸的特征图压成 1x1这样全连接层的输入维度就固定为 512不会因为输入图片尺寸变化而报错。实际用的时候这个网络和torchvision.models.resnet18在结构上是等价的只是便于你逐层讲解。4. 训练与评估epoch、batch_size、学习率怎么设混淆矩阵怎么看4.1 训练脚本的核心循环跑通一个 epoch 要改哪些参数训练脚本的流程是常规的 PyTorch 套路读数据 → 归一化 → 进模型 → 算交叉熵损失 → 反向传播 → 更新权重 → 记录准确率。期末大作业场景下我不建议你追求 state-of-the-art 准确率更关键的是让训练过程稳定收敛能在笔记本 CPU 或低端 GPU 上跑完几个 epoch 并看到 loss 下降。import torch import torch.nn as nn import torch.optim as optim # 常见配置epoch 10~20batch 32~64初始学习率 1e-3 model ResNet(num_classes7) criterion nn.CrossEntropyLoss() optimizer optim.Adam(model.parameters(), lr1e-3) for epoch in range(10): model.train() running_loss 0.0 for images, labels in train_loader: images images / 255.0 # 如果数据加载时没归一化这里手动缩放到 [0, 1] optimizer.zero_grad() outputs model(images) loss criterion(outputs, labels) loss.backward() optimizer.step() running_loss loss.item() print(fEpoch {epoch1}/10, Loss: {running_loss/len(train_loader):.4f})batch_size的取值直接影响显存占用和收敛稳定性。显存不够就调小到 16 或 8但不要小于 8否则 BatchNorm 的统计量会很不稳定loss 会出现明显抖动。Adam的默认学习率 1e-3 对这个小数据集是安全起点如果 loss 震荡厉害降到 1e-4。这里没有手工实现学习率衰减因为 epoch 数少衰减的价值体现不出来。如果你解压后发现训练脚本里已经写好了train_loader的定义我建议你检查一下里面有没有做标准化。很多入门代码只做ToTensor()不做均值方差归一化这种写法在浅层网络上影响不大但 ResNet 的 BatchNorm 层对输入分布有一定预期标准化之后训练会明显更稳。4.2 混淆矩阵怎么读哪两个类容易被认混cousion_matrix.py做的事情是在训练结束后把测试集所有图片过一遍模型统计真实标签和预测标签的交叉情况画出一张 7x7 的矩阵图。期末答辩时这张图比准确率数字更有说服力因为它能直接暴露模型在哪两个类别上分不清。import numpy as np import matplotlib.pyplot as plt from sklearn.metrics import confusion_matrix, ConfusionMatrixDisplay # 假设 test_loader 已经定义好y_true 和 y_pred 已收集完成 cm confusion_matrix(y_true, y_pred) disp ConfusionMatrixDisplay(confusion_matrixcm, display_labelslist(class_indices.keys())) disp.plot(cmapBlues, xticks_rotation45) plt.title(Emotion Classification Confusion Matrix) plt.tight_layout() plt.savefig(confusion_matrix.png, dpi200)ConfusionMatrixDisplay会把每个格子填上计数颜色越深代表数量越多。对角线上的数字越大说明分类越准确如果Angry和Disgust这两个类在非对角线位置出现大量计数说明模型学到的特征没能区分这两类究其原因通常是训练集中这两个类的样本量存在明显差距或者是图片里人脸角度差异太大。拿到这张图后你可以针对混淆最严重的两个类去查看对应样本这比盲目调参更有效。5. test.py 的检测流程和五个高频翻车点5.1 从人脸框到表情标签Haar 级联器加 ResNet 的分类链路test.py做的是两阶段任务先用 OpenCV 的 Haar 级联器把图片里的人脸框出来再把框出的区域缩放成 ResNet 的输入尺寸送入网络得到 7 类的概率分布最后取概率最大的类作为结果。haarcascade_frontalface_default.xml就是前阶段用到的级联分类器模型文件。import cv2 import torch import numpy as np from model import ResNet # 加载级联器与训练好的模型权重 face_cascade cv2.CascadeClassifier(haarcascade_frontalface_default.xml) model ResNet(num_classes7) model.load_state_dict(torch.load(model.pth, map_locationcpu)) model.eval() img cv2.imread(test.jpg) gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) faces face_cascade.detectMultiScale(gray, scaleFactor1.1, minNeighbors5, minSize(48, 48)) for (x, y, w, h) in faces: face img[y:yh, x:xw] face cv2.resize(face, (224, 224)) face cv2.cvtColor(face, cv2.COLOR_BGR2RGB) face face / 255.0 tensor torch.tensor(face, dtypetorch.float32).permute(2, 0, 1).unsqueeze(0) with torch.no_grad(): logits model(tensor) pred_idx torch.argmax(logits, dim1).item() label list(class_indices.keys())[pred_idx] cv2.rectangle(img, (x, y), (xw, yh), (0, 255, 0), 2) cv2.putText(img, label, (x, y-10), cv2.FONT_HERSHEY_SIMPLEX, 0.9, (0, 255, 0), 2)scaleFactor1.1表示每次检测窗口缩放 10%这个值越小检测越慢但越准。minNeighbors5控制误检率值越大越严格。minSize(48, 48)过滤掉太小的检测框因为小于 48 像素的人脸送到 224x224 的网络里基本都是噪声。整个链路里最容易出错的是face img[y:yh, x:xw]这一步如果拿到的faces坐标不是整数或者人脸在图片边缘出界切片会报错或给出空图。5.2 避坑清单这五个坑我全踩过你照着避现象 1训练 loss 一直不降卡在 1.9 左右不动。原因数据集标签和模型输出类别没有对齐比如class_indices.json里是 7 类但你加载的数据集目录里其实只有 6 类子文件夹导致 DataLoader 的类别索引错位。解决训练前先统计train_loader.dataset.classes的长度和内容确认和class_indices一致。现象 2训练时 loss 直接变成 NaN。原因学习率偏大加上输入图片没有归一化数值不稳定。解决把初始学习率降到 3e-4并在进入模型前明确做images images.float() / 255.0。还有一种可能是有损图片文件混在数据集里cv2.imread读出来是None转 tensor 时出现空值解决方法是把读取函数改成对失败图片跳过的逻辑。现象 3cv2.imread读中文路径图片返回 None。原因OpenCV 的imread不支持非 ASCII 路径。解决用cv2.imdecode(np.fromfile(path, dtypenp.uint8), cv2.IMREAD_COLOR)替代。这个坑在期末答辩现场最容易出现因为老师测试你自己的演示图片时很可能把图放在中文路径下的桌面上。现象 4加载模型时报Missing key(s) in state_dict。原因直接torch.load拿到的只是权重字典但模型结构里如果有module.前缀用nn.DataParallel训练过key 就对不上。解决用state_dict torch.load(model.pth, map_locationcpu)然后state_dict {k.replace(module., ): v for k, v in state_dict.items()}去掉前缀再load_state_dict。现象 5CPU 上跑test.py对视频帧推理特别慢只有一两帧。原因每帧都做一次完整前向计算没有做帧间隔采样。解决每 3 帧或每 5 帧检测一次中间帧沿用上一个检测结果。压缩包里的video目录和jntm.mp4就是给你试这个场景的第一次运行先拿短视频测不要直接上长视频。6. 分数再往上提一步数据增强和置信度过滤两个小技巧6.1 数据增强一行代码把样本量翻三倍期末项目的提分空间往往不在网络结构而在数据增强。原项目如果没做增强你可以用torchvision.transforms构造一组轻量变换随机水平翻转、随机小角度旋转、随机亮度抖动。这三个操作用在学习率不敏感的场景下几乎不会让训练崩溃但能明显降低过拟合。from torchvision import transforms train_transform transforms.Compose([ transforms.RandomHorizontalFlip(p0.5), transforms.RandomRotation(degrees10), transforms.ColorJitter(brightness0.2, contrast0.2), transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ])RandomRotation(degrees10)的旋转角度不要超过 15否则人脸关键区域可能旋转出边界反而引入噪声。ColorJitter只调亮度和对比度不调饱和度和色相因为表情识别对肤色和色相变化敏感调多了会把愤怒和悲伤之间的颜色线索抹掉。Normalize用的均值方差是 ImageNet 标准值如果你不想下载预训练权重用这个数值也足够。做过这个增强之后训练集的实际样本量是原来的 6 倍因为每次 epoch 抽样都会随机生成不同版本的图片。代价是训练时间变长。我一般会先把增强后的数据跑 3 个 epoch对比加增强前的 loss 下降速度。如果增强后 loss 下降明显变慢说明旋转角度太大或者亮度抖动过强减小参数再试。6.2 置信度过滤让模型说“我看不出来”而不是硬猜实际测试时会遇到一种情况视频里人脸角度很偏或者表情本身模糊不清模型仍然会硬输出一个类别而且大概率是错的。与其强行输出不如加一道置信度判断如果最高概率低于阈值就显示“等会确认一下”而不是一个表情标签。这个技巧可以让你的演示过程看起来更可信。with torch.no_grad(): logits model(tensor) prob torch.softmax(logits, dim1) max_prob, pred_idx torch.max(prob, dim1) if max_prob.item() 0.6: label uncertain else: label list(class_indices.keys())[pred_idx.item()]阈值0.6是经验值在 7 分类任务里随机猜的概率约 14%0.6 相当于要求模型有 4 倍以上的确定性才输出结果。如果测试集准确率本身就不高阈值要往下调否则屏幕上全是 uncertain答辩观感反而差。你可以先不加阈值跑一遍测试集统计所有正确样本的最大概率分布在哪个区间再用这个分布来定阈值。我自己的习惯是每次交期末作业前强制走一遍完整流程先看一眼class_indices.json确认映射跑 1 个 epoch 验证 loss 能降再跑完整训练拿到混淆矩阵最后用置信度过滤跑一遍视频。以前有次为了赶时间跳过了混淆矩阵这一步直接提交答辩时老师指着评估图表问“愤怒和厌恶为什么分不清”我当场答不上来。从那以后每次演示我都强制跑一遍cousion_matrix.py把混淆严重的成对类别提前准备好解释。希望这个习惯也能帮到你。本文还有配套的精品资源点击获取