ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

基于CNN与ResNet18的人脸表情识别实战:PyTorch与OpenCV全流程

基于CNN与ResNet18的人脸表情识别实战:PyTorch与OpenCV全流程 简介面向计算机专业课程设计场景的CNN人脸表情识别项目适合需完成期末大作业或进行深度学习实战的Python学习者。压缩包共23个文件以10个Python脚本为核心覆盖数据读取、模型构建、训练与识别等完整流程辅以tfrecord训练数据、png/jpeg可视化样例、项目说明Markdown及界面HTML等整体约19.17MB。目前已有52人学习下载。源码经过导师认可、评审得分98分并在本地编译调试确保可运行内容源于网络分享使用前请注意版权。通过该项目可系统掌握CNN表情识别全链路从TFRecord数据预处理、Keras模型训练与损失可视化到静态图片推理和摄像头实时识别并附带简单UI界面与训练脚本便于理解深度学习落地方法难度适中是课程设计或求职作品的实用参考。1. 期末周才动手也不慌基于CNN的人脸表情识别该从哪里入手期末前两周才定课题前端写了一年半机器学习只上过半个学期最后锁定了「基于CNN的人脸表情识别系统的Python期末大作业源码」这个题目。这类项目真正要解决的是让计算机从一张人脸图像里判断出愤怒、厌恶、恐惧、开心、悲伤、惊讶、中性这7类表情核心是CNN卷积神经网络的分类能力加OpenCV的人脸检测串联。它能拿来做实时摄像头识别也能离线对图片批量预测训练指标、混淆矩阵、推理演示都能填进答辩PPT属于期末项目里安全又出效果的方向。适合有Python基础、想在一周内跑通并能讲清原理的同学。2. 先把技术栈锁死PyTorch、ResNet18 与数据流少走一半弯路2.1 为什么先定任务边界表情识别本质上是个分类问题很多人一听到人脸表情识别就去翻目标检测论文这是期末项目最容易跑偏的地方。期末大作业的输入通常有两种形态一种是单张已经裁好的人脸图模型只需要做分类另一种是摄像头画面需要先找出人脸位置再判断表情。无论哪种核心模型要承担的都是「7选1」的分类任务而不是把表情框出来的检测任务。这意味着你不需要YOLO不需要Faster R-CNN不需要在这上面浪费时间去调anchor。你需要的是一个图像分类网络加上一个人脸检测器。人脸检测可以用OpenCV自带的Haar级联检测器完成表情分类交给CNN。把任务拆成「检测分类」两段每一段都有成熟的轮子期末时间才够用。这个拆法还有一层好处答辩时你可以很清楚地说出整个流水线的每一环。老师问「为什么不用端到端检测」你回「因为表情区域已经被检测框固定分类网络结构更简单、训练更快效果也更稳定」这句话就能接住追问。2.2 工具分工PyTorch负责训练OpenCV负责取流与检测我把这套项目的工具链固定为以下四个分工明确PyTorch构建CNN、训练、评估、导出模型torchvision加载ResNet18骨架、图像变换OpenCV读取摄像头帧、Haar人脸检测、图像缩放matplotlib与scikit-learn画loss曲线、算混淆矩阵和分类报告选PyTorch而不是TensorFlow不是因为哪个绝对更好而是期末场景下PyTorch有几个实际优势。第一PyTorch的模型定义是命令式风格print(model)就能看到每一层的名字和参数答辩前临时被问「你的网络有几层」可以直接数第二报错信息更接近Python习惯新手能顺着堆栈找到问题第三torchvision里集成好的ResNet18改两行就能适配灰度单通道输入省去从零搭网络的时间。TensorFlow的Keras API也简单但你一旦在Windows上遇到protobuf版本冲突或者GPU配置问题排查时间会翻倍。期末项目的时间预算里不应该包含这种环境折腾。至于源码标准的项目结构就是data_loader.py负责读数据和预处理model.py定义网络train.py跑训练webcam_demo.py做摄像头推理。源码是骨架跑通靠的是你理解每一步。2.3 骨架网络选型ResNet18是期末项目的最优解表情识别领域常用的骨架网络就那么几个VGG、ResNet、MobileNet、EfficientNet。期末项目我一般直接选ResNet18原因很实际。VGG16参数约1.38亿训练慢且容易过拟合CPU上跑一轮要等到天荒地老。ResNet50、ResNet101参数多、训练时间长对48×48的小图并没有明显优势。MobileNet虽然轻量但深度可分离卷积的解释成本高答辩时你得多花口舌讲这块。ResNet18只有约1100万参数CPU上训练30轮也能在可接受时间内完成残差结构还能有效缓解梯度消失是「能讲清楚、能跑完、效果不差」的三好学生。还有一点要注意torchvision自带的ResNet18默认输入是RGB三通道输出是1000类ImageNet分类。我们需要把第一个卷积层从3通道改成1通道把最后的全连接层从1000类改成7类。这两处改动是整篇源码里最关键的适配点第四章会给出具体代码。有人问我为什么不直接加载ImageNet预训练权重来微调。可以但灰度单通道和预训练权重不匹配需要把RGB权重平均成单通道属于额外操作期末项目里从零训练完全够用不必给自己加戏。3. 把FER2013变成能训练的数据集格式剖析、加载脚本与标签映射3.1 认识原始CSV三列结构先花十分钟看明白再写代码FER2013是人脸表情识别最常用的公开数据集也是一个CSV文件解决问题的典型例子。每一行代表一张人脸样本一共3列emotion整数标签0到6分别对应angry、disgust、fear、happy、sad、surprise、neutralpixels2304个灰度像素值范围0到255用空格分隔Usage当前样本属于Training、PublicTest还是PrivateTest2304个像素就是48×48的灰度图。数据不是常见的文件夹图片结构所有图像都摊在一个CSV里读取时必须自己解析像素字符串再reshape。很多刚接触这个数据集的人会在这里卡住用Pandas读出来发现pixels是一整列字符串不知道下一步怎么处理。我的做法是先打印前两行的列名和数据类型确认emotion是int字符串、pixels是一长串用空格隔开的数字然后再写解析函数。不建议在没看原始数据的情况下直接写加载脚本因为不同来源的FER2013格式可能略有差异有的带表头有的不带。3.2 加载与可视化解析像素串、reshape到48x48下面这段代码是数据加载的核心也是整个期末项目里最不能出错的函数之一。import csv import numpy as np label_map { 0: angry, 1: disgust, 2: fear, 3: happy, 4: sad, 5: surprise, 6: neutral } def load_fer2013(csv_path): images, labels, usages [], [], [] with open(csv_path, r) as f: reader csv.reader(f) header next(reader) # 跳过表头: emotion,pixels,Usage for row in reader: labels.append(int(row[0])) pixels np.array(row[1].split( ), dtypenp.float32) images.append(pixels.reshape(48, 48, 1)) usages.append(row[2]) return np.array(images), np.array(labels), np.array(usages)逻辑说明row[1]是空格分隔的字符串用split( )切成2304个数字字符串再交给np.array(..., dtypenp.float32)转成浮点数组。reshape(48, 48, 1)的含义是把它还原成高度48、宽度48、单通道的图像最后的1表示灰度通道是后面接入CNN时必须保留的维度。参数说明dtypenp.float32一定不能写成默认的float64。PyTorch模型权重默认是float32输入如果是float64会触发类型不匹配报错或者说即使能跑内存占用翻倍、训练变慢。int(row[0])用于把标签字符串转成整数这一点很重要因为后面CrossEntropyLoss要求标签是torch.long类型如果这里漏了转换后面还得改。加载完之后建议顺手检查一个sample把images[0][:, :, 0]交给plt.imshow(..., cmapgray)能看出人脸轮廓再继续往下走。不要跳过大纲就直接训练数据形态错了后面全是白跑。3.3 构建Dataset与DataLoader归一化、通道顺序、切分数据加载只是拿到一个Numpy数组要进入PyTorch训练流程还需要包装成Dataset并由DataLoader负责分批。下面这段代码同时做了三件事归一化、调整通道顺序、生成可迭代的数据批次。import torch from torch.utils.data import Dataset, DataLoader class Fer2013Dataset(Dataset): def __init__(self, images, labels): # 输入images形状: (N, 48, 48, 1)PyTorch需要(N, 1, 48, 48) self.images torch.from_numpy(images).permute(0, 3, 1, 2) / 255.0 self.labels torch.from_numpy(labels).long() def __len__(self): return len(self.labels) def __getitem__(self, idx): return self.images[idx], self.labels[idx] # 按Usage列做原始划分也可以自己随机切 train_mask usages Training val_mask usages PublicTest train_ds Fer2013Dataset(images[train_mask], labels[train_mask]) val_ds Fer2013Dataset(images[val_mask], labels[val_mask]) train_loader DataLoader(train_ds, batch_size64, shuffleTrue, num_workers0) val_loader DataLoader(val_ds, batch_size64, shuffleFalse, num_workers0)逻辑说明Numpy数组默认形状是(N, 48, 48, 1)这是HWC布局。PyTorch的卷积层要求NCHW布局也就是通道维必须在第二位所以要用permute(0, 3, 1, 2)把轴顺序换成(样本数, 通道数, 高, 宽)。除以255是因为原始像素值范围是0到255归一化到0到1之间能让损失函数的梯度更稳定训练收敛更快。参数说明batch_size64在显卡显存8G以下建议改成32避免显存溢出shuffleTrue能让每个epoch的样本顺序都不同防止模型学到批次顺序带来的假规律num_workers0在Windows上几乎是必须的因为如果设置大于0程序跑起来极大概率报BrokenPipeError这是PyTorch在Windows下的老坑。3.4 看一眼类别分布不均衡问题越早知道越好训练前统计一下每个类别的样本数量看起来是多余的一步实际上能帮你提前避开期末答辩最尴尬的一个问题——少数类别完全学不出来。你只需要一行代码from collections import Counter train_labels labels[train_mask] print(Counter(train_labels)) # 例如 Counter({6: 5000, 3: 4800, ...})真实统计里disgust类别的样本数比happy少一个数量级是常事。7个类别的样本数量严重不均如果不做任何处理模型会倾向于把不确定的样本判给数量多的类表现为混淆矩阵里少数类所在的整行几乎全是0。这是数据集本身的问题不是你的代码写错了但要提前知道并且想好应对策略。常见的应对方式有两种一种是给CrossEntropyLoss传入weight参数按类别样本数的倒数加权让少数类的损失在总损失里占更大比重另一种是用WeightedRandomSampler在采样时多抽少数类样本。这个坑的具体解法我会在第五章完整展开。4. 模型构建、训练与调参从最小可运行脚本到能答辩的准确率4.1 改两处就够把ResNet18的输入输出改成灰度图与7类torchvision的ResNet18想要直接用在FER2013上只需要改前两处。第一处是第一个卷积层conv1默认输入通道数是3对应RGB三通道图片我们要把输入通道改成1适配灰度图。第二处是最后的全连接层fc默认输出1000类改成7类。import torch.nn as nn from torchvision.models import resnet18 model resnet18(pretrainedFalse) model.conv1 nn.Conv2d(1, 64, kernel_size7, stride2, padding3, biasFalse) model.fc nn.Linear(model.fc.in_features, 7)逻辑说明resnet18(pretrainedFalse)表示不加载ImageNet预训练权重从零开始训练。为什么这里不能设成True因为预训练权重的第一层卷积期望输入3通道一旦改成1通道权重矩阵形状不匹配模型加载会直接报错。即使你通过灰度值复制三通道来绕过去预训练权重本身是在自然图像上学的特征对48×48的灰度人脸帮助有限期末项目不值得冒这个险。参数说明model.conv1保留kernel_size7, stride2, padding3与原版一致这样网络整体结构不变答辩时讲起来也清爽。model.fc.in_features会自动读取上一层传入的512维特征nn.Linear(512, 7)把这512维特征映射到7个表情类别的得分上。biasFalse同样沿用官方设置减少不必要的参数。如果你想让训练更快一点可以在conv1里把kernel_size改成3相对7×7卷积感受野更快缩小。但这属于可选项建议第一次跑通时保持官方参数不要同时改太多变量。4.2 训练脚本最小版本loss在降就说明骨架通了下面这段训练代码是期末项目里最核心的脚本我一般建议在一个文件里跑通不要一开始就拆成多个模块。拆模块是最后代码整理阶段的事调试阶段全部都放在train.py里出错了顺藤摸瓜更快。import torch import torch.nn as nn import torch.optim as optim from torch.optim.lr_scheduler import StepLR device cuda if torch.cuda.is_available() else cpu model model.to(device) criterion nn.CrossEntropyLoss() optimizer optim.Adam(model.parameters(), lr1e-4, weight_decay1e-4) scheduler StepLR(optimizer, step_size5, gamma0.8) num_epochs 30 for epoch in range(num_epochs): model.train() running_loss 0.0 for X_batch, y_batch in train_loader: X_batch, y_batch X_batch.to(device), y_batch.to(device) optimizer.zero_grad() logits model(X_batch) loss criterion(logits, y_batch) loss.backward() optimizer.step() running_loss loss.item() scheduler.step() avg_loss running_loss / len(train_loader) print(fEpoch [{epoch1}/{num_epochs}] avg_loss: {avg_loss:.4f})逻辑说明每一轮训练分为三个动作。optimizer.zero_grad()清空上一次迭代保留的梯度因为PyTorch默认是累积梯度不清空会把多个batch的梯度叠加起来。loss.backward()计算当前batch的梯度optimizer.step()用梯度更新模型权重。running_loss累加每个batch的loss最后除以batch数量得到这一轮的平均loss打印出来用于观察收敛情况。参数说明CrossEntropyLoss内部自带softmax计算所以模型输出的logits不需要再手动套softmax直接把最后一层的原始输出传进去即可。Adam优化器配合lr1e-4是从零训练小数据集非常稳的起点如果发现loss降得太慢可以改成3e-4但不要直接用0.001容易震荡。weight_decay1e-4加了一点L2正则有助于控制过拟合。StepLR每5轮把学习率乘以0.8作用是训练后期让权重在小学习率下微调得到更细的决策边界。4.3 验证集与合理预期准确率到多少才算没白练训练过程中如果不验证你就不知道模型到底是真学会了还是背住了训练集。下面这段验证代码在每一轮训练结束后调用一次返回验证集准确率。def evaluate(model, loader, device): model.eval() correct 0 total 0 with torch.no_grad(): for X_batch, y_batch in loader: X_batch, y_batch X_batch.to(device), y_batch.to(device) logits model(X_batch) preds torch.argmax(logits, dim1) correct (preds y_batch).sum().item() total y_batch.size(0) return correct / total逻辑说明torch.argmax(logits, dim1)的含义是在每一行7个得分里找最大值的索引也就是模型认为最可能的类别。(preds y_batch)的元素级比较会得到一个布尔张量用.sum()统计相等的个数再除以总样本数得到准确率。关于FER2013的准确率预期要先给大家泼一盆冷水公开复现实验里ResNet18从零训练大约能到60%到65%这已经是正常水平。原因是FER2013是48×48的灰度小图部分样本标注本身就有噪声7个类别之间还存在语义重叠比如恐惧和惊讶经常被标混。所以你的模型跑到60%以上就说明代码逻辑没问题到65%可以很有底气地写进答辩报告。不要拿ImageNet的90%心理预期来卡自己。4.4 推理链路最小实现从单张图得到表情标签训练完之后无论是要对测试集做结果验证还是做摄像头实时识别都需要先把推理函数写好。下面这段单张图片推理代码是推理链路的最小实现。import cv2 def predict_single(gray_img, model, device): # gray_img为48x48或任意尺寸的灰度图函数内统一缩放 img cv2.resize(gray_img, (48, 48)) tensor torch.from_numpy(img).float().view(1, 1, 48, 48) / 255.0 tensor tensor.to(device) model.eval() with torch.no_grad(): logits model(tensor) prob torch.softmax(logits, dim1) idx torch.argmax(prob).item() return idx, prob[0][idx].item()逻辑说明view(1, 1, 48, 48)把形状为(48, 48)的二维灰度数组补成四维张量前面两个数字分别代表batch大小为1、通道数为1。这正好对应模型期望的(N, C, H, W)输入格式不需要像Dataset里那样用permute因为这里没有多余的通道轴。softmax(logits, dim1)把7个得分转成总和为1的概率分布argmax取最大概率对应的类别索引。推理时用model.eval()和torch.no_grad()组合一个关掉训练模式的特殊行为一个关掉梯度计算内存占用小、推理更快。注意如果你在摄像头画面里拿到了人脸框需要先把BGR彩色图转成灰度图再送入这个函数gray cv2.cvtColor(face_region, cv2.COLOR_BGR2GRAY)这个细节漏了会直接报通道数错误。5. 避坑指南期末最容易翻车的五个环节血泪经验一次说清5.1 pip装torch装错版本CPU训练等到怀疑人生现象代码一步不差照着抄训练时每个epoch要跑六七十秒30轮下来将近四十分钟再加上改参数重试答辩前完全来不及。原因大多数情况下是装成了CPU版本的PyTorch。pip install torch默认安装的版本在Windows上经常不带CUDA支持即使电脑有NVIDIA显卡也用不上。也有可能是电脑根本没有独立显卡或显卡太旧驱动版本和CUDA版本不匹配。解决先在你的Python环境里执行import torch; print(torch.cuda.is_available())。如果输出False去PyTorch官网找到对应Commit的安装命令安装CUDA版本。如果机器确实没有N卡就接受现实把batch_size降到32epoch降到15图像尺寸不要强行放大这样CPU训练也能在20分钟左右跑完。5.2 灰度图与三通道图不一致报错信息看得头皮发麻现象训练刚跑第一个batch就报错提示expected input[64, 1, 48, 48] to have 3 channels, but got 1 channels instead或者反过来。这类通道数错误的报错通常很长新手很容易被吓住。原因torchvision官方ResNet的第一层是3通道卷积输入必须是有3个通道的RGB图。FER2013是灰度图只有1个通道。如果你改了输入通道却没改数据或者改了数据却没改模型都会在形状对不上时报错。解决如果使用本文第四章的改法model.conv1 nn.Conv2d(1, 64, ...)那么输入必须是1通道灰度图。不要在灰度图上cv2.cvtColor转成BGR三通道再送进网络否则维度变成(1, 3, 48, 48)而模型期望的是(1, 1, 48, 48)依旧报错。反过来如果你在别人源码里看到模型没改conv1说明对方是把灰度图复制成三通道再训练的两种方案选其一不要混用。5.3 Haar人脸框偏大表情区域总是被头发和背景污染现象摄像头演示时人脸稍微离镜头近一点模型就稳定输出neutral或者happy怎么换角度都救不回来离线测试单人脸图片却表现正常。原因OpenCV的Haar级联检测器输出的框是整个人头区域而表情信息主要集中在中脸区域——从眉毛到下巴。框把额头和头发都包进去了等于强行给模型输入了一堆与表情无关的像素。解决在把人脸框交给表情模型前向内侧收缩20%。假设检测框是(x, y, w, h)那么让x 0.15 * w、y 0.15 * h、w - 0.3 * w、h - 0.3 * h再裁剪。这一小段逻辑在多人脸或单人脸检测里都适用。注意收缩后要检查w和h仍然大于0否则cv2.resize会报错。5.4 类别不均衡导致少数类别学不出来混淆矩阵整行为0现象训练完看混淆矩阵disgust那一行几乎全是0其他类别也有互相混着分不清的现象但整体准确率看起来还可以。原因FER2013的disgust类样本量远小于happy和neutral模型从训练中见到的disgust太少决策边界被多数类推着走。解决先统计类别样本数量计算每个类别的权重然后传给损失函数。class_counts Counter(train_labels) total sum(class_counts.values()) weights torch.tensor( [total / class_counts[i] for i in range(7)], dtypetorch.float32 ).to(device) criterion nn.CrossEntropyLoss(weightweights)逻辑说明total / class_counts[i]的含义是让样本数越少的类别权重越大计算得到的每个样本的损失乘以对应类别权重后少数类的梯度贡献被放大模型不再倾向于忽略它。参数说明如果加了weight之后发现少数类recall上去了但整体准确率掉下来这是正常的权衡结果。答辩时你可以说「在类别不均衡条件下更关注少数类识别率」这比死守整体准确率更有说服力。5.5 答辩被问「CNN每一层在做什么」答不上来现象老师问你为什么网络能识别表情你只能背「卷积提取特征」再往深处问就卡住了。原因没有真正看过自己模型的中间输出心里没底。解决答辩前做两件事。第一用torchsummary打印模型每一层输出尺寸和参数量做到对conv1、layer1到layer4、fc的shape变化了如指掌。from torchsummary import summary summary(model, (1, 48, 48))第二随便拿一张验证集图片把第一层卷积后的特征图抽出来画成网格能直观看到边缘、纹理一类的低级特征把最后一个卷积层后的特征图画出来能看到和表情相关的语义区域。哪怕只画这两个阶段老师也会认为你真正理解了网络在干什么。6. 让项目体面收尾loss曲线、混淆矩阵与一键复现6.1 画loss曲线与准确率曲线训练结束后把每个epoch的训练loss和验证准确率单独存成列表用matplotlib画图是期末答辩里性价比最高的可视化操作。两张图能直接证明训练过程没有过拟合、没有震荡。import matplotlib.pyplot as plt plt.plot(train_losses, labeltrain_loss) plt.plot(val_losses, labelval_loss) plt.legend() plt.xlabel(epoch) plt.ylabel(loss) plt.savefig(loss_curve.png, dpi150)6.2 输出混淆矩阵与分类报告混淆矩阵比单独一个准确率数字更有信息量它能展示模型到底把哪些类别互相搞混了。from sklearn.metrics import confusion_matrix, ConfusionMatrixDisplay, classification_report cm confusion_matrix(all_labels, all_preds) ConfusionMatrixDisplay(cm, display_labelslist(label_map.values())).plot(cmapBlues) plt.xticks(rotation45) plt.tight_layout() plt.savefig(confusion_matrix.png, dpi150) print(classification_report(all_labels, all_preds, target_nameslist(label_map.values()), zero_division0))6.3 一键复现的入口脚本最后把训练、推理、可视化三个入口统一成命令再补一个requirements.txt锁版本源码交给老师时对方能直接复现。python train.py python webcam_demo.py我个人习惯在交源码前做一次彻底的断网环境复测新建一个虚拟环境按requirements安装依赖跑通训练验证全流程。期末项目最忌讳「在我电脑上能跑」这种话一旦说出口基本等于主动给答辩挖坑。每一份源码交付前都该走一遍这条验证流程这也让评估老师能顺畅复现。希望帮到你。本文还有配套的精品资源点击获取
返回列表