OpenCV+ResNet人脸表情识别实战:从检测到分类的完整实现

发布时间:2026/9/11 20:59:42
OpenCV+ResNet人脸表情识别实战:从检测到分类的完整实现 简介面向Python深度学习初学者的期末大作业完整项目包实现基于ResNet的人脸表情识别。项目涵盖图像预处理尺寸调整、归一化、ResNet残差网络定义、FER2013等数据集准备与数据增强、交叉熵损失训练、Adam优化器配置、混淆矩阵可视化及视频实时测试等完整流程适合作为课程设计参考或深度学习入门练习。资源共16个文件包括3个Python脚本模型定义、训练、测试、表情分类图片、配置文件、说明文档、依赖清单和演示视频压缩包仅5.2MB轻量易部署。已有235人学习下载。通过该资源可快速复现人脸表情识别流程掌握残差网络在图像分类任务中的实际应用并借助可视化代码与演示视频理解模型训练效果和预测结果。1. 为什么期末大作业里最适合用ResNet做人脸表情识别这学期帮人看了一份期末大作业压缩包解开之后结构很干净model.py定义 ResNettest.py做推理dataset放训练数据model目录存权重最显眼的是根目录下的haarcascade_frontalface_default.xml。它把 OpenCV 的人脸检测和 ResNet 的表情分类串成一条完整链路。做表情识别最容易被忽视的一点是先定位人脸再分类表情而不是把整张图直接丢给网络。这份作业选择两阶段方案而不是端到端检测非常务实。读完这篇你会清楚每个文件在链路里的位置知道预处理为什么通常是灰度加直方图均衡化以及把video里的 jntm.mp4 跑通时哪些参数值得调。适合正在补深度学习大作业、或者想用 Keras 加 OpenCV 快速复现一套表情识别流程的读者。2. 人脸检测与预处理从OpenCV帧到ResNet需要的224×224输入2.1 为什么先检测人脸再分类表情分类器的训练数据绝大多数是裁剪好的人脸FER2013 这类公开数据集在发布前就完成了人脸对齐。推理时如果把整张 1920×1080 的照片直接 resize 到 224×224背景和衣物会占掉大部分像素模型看到的根本不是一个“脸”而是“场景”。haarcascade_frontalface_default.xml是 OpenCV 官方训练的 frontal face 检测器基于 AdaBoost 加 Haar 特征在 CPU 上也能跑到实时误检率对表情识别这个任务完全可接受。它的局限也很明确对正脸和轻微偏转的人脸效果好大角度侧脸和低头场景容易漏检所以预处理结果里经常会出现“这一帧没检测到人脸”的情况这在代码里要做空结果判断。2.2 预处理全流程与参数卡从原始帧到 ResNet 的输入张量中间要经历灰度化、直方图均衡化、人脸检测、裁剪、缩放、归一化这几个步骤。每步的目标和常用参数如下步骤目标常用参数与说明BGR 转灰度降低通道冗余Haar 检测速度更快cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)直方图均衡化增强人脸区域对比度缓解侧光影响cv2.equalizeHist(gray)人脸检测定位人脸框scaleFactor1.1minNeighbors5minSize(48,48)裁剪并 resize统一模型输入尺寸cv2.resize(face, (224,224))归一化匹配预训练权重分布tf.keras.applications.resnet50.preprocess_inputscaleFactor控制检测窗口每次缩小的比例越小检测越精细但耗时越高minNeighbors是候选框最少保留的邻居数量越大误检越少但漏检越多minSize设置为 48×48 是因为表情识别在低分辨率下依然有判别性小于这个尺寸的框大概率是噪声。2.3 可复用的预处理函数下面这段代码把检测和裁剪封装成一个函数兼顾单张图片和视频帧两种场景import cv2 import numpy as np def crop_face(image, face_cascade, target_size(224, 224), padding0.2): gray cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) gray cv2.equalizeHist(gray) faces face_cascade.detectMultiScale( gray, scaleFactor1.1, minNeighbors5, minSize(48, 48)) if len(faces) 0: return None, None # 选面积最大的框视频场景下人脸远近变化时更稳定 x, y, w, h max(faces, keylambda f: f[2] * f[3]) # padding 把额头和下巴包进来避免只裁到眉眼 px, py int(w * padding), int(h * padding) x0, y0 max(0, x - px), max(0, y - py) x1, y1 min(image.shape[1], x w px), min(image.shape[0], y h py) face image[y0:y1, x0:x1] face cv2.resize(face, target_size) return face, (x0, y0, x1 - x0, y1 - y0)代码里做了两件容易忽略的事一是多个检测框时选面积最大的而不是取第一个二是给原始框加了 padding。实测中直接按(x, y, w, h)裁剪经常把下巴切掉如果数据集里人脸本身就比较大padding 建议在 0.15 到 0.25 之间调。函数返回None时要让调用方跳过当前帧而不是抛异常终止整个视频推理。2.4 裁剪细节决定准确率下限同样一份权重有人跑出来一帧一换标签有人跑出来稳定输出差的就是裁剪边界。minNeighbors调成 5 后误检会少很多但带来的副作用是某些真脸被跳过。我在做视频推理时会退一步当前帧没检测到人脸就沿用上一帧的检测框坐标。这个技巧在侧面转头场景里特别有用框不会突然消失推理结果也就不会频繁跳变。另外注意preprocess_input内部做的是减均值除标准差不要再额外除以 255否则输入分布完全偏离预训练权重期望的分布。3. 残差结构与model.py为什么ResNet比VGG更适合表情识别3.1 退化问题与恒等映射VGG 这类网络在层数加深到一定程度后训练集上的误差反而会升高这不是过拟合而是优化困难导致的退化问题。ResNet 的核心改动是让每个模块学习残差F(x) H(x) - x原始映射变成H(x) F(x) x。这样求梯度时路径上始终有一条恒等连接反向传播的信号可以不经过卷积层直接回传到浅层梯度消失被明显缓解。表情识别任务输入的人脸结构高度相似差异性集中在眼部、嘴部这些局部区域残差结构能让网络在较深的情况下依然保留浅层的纹理和边缘信息这对捕捉嘴角上扬幅度、眼睛开合程度这类细微变化很有利。3.2 model.py中的两种落地方式model.py在这个包里只负责模型定义不负责训练。写 ResNet 有两条路直接调 Keras 内置的ResNet50或者自己实现 BasicBlock。内置版本适合快速出结果自定义版本适合答辩时被问到“你讲讲残差块怎么实现的”。3.2.1 直接调用Keras内置ResNetimport tensorflow as tf def build_resnet50(num_classes7, input_shape(224, 224, 3)): base tf.keras.applications.ResNet50( weightsimagenet, include_topFalse, input_shapeinput_shape, poolingavg ) x tf.keras.layers.Dense(512, activationrelu)(base.output) x tf.keras.layers.Dropout(0.3)(x) out tf.keras.layers.Dense(num_classes, activationsoftmax)(x) model tf.keras.Model(inputsbase.input, outputsout) return modelinclude_topFalse去掉 ImageNet 的 1000 类分类头poolingavg用全局平均池化把特征图压成向量再拼接一个 512 维全连接层和 7 维输出层。Dropout(0.3)是防止小数据集上全连接层过拟合的常规手段。期末作业的数据量通常在几千到几万张微调时学习率建议从1e-4起步而不是默认的1e-3否则预训练权重很快被破坏。3.2.2 自定义BasicBlock实现import tensorflow as tf from tensorflow.keras import layers class BasicBlock(layers.Layer): def __init__(self, filters, stride1): super().__init__() self.conv1 layers.Conv2D(filters, 3, stridesstride, paddingsame) self.bn1 layers.BatchNormalization() self.conv2 layers.Conv2D(filters, 3, strides1, paddingsame) self.bn2 layers.BatchNormalization() if stride ! 1: self.shortcut tf.keras.Sequential([ layers.Conv2D(filters, 1, stridesstride), layers.BatchNormalization() ]) else: self.shortcut None def call(self, inputs, trainingFalse): x tf.nn.relu(self.bn1(self.conv1(inputs), trainingtraining)) x self.bn2(self.conv2(x), trainingtraining) if self.shortcut is not None: inputs self.shortcut(inputs) return tf.nn.relu(x inputs)这里的关键是stride ! 1时用 1×1 卷积做 shortcut 的通道对齐否则输入输出形状不一致加法会直接报错。x inputs就是残差连接的实现BatchNorm 在训练和推理时的行为不同所以call里要透传training参数。整体堆叠时每个 stage 的第一层 stride 取 2 做下采样通道数翻倍结构跟 ResNet18 对齐。3.3 模型深度怎么选模型参数量适合场景本项目的取舍ResNet18约 11M小数据集、CPU 推理优先推荐训练快够用ResNet34约 21M中等规模数据可尝试收益有限ResNet50约 25M大数据量 预训练需要 ImageNet 权重训练更慢qqemo目录下的 Angry、Disgust、Fear、Happy、Neutral、Sad、Surprise 七个表情文件决定了输出层必须是 7。项目里选用 ResNet50 也行但期末作业的数据量下 50 层的优势体现不出来训练时间却成倍增加。我一般先跑 ResNet18 确认流程正确再根据混淆矩阵决定要不要换成更深的版本。注意加载weightsimagenet时输入必须是三通道 RGB灰度图要复制成三通道才能过预训练模型否则到Dense层形状对不上。4. 数据集组织、训练超参与混淆矩阵评估4.1 dataset/目录结构与class_indices.jsonKeras 的flow_from_directory要求数据集按“训练集/类别/图片”三层组织。dataset下如果分成train和val两个目录每个目录里再放七个表情子目录代码里通过train_generator.class_indices就能拿到类别到索引的映射这份映射在训练结束后被保存为class_indices.json。这个文件在test.py里承担逆向映射作用模型输出的是索引编号要查这个 JSON 才能变成人类可读的标签。需要提醒的是目录名按字符串排序如果train/下出现了多余的子目录生成的映射就会和qqemo里的七张示例图对不上推理结果自然全乱。4.2 ImageDataGenerator数据增强参数from tensorflow.keras.preprocessing.image import ImageDataGenerator train_gen ImageDataGenerator( rescale1.0 / 255, rotation_range10, width_shift_range0.1, height_shift_range0.1, horizontal_flipTrue, zoom_range0.1, fill_modenearest ) val_gen ImageDataGenerator(rescale1.0 / 255)训练集做了小幅旋转、平移、翻转和缩放验证集只做了归一化这是防止数据泄露的基本要求。表情识别里rotation_range不建议超过 15 度人脸的语义方向性很强旋转 30 度以上就不像自然状态下的表情了。horizontal_flipTrue依赖人脸左右对称性Fear 和 Surprise 这类眼睛睁大的表情翻转后依然保留类别特征是性价比最高的增强方式。注意如果使用第 3 章里带预训练的模型rescale1.0/255要和preprocess_input二选一两者叠加相当于输入分布被二次缩放。4.3 训练入口缺失时怎么补这个包的根目录下没有直接的train.pymodel.py负责模型定义README 里通常只写了训练逻辑需要自己补。常见做法是单独建一个训练脚本把数据生成器和模型拼起来model.compile( optimizertf.keras.optimizers.Adam(learning_rate1e-4), losscategorical_crossentropy, metrics[accuracy] ) callbacks [ tf.keras.callbacks.ModelCheckpoint( model/emotion_model.h5, save_best_onlyTrue), tf.keras.callbacks.ReduceLROnPlateau( monitorval_loss, factor0.5, patience3) ] model.fit( train_gen, validation_dataval_gen, steps_per_epochtrain_gen.n // 32, epochs30, callbackscallbacks )损失函数选交叉熵是因为七类互斥优化器选 Adam 是因为它自适应调整学习率期末作业场景下不需要手动调动量参数。ReduceLROnPlateau在验证损失连续三个 epoch 不下降时把学习率减半这比固定学习率硬跑到底要稳定得多。以下给出常用超参的参考范围参数建议值说明优化器Adam收敛快对学习率不敏感初始学习率1e-4微调预训练权重时偏保守Batch Size32显存不足时降为 16Epochs30 ~ 50配合早停看验证 loss 决定4.4 cousion_matrix.py混淆矩阵与常见混淆对cousion_matrix.py是 confusion matrix 的笔误import 时要按实际文件名来。它做的事情是对验证集批量预测再统计真实类别和预测类别的交叉计数from sklearn.metrics import confusion_matrix, classification_report import seaborn as sns import matplotlib.pyplot as plt import numpy as np y_true, y_pred [], [] for i in range(len(val_gen)): x_batch, y_batch val_gen[i] pred model.predict(x_batch) y_true.extend(np.argmax(y_batch, axis1)) y_pred.extend(np.argmax(pred, axis1)) cm confusion_matrix(y_true, y_pred) sns.heatmap(cm, annotTrue, fmtd, cmapBlues, xticklabelslabels, yticklabelslabels) plt.ylabel(True Label) plt.xlabel(Predict Label) plt.show()读混淆矩阵时重点看对角线以外的密集区域。Happy 和 Neutral 经常互混因为中性表情和微笑的嘴角幅度边界模糊Surprise 和 Fear 也容易混两者都伴随眼睛睁大。如果某一行除了对角线外还有明显的次高峰优先检查对应类别的训练图片是不是本身就有标注噪声。5. test.py推理实战图片、摄像头与jntm.mp4视频5.1 test.py的推理管线test.py的完整流程可以拆成五步加载模型权重、加载 Haar 检测器、读取输入图像或视频帧、对每个人脸框执行预处理和预测、把标签画在框上并输出。下面是单张图片推理的核心代码import json import cv2 import numpy as np import tensorflow as tf with open(class_indices.json, r) as f: idx_to_class {v: k for k, v in json.load(f).items()} model tf.keras.models.load_model(model/emotion_model.h5) face_cascade cv2.CascadeClassifier(haarcascade_frontalface_default.xml) img cv2.imread(test.jpg) face, bbox crop_face(img, face_cascade) if face is None: print(no face detected) else: x_input tf.keras.applications.resnet50.preprocess_input( face.astype(np.float32)) preds model.predict(np.expand_dims(x_input, axis0))[0] idx int(np.argmax(preds)) label idx_to_class[idx] cv2.rectangle(img, (bbox[0], bbox[1]), (bbox[0] bbox[2], bbox[1] bbox[3]), (0, 255, 0), 2) cv2.putText(img, f{label} {preds[idx]:.2f}, (bbox[0], bbox[1] - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 255, 0), 2)predict需要四维输入(batch, height, width, channels)单张图要显式加 batch 维度否则会报维度不匹配。idx_to_class把 JSON 的键值反转让预测索引能查到对应的表情名称。画框时显示置信度百分比的目的是调试如果一张图上频繁出现低置信度预测说明检测框位置或预处理需要调整而不是模型坏了。model目录下的权重文件名以训练脚本保存时为准load_model加载的是完整模型结构加参数。5.2 视频推理与jntm.mp4视频推理只是把图片推理放进循环里但有两个新增问题帧率控制和结果输出。video目录下的 jntm.mp4 是测试素材处理后的视频需要用VideoWriter写回磁盘输入方式关键代码输出格式图片cv2.imread(path)标注后的图片视频文件cv2.VideoCapture(video/jntm.mp4)标注后的 mp4 文件摄像头cv2.VideoCapture(0)实时窗口cap cv2.VideoCapture(video/jntm.mp4) fourcc cv2.VideoWriter_fourcc(*mp4v) out cv2.VideoWriter(output.mp4, fourcc, 30.0, (int(cap.get(3)), int(cap.get(4)))) while cap.isOpened(): ret, frame cap.read() if not ret: break face, bbox crop_face(frame, face_cascade) if face is not None: x_input tf.keras.applications.resnet50.preprocess_input( face.astype(np.float32)) preds model.predict(np.expand_dims(x_input, axis0))[0] idx int(np.argmax(preds)) label idx_to_class[idx] cv2.rectangle(frame, (bbox[0], bbox[1]), (bbox[0] bbox[2], bbox[1] bbox[3]), (0, 255, 0), 2) cv2.putText(frame, label, (bbox[0], bbox[1] - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 255, 0), 2) out.write(frame) cv2.imshow(emotion, frame) if cv2.waitKey(1) 0xFF ord(q): break cap.release() out.release() cv2.destroyAllWindows()fourcc编码格式选mp4v兼容性最好直接写默认的MP4V在某些环境会输出空文件。每一帧都调用一次model.predict速度较慢如果只追求演示效果可以每三帧推理一次中间帧沿用上一帧的结果。5.3 先看这三个报错第一个是cv2.error: OpenCV(4.x) ... !_cascade.load说明CascadeClassifier的路径不对test.py被命令行从别的目录调用时相对路径会失效统一用os.path.join(os.path.dirname(__file__), ...)拼绝对路径最稳妥。第二个是ValueError: Input 0 of layer ... is incompatible with the layer检查输入是不是(224, 224, 3)且带 batch 维度灰度图没有复制成三通道也会触发这个错误。第三个是UnknownError: Failed to get convolution algorithm通常是显存不足或 cuDNN 版本不匹配把 batch size 降到 1 能临时绕过根治要统一 TensorFlow 和显卡驱动版本。6. 精度再进一步类不均衡、概率平滑与微调顺序6.1 用类别权重修正样本不平衡表情数据集里 Neutral 和 Happy 的样本数往往远多于 Fear、Disgust模型会倾向于把不确定的样本预测成高频类。class_weight可以在不改变数据分布的情况下惩罚错分低频类计算方式是n_samples / (n_classes * n_class_samples)import numpy as np class_counts np.array([1200, 800, 300, 350, 1500, 750, 400]) total class_counts.sum() class_weight { i: total / (len(class_counts) * count) for i, count in enumerate(class_counts) }把字典传给model.fit(class_weightclass_weight)即可。类别权重让 Fear 和 Disgust 的梯度贡献变大代价是整体准确率可能略降但混淆矩阵上低频类的召回率会明显改善。6.2 视频推理的softmax滑动平均单帧预测的抖动通常不是模型问题而是检测框在连续帧间有微小位移导致裁剪区域变化。对输出概率做指数加权平均是成本最低的平滑方式ewma_pred None alpha 0.7 for each frame: preds model.predict(...) if ewma_pred is None: ewma_pred preds else: ewma_pred alpha * ewma_pred (1 - alpha) * preds final_label idx_to_class[int(np.argmax(ewma_pred))]alpha0.7表示历史概率占 70%当前帧占 30%。值越大输出越稳定但对真实的表情切换响应变慢值越小越灵敏0.5 时基本只能消除单帧毛刺0.8 时适合展示用。6.3 微调预训练模型的正确顺序如果决定用 ResNet50 预训练权重不要一上来就全量微调。第一步冻结全部卷积层只训练新加的全连接层几个 epoch让新分类头先适配表情特征分布第二步解冻最后两个残差块的卷积层学习率降到1e-5继续训练最后再考虑解冻更多层。跳步操作经常导致 Loss 震荡因为随机初始化的分类头在第一步产生的梯度太大回传会冲刷掉预训练参数。另外把minNeighbors从 5 提到 6、给检测框加 0.2 的 padding这两项改动往往比换模型更直观地提升识别稳定度。本文还有配套的精品资源点击获取