
简介面向Python课程期末大作业的人脸表情识别项目基于ResNet深度学习模型适合高校学生完成图像分类综合实践或毕业设计预研。资源包共103个文件包含19个可运行的Python源码、多个hdf5预训练权重、近50张图片样本、xml配置及说明文档另有mp4/gif演示动画辅助理解整体约54.11MB目录结构便于对照学习。项目源码已经本地调试通过据作者说明评审分达95分以上难度适中内容经助教审定可直接运行验证。已有135人浏览学习适合需要完整参考实现、快速跑通表情识别流程并做二次改进的使用者。获取后可获得完整代码、数据集、说明文档和演示素材覆盖数据加载、模型构建、训练/推理及界面展示等关键环节兼顾学习与复用价值。1. 期末大作业选 ResNet 人脸表情识别为什么它是最稳的起点如果你正在为 Python 期末大作业发愁看到「基于 ResNet 的人脸表情识别」这个标题说明你已经摸到了性价比最高的门路。这门课作业最怕的不是难而是「看起来难、做起来飘」——要么模型太浅识别率上不去要么环境配置复杂到还没开始就劝退。ResNet 恰好卡在中间它比 LeNet、VGG 深但又有现成的预训练模型和大量教程兜底解决了一门课作业最大的痛点——「能跑、能出指标、能写进报告」。这份作业的核心链路其实就三件事人脸检测裁剪、表情分类、训练评估。数据集的公开版本很成熟像 fer2013 和 CK 都是学术界常用的表情基准不用你自己去采集人脸。你只需要把 ResNet 跑通在测试集上拿出一个还说得过去的准确率剩下的就是把数据增强、学习率这些参数调来调去写进实验对比。对这个方向有 Python 基础、懂一点 PyTorch 或 TensorFlow 的同学都能在两周内完成前提是别把精力耗在环境安装和踩坑上——这正是后面几章要给你解决的问题。2. 表情识别任务拆解网络结构、数据集与预处理怎么选2.1 ResNet 残差结构在表情识别里为什么有效人脸表情识别本质上是一个图像分类任务输入是单张人脸图输出是 7 类情绪标签生气、厌恶、恐惧、开心、悲伤、惊讶、中性。这个任务在深度学习早期用 AlexNet、VGG 也能做但问题很明显网络一旦加深梯度消失导致训练集准确率反而下降。ResNet 提出残差连接让网络学习的是「残差 F(x) H(x) - x」而不是直接拟合 H(x)。这样梯度可以通过跳跃连接直接回传到浅层训练 50 层甚至 100 层都不会明显退化。表情识别的特殊性在于人脸表情的差异往往是精细的局部纹理变化比如嘴角上扬、眉头紧锁。浅层网络提取的是边缘、颜色块这类低级特征深层网络才能组合出「嘴型」「眼周纹理」这类高级语义。ResNet 恰好同时保留了浅层的位置信息和深层的语义信息这在表情这种「差之毫厘」的分类任务上很占优势。2.2 数据集选择fer2013 与 CK 的取舍公开数据集里最常用的两个是 fer2013 和 CK。fer2013 有 35887 张 48x48 灰度图分了训练集、公开测试集和私有测试集类别是 7 类优点是数据量大、下载方便很多论文都拿它做基准。它的缺点是标签噪声不小图片质量参差不齐很多人脸的姿势和遮挡比较随意所以上限精度大概在 70% 左右。CK 是实验室环境采集的只有 593 个序列但每张都是正脸、光照均匀标签质量高测试准确率能到 95% 以上。不过它的数据量太小直接训练容易过拟合一般用来做最终验证而不是主训练集。我一般会建议学生主用 fer2013 训练最后拿 CK 做泛化性展示这样报告里可以写「跨数据集测试」——这个亮点很多往届作业都没有。2.3 预处理三步灰度、归一化与数据增强fer2013 本身就是 48x48 的灰度图所以不需要做色彩空间转换直接读像素值就行。三个关键操作一是归一化把像素值从 [0,255] 缩放到 [0,1] 或 [-1,1]这能加速收敛二是做数据增强随机水平翻转、随机旋转 10 度、随机裁剪能让模型对姿态变化更鲁棒三是按 ImageNet 的均值和标准差做标准化这一步很多人会漏掉导致迁移学习的效果大打折扣。from torchvision import transforms data_transforms { train: transforms.Compose([ transforms.RandomResizedCrop(48, scale(0.8, 1.0)), transforms.RandomHorizontalFlip(), transforms.RandomRotation(degrees10), transforms.ToTensor(), transforms.Normalize(mean[0.5], std[0.5]) ]), val: transforms.Compose([ transforms.ToTensor(), transforms.Normalize(mean[0.5], std[0.5]) ]) }这里 train 流程里的 RandomResizedCrop 相当于在做随机裁剪的同时缩放到 48x48一方面增加样本多样性另一方面模拟人脸在画面中大小不一的情况。RandomHorizontalFlip 对表情识别来说基本无损因为左右翻转后表情含义不变但要注意不能做垂直翻转否则「生气」可能会被模型理解成「开心」以外的其他东西。Normalize 的 mean 和 std 都设为 0.5是因为灰度图只有一个通道如果你的输入改成了三通道 RGB这里就要换成 ImageNet 的 [0.485, 0.456, 0.406] 和 [0.229, 0.224, 0.225]。3. 用 PyTorch 搭建 ResNet 训练流程从数据加载到模型评估3.1 数据加载自定义 Dataset 与 DataLoader 配置fer2013 的原始文件是 CSV 格式每一行是「情绪标签,48x48像素值,用途」。先把它读进来按 Usage 字段切分成训练集和验证集再包装成 PyTorch 的 Dataset。这里有个常见的坑CSV 里的像素值是用空格分隔的字符串一定要用np.fromstring或np.array(pixels.split(), dtypenp.uint8)转换不然会把整个字符串当成一个数字读进去报 shape 不匹配的错。import pandas as pd import numpy as np from torch.utils.data import Dataset, DataLoader from PIL import Image class Fer2013Dataset(Dataset): def __init__(self, csv_path, splitTraining, transformNone): df pd.read_csv(csv_path) df df[df[Usage] split] self.transform transform self.labels df[emotion].values.astype(np.int64) self.images [] for pixels in df[pixels].values: img np.array(pixels.split(), dtypenp.uint8).reshape(48, 48) self.images.append(img) def __len__(self): return len(self.labels) def __getitem__(self, idx): img Image.fromarray(self.images[idx]) label self.labels[idx] if self.transform: img self.transform(img) return img, label train_dataset Fer2013Dataset(fer2013.csv, splitTraining, transformdata_transforms[train]) val_dataset Fer2013Dataset(fer2013.csv, splitPublicTest, transformdata_transforms[val]) train_loader DataLoader(train_dataset, batch_size64, shuffleTrue, num_workers4) val_loader DataLoader(val_dataset, batch_size64, shuffleFalse, num_workers4)上面这段里用pd.read_csv读取 CSV按Usage字段过滤出 Training 和 PublicTest 两个数据集。转换像素时先 resize 成 (48, 48)这样每个样本的原始特征是 2304 维——这里 dimension 对不上就是你经常在训练时报错的原因。DataLoader 里的num_workers4表示用 4 个子进程并行加载数据能在训练时省不少时间如果是在 Windows 上跑建议把num_workers设为 0否则容易报多进程相关的错。3.2 模型构建加载预训练 ResNet18 并修改最后一层最省事也最靠谱的做法是用 torchvision 自带的 ResNet18加载 ImageNet 预训练权重然后把最后一层全连接改成本地化微调。为什么不直接随机初始化从零训练因为 fer2013 只有 2.8 万张训练图从零训练容易欠拟合而 ImageNet 上几百个类的特征提取能力可以直接迁移过来。import torch.nn as nn from torchvision import models def build_resnet(num_classes7, pretrainedTrue): model models.resnet18(pretrainedpretrained) in_features model.fc.in_features model.fc nn.Linear(in_features, num_classes) return model model build_resnet(num_classes7, pretrainedTrue)因为 ResNet18 的最后一层 fc 输出了 1000 类ImageNet 的类别数这里必须把它换成 7。in_features model.fc.in_features取出原来的输入维度 512然后接一个新的 Linear 层。注意换掉 fc 之后这层是随机初始化的所以它的学习率建议比其他层大一些否则前面卷积层都收敛了fc 还在那里缓慢学习。3.3 训练循环损失函数、优化器与学习率调度表情识别是标准的分类任务损失函数直接选 CrossEntropyLoss。优化器用 Adam 或 SGD momentum 都行区别在于 Adam 收敛快但可能到不了最优点SGD 收敛慢但最终精度略高。期末作业时间有限我建议用 AdamW在 Adam 的基础上加了权重衰减泛化性更好。学习率的设置是这门课作业里最值得写进报告的地方初始学习率 1e-3每 5 个 epoch 乘以 0.1。import torch.optim as optim from torch.optim.lr_scheduler import StepLR criterion nn.CrossEntropyLoss() optimizer optim.AdamW(model.parameters(), lr1e-3, weight_decay1e-4) scheduler StepLR(optimizer, step_size5, gamma0.1) def train_one_epoch(model, loader, criterion, optimizer, device): model.train() running_loss, correct, total 0.0, 0, 0 for inputs, labels in loader: inputs, labels inputs.to(device), labels.to(device) optimizer.zero_grad() outputs model(inputs) loss criterion(outputs, labels) loss.backward() optimizer.step() running_loss loss.item() * inputs.size(0) _, preds torch.max(outputs, 1) correct (preds labels).sum().item() total labels.size(0) epoch_loss running_loss / total epoch_acc correct / total return epoch_loss, epoch_accoptimizer.zero_grad()这一步必须放在每次前向之前否则 PyTorch 会默认累积梯度导致 loss 爆炸。torch.max(outputs, 1)返回每个样本预测类别1表示沿着类别维度取最大值。这段代码里的loss.item()取出的是 Python 标量可以脱离计算图方便记录日志。3.4 评估与保存验证集准确率与模型权重导出训练完每个 epoch 后一定在验证集上测一次准确率不要只盯着训练集看。原因是期末作业的答辩环节里老师最常问的一个问题就是「你的模型有没有过拟合」——如果你只报告训练集 98%、验证集 60%这一问就翻车了。验证集评估的代码要放在model.eval()模式里并配合torch.no_grad()关闭梯度计算这样省内存同时也快很多。import torch def evaluate(model, loader, criterion, device): model.eval() running_loss, correct, total 0.0, 0, 0 with torch.no_grad(): for inputs, labels in loader: inputs, labels inputs.to(device), labels.to(device) outputs model(inputs) loss criterion(outputs, labels) running_loss loss.item() * inputs.size(0) _, preds torch.max(outputs, 1) correct (preds labels).sum().item() total labels.size(0) val_loss running_loss / total val_acc correct / total return val_loss, val_acc # 训练主循环示例 device torch.device(cuda if torch.cuda.is_available() else cpu) model.to(device) for epoch in range(15): train_loss, train_acc train_one_epoch(model, train_loader, criterion, optimizer, device) val_loss, val_acc evaluate(model, val_loader, criterion, device) scheduler.step() print(fEpoch {epoch1}/15, Train Acc: {train_acc:.4f}, Val Acc: {val_acc:.4f}) if val_acc best_acc: best_acc val_acc torch.save(model.state_dict(), best_model.pth)这里用best_acc记录历史最好的验证集准确率只有当验证集准确率创新高时才保存模型。这样不会用最后一个 epoch 的模型作为最终产物——很多人在训练后期会出现验证集波动最后一个 epoch 反而不是最优状态保存 best 模型就是给这种情况上的保险。最后提交作业时best_model.pth就是你用来写报告和做演示的那个权重文件。4. 结果可视化与说明文档报告里必须有的图和表4.1 训练曲线损失与准确率的变化趋势一份能被老师认可的期末作业报告至少要有两张图训练/验证损失曲线、训练/验证准确率曲线。这两张图能直观说明模型收敛情况和是否过拟合。用 matplotlib 画的时候注意横轴统一用 epoch纵轴分别画 loss 和 acc两条线用不同颜色区分图例标清楚 train 和 val。import matplotlib.pyplot as plt def plot_training_history(train_losses, val_losses, train_accs, val_accs): epochs range(1, len(train_losses) 1) plt.figure(figsize(12, 5)) plt.subplot(1, 2, 1) plt.plot(epochs, train_losses, b-, labelTrain Loss) plt.plot(epochs, val_losses, r-, labelVal Loss) plt.xlabel(Epoch) plt.ylabel(Loss) plt.legend() plt.title(Loss Curves) plt.subplot(1, 2, 2) plt.plot(epochs, train_accs, b-, labelTrain Accuracy) plt.plot(epochs, val_accs, r-, labelVal Accuracy) plt.xlabel(Epoch) plt.ylabel(Accuracy) plt.legend() plt.title(Accuracy Curves) plt.tight_layout() plt.savefig(training_history.png, dpi150)画图不是给机器看的是要给答辩老师看的。所以这张图必须在训练过程中把 loss 和 acc 的列表存下来而不是只打印几个数字。一个朴素但管用的经验是如果 val loss 和 train loss 的差距在 5 个 epoch 后越来越大说明过拟合来了这时候要提前停止训练或者在报告里展示数据增强带来的缓解效果。4.2 混淆矩阵看清哪两类表情容易搞混准确率是一个总体指标但它掩盖了类别之间的差异。表情识别里最容易混的类别是「恐惧」和「惊讶」「生气」和「厌恶」因为它们在肌肉运动模式上本来就有重叠。画一个 7x7 的混淆矩阵你能一眼看出模型在哪一类上表现差报告里针对这些混淆做解释立刻显得专业。import numpy as np from sklearn.metrics import confusion_matrix import seaborn as sns def plot_confusion_matrix(model, loader, device, class_names): model.eval() all_preds, all_labels [], [] with torch.no_grad(): for inputs, labels in loader: inputs inputs.to(device) outputs model(inputs) _, preds torch.max(outputs, 1) all_preds.extend(preds.cpu().numpy()) all_labels.extend(labels.numpy()) cm confusion_matrix(all_labels, all_preds) plt.figure(figsize(10, 8)) sns.heatmap(cm, annotTrue, fmtd, cmapBlues, xticklabelsclass_names, yticklabelsclass_names) plt.xlabel(Predicted) plt.ylabel(True) plt.title(Confusion Matrix on Validation Set) plt.savefig(confusion_matrix.png, dpi150)confusion_matrix返回的矩阵行是真值、列是预测值热力图上颜色越深说明数量越多。如果某一行明显比别的行浅说明这个类别的召回率偏低。这个图建议放在验证集上生成不要拿训练集画——训练集上的混淆矩阵几乎是对角线全红没有分析价值。4.3 说明文档结构从环境到复现步骤的完整闭环这可能是期末作业最重要但最容易被忽视的部分。老师下载了你提交的 zip如果解压后不知道第一步做什么整个作业就失去了意义。说明文档建议按「环境配置 → 数据准备 → 训练命令 → 评估命令 → 结果预期」五个部分写每个部分写清楚作用和输入输出。环境配置部分要列 Python 版本、PyTorch/CUDA 版本、依赖库清单最好附一句pip install -r requirements.txt就能跑通。数据准备部分要写清楚数据集的存放路径因为很多同学代码里写的是绝对路径换一台电脑就废了。训练命令给出一行可复制的 bash 命令比如python train.py --epochs 15 --batch_size 64 --lr 0.001并说明每个参数的含义。结果预期部分建议放一张你训练好的准确率截图这样老师看到数字符合自然对你作业的信任度高一分。5. 避坑指南人脸表情识别训练的 5 个高频翻车点5.1 现象训练 loss 下降很慢准确率徘徊在 20% 上下原因最常见的是没有做数据归一化或者 Normalize 的参数用错了。如果输入像素范围是 0 到 255而模型期望的是 [0,1]那第一层的权重更新会在一个错误的量级上挣扎导致收敛极慢。解决确认 transform 里的 ToTensor() 一定在 Normalize 之前ToTensor 会把 PIL Image 从 0-255 缩放到 0-1。如果用了预训练模型要检查 Normalize 的 mean 和 std 是否与预训练时一致——ResNet 的 ImageNet 权重要求用 [0.485, 0.456, 0.406] 这套参数你输入三通道图却写 mean[0.5] 就会出问题。5.2 现象训练集准确率很高验证集准确率低 20 个点以上原因这是典型的过拟合。表情识别数据集的样本量本身不大模型又把背景信息和个体特征也记住了没学到真正通用的表情模式。还有一种隐蔽原因训练集和验证集的分布差异太大比如 fer2013 划分时就是随机切分的人脸的背景差异明显。解决在数据增强里增加 RandomResizedCrop 的比例范围和随机擦除让模型看不全整张脸。把 weight_decay 从 0 调到 1e-4 或 1e-5给权重加 L2 正则。另外把全连接层改成 Dropout(0.5)这几个操作叠加过拟合能明显缓解。这种情况下最好用 3.2 节的 best_model 保存策略回滚到验证集最优的 epoch。5.3 现象迁移学习时 fc 层改完之后训练报维度不匹配的错误原因model.fc改了但输入数据的通道数和你模型期望的不匹配。ResNet18 默认接受 3 通道 RGB 输入如果你把 48x48 灰度图直接传进去第一层卷积会报Expected 3 channels, got 1。解决最省事的办法是在数据加载时把单通道图复制成 3 通道img img.convert(RGB)这样 model 的输入结构完全不需要改。如果你想保留灰度输入就要修改model.conv1 nn.Conv2d(1, 64, kernel_size7, stride2, padding3, biasFalse)但这样预训练权重的前几层就用不了了一般不建议。5.4 现象同样的数据我训练的准确率不如别人论文报的原因很可能是没有正确地按 batch 归一化统计量来评估。训练时 BatchNorm 会用当前 batch 的均值和方差但验证集上应该用训练阶段累积的 running_mean 和 running_var。如果model.eval()没写BatchNorm 就用验证集自己的统计量结果会偏低。解决评估之前一定要写model.eval()。另一个原因是数据划分不一样论文里的准确率是在私有测试集上测的拿公开测试集对比本身就不公平——写报告时注明你的评估集是什么。5.5 现象显存不够batch_size 一调大就 OOM原因ResNet18 不算大模型但如果你用 GPU 训练batch_size 设到 128 以上48x48 的输入叠加起来显存消耗也很快。教学用的电脑往往还是老款显卡。解决batch_size 降到 32 或 16同时把输入图尺寸从 48x48 临时改成 44x44 做输入不一定影响模型收敛。还有一个技巧是把验证集的 batch_size 设小一点评估阶段不需要和训练阶段一样大。实在不行就回退到 CPU 训练ResNet18 在 CPU 上一个 epoch 也就 5 分钟左右完全能接受。6. 进阶玩法用摄像头实时推理验证你的模型真的能用期末作业答辩最亮眼的一步莫过于现场打开摄像头对着镜头做个表情屏幕上实时显示识别结果。这个功能技术上不复杂但需要把训练好的模型权重导出后接一个推理脚本。先用 OpenCV 打开摄像头做人脸检测然后把人脸区域裁剪、缩放到 48x48给模型推理最后把标签画在画面上。import cv2 import torch from torchvision import transforms from PIL import Image emotion_labels [angry, disgust, fear, happy, sad, surprise, neutral] face_cascade cv2.CascadeClassifier(cv2.data.haarcascades haarcascade_frontalface_default.xml) model build_resnet(num_classes7, pretrainedFalse) model.load_state_dict(torch.load(best_model.pth, map_locationcpu)) model.eval() transform transforms.Compose([ transforms.Resize((48, 48)), transforms.ToTensor(), transforms.Normalize(mean[0.5], std[0.5]) ]) 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, scaleFactor1.1, minNeighbors5, minSize(48, 48)) for (x, y, w, h) in faces: face_roi gray[y:yh, x:xw] pil_img Image.fromarray(face_roi).convert(RGB) input_tensor transform(pil_img).unsqueeze(0) with torch.no_grad(): outputs model(input_tensor) _, pred torch.max(outputs, 1) label emotion_labels[pred.item()] cv2.rectangle(frame, (x, y), (xw, yh), (0, 255, 0), 2) cv2.putText(frame, label, (x, y-10), cv2.FONT_HERSHEY_SIMPLEX, 0.9, (0, 255, 0), 2) cv2.imshow(Real-time Emotion Recognition, frame) if cv2.waitKey(1) 0xFF ord(q): break cap.release() cv2.destroyAllWindows()detectMultiScale的scaleFactor1.1表示检测窗口每一步放大 10%调小一点更慢但更准minNeighbors5控制误检率值太小人脸框会乱跳太大又容易漏检。真实场景和训练数据有 domain gap摄像头拍出来的人脸和 fer2013 的灰度头像差异很大所以实时推理准确率通常比验证集低 10 个百分点左右——不然答辩的演示翻车概率会很高。常见补救是训练时多加入随机光照变化和不同尺度的人脸裁剪另外把摄像头分辨率设在 640x480处理速度比高清流畅得多。如果你不想现场赌摄像头可以把这段代码里的cv2.VideoCapture(0)换成本地视频文件路径效果一样还不受现场网速和设备限制。我自己的习惯是提前录一段自己说话的视频答辩演示用视频稳得很——这算是我吃过几次现场翻车的亏后总结出的后撤方案。希望帮到你。如果你想让演示更生动还可以在画面上画出 7 个类别的概率柱状图用cv2.putText或者一个简单的条形图来展示模型判断的置信度这样老师能直观看到模型对不同表情的判别依据。这一步不加任何新依赖代码量也只有十几行但汇报效果提升明显。本文还有配套的精品资源点击获取