精通-OpenCV3-全-
精通 OpenCV3(全)
原文:
annas-archive.org/md5/686c097be00b01bb7bed0612adb11c34译者:飞龙
前言
精通 OpenCV3,第二版包含七个章节,每个章节都是一个从开始到结束的完整项目教程,基于 OpenCV 的 C++接口,包括完整的源代码。每个章节的作者都是因其在该主题上对 OpenCV 社区的良好在线贡献而被选中的,并且本书由主要的 OpenCV 开发者之一进行了审阅。本书不是解释 OpenCV 函数的基础,而是展示了如何将 OpenCV 应用于解决整个问题,包括几个 3D 相机项目(增强现实和 3D 结构从运动)以及几个面部分析项目(如皮肤检测、简单面部和眼睛检测、复杂面部特征跟踪、3D 头部姿态估计和面部识别),因此它是现有 OpenCV 书籍的优秀伴侣。
本书涵盖内容
第一章,Raspberry Pi 的卡通化器和皮肤变换器,包含了一个桌面应用程序和 Raspberry Pi 的完整教程和源代码,这些应用程序可以自动从真实相机图像生成卡通或绘画,包括多种可能的卡通类型,以及皮肤颜色变换功能。
第二章,使用 OpenCV 探索运动结构,通过在 OpenCV 中实现 SfM(运动结构)概念来介绍 SfM。读者将学习如何从多个 2D 图像中重建 3D 几何形状并估计相机位置。
第三章,使用 SVM 和神经网络进行车牌识别,包含了一个完整的教程和源代码,用于构建一个使用模式识别算法以及支持向量机(SVM)和人工神经网络(ANN)的自动车牌识别应用程序。读者将学习如何训练和预测模式识别算法以判断一个图像是否为车牌,并且它还将帮助将一组特征分类为字符。
第四章,非刚性面部跟踪,包含了一个完整的教程和源代码,用于构建一个动态面部跟踪系统,该系统能够建模和跟踪一个人面部复杂的多部分。
第五章,使用 AAM 和 POSIT 进行 3D 头部姿态估计,包含了理解什么是主动外观模型(AAMs)以及如何使用 OpenCV 通过一组具有不同面部表情的面部帧来创建它们的全部背景知识。此外,本章还解释了如何通过 AAMs 提供的拟合能力来匹配给定的帧。然后,通过应用 POSIT 算法,可以找到 3D 头部姿态。
第六章,使用特征脸或 Fisher 脸进行人脸识别,包含一个完整的教程和实时人脸识别应用程序的源代码,该应用程序包括基本的面部和眼部检测,以处理图像中面部旋转和光照条件的变化。
第七章,增强现实的自然特征跟踪,包括如何为 iPad 和 iPhone 设备构建基于标记的增强现实(AR)应用程序的完整教程,每个步骤都有解释和源代码。它还包含如何开发无标记增强现实桌面应用程序的完整教程,解释了无标记 AR 是什么,以及源代码。
您需要这本书的内容
您不需要在计算机视觉方面有特殊知识来阅读这本书,但您应该在阅读此书之前拥有良好的 C/C++编程技能和基本的 OpenCV 经验。没有 OpenCV 经验的读者可能希望阅读《Learning OpenCV》以了解 OpenCV 功能介绍,或阅读《OpenCV 2 Cookbook》以了解如何使用推荐的 C/C++模式使用 OpenCV 的示例,因为这本书将向您展示如何解决实际问题,假设您已经熟悉 OpenCV 和 C/C++开发的基础知识。
除了 C/C++和 OpenCV 经验外,您还需要一台计算机,以及您选择的 IDE(例如 Visual Studio、XCode、Eclipse 或 QtCreator,运行在 Windows、Mac 或 Linux 上)。某些章节有进一步的要求,特别是:
-
要为 Raspberry Pi 开发 OpenCV 程序,您需要 Raspberry Pi 设备、其工具以及基本的 Raspberry Pi 开发经验。
-
要开发 iOS 应用程序,您需要 iPhone、iPad 或 iPod Touch 设备,iOS 开发工具(包括 Apple 计算机、XCode IDE 和 Apple 开发者证书),以及基本的 iOS 和 Objective-C 开发经验。
-
几个桌面项目需要连接到您的计算机的摄像头。任何常见的 USB 摄像头都足够使用,但至少 1 兆像素的摄像头可能更受欢迎。
-
一些项目(包括 OpenCV 本身)使用 CMake 在操作系统和编译器之间构建。需要了解构建系统的基础知识,并建议了解跨平台构建。
预期您对线性代数有所了解,例如基本的向量矩阵运算和特征分解。
这本书面向的对象
《精通 OpenCV 3,第 2 版》 是适合具有基本 OpenCV 知识的开发者使用,以创建实用的计算机视觉项目,同时也适合希望将更多计算机视觉主题添加到其技能集的资深 OpenCV 专家。本书面向高级计算机科学大学生、毕业生、研究人员和希望使用 OpenCV C++接口解决实际问题的计算机视觉专家,通过实用的分步教程。
约定
在本书中,您将找到多种文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义解释。
文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称将如下所示:“您应该将本章的大部分代码放入cartoonifyImage()函数中”
代码块将如下设置:
int cameraNumber = 0;
if (argc> 1)
cameraNumber = atoi(argv[1]);
// Get access to the camera.
cv::VideoCapture capture
当我们希望将您的注意力引到代码块的一个特定部分时,相关的行或项目将以粗体显示:
// Get access to the camera.
cv::VideoCapture capture;
camera.open(cameraNumber);
if (!camera.isOpened()) {
std::cerr<< "ERROR: Could not access the camera or video!" <<
任何命令行输入或输出将如下所示:
cmake -G "Visual Studio 10"
新术语和重要单词将以粗体显示。您在屏幕上看到的单词,例如在菜单或对话框中,在文本中将如下所示:“为了下载新模块,我们将转到文件 | 设置 | 项目名称 | 项目解释器。”
警告或重要提示将以这样的框显示。
小贴士和技巧将如下所示。
读者反馈
我们始终欢迎读者的反馈。请告诉我们您对这本书的看法——您喜欢或不喜欢的地方。读者反馈对我们来说很重要,因为它帮助我们开发出您真正能从中受益的标题。
要发送一般反馈,请简单地发送电子邮件至feedback@packtpub.com,并在邮件主题中提及书籍的标题。
如果您在某个主题上具有专业知识,并且您对撰写或为书籍做出贡献感兴趣,请参阅我们的作者指南www.packtpub.com/authors。
客户支持
现在您是 Packt 图书的骄傲拥有者,我们有一些事情可以帮助您从您的购买中获得最大收益。
下载示例代码
您可以从您的账户下载此书的示例代码文件www.packtpub.com。如果您在其他地方购买了此书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。
您可以通过以下步骤下载代码文件:
-
使用您的电子邮件地址和密码登录或注册我们的网站。
-
将鼠标指针悬停在顶部的“支持”标签上。
-
点击“代码下载与勘误表”。
-
在搜索框中输入书籍名称。
-
选择您想要下载代码文件的书籍。
-
从下拉菜单中选择您购买此书的来源。
-
点击“代码下载”。
文件下载后,请确保您使用最新版本解压缩或提取文件夹:
-
WinRAR / 7-Zip for Windows
-
Zipeg / iZip / UnRarX for Mac
-
7-Zip / PeaZip for Linux
该书的代码包也托管在 GitHub 上,地址为h t t p s 😕/g i t h u b . c o m /P a c k t P u b l i s h i n g /M a s t e r i n g - O p e n C V 3- S e c o n d - E d i t i o n。我们还有其他来自我们丰富图书和视频目录的代码包可供在h t t p s 😕/g i t h u b . c o m /P a c k t P u b l i s h i n g /获取。请查看它们!
下载本书的颜色图像
我们还为您提供了一个包含本书中使用的截图/图表的颜色图像的 PDF 文件。这些颜色图像将帮助您更好地理解输出的变化。您可以从h t t p s 😕/w w w . p a c k t p u b . c o m /s i t e s /d e f a u l t /f i l e s /d o w n l o a d s /M a s t e r i n g O p e n C V 3S e c o n d E d i t i o n _ C o l o r I m a g e s . p d f下载此文件。
勘误
尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在我们的书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以避免其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何勘误,请通过访问h t t p 😕/w w w . p a c k t p u b . c o m /s u b m i t - e r r a t a来报告它们,选择您的书籍,点击勘误提交表单链接,并输入您的勘误详情。一旦您的勘误得到验证,您的提交将被接受,勘误将被上传到我们的网站或添加到该标题的勘误部分下的现有勘误列表中。
要查看之前提交的勘误,请访问h t t p s 😕/w w w . p a c k t p u b . c o m /b o o k s /c o n t e n t /s u p p o r t,并在搜索字段中输入书籍名称。所需信息将出现在勘误部分下。
盗版
互联网上版权材料的盗版是一个跨所有媒体的持续问题。在 Packt,我们非常重视我们版权和许可证的保护。如果您在互联网上遇到任何形式的非法复制我们的作品,请立即向我们提供位置地址或网站名称,以便我们可以寻求补救措施。
请通过copyright@packtpub.com与我们联系,并提供疑似盗版材料的链接。
我们感谢您在保护我们的作者和我们为您提供有价值内容的能力方面的帮助。
问题
如果您对本书的任何方面有问题,您可以联系我们的questions@packtpub.com,我们将尽力解决问题。
第一章:树莓派的卡通化器和皮肤转换器
本章将展示如何为桌面和如树莓派等小型嵌入式系统编写一些图像处理过滤器。首先,我们为桌面(使用 C/C++)开发它,然后将项目移植到树莓派上,因为这是为嵌入式设备开发时推荐的场景。本章将涵盖以下主题:
-
如何将现实生活中的图像转换为素描草图
-
如何将图像转换为绘画并在其上叠加草图以产生卡通效果
-
一个恐怖的邪恶模式,用于创建反派角色而不是正面角色
-
一个基本的皮肤检测器和皮肤颜色转换器,可以给某人绿色的外星皮肤
-
最后,如何基于我们的桌面应用程序创建嵌入式系统
注意,一个嵌入式系统基本上是将计算机主板放置在产品或设备内部,用于执行特定任务,而树莓派是构建嵌入式系统的一个非常低廉且流行的主板:

上一张图片展示了本章完成后你可以制作的内容:一个可穿戴的电池供电的树莓派加屏幕,可以将每个人变成卡通人物!
我们希望使现实世界的相机帧自动看起来像是从卡通中来的。基本思路是填充平面部分一些颜色,然后在强边缘上画粗线。换句话说,平面区域应该变得更加平坦,边缘应该变得更加明显。我们将检测边缘,平滑平面区域,并在其上重新绘制增强的边缘,以产生卡通或漫画效果。
在开发嵌入式计算机视觉系统时,在将其移植到嵌入式系统之前先构建一个完全工作的桌面版本是一个好主意,因为与嵌入式系统相比,开发桌面程序和调试要容易得多!因此,本章将从完整的卡通化器桌面程序开始,你可以使用你喜欢的 IDE(例如,Visual Studio、XCode、Eclipse、QtCreator)创建它。在桌面上运行正常后,最后一节将展示如何基于桌面版本创建嵌入式系统。许多嵌入式项目需要为嵌入式系统编写一些自定义代码,例如使用不同的输入和输出或使用一些平台特定的代码优化。然而,对于本章,我们实际上将在嵌入式系统和桌面系统上运行相同的代码,所以我们只需要创建一个项目。
该应用程序使用OpenCV GUI 窗口,初始化摄像头,并且对于每一帧摄像头图像,它都会调用cartoonifyImage()函数,其中包含本章的大部分代码。然后,它在 GUI 窗口上显示处理后的图像。本章将解释如何从头开始使用 USB 摄像头创建桌面应用程序,以及使用树莓派摄像头模块的基于桌面应用程序的嵌入式系统。因此,首先你会在你喜欢的 IDE 中创建一个桌面项目,创建一个main.cpp文件来保存以下章节中给出的 GUI 代码,例如主循环、摄像头功能、键盘输入,并且你会创建一个cartoon.cpp文件,其中包含图像处理操作,大部分本章的代码都在一个名为cartoonifyImage()的函数中。
本书的全源代码可在github.com/MasteringOpenCV/code找到。
访问摄像头
要访问计算机的摄像头或相机设备,你可以简单地在cv::VideoCapture对象上调用open()函数(OpenCV 访问你的相机设备的方法),并将 0 作为默认的摄像头 ID 号传递。一些计算机连接了多个摄像头,或者它们不是作为默认的摄像头 0 工作,因此允许用户将所需的摄像头编号作为命令行参数传递是一种常见的做法,例如,他们可能想尝试摄像头 1、2 或-1。我们还将尝试使用cv::VideoCapture::set()将摄像头分辨率设置为 640x480,以便在高清摄像头上运行更快。
根据你的摄像头型号、驱动程序或系统,OpenCV 可能不会更改你摄像头的属性。对于这个项目来说并不重要,所以如果你发现它不能与你的摄像头一起工作,请不要担心。
你可以将此代码放在main.cpp文件的main()函数中:
int cameraNumber = 0;
if (argc> 1)
cameraNumber = atoi(argv[1]);
// Get access to the camera.
cv::VideoCapture camera;
camera.open(cameraNumber);
if (!camera.isOpened()) {
std::cerr<<"ERROR: Could not access the camera or video!"<<
std::endl;
exit(1);
}
// Try to set the camera resolution.
camera.set(cv::CV_CAP_PROP_FRAME_WIDTH, 640);
camera.set(cv::CV_CAP_PROP_FRAME_HEIGHT, 480);
在摄像头初始化之后,你可以将当前摄像头图像作为cv::Mat对象(OpenCV 的图像容器)捕获。你可以通过使用 C++流操作符从你的cv::VideoCapture对象到cv::Mat对象来捕获每一帧摄像头图像,就像你从控制台获取输入一样。
OpenCV 使得从视频文件(如 AVI 或 MP4 文件)或网络流中捕获帧变得非常容易,而不是从摄像头中捕获。你不需要传递整数,如camera.open(0),而是传递一个字符串,如camera.open("my_video.avi"),然后像处理摄像头一样捕获帧。本书提供的源代码中有一个initCamera()函数,它可以打开摄像头、视频文件或网络流。
桌面应用程序的主摄像头处理循环
如果您想使用 OpenCV 在屏幕上显示 GUI 窗口,您需要调用cv::namedWindow()函数,然后对每个图像调用cv::imshow()函数,但您还必须对每个帧调用一次cv::waitKey(),否则您的窗口将完全不会更新!调用cv::waitKey(0)将无限期等待,直到用户在窗口中按下一个键,但一个正数,例如waitKey(20)或更高,将至少等待那么多的毫秒。
将此主循环放入main.cpp文件中,作为您实时相机应用程序的基础:
while (true) {
// Grab the next camera frame.
cv::Mat cameraFrame;
camera>>cameraFrame;
if (cameraFrame.empty()) {
std::cerr<<"ERROR: Couldn't grab a camera frame."<<
std::endl;
exit(1);
}
// Create a blank output image, that we will draw onto.
cv::Mat displayedFrame(cameraFrame.size(), cv::CV_8UC3);
// Run the cartoonifier filter on the camera frame.
cartoonifyImage(cameraFrame, displayedFrame);
// Display the processed image onto the screen.
imshow("Cartoonifier", displayedFrame);
// IMPORTANT: Wait for atleast 20 milliseconds,
// so that the image can be displayed on the screen!
// Also checks if a key was pressed in the GUI window.
// Note that it should be a "char" to support Linux.
char keypress = cv::waitKey(20); // Needed to see anything!
if (keypress == 27) { // Escape Key
// Quit the program!
break;
}
}//end while
生成黑白素描
要获得相机帧的素描(黑白绘图),我们将使用边缘检测滤波器,而要获得彩色绘画,我们将使用边缘保持滤波器(双边滤波器)来进一步平滑平坦区域,同时保持边缘完整。通过将素描绘制叠加在彩色绘画之上,我们获得卡通效果,如之前截图中的最终应用程序所示。
有许多不同的边缘检测滤波器,例如 Sobel、Scharr、拉普拉斯滤波器或 Canny 边缘检测器。我们将使用拉普拉斯边缘滤波器,因为它产生的边缘与手绘草图相比最为相似,与 Sobel 或 Scharr 相比相当一致,与 Canny 边缘检测器相比则相当干净,但更容易受到相机帧中随机噪声的影响,因此线图在帧之间往往会发生剧烈变化。
尽管如此,我们仍然需要在使用拉普拉斯边缘滤波器之前减少图像中的噪声。我们将使用中值滤波器,因为它擅长去除噪声同时保持边缘锐利,但比双边滤波器慢。由于拉普拉斯滤波器使用灰度图像,我们必须将 OpenCV 的默认 BGR 格式转换为灰度。在您的空cartoon.cpp文件中,将此代码放在顶部,这样您就可以在不输入cv::和std::的情况下访问 OpenCV 和 STD C++模板:
// Include OpenCV's C++ Interface
#include "opencv2/opencv.hpp"
using namespace cv;
using namespace std;
将此代码和所有剩余代码放入您的cartoon.cpp文件中的cartoonifyImage()函数中:
Mat gray;
cvtColor(srcColor, gray, CV_BGR2GRAY);
const int MEDIAN_BLUR_FILTER_SIZE = 7;
medianBlur(gray, gray, MEDIAN_BLUR_FILTER_SIZE);
Mat edges;
const int LAPLACIAN_FILTER_SIZE = 5;
Laplacian(gray, edges, CV_8U, LAPLACIAN_FILTER_SIZE);
拉普拉斯滤波器产生的边缘亮度各异,因此为了使边缘看起来更像素描,我们应用二值阈值使边缘要么是白色要么是黑色:
Mat mask;
const int EDGES_THRESHOLD = 80;
threshold(edges, mask, EDGES_THRESHOLD, 255, THRESH_BINARY_INV);
在以下图中,您可以看到原始图像(左侧)和生成的类似素描的边缘掩码(右侧)。在我们生成彩色绘画(稍后解释)之后,我们也将此边缘掩码放在顶部,以获得黑色线图:

生成彩色绘画和卡通
强大的双边滤波器可以平滑平坦区域,同时保持边缘锐利;因此,它非常适合作为自动卡通化或绘画滤镜,除了它极其缓慢(即以秒或甚至分钟计算,而不是毫秒!)之外。因此,我们将使用一些技巧来获得一个不错的卡通化效果,同时仍然以可接受的速度运行。我们可以使用的一个最重要的技巧是,我们可以在较低的分辨率下执行双边滤波,它仍然会产生与全分辨率相似的效果,但运行得更快。让我们将总像素数减少四分之一(例如,宽度和高度各减半):
Size size = srcColor.size();
Size smallSize;
smallSize.width = size.width/2;
smallSize.height = size.height/2;
Mat smallImg = Mat(smallSize, CV_8UC3);
resize(srcColor, smallImg, smallSize, 0,0, INTER_LINEAR);
我们不会应用一个大的双边滤波器,而是会应用许多小的双边滤波器,以在更短的时间内产生强烈的卡通效果。我们将截断滤波器(见下图),这样它就只使用达到令人信服结果所需的最小滤波器尺寸(例如,即使钟形曲线宽度为 21 像素,也只使用 9x9 的滤波器尺寸)。这个截断滤波器将应用滤波器的大部分(灰色区域),而不会在滤波器的次要部分(曲线下的白色区域)上浪费时间,因此它将运行得快几倍:

因此,我们有四个参数控制双边滤波器:颜色强度、位置强度、大小和重复计数。我们需要一个临时的Mat,因为bilateralFilter()函数不能覆盖其输入(称为就地处理),但我们可以应用一个存储临时Mat的滤波器,然后应用另一个存储输入的滤波器:
Mat tmp = Mat(smallSize, CV_8UC3);
int repetitions = 7; // Repetitions for strong cartoon effect.
for (int i=0; i<repetitions; i++) {
int ksize = 9; // Filter size. Has large effect on speed.
double sigmaColor = 9; // Filter color strength.
double sigmaSpace = 7; // Spatial strength. Affects speed.
bilateralFilter(smallImg, tmp, ksize, sigmaColor, sigmaSpace); bilateralFilter(tmp, smallImg, ksize, sigmaColor, sigmaSpace);
}
记住,这是应用于缩小后的图像,因此我们需要将图像扩展回原始大小。然后我们可以叠加之前找到的边缘蒙版。要将边缘蒙版草图叠加到双边滤波器绘画(下图的左侧),我们可以从黑色背景开始,并复制草图蒙版中不是边缘的绘画像素:
Mat bigImg;
resize(smallImg, bigImg, size, 0,0, INTER_LINEAR);
dst.setTo(0);
bigImg.copyTo(dst, mask);
结果是原始照片的卡通版本,如图中右侧所示,其中在绘画上叠加了草图蒙版:

使用边缘滤镜生成邪恶模式
卡通和漫画总是有好的和坏的角色。通过合适的边缘滤镜组合,可以从看起来最无辜的人那里生成一个令人恐惧的图像!技巧是使用一个小边缘滤镜,它将在整个图像中找到许多边缘,然后使用一个小中值滤波器合并这些边缘。
我们将在一个带有一些噪声减少的灰度图像上执行此操作,因此之前将原始图像转换为灰度并应用 7x7 中值滤波器的代码仍然应该使用(以下图中第一幅图像显示了灰度中值模糊的输出)。我们不需要跟随拉普拉斯滤波器和二值阈值,如果我们沿着x和y应用 3x3 Scharr 梯度滤波器(图中第二幅图像),然后使用一个非常低的截止值的二值阈值(图中第三幅图像),以及 3x3 中值模糊,就可以得到最终的邪恶蒙版(图中第四幅图像):
Mat gray;
cvtColor(srcColor, gray, CV_BGR2GRAY);
const int MEDIAN_BLUR_FILTER_SIZE = 7;
medianBlur(gray, gray, MEDIAN_BLUR_FILTER_SIZE);
Mat edges, edges2;
Scharr(srcGray, edges, CV_8U, 1, 0);
Scharr(srcGray, edges2, CV_8U, 1, 0, -1);
edges += edges2;
// Combine the x & y edges together.
const int EVIL_EDGE_THRESHOLD = 12
threshold(edges, mask, EVIL_EDGE_THRESHOLD, 255,
THRESH_BINARY_INV);
medianBlur(mask, mask, 3)

现在我们有了邪恶蒙版,我们可以像处理常规的草图边缘蒙版一样将此蒙版叠加到卡通化的绘画图像上。最终结果显示在以下图的右侧:

使用皮肤检测生成外星人模式
现在我们有了草图模式、卡通模式(绘画+草图蒙版)和邪恶模式(绘画+邪恶蒙版),为了好玩,让我们尝试更复杂的东西:一个外星人模式,通过检测面部皮肤区域,然后将皮肤颜色变为绿色。
皮肤检测算法
在检测皮肤区域方面,有许多不同的技术被使用,从简单的使用RGB(红-绿-蓝)、HSV(色调-饱和度-亮度)值或颜色直方图计算和重新投影的颜色阈值,到复杂的混合模型机器学习算法,这些算法需要在CIELab颜色空间中进行相机校准,并使用许多样本面部进行离线训练,等等。但是,即使是复杂的方法也不一定在各种相机、照明条件和皮肤类型下都能稳健地工作。由于我们希望我们的皮肤检测在嵌入式设备上运行,无需任何校准或训练,而我们只是使用皮肤检测作为有趣的图像过滤器,因此对我们来说,使用简单的皮肤检测方法就足够了。然而,树莓派相机模块中的微型相机传感器的颜色响应往往会显著变化,我们希望支持任何肤色的人的皮肤检测,但无需任何校准,因此我们需要比简单的颜色阈值更稳健的方法。
例如,一个简单的 HSV 皮肤检测器可以将其色调颜色相当红色、饱和度相当高但不是极高、亮度不是太暗或极度明亮的任何像素视为皮肤。但是,手机或树莓派相机模块中的相机通常有很差的白平衡,因此一个人的皮肤可能看起来是略带蓝色而不是红色,等等,这将是简单 HSV 阈值的一个主要问题。
一个更稳健的解决方案是使用 Haar 或 LBP 级联分类器进行人脸检测(见第六章,使用特征脸或费舍尔脸进行人脸识别),然后查看检测到的脸部中间像素的颜色范围,因为你知道那些像素应该是实际人的皮肤像素。然后你可以在整个图像或附近区域扫描与脸部中心颜色相似的像素。这个方法的优势是,无论检测到的人的皮肤颜色如何,甚至如果他们的皮肤在相机图像中看起来有些蓝色或红色,都极有可能找到至少一些真正的皮肤区域。
不幸的是,使用级联分类器进行人脸检测在当前的嵌入式设备上相当慢,因此这种方法可能对某些实时嵌入式应用来说不太理想。另一方面,我们可以利用这样一个事实,对于移动应用和一些嵌入式系统,可以预期用户将直接从非常近的距离面对相机,因此要求用户将脸部放置在特定的位置和距离是合理的,而不是试图检测他们脸部的位置和大小。这是许多手机应用的基础,应用会要求用户将脸部放置在某个位置,或者手动在屏幕上拖动点来显示照片中脸部角的位置。所以让我们简单地绘制屏幕中央的脸部轮廓,并要求用户将脸部移动到显示的位置和大小。
向用户展示他们应该将脸部放在哪里
当第一次启动外星人模式时,我们将在相机帧上绘制脸部轮廓,以便用户知道他们的脸部应该放在哪里。我们将绘制一个覆盖 70%图像高度的椭圆,具有固定的宽高比 0.72,这样脸部就不会因为相机的宽高比而变得太瘦或太胖:
// Draw the color face onto a black background.
Mat faceOutline = Mat::zeros(size, CV_8UC3);
Scalar color = CV_RGB(255,255,0); // Yellow.
int thickness = 4;
// Use 70% of the screen height as the face height.
int sw = size.width;
int sh = size.height;
int faceH = sh/2 * 70/100; // "faceH" is radius of the ellipse.
// Scale the width to be the same nice shape for any screen width.
int faceW = faceH * 72/100;
// Draw the face outline.
ellipse(faceOutline, Point(sw/2, sh/2), Size(faceW, faceH),
0, 0, 360, color, thickness, CV_AA);
为了更明显地表明这是一个脸部,我们还可以绘制两个眼睛轮廓。而不是将眼睛画成椭圆,我们可以通过绘制眼睛顶部和底部的截断椭圆来给它增加一点现实感(见以下图示),因为我们可以在使用ellipse()函数绘制时指定起始和结束角度:
// Draw the eye outlines, as 2 arcs per eye.
int eyeW = faceW * 23/100;
int eyeH = faceH * 11/100;
int eyeX = faceW * 48/100;
int eyeY = faceH * 13/100;
Size eyeSize = Size(eyeW, eyeH);
// Set the angle and shift for the eye half ellipses.
int eyeA = 15; // angle in degrees.
int eyeYshift = 11;
// Draw the top of the right eye.
ellipse(faceOutline, Point(sw/2 - eyeX, sh/2 -eyeY),
eyeSize, 0, 180+eyeA, 360-eyeA, color, thickness, CV_AA);
// Draw the bottom of the right eye.
ellipse(faceOutline, Point(sw/2 - eyeX, sh/2 - eyeY-eyeYshift),
eyeSize, 0, 0+eyeA, 180-eyeA, color, thickness, CV_AA);
// Draw the top of the left eye.
ellipse(faceOutline, Point(sw/2 + eyeX, sh/2 - eyeY),
eyeSize, 0, 180+eyeA, 360-eyeA, color, thickness, CV_AA);
// Draw the bottom of the left eye.
ellipse(faceOutline, Point(sw/2 + eyeX, sh/2 - eyeY-eyeYshift),
eyeSize, 0, 0+eyeA, 180-eyeA, color, thickness, CV_AA);
我们可以用同样的方法来绘制嘴巴的底部:
// Draw the bottom lip of the mouth.
int mouthY = faceH * 48/100;
int mouthW = faceW * 45/100;
int mouthH = faceH * 6/100;
ellipse(faceOutline, Point(sw/2, sh/2 + mouthY), Size(mouthW,
mouthH), 0, 0, 180, color, thickness, CV_AA);
为了让用户更清楚地知道应该将脸部放在显示的位置,让我们在屏幕上写一条信息!
// Draw anti-aliased text.
int fontFace = FONT_HERSHEY_COMPLEX;
float fontScale = 1.0f;
int fontThickness = 2;
char *szMsg = "Put your face here";
putText(faceOutline, szMsg, Point(sw * 23/100, sh * 10/100),
fontFace, fontScale, color, fontThickness, CV_AA);
现在我们已经画出了脸部轮廓,我们可以通过使用透明度混合将其叠加到显示的图像上,以将卡通化图像与绘制的轮廓结合:
addWeighted(dst, 1.0, faceOutline, 0.7, 0, dst, CV_8UC3);
这导致了以下图中的轮廓,显示了用户应该将脸部放在哪里,因此我们不需要检测脸部位置:

肌肤颜色改变器的实现
而不是先检测肤色然后检测具有该肤色的区域,我们可以使用 OpenCV 的floodFill()函数,这个函数在很多图像编辑软件中的桶填充工具类似。我们知道屏幕中间的区域应该是皮肤像素(因为我们要求用户将脸部放在中间),所以要将整个脸部变成绿色皮肤,我们只需在中心像素上应用绿色填充,这将始终使脸部的一些部分变成绿色。实际上,颜色、饱和度和亮度在脸的不同部分可能不同,所以除非阈值非常低,否则填充通常不会覆盖脸部所有的皮肤像素。因此,我们不是在图像的中心应用单个填充,而是在脸部周围六个不同的点应用填充,这些点应该是皮肤像素。
OpenCV 的floodFill()的一个不错的特点是它可以将填充绘制到外部图像中,而不是修改输入图像。因此,这个功能可以给我们一个掩码图像,用于调整皮肤像素的颜色,而无需改变亮度或饱和度,产生比所有皮肤像素都变成相同的绿色像素(丢失显著的面部细节)更逼真的图像。
在 RGB 颜色空间中,肤色改变的效果并不好,因为你希望脸部亮度可以变化,但肤色变化不大,而 RGB 没有将亮度与颜色分开。一个解决方案是使用 HSV 颜色空间,因为它将亮度从颜色(色调)以及色彩的鲜艳程度(饱和度)中分离出来。不幸的是,HSV 将色调值围绕红色进行循环,由于皮肤主要是红色,这意味着你需要同时处理色调 < 10%和*色调 > 90%,因为这两个都是红色。因此,我们将使用Y'CrCb颜色空间(OpenCV 中 YUV 的变体),因为它将亮度与颜色分开,并且对于典型的皮肤颜色只有一个值范围,而不是两个。请注意,大多数相机、图像和视频在转换为 RGB 之前实际上使用某种类型的 YUV 作为它们的颜色空间,所以在许多情况下,你可以免费获得 YUV 图像,而无需自己转换。
由于我们希望我们的外星模式看起来像卡通,我们将在图像已经被卡通化之后应用外星滤镜。换句话说,我们有权访问双边滤波器产生的缩小颜色图像,以及完整的边缘掩码。皮肤检测在低分辨率下通常效果更好,因为它相当于分析每个高分辨率像素邻居的平均值(或低频信号而不是高频噪声信号)。所以让我们以双边滤波器相同的缩小比例工作(半宽度和半高度)。让我们将绘画图像转换为 YUV:
Mat yuv = Mat(smallSize, CV_8UC3);
cvtColor(smallImg, yuv, CV_BGR2YCrCb);
我们还需要缩小边缘掩码,使其与绘画图像具有相同的比例。在使用 OpenCV 的floodFill()函数时存在一个复杂问题,当存储到单独的掩码图像中时,掩码应围绕整个图像有 1 像素的边界,因此如果输入图像大小为WxH像素,则单独的掩码图像大小应为(W+2) x (H+2)像素。但是floodFill()函数也允许我们使用边缘初始化掩码,这样洪水填充算法将确保它不会交叉。让我们利用这个特性,希望它能帮助防止洪水填充扩展到面部之外。因此,我们需要提供两个掩码图像:一个是WxH大小的边缘掩码,另一个图像是大小完全相同的边缘掩码,但为(W+2)x(H+2),因为它应包括图像周围的边界。可能存在多个cv::Mat对象(或头文件)引用相同的数据,或者甚至有一个cv::Mat对象引用另一个cv::Mat图像的子区域。因此,我们不是分配两个单独的图像并将边缘掩码像素复制过来,而是分配一个包含边界的单个掩码图像,并创建一个额外的WxH大小的cv::Mat头文件(它仅引用洪水填充掩码中的感兴趣区域,不包括边界)。换句话说,只有一个大小为(W+2)x(H+2)的像素数组,但有两个cv::Mat对象,其中一个是引用整个(W+2)x(H+2)图像,另一个是引用该图像中间的WxH区域:
int sw = smallSize.width;
int sh = smallSize.height;
Mat mask, maskPlusBorder;
maskPlusBorder = Mat::zeros(sh+2, sw+2, CV_8UC1);
mask = maskPlusBorder(Rect(1,1,sw,sh));
// mask is now in maskPlusBorder.
resize(edges, mask, smallSize); // Put edges in both of them.
边缘掩码(如图中左侧所示)充满了强和弱边缘,但我们只想保留强边缘,因此我们将应用二值阈值(结果如图中中间图像所示)。为了连接边缘之间的某些间隙,我们将结合形态学算子dilate()和erode()来消除一些间隙(也称为闭合算子),结果如图中右侧所示:
const int EDGES_THRESHOLD = 80;
threshold(mask, mask, EDGES_THRESHOLD, 255, THRESH_BINARY);
dilate(mask, mask, Mat());
erode(mask, mask, Mat());

如前所述,我们想在面部周围多个点应用洪水填充,以确保包括整个面部的各种颜色和阴影。让我们选择六个点,围绕鼻子、面颊和额头,如图中左侧所示。请注意,这些值取决于之前绘制的面部轮廓:
int const NUM_SKIN_POINTS = 6;
Point skinPts[NUM_SKIN_POINTS];
skinPts[0] = Point(sw/2, sh/2 - sh/6);
skinPts[1] = Point(sw/2 - sw/11, sh/2 - sh/6);
skinPts[2] = Point(sw/2 + sw/11, sh/2 - sh/6);
skinPts[3] = Point(sw/2, sh/2 + sh/16);
skinPts[4] = Point(sw/2 - sw/9, sh/2 + sh/16);
skinPts[5] = Point(sw/2 + sw/9, sh/2 + sh/16);
现在我们只需要找到洪水填充的一些良好的上下限。记住,这是在Y'CrCb颜色空间中进行的,所以我们基本上决定亮度可以变化多少,红色分量可以变化多少,以及蓝色分量可以变化多少。我们希望亮度变化很大,包括阴影、高光和反射,但我们不希望颜色变化很大:
const int LOWER_Y = 60;
const int UPPER_Y = 80;
const int LOWER_Cr = 25;
const int UPPER_Cr = 15;
const int LOWER_Cb = 20;
const int UPPER_Cb = 15;
Scalar lowerDiff = Scalar(LOWER_Y, LOWER_Cr, LOWER_Cb);
Scalar upperDiff = Scalar(UPPER_Y, UPPER_Cr, UPPER_Cb);
我们将使用带有默认标志的floodFill()函数,除了我们希望将其存储到外部掩码中,因此必须指定FLOODFILL_MASK_ONLY:
const int CONNECTED_COMPONENTS = 4; // To fill diagonally, use 8\.
const int flags = CONNECTED_COMPONENTS | FLOODFILL_FIXED_RANGE
| FLOODFILL_MASK_ONLY;
Mat edgeMask = mask.clone(); // Keep a copy of the edge mask.
// "maskPlusBorder" is initialized with edges to block floodFill().
for (int i = 0; i < NUM_SKIN_POINTS; i++) {
floodFill(yuv, maskPlusBorder, skinPts[i], Scalar(), NULL,
lowerDiff, upperDiff, flags);
}
下图左侧显示了六个洪水填充位置(以圆圈表示),图例右侧显示了生成的外部掩码,其中皮肤以灰色显示,边缘以白色显示。请注意,右侧图像已被修改以供本书使用,以便皮肤像素(值为 1)清晰可见:

上一个图例右侧显示的mask图像现在包含以下内容:
-
边缘像素的值为 255
-
皮肤区域的像素值为 1
-
其余像素的值为 0
同时,edgeMask仅包含边缘像素(值为 255)。因此,要仅获取皮肤像素,我们可以从其中移除边缘:
mask -= edgeMask;
mask变量现在仅包含皮肤像素的 1 和非皮肤像素的 0。要更改原始图像的皮肤颜色和亮度,我们可以使用cv::add()函数与皮肤掩码一起使用,以增加原始 BGR 图像中的绿色分量:
int Red = 0;
int Green = 70;
int Blue = 0;
add(smallImgBGR, CV_RGB(Red, Green, Blue), smallImgBGR, mask);
下图显示了左侧的原始图像和右侧的最终外星人卡通图像,其中现在至少有六个面部部分将变为绿色!

注意,我们已经使皮肤看起来更绿也更亮(看起来像在黑暗中发光的外星人)。如果您只想改变皮肤颜色而不使其变亮,可以使用其他颜色变换方法,例如在绿色上添加 70,同时从红色和蓝色中减去 70,或者使用cvtColor(src, dst, "CV_BGR2HSV_FULL")转换为 HSV 颜色空间,并调整色调和饱和度。
减少草图图像中的随机胡椒噪声
智能手机中的大多数小型摄像头、RPi 摄像头模块和一些网络摄像头都有明显的图像噪声。这通常是可接受的,但它对我们的 5x5 拉普拉斯边缘过滤器有很大的影响。边缘掩码(如图所示为草图模式)通常会有成千上万的黑色像素小团块,称为胡椒噪声,由几个相邻的黑色像素在白色背景中组成。我们已经在使用中值滤波器,这通常足以去除胡椒噪声,但在此情况下可能不够强大。我们的边缘掩码主要是纯白色背景(值为 255)和一些黑色边缘(值为 0)以及噪声点(也是值为 0)。我们可以使用标准的闭合形态学算子,但它将去除很多边缘。因此,我们将应用一个自定义过滤器,该过滤器移除完全被白色像素包围的小黑色区域。这将去除很多噪声,同时对实际边缘的影响很小。
我们将扫描图像中的黑色像素,并在每个黑色像素周围检查 5x5 平方区域的边界,看是否所有 5x5 边界像素都是白色。如果它们都是白色,那么我们知道我们有一个小的黑色噪声岛,因此我们将整个块填充为白色像素以去除黑色岛屿。为了简化我们的 5x5 过滤器,我们将忽略图像周围的两个边界像素,并保持它们不变。
下图显示了左侧的 Android 平板电脑上的原始图像,中间是草图模式,显示了中间的小黑点椒噪声,右侧显示了我们的椒噪声去除结果,皮肤看起来更干净:

以下代码可以命名为removePepperNoise()函数,以简化图像的编辑:
void removePepperNoise(Mat &mask)
{
for (int y=2; y<mask.rows-2; y++) {
// Get access to each of the 5 rows near this pixel.
uchar *pUp2 = mask.ptr(y-2);
uchar *pUp1 = mask.ptr(y-1);
uchar *pThis = mask.ptr(y);
uchar *pDown1 = mask.ptr(y+1);
uchar *pDown2 = mask.ptr(y+2);
// Skip the first (and last) 2 pixels on each row.
pThis += 2;
pUp1 += 2;
pUp2 += 2;
pDown1 += 2;
pDown2 += 2;
for (int x=2; x<mask.cols-2; x++) {
uchar value = *pThis; // Get pixel value (0 or 255).
// Check if it's a black pixel surrounded bywhite
// pixels (ie: whether it is an "island" of black).
if (value == 0) {
bool above, left, below, right, surroundings;
above = *(pUp2 - 2) && *(pUp2 - 1) && *(pUp2) &&
*(pUp2 + 1) && *(pUp2 + 2);
left = *(pUp1 - 2) && *(pThis - 2) && *(pDown1 - 2);
below = *(pDown2 - 2) && *(pDown2 - 1) && *(pDown2)
&&*(pDown2 + 1) && *(pDown2 + 2);
right = *(pUp1 + 2) && *(pThis + 2) && *(pDown1 + 2);
surroundings = above && left && below && right;
if (surroundings == true) {
// Fill the whole 5x5 block as white. Since we
// knowthe 5x5 borders are already white, we just
// need tofill the 3x3 inner region.
*(pUp1 - 1) = 255;
*(pUp1 + 0) = 255;
*(pUp1 + 1) = 255;
*(pThis - 1) = 255;
*(pThis + 0) = 255;
*(pThis + 1) = 255;
*(pDown1 - 1) = 255;
*(pDown1 + 0) = 255;
*(pDown1 + 1) = 255;
// Since we just covered the whole 5x5 block with
// white, we know the next 2 pixels won't be
// black,so skip the next 2 pixels on the right.
pThis += 2;
pUp1 += 2;
pUp2 += 2;
pDown1 += 2;
pDown2 += 2;
}
}
// Move to the next pixel on the right.
pThis++;
pUp1++;
pUp2++;
pDown1++;
pDown2++;
}
}
}
就这样!在不同的模式下运行应用程序,直到你准备好将其迁移到嵌入式系统!
从桌面迁移到嵌入式系统
现在程序在桌面上运行良好,我们可以从它制作一个嵌入式系统。这里给出的细节是针对 Raspberry Pi 的,但类似的步骤也适用于为其他嵌入式 Linux 系统开发,如 BeagleBone、ODROID、Olimex、Jetson 等。
在嵌入式系统上运行我们的代码有几种不同的选择,每种选择在不同场景下都有一些优缺点。
编译嵌入式设备代码有两种常见方法:
-
将源代码从桌面复制到设备上,并在设备上直接编译。这通常被称为原生编译,因为我们是在最终运行代码的同一系统上本地编译代码。
-
在桌面上编译所有代码,但使用特殊方法生成设备的代码,然后将最终的可执行程序复制到设备上。这通常被称为交叉编译,因为你需要一个特殊的编译器,它知道如何为其他类型的 CPU 生成代码。
与原生编译相比,交叉编译通常配置起来要困难得多,尤其是如果你使用了多个共享库。但是,由于你的桌面通常比你的嵌入式设备快得多,交叉编译在编译大型项目时通常要快得多。如果你预计要编译你的项目数百次,以便在几个月内对其进行工作,并且你的设备与桌面相比相当慢,比如与桌面相比非常慢的 Raspberry Pi 1 或 Raspberry Pi Zero,那么交叉编译是一个好主意。但在大多数情况下,特别是对于小型简单项目,你应该坚持使用原生编译,因为它更容易。
注意,你项目中使用的所有库也需要为设备编译,因此你需要为你的设备编译 OpenCV。在 Raspberry Pi 1 上本地编译 OpenCV 可能需要数小时,而桌面交叉编译 OpenCV 可能只需 15 分钟。但通常你只需要编译一次 OpenCV,然后你就可以为所有项目使用它,所以大多数情况下,坚持使用项目的原生编译(包括 OpenCV 的原生编译)仍然是值得的。
在嵌入式系统上运行代码也有几种选择:
-
使用与桌面相同的输入和输出方法,例如相同的视频文件或 USB 摄像头或键盘作为输入,并以与桌面相同的方式将文本或图形显示到 HDMI 显示器上。
-
使用特殊的输入和输出设备。例如,你不必坐在桌前使用 USB 摄像头和键盘作为输入,并将输出显示到桌面显示器上,你可以使用特殊的树莓派摄像头模块作为视频输入,使用定制的 GPIO 推按钮或传感器作为输入,并使用 7 英寸 MIPI DSI 屏幕或 GPIO LED 灯作为输出,然后通过一个通用的便携式 USB 充电器为所有设备供电,你就可以将整个计算机平台背在背包里,或者将其固定在自行车上!
-
另一个选择是将数据流进或流出嵌入式设备到其他计算机,或者甚至使用一个设备来输出摄像头数据,另一个设备来使用这些数据。例如,你可以使用 Gstreamer 框架来配置树莓派,从其摄像头模块流式传输 H.264 压缩视频到以太网网络或通过 Wi-Fi,这样局域网中的强大 PC 或服务器机架或亚马逊 AWS 云计算服务就可以在其他地方处理视频流。这种方法允许使用一个小巧廉价的摄像头设备,在需要位于其他地方的大量处理资源的复杂项目中使用。
如果你确实希望在设备上执行计算机视觉,请注意,一些低成本嵌入式设备,如树莓派 1、树莓派 Zero 和 BeagleBone Black,其计算能力比桌面或甚至廉价的上网本或智能手机慢得多,可能比你的桌面慢 10-50 倍,因此根据你的应用,你可能需要一个功能强大的嵌入式设备,或者像之前提到的那样将视频流到另一台计算机。如果你不需要太多的计算能力(例如,你只需要每 2 秒处理一帧,或者你只需要使用 160x120 的图像分辨率),那么运行一些计算机视觉的树莓派 Zero 可能足够快以满足你的需求。但许多计算机视觉系统需要更多的计算能力,因此如果你想在设备上执行计算机视觉,你通常会想要使用一个速度更快的设备,其 CPU 在 2 GHz 范围内,例如树莓派 3、ODROID-XU4 或 Jetson TK1。
为开发嵌入式设备代码的设备设置
让我们从尽可能简单开始,使用 USB 键盘和鼠标以及 HDMI 显示器,就像我们的桌面系统一样,在设备上本地编译代码,并在设备上运行我们的代码。我们的第一步将是将代码复制到设备上,安装构建工具,并在嵌入式系统上编译 OpenCV 和我们的源代码。
许多嵌入式设备,如 Raspberry Pi,都有一个 HDMI 端口和至少一个 USB 端口。因此,开始使用嵌入式设备的最简单方法是连接一个 HDMI 显示器和 USB 键盘和鼠标,以配置设置并查看输出,同时在您的桌面机器上开发代码并进行测试。如果您有一个备用的 HDMI 显示器,可以将其连接到设备上,但如果您没有备用的 HDMI 显示器,您可能需要考虑购买一个小型 HDMI 屏幕专门用于您的嵌入式设备。
此外,如果您没有备用的 USB 键盘和鼠标,您可能需要考虑购买一个带有单个 USB 无线接收器的无线键盘和鼠标,这样您只需为键盘和鼠标使用一个 USB 端口。许多嵌入式设备使用 5V 电源,但它们通常需要的电力(电流)比桌面或笔记本电脑的 USB 端口提供的要多。因此,您应该获得一个单独的 5V USB 充电器(至少 1.5 安培,理想情况下 2.5 安培),或者一个可携带的 USB 电池充电器,可以提供至少 1.5 安培的输出电流。您的设备可能大多数时候只需要 0.5 安培的电流,但偶尔它可能需要超过 1 安培的电流,因此使用至少 1.5 安培或更高额定功率的电源很重要,否则您的设备可能会偶尔重新启动,或者某些硬件在关键时刻可能会出现异常行为,或者文件系统可能会损坏,您会丢失文件!如果您不使用摄像头或配件,1 安培的电源可能足够好,但 2.0-2.5 安培更安全。
例如,以下照片展示了一个方便的设置,包括一个 Raspberry Pi 3,一张质量良好的 8 GB micro-SD 卡,售价 10 美元 (ebay.to/2ayp6Bo),一个 5 英寸 HDMI 触摸屏,售价 30-45 美元 (bit.ly/2aHQO2G),一个无线 USB 键盘和鼠标,售价 30 美元 (ebay.to/2aN2oXi),一个 5V 2.5A 电源,售价 5 美元 (ebay.to/2aCBLVK),一个 USB 摄像头,例如非常快速的 PS3 Eye,只需 5 美元 (ebay.to/2aVWCUS),一个 Raspberry Pi Camera Module v1 或 v2,售价 15-30 美元 (bit.ly/2aF9PxD),以及一根网线,售价 2 美元 (ebay.to/2aznnjd),将 Raspberry Pi 连接到与您的开发 PC 或笔记本电脑相同的局域网。请注意,这个 HDMI 屏幕是专门为 Raspberry Pi 设计的,因为屏幕直接插在下面的 Raspberry Pi 上,并有一个 HDMI 公对公适配器(如右手边照片所示)供 Raspberry Pi 使用,因此您不需要 HDMI 线,而其他屏幕可能需要 HDMI 线 (ebay.to/2aW4Fko) 或 MIPI DSI 或 SPI 线。另外,请注意,一些屏幕和触摸屏在它们工作之前需要配置,而大多数 HDMI 屏幕则无需配置即可工作:

注意黑色 USB 摄像头(位于 LCD 最左侧),Raspberry Pi 摄像头模块(绿色和黑色的板子位于 LCD 的左上角),Raspberry Pi 板(位于 LCD 下方),HDMI 适配器(连接 LCD 和下方的 Raspberry Pi),蓝色以太网线(插入路由器),小型 USB 无线键盘和鼠标适配器,以及微型 USB 电源线(插入5V 2.5A电源供应)。
配置新的 Raspberry Pi
以下步骤仅适用于 Raspberry Pi(也称为RPi),如果您使用的是不同的嵌入式设备或您想要不同的设置类型,请在网络上搜索如何设置您的板子。要设置 RPi 1、2 或 3(包括其变体,如 RPi Zero、RPi2B、3B 等,以及如果您插入 USB 以太网适配器,RPi 1A+),请按照以下步骤操作:
-
获取一张至少 8 GB 的较新、高质量的小型 SD 卡。如果您使用的是廉价的微型 SD 卡或已经多次使用且质量下降的旧微型 SD 卡,它可能不足以可靠地启动 RPi,因此如果您在启动 RPi 时遇到问题,应尝试使用高质量 Class 10 微型 SD 卡(例如 SanDisk Ultra 或更好),这种卡声称至少可以处理 45 MB/s 或可以处理 4K 视频。
-
下载并将最新的Raspbian IMG(不是 NOOBS)烧录到微型 SD 卡上。请注意,烧录 IMG 与简单地将文件复制到 SD 卡不同。访问
www.raspberrypi.org/documentation/installation/installing-images/,按照您桌面操作系统的说明将 Raspbian 烧录到微型 SD 卡。请注意,您将丢失卡上之前存在的任何文件。 -
将 USB 键盘、鼠标和 HDMI 显示器插入 RPi,这样您就可以轻松运行一些命令并查看输出。
-
将 RPi 插入至少 1.5A、理想情况下 2.5A 或更高电压的 5V USB 电源。计算机 USB 端口不够强大。
-
在启动 Raspbian Linux 时,您应该会看到许多页面文本滚动,然后大约 1 到 2 分钟后应该就绪。
-
如果启动后只显示一个带有一些文本(如下载了Raspbian Lite)的黑屏控制台,您处于纯文本登录提示符。通过输入用户名
pi并按Enter键登录。然后输入密码raspberry并再次按Enter键。 -
或者,如果它启动到图形显示,请点击顶部的黑色终端图标以打开 shell(命令提示符)。
-
在您的 RPi 中初始化一些设置:
-
输入
sudo raspi-config并按Enter键(见以下截图)。 -
首先,运行扩展文件系统,然后完成并重新启动您的设备,这样 Raspberry Pi 就可以使用整个微型 SD 卡。
-
如果您使用的是普通(美国)键盘,而不是英国键盘,在国际化选项中,将其更改为通用 104 键键盘,其他,英语(美国),然后对于
AltGr和类似的问题,只需按Enter键,除非您使用的是特殊键盘。 -
在启用摄像头中,启用 RPi 摄像头模块。
-
在超频选项中,设置为 RPi2 或类似型号,以便设备运行更快(但会产生更多热量)。
-
在高级选项中,启用 SSH 服务器。
-
在高级选项中,如果您使用的是 Raspberry Pi 2 或 3,将内存分割更改为 256MB,以便 GPU 有足够的 RAM 进行视频处理。对于 Raspberry Pi 1 或 Zero,使用 64MB 或默认值。
-
完成后重启设备。
- (可选)删除 Wolfram,以在您的 SD 卡上节省 600MB 的空间:
sudo apt-get purge -y wolfram-engine
可以使用sudo apt-get install wolfram-engine重新安装。
要查看 SD 卡上的剩余空间,运行df -h | head -2

- 假设您已将 RPi 连接到您的互联网路由器,它应该已经具有互联网访问权限。因此,将您的 RPi 更新到最新的 RPi 固件、软件位置、操作系统和软件。警告:许多 Raspberry Pi 教程建议您运行
sudo rpi-update;然而,近年来运行rpi-update已不再是一个好主意,因为它可能会给您带来不稳定的系统或固件。以下说明将更新您的 Raspberry Pi,使其具有稳定的软件和固件(请注意,这些命令可能需要 1 小时左右):
sudo apt-get -y update
sudo apt-get -y upgrade
sudo apt-get -y dist-upgrade
sudo reboot
- 查找设备的 IP 地址:
hostname -I
- 尝试从您的桌面访问设备。
例如,假设设备的 IP 地址是192.168.2.101。
在 Linux 桌面上:
ssh-X pi@192.168.2.101
或者在一个 Windows 桌面上:
-
下载、安装并运行 PuTTY
-
然后在 PuTTY 中,连接到 IP 地址(192.168.2.101),
-
使用用户
pi和密码raspberry
- (可选)如果您希望您的命令提示符与命令颜色不同,并在每个命令后显示错误值:
nano ~/.bashrc
将此行添加到末尾:
PS1="[e[0;44m]u@h: w ($?) $[e[0m] "
保存文件(按Ctrl + X,然后按Y,然后按Enter)。
开始使用新设置:
source ~/.bashrc
- 要禁用 Raspbian 中的屏幕保护程序/屏幕空白省电功能,以防止在空闲时关闭屏幕:
sudo nano /etc/lightdm/lightdm.conf
-
查找说
#xserver-command=X的行(通过按Alt + G跳转到行 87,然后输入87并按Enter)。 -
更改为:
**xserver-command=X -s 0 dpms** -
保存文件(按Ctrl + X然后按Y然后按Enter)。
sudo reboot
您现在应该准备好开始在设备上开发了!
在嵌入式设备上安装 OpenCV
在基于 Debian 的嵌入式设备(如 Raspberry Pi)上安装 OpenCV 及其所有依赖项有一个非常简单的方法:
sudo apt-get install libopencv-dev
然而,这可能会安装来自 1 或 2 年前的旧版 OpenCV。
要在 Raspberry Pi 这样的嵌入式设备上安装 OpenCV 的最新版本,我们需要从源代码构建 OpenCV。首先,我们安装一个编译器和构建系统,然后安装 OpenCV 所需的库,最后安装 OpenCV 本身。请注意,在 Linux 上从源代码编译 OpenCV 的步骤,无论是为桌面还是为嵌入式编译都是相同的。本书提供了一个名为install_opencv_from_source.sh的 Linux 脚本;建议您将文件复制到 Raspberry Pi 上(例如,使用 USB 闪存驱动器)并运行脚本以下载、构建和安装 OpenCV,包括潜在的 CPU 多核和ARM NEON SIMD优化(取决于硬件支持):
chmod +x install_opencv_from_source.sh
./install_opencv_from_source.sh
如果有任何错误,脚本将停止;例如,如果您没有互联网访问,或者依赖包与您已经安装的其他东西冲突。如果脚本因错误而停止,请尝试使用网络上的信息来解决该错误,然后再次运行脚本。脚本将快速检查所有之前的步骤,然后从上次停止的地方继续。请注意,根据您的硬件和软件,这可能需要 20 分钟到 12 小时不等!
高度推荐每次安装 OpenCV 后都构建和运行几个 OpenCV 示例,这样当您构建自己的代码时遇到问题时,至少您会知道问题是不是 OpenCV 安装的问题,或者是不是代码本身的问题。
让我们尝试构建简单的edge示例程序。如果我们尝试使用相同的 Linux 命令从 OpenCV 2 构建它,我们会得到一个构建错误:
cd ~/opencv-3.*/samples/cpp
g++ edge.cpp -lopencv_core -lopencv_imgproc -lopencv_highgui
-o edge
/usr/bin/ld: /tmp/ccDqLWSz.o: undefined reference to symbol '_ZN2cv6imreadERKNS_6StringEi'
/usr/local/lib/libopencv_imgcodecs.so.3.1: error adding symbols: DSO missing from command line
collect2: error: ld returned 1 exit status
那个错误信息的倒数第二行告诉我们命令行中缺少了一个库,所以我们只需要在链接到其他 OpenCV 库的命令旁边添加-lopencv_imgcodecs。现在您知道如何修复在编译 OpenCV 3 程序时遇到该错误信息的问题。所以让我们正确地做:
cd ~/opencv-3.*/samples/cpp
g++ edge.cpp -lopencv_core -lopencv_imgproc -lopencv_highgui
-lopencv_imgcodecs -o edge
成功了!所以现在您可以运行程序:
./edge
在键盘上按Ctrl + C来退出程序。请注意,如果尝试在 SSH 终端中运行命令而没有将窗口重定向到设备的 LCD 屏幕上,edge程序可能会崩溃。所以如果您使用 SSH 远程运行程序,请在命令前添加DISPLAY=:0:
DISPLAY=:0 ./edge
您还应该将 USB 摄像头插入设备并测试它是否工作:
g++ starter_video.cpp -lopencv_core -lopencv_imgproc
-lopencv_highgui -lopencv_imgcodecs -lopencv_videoio \
-o starter_video
DISPLAY=:0 ./starter_video 0
注意:如果您没有 USB 摄像头,可以使用视频文件进行测试:
DISPLAY=:0 ./starter_video ../data/768x576.avi
现在 OpenCV 已成功安装在您的设备上,您可以运行我们之前开发的 Cartoonifier 应用程序。将Cartoonifier文件夹复制到设备上(例如,使用 USB 闪存驱动器或使用scp通过网络复制文件)。然后像为桌面一样构建代码:
cd ~/Cartoonifier
export OpenCV_DIR="~/opencv-3.1.0/build"
mkdir build
cd build
cmake -D OpenCV_DIR=$OpenCV_DIR ..
make
然后运行它:
DISPLAY=:0 ./Cartoonifier

使用 Raspberry Pi 摄像头模块
在 Raspberry Pi 上使用 USB 摄像头可以方便地支持在桌面和嵌入式设备上具有相同的行为和代码,但你可能考虑使用官方的 Raspberry Pi 摄像头模块(简称RPi Cams)。它们与 USB 摄像头相比,有一些优缺点。
RPi 摄像头使用特殊的 MIPI CSI 摄像头格式,专为智能手机摄像头设计,以减少功耗。与 USB 相比,它们具有更小的物理尺寸、更快的带宽、更高的分辨率、更高的帧率和更低的延迟。大多数 USB 2.0 摄像头只能提供 640x480 或 1280x720 30 FPS 的视频,因为 USB 2.0 对于任何更高的速度都太慢(除非是一些昂贵的 USB 摄像头,它们在板上进行视频压缩),而 USB 3.0 仍然太贵。而智能手机摄像头(包括 RPi 摄像头)通常可以提供 1920x1080 30 FPS 或甚至超高清/4K 分辨率。实际上,RPi Cam v1 可以在$5 的 Raspberry Pi Zero 上提供高达 2592x1944 15 FPS 或 1920x1080 30 FPS 的视频,这得益于使用了 MIPI CSI 摄像头和 Raspberry Pi 内部兼容的视频处理 ISP 和 GPU 硬件。RPi 摄像头还支持在 90 FPS 模式下使用 640x480(例如用于慢动作捕捉),这对于实时计算机视觉非常有用,因为你可以看到每一帧中的非常小的运动,而不是难以分析的大运动。
然而,RPi 摄像头是一个简单的电路板,对电气干扰、静电或物理损伤非常敏感(只需用手指轻轻触摸那根小橙色的扁平电缆,就可能导致视频干扰,甚至永久损坏你的摄像头!)大扁平白色电缆的敏感性较低,但它对电气噪声或物理损伤仍然非常敏感。RPi 摄像头附带一根非常短的 15 厘米电缆。你可以在 eBay 上购买第三方电缆,长度在 5 厘米到 1 米之间,但 50 厘米或更长的电缆可靠性较低,而 USB 摄像头可以使用 2 米到 5 米的电缆,并且可以插入 USB 集线器或主动延长线以实现更长的距离。
目前有几种不同的 RPi Cam 型号,特别是没有内部红外滤光片的 NoIR 版本;因此,NoIR 相机可以轻易地在黑暗中看到(如果你有一个不可见红外光源),或者比包含内部红外滤光片的普通相机更清晰地看到红外激光或信号。RPi Cam 也有两种不同的版本:RPi Cam v1.3 和 RPi Cam v2.1,其中 v2.1 使用更宽的视角镜头和索尼 8 百万像素传感器,而不是 5 百万像素的OmniVision传感器,并且在低光照条件下有更好的运动支持,并增加了 3240x2464 视频在 15 FPS 的支持,以及可能在 720p 下高达 120 FPS 的视频。然而,USB 网络摄像头有数千种不同的形状和版本,这使得找到专门的网络摄像头(如防水或工业级网络摄像头)变得容易,而不是需要你为 RPi Cam 创建自己的定制外壳。
IP 相机也是另一个可以选择的相机接口,它可以允许使用 Raspberry Pi 进行 1080p 或更高分辨率的视频,并且 IP 相机不仅支持非常长的电缆,而且有可能通过互联网在世界上的任何地方工作。但是,与 USB 网络摄像头或 RPi Cam 相比,IP 相机与 OpenCV 的接口并不那么简单。
在过去,RPi Cams 和官方驱动程序与 OpenCV 不直接兼容;你通常需要使用自定义驱动程序并修改你的代码,以便从 RPi Cams 捕获帧,但现在你可以以与 USB 网络摄像头完全相同的方式在 OpenCV 中访问 RPi Cam!多亏了 v4l2 驱动程序的最近改进,一旦加载了 v4l2 驱动程序,RPi Cam 将像普通 USB 网络摄像头一样出现在/dev/video0或/dev/video1文件中。因此,传统的 OpenCV 网络摄像头代码,如cv::VideoCapture(0),将能够像网络摄像头一样使用它。
安装 Raspberry Pi Camera Module 驱动程序
首先,让我们暂时加载 RPi Cam 的 v4l2 驱动程序,以确保我们的相机已经正确连接:
sudo modprobe bcm2835-v4l2
如果命令失败(如果它在控制台打印了错误消息,或者它冻结了,或者命令返回了除了 0 以外的数字),那么可能你的相机没有正确连接。关闭并从你的 RPi 上拔掉电源,然后再次尝试连接扁平的白色电缆,查看网络上的照片以确保它以正确的方式连接。如果是以正确的方式连接的,那么可能在你关闭 RPi 上的锁定标签之前,电缆没有完全插入。还要检查你是否在之前配置 Raspberry Pi 时忘记了点击启用相机,使用sudoraspi-config命令。
如果命令成功(如果命令返回了 0 并且没有错误打印到控制台),那么我们可以通过将其添加到/etc/modules文件的底部,确保 RPi Cam 的 v4l2 驱动程序在启动时始终加载:
sudo nano /etc/modules
# Load the Raspberry Pi Camera Module v4l2 driver on bootup:
bcm2835-v4l2
保存文件并重启你的 RPi 后,你应该能够运行ls /dev/video*来查看你的 RPi 上可用的摄像头列表。如果 RPi 摄像头是你板上唯一连接的摄像头,你应该看到它是默认摄像头(/dev/video0),或者如果你还连接了一个 USB 摄像头,它将是/dev/video0或/dev/video1。
让我们使用我们之前编译的starter_video示例程序来测试 RPi 摄像头:
cd ~/opencv-3.*/samples/cpp
DISPLAY=:0 ./starter_video 0
如果显示错误的摄像头,尝试DISPLAY=:0 ./starter_video 1。
现在我们知道 RPi 摄像头在 OpenCV 中工作正常,让我们尝试运行卡通化器:
cd ~/Cartoonifier
DISPLAY=:0 ./Cartoonifier 0
或者DISPLAY=:0 ./Cartoonifier 1用于其他摄像头。
使卡通化器全屏运行
在嵌入式系统中,你通常希望你的应用程序全屏显示,并隐藏 Linux GUI 和菜单。OpenCV 提供了一个简单的方法来设置全屏窗口属性,但请确保你使用NORMAL标志创建了窗口:
// Create a fullscreen GUI window for display on the screen.
namedWindow(windowName, WINDOW_NORMAL);
setWindowProperty(windowName, WND_PROP_FULLSCREEN,
CV_WINDOW_FULLSCREEN);
隐藏鼠标光标
你可能会注意到,即使你不想在嵌入式系统中使用鼠标,鼠标光标仍然显示在你的窗口上方。要隐藏鼠标光标,你可以使用xdotool命令将其移动到右下角像素,这样它就不那么显眼了,但如果你偶尔需要将鼠标插入进行设备调试,它仍然可用。安装xdotool并创建一个简短的 Linux 脚本来与卡通化器一起运行:
sudo apt-get install -y xdotool
cd ~/Cartoonifier/build
nano runCartoonifier.sh
#!/bin/sh
# Move the mouse cursor to the screen's bottom-right pixel.
xdotoolmousemove 3000 3000
# Run Cartoonifier with any arguments given.
/home/pi/Cartoonifier/build/Cartoonifier "$@"
最后,使你的脚本可执行:
chmod +x runCartoonifier.sh
尝试运行你的脚本,以确保它工作:
DISPLAY=:0 ./runCartoonifier.sh
启动后自动运行卡通化器
通常在构建嵌入式设备时,你希望设备启动后自动执行你的应用程序,而不是要求用户手动运行。为了在设备完全启动并登录到图形桌面后自动运行我们的应用程序,创建一个包含特定内容的autostart文件夹,其中包含脚本或应用程序的完整路径:
mkdir ~/.config/autostart
nano ~/.config/autostart/Cartoonifier.desktop
[Desktop Entry]
Type=Application
Exec=/home/pi/Cartoonifier/build/runCartoonifier.sh
X-GNOME-Autostart-enabled=true
现在,无论何时你开启设备或重启它,卡通化器都将开始运行!
桌面与嵌入式系统上卡通化器的速度比较
你会注意到代码在 Raspberry Pi 上的运行速度比在桌面上的运行速度慢得多!到目前为止,让它运行得更快的最简单两种方法是用更快的设备或使用更小的摄像头分辨率。以下表格显示了桌面、RPi 1、RPi 2、RPi 3 和 Jetson TK1 上卡通化器的草图和绘画模式的某些帧率,每秒帧数(FPS)。请注意,这些速度没有进行任何自定义优化,并且只在单个 CPU 核心上运行,时间包括将图像渲染到屏幕上的时间。所使用的 USB 摄像头是运行在 640x480 的快速 PS3 Eye 摄像头,因为它是目前市场上速度最快且价格低廉的摄像头。
值得注意的是,Cartoonifier 只使用单个 CPU 核心,但列出的所有设备都有四个 CPU 核心,除了 RPi 1 只有一个核心,许多 x86 计算机有超线程技术,可以提供大约八个 CPU 核心。所以如果你编写的代码能够高效地利用多个 CPU 核心(或 GPU),速度可能会比单线程的数值快 1.5 到 3 倍:
| Computer | Sketch mode | Paint mode |
|---|---|---|
| Intel Core i7 PC | 20 FPS | 2.7 FPS |
| Jetson TK1ARM CPU | 16 FPS | 2.3 FPS |
| Raspberry Pi 3 | 4.3 FPS | 0.32 FPS (3 seconds/frame) |
| Raspberry Pi 2 | 3.2 FPS | 0.28 FPS (4 seconds/frame) |
| Raspberry Pi Zero | 2.5 FPS | 0.21 FPS (5 seconds/frame) |
| Raspberry Pi 1 | 1.9 FPS | 0.12 FPS (8 seconds/frame) |
注意到 Raspberry Pi 在运行代码时非常慢,尤其是Paint模式,因此我们将尝试简单地更改相机和相机的分辨率。
更改相机和相机分辨率
以下表格显示了在 Raspberry Pi 2 上使用不同类型的相机和不同相机分辨率时Sketch模式的速度对比:
| Hardware | 640x480 resolution | 320x240 resolution |
|---|---|---|
| RPi 2 with RPi Cam | 3.8 FPS | 12.9 FPS |
| RPi 2 with PS3 Eye webcam | 3.2 FPS | 11.7 FPS |
| RPi 2 with unbranded webcam | 1.8 FPS | 7.4 FPS |
如您所见,当使用 RPi Cam 在 320x240 分辨率下时,似乎我们已经有一个足够好的解决方案来享受一些乐趣,即使它不在我们更希望的 20-30 FPS 范围内。
在台式机和嵌入式系统上运行 Cartoonifier 的功耗对比
我们已经看到,各种嵌入式设备比台式机慢,从 RPi 1 大约比台式机慢 20 倍,到 Jetson TK1 大约比台式机慢 1.5 倍。但对于某些任务来说,如果这意味着电池消耗也会显著降低,允许使用小电池或降低服务器全年电费成本,低速度是可以接受的。
Raspberry Pi 即使对于相同的处理器也有不同的型号,例如 Raspberry Pi 1B、Zero 和 1A+,它们的运行速度相似,但功耗差异很大。MIPI CSI 相机,如 RPi Cam,也比网络摄像头耗电少。以下表格显示了运行相同 Cartoonifier 代码的不同硬件所消耗的电能。Raspberry Pi 的功耗测量方法如下所示的照片,使用简单的 USB 电流监控器(例如,J7-T Safety Tester--h t t p 😕/b i t . l y /2aS Z a 6H--售价 5 美元),以及一个 DMM 万用表来测量其他设备:

Idle Power测量的是计算机运行但未使用任何主要应用程序时的功耗,而Cartoonifier Power测量的是 Cartoonifier 运行时的功耗。效率是指 640x480 Sketch模式下 Cartoonifier Power 与 Cartoonifier Speed 的比值。
| Hardware | Idle Power | Cartoonifier Power | Efficiency |
|---|---|---|---|
| RPi Zero 与 PS3 Eye | 1.2 瓦 | 1.8 瓦 | 1.4 帧每瓦 |
| RPi 1A+与 PS3 Eye | 1.1 瓦 | 1.5 瓦 | 1.1 帧每瓦 |
| RPi 1B 与 PS3 Eye | 2.4 瓦 | 3.2 瓦 | 0.5 帧每瓦 |
| RPi 2B 与 PS3 Eye | 1.8 瓦 | 2.2 瓦 | 1.4 帧每瓦 |
| RPi 3B 与 PS3 Eye | 2.0 瓦 | 2.5 瓦 | 1.7 帧每瓦 |
| Jetson TK1 与 PS3 Eye | 2.8 瓦 | 4.3 瓦 | 3.7 帧每瓦 |
| 配备 PS3 Eye 的 Core i7 笔记本电脑 | 14.0 瓦 | 39.0 瓦 | 0.5 帧每瓦 |
我们可以看到 RPi 1A+使用的功率最少,但最节能的选项是 Jetson TK1 和 Raspberry Pi 3B。有趣的是,原始的 Raspberry Pi(RPi1B)的效率与 x86 笔记本电脑大致相同。所有后续的 Raspberry Pi 都比原始的(RPi 1B)节能得多。
免责声明:作者曾是 NVIDIA 的前员工,该公司生产了 Jetson TK1,但相信结果和结论是真实的。
让我们也看看与 Raspberry Pi 兼容的不同摄像头的功耗:
| 硬件 | 空闲功耗 | 卡通化器功耗 | 效率 |
|---|---|---|---|
| RPi Zero 与 PS3 Eye | 1.2 瓦 | 1.8 瓦 | 1.4 帧每瓦 |
| RPi Zero 与 RPi Cam v1.3 | 0.6 瓦 | 1.5 瓦 | 2.1 帧每瓦 |
| RPi Zero 与 RPi Cam v2.1 | 0.55 瓦 | 1.3 瓦 | 2.4 帧每瓦 |
我们可以看到 RPi Cam v2.1 比 RPi Cam v1.3 略节能,但比 USB 摄像头节能得多。
从 Raspberry Pi 向高性能计算机传输视频流
感谢所有现代 ARM 设备(包括 Raspberry Pi)中的硬件加速视频编码器,这些设备可以作为在嵌入式设备上执行计算机视觉的有效替代方案,即使用该设备仅捕获视频,并通过网络实时传输到 PC 或服务器机架。所有 Raspberry Pi 型号都包含相同的视频编码器硬件,因此带有 Pi Cam 的 RPi 1A+或 RPi Zero 是一个很好的低成本、低功耗便携式视频流媒体服务器选项。Raspberry Pi 3 增加了 Wi-Fi,以提供额外的便携式功能。
有多种方法可以从 Raspberry Pi 传输实时摄像头视频,例如使用官方的 RPi V4L2 摄像头驱动程序,使 RPi Cam 看起来像是一个网络摄像头,然后使用 Gstreamer、liveMedia、netcat 或 VLC 在网络中传输视频。然而,这些方法通常引入 1 到 2 秒的延迟,并且通常需要自定义 OpenCV 客户端代码或学习如何高效地使用 Gstreamer。因此,以下部分将展示如何使用名为UV4L的替代摄像头驱动程序同时执行摄像头捕获和网络流:
- 按照以下链接在 Raspberry Pi 上安装 UV4L:
www.linux-projects.org/uv4l/installation/:
curl http://www.linux-projects.org/listing/uv4l_repo/lrkey.asc sudo apt-key add -
sudo su
echo "# UV4L camera streaming repo:">> /etc/apt/sources.list
echo "deb http://www.linux-
projects.org/listing/uv4l_repo/raspbian/jessie main">>
/etc/apt/sources.list
exit
sudo apt-get update
sudo apt-get install uv4l uv4l-raspicam uv4l-server
- 手动运行 UV4L 流媒体服务器(在 RPi 上)以检查其是否工作:
sudo killall uv4l
sudo LD_PRELOAD=/usr/lib/uv4l/uv4lext/armv6l/libuv4lext.so
uv4l -v7 -f --sched-rr --mem-lock --auto-video_nr
--driverraspicam --encoding mjpeg
--width 640 --height 480 --framerate15
- 从您的桌面测试摄像头的网络流:
-
安装 VLC 媒体播放器。
-
文件 | 打开网络流 | 访问h t t p 😕/192. 168. 2. 111:8080/s t r e a m /v i d e o . m j p e g。
-
将 URL 调整为您的树莓派的 IP 地址。在 RPi 上运行
hostname -I以找到其 IP 地址。
- 现在让 UV4L 服务器在启动时自动运行:
sudo apt-get install uv4l-raspicam-extras
- 在
uv4l-raspicam.conf中编辑您想要的任何 UV4L 服务器设置,例如分辨率和帧率:
sudo nano /etc/uv4l/uv4l-raspicam.conf
drop-bad-frames = yes
nopreview = yes
width = 640
height = 480
framerate = 24
sudo reboot
- 现在我们可以告诉 OpenCV 将其作为网络流使用,就像它是网络摄像头一样。只要您的 OpenCV 安装可以内部使用 FFMPEG,OpenCV 就能像网络摄像头一样从 MJPEG 网络流中抓取帧:
./Cartoonifier http://192.168.2.101:8080/stream/video.mjpeg
您的树莓派现在正在使用 UV4L 将 640x480 分辨率的 24 FPS 实时视频流传输到运行在Sketch模式的 Cartoonifier 的 PC 上,大约达到 19 FPS(延迟 0.4 秒)。注意,这几乎与直接在 PC 上使用 PS3 Eye 网络摄像头(20 FPS)的速度相同!
注意,当您将视频流到 OpenCV 时,它将无法设置摄像头分辨率;您需要调整 UV4L 服务器设置以更改摄像头分辨率。另外请注意,我们本可以流式传输 H.264 视频,它使用较低的带宽,但某些计算机视觉算法处理不了 H.264 这样的视频压缩,所以 MJPEG 比 H.264 引起的问题要少。
如果您已安装了官方的 RPi V4L2 驱动程序和 UV4L 驱动程序,它们都将作为摄像头 0 和 1(设备/dev/video0和/dev/video1)可用,但您一次只能使用一个摄像头驱动程序。
定制您的嵌入式系统!
现在您已经创建了一个完整的嵌入式 Cartoonifier 系统,并且您知道它的工作原理以及各个部分的作用,您应该对其进行定制!使视频全屏,更改 GUI,或更改应用程序的行为和工作流程,或更改 Cartoonifier 的过滤器常数,或皮肤检测算法,或用您自己的项目想法替换 Cartoonifier 代码。或者将视频流到云端进行处理!
您可以通过多种方式改进皮肤检测算法,例如使用更复杂的皮肤检测算法(例如,使用来自许多最近 CVPR 或 ICCV 会议论文的经过训练的高斯模型www.cvpapers.com),或者向皮肤检测器添加人脸检测(参见第六章的Face detection部分,Face Recognition using Eigenfaces and Fisherfaces),以便检测用户的面部位置,而不是要求用户将面部置于屏幕中央。请注意,在某些设备或高分辨率摄像头上进行人脸检测可能需要几秒钟,因此它们在当前的实时应用中可能受到限制。但是,嵌入式系统平台每年都在变快,所以这可能会随着时间的推移而变得不那么成问题。
加速嵌入式计算机视觉应用最显著的方法是尽可能降低相机分辨率(例如,0.5 百万像素而不是 5 百万像素),尽可能少地分配和释放图像,尽可能少地进行图像格式转换。在某些情况下,可能存在一些优化的图像处理或数学库,或者来自您设备 CPU 供应商的 OpenCV 优化版本(例如,Broadcom、NVIDIA Tegra、Texas Instruments OMAP、Samsung Exynos),或者针对您的 CPU 系列(例如,ARM Cortex-A9)。
为了使定制嵌入式和桌面图像处理代码更容易,这本书附带ImageUtils.cpp和ImageUtils.h文件,以帮助您进行实验。它们包括printMatInfo()等函数,该函数会打印有关cv::Mat对象的大量信息,使调试 OpenCV 变得更容易。还有计时宏,可以轻松地将详细的计时统计信息添加到您的 C/C++代码中。例如:
DECLARE_TIMING(myFilter);
void myImageFunction(Mat img) {
printMatInfo(img, "input");
START_TIMING(myFilter);
bilateralFilter(img, ...);
STOP_TIMING(myFilter);
SHOW_TIMING(myFilter, "My Filter");
}
您将在控制台看到如下类似的内容:
input: 800w600h 3ch 8bpp, range[19,255][17,243][47,251]
My Filter: time: 213ms (ave=215ms min=197ms max=312ms, across 57 runs).
这在您的 OpenCV 代码未按预期工作时很有用,尤其是在嵌入式开发中,通常很难使用 IDE 调试器。
摘要
本章展示了多种不同类型的图像处理滤镜,可用于生成各种卡通效果,从类似铅笔素描的普通草图模式,到类似彩色绘画的绘画模式,再到在绘画模式之上叠加草图模式以呈现卡通效果。它还展示了可以获得其他有趣的效果,例如大大增强噪边界的邪恶模式,以及将人脸皮肤变为明亮的绿色的外星人模式。
有许多商业智能手机应用程序可以对用户的脸部执行类似有趣的特效,例如卡通滤镜和肤色改变器。还有使用类似概念的专业工具,例如皮肤平滑视频后期处理工具,它试图通过平滑皮肤来美化女性的脸部,同时保持边缘和非皮肤区域的清晰,以便使她们的脸看起来更年轻。
本章展示了如何通过遵循首先开发一个可工作的桌面版本的建议指南,然后将应用程序移植到嵌入式系统,并创建一个适合嵌入式应用程序的用户界面。图像处理代码在这两个项目之间共享,以便读者可以修改桌面应用程序的卡通滤镜,并轻松地在嵌入式系统中看到这些修改。
请记住,这本书包括 Linux 的 OpenCV 安装脚本以及所有讨论项目的完整源代码。
第二章:使用 OpenCV 探索运动结构
在本章中,我们将讨论运动结构(SfM)的概念,或者更确切地说,使用 OpenCV 的 API 帮助我们从相机运动中提取几何结构。首先,让我们将使用单相机(通常称为单目方法)和离散且稀疏的帧集而不是连续视频流来约束 SfM 的非常宽泛的方法。这两个约束将极大地简化我们在接下来的页面中将要绘制的系统,并帮助我们理解任何 SfM 方法的基本原理。为了实现我们的方法,我们将遵循 Hartley 和 Zisserman(以下简称 H&Z)的步伐,正如他们在其开创性著作《计算机视觉中的多视图几何》的第九章至第十二章中所记录的。
在本章中,我们将涵盖以下内容:
-
运动结构概念
-
从一对图像中估计相机运动
-
重建场景
-
从多个视角重建
-
优化重建
在本章中,我们假设使用的是校准过的相机,即事先已经校准过的相机。校准是计算机视觉中的一个普遍操作,在 OpenCV 中通过命令行工具完全支持,并在前几章中进行了讨论。因此,我们假设存在相机内参参数,这些参数体现在 K 矩阵和畸变系数向量中——这是校准过程的输出。
为了在语言上明确,从现在开始,我们将把相机称为场景的单个视图,而不是成像的光学和硬件。相机在空间中有一个 3D 位置(平移)和一个 3D 观察方向(方向)。通常,我们将其描述为具有 6 个自由度(DOF)的相机姿态,有时也称为外参数。因此,在两个相机之间,存在一个 3D 平移元素(空间中的移动)和一个观察方向的 3D 旋转。
我们还将统一场景中的点、世界、真实或 3D 的术语,它们都是指存在于我们真实世界中的点。同样,图像中的点或 2D 点也是指图像坐标中的点,这些点是在该位置和时间被投影到相机传感器上的某个真实 3D 点的图像坐标。
在本章的代码部分,您将注意到对计算机视觉中的多视图几何的引用,例如// HZ 9.12。这指的是该书的第九章的第 12 个方程。此外,文本将包括代码摘录;而完整的可运行代码包含在随书附带的材料中。
下面的流程图描述了我们将要实现的 SfM 流程中的过程。我们首先通过在图像集中匹配的二维特征和两个摄像头的位姿计算,对场景的初始重建点云进行三角测量。然后,我们通过将更多点匹配到正在形成的点云中,计算摄像头位姿并对它们的匹配点进行三角测量,来添加更多视图到重建中。在此之间,我们还将执行捆绑调整以最小化重建中的误差。所有步骤都在本章的下一节中详细说明,包括相关的代码片段、指向有用的 OpenCV 函数的指针和数学推理:

运动结构概念
我们首先应该区分的是立体(或实际上任何多视图)与使用校准设备和运动结构(SfM)进行的三维重建之间的区别。由两个或更多摄像头组成的设备假设我们已经知道了摄像头之间的运动,而在 SfM 中,我们并不知道这种运动是什么,我们希望找到它。从简单观点来看,校准设备允许更精确地重建三维几何形状,因为没有误差在估计摄像头之间的距离和旋转,这些信息已经已知。实现 SfM 系统的第一步是找到摄像头之间的运动。OpenCV 可以通过多种方式帮助我们获得这种运动,特别是使用findFundamentalMat和findEssentialMat函数。
让我们思考一下选择 SfM 算法背后的目标。在大多数情况下,我们希望获得场景的几何形状,例如,物体相对于摄像头的位置以及它们的形状。在找到描绘相同场景、从合理相似的角度拍摄的两个摄像头之间的运动后,我们现在希望重建几何形状。在计算机视觉术语中,这被称为三角测量,并且有众多方法可以实现。这可能通过射线交点来完成,其中我们构建两条射线——一条来自每个摄像头的投影中心,以及每个图像平面上的一点。这些射线在空间中的交点理想情况下将交于一个 3D 点,该点在每个摄像头中成像,如图所示:

在现实中,射线交点非常不可靠;H&Z 建议不要使用它。这是因为射线通常不会相交,这使我们不得不退回到使用连接两条射线的最短线段的中点。OpenCV 包含一个用于更精确形式三角测量的简单 API——triangulatePoints函数,因此我们不需要自己编写这部分代码。
在你学习了如何从两个视图中恢复 3D 几何形状之后,我们将看到如何将同一场景的更多视图纳入以获得更丰富的重建。在那个阶段,大多数 SfM 方法试图通过在 重建细化 部分中通过 捆绑调整 来优化我们相机的估计位置和 3D 点的集合。OpenCV 在其新的图像拼接工具箱中包含了捆绑调整的手段。然而,与 OpenCV 和 C++ 一起工作的美妙之处在于,有大量的外部工具可以轻松集成到流程中。因此,我们将看到如何集成外部捆绑调整器,即 Ceres 非线性优化包。
现在我们已经概述了使用 OpenCV 进行 SfM 的方法,我们将看到每个元素是如何实现的。
从一对图像中估计相机运动
在我们着手实际找到两个相机之间的运动之前,让我们检查一下我们执行此操作时拥有的输入和工具。首先,我们有来自(希望不是极端的)不同空间位置的同一场景的两个图像。这是一个强大的资产,我们将确保我们使用它。至于工具,我们应该看看那些对图像、相机和场景施加约束的数学对象。
两个非常有用的数学对象是基本矩阵(用 F 表示)和基本矩阵(用 E 表示),它们对场景的两个图像中的对应 2D 点施加约束。它们大部分是相似的,除了基本矩阵假设使用校准过的相机;对我们来说就是这样,所以我们将选择它。OpenCV 允许我们通过 findFundamentalMat 函数找到基本矩阵,通过 findEssentialMatrix 函数找到基本矩阵。找到基本矩阵的方法如下:
Mat E = findEssentialMat(leftPoints, rightPoints, focal, pp);
此函数利用左侧图像中的匹配点 leftPoints 和右侧图像中的匹配点 rightPoints(我们将在稍后讨论),以及来自相机校准的额外两条信息:焦距 focal 和主点 pp。
基本矩阵 ( E ) 是一个 3x3 的大矩阵,它对一个图像中的点 ( x ) 和对应图像中的点 ( x' ) 施加以下约束:
( x'K^TEKx = 0 )
这里,( K ) 是校准矩阵。
这非常实用,正如我们即将看到的。我们使用的另一个重要事实是,基本矩阵是我们从图像中恢复两个相机位置所需的所有内容,尽管仅限于任意单位尺度。因此,如果我们获得了基本矩阵,我们就知道每个相机在空间中的位置以及它所朝向的方向。如果我们有足够的约束方程,我们可以轻松地计算出这个矩阵,因为每个方程都可以用来求解矩阵的一部分。实际上,OpenCV 内部仅使用五个点对来计算它,但通过随机样本一致性算法(RANSAC),可以使用更多的点对,这提供了更鲁棒的解决方案。
使用丰富特征描述符的点匹配
现在,我们将利用我们的约束方程来计算基本矩阵。为了获取我们的约束,请记住,对于图像 A 中的每个点,我们必须在图像 B 中找到一个相应的点。我们可以使用 OpenCV 广泛的 2D 特征匹配框架来实现这种匹配,该框架在过去几年中已经得到了很大的发展。
特征提取和描述符匹配是计算机视觉中的基本过程,并被用于许多方法来执行各种操作,例如,检测图像中物体的位置和方向,或者通过给定的查询在大型图像数据库中搜索相似图像。本质上,特征提取意味着在图像中选择将构成良好特征的点,并为它们计算描述符。描述符是一个描述图像中特征点周围环境的数字向量。不同的方法有不同的描述符向量的长度和数据类型。描述符匹配是使用其描述符在另一集中找到对应特征的过程。OpenCV 提供了非常简单且强大的方法来支持特征提取和匹配。
让我们考察一个非常简单的特征提取和匹配方案:
vector<KeyPoint> keypts1, keypts2;
Mat desc1, desc2;
// detect keypoints and extractORBdescriptors
Ptr<Feature2D>orb = ORB::create(2000);
orb->detectAndCompute(img1, noArray(), keypts1, desc1);
orb->detectAndCompute(img2, noArray(), keypts2, desc2);
// matching descriptors
Ptr<DescriptorMatcher>matcher
=DescriptorMatcher::create("BruteForce-Hamming");
vector<DMatch> matches;
matcher->match(desc1, desc2, matches);
你可能已经看到了类似的 OpenCV 代码,但让我们快速回顾一下。我们的目标是获得三个元素:两张图像的特征点、它们的描述符以及这两组特征之间的匹配。OpenCV 提供了一系列特征检测器、描述符提取器和匹配器。在这个简单的例子中,我们使用 ORB 类来获取Oriented BRIEF (ORB)(其中,BRIEF代表二进制鲁棒独立基本特征)特征点的 2D 位置以及它们各自的描述符。ORB 可能比传统的 2D 特征(如加速鲁棒特征(SURF)或尺度不变特征变换(SIFT)更受欢迎,因为它不受知识产权的约束,并且已被证明在检测、计算和匹配方面更快。
我们使用一个暴力二进制匹配器来获取匹配,它简单地通过比较第一组中的每个特征与第二组中的每个特征来匹配两个特征集(因此称为暴力)。
在以下图像中,我们将看到来自 Fountain P11 序列的两张图像上的特征点匹配可以在cvlab.epfl.ch/~strecha/multiview/denseMVS.html找到:

实际上,我们刚才进行的原始匹配只适用于一定水平,并且许多匹配可能是错误的。因此,大多数 SfM 方法都会对匹配进行某种形式的过滤,以确保正确性并减少错误。一种内置在 OpenCV 暴力匹配器中的过滤形式是交叉检查过滤。也就是说,如果一个图像的特征与另一个图像的特征匹配,并且反向检查也匹配第二个图像的特征与第一个图像的特征,那么这个匹配被认为是真实的。另一种常见的过滤机制,在提供的代码中使用,是基于两个图像是同一场景并且它们之间存在某种立体视觉关系的实际情况。在实践中,过滤器试图稳健地计算基础矩阵或本质矩阵,我们将在寻找相机矩阵部分学习到这些内容,并保留与这种计算有微小误差的特征对。
使用如 ORB 等丰富特征的一种替代方法是使用光流。以下信息框提供了一个关于光流的简要概述。可以使用光流代替描述符匹配来在两张图像之间找到所需的关键点匹配,而 SfM 管道的其他部分保持不变。OpenCV 最近扩展了其 API,可以从两张图像中获取光流场,现在它更快、更强大。
光流是将一个图像中选定的点匹配到另一个图像的过程,假设这两个图像是序列的一部分,并且彼此相对较近。大多数光流方法比较每个点周围的一个小区域,称为搜索窗口或补丁,从图像 A到图像 B中的相同区域。遵循计算机视觉中一个非常常见的规则,称为亮度恒常约束(以及其他名称),图像的小补丁不会从一个图像剧烈变化到另一个图像,因此它们减法的结果的幅度应该接近于零。除了匹配补丁之外,较新的光流方法使用一些额外的技术来获得更好的结果。其中一种是使用图像金字塔,这是图像的越来越小的缩放版本,允许从粗到细地工作,这是计算机视觉中一个非常常用的技巧。另一种方法是定义流场的全局约束,假设彼此靠近的点以相同方向移动。在 Packt 网站上可以找到关于 OpenCV 中光流方法的更深入回顾,章节名为Developing Fluid Wall Using the Microsoft Kinect。
寻找相机矩阵
现在我们已经获得了关键点的匹配,我们可以计算基础矩阵。然而,我们首先必须将匹配点对齐到两个数组中,其中一个数组中的索引与另一个数组中的相同索引相对应。这是由我们在“估计相机运动”部分看到的findEssentialMat函数所要求的。我们还需要将KeyPoint结构转换为Point2f结构。我们必须特别注意DMatch成员变量queryIdx和trainIdx,因为它们必须与我们在使用DescriptorMatcher::match()函数时的方式相匹配。以下代码部分展示了如何将匹配对齐到两个对应的二维点集,以及如何使用这些点集来找到基础矩阵:
vector<KeyPoint> leftKpts, rightKpts;
// ... obtain keypoints using a feature extractor
vector<DMatch> matches;
// ... obtain matches using a descriptor matcher
//align left and right point sets
vector<Point2f>leftPts, rightPts;
for(size_ti = 0; i < matches.size(); i++){
// queryIdx is the "left" image
leftPts.push_back(leftKpts[matches[i].queryIdx].pt);
// trainIdx is the "right" image
rightPts.push_back(rightKpts[matches[i].trainIdx].pt);
}
//robustly find the Essential Matrix
Mat status;
Mat E = findEssentialMat(
leftPts, // points from left image
rightPts, // points from right image
focal, // camera focal length factor
pp, // camera principal point
cv::RANSAC, // use RANSAC for a robust solution
0.999, // desired solution confidence level
1.0, // point-to-epipolar-line threshold
status); // binary vector for inliers
我们可能稍后使用status二进制向量来修剪与恢复的基础矩阵对齐的点。查看以下图像以了解修剪后的点匹配的说明。红色箭头标记了在寻找矩阵过程中被移除的特征匹配,绿色箭头是保留的特征匹配:

现在我们已经准备好找到相机矩阵。这个过程在 H&Z 的书中被详细描述;然而,新的 OpenCV 3 API 通过引入recoverPose函数,使我们能够非常容易地完成这个任务。首先,我们将简要检查我们将要使用的相机矩阵的结构:

这是我们的相机姿态模型,它由两个元素组成:旋转(用R表示)和平移(用t表示)。有趣的是,它包含一个非常关键的方程:x = PX,其中x是图像上的一个 2D 点,X是空间中的一个 3D 点。还有更多内容,但这个矩阵给我们提供了图像点和场景点之间的重要关系。因此,既然我们已经有了寻找相机矩阵的动机,我们将看到它是如何实现的。以下代码部分展示了如何将基础矩阵分解为旋转和平移元素:
Mat E;
// ... find the essential matrix
Mat R, t; //placeholders for rotation and translation
//Find Pright camera matrix from the essential matrix
//Cheirality check is performed internally.
recoverPose(E, leftPts, rightPts, R, t, focal, pp, mask);
非常简单。不深入数学解释,将基础矩阵转换为旋转和平移是可能的,因为基础矩阵最初是由这两个元素组成的。严格来说,为了满足我们的好奇心,我们可以查看文献中出现的以下关于基础矩阵的方程:E=[t][x]R。我们看到它由(某种形式的)平移元素t和旋转元素R组成。
注意,在recoverPose函数中会内部执行一个手性检查。手性检查确保所有三角化的 3D 点都位于重建相机的前方。H&Z 表明,从基础矩阵恢复相机矩阵实际上有四种可能的解,但唯一正确的解是能够产生位于相机前方的三角化点的解,因此需要进行手性检查。我们将在下一节学习三角化和 3D 重建。
注意,我们刚才所做的只给我们提供了一个相机矩阵,而对于三角化,我们需要两个相机矩阵。这个操作假设一个相机矩阵是固定的和规范的(没有旋转和没有平移,放置在世界原点):

我们从基础矩阵中恢复的另一个相机相对于固定的相机已经移动并旋转。这也意味着,我们从这两个相机矩阵中恢复的任何 3D 点都将具有第一个相机位于世界原点(0, 0, 0)。规范相机的假设正是cv::recoverPose的工作方式;然而,在其他情况下,原点相机的姿态矩阵可能不同于规范,但仍适用于 3D 点的三角化,正如我们稍后将看到的那样,当我们不使用cv::recoverPose来获取新的相机姿态矩阵时。
我们还可以考虑添加到我们方法中的一个额外内容是错误检查。很多时候,从点匹配计算基础矩阵是错误的,这会影响结果的相机矩阵。继续使用有缺陷的相机矩阵进行三角化是没有意义的。我们可以安装一个检查来查看旋转元素是否是有效的旋转矩阵。考虑到旋转矩阵必须有一个行列式为 1(或-1),我们可以简单地做以下操作:
bool CheckCoherentRotation(const cv::Mat_<double>& R) {
if(fabsf(determinant(R))-1.0 >EPS) {
cerr <<"rotation matrix is invalid" <<endl;
return false;
}
return true;
}
将EPS(来自 Epsilon)想象成一个非常小的数,它帮助我们应对 CPU 的数值计算限制。实际上,我们可能在代码中定义如下:
#define EPS 1E-07
现在,我们可以看到所有这些元素是如何组合成一个恢复P矩阵的函数。首先,我们将介绍一些便利的数据结构和类型简写:
typedef std::vector<cv::KeyPoint> Keypoints;
typedef std::vector<cv::Point2f> Points2f;
typedef std::vector<cv::Point3f> Points3f;
typedef std::vector<cv::DMatch> Matching;
struct Features { //2D features
Keypoints keyPoints;
Points2f points;
cv::Mat descriptors;
};
struct Intrinsics { //camera intrinsic parameters
cv::Mat K;
cv::Mat Kinv;
cv::Mat distortion;
};
现在我们可以编写相机矩阵查找函数:
void findCameraMatricesFromMatch(
const Intrinsics& intrin,
const Matching& matches,
const Features& featuresLeft,
const Features& featuresRight,
cv::Matx34f& Pleft,
cv::Matx34f& Pright) {
{
//Note: assuming fx = fy
const double focal = intrin.K.at<float>(0, 0);
const cv::Point2d pp(intrin.K.at<float>(0, 2),
intrin.K.at<float>(1, 2));
//align left and right point sets using the matching
Features left;
Features right;
GetAlignedPointsFromMatch(
featuresLeft,
featuresRight,
matches,
left,
right);
//find essential matrix
Mat E, mask;
E = findEssentialMat(
left.points,
right.points,
focal,
pp,
RANSAC,
0.999,
1.0,
mask);
Mat_<double> R, t;
//Find Pright camera matrix from the essential matrix
recoverPose(E, left.points, right.points, R, t, focal, pp, mask);
Pleft = Matx34f::eye();
Pright = Matx34f(R(0,0), R(0,1), R(0,2), t(0),
R(1,0), R(1,1), R(1,2), t(1),
R(2,0), R(2,1), R(2,2), t(2));
}
在这个阶段,我们已经拥有了重建场景所需的两个相机。一个是Pleft变量中的标准第一相机,另一个是通过Pright变量计算出的从基础矩阵得到的第二相机。
选择首先使用的图像对
由于我们场景的图像视图不止两个,我们必须选择从哪个两个视图开始重建。在他们的论文中,Snavely 等人建议选择具有最少单应性内点的两个视图。单应性是两个图像或位于平面上的点集之间的关系;单应性矩阵定义了一个平面到另一个平面的转换。对于图像或一组 2D 点,单应性矩阵的大小为 3x3。
当Snavely 等人寻找最低的内点比率时,他们实际上建议你计算所有图像对之间的单应性矩阵,并选择那些点大部分不与单应性矩阵对应的对。这意味着这两个视图中的场景几何不是平面的,或者至少,两个视图中的平面不相同,这有助于进行三维重建。对于重建,最好查看一个具有非平面几何的复杂场景,其中物体距离相机远近不一。
以下代码片段展示了如何使用 OpenCV 的findHomography函数来计算两个已经提取并匹配的特征点视图之间的内点数:
int findHomographyInliers(
const Features& left,
const Features& right,
const Matching& matches) {
//Get aligned feature vectors
Features alignedLeft;
Features alignedRight;
GetAlignedPointsFromMatch(left, right, matches, alignedLeft,
alignedRight);
//Calculate homography with at least 4 points
Mat inlierMask;
Mat homography;
if(matches.size() >= 4) {
homography = findHomography(alignedLeft.points,
alignedRight.points,
cv::RANSAC, RANSAC_THRESHOLD,
inlierMask);
}
if(matches.size() < 4 or homography.empty()) {
return 0;
}
return countNonZero(inlierMask);
}
下一步是对我们捆绑中的所有图像对视图执行此操作,并根据单应性内点与外点比率进行排序:
//sort pairwise matches to find the lowest Homography inliers
map<float, ImagePair>pairInliersCt;
const size_t numImages = mImages.size();
//scan all possible image pairs (symmetric)
for (size_t i = 0; i < numImages - 1; i++) {
for (size_t j = i + 1; j < numImages; j++) {
if (mFeatureMatchMatrix[i][j].size() < MIN_POINT_CT) {
//Not enough points in matching
pairInliersCt[1.0] = {i, j};
continue;
}
//Find number of homography inliers
const int numInliers = findHomographyInliers(
mImageFeatures[i],
mImageFeatures[j],
mFeatureMatchMatrix[i][j]);
const float inliersRatio =
(float)numInliers /
(float)(mFeatureMatchMatrix[i][j].size());
pairInliersCt[inliersRatio] = {i, j};
}
}
注意,std::map<float, ImagePair>将内部根据映射的键:内点比率进行排序。然后我们只需从映射的开头遍历,找到内点比率最低的图像对,如果这对不能使用,我们可以轻松地跳到下一个对。下一节将揭示我们如何使用这些相机对来获得场景的 3D 结构。
重建场景
接下来,我们探讨从我们迄今为止获得的信息中恢复场景的 3D 结构的问题。正如我们之前所做的那样,我们应该看看我们手头上的工具和信息,以实现这一目标。在前一节中,我们从基础矩阵中获得了两个相机矩阵;我们已经讨论了这些工具如何有助于获得空间中一点的 3D 位置。然后,我们可以回到我们的匹配点对,用数值数据填充我们的方程。点对也将有助于计算我们从所有近似计算中得到的误差。
现在是时候看看我们如何使用 OpenCV 来执行三角测量了。幸运的是,OpenCV 为我们提供了一系列函数,使得这个过程易于实现:triangulatePoints、undistortPoints 和 convertPointsFromHomogeneous。
记得我们有两个关键方程是从 2D 点匹配和 P 矩阵中产生的:x=PX 和 x'= P'X,其中 x 和 x' 是匹配的 2D 点,X 是由两个相机成像的真实世界 3D 点。如果我们检查这些方程,我们会看到代表 2D 点的 x 向量应该大小为 (3x1),而代表 3D 点的 X 应该是 (4x1)。这两个点在向量中都有一个额外的条目;这被称为齐次坐标。我们使用这些坐标来简化三角测量过程。
方程式 x = PX(其中 x 是一个 2D 图像点,X 是一个世界 3D 点,而 P 是一个相机矩阵)缺少一个关键元素:相机标定参数矩阵,K。矩阵 K 用于将 2D 图像点从像素坐标转换为归一化坐标(在 [-1, 1] 范围内),从而消除对图像像素大小的依赖,这是绝对必要的。例如,在 320x240 图像中的一个 2D 点 x[1] = (160, 120),在特定情况下可能会转换为 x[1]' = (0, 0)。为此,我们使用 undistortPoints 函数:
Vector<Point2f> points2d; //in 2D coordinates (x, y)
Mat normalizedPts; //in homogeneous coordinates (x', y', 1)
undistortPoints(points2d, normalizedPts, K, Mat());
我们现在已准备好将归一化的 2D 图像点三角测量为 3D 世界点:
Matx34f Pleft, Pright;
//... findCameraMatricesFromMatch
Mat normLPts;
Mat normRPts;
//... undistortPoints
//the result is a set of 3D points in homogeneous coordinates (4D)
Mat pts3dHomog;
triangulatePoints(Pleft, Pright, normLPts, normRPts, pts3dHomog);
//convert from homogeneous to 3D world coordinates
Mat points3d;
convertPointsFromHomogeneous(pts3dHomog.t(), points3d);
在以下图像中,我们可以看到 Fountain P-11 序列中两幅图像的三角测量结果,cvlabwww.epfl.ch/data/multiview/denseMVS.html。顶部两幅图像是场景的原始两个视图,底部一对是两个视图重建的点云视图,包括观察喷泉的估计相机。我们可以看到红砖墙右侧部分的重建情况,以及从墙上突出的喷泉:

然而,正如我们之前讨论的,我们有一个问题,即重建仅限于比例。我们应该花点时间理解“限于比例”的含义。我们在两个相机之间获得的运动将有一个任意的测量单位,即它不是以厘米或英寸为单位,而只是一个给定的比例单位。我们的重建相机将相隔一个比例单位距离。如果我们决定稍后恢复更多相机,这将有很大的影响,因为每一对相机将有自己的比例单位,而不是一个共同的单位。
我们现在将讨论我们设置的误差度量如何帮助我们找到更鲁棒的重建方法。首先,我们应该注意,重投影意味着我们只需将三角化的 3D 点重新投影到相机上以获得重投影的 2D 点,然后我们比较原始 2D 点和重投影 2D 点之间的距离。如果这个距离很大,这意味着我们可能在三角化中存在误差,所以我们可能不想将这个点包含在最终结果中。我们的全局度量是平均重投影距离,可能会给我们一些关于我们的三角化整体表现的建议。高平均重投影率可能表明P矩阵存在问题,因此可能存在计算基础矩阵或匹配特征点的问题。为了重投影点,OpenCV 提供了projectPoints函数:
Mat x34f P; //camera pose matrix
Mat points3d; //triangulated points
Points2d imgPts; //2D image points that correspond to 3D points
Mat K; //camera intrinsics matrix
// ... triangulate points
//get rotation and translation elements
Mat R;
Rodrigues(P.get_minor<3, 3>(0, 0), rvec);
Mat t = P.get_minor<3, 1>(0, 3);
//reproject 3D points back into image coordinates
Mat projPts;
projectPoints(points3d, R, t, K, Mat(),projPts);
//check individual reprojection error
for (size_t i = 0; i < points3d.rows; i++) {
const double err = norm(projPts.at<Point2f>(i) - imgPts[i]);
//check if point reprojection error is too big
if (err > MIN_REPROJECTION_ERROR){
// Point reprojection error is too big.
}
}
接下来,我们将探讨恢复更多观察同一场景的相机,并组合 3D 重建结果。
从多个视图中进行重建
既然我们已经知道了如何从两个相机中恢复运动和场景几何形状,似乎通过应用相同的过程简单地获取额外相机的参数和更多场景点似乎是件简单的事。实际上,这个问题并不简单,因为我们只能得到一个到比例的重建,并且每一对图片都有不同的比例。
从多个视图中正确重建 3D 场景数据有几种方法。一种实现相机姿态估计或相机重投影的方法是透视 N 点(PnP)算法,其中我们尝试使用N个 3D 场景点(我们已找到并对应于 2D 图像点)来求解新相机的位置。另一种方法是三角化更多点,看看它们如何适应我们现有的场景几何形状;这将通过点云配准告诉我们新相机的位置。在本节中,我们将讨论使用 OpenCV 的solvePnP函数实现第一种方法。
在这种重建中,我们选择的第一步是增量 3D 重建与相机重投影,目的是获取基线场景结构。因为我们将根据场景的已知结构寻找任何新相机的位置,所以我们需要找到一个初始结构来工作。我们可以使用之前讨论过的方法——例如,在第一帧和第二帧之间,通过找到相机矩阵(使用findCameraMatricesFromMatch函数)和三角化几何形状(使用triangulatePoints)来获取基线。
找到初始结构后,我们可以继续;然而,我们的方法需要做大量的记录。首先我们应该注意,solvePnP函数需要 3D 和 2D 点的齐次向量。齐次向量意味着一个向量中的第 i 个位置与另一个向量中的第 i^(th)个位置对齐。为了获得这些向量,我们需要找到我们之前恢复的 3D 点中的那些点,它们与我们的新帧中的 2D 点对齐。一种简单的方法是为云中的每个 3D 点附加一个表示它来自的 2D 点的向量。然后我们可以使用特征匹配来获取匹配对。
让我们按照以下方式引入一个 3D 点的新结构:
struct Point3DInMap {
// 3D point.
cv::Point3f p;
// Mapping from image index to a 2D point in that image's
// list of features that correspond to this 3D point.
std::map<int, int> originatingViews;
};
在 3D 点之上,它还保留了一个指向向量中 2D 点的索引,该向量是每个帧贡献给这个 3D 点的。当三角化新的 3D 点时,必须初始化Point3DInMap::originatingViews的信息,记录哪些相机参与了三角化。然后我们可以用它来从我们的 3D 点云追踪到每个帧中的 2D 点。
让我们添加一些便利的定义:
struct Image2D3DMatch { //Aligned vectors of 2D and 3D points
Points2f points2D;
Points3f points3D;
};
//A mapping between an image and its set of 2D-3D aligned points
typedef std::map<int, Image2D3DMatch> Images2D3DMatches;
现在,让我们看看如何获取用于solvePnP的齐次 2D-3D 点向量。以下代码段说明了从现有的 3D 点云(包含原始的 2D 视图)中找到新图像中 2D 点的过程。简单来说,算法扫描云中的现有 3D 点,查看它们的原始 2D 点,并尝试找到匹配(通过特征描述符)到新图像中的 2D 点。如果找到这样的匹配,这可能表明这个 3D 点也出现在新图像的特定 2D 点上:
Images2D3DMatches matches;
//scan all pending new views
for (size_tnewView = 0; newView<images.size(); newView++) {
if (doneViews.find(newView) != doneViews.end()) {
continue; //skip done views
}
Image2D3DMatch match2D3D;
//scan all current cloud's 3D points
for (const Point3DInMap&p : currentCloud) {
//scan all originating views for that 3D cloud point
for (const auto& origViewAndPoint : p.originatingViews) {
//check for 2D-2D matching via the match matrix
int origViewIndex = origViewAndPoint.first;
int origViewFeatureIndex = origViewAndPoint.second;
//match matrix is upper-triangular (not symmetric)
//so the left index must be the smaller one
bool isLeft = (origViewIndex <newView);
int leftVIdx = (isLeft) ? origViewIndex: newView;
int rightVIdx = (isLeft) ? newView : origViewIndex;
//scan all 2D-2D matches between originating and new views
for (const DMatch& m : matchMatrix[leftVIdx][rightVIdx]) {
int matched2DPointInNewView = -1;
//find a match for this new view with originating view
if (isLeft) {
//originating view is 'left'
if (m.queryIdx == origViewFeatureIndex) {
matched2DPointInNewView = m.trainIdx;
}
} else {
//originating view is 'right'
if (m.trainIdx == origViewFeatureIndex) {
matched2DPointInNewView = m.queryIdx;
}
}
if (matched2DPointInNewView >= 0) {
//This point is matched in the new view
const Features& newFeat = imageFeatures[newView];
//Add the 2D point form the new view
match2D3D.points2D.push_back(
newFeat.points[matched2DPointInNewView]
);
//Add the 3D point
match2D3D.points3D.push_back(cloudPoint.p);
break; //look no further
}
}
}
}
matches[viewIdx] = match2D3D;
}
现在我们已经将场景中的 3D 点对齐到新帧中的 2D 点,我们可以使用它们来恢复相机位置如下:
Image2D3DMatch match;
//... find 2D-3D match
//Recover camera pose using 2D-3D correspondence
Mat rvec, tvec;
Mat inliers;
solvePnPRansac(
match.points3D, //3D points
match.points2D, //2D points
K, //Calibration intrinsics matrix
distortion, //Calibration distortion coefficients
rvec,//Output extrinsics: Rotation vector
tvec, //Output extrinsics: Translation vector
false, //Don't use initial guess
100, //Iterations
RANSAC_THRESHOLD, //Reprojection error threshold
0.99, //Confidence
inliers //Output: inliers indicator vector
);
//check if inliers-to-points ratio is too small
const float numInliers = (float)countNonZero(inliers);
const float numPoints = (float)match.points2D.size();
const float inlierRatio = numInliers / numPoints;
if (inlierRatio < POSE_INLIERS_MINIMAL_RATIO) {
cerr << "Inliers ratio is too small: "
<< numInliers<< " / " <<numPoints<< endl;
//perhaps a 'return;' statement
}
Mat_<double>R;
Rodrigues(rvec, R); //convert to a 3x3 rotation matrix
P(0, 0) = R(0, 0); P(0, 1) = R(0, 1); P(0, 2) = R(0, 2);
P(1, 0) = R(1, 0); P(1, 1) = R(1, 1); P(1, 2) = R(1, 2);
P(2, 0) = R(2, 0); P(2, 1) = R(2, 1); P(2, 2) = R(2, 2);
P(0, 3) = tvec.at<double>(0, 3);
P(1, 3) = tvec.at<double>(1, 3);
P(2, 3) = tvec.at<double>(2, 3);
注意,我们使用的是solvePnPRansac函数而不是solvePnP函数,因为它对异常值更鲁棒。现在我们有了新的P矩阵,我们可以简单地使用之前使用的triangulatePoints函数,并用更多的 3D 点填充我们的点云。
在以下图像中,我们看到的是 Fountain-P11 场景的增量重建,链接为 cvlabwww.epfl.ch/data/multiview/denseMVS.html,从第四张图像开始。左上角的图像是使用四张图像后的重建结果;参与重建的相机以红色金字塔的形式显示,白色线条表示方向。其他图像展示了增加更多相机如何向点云中添加更多点:

重建的细化
SfM 方法中最重要的部分之一是对重建场景进行精炼和优化,也称为捆绑调整(BA)。这是一个优化步骤,我们将收集到的所有数据拟合到一个单一模型中。我们优化了恢复的 3D 点的位置和相机的位置,以最小化重投影误差。换句话说,重新投影到图像上的恢复 3D 点应位于生成它们的原始 2D 特征点的位置附近。我们使用的 BA 过程将尝试最小化所有 3D 点的这个误差,从而形成一个包含数千个参数的非常大的同时线性方程组。
我们将使用Ceres库实现 BA 算法,这是 Google 的一个知名优化包。Ceres 内置了帮助进行 BA 的工具,例如自动微分和多种线性及非线性优化方案,这减少了代码量并增加了灵活性。
为了使事情简单且易于实现,我们将做出一些假设,而在实际的 SfM 系统中,这些假设不能被忽视。首先,我们将假设我们的相机具有简单的内在模型,具体来说,x和y方向上的焦距相同,投影中心正好位于图像的中间。我们进一步假设所有相机共享相同的内在参数,这意味着相同的相机以精确的配置(例如,缩放)拍摄了捆绑中的所有图像。这些假设大大减少了需要优化的参数数量,从而使得优化不仅更容易编码,而且收敛速度更快。
首先,我们将建模误差函数,有时也称为成本函数,简单来说,这是优化知道新参数有多好以及如何获得更好的参数的方式。我们可以编写以下使用 Ceres 自动微分机制的 functor:
// The pinhole camera is parameterized using 7 parameters:
// 3 for rotation, 3 for translation, 1 for focal length.
// The principal point is not modeled (assumed be located at the
// image center, and already subtracted from 'observed'),
// and focal_x = focal_y.
struct SimpleReprojectionError {
using namespace ceres;
SimpleReprojectionError(double observed_x, double observed_y) :
observed_x(observed_x), observed_y(observed_y) {}
template<typenameT>
bool operator()(const T* const camera,
const T* const point,
const T* const focal,
T* residuals) const {
T p[3];
// Rotate: camera[0,1,2] are the angle-axis rotation.
AngleAxisRotatePoint(camera, point, p);
// Translate: camera[3,4,5] are the translation.
p[0] += camera[3];
p[1] += camera[4];
p[2] += camera[5];
// Perspective divide
const T xp = p[0] / p[2];
const T yp = p[1] / p[2];
// Compute projected point position (sans center of
// projection)
const T predicted_x = *focal * xp;
const T predicted_y = *focal * yp;
// The error is the difference between the predicted
// and observed position.
residuals[0] = predicted_x - T(observed_x);
residuals[1] = predicted_y - T(observed_y);
return true;
}
// A helper construction function
static CostFunction* Create(const double observed_x,
const double observed_y) {
return (newAutoDiffCostFunction<SimpleReprojectionError,
2, 6, 3, 1>(
newSimpleReprojectionError(observed_x,
observed_y)));
}
double observed_x;
double observed_y;
};
这个 functor 通过使用简化的外在和内在相机参数重新投影 3D 点,计算该 3D 点与其原始 2D 点之间的偏差。x和y方向上的误差被保存为残差,它指导优化过程。
BA 实现中包含了很多额外的代码,但它主要处理云 3D 点、起源 2D 点及其相应相机的簿记。读者可能希望查看书中附带的代码是如何实现这一点的。
以下图像显示了 BA 的影响。左侧的两个图像是调整前点云的视角点,右侧的图像显示了优化的云。变化非常显著,许多来自不同视角的三角化点之间的不匹配现在大部分已经合并。我们还可以注意到调整如何创建了一个远比以前更好的平面表面重建:

使用示例代码
我们可以在本书的支持材料中找到 SfM 的示例代码。现在我们将看到如何构建、运行并使用它。该代码使用了 CMake,这是一个类似于 Maven 或 SCons 的跨平台构建环境。我们还应该确保我们拥有以下所有先决条件来构建应用程序:
-
OpenCV v3.0 或更高版本
-
Ceres v1.11 或更高版本
-
Boost v1.54 或更高版本
首先,我们必须设置构建环境。为此,我们可以在其中放置所有构建相关文件的文件夹中创建一个名为 build 的文件夹;我们现在假设所有命令行操作都在 build/ 文件夹内,尽管即使不使用 build 文件夹,过程也是相似的(直到文件的位置)。我们还应该确保 CMake 可以找到 boost 和 Ceres。
如果我们使用 Windows 作为操作系统,我们可以使用 Microsoft Visual Studio 来构建;因此,我们应该运行以下命令:
cmake -G "Visual Studio 10"
如果我们使用 Linux、Mac OS 或其他类 Unix 操作系统,我们将执行以下命令:
cmake -G "Unix Makefiles"
如果我们更喜欢在 Mac OS 上使用 XCode,请执行以下命令:
cmake -G Xcode
CMake 还具有为 Eclipse、Codeblocks 等构建宏的能力。
在 CMake 完成创建环境后,我们就准备好构建了。如果我们使用类 Unix 系统,我们可以简单地执行 make 工具,否则我们应该使用我们的开发环境的构建过程。
构建完成后,我们应该得到一个名为 ExploringSfM 的可执行文件,该文件运行 SfM 过程。不带参数运行它
将导致以下结果:
USAGE ./build/ExploringSfM [options] <input-directory>
-h [ --help ] Produce help message
-d [ --console-debug ] arg (=2) Debug output to console log level
(0 = Trace, 4 = Error).
-v [ --visual-debug ] arg (=3) Visual debug output to screen log
level
(0 = All, 4 = None).
-s [ --downscale ] arg (=1) Downscale factor for input images.
-p [ --input-directory ] arg Directory to find input images.
-o [ --output-prefix ] arg (=output) Prefix for output files.
要在图像集上执行过程,我们应该提供驱动器上的位置以查找图像文件。如果提供了有效位置,则过程应开始,我们应在屏幕上看到进度和调试信息。如果没有错误发生,则过程将以一条消息结束,表明从图像中产生的点云已保存到 PLY 文件中,这些文件可以在大多数 3D 编辑软件中打开。
摘要
在这一章中,我们看到了 OpenCV v3 如何帮助我们以简单编码和简单理解的方式接近运动结构(Structure from Motion)。OpenCV v3 的新 API 包含了许多有用的函数和数据结构,使我们的工作更加轻松,并有助于更清晰的实现。
然而,最先进的 SfM 方法要复杂得多。我们选择忽略许多问题以换取简单性,还有更多通常存在的错误检查。我们对 SfM 不同元素选择的方法也可以重新审视。例如,H&Z 提出了一种高度精确的三角测量方法,该方法在图像域中最小化了重投影误差。一些方法甚至在理解了多张图像中特征之间的关系后,就使用 N 视图三角测量。
如果我们想要扩展和深化我们对结构光束测距(SfM)的了解,我们当然会从查看其他开源的 SfM 库中受益。一个特别有趣的项目是 libMV,它实现了一系列可互换的 SfM 元素,以获得最佳结果。华盛顿大学有一大批研究成果,提供了许多 SfM(Bundler 和 VisualSfM)的工具。这项工作启发了微软的一个在线产品PhotoSynth和 Adobe 的123D Catch。还有许多 SfM 的实现可以在网上找到,只需搜索就能找到很多。
另一个我们没有深入讨论的重要关系是 SfM 与视觉定位和制图的关系,更广为人知的是同时定位与制图(SLAM)方法。在这一章中,我们处理了一个给定的图像数据集和视频序列,在这些情况下使用 SfM 是实用的;然而,有些应用没有预先记录的数据集,必须在运行时启动重建。这个过程更广为人知的是制图,它在我们创建世界 3D 地图的同时进行,使用二维特征匹配和跟踪,并在三角测量之后完成。
在下一章中,我们将看到如何使用 OpenCV 从图像中提取车牌号码,利用机器学习中的各种技术。
参考文献
-
Hartley, Richard, 和 Andrew Zisserman, 计算机视觉中的多视图几何,剑桥大学出版社,2003
-
Hartley, Richard I., 和 Peter Sturm; 三角测量,计算机视觉与图像理解 68.2 (1997): 146-157
-
Snavely, Noah, Steven M. Seitz, 和 Richard Szeliski; 照片旅游:在 3D 中探索照片收藏,ACM Transactions on Graphics (TOG). 第 25 卷. 第 3 期. ACM,2006
-
Strecha, Christoph, 等人,关于高分辨率图像的相机标定和多视图立体视觉的基准测试,IEEE 计算机视觉与模式识别会议(CVPR)2008
第三章:使用 SVM 和神经网络进行车牌识别
本章向我们介绍了创建自动车牌识别(ANPR)应用程序所需的步骤。根据不同的情况,有不同方法和技巧,例如红外摄像头、固定车辆位置、光照条件等。我们可以继续构建一个 ANPR 应用程序,以检测从 2 或 3 米远的车上拍摄的模糊光照条件下、非平行地面带有轻微透视畸变的汽车车牌。
本章的主要目的是向我们介绍图像分割和特征提取、模式识别基础以及两个重要的模式识别算法:支持向量机(SVM)和人工神经网络(ANN)。在本章中,我们将涵盖以下主题:
-
ANPR
-
车牌检测
-
车牌识别
ANPR 简介
自动车牌识别,或称为自动牌照识别(ALPR)、自动车辆识别(AVI)或汽车牌照识别(CPR),是一种使用光学字符识别(OCR)和其他方法(如分割和检测)来读取车辆登记牌的监控方法。
在 ANPR 系统中,使用红外(IR)摄像头可以获得最佳结果,因为检测和 OCR 分割的分割步骤既简单又干净,并且它们最小化了错误。这是由于光的基本定律,其中最基本的定律是入射角等于反射角。当我们看到像平面镜这样的光滑表面时,我们可以看到这种基本的反射。从像纸张这样的粗糙表面反射会导致一种称为漫反射或散射反射的类型。然而,大多数国家车牌具有特殊特性,称为反光,即车牌的表面是用覆盖有数千个微小半球体的材料制成的,这些半球体使光线反射回光源,正如我们可以在以下图中看到的那样:

如果我们使用带有滤波耦合结构红外光投影仪的摄像头,我们可以仅检索红外光,然后,我们有一个非常高质量的图像进行分割,我们可以用这个图像随后检测和识别车牌号码,它不受任何光照环境的影响,如图所示:

在本章中,我们不会使用红外照片;我们将使用普通照片,这样我们不会获得最佳结果,并且检测错误率和误识别率更高,与使用红外摄像头相比。然而,两种方法的步骤是相同的。
每个国家都有不同的车牌尺寸和规格。了解这些规格对于获得最佳结果和减少错误是有用的。每章中使用的算法都是为了解释 ANPR 的基本原理,具体针对西班牙使用的车牌,但我们也可以将其扩展到任何国家或规格。
在本章中,我们将使用西班牙的车牌。在西班牙,车牌有三种不同的尺寸和形状,但我们只会使用最常见的(大型)车牌,其宽度为 520 毫米,高度为 110 毫米。两组字符之间有一个 41 毫米的间隔,每个单独字符之间有一个 14 毫米的间隔。第一组字符有四个数字,第二组有三个字母,没有元音 A、E、I、O、U,也没有字母 N 或 Q。所有字符的尺寸为 45 毫米乘以 77 毫米。
这些数据对于字符分割很重要,因为我们既可以检查字符,也可以检查空白空间,以验证我们得到的是字符而不是其他图像块:

ANPR 算法
在解释 ANPR 代码之前,我们需要定义 ANPR 算法中的主要步骤和任务。ANPR 分为两个主要步骤:车牌检测和车牌识别。车牌检测的目的是检测车牌在相机整个帧中的位置。当在图像中检测到车牌时,车牌段被传递到第二步(车牌识别),该步骤使用 OCR 算法来确定车牌上的字母数字字符。
在以下图中,我们可以看到两个主要算法步骤:车牌检测和车牌识别。在这些步骤之后,程序会在相机帧上绘制检测到的车牌字符。算法可能会返回错误的结果,或者可能不返回任何结果:

在前图中显示的每个步骤中,我们将定义三个在模式识别算法中常用的附加步骤。这些步骤如下:
-
分割:这一步检测并移除图像中的每个块/感兴趣区域。
-
特征提取:这一步从每个块中提取一组特征。
-
分类:这一步从车牌识别步骤中提取每个字符,或者在车牌检测步骤中将每个图像块分类为“车牌”或“无车牌”。
在以下图中,我们可以看到整个算法应用中的这些模式识别步骤:

除了主要应用,其目的是检测和识别车牌号码之外,我们还将简要解释两个通常不会被解释的任务:
-
如何训练一个模式识别系统
-
如何评估它
然而,这些任务可能比主要应用更重要,因为我们如果不正确训练模式识别系统,我们的系统可能会失败并且无法正确工作;不同的模式需要不同的训练和评估。我们需要在不同的环境、条件和特征中评估我们的系统以获得最佳结果。这两个任务有时会一起使用,因为不同的特征可以产生我们在评估部分可以看到的不同结果。
车牌检测
在这一步,我们必须检测当前相机帧中的所有车牌。为了完成这个任务,我们将它分为两个主要步骤:分割和分割分类。特征步骤没有解释,因为我们使用图像块作为向量特征。
在第一步(分割)中,我们将应用不同的过滤器、形态学操作、轮廓算法和验证来检索可能包含车牌的图像部分。
在第二步(分类)中,我们将对每个图像块,即我们的特征,应用支持向量机(SVM)分类器。在创建我们的主要应用程序之前,我们将使用两个不同的类别进行训练:车牌和非车牌。我们将处理具有 800 像素宽度的并行正面视图彩色图像,这些图像是从 2 到 4 米远的车上拍摄的。这些要求对于正确的分割非常重要。如果我们创建一个多尺度图像算法,我们可以得到性能检测。
在下一张图像中,我们将展示所有涉及车牌检测的过程:
-
Sobel 滤波器
-
阈值操作
-
形态学闭运算
-
填充区域之一的掩码
-
红色表示可能检测到的车牌(特征图像)
-
SVM 分类器检测到的车牌

分割
分割是将图像分割成多个段的过程。这个过程是为了简化图像以便分析,并使特征提取更容易。
车牌分割的一个重要特征是车牌中垂直边缘的数量很高,假设图像是正面拍摄的,车牌没有旋转且没有透视失真。这个特征可以在第一个分割步骤中利用,以消除没有任何垂直边缘的区域。
在找到垂直边缘之前,我们需要将彩色图像转换为灰度图像(因为颜色在这个任务中帮不上忙)并移除来自相机或其他环境噪声产生的可能噪声。我们将应用 5x5 高斯模糊并去除噪声。如果我们不应用噪声去除方法,我们可以得到很多垂直边缘,这会导致检测失败:
//convert image to gray
Mat img_gray;
cvtColor(input, img_gray, CV_BGR2GRAY);
blur(img_gray, img_gray, Size(5,5));
为了找到垂直边缘,我们将使用 Sobel 滤波器并找到第一个水平导数。导数是一个数学函数,它允许我们在图像上找到垂直边缘。OpenCV 中 Sobel 函数的定义如下:
void Sobel(InputArray src, OutputArray dst, int ddepth, int
xorder, int yorder, int ksize=3, double scale=1, double delta=0, int borderType=BORDER_DEFAULT )
在这里,ddepth是目标图像深度;xorder是 x 导数的阶数;yorder是 y 导数的阶数;ksize是 1、3、5 或 7 的核大小;scale是计算导数值的可选因子;delta是可选的值,添加到结果中;borderType是像素插值方法。
然后,在我们的情况下,我们可以使用xorder=1、yorder=0和ksize=3:
//Find vertical lines. Car plates have high density of vertical
lines
Mat img_sobel;
Sobel(img_gray, img_sobel, CV_8U, 1, 0, 3, 1, 0);
在应用 Sobel 滤波器之后,我们将应用阈值滤波器以获得一个二值图像,阈值值是通过 Otsu 方法获得的。Otsu 算法需要一个 8 位输入图像,并且 Otsu 方法自动确定最佳阈值值:
//threshold image
Mat img_threshold;
threshold(img_sobel, img_threshold, 0, 255,
CV_THRESH_OTSU+CV_THRESH_BINARY);
要在阈值函数中定义 Otsu 方法,我们将类型参数与CV_THRESH_OTSU值组合,并且阈值值参数被忽略。
当定义CV_THRESH_OTSU值时,阈值函数返回 Otsu 算法获得的最佳阈值值。
通过应用闭形态学操作,我们可以移除每个垂直边缘线之间的空白空间,并连接所有具有高边缘数目的区域。在这个步骤中,我们有可能包含车牌的区域。
首先,我们将定义我们在形态学操作中使用的结构元素。我们将使用getStructuringElement函数定义一个具有 17x3 维度的矩形结构元素;在其他图像大小中可能不同:
Mat element = getStructuringElement(MORPH_RECT, Size(17, 3));
然后,我们将使用这个结构元素在morphologyEx函数中执行闭形态学操作:
morphologyEx(img_threshold, img_threshold, CV_MOP_CLOSE,
element);
应用这些函数后,图像中将有可能包含车牌的区域;然而,大多数区域并不包含车牌。这些区域可以通过连通分量分析或使用findContours函数来分割。这个最后的函数以不同的方法和结果检索二值图像的轮廓。我们只需要获取具有任何层次关系和任何多边形逼近结果的轮廓:
//Find contours of possibles plates
vector< vector< Point>> contours;
findContours(img_threshold,
contours, // a vector of contours
CV_RETR_EXTERNAL, // retrieve the external contours
CV_CHAIN_APPROX_NONE); // all pixels of each contours
对于每个检测到的轮廓,提取最小面积的边界矩形。OpenCV 提供了minAreaRect函数来完成这个任务。这个函数返回一个旋转的RotatedRect矩形类。然后,使用向量迭代器遍历每个轮廓,我们可以获取旋转矩形并在对每个区域进行分类之前进行一些初步验证:
//Start to iterate to each contour founded
vector<vector<Point>>::iterator itc= contours.begin();
vector<RotatedRect> rects;
//Remove patch that has no inside limits of aspect ratio and
area.
while (itc!=contours.end()) {
//Create bounding rect of object
RotatedRect mr= minAreaRect(Mat(*itc));
if(!verifySizes(mr)){
itc= contours.erase(itc);
}else{
++itc;
rects.push_back(mr);
}
}
我们根据检测到的区域的面积和宽高比进行基本验证。如果宽高比大约为520/110 = 4.727272(板宽除以板高),误差范围为 40%,并且板的高度基于至少 15 像素和最多 125 像素的面积,我们将考虑该区域可以是一个板。这些值取决于图像大小和相机位置:
bool DetectRegions::verifySizes(RotatedRect candidate ){
float error=0.4;
//Spain car plate size: 52x11 aspect 4,7272
const float aspect=4.7272;
//Set a min and max area. All other patchs are discarded
int min= 15*aspect*15; // minimum area
int max= 125*aspect*125; // maximum area
//Get only patches that match to a respect ratio.
float rmin= aspect-aspect*error;
float rmax= aspect+aspect*error;
int area= candidate.size.height * candidate.size.width;
float r= (float)candidate.size.width /
(float)candidate.size.height;
if(r<1)
r= 1/r;
if(( area < min || area > max ) || ( r < rmin || r > rmax )){
return false;
}else{
return true;
}
}
我们可以利用车牌的白色背景属性进行更多改进。所有车牌都有相同的背景颜色,我们可以使用填充算法来检索旋转矩形以进行精确裁剪。
裁剪车牌的第一步是获取靠近最后旋转矩形中心的几个种子。然后,我们将获取车牌在宽度和高度之间的最小尺寸,并使用它来生成靠近修补中心附近的随机种子。
我们想要选择白色区域,并且我们需要几个种子至少接触一个白色像素。然后,对于每个种子,我们使用floodFill函数来绘制一个新的掩码图像以存储新的最近裁剪区域:
for(int i=0; i< rects.size(); i++){
//For better rect cropping for each possible box
//Make floodfill algorithm because the plate has white background
//And then we can retrieve more clearly the contour box
ircle(result, rects[i].center, 3, Scalar(0,255,0), -1);
//get the min size between width and height
float minSize=(rects[i].size.width < rects[i].size.height)?
rects[i].size.width:rects[i].size.height;
minSize=minSize-minSize*0.5;
//initialize rand and get 5 points around center for floodfill
algorithm
srand ( time(NULL) );
//Initialize floodfill parameters and variables
Mat mask;
mask.create(input.rows + 2, input.cols + 2, CV_8UC1);
mask= Scalar::all(0);
int loDiff = 30;
int upDiff = 30;
int connectivity = 4;
int newMaskVal = 255;
int NumSeeds = 10;
Rect ccomp;
int flags = connectivity + (newMaskVal << 8 ) +
CV_FLOODFILL_FIXED_RANGE + CV_FLOODFILL_MASK_ONLY;
for(int j=0; j<NumSeeds; j++){
Point seed;
seed.x=rects[i].center.x+rand()%(int)minSize-(minSize/2);
seed.y=rects[i].center.y+rand()%(int)minSize-(minSize/2);
circle(result, seed, 1, Scalar(0,255,255), -1);
int area = floodFill(input, mask, seed, Scalar(255,0,0), &ccomp, Scalar(loDiff, loDiff, loDiff), Scalar(upDiff, upDiff, upDiff),
flags);
floodfill函数从点种子开始填充一个连通组件的颜色到掩码图像中,并设置填充像素与像素邻居或像素种子之间的最大亮度/颜色差异:
intfloodFill(InputOutputArray image, InputOutputArray mask, Point seed, Scalar newVal, Rect* rect=0, Scalar loDiff=Scalar(), Scalar upDiff=Scalar(), int flags=4 )
newval参数是我们希望在填充图像时放入的新颜色。参数loDiff和upDiff是填充像素与相邻像素或像素种子之间的最大亮度/颜色差异。
参数flag是以下位组合:
-
低位位:这些位包含连接值,默认为 4 或 8,在函数中使用。连接性决定了哪些像素邻居被认为是
-
高位位:这些可以是 0 或以下值的组合-
CV_FLOODFILL_FIXED_RANGE和CV_FLOODFILL_MASK_ONLY。
CV_FLOODFILL_FIXED_RANGE设置当前像素和种子像素之间的差异。CV_FLOODFILL_MASK_ONLY将只填充图像掩码而不会更改图像本身。
一旦我们有了裁剪掩码,我们将从图像掩码点获取最小面积矩形,并再次检查有效尺寸。对于每个掩码,白色像素获取位置并使用minAreaRect函数来检索最近的裁剪区域:
//Check new floodfill mask match for a correct patch.
//Get all points detected for get Minimal rotated Rect
vector<Point> pointsInterest;
Mat_<uchar>::iterator itMask= mask.begin<uchar>();
Mat_<uchar>::iterator end= mask.end<uchar>();
for( ; itMask!=end; ++itMask)
if(*itMask==255)
pointsInterest.push_back(itMask.pos());
RotatedRect minRect = minAreaRect(pointsInterest);
if(verifySizes(minRect)){
分割过程已完成,并且我们有有效区域。现在,我们可以裁剪每个检测区域,去除可能的旋转,裁剪图像区域,调整图像大小,并均衡裁剪图像区域的光线。
首先,我们需要使用getRotationMatrix2D生成变换矩阵以去除检测区域中可能存在的旋转。我们需要注意高度,因为RotatedRect可以返回并旋转 90 度。因此,我们必须检查矩形纵横比,如果它小于1,我们需要将其旋转 90 度:
//Get rotation matrix
float r= (float)minRect.size.width / (float)minRect.size.height;
float angle=minRect.angle;
if(r<1)
angle=90+angle;
Mat rotmat= getRotationMatrix2D(minRect.center, angle,1);
使用变换矩阵,我们现在可以使用warpAffine函数通过仿射变换(在几何中,仿射变换是一种将平行线映射为平行线的变换)来旋转输入图像。在这里,我们设置输入和目标图像、变换矩阵、输出大小(在我们的情况下与输入相同)以及要使用的插值方法。如果需要,我们可以定义边界方法和边界值:
//Create and rotate image
Mat img_rotated;
warpAffine(input, img_rotated, rotmat, input.size(),
CV_INTER_CUBIC);
在我们对图像进行旋转后,我们将使用getRectSubPix裁剪图像,该函数裁剪并复制一个以点为中心的图像部分。如果图像已旋转,我们需要使用 C++的swap函数更改宽度和高度大小:
//Crop image
Size rect_size=minRect.size;
if(r < 1)
swap(rect_size.width, rect_size.height);
Mat img_crop;
getRectSubPix(img_rotated, rect_size, minRect.center,
img_crop);
裁剪的图像不适合用于训练和分类,因为它们的大小不同。此外,每张图像都包含不同的光照条件,使它们更加不同。为了解决这个问题,我们将所有图像调整到相同的宽度和高度,并应用光直方图均衡化:
Mat resultResized;
resultResized.create(33,144, CV_8UC3);
resize(img_crop, resultResized, resultResized.size(), 0, 0,
INTER_CUBIC);
//Equalize croped image
Mat grayResult;
cvtColor(resultResized, grayResult, CV_BGR2GRAY);
blur(grayResult, grayResult, Size(3,3));
equalizeHist(grayResult, grayResult);
对于每个检测到的区域,我们将裁剪的图像及其位置存储在一个向量中:
output.push_back(Plate(grayResult,minRect.boundingRect()));
分类
在预处理和分割图像的所有可能部分之后,我们现在需要决定每个部分是否是(或不是)车牌。为此,我们将使用支持向量机(SVM)算法。
支持向量机是包含在最初为二元分类而创建的监督学习算法家族中的模式识别算法。监督学习是使用标记数据进行训练的机器学习算法技术。我们需要用一定数量的标记数据进行算法训练;每个数据集都需要有一个类别。
SVM 创建一个或多个超平面,用于区分每个数据类别。
一个经典的例子是定义两个类别的 2D 点集;SVM 搜索区分每个类的最佳直线:

在任何分类之前的首要任务是训练我们的分类器;这是主应用之前的工作,被称为“离线训练”。这不是一项容易的工作,因为它需要足够的数据来训练系统,但更大的数据集并不总是意味着最好的结果。在我们的情况下,由于没有公开的车牌数据库,我们数据不足。因此,我们需要拍摄数百张汽车照片,然后对它们进行预处理和分割。
我们使用 75 张车牌图像和 35 张无车牌图像训练了我们的系统,这些图像具有 144x33 像素的分辨率。我们可以在以下图像中看到这些数据的样本。这不是一个大型数据集,但对于我们这一章来说已经足够了。在实际应用中,我们需要使用更多数据进行训练:

为了轻松理解机器学习的工作原理,我们将使用分类算法的图像像素特征(记住,有更好的方法和特征来训练 SVM,例如主成分分析(PCA)、傅里叶变换、纹理分析等等)。
为了使用DetectRegions类创建训练我们系统的图像并设置savingRegions变量为"true"以保存图像,我们需要这样做。我们可以使用segmentAllFiles.shbash 脚本来对文件夹下的所有图像文件重复此过程。这可以从本书的源代码中获取:
为了使这个过程更容易,我们将所有处理和准备好的图像训练数据存储到一个 XML 文件中,以便直接与 SVM 函数一起使用。trainSVM.cpp应用程序使用文件夹和图像文件的数量创建这个文件。
机器学习 OpenCV 算法的训练数据存储在一个NxM矩阵中,其中N是样本数,M是特征数。每个数据集作为训练矩阵中的一行保存。
类别存储在另一个大小为nx1 的矩阵中,其中每个类别由一个浮点数标识。
OpenCV 通过FileStorage类提供了一个简单的方式来管理 XML 或 YAML 格式的数据文件。这个类允许我们存储和读取 OpenCV 变量和结构或我们的自定义变量。使用这个函数,我们可以读取训练数据矩阵和训练类别,并将其保存到SVM_TrainingData和SVM_Classes中:
FileStorage fs;
fs.open("SVM.xml", FileStorage::READ);
Mat SVM_TrainingData;
Mat SVM_Classes;
fs["TrainingData"] >>SVM_TrainingData;
fs["classes"] >>SVM_Classes;
现在,我们已经有了SVM_TrainingData变量中的训练数据,以及SVM_Classes中的标签。然后,我们只需要创建一个训练数据对象,将数据和标签连接起来,以便在我们的机器学习算法中使用。为此,我们将使用TrainData类作为 OpenCV 指针Ptr类,如下所示:
Ptr<TrainData> trainData = TrainData::create(SVM_TrainingData,
ROW_SAMPLE, SVM_Classes);
我们将使用SVM类和PtrOpenCV 类来创建分类器对象:
Ptr<SVM> svmClassifier = SVM::create()
现在,我们需要设置 SVM 参数,这些参数定义了在 SVM 算法中使用的基本参数。为此,我们只需要更改一些对象变量。经过不同的实验后,我们将选择下一个参数设置:
svmClassifier-
>setTermCriteria(TermCriteria(TermCriteria::MAX_ITER, 1000,
0.01));
svmClassifier->setC(0.1);
svmClassifier->setKernel(SVM::LINEAR);
我们为训练选择了 1000 次迭代,C 参数变量优化为 0.1,最后使用核函数。
我们只需要使用train函数和训练数据来训练我们的分类器:
svmClassifier->train(trainData);
我们的分类器已经准备好使用我们的 SVM 类的predict函数来预测一个可能裁剪的图像;这个函数返回类标识符i。在我们的情况下,我们将车牌类标记为 1,无车牌类标记为 0。然后,对于每个可能为车牌的检测区域,我们将使用 SVM 将其分类为车牌或无车牌,并仅保存正确的响应。以下代码是主应用程序中在线处理的一部分:
vector<Plate> plates;
for(int i=0; i< posible_regions.size(); i++)
{
Mat img=posible_regions[i].plateImg;
Mat p= img.reshape(1, 1);//convert img to 1 row m features
p.convertTo(p, CV_32FC1);
int response = (int)svmClassifier.predict( p );
if(response==1)
plates.push_back(posible_regions[i]);
}
车牌识别
许可证识别的第二步旨在使用光学字符识别技术检索车牌上的字符。对于每个检测到的车牌,我们将对每个字符进行分割,并使用人工神经网络机器学习算法来识别字符。在本节中,你还将学习如何评估分类算法。
OCR 分割
首先,我们将获取一个车牌图像块作为输入,传递给具有均衡直方图的 OCR 分割函数。然后,我们只需要应用一个阈值滤波器,并使用这个阈值图像作为 Find Contours 算法的输入。我们可以在以下图像中观察到这个过程:

这个分割过程如下所示:
Mat img_threshold;
threshold(input, img_threshold, 60, 255, CV_THRESH_BINARY_INV);
if(DEBUG)
imshow("Threshold plate", img_threshold);
Mat img_contours;
img_threshold.copyTo(img_contours);
//Find contours of possibles characters
vector< vector< Point>> contours;
findContours(img_contours,
contours, // a vector of contours
CV_RETR_EXTERNAL, // retrieve the external contours
CV_CHAIN_APPROX_NONE); // all pixels of each contours
我们使用了CV_THRESH_BINARY_INV参数来通过将白色输入值转换为黑色和黑色输入值转换为白色来反转阈值输出。这是获取每个字符轮廓所必需的,因为轮廓算法寻找白色像素。
对于每个检测到的轮廓,我们可以进行大小验证并移除所有大小较小或纵横比不正确的区域。在我们的情况下,字符的纵横比为 45/77,我们可以接受旋转或扭曲字符的 35%纵横比误差。如果一个区域高于 80%,我们将考虑该区域为黑色块而不是字符。对于计数面积,我们可以使用countNonZero函数,该函数计算值高于 0 的像素数量:
bool OCR::verifySizes(Mat r){
//Char sizes 45x77
float aspect=45.0f/77.0f;
float charAspect= (float)r.cols/(float)r.rows;
float error=0.35;
float minHeight=15;
float maxHeight=28;
//We have a different aspect ratio for number 1, and it can be
~0.2
float minAspect=0.2;
float maxAspect=aspect+aspect*error;
//area of pixels
float area=countNonZero(r);
//bb area
float bbArea=r.cols*r.rows;
//% of pixel in area
float percPixels=area/bbArea;
if(percPixels < 0.8 && charAspect > minAspect && charAspect <
maxAspect && r.rows >= minHeight && r.rows < maxHeight)
return true;
else
return false;
}
如果一个分割的字符被验证,我们必须对其进行预处理,以设置所有字符相同的大小和位置,并使用辅助CharSegment类将其保存为向量。这个类保存了分割字符图像和我们需要按顺序排列字符的位置,因为 Find Contour 算法不会以正确和所需的顺序返回轮廓。
特征提取
对于每个分割的字符,下一步是提取用于训练的特征并对人工神经网络算法进行分类。
与车牌检测不同,用于 SVM 的特征提取步骤不使用所有图像像素。我们将应用在 OCR 中更常见的特征,这些特征包含水平和垂直累积直方图以及低分辨率图像样本。我们可以在下一张图像中更直观地看到这个特征,因为每个图像都有一个低分辨率的 5x5 图像和直方图累积:

对于每个字符,我们将使用countNonZero函数计算具有非零值的行或列中的像素数量,并将其存储在名为mhist的新数据矩阵中。我们将使用minMaxLoc函数查找数据矩阵中的最大值,并使用convertTo函数将mhist的所有元素除以最大值来归一化。我们将创建ProjectedHistogram函数来创建具有二值图像和所需类型的直方图累积,该直方图累积以二进制图像和水平或垂直类型为输入:
Mat OCR::ProjectedHistogram(Mat img, int t)
{
int sz=(t)?img.rows:img.cols;
Mat mhist=Mat::zeros(1,sz,CV_32F);
for(int j=0; j<sz; j++){
Mat data=(t)?img.row(j):img.col(j);
mhist.at<float>(j)=countNonZero(data);
}
//Normalize histogram
double min, max;
minMaxLoc(mhist, &min, &max);
if(max>0)
mhist.convertTo(mhist,-1 , 1.0f/max, 0);
return mhist;
}
其他特征使用低分辨率样本图像。我们不会使用整个字符图像,而是创建一个低分辨率的字符,例如,一个 5x5 的字符。我们将使用 5x5、10x10、15x15 和 20x20 的字符来训练系统,然后评估哪个返回最佳结果以用于我们的系统。一旦我们有了所有特征,我们将创建一个由一行的M列组成的矩阵,其中列是特征:
Mat OCR::features(Mat in, int sizeData){
//Histogram features
Mat vhist=ProjectedHistogram(in,VERTICAL);Mat
hhist=ProjectedHistogram(in,HORIZONTAL);
//Low data feature
Mat lowData;resize(in, lowData, Size(sizeData, sizeData) );
int numCols=vhist.cols + hhist.cols + lowData.cols *
lowData.cols;
Mat out=Mat::zeros(1,numCols,CV_32F);
//Asign values to feature
int j=0;
for(int i=0; i<vhist.cols; i++){
out.at<float>(j)=vhist.at<float>(i); j++;}
for(int i=0; i<hhist.cols; i++){
out.at<float>(j)=hhist.at<float>(i);
j++;}
for(int x=0; x<lowData.cols; x++){
for(int y=0; y<lowData.rows; y++){
out.at<float>(j)=(float)lowData.at<unsigned char>(x,y);
j++;
}
}
return out;
}
OCR 分类
在分类步骤中,我们使用了人工神经网络机器学习算法,更具体地说,是一个多层感知器(MLP),这是最常用的 ANN 算法。
MLP 由一个包含输入层、输出层和一层或更多隐藏层的神经元网络组成。每一层都通过前一层和后一层的神经元连接。
以下示例表示一个三层感知器(是一个将实值向量输入映射到单个二进制值输出的二分类器),有三个输入、两个输出,隐藏层包含五个神经元:

MLP 中的所有神经元都是相似的,每个神经元都有几个输入(前一个连接的神经元)和几个具有相同值的输出链接(下一个连接的神经元)。每个神经元通过加权输入的总和加上一个偏置项来计算输出值,并通过选定的激活函数进行转换:

有三种广泛使用的激活函数:恒等函数、Sigmoid 和高斯。最常见和默认的激活函数是 Sigmoid 函数;它将 alpha 和 beta 值设置为 1:

经过训练的 ANN 网络有一个具有特征的输入向量;它将值传递到隐藏层,并通过权重和激活函数计算结果。它将输出进一步传递到下游,直到它到达具有类别神经元数量的输出层。
每一层的权重、突触和神经元是通过训练 ANN 算法来计算和学习的。为了训练我们的分类器,我们将创建两个数据矩阵,就像我们在 SVM 训练中所做的那样,但训练标签略有不同。而不是一个Nx1 的矩阵,其中N代表训练数据行数,1 是列,我们将使用标签编号标识符。我们必须创建一个NxM的矩阵,其中N是训练/样本数据,M是类别(在我们的情况下是 10 个数字和 20 个字母),并在位置i,j处设置 1,如果数据行i被分类为类别j:

我们将创建一个OCR::train函数来创建所有需要的矩阵并训练我们的系统,包括训练数据矩阵、类别矩阵和隐藏层中的隐藏神经元数量。训练数据从 XML 文件中加载,就像我们在 SVM 训练中所做的那样。
我们必须定义每一层的神经元数量来初始化 ANN 类。在我们的示例中,我们将只使用一个隐藏层。然后,我们将定义一个一行三列的矩阵。第一列位置是特征数量,第二列位置是隐藏层上的隐藏神经元数量,第三列位置是类别数量。
OpenCV 定义了一个ANN_MLP类用于 ANN。使用create函数,我们可以初始化类指针,然后定义层数、神经元数量和激活函数。然后我们可以创建像 SVM 一样的训练数据,以及训练方法的alpha和beta参数:
void OCR::train(Mat TrainData, Mat classes, int nlayers){
Mat_<int> layerSizes(1, 3);
layerSizes(0, 0) = data.cols;
layerSizes(0, 1) = nlayers;
layerSizes(0, 2) = numCharacters;
ann= ANN_MLP::create();
ann->setLayerSizes(layerSizes);
ann->setActivationFunction(ANN_MLP::SIGMOID_SYM, 0, 0);
ann->setTrainMethod(ANN_MLP::BACKPROP, 0.0001, 0.0001);
//Prepare trainClases
//Create a mat with n trained data by m classes
Mat trainClasses;
trainClasses.create( TrainData.rows, numCharacters, CV_32FC1 );
for( int i = 0; i <trainClasses.rows; i++ )
{
for( int k = 0; k < trainClasses.cols; k++ )
{
//If class of data i is same than a k class
if( k == classes.at<int>(i) )
trainClasses.at<float>(i,k) = 1;
else
trainClasses.at<float>(i,k) = 0;
}
}
Ptr<TrainData> trainData = TrainData::create(data, ROW_SAMPLE, trainClasses);
//Learn classifier
ann->train( trainData );
}
训练后,我们可以使用OCR::classify函数对任何分割的牌照特征进行分类:
int OCR::classify(Mat f){
int result=-1;
Mat output;
ann.predict(f, output);
Point maxLoc;
double maxVal;
minMaxLoc(output, 0, &maxVal, 0, &maxLoc);
//We need know where in output is the max val, the x (cols) is
the class.
return maxLoc.x;
}
ANN_MLP类使用predict函数对类中的特征向量进行分类。与 SVM 的classify函数不同,ANN 预测函数返回一个行大小等于类数量的行,其中包含输入特征属于每个类的概率。
为了获得最佳结果,我们可以使用minMaxLoc函数获取最大和最小响应及其在矩阵中的位置。我们的字符类别由较高值的x位置指定:

为了完成每个检测到的车牌,我们对其字符进行排序,并使用Plate类的str()函数返回一个字符串,我们可以在原始图像上绘制它:
string licensePlate=plate.str();
rectangle(input_image, plate.position, Scalar(0,0,200));
putText(input_image, licensePlate, Point(plate.position.x, plate.position.y), CV_FONT_HERSHEY_SIMPLEX, 1,
Scalar(0,0,200),2);
评估
我们的项目已经完成。然而,当我们训练像 OCR 这样的机器学习算法时,例如,我们需要知道最佳特征和参数以及如何纠正系统中的分类、识别和检测错误。
我们需要用不同的情况和参数评估我们的系统,并评估产生的错误,以便获得最小化这些错误的最佳参数。
在本章中,我们使用变量评估了 OCR 任务:低级分辨率图像特征的大小和隐藏层中隐藏神经元的数量。
我们创建了evalOCR.cpp应用程序,其中我们使用由trainOCR.cpp应用程序生成的 XML 训练数据文件。OCR.xml文件包含 5x5、10x10、15x15 和 20x20 下采样图像特征的训练数据矩阵:
Mat classes;
Mat trainingData;
//Read file storage.
FileStorage fs;
fs.open("OCR.xml", FileStorage::READ);
fs[data] >> trainingData;
fs["classes"] >> classes;
评估应用程序获取每个下采样矩阵特征,并为训练获取 100 个随机行,以及用于测试 ANN 算法和检查错误的其它行。
在训练系统之前,我们将测试每个随机样本并检查其响应是否正确。如果响应不正确,我们将错误计数器变量增加,然后除以样本数量以评估。这表示使用随机数据进行训练的错误率在 0 到 1 之间:
float test(Mat samples, Mat classes){
float errors=0;
for(int i=0; i<samples.rows; i++){
int result= ocr.classify(samples.row(i));
if(result!= classes.at<int>(i))
errors++;
}
return errors/samples.rows;
}
应用程序为每个样本大小返回输出命令行错误率。为了进行良好的评估,我们需要用不同的随机训练行训练应用程序。这会产生不同的测试错误值。然后,我们可以将所有错误加起来并得到平均值。为此任务,我们将创建 bash UNIX 脚本来自动化它:
#!/bin/bash
echo "#ITS t 5 t 10 t 15 t 20">data.txt
folder=$(pwd)
for numNeurons in 10 20 30 40 50 60 70 80 90 100 120 150 200 500
do
s5=0;
s10=0;
s15=0;
s20=0;
for j in {1..100}
do
echo $numNeurons $j
a=$($folder/build/evalOCR $numNeurons TrainingDataF5)
s5=$(echo "scale=4; $s5+$a" | bc -q 2>/dev/null)
a=$($folder/build/evalOCR $numNeurons TrainingDataF10)
s10=$(echo "scale=4; $s10+$a" | bc -q 2>/dev/null)
a=$($folder/build/evalOCR $numNeurons TrainingDataF15)
s15=$(echo "scale=4; $s15+$a" | bc -q 2>/dev/null)
a=$($folder/build/evalOCR $numNeurons TrainingDataF20)
s20=$(echo "scale=4; $s20+$a" | bc -q 2>/dev/null)
done
echo "$i t $s5 t $s10 t $s15 t $s20"
echo "$i t $s5 t $s10 t $s15 t $s20">>data.txt
done
此脚本保存一个data.txt文件,其中包含每个大小和神经元隐藏层数量的所有结果。此文件可用于与gnuplot绘图。我们可以在以下图像中看到结果:

我们可以看到,最低的错误率超过 8%,使用了隐藏层中的 20 个神经元和从下采样到 10x10 图像块中提取的字符特征。
摘要
在本章中,你学习了自动车牌识别程序的工作原理及其两个重要步骤:车牌定位和车牌识别。
在第一步中,你学习了如何通过寻找可以放置牌照的区域来分割图像,并使用简单的启发式方法和 SVM 算法对带有牌照和无牌照的区块进行二分类。
在第二步中,你学习了如何使用查找轮廓算法进行分割,从每个字符中提取特征向量,并使用人工神经网络(ANN)对字符类中的每个特征进行分类。
你还学习了如何通过随机样本训练和不同的参数及特征来评估机器算法。
在下一章中,你将学习如何使用特征脸创建人脸识别应用程序。
第四章:非刚性面部跟踪
非刚性面部跟踪,即在每个视频流帧中估计一个准密集的面部特征集,是一个难题,现代方法借鉴了多个相关领域的思想,包括计算机视觉、计算几何、机器学习和图像处理。这里的非刚性指的是面部特征之间的相对距离在面部表情和人群之间是变化的,并且与面部检测和跟踪不同,后者旨在仅找到每帧中面部位置,而不是面部特征配置。非刚性面部跟踪是一个热门的研究课题,已经持续了二十多年,但直到最近,各种方法才足够稳健,处理器足够快,使得商业应用的开发成为可能。
尽管商业级面部跟踪可以非常复杂,甚至对经验丰富的计算机视觉科学家来说也是一个挑战,但在本章中,我们将看到,在受限设置下表现合理的面部跟踪器可以使用适度的数学工具和 OpenCV 在线性代数、图像处理和可视化方面的强大功能来设计。这在需要跟踪的人事先已知,并且有图像和地标注释形式的训练数据可用时尤其如此。以下描述的技术将作为一个有用的起点和指南,以进一步追求更复杂的人脸跟踪系统。
本章的概述如下:
-
概述:本节涵盖面部跟踪的简要历史。
-
工具:本节概述了本章中使用的常见结构和约定。它包括面向对象设计、数据存储和表示,以及用于数据收集和注释的工具。
-
几何约束:本节描述了如何从训练数据中学习面部几何及其变化,并在跟踪过程中利用这些变化来约束解决方案。这包括将面部建模为线性形状模型以及如何将全局变换集成到其表示中。
-
面部特征检测器:本节描述了如何学习面部特征的外观以便在需要跟踪面部图像中检测它们。
-
面部检测和初始化:本节描述了如何使用面部检测来初始化跟踪过程。
-
面部跟踪:本节将之前描述的所有组件通过图像配准的过程组合成一个跟踪系统。讨论系统在哪些设置下可以预期表现最佳。
以下方块图说明了系统各个组件之间的关系:

注意,本章中采用的所有方法都遵循数据驱动范式,即所有使用的模型都是通过数据学习得到的,而不是在基于规则的设置中手工设计。因此,系统的每个组件都将涉及两个部分:训练和测试。训练从数据中构建模型,测试则将这些模型应用于新的未见数据。
概述
非刚性面部追踪首次在 20 世纪 90 年代初至中期流行起来,当时 Cootes 和 Taylor 推出了主动形状模型(ASM)。从那时起,大量研究致力于解决通用面部追踪的难题,并对 ASM 最初提出的方法进行了许多改进。第一个里程碑是在 2001 年将 ASM 扩展到主动外观模型(AAM),这也是由 Cootes 和 Taylor 完成的。这种方法后来在 2005 年中期通过 Baker 及其同事对图像扭曲的原理性处理而得到形式化。沿着这些方向的另一项工作是 Blanz 和 Vetter 的3D 可变形模型(3DMM),它类似于 AAM,不仅将图像纹理建模,而不是像 ASM 那样沿物体边界建模轮廓,而且更进一步,通过从面部激光扫描中学习的高度密集的 3D 数据来表示模型。从 2000 年代中期到晚期,面部追踪的研究重点从如何参数化面部转向了如何提出和优化跟踪算法的目标。机器学习社区的各种技术被应用于此,并取得了不同程度的成功。进入 21 世纪后,研究重点再次转移,这次转向了联合参数和目标设计策略,以确保全局解。
尽管对面部追踪的研究持续深入,但使用该技术的商业应用相对较少。尽管有大量免费开源代码包可用于多种常见方法,但业余爱好者和爱好者对该技术的采用也相对滞后。然而,在过去的两年里,公众对面部追踪的潜在用途重新产生了兴趣,并且商业级产品开始涌现。
工具
在深入研究面部追踪的复杂性之前,必须首先介绍一些常见的账务任务和约定,这些任务和约定适用于所有面部追踪方法。本节的其余部分将处理这些问题。感兴趣的读者可能希望在第一次阅读时跳过本节,直接跳到关于几何约束的部分。
面向对象设计
与面部检测和识别一样,从程序的角度来看,面部追踪由两个组件组成:数据和算法。算法通常通过参考预先存储的数据(即离线数据)作为指南,对传入的(即在线)数据进行某种操作。因此,将算法与它们依赖的数据相结合的面向对象设计是一种方便的设计选择。
在 OpenCV v2.x 版本中,引入了一个方便的 XML/YAML 文件存储类,它极大地简化了为算法组织离线数据的工作。为了利用这一功能,本章中描述的所有类都将实现读写序列化函数。以下是一个虚构的类foo的示例:
#include <opencv2/opencv.hpp>
using namespace cv;
class foo {
public:
Mat a;
type_b b;
void write(FileStorage &fs) const{
assert(fs.isOpened());
fs<< "{" << "a" << a << "b" << b << "}";
}
void read(const FileNode& node){
assert(node.type() == FileNode::MAP);
node["a"] >> a; node["b"] >> b;
}
};
在这里,Mat是 OpenCV 的矩阵类,type_b是一个(虚构的)用户定义的类,它也定义了序列化功能。I/O 函数read和write实现了序列化。FileStorage类支持两种可以序列化的数据结构。为了简单起见,在本章中,所有类将仅利用映射,其中每个存储的变量创建一个类型为FileNode::MAP的FileNode对象。这要求为每个元素分配一个唯一的键。尽管对键的选择是任意的,但为了保持一致性,我们将使用变量名作为标签。如前述代码片段所示,read和write函数采用了特别简单的形式,其中使用了流操作符(<<和>>)将数据插入和提取到FileStorage对象中。大多数 OpenCV 类都有read和write函数的实现,这使得存储它们包含的数据变得容易。
除了定义序列化函数外,还必须定义两个额外的函数,以便在FileStorage类中实现序列化,如下所示:
void write(FileStorage& fs, const string&, const foo& x) {
x.write(fs);
}
void read(const FileNode& node, foo& x,const foo& default){
if(node.empty())x = d; else x.read(node);
}
由于这些两个函数在我们本节描述的所有类中功能相同,因此它们被模板化并在源代码中与本章相关的ft.hpp头文件中定义。最后,为了轻松保存和加载使用序列化功能的自定义类,头文件中也实现了这些模板化函数,如下所示:
template<class T>
T load_ft(const char* fname){
T x; FileStorage f(fname,FileStorage::READ);
f["ft object"] >> x; f.release(); return x;
}
template<class T>
void save_ft(const char* fname,const T& x){
FileStorage f(fname,FileStorage::WRITE);
f << "ft object" << x; f.release();
}
注意,与对象关联的标签始终相同(即,ft object)。定义了这些函数后,保存和加载对象数据就变得轻松愉快了。以下示例展示了这一点:
#include "opencv_hotshots/ft/ft.hpp"
#include "foo.hpp"
int main() {
...
foo A; save_ft<foo>("foo.xml",A);
...
foo B = load_ft<foo>("foo.xml");
...
}
注意,.xml扩展名会导致生成 XML 格式的数据文件。对于任何其他扩展名,它默认为(更易于阅读的)YAML 格式。
数据收集 - 图像和视频标注
现代人脸追踪技术几乎完全是数据驱动的,也就是说,用于检测图像中面部特征位置的算法依赖于面部特征的外观模型以及它们相对位置之间的几何依赖关系。示例集越大,算法的行为就越稳健,因为它们对面部可以展现的变异性范围了解得越多。因此,构建人脸追踪算法的第一步是创建一个图像/视频标注工具,用户可以在每个示例图像中指定所需面部特征的位置。
训练数据类型
训练人脸跟踪算法的数据通常包括四个组成部分:
-
图像:该组成部分是一组包含整个面部的图像(静态图像或视频帧)。为了获得最佳效果,该集合应专门针对跟踪器随后部署的条件类型(即身份、照明、相机距离、捕获设备等)进行定制。此外,集合中的面部必须展现出预期应用所期望的头姿和面部表情的范围。
-
标注:该组成部分在每个图像中都有有序的手动标注位置,对应于要跟踪的每个面部特征。更多的面部特征通常会导致跟踪器更加鲁棒,因为跟踪算法可以使用它们的测量值相互强化。常见跟踪算法的计算成本通常与面部特征的数量成线性关系。
-
对称指数:该组成部分为每个面部特征点提供一个索引,用于定义其双边对称特征。这可以用来镜像训练图像,有效地将训练集大小加倍,并沿 y 轴对称化数据。
-
连接指数:该组成部分有一组标注的索引对,用于定义面部特征的语义解释。这些连接对于可视化跟踪结果非常有用。
下面的图像展示了这四个组成部分的可视化,从左到右分别是原始图像、面部特征标注、颜色编码的双边对称点、镜像图像以及标注和面部特征连接:

为了方便管理此类数据,实现存储和访问功能的类是一个有用的组成部分。OpenCV 的ml模块中的CvMLData类具有处理机器学习问题中常用的一般数据的函数。然而,它缺少处理人脸跟踪数据所需的函数。因此,在本章中,我们将使用在ft_data.hpp头文件中声明的ft_data类,该类专门考虑了人脸跟踪数据的特殊性。所有数据元素都被定义为类的公共成员,如下所示:
class ft_data{
public:
vector<int> symmetry;
vector<Vec2i> connections;
vector<string> imnames;
vector<vector<Point2f>> points;
...
}
Vec2i 和 Point2f 类型分别是 OpenCV 的两个整数字符串和二维浮点坐标类。symmetry 向量包含与面部特征点(由用户定义)数量相同数量的组件。每个 connections 定义了一对连接面部特征的零索引。由于训练集可能非常大,而不是直接存储图像,该类将每个图像的文件名存储在 imnames 成员变量中(注意,这要求图像位于相同的相对路径,以便文件名保持有效)。最后,对于每个训练图像,将面部特征位置作为浮点坐标的向量存储在 points 成员变量中。
ft_data 类实现了一系列方便的方法来访问数据。要访问数据集中的图像,get_image 函数会加载指定索引 idx 的图像,并可选择沿 y 轴进行镜像,如下所示:
Mat
ft_data::get_image(
const int idx, //index of image to load from file
const int flag) { //0=gray,1=gray+flip,2=rgb,3=rgb+flip
if((idx < 0) || (idx >= (int)imnames.size()))return Mat();
Mat img,im;
if(flag < 2) img = imread(imnames[idx],0);
else img = imread(imnames[idx],1);
if(flag % 2 != 0) flip(img,im,1);
else im = img;
return im;
}
传递给 OpenCV 的 imread 函数的 (0,1) 标志指定图像是作为三通道彩色图像还是单通道灰度图像加载。传递给 OpenCV 的 flip 函数的标志指定沿 y 轴的镜像。
要访问特定索引处的图像对应的点集,get_points 函数返回一个浮点坐标向量,可以选择如下镜像它们的索引:
vector<Point2f>
ft_data::get_points(
const int idx, //index of image corresponding to points
const bool flipped) { //is the image flipped around the y-axis?
if((idx < 0) || (idx >= (int)imnames.size()))
return vector<Point2f>();
vector<Point2f> p = points[idx];
if(flipped){
Mat im = this->get_image(idx,0); int n = p.size();
vector<Point2f> q(n);
for(int i = 0; i < n; i++){
q[i].x = im.cols-1-p[symmetry[i]].x;
q[i].y = p[symmetry[i]].y;
} return q;
} else return p;
}
注意,当指定镜像标志时,此函数会调用 get_image 函数。这是为了确定图像的宽度,以便正确镜像面部特征坐标。可以设计一个更有效的方法,只需简单地将图像宽度作为变量传递。最后,此函数展示了 symmetry 成员变量的实用性。特定索引的镜像特征位置只是 symmetry 变量中指定的索引处的特征位置,其 x 坐标翻转并偏移。
如果指定的索引超出了数据集存在的索引范围,get_image 和 get_points 函数都会返回空结构。也可能不是集合中的所有图像都有注释。可以设计处理缺失数据的面部跟踪算法;然而,这些实现通常相当复杂,并且超出了本章的范围。ft_data 类实现了一个函数,用于从其集合中删除没有对应注释的样本,如下所示:
void ft_data::rm_incomplete_samples(){
int n = points[0].size(),N = points.size();
for(int i = 1; i < N; i++)n = max(n,int(points[i].size()));
for(int i = 0; i < int(points.size()); i++){
if(int(points[i].size()) != n){
points.erase(points.begin()+i);
imnames.erase(imnames.begin()+i); i--;
} else {
int j = 0;
for(; j < n; j++) {
if((points[i][j].x <= 0) ||
(points[i][j].y <= 0))break;
}
if(j < n) {
points.erase(points.begin()+i);
imnames.erase(imnames.begin()+i); i--;
}
}
}
}
假设具有最多注释的样本实例是规范样本。使用向量的 erase 函数从集合中删除具有少于该数量点的所有数据实例。还请注意,具有 (x, y) 坐标小于 1 的点在相应的图像中被视为缺失(可能是由于遮挡、可见性差或模糊)。
ft_data 类实现了 read 和 write 序列化函数,因此可以轻松存储和加载。例如,保存数据集可以像以下这样简单完成:
ft_data D; //instantiate data structure
... //populate data
save_ft<ft_data>("mydata.xml",D); //save data
为了可视化数据集,ft_data 实现了多个绘图函数。它们的使用在 visualize_annotations.cpp 文件中有说明。这个简单的程序加载存储在命令行指定的文件中的注释数据,移除不完整的样本,并显示带有相应注释、对称性和连接的训练图像。这里展示了 OpenCV 的 highgui 模块的一些显著特性。尽管非常基础且不适合复杂的用户界面,但 OpenCV 的 highgui 模块在计算机视觉应用中加载和可视化数据和算法输出方面非常实用。这可能是 OpenCV 与其他计算机视觉库相比的一个区别特征。
注释工具
为了帮助生成本章代码中使用的注释,可以在 annotate.cpp 文件中找到一个基本的注释工具。该工具接受视频流作为输入,无论是来自文件还是来自摄像头。使用该工具的步骤如下:
-
捕获图像:在这个第一步中,图像流显示在屏幕上,用户通过按
S键选择要注释的图像。最适合注释的特征是那些最大限度地覆盖面部行为范围的特征,这些特征是面部追踪系统需要追踪的。 -
注释第一张图像:在这个第二步中,用户被展示在上一阶段选中的第一张图像。然后用户继续点击图像上与需要追踪的面部特征相关的位置。
-
注释连接性:在这个第三步中,为了更好地可视化形状,需要定义点的连接结构。在这里,用户被展示与之前阶段相同的图像,现在的任务是依次点击一系列点对,以构建面部模型的连接结构。
-
注释对称性:在这个步骤中,仍然使用相同的图像,用户选择表现出双边对称性的点对。
-
注释剩余图像:在这个最后一步中,这里的程序与 步骤 2 类似,但用户可以浏览图像集并对它们进行异步注释。
对此感兴趣的读者可能希望通过改进其可用性或甚至集成增量学习过程来改进此工具,其中在注释每张额外的图像后更新追踪模型,并随后使用它来初始化点以减少注释负担。
尽管一些公开可用的数据集可以与本章开发的代码一起使用(例如,参见下节中的描述),但标注工具可以用来构建特定于个人的面部跟踪模型,这些模型通常比它们的通用、独立于个人的对应模型表现要好得多。
预标注数据(MUCT 数据集)
开发人脸跟踪系统的一个阻碍因素是手动标注大量图像的繁琐且易出错的过程,每张图像都有大量标注点。为了简化这一过程,以便跟随本章的工作,可以从公开可用的 MUCT 数据集下载 h t t p 😕/w w w /m i l b o . o r g /m u c t 。
该数据集包含 3,755 张带有 76 个关键点的面部图像。数据集中的受试者年龄和种族各异,并在多种不同的光照条件和头部姿势下进行拍摄。
要使用本章中的代码与 MUCT 数据集,请执行以下步骤:
-
下载图像集:在这一步中,可以通过下载文件
muct-a-jpg-v1.tar.gz到muct-e-jpg-v1.tar.gz并解压它们来获取数据集中的所有图像。这将生成一个新文件夹,其中将存储所有图像。 -
下载标注:在这一步中,下载包含标注的文件
muct-landmarks-v1.tar.gz。将此文件保存并解压到与下载图像相同的文件夹中。 -
使用标注工具定义连接和对称性:在这一步中,从命令行发出命令
./annotate -m $mdir -d $odir,其中$mdir表示保存 MUCT 数据集的文件夹,而$odir表示将包含作为ft_data对象存储的数据的annotations.yaml文件写入的文件夹。
鼓励使用 MUCT 数据集,以快速了解本章所述面部跟踪代码的功能。
几何约束
在人脸追踪中,几何指的是一组预定义点的空间配置,这些点对应于人类面部上的物理一致位置(例如眼角、鼻尖和眉毛边缘)。这些点的特定选择取决于应用,有些应用需要超过 100 个点的密集集合,而有些则只需要较稀疏的选择。然而,随着点数的增加,人脸追踪算法的鲁棒性通常会提高,因为它们各自的测量值可以通过它们之间的相对空间依赖性相互加强。例如,眼角的位置是预测鼻尖位置的良方。然而,通过增加点数来提高鲁棒性的改进是有极限的,性能通常在约 100 个点后趋于平稳。此外,用于描述面部的点集的增加会导致计算复杂性的线性增加。因此,对计算负载有严格限制的应用可能更适合使用较少的点。
也有这样的情况,更快的追踪往往会导致在线设置中更精确的追踪。这是因为,当帧被丢弃时,帧之间的感知运动增加,用于在每一帧中找到面部配置的优化算法必须搜索一个更大的特征点可能配置空间;当帧之间的位移变得过大时,这个过程通常会失败。总之,尽管有关于如何最佳设计面部特征点选择的通用指南,但要获得最佳性能,这种选择应该专门针对应用的领域进行定制。
面部几何通常被参数化为两个元素的组合:一个全局变换(刚体)和一个局部变形(非刚体)。全局变换负责解释面部在图像中的整体位置,这通常允许其无约束地变化(也就是说,面部可以出现在图像的任何位置)。这包括面部在图像中的(x, y)位置、平面内头部旋转以及面部在图像中的大小。另一方面,局部变形负责解释不同身份之间的面部形状差异以及表情之间的差异。与全局变换相比,这些局部变形通常受到更多的约束,这主要归因于面部特征的复杂结构配置。全局变换是二维坐标的通用函数,适用于任何类型的对象,而局部变形是特定于对象的,必须从训练数据集中学习。
在本节中,我们将描述人脸结构的几何模型的构建,这里称为形状模型。根据应用的不同,它可以捕捉单个个体的表情变化、人群之间面部形状的差异,或者两者的组合。该模型在 shape_model 类中实现,可以在 shape_model.hpp 和 shape_model.cpp 文件中找到。以下代码片段是 shape_model 类头文件的一部分,突出了其主要功能:
class shape_model { //2d linear shape model
public:
Mat p; //parameter vector (kx1) CV_32F
Mat V; //linear subspace (2nxk) CV_32F
Mat e; //parameter variance (kx1) CV_32F
Mat C; //connectivity (cx2) CV_32S
...
void calc_params(
const vector<Point2f>&pts, //points to compute parameters
const Mat &weight = Mat(), //weight/point (nx1) CV_32F
const float c_factor = 3.0); //clamping factor
...
vector<Point2f> //shape described by parameters
calc_shape();
...
void train(
const vector<vector<Point2f>>&p, //N-example shapes
const vector<Vec2i>&con = vector<Vec2i>(),//connectivity
const float frac = 0.95, //fraction of variation to retain
const int kmax = 10); //maximum number of modes to retain
...
}
表示人脸形状变化的模型编码在子空间矩阵 V 和方差向量 e 中。参数向量 p 存储了相对于模型的形状编码。连接矩阵 C 也存储在这个类中,因为它仅与可视化人脸形状的实例相关。这个类中主要关注的三个函数是 calc_params、calc_shape 和 train。calc_params 函数将一组点投影到可能的人脸形状空间。它可以选择为要投影的每个点提供单独的置信权重。calc_shape 函数通过使用人脸模型(由 V 和 e 编码)解码参数向量 p 来生成一组点。train 函数从人脸形状的数据集中学习编码模型,每个数据集都由相同数量的点组成。frac 和 kmax 是训练过程的参数,可以根据手头的数据进行专门化。
在接下来的章节中,我们将详细说明这个类的功能,首先描述Procrustes 分析,这是一种刚性注册点集的方法,然后是用于表示局部变形的线性模型。train_shape_model.cpp 和 visualize_shape_model.cpp 文件中的程序分别训练和可视化形状模型。它们的用法将在本节末尾概述。
Procrustes 分析
为了构建人脸形状的变形模型,我们首先必须处理原始标注数据,以去除与全局刚性运动相关的成分。在 2D 几何建模中,刚性运动通常表示为相似变换;这包括缩放、平面旋转和平移。以下图像说明了在相似变换下的允许的运动类型集合。从点集集合中去除全局刚性运动的过程称为Procrustes 分析。

从数学上讲,Procrustes 分析的目的是同时找到规范形状和相似性,并将每个数据实例转换,使其与规范形状对齐。在这里,对齐是通过每个转换形状与规范形状之间的最小二乘距离来衡量的。实现这一目标的迭代过程在 shape_model 类中如下实现:
#define fl at<float>
Mat shape_model::procrustes (
const Mat &X, //interleaved raw shape data as columns
const int itol, //maximum number of iterations to try
const float ftol) //convergence tolerance
{
int N = X.cols,n = X.rows/2; Mat Co,P = X.clone();//copy
for(int i = 0; i < N; i++){
Mat p = P.col(i); //i'th shape
float mx = 0,my = 0; //compute centre of mass...
for(int j = 0; j < n; j++) { //for x and y separately
mx += p.fl(2*j); my += p.fl(2*j+1);
}
mx /= n; my /= n;
for(int j = 0; j < n; j++) { //remove center of mass
p.fl(2*j) -= mx; p.fl(2*j+1) -= my;
}
}
for(int iter = 0; iter < itol; iter++) {
Mat C = P*Mat::ones(N,1,CV_32F)/N; //compute normalized...
normalize(C,C); //canonical shape
if(iter > 0) { if(norm(C,Co) < ftol) break; } //converged?
Co = C.clone(); //remember current estimate
for(int i = 0; i < N; i++){
Mat R = this->rot_scale_align(P.col(i),C);
for(int j = 0; j < n; j++) { //apply similarity transform
float x = P.fl(2*j,i), y = P.fl(2*j+1,i);
P.fl(2*j ,i) = R.fl(0,0)*x + R.fl(0,1)*y;
P.fl(2*j+1,i) = R.fl(1,0)*x + R.fl(1,1)*y;
}
}
} return P; //returned procrustes aligned shapes
}
算法首先从每个形状实例的质量中心减去,然后通过一个迭代过程,交替计算规范形状,即所有形状的归一化平均值,以及旋转和缩放每个形状以最佳匹配规范形状。估计规范形状的归一化步骤是必要的,以固定问题的比例并防止所有形状缩小到零。这个锚定比例的选择是任意的;在这里,我们选择强制规范形状向量 C 的长度为 1.0,这是 OpenCV 的 normalize 函数的默认行为。通过 rot_scale_align 函数计算平面内旋转和缩放,以最佳对齐每个形状实例到当前规范形状的估计值如下:
Mat shape_model::rot_scale_align(
const Mat &src, //[x1;y1;...;xn;yn] vector of source shape
const Mat &dst) //destination shape
{
//construct linear system
int n = src.rows/2;
float a=0, b=0, d=0;
for(int i = 0; i < n; i++) {
d+= src.fl(2*i)*src.fl(2*i )+src.fl(2*i+1)*src.fl(2*i+1);
a+= src.fl(2*i)*dst.fl(2*i )+src.fl(2*i+1)*dst.fl(2*i+1);
b+= src.fl(2*i)*dst.fl(2*i+1)-src.fl(2*i+1)*dst.fl(2*i );
}
a /= d; b /= d;//solve linear system
return (Mat_<float>(2,2) << a,-b,b,a);
}
此函数最小化旋转形状和规范形状之间的以下最小二乘差异。从数学上讲,这可以写成如下:

在这里,最小二乘问题的解采用以下方程右侧所示方程的封闭形式解。请注意,我们不是求解与缩放 2D 旋转矩阵非线性相关的缩放和内平面旋转,而是求解变量(a,b)。这些变量与缩放和旋转矩阵的关系如下:

Procrustes 分析对原始标注形状数据的影响可视化如图所示。每个面部特征以独特的颜色显示。在平移归一化后,面部结构变得明显,面部特征的位置围绕其平均位置聚集。在迭代比例和旋转归一化过程之后,特征聚类变得更加紧凑,它们的分布更加代表面部变形引起的变异。这一点很重要,因为这是我们将在下一节尝试建模的变形。因此,Procrustes 分析的作用可以被视为对原始数据进行预处理操作,这将允许学习更好的面部局部变形模型:

线性形状模型
面部变形建模的目的是找到面部形状在身份和表情之间变化的紧凑参数表示。有许多方法可以实现这一目标,并且具有不同的复杂程度。其中最简单的一种是使用面部几何的线性表示。尽管它很简单,但它已被证明可以准确地捕捉面部变形的空间,尤其是在数据集中的面部大多处于正面姿态时。它还有优势,即推断其表示的参数是一个极其简单且成本极低的操作,与它的非线性对应物形成对比。这在将其部署到跟踪期间约束搜索过程时起着重要作用。
线性建模面部形状的主要思想在以下图像中得到了说明。在这里,一个由N个面部特征组成的面部形状被建模为 2N维空间中的一个单独的点。线性建模的目的是在 2N维空间中找到一个低维超平面,其中所有面部形状点都位于该超平面内(即图像中的绿色点)。由于这个超平面仅覆盖整个 2N维空间的一个子集,因此它通常被称为子空间。子空间的维度越低,面部表示就越紧凑,对跟踪过程施加的约束就越强。这通常会导致更鲁棒的跟踪。然而,在选择子空间的维度时应该小心,以确保它有足够的容量来涵盖所有面部的空间,但又不能太多以至于非面部形状位于其覆盖范围内(即图像中的红色点)。需要注意的是,当从单个人建模数据时,捕捉面部变异性的子空间通常比建模多个身份的子空间要紧凑得多。这就是为什么特定于个人的跟踪器比通用跟踪器表现更好的原因之一。

寻找最佳低维子空间以涵盖数据集的过程被称为主成分分析(PCA)。OpenCV 实现了一个用于计算 PCA 的类;然而,它需要预先指定保留的子空间维度数。由于这通常很难事先确定,一个常见的启发式方法是根据它所解释的总变异量的一部分来选择它。在shape_model::train函数中,PCA 的实现如下:
SVD svd(dY*dY.t());
int m = min(min(kmax,N-1),n-1);
float vsum = 0; for(int i = 0; i < m; i++)vsum += svd.w.fl(i);
float v = 0; int k = 0;
for(k = 0; k < m; k++){
v += svd.w.fl(k); if(v/vsum >= frac){k++; break;}
}
if(k > m)k = m;
Mat D = svd.u(Rect(0,0,k,2*n));
在这里,dY 变量的每一列表示减去均值后的 Procrustes 对齐形状。因此,奇异值分解(SVD)有效地应用于形状数据的协方差矩阵(即 dY.t()*dY)。OpenCV 的SVD类的w成员存储了数据主要变化方向上的方差,按从大到小的顺序排列。选择子空间维数的一种常见方法是选择能够保留数据总能量的一定分数 frac 的最小方向集,这由 svd.w 的条目表示。由于这些条目是按从大到小的顺序排列的,因此只需通过贪婪地评估变化性的前 k 个方向中的能量来枚举子空间选择。这些方向本身存储在 SVD 类的 u 成员中。svd.w 和 svd.u 成分通常分别被称为特征谱和特征向量。以下图显示了这两个组件的可视化:

注意,特征谱迅速下降,这表明数据中的大部分变化可以用低维子空间来建模。
结合局部-全局表示
图像帧中的形状是通过局部变形和全局变换的组合生成的。从数学上讲,这种参数化可能存在问题,因为这些变换的组合产生了一个非线性函数,该函数没有封闭形式的解。绕过这个问题的常见方法是将全局变换建模为线性子空间,并将其附加到变形子空间上。对于固定的形状,可以使用子空间来建模相似变换,如下所示:

在 shape_model 类中,这个子空间是通过 calc_rigid_basis 函数生成的。从该子空间生成的形状(即前述方程中的 x 和 y 分量)是 Procustes 对齐形状(即规范形状)的平均形状。除了以上所述的形式构建子空间外,矩阵的每一列都被归一化为单位长度。在 shape_model::train 函数中,上一节中描述的变量 dY 通过以下方式计算,即投影掉与刚性运动相关的数据分量:
Mat R = this->calc_rigid_basis(Y); //compute rigid subspace
Mat P = R.t()*Y; Mat dY = Y - R*P; //project-out rigidity
注意,这个投影是通过简单的矩阵乘法实现的。这是可能的,因为刚体子空间的列已经被长度归一化。这不会改变模型所涵盖的空间,仅仅意味着 R.t()*R 等于单位矩阵。
由于在学习变形模型之前已经从数据中移除了由刚性变换产生的可变性方向,因此得到的变形子空间将与刚性变换子空间正交。因此,将两个子空间连接起来,结果是一个面部形状的局部-全局线性表示,它也是正交归一的。这里的连接可以通过将两个子空间矩阵分配给组合子空间矩阵的子矩阵来实现,这是通过在 OpenCV 的Mat类中实现的 ROI 提取机制来完成的,如下所示:
V.create(2*n,4+k,CV_32F); //combined subspace
Mat Vr = V(Rect(0,0,4,2*n)); R.copyTo(Vr); //rigid subspace
Mat Vd = V(Rect(4,0,k,2*n)); D.copyTo(Vd); //nonrigid subspace
结果模型的正交性意味着可以轻松计算描述形状的参数,就像在shape_model::calc_params函数中所做的那样:
p = V.t()*s;
这里s是一个向量化的面部形状,而p存储表示它的面部子空间中的坐标。
关于线性建模面部形状的一个需要注意的最终点是,如何约束子空间坐标,使得使用它生成的形状仍然有效。在下面的图像中,展示了随着一个方向的可变性坐标值以四个标准差为增量增加时,位于子空间内的面部形状实例。请注意,对于较小的值,生成的形状仍然类似面部,但随着值的增大,形状会变差。

防止这种变形的简单方法是将子空间坐标值夹具在从数据集确定的允许区域内。对此的一个常见选择是在数据加减 3 个标准差内的箱型约束,这解释了数据中 99.7%的变化。这些夹具值是在找到子空间之后在shape_model::train函数中计算的,如下所示:
Mat Q = V.t()*X; //project raw data onto subspace
for(int i = 0; i < N; i++) { //normalize coordinates w.r.t scale
float v = Q.fl(0,i); Mat q = Q.col(i); q /= v;
}
e.create(4+k,1,CV_32F); multiply(Q,Q,Q);
for(int i = 0; i < 4+k; i++) {
if(i < 4) e.fl(i) = -1; //no clamping for rigid coefficients
else e.fl(i) = Q.row(i).dot(Mat::ones(1,N,CV_32F))/(N-1);
}
注意,方差是在将第一维度的坐标(即尺度)归一化后,在子空间坐标Q上计算的。这防止了具有相对较大尺度的数据样本主导估计。此外,请注意,将负值分配给刚性子空间坐标的方差(即V的前四列)。shape_model::clamp函数检查特定方向的方差是否为负,并且只有在不是负值的情况下才应用夹具,如下所示:
void shape_model::clamp(const float c) {
//clamping as fraction of standard deviation
double scale = p.fl(0); //extract scale
for(int i = 0; i < e.rows; i++) {
if(e.fl(i) < 0)continue; //ignore rigid components
float v = c*sqrt(e.fl(i)); //c*standard deviations box
if(fabs(p.fl(i)/scale) > v) { //preserve sign of coordinate
if(p.fl(i) > 0) p.fl(i) = v*scale; //positive threshold
else p.fl(i) = -v*scale; //negative threshold
}
}
}
原因在于训练数据通常是在人为设置下捕获的,其中面部在图像中垂直且居中,并且具有特定的尺度。将形状模型的刚性组件夹具到训练集中的配置将过于严格。最后,由于每个可变形坐标的方差是在尺度归一化框架中计算的,因此在夹具期间必须应用相同的缩放。
训练和可视化
可以在 train_shape_model.cpp 中找到一个从注释数据训练形状模型的示例程序。通过命令行参数 argv[1] 包含注释数据的路径,训练开始于将数据加载到内存中并移除不完整样本,如下所示:
ft_data data = load_ft<ft_data>(argv[1]);
data.rm_incomplete_samples();
然后将每个示例的注释以及可选的镜像对应物存储在一个向量中,然后再按照以下方式将它们传递给训练函数:
vector<vector<Point2f>> points;
for(int i = 0; i < int(data.points.size()); i++) {
points.push_back(data.get_points(i,false));
if(mirror)points.push_back(data.get_points(i,true));
}
然后通过一个函数调用 shape_model::train 来训练形状模型,如下所示:
shape_model smodel;
smodel.train(points,data.connections,frac,kmax);
在这里,frac(即保留变化的分数)和 kmax(即保留的最大特征向量数)可以通过命令行选项进行设置,尽管在大多数情况下,默认设置 0.95 和 20 通常效果很好。最后,通过命令行参数 argv[2] 包含保存训练好的形状模型的路径,可以通过单个函数调用执行保存,如下所示:
save_ft(argv[2],smodel);
这一步的简单性源于为 shape_model 类定义了 read 和 write 序列化函数。
为了可视化训练好的形状模型,visualize_shape_model.cpp 程序依次动画化每个方向学习到的非刚性变形。它首先按照以下方式将形状模型加载到内存中:
shape_model smodel = load_ft<shape_model>(argv[1]);
将模型放置在显示窗口中心的刚性参数如下计算:
int n = smodel.V.rows/2;
float scale = calc_scale(smodel.V.col(0),200);
float tranx =
n*150.0/smodel.V.col(2).dot(Mat::ones(2*n,1,CV_32F));
float trany =
n*150.0/smodel.V.col(3).dot(Mat::ones(2*n,1,CV_32F));
在这里,calc_scale 函数找到生成宽度为 200 像素的面部形状的缩放系数。通过找到生成 150 像素平移的系数来计算平移分量(即,模型是均值中心化的,显示窗口大小为 300x300 像素)。
注意,shape_model::V 的第一列对应于缩放,第三和第四列分别对应于 x 和 y 平移。
然后生成一个参数值轨迹,它从零开始,移动到正极限,然后移动到负极限,最后回到零,如下所示:
vector<float> val;
for(int i = 0; i < 50; i++)val.push_back(float(i)/50);
for(int i = 0; i < 50; i++)val.push_back(float(50-i)/50);
for(int i = 0; i < 50; i++)val.push_back(-float(i)/50);
for(int i = 0; i < 50; i++)val.push_back(-float(50-i)/50);
在这里,动画的每个阶段由 50 个增量组成。这个轨迹
然后用于动画化面部模型并在显示窗口中渲染结果
如下所示:
Mat img(300,300,CV_8UC3); namedWindow("shape model");
while(1) {
for(int k = 4; k < smodel.V.cols; k++){
for(int j = 0; j < int(val.size()); j++){
Mat p = Mat::zeros(smodel.V.cols,1,CV_32F);
p.at<float>(0) = scale;
p.at<float>(2) = tranx;
p.at<float>(3) = trany;
p.at<float>(k) = scale*val[j]*3.0*
sqrt(smodel.e.at<float>(k));
p.copyTo(smodel.p); img = Scalar::all(255);
vector<Point2f> q = smodel.calc_shape();
draw_shape(img,q,smodel.C);
imshow("shape model",img);
if(waitKey(10) == 'q')return 0;
}
}
}
注意,刚性系数(即对应于 shape_model::V 的前四列)始终设置为之前计算出的值,以将面部放置在显示窗口的中心。
面部特征检测器
在图像中检测面部特征与通用对象检测有很强的相似性。OpenCV 有一个用于构建通用对象检测器的复杂函数集,其中最著名的是用于其实现中已知的 Viola-Jones 面部检测器 的基于 Haar 特征检测器的级联。然而,有几个区分因素使得面部特征检测独特。这些如下:
-
精度与鲁棒性:在通用目标检测中,目标是找到图像中对象的大致位置;面部特征检测器需要给出特征位置的高度精确估计。在目标检测中,几个像素的误差被认为是无关紧要的,但在通过特征检测估计面部表情时,这可能会意味着微笑和皱眉之间的区别。
-
有限空间支持带来的歧义:通常假设在通用目标检测中,感兴趣的对象在图像中表现出足够的结构,可以可靠地区分出不包含该对象的图像区域。对于面部特征来说,这通常不是情况,因为面部特征通常具有有限的空间支持。这是因为不包含对象的图像区域可以表现出与面部特征非常相似的结构。例如,从以特征为中心的小边界框中看到的面部边缘特征,很容易与其他任何包含通过其中心的强烈边缘的图像块混淆。
-
计算复杂度:通用目标检测旨在找到图像中所有对象实例。另一方面,面部跟踪需要所有面部特征的位置,这通常在 20 到 100 个特征之间。因此,在构建一个能够实时运行的跟踪器时,能够高效地评估每个特征检测器的能力至关重要。
由于这些差异,用于面部跟踪的面部特征检测器通常是专门设计来满足这一目的的。当然,有许多通用目标检测技术被应用于面部特征检测器的例子。然而,在社区中似乎没有关于哪种表示最适合该问题的共识。
在本节中,我们将使用可能是最简单的模型之一:线性图像块来构建面部特征检测器。尽管它很简单,但只要在设计其学习过程时足够小心,我们就会看到这种表示实际上可以给出合理的面部特征位置估计,用于面部跟踪算法。此外,它们的简单性使得评估速度极快,使得实时面部跟踪成为可能。由于它们的表示形式为图像块,面部特征检测器因此被称为块模型。此模型在 patch_model 类中实现,可以在以下位置找到:
patch_model.hpp 和 patch_model.cpp 文件。以下代码片段是
patch_model 类的标题中突出其主要功能:
class patch_model{
public:
Mat P; //normalized patch
...
Mat //response map
calc_response(
const Mat &im, //image patch of search region
const bool sum2one = false); //normalize to sum-to-one?
...
void train(const vector<Mat>&images, //training image patches
const Size psize, //patch size
const float var = 1.0, //ideal response variance
const float lambda = 1e-6, //regularization weight
const float mu_init = 1e-3, //initial step size
const int nsamples = 1000, //number of samples
const bool visi = false); //visualize process?
...
};
用于检测面部特征的图像块模型存储在矩阵P中。此类中两个主要的功能是calc_response和train。calc_response函数评估搜索区域im上每个整数位移处的图像块模型的响应。train函数学习大小为psize的图像块模型P,在平均情况下,在训练集上产生的响应图与理想响应图尽可能接近。var、lambda、mu_init和nsamples是训练过程的参数,可以根据手头的数据进行调整以优化性能。
本节将详细阐述此类功能。我们首先讨论相关图像块及其训练过程,这将用于学习图像块模型。接下来,将描述patch_models类,它是一组每个面部特征的图像块模型,并具有考虑全局变换的功能。train_patch_model.cpp和visualize_patch_model.cpp程序分别训练和可视化图像块模型,其用法将在本节末尾关于面部特征检测器的部分中概述。
基于相关性的图像块模型
在学习检测器中,存在两种主要的竞争性范式:生成性和判别性。生成性方法学习图像块的下层表示,可以最好地生成对象的所有表现形态。另一方面,判别性方法学习一种表示,可以最好地区分对象的实例与其他对象,这些对象是模型在部署时可能遇到的对象。生成性方法的优势在于,所得到的模型编码了特定于对象的特征,允许对对象的全新实例进行视觉检查。属于生成性方法范式的流行方法之一是著名的Eigenfaces方法。判别性方法的优势在于,模型的全部能力直接针对手头的问题;从所有其他对象中区分对象的实例。也许最著名的判别性方法就是支持向量机。尽管这两种范式在许多情况下都可以很好地工作,但我们将看到,当将面部特征建模为图像块时,判别性范式远优于生成性范式。
注意,Eigenfaces和支持向量机方法最初是为分类而不是检测或图像对齐而开发的。然而,它们背后的数学概念已被证明适用于面部跟踪领域。
学习判别性图像块模型
给定一个标注数据集,特征检测器可以独立于彼此进行学习。判别性图像块模型的目的是构建一个图像块,当与包含面部特征的图像区域进行交叉相关时,在特征点处产生强烈的响应。从数学上讲,这可以表示为:

在这里,P表示图像块模型,I表示第 i 个训练图像,I(a:b, c:d)表示其左上角和右下角分别位于(a, c)和(b, d)的矩形区域。周期符号表示内积运算,R表示理想响应图。该方程的解是一个图像块模型,它生成的响应图在平均意义上,使用最小二乘标准测量时,最接近理想响应图。理想响应图R的一个明显选择是一个除了中心外所有地方都是零的矩阵(假设训练图像块以感兴趣的面部特征为中心)。在实践中,由于图像是手工标注的,总会存在标注错误。为了解决这个问题,通常将 R 描述为中心距离的衰减函数。一个好的选择是二维高斯分布,这相当于假设标注错误是高斯分布的。以下图显示了左外眼角的这种设置的可视化:

如前所述的学习目标通常以线性最小二乘的形式表示。因此,它提供了一个封闭形式的解。然而,这个问题的自由度;也就是说,变量可以变化以解决问题的方式的数量,等于图像块中的像素数。因此,求解最优图像块模型的计算成本和内存需求可能是制约因素,即使是对于中等大小的图像块;例如,一个 40x40 的图像块模型有 1,600 个自由度。
将学习问题作为线性方程组求解的一个有效替代方法是称为随机梯度下降的方法。通过将学习目标可视化为图像块模型自由度上的误差地形,随机梯度下降迭代地做出对地形梯度方向的近似估计,并朝相反方向迈出小步。对于我们的问题,梯度近似的计算可以通过仅考虑从训练集中随机选择的一个图像的学习目标梯度来完成:

在patch_model类中,这个学习过程在train函数中实现:
void patch_model::train(
const vector<Mat>&images, //featured centered training images
const Size psize, //desired patch model size
const float var, //variance of annotation error
const float lambda, //regularization parameter
const float mu_init, //initial step size
const int nsamples, //number of stochastic samples
const bool visi) { //visualise training process
int N = images.size(),n = psize.width*psize.height;
int dx = wsize.width-psize.width; //center of response map
int dy = wsize.height-psize.height; //...
Mat F(dy,dx,CV_32F); //ideal response map
for(int y = 0; y < dy; y++) {
float vy = (dy-1)/2 - y;
for(int x = 0; x < dx; x++) {
float vx = (dx-1)/2 - x;
F.fl(y,x) = exp(-0.5*(vx*vx+vy*vy)/var); //Gaussian
}
}
normalize(F,F,0,1,NORM_MINMAX); //normalize to [0:1] range
//allocate memory
Mat I(wsize.height,wsize.width,CV_32F);
Mat dP(psize.height,psize.width,CV_32F);
Mat O = Mat::ones(psize.height,psize.width,CV_32F)/n;
P = Mat::zeros(psize.height,psize.width,CV_32F);
//optimise using stochastic gradient descent
RNG rn(getTickCount()); //random number generator
double mu=mu_init,step=pow(1e-8/mu_init,1.0/nsamples);
for(int sample = 0; sample < nsamples; sample++){
int i = rn.uniform(0,N); //randomly sample image index
I = this->convert_image(images[i]); dP = 0.0;
for(int y = 0; y < dy; y++) { //compute stochastic gradient
for(int x = 0; x < dx; x++){
Mat Wi=I(Rect(x,y,psize.width,psize.height)).clone();
Wi -= Wi.dot(O); normalize(Wi,Wi); //normalize
dP += (F.fl(y,x) - P.dot(Wi))*Wi;
}
}
P += mu*(dP - lambda*P); //take a small step
mu *= step; //reduce step size
...
} return;
}
上一段代码中第一个突出显示的代码片段是计算理想响应图的地方。由于图像是以感兴趣的面部特征为中心的,因此响应图对所有样本都是相同的。在第二个突出显示的代码片段中,确定了步长大小的衰减率step,使得在nsamples次迭代后,步长将衰减到接近零的值。第三个突出显示的代码片段是计算随机梯度方向并用于更新补丁模型的地方。这里有两点需要注意。首先,用于训练的图像被传递到patch_model::convert_image函数,该函数将图像转换为单通道图像(如果它是彩色图像)并应用自然对数到图像像素强度:
I += 1.0; log(I,I);
在应用对数之前,每个像素都添加了一个偏置值 1,因为零的对数是未定义的。在训练图像上执行这种预处理的原因是因为对数尺度图像对对比度差异和光照条件变化更加鲁棒。以下图显示了两个面部图像,面部区域的对比度不同。在对数尺度图像中,图像之间的差异比在原始图像中要小得多。

关于更新方程的第二个需要注意的问题是减去lambda*P。这有效地限制了解的增长,这是一种在机器学习算法中常用于促进对未见数据的泛化的过程。缩放因子lambda由用户定义,通常与问题相关。然而,对于学习面部特征检测的补丁模型,通常一个较小的值效果很好。
生成性补丁模型与判别性补丁模型
尽管如前所述,判别性补丁模型可以很容易地学习,但考虑生成性补丁模型及其相应的训练机制是否足够简单以实现类似的结果是值得的。相关补丁模型的生成对应物是平均补丁。该模型的训练目标是构建一个尽可能接近通过最小二乘准则测量的所有面部特征示例的单个图像补丁:

这个问题的解决方案正好是所有特征中心化训练图像补丁的平均值。因此,从这个目标提供的解决方案来看,它要简单得多。
在以下图中,展示了通过交叉相关平均和关联补丁模型与示例图像获得的响应图比较。相应的平均和关联补丁模型也显示出来,其中像素值范围已归一化以供可视化。尽管两种补丁模型类型表现出一些相似之处,但它们生成的响应图差异很大。关联补丁模型生成的响应图在特征位置周围高度峰值,而平均补丁模型生成的响应图过于平滑,并且没有强烈地区分特征位置和附近的特征。检查补丁模型的外观,关联补丁模型主要是灰色,对应于未归一化像素范围中的零,而在面部特征显著区域周围放置了强烈的正负值。因此,它仅保留那些对区分错位配置有用的训练补丁组件,导致高度峰值响应。相比之下,平均补丁模型不编码错位数据的知识。因此,它不适合面部特征定位的任务,该任务的目的是区分对齐图像补丁与其局部平移版本:

考虑全局几何变换
到目前为止,我们假设训练图像是以面部特征为中心,并且相对于全局尺度和平移进行了归一化。在实践中,面部可以在图像中的任何尺度和平移下出现,因此在跟踪过程中。因此,必须设计一种机制来处理训练和测试条件之间的这种差异。一种方法是在部署期间预期遇到的范围内在尺度和平移上对训练图像进行人工扰动。然而,检测器作为关联补丁模型的形式过于简单,通常缺乏生成此类数据的有用响应图的能力。另一方面,关联补丁模型对尺度和平移的小扰动具有一定的鲁棒性。由于视频序列中连续帧之间的运动相对较小,可以利用前一帧中面部估计的全局变换来相对于尺度和平移归一化当前图像。要启用此过程,只需要选择一个学习关联补丁模型的参考帧。
patch_models 类存储了每个面部特征的关联补丁模型以及它们训练时的参考帧。直接与代码接口以获取特征检测的是 patch_models 类,而不是 patch_model 类。以下代码片段展示了该类的核心功能:
class patch_models {
public:
Mat reference; //reference shape [x1;y1;...;xn;yn]
vector<patch_model> patches; //patch model/facial feature
...
void train(ft_data &data, //annotated image and shape data
const vector<Point2f>&ref, //reference shape
const Size psize, //desired patch size
const Size ssize, //training search window size
const bool mirror = false, //use mirrored training data
const float var = 1.0, //variance of annotation error
const float lambda = 1e-6, //regularisation weight
const float mu_init = 1e-3, //initial step size
const int nsamples = 1000, //number of samples
const bool visi = false); //visualise training procedure?
...
vector<Point2f>//location of peak responses/feature in image
calc_peaks(
const Mat &im, //image to detect features in
const vector<Point2f>&points, //current estimate of shape
const Size ssize = Size(21,21)); //search window size
...
};
reference 形状存储为交织的 (x, y) 坐标集,这些坐标用于归一化训练图像的缩放和旋转,并在部署期间,测试图像的缩放和旋转。在 patch_models::train 函数中,这是通过首先使用 patch_models::calc_simil 函数计算给定图像的 reference 形状和标注形状之间的相似性变换来完成的,该函数解决了一个类似于 shape_model::procrustes 函数中的问题,尽管是针对一对形状。由于旋转和缩放在所有面部特征中是通用的,因此图像归一化过程只需要调整这个相似性变换,以考虑图像中每个特征的中心和归一化图像块的中心。在 patch_models::train 中,这是通过以下方式实现的:
Mat S = this->calc_simil(pt),A(2,3,CV_32F);
A.fl(0,0) = S.fl(0,0); A.fl(0,1) = S.fl(0,1);
A.fl(1,0) = S.fl(1,0); A.fl(1,1) = S.fl(1,1);
A.fl(0,2) = pt.fl(2*i ) - (A.fl(0,0)*(wsize.width -1)/2 +
A.fl(0,1)*(wsize.height-1)/2);
A.fl(1,2) = pt.fl(2*i+1) - (A.fl(1,0)*(wsize.width -1)/2 +
A.fl(1,1)*(wsize.height-1)/2);
Mat I; warpAffine(im,I,A,wsize,INTER_LINEAR+WARP_INVERSE_MAP);
在这里,wsize 是归一化训练图像的总大小,它是补丁大小和搜索区域大小的总和。正如刚才提到的,从参考形状到标注形状 pt 的相似性变换的左上角(2x2)块,对应于变换的缩放和旋转组件,被保留在传递给 OpenCV 的 warpAffine 函数的仿射变换中。仿射变换 A 的最后一列是一个调整,它将在扭曲后使第 i 个面部特征位置在归一化图像中居中(即归一化平移)。最后,cv::warpAffine 函数具有默认的从图像到参考框架的扭曲设置。由于相似性变换是为了将 reference 形状转换到图像空间标注而计算的,因此需要设置 WARP_INVERSE_MAP 标志以确保函数按所需方向应用扭曲。在 patch_models::calc_peaks 函数中也执行了完全相同的程序,额外的步骤是重新使用在图像框架中参考形状和当前形状之间计算的相似性变换来反归一化检测到的面部特征,将它们适当地放置在图像中:
vector<Point2f>
patch_models::calc_peaks(const Mat &im,
const vector<Point2f>&points,const Size ssize){
int n = points.size(); assert(n == int(patches.size()));
Mat pt = Mat(points).reshape(1,2*n);
Mat S = this->calc_simil(pt);
Mat Si = this->inv_simil(S);
vector<Point2f> pts = this->apply_simil(Si,points);
for(int i = 0; i < n; i++){
Size wsize = ssize + patches[i].patch_size();
Mat A(2,3,CV_32F),I;
A.fl(0,0) = S.fl(0,0); A.fl(0,1) = S.fl(0,1);
A.fl(1,0) = S.fl(1,0); A.fl(1,1) = S.fl(1,1);
A.fl(0,2) = pt.fl(2*i ) - (A.fl(0,0)*(wsize.width -1)/2 +
A.fl(0,1)*(wsize.height-1)/2);
A.fl(1,2) = pt.fl(2*i+1) - (A.fl(1,0)*(wsize.width -1)/2 +
A.fl(1,1)*(wsize.height-1)/2);
warpAffine(im,I,A,wsize,INTER_LINEAR+WARP_INVERSE_MAP);
Mat R = patches[i].calc_response(I,false);
Point maxLoc; minMaxLoc(R,0,0,0,&maxLoc);
pts[i] = Point2f(pts[i].x + maxLoc.x - 0.5*ssize.width,
pts[i].y + maxLoc.y - 0.5*ssize.height);
} return this->apply_simil(S,pts);
在前面代码中第一个高亮的代码片段中,计算了前向和逆相似性变换。这里需要逆变换的原因是为了根据当前形状估计的归一化位置调整每个特征的响应图峰值。这必须在重新应用相似性变换,将面部特征位置的新估计放回图像框架之前完成。
patch_models::apply_simil 函数。
训练和可视化
从注释数据中训练补丁模型的示例程序可以在 train_patch_model.cpp 中找到。通过命令行参数 argv[1] 包含注释数据的路径,训练开始于将数据加载到内存中并移除不完整样本:
ft_data data = load_ft<ft_data>(argv[1]);
data.rm_incomplete_samples();
在 patch_models 类中,参考形状的最简单选择是训练集的平均形状,缩放到期望的大小。假设已经为该数据集训练了一个形状模型,则通过以下方式计算参考形状:首先加载存储在 argv[2] 中的形状模型:
shape_model smodel = load_ft<shape_model>(argv[2]);
这之后是计算缩放中心平均形状:
smodel.p = Scalar::all(0.0);
smodel.p.fl(0) = calc_scale(smodel.V.col(0),width);
vector<Point2f> r = smodel.calc_shape();
calc_scale 函数计算缩放因子,将平均形状(即 shape_model::V 的第一列)转换为宽度为 width 的形状。一旦定义了参考形状 r,就可以通过单个函数调用来训练补丁模型集:
patch_models pmodel;
pmodel.train(data,r,Size(psize,psize),Size(ssize,ssize));
参数 width、psize 和 ssize 的最佳选择取决于应用;然而,通常情况下,默认值 100、11 和 11 分别可以给出合理的结果。
尽管训练过程相当简单,但仍可能需要一些时间来完成。根据面部特征的数量、补丁的大小以及优化算法中的随机样本数量,训练过程可能需要几分钟到超过一小时。然而,由于每个补丁的训练可以独立于所有其他补丁进行,这个过程可以通过在多个处理器核心或机器上并行化训练过程来显著加速。
通过在多个处理器核心或机器上并行化训练过程,可以显著提高训练过程的效率。
一旦完成训练,就可以使用 visualize_patch_model.cpp 中的程序来可视化结果补丁模型。与 visualize_shape_model.cpp 程序一样,这里的目的是通过视觉检查结果来验证训练过程中是否出现了错误。程序生成所有补丁模型的合成图像,patch_model::P,每个都在参考形状 patch_models::reference 的相应特征位置中心,并显示围绕当前活动索引的补丁的边界矩形。使用 cv::waitKey 函数获取用户输入以选择活动补丁索引并终止程序。以下图像显示了为具有不同空间支持的补丁模型学习到的三个合成补丁图像示例。尽管使用了相同的训练数据,但修改补丁模型的空间支持似乎显著改变了补丁模型的结构。以这种方式检查结果可以提供对如何修改训练过程的参数,甚至整个训练过程本身,以优化特定应用的直觉:

面部检测和初始化
到目前为止所描述的人脸跟踪方法假设图像中的面部特征位于当前估计的合理距离内。尽管在跟踪期间这个假设是合理的,因为帧与帧之间的面部运动通常很小,但我们仍然面临着如何在序列的第一帧中初始化模型的两难困境。对于这个选择,一个明显的选择是使用 OpenCV 的内置级联检测器来找到面部。然而,模型在检测到的边界框内的放置将取决于跟踪的面部特征的选择。遵循本章中迄今为止所遵循的数据驱动范式,一个简单的解决方案是学习人脸检测边界框与面部特征之间的几何关系。
face_detector类实现了这个解决方案。以下是其声明的片段,突出了其功能:
class face_detector{ //face detector for initialisation
public:
string detector_fname; //file containing cascade classifier
Vec3f detector_offset; //offset from center of detection
Mat reference; //reference shape
CascadeClassifier detector; //face detector
vector<Point2f> //points describing detected face in image
detect(const Mat &im, //image containing face
const float scaleFactor = 1.1,//scale increment
const int minNeighbours = 2, //minimum neighborhood size
const Size minSize = Size(30,30));//minimum window size
void train(ft_data &data, //training data
const string fname, //cascade detector
const Mat &ref, //reference shape
const bool mirror = false, //mirror data?
const bool visi = false, //visualize training?
const float frac = 0.8, //fraction of points in detection
const float scaleFactor = 1.1, //scale increment
const int minNeighbours = 2, //minimum neighbourhood size
const Size minSize = Size(30,30)); //minimum window size
...
};
该类有四个公共成员变量:一个名为detector_fname的cv::CascadeClassifier类型对象的路径,一个从检测边界框到图像中面部位置和缩放的偏移量集detector_offset,一个放置在边界框中的参考形状reference,以及一个面部检测器detector。对面部跟踪系统最有用的主要功能是face_detector::detect,它接受一个图像作为输入,以及cv::CascadeClassifier类的标准选项,并返回图像中面部特征位置的粗略估计。其实现如下:
Mat gray; //convert image to grayscale and histogram equalize
if(im.channels() == 1) gray = im;
else cvtColor(im,gray,CV_RGB2GRAY);
Mat eqIm; equalizeHist(gray,eqIm);
vector<Rect> faces; //detect largest face in image
detector.detectMultiScale(eqIm,faces,scaleFactor, minNeighbours,0
|CV_HAAR_FIND_BIGGEST_OBJECT
|CV_HAAR_SCALE_IMAGE,minSize);
if(faces.size() < 1) { return vector<Point2f>(); }
Rect R = faces[0]; Vec3f scale = detector_offset*R.width;
int n = reference.rows/2; vector<Point2f> p(n);
for(int i = 0; i < n; i++){ //predict face placement
p[i].x = scale[2]*reference.fl(2*i ) + R.x + 0.5 * R.width +
scale[0];
p[i].y = scale[2]*reference.fl(2*i+1) + R.y + 0.5 * R.height +
scale[1];
} return p;
人脸以通常的方式在图像中被检测到,除了设置了CV_HAAR_FIND_BIGGEST_OBJECT标志,以便能够跟踪图像中最突出的面部。高亮显示的代码是按照检测到的面部边界框将参考形状放置在图像中的位置。detector_offset成员变量由三个部分组成:人脸中心与检测边界框中心的(x, y)偏移量,以及将参考形状调整大小以最佳匹配图像中人脸的缩放因子。所有这三个部分都是边界框宽度的线性函数。
在face_detector::train函数中,从标注的数据集中学习边界框宽度与detector_offset变量之间的线性关系。学习过程通过将训练数据加载到内存中并分配参考形状来启动:
detector.load(fname.c_str()); detector_fname = fname; reference = ref.clone();
与patch_models类中的参考形状一样,对于参考形状的一个方便的选择是数据集中归一化的平均人脸形状。然后,cv::CascadeClassifier被应用于数据集中的每一张图像(以及可选的镜像图像),并对检测结果进行检查,以确保足够的标注点位于检测到的边界框内(参见本节末尾的图),以防止从误检中学习:
if(this->enough_bounded_points(pt,faces[0],frac)){
Point2f center = this->center_of_mass(pt);
float w = faces[0].width;
xoffset.push_back((center.x -
(faces[0].x+0.5*faces[0].width ))/w);
yoffset.push_back((center.y -
(faces[0].y+0.5*faces[0].height))/w);
zoffset.push_back(this->calc_scale(pt)/w);
}
如果超过frac分数的注释点位于边界框内,则将该图像的宽度和偏移参数之间的线性关系添加为 STL vector类对象的新条目。在这里,face_detector::center_of_mass函数计算该图像注释点集的质量中心,而face_detector::calc_scale函数计算将参考形状转换为居中注释形状的缩放因子。一旦处理完所有图像,detector_offset变量被设置为所有图像特定偏移的中位数:
Mat X = Mat(xoffset),Xsort,Y = Mat(yoffset),Ysort,Z =
Mat(zoffset),Zsort;
cv::sort(X,Xsort,CV_SORT_EVERY_COLUMN|CV_SORT_ASCENDING);
int nx = Xsort.rows;
cv::sort(Y,Ysort,CV_SORT_EVERY_COLUMN|CV_SORT_ASCENDING);
int ny = Ysort.rows;
cv::sort(Z,Zsort,CV_SORT_EVERY_COLUMN|CV_SORT_ASCENDING);
int nz = Zsort.rows;
detector_offset =
Vec3f(Xsort.fl(nx/2),Ysort.fl(ny/2),Zsort.fl(nz/2));
与形状和补丁模型一样,train_face_detector.cpp中的简单程序是构建和保存face_detector对象以供跟踪器后续使用的示例。它首先加载注释数据和形状模型,并将参考形状设置为训练数据的均值中心平均(即shape_model类的身份形状):
ft_data data = load_ft<ft_data>(argv[2]);
shape_model smodel = load_ft<shape_model>(argv[3]);
smodel.set_identity_params();
vector<Point2f> r = smodel.calc_shape();
Mat ref = Mat(r).reshape(1,2*r.size());
因此,训练和保存面部检测器包括两个函数调用:
face_detector detector;
detector.train(data,argv[1],ref,mirror,true,frac);
save_ft<face_detector>(argv[4],detector);
为了测试形状放置过程的性能,visualize_face_detector.cpp程序对视频或相机输入流中的每张图像调用face_detector::detect函数,并在屏幕上绘制结果。以下图显示了使用这种方法的结果示例。尽管放置的形状与图像中的个体不匹配,但其放置足够接近,可以使用下节描述的方法进行面部跟踪:

面部跟踪
面部跟踪问题可以表述为寻找一种高效且鲁棒的方法,将各种面部特征的独立检测与它们所表现出的几何依赖性结合起来,以便对序列中每张图像的面部特征位置进行准确估计。考虑到这一点,也许值得考虑几何依赖性是否真的必要。在下图中,显示了带有和不带有几何约束的面部特征检测结果。这些结果清楚地突出了捕捉面部特征之间空间相互依赖性的好处。这两种方法的相对性能是典型的,完全依赖检测会导致过度嘈杂的解决方案。原因在于,对于每个面部特征的响应图不能期望总是峰值出现在正确的位置。无论是因为图像噪声、光照变化还是表情变化,克服面部特征检测器局限性的唯一方法是通过利用它们之间共享的几何关系:

将面部几何形状纳入跟踪过程的一种特别简单但出奇有效的方法是将特征检测的输出投影到线性形状模型的子空间。这相当于最小化原始点与其在子空间上最接近的合理形状之间的距离。因此,当特征检测中的空间噪声接近高斯分布时,投影会产生最可能的解。在实践中,检测错误的分布有时并不遵循高斯分布,需要引入额外的机制来解释这一点。
面部跟踪实现
面部跟踪算法的实现可以在face_tracker类中找到(参见face_tracker.cpp和face_tracker.hpp)。以下代码是该头文件的一个片段,突出了其主要功能:
class face_tracker{
public:
bool tracking; //are we in tracking mode?
fps_timer timer; //frames/second timer
vector<Point2f> points; //current tracked points
face_detector detector; //detector for initialisation
shape_model smodel; //shape model
patch_models pmodel; //feature detectors
face_tracker(){tracking = false;}
int //0 = failure
track(const Mat &im, //image containing face
const face_tracker_params &p = //fitting parameters
face_tracker_params()); //default tracking parameters
void
reset(){
//reset tracker
tracking = false; timer.reset();
}
...
protected:
...
vector<Point2f> //points for fitted face in image
fit(const Mat &image,//image containing face
const vector<Point2f>&init, //initial point estimates
const Size ssize = Size(21,21),//search region size
const bool robust = false, //use robust fitting?
const int itol = 10, //maximum number of iterations
const float ftol = 1e-3); //convergence tolerance
};
该类具有shape_model、patch_models和face_detector类的公共成员实例。它使用这三个类的功能来实现跟踪。timer变量是fps_timer类的实例,用于跟踪face_tracker::track函数被调用的帧率,这对于分析补丁和形状模型配置对算法计算复杂性的影响是有用的。tracking成员变量是一个标志,用于指示跟踪过程的状态。当此标志设置为false时,如构造函数和face_tracker::reset函数中所示,跟踪器进入检测模式,其中face_detector::detect函数应用于下一个传入的图像以初始化模型。在跟踪模式下,用于推断下一个传入图像中面部特征位置的初始估计只是它们在上一帧中的位置。完整的跟踪算法简单地如下实现:
int face_tracker::
track(const Mat &im,const face_tracker_params &p) {
Mat gray; //convert image to grayscale
if(im.channels()==1) gray=im;
else cvtColor(im,gray,CV_RGB2GRAY);
if(!tracking) //initialize
points = detector.detect(gray,p.scaleFactor,
p.minNeighbours,p.minSize);
if((int)points.size() != smodel.npts()) return 0;
for(int level = 0; level < int(p.ssize.size()); level++)
points = this->fit(gray,points,p.ssize[level],
p.robust,p.itol,p.ftol);
tracking = true; timer.increment(); return 1;
}
除了簿记操作,例如设置适当的跟踪状态和增加跟踪时间之外,跟踪算法的核心是多级拟合过程,这在前面代码片段中已突出显示。拟合算法在face_tracker::fit函数中实现,并多次应用,使用存储在face_tracker_params::ssize中的不同搜索窗口大小,其中前一个阶段的输出用作下一个阶段的输入。在其最简单设置中,face_tracker_params::ssize函数在图像中当前形状估计的周围执行面部特征检测:
smodel.calc_params(init);
vector<Point2f> pts = smodel.calc_shape();
vector<Point2f> peaks = pmodel.calc_peaks(image,pts,ssize);
它还将结果投影到面部形状的子空间:
smodel.calc_params(peaks);
pts = smodel.calc_shape();
为了处理面部特征检测位置中的粗大异常值,可以使用鲁棒模型拟合过程而不是简单的投影,通过将robust标志设置为true来实现。然而,在实践中,当使用衰减的搜索窗口大小时(即,在face_tracker_params::ssize中设置),这通常是不必要的,因为粗大异常值通常远离其在投影形状中的对应点,并且可能位于拟合过程下一级的搜索区域之外。因此,搜索区域大小减少的速率充当了一个增量异常值拒绝方案。
训练和可视化
与本章中详细描述的其他类别不同,训练face_tracker对象不涉及任何学习过程。它简单地通过train_face_tracker.cpp实现:
face_tracker tracker;
tracker.smodel = load_ft<shape_model>(argv[1]);
tracker.pmodel = load_ft<patch_models>(argv[2]);
tracker.detector = load_ft<face_detector>(argv[3]);
save_ft<face_tracker>(argv[4],tracker);
在这里,arg[1]到argv[4]包含shape_model、patch_model、face_detector和face_tracker对象的路径。visualize_face_tracker.cpp中面部跟踪器的可视化同样简单。通过cv::VideoCapture类从摄像头或视频文件中获取输入图像流,程序简单地循环直到流结束或用户按下 Q 键,跟踪每个进入的帧。用户还可以通过在任何时候按下 D 键来重置跟踪器。
通用模型与个人特定模型
在训练和跟踪过程中有许多变量可以调整以优化特定应用的性能。然而,跟踪质量的一个主要决定因素是跟踪器需要建模的形状和外观变化范围。作为一个例子,考虑通用模型与个人特定模型的情况。通用模型是使用来自多个身份、表情、光照条件和其他变化来源的标注数据进行训练的。相比之下,个人特定模型是专门为单个个体训练的。因此,它需要考虑的变化量要小得多。因此,个人特定跟踪通常比其通用对应物更准确,差异很大。
以下图像展示了这一点的示例。在这里,通用的模型是使用 MUCT 数据集进行训练的。个人特定的模型是通过使用本章前面描述的标注工具生成数据学习得到的。结果清楚地表明,个人特定的模型提供了显著更好的跟踪效果,能够捕捉复杂的表情和头部姿态变化,而通用模型甚至在一些简单的表情上似乎也显得有些吃力:

应该注意的是,本章中描述的面部跟踪方法是基础的方法,旨在突出大多数非刚性面部跟踪算法中使用的各种组件。为了补救众多方法
一些方法的缺点超出了本书的范围
需要专门的数学工具,而这些工具目前尚未得到 OpenCV 功能的支持。相对较少的商业级面部追踪软件
可用的包证明了这个问题在一般情况下的难度。
设置。尽管如此,本章中描述的简单方法在受限环境中可以非常出色地工作。
摘要
在本章中,我们构建了一个简单的面部追踪器,它仅使用适度的数学工具和 OpenCV 的基本图像处理和线性代数运算功能,在受限环境中可以合理工作。通过在每个追踪器的三个组成部分(形状模型、特征检测器和拟合算法)中采用更复杂的技术,可以改进这个简单的追踪器。本节中描述的追踪器的模块化设计应允许这三个组件被修改,而不会对其他组件的功能造成重大干扰。
参考文献
- 普鲁克鲁斯特问题,Gower, John C. 和 Dijksterhuis, Garmt B, 牛津大学出版社,2004.
第五章:使用 AAM 和 POSIT 进行 3D 头部姿态估计
一个好的计算机视觉算法不能没有强大的、鲁棒的特性,以及广泛的泛化能力和坚实的数学基础。所有这些特性都伴随着 Timothy Cootes 主要开发的活动外观模型的工作。本章将教你如何使用 OpenCV 创建自己的活动外观模型(AAM),以及如何使用它来搜索模型在给定帧中的最接近位置。此外,你将学习如何使用 POSIT 算法以及如何将你的 3D 模型拟合到姿态图像中。有了所有这些工具,你将能够实时跟踪视频中的 3D 模型——这不是很棒吗?尽管示例侧重于头部姿态,但实际上任何可变形模型都可以使用相同的方法。
本章将涵盖以下主题:
-
活动外观模型概述
-
活动外观模型概述
-
模型实例化--与活动外观模型玩耍
-
AAM 搜索和拟合
-
POSIT
以下列表解释了你在本章中会遇到的一些术语:
-
活动外观模型(AAM):这是一个包含其形状和纹理统计信息的对象模型。它是从对象中捕获形状和纹理变化的一种强大方式。
-
活动形状模型(ASM):这是一个对象的形状的统计模型。它对于学习形状变化非常有用。
-
主成分分析(PCA):这是一种正交线性变换,将数据转换到新的坐标系中,使得数据通过任何投影的最大方差都落在第一个坐标(称为第一个主成分)上,第二个最大方差落在第二个坐标上,依此类推。这个程序通常用于降维。在降低原始问题的维度时,可以使用更快的拟合算法。
-
Delaunay 三角剖分(DT):对于平面上一组P个点,它是一种三角剖分,使得P中的任何点都不在三角剖分中任何三角形的外接圆内。它倾向于避免瘦三角形。这种三角剖分对于纹理映射是必需的。
-
仿射变换:这是任何可以用矩阵乘法后跟向量加法表示的变换。这可以用于纹理映射。
-
从正写法和迭代缩放中提取姿态(POSIT):这是一个进行 3D 姿态估计的计算机视觉算法。
活动外观模型概述
简而言之,活动外观模型是结合纹理和形状的优良模型参数化,与一个高效的搜索算法相结合,该算法可以精确地告诉模型在图片框架中的位置和姿态。为了做到这一点,我们将从“活动形状模型”部分开始,并看到它们与地标位置更为紧密相关。主成分分析和一些实践经验将在以下部分中更好地描述。然后,我们将能够利用 OpenCV 的 Delaunay 函数和了解一些三角剖分。从那以后,我们将发展到在三角形纹理变形部分应用分片仿射变形,在那里我们可以从物体的纹理中获取信息。
当我们收集到足够的背景来构建一个好的模型时,我们可以在模型实例化部分玩转这些技术。然后,我们可以通过 AAM 搜索和拟合来解决逆问题。这些本身就是非常有用的算法,用于 2D 甚至可能是 3D 图像匹配。然而,当一个人能够让它工作的时候,为什么不将其与POSIT(通过迭代进行正射投影和缩放姿态)联系起来,这是另一个用于 3D 模型拟合的稳固算法呢?深入研究 POSIT 部分将为我们提供足够的信息,以便在 OpenCV 中使用它,你将在以下部分中学习如何将其与头模型结合。这样,我们可以使用 3D 模型来拟合已经匹配的 2D 框架。如果你想知道这将把我们带到哪里,那只是将 AAM 和 POSIT 逐帧结合,以获得变形模型的实时 3D 检测跟踪!这些细节将在从网络摄像头或视频文件进行跟踪的部分中介绍。
据说“一图胜千言”;想象一下如果我们得到N张图片。这样,我们之前提到的内容在以下截图中将很容易追踪:

章节算法概述
给定一张图像(前一张截图中的左上角图像),我们可以使用活动外观搜索算法来找到人头部的 2D 姿态。截图右上角的图像显示了用于搜索算法的先前训练好的活动外观模型。找到姿态后,可以使用 POSIT 来扩展结果到 3D 姿态。如果将此过程应用于视频序列,将获得检测的 3D 跟踪。
活动形状模型
如前所述,AAMs 需要形状模型,而这个角色由主动形状模型(ASMs)扮演。在接下来的章节中,我们将创建一个 ASM,它是一个形状变化的统计模型。形状模型是通过形状变化的组合生成的。需要一组标记的图像训练集,如 Timothy Cootes 在文章《主动形状模型--它们的训练和应用》中所述。为了构建人脸形状模型,需要几幅在人脸关键位置标记点的图像来描绘主要特征。以下截图显示了这样一个例子:

人脸上有 76 个标记点,这些点来自MUCT数据集。这些标记点通常手动标记,它们描绘了几个面部特征,如嘴巴轮廓、鼻子、眼睛、眉毛和面部形状,因为它们更容易追踪。
Procrustes 分析:一种用于分析形状集分布的统计形状分析方法。Procrustes 叠加通过最优地平移、旋转和均匀缩放对象来实现。
如果我们有前面提到的图像集,我们可以生成形状变化的统计模型。由于物体上的标记点描述了该物体的形状,我们将首先使用 Procrustes 分析将所有点集对齐到一个坐标系中,如果需要的话,然后通过一个向量x来表示每个形状。然后,我们将对数据进行主成分分析。然后我们可以使用以下公式来近似任何示例:
x = x + Ps bs
在前面的公式中,x是平均形状,Ps是一组正交变化模式,bs是一组形状参数。好吧,为了更好地理解这一点,我们将在本节的其余部分创建一个简单的应用程序,它将向我们展示如何处理 PCA 和形状模型。
为什么一定要使用 PCA 呢?因为 PCA 在减少我们模型参数数量时将真正帮助我们。我们还将看到,在本章稍后搜索给定图像时它能帮我们多少。以下是对 PCA 的描述(en.wikipedia.org/wiki/Principal_component_analysis):
主成分分析(PCA)可以为用户提供一个低维度的图像,从其(在某种意义上)最有信息量的视角看,这是该对象的一个“影子”。这是通过仅使用前几个主成分来实现的,从而降低了变换数据的维度。
当我们看到以下图时,这一点变得清晰:

前面的图显示了以(2,3)为中心的多变量高斯分布的 PCA。显示的向量是协方差矩阵的特向量,它们的尾巴被移到了均值处。
这样,如果我们想用一个单参数来表示我们的模型,从指向截图右上角的方向的特向量取方向会是一个好主意。此外,通过稍微改变参数,我们可以外推数据并获得类似我们正在寻找的值。
了解 PCA
为了了解 PCA 如何帮助我们构建面部模型,我们将从一个主动形状模型开始,并测试一些参数。
由于面部检测和跟踪已经被研究了一段时间,因此在线上可以找到几个用于研究目的的面部数据库。我们将使用 IMM 数据库中的几个样本。
首先,让我们了解 PCA 类在 OpenCV 中的工作原理。我们可以从文档中得出结论,PCA 类用于计算一组向量的特殊基,这些基是来自输入向量集的协方差矩阵的主元向量。此类还可以使用投影和反向投影方法将向量转换到新坐标系中。这个新坐标系可以通过仅取其前几个分量来相当准确地近似。这意味着,我们可以用一个由投影向量在子空间中的坐标组成的更短的向量来表示来自高维空间的原向量。
由于我们希望用几个标量值来参数化,我们将主要使用类中的反向投影方法。它接受投影向量的主成分坐标并重建原始向量。如果我们保留了所有分量,我们可以检索原始向量,但如果只使用几个分量,差异将非常小;这就是使用 PCA 的原因之一。由于我们希望在原始向量周围有一些可变性,我们的参数化标量将能够外推原始数据。
此外,PCA 类可以将向量转换到由基定义的新坐标系中,并从该坐标系转换回来。从数学上讲,这意味着我们计算向量的投影,该投影是协方差矩阵的主元向量形成的子空间,正如文档中所示。
我们的方法是将我们的面部图像标注上地标,从而得到用于我们点分布模型(PDM)的训练集。如果我们有两个维度的k个对齐地标,我们的形状描述将看起来像这样:
X = { x1, y1, x2, y2, ..., xk, yk}
需要注意的是,我们需要对所有图像样本进行一致的标注。例如,如果第一幅图中嘴巴的左侧是地标编号3,那么在其他所有图像中它也必须是编号3。
这些特征点的序列将现在形成形状轮廓,给定的训练形状可以定义为向量。我们通常假设在这个空间中这种散布是高斯分布的,我们使用 PCA 来计算所有训练形状协方差矩阵的归一化特征向量和特征值。使用顶部的中心特征向量,我们将创建一个2k * m维度的矩阵,我们将称之为P。这样,每个特征向量描述了沿集合的变异的主模式。
现在,我们可以通过以下方程定义一个新的形状:
X' = X' + Pb
在这里,X'是所有训练图像的平均形状——我们只是平均每个特征点——而b是每个主成分的缩放值向量。这导致我们创建一个新的形状,通过修改b的值来实现。通常将b设置为在三个标准差内变化,以便生成的形状可以落在训练集内。
以下截图显示了三张不同图片的点标注嘴部特征点:

如前一个截图所示,形状是通过其特征点序列来描述的。可以使用像GIMP或ImageJ这样的程序,也可以在 OpenCV 中构建一个简单的应用程序来标注训练图像。我们假设用户已经完成了这个过程,并将点保存为所有训练图像的x和y特征点位置的序列,这些序列将被用于我们的 PCA 分析。然后,我们将在这个文件的第一个行添加两个参数,即训练图像的数量和读取列的数量。因此,对于k个 2D 点,这个数字将是2k*。
在以下数据中,我们有一个通过标注 IMM 数据库中的三个图像获得的该文件实例,其中k等于 5:
3 10
265 311 303 321 337 310 302 298 265 311
255 315 305 337 346 316 305 309 255 315
262 316 303 342 332 315 298 299 262 316
现在我们已经标注了图像,让我们将此数据转换为我们的形状模型。首先,将此数据加载到矩阵中。这将通过loadPCA函数实现。以下代码片段显示了loadPCA函数的使用:
PCA loadPCA(char* fileName, int& rows, int& cols,Mat& pcaset){
FILE* in = fopen(fileName,"r");
int a;
fscanf(in,"%d%d",&rows,&cols);
pcaset = Mat::eye(rows,cols,CV_64F);
int i,j;
for(i=0;i<rows;i++){
for(j=0;j<cols;j++){
fscanf(in,"%d",&a);
pcaset.at<double>(i,j) = a;
}
}
PCA pca(pcaset, // pass the data
Mat(), // we do not have a pre-computed mean vector,
// so let the PCA engine compute it
CV_PCA_DATA_AS_ROW, // indicate that the vectors
// are stored as matrix rows
// (use CV_PCA_DATA_AS_COL if the vectors are
// the matrix columns)
pcaset.cols// specify, how many principal components to retain
);
return pca;
}
注意,我们的矩阵是在pcaset = Mat::eye(rows,cols,CV_64F)这一行创建的,并且为2k*个值分配了足够的空间。在两个for循环将数据加载到矩阵之后,我们使用数据、一个空矩阵(如果愿意,可以是我们预先计算的平均向量)调用 PCA 构造函数,如果只想计算一次。我们还指出,我们的向量将被存储为矩阵行,并且我们希望保持与成分数量相同的给定行数,尽管我们也可以只使用少数几个。
现在我们已经用我们的训练集填充了 PCA 对象,它拥有了根据参数回投影形状所需的一切。我们通过调用PCA.backproject,将参数作为行向量传递,并将回投影向量作为第二个参数接收来实现这一点:


两个之前的截图显示了根据从滑块选择的参数选择的两个不同的形状配置。黄色和绿色的形状表示训练数据,而红色形状反映了从所选参数生成的形状。可以使用一个示例程序来实验 Active Shape Models,因为它允许用户尝试为模型尝试不同的参数。可以注意到,通过滑动条仅改变前两个标量值(对应于第一和第二种变化模式),我们可以得到一个非常接近训练形状的形状。这种可变性将帮助我们寻找 AAM 中的模型,因为它提供了插值形状。我们将在以下章节中讨论三角剖分、纹理、AAM 和 AAM 搜索。
三角剖分
由于我们寻找的形状可能会发生扭曲,例如开口的嘴巴,我们需要将我们的纹理映射回一个平均形状,然后对这种归一化纹理应用 PCA。为了做到这一点,我们将使用三角剖分。这个概念非常简单:我们将创建包含我们的标注点的三角形,然后从一个三角形映射到另一个三角形。OpenCV 附带一个名为Subdiv2D的便捷类,用于处理 Delaunay 三角剖分。你可以将其视为一种好的三角剖分,它将避免瘦三角形。
在数学和计算几何中,对于平面上点集P的 Delaunay 三角剖分是一个三角剖分 DT(P),其中P中的任何点都不在 DT(P)中任何三角形的外接圆内。Delaunay 三角剖分最大化了三角剖分中所有三角形的最小角度;它们倾向于避免瘦三角形。这个三角剖分是以 Boris Delaunay 的名字命名的,因为他从 1934 年开始在这个领域的工作。
在创建了一个 Delaunay 细分之后,将使用insert成员函数将点填充到细分中。以下代码行将阐明直接使用三角剖分会是什么样的:
Subdiv2D* subdiv;
CvRect rect = { 0, 0, 640, 480 };
subdiv = new Subdiv2D(rect);
std::vector<CvPoint> points;
//initialize points somehow
...
//iterate through points inserting them in the subdivision
for(int i=0;i<points.size();i++){
float x = points.at(i).x;
float y = points.at(i).y;
Point2f fp(x, y);
subdiv->insert(fp);
}
注意,我们的点将位于一个矩形框架内,该框架作为参数传递给Subdiv2D。为了创建一个细分,我们需要实例化Subdiv2D类,如前所述。然后,为了创建三角剖分,我们需要使用Subdiv2D的insert方法插入点。这发生在前述代码中的for循环内。注意,点应该已经初始化,因为它们是我们通常用作输入的点。以下图显示了三角剖分可能的样子:

此图是前述代码对一组使用 Delaunay 算法生成三角剖分的点的输出。
为了遍历给定细分中的所有三角形,可以使用以下代码:
vector<Vec6f> triangleList;
subdiv->getTriangleList(triangleList);
vector<Point> pt(3);
for( size_t i = 0; i < triangleList.size(); i++ )
{
Vec6f t = triangleList[i];
pt[0] = Point(cvRound(t[0]), cvRound(t[1]));
pt[1] = Point(cvRound(t[2]), cvRound(t[3]));
pt[2] = Point(cvRound(t[4]), cvRound(t[5]));
}
给定一个细分,我们将通过一个Vec6f向量初始化其triangleList,这将为每组三个点节省空间,可以通过遍历triangleList获得,如前述for循环所示。
三角纹理扭曲
现在我们已经能够遍历细分三角形的各个部分,我们能够将原始标注图像中的一个三角形扭曲成生成的扭曲图像。这对于将纹理从原始形状映射到扭曲形状非常有用。以下代码片段将指导这个过程:
void warpTextureFromTriangle(Point2f srcTri[3], Mat originalImage, Point2f dstTri[3], Mat warp_final){
Mat warp_mat(2, 3, CV_32FC1);
Mat warp_dst, warp_mask;
CvPoint trianglePoints[3];
trianglePoints[0] = dstTri[0];
trianglePoints[1] = dstTri[1];
trianglePoints[2] = dstTri[2];
warp_dst = Mat::zeros(originalImage.rows, originalImage.cols, originalImage.type());
warp_mask = Mat::zeros(originalImage.rows, originalImage.cols, originalImage.type());
/// Get the Affine Transform
warp_mat = getAffineTransform(srcTri, dstTri);
/// Apply the Affine Transform to the src image
warpAffine(originalImage, warp_dst, warp_mat, warp_dst.size());
cvFillConvexPoly(new IplImage(warp_mask), trianglePoints, 3, CV_RGB(255,255,255), CV_AA, 0);
warp_dst.copyTo(warp_final, warp_mask);
}
上述代码假设我们已经有三角形的顶点打包在srcTri数组中,目标顶点打包在dstTri数组中。2x3 的warp_mat矩阵用于从源三角形到目标三角形的仿射变换。更多信息可以参考 OpenCV 的cvGetAffineTransform文档:
cvGetAffineTransform函数以以下方式计算仿射变换的矩阵:

在前述方程中,目标(i)等于(xi',yi'),源(i)等于(xi, yi),而i等于0, 1, 2。
在检索到仿射矩阵后,我们可以将仿射变换应用于源图像。这是通过warpAffine函数完成的。由于我们不想在整个图像上执行此操作,我们只想关注我们的三角形,因此可以使用掩码来完成此任务。这样,最后一行只复制我们原始图像中的三角形,使用我们刚刚创建的掩码,该掩码是通过cvFillConvexPoly调用来创建的。
以下截图显示了将此过程应用于标注图像中每个三角形的结果。请注意,三角形被映射回面向观察者的对齐框架。此过程用于创建 AAM 的统计纹理:

上述截图显示了将左图中所有映射的三角形扭曲到平均参考框架的结果。
模型实例化 - 玩转 AAM
AAMs 的一个有趣方面是它们能够轻松地插值我们训练图像上的模型。我们可以通过调整几个形状或模型参数来习惯它们的惊人表达能力。当我们改变形状参数时,我们的扭曲目标会根据训练的形状数据变化。另一方面,当外观参数被修改时,基础形状上的纹理也会被修改。我们的扭曲变换将把基础形状中的每个三角形转换到修改后的目标形状,这样我们就可以在张开嘴的上方合成一个闭嘴,如下面的截图所示:

上述截图显示了通过在另一图像上对 Active Appearance Model 进行实例化而获得的合成闭嘴。它显示了如何将微笑的嘴巴与受人钦佩的面孔结合在一起,外推训练图像。
前面的截图是通过仅更改形状和纹理的三个参数获得的,这是 AAM 的目标。已经开发了一个示例应用程序,可在 www.packtpub.com/ 上试用 AAM。实例化新模型只是滑动方程参数的问题,如 获取 PCA 感觉 部分中定义的那样。你应该注意,AAM 搜索和拟合依赖于这种灵活性,以找到与训练位置不同的给定捕获帧的最佳匹配。我们将在下一节中看到这一点。
AAM 搜索和拟合
使用我们新鲜、新的组合形状和纹理模型,我们找到了一种描述面部如何不仅在形状上,而且在外观上发生变化的好方法。现在,我们想要找到哪组 p 形状和 λ 外观参数将使我们的模型尽可能接近给定的输入图像 I(x)。我们可以在 I(x) 的坐标系中自然地计算我们的实例化模型和给定输入图像之间的误差,或者将点映射回基础外观并计算那里的差异。我们将采用后一种方法。这样,我们想要最小化以下函数:

在前面的方程中,S0 表示像素 x 等于 (x,y)T 且位于 AAM 基础网格内部的像素集,A0(x) 是我们的基础网格纹理,Ai(x) 是来自 PCA 的外观图像,而 W(x;p) 是将像素从输入图像返回到基础网格框架的变形。
通过多年的研究,已经提出了几种用于这种最小化的方法。最初的想法是使用一种加性方法,其中 ∆pi 和 ∆λi 被计算为误差图像的线性函数,然后形状参数 p 和外观 λ 通过迭代更新为 pi ← pi + ∆pi 和 λi ← λi + ∆λi。尽管有时可以发生收敛,但增量并不总是依赖于当前参数,这可能会导致发散。另一种基于梯度下降算法的研究方法非常慢,因此寻求另一种找到收敛的方法。不是更新参数,而是整个变形可以更新。这种方式,Ian Mathews 和 Simon Baker 在一篇名为 Active Appearance Models Revisited 的著名论文中提出了一种组合方法。更多细节可以在论文中找到,但它在拟合方面的重要贡献是将最密集的计算带到了预计算步骤,正如以下截图所示:

注意,更新是通过组合步骤发生的,正如步骤(9)所示(参见前面的截图)。以下截图显示了论文中的方程(40)和(41):


尽管前面提到的算法将从接近最终位置的位置开始大多数情况下都会很好地收敛,但在旋转、平移或比例有较大差异的情况下可能并非如此。我们可以通过全局 2D 相似变换的参数化来引入更多信息以促进收敛。这是论文中的方程42,如下所示:

在前面的方程中,四个参数q = (a, b, t[x], t[y])有以下解释。第一对(a, b)与比例k和旋转θ相关:a等于k cos θ - 1,而b = k sin θ。第二对(t[x], t[y])是x和y平移,如Active Appearance Models Revisited论文中提出的那样。
通过更多的数学变换,您最终可以使用前面的算法通过全局 2D 变换找到最佳图像拟合。
由于组合变换算法具有几个性能优势,我们将使用 AAM Revisited 论文中描述的算法:逆组合投影算法。记住,在这个方法中,拟合过程中外观变化的影响可以预先计算或投影出去,从而提高 AAM 拟合性能。
下面的截图显示了使用逆组合投影和我们的 AAM 拟合算法在 MUCT 数据集的不同图像上的收敛情况:

前面的截图显示了使用逆组合投影和我们的 AAM 拟合算法在 AAM 训练集外部面上成功收敛的情况。
POSIT
在我们找到我们的特征点的 2D 位置后,我们可以使用 POSIT 推导出我们模型的 3D 姿态。3D 对象的位置P定义为 3 x 3 旋转矩阵R和 3D 平移向量T;因此,P等于[ R | T ]。
本节的大部分内容基于 Javier Barandiaran 的OpenCV POSIT教程。
正如其名所示,POSIT 在多次迭代中使用从正射投影和缩放中获取姿态(POS)算法,因此它是 POS 的迭代缩写。其工作假设是我们可以检测和匹配图像中对象四个或更多非共面的特征点,并且我们知道它们在对象上的相对几何形状。
该算法的主要思想是,假设所有模型点都在同一平面上,我们可以找到一个很好的对象姿态近似,因为它们的深度彼此之间并不非常不同,与相机到人脸的距离相比。在获得初始姿态后,通过求解线性系统找到对象的旋转矩阵和平移向量。然后,迭代地使用近似的姿态来更好地计算特征点的缩放正交投影,随后对这些投影而不是原始投影应用 POS。有关更多信息,您可以参考 DeMenton 的论文,25 行代码中的基于模型的物体姿态。
深入 POSIT
为了使 POSIT 能够工作,你需要至少四个非共面的 3D 模型点和它们在 2D 图像中的对应匹配。我们将为此添加一个终止条件,因为 POSIT 是一个迭代算法,通常是一定次数的迭代或距离参数。然后我们将调用包含在 calib3d_c.h 中的 cvPOSIT 函数,该函数会得到旋转矩阵和平移向量。
作为示例,我们将遵循 Javier Barandiaran 的教程,该教程使用 POSIT 获取一个立方体的姿态。模型是通过四个点创建的。它使用以下代码初始化:
float cubeSize = 10.0;
std::vector<CvPoint3D32f> modelPoints;
modelPoints.push_back(cvPoint3D32f(0.0f, 0.0f, 0.0f));
modelPoints.push_back(cvPoint3D32f(0.0f, 0.0f, cubeSize));
modelPoints.push_back(cvPoint3D32f(cubeSize, 0.0f, 0.0f));
modelPoints.push_back(cvPoint3D32f(0.0f, cubeSize, 0.0f));
CvPOSITObject *positObject = cvCreatePOSITObject( &modelPoints[0],
static_cast<int>(modelPoints.size()) );
注意,模型本身是通过 cvCreatePOSITObject 方法创建的,该方法返回一个 CvPOSITObject 方法,该方法将在 cvPOSIT 函数中使用。请注意,姿态计算将参照第一个模型点,这使得将其放置在原点是一个好主意。
然后,我们需要将 2D 图像点放入另一个向量中。记住,它们必须按照模型点插入的顺序放入数组中;这样,第 i 个 2D 图像点与第 i 个 3D 模型点匹配。这里的一个难点是,2D 图像点的原点位于图像中心,这可能需要你进行平移。你可以插入以下 2D 图像点(当然,它们将根据用户的匹配而变化):
std::vector<CvPoint2D32f> srcImagePoints;
srcImagePoints.push_back( cvPoint2D32f( -48, -224 ) );
srcImagePoints.push_back( cvPoint2D32f( -287, -174 ) );
srcImagePoints.push_back( cvPoint2D32f( 132, -153 ) );
srcImagePoints.push_back( cvPoint2D32f( -52, 149 ) );
现在,你只需要为矩阵分配内存,创建终止条件,然后调用 cvPOSIT,如下面的代码片段所示:
//Estimate the pose
float* rotation_matrix = new float[9];
float* translation_vector = new float[3];
CvTermCriteria criteria = cvTermCriteria(CV_TERMCRIT_EPS |
CV_TERMCRIT_ITER, 100, 1.0e-4f);
cvPOSIT( positObject, &srcImagePoints[0], FOCAL_LENGTH, criteria,
rotation_matrix, translation_vector );
迭代完成后,cvPOSIT 将结果存储在 rotation_matrix 和 translation_vector 中。以下截图显示了插入的 srcImagePoints 以及显示旋转和平移结果的坐标轴:

参考前面的截图,让我们看看以下输入点和运行 POSIT 算法的结果:
-
白色圆圈显示输入点,而坐标轴显示结果模型姿态。
-
确保你使用通过校准过程获得的相机焦距。你可能想检查 第七章 中 相机校准 部分提供的校准过程之一,自然特征跟踪增强现实。当前 POSIT 的实现将只允许正方形像素,因此在 x 和 y 轴上不会有焦距的空间。
-
期望以下格式的旋转矩阵:
-
[rot[0] rot[1] rot[2]]
-
[rot[3] rot[4] rot[5]]
-
[rot[6] rot[7] rot[8]]
-
-
平移向量将具有以下格式:
-
[trans[0]]
-
[trans[1]]
-
[trans[2]]
-
POSIT 和头部模型
为了将 POSIT 用作头部姿态的工具,你需要使用一个 3D 头部模型。可以从科英布拉大学系统与机器人研究所获得一个,可以在aifi.isr.uc.pt/Downloads/OpenGL/glAnthropometric3DModel.cpp找到。请注意,模型可以从它所说的位置获得:
float Model3D[58][3]= {{-7.308957,0.913869,0.000000}, ...
模型可以在以下截图中看到:

前面的截图显示了可用于 POSIT 的 58 点 3D 头部模型。
为了使 POSIT 工作,必须相应地匹配对应于 3D 头部模型的点。请注意,至少需要四个非共面的 3D 点和它们对应的 2D 投影才能使 POSIT 工作,因此这些必须作为参数传递,基本上就像在深入 POSIT部分所描述的那样。请注意,这个算法在匹配点数方面是线性的。以下截图显示了匹配应该如何进行:

前面的截图显示了 3D 头部模型和 AAM 网格正确匹配的点。
从摄像头或视频文件进行跟踪
现在所有工具都已组装完毕,以实现 6 自由度的头部跟踪,我们可以将其应用于相机流或视频文件。OpenCV 提供了VideoCapture类,可以按以下方式使用(有关更多详细信息,请参阅第一章的访问摄像头部分,Raspberry Pi 的卡通化皮肤变换器):
#include "opencv2/opencv.hpp"
using namespace cv;
int main(int, char**)
{
VideoCapture cap(0);// opens the default camera, could use a
// video file path instead
if(!cap.isOpened()) // check if we succeeded
return -1;
AAM aam = loadPreviouslyTrainedAAM();
HeadModel headModel = load3DHeadModel();
Mapping mapping = mapAAMLandmarksToHeadModel();
Pose2D pose = detectFacePosition();
while(1)
{
Mat frame;
cap >> frame; // get a new frame from camera
Pose2D new2DPose = performAAMSearch(pose, aam);
Pose3D new3DPose = applyPOSIT(new2DPose, headModel, mapping);
if(waitKey(30) >= 0) break;
}
// the camera will be deinitialized automatically in VideoCapture
// destructor
return 0;
}
该算法的工作原理如下。通过VideoCapture cap(0)初始化视频捕获,以便使用默认的摄像头。现在我们已经使视频捕获工作,我们还需要加载我们训练好的活动外观模型,这将在loadPreviouslyTrainedAAM伪代码映射中发生。我们还将加载用于 POSIT 的 3D 头部模型以及我们的映射变量中地标点到 3D 头部点的映射。
在加载了所有需要的工具之后,我们需要从一个已知的姿态初始化算法,这是一个已知的 3D 位置、已知的旋转和一组已知的 AAM 参数。这可以通过 OpenCV 高度文档化的 Haar 特征分类器人脸检测器自动完成(更多细节请参阅第四章的人脸检测部分,非刚性人脸跟踪,或在 OpenCV 的级联分类器文档中),或者我们可以从先前标注的帧中手动初始化姿态。一种暴力方法,即对每个矩形运行 AAM 拟合,也可以使用,因为它在第一帧中会非常慢。请注意,我们所说的初始化是指通过它们的参数找到 AAM 的 2D 特征点。
当一切加载完成后,我们可以遍历由while循环定义的主循环。在这个循环中,我们首先查询下一个捕获的帧,然后运行一个主动外观模型拟合,以便我们可以在下一帧上找到特征点。由于当前位置在这一步非常重要,所以我们将其作为参数传递给伪代码函数performAAMSearch(pose,aam)。如果我们找到当前姿态,这通过错误图像收敛来表示,我们将获得下一个特征点位置,因此我们可以将它们提供给 POSIT。这发生在以下行,applyPOSIT(new2DPose, headModel, mapping),其中新的 2D 姿态作为参数传递,以及我们之前加载的headModel和映射。之后,我们可以以坐标轴或增强现实模型的方式渲染获得的姿态中的任何 3D 模型。由于我们有特征点,可以通过模型参数化获得更多有趣的效果,例如张开嘴巴或改变眉毛位置。
由于此过程依赖于前一个姿态进行下一个估计,我们可能会累积误差并偏离头部位置。一种解决方案是在每次发生时重新初始化此过程,检查给定的错误图像阈值。另一个需要注意的因素是在跟踪时使用过滤器,因为可能会发生抖动。对于每个平移和旋转坐标的简单均值滤波器可以给出合理的结果。
摘要
在本章中,我们讨论了如何将主动外观模型(AAM)与 POSIT 算法结合以获得 3D 头部姿态。我们概述了如何创建、训练和操作 AAM,并且你可以将这些背景知识应用于任何其他领域,例如医学、成像或工业。除了处理 AAM 之外,我们还熟悉了 Delaunay 细分,并学习了如何使用这种有趣的结构作为三角网格。我们还展示了如何使用 OpenCV 函数在三角形上执行纹理映射。在 AAM 拟合中,我们还探讨了一个有趣的话题。尽管只描述了逆合成投影算法,但我们可以通过简单地使用其输出轻松地获得多年研究的结果。
在对 AAM 有足够的理论和实践之后,我们深入探讨了 POSIT 的细节,以便将 2D 测量与 3D 测量相结合,解释了如何通过模型点之间的匹配来拟合 3D 模型。我们通过展示如何通过检测使用在线人脸追踪器中的所有工具来结束本章,这产生了 6 个自由度的头部姿态——3 个用于旋转,3 个用于平移。本章的完整代码可以从www.packtpub.com/下载。
参考文献
-
主动外观模型,T.F. Cootes, G. J. Edwards, 和 C. J. Taylor, ECCV, 2:484-498, 1998 (
www.cs.cmu.edu/~efros/courses/AP06/Papers/cootes-eccv-98.pdf) -
主动形状模型及其训练与应用,T.F. Cootes,C.J. Taylor,D.H. Cooper,和 J. Graham,计算机视觉与图像理解,第 61 卷,第 38-59 页,1995 年,(
www.wiau.man.ac.uk/~bim/Papers/cviu95.pdf) -
MUCT 标记人脸数据库,S. Milborrow,J. Morkel,和 F. Nicolls,南非模式识别协会,2010 年,(
www.milbo.org/muct/) -
IMM 人脸数据库 - 240 张人脸图像的注释数据集,Michael M. Nordstrom,Mads Larsen,Janusz Sierakowski,以及 Mikkel B.**Stegmann,丹麦技术大学信息与数学建模系,2004 年,(
www2.imm.dtu.dk/~aam/datasets/datasets.html) -
关于空球面,B. Delaunay,苏联科学院院刊,数学和自然科学部,第 7 卷,第 793-800 页,1934 年
-
用于面部表情识别和单目头部姿态估计的主动外观模型,P. Martins,硕士论文,2008 年
-
重新审视主动外观模型,国际计算机视觉杂志,第 60 卷,第 2 期,第 135-164 页,I. Mathews 和 S. Baker,2004 年 11 月,(
www.ri.cmu.edu/pub_files/pub4/matthews_iain_2004_2/matthews_iain_2004_2.pdf) -
POSIT 教程,Javier Barandiaran,(
opencv.willowgarage.com/wiki/Posit) -
25 行代码实现基于模型的物体姿态,国际计算机视觉杂志,第 15 卷,第 123-141 页,Dementhon 和 L.S Davis,1995 年,(
www.cfar.umd.edu/~daniel/daniel_papersfordownload/Pose25Lines.pdf)
第六章:使用特征脸或费舍尔脸进行面部识别
本章涵盖以下内容:
-
面部检测
-
面部预处理
-
从收集到的面部训练机器学习算法
-
面部识别
-
完成细节
面部识别和面部检测简介
面部识别是将标签贴在已知面部上的过程。就像人类通过看到他们的面孔来识别家人、朋友和名人一样,计算机学习识别已知面部有许多技术。这些通常涉及四个主要步骤:
-
面部检测:这是在图像中定位面部区域的过程(如下截图中心附近的大矩形)。这一步不关心这个人是谁,只关心它是一个人脸。
-
面部预处理:这是调整面部图像以使其看起来更清晰并与其他面部相似的过程(如下截图顶部中央的小灰度面部图像)。
-
收集和学习面部:这是保存许多预处理后的面部(对于应该被识别的每个人),然后学习如何识别它们的过程。
-
面部识别:这是检查收集到的人中谁与相机中的面部最相似的过程(如下截图右上角的小矩形)。
注意,短语面部识别通常被公众用于查找面部位置(即面部检测,如步骤 1 中所述),但本书将使用正式的定义,面部识别指步骤 4,面部检测指步骤 1。
以下截图显示了最终的WebcamFaceRec项目,包括右上角的小矩形突出显示识别的人。同时注意旁边的置信度条,它位于预处理后的面部旁边(矩形标记面部顶部中央的小面部),在这种情况下,显示大约 70%的置信度,表明它已正确识别了正确的人:

当前的人脸检测技术在现实世界条件下相当可靠,而当前的人脸识别技术在现实世界条件下使用时可靠性要低得多。例如,很容易找到显示人脸识别准确率超过 95%的研究论文,但当你自己测试这些相同的算法时,你可能会经常发现准确率低于 50%。这源于当前的人脸识别技术对图像中的精确条件非常敏感,例如光照类型、光照方向和阴影、人脸的精确方向、面部表情以及人的当前情绪。如果它们在训练(收集图像)以及测试(从摄像头图像)时都保持恒定,那么人脸识别应该会工作得很好,但如果人在训练时站在房间灯光的左侧,而在测试时站在摄像头的右侧,可能会得到相当糟糕的结果。因此,用于训练的数据集非常重要。
人脸预处理(第二步)旨在减少这些问题,例如确保人脸始终看起来具有相似的亮度和对比度,并可能确保人脸的特征始终位于相同的位置(例如将眼睛和/或鼻子对齐到某些位置)。一个良好的人脸预处理阶段将有助于提高整个人脸识别系统的可靠性,因此本章将重点介绍人脸预处理方法。
尽管媒体上关于人脸识别在安全方面的夸大其词,但单独依靠当前的人脸识别方法不太可能足够可靠,以用于任何真正的安全系统,但它们可以用于不需要高可靠性的目的,例如为进入房间或看到你时说出你名字的机器人播放个性化的音乐。还有各种人脸识别的实际扩展,如性别识别、年龄识别和情绪识别。
第一步 - 人脸检测
直到 2000 年,用于寻找面部的方法有很多种,但它们要么非常慢,要么非常不可靠,或者两者兼而有之。2001 年,Viola 和 Jones 发明了基于 Haar 的级联分类器用于物体检测,2002 年,Lienhart 和 Maydt 对其进行了改进。结果是,这个物体检测器既快(它可以在典型的桌面电脑上使用 VGA 摄像头实时检测面部)又可靠(它正确检测大约 95%的前置面部)。这个物体检测器彻底改变了面部识别领域(以及机器人学和计算机视觉领域),因为它最终实现了实时面部检测和面部识别,尤其是 Lienhart 本人还编写了与 OpenCV 一起免费提供的物体检测器!它不仅适用于正面面部,还适用于侧面视角的面部(称为侧面面部)、眼睛、嘴巴、鼻子、公司标志以及许多其他物体。
在 OpenCV v2.0 中,这个物体检测器被扩展,以使用 Ahonen、Hadid 和 Pietikäinen 在 2006 年的工作成果中的 LBP 特征进行检测,因为基于 LBP 的检测器可能比基于 Haar 的检测器快几倍,而且没有许多 Haar 检测器所面临的许可问题。
基于 Haar 的面部检测器的基本思想是,如果你观察大多数正面面部,眼睛所在区域应该比额头和脸颊暗,嘴巴所在区域应该比脸颊暗,等等。它通常进行大约 20 次这样的比较来决定是否为面部,但它必须在图像的每个可能位置和每个可能的面部大小上进行这种操作,因此实际上它通常每张图像都要进行数千次检查。基于 LBP 的面部检测器的基本思想与基于 Haar 的类似,但它使用像素强度比较的直方图,例如边缘、角点和平坦区域。
而不是让一个人决定哪些比较最能定义面部,基于 Haar 和 LBP 的面部检测器都可以自动训练,从大量图像中找到面部,信息存储为 XML 文件以供以后使用。这些级联分类器检测器通常使用至少 1,000 张独特的面部图像和 10,000 张非面部图像(例如树木、汽车和文本的照片)进行训练,即使在多核桌面电脑上训练过程也可能需要很长时间(通常 LBP 需要几个小时,而 Haar 需要一周!)幸运的是,OpenCV 附带了一些预训练的 Haar 和 LBP 检测器供你使用!实际上,你只需将不同的级联分类器 XML 文件加载到物体检测器中,就可以检测正面面部、侧面(侧面视角)面部、眼睛或鼻子,并根据你选择的 XML 文件选择 Haar 或 LBP 检测器。
使用 OpenCV 实现面部检测
如前所述,OpenCV v2.4 包含各种预训练的 XML 检测器,可用于不同的目的。以下表格列出了其中一些最受欢迎的 XML 文件:
| 级联分类器类型 | XML 文件名 |
|---|---|
| 人脸检测器(默认) | haarcascade_frontalface_default.xml |
| 人脸检测器(快速 Haar) | haarcascade_frontalface_alt2.xml |
| 人脸检测器(快速 LBP) | lbpcascade_frontalface.xml |
| 轮廓(侧面)人脸检测器 | haarcascade_profileface.xml |
| 眼睛检测器(左眼和右眼分开) | haarcascade_lefteye_2splits.xml |
| 嘴巴检测器 | haarcascade_mcs_mouth.xml |
| 鼻子检测器 | haarcascade_mcs_nose.xml |
| 全身检测器 | haarcascade_fullbody.xml |
基于 Haar 的检测器存储在 OpenCV 根目录的 datahaarcascades 文件夹中,而基于 LBP 的检测器存储在 datalbpcascades 文件夹中,例如 C:opencvdatalbpcascades。
对于我们的面部识别项目,我们希望检测正面人脸,因此让我们使用 LBP 人脸检测器,因为它是最快的,并且没有专利许可问题。请注意,OpenCV v2.x 中包含的此预训练 LBP 人脸检测器没有像预训练的 Haar 人脸检测器那样经过良好的调整,因此如果您想要更可靠的人脸检测,则可能需要训练自己的 LBP 人脸检测器或使用 Haar 人脸检测器。
加载 Haar 或 LBP 检测器以进行物体或人脸检测
要执行物体或人脸检测,首先您必须使用 OpenCV 的 CascadeClassifier 类加载预训练的 XML 文件,如下所示:
CascadeClassifier faceDetector;
faceDetector.load(faceCascadeFilename);
通过提供不同的文件名,可以加载 Haar 或 LBP 检测器。在使用此功能时,一个常见的错误是提供错误的文件夹或文件名,但根据您的构建环境,load() 方法将返回 false 或生成一个 C++ 异常(并使用断言错误退出您的程序)。因此,最好将 load() 方法用 try... catch 块包围,并在出现问题时向用户显示一个友好的错误消息。许多初学者会跳过错误检查,但显示帮助消息对于用户来说至关重要,否则您可能会花费很长时间调试代码的其他部分,最终意识到某些内容没有正确加载。以下是一个简单的错误消息示例:
CascadeClassifier faceDetector;
try {
faceDetector.load(faceCascadeFilename);
} catch (cv::Exception e) {}
if ( faceDetector.empty() ) {
cerr << "ERROR: Couldn't load Face Detector (";
cerr << faceCascadeFilename << ")!" << endl;
exit(1);
}
访问摄像头
要从计算机的摄像头或视频文件中捕获帧,您可以简单地调用 VideoCapture::open() 函数,传入摄像头编号或视频文件名,然后使用 C++ 流操作符捕获帧,如第一章“访问摄像头”部分所述,第一章,“Raspberry Pi 的卡通化器和皮肤变换器”。
使用 Haar 或 LBP 分类器检测物体
既然我们已经加载了分类器(初始化期间只加载一次),我们就可以用它来检测每个新摄像头帧中的人脸。但首先,我们应该对摄像头图像进行一些初始处理,以便进行人脸检测,具体步骤如下:
-
灰度颜色转换:人脸检测仅适用于灰度图像。因此,我们应该将彩色摄像头帧转换为灰度。
-
缩小摄像头图像:人脸检测的速度取决于输入图像的大小(对于大图像来说非常慢,但对于小图像来说很快),即使在低分辨率下,检测仍然相当可靠。因此,我们应该将摄像头图像缩小到更合理的大小(或者,如稍后所述,在检测器中将
minFeatureSize变量的值设置得很大)。 -
直方图均衡化:在低光照条件下,人脸检测的可靠性不高。因此,我们应该进行直方图均衡化以改善对比度和亮度。
灰度颜色转换
我们可以使用cvtColor()函数轻松地将 RGB 彩色图像转换为灰度。但我们应该只在知道我们有一个彩色图像的情况下这样做(也就是说,它不是一个灰度摄像头),并且我们必须指定输入图像的格式(通常在桌面上是 3 通道 BGR,在移动设备上是 4 通道 BGRA)。因此,我们应该允许三种不同的输入颜色格式,如下面的代码所示:
Mat gray;
if (img.channels() == 3) {
cvtColor(img, gray, CV_BGR2GRAY);
}
else if (img.channels() == 4) {
cvtColor(img, gray, CV_BGRA2GRAY);
}
else {
// Access the grayscale input image directly.
gray = img;
}
缩小摄像头图像
我们可以使用resize()函数将图像缩小到特定大小或缩放因子。人脸检测通常对任何大于 240x240 像素的图像大小效果都很好(除非你需要检测远离摄像头的面孔),因为人脸检测会寻找大于minFeatureSize(通常是 20x20 像素)的人脸。所以让我们将摄像头图像缩小到 320 像素宽;无论是 VGA 摄像头还是五百万像素的 HD 摄像头,这都无关紧要。同时,记住并放大检测结果也很重要,因为如果你在缩小的图像中检测到人脸,那么结果也会被缩小。请注意,你不必缩小输入图像,也可以在检测器中将minFeatureSize变量的值设置得很大。我们还必须确保图像不会变得过宽或过窄。例如,当 800x400 的宽屏图像缩小到 300x200 时,会使人物看起来很瘦。因此,我们必须保持输出图像的宽高比(宽度和高度的比率)与输入相同。让我们计算图像宽度需要缩小多少,然后应用相同的缩放因子到高度,如下所示:
const int DETECTION_WIDTH = 320;
// Possibly shrink the image, to run much faster.
Mat smallImg;
float scale = img.cols / (float) DETECTION_WIDTH;
if (img.cols > DETECTION_WIDTH) {
// Shrink the image while keeping the same aspect ratio.
int scaledHeight = cvRound(img.rows / scale);
resize(img, smallImg, Size(DETECTION_WIDTH, scaledHeight));
}
else {
// Access the input directly since it is already small.
smallImg = img;
}
直方图均衡化
我们可以使用equalizeHist()函数轻松地进行直方图均衡化,以改善图像的对比度和亮度。有时这会使图像看起来很奇怪,但通常应该提高亮度和对比度,并有助于人脸检测。equalizeHist()函数的使用方法如下:
// Standardize the brightness & contrast, such as
// to improve dark images.
Mat equalizedImg;
equalizeHist(inputImg, equalizedImg);
检测人脸
现在我们已经将图像转换为灰度,缩小了图像,并均衡了直方图,我们就可以使用CascadeClassifier::detectMultiScale()函数来检测面部了!我们向此函数传递了许多参数:
-
minFeatureSize:此参数确定我们关心的最小面部大小,通常是 20x20 或 30x30 像素,但这取决于你的使用情况和图像大小。如果你在摄像头或智能手机上执行面部检测,其中面部始终非常靠近摄像头,你可以将其放大到 80 x 80 以获得更快的检测,或者如果你想检测远处的面部,例如在海滩上和朋友在一起,那么保持为 20x20。 -
searchScaleFactor:此参数确定要查找多少种不同大小的面部;通常它会是1.1,以获得良好的检测效果,或者1.2以获得更快的检测速度,但可能不会经常找到面部。 -
minNeighbors:此参数确定检测器应该有多确信它已经检测到面部,通常值为3,但如果你想获得更可靠的面部,即使许多面部没有被检测到,你也可以将其设置得更高。 -
flags:此参数允许你指定是否寻找所有面部(默认)或只寻找最大的面部(CASCADE_FIND_BIGGEST_OBJECT)。如果你只寻找最大的面部,它应该运行得更快。你可以添加几个其他参数来使检测速度提高大约 1%或 2%,例如CASCADE_DO_ROUGH_SEARCH或CASCADE_SCALE_IMAGE。
detectMultiScale()函数的输出将是一个cv::Rect类型对象的std::vector。例如,如果它检测到两个面部,那么它将在输出中存储两个矩形的数组。detectMultiScale()函数的使用方法如下:
int flags = CASCADE_SCALE_IMAGE; // Search for many faces.
Size minFeatureSize(20, 20); // Smallest face size.
float searchScaleFactor = 1.1f; // How many sizes to search.
int minNeighbors = 4; // Reliability vs many faces.
// Detect objects in the small grayscale image.
std::vector<Rect> faces;
faceDetector.detectMultiScale(img, faces, searchScaleFactor,
minNeighbors, flags, minFeatureSize);
我们可以通过查看我们矩形向量中存储的元素数量来查看是否检测到了任何面部;即,通过使用objects.size()函数。
如前所述,如果我们给面部检测器一个缩小的图像,结果也会缩小,所以如果我们想了解原始图像的面部区域,我们需要将其放大。我们还需要确保图像边缘的面部完全位于图像内,因为如果发生这种情况,OpenCV 现在会抛出异常,如下面的代码所示:
// Enlarge the results if the image was temporarily shrunk.
if (img.cols > scaledWidth) {
for (int i = 0; i < (int)objects.size(); i++ ) {
objects[i].x = cvRound(objects[i].x * scale);
objects[i].y = cvRound(objects[i].y * scale);
objects[i].width = cvRound(objects[i].width * scale);
objects[i].height = cvRound(objects[i].height * scale);
}
}
// If the object is on a border, keep it in the image.
for (int i = 0; i < (int)objects.size(); i++ ) {
if (objects[i].x < 0)
objects[i].x = 0;
if (objects[i].y < 0)
objects[i].y = 0;
if (objects[i].x + objects[i].width > img.cols)
objects[i].x = img.cols - objects[i].width;
if (objects[i].y + objects[i].height > img.rows)
objects[i].y = img.rows - objects[i].height;
}
注意,前面的代码将寻找图像中的所有面部,但如果你只关心一个面部,那么你可以按以下方式更改标志变量:
int flags = CASCADE_FIND_BIGGEST_OBJECT |
CASCADE_DO_ROUGH_SEARCH;
WebcamFaceRec项目包括 OpenCV 的 Haar 或 LBP 检测器的包装,以便更容易在图像中找到面部或眼睛。例如:
Rect faceRect; // Stores the result of the detection, or -1\.
int scaledWidth = 320; // Shrink the image before detection.
detectLargestObject(cameraImg, faceDetector, faceRect, scaledWidth);
if (faceRect.width > 0)
cout << "We detected a face!" << endl;
现在我们已经有一个面部矩形,我们可以用它以多种方式使用,例如从原始图像中提取或裁剪面部图像。以下代码允许我们访问面部:
// Access just the face within the camera image.
Mat faceImg = cameraImg(faceRect);
下图显示了面部检测器给出的典型矩形区域:

第 2 步 - 面部预处理
如前所述,人脸识别对光照条件、人脸朝向、面部表情等变化极为敏感,因此尽可能减少这些差异非常重要。否则,人脸识别算法通常会认为在相同条件下,两个人的面部之间的相似性比同一个人的两个面部之间的相似性更大。
最简单的人脸预处理形式就是使用 equalizeHist() 函数应用直方图均衡化,就像我们刚才对人脸检测所做的那样。这可能对于一些项目来说已经足够,在这些项目中,光照和位置条件不会发生很大变化。但是,为了在现实世界条件下的可靠性,我们需要许多复杂的技术,包括面部特征检测(例如,检测眼睛、鼻子、嘴巴和眉毛)。为了简单起见,本章将仅使用眼睛检测并忽略其他面部特征,如嘴巴和鼻子,这些特征不太有用。以下图像显示了使用本节将要介绍的技术对典型预处理人脸的放大视图。
眼睛检测
眼睛检测对于人脸预处理非常有用,因为对于正面人脸,你可以始终假设一个人的眼睛应该是水平的,并且位于脸的相对位置,并且在一个脸内应该有相当标准的位置和大小,尽管面部表情、光照条件、相机属性、与相机的距离等发生变化。当人脸检测器声称它检测到人脸但实际上是其他东西时,丢弃假阳性也是很有用的。同时被人脸检测器和两个眼睛检测器欺骗的情况很少见,所以如果你只处理检测到人脸和两个眼睛的图像,那么假阳性将不会很多(但也会处理更少的人脸,因为眼睛检测器不会像人脸检测器那样经常工作)。
OpenCV v2.4 附带的一些预训练的眼睛检测器可以检测睁眼或闭眼,而其中一些只能检测睁眼。
检测睁眼或闭眼的眼睛检测器如下:
-
haarcascade_mcs_lefteye.xml(以及haarcascade_mcs_righteye.xml) -
haarcascade_lefteye_2splits.xml(以及haarcascade_righteye_2splits.xml)
仅检测睁眼的眼睛检测器如下:
-
haarcascade_eye.xml -
haarcascade_eye_tree_eyeglasses.xml
由于睁眼或闭眼检测器指定了它们训练的是哪只眼睛,因此你需要为左右眼使用不同的检测器,而仅检测睁眼的检测器可以使用左右眼相同的检测器。
检测器 haarcascade_eye_tree_eyeglasses.xml 可以检测佩戴眼镜的人的眼睛,但如果他们不戴眼镜则不可靠。
如果 XML 文件名是左眼,那么它指的是人的实际左眼,所以在相机图像中它通常出现在脸的右侧,而不是左侧!
提到的四个眼检测器的列表按从最可靠到最不可靠的顺序排列,所以如果你知道你不需要找到戴眼镜的人,那么第一个检测器可能是最佳选择。
眼搜索区域
对于眼检测,重要的是将输入图像裁剪,仅显示大约的眼部区域,就像进行人脸检测然后裁剪到仅包含左眼的小矩形中(如果你使用的是左眼检测器)以及对于右眼检测器的相同操作。如果你在整个脸部或整个照片上仅进行眼检测,那么它将慢得多且可靠性较低。不同的眼检测器更适合脸部的不同区域;例如,haarcascade_eye.xml检测器在仅搜索实际眼睛周围非常紧密的区域时效果最佳,而haarcascade_mcs_lefteye.xml和haarcascade_lefteye_2splits.xml检测器在眼睛周围有较大区域时效果最佳。
下表列出了不同眼检测器(在使用 LBP 人脸检测器时)的一些良好的搜索区域,使用检测到的脸矩形内的相对坐标:
| 级联分类器 | EYE_SX | EYE_SY | EYE_SW | EYE_SH |
|---|---|---|---|---|
haarcascade_eye.xml |
0.16 | 0.26 | 0.30 | 0.28 |
haarcascade_mcs_lefteye.xml |
0.10 | 0.19 | 0.40 | 0.36 |
haarcascade_lefteye_2splits.xml |
0.12 | 0.17 | 0.37 | 0.36 |
这里是提取左眼和右眼区域的源代码
检测到的脸:
int leftX = cvRound(face.cols * EYE_SX);
int topY = cvRound(face.rows * EYE_SY);
int widthX = cvRound(face.cols * EYE_SW);
int heightY = cvRound(face.rows * EYE_SH);
int rightX = cvRound(face.cols * (1.0-EYE_SX-EYE_SW));
Mat topLeftOfFace = faceImg(Rect(leftX, topY, widthX, heightY));
Mat topRightOfFace = faceImg(Rect(rightX, topY, widthX, heightY));
下图显示了不同眼检测器的理想搜索区域,其中haarcascade_eye.xml和haarcascade_eye_tree_eyeglasses.xml文件最适合小搜索区域,而haarcascade_mcs_*eye.xml和haarcascade_*eye_2splits.xml文件最适合大搜索区域。请注意,检测到的脸矩形也显示出来,以给出眼搜索区域与检测到的脸矩形相比的大小概念:

当使用前面表格中给出的眼搜索区域时,以下是不同眼检测器的近似检测特性:
| 级联分类器 | 可靠性 | 速度 | 检测到的眼睛 | 眼镜 |
|---|---|---|---|---|
haarcascade_mcs_lefteye.xml |
80% | 18 毫秒 | 开或闭 | 无 |
haarcascade_lefteye_2splits.xml |
60% | 7 毫秒 | 开或闭 | 无 |
haarcascade_eye.xml |
40% | 5 毫秒 | 仅开 | 无 |
haarcascade_eye_tree_eyeglasses.xml |
15% | 10 毫秒 | 仅开 | 是 |
可靠性值显示了在无眼镜和双眼睁开的情况下,经过 LBP 正面面部检测后,双眼被检测到的频率。如果眼睛是闭着的,可靠性可能会下降,或者如果戴着眼镜,可靠性和速度都会下降。
速度值是以毫秒为单位,针对在 Intel Core i7 2.2 GHz(在 1000 张照片上平均)上缩放到 320x240 像素大小的图像。当找到眼睛时,速度通常比找不到眼睛时快得多,因为它必须扫描整个图像,但haarcascade_mcs_lefteye.xml仍然比其他眼睛检测器慢得多。
例如,如果你将照片缩小到 320x240 像素,对其执行直方图均衡化,使用 LBP 正面面部检测器获取面部,然后提取
使用haarcascade_mcs_lefteye.xml值从面部检测到左眼区域和右眼区域,然后对每个眼睛区域执行直方图均衡化。然后,如果你在左眼上使用haarcascade_mcs_lefteye.xml检测器(实际上位于图像的右上角)并在右眼上使用haarcascade_mcs_righteye.xml检测器(图像的左上部分),每个眼睛检测器应该在大约 90%的 LBP 检测到的正面面部照片中正常工作。所以如果你想检测两只眼睛,那么它应该在大约 80%
使用 LBP 检测到的正面面部照片。
注意,虽然建议在检测面部之前缩小相机图像,但你应使用完整的相机分辨率来检测眼睛,因为眼睛显然比面部小得多,因此你需要尽可能多的分辨率。
根据表格,似乎在选择要使用的眼睛检测器时,你应该决定是想检测闭眼还是只检测睁眼。并且记住,你甚至可以使用一个单眼检测器,如果它没有检测到眼睛,那么你可以尝试另一个。
对于许多任务,检测睁眼或闭眼的眼晴都是有用的,所以如果速度不是关键因素,最好首先使用mcs_*eye检测器进行搜索,如果失败,则使用eye_2splits检测器进行搜索。
但对于面部识别,如果一个人的眼睛是闭着的,那么他们的外观会有很大不同,因此最好首先使用普通的haarcascade_eye检测器进行搜索,如果失败,则使用haarcascade_eye_tree_eyeglasses检测器进行搜索。
我们可以使用与面部检测相同的detectLargestObject()函数来搜索眼睛,但在进行眼睛检测之前,我们指定完整的眼睛区域宽度以获得更好的眼睛检测。使用一个检测器很容易找到左眼,如果失败,则尝试另一个检测器(右眼也是如此)。眼睛检测的步骤如下:
CascadeClassifier eyeDetector1("haarcascade_eye.xml");
CascadeClassifier eyeDetector2("haarcascade_eye_tree_eyeglasses.xml");
...
Rect leftEyeRect; // Stores the detected eye.
// Search the left region using the 1st eye detector.
detectLargestObject(topLeftOfFace, eyeDetector1, leftEyeRect,
topLeftOfFace.cols);
// If it failed, search the left region using the 2nd eye
// detector.
if (leftEyeRect.width <= 0)
detectLargestObject(topLeftOfFace, eyeDetector2,
leftEyeRect, topLeftOfFace.cols);
// Get the left eye center if one of the eye detectors worked.
Point leftEye = Point(-1,-1);
if (leftEyeRect.width <= 0) {
leftEye.x = leftEyeRect.x + leftEyeRect.width/2 + leftX;
leftEye.y = leftEyeRect.y + leftEyeRect.height/2 + topY;
}
// Do the same for the right-eye
...
// Check if both eyes were detected.
if (leftEye.x >= 0 && rightEye.x >= 0) {
...
}
在检测到面部和两只眼睛后,我们将通过以下方式执行面部预处理:
-
几何变换和裁剪:这个过程将包括缩放、旋转和移动图像,以便眼睛对齐,然后从脸部图像中移除额头、下巴、耳朵和背景。
-
左右两侧分别进行直方图均衡化:这个过程独立地对脸部的左右两侧的亮度和对比度进行标准化。
-
平滑:这个过程使用双边滤波器减少图像噪声。
-
椭圆形遮罩:椭圆形遮罩从脸部图像中移除一些剩余的头发和背景。
以下图像显示了将步骤 1 到 4 应用于检测到的脸部预处理步骤。注意最终图像在脸部的两侧都有良好的亮度和对比度,而原始图像则没有:
几何变换
确保所有脸部都对齐在一起非常重要,否则人脸识别算法可能会将鼻子的部分与眼睛的部分进行比较,等等。刚刚看到的脸部检测输出将在一定程度上提供对齐的脸部,但并不非常准确(也就是说,脸部矩形不会始终从额头上的同一点开始)。
为了获得更好的对齐,我们将使用眼睛检测来对齐脸部,使得两个检测到的眼睛的位置在期望的位置上完美对齐。我们将使用warpAffine()函数进行几何变换,这是一个单一的操作,将完成以下四件事情:
-
旋转脸部,使得两个眼睛水平
-
将脸部缩放,使得两眼之间的距离始终相同
-
将脸部进行平移,使得眼睛始终水平居中并处于期望的高度
-
裁剪脸部的外部部分,因为我们想要裁剪掉图像背景、头发、额头、耳朵和下巴
仿射变换接受一个仿射矩阵,将两个检测到的眼睛位置变换为两个期望的眼睛位置,然后裁剪到期望的大小和位置。为了生成这个仿射矩阵,我们将获取眼睛之间的中心,计算两个检测到的眼睛出现的角度,并观察它们之间的距离,如下所示:
// Get the center between the 2 eyes.
Point2f eyesCenter;
eyesCenter.x = (leftEye.x + rightEye.x) * 0.5f;
eyesCenter.y = (leftEye.y + rightEye.y) * 0.5f;
// Get the angle between the 2 eyes.
double dy = (rightEye.y - leftEye.y);
double dx = (rightEye.x - leftEye.x);
double len = sqrt(dx*dx + dy*dy);
// Convert Radians to Degrees.
double angle = atan2(dy, dx) * 180.0/CV_PI;
// Hand measurements shown that the left eye center should
// ideally be roughly at (0.16, 0.14) of a scaled face image.
const double DESIRED_LEFT_EYE_X = 0.16;
const double DESIRED_RIGHT_EYE_X = (1.0f - 0.16);
// Get the amount we need to scale the image to be the desired
// fixed size we want.
const int DESIRED_FACE_WIDTH = 70;
const int DESIRED_FACE_HEIGHT = 70;
double desiredLen = (DESIRED_RIGHT_EYE_X - 0.16);
double scale = desiredLen * DESIRED_FACE_WIDTH / len;
现在我们可以变换脸部(旋转、缩放和移动),以使两个检测到的眼睛在理想的脸部中位于期望的眼睛位置,如下所示:
// Get the transformation matrix for the desired angle & size.
Mat rot_mat = getRotationMatrix2D(eyesCenter, angle, scale);
// Shift the center of the eyes to be the desired center.
double ex = DESIRED_FACE_WIDTH * 0.5f - eyesCenter.x;
double ey = DESIRED_FACE_HEIGHT * DESIRED_LEFT_EYE_Y -
eyesCenter.y;
rot_mat.at<double>(0, 2) += ex;
rot_mat.at<double>(1, 2) += ey;
// Transform the face image to the desired angle & size &
// position! Also clear the transformed image background to a
// default grey.
Mat warped = Mat(DESIRED_FACE_HEIGHT, DESIRED_FACE_WIDTH,
CV_8U, Scalar(128));
warpAffine(gray, warped, rot_mat, warped.size());
左右两侧分别进行直方图均衡化
在现实世界的条件下,脸部的一半通常会有强烈的照明,而另一半则照明较弱。这对人脸识别算法有巨大的影响,因为同一张脸的左右两侧看起来像是非常不同的人。因此,我们将分别对脸部的左右两侧进行直方图均衡化,以使脸部的每一侧都具有标准化的亮度和对比度。
如果我们只是对左半部分应用直方图均衡化,然后再对右半部分应用直方图均衡化,我们会在中间看到一个非常明显的边缘,因为左右两侧的平均亮度很可能是不同的,因此为了消除这个边缘,我们将从左或右侧面逐渐向中心应用两个直方图均衡化,并将其与整个脸部的直方图均衡化混合,这样远左侧面将使用左直方图均衡化,远右侧面将使用右直方图均衡化,而中心将使用左或右值和整个脸部均衡化值的平滑混合。
以下图像显示了左均衡化、整体均衡化和右均衡化图像是如何混合在一起的:

要执行此操作,我们需要整个脸部均衡化的副本,以及左半部分均衡化和右半部分均衡化的副本,具体操作如下:
int w = faceImg.cols;
int h = faceImg.rows;
Mat wholeFace;
equalizeHist(faceImg, wholeFace);
int midX = w/2;
Mat leftSide = faceImg(Rect(0,0, midX,h));
Mat rightSide = faceImg(Rect(midX,0, w-midX,h));
equalizeHist(leftSide, leftSide);
equalizeHist(rightSide, rightSide);
现在我们将这三张图像合并在一起。由于图像较小,我们可以通过使用image.at<uchar>(y,x)函数直接访问像素,即使它比较慢;因此,让我们通过直接访问三个输入图像和输出图像中的像素来合并这三张图像,如下所示:
for (int y=0; y<h; y++) {
for (int x=0; x<w; x++) {
int v;
if (x < w/4) {
// Left 25%: just use the left face.
v = leftSide.at<uchar>(y,x);
}
else if (x < w*2/4) {
// Mid-left 25%: blend the left face & whole face.
int lv = leftSide.at<uchar>(y,x);
int wv = wholeFace.at<uchar>(y,x);
// Blend more of the whole face as it moves
// further right along the face.
float f = (x - w*1/4) / (float)(w/4);
v = cvRound((1.0f - f) * lv + (f) * wv);
}
else if (x < w*3/4) {
// Mid-right 25%: blend right face & whole face.
int rv = rightSide.at<uchar>(y,x-midX);
int wv = wholeFace.at<uchar>(y,x);
// Blend more of the right-side face as it moves
// further right along the face.
float f = (x - w*2/4) / (float)(w/4);
v = cvRound((1.0f - f) * wv + (f) * rv);
}
else {
// Right 25%: just use the right face.
v = rightSide.at<uchar>(y,x-midX);
}
faceImg.at<uchar>(y,x) = v;
} // end x loop
} //end y loop
这种分离的直方图均衡化应该可以显著减少不同光照对脸部左右两侧的影响,但我们必须理解,它并不能完全消除单侧光照的影响,因为脸部是一个复杂的 3D 形状,有许多阴影。
平滑
为了减少像素噪声的影响,我们将在脸部使用双边滤波器,因为双边滤波器非常擅长在保持边缘清晰的同时平滑图像的大部分区域。直方图均衡化可以显著增加像素噪声,因此我们将滤波器强度设置为20以覆盖严重的像素噪声,但使用仅两个像素的邻域,因为我们想要大量平滑微小的像素噪声,而不是大图像区域,如下所示:
Mat filtered = Mat(warped.size(), CV_8U);
bilateralFilter(warped, filtered, 0, 20.0, 2.0);
椭圆形蒙版
尽管我们在进行几何变换时已经移除了大部分图像背景、额头和头发,但我们仍然可以应用一个椭圆形蒙版来移除一些角落区域,如颈部,这些区域可能因脸部而处于阴影中,尤其是如果脸部没有完美直视相机的话。为了创建这个蒙版,我们将在一个白色图像上绘制一个黑色填充的椭圆形。为了执行此操作,一个椭圆形的水平半径为 0.5(即完美覆盖脸部宽度),垂直半径为 0.8(因为脸部通常比宽),中心坐标为 0.5, 0.4,如下面的图像所示,其中椭圆形蒙版已经从脸部移除了一些不需要的角落:

我们可以在调用cv::setTo()函数时应用掩码,该函数通常将整个图像设置为某个像素值,但因为我们提供了一个掩码图像,所以它只会将某些部分设置为给定的像素值。我们将用灰色填充图像,以便它与面部其余部分对比度更低:
// Draw a black-filled ellipse in the middle of the image.
// First we initialize the mask image to white (255).
Mat mask = Mat(warped.size(), CV_8UC1, Scalar(255));
double dw = DESIRED_FACE_WIDTH;
double dh = DESIRED_FACE_HEIGHT;
Point faceCenter = Point( cvRound(dw * 0.5),
cvRound(dh * 0.4) );
Size size = Size( cvRound(dw * 0.5), cvRound(dh * 0.8) );
ellipse(mask, faceCenter, size, 0, 0, 360, Scalar(0),
CV_FILLED);
// Apply the elliptical mask on the face, to remove corners.
// Sets corners to gray, without touching the inner face.
filtered.setTo(Scalar(128), mask);
以下放大图像显示了所有面部预处理阶段的示例结果。请注意,它在不同亮度、面部旋转、摄像头角度、背景、灯光位置等方面对面部识别的一致性要好得多。这个预处理面部将被用作收集面部进行训练和尝试识别输入面部时的面部识别阶段的输入:

第 3 步 - 收集人脸并从中学习
收集人脸可以像将每个新预处理的面部放入来自摄像头的预处理面部数组中一样简单,以及将标签放入数组中(以指定面部是从哪个人那里拍摄的)。例如,您可以使用第一个人预处理的前 10 张面部和第二个人预处理的前 10 张面部,因此面部识别算法的输入将是一个包含 20 张预处理面部的数组和一个包含 20 个整数的数组(其中前 10 个数字是 0,接下来的 10 个数字是 1)。
面部识别算法将学习如何区分不同人的面部。这被称为训练阶段,收集的面部被称为训练集。面部识别算法完成训练后,您可以将其生成的知识保存到文件或内存中,然后用于识别摄像头前的人。这被称为测试阶段。如果您直接从摄像头输入使用,则预处理后的面部被称为测试图像;如果您使用多张图像(例如从图像文件文件夹中)进行测试,则称为测试集。
提供一个涵盖你预期在测试集中出现的各种变化的良好训练集非常重要。例如,如果你只使用正面直视的脸部进行测试(例如身份证照片),那么你只需要提供正面直视的脸部训练图像。但如果这个人可能向左或向上看,那么你应该确保训练集也包括这个人这样做时的脸部图像,否则面部识别算法将难以识别他们,因为他们的脸部看起来会相当不同。这也适用于其他因素,如面部表情(例如,如果训练集中的人总是微笑,但在测试集中不微笑)或光照方向(例如,训练集中强烈的灯光在左侧,但在测试集中在右侧),那么面部识别算法将难以识别他们。我们刚才看到的面部预处理步骤将有助于减少这些问题,但绝对不能消除这些因素,尤其是脸部朝向的方向,因为它对脸部所有元素的位置都有很大影响。
获得一个能够涵盖许多不同真实世界条件的良好训练集的一种方法,是让每个人从向左看,转到向上看,再转到向右看,然后向下看,最后直接向前看。然后,这个人将头部向侧面倾斜,并上下移动,同时改变面部表情,例如在微笑、愤怒和保持中性表情之间交替。如果每个人在收集面部图像时都遵循这样的常规,那么在真实世界条件下识别每个人的可能性会大得多。
为了获得更好的结果,应该再次在更多的一个或两个位置或方向上执行,例如通过将相机旋转 180 度,然后向与相机相反的方向行走,并重复整个程序,这样训练集就会包括许多不同的光照条件。
因此,一般来说,为每个人提供 100 个训练面部可能比每人只有 10 个训练面部给出更好的结果,但如果所有 100 个面部几乎完全相同,那么它仍然表现不佳,因为更重要的是训练集有足够的多样性来覆盖测试集,而不是仅仅拥有大量的面部。所以为了确保训练集中的面部不是都太相似,我们应在每个收集到的面部之间添加一个明显的延迟。例如,如果相机以每秒 30 帧的速度运行,那么当这个人没有时间移动时,它可能在几秒钟内收集 100 个面部,所以最好每秒只收集一个面部,同时这个人移动他们的面部。另一种提高训练集多样性的简单方法是在收集面部时,只有当它与之前收集到的面部明显不同时才收集。
收集预处理面部进行训练
为了确保收集新面部之间至少有 1 秒的间隔,我们需要测量已经过去的时间。这是按照以下步骤进行的:
// Check how long since the previous face was added.
double current_time = (double)getTickCount();
double timeDiff_seconds = (current_time -
old_time) / getTickFrequency();
要比较两张图像的相似度,逐像素比较,你可以找到相对 L2 误差,这仅仅涉及从一张图像中减去另一张图像,求和其平方值,然后取其平方根。所以如果这个人完全没有移动,当前面部与之前面部的减法应该在每个像素上给出一个非常小的数字,但如果他们只在任何方向上稍微移动了一点,减去像素会给出一个大的数字,因此 L2 误差会很高。由于结果是所有像素的总和,所以这个值将取决于图像分辨率。因此,为了得到平均误差,我们应该将这个值除以图像中的总像素数。让我们把这个放入一个方便的函数getSimilarity()中,如下所示:
double getSimilarity(const Mat A, const Mat B) {
// Calculate the L2 relative error between the 2 images.
double errorL2 = norm(A, B, CV_L2);
// Scale the value since L2 is summed across all pixels.
double similarity = errorL2 / (double)(A.rows * A.cols);
return similarity;
}
...
// Check if this face looks different from the previous face.
double imageDiff = MAX_DBL;
if (old_prepreprocessedFaceprepreprocessedFace.data) {
imageDiff = getSimilarity(preprocessedFace,
old_prepreprocessedFace);
}
如果图像没有太大移动,这个相似度通常会小于 0.2,
如果图像移动了,这个相似度会高于 0.4,所以让我们将 0.3 作为我们收集的阈值
一个新的面部。
我们可以采取许多技巧来获取更多的训练数据,例如使用镜像面部、添加随机噪声、将面部移动几个像素、按百分比缩放面部,或者旋转面部几个度(尽管我们在预处理面部时特意尝试去除这些效果!)。让我们将镜像面部添加到训练集中,这样我们就有了一个更大的训练集,同时减少了不对称面部的问题,或者如果用户在训练时总是稍微向左或向右倾斜,但在测试时没有这样做。这是按照以下步骤进行的:
// Only process the face if it's noticeably different from the
// previous frame and there has been a noticeable time gap.
if ((imageDiff > 0.3) && (timeDiff_seconds > 1.0)) {
// Also add the mirror image to the training set.
Mat mirroredFace;
flip(preprocessedFace, mirroredFace, 1);
// Add the face & mirrored face to the detected face lists.
preprocessedFaces.push_back(preprocessedFace);
preprocessedFaces.push_back(mirroredFace);
faceLabels.push_back(m_selectedPerson);
faceLabels.push_back(m_selectedPerson);
// Keep a copy of the processed face,
// to compare on next iteration.
old_prepreprocessedFace = preprocessedFace;
old_time = current_time;
}
这将收集预处理面部std::vector数组preprocessedFaces和faceLabels以及该人的标签或 ID 号(假设它在整数m_selectedPerson变量中)。
为了让用户更明显地意识到我们已经将他们的当前面部添加到集合中,您可以通过在整张图像上显示一个大的白色矩形或仅显示他们的面部几秒钟来实现视觉通知,使他们意识到已经拍摄了一张照片。使用 OpenCV 的 C++接口,您可以使用+重载的cv::Mat运算符向图像中的每个像素添加值,并将其裁剪到 255(使用saturate_cast,这样就不会从白色溢出到黑色!)假设displayedFrame将是应该显示的颜色摄像头帧的副本,在收集人脸的前面代码后插入此代码:
// Get access to the face region-of-interest.
Mat displayedFaceRegion = displayedFrame(faceRect);
// Add some brightness to each pixel of the face region.
displayedFaceRegion += CV_RGB(90,90,90);
从收集的人脸训练人脸识别系统
在为每个人收集足够的人脸以供识别后,您必须使用适合人脸识别的机器学习算法来训练系统学习数据。文献中有很多不同的人脸识别算法,其中最简单的是 Eigenfaces 和人工神经网络。Eigenfaces 通常比人工神经网络表现更好,尽管它很简单,但它的表现几乎与许多更复杂的人脸识别算法相当,因此它已成为初学者以及与新技术比较的基本人脸识别算法。
建议任何希望进一步研究人脸识别的读者阅读以下理论:
-
Eigenfaces(也称为主成分分析(PCA))
-
Fisherfaces(也称为线性判别分析(LDA))
-
其他经典的人脸识别算法(许多可以在
www.face-rec.org/algorithms/找到) -
近期计算机视觉研究论文中更新的面部识别算法(如 CVPR 和 ICCV 在
www.cvpapers.com/),每年有数百篇面部识别论文发表
然而,您不需要理解这些算法的理论,就可以像本书中展示的那样使用它们。感谢 OpenCV 团队和 Philipp Wagner 的libfacerec贡献,OpenCV v2.4.1 提供了cv::Algorithm作为使用几种不同算法(甚至可以在运行时选择)进行人脸识别的简单通用方法,而不必理解它们是如何实现的。您可以通过使用Algorithm::getList()函数找到您版本 OpenCV 中可用的算法,例如以下代码:
vector<string> algorithms;
Algorithm::getList(algorithms);
cout << "Algorithms: " << algorithms.size() << endl;
for (int i=0; i<algorithms.size(); i++) {
cout << algorithms[i] << endl;
}
下面是 OpenCV v2.4.1 中可用的三种人脸识别算法:
-
FaceRecognizer.Eigenfaces:Eigenfaces,也称为 PCA,由 Turk 和 Pentland 于 1991 年首次使用。 -
FaceRecognizer.Fisherfaces:Fisherfaces,也称为 LDA,由 Belhumeur、Hespanha 和 Kriegman 于 1997 年发明。 -
FaceRecognizer.LBPH:局部二值模式直方图,由 Ahonen、Hadid 和 Pietikäinen 于 2004 年发明。
关于这些人脸识别算法实现的更多信息可以在 Philipp Wagner 的网站上找到,包括文档、示例和每个算法的 Python 等效代码(bytefish.de/blog 和 bytefish.de/dev/libfacerec/)。
这些人脸识别算法可以通过 OpenCV 的contrib模块中的FaceRecognizer类获得。由于动态链接,您的程序可能链接到了contrib模块,但在运行时实际上并没有加载(如果它被认为不是必需的)。因此,建议在尝试访问FaceRecognizer算法之前调用cv::initModule_contrib()函数。此函数仅从 OpenCV v2.4.1 版本开始提供,因此它还确保人脸识别算法至少在编译时对您可用:
// Load the "contrib" module is dynamically at runtime.
bool haveContribModule = initModule_contrib();
if (!haveContribModule) {
cerr << "ERROR: The 'contrib' module is needed for ";
cerr << "FaceRecognizer but hasn't been loaded to OpenCV!";
cerr << endl;
exit(1);
}
要使用人脸识别算法之一,我们必须使用cv::Algorithm::create<FaceRecognizer>()函数创建一个FaceRecognizer对象。我们将要使用的人脸识别算法的名称作为字符串传递给这个创建函数。这将使我们能够访问该算法,如果它在 OpenCV 版本中可用。因此,它可以作为一个运行时错误检查,以确保用户有 OpenCV v2.4.1 或更新的版本。例如:
string facerecAlgorithm = "FaceRecognizer.Fisherfaces";
Ptr<FaceRecognizer> model;
// Use OpenCV's new FaceRecognizer in the "contrib" module:
model = Algorithm::create<FaceRecognizer>(facerecAlgorithm);
if (model.empty()) {
cerr << "ERROR: The FaceRecognizer [" << facerecAlgorithm;
cerr << "] is not available in your version of OpenCV. ";
cerr << "Please update to OpenCV v2.4.1 or newer." << endl;
exit(1);
}
一旦我们加载了FaceRecognizer算法,我们只需使用以下方式调用FaceRecognizer::train()函数,并传入我们收集到的面部数据:
// Do the actual training from the collected faces.
model->train(preprocessedFaces, faceLabels);
这一行代码将运行您所选的整个人脸识别训练算法(例如,Eigenfaces、Fisherfaces 或可能的其他算法)。如果您只有少数人,且每人少于 20 张脸,那么这次训练应该会非常快地完成,但如果您有很多人,且每人有很多张脸,那么train()函数可能需要几秒钟甚至几分钟来处理所有数据。
查看学习到的知识
虽然这不是必需的,但查看人脸识别算法在学习您的训练数据时生成的内部数据结构非常有用,尤其是如果您理解您选择的算法背后的理论,并希望验证它是否按预期工作或找出为什么它没有按预期工作。不同的算法可能有不同的内部数据结构,但幸运的是,对于主成分面(eigenfaces)和 Fisher 面(fisherfaces),它们是相同的,所以我们只需查看这两个。它们都基于 1D 特征向量矩阵,当作为 2D 图像查看时,看起来有点像人脸,因此在使用Eigenface算法时通常将特征向量称为 eigenfaces,在使用Fisherface算法时称为 fisherfaces。
简而言之,Eigenfaces 的基本原理是,它将计算一组特殊的图像(特征脸)和混合比(特征值),这些图像以不同的方式组合可以生成训练集中每个图像,同时也可以用来区分训练集中许多不同的脸图像。例如,如果训练集中的一些人脸有胡须,而另一些没有,那么至少会有一个特征脸显示出胡须,因此有胡须的训练人脸将会有一个高混合比的特征脸来显示它有胡须,而没有胡须的人脸将会有一个低混合比的特征向量。如果训练集中有五个人,每人有 20 张脸,那么将会有 100 个特征脸和特征值来区分训练集中的 100 张脸,实际上这些特征脸和特征值会被排序,前几个特征脸和特征值将是最重要的区分因素,而最后几个特征脸和特征值将只是随机像素噪声,实际上并不能帮助区分数据。因此,通常的做法是丢弃一些最后的特征脸,只保留前 50 个左右的特征脸。
与之相比,Fisherfaces 的基本原理是,它不是为训练集中每个图像计算一个特殊的特征向量和特征值,而是为每个人只计算一个特殊的特征向量和特征值。因此,在前面提到的例子中,有五个人,每人有 20 张脸,Eigenfaces 算法将使用 100 个特征脸和特征值,而 Fisherfaces 算法只需使用五个 fisherfaces 和特征值。
要访问 Eigenfaces 和 Fisherfaces 算法的内部数据结构,我们必须使用cv::Algorithm::get()函数在运行时获取它们,因为在编译时无法访问它们。这些数据结构作为数学计算的一部分内部使用,而不是用于图像处理,因此它们通常以介于 0.0 和 1.0 之间的浮点数存储,而不是介于 0 到 255 之间的 8 位uchar像素,类似于常规图像中的像素。此外,它们通常是 1D 行或列矩阵,或者构成更大矩阵的许多 1D 行或列之一。因此,在您能够显示这些内部数据结构之前,您必须将它们重塑为正确的矩形形状,并将它们转换为介于 0 到 255 之间的 8 位uchar像素。由于矩阵数据可能介于 0.0 到 1.0 或-1.0 到 1.0 或任何其他值,您可以使用带有cv::NORM_MINMAX选项的cv::normalize()函数来确保无论输入范围如何,它都输出介于 0 到 255 之间的数据。以下是一个创建函数以执行此重塑到矩形形状并将它们转换为 8 位像素的示例:
// Convert the matrix row or column (float matrix) to a
// rectangular 8-bit image that can be displayed or saved.
// Scales the values to be between 0 to 255\.
Mat getImageFrom1DFloatMat(const Mat matrixRow, int height)
{
// Make a rectangular shaped image instead of a single row.
Mat rectangularMat = matrixRow.reshape(1, height);
// Scale the values to be between 0 to 255 and store them
// as a regular 8-bit uchar image.
Mat dst;
normalize(rectangularMat, dst, 0, 255, NORM_MINMAX,
CV_8UC1);
return dst;
}
为了更容易调试 OpenCV 代码,尤其是在内部调试cv::Algorithm数据结构时,我们可以使用ImageUtils.cpp和ImageUtils.h文件来轻松显示有关cv::Mat结构的信息,如下所示:
Mat img = ...;
printMatInfo(img, "My Image");
你将在控制台看到类似以下内容打印出来:
My Image: 640w480h 3ch 8bpp, range[79,253][20,58][18,87]
这表明它宽度为 640 个元素,高度为 480 个元素(即 640 x 480 的图像或 480 x 640 的矩阵,具体取决于你如何查看它),每个像素有三个通道,每个通道是 8 位的(即常规 BGR 图像),并且它显示了图像中每个颜色通道的最小值和最大值。
也可以通过使用printMat()函数而不是printMatInfo()函数来打印图像或矩阵的实际内容。这对于查看矩阵和多通道浮点矩阵非常有用,因为这些对于初学者来说可能相当难以查看。
ImageUtils代码主要是为 OpenCV 的 C 接口编写的,但随着时间的推移,它逐渐包括了更多的 C++接口。最新版本可以在shervinemami.info/openCV.html找到。
平均脸
无论是 Eigenfaces 算法还是 Fisherfaces 算法,它们首先计算平均脸,即所有训练图像的数学平均值,因此它们可以从每个面部图像中减去平均图像,以获得更好的面部识别结果。因此,让我们查看我们的训练集中的平均脸。在 Eigenfaces 和 Fisherfaces 实现中,平均脸被命名为mean,如下所示:
Mat averageFace = model->get<Mat>("mean");
printMatInfo(averageFace, "averageFace (row)");
// Convert a 1D float row matrix to a regular 8-bit image.
averageFace = getImageFrom1DFloatMat(averageFace, faceHeight);
printMatInfo(averageFace, "averageFace");
imshow("averageFace", averageFace);
你现在应该在屏幕上看到类似以下(放大)的平均脸图像,它是一个男人、一个女人和一个婴儿的组合。你也应该在控制台上看到类似此处的文本:
averageFace (row): 4900w1h 1ch 64bpp, range[5.21,251.47]
averageFace: 70w70h 1ch 8bpp, range[0,255]
图像将如图下所示截图所示:

注意到averageFace (row)是一个 64 位浮点数的单行矩阵,而averageFace是一个覆盖整个范围的 8 位像素的矩形图像
从 0 到 255。
特征值、Eigenfaces 和 Fisherfaces
让我们查看特征值中的实际分量值(以文本形式):
Mat eigenvalues = model->get<Mat>("eigenvalues");
printMat(eigenvalues, "eigenvalues");
对于 Eigenfaces,每个面部都有一个特征值,所以如果我们有三个人各有四个面部,我们得到一个包含 12 个特征值的列向量,从最好到最差排序如下:
eigenvalues: 1w18h 1ch 64bpp, range[4.52e+04,2.02836e+06]
2.03e+06
1.09e+06
5.23e+05
4.04e+05
2.66e+05
2.31e+05
1.85e+05
1.23e+05
9.18e+04
7.61e+04
6.91e+04
4.52e+04
对于 Fisherfaces,每个额外的人只有一个特征值,所以如果有三个人各有四个面部,我们只得到一个包含两个特征值的行向量,如下所示:
eigenvalues: 2w1h 1ch 64bpp, range[152.4,316.6]
317, 152
要查看特征向量(作为特征脸或费舍尔脸图像),我们必须从大特征向量矩阵中提取它们作为列。由于 OpenCV 和 C/C++中的数据通常使用行主序存储在矩阵中,这意味着要提取一列,我们应该使用Mat::clone()函数来确保数据将是连续的,否则我们无法将数据重塑为矩形。一旦我们有一个连续的列Mat,我们就可以使用getImageFrom1DFloatMat()函数来显示特征向量,就像我们为平均脸所做的那样:
// Get the eigenvectors
Mat eigenvectors = model->get<Mat>("eigenvectors");
printMatInfo(eigenvectors, "eigenvectors");
// Show the best 20 eigenfaces
for (int i = 0; i < min(20, eigenvectors.cols); i++) {
// Create a continuous column vector from eigenvector #i.
Mat eigenvector = eigenvectors.col(i).clone();
Mat eigenface = getImageFrom1DFloatMat(eigenvector,
faceHeight);
imshow(format("Eigenface%d", i), eigenface);
}
下图显示了特征向量作为图像。你可以看到,对于有四个脸的三个人的情况,存在 12 个特征脸(图的左侧)或两个费舍尔脸(图的右侧):

注意,特征脸和费舍尔脸似乎都有一些面部特征的相似之处,但它们实际上并不像脸。这仅仅是因为从它们中减去了平均脸,所以它们只是显示了每个特征脸与平均脸的差异。编号显示了它是哪个特征脸,因为它们总是从最重要的特征脸到最不重要的特征脸有序排列,如果你有 50 个或更多的特征脸,那么后面的特征脸通常会只显示随机的图像噪声,因此应该被丢弃。
第 4 步 - 面部识别
现在我们已经使用我们的训练图像和面部标签集训练了特征脸或费舍尔脸机器学习算法,我们终于准备好确定一个人的身份,仅从面部图像中!这一最后步骤被称为面部识别或面部识别。
面部识别 - 从面部识别人
感谢 OpenCV 的FaceRecognizer类,我们可以通过在面部图像上调用FaceRecognizer::predict()函数来简单地识别照片中的人
如下所示:
int identity = model->predict(preprocessedFace);
这个identity值将是我们最初在收集训练用面部时使用的标签号。例如,第一个人为 0,第二个人为 1,依此类推。
这个识别的问题在于,它总是会预测给定的人之一,即使输入的照片是未知的人或汽车的照片。它仍然会告诉你照片中最可能的人是谁,因此很难相信结果!解决方案是获得一个置信度指标,这样我们就可以判断结果有多可靠,如果看起来置信度太低,那么我们假设它是一个未知的人。
面部验证 - 验证是否为声称的人
为了确认预测结果是否可靠,或者是否应该将其视为未知的人,我们执行面部验证(也称为面部认证),以获得一个置信度指标,显示单个面部图像是否与声称的人相似(与我们所做的面部识别相反,我们比较的是单个面部图像与许多人)。
当你调用predict()函数时,OpenCV 的FaceRecognizer类可以返回一个置信度指标,但遗憾的是,置信度指标仅仅是基于特征子空间中的距离,因此它并不非常可靠。我们将使用的方法是使用特征向量和特征值来重建面部图像,并将这个重建图像与输入图像进行比较。如果这个人在训练集中包含了很多面部,那么从学习到的特征向量和特征值中重建应该会相当好,但如果这个人在训练集中没有任何面部(或者没有与测试图像具有相似照明和面部表情的面部),那么重建的面部将与输入面部非常不同,这表明它可能是一个未知的面部。
记得我们之前说过,Eigenfaces 和 Fisherfaces 算法是基于这样的观点:一个图像可以被大致表示为一组特征向量(特殊的面部图像)和特征值(混合比率)。因此,如果我们结合训练集中某个面部特征向量和特征值,我们应该能够获得一个相当接近原始训练图像的复制品。同样的原理也适用于与训练集相似的其他图像——如果我们结合训练的特征向量和与训练集相似的测试图像的特征值,我们应该能够重建一个与测试图像相似度较高的图像。
再次强调,OpenCV 的FaceRecognizer类通过使用subspaceProject()函数将图像投影到特征空间,以及使用subspaceReconstruct()函数从特征空间返回到图像空间,使得从任何输入图像生成重建人脸变得非常容易。技巧在于我们需要将其从浮点行矩阵转换为矩形 8 位图像(就像我们在显示平均人脸和特征人脸时做的那样),但我们不想归一化数据,因为它已经在理想的尺度上,可以与原始图像进行比较。如果我们归一化数据,它将与输入图像具有不同的亮度和对比度,这将使得仅通过使用 L2 相对误差来比较图像相似度变得困难。以下是实现方法:
// Get some required data from the FaceRecognizer model.
Mat eigenvectors = model->get<Mat>("eigenvectors");
Mat averageFaceRow = model->get<Mat>("mean");
// Project the input image onto the eigenspace.
Mat projection = subspaceProject(eigenvectors, averageFaceRow,
preprocessedFace.reshape(1,1));
// Generate the reconstructed face back from the eigenspace.
Mat reconstructionRow = subspaceReconstruct(eigenvectors,
averageFaceRow, projection);
// Make it a rectangular shaped image instead of a single row.
Mat reconstructionMat = reconstructionRow.reshape(1,
faceHeight);
// Convert the floating-point pixels to regular 8-bit uchar.
Mat reconstructedFace = Mat(reconstructionMat.size(), CV_8U);
reconstructionMat.convertTo(reconstructedFace, CV_8U, 1, 0);
以下图像展示了两个典型的重建人脸。左侧的人脸重建得很好,因为它来自一个已知的人,而右侧的人脸重建得不好,因为它来自一个未知的人或者是一个已知的人但具有未知的照明条件/面部表情/面部方向:

现在我们可以通过使用之前为比较两张图像而创建的相同的getSimilarity()函数来计算这个重建的面部与输入面部的相似度。其中,小于 0.3 的值意味着两张图像非常相似。对于 Eigenfaces,每个面部都有一个特征向量,因此重建通常效果很好,因此我们可以通常使用 0.5 的阈值,但 Fisherfaces 对每个人只有一个特征向量,因此重建效果不会很好,因此需要更高的阈值,比如 0.7。以下是实现方法:
similarity = getSimilarity(preprocessedFace, reconstructedFace);
if (similarity > UNKNOWN_PERSON_THRESHOLD) {
identity = -1; // Unknown person.
}
现在,你只需将身份信息打印到控制台,或者将其用于你的想象所及之处!记住,这种面部识别方法和这种面部验证方法只有在训练它们时特定的条件下才是可靠的。因此,为了获得良好的识别精度,你需要确保每个人的训练集涵盖了预期的测试中的全部光照条件、面部表情和角度。面部预处理阶段有助于减少与光照条件和平面旋转(如果人将头部倾斜向左或右肩)的一些差异,但对于其他差异,如平面外旋转(如果人将头部转向左侧或右侧),只有在你的训练集中得到很好的覆盖时才会有效。
完成细节 - 保存和加载文件
你可以潜在地添加一个基于命令行的方法来处理输入文件并将它们保存到磁盘上,或者甚至将面部检测、面部预处理和/或面部识别作为网络服务执行,等等。对于这些类型的项目,通过使用FaceRecognizer类的save和load函数添加所需的功能相当容易。你可能还想在程序启动时保存训练数据然后加载它。
将训练模型保存到 XML 或 YML 文件非常简单:
model->save("trainedModel.yml");
你可能还想保存预处理过的面部和标签数组,以便以后向训练集添加更多数据。
例如,以下是从文件中加载训练模型的示例代码。请注意,你必须指定最初用于创建训练模型的面部识别算法(例如,FaceRecognizer.Eigenfaces或FaceRecognizer.Fisherfaces):
string facerecAlgorithm = "FaceRecognizer.Fisherfaces";
model = Algorithm::create<FaceRecognizer>(facerecAlgorithm);
Mat labels;
try {
model->load("trainedModel.yml");
labels = model->get<Mat>("labels");
} catch (cv::Exception &e) {}
if (labels.rows <= 0) {
cerr << "ERROR: Couldn't load trained data from "
"[trainedModel.yml]!" << endl;
exit(1);
}
完成细节 - 制作一个漂亮且交互式的 GUI
尽管本章中给出的代码足以构建一个完整的面部识别系统,但仍需要一种方法将数据放入系统中,以及一种使用它的方法。许多用于研究的人脸识别系统会选择将文本文件作为理想的输入,列出静态图像文件在计算机上的存储位置,以及其他重要数据,例如人员的真实姓名或身份,以及面部区域的真实像素坐标(例如面部和眼睛中心的确切位置)。这些数据可以手动收集,或者由另一个面部识别系统收集。
理想输出将是一个文本文件,比较识别结果与地面实况,以便可以获得统计数据,用于比较面部识别系统与其他面部识别系统。
然而,由于本章中的人脸识别系统旨在学习以及实际娱乐目的,而不是与最新的研究方法竞争,因此拥有一个易于使用的 GUI,允许从实时摄像头交互式地进行面部收集、训练和测试,是非常有用的。因此,本节将提供一个提供这些功能的交互式 GUI。预期读者要么使用本书附带提供的 GUI,要么根据自身目的修改 GUI,或者忽略此 GUI 并设计自己的 GUI 以执行迄今为止讨论的人脸识别技术。
由于我们需要 GUI 执行多个任务,让我们创建一组 GUI 将具有的模式或状态,以及用户可以通过按钮或鼠标点击来更改模式:
-
启动:此状态加载并初始化数据和摄像头。
-
检测:此状态检测面部,并在预处理后显示,直到用户点击添加人员按钮。
-
收集:此状态收集当前人员的面部,直到用户在窗口中点击任何位置。这也会显示每个人的最新面部。用户点击现有人员之一或添加人员按钮,以收集不同人员的面部。
-
训练:在此状态下,系统使用收集到的所有人员的所有面部进行训练。
-
识别:这包括突出显示识别出的人员并显示置信度计。用户点击人员之一或添加人员按钮,以返回模式 2(收集)。
要退出,用户可以在窗口中的任何时间按Esc键。我们还可以添加一个删除所有模式,该模式将重新启动一个新的面部识别系统,以及一个切换额外调试信息显示的调试按钮。我们可以创建一个枚举的mode变量来显示当前模式。
绘制 GUI 元素
要在屏幕上显示当前模式,让我们创建一个函数来轻松绘制文本。OpenCV 提供了一个具有多种字体和抗锯齿的 cv::putText() 函数,但将其放置在正确位置可能有些棘手。幸运的是,还有一个 cv::getTextSize() 函数可以计算文本周围的边界框,因此我们可以创建一个包装函数来简化文本的放置。我们希望能够在窗口的任何边缘放置文本,并确保它完全可见,同时允许放置多行或多词文本,而不会相互覆盖。所以这里有一个包装函数,允许您指定左对齐或右对齐,以及指定顶部对齐或底部对齐,并返回边界框,这样我们就可以轻松地在窗口的任何角落或边缘绘制多行文本:
// Draw text into an image. Defaults to top-left-justified
// text, so give negative x coords for right-justified text,
// and/or negative y coords for bottom-justified text.
// Returns the bounding rect around the drawn text.
Rect drawString(Mat img, string text, Point coord, Scalar
color, float fontScale = 0.6f, int thickness = 1,
int fontFace = FONT_HERSHEY_COMPLEX);
现在要在 GUI 上显示当前模式,由于窗口的背景是摄像头视频流,如果我们直接在摄像头视频流上绘制文本,它可能与摄像头背景颜色相同!所以让我们只绘制一个与前景文本相隔 1 像素的黑色阴影文本。我们还可以在下面绘制一行有用的文本,以便用户知道要遵循的步骤。以下是如何使用 drawString() 函数绘制文本的示例:
string msg = "Click [Add Person] when ready to collect faces.";
// Draw it as black shadow & again as white text.
float txtSize = 0.4;
int BORDER = 10;
drawString (displayedFrame, msg, Point(BORDER, -BORDER-2),
CV_RGB(0,0,0), txtSize);
Rect rcHelp = drawString(displayedFrame, msg, Point(BORDER+1,
-BORDER-1), CV_RGB(255,255,255), txtSize);
以下部分截图显示了 GUI 窗口底部的模式和信息,叠加在相机图像之上:

我们提到过,我们想要一些 GUI 按钮,所以让我们创建一个函数来轻松绘制 GUI 按钮,如下所示:
// Draw a GUI button into the image, using drawString().
// Can give a minWidth to have several buttons of same width.
// Returns the bounding rect around the drawn button.
Rect drawButton(Mat img, string text, Point coord,
int minWidth = 0)
{
const int B = 10;
Point textCoord = Point(coord.x + B, coord.y + B);
// Get the bounding box around the text.
Rect rcText = drawString(img, text, textCoord,
CV_RGB(0,0,0));
// Draw a filled rectangle around the text.
Rect rcButton = Rect(rcText.x - B, rcText.y - B,
rcText.width + 2*B, rcText.height + 2*B);
// Set a minimum button width.
if (rcButton.width < minWidth)
rcButton.width = minWidth;
// Make a semi-transparent white rectangle.
Mat matButton = img(rcButton);
matButton += CV_RGB(90, 90, 90);
// Draw a non-transparent white border.
rectangle(img, rcButton, CV_RGB(200,200,200), 1, CV_AA);
// Draw the actual text that will be displayed.
drawString(img, text, textCoord, CV_RGB(10,55,20));
return rcButton;
}
现在我们使用 drawButton() 函数创建几个可点击的 GUI 按钮,
它将始终显示在 GUI 的左上角,如下所示
部分截图:

正如我们提到的,GUI 程序有一些模式,它会在这些模式之间切换(作为一个有限状态机),从启动模式开始。我们将当前模式存储为 m_mode 变量。
启动模式
在启动模式下,我们只需要加载 XML 检测器文件以检测面部和眼睛,并初始化摄像头,这部分我们已经讨论过了。让我们也创建一个带有鼠标回调函数的主 GUI 窗口,当用户在我们的窗口中移动或点击鼠标时,OpenCV 将会调用这个函数。也许还需要将摄像头分辨率设置为合理的值;例如,如果摄像头支持的话,640x480。以下是实现方式:
// Create a GUI window for display on the screen.
namedWindow(windowName);
// Call "onMouse()" when the user clicks in the window.
setMouseCallback(windowName, onMouse, 0);
// Set the camera resolution. Only works for some systems.
videoCapture.set(CV_CAP_PROP_FRAME_WIDTH, 640);
videoCapture.set(CV_CAP_PROP_FRAME_HEIGHT, 480);
// We're already initialized, so let's start in Detection mode.
m_mode = MODE_DETECTION;
检测模式
在检测模式下,我们希望持续检测面部和眼睛,围绕它们绘制矩形或圆形以显示检测结果,并显示当前的预处理面部。实际上,我们希望无论处于哪种模式,这些内容都能显示出来。检测模式唯一特殊的地方是,当用户点击添加人员按钮时,它将切换到下一个模式(收集)。
如果您还记得本章之前提到的检测步骤,我们的检测阶段的输出将是:
-
Mat preprocessedFace: 预处理的脸部图像(如果检测到脸部和眼睛)were detected).
-
Rect faceRect: 检测到的脸部区域坐标。 -
Point leftEye,rightEye: 检测到的左右眼中心坐标。
因此,我们应该检查是否返回了预处理的脸部图像,并在检测到脸部和眼睛的情况下绘制围绕脸部和眼睛的矩形和圆圈,如下所示:
bool gotFaceAndEyes = false;
if (preprocessedFace.data)
gotFaceAndEyes = true;
if (faceRect.width > 0) {
// Draw an anti-aliased rectangle around the detected face.
rectangle(displayedFrame, faceRect, CV_RGB(255, 255, 0), 2,
CV_AA);
// Draw light-blue anti-aliased circles for the 2 eyes.
Scalar eyeColor = CV_RGB(0,255,255);
if (leftEye.x >= 0) { // Check if the eye was detected
circle(displayedFrame, Point(faceRect.x + leftEye.x,
faceRect.y + leftEye.y), 6, eyeColor, 1, CV_AA);
}
if (rightEye.x >= 0) { // Check if the eye was detected
circle(displayedFrame, Point(faceRect.x + rightEye.x,
faceRect.y + rightEye.y), 6, eyeColor, 1, CV_AA);
}
}
我们将在窗口顶部中央叠加当前预处理的脸部图像
as follows:
int cx = (displayedFrame.cols - faceWidth) / 2;
if (preprocessedFace.data) {
// Get a BGR version of the face, since the output is BGR.
Mat srcBGR = Mat(preprocessedFace.size(), CV_8UC3);
cvtColor(preprocessedFace, srcBGR, CV_GRAY2BGR);
// Get the destination ROI.
Rect dstRC = Rect(cx, BORDER, faceWidth, faceHeight);
Mat dstROI = displayedFrame(dstRC);
// Copy the pixels from src to dst.
srcBGR.copyTo(dstROI);
}
// Draw an anti-aliased border around the face.
rectangle(displayedFrame, Rect(cx-1, BORDER-1, faceWidth+2,
faceHeight+2), CV_RGB(200,200,200), 1, CV_AA);
以下截图显示了在检测模式下的显示 GUI。
预处理的脸部图像显示在顶部中央,检测到的脸部和
eyes are marked:

集合模式
当用户点击添加人员按钮以表示他们想要开始收集新人物的脸部图像时,我们进入集合模式。如前所述,我们将脸部收集限制为每秒一个脸部,并且只有当它与之前收集的脸部有显著变化时才进行收集。并且记住,我们决定收集不仅包括预处理的脸部,还包括预处理的镜像图像。
在集合模式中,我们希望显示每个已知人物的最新脸部图像,并允许用户点击其中之一以添加更多脸部图像,或者点击添加人员按钮以将新人物添加到集合中。用户必须点击窗口中间的某个位置以继续到下一个(训练模式)模式。
因此,首先我们需要为每个人收集的最新脸部图像保留一个引用。我们将通过更新整数数组m_latestFaces来完成此操作,该数组仅存储每个人员在大型preprocessedFaces数组(即所有人的所有脸部图像的集合)中的数组索引。由于我们也在该数组中存储了镜像脸部,我们想要引用倒数第二个脸部,而不是最后一个脸部。以下代码应附加到将新脸部(和镜像脸部)添加到preprocessedFaces数组的代码中,如下所示:
// Keep a reference to the latest face of each person.
m_latestFaces[m_selectedPerson] = preprocessedFaces.size() - 2;
我们只需要记住,每当添加或删除新人物时(例如,由于用户点击添加人员按钮),我们都要记得增长或缩小m_latestFaces数组。现在让我们按照以下方式显示收集到的每个人的最新脸部图像,在窗口的右侧(在后续的集合模式和识别模式中):
m_gui_faces_left = displayedFrame.cols - BORDER - faceWidth;
m_gui_faces_top = BORDER;
for (int i=0; i<m_numPersons; i++) {
int index = m_latestFaces[i];
if (index >= 0 && index < (int)preprocessedFaces.size()) {
Mat srcGray = preprocessedFaces[index];
if (srcGray.data) {
// Get a BGR face, since the output is BGR.
Mat srcBGR = Mat(srcGray.size(), CV_8UC3);
cvtColor(srcGray, srcBGR, CV_GRAY2BGR);
// Get the destination ROI
int y = min(m_gui_faces_top + i * faceHeight,
displayedFrame.rows - faceHeight);
Rect dstRC = Rect(m_gui_faces_left, y, faceWidth,
faceHeight);
Mat dstROI = displayedFrame(dstRC);
// Copy the pixels from src to dst.
srcBGR.copyTo(dstROI);
}
}
}
我们还希望使用粗红色的边框突出显示当前正在收集的人物,如下所示:
if (m_mode == MODE_COLLECT_FACES) {
if (m_selectedPerson >= 0 &&
m_selectedPerson < m_numPersons) {
int y = min(m_gui_faces_top + m_selectedPerson *
faceHeight, displayedFrame.rows - faceHeight);
Rect rc = Rect(m_gui_faces_left, y, faceWidth, faceHeight);
rectangle(displayedFrame, rc, CV_RGB(255,0,0), 3, CV_AA);
}
}
以下部分截图显示了收集到几个人的脸部图像时的典型显示。用户可以点击右上角的人来收集该人的更多脸部图像。

训练模式
当用户最终点击窗口中间时,面部识别算法将开始对所有收集到的面部进行训练。但重要的是要确保收集到了足够多的面部或人物,否则程序可能会崩溃。一般来说,这只需要确保训练集中至少有一个面部(这意味着至少有一个人)。但 Fisherfaces 算法寻找人物之间的比较,所以如果训练集中少于两个人,它也会崩溃。因此,我们必须检查所选的面部识别算法是否为 Fisherfaces。如果是的话,那么我们要求至少有两个人有面部,否则我们要求至少有一个人有面部。如果数据不足,程序将回到收集模式,以便用户在训练前添加更多面部。
为了检查是否至少有两个人收集到了面部,我们可以确保当用户点击添加人员按钮时,只有在没有空人员(即添加了但没有收集到任何面部的人)的情况下才会添加新人员。如果只有两个人,并且我们使用 Fisherfaces 算法,那么我们必须确保在收集模式下为最后一个人设置了m_latestFaces引用。m_latestFaces[i]在还没有为该人添加任何面部时初始化为-1,一旦为该人添加了面部,它就变为0或更高。这是按照以下方式完成的:
// Check if there is enough data to train from.
bool haveEnoughData = true;
if (!strcmp(facerecAlgorithm, "FaceRecognizer.Fisherfaces")) {
if ((m_numPersons < 2) ||
(m_numPersons == 2 && m_latestFaces[1] < 0) ) {
cout << "Fisherfaces needs >= 2 people!" << endl;
haveEnoughData = false;
}
}
if (m_numPersons < 1 || preprocessedFaces.size() <= 0 ||
preprocessedFaces.size() != faceLabels.size()) {
cout << "Need data before it can be learnt!" << endl;
haveEnoughData = false;
}
if (haveEnoughData) {
// Train collected faces using Eigenfaces or Fisherfaces.
model = learnCollectedFaces(preprocessedFaces, faceLabels,
facerecAlgorithm);
// Now that training is over, we can start recognizing!
m_mode = MODE_RECOGNITION;
}
else {
// Not enough training data, go back to Collection mode!
m_mode = MODE_COLLECT_FACES;
}
训练可能只需要几分之一秒,也可能需要几秒甚至几分钟,这取决于收集了多少数据。一旦收集到的面部训练完成,面部识别系统将自动进入识别模式。
识别模式
在识别模式下,预处理后的面部旁边会显示一个置信度计,这样用户就知道识别的可靠性。如果置信度高于未知阈值,它将在识别出的人周围画一个绿色的矩形,以便轻松显示结果。如果用户点击添加人员按钮或现有人员之一,程序将返回收集模式,以便用户可以添加更多面部进行进一步训练。
现在我们已经获得了识别出的身份和与重建面部的相似度,如前所述。为了显示置信度计,我们知道 L2 相似度值通常在 0 到 0.5 之间表示高置信度,在 0.5 到 1.0 之间表示低置信度,因此我们可以从 1.0 中减去它,以获得 0.0 到 1.0 之间的置信度水平。然后我们只需使用置信度水平作为以下所示的比例绘制一个填充矩形:
int cx = (displayedFrame.cols - faceWidth) / 2;
Point ptBottomRight = Point(cx - 5, BORDER + faceHeight);
Point ptTopLeft = Point(cx - 15, BORDER);
// Draw a gray line showing the threshold for "unknown" people.
Point ptThreshold = Point(ptTopLeft.x, ptBottomRight.y -
(1.0 - UNKNOWN_PERSON_THRESHOLD) * faceHeight);
rectangle(displayedFrame, ptThreshold, Point(ptBottomRight.x,
ptThreshold.y), CV_RGB(200,200,200), 1, CV_AA);
// Crop the confidence rating between 0 to 1 to fit in the bar.
double confidenceRatio = 1.0 - min(max(similarity, 0.0), 1.0);
Point ptConfidence = Point(ptTopLeft.x, ptBottomRight.y -
confidenceRatio * faceHeight);
// Show the light-blue confidence bar.
rectangle(displayedFrame, ptConfidence, ptBottomRight,
CV_RGB(0,255,255), CV_FILLED, CV_AA);
// Show the gray border of the bar.
rectangle(displayedFrame, ptTopLeft, ptBottomRight,
CV_RGB(200,200,200), 1, CV_AA);
为了突出显示识别出的人,我们在他们的面部周围画一个绿色的矩形。
如下所示:
if (identity >= 0 && identity < 1000) {
int y = min(m_gui_faces_top + identity * faceHeight,
displayedFrame.rows - faceHeight);
Rect rc = Rect(m_gui_faces_left, y, faceWidth, faceHeight);
rectangle(displayedFrame, rc, CV_RGB(0,255,0), 3, CV_AA);
}
以下部分截图显示了在识别模式下运行时的典型显示,显示了预处理后的面部旁边的置信度计。
在顶部中央,并突出显示右上角被识别的人。
检查和处理鼠标点击
现在我们已经绘制了所有 GUI 元素,我们只需要处理鼠标事件。当我们初始化显示窗口时,我们告诉 OpenCV 我们想要将鼠标事件回调到我们的onMouse函数。我们只关心鼠标点击,而不关心鼠标移动,所以我们首先跳过不是针对左鼠标按钮点击的鼠标事件,如下所示:
void onMouse(int event, int x, int y, int, void*)
{
if (event != CV_EVENT_LBUTTONDOWN)
return;
Point pt = Point(x,y);
... (handle mouse clicks)
...
}
由于我们在绘制按钮时获得了绘制的矩形边界,我们只需通过调用 OpenCV 的inside()函数来检查鼠标点击位置是否在我们的任何按钮区域内。现在我们可以检查我们创建的每个按钮。
当用户点击添加人员按钮时,我们只需将m_numPersons变量加 1,在m_latestFaces变量中分配更多空间,选择新的人员进行收集,并开始收集模式(无论我们之前处于哪种模式)。
但有一个复杂的问题;为了确保我们至少为每个
在训练时,如果还没有零人脸的人员,我们才会为新人分配空间。这将确保我们始终可以检查m_latestFaces[m_numPersons-1]的值,以查看是否为每个人收集到了人脸。这是如下进行的:
if (pt.inside(m_btnAddPerson)) {
// Ensure there isn't a person without collected faces.
if ((m_numPersons==0) ||
(m_latestFaces[m_numPersons-1] >= 0)) {
// Add a new person.
m_numPersons++;
m_latestFaces.push_back(-1);
}
m_selectedPerson = m_numPersons - 1;
m_mode = MODE_COLLECT_FACES;
}
此方法可用于测试其他按钮点击,例如如下切换调试标志:
else if (pt.inside(m_btnDebug)) {
m_debug = !m_debug;
}
要处理删除所有按钮,我们需要清空我们主循环中局部的一些数据结构(即,不可从鼠标事件回调函数中访问),因此我们切换到删除所有模式,然后我们就可以在主循环内部删除所有内容。我们还必须处理用户点击主窗口(即,不是按钮)。如果他们点击了右侧的某个人,那么我们想要选择那个人并切换到收集模式。或者如果他们在收集模式时点击主窗口,那么我们想要切换到训练模式。这是如下进行的:
else {
// Check if the user clicked on a face from the list.
int clickedPerson = -1;
for (int i=0; i<m_numPersons; i++) {
if (m_gui_faces_top >= 0) {
Rect rcFace = Rect(m_gui_faces_left,
m_gui_faces_top + i * faceHeight, faceWidth, faceHeight);
if (pt.inside(rcFace)) {
clickedPerson = i;
break;
}
}
}
// Change the selected person, if the user clicked a face.
if (clickedPerson >= 0) {
// Change the current person & collect more photos.
m_selectedPerson = clickedPerson;
m_mode = MODE_COLLECT_FACES;
}
// Otherwise they clicked in the center.
else {
// Change to training mode if it was collecting faces.
if (m_mode == MODE_COLLECT_FACES) {
m_mode = MODE_TRAINING;
}
}
}
摘要
本章向您展示了创建实时人脸识别应用所需的所有步骤,包括足够的预处理以允许训练集条件与测试集条件之间的一些差异,仅使用基本算法。我们使用人脸检测来找到相机图像中人脸的位置,然后通过几种形式的人脸预处理来减少不同光照条件、相机和人脸方向以及面部表情的影响。然后,我们使用收集到的预处理人脸训练了一个 Eigenfaces 或 Fisherfaces 机器学习系统,并最终进行了人脸识别以确定该人是谁,如果是一个未知的人,则通过人脸验证提供一个置信度指标。
我们不是提供一个处理图像文件的离线命令行工具,而是将所有前面的步骤组合成一个自包含的实时 GUI 程序,以便立即使用面部识别系统。您应该能够根据您的需求修改系统的行为,例如允许计算机自动登录,或者如果您对提高识别可靠性感兴趣,您可以阅读关于面部识别最新进展的会议论文,以潜在地改进程序的每个步骤,直到它足够可靠以满足您的特定需求。例如,您可以改进面部预处理阶段,或使用更先进的机器学习算法,或基于www.face-rec.org/algorithms/和www.cvpapers.com上的方法使用更好的面部验证算法。
参考文献
-
使用简单特征级联快速对象检测,P. Viola
和 M.J. Jones,《IEEE Transactions on Computer Vision and Pattern Recognition》2001 年第 1 卷*,
pp. 511-518
-
用于快速对象检测的扩展 Haar-like 特征集,R. Lienhart 和 J. Maydt,《IEEE Transactions on Image Processing》2002 年第 1 卷,pp. 900-903
-
基于局部二值模式的面部描述:应用于面部识别,T. Ahonen, A. Hadid 和 M. Pietikäinen,《IEEE Transactions on Pattern Analysis and Machine Intelligence》2006 年第 28 卷第 12 期,pp. 2037-2041
-
《学习 OpenCV:使用 OpenCV 库进行计算机视觉》,G. Bradski 和 A. Kaehler,pp. 186-190,O'Reilly Media.
-
特征脸用于识别,M. Turk 和 A. Pentland,《Journal of Cognitive Neuroscience》第 3 卷,pp. 71-86
-
特征脸与 Fisher 脸:基于类特定线性投影的识别,P.N. Belhumeur, J. Hespanha 和 D. Kriegman,《IEEE Transactions on Pattern Analysis and Machine Intelligence》1997 年第 19 卷第 7 期,pp. 711-720
-
基于局部二值模式的面部识别,T. Ahonen, A. Hadid 和 M. Pietikäinen,《Computer Vision - ECCV 2004》,pp. 469-48


浙公网安备 33010602011771号