基于ResNet的人脸表情识别实战:从FER2013训练到实时推理
简介一套基于ResNet的人脸表情识别Python实现源码与配套数据集面向需要完成课程设计、期末大作业或毕业设计的计算机相关专业学生。项目经本地编译可运行评审得分98分难度适中能够帮助读者快速理解残差网络在图像分类任务中的完整应用。包体共16个文件包含3个Python源码模型训练、测试与混淆矩阵绘制、7张表情类别样例图、数据集说明文档、依赖环境清单、配置文件及1段演示视频压缩后仅5.2MB结构紧凑便于直接部署。数据集按七类常见表情组织可直接用于模型训练与评估Markdown说明和录屏演示则帮助快速复现与预览效果。已有237人学习下载特别适合以人脸表情识别为课程设计、期末大作业或毕业设计课题的学习者。资源从数据准备、模型构建到结果可视化形成完整闭环可作为高分参考方案。1. 这个项目包到底给你什么ResNet 人脸表情识别不是玄学如果你在课程设计、毕业设计或者实训项目里搜到过「基于ResNet的人脸表情识别python实现源码数据集高分项目.zip」这种东西先别急着解压跑训练。这类包在网上一抓一大把真正拉开差距的不是代码不是ResNet这个网络结构有多新而是数据集的处理、训练参数的设置、以及最后的验证和可视化能不能拿得上台面。这个项目的核心任务很直接输入一张人脸图片输出七类表情之一——生气、厌恶、恐惧、开心、悲伤、惊讶、中性也就是 FER2013 数据集的经典分类标准。ResNet 就是承担特征提取的骨干网络常见做法是用 ResNet18 或 ResNet34 在 48×48 的灰度人脸图上做分类而不是一上来就上 ResNet50——图片太小深层网络不仅跑得慢还会过拟合。这个方向适合谁适合手里有一个课程项目要交、想跑通一条完整「数据加载 → 模型训练 → 评估 → 推理」链路的人也适合想搞明白表情识别为什么在真实场景里容易翻车的人。说白了它解决的是「用一套能复现的代码把表情分类这件事从零做到能答辩」的问题而不是让你发明新模型。2. 任务定义与ResNet选型为什么表情识别用18层就够2.1 表情识别的本质是细粒度小图分类人脸表情识别在工程上通常被定义为单张静态图像的七分类问题输入是已经裁剪好的人脸区域输出是表情类别。注意「已经裁剪好」这四个字——大部分公开数据集比如 FER2013给的就是对齐后的 48×48 灰度图所以在课程项目里你根本不需要做人脸检测直接把图片扔给网络就行。这点很重要因为很多人一开始以为要自己写检测框其实数据集已经帮你完成了最脏的活。输入尺寸小意味着什么48×48 的灰度图经过 ResNet 第一层卷积后空间分辨率会迅速下降深层网络能看到的有效信息其实非常有限。一个直观的对比ImageNet 是 224×224 的彩色图ResNet50 在那里能发挥优势是因为图片有足够的空间细节供深层网络提取而 48×48 的灰度人脸图全局池化后特征向量长度也就 512 或 1024真没必要堆到 50 层。我一般会直接用 ResNet18如果训练集比较大、或者你想在报告里多写点对比实验可以再试 ResNet34——再多就是纯粹的算力浪费。2.2 ResNet的残差结构在小数据集上的隐性收益ResNet 的核心是残差块让网络学习输入和输出的差值而不是直接学习完整的映射。公式上就是output F(x) x其中 F 是卷积层的组合。这个结构带来的直接好处是梯度可以绕过中间层直接回传网络做深了也不容易梯度消失。但在表情识别这个场景里残差结构还有一个不怎么被提起的优势它对「特征冗余」的容忍度更高。什么意思表情识别的特征其实很微妙——嘴角上扬、眉毛抬高、眼睑开合程度这些都是像素级的细微变化。深层网络容易把这些细节当作噪声抹掉而残差连接相当于给每条路径留了一条「绕行通道」让浅层细节能直接跳到后面。所以你会发现一个反直觉的现象在表情识别上不预训练的 ResNet18 往往比 VGG 风格的大网络表现更好一是参数少不容易过拟合二是残差结构保留了局部细节。2.3 一个可以直接用的模型定义不管那个 zip 包里给的是什么写法你自己动手时直接用下面的定义就行PyTorch 官方预训练权重可以通过weightsResNet18_Weights.IMAGENET1K_V1加载但要注意把第一层卷积改成单通道灰度图否则维度对不上。import torch import torch.nn as nn import torchvision.models as models class ResNetFER(nn.Module): def __init__(self, num_classes7, pretrainedFalse): super(ResNetFER, self).__init__() # 使用官方实现避免手写残差块出错 self.backbone models.resnet18(weightsNone) # FER2013是48x48灰度图将输入通道改为1 self.backbone.conv1 nn.Conv2d(1, 64, kernel_size7, stride2, padding3, biasFalse) # 替换最后一层全连接输出7类表情 in_features self.backbone.fc.in_features self.backbone.fc nn.Linear(in_features, num_classes) def forward(self, x): return self.backbone(x)这段代码里最关键的是两处改动conv1的输入通道从 3 改成 1以及fc的输出维度改成 7。pretrainedFalse表示不加载 ImageNet 预训练权重——对 FER2013 来说加载预训练权重反而效果不好因为 ImageNet 是三通道彩色自然图像的分布迁移到单通道灰度人脸图会有很严重的域偏移让模型在早期训练阶段震荡。如果数据集足够大比如 3 万张以上从头训练完全可行。3. 直接跑通这个项目目录结构、环境与训练命令3.1 解压后应该长什么样拿到 zip 包后第一步不是看代码而是看目录结构。一个规范的图像分类项目代码、数据、权重、日志应该是分开的。常见做法是下面这样的布局你拿到手的包如果不是这样建议自己重构一遍再动手。fer_resnet/ ├── data/ │ ├── train/ # 按类别分文件夹如 anger/, happy/, neutral/ │ └── val/ # 验证集同样按类别分 ├── checkpoints/ # 保存训练好的模型权重 ├── logs/ # TensorBoard 日志 ├── dataset.py # 数据加载与增强 ├── model.py # 模型定义 ├── train.py # 训练入口 ├── predict.py # 单张图片推理 └── requirements.txt # 依赖清单为什么要按类别分文件夹因为torchvision.datasets.ImageFolder可以直接读取这种结构自动把文件夹名映射成类别标签不用手写 CSV 解析。很多高分项目的数据集恰恰是 csv 格式而不是文件夹格式这就要多写一个转换脚本。先确认包里的数据是什么格式再决定改代码还是转数据——我见过有人花一晚上改代码去适配 csv其实写个二十行的转换脚本就解决了。3.2 环境安装要装到什么程度先把依赖列清楚。不需要 GPU 也能跑通全流程只是训练慢三到五倍用 CPU 跑完 30 个 epoch 大概要 68 个小时用入门级 GPU比如 6GB 显存能压缩到 1 小时以内。pip install torch torchvision tqdm tensorboard numpy pillow这一条命令装完之后建议再验证一次 CUDA 是否可用很多翻车现场就发生在这一步——代码在 CPU 上跑了两小时才发现 GPU 压根没被调用。import torch print(torch.cuda.is_available()) # 显示 True 才说明 GPU 可用 print(torch.cuda.get_device_name(0)) # 显示 GPU 型号如果 CUDA 不可用大概率是 PyTorch 装成了 CPU 版本需要去官网选择 CUDA 版本对应的安装命令重装。验证这一步最多花两分钟但能省下后面好几个小时的等待。3.3 训练命令与核心参数说明进入项目根目录后常见做法是直接用命令行参数控制训练配置。一个可复现的训练命令长这样python train.py \ --data_dir ./data \ --batch_size 64 \ --epochs 50 \ --lr 0.001 \ --device cuda \ --save_dir ./checkpointsbatch_size选 64 是个平衡点ResNet18 在 48×48 输入下显存占用不大64 张图约占用 3-4GB 显存太小则 BN 层的统计量不稳定太大会让梯度方向过于单一。lr0.001是 Adam 优化器最常用的起始学习率配合StepLR每 15 个 epoch 衰减 0.1 倍。epoch 数 50 对于 FER2013 这种三万张图的数据集是够的——更多 epoch 也不会显著提升准确率反而会开始过拟合训练集验证集指标出现平台期甚至回退。训练结束后权重默认保存在checkpoints/best_model.pth这份权重就是后面推理和报告里要用的核心产出物。4. 把数据集用起来加载、增强与训练验证策略4.1 用 csv 训练集重建文件夹格式FER2013 原始数据是 CSV 格式每一行包含pixels像素值、emotion标签和Usage训练/验证/测试标记。很多下载到的项目包直接给了 csv但前面说过ImageFolder需要文件夹结构。用这个脚本一次性完成转换import csv import os from collections import defaultdict # 类别索引到名称的映射顺序要与训练时保持一致 emotion_dict {0: anger, 1: disgust, 2: fear, 3: happy, 4: sad, 5: surprise, 6: neutral} def csv_to_folder(csv_path, output_dir): os.makedirs(output_dir, exist_okTrue) # 按 Usage 字段区分训练集和验证集 splits defaultdict(lambda: defaultdict(list)) with open(csv_path, r) as f: reader csv.DictReader(f) for row in reader: label int(row[emotion]) pixels row[pixels].split() split row[Usage].strip() splits[split][label].append(pixels) # 用 PIL 转成图像并写入对应类别的子目录 from PIL import Image for split, label_dict in splits.items(): folder train if split Training else val for label, pixel_lists in label_dict.items(): class_dir os.path.join(output_dir, folder, emotion_dict[label]) os.makedirs(class_dir, exist_okTrue) save_dir os.path.join(class_dir, split _ str(label)) os.makedirs(save_dir, exist_okTrue) for idx, pix in enumerate(pixel_lists): # 48x48像素值转灰度图 img Image.new(L, (48, 48)) img.putdata([int(p) for p in pix]) img.save(os.path.join(save_dir, f{idx:05d}.png)) print(f转换完成输出到 {output_dir}) if __name__ __main__: csv_to_folder(./fer2013/fer2013.csv, ./data)注意这里我用split _ str(label)作为子目录名而不是直接写类别名原因是避免不同 split 下同标签的图片互相覆盖。你可能觉得多此一举——但如果你直接按类别合并训练集和验证集图片会混在一起验证集的指标就完全失真了。这是数据集处理最常见的坑没有之一。4.2 数据增强表情识别的隐藏加分项表情识别数据增强和 ImageNet 不完全一样。随机裁剪、水平翻转、旋转这些通用手段可以用但要控制幅度因为表情是高度依赖局部细节的任务翻转后表情语义不会变生气还是生气但亮度扰动太大会让模型去学「光照」而不是「表情」。标准的增强策略是训练集用RandomHorizontalFlip(p0.5)加小角度旋转验证集只做 resize 和归一化。归一化参数对灰度图用均值 0.5、标准差 0.5 就够了。要注意灰度图归一化只有两个通道维度对应的值不要复制成三通道的 RGB 参数。4.3 一个完整的训练验证循环如果 zip 包里的训练代码写得比较乱或者你想换一种更可控的写法这个精简版训练循环可以直接替换from torch.utils.data import DataLoader from torchvision import datasets, transforms from torch.optim import Adam from torch.optim.lr_scheduler import StepLR import torch.nn as nn transform_train transforms.Compose([ transforms.RandomHorizontalFlip(p0.5), transforms.RandomRotation(degrees10), transforms.ToTensor(), transforms.Normalize(mean[0.5], std[0.5]) ]) # ImageFolder 会自动按照子目录名生成类别标签字典 train_dataset datasets.ImageFolder(./data/train, transformtransform_train) val_dataset datasets.ImageFolder(./data/val, transformtransform_train) # 类别的实际顺序通过 class_to_idx 查看推理时要用到 print(train_dataset.class_to_idx) train_loader DataLoader(train_dataset, batch_size64, shuffleTrue, num_workers4, pin_memoryTrue) val_loader DataLoader(val_dataset, batch_size64, shuffleFalse, num_workers4, pin_memoryTrue) model ResNetFER(num_classes7).cuda() criterion nn.CrossEntropyLoss() optimizer Adam(model.parameters(), lr0.001) scheduler StepLR(optimizer, step_size15, gamma0.1) # 训练主循环每个 epoch 先训练后验证 for epoch in range(50): model.train() running_loss 0.0 for images, labels in train_loader: images, labels images.cuda(), labels.cuda() optimizer.zero_grad() outputs model(images) loss criterion(outputs, labels) loss.backward() optimizer.step() running_loss loss.item() * images.size(0) model.eval() correct, total 0, 0 with torch.no_grad(): for images, labels in val_loader: images, labels images.cuda(), labels.cuda() outputs model(images) _, predicted torch.max(outputs, 1) total labels.size(0) correct (predicted labels).sum().item() scheduler.step() val_acc 100 * correct / total if val_acc best_acc: best_acc val_acc torch.save(model.state_dict(), ./checkpoints/best_model.pth) print(fEpoch {epoch1}: loss{running_loss/len(train_dataset):.4f}, fval_acc{val_acc:.2f}%)pin_memoryTrue在 GPU 训练时会明显加快数据从内存搬到显存的速度。num_workers4一般够用Windows 系统上如果报错可以改成 0。建议每个 epoch 后都保存最佳验证准确率的模型而不是保存最后一个 epoch 的模型——最后一个 epoch 往往已经出现过拟合了。5. 避坑指南表情识别项目的 5 个经典翻车现场5.1 验证集准确率虚高换个数据就崩现象训练结束时验证集准确率能到 92% 以上但拿一张自己拍的照片去预测结果完全不对。原因数据集划分没做好训练集和验证集有重叠或者验证集本身就参与了训练过程中的早停选择。另一个常见原因是数据增强太弱模型把背景、拍摄角度等干扰信息当成了特征。解决先把数据集按原始 csv 的 Usage 字段严格分层划分不要自己随机切分。其次在训练时只选验证集上表现最好的 epoch 的权重不要用最后一个 epoch 的权重做汇报。拿真实照片测试时要先确保人脸已经裁剪对齐到 48×48这一步很多人遗漏。5.2 训练时 loss 是 NaN现象训练到第几个 epoch 后 loss 突然变成 NaN之后越走越远。原因学习率太大导致梯度爆炸或者数据集里存在全零图片输入到网络后产生无效的梯度。FER2013 的原始像素值里偶尔会有全黑的坏样本。解决把学习率降到 0.0001 起步先用一个 batch 做 sanity check 确认 loss 能正常下降再加回正常学习率。数据侧在转换 csv 时加一条过滤如果某张图的像素值求和为 0直接丢弃。5.3 灰度图加载报维度错误现象RuntimeError: Expected 3D input, got 2D这类报错。原因很多人下载到的图片是单通道灰度图但torchvision.datasets.ImageFolder默认读取的是 RGB 三通道。输入通道不一致模型第一层卷积直接炸掉。解决在transforms里加transforms.Grayscale(num_output_channels1)强制统一为单通道或者改模型conv1的输入通道数为 1。这两条路选一条就够别同时用否则会在归一化的地方再撞一次维度错误。5.4 训练速度慢到离谱现象GPU 利用率只有 20% 左右训练一个 epoch 要十分钟。原因数据加载管线没调好最常见的两个痛点是num_workers0数据加载全在主进程和 CPU 解码瓶颈——大量小图片逐个读盘比 GPU 计算还慢。解决num_workers改成 4 或 8pin_memoryTrue显存拷贝速度翻倍。如果数据已经在内存里csv 转过来的小图考虑把图片打包成lmdb或h5py格式顺序读取能再快一截。48×48 的小图总量不大全部加载进内存训练完全可行。5.5 准确率只有 65%怎么调都上不去现象70% 以下准确率徘徊调整结构、调学习率、加数据增强全试过没有本质变化。原因这种准确率瓶颈几乎总是数据侧的分布问题特别是数据不均衡。FER2013 里 disgust 类别只有几百张图而 happy 有近万张图模型天然会把所有结果往高频类别上推。解决用ClassWeightedCrossEntropyLoss或者直接按类别采样。最简单的做法是给每个类别一个权重使得少样本类别贡献的 loss 比例提高。PyTorch 的CrossEntropyLoss(weightclass_weights)直接支持。权重计算用n_samples / (n_classes * torch.bincount(labels))即可。6. 把这个项目变成自己的实时推理与可信验证6.1 单张图片推理脚本训练完成后推理脚本要做到「加载模型 → 预处理输入 → 输出七类概率」三步走。这个脚本同时也是答辩时最重要的展示工具建议把它写得干净一些import torch import torch.nn.functional as F from PIL import Image from torchvision import transforms def predict_image(model_path, image_path, devicecuda): # 加载训练好的权重 model ResNetFER(num_classes7) model.load_state_dict(torch.load(model_path, map_locationdevice)) model.to(device).eval() # 注意推理预处理必须和训练时验证集保持一致 transform transforms.Compose([ transforms.Resize((48, 48)), transforms.ToTensor(), transforms.Normalize(mean[0.5], std[0.5]) ]) img Image.open(image_path).convert(L) # 转换为灰度图 tensor transform(img).unsqueeze(0).to(device) with torch.no_grad(): logits model(tensor) probs F.softmax(logits, dim1).squeeze(0) # 类别名顺序来自训练时 ImageFolder 的 class_to_idx 输出 labels [anger, disgust, fear, happy, sad, surprise, neutral] top_prob, top_idx torch.max(probs, dim0) print(f预测表情: {labels[top_idx.item()]}, 置信度: {top_prob.item():.4f}) # 把七类概率都打印出来方便看混淆情况 for i, p in enumerate(probs): print(f {labels[i]:10s}: {p.item():.4f})一个容易被忽视的点Image.open(...).convert(L)这行一定不能省如果输入是彩色图不转灰度通道数不匹配模型输出会全体乱套。置信度打印全部分数而不是只打印最大值是因为真实场景里「恐惧」和「惊讶」经常混淆看到完整概率分布能帮你判断模型是在认真分类还是瞎猜。6.2 在线摄像头实时表情识别把这个项目升级成实时版本是答辩加分项本质就是每帧做一次人脸检测把检测框裁出来缩放成 48×48 灰度图然后丢给已经训练好的模型。人脸检测不用自己写直接用 OpenCV 的 Haar 级联或者更轻量化的方案。推理速度上单帧在 CPU 上大约 3050ms完全可以满足实时要求。import cv2 import numpy as np import torch def realtime_infer(model, labels, devicecuda): # OpenCV 自带的人脸检测器零额外依赖 face_cascade cv2.CascadeClassifier( cv2.data.haarcascades haarcascade_frontalface_default.xml) 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) for (x, y, w, h) in faces: # 裁剪人脸区域并缩放到模型输入尺寸 face gray[y:yh, x:xw] face cv2.resize(face, (48, 48)) face_tensor torch.from_numpy(face).float().div(255.0) face_tensor face_tensor.unsqueeze(0).unsqueeze(0) face_tensor (face_tensor - 0.5) / 0.5 face_tensor face_tensor.to(device) with torch.no_grad(): logits model(face_tensor) prob torch.softmax(logits, dim1) idx torch.argmax(prob, dim1).item() # 在画面里画出检测框和表情文字 cv2.rectangle(frame, (x, y), (xw, yh), (0, 255, 0), 2) cv2.putText(frame, labels[idx], (x, y-10), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 255, 0), 2) cv2.imshow(FER, frame) if cv2.waitKey(1) 0xFF ord(q): break cap.release() cv2.destroyAllWindows()scaleFactor1.1表示每次缩小的比例值越小检测越精细但越慢minNeighbors5越大越能过滤误检但也会漏掉一些小尺寸的脸。这两个参数在普通教室场景下用默认值就好如果检测不到脸优先调小 minNeighbors。6.3 验证实验的三种方式答辩时能拿出真东西训练结束后不要只给一个准确率数字要有多维度的验证结果。加分做法是这三件套一是训练曲线用 TensorBoard 画 loss 和准确率随 epoch 的变化曲线能证明模型没有过拟合或欠拟合二是混淆矩阵看哪几类表情最容易互相混淆——通常「恐惧」会被误判为「惊讶」「厌恶」会被误判为「生气」能展示你对模型局限性的理解三是真实照片测试拿几张别人的人脸照片和几张自己的自拍照放到predict_image里跑把输出截图放进步骤汇报中。这三样东西加起来比任何空洞的准确率数字都有说服力。我最后补一句自己的血泪经验花三小时把训练曲线和混淆矩阵做漂亮比多熬一晚追那 2 个百分点的准确率划算得多评委想看的是你知道自己在做什么而不是你刷到了多高的数字。希望这个方向能帮到你。本文还有配套的精品资源点击获取