ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Python面部表情识别实战:CNN二分类与OpenCV实时检测

Python面部表情识别实战:CNN二分类与OpenCV实时检测 简介基于Python的面部表情识别分析系统是一份面向计算机视觉课程设计与毕业设计的完整项目资料覆盖图像处理、图像分析与深度学习建模流程。压缩包共16个文件以10个Python源码为主包括CNN模型、表情分类器、训练脚本和图像预处理工具另有设计报告Word、技术说明PDF与Markdown、授权文件整体仅4.43MB结构清晰。目前已有835人学习下载适合初学Keras/TensorFlow与表情识别任务的学生。项目针对高兴与沮丧两类情绪做二分类识别实验数据各5000张表情图像借助VoTT完成标注并用OpenCV、PIL实现图片裁剪、灰度转换等预处理源码中提供从数据转换到模型训练的完整脚本设计报告则说明了图像识别原理、实验设计与结果评估可帮助读者快速复现系统或迁移到其他图像分类项目也可作为课程作业和毕设的现成参考。1. 基于 Python 的面部表情识别系统课程设计里的二分类实战基线期末周一边改论文一边调模型的场景做过课程设计的都懂。这套基于 Python 的面部表情识别分析系统是一个标准的二分类实战项目情绪只分高兴和沮丧两类每类 5000 张图用 Keras TensorFlow 搭 CNN 训练配套 OpenCV、PIL 做图像预处理。资源里 src 目录有十个 Python 脚本从图片下载、人脸裁剪、CSV 与灰度图互转到训练和推理全链路都覆盖了。对这个项目最值得拿走的不是网络结构本身——两层卷积也能跑——而是那套数据转换脚本和设计报告。适合正在做课程设计、毕业设计或者想快速走一遍完整 CV 流程的人新手照着跑能出结果熟手能直接替换数据训练自己的分类器。2. 数据链路搭起来从 10000 张图到灰度 CSV 的预处理全流程2.1 数据设定二分类与每类 5000 张的最初动机面部表情识别最常见的参照是 FER2013那套数据集有 7 类表情共 35000 多张灰度图。但课程设计不放那么大的数据集是有原因的类别多、样本不均衡新手调半天 loss 都降不下去最后答辩时 acc 卡在 60% 很尴尬。改成高兴和沮丧二分类之后问题性质完全变了——10000 张图、两个类别哪怕用一台没有 GPU 的笔记本CPU 跑 30 个 epoch 也能看到明显的收敛曲线。这个资源的设计报告和技术文档里也交代了同样的思路图像识别分图像处理和图像分析两步处理阶段做拉伸缩放、旋转翻转、颜色变换分析阶段用深度学习方法做分类。二分类正好能把这两步都走一遍又不至于把时间耗在调参上。src 目录里 convert_csv2gray.py 和 convert_file.py 这类脚本的存在也说明数据不是直接拿来用的中间经过了至少一层格式转换。数据量的选择也值得说一句每类 5000 张总数上万对 CNN 来说刚过门槛。太少会严重过拟合太多在 CPU 上训练时间不可接受。5000 这个数字是课程设计场景下比较折中的选择我自己后来做类似项目起步也按这个量级来。2.2 image_download.py 与 image_crop.py下载、裁剪、对齐三步走资源里有独立的图片下载脚本说明数据来源可能不是单一现成数据集而是有一部分靠爬取。爬图这件事水很深但 image_download.py 至少把代码框架给你了你需要改的通常是搜索关键词、图片数量和保存路径。更关键的其实是 image_crop.py——这个脚本做的事是拿 OpenCV 自带的 Haar 级联分类器做人脸检测把检测到的人脸从原图里裁出来。import cv2 import os face_cascade cv2.CascadeClassifier( cv2.data.haarcascades haarcascade_frontalface_default.xml ) def crop_faces(src_dir, out_dir, margin_ratio0.2): for file in os.listdir(src_dir): path os.path.join(src_dir, file) img cv2.imread(path) if img is None: continue gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) faces face_cascade.detectMultiScale( gray, scaleFactor1.1, minNeighbors5) for i, (x, y, w, h) in enumerate(faces): margin int(w * margin_ratio) x0, y0 max(0, x - margin), max(0, y - margin) x1, y1 x w margin, y h margin crop img[y0:y1, x0:x1] cv2.imwrite(os.path.join(out_dir, f{file}_face_{i}.jpg), crop)逻辑说明detectMultiScale 是 VJ 人脸检测器的核心接口返回的是人脸矩形列表。margin_ratio 用于给裁剪框留边距避免把额头、下巴切掉——表情识别的关键特征在眉眼和嘴角留边非常必要。参数说明scaleFactor1.1 表示每层缩放 1.1 倍越小越慢但检出越全minNeighbors5 表示至少邻域内 5 个窗口确认才算人脸太大容易漏检太小误检多。裁剪后建议统一 resize 到 48×48 或 64×64这个尺寸要和后面训练输入一致。2.3 convert_csv2gray.py 与 convert_file.py灰度图与 CSV 互转的两种姿势如果数据来源是 FER2013 那种 CSV 格式convert_csv2gray.py 就是入口。每行一列标签加 2304 个像素值需要把像素行 reshape 成 48×48 的矩阵再存成图片。反过来如果你已经有一堆图片也可以把图像数据转回 CSV方便用 pandas 做数据分析。convert_file.py 干的大概率是这件事批量做文件格式转换、尺寸统一、重命名。这类脚本不复杂但在课程设计里最好用。import pandas as pd import numpy as np from PIL import Image def csv2gray(csv_path, out_dir, img_size48): df pd.read_csv(csv_path) labels df[emotion] pixels df.drop(emotion, axis1).values for i, (label, row) in enumerate(zip(labels, pixels)): img_arr row.reshape(img_size, img_size).astype(np.uint8) img Image.fromarray(img_arr, modeL) img.save(f{out_dir}/{label}_{i:05d}.png)逻辑说明CSV 里每个样本的形状是 1×2304reshape 成 48×48 就和原图一致了。归一化放在模型训练阶段做转图这一步只负责还原原始灰度。参数说明img_size 必须和训练用的尺寸严格一致改成 64 之前先确认 CSV 里每行像素总数是 4096modeL 表示 8 位灰度信息够用且省训练时间。文件命名里带 label 前缀是刻意设计的——后面 emotion_train_self.py 可以直接从文件名切分标签不用单独维护一份映射表。2.4 src 目录文件清单先搞清楚每个脚本是干什么的再动手拿到压缩包别急着双击跑。先解压看一眼 emotionrecognition 目录结构README.md、LICENSE、技术.pdf、技术.md、设计报告.docx对应的是项目说明、协议、技术文档和答辩用的设计报告。src 目录下是代码下表是我按命名和模块推断的职责跑之前先对照确认。文件名职责推断CNN.py网络结构定义Sequential 搭的卷积网络emotion_train_self.py训练主脚本负责数据读取和 fitemotion_classifier.py推理主脚本加载权重做预测emotion_classifier0.py / emotion_classifier00.py历史迭代版本可能是旧接口的单图/批量预测convert_csv2gray.pyCSV 像素转灰度图convert_file.py图片格式或尺寸批量转换emotion_file.py与 label、文件名相关的处理image_download.py图片采集image_crop.py人脸检测与裁剪建议先把 emotion_classifier.py 打开读一遍确认它用的是 load_model 还是 load_weights这会决定你要不要先跑训练脚本。旧版本文件不用急着全看懂迭代过程中留几个半成品很正常重点盯主版本三个文件emotion_train_self.py、emotion_classifier.py、CNN.py。3. CNN 训练主流程从零搭网络到 emotion_train_self.py 跑通3.1 CNN.pySequential 堆出来的卷积网络长什么样课程设计场景的 CNN 不需要很花哨。这个项目是二分类、灰度图、1 万张数据两层卷积加两层全连接是完全够用的配置。CNN.py 里大概率是这样一种结构from tensorflow.keras.models import Sequential from tensorflow.keras.layers import (Conv2D, MaxPooling2D, Flatten, Dense, Dropout, BatchNormalization) model Sequential([ Conv2D(32, (3, 3), activationrelu, paddingsame, input_shape(48, 48, 1)), BatchNormalization(), MaxPooling2D((2, 2)), Conv2D(64, (3, 3), activationrelu, paddingsame), MaxPooling2D((2, 2)), Dropout(0.25), Flatten(), Dense(128, activationrelu), Dropout(0.5), Dense(2, activationsoftmax) ])逻辑说明第一层卷积从 1 通道灰度图提取 32 个特征图paddingsame 保证尺寸不变池化层把特征图缩小一半减少参数量的同时增强平移不变性第二个卷积块把通道数翻倍到 64这是常见做法因为浅层特征少、深层需要更多特征表达。全连接前的 Dropout(0.25) 和全连接后的 Dropout(0.5) 都是防过拟合的——1 万张图对 CNN 来说不算多不加 Dropout 训练集 acc 很快到 99%验证集只有 80%。最后一层是两个神经元配 softmax输出高兴、沮丧两个类别的概率分布。参数说明input_shape 里的 48×48 要和预处理阶段的裁剪尺寸一致改成 64×64 的话这一行必须改否则模型根本建不起来。BatchNormalization 放卷积层和激活函数之间是常见位置它对这种小规模数据集收敛速度帮助很明显。3.2 emotion_train_self.py训练脚本的读取方式与标签生成训练脚本的核心是把图片批量喂给模型。常见做法是用 Keras 的 ImageDataGenerator 配合 flow_from_directory这样标签是自动从子目录名生成的省去手写标签的麻烦。目录结构建议这样组织data/ ├── train/ │ ├── happy/ │ └── sad/ └── val/ ├── happy/ └── sad/from tensorflow.keras.preprocessing.image import ImageDataGenerator train_datagen ImageDataGenerator( rescale1./255, rotation_range10, width_shift_range0.1, height_shift_range0.1, horizontal_flipTrue ) val_datagen ImageDataGenerator(rescale1./255) train_generator train_datagen.flow_from_directory( data/train, target_size(48, 48), color_modegrayscale, batch_size32, class_modecategorical) val_generator val_datagen.flow_from_directory( data/val, target_size(48, 48), color_modegrayscale, batch_size32, class_modecategorical)逻辑说明rescale1./255 是把 0~255 像素值压到 0~1这步不做模型很难收敛。rotation_range 和 shift 系列是数据增强用随机旋转和位移模拟拍照时的角度变化对表情识别特别有用——人歪一点头不代表情绪变了。val_datagen 只做归一化不增强验证集要保留原始分布加了增强会污染评估结果。参数说明target_size 必须与 CNN.py 的 input_shape 对应color_modegrayscale 读入单通道你要是漏了这个参数模型输入是 1 通道而数据是 3 通道直接报维度错误class_modecategorical 对应 softmax 输出要用 binary 的话最后一层就得改成 1 个神经元加 sigmoid两者配套不能混。3.3 训练参数怎么设epochs、batch_size、学习率的课程设计选择课程设计里最常见的失误是照抄别人的训练参数。这个资源是二分类、1 万张图以下参数是我在这个场景下反复试过比较稳的组合。from tensorflow.keras.callbacks import ModelCheckpoint, EarlyStopping checkpoint ModelCheckpoint(best_emotion.h5, monitorval_acc, save_best_onlyTrue) earlystop EarlyStopping(monitorval_loss, patience5, restore_best_weightsTrue) model.compile(optimizeradam, losscategorical_crossentropy, metrics[accuracy]) history model.fit( train_generator, steps_per_epochtrain_generator.samples // 32, validation_dataval_generator, validation_stepsval_generator.samples // 32, epochs30, callbacks[checkpoint, earlystop] )逻辑说明ModelCheckpoint 监控 val_acc每次验证集准确率比之前好就把权重存一次这样即使后面过拟合了best_emotion.h5 还是最优版本。EarlyStopping 监控 val_loss连续 5 个 epoch 不改善就提前停省时间。epochs 定 30 是因为二分类在 1 万张图上通常在 15~25 个 epoch 达到峰值给到 30 足够剩下的靠 EarlyStopping 兜底。参数说明batch_size 用 32 是平衡点太小梯度震荡厉害太大 CPU 上单次迭代慢optimizer 用 adam 是省心选择它自带自适应学习率不用像 SGD 那样手动调 decay对课程设计来说 adam 默认学习率基本能直接跑。steps_per_epoch 写 train_generator.samples // 32 是让每轮迭代次数正好覆盖所有训练样本不写的话 Keras 会有警告但也能跑。跑完的产物有两个best_emotion.h5 是模型文件和权重history 对象里存了每个 epoch 的 acc 和 loss——这个 history 后面要拿来画曲线图别把它丢了或者覆盖掉。要是训练过程报维度错误先回头看 3.1 的 input_shape、3.2 的 target_size 和 color_mode这三个参数任何一个不一致都会在第一个 epoch 直接崩。4. 推理与结果分析emotion_classifier.py 从单图到批量判别的完整走法4.1 emotion_classifier.py 的主流程加载权重、预处理、predict训练完只是第一步课程设计答辩要现场演示推理。emotion_classifier.py 干的活就是加载模型对输入图片走一遍和训练一致的预处理然后输出类别。最关键的约束是推理时的预处理必须和训练时严格一致差一个归一化步骤结果就会偏。常见主流程是这样import numpy as np from tensorflow.keras.models import load_model from PIL import Image model load_model(best_emotion.h5) IMG_SIZE 48 def predict_face(img_path): img Image.open(img_path).convert(L) img img.resize((IMG_SIZE, IMG_SIZE)) arr np.array(img).astype(float32) / 255.0 arr arr.reshape(1, IMG_SIZE, IMG_SIZE, 1) prob model.predict(arr, verbose0)[0] label happy if prob[0] prob[1] else sad return label, prob逻辑说明convert(L) 把任意图片转成灰度resize 对齐训练尺寸除以 255 做归一化reshape 补上 batch 维度和通道维度。这些步骤少一步预测结果就可能是错的——特别是忘了除以 255 的情况模型从没见过 0~255 范围的输入输出概率会非常接近某一类但原因不明。label 的判断逻辑是二分类经典的 argmaxprob[0] 和 prob[1] 分别对应当前模型输出层的两个神经元。参数说明IMG_SIZE 必须和 train_generator 的 target_size 一致比如都是 48predict 里的 verbose0 是抑制进度条命令行里跑批量推理时不会刷屏如果报错提示模型里有未知层或自定义层说明 h5 的兼容性出了问题这个在第 5 章会讲具体解法。4.2 emotion_classifier0.py 与 emotion_classifier00.py两个历史脚本的边界src 里两个带后缀的脚本到底该不该用我的建议是先当旧版本对待不要浪费时间猜。这类带 0、00 后缀的文件在课程设计迭代过程里通常是为了某个验证临时改的或者用了已经废弃的 API比如旧 Keras 的 predict_classes。最常见的差异有两个一是它们可能没有加载 BatchNormalization 和 Dropout 层的权重就跑预测导致结果异常二是它们接受输入的方式可能不同——一个吃 CSV 行一个吃单张图片路径。怎么快速确认打开文件看这两点就够第一模型加载用的是 load_model 还是 load_weights前者直接恢复完整网络后者需要你手动重建 CNN 结构步骤多出错概率也大第二预处理部分有没有除以 255。如果两个文件都和主版本逻辑一致那随便用一个如果有出入直接用 emotion_classifier.py。这个资源的核心链路是 train → save → classify历史脚本只是备选的参考不必强求都用上。判断脚本边界有一个小技巧直接看最后 20 行看它打印输出的格式。如果输出是一行数字说明是模型内部的 logits 或概率如果有 if 判断输出 happy/sad说明已经处理成了最终结果。课程设计里最容易踩的时间陷阱就是在这些旧文件上反复试错最后发现主版本一句没改就能跑。4.3 用 OpenCV 接摄像头把离线分类器变成实时演示答辩时只跑单张图片演示效果远不如现场开摄像头实时识别。OpenCV 的 VideoCapture 读摄像头的帧人脸检测裁出脸部区域送进模型预测再把结果画在视频流上。VoTT 这个工具在这个链路里也有用——如果你想换成自己的数据集、标注新的表情类别VoTT 标注完导出的 CSV 可以走 convert_csv2gray.py 那套流程转成训练数据。import cv2 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, 1.1, 5, minSize(64, 64)) for (x, y, w, h) in faces: face gray[y:y h, x:x w] face cv2.resize(face, (IMG_SIZE, IMG_SIZE)) / 255.0 prob model.predict(face.reshape(1, IMG_SIZE, IMG_SIZE, 1))[0] label happy if prob[0] prob[1] else sad cv2.rectangle(frame, (x, y), (x w, y h), (0, 255, 0), 2) cv2.putText(frame, f{label} {max(prob):.2f}, (x, y - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 255, 0), 2) cv2.imshow(Emotion Recognition, frame) if cv2.waitKey(1) 0xFF ord(q): break cap.release() cv2.destroyAllWindows()逻辑说明每一帧先转灰度做人脸检测检测到的人脸区域直接送模型推理。这里的灰度转换和 resize 归一化是必须重复的因为摄像头输入是 BGR 彩色帧直接送模型会维度不匹配。minSize(64, 64) 是过滤掉太小的检测框——脸太小模型识别不了还白白增加 predict 次数。参数说明waitKey(1) 是每帧只等 1 毫秒保证视频流畅按 q 退出循环。实时显示时 CPU 可能跟不上常见做法是每 5 帧检测一次人脸、每隔几帧更新一次 label表现手法上可以用上一帧的结果填充中间帧视觉上会顺滑很多。如果摄像头打不开优先检查 VideoCapture(0) 的 0 是不是被占用换 1 试试这是回调用的第一排查项。5. 表情识别踩坑记录环境、数据、训练里的 5 个真实翻车现场5.1 现象Keras 报 AttributeError模型权重加载失败跑 emotion_classifier.py 时直接抛 AttributeError: Sequential object has no attribute predict_classes或者 load_model 时报 Unknown layer。原因资源是基于某个固定版本的 Keras 写的你本地装的 TensorFlow 版本太新很多旧 API 在 2.6 之后被移除predict_classes 就是重灾区。解决轻量改法是把 predict_classes 换成 predict 后手动 np.argmax一劳永逸的做法是装一个和资源年代匹配的环境推荐 tensorflow 2.4 或 2.6配 Python 3.7 或 3.8这个组合在课程设计资源里兼容性最高。装好之后用 python -c import tensorflow as tf; print(tf.version) 验证别跳过这一步。5.2 现象CPU 训练一个 epoch 要 40 分钟训练脚本跑起来epoch 进度条走一圈要半个多小时总共 30 个 epoch 根本等不起。原因有两个一是数据集本身可能是彩色大图没有走灰度转换和 resize二是 ImageDataGenerator 的旋转、平移增强是逐样本实时计算的CPU 上这步开销很大。解决先把所有图片离线转成 48×48 灰度图缩小数据量数据增强参数降下来width_shift_range 和 height_shift_range 都改成 0.05rotation_range 改 5 度自己的笔记本没有 GPU 的话把整个训练代码搬到 Colab 免费 GPU 上跑epochs 从 30 加到 50 都只要十几分钟。最后大招是先用 1000 张图跑 5 个 epoch验证链路没问题再上全量数据。5.3 现象准确率卡在 50% 上下跟抛硬币一样训练正常推进loss 也在降但 acc 始终在 0.5 附近震荡。原因标签和图像没有对齐。常见场景是 CSV 转换时行序错乱或者 flow_from_directory 的类别顺序和你手动标注的顺序反了——目录名 happy 排在前面生成标签时它在第一位但模型输入的第二类才是 happy整个训练过程就学到了一个随机映射。解决训练前打印一个 batch 的图片和标签人工核对用 matplotlib 可视化确认 happy 目录的图对应 [1, 0] 还是 [0, 1]另一个可能原因是数据没打乱1 万张图前 5000 全是 happy、后 5000 全是 sad分 batch 训练时每个 batch 内类别不平衡解决方法是 flow_from_directory 自带 shuffleTrue但 CSV 手动加载时记得自己 shuffle。5.4 现象测试集准确率很高真实照片一测就错模型的 val_acc 有 90% 以上但拿手机拍一张自己的脸测出来完全不对。原因训练数据是爬来的或 FER2013 的光照条件、拍摄角度、人脸占比都和你手机实拍差很多模型学到的特征在分布外数据上失效了。解决训练数据先全部过一遍 image_crop.py 的人脸裁剪保证所有人脸位置基本居中这一步影响非常大训练时加强数据增强特别是 brightness_range 和 horizontal_flip 这两个参数能在一定程度上模拟不同光照和左右脸差异预测时你的输入图也要先走同样的人脸裁剪流程而不是把整张背景图塞进模型。这属于典型的训练分布和推理分布不一致问题不解决调什么参数都没用。5.5 现象pycharm 里 import cv2 失败图片全读不进来在 PyCharm 里跑 image_crop.py报 ModuleNotFoundError: No module named cv2但命令行里又能正常导入。原因PyCharm 用的是项目解释器和系统的 Python 不是同一个环境你在命令行 pip install 的包装到了系统 Python 里PyCharm 根本看不到。解决先确认 PyCharm 右下角的 Python Interpreter 指向的是哪个路径然后直接在 PyCharm 的 Terminal 面板执行 pip install opencv-python opencv-contrib-python pillow pandas h5py或者用 PyCharm 的 Settings → Project → Python Interpreter 界面点加号装包确保装到当前解释器。顺便说一句cv2.imread 对中文路径支持很差项目路径里如果有中文读进来的图全是 None这个我在课程设计里踩过不止一次项目目录尽量全英文。6. 课程设计之外把模型存下来、画训练曲线、用 Grad-CAM 验证注意力6.1 模型持久化save 和 save_weights 的区别训练完别直接把模型丢了。model.save(emotion_final.h5) 会把网络结构和权重存到同一个文件以后 load_model 一行恢复不需要重新构建 CNN.py。如果用的是 save_weights只存权重下次推理前必须先手动跑一遍 CNN.py 的结构定义再 load_weights 把权重填进去。我一般直接用 save答辩现场演示时最省心。6.2 训练曲线与混淆矩阵答辩能拿出来的两张图history 对象里存了每个 epoch 的训练和验证指标画两条曲线就能直观展示收敛过程。import matplotlib.pyplot as plt from sklearn.metrics import confusion_matrix, ConfusionMatrixDisplay plt.plot(history.history[accuracy], labeltrain_acc) plt.plot(history.history[val_accuracy], labelval_acc) plt.legend(); plt.savefig(acc_curve.png) y_true, y_pred [], [] # 对验证集逐批预测收集真实标签和预测标签 cm confusion_matrix(y_true, y_pred) ConfusionMatrixDisplay(cm, display_labels[happy, sad]).plot()准确率曲线说明模型有没有收敛、有没有过拟合混淆矩阵说明模型具体错在哪——是把沮丧误判成高兴多还是反过来。这两张图放进设计报告比贴十页代码有用得多。6.3 Grad-CAM看看卷积层到底在看脸的哪个部位答辩老师问「模型为什么把这张图判成沮丧」时最有力的回答是给一张热力图红色区域是模型判断时重点关注的像素位置。实现思路是取最后一个卷积层的输出对目标类别计算梯度把梯度加权聚合叠回原图。关键代码只涉及几行 TensorFlow 梯度运算网上模板很多改一下 layer 名称和自己的模型对接就行。对快乐和沮丧这种二分类合理的注意力应该集中在嘴、眼角和脸颊的肌肉纹理上。如果热力图是乱的、看天空看背景说明数据预处理的对齐环节出了大问题回去重做人脸裁剪。有一次我图省事直接把整套资源跑完就去答辩老师问「你这个模型判断依据到底是什么」我盯着黑匣子一句话说不出来。从那以后我拿到任何别人写的模型资源都强制自己先跑推理脚本再拆训练脚本最后把 Grad-CAM 热力图和训练曲线一起放进设计报告。环境固定、预处理一致、可解释可视化这三件事做完这门课基本就稳了。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进