OpenCV-Python实战(31)——实时面部情绪检测与识别系统

作者:盼小辉丶日期:2026/7/4

OpenCV-Python实战(31)——实时面部情绪检测与识别系统

    • 0. 前言
    • 1. 规划应用程序
    • 1. 人脸检测
      • 1.1 基于 Haar 的级联分类器
        • 1.2 预训练的级联分类器
        • 1.3 使用预训练的级联分类器
        • 1.4 FaceDetector 类
    • 2. 收集数据
      • 2.1 构建训练数据集
        • 2.2 运行应用程序
        • 2.3 实现数据收集器 GUI
    • 3. 面部情绪识别
      • 3.1 处理数据集
        • 3.2 多层感知机
        • 3.3 构建 MLP用于面部表情识别
    • 4. 整合所有内容
    • 小结
    • 系列链接

0. 前言

我们已经熟悉了目标检测和目标识别的概念。在本节中,我们将开发一个能同时完成这两项任务的应用程序。该应用能够在摄像头实时画面的每一帧中检测人物面部,识别面部情绪,并在图形用户界面 (Graphical User Interface, GUI) 上标注出来。
本节的目标是开发一个应用,将人脸检测与人脸识别相结合,重点是对检测到的人脸进行情绪表达识别。读完本节后,我们将能够在自己的不同应用中使用人脸检测和人脸识别技术。
我们将学习 OpenCV 自带的两种经典算法——Haar 级联分类器和多层感知机 (Multi-Layer Perceptron, MLP)。前者可用于快速检测(即定位,回答“在哪里?”的问题)图像中不同尺寸和方向的目标,而后者可用于识别它们(即辨认,回答“是什么?”的问题)。学习 MLP 也是迈向学习当今最热门算法之一——深度神经网络 (Deep Neural Network, DNN) 的第一步。当数据量不大时,我们将使用主成分分析 (Principal Component Analysis, PCA) 来加速算法并提高其精度,从而改进我们的模型。
我们将自行收集训练数据,以展示这一过程的具体做法,从而能够为那些尚无现成数据的任务训练机器学习模型,缺乏合适的数据仍然是当今机器学习广泛普及的最大障碍之一。

1. 规划应用程序

可靠地识别人脸和面部表情对人工智能 (Artificial Intelligence, AI) 来说是一项具有挑战性的任务,而人类却能轻松完成这类任务。为了降低任务的可行性门槛,我们将把情绪表达限定为以下六种:

  • 中性 (Neutral)
  • 高兴 (Happy)
  • 悲伤 (Sad)
  • 惊讶 (Surprised)
  • 愤怒 (Angry)
  • 厌恶 (Disgusted)

当今最先进的模型涵盖了从用于卷积神经网络 (convolutional neural network, CNN) 的三维可变形人脸模型到深度学习算法的各种技术。诚然,这些方法远比我们的方法复杂。
然而,MLP 是一种曾推动机器学习领域变革的经典算法,因此出于学习目的,我们将首先学习使用 OpenCV 自带的一系列算法。
为了构建这样一个应用程序,我们需要解决以下两个挑战:

  • 人脸检测:我们将使用 ViolaJones 提出的经典 Haar 级联分类器,OpenCV 为此提供了丰富的预训练模型。我们将利用人脸级联和眼睛级联来可靠地检测并对齐每一帧中的人脸区域
  • 面部表情识别:我们将训练一个 MLP 来识别每个检测到的人脸中所包含的上述六种不同情绪表达。该方法的成功与否关键取决于我们整理的训练集,以及我们选择对训练集中每个样本应用的预处理方法

为了提高自建训练集的质量,我们将确保所有数据样本都通过仿射变换进行对齐,并通过应用 PCA 来降低特征空间的维度。这种降维后的表示有时也被称为特征脸。
我们将把上述算法整合到一个端到端的应用程序中,该应用能够在视频实时流的每一帧中,为检测到的人脸标注相应的面部表情标签。最终效果类似于下图:

最终的应用程序将包含一个主脚本,该脚本端到端地整合了整个流程(即从人脸检测到面部表情识别),以及一些辅助功能函数。
因此,最终应用程序需要多个组件,具体如下:

  • rec_facial_emotions.py:主脚本和本章的入口点,我们将用它来进行数据收集和演示。它将包含以下布局:
    • rec_facial_emotions.FacialExpressionRecognizerLayout:一个基于 wx_gui.BaseLayout 的自定义布局,它会在每一帧视频中检测人脸,并使用预训练模型预测对应的类别标签
    • rec_facial_emotions.DataCollectorLayout:一个基于 wx_gui.BaseLayout 的自定义布局,它会收集图像帧、检测其中的人脸、使用用户选择的面部表情标签进行标注,并将这些帧保存到 data/ 目录中
  • wx_gui.py:这是我们在《图像滤镜》一节中编写的 wxpython GUI 文件
  • detectors.FaceDetector:一个包含基于 Haar 级联进行人脸检测的所有代码的类。它有以下两个方法:
    • detect_face:在灰度图像中检测人脸。可选地,可对图像进行下采样以提高可靠性。检测成功后,该方法返回提取出的头部区域
    • align_head:通过仿射变换对提取出的头部区域进行预处理,使最终的人脸呈现居中且正向的姿态
  • params/:一个包含我们在本专栏中使用的默认 Haar 级联文件的目录
  • data/:我们将在此编写所有用于存储和处理自定义数据的代码。代码分为以下几个文件:
    • store.py:包含所有辅助函数,用于将数据写入磁盘以及从磁盘加载数据到内存
    • process.py:包含所有在保存前对数据进行预处理的代码,还包含从原始数据构建特征的代码

在接下来的学习中,我们将详细讨论这些组件。首先,让我们来看看人脸检测算法。

1. 人脸检测

OpenCV 预装了一系列用于通用目标检测的复杂分类器。这些分类器的 API 非常相似,一旦知道要检测什么,就很容易使用。最广为人知的检测器是基于 Haar 特征的级联检测器,用于人脸检测。
基于 Haar 特征的检测器是一种机器学习算法,它使用大量正样本和负样本标签进行训练。在我们的应用程序中,我们将使用 OpenCV 自带的预训练分类器。但首先,让我们更深入地了解该分类器的工作原理。

1.1 基于 Haar 的级联分类器

OpenCV 包含 Viola-Jones 人脸检测器,这个级联分类器于 2001 年提出,它彻底改变了计算机视觉领域,因为它实现了实时人脸检测和人脸识别。
该分类器基于类似 Haar 的特征(类似于 Haar 基函数),这些特征通过求和图像小区域内的像素强度,来捕捉相邻图像区域之间的差异。
下图展示了四种矩形特征。该图说明了如何计算在某个位置应用该特征的值:你需要将深灰色矩形中的所有像素值求和,然后用白色矩形中所有像素值的总和减去这个值:

在上图中,顶行展示了两种边缘特征(即可以用它们来检测边缘)的示例:一种是垂直方向(左上),另一种是 45° 角方向(右上)。底行则展示了一个线条特征(左下)和一个中心环绕特征(右下)。
在所有可能的位置应用这些滤波器,使算法能够捕捉人脸的某些细节,例如眼周区域通常比脸颊周围的区域更暗。
因此,一个典型的 Haar 特征会在一个亮矩形(代表脸颊区域)上方有一个暗矩形(代表眼周区域)。ViolaJones 将这个特征与一组旋转的、稍微更复杂的小波结合,最终得到了一个用于人脸的强大特征描述符。此外,他们还提出了一种高效计算这些特征的方法,首次使实时人脸检测成为可能。
最终的分类器是多个较弱的子分类器的加权和,每个子分类器本身都是基于上述单个特征的二分类器。其中最困难的部分是找出哪些特征组合有助于检测不同类型的目标。OpenCV 已经提供了这样一组分类器,在下一小节中我们将对其中一部分进行介绍。

1.2 预训练的级联分类器

这种方法不仅适用于人脸,还适用于眼睛、嘴巴、全身、公司标志等等。下表列出了多个预训练分类器,这些分类器可以在 OpenCV 安装路径下的 data 文件夹中找到:

级联分类器类型XML文件名
人脸检测器 (默认)haarcascade_frontalface_default.xml
人脸检测器 (快速 Haar)haarcascade_frontalface_alt2.xml
眼睛检测器haarcascade_eye.xml
嘴巴检测器haarcascade_mcs_mouth.xml
鼻子检测器haarcascade_mcs_nose.xml
全身检测器haarcascade_fullbody.xml

在本节中,我们将仅使用 haarcascade_frontalface_default.xmlhaarcascade_eye.xml。如果佩戴眼镜,改用 haarcascade_eye_tree_eyeglasses.xml 来进行眼睛检测。
我们首先来看如何使用级联分类器。

1.3 使用预训练的级联分类器

可以使用以下代码加载级联分类器并将其应用于图像(灰度图):先读取图像,然后将其转换为灰度图,最后使用级联分类器检测所有面部:

1import cv2
2gray_img = cv2.cvtColor(cv2.imread('21.png'), cv2.COLOR_RGB2GRAY)
3cascade_clf = cv2.CascadeClassifier('params/haarcascade_frontalface_default.xml')
4faces = cascade_clf.detectMultiScale(gray_img, scaleFactor=1.1, minNeighbors=3, flags=cv2.CASCADE_SCALE_IMAGE)
5

从上述代码中可以看出,detectMultiScale 函数带有多个可选参数:

  • minFeatureSize:考虑的最小面部尺寸,例如 20×20 像素
  • searchScaleFactor:图像缩放的比例因子(即尺度金字塔)。例如,值为 1.1 时会逐步将输入图像缩小 10%,使得尺寸较大的面部(图像)更容易被检测到
  • minNeighbors:每个候选矩形需要保留的邻居数量。通常选择 35
  • flags:用于调整算法的选项对象,例如指定是查找所有面部还是仅查找最大的面部 (cv2.cv.CASCADE_FIND_BIGGEST_OBJECT)

如果检测成功,该函数将返回一个包含检测到的人脸区域的边界框列表(即人脸列表),如下所示:

1for (x, y, w, h) in faces:
2    cv2.rectangle(gray_img, (x, y), (x + w, y + h), (100, 255, 0), thickness=2)
3

在上述代码中,我们遍历返回的人脸列表,并为每张人脸添加一个粗细为 2 像素的矩形轮廓。
如果预训练的人脸级联检测器没有检测到任何内容,常见原因通常是找不到预训练级联文件的路径。在这种情况下,CascadeClassifier 会静默失败。因此,建议通过 casc.empty() 检查返回的分类器 casc = cv2.CascadeClassifier(filename) 是否为空。
在测试图片上运行上述代码,结果如下所示:

从上图可以看到,左侧是原始图像,右侧是传递给 OpenCV 的图像,以及检测到的人脸矩形轮廓。
现在,让我们尝试将此检测器封装到一个类中,使其可用于我们的应用程序。

1.4 FaceDetector 类

本节所有相关的人脸检测代码都位于 detectors 模块中的 FaceDetector 类里。在实例化时,该类会加载预处理所需的两个不同级联分类器:即 face_cascade 分类器和 eye_cascade 分类器:

1class FaceDetector:
2    def __init__(self, *,
3                 face_cascade='params/haarcascade_frontalface_default.xml',
4                 eye_cascade='params/haarcascade_lefteye_2splits.xml',
5                 scale_factor=4):
6

由于我们的预处理需要一个有效的人脸级联分类器,我们需要确保文件能够被成功加载。如果加载失败,则抛出 ValueError 异常,程序将终止并通知用户哪里出了问题:

1        # load pre-trained cascades
2        self.face_clf = cv2.CascadeClassifier(face_cascade)
3        if self.face_clf.empty():
4            raise ValueError(f'Could not load face cascade "{face_cascade}"')
5

我们对眼睛分类器也执行同样的操作:

1        self.eye_clf = cv2.CascadeClassifier(eye_cascade)
2        if self.eye_clf.empty():
3            raise ValueError(
4                f'Could not load eye cascade "{eye_cascade}"')
5

人脸检测在低分辨率的灰度图像上效果最佳。因此,我们还存储了一个缩放因子 (scale_factor),以便在必要时对输入图像的下采样版本进行操作:

1        self.scale_factor = scale_factor
2

完成类初始化设置后,让我们来看看检测人脸的算法。

1.4.1 在灰度图像中检测人脸

现在,我们将把前一小节学到的内容封装到一个方法中,该方法接收一张图像并返回图像中最大的那张脸。我们选择返回最大的脸以简化处理,因为在我们的应用中,网络摄像头前只会有单个用户。作为挑战,可以尝试将其扩展以支持多人脸检测。
我们将这个方法命名为 detect_face (检测最大人脸)。让我们逐步分析:

(1) 与上一小节类似,首先将输入的 RGB 图像转换为灰度图,并根据 scale_factor 进行缩放:

1    def detect_face(self, rgb_img, *, outline=True):
2        frameCasc = cv2.cvtColor(cv2.resize(rgb_img, (0, 0),
3                                            fx=1.0 / self.scale_factor,
4                                            fy=1.0 / self.scale_factor),
5                                 cv2.COLOR_RGB2GRAY)
6

(2) 然后在灰度图像中检测人脸:

1        faces = self.face_clf.detectMultiScale(
2            frameCasc,
3            scaleFactor=1.1,
4            minNeighbors=3,
5            flags=cv2.CASCADE_SCALE_IMAGE) * self.scale_factor
6

(3) 遍历检测到的人脸,如果 detect_face 方法传入了 outline=True 参数,则绘制轮廓。OpenCV 返回人脸左上角的坐标 (x, y) 以及人脸的宽度 w 和高度 h。因此,为了构建轮廓,我们只需计算出轮廓的右下角坐标,然后调用 cv2.rectangle 函数:

1        for (x, y, w, h) in faces:
2            if outline:
3                cv2.rectangle(rgb_img, (x, y), (x + w, y + h), (100, 255, 0),
4                              thickness=2)
5

(4) 从原始 RGB 图像中裁剪出头部区域。如果我们要对头部进行进一步处理(例如识别面部表情),这将非常有用:

1            head = cv2.cvtColor(rgb_img[y:y + h, x:x + w],
2                                cv2.COLOR_RGB2GRAY)
3

(5) 返回以下四元组:
一个布尔值,用于检测是否成功
添加了人脸轮廓的原始图像(如果参数 outline=True)
裁剪出的头部图像,供后续使用
头部在原始图像中的位置坐标

(6) 成功时返回:

1            return True, rgb_img, head, (x, y)
2

失败时返回未检测到头部,并将所有不确定的值返回 None:

1        return False, rgb_img, None, (None, None)
2

现在,让我们看看检测到人脸之后,如何为机器学习算法处理好这些数据。

1.4.2 预处理检测到的人脸

检测到人脸之后,我们可能希望在将提取的头部区域送入分类器之前对其进行预处理。尽管人脸级联检测相当准确,但对于识别任务而言,确保所有人脸都是正向且居中于图像的是非常重要的。
以下是我们想要达成的效果:

从上图可以看出,图中人物在头部有轻微侧倾。由人脸级联检测器提取的面部区域如上图中间的缩略图所示。
为了补偿头部在检测框内的朝向和位置,我们需要对人脸进行旋转、平移和缩放,使得所有数据样本能够完美对齐。这正是 FaceDetector 类中 align_head 方法的任务,如下代码所示:

1    def align_head(self, head):
2        desired_eye_x = 0.25
3        desired_eye_y = 0.2
4        desired_img_width = desired_img_height = 200
5

在上述代码中,我们硬编码了一些用于对齐头部的参数。我们希望所有眼睛位于最终图像顶部下方 25% 的位置,并且距离左右边缘各为 20%。该函数将返回一张处理后的头部图像,其固定大小为 200×200 像素。
该过程的第一步是检测图像中眼睛的位置,之后我们将利用它们的位置来构建所需的变换。

检测眼睛

OpenCV 自带了一些眼睛级联检测器,可以检测睁眼和闭眼,例如 haarcascade_eye.xml。这使我们能够计算连接两只眼睛中心的直线与水平线之间的角度,从而可以相应地旋转人脸。
此外,加入眼睛检测器还可以降低数据集中出现假正例的风险,因为只有同时成功检测到头部和眼睛时,我们才会添加一个数据样本。
FaceDetector 构造函数中从文件加载眼睛级联检测器后,将其应用于输入图像 (head):

1        try:
2            eye_centers = self.eye_centers(head)
3        except RuntimeError:
4            return False, head
5

如果检测失败,级联分类器找不到眼睛,OpenCV 将会抛出 RuntimeError。我们在这里捕获该异常,并返回 (False, head) 元组,表示头部对齐失败。
接下来,我们尝试对检测到的眼睛进行左右排序。我们将 left_eye 设置为第一个坐标值较小的眼睛——即位于左侧的那只眼睛:

1        if eye_centers[0][0] < eye_centers[0][1]:
2            left_eye, right_eye = eye_centers
3        else:
4            right_eye, left_eye = eye_centers
5

现在我们已经获得了双眼的位置,接下来需要确定应该进行何种变换,以便将眼睛放置到硬编码的位置上——即距离左右边缘各 25%,距离图像顶部下方 25%

人脸变换

人脸变换是一个标准过程,可以通过使用 cv2.warpAffine 对图像进行仿射变换来实现(参考《通过特征匹配与透视变换寻找物体》)。我们将按照以下步骤完成这一变换:

(1) 首先,计算连接双眼的直线与水平线之间的夹角(以度为单位):

1        eye_angle_deg = 180 / np.pi * np.arctan2(right_eye[1] - left_eye[1],
2                                                 right_eye[0] - left_eye[0])
3

(2) 然后,推导出一个缩放因子,使得双眼之间的距离恰好达到图像宽度的 50%

1        eye_dist = np.linalg.norm(left_eye - right_eye)
2        eyeSizeScale = (1.0 - desired_eye_x * 2) * desired_img_width / eye_dist
3

(3) 有了这两个参数 (eye_angle_degeye_size_scale),我们现在可以构建一个合适的旋转矩阵来变换图像:

1        eye_midpoint = (left_eye + right_eye) / 2
2        rot_mat = cv2.getRotationMatrix2D(tuple(eye_midpoint), eye_angle_deg,
3                                          eyeSizeScale)
4

(4) 接下来,确保双眼中心位于图像中心位置:

1        rot_mat[0, 2] += desired_img_width * 0.5 - eye_midpoint[0]
2        rot_mat[1, 2] += desired_eye_y * desired_img_height - eye_midpoint[1]
3

(5) 最后,我们得到了一个正向且缩放后的面部区域,如上图截图中第三张图像(标记为“训练图像”)所示(图中的眼睛区域仅为演示而高亮显示):

1        res = cv2.warpAffine(head, rot_mat, (desired_img_width,
2                                             desired_img_width))
3        return True, res
4

完成这一步后,我们知道了如何从原始图像中提取出经过良好对齐、裁剪和旋转的人脸图像。现在,是时候看看如何使用这些图像来识别面部表情了。

2. 收集数据

面部表情识别流程封装在 rec_facial_emotions.py 文件中。该文件包含一个交互式图形界面,如前所述,它可以在两种模式下运行(训练模式与测试模式)。
我们的整个应用程序分为以下几个部分:

(1) 使用以下命令行命令在收集模式下运行应用程序:

1$ python rec_facial_emotions.py collect
2

上述命令会弹出一个图形界面,用于以数据收集模式运行,以组装训练集。然后通过 python train_classifier.py 在训练集上训练一个 MLP 分类器。因为这一步可能需要较长时间,所以训练过程在单独的脚本中进行。成功训练后,将训练好的权重视存储到文件中,以便在下一步中加载预训练的 MLP

(2) 然后,再次以如下所示的演示模式运行图形界面,我们将能看到面部识别在真实数据上的表现:

1$ python rec_facial_emotions.py demo
2

在此模式下,将拥有一个图形界面,能够在实时视频流上实时分类面部表情。这一步需要加载多个预训练的级联分类器以及我们预训练的 MLP 分类器。然后将这些分类器应用于每一帧捕获的视频画面。

2.1 构建训练数据集

在训练 MLP 之前,我们需要构建合适的训练集。之所以这样做,是因为我们所用的面部很可能还不属于任何现有数据集,因此我们必须自己构建数据集。最简单的方法是使用 GUI 应用程序访问网络摄像头,并对视频流的每一帧进行操作。
我们将继承 wx_gui.BaseLayout 并按自己的喜好调整用户界面。两种不同的模式对应两个不同的类。
GUI 将向用户提供记录以下六种情绪表达之一的选项:中性、高兴、悲伤、惊讶、愤怒和厌恶。点击按钮后,应用程序将拍摄检测到的人脸区域的快照,并将其添加到 data 文件夹中。
然后,这些样本可以从文件中加载,并用于在 train_classifier.py 中训练机器学习分类器。

2.2 运行应用程序

运行此应用程序 (rec_facial_emotions.py),我们需要使用 cv2.VideoCapture 捕获屏幕,并将句柄传递给 FaceLayout 类。我们可以按照以下步骤进行:

(1) 首先,创建一个 run_layout 函数,该函数可以处理任何 BaseLayout 的子类:

1def run_layout(layout_cls, **kwargs):
2    capture = cv2.VideoCapture(0)
3    if not(capture.isOpened()):
4        capture.open()
5
6    capture.set(cv2.CAP_PROP_FRAME_WIDTH, 640)
7    capture.set(cv2.CAP_PROP_FRAME_HEIGHT, 480)
8
9    app = wx.App()
10    layout = layout_cls(capture, **kwargs)
11    layout.Center()
12    layout.Show()
13    app.MainLoop()
14

可以看到,该代码与之前学习中使用 wxPython 的代码非常相似。我们打开网络摄像头,设置分辨率,初始化布局,然后启动应用程序的主循环。当需要多次使用同一函数时,这种优化方式非常有用。

(2) 接下来,设置一个参数解析器,用于判断需要运行两种布局中的哪一种,并使用适当的参数运行它。
为了在两种模式下都能使用 run_layout 函数,我们使用 argparse 模块向脚本添加一个命令行参数:

1if __name__ == '__main__':
2    parser = argparse.ArgumentParser()
3    parser.add_argument('mode', choices=['collect', 'demo'])
4    parser.add_argument('--classifier', type=Path)
5    args = parser.parse_args()
6

我们使用了 Python 自带的 argparse 模块来设置参数解析器,并添加了一个带有 collectdemo 选项的参数。我们还添加了一个可选的 --classifier 参数,该参数仅用于演示模式。

(3) 现在,使用用户传递的所有参数,以适当的参数调用 run_layout 函数:

1    if args.mode == 'collect':
2        run_layout(DataCollectorLayout, title='Collect Data')
3    elif args.mode == 'demo':
4        assert args.classifier is not None, 'you have to provide --classifier'
5        run_layout(FacialExpressionRecognizerLayout,
6                   title='Facial Expression Recognizer',
7                   clf_path=args.classifier)
8

如上述代码所示,我们设置在演示模式下传递一个额外的 classifier_path 参数。我们将在本续讨论 FacialExpressionRecognizerLayout 时看到它的用法。
现在已经确定了如何运行应用程序,接下来让我们构建 GUI 元素。

2.3 实现数据收集器 GUI

之前学习类似,该应用程序的 GUI 是通用 BaseLayout 的自定义版本:

1import wx
2from wx_gui import BaseLayout
3class DataCollectorLayout(BaseLayout):
4

我们通过调用父类的构造函数来开始构建 GUI,以确保其正确初始化:

1    def __init__(self, *args,
2                 training_data='data/cropped_faces.csv',
3                 **kwargs):
4        super().__init__(*args, **kwargs)
5

需要注意的是,在上述代码中添加了一些额外参数。这些参数对应于我们的类拥有而父类没有的所有额外属性。
接下来,在添加 UI 组件之前,我们还要初始化一个 FaceDetector 实例和一个用于存储数据的文件引用:

1        self.face_detector = FaceDetector(
2            face_cascade='params/haarcascade_frontalface_default.xml',
3            eye_cascade='params/haarcascade_lefteye_2splits.xml')
4
5        self.training_data = training_data
6

需要注意的是,我们使用了硬编码的级联 XML 文件,也可以随意尝试使用这些文件。
接下来,让我们看看如何使用 wxPython 构建 UI

2.3.1 扩展基本布局

布局的创建委托给一个名为 augment_layout 的方法。我们保持布局尽可能简单。我们为获取的视频帧创建一个面板,并在其下方绘制一排按钮。
其思路是:首先点击六个单选按钮之一,指示正在尝试记录哪种面部表情,然后将头部置于边界框内,最后点击“拍摄快照”按钮。
那么,让我们来看看如何构建这六个按钮,并将它们正确放置在 wx.Panel 对象上:

1    def augment_layout(self):
2        self.samples = []
3        self.labels = []
4
5        pnl2 = wx.Panel(self, -1)
6        self.neutral = wx.RadioButton(pnl2, -1, 'neutral', (10, 10),
7                                      style=wx.RB_GROUP)
8        self.happy = wx.RadioButton(pnl2, -1, 'happy')
9        self.sad = wx.RadioButton(pnl2, -1, 'sad')
10        self.surprised = wx.RadioButton(pnl2, -1, 'surprised')
11        self.angry = wx.RadioButton(pnl2, -1, 'angry')
12        self.disgusted = wx.RadioButton(pnl2, -1, 'disgusted')
13        hbox2 = wx.BoxSizer(wx.HORIZONTAL)
14        hbox2.Add(self.neutral, 1)
15        hbox2.Add(self.happy, 1)
16        hbox2.Add(self.sad, 1)
17        hbox2.Add(self.surprised, 1)
18        hbox2.Add(self.angry, 1)
19        hbox2.Add(self.disgusted, 1)
20        pnl2.SetSizer(hbox2)
21

可以看出,尽管代码量较大,但大部分是重复的。我们为每种情绪创建一个 RadioButton,并将这些按钮添加到 pnl2 面板中。
“拍摄快照” (Take Snapshot) 按钮位于单选按钮下方,并将绑定到 _on_snapshot 方法:

1        pnl3 = wx.Panel(self, -1)
2        self.snapshot = wx.Button(pnl3, -1, 'Take Snapshot')
3        self.Bind(wx.EVT_BUTTON, self._on_snapshot, self.snapshot)
4        hbox3 = wx.BoxSizer(wx.HORIZONTAL)
5        hbox3.Add(self.snapshot, 1)
6        pnl3.SetSizer(hbox3)
7

如注释所述,我们创建了一个新面板,并添加了一个带有“Take Snapshot”标签的普通按钮。关键在于,我们将按钮的点击事件绑定到 self._on_snapshot 方法上,该方法将在我们点击“拍摄快照”按钮时处理每一张捕获的图像。
该布局的外观如下图所示:

为了使这些更改生效,需要将创建的面板添加到现有面板列表中:

1        self.panels_vertical.Add(pnl2, flag=wx.EXPAND | wx.BOTTOM, border=1)
2        self.panels_vertical.Add(pnl3, flag=wx.EXPAND | wx.BOTTOM, border=1)
3

可视化管道的其余部分由 BaseLayout 类处理。
现在,让我们看一下如何使用 process_frame 方法将边界框添加到视频捕获中的边界上。

2.3.2 处理当前帧

process_frame 方法会在所有图像上被调用,我们希望在视频画面中出现人脸时,在人脸周围显示一个边框:

1    def process_frame(self, frame_rgb: np.ndarray) -> np.ndarray:
2        _, frame, self.head, _ = self.face_detector.detect_face(frame_rgb)
3        return frame
4

我们刚刚调用了 self.face_detector 对象的 FaceDetector.detect_face 方法,该对象是在布局类的构造函数中创建的。回顾上一节可知,它使用 Haar 级联在当前帧的下采样灰度版本中检测人脸。
因此,如果识别到人脸,我们就添加一个边框。现在,让我们看看如何在 _on_snapshot 方法内部存储训练图像。

2.3.3 存储数据

当用户点击“拍摄快照”按钮时,_on_snapshot 事件监听方法会被调用,我们将在此存储数据:

1    def _on_snapshot(self, evt):
2

让我们看看该方法内部的代码:

(1) 首先,通过找出哪个单选按钮被选中,来确定图像的标签:

1        if self.neutral.GetValue():
2            label = 'neutral'
3        elif self.happy.GetValue():
4            label = 'happy'
5        elif self.sad.GetValue():
6            label = 'sad'
7        elif self.surprised.GetValue():
8            label = 'surprised'
9        elif self.angry.GetValue():
10            label = 'angry'
11        elif self.disgusted.GetValue():
12            label = 'disgusted'
13

可以看到,一旦我们知道每个单选按钮都有一个 GetValue() 方法(只有当按钮被选中时才返回 True)。

(2) 接下来,我们需要获取当前帧中检测到的人脸区域(存储在 self.head 中,由 detect_head 得到),并将其与其他所有收集到的帧进行对齐。也就是说,我们希望所有人脸都是正向的,并且眼睛对齐。否则,如果我们不对数据样本进行对齐,分类器可能会将眼睛与鼻子进行比较。由于这种计算可能比较耗时,我们不在 process_frame 方法中的每一帧上都执行,而只在 _on_snapshot 方法中拍摄快照时执行:

1        if self.head is None:
2            print("No face detected")
3        else:
4            success, aligned_head = self.face_detector.align_head(self.head)
5

由于这发生在 process_frame 被调用之后,我们已经能够访问 self.head,其中存储了当前帧中头部图像。

(3) 然后,如果头部对齐成功(即找到了眼睛),我们将存储该数据样本;否则,我们通过打印到终端的方式通知用户:

1            if success:
2                save_datum(self.training_data, label, aligned_head)
3                print(f"Saved {label} training datum.")
4            else:
5                print("Could not align head (eye detection failed?)")
6

实际的保存操作在 save_datum 函数中完成,我们将其抽象出来,因为它不属于 UI 部分。如果想要向文件中添加不同的数据集,这也非常方便:

1def save_datum(path, label, img):
2    with open(path, 'a', newline='') as outfile:
3        writer = csv.writer(outfile)
4        writer.writerow([label, img.tolist()])
5

如上述代码所示,我们使用 .csv 文件来存储数据,每张图像占据一行。因此,如果想返回删除某张图像,只需用文本编辑器打开 .csv 文件并删除对应行即可。
现在,让我们进入更有趣的部分,看看如何使用收集到的数据来训练一个机器学习模型以检测情绪。

3. 面部情绪识别

在本节中,我们将训练一个 MLP 来识别图片中的面部情绪。
我们之前已经指出,找到最能描述数据的特征通常是整个学习任务中至关重要的部分。我们还介绍了一些常见的预处理方法,例如均值减法和归一化。
在这里,我们将研究在面部识别方面具有悠久传统的另一种方法,即 PCA。我们希望,即使我们没有收集到成千上万张训练图片,PCA 也能帮助我们获得良好的结果。

3.1 处理数据集

与《交通标志识别》一节类似,我们在 data/emotions.py 中编写了一个新的数据集解析器,用于解析我们自行组装的训练集。
我们定义了一个 load_data 函数,该函数将加载训练数据并返回收集到的数据及其对应标签的元组:

1def load_collected_data(path):
2    data, targets = [], []
3    with open(path, 'r', newline='') as infile:
4        reader = csv.reader(infile)
5        for label, sample in reader:
6            targets.append(label)
7            data.append(json.loads(sample))
8    return data, targets
9

这段代码是独立的,位于 data/process.py 文件中。
本节中的特征化函数是 pca_featurize 函数,它将对所有样本执行 PCA。但与《交通标志识别》一节不同的是,我们的特征化函数会考虑整个数据集的特征,而不是单独对每张图像进行操作。
现在,它不再是仅仅返回特征化后的数据,而是返回一个包含训练数据以及将相同函数应用于测试数据所需的所有参数的元组:

1def pca_featurize(data) -> (np.ndarray, List)
2

现在,让我们弄清楚什么是主成分分析 (Principal Component Analysis, PCA),以及为什么需要它。

3.1.1 主成分分析

主成分分析 (Principal Component Analysis, PCA) 是一种降维技术,可以用于处理高维数据。从某种意义上说,我们可以将图像视为高维空间中的一个点。如果我们展平一个高度为 m、宽度为 n 的二维图像(通过拼接所有行或所有列),我们会得到一个长度为 m × n 的(特征)向量。该向量中第 i 个元素的值就是图像中第 i 个像素的灰度值。
要描述所有具有这些确切尺寸的可能的二维灰度图像,我们需要一个 m × n 维的向量空间,其中包含 256 m × n 256^{m×n}256m×n 个向量。
那么是否存在一个更小、更紧凑的向量空间(使用少于 m × n 个特征),能够同样好地描述所有这些图像?毕竟,我们了解了灰度值并不是衡量内容的最具信息量的指标。
这正是 PCA 发挥作用的地方。考虑一个数据集,我们从中准确地提取了两个特征。这些特征可以是某个 xy 位置上的像素灰度值,但也可能比这更复杂。如果我们沿着这两个特征轴绘制数据集,数据可能会映射到某个多元高斯分布内,如下图所示:

PCA 所做的是旋转所有数据点,直到数据映射到与两个坐标轴(两个内嵌向量)对齐,这两个坐标轴解释了数据的大部分离散程度。PCA 认为这两个坐标轴是最具信息量的,因为沿着它们移动时,能够观察到大多数数据点被区分开。用更专业的术语来说,PCA 旨在通过正交线性变换将数据转换到一个新的坐标系中。
选择新坐标系的标准是:如果将数据投影到这些新轴上,第一个坐标(称为第一主成分)具有最大的方差。在上图中,绘制的小向量对应于协方差矩阵的特征向量,它们被平移以使尾部位于分布的均值处。
如果我们之前已经计算了一组基向量 (top_vecs) 和均值 (center),那么如前所述,变换数据将变得简单直接:从每个数据点中减去中心,然后将这些向量乘以主成分:

1def _pca_featurize(data, center, top_vecs):
2    return np.array([np.dot(top_vecs, np.array(datum).flatten() - center)
3                     for datum in data]).astype(np.float32)
4

需要注意的是,上述代码适用于任意数量的 top_vecs;因此,如果我们只提供 num_components 个顶部向量,它会将数据的维度降低到 num_components
现在,让我们构建一个 pca_featurize 函数,该函数仅接收数据,并返回变换结果以及复制该变换所需的参数列表(即 centertop_vecs),以便我们也可以对测试数据应用 _pca_featurize

1def pca_featurize(training_data, *, num_components=20):
2

OpenCV 中,执行 PCA 就像调用 cv2.PCACompute 一样简单,但我们必须传递正确的参数。步骤如下:

(1) 首先,将 training_data 转换为 NumPy 二维数组:

1    x_arr = np.array(training_data).reshape((len(training_data), -1)).astype(np.float32)
2

(2) 然后,调用 cv2.PCACompute,它会计算数据的中心和主成分:

1    mean, eigvecs = cv2.PCACompute(x_arr, mean=None)
2

(3) 通过运行以下代码,可以限制在信息量最大的 num_components 个成分上:

1    top_vecs = eigvecs[:num_components]
2

PCA 的优点在于,根据定义,第一主成分解释了数据的大部分方差。换句话说,第一主成分是数据中信息量最大的。这意味着我们不需要保留所有成分就能获得良好的数据表示。

(4) 我们还将均值转换为一个新的 center 变量,它是一个表示数据中心的一维向量:

1    center = mean.flatten()
2

(5) 最后,返回经过 _pca_featurize 函数处理后的训练数据,以及传递给 _pca_featurize 函数所需的参数,以便复制相同的变换,从而使测试数据能够以与训练数据完全相同的方式进行特征化:

1    args = (center, top_vecs)
2    return _pca_featurize(training_data, *args), args
3

现在我们已经知道如何清洗和特征化数据,是时候看看我们用来学习识别面部情绪的训练方法了。

3.2 多层感知机

多层感知机 (Multi-Layer Perceptron, MLP) 是一种人工神经网络 (Artificial Neural Network, ANN),用于将一组输入数据转换为一组输出数据。
MLP 的核心是感知机,它类似于(但被过度简化了)生物神经元。通过在多个层级中组合大量感知机,MLP 能够对其输入数据做出非线性决策,此外,MLP 可以通过反向传播进行训练。以下部分将解释感知机的概念。

3.2.1 感知机

感知机是一种二分类器,感知机计算其输入的加权和,如果该和超过某个阈值,则输出 1;否则输出 0
从某种意义上讲,感知器正在整合证据,传递的某个目标实例存在(或不存在)的证据,如果证据足够强,感知机会被激活。这与研究人员认为生物神经元在大脑中所做的事情有松散的联系,因此称为人工神经网络 ANN
感知机的示意图如下图所示:

其中,一个感知机计算其所有输入 x i x_ixi​ 的加权和(权重为 w i w_iwi​),再加上一个偏置项 b bb。该输入被馈送到一个非线性激活函数 θ θθ 中,该函数决定了感知机的输出 y yy。在最初的算法中,激活函数是 Heaviside 阶跃函数。
在现代 ANN 实现中,激活函数可以是任何形式,从 sigmoid 函数到双曲正切函数等。Heaviside 阶跃函数和 sigmoid 函数的图像如下图所示:

根据激活函数的不同,这些网络能够执行分类或回归任务。传统上,只有当节点使用 Heaviside 阶跃函数时,人们才称之为 MLP

3.2.2 深度架构

一旦理解了感知机,组合多个感知机以形成更大的网络就变得顺理成章。MLP 通常至少包含三层,其中第一层为数据集中的每个输入特征设置一个节点(或称神经元),最后一层为每个类别标签设置一个节点。
第一层和最后一层之间的层称为隐藏层。这种前馈神经网络的示例如下图所示:

在前馈神经网络中,输入层中的部分或全部节点连接到隐藏层中的全部节点,而隐藏层中的部分或全部节点又连接到输出层中的部分或全部节点。通常,会选择输入层的节点数等于数据集中的特征数,这样每个节点代表一个特征。
类似地,输出层的节点数通常等于数据中的类别数,这样当输入一个类别 c 的样本时,输出层中的第 c 个节点被激活,而其他节点保持静默。
当然,也可以有多个隐藏层。通常,事先并不清楚网络的最佳规模应该是多少。
通常,向网络中添加更多神经元时,会看到训练集上的错误率降低,如下图(较细的红色曲线)所示:

这是因为模型的表达能力或复杂度(也称为 Vapnik-Chervonenkis 维数,即 VC 维)随着神经网络规模的增大而增加。然而,测试集上的错误率(上图中较粗的蓝色曲线)则并非如此。
相反,会发现,随着模型复杂度的增加,测试误差会先达到最小值,之后向网络中添加更多神经元将不再提升测试数据上的性能。因此,希望将神经网络的大小保持在上图中标记为“最佳范围”的位置,此时网络能够达到最佳的泛化性能。
我们可以这样理解:一个复杂度较弱的模型(在图的最左侧)可能太小而无法真正理解它试图学习的数据集,从而在训练集和测试集上都产生较大的错误率。这通常被称为欠拟合。
另一方面,图中最右侧的模型可能过于复杂,以至于开始记住训练集中每个样本的具体细节,而没有关注那些使样本区别于其他样本的通用属性。因此,当该模型需要预测从未见过的数据时将会失败,从而在测试集上产生较大的错误率。这通常被称为过拟合。
我们所希望的是开发一个既不过拟合也不欠拟合的模型。通常,这只能通过反复试验来实现;也就是说,将网络规模视为一个超参数,需要根据要执行的具体任务进行调整和优化。
MLP 通过调整其权重进行学习,使得当输入一个属于类别 c 的样本时,输出层中的第 c 个节点被激活,而所有其他节点保持静默。MLP 通过反向传播方法进行训练,该算法可以计算损失函数相对于网络中任何突触权重或神经元偏置的偏导数。然后,利用这些偏导数来更新网络中的权重和偏置,从而逐步降低总体损失。
损失函数可以通过向网络呈现训练样本并观察网络输出来获得。通过观察哪些输出节点被激活、哪些处于静默状态,我们可以计算最后一层输出与真实标签之间的相对误差。
然后,我们对网络中的所有权重进行修正,使误差随时间推移而减小。事实证明,隐藏层中的误差依赖于输出层,而输入层中的误差则依赖于隐藏层和输出层两者的误差。因此,从某种意义上说,误差是通过网络反向传播的。在 OpenCV 中,通过在训练参数中指定 cv2.ANN_MLP_TRAIN_PARAMS_BACKPROP 来使用反向传播。
梯度下降有两种常见的形式:随机梯度下降和批量学习。
在随机梯度下降中,我们在每次给出训练示例后更新权重,而在批处理学习中,我们分批提供训练示例,并且仅在每次给出一批训练示例后才更新权重。在这两种情况下,我们必须确保每个样本只对权重做微小的调整(通过调整学习率),以便网络随着时间逐渐收敛到一个稳定的解。
学习了 MLP 背后的理论之后,接下来,我们动手使用 OpenCV 编写代码。

3.3 构建 MLP用于面部表情识别

与《交通标志识别》类似,我们将使用 OpenCV 提供的机器学习类 ml.ANN_MLP。以下是在 OpenCV 中创建和配置 MLP 的步骤:

(1) 实例化一个空的 ANN_MLP 对象:

1    mlp = cv2.ml.ANN_MLP_create()
2

(2) 设置网络架构:第一层等于数据的维度,最后一层等于可能的情绪数量所需的输出大小 (6):

1    mlp.setLayerSizes(np.array([args.num_components, 10, last_layer_count], dtype=np.uint8))
2

(3) 设置训练算法为反向传播,并使用 sigmoid 函数作为激活函数:

1    mlp.setTrainMethod(cv2.ml.ANN_MLP_BACKPROP, 0.1)
2    mlp.setActivationFunction(cv2.ml.ANN_MLP_SIGMOID_SYM)
3

(4) 最后,我们按如下方式将终止条件设置为达到 30 次迭代或损失值小于 0.000001,此时即可准备训练 MLP

1    mlp.setTermCriteria((cv2.TERM_CRITERIA_COUNT | cv2.TERM_CRITERIA_EPS, 30, 0.000001 ))
2

为了训练 MLP,我们需要训练数据。同时,我们还想了解分类器的表现如何,因此需要将收集到的数据划分为训练集和测试集。划分数据的最佳方式是确保训练集和测试集中不存在近乎相同的图像。
我们按如下方式定义函数的签名:希望获取一个长度为 n 的数组的索引,并指定训练数据占全部数据的比例:

1def train_test_split(n, train_portion=0.8):
2

有了函数签名后,我们逐步解析 train_test_split 函数的具体实现:

(1) 首先,创建一个索引列表并对其进行随机打乱:

1    indices = np.arange(n)
2    np.random.shuffle(indices)
3

(2) 然后,计算训练数据集中所需的样本点数量:

1    N = int(n * train_portion)
2

(3) 最后,为前 N 个索引创建训练数据选择器,为剩余索引创建测试数据选择器:

1    return indices[:N], indices[N:]
2

现在我们已经构建了模型类和训练数据生成器,接下来将探讨如何训练 MLP

3.3.1 训练 MLP

OpenCV 提供了训练和预测方法,因此我们需要弄清楚如何格式化数据以适应 OpenCV 的要求。
首先,我们将数据分割为训练集/测试集,并对训练数据进行特征化:

1    train, test = train_test_split(len(data), 0.8)
2    x_train, pca_args = pca_featurize(np.array(data)[train],
3                                      num_components=args.num_components)
4

其中,pca_args 是我们在将来需要对任何新数据(例如演示期间的实时画面)进行特征化时需要存储的参数。
由于 cv2.ANN_MLP 模块的 train 方法不允许使用整数值的类别标签,我们需要首先将 y_train 转换为独热编码,该编码仅包含 01,然后才能将其传递给 train 方法:

1    encoded_targets, index_to_label = one_hot_encode(targets)
2    mlp.train(x_train, cv2.ml.ROW_SAMPLE, y_train)
3

独热编码在 train_classifiers.py 中的 one_hot_encode 函数中实现,具体方式如下:

(1) 首先,我们确定数据中有多少个点,如下所示:

1def one_hot_encode(all_labels) -> (np.ndarray, Callable):
2    unique_lebels = list(sorted(set(all_labels)))
3

(2) all_labels 中的每个类别标签 c 需要被转换为一个长度为 len(unique_labels)01 向量,其中除了第 c 个位置为 1 之外,所有其他位置均为 0。我们通过分配一个零向量来准备这个操作:

1    y = np.zeros((len(all_labels), len(unique_lebels))).astype(np.float32)
2

(3) 然后,创建将列索引映射到标签的字典,反之亦然:

1    index_to_label = dict(enumerate(unique_lebels))
2    label_to_index = {v: k for k, v in index_to_label.items()}
3

(4) 接着,将这些索引处的向量元素设置为 1

1    for i, label in enumerate(all_labels):
2        y[i, label_to_index[label]] = 1
3

(5) 我们还要返回 index_to_label,以便能够从预测向量中恢复出标签:

1    return y, index_to_label
2

现在,我们继续测试刚刚训练好的 MLP

3.3.2 测试 MLP

与《交通标志识别》类似,我们将从准确率、精确率和召回率等方面评估分类器的性能。
为了复用之前的代码,我们只需计算 y_hat,并将其与 y_true 一起传递给评估函数,步骤如下:

(1) 首先,使用特征化训练数据时存储的 pca_args 以及 _pca_featurize 函数,对测试数据进行特征化:

1    x_test = _pca_featurize(np.array(data)[test], *pca_args)
2

(2) 然后,预测新标签:

1    _, predicted = mlp.predict(x_test)
2
3    y_hat = np.array([index_to_label[np.argmax(y)] for y in predicted])
4

(3) 最后,使用存储的测试集索引提取真实测试标签:

1    y_true = np.array(targets)[test]
2

剩下的工作就是将 y_haty_true 传递给函数,以计算分类器的准确率。本节只用了 84 张图片(每种情绪 10-15 张)就达到了 0.92 的训练准确率。
现在,让我们看看如何运行训练脚本,并以演示应用程序能够使用的格式保存输出。

3.3.3 运行脚本

可以使用 train_classifier.py 脚本来训练和测试 MLP 分类器,该脚本执行以下操作:

(1) 脚本首先设置命令行选项:--data 指向保存数据的位置,--save 指向我们想要保存训练模型的目录位置(该参数是可选的):

1if __name__ == '__main__':
2    parser = argparse.ArgumentParser()
3    parser.add_argument('--data', required=True)
4    parser.add_argument('--save', type=Path)
5    args = parser.parse_args()
6

(2) 然后,加载保存的数据,并按照上一小节描述的训练过程进行操作:

1    data, targets = load_collected_data(args.data)
2    mlp = cv2.ml.ANN_MLP_create()
3    # ...
4    mlp.train(x_train, cv2.ml.ROW_SAMPLE, y_train)
5

(3) 最后,检查用户是否希望我们保存训练好的模型:

1    if args.save:
2        x_all, pca_args = pca_featurize(np.array(data), num_components=args.num_components)
3        mlp.train(x_all, cv2.ml.ROW_SAMPLE, encoded_targets)
4        args.save.mkdir(exist_ok=True)
5        mlp.save(str(args.save / 'mlp.xml'))
6        pickle_dump(index_to_label, args.save / 'index_to_label')
7        pickle_dump(pca_args, args.save / 'pca_args')
8

上述代码保存了训练好的模型、index_to_label 字典(以便在演示中显示人类可读的标签)以及 pca_args (以便在演示中对实时摄像头画面进行特征化)。
保存的 mlp.xml 文件包含网络配置和学到的权重。接下来,让我们看看演示应用程序是什么样的。

4. 整合所有内容

为了运行我们的应用程序,我们需要执行主函数流程 (rec_facial_emotions.py),该流程加载预训练的级联分类器和预训练的 MLP,并将它们应用于网络摄像头实时流的每一帧。
然而,这次我们将使用不同的选项启动程序,而不是收集更多的训练样本:

1$ python rec_facial_emotions.py demo --classifier data
2

这将使用新的 FacialExpressionRecognizerLayout 布局启动应用程序,该布局是 BasicLayout 的子类,没有任何额外的 UI 元素。我们首先来看构造函数:

(1) 它读取并初始化训练脚本存储的所有数据:

1class FacialExpressionRecognizerLayout(BaseLayout):
2    def __init__(self, *args,
3                 clf_path=None,
4                 **kwargs):
5        super().__init__(*args, **kwargs)
6

(2) 使用 ANN_MLP_load 加载预训练的分类器:

1        self.clf = cv2.ml.ANN_MLP_load(str(clf_path / 'mlp.xml'))
2

(3) 加载我们希望从训练过程中传递过来的 Python 变量:

1        self.index_to_label = pickle_load(clf_path / 'index_to_label')
2        self.pca_args = pickle_load(clf_path / 'pca_args')
3

(4) 初始化一个 FaceDetector 类,以便能够进行人脸识别:

1        self.face_detector = FaceDetector(
2            face_cascade='params/haarcascade_frontalface_default.xml',
3            eye_cascade='params/haarcascade_lefteye_2splits.xml')
4

一旦所有训练部分的组件就位,我们就可以继续编写代码为人脸添加标签。在这个演示中,我们不需要使用任何额外的按钮;因此,我们只需要实现 process_frame 方法,该方法首先尝试在实时画面中检测人脸,并在其上方放置一个标签。具体步骤如下::

(1) 首先,尝试检测视频流中是否存在人脸:

1    def process_frame(self, frame_rgb: np.ndarray) -> np.ndarray:
2        success, frame, self.head, (x, y) = self.face_detector.detect_face(
3            frame_rgb)
4

(2) 如果没有人脸,则不做任何处理,直接返回未处理的帧:

1        if not success:
2            return frame
3

(3) 一旦检测到人脸,尝试对齐人脸(与收集训练数据时相同):

1        success, head = self.face_detector.align_head(self.head)
2        if not success:
3            return frame
4

(4) 如果对齐成功,对头部进行特征化,并使用 MLP 预测标签:

1        _, output = self.clf.predict(self.featurize_head(head))
2        label = self.index_to_label[np.argmax(output)]
3

(5) 最后,在人脸边界框上方添加带有标签的文本,并向用户显示:

1        cv2.putText(frame, label, (x, y - 20),
2                    cv2.FONT_HERSHEY_COMPLEX, 1, (0, 255, 0), 2)
3
4        return frame
5

在上述方法中,我们使用了 featurize_head,这是一个调用 _pca_featurize 的便捷函数:

1    def featurize_head(self, head):
2        return _pca_featurize(head[None], *self.pca_args)
3

最终结果如下图所示:

尽管分类器仅在大约 100 个训练样本上进行了训练,但它能够在实时流的每一帧中可靠地检测各种面部表情。
这很好地表明,表明之前训练的神经网络既没有欠拟合也没有过拟合数据,因为它能够正确预测新的数据样本的类别标签。

小结

本节我们构建了一个集目标检测与目标识别于一体的端到端应用。我们熟悉了 OpenCV 提供的一系列预训练的级联分类器,收集并创建了自己的训练数据集,学习了多层感知机 (Multi-Layer Perceptron, MLP),并训练它们识别面部情绪表达。

系列链接

OpenCV-Python实战(1)——OpenCV简介与图像处理基础
OpenCV-Python实战(2)——图像与视频文件的处理
OpenCV-Python实战(3)——OpenCV中绘制图形与文本
OpenCV-Python实战(4)——OpenCV常见图像处理技术
OpenCV-Python实战(5)——OpenCV图像运算
OpenCV-Python实战(6)——OpenCV中的色彩空间和色彩映射
OpenCV-Python实战(7)——直方图详解
OpenCV-Python实战(8)——直方图均衡化
OpenCV-Python实战(9)——OpenCV用于图像分割的阈值技术
OpenCV-Python实战(10)——OpenCV轮廓检测
OpenCV-Python实战(11)——OpenCV轮廓检测相关应用
OpenCV-Python实战(12)——一文详解AR增强现实
OpenCV-Python实战(13)——OpenCV与机器学习的碰撞
OpenCV-Python实战(14)——人脸检测详解
OpenCV-Python实战(15)——面部特征点检测详解
OpenCV-Python实战(16)——人脸追踪详解
OpenCV-Python实战(17)——人脸识别详解
OpenCV-Python实战(18)——深度学习简介与入门示例
OpenCV-Python实战(19)——OpenCV与深度学习的碰撞
OpenCV-Python实战(20)——OpenCV计算机视觉项目在Web端的部署
OpenCV-Python实战(21)——OpenCV人脸检测项目在Web端的部署
OpenCV-Python实战(22)——使用Keras和Flask在Web端部署图像识别应用
OpenCV-Python实战(23)——将OpenCV计算机视觉项目部署到云端
OpenCV-Python实战(24)——打造实时图像滤镜系统
OpenCV-Python实战(25)——基于深度传感器与凸性分析打造实时手势识别系统
OpenCV-Python实战(26)——复杂场景下的实时物体检测与跟踪
OpenCV-Python实战(27)——基于对极几何的3D场景重建
OpenCV-Python实战(28)——OpenCV计算摄影从HDR图像融合到全景拼接
OpenCV-Python实战(29)——基于视觉显著性的自动多目标跟踪系统
OpenCV-Python实战(30)——基于支持向量机的交通标志识别模型


OpenCV-Python实战(31)——实时面部情绪检测与识别系统》 是转载文章,点击查看原文


相关推荐


2026年6月远程控制软件横评:UU远程、ToDesk、向日葵全方位对比
凤年徐2026/6/26

2026远程控制软件横评:UU远程、ToDesk、向日葵全方位对比 远程控制早已不是“应急连一下电脑”那么简单了。开发者用它连服务器改代码,设计师用它调家里的渲染机,留学生用它操作国内的网盘和银行App,甚至游戏玩家也用它“云挂机”。市面上主流的三款工具——UU远程、ToDesk、向日葵——各有拥趸,但到底谁在哪个场景下更顺手?这篇横评不吹不黑,直接把你最关心的七个核心能力摆在一起,逐项对比。 一、终端能力:谁能让开发者真正扔掉SSH客户端? 对于开发者、运维和AI训练者来说,远程命令行是最


JavaScript 函数性能优化:配置驱动 + 按需计算实战指南
m0_733915432026/6/17

JavaScript 函数优化是前端性能调优的核心环节。当业务逻辑中存在多层条件分支与重复计算时,代码不仅执行效率低下,还难以维护和扩展。本文将基于真实业务场景,提供一套配置驱动的优化方案,通过规则表集中管理 + 按需计算策略,帮助你提升函数执行效率 80% 以上。 为什么传统条件分支会拖累性能? 理解问题根源是有效优化的前提。传统 if-else 嵌套方案存在三大性能瓶颈: 问题类型 具体表现 性能影响 重复计算


PySide6 + Qt Designer + PyCharm 完整开发流程
资深流水灯工程师2026/6/10

PyCharm 对 Python 桌面开发有更完善的支持,包括智能代码补全、断点调试、集成终端、版本控制等功能,结合 Qt Designer 的可视化 UI 设计,是工业级上位机开发的首选组合。以下是完全适配 PyCharm 的标准化开发流程。 一、环境准备与 PyCharm 配置 1. 创建项目并配置虚拟环境(必做) PyCharm 强烈推荐使用虚拟环境隔离项目依赖,避免版本冲突: 打开 PyCharm → 新建项目 (New Project)选择项目位置,命名为test_equipm


claude-code下载安装与使用
veminhe2026/6/2

1、官方网站 Claude Code by Anthropic | AI Coding Agent, Terminal, IDE 2、github的项目地址 GitHub - anthropics/claude-code: Claude Code is an agentic coding tool that lives in your terminal, understands your codebase, and helps you code faster by executing ro


从社区路标到生态基石:Dave Verwer 的新篇章 -- 肘子的 Swift 周报 #137
东坡肘子2026/5/26

从社区路标到生态基石:Dave Verwer 的新篇章 Dave Verwer 在 iOS Dev Weekly 第 751 期宣布,这份已经持续近 15 年的周报将交由新的团队继续运营,而他自己接下来会全职投入 Swift Package Index。我的博客在早期获得关注,也曾得益于 iOS Dev Weekly 的推荐;而我在周报中坚持撰写每期周评,同样在很大程度上受到 Dave Verwer 的启发。对于很多 Apple 平台开发者来说,iOS Dev Weekly 早已不只是一份链接合


C 标准库 - <assert.h>
lsx2024062026/5/4

C 标准库 - <assert.h> 引言 在C语言编程中,错误检测和处理是保证程序稳定性和可靠性的重要环节。《assert.h》头文件提供了用于在开发过程中进行断言检查的函数,这些函数在编译时默认是开启的。本文将详细介绍C标准库中<assert.h>的相关内容,包括其使用方法、作用以及在实际开发中的应用。 断言简介 断言(assertion)是一种用于在程序运行时检测错误的方法。当断言的条件为假时,程序将终止运行,并打印出错误信息。这使得开发者能够快速定位并修复代码中的问题。 <assert.


我学习到的结构化提示词三技巧
前端工作日常2026/4/25

提示词框架(Prompt Framework) 在大模型中,设计一组清晰且结构化的提示词,用以引导模型生成特定类型的输出。 它有助于提高生成的准确性、相关性和质量,确保模型的回应更符合用户的需求。 一个简单的结构化提示词来改写我们的问题输入 在 Coze 上创建一个智能体,在“人设与回复逻辑”那里输入: 你是一位曾经就职于互联网头部企业的资深软件工程师和IT教育专家,擅长用通俗易懂的语言来给初学者讲解! 根据用户的输入,整理一门入门级技术课程的大纲,要求: 1.注重基本概念和原理,为学员打下


Spring Boot一键限速:守护你的接口“高速路”
小码哥_常2026/4/16

Spring Boot一键限速:守护你的接口“高速路” 为什么网络限速很重要 在当今互联网应用广泛的时代,网络限速绝非多此一举,而是保障系统稳定、高效运行的关键策略。想象一下电商平台举办秒杀活动,成千上万的用户在同一时刻疯狂点击抢购按钮,倘若没有网络限速机制,瞬间涌入的海量请求可能会直接把服务器 “压垮”,导致整个系统瘫痪,无论是正常用户的购买请求,还是服务器后续的订单处理,都无法顺利进行。 再看看视频平台,每到热门剧集首播或者大型体育赛事直播时,大量用户同时在线观看,对视频资源的请求量呈爆发式


从源码泄露看AI Agent未来:深度对比Claude Code原生实现与OpenClaw开源方案
半行代码2026/4/8

Claude Code 是 Anthropic 推出的终端 AI 编程助手。与普通的聊天式 AI 不同,它直接在终端里工作,能够读取代码、执行命令、修改文件、管理 Git 操作。阅读其源码后,可以从 Agent 循环、上下文工程、提示词工程和多 Agent 协同几个维度梳理出它的设计脉络。 整体架构 Claude Code 的核心是一个典型的 ReAct Agent 架构,入口是 query() 函数,它内部委托给 queryLoop() —— 一个通过 while(true) 无限循环驱动的


OpenClaw 接入 Telegram:BotFather 实战
七夜zippoe2026/3/31

目录 摘要1. 引言2. Telegram Bot API 介绍2.1 什么是 Telegram Bot API2.2 Bot 与普通用户的区别2.2 Bot 的核心特性2.3 API 通信模式2.4 消息类型与格式2.5 API 请求示例 3. 通过 BotFather 创建机器人3.1 BotFather 简介3.2 创建 Bot 的详细步骤3.3 Bot 配置选项3.4 配置命令示例3.5 Bot 头像与品牌设置3.6 多语言支持 4. 获取 Bot Token 与安全实践4

首页编辑器站点地图

本站内容在 CC BY-SA 4.0 协议下发布

Copyright © 2026 聚合阅读