简介:基于深度学习TensorFlow与PyQt5实现的人脸表情识别毕业设计项目,面向计算机相关专业正在完成大作业、毕业设计的本科生,以及需要项目实战练习的学习者。项目包含完整的人脸检测与表情分类流程:利用级联分类器定位人脸,调用训练好的权重模型完成开心、中性、惊讶、难过、愤怒等表情识别,并通过PyQt5构建GUI界面,支持摄像头实时识别与静态图片测试。资源包共22个文件,以Python源码(界面逻辑、主流程、摄像头线程)、h5权重文件、png表情图标、XML人脸检测模型、UI界面描述文件为主,附使用说明与依赖环境清单,整体体积约13.36MB。所有源码均经本地编译调试并获导师认可,评审分98分,难度适中可直接运行;目前已有59人学习下载,解压后按说明部署环境即可使用,也便于在现有代码基础上二次开发。
1. 一套能直接答辩的TensorFlow人脸表情识别工程长什么样
临近交毕设文档的时候,最怕的不是模型效果差,而是演示环节界面卡死、摄像头黑屏。这套基于TensorFlow和PyQt5的人脸表情识别源码,恰好把最容易翻车的三块都补齐了:weight.h5模型权重、haarcascade_frontalface_default.xml人脸检测器、Camera_Thread_class.py摄像头线程。工程里还带mainwindow2.ui、表情png图标和测试图片,不用自己拼界面,启动入口是mainfile.py。它的核心链路很清晰:OpenCV定位人脸,TensorFlow模型输出五类表情,PyQt5把结果实时画到窗口上。适合正在做大作业或毕业设计的学生,也适合想从终端推理跨到GUI工程的学习者。难度偏向中等,但把“识别”和“界面”连起来这一步,就是这个项目的含金量所在。
2. weight.h5与TensorFlow模型:从权重快照到五类表情推理管线
表情识别可以拆成“人在哪里-脸长什么样-表情是什么”三段,前两段不需要深度网络,OpenCV的Haar特征就可以完成。真正决定精度的是第三段:weight.h5里保存的TensorFlow模型权重。拆这个项目的第一步,不是打开GUI,而是先搞清楚模型结构和输入约定。
2.1 五类标签与模型输出层设计
从emoji_pics目录里的png文件名能直接看出训练时的标签集合:angry、happy、neutral、sad、surprise,恰好是5类。所以模型最后全连接层应该是5个神经元,激活函数用softmax,输出是“属于每一类的概率”。预测时取argmax拿到索引,再通过标签表映射成文字,才不会在UI里张冠李戴。
| 索引 | 标签 | 对应图标文件 |
|---|---|---|
| 0 | angry | emoji_pics/angry.png |
| 1 | happy | emoji_pics/happy.png |
| 2 | neutral | emoji_pics/neutral.png |
| 3 | sad | emoji_pics/sad.png |
| 4 | surprise | emoji_pics/surprise.png |
标签顺序要与训练时保持一致,这是最容易踩的坑。如果项目自带使用说明.txt,第一步先看它是否标注了类别顺序;没有标注的话,就按上面的索引顺序对照代码里的labels数组,常常会发现有人把fear也混进来,导致softmax输出维度对不上。
2.2 人脸检测与预处理:Haar级联的参数边界
weight.h5本身不知道人脸在哪,需要先靠haarcascade_frontalface_default.xml框出人脸。实际使用OpenCV的CascadeClassifier加载,然后对灰度图调用detectMultiScale。
import cv2 face_cascade = cv2.CascadeClassifier('haarcascade_frontalface_default.xml') gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) faces = face_cascade.detectMultiScale( gray, scaleFactor=1.1, minNeighbors=5, minSize=(48, 48) )这里的参数说明一下:scaleFactor是每次缩放搜索窗口的比例,1.1表示每次缩小10%,数值越小检测越慢、漏检越少;minNeighbors是候选框需要满足的邻近框数量,调大到6~7能过滤掉一部分误检,但也可能把侧脸丢掉;minSize限制最小人脸尺寸,如果窗口比输入到模型的人脸还小,resize后信息量不够,所以直接设成48x48最稳妥。
2.2.1 人脸框的扩展和裁剪
Haar框出来的人脸往往贴着皮肤,眉毛和嘴巴边缘容易被切掉。常见做法是把框向外扩展20%再裁剪。要注意不能扩出图像边界,clip一下就好:
x, y, w, h = faces[0] margin = int(0.2 * w) x0 = max(0, x - margin) y0 = max(0, y - margin) x1 = min(img.shape[1], x + w + margin) y1 = min(img.shape[0], y + h + margin) face_roi = gray[y0:y1, x0:x1]2.2.2 灰度图resize与归一化
常见卷积输入是48x48单通道,所以把裁剪后的人脸resize到这个尺寸,再除以255归一化。有些模型训练时用的归一化范围是[-1, 1],也就是face_roi / 127.5 - 1;如果按[0,1]预测效果明显偏差,就改成后一种再试。
2.3 加载weight.h5并跑通单张图片
拿到weight.h5要先确认它是完整模型还是纯权重。直接尝试load_model,如果报错或打不出模型结构,就说明它只是权重快照,需要在mainfile.py里找到构建模型的部分,先建同结构模型再load_weights。
from tensorflow.keras.models import load_model import numpy as np import cv2 # 完整模型:直接加载 model = load_model('weight.h5') # 只是权重:先重建网络结构,再加载权重 # model = build_model() # model.load_weights('weight.h5') img = cv2.imread('imgs/coffee.jpg') gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) faces = face_cascade.detectMultiScale(gray, 1.1, 5, minSize=(48, 48)) _, x, y, w, h = faces[0] face_roi = gray[y:y+h, x:x+w] face_roi = cv2.resize(face_roi, (48, 48)) face_roi = face_roi.astype('float32') / 255.0 face_roi = face_roi.reshape(1, 48, 48, 1) pred = model.predict(face_roi, verbose=0)[0] class_idx = int(np.argmax(pred)) labels = ['angry', 'happy', 'neutral', 'sad', 'surprise'] print(labels[class_idx], pred[class_idx])注意reshape时维度顺序是(batch, height, width, channels),PyQt5里的图像是RGB,OpenCV是BGR,但灰度图不涉及通道顺序问题。模型输入要是3通道彩色图,就把gray[y:y+h, x:x+w]换成img[y:y+h, x:x+w],reshape最后一位改成3。预测概率confidence如果普遍低于0.4,基本可以判断是预处理规范和训练时不一致,优先检查归一化范围。
2.3.1 只有权重时重建模型结构
如果weight.h5用load_model报错,只看得到权重字典,就需要按原来的训练代码重建结构。以48x48灰度输入为例,表情识别常用的结构是若干卷积池化层后接全连接:
from tensorflow.keras import Sequential from tensorflow.keras.layers import Conv2D, MaxPooling2D, Flatten, Dense, Dropout def build_model(): model = Sequential([ Conv2D(32, (3, 3), activation='relu', padding='same', input_shape=(48, 48, 1)), MaxPooling2D((2, 2)), Conv2D(64, (3, 3), activation='relu', padding='same'), MaxPooling2D((2, 2)), Flatten(), Dense(128, activation='relu'), Dropout(0.5), Dense(5, activation='softmax') ]) return model第一层input_shape必须与训练时一致;Dense(5)对应表情类别数;Dropout只在训练时生效,不影响加载权重。如果load_weights时提示shape mismatch,多半是输入尺寸或最后一层类别数对不上。项目里如果有model summary或使用说明里写了input shape,以那份为准。
3. PyQt5界面与Camera_Thread_class.py:实时显示不卡顿的线程设计
GUI程序直接在主线程里用OpenCV循环读摄像头是大忌,read()会阻塞事件循环,拖动窗口就白屏。这个项目里单独放了Camera_Thread_class.py,目的就是把取帧放到QThread里。界面本身在mainwindow2.py里实现,线程只负责源源不断把帧发出来。有的初级版本在主线程里用QTimer每30ms调一次cap.read(),摄像头响应快,但一旦在回调里做模型推理,界面事件循环被阻塞,窗口拖动、按钮点击都会卡。QThread方案把取帧和推理全部移出主线程,主线程只接收结果,这才是PyQt5 GUI稳定工作的前提。
3.1 mainwindow2.ui与mainwindow2.py分工
mainwindow2.ui是用Qt Designer画好的界面布局,包括摄像头显示区、按钮、表情文本或图标。运行时可以动态加载ui文件,也可以用pyuic5转换成mainwindow2.py。两种方式没有对错,但动态加载利于改布局,转成py文件则方便直接引用控件变量。项目里两个文件都存在,说明原始工程可能用Designer改完再转py。窗口里的背景图、按钮样式和emoji_pics图标都在designer里指定,代码里只需要操作对象名。
3.2 摄像头线程核心实现
看命名,Camera_Thread_class.py本质上就是下面这个结构的封装:
import cv2 from PyQt5.QtCore import QThread, pyqtSignal class CameraThread(QThread): frame_ready = pyqtSignal(object) def __init__(self, camera_index=0): super().__init__() self.camera_index = camera_index self.running = True def run(self): cap = cv2.VideoCapture(self.camera_index) cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480) while self.running: ret, frame = cap.read() if not ret: continue self.frame_ready.emit(frame) cap.release() def stop(self): self.running = False self.wait()frame_ready是一个pyqtSignal,emit出的frame是numpy数组。PyQt的跨线程信号槽会自己切到主线程,所以后面连接的槽函数里可以直接操作QLabel,不会出现“Cannot create children for a parent that is in a different thread”这种报错。
3.2.1 低速识别场景下的帧丢弃
如果在线程里同时做人脸检测和TensorFlow推理,一帧可能要100ms以上,640x480帧源源不断塞给主线程会让队列堆积。常见做法是在线程里把推理做完,只往主线程发“画面+结果”两样东西;不要在帧信号里再做识别。也可以加一个标志位,处理完一帧再去read下一帧:
while self.running: ret, frame = cap.read() if not ret: continue frame = self.process_frame(frame) # 返回带识别结果的画帧 self.frame_ready.emit(frame)这样就不会丢帧,收到的每一帧都是已经画好框和文字的。
3.2.2 线程退出策略
在closeEvent里调用stop,否则程序退出后摄像头可能仍被占用。stop()里先置running为False,再wait()等待run退出。注意不要在线程run里调self.wait(),会造成死锁。
3.3 从按钮点击到表情回显的数据流
mainwindow2.py里启动按钮的槽函数一般只做三件事:建线程、连信号、start。帧信号到达后,先处理灰度、人脸检测、模型推理,再把表情结果用QLabel显示出来。下面是一段可直接替换的update_frame槽实现:
from PyQt5.QtGui import QImage, QPixmap def update_frame(self, frame): gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) result_frame, label = self.run_face_expression(gray, frame) self.result_label.setText(label) self.emoji_label.setPixmap(QPixmap(f'emoji_pics/{label}.png')) rgb = cv2.cvtColor(result_frame, cv2.COLOR_BGR2RGB) h, w, ch = rgb.shape image = QImage(rgb.data, w, h, ch * w, QImage.Format_RGB888) self.camera_label.setPixmap(QPixmap.fromImage(image))这里run_face_expression内部是第2章写的检测、预处理、predict,结果帧里已经带框和文字,主线程只负责显示和图标切换。注意QImage构造时传入的bytes数据要和rgb声明周期同时,建议把rgb存到self.last_rgb,防止PyQt显示时引用失效。
| 控件对象名(常见命名) | 类型 | 作用 |
|---|---|---|
| camera_label | QLabel | 显示摄像头实时画面 |
| result_label | QLabel | 显示当前表情文字 |
| emoji_label | QLabel | 显示对应的表情png图标 |
| start_button | QPushButton | 启动/停止摄像头线程 |
| quit_button | QPushButton | 退出程序 |
这套结构和mainwindow2.ui对得上,实际操作时只改控件对象名就行。mainfile.py是入口文件,负责创建QApplication、加载窗口并显示;mainwindow2.ui是designer源文件,不直接执行。
4. 环境搭建与版本匹配:TensorFlow、PyQt5、OpenCV的兼容区间
这套工程能不能一次跑起来,多半看环境。有人用Python 3.12装TensorFlow,又pin一个TensorFlow 2.16后PyQt5的DLL炸了。下面是我在自己机器上复现时用的组合,优先级是“TensorFlow可用 > OpenCV可用 > PyQt5可用”,而不是全装最新版。
4.1 requirements.txt之外的版本对照
requirements.txt里列的是项目作者本机的依赖,直接照装可能踩到坑。更稳的做法是按表格里的组合手动指定。
| 依赖包 | 推荐版本 | 备注 |
|---|---|---|
| python | 3.9 | 与tensorflow和pyqt5兼容面最宽 |
| tensorflow | 2.10.0 | Windows下原生GPU的最后一个稳定版本 |
| PyQt5 | 5.15.9 | 5.15.x系列稳定 |
| pyqt5-qt5 | 5.15.19 | 注意与PyQt5版本配套 |
| opencv-python | 4.8.0.74 | 提供cascade和VideoCapture |
| numpy | 1.24.3 | 兼容tensorflow 2.10 |
| h5py | 3.8.0 | 读取weight.h5的底层库 |
特别注意,TensorFlow 2.10是Windows原生GPU的最后一个稳定版本,2.11开始Windows GPU支持转移到WSL,所以老项目用2.10最省事。PyQt5和pyqt5-qt5版本要配套,出现过PyQt5 5.15.10配pyqt5-qt5 5.15.2后加载失败的案例。
4.2 用Anaconda创建隔离环境并配置PyQt5
先建环境再装包,避免污染base环境。
conda create -n face python=3.9 -y conda activate face pip install -r requirements.txt pip install PyQt5==5.15.9 pyqt5-qt5==5.15.19 pip install tensorflow==2.10.0 pip install opencv-python==4.8.0.74 numpy==1.24.3 h5py==3.8.0如果requirements.txt里已有PyQt5,直接install会把最新版拉进来,可能破坏上面约束。建议先装requirements,再用上面命令覆盖关键包。另外,requirements.txt里如果出现pyqt5-qt5==5.15.19 @ registry+https://pypi.tuna...这样的PEP 508写法,pip解析时可能卡住,把它换成普通pyqt5-qt5==5.15.19就好。在PyCharm里把Project Interpreter切到conda face环境,然后直接运行mainfile.py。
4.3 模型加载失败和摄像头打不开的定位手段
运行时报错分三类,先说现象再给判断方式。
第一类:ValueError: Unable to synchronously read attribute from HDF5 file。说明h5文件不是完整的Keras模型文件。用h5py看顶层键,确认它只是权重字典。
import h5py with h5py.File('weight.h5', 'r') as f: print(list(f.keys()))有model_config或model_weights说明是完整模型;只看到层名开头的data组,就必须走load_weights分支。
第二类:DLL load failed: The specified procedure could not be found。通常是opencv-python或PyQt5的C扩展和Python版本不匹配。在conda环境里重装对应版本即可,别混用pip和conda。
第三类:摄像头黑屏,但程序不崩。检查cap = cv2.VideoCapture(0)是否返回False,返回False说明编号不对。换1、2或直接传摄像头设备路径再试。另外,有的电脑自带相机被隐私设置禁用了,需要到系统设置里打开“桌面应用可访问相机”。
这套工程里,mainfile.py读写文件用的是相对路径,所以工作目录必须放在项目根目录。在PyCharm Run Configuration里把Working directory指向解压目录,否则找不到weight.h5和xml。
5. 让答辩演示更稳的验证路径:从静态图到实时叠加
先不要直接上摄像头。把模型、UI、线程都分开验证,出问题好定位。
5.1 先用测试图把模型链路钉死
项目里有imgs/coffee.jpg和img.png,可以先写一个小脚本只跑第2章的检测和推理,确认能输出“happy”这类结果。只要静态图能出标签,说明weight.h5和预处理没问题,后面调试GUI时不会怀疑模型。这一步如果发现置信度都在0.3以下,优先怀疑resize尺寸和归一化范围。
5.2 把识别结果直接画到每一帧画面上
很多模板只在UI上放文字,演示时观感很干。把结果框和标签画在原图上,在摄像头线程里做掉,UI只负责显示,效果立刻不一样。
def process_frame(self, frame): gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) faces = face_cascade.detectMultiScale(gray, 1.1, 5, minSize=(48, 48)) label = 'unknown' for (x, y, w, h) in faces: face_roi = cv2.resize(gray[y:y+h, x:x+w], (48, 48)) face_roi = face_roi.astype('float32') / 255.0 pred = model.predict(face_roi.reshape(1, 48, 48, 1), verbose=0)[0] label = labels[int(np.argmax(pred))] cv2.rectangle(frame, (x, y), (x+w, y+h), (0, 255, 0), 2) cv2.putText(frame, label, (x, y-10), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 255, 0), 2) self.expression_signal.emit(label) # 让主线程切换emoji图标 return frame这里在同一个循环里做了检测和推理,process_frame返回后一帧才送出去,相当于强制“拿完一帧再做识别再显示”,不会越积越多。多人脸时for循环会画出多个框,UI里emoji只显示最后一个框的label,演示时站远一点只看单人更稳。
如果觉得模型预测太慢,可以只对固定区域做推理:先让Haar找脸,没有脸直接跳过,不做predict。这套工程的静态图路径验证好后,摄像头路径只是把输入从imread换成cap.read,再用信号槽和emoji_pics的setPixmap联动,就是完整的答辩演示了。
本文还有配套的精品资源,点击获取