ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

ResNet人脸表情识别实战:从数据处理到模型部署全解析

ResNet人脸表情识别实战:从数据处理到模型部署全解析 简介这是一套基于残差网络ResNet的人脸表情识别项目采用Python实现专为高校期末大作业与课程设计打造。项目覆盖七类基本表情的分类任务适合需要快速完成高质量课题的计算机相关专业学生。代码中包含详细注释从数据读取、模型搭建到训练预测均有清晰说明新手也能顺利上手。资源共16个文件压缩包仅5.2MB其中包括Python脚本、表情图片样本、Markdown说明文档、OpenCV人脸检测配置及类别索引文件并附带演示视频便于直观了解运行效果。py文件涵盖模型定义、混淆矩阵绘制与测试脚本png为各类表情样本xml用于人脸检测预处理目录结构清晰可按需查看对应模块。整套项目逻辑完整内置数据集与已训练模型部署门槛低下载解压即可运行。目前已有190人学习使用是期末答辩与课程设计的高性价比参考。1. ResNet 人脸表情识别大作业不是跑通就完事能讲清每一步才拿高分期末大作业里人脸表情识别几乎是 Python 加深度学习方向出现频率最高的选题FER2013 数据集公开、七分类任务直观、ResNet 结构成熟源码和数据集打包好的资源一搜一大把。但多数人拿到「python大作业-ResNet的人脸表情识别项目源码数据集」后第一反应是直接跑训练脚本看到 85% 准确率截图就以为万事大吉。实际交作业时老师随口一句「为什么用 ResNet 不是 VGG数据怎么划分的disgust 类样本那么少怎么处理」就能问到你卡壳。这篇笔记把项目从环境配置、数据划分、训练调参到部署演示的完整路径拆开讲重点标出那些不跑一遍绝对不知道的坑。适合正要交期末大作业、或想拿这个项目练手 PyTorch 图像分类的 Python 开发者。2. 项目结构与运行环境先跑通推理再谈训练2.1 拿到源码先做的三件事目录梳理、Python 环境与依赖安装这类大作业的资源包通常是一个 zip解压后的目录结构大同小异核心就四块数据集目录、模型定义、训练脚本和推理脚本外加一个 requirements.txt。拿到手后不要急着双击 train.py先花十分钟把目录理清楚确认数据集文件是否完整、模型权重文件是否还在。很多网传压缩包为了减体积会把 .pth 权重文件单独拆出来你拿到的包里可能只有源码和 CSV权重要去单独的下载地址拿这一步漏了后面全部跑不起来。resnet_fer/ ├── data/ │ └── fer2013/ │ └── fer2013.csv # 约 48MB三列emotion/pixels/Usage ├── models/ │ ├── resnet18_fer.pth # 训练好的权重文件关键资产 │ └── resnet18_fer_traced.pt # TorchScript 导出文件答辩演示用 ├── src/ │ ├── model.py # ResNet18 模型定义 │ ├── dataset.py # FER2013 数据集类 │ ├── train.py # 训练入口 │ ├── predict.py # 单图推理 │ └── demo_camera.py # 摄像头实时识别 ├── checkpoints/ # 训练中间保存 └── requirements.txt这个清单不一定和你拿到的一模一样但照着这个结构对一遍能快速定位每个文件的作用。接下来是 Python 环境的安装。这类项目大多基于 PyTorch 1.x 写成直接上 Python 3.11 很容易在装 torchvision 时因为版本不匹配报错。我一般固定用 Python 3.8兼容性最稳妥。# 创建虚拟环境Python 3.8 是这类老项目的安全牌 conda create -n fer python3.8 -y conda activate fer # CPU 版 PyTorch 起步就够先跑通流程再考虑 CUDA pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu pip install numpy opencv-python pandas matplotlib tqdm scikit-learn这段命令里第一行创建环境时指定 python3.8 是为了避开 PyTorch 1.x 系列在新版本解释器下的 wheel 缺失问题。第二行装 torch 时用 CPU 版的原因是很多人的笔记本没有可用的 NVIDIA 显卡先装 CPU 版能确保第一天就能跑起来等确认代码没问题再按官方命令换成 CUDA 版。最后一行装的是训练和可视化会用到的基础库其中 opencv-python 有人觉得推理时才用得到但后面做摄像头演示和画检测框都离不开它。如果你不想用 conda用 python -m venv fer 也是一样的效果区别只是包管理的习惯问题。装完依赖后打开项目里的 model.py 确认一件事模型类是完整定义还是从 torchvision 直接 import 的。如果是从 torchvision 里调 resnet18需要确认源码里有没有改第一层卷积的输入通道。FER2013 是单通道灰度图标准 ResNet 的 conv1 是 3 通道不改就用不了这是第一个容易翻车的地方。有的源码会把它改成 1 通道但代价是预训练权重加载不了这是一个两难选择第三章会专门讲怎么处理。还有一个高概率出现的问题加载权重时报 size mismatch。现象是 torch.load 没问题但 model.load_state_dict(state) 报错提示 fc.weight 尺寸对不上。原因是有人把训练脚本里最后一层改成了别的输出维度或者源码里加载的是 ImageNet 预训练的 1000 类权重。解决方式很简单先打印 state 里每个 key 对应的 shape和 model.state_dict() 对比然后只保留前缀匹配的卷积层参数最后一层 fc 不加载让模型随机初始化这一层。提示遇到 size mismatch 不要硬改模型结构去适配权重先看 mismatch 的层是哪一层。大多数情况下只是最后一层全连接维度不对把那一层从加载字典里剔除即可。2.2 用预训练权重跑一次推理最小可执行代码与输出解读目录理清、环境装好下一步不是训练而是先跑一次单图推理。这一步有两个作用验证环境没问题同时拿到一份「标准输出」作为之后自己训练结果的对照基准。import torch from PIL import Image from torchvision import transforms # FER2013 的类别顺序按 CSV 里的 emotion 字段排列别自己乱改 class_names [angry, disgust, fear, happy, sad, surprise, neutral] def load_model(model_pathmodels/resnet18_fer.pth, devicecpu): # 以 torchvision 自带的 resnet18 为例替换最后一层为 7 分类 from torchvision.models import resnet18 model resnet18(num_classes7) state torch.load(model_path, map_locationdevice) # 很多 checkpoint 会把 epoch 和 optimizer 一起存直接取 model 字段 if model in state: state state[model] model.load_state_dict(state) model.eval() return model def predict_single(model, image_path, devicecpu): img Image.open(image_path).convert(L) # 强制转灰度防 3 通道图片静默出错 transform transforms.Compose([ transforms.Resize((48, 48)), transforms.ToTensor(), transforms.Normalize(mean[0.5], std[0.5]) ]) tensor transform(img).unsqueeze(0).to(device) with torch.no_grad(): logits model(tensor) probs torch.softmax(logits, dim1)[0] idx torch.argmax(probs).item() return class_names[idx], {c: f{p:.3f} for c, p in zip(class_names, probs)} if __name__ __main__: model load_model() label, conf predict_single(model, data/sample_happy.jpg) print(f预测类别: {label}) print(f各类别概率: {conf})这段代码有三个关键点。第一Image.open 之后一定要 .convert(L)否则遇到 RGB 图片时 ToTensor 会产出 3 通道张量而模型是 1 通道输入直接报 shape mismatch。第二Normalize 的 mean 和 std 都取 0.5这是配合 ToTensor 把像素归一化到 -1 到 1 区间的标准做法注意训练脚本里用什么值推理时就必须一模一样少一个负号结果就完全不同。第三torch.no_grad() 包住推理避免 PyTorch 为中间张量构建计算图省内存也提速。跑通之后你会得到两类信息预测类别和 7 个类别的概率分布。如果概率分布里 happy 和 neutral 五五开说明这张图本身表情比较中性属于正常现象不是模型坏了。如果输出和直觉明显不符优先检查 transform 是否和训练时一致而不是怀疑模型有问题。概率输出保留到小数点后三位即可大作业报告里截图可以直接用老师更愿意看到你能解释每一类概率的含义这比单纯贴一个准确率数字有说服力得多。3. ResNet 为什么能打表情识别残差结构、预训练与 FER2013 数据分布3.1 残差连接解决的核心问题深层网络在小数据上的退化ResNet 在 2015 年提出时解决的核心问题叫「退化」网络层数加深到一定程度后训练集上的 loss 反而升高这不是过拟合而是梯度在反向传播过程中逐层衰减浅层参数几乎收不到有效更新。残差连接的做法是在每个 block 里加一条捷径让输入 x 直接跳到输出端网络只需要学习 F(x) H(x) - x 这个残差。好处是梯度可以通过捷径直接回传到浅层深层网络终于能训练得动。这个机制放到人脸表情识别上的意义很直接表情特征分布在眼睛、嘴角、眉毛这些局部区域网络需要一定的深度来组合低层纹理和高层语义残差连接让这个组合过程在数据量不大时不至于训不动。表情识别这个任务数据量不大但人脸结构差异不小恰好需要一定深度来捕获纹理特征。ResNet18 作为最浅的 ResNet 变体在 FER2013 上的表现通常稳过 VGG16而参数量只有后者的四分之一不到训练时间大幅缩短。这也是为什么大作业普遍选 ResNet18 而不是 ResNet50后者在 35887 张 48×48 小图上容易过拟合而且训练时间成倍增加性价比很低。以下是一个标准的 BasicBlock 实现很多大作业源码里就是这段的微调版。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, 3, stridestride, padding1, biasFalse) self.bn1 nn.BatchNorm2d(out_channels) self.conv2 nn.Conv2d(out_channels, out_channels, 3, stride1, padding1, biasFalse) self.bn2 nn.BatchNorm2d(out_channels) self.relu nn.ReLU(inplaceTrue) # 当通道数或尺寸变化时shortcut 需要做 1x1 卷积对齐 self.shortcut nn.Sequential() if stride ! 1 or in_channels ! out_channels: self.shortcut nn.Sequential( nn.Conv2d(in_channels, out_channels, 1, stridestride, biasFalse), nn.BatchNorm2d(out_channels) ) def forward(self, x): identity self.shortcut(x) out self.relu(self.bn1(self.conv1(x))) out self.bn2(self.conv2(out)) out out identity # 残差连接梯度回传的捷径 return self.relu(out)注意 shortcut 的分支条件当 stride 不为 1 或输入输出通道数不一致时必须用 1×1 卷积把 x 投影到和 F(x) 相同的形状否则加法会报 shape mismatch。这个细节在答辩时被问到的概率很高把代码里这行存在意义讲清楚比你背十遍 ResNet 结构图管用。关于预训练权重常见做法是加载在 ImageNet1K 上预训练好的 resnet18 权重再把最后一层全连接替换成 7 分类。但 FER2013 是 48×48 单通道灰度图ImageNet 预训练模型的 conv1 接受的是 224×224 三通道输入直接用要么报错要么效果差。处理办法有两种把灰度图复制成三通道喂进去或者把 conv1 的权重对三个通道取平均后再加载。前者兼容性最好后者省内存大作业里用前者即可。还有更省事的选择直接从头训练 ResNet18因为数据量不大从头训也能到 80% 左右的准确率代价是训练时间多一倍。如果你之前下过 imagenet1k 数据集做别的项目也可以自己把预训练权重抽出来用但对这个大作业来说没必要。3.2 FER2013 的七类表情与标签不均衡准确率不是唯一指标FER2013 数据集有 35887 张 48×48 灰度人脸图分成七类表情angry、disgust、fear、happy、sad、surprise、neutral。官方已经按 Training、PublicTest、PrivateTest 划分好了其中 PublicTest 和 PrivateTest 各 3589 张其余为训练集。这个划分是固定的不要自己重新随机切分否则结果没法和其他人的公开指标对比答辩时也会被老师质疑。这里有个特别容易被忽略的问题类分布极不均衡。happy 和 neutral 各有几千张disgust 只有约 600 张相差接近十倍。一个把所有图都预测成 happy 的模型整体准确率也能有 20% 以上如果只看准确率这类模型在答辩时会被一眼看穿。正确做法是同时看混淆矩阵和每一类的 precision/recall而不是只看整体准确率。混淆矩阵用 scikit-learn 两行就能出from sklearn.metrics import confusion_matrix, classification_report # y_true 是验证集真实标签y_pred 是模型预测标签 cm confusion_matrix(y_true, y_pred) report classification_report(y_true, y_pred, target_namesclass_names, digits3) print(cm) print(report)看 classification_report 时重点盯 disgust 这一行的 recall。很多模型在 disgust 上的 recall 不到 0.3这不是模型结构有问题是训练样本太少导致的常见现象。应对方案有类别加权损失、对少数类做过采样、或者用数据增强给 disgust 生成更多变体。下一章训练部分会给出类别加权损失的具体代码。这个道理是通用的就像用 yolov8 训练自己的数据集时如果某个类别只有几百张标注图再强的模型结构也救不回来先解决数据再看网络。4. 训练一个能交差的表情识别模型数据加载、增强与超参数设置4.1 数据加载与预处理48×48 灰度图、归一化与增强策略FER2013 的原始数据是一个 CSV 文件每一行是 emotion 标签、pixels 列和 Usage 列pixels 是 48×48 像素值的空格分隔字符串。很多第一次做的人会对着这个 CSV 发懵因为在本地找不到任何一张 JPG。处理方式是用 pandas 读 CSV把 pixels 字符串转成 numpy 数组再 reshape 成 48×48。这里的坑在于 split 或类型转换用错导致数组长度不对reshape 直接报错。以下是完整的 Dataset 类直接抄进 dataset.py 就能用。import pandas as pd import numpy as np from PIL import Image from torch.utils.data import Dataset class FER2013Dataset(Dataset): def __init__(self, csv_path, phasetrain, transformNone): df pd.read_csv(csv_path) # FER2013 官方 CSV 有三列emotion, pixels, Usage usage_map {train: Training, val: PublicTest, test: PrivateTest} df df[df[Usage] usage_map[phase]] self.transform transform self.images [] self.labels [] for _, row in df.iterrows(): pixels np.array(row[pixels].split(), dtypenp.uint8) # 48*482304长度不对直接跳过比静默出错好排查 if pixels.shape[0] ! 2304: continue self.images.append(pixels.reshape(48, 48)) self.labels.append(int(row[emotion])) def __len__(self): return len(self.images) def __getitem__(self, idx): img Image.fromarray(self.images[idx], modeL) label self.labels[idx] if self.transform: img self.transform(img) return img, label这段代码里有两处值得解释。第一iterrows 虽然慢但胜在稳对大作业这个数据量两万多张完全够用如果你想把训练时间压下来可以用 pandas 的 vectorized 操作一次性把整个 pixels 列转成 numpy 数组但代码可读性会下降。第二pixels 长度检查那行是血泪经验加上的网上流传的部分 FER2013 版本 CSV 里有个别行数据缺失不检查的话会在某个 epoch 中途突然崩掉而且崩的位置不固定非常难排查。如果你遇到 epoch 跑到一半报 reshape 错误先来这里看是不是数据行缺失。数据增强策略要分训练集和验证集两套。训练集用随机裁剪、水平翻转和轻微旋转来扩大样本量验证集只做最基本的 ToTensor 和归一化否则验证集准确率会被增强的随机性拉低。增强的强度要克制FER2013 本来就是 48×48 的小图旋转超过 15 度容易把关键表情区域转出边界。from torchvision import transforms train_transform transforms.Compose([ transforms.RandomCrop(48, padding4), # 四边补 4 像素再随机裁回 48 transforms.RandomHorizontalFlip(), # 水平翻转表情语义不变 transforms.RandomRotation(10), # 正负 10 度以内超过会损失关键区域 transforms.ToTensor(), transforms.Normalize(mean[0.5], std[0.5]) ]) val_transform transforms.Compose([ transforms.ToTensor(), transforms.Normalize(mean[0.5], std[0.5]) ])RandomCrop 的 padding4 相当于给每张图四周补了 4 像素的黑边再随机裁剪等效于做平移这是小数据集上性价比最高的增强手段。RandomHorizontalFlip 对表情任务是有效的因为人的表情在左右翻转后语义不变。RandomRotation 的 10 度不是随便定的角度再大一点眼睛和嘴的几何位置会被破坏模型学到的是旋转不变性而不是表情特征。Normalize 用 0.5 而不是数据集的真实均值和标准差是因为 48×48 的灰度图本身像素分布相对集中用 0.5 做对称归一化足够稳定如果你在答辩时讲「我用的是标准归一化」这就够了真要算真实 mean/std 也可以但对最终准确率的影响通常在一个点以内。4.2 训练脚本与超参数SGD 动量、余弦退火与类别加权训练部分最常见的错误是一上来就用 Adam 加默认学习率 0.001 硬训。Adam 收敛快但最终精度通常不如调好参数的 SGD尤其在这个数据量下SGD 加 momentum 的泛化能力明显更好。我的习惯是初始学习率 0.01、momentum 0.9、weight decay 1e-4配合余弦退火调度器60 个 epoch 左右能稳定收敛。这套参数在 FER2013 上跑出来的最终准确率大致在 85% 上下足够大作业交差。import torch import torch.nn as nn from torch.optim import SGD from torch.optim.lr_scheduler import CosineAnnealingLR from torch.utils.data import DataLoader from model import get_resnet18 device torch.device(cuda if torch.cuda.is_available() else cpu) model get_resnet18(num_classes7).to(device) # 类别加权disgust 样本少给它更高权重 class_weights torch.tensor([1.0, 3.0, 1.2, 0.8, 1.0, 0.8, 1.0]).to(device) criterion nn.CrossEntropyLoss(weightclass_weights) optimizer SGD(model.parameters(), lr0.01, momentum0.9, weight_decay1e-4) scheduler CosineAnnealingLR(optimizer, T_max60) train_loader DataLoader(train_dataset, batch_size128, shuffleTrue, num_workers4, pin_memoryTrue) for epoch in range(60): model.train() running_loss 0.0 for inputs, labels in train_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) scheduler.step() avg_loss running_loss / len(train_dataset) print(fEpoch {epoch1:02d}/60 Loss: {avg_loss:.4f} LR: {scheduler.get_last_lr()[0]:.6f})类别权重的取值是有讲究的。disgust 的样本量大约是 happy 的十分之一所以权重给到 3.0 而不是 10.0因为权重过高会导致模型把所有不确定样本全判成 disgust反而拉低整体精度。surprise 给 0.8 是因为它和 fear 在视觉上本来就容易混淆压低一点权重让模型更关注可区分特征。这个表是我调过几轮之后的结果你可以拿自己的验证集试但记住一个原则权重变化后用同一套验证集对比别凭感觉调。batch size 128 是个折中值。在 8GB 显存的卡上ResNet18 加 128 的 batch 勉强放得下如果你的显存只有 4GB降到 64 即可。num_workers 设 4 一般够用设太高在 Windows 上会触发多进程报错这也是个常见踩坑点。每次跑完一个 epoch 打印学习率是因为余弦退火下学习率会从 0.01 平滑降到接近 0你看一眼当前值就知道训练是正常收敛还是已经停在平台期。训练 60 个 epoch 是经验值少于 40 个 epochResNet18 在 FER2013 上大概率还在欠拟合超过 80 个 epoch提升就非常有限了不值得等。如果想在期末报告里写得更细可以加一个每 5 个 epoch 跑一次验证集 acc 的逻辑把数据整理成表格放进报告。验证集评估的代码很短就是模型切 eval 模式后遍历验证集算平均准确率注意验证时也要用 torch.no_grad() 并且模型要调 model.eval()否则 BatchNorm 在训练模式下的统计量会让验证结果偏低这个问题有相当多的人踩过。5. 大作业避坑清单从 CSV 解析错位到 Loss 不下降的五个典型问题这一章直接上踩坑记录每一条都是「现象 → 原因 → 解决」三段式前四条在答辩前必查最后一条属于训练过程中的救急手段。5.1 坑一CSV 解析错位训练集里混进测试样本现象训练集准确率很高但验证集准确率只有 50% 出头而且 loss 在验证集上出现明显震荡。原因有些流传版本的 FER2013 CSV 里Usage 列的值有大小写不一致或首尾空格pandas 过滤时用严格等于 Training导致一部分本该进训练集的行被丢进其他 split。还有更隐蔽的情况网上部分源码在处理 CSV 时直接忽略 Usage 列用行号前 80% 当训练集这和官方划分完全对不上。解决在加载数据后立即打印每个 split 的样本数和类别分布和官方数字对一遍。import pandas as pd df pd.read_csv(data/fer2013.csv) for usage in df[Usage].unique(): subset df[df[Usage] usage] counts subset[emotion].value_counts().sort_index() print(f{usage}: 样本数 {len(subset)}) print(counts.to_dict())正常情况的输出是Training 有 28709 张PublicTest 和 PrivateTest 各 3589 张七类分布里 disgust 最少。如果输出的 Training 样本数和 28709 对不上先检查 Usage 列有没有脏数据再检查是不是 CSV 文件被换过版本。把这一步写进报告老师会觉得你对数据质量有意识。5.2 坑二cv2 读图通道顺序BGR 当 RGB 用现象训练出模型后单图预测时所有图都被判成同一个类别但验证集准确率却正常。原因验证集走的是 PIL 管线而推理脚本里用 cv2.imread 读图。cv2 默认读出来的是 BGR 三通道直接转灰度时如果代码写的是 cv2.COLOR_BGR2GRAY 那没问题但如果你用的是 cv2.COLOR_RGB2GRAY通道顺序就乱了灰度值完全不是训练时看到的样子。还有一个同样隐蔽的问题opencv 的 resize 插值算法和 PIL 的 resize 默认插值算法不同在 48×48 这种小尺寸下差异会被放大。解决推理管线里全部统一用 PIL或者统一用 cv2 并写对转换宏。我个人的做法是推理代码和训练代码共用同一个 transform 定义从源头杜绝两套逻辑。如果你坚持用 cv2记住整条链路都用 cv2不要混用。5.3 坑三CPU 上训练慢到怀疑人生现象笔记本 CPU 上跑一个 epoch 要 20 分钟60 个 epoch 要一天一夜。原因模型定义没问题但 DataLoader 的 num_workers 设成了 0所有数据预处理都挤在主线程同时 BatchNorm 在 CPU 上的同步开销也不小。还有一个隐藏杀手pixels 字符串用 Python 原生 split 再 map 转 int两万张图每张 2304 个像素光是解析就要半小时。解决把 num_workers 调成 4确认 CPU 有足够核心pixels 解析用 numpy 的矢量化写法。如果还是慢最务实的方案是把训练放到云端 GPU 环境跑本地只留推理和可视化。大作业答辩看重的是你能讲清训练流程和结果不是在你自己电脑上跑满了多少个 epoch。5.4 坑四验证集准确率高但单张图片预测翻车现象验证集 acc 有 88%但随便从网上下载一张表情图喂进去预测结果完全不对。原因这是老生常谈但人人都会犯的错——预处理不一致。你的验证集图片是已经过 ToTensor 和 Normalize 的 48×48 灰度图而网图可能是 500×500 的彩色 JPG人脸只占画面的一小部分。模型从没见过这种尺度的人脸当然会乱猜。解决单图预测前先用 opencv 的 Haar 级联检测器把人脸框出来裁剪后再 resize 到 48×48然后走完全相同的归一化。这段代码在下一章摄像头实时识别里会给出完整版本。注意 FER2013 里的图本来就是居中裁剪好的人脸直接从整图开跑等于让模型处理它没见过的分布。这个坑的变体是你本地有训练集里的一张图预测是对的换成手机拍的就翻车这时候先别怀疑模型检查人脸有没有被正确裁剪和缩放。5.5 坑五显存 OOM 与训练中断断点续训现象训练到第 40 个 epoch 时爆出 CUDA out of memory前面几个小时白跑。原因最常见的是 batch size 设太大或者模型被反复复制到了多个 device。另一个隐蔽的原因是 PyTorch 的显存缓存机制中途改过模型结构或数据加载方式显存碎片没有被及时回收。解决两件事必做。一是按验证集 acc 实时保存最优模型每 10 个 epoch 存一个 checkpoint 兜底。二是训练循环里定期调用 torch.cuda.empty_cache() 清缓存。以下是一段可以直接抄的 checkpoint 保存逻辑。best_acc 0.0 for epoch in range(EPOCHS): # 训练代码略epoch 结束后跑一次验证 val_acc evaluate(model, val_loader) if val_acc best_acc: best_acc val_acc torch.save({ model: model.state_dict(), optimizer: optimizer.state_dict(), epoch: epoch, best_acc: best_acc }, checkpoints/best_model.pth) if epoch % 10 0: torch.save({ model: model.state_dict(), epoch: epoch }, fcheckpoints/epoch_{epoch}.pth) torch.cuda.empty_cache()checkpoint 里只存状态字典不存整个模型对象是标准做法文件小、加载快。断点续训时用 torch.load 读回 model 字段后要把对应的 epoch 和 optimizer 状态也恢复否则学习率调度器会从零开始余弦退火曲线就乱了。这个恢复逻辑虽然只几行代码但能救回一次通宵训练属于必须有的保险措施。6. 从大作业到可演示成果模型导出、摄像头实时识别与置信度展示6.1 把 PyTorch checkpoint 转成 TorchScript 与 ONNX答辩现场最加分的环节永远是「老师你看这是实时演示」。但直接拿 Python 脚本跑摄像头万一现场环境缺库就尴尬了。先把模型转成 TorchScript可以在不装 PyTorch 的机器上用 libtorch 跑也可以直接用 Python 加载不依赖原始模型类定义。import torch model.eval() example_input torch.rand(1, 1, 48, 48) # 单通道灰度图和训练输入一致 traced_model torch.jit.trace(model, example_input) traced_model.save(resnet18_fer_traced.pt) # 同时转一份 ONNX方便后续换推理后端 torch.onnx.export(model, example_input, resnet18_fer.onnx, input_names[input], output_names[output], dynamic_axes{input: {0: batch_size}})TorchScript 的 trace 方式是拿示例输入走一遍计算图并固化路径所以 example_input 的 shape 必须和真实推理输入一致。ONNX 导出加 dynamic_axes 是为了让 batch 维度可变摄像头逐帧推理时不必每帧都猜 batch 大小。这一步做完checkpoint 就变成了两个独立文件换机器也能跑。6.2 摄像头实时识别的两个优化技巧实时识别相比单图预测多了一个性能约束单帧推理必须控制在 50 毫秒以内否则画面卡顿。两个优化技巧值得记住一是用 opencv 的 Haar 人脸检测器先框人再分类二是推理时用 torch.no_grad() 并保持 eval 模式避免逐帧构建计算图。import cv2 import torch from torchvision import transforms cap cv2.VideoCapture(0) face_cascade cv2.CascadeClassifier( cv2.data.haarcascades haarcascade_frontalface_default.xml) model torch.jit.load(resnet18_fer_traced.pt) # 加载 TorchScript transform transforms.Compose([ transforms.ToTensor(), transforms.Normalize(mean[0.5], std[0.5]) ]) 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 gray[y:yh, x:xw] face cv2.resize(face, (48, 48)) tensor transform(face.astype(np.float32) / 255.0).unsqueeze(0) with torch.no_grad(): probs torch.softmax(model(tensor), dim1)[0] idx torch.argmax(probs).item() conf probs[idx].item() cv2.rectangle(frame, (x, y), (xw, yh), (0, 255, 0), 2) label f{class_names[idx]} {conf:.2f} cv2.putText(frame, label, (x, y-10), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2) cv2.imshow(FER Demo, frame) if cv2.waitKey(1) 0xFF ord(q): break cap.release() cv2.destroyAllWindows()这里的 face.astype(np.float32) / 255.0 是必须的因为在 opencv 里灰度图默认是 uint8直接喂给 ToTensor 会被当作整数处理归一化结果全错。置信度显示在框上方是答辩时的加分细节因为老师能直接看到模型对当前表情的把握程度而不只是一个类别标签。scaleFactor1.1 和 minNeighbors5 是 Haar 检测器的常用参数前者控制每次缩放比例后者控制误检抑制强度如果你发现漏框太多就把 minNeighbors 降到 3误检变多就调回 5。我的习惯是每次改完预处理第一步都是拿训练集里的一张图走一遍预测验证输出正确再开摄像头。这套项目的完整价值不只是交一份作业而是让你把数据、模型、训练、部署这条链路都亲手走通。希望帮到你。本文还有配套的精品资源点击获取
返回列表