
简介基于ResNet的人脸表情识别Python期末大作业项目面向计算机相关专业学生及图像识别初学者旨在通过完整可运行的代码帮助读者高效完成课程设计并理解深度学习在表情分类中的落地流程。压缩包整体约5.2MB共16个文件包含3个Python脚本分别负责模型结构定义、训练过程与测试推理另附7张不同表情的示例图片、依赖库清单、人脸检测器配置文件、类别索引JSON以及项目说明文档目录划分合理便于对照学习。项目覆盖图像预处理、尺寸归一化、随机旋转/裁剪/翻转等数据增强手段并基于ResNet残差网络搭建分类模型训练时采用交叉熵损失与Adam优化器同时通过混淆矩阵可视化逐类识别效果视频文件还可用于实时表情识别演示。已有235人学习配合源码注释可快速掌握从数据准备、模型训练到测试部署的关键环节适合作为期末答辩材料或后续扩展表情识别功能的基础。1. python期末大作业里那句“跑不通”先说清楚这份 zip 该有什么如果你手上拿到的期末大作业资料正好叫 python期末大作业基于ResNet的人脸表情识别.zip先别急着解压跑训练。去年我帮人救过好几个同款课题解压以后train.py双击执行loss 在前三轮降到 0.4 左右就开始来回横跳验证集准确率始终卡在 40% 出头最后发现既不是网络结构写错也不是数据集损坏而是数据管道没接对、预训练权重没对齐、学习率一上来就设成 0.01。这类课设的分水岭从来不在“模型有多新”而在你能不能三条命令从零跑出一个有说服力的基线。这个标题拆开看就三条线Python 负责把数据装进 DataLoaderResNet 负责从人脸灰度图里提特征并把 7 类表情做分类zip 则是你要提交的交付物里面必须有一颗能直接用的权重。下面按我自己的落地顺序讲环境与数据、模型改造、训练配置、踩坑清单最后给两个验收时能加分的入口。适合正在赶这门课设的人也想复制这套方案做表情识别原型的开发者。2. 环境与数据准备先把 Python 与 fer2013 数据集弄到能跑2.1 搭建虚拟环境版本对齐能省一半排错时间人脸表情识别最常用的公开数据集是 fer2013单张图只有 48×48 像素灰度单通道7 个类别Angry、Disgust、Fear、Happy、Sad、Surprise、Neutral。整个数据集解压后就是一个 CSV每行是 emotion 标签、48×48 的像素值空格分隔、以及 Usage 字段标记它是训练集还是测试集。因为这个作业本质上是一个“小图多分类”任务常用做法是先把 CSV 解析成图片再在读取时缩放到 ResNet 需要的输入尺寸。第一步先把 Python 环境从系统环境里隔离出来我一般用venv不额外装 Anaconda够用且不占地方。需要说明的是 torch 和 torchvision 的版本必须配对否则models.resnet18(weights...)可能找不到枚举对象。下面这段是 Windows 和 Linux 通用流程python -m venv .venv # Windows 下激活 .venv\Scripts\activate # Linux / macOS 下激活 source .venv/bin/activate pip install torch torchvision --index-url https://download.pytorch.org/whl/cu121 pip install pandas numpy opencv-python scikit-learn matplotlib--index-url走的是 PyTorch 官方仓库cu121表示 CUDA 12.1 版如果你只是用 CPU 跑把cu121改成cpu即可。这行代码里最容易被忽略的是前两步激活虚拟环境和安装顺序。先装 torch 再装其他库能避免 pip 因为依赖冲突把 torch 降级后面那组 pandas、opencv 是数据解析和推理演示需要的缺一个都会在验收现场翻车。2.2 从 CSV 到 Dataset一行像素是怎么变成训练样本的拿到fer2013.csv后不要把它转成几千张散落的 PNG那样既占磁盘又慢。直接把 CSV 读进 DataFrame按Usage字段切分再在Dataset.__getitem__里动态解析像素。官方已经把数据分成 Training、PublicTest、PrivateTest 三份我一般直接用 Training 做训练、PublicTest 做验证不用自己再动手切。import pandas as pd import numpy as np from PIL import Image from torch.utils.data import Dataset class FerDataset(Dataset): # emotion: 0Angry,1Disgust,2Fear,3Happy,4Sad,5Surprise,6Neutral def __init__(self, csv_path, splitTraining, transformNone): df pd.read_csv(csv_path) self.df df[df[Usage] split].reset_index(dropTrue) self.transform transform def __len__(self): return len(self.df) def __getitem__(self, idx): row self.df.iloc[idx] pixels np.array(row[pixels].split(), dtypenp.uint8).reshape(48, 48) img Image.fromarray(pixels, modeL) # L 表示单通道灰度图 label int(row[emotion]) if self.transform: img self.transform(img) return img, label这段代码有三个关键参数值得解释。modeL把 48×48 的一维数组还原成灰度图如果这里漏了L模式PIL 会默认把它当 RGB图片直接变花。split参数不是随便取的必须和 CSV 里的Usage字段值完全一致写错一个字数据集就是空的。reset_index(dropTrue)是为了防止从原始 DataFrame 切片后索引不连续Dataloader 按序号取值会越界。2.3 图像预处理为什么 48×48 要被放大到 224ResNet 系列网络里有 4 个下采样阶段特征图一路从输入尺寸缩小 32 倍。如果直接把 48×48 的原图喂进去到最后全连接层前面的特征图只有 1×1空间信息几乎全丢。常见做法是用Resize把图先放到 224×224再复制成 3 通道这样可以直接加载 ImageNet 预训练权重不用改网络第一层。from torchvision import transforms transform transforms.Compose([ transforms.Resize((224, 224)), transforms.Grayscale(num_output_channels3), # 单通道复制成 RGB 三通道 transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) train_ds FerDataset(data/fer2013.csv, splitTraining, transformtransform) val_ds FerDataset(data/fer2013.csv, splitPublicTest, transformtransform)Grayscale(num_output_channels3)把灰度图复制成三通道是为了对上预训练权重的输入形状Normalize用的均值方差是 ImageNet 的统计量虽然 fer2013 是灰度图但复制三通道后仍按 ImageNet 分布做归一化是业界最常见做法没必要自己重算。训练和验证共用同一个 transform 是可以的但验证集不要加随机增强否则指标会被数据扰动污染。最后接 Dataloader 时Windows 上如果num_workers4报多进程错误直接改成 0这是 PyTorch 在 Windows 下的老问题。3. 把 ResNet 改成表情分类器结构选择、预训练权重与冻结策略3.1 为什么是 ResNet18 而不是 ResNet50 或自注意力模型表情识别是典型的“小数据、中等类别数”任务fer2013 训练集约 2.8 万张单张分辨率又只有 48×48。ResNet18 的残差块结构在这里有天然优势每个 block 里那条恒等映射的 shortcut 把细粒度特征直接往后传而深层输出的粗粒度语义信息负责判断“这张脸是开心还是难过”。对静态人脸表情图来说Transfomer 那套自注意力确实能在长距离依赖上做得更细但这种全局建模在小图上收益不明显还容易在小数据集上过拟合课设阶段没必要给自己加难度。ResNet18 与 ResNet50 的取舍也是同一个逻辑。下表是我常用的对比口径模型参数量单张前向耗时CPU在 fer2013 上的适用性ResNet18约 1100 万慢但可接受推荐微调收敛快过拟合风险小ResNet50约 2500 万明显变慢需要更强数据增强否则训练集涨点、验证集不动实际体验下来ResNet18 在验证集上能到接近 70% 的准确率而 ResNet50 在同样的超参数下通常只高 1 个点左右代价是训练时间翻倍。课设答辩时“你为什么选 ResNet18”是个送分题把残差 shortcut 保留细节、粗粒度语义适合全局表情判断这两个理由讲清楚比硬上大模型更能体现你理解结构。3.2 加载预训练权重从 ImageNet 迁移还是从头训在这个任务上从头训练 ResNet18 是最常见的翻车点。2.8 万张图对 1100 万参数的网络来说太少没有预训练权重的情况下 loss 降到 0.6 就下不去了。我的建议是无脑加载 ImageNet 预训练权重额外收获是没有踩坑的后悔药。torchvision 新接口里直接用枚举对象指定权重避免老式pretrainedTrue的过期警告import torch.nn as nn from torchvision import models def build_model(num_classes7, pretrainedTrue): weights models.ResNet18_Weights.IMAGENET1K_V1 if pretrained else None model models.resnet18(weightsweights) # 取原全连接层的输入维度替换输出为 7 类 in_features model.fc.in_features model.fc nn.Linear(in_features, num_classes) return modelmodel.fc.in_features是 512ResNet18 最后一个残差阶段输出的特征图经过全局平均池化后就是这个维度。这样写而不是硬编码 512是为了以后换 ResNet34/50 不用改第二处。加载预训练权重时还有一个隐性要求输入必须是三通道图所以第 2 章里的Grayscale(num_output_channels3)和这里是配套的。如果你改成 1 通道卷积层加载权重时conv1.weight形状不匹配就必须写权重映射脚本不值得。3.3 冻结特征层再解冻先让分类头学会读特征预训练模型的特征提取能力已经很强开头几轮训练时让主干变更参数意义不大反而容易把 ImageNet 上学到的通用特征冲掉。常见的两阶段策略是先冻结所有特征层只训练新加的 fc 层等损失降到平台期再解冻整个网络用更低学习率微调。这样既省时间最终精度通常也更高。for name, param in model.named_parameters(): param.requires_grad name.startswith(fc) # 只有 fc 层可训练执行这段以后优化器只更新 fc 层的参数。一个容易被忽略的细节解冻时不要把model.train()和model.eval()搞混因为 ResNet 里有 BatchNorm 层eval()模式下会用训练时累计的 running mean 和 running variance如果在验证阶段漏了model.eval()验证指标的波动会非常大。等第一阶段跑完想解冻全部层把requires_grad全部置为 True 即可后面我会在第 4 章给两段不同学习率的优化器写法。4. 训练脚本与超参数一套能顺利收敛的默认配置4.1 轻量数据增强让训练集“看起来”更多fer2013 的问题是训练集不够大且灰度图纹理简单不加增强的话ResNet18 大约在 15 轮之后就出现训练集 loss 继续降、验证集 loss 反弹的过拟合信号。我用的一组加法组合是随机裁剪、水平翻转、随机旋转。注意表情任务不能乱加颜色抖动灰度图本身没有颜色信息加了反而引入噪声。train_transform transforms.Compose([ transforms.Resize((224, 224)), transforms.RandomResizedCrop(size(224, 224), scale(0.8, 1.0)), transforms.RandomHorizontalFlip(p0.5), transforms.RandomRotation(degrees10), transforms.Grayscale(num_output_channels3), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ])RandomResizedCrop里的scale(0.8, 1.0)表示裁剪区域占原图的 80% 到 100%太小的裁剪比例会让脸部关键信息被切掉。RandomRotation(degrees10)只转 10 度因为人脸稍微歪一点是自然的超过 15 度反而会让模型学到错误的姿态关联。这一套增强不复杂但在有预训练权重的情况下能把验证集准确率拉开 5 个百分点左右属于性价比最高的操作。4.2 类别不均衡不要让 Disgust 类直接躺平fer2013 里 Disgust 类样本常年只有几百张而 Happy、Neutral 都有大几千张。如果直接用普通交叉熵模型会把所有样本都猜成多数类Disgust 的召回率趋近于 0。处理办法是计算各类别样本数的倒数权重然后把权重送进损失函数。用 scikit-learn 提供的工具算最省事import numpy as np from sklearn.utils.class_weight import compute_class_weight import torch.nn as nn train_labels train_df[emotion].values.astype(int) class_weights compute_class_weight( class_weightbalanced, classesnp.arange(7), ytrain_labels ) criterion nn.CrossEntropyLoss( weighttorch.tensor(class_weights, dtypetorch.float32).to(device) )balanced的计算方式是总样本数 / (类别数 × 该类别样本数)少数类会拿到更大的权重Disgust 的 loss 贡献被抬起来。这里有个参数细节compute_class_weight的classes必须传入完整的 0 到 6不能只传出现过的类别否则权重向量长度对不上。也可以在损失函数里加标签平滑比如label_smoothing0.07让模型对训练标签没那么自信能进一步压低过拟合但权重和标签平滑同时用时少数类的梯度会被稀释我一般只用权重、不加平滑。4.3 优化器、学习率与训练循环多段学习率怎么配训练配置我建议直接抄这一套优化器用 AdamW主干和分类头分两段学习率主干 1e-4、分类头 1e-3批大小 64轮次 30配合余弦退火调度器。这套配置在 NVIDIA 显卡 8GB 显存上能跑动CPU 也能跑但每轮会慢很多可以把 batch 降到 32。import torch from torch.optim.lr_scheduler import CosineAnnealingLR fc_params [p for n, p in model.named_parameters() if n.startswith(fc)] backbone_params [p for n, p in model.named_parameters() if not n.startswith(fc)] optimizer torch.optim.AdamW([ {params: fc_params, lr: 1e-3}, {params: backbone_params, lr: 1e-4}, ], weight_decay1e-2) scheduler CosineAnnealingLR(optimizer, T_max30) for epoch in range(30): model.train() running_loss 0.0 for imgs, labels in train_loader: imgs, labels imgs.to(device), labels.to(device) preds model(imgs) loss criterion(preds, labels) optimizer.zero_grad() loss.backward() optimizer.step() running_loss loss.item() model.eval() correct total 0 with torch.no_grad(): for imgs, labels in val_loader: imgs, labels imgs.to(device), labels.to(device) preds model(imgs).argmax(dim1) correct (preds labels).sum().item() total labels.size(0) print(fepoch {epoch1}: loss{running_loss/len(train_loader):.4f}, fval_acc{correct/total*100:.2f}%) scheduler.step()两段学习率的核心逻辑是分类头是随机初始化的需要大步子快速收敛主干提特征的能力已经很好只做精细微调所以低一个数量级。weight_decay1e-2对 AdamW 来说比常见的 1e-4 更能压过拟合如果验证集准确率出现抖动优先检查它而不是动学习率。CosineAnnealingLR的T_max要和总轮次一致否则退火在最后几轮提前走完学习率变成 0 之后更新不了。监控指标要看val_acc训练集的running_loss只是判断是否收敛的参考它降不代表验证集在降。4.4 交付结构zip 里千万别只丢一个训练脚本验收时最常见的问题是“代码能跑但怎么跑没人知道”。我一般会在 zip 里放一个明确的结构让助教照着 README 三分钟跑通。注意不要图省事把权重文件放网盘外链放进去直接可用才是交付的底线。路径作用data/fer2013.csv原始数据集src/model.py网络定义与模型构建函数src/train.py训练入口支持命令行指定 epoch 与 batchsrc/demo.py图片/摄像头推理入口weights/checkpoint.pt训练好的权重文件requirements.txt依赖清单逐行pip installREADME.md运行顺序、数据集来源、结构说明README 里最少要写三件事先建虚拟环境、再装依赖、最后是训练和推理两条命令。别小看这个结构答辩演示时助教机器上的环境和你的不一致是常有的事有requirements.txt和说明文档至少能证明这套代码是可复现的。训练脚本里把 batch、epoch、学习率提成命令行参数比硬编码在代码里更专业这也是我后来一直在用的习惯。5. 避坑清单解压、权重、显存和全偏问题的 5 条排查记录5.1 zip 解压报错伪加密和损坏压缩包是两回事现象右键解压python期末大作业基于ResNet的人脸表情识别.zip时提示需要密码但根本没人设过密码或者解压到一半报missing zip entry、CRC 校验失败。原因前者叫伪加密zip 格式里有个 general purpose bit flag某些工具把第 0 位置 1 造成“看起来加密了”的假象后者大多是下载过程数据缺失或者压缩包制作不规范。区分方法很简单用 7-Zip 打开看看能不能直接看到文件列表能看到列表只是解压时才要密码基本就是伪加密。解决伪加密可以用一段 Python 脚本直接修掉 flag 位不用去找什么密码移除工具那些工具绝大多数也是干这件事def strip_fake_encryption(zip_path, out_path): with open(zip_path, rb) as f: data bytearray(f.read()) idx 0 local_num 0 while idx len(data) - 4: if data[idx:idx 4] bPK\x03\x04: # 本地文件头 flag int.from_bytes(data[idx 6:idx 8], little) if flag 0x1: # 第 0 位是加密标志 data[idx 6:idx 8] (flag ~0x1).to_bytes(2, little) local_num 1 idx 1 with open(out_path, wb) as f: f.write(data) print(fprocessed {local_num} local file headers)PK\x03\x04是 zip 本地文件头的魔数idx 6到idx 8这两个字节就是通用标志位。真正的加密包靠这个脚本是解不开的需要用它来判断是哪种情况避免在伪加密上浪费时间。如果改完 flag 仍然提示解压失败那就是压缩包本身损坏重新下载一份比手动修要可靠。另外提一句如果解压出来发现.py文件是 UTF-8 带 BOM 的Windows 上直接跑可能报语法错误用编辑器另存为 UTF-8 无 BOM 即可。5.2 加载预训练权重报尺寸不匹配现象执行model.load_state_dict(torch.load(checkpoint.pt))时抛出size mismatch for fc.weight: copying a param with shape torch.Size([1000, 512]) ...。原因模型结构和权重文件的分类头维度不一致。torchvision 原始 ResNet18 的 fc 层输出是 1000 类你把它换成 7 类以后再加载旧权重键名对得上但形状对不上。解决如果你是在训练脚本里通过build_model(num_classes7)构建的模型直接用初始化好的模型加载权重。如果是先加载原始权重再改结构顺序反了就会报这个错。正确顺序是先改结构、再加载。另一个相关坑是strictTrue默认参数如果权重文件里包含了优化器状态直接加载会报Missing key(s)加载时加map_locationcpu并确认你保存的是model.state_dict()而不是整个模型这两点分别解决无 GPU 机器读取和键名冗余的问题。5.3 显存或内存溢出现象训练脚本跑上几个 batch弹出CUDA out of memory或者是 CPU 环境下电脑直接卡死、内存占用涨到十几个 GB。原因num_workers开得太大每个进程复制一份数据或者 batch_size 超过显存容量。fer2013 单张图本身很小但放大到 224 并做随机裁剪后每个 batch 在 GPU 上同时保存输入、中间特征和梯度64 的 batch 对 8GB 显存刚好卡在边缘。解决批量大小减半、梯度累积两步恢复等效 batchnum_workers在 Windows 上直接设 0在 Linux 上设 2 就够。如果机器实在带不动把输入尺寸从 224 降到 160ResNet18 照样能跑精度损失大约在 1 到 2 个百分点。CPU 环境下建议关闭pin_memoryTrue它只为 GPU 传输提速对 CPU 训练反而增加内存占用。5.4 模型预测结果几乎全偏向某一类现象验证集准确率看着有 60% 多但一打印分类报告发现模型把所有样本都预测成 Happy 或 NeutralDisgust、Fear 类召回率接近 0。训练时 loss 明明在下降。原因这是类别不均衡的典型特征损失函数被多数类主导。交叉熵在类别分布极不均匀时把样本猜成多数类也能拿到较低的 loss但对少数类来说模型完全没有辨别能力。解决第 4.2 节的compute_class_weight就是为这个问题准备的。算完权重后必须确认torch.tensor(class_weights).to(device)和标签是在同一个设备上否则会出现 device mismatch 报错。改完权重后不要只盯总准确率要看classification_report里 Disgust 和 Fear 的召回率是否从 0 升起来哪怕总准确率掉了 2 个点类别均衡后的模型才是真的可用。另外验证时不要在model.eval()之前裁图或做随机增强那种伪随机性会让预测结果偏向某些类。5.5 权重在本地能跑到验收机器上就不行现象答辩时在教室电脑上跑demo.py提示KeyError: fc.weight或者根本没装 CUDA脚本直接崩在model model.cuda()这一行。原因代码里把 GPU 写死了或者权重是在 GPU 上保存的在无 GPU 机器上直接torch.load会报RuntimeError: Attempting to deserialize object on a CUDA device。解决所有设备选择统一写成device torch.device(cuda if torch.cuda.is_available() else cpu)不要出现任何硬编码的.cuda()。保存权重时直接torch.save(model.state_dict(), weights/checkpoint.pt)加载时用torch.load(weights/checkpoint.pt, map_locationcpu)再转model.load_state_dict()。最后在 README 里写清你测过的 Python 版本和 torch 版本验收到别的环境时至少能锁定一个大版本范围。这也是我每次交付 zip 前必做的最后一遍检查。6. 验收加分项混淆矩阵与实时推理入口6.1 用分类报告和混淆矩阵展示模型行为期末答辩时光给一个val_acc68%太单薄老师更想看到每个类别分别表现如何。把验证集所有预测结果收集起来跑一遍 scikit-learn 的分类报告并画混淆矩阵既能证明你做了认真评估也比一张 loss 曲线更有说服力from sklearn.metrics import confusion_matrix, classification_report import seaborn as sns import matplotlib.pyplot as plt import numpy as np report classification_report(all_labels, all_preds, target_namesclass_names, digits3) with open(report.txt, w) as f: f.write(report) cm confusion_matrix(all_labels, all_preds, labelsrange(7)) plt.figure(figsize(8, 6)) sns.heatmap(cm, annotTrue, fmtd, xticklabelsclass_names, yticklabelsclass_names) plt.xlabel(Predicted) plt.ylabel(True) plt.savefig(confusion_matrix.png, dpi150, bbox_inchestight)把all_labels和all_preds在验证循环里通过extend收集即可。classes用 0 到 6 的整数才能对应 7 类标签target_names顺序要和训练时的索引完全一致否则混淆矩阵会错位。分类报告里的 per-class 召回率比总准确率更能说明类别不均衡有没有真的有缓解答辩时这条数据是最硬的。6.2 图片和摄像头推理入口让模型“跑给人看”另一个加分方向是给 zip 里加一个推理脚本支持传入一张图片输出预测标签再接一个摄像头实时识别。OpenCV 自带的 Haar 级联人脸检测器足够课设用不需要额外训练检测模型。核心流程是每一帧先检测人脸框把人脸区域裁出来走和第 2 章相同的预处理再进 ResNet 预测。face_cascade cv2.CascadeClassifier( cv2.data.haarcascades haarcascade_frontalface_default.xml ) cap cv2.VideoCapture(0) while True: ret, frame cap.read() 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 Image.fromarray(gray[y:yh, x:xw]).resize((224, 224)) face val_transform(face).unsqueeze(0).to(device) label model(face).argmax(dim1).item() cv2.rectangle(frame, (x, y), (x w, y h), (0, 255, 0), 2) cv2.putText(frame, class_names[label], (x, y - 8), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 255, 0), 2) cv2.imshow(face expression, frame) if cv2.waitKey(1) 0xFF ord(q): break cap.release() cv2.destroyAllWindows()scaleFactor1.1表示每次检测窗口放大 10%窗口拖得越小越慢但越准minNeighbors5是每个候选框最少要有 5 个邻居确认才算人脸太小容易把背景误检成人脸。摄像头画面尺寸大时检测会变慢如果帧率跟不上就把检测改到每隔一帧执行。这个 demo 的价值不只是好看它能直接检验模型在真实人脸上的泛化能力让验证集上有 70% 精度的模型通不过摄像头测试的情况提前暴露。我给自己定的规矩是任何课设 zip 交出去之前一定在空目录里按 README 从零建环境、跑一遍训练和推理能走通才敢提交。模型本身不是黑匣子真正让人翻车的是那些没对齐的细节希望这份拆解能帮到正在赶期末作业的你。本文还有配套的精品资源点击获取