使用计算机视觉实战项目精通-OpenCV-全-
使用计算机视觉实战项目精通 OpenCV(全)
零、前言
《通过实用的计算机视觉项目掌握 OpenCV》包含九章,其中每一章都是基于 OpenCV 的 C++ 接口(包括完整的源代码),从头到尾针对整个项目的教程。 选择每章的作者是因为他们在该主题上为 OpenCV 社区提供了备受推崇的在线贡献,并且该书由一位主要的 OpenCV 开发人员进行了审查。 这是第一本书,而不是说明 OpenCV 功能的基础,而是展示了如何应用 OpenCV 解决整体问题,包括几个 3D 摄像机项目(增强现实,Motion 的 3D 结构,Kinect 交互)和几个面部分析项目(例如 ,皮肤检测,简单的面部和眼睛检测,复杂的人脸特征跟踪,3D 头部方向估计和人脸识别),因此它与现有的 OpenCV 书籍非常适合。
这本书涵盖的内容
第 1 章,“卡通化器和适用于 Android 的换肤器”包含完整的教程和源代码,适用于台式机应用和 Android 应用,它们可以从真实的相机图像中自动生成卡通或绘画 ,其中包括几种可能的卡通类型,包括换肤器。
第 2 章,“在 iPhone 或 iPad 上基于标记的增强现实”包含完整的教程,介绍如何为 iPad 和 iPhone 设备构建基于标记的增强现实(AR)应用。 每个步骤的说明和源代码。
第 3 章,“无标记增强现实”包含有关如何开发无标记增强现实桌面应用的完整教程,并解释了无标记增强现实是什么和源代码 。
第 4 章,“使用 OpenCV 探索运动结构”包含通过 OpenCV 中 SfM 概念的实现对运动结构(SfM)的介绍。 读者将学习如何从多个 2D 图像重建 3D 几何形状并估计相机位置。
第 5 章,“使用 SVM 和神经网络进行车牌识别的方法”包含完整的教程和源代码,可使用支持向量机和人工模式,使用模式识别算法构建自动车牌识别神经网络应用。 读者将学习如何训练和预测模式识别算法,从而确定图像是否为车牌。 它还将有助于将一组特征分类为一个角色。
第 6 章,“非刚性人脸跟踪”包含完整的教程和源代码,用于构建可对人脸的许多复杂部分进行建模和跟踪的动态人脸跟踪系统。
第 7 章,“使用 AAM 和 POSIT 进行 3D 头部姿势估计”包含了解什么有效外观模型(AAM)所需的所有背景知识,以及如何使用 OpenCV 使用一组具有不同面部表情的面部框架来创建它们。 此外,本章还介绍了如何通过 AAM 提供的拟合功能来匹配给定的框架。 然后,通过应用 POSIT 算法,可以找到 3D 头部姿势。
第 8 章,“使用 Eigenfaces 或 Fisherfaces 进行人脸识别”,包含用于实时人脸识别应用的完整教程和源代码,该应用包括基本的人脸和眼睛检测以处理旋转的眼睛。 图像中的面部和变化的照明条件。
第 9 章,“使用 Microsoft Kinect 开发流体墙”涵盖了交互式流体仿真的完整开发,该流体流体称为流体墙,它使用 Kinect 传感器。 本章将说明如何通过 OpenCV 的光流方法使用 Kinect 数据并将其集成到流体求解器中。
这本书需要什么
您无需具备计算机视觉方面的专门知识即可阅读本书,但是在阅读本书之前,您应该具有良好的 C/C++ 编程技能和 OpenCV 的基本经验。 没有 OpenCV 经验的读者可能希望阅读《学习 OpenCV》这本书,以了解 OpenCV 功能,或者阅读《OpenCV 2 Cookbook》,了解有关如何在推荐的 C/C++ 中使用 OpenCV 的示例。 模式,因为《通过实用的计算机视觉项目掌握 OpenCV》将向您展示如何解决实际问题,前提是您已经熟悉 OpenCV 和 C/C++ 开发的基础知识。
除了具有 C/C++ 和 OpenCV 经验外,您还需要一台计算机和您选择的 IDE(例如在 Windows,Mac 或 Linux 上运行的 Visual Studio,XCode,Eclipse 或 QtCreator)。 一些章节有进一步的要求,特别是:
- 要开发 Android 应用,您将需要 Android 设备,Android 开发工具和基本的 Android 开发经验。
- 要开发 iOS 应用,您将需要 iPhone,iPad 或 iPod Touch 设备,iOS 开发工具(包括 Apple 计算机,XCode IDE 和 Apple Developer Certificate)以及基本的 iOS 和 Objective-C 开发经验。
- 多个桌面项目需要将网络摄像头连接到计算机。 任何普通的 USB 网络摄像头都足够,但是可能需要至少 1 兆像素的网络摄像头。
- CMake 用于某些项目(包括 OpenCV 本身)中,以跨操作系统和编译器进行构建。 需要对构建系统有基本的了解,并且建议您了解跨平台的构建。
- 期望对线性代数有所了解,例如基本向量和矩阵运算以及本征分解。
这本书适合谁
《通过实用的计算机视觉项目掌握 OpenCV》对于拥有基本的 OpenCV 知识的开发人员创建实用的计算机视觉项目以及希望将更多计算机视觉主题添加到其技能集中的经验丰富的 OpenCV 专家来说,都是一本完美的书。 它面向希望通过 OpenCV C++ 接口解决实际问题的高级计算机科学大学生,毕业生,研究人员和计算机视觉专家,并通过实用的分步教程进行解决。
约定
在本书中,您会发现许多可以区分不同类型信息的文本样式。 以下是这些样式的一些示例,并解释了其含义。
文本中的代码字如下所示:“您应该将本章的大部分代码放入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!" <<
新术语和重要词以粗体显示。 您在屏幕上看到的单词,例如在菜单或对话框中,将以如下形式显示在文本中:“单击Next按钮可将您移至下一个屏幕”。
注意
警告或重要提示会出现在这样的框中。
提示
提示和技巧如下所示。
读者反馈
始终欢迎读者的反馈。 让我们知道您对这本书的看法-您喜欢或不喜欢的东西。 读者反馈对于我们开发您真正能充分利用的标题非常重要。
要向我们发送一般性反馈,只需将电子邮件发送到<[feedback@packtpub.com](mailto:feedback@packtpub.com)>,然后通过您的邮件主题提及书名。
如果您有专业知识的主题,并且对写作或撰写书籍感兴趣,请参阅 www.packtpub.com/authors 上的作者指南。
一、Android 的卡通化器和换肤器
本章将向您展示如何为 Android 智能手机和平板电脑编写一些图像处理过滤器,该过滤器首先针对台式机(使用 C/C++)编写,然后移植到 Android(使用相同的 C/C++ 代码,但使用 Java GUI), 这是为移动设备开发时的推荐方案。 本章将涵盖:
- 如何将真实图像转换为草图
- 如何转换为绘画并叠加草图来生成卡通
- 一种可怕的“邪恶”模式,用于创建坏角色而不是好角色
- 基本的皮肤检测器和皮肤颜色更改器,可为某人提供绿色的“异形”皮肤
- 如何将项目从桌面应用转换为移动应用
以下屏幕快照显示了在 Android 平板电脑上运行的最终 Cartoonifier 应用:

我们想要使真实世界的相机帧看起来像真的是动画片。 基本思想是用一些颜色填充扁平零件,然后在坚固的边缘上绘制粗线。 换句话说,平坦区域应该变得更加平坦,边缘应该变得更加清晰。 我们将检测边缘并平滑平坦的区域,然后在顶部绘制增强的边缘以产生卡通或漫画效果。
开发移动计算机视觉应用时,最好先构建一个完全正常运行的桌面版本,然后再将其移植到移动设备上,因为开发和调试桌面程序比移动应用容易得多! 因此,本章将以完整的 Cartoonifier 桌面程序开始,您可以使用自己喜欢的 IDE 创建该程序(例如 Visual Studio,XCode , Eclipse, QtCreator 等)。 在桌面上正常运行后,最后一部分将说明如何使用 Eclipse 将其移植到 Android(或可能的 iOS)。 由于我们将创建两个不同的项目,这些项目大多使用不同的图形用户界面共享相同的源代码,因此您可以创建一个由两个项目链接的库,但为简单起见,我们将桌面和 Android 项目彼此相邻并设置 Android 项目以访问Desktop文件夹中的某些文件(cartoon.cpp和cartoon.h ,其中包含所有图像处理代码)。 例如:
C:\Cartoonifier_Desktop\cartoon.cppC:\Cartoonifier_Desktop\cartoon.hC:\Cartoonifier_Desktop\main_desktop.cppC:\Cartoonifier_Android\...
桌面应用使用 OpenCV GUI 窗口,初始化摄像头,并通过每个摄像头框架调用cartoonifyImage()函数,该函数包含本章中的大部分代码。 然后,它将在 GUI 窗口上显示处理后的图像。 同样,Android 应用使用 Android GUI 窗口,使用 Java 初始化摄像头,并且每个摄像头框架都调用与前面提到的完全相同的 C++ cartoonifyImage()函数,但是具有 Android 菜单和手指触摸输入。 本章将解释如何从头开始创建桌面应用,以及如何从一个 OpenCV Android 示例项目中创建 Android 应用。 因此,首先您应该在自己喜欢的 IDE 中创建一个桌面程序,并使用main_desktop.cpp文件来保存以下各节中提供的 GUI 代码,例如主循环,网络摄像头功能和键盘输入,然后创建在项目之间共享的cartoon.cpp文件。 您应该将本章的大部分代码作为称为cartoonifyImage()的函数放入cartoon.cpp中。
访问网络摄像头
要访问计算机的网络摄像头或摄像头设备,只需在cv::VideoCapture对象(OpenCV 访问摄像头设备的方法)上调用open(),然后将0作为默认摄像头 ID 号。 某些计算机连接了多个摄像机,或者它们不作为默认摄像机0起作用; 因此,通常的做法是允许用户在希望尝试使用 1 号,2 号或 -1 号摄像机的情况下,将所需的摄像机号作为命令行参数传递。 我们还将尝试使用cv::VideoCapture::set() 将摄像机分辨率设置为640 x 480,以便在高分辨率摄像机上更快地运行。
注意
根据您的相机模型,驱动程序或系统,OpenCV 可能不会更改相机的属性。 对于这个项目而言,这并不重要,所以请放心,如果它不适用于您的相机。
您可以将此代码放入main_desktop.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 或 MPG 文件)并使用它代替网络摄像头非常容易。 与您的代码唯一的不同是,您应该使用视频文件名(例如camera.open("my_video.avi"))而不是摄像机编号(例如camera.open(0))创建cv::VideoCapture对象。 两种方法均会创建可以以相同方式使用的cv::VideoCapture对象。
桌面应用的主摄像头处理循环
如果要使用 OpenCV 在屏幕上显示 GUI 窗口,请为每个图像调用cv::imshow() ,但还必须每帧调用一次cv::waitKey() , 否则,您的 Windows 将根本不会更新! 调用cv::waitKey(0)会无限期地等待,直到用户敲击窗口中的某个键为止,但是正数(例如waitKey(20)或更高版本)将至少等待那么多毫秒。
将此主循环放在main_desktop.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 at least 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); // Need this to see anything!
if (keypress == 27) { // Escape Key
// Quit the program!
break;
}
}//end while
生成黑白草图
要获得相机帧的草图(黑白图),我们将使用边缘检测过滤器; 而要获得彩色绘画,我们将使用边缘保留过滤器(双边过滤器)进一步平滑平坦区域,同时保持边缘完整。 通过将素描图覆盖在彩色绘画的顶部,我们获得了卡通效果,如最终应用的屏幕截图中所示。
有许多不同的边缘检测过滤器,例如 Sobel, Scharr,拉普拉斯过滤器或 Canny 边缘检测器。 我们将使用 Laplacian 边缘过滤器,因为它产生的边缘与索贝尔或 Scharr 相比看起来与手绘草图最为相似,并且与 Canny 边缘检测器相比非常一致,后者产生的线条非常清晰,但受到随机噪声的影响更大。 因此,相机镜架中的“线条”和“线条图”通常会在镜架之间发生巨大变化。
尽管如此,在使用拉普拉斯边缘过滤器之前,我们仍然需要减少图像中的噪声。 我们将使用中值过滤器,因为它可以在消除噪声的同时保持边缘清晰; 而且,它不如双边过滤器慢。 由于拉普拉斯过滤器使用灰度图像,因此我们必须将 OpenCV 的默认 BGR 格式转换为灰度。 在空文件cartoon.cpp中,将此代码放在顶部,这样您就可以访问 OpenCV 和标准 C++ 模板,而无需在任何地方键入cv::和std:::
// 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 像素宽时,过滤器的尺寸为21 x 21),而仅使用过滤器所需的最小过滤器尺寸。 令人信服的结果(例如,即使钟形曲线的宽度为 21 像素,过滤器大小也仅为9 x 9)。 该截断的过滤器将应用过滤器的主要部分(灰色区域),而不会浪费时间在过滤器的次要部分(曲线下方的白色区域)上,因此它将运行几倍:

我们有四个参数来控制双边过滤器:颜色强度,位置强度,大小和重复计数。 我们需要一个临时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 a 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);
结果是原始照片的卡通版本,如右图所示,其中“素描”遮罩覆盖在“绘画”上:

使用边缘过滤器生成“邪恶”模式
卡通和漫画总是有好有坏的角色。 使用边缘过滤器的正确组合,最无辜的人可能会生成可怕的图像! 诀窍是使用小边缘过滤器,它将在整个图像中找到许多边缘,然后使用小中值过滤器合并边缘。
我们将在具有一定降噪效果的灰度图像上执行此操作,因此应再次使用前面的代码将原始图像转换为灰度并应用7 x 7中值过滤器(下图中的第一幅图像显示了灰度的输出) 中值模糊)。 如果我们沿 x 和 y 应用3 x 3 Scharr 梯度过滤器(图中的第二个图像),然后应用具有非常高的二值阈值,则不用拉普拉斯过滤器和二进制阈值跟随它,就可以得到更恐怖的外观。 低截止(图中的第三幅图像)和7 x 7中值模糊,从而产生最终的“邪恶”遮罩(图中的第四幅图像):
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 级联分类器执行人脸检测(如第 8 章,“使用 EigenFace 进行人脸识别”所示),然后查看检测到的面部中间像素的颜色,因为您知道这些像素应该是实际人物的皮肤像素。 然后,您可以扫描整个图像或附近区域中与脸部中心颜色相似的像素。 这具有的优点是,无论他们的肤色是什么,或者即使他们的皮肤在相机图像中显得有些蓝色或红色,也很有可能找到任何被检测到的人的至少某些真实皮肤区域。
不幸的是,在当前的移动设备上,使用级联分类器进行人脸检测的速度相当慢,因此该方法对于某些实时移动应用可能不太理想。 另一方面,我们可以利用以下事实:对于移动应用,可以假设用户将相机从近处直接朝向人脸握持,并且由于用户握住了相机可以轻松移动,因此要求用户将脸部放置在特定的位置和距离,而不是尝试检测脸部的位置和大小是很合理的。 这是许多移动电话应用的基础,其中该应用要求用户将其脸部放置在某个位置,或者手动在屏幕上拖动点以显示其脸角在照片中的位置。 因此,我们只需在屏幕中央绘制一个脸部轮廓,然后让用户将其脸部移动到所示位置和大小即可。
向用户显示放置脸部的位置
首次启动外星人模式时,我们将在相机框的顶部绘制脸部轮廓,以便用户知道将脸部放置在何处。 我们将绘制一个大椭圆,覆盖图像高度的 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 the radius of the ellipse.
// Scale the width to be the same 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);
现在我们已经绘制了人脸轮廓,我们可以通过使用 alpha 混合将卡通化的图像与此绘制的轮廓相结合,将其叠加到显示的图像上:
addWeighted(dst, 1.0, faceOutline, 0.7, 0, dst, CV_8UC3);
它导致下图的轮廓,向用户显示了将脸放在哪里,因此我们无需检测脸部位置:

换肤器的实现
我们可以使用 OpenCV 的floodFill()而不是先检测肤色,然后再检测具有该肤色的区域,这与许多图像编辑程序中的存储桶填充工具类似。 我们知道屏幕中间的区域应该是皮肤像素(因为我们要求用户将其脸部放在中间),因此要将整个脸部更改为绿色皮肤,我们只需在屏幕上应用绿色填充中心像素即可,它将始终将脸部的至少某些部分着色为绿色。 实际上,脸部的不同部分的颜色,饱和度和亮度可能会有所不同,因此,除非阈值太低以至于也覆盖了脸部之外的多余像素,否则泛色填充将很少覆盖脸部的所有皮肤像素。 面对。 因此,与其在图像的中心应用单个泛洪填充,不如在脸部周围六个不同的点(应该是皮肤像素)上应用泛洪填充。
OpenCV 的floodFill()函数的一个不错的功能是它可以将泛洪填充绘制到外部图像中,而不用修改输入图像。 因此,此函数可以为我们提供用于调整皮肤像素颜色的遮罩图像,而不必更改亮度或饱和度,从而比所有皮肤像素都变成相同的绿色像素(因此会丢失大量面部细节)时产生更逼真的图像。
肤色更改在 RGB 颜色空间中效果不佳。 这是因为您要允许脸部的亮度变化,但不允许肤色的变化很大,并且 RGB 不能将亮度与颜色分开。 一种解决方案是使用色相-饱和度-亮度(HSV) 色空间,因为它可以将亮度与颜色(色相)和彩色(饱和度)分开。 不幸的是,HSV 将色调值包裹在红色周围,并且由于皮肤主要是红色,这意味着您需要同时使用小于 10% 的色调和大于 90% 的色调,因为它们都是红色。 因此,我们将改用 Y'CrCb 颜色空间(YUV 的变体,在 OpenCV 中),因为它可以将亮度与颜色分开,并且对于典型的皮肤颜色只有一个值范围,而不是两个。 请注意,大多数相机,图像和视频在转换为 RGB 之前实际上使用某种类型的 YUV 作为其色彩空间,因此在许多情况下,您无需手动转换就可以获取 YUV 图像。
由于我们希望外星人模式看起来像卡通漫画,因此我们将在图像已被卡通化后应用外星人过滤器; 换句话说,我们可以访问由双边过滤器生成的缩小的彩色图像,以及完整尺寸的边缘遮罩。 皮肤检测通常在低分辨率下效果更好,因为它等效于分析每个高分辨率像素的邻居(或低频信号而不是高频噪声信号)的平均值。 因此,让我们以与双边过滤器相同的缩小比例(一半宽度和一半高度)工作。 让我们将绘画图像转换为 YUV:
Mat yuv = Mat(smallSize, CV_8UC3);
cvtColor(smallImg, yuv, CV_BGR2YCrCb);
我们还需要缩小边缘遮罩,使其与绘画图像的比例相同。 当存储到单独的遮罩图像时,OpenCV 的floodFill()函数有一个复杂之处,因为遮罩在整个图像周围应具有 1 个像素的边框,因此如果输入图像为W x H像素,单独的遮罩图像应为(W + 2) x (H + 2)像素。 但是floodFill()还允许我们使用填充算法可以确保其不会交叉的边缘来初始化遮罩。 让我们使用此函数,希望它有助于防止洪水填充扩展到工作面之外。 因此,我们需要提供两个遮罩图像:尺寸为W x H的边缘遮罩,以及相同的边缘遮罩,但尺寸为(W + 2) x (H + 2)的大小,因为它应该在图像周围包括边框。 可能有多个cv::Mat对象(或标头)引用相同的数据,甚至可能有一个cv::Mat 对象引用另一个cv::Mat图像的子区域。 因此,与其分配两个单独的图像并复制边缘遮罩像素,不如分配一个包含边框的单个遮罩图像,并创建一个W x H的额外cv::Mat标头(它只是引用了洪水填充遮罩中没有边界的兴趣区域)。 换句话说,只有一个像素数组(W + 2) x (H + 2),但是有两个cv::Mat对象,其中一个是引用整个像素(W + 2) x (H + 2)图像,另一张图像则参考了图片的W x H区域:
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 in maskPlusBorder.
resize(edge, 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 颜色空间,然后调整色相和饱和度。
就这样! 在准备好将其移植到手机上之前,请以不同的模式运行该应用。
从台式机移植到 Android
现在,程序可在桌面上运行,我们可以从中制作一个 Android 或 iOS 应用。 此处提供的详细信息特定于 Android,但在为 Apple iPhone 和 iPad 或类似设备移植到 iOS 时也适用。 在开发 Android 应用时,可以直接从 Java 使用 OpenCV,但是结果不太可能像本机 C/C++ 代码一样高效,并且不允许在桌面上运行与移动设备相同的代码。 因此,建议在大多数 OpenCV + Android 应用开发中使用 C/C++(想要纯粹用 Java 编写 OpenCV 应用的读者可以使用 Samuel Audet 的 JavaCV 库,可从这个页面,以便在我们在 Android 上运行的桌面上运行相同的代码)。
注意
这个 Android 专案使用摄影机进行即时输入,因此无法在 Android 模拟器上运作。 它需要一个带相机的真实 Android 2.2(Froyo)或更高版本的设备。
Android 应用的用户界面应使用 Java 编写,但对于图像处理,我们将使用与桌面相同的cartoon.cpp C++ 文件。 要在 Android 应用中使用 C/C++ 代码,我们必须使用基于 JNI(Java 本机接口)的 NDK(本机开发套件)。 我们将为cartoonifyImage()函数创建一个 JNI 包装器,以便可以在具有 Java 的 Android 中使用它。
设置一个使用 OpenCV 的 Android 项目
Android OpenCV 的端口每年都会发生很大变化,Android 的摄像头访问方法也是如此,因此,本书并不是描述如何设置的最佳地方。 因此,读者可以按照这个页面上的最新说明,使用 OpenCV 设置和构建本机(NDK)Android 应用。 OpenCV 带有一个名为 Sample3Native 的 Android 示例项目,该示例项目使用 OpenCV 访问相机并在屏幕上显示修改后的图像。 该示例项目可用作本章中开发的 Android 应用的基础,因此读者应熟悉此示例应用(当前可在这个页面)。 然后,我们将修改一个 Android OpenCV 基础项目,以便它可以对摄像机的视频帧进行卡通化处理,并在屏幕上显示结果帧。
如果您坚持使用 Android 的 OpenCV 开发,例如,如果遇到编译错误,或者相机始终显示空白帧,请尝试在以下网站上搜索解决方案:
- 前面提到的针对 OpenCV 的 Android Binary Package NDK 教程。
- 官方的 Android-OpenCV Google 组。
- OpenCV 的问答网站。
- StackOverflow 问答网站。
- 网络(例如 Google)。
- 如果尝试了所有这些方法后仍然无法解决问题,则应在 Android-OpenCV Google 组中发布问题,并提供错误消息的详细信息,等等。
用于 Android 上图像处理的颜色格式
在为桌面开发时,我们只需要处理 BGR 像素格式,因为输入(来自相机,图像或视频文件)的输入是 BGR 格式,输出(HighGUI 窗口,图像或视频文件)。 但是,为手机开发时,通常必须自己转换本机颜色格式。
从相机输入色彩格式
查看jni\jni_part.cpp中的示例代码,myuv变量是 Android 默认相机格式"NV21" YUV420sp的彩色图像。 数组的第一部分是灰度像素数组,其后是在 U 和 V 颜色通道之间交替的半尺寸像素数组。 因此,如果我们只想访问灰度图像,则可以直接从YUV420sp半平面图像的第一部分获取它,而无需进行任何转换。 但是,如果需要彩色图像(例如 BGR 或 BGRA 彩色格式),则必须使用cvtColor()转换颜色格式。
输出显示的颜色格式
查看来自 OpenCV 的 Sample3Native 代码, mbgra变量是要在 Android 设备上以 BGRA 格式显示的彩色图像。 OpenCV 的默认格式是 BGR(与 RGB 相反的字节顺序),而 BGRA 只是在每个像素的末尾添加了一个未使用的字节,因此每个像素都存储为“蓝-绿-红-未使用”。 您可以使用 OpenCV 的默认 BGR 格式进行所有处理,然后在屏幕上显示之前将最终输出从 BGR 转换为 BGRA,或者可以确保图像处理代码可以处理 BGRA 格式,而不是 BGR 格式。 在 OpenCV 中通常很容易做到这一点,因为许多 OpenCV 函数都接受 BGRA,但是您必须确保通过查看图像中的Mat::channels() 值是否是与输入相同的通道数来创建图像 3 或 4。此外,如果您直接访问代码中的像素,则需要单独的代码来处理 3 通道 BGR 和 4 通道 BGRA 图像。
注意
某些 CV 操作使用 BGRA 像素运行更快(因为它对齐到 32 位),而某些使用 BGR 像素运行(更快,因为它需要更少的内存来读写),因此为了获得最大的效率,您应该同时支持 BGR 和 BGRA,然后找到哪种颜色格式在您的应用中总体上运行最快。
让我们从简单的事情开始:在 OpenCV 中访问摄像机帧,但不对其进行处理,而是将其显示在屏幕上。 使用 Java 代码可以很容易地做到这一点,但是了解如何使用 OpenCV 做到这一点也很重要。 如前所述,摄像机图像以YUV420sp格式到达我们的 C++ 代码,并应以 BGRA 格式保留。 因此,如果我们准备好cv::Mat用于输入和输出,则只需使用cvtColor从YUV420sp转换为 BGRA。 要为 Android Java 应用编写 C/C++ 代码,我们需要使用特殊的 JNI 函数名称,该名称与将使用该 JNI 函数的 Java 类和包名称相匹配,格式为:
JNIEXPORT <Return> JNICALL Java_<Package>_<Class>_<Function>(JNIEnv* env, jobject, <Args>)
因此,让我们创建一个ShowPreview() C/C++ 函数,该函数在Cartoonifier Java 包中的CartoonifierView Java 类中使用。 将此ShowPreview() C/C++ 函数添加到jni\jni_part.cpp中:
// Just show the plain camera image without modifying it.
JNIEXPORT void JNICALL Java_com_Cartoonifier_CartoonifierView_ShowPreview(
JNIEnv* env, jobject,
jint width, jint height, jbyteArray yuv, jintArray bgra)
{
jbyte* _yuv = env->GetByteArrayElements(yuv, 0);
jint* _bgra = env->GetIntArrayElements(bgra, 0);
Mat myuv = Mat(height + height/2, width, CV_8UC1, (uchar *)_yuv);
Mat mbgra = Mat(height, width, CV_8UC4, (uchar *)_bgra);
// Convert the color format from the camera's
// NV21 "YUV420sp" format to an Android BGRA color image.
cvtColor(myuv, mbgra, CV_YUV420sp2BGRA);
// OpenCV can now access/modify the BGRA image "mbgra" ...
env->ReleaseIntArrayElements(bgra, _bgra, 0);
env->ReleaseByteArrayElements(yuv, _yuv, 0);
}
虽然这段代码乍看之下很复杂,但该函数的前两行仅使我们能够对给定的 Java 数组进行本机访问,而后两行则围绕给定的像素缓冲区构造cv::Mat对象(也就是说,它们不分配新的图像,它们使myuv访问_yuv数组中的像素,依此类推,等等),该函数的最后两行释放了我们在 Java 数组上放置的本机锁。 我们在函数中所做的唯一实际工作是将 YUV 转换为 BGRA 格式,因此该函数是我们可以用于新函数的基础。 现在,我们将其扩展为在显示之前分析和修改 BGRA cv::Mat。
注意
OpenCV v2.4.2 中的jni\jni_part.cpp示例代码使用以下代码:
cvtColor(myuv, mbgra, CV_YUV420sp2BGR, 4);
看起来它转换为 3 通道 BGR 格式(OpenCV 的默认格式),但是由于使用[4"参数,它实际上转换为 4 通道 BGRA(Android 的默认输出格式)!因此,它与此代码相同, 不太混乱:
cvtColor(myuv, mbgra, CV_YUV420sp2BGRA);
由于我们现在有了 BGRA 图像作为输入和输出,而不是 OpenCV 的默认 BGR,因此为我们提供了两种处理方式:
- 在执行图像处理之前,先从 BGRA 转换为 BGR,然后在 BGR 中进行处理,然后将输出转换为 BGRA,以便 Android 可以显示
- 修改我们所有的代码以处理 BGRA 格式,以补充 BGR 格式(或代替 BGR 格式),因此我们无需在 BGRA 和 BGR 之间执行慢速转换
为简单起见,我们将仅应用从 BGRA 到 BGR 的色彩转换,而不支持 BGR 和 BGRA 格式。 如果您正在编写实时应用,则应考虑在代码中添加 4 通道 BGRA 支持以潜在地提高表现。 我们将做一个简单的更改,以使操作更快一些:我们将输入从YUV420sp转换为 BGRA,然后从 BGRA 转换为 BGR,所以我们也可能直接从YUV420sp转换为 BGR!
在设备上使用ShowPreview()函数(如前所示)进行构建和运行是一个好主意,因此,如果以后对 C/C++ 代码有疑问,可以参考一下。 要从 Java 调用它,我们在CartoonifyView.java底部附近的CartoonifyImage()的 Java 声明旁边添加 Java 声明:
public native void ShowPreview(int width, int height,
byte[] yuv, int[] rgba);
然后,我们可以像称为FindFeatures()的 OpenCV 示例代码一样来调用它。 将其放在CartoonifierView.java的processFrame()函数的中间:
ShowPreview(getFrameWidth(), getFrameHeight(), data, rgba);
您现在应该在设备上构建并运行它,以查看实时摄像机预览。
将卡通化代码添加到 Android NDK 应用
我们需要添加用于桌面应用的cartoon.cpp文件。 文件jni\Android.mk为您的项目设置 C/C++/Assembly 源文件,标头搜索路径,本机库和 GCC 编译器设置:
-
在
LOCAL_SRC_FILES中添加cartoon.cpp(如果想更方便地调试,请添加ImageUtils_0.7.cpp),但请记住,它们位于桌面文件夹中,而不是默认的jni文件夹中。 因此,请在以下位置添加:LOCAL_SRC_FILES := jni_part.cpp:LOCAL_SRC_FILES += ../../Cartoonifier_Desktop/cartoon.cpp LOCAL_SRC_FILES += ../../Cartoonifier_Desktop/ImageUtils_0.7.cpp -
添加头文件搜索路径,以便它可以在公共父文件夹中找到
cartoon.h:LOCAL_C_INCLUDES += $(LOCAL_PATH)/../../Cartoonifier_Desktop -
在文件
jni\jni_part.cpp中,将其插入顶部而不是#include <vector>:#include "cartoon.h" // Cartoonifier. #include "ImageUtils.h" // (Optional) OpenCV debugging // functions. -
向该文件添加一个 JNI 函数
CartoonifyImage(); 这将使图像卡通化。 我们可以从复制我们先前创建的ShowPreview()函数开始,该函数仅显示摄像机预览而无需对其进行修改。 请注意,由于我们不想处理 BGRA 图像,因此我们直接从YUV420sp转换为 BGR:// Modify the camera image using the Cartoonifier filter. JNIEXPORT void JNICALL Java_com_Cartoonifier_CartoonifierView_CartoonifyImage( JNIEnv* env, jobject, jint width, jint height, jbyteArray yuv, jintArray bgra) { // Get native access to the given Java arrays. jbyte* _yuv = env->GetByteArrayElements(yuv, 0); jint* _bgra = env->GetIntArrayElements(bgra, 0); // Create OpenCV wrappers around the input & output data. Mat myuv(height + height/2, width, CV_8UC1, (uchar *)_yuv); Mat mbgra(height, width, CV_8UC4, (uchar *)_bgra); // Convert the color format from the camera's YUV420sp // semi-planar // format to OpenCV's default BGR color image. Mat mbgr(height, width, CV_8UC3); // Allocate a new image buffer. cvtColor(myuv, mbgr, CV_YUV420sp2BGR); // OpenCV can now access/modify the BGR image "mbgr", and should // store the output as the BGR image "displayedFrame". Mat displayedFrame(mbgr.size(), CV_8UC3); // TEMPORARY: Just show the camera image without modifying it. displayedFrame = mbgr; // Convert the output from OpenCV's BGR to Android's BGRA //format. cvtColor(displayedFrame, mbgra, CV_BGR2BGRA); // Release the native lock we placed on the Java arrays. env->ReleaseIntArrayElements(bgra, _bgra, 0); env->ReleaseByteArrayElements(yuv, _yuv, 0); } -
先前的代码不会修改图像,但是我们想使用本章前面开发的卡通化器来处理图像。 现在,让我们插入对我们在
cartoon.cpp中为桌面应用创建的现有cartoonifyImage()函数的调用。 将临时代码行displayedFrame = mbgr替换为:cartoonifyImage(mbgr, displayedFrame); -
而已! 生成代码(Eclipse 应该使用
ndk-build为您编译 C/C++ 代码)并在设备上运行它。 您应该有一个可以正常工作的卡通化器 Android 应用(在本章开始的地方,有一个示例屏幕快照显示了您的期望)! 如果它没有生成或运行,请返回步骤并解决问题(如果需要,请查看本书随附的代码)。 工作正常后,继续下一步。
审核 Android 应用
您会很快注意到设备上正在运行的应用存在四个问题:
- 它非常慢; 每帧很多秒! 因此,我们应该只显示摄像机预览,并且仅在用户触摸屏幕以说它是一张好照片时才将摄像机帧卡通化。
- 它需要处理用户输入,例如在草图,绘画,邪恶或异物模式之间更改模式。 我们将它们添加到 Android 菜单栏中。
- 如果我们可以将卡通化的结果保存到图像文件中,并与他人共享,那将是很好的。 每当用户触摸屏幕以获取卡通化图像时,我们都会将结果另存为图像文件到用户的 SD 卡中,并将其显示在 Android Gallery 中。
- 草图边缘检测器中存在很多随机噪声。 我们将创建一个特殊的“胡椒”降噪过滤器以在以后处理。
当用户点击屏幕时将图像卡通化
要显示摄像机预览(直到用户要对选定的摄像机帧进行卡通化),我们可以调用我们先前编写的ShowPreview() JNI 函数。 在将摄像机图像卡通化之前,我们还将等待用户的触摸事件。 我们只想在用户触摸屏幕时对一幅图像进行卡通化; 因此,我们设置了一个标志,说应该将下一个摄像机帧卡通化,然后重置该标志,以便再次进行摄像机预览。 但这意味着动画片化后的图像仅显示一秒钟,然后会再次显示下一个摄像机预览。 因此,我们将使用第二个标志来表示当前图像应在摄像机帧覆盖它之前在屏幕上冻结几秒钟,以便用户有时间查看它:
-
在
src\com\Cartoonifier文件夹中CartoonifierApp.java文件顶部附近添加以下标头导入:import android.view.View; import android.view.View.OnTouchListener; import android.view.MotionEvent; -
修改
CartoonifierApp.java顶部附近的类定义:public class CartoonifierApp extends Activity implements OnTouchListener { -
将此代码插入
onCreate()函数的底部:// Call our "onTouch()" callback function whenever the user // touches the screen. mView.setOnTouchListener(this); -
添加函数
onTouch()以处理触摸事件:public boolean onTouch(View v, MotionEvent m) { // Ignore finger movement event, we just care about when the // finger first touches the screen. if (m.getAction() != MotionEvent.ACTION_DOWN) { return false; // We didn't use this touch movement event. } Log.i(TAG, "onTouch down event"); // Signal that we should cartoonify the next camera frame and save // it, instead of just showing the preview. mView.nextFrameShouldBeSaved(getBaseContext()); return true; } -
现在我们需要将
nextFrameShouldBeSaved()函数添加到CartoonifierView.java中:// Cartoonify the next camera frame & save it instead of preview. protected void nextFrameShouldBeSaved(Context context) { bSaveThisFrame = true; } -
在
CartoonifierView类的顶部附近添加以下变量:private boolean bSaveThisFrame = false; private boolean bFreezeOutput = false; private static final int FREEZE_OUTPUT_MSECS = 3000; -
CartoonifierView的processFrame()函数现在可以在卡通和预览之间切换,但是还应确保仅在不尝试显示冻结卡通图像几秒钟的情况下才显示某些内容。 因此,将processFrame()替换为:@Override protected Bitmap processFrame(byte[] data) { // Store the output image to the RGBA member variable. int[] rgba = mRGBA; // Only process the camera or update the screen if we aren't // supposed to just show the cartoon image. if (bFreezeOutputbFreezeOutput) { // Only needs to be triggered here once. bFreezeOutput = false; // Wait for several seconds, doing nothing! try { wait(FREEZE_OUTPUT_MSECS); } catch (InterruptedException e) { e.printStackTrace(); } return null; } if (!bSaveThisFrame) { ShowPreview(getFrameWidth(), getFrameHeight(), data, rgba); } else { // Just do it once, then go back to preview mode. bSaveThisFrame = false; // Don't update the screen for a while, so the user can // see the cartoonifier output. bFreezeOutput = true; CartoonifyImage(getFrameWidth(), getFrameHeight(), data, rgba, m_sketchMode, m_alienMode, m_evilMode, m_debugMode); } // Put the processed image into the Bitmap object that will be // returned for display on the screen. Bitmap bmp = mBitmap; bmp.setPixels(rgba, 0, getFrameWidth(), 0, 0, getFrameWidth(), getFrameHeight()); return bmp; } -
您应该可以生成并运行它,以验证该应用现在是否可以正常运行。
将图像保存到文件和 Android 图片库
我们将输出保存为 PNG 文件并显示在 Android 图片库中。 Android Gallery 专为 JPEG 文件设计,但是 JPEG 对于具有纯色和边缘的卡通图像不利,因此我们将使用繁琐的方法将 PNG 图像添加到图库中。 我们将创建一个 Java 函数savePNGImageToGallery()来为我们执行此操作。 在前面看到的processFrame()函数的底部,我们看到使用输出数据创建了一个 Android Bitmap对象; 因此,我们需要一种将Bitmap对象保存到 PNG 文件的方法。 OpenCV 的imwrite() Java 函数可用于保存到 PNG 文件,但这将需要链接到 OpenCV 的 Java API 和 OpenCV 的 C/C++ API(就像 OpenCV4Android 示例项目“tutorial-4-mixed”一样) )。 由于我们不需要 OpenCV Java API,因此以下代码将仅显示如何使用 Android API 而非 OpenCV Java API 保存 PNG 文件:
-
Android 的
Bitmap类可以将文件保存为 PNG 格式,因此让我们使用它。 另外,我们需要为图像选择文件名。 让我们使用当前的日期和时间,以允许保存许多文件,并使用户可以记住拍摄时间。 将其插入processFrame()的return bmp语句之前:if (bFreezeOutput) { // Get the current date & time SimpleDateFormat s = new SimpleDateFormat("yyyy-MM-dd,HH-mm-ss"); String timestamp = s.format(new Date()); String baseFilename = "Cartoon" + timestamp + ".png"; // Save the processed image as a PNG file on the SD card and show // it in the Android Gallery. savePNGImageToGallery(bmp, mContext, baseFilename); } -
将其添加到
CartoonifierView.java的顶部:// For saving Bitmaps to file and the Android picture gallery. import android.graphics.Bitmap.CompressFormat; import android.net.Uri; import android.os.Environment; import android.provider.MediaStore; import android.provider.MediaStore.Images; import android.text.format.DateFormat; import android.util.Log; import java.io.BufferedOutputStream; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; import java.io.OutputStream; import java.text.SimpleDateFormat; import java.util.Date; -
将它插入
CartoonifierView类的顶部:private static final String TAG = "CartoonifierView"; private Context mContext; // So we can access the Android // Gallery. -
将此添加到
CartoonifierView中的nextFrameShouldBeSaved()函数中:mContext = context; // Save the Android context, for GUI // access. -
将
savePNGImageToGallery()函数添加到CartoonifierView:// Save the processed image as a PNG file on the SD card // and shown in the Android Gallery. protected void savePNGImageToGallery(Bitmap bmp, Context context, String baseFilename) { try { // Get the file path to the SD card. String baseFolder = \ Environment.getExternalStoragePublicDirectory( \ Environment.DIRECTORY_PICTURES).getAbsolutePath() \ + "/"; File file = new File(baseFolder + baseFilename); Log.i(TAG, "Saving the processed image to file [" + \ file.getAbsolutePath() + "]"); // Open the file. OutputStream out = new BufferedOutputStream( new FileOutputStream(file)); // Save the image file as PNG. bmp.compress(CompressFormat.PNG, 100, out); // Make sure it is saved to file soon, because we are about // to add it to the Gallery. out.flush(); out.close(); // Add the PNG file to the Android Gallery. ContentValues image = new ContentValues(); image.put(Images.Media.TITLE, baseFilename); image.put(Images.Media.DISPLAY_NAME, baseFilename); image.put(Images.Media.DESCRIPTION, "Processed by the Cartoonifier App"); image.put(Images.Media.DATE_TAKEN, System.currentTimeMillis()); // msecs since 1970 UTC. image.put(Images.Media.MIME_TYPE, "img/png"); image.put(Images.Media.ORIENTATION, 0); image.put(Images.Media.DATA, file.getAbsolutePath()); Uri result = context.getContentResolver().insert( MediaStore.Images.Media.EXTERNAL_CONTENT_URI,image); } catch (Exception e) { e.printStackTrace(); } } -
如果 Android 应用需要在设备上存储文件,则需要在安装过程中获得用户的许可。 因此,请将此行插入
AndroidManifest.xml中,请求请求摄像机访问权限的类似行旁边:<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/> -
生成并运行该应用! 当您触摸屏幕以保存照片时,您最终应该看到屏幕上显示的卡通化图像(也许经过 5 或 10 秒的处理后)。 一旦它显示在屏幕上,这意味着它应该被保存到您的 SD 卡和您的相册。 退出 Cartoonifier 应用,打开 Android Gallery 应用,然后查看图片相册。 您应该在屏幕的全分辨率下将卡通图像视为 PNG 图像。
显示有关已保存图像的 Android 通知消息
如果要在每次将新图像保存到 SD 卡和 Android Gallery 中时显示通知消息,请按照以下步骤操作: 否则,请跳过此部分:
-
将以下内容添加到
CartoonifierView.java的顶部:// For showing a Notification message when saving a file. import android.app.Notification; import android.app.NotificationManager; import android.app.PendingIntent; import android.content.ContentValues; import android.content.Intent; -
将其添加到
CartoonifierView的顶部附近:private int mNotificationID = 0; // To show just 1 notification. -
将其插入到
processFrame()中对savePNGImageToGallery()的调用下面的if语句内:showNotificationMessage(mContext, baseFilename); -
将
showNotificationMessage()函数添加到CartoonifierView:// Show a notification message, saying we've saved another image. protected void showNotificationMessage(Context context, String filename) { // Popup a notification message in the Android status // bar. To make sure a notification is shown for each // image but only 1 is kept in the status bar at a time, // use a different ID each time // but delete previous messages before creating it. final NotificationManager mgr = (NotificationManager) \ context.getSystemService(Context.NOTIFICATION_SERVICE); // Close the previous popup message, so we only have 1 //at a time, but it still shows a popup message for each //one. if (mNotificationID > 0) mgr.cancel(mNotificationID); mNotificationID++; Notification notification = new Notification(R.drawable.icon, "Saving to gallery (image " + mNotificationID + ") ...", System.currentTimeMillis()); Intent intent = new Intent(context, CartoonifierView.class); // Close it if the user clicks on it. notification.flags |= Notification.FLAG_AUTO_CANCEL; PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, intent, 0); notification.setLatestEventInfo(context, "Cartoonifier saved " + mNotificationID + " images to Gallery", "Saved as '" + filename + "'", pendingIntent); mgr.notify(mNotificationID, notification); } -
再次构建并运行该应用! 每当您触摸屏幕上另一张保存的图像时,您应该会看到一条通知消息弹出。 如果要在长时间的图像处理之前而不是之后弹出通知消息,请将调用移至
showNotificationMessage(),然后移至cartoonifyImage(),然后将用于生成日期和时间的代码移至时间字符串,以便为通知消息提供相同的字符串,并保存实际文件。
通过 Android 菜单栏更改卡通模式
让我们允许用户通过菜单更改模式:
-
在文件
src\com\Cartoonifier\CartoonifierApp.java顶部附近添加以下标头:import android.view.Menu; import android.view.MenuItem; -
在
CartoonifierApp类中插入以下成员变量:// Items for the Android menu bar. private MenuItem mMenuAlien; private MenuItem mMenuEvil; private MenuItem mMenuSketch; private MenuItem mMenuDebug; -
将以下函数添加到
CartoonifierApp:/** Called when the menu bar is being created by Android. */ public boolean onCreateOptionsMenu(Menu menu) { Log.i(TAG, "onCreateOptionsMenu"); mMenuSketch = menu.add("Sketch or Painting"); mMenuAlien = menu.add("Alien or Human"); mMenuEvil = menu.add("Evil or Good"); mMenuDebug = menu.add("[Debug mode]"); return true; } /** Called whenever the user pressed a menu item in the menu bar. */ public boolean onOptionsItemSelected(MenuItem item) { Log.i(TAG, "Menu Item selected: " + item); if (item == mMenuSketch) mView.toggleSketchMode(); else if (item == mMenuAlien) mView.toggleAlienMode(); else if (item == mMenuEvil) mView.toggleEvilMode(); else if (item == mMenuDebug) mView.toggleDebugMode(); return true; } -
在
CartoonifierView类中插入以下成员变量:private boolean m_sketchMode = false; private boolean m_alienMode = false; private boolean m_evilMode = false; private boolean m_debugMode = false; -
在
CartoonifierView中添加以下函数:protected void toggleSketchMode() { m_sketchMode = !m_sketchMode; } protected void toggleAlienMode() { m_alienMode = !m_alienMode; } protected void toggleEvilMode() { m_evilMode = !m_evilMode; } protected void toggleDebugMode() { m_debugMode = !m_debugMode; } -
我们需要将模式值传递给
cartoonifyImage()JNI 代码,因此让我们将它们作为参数发送。 修改CartoonifierView中CartoonifyImage()的 Java 声明:public native void CartoonifyImage(int width, int height,byte[] yuv, int[] rgba, boolean sketchMode, boolean alienMode, boolean evilMode, boolean debugMode); -
现在修改 Java 代码,以便我们在
processFrame()中传递当前的模式值:CartoonifyImage(getFrameWidth(), getFrameHeight(), data,rgba, m_sketchMode, m_alienMode, m_evilMode, m_debugMode); -
jni\jni_part.cpp中CartoonifyImage()的 JNI 声明现在应为:JNIEXPORT void JNICALL Java_com_Cartoonifier_CartoonifierView_CartoonifyImage( JNIEnv* env, jobject, jint width, jint height, jbyteArray yuv, jintArray bgra, jboolean sketchMode, jboolean alienMode, jboolean evilMode, jboolean debugMode) -
然后,我们需要从
jni\jni_part.cpp中的 JNI 函数将模式传递给cartoon.cpp中的 C/C++ 代码。 在为 Android 开发时,一次只能显示一个 GUI 窗口,但是在桌面上,在调试时显示额外的窗口很方便。 因此,我们不要为debugMode设置布尔值标志,而是为非调试传递一个数字0,为移动设备传递 1 的数字(在 OpenCV 中创建 GUI 窗口会导致崩溃!),为 2 传递一个数字。 在桌面上进行调试(我们可以在其中创建任意数量的额外窗口):int debugType = 0; if (debugMode) debugType = 1; cartoonifyImage(mbgr, displayedFrame, sketchMode, alienMode, evilMode, debugType); -
更新
cartoon.cpp中的实际 C/C++ 实现:
```cpp
void cartoonifyImage(Mat srcColor, Mat dst, bool sketchMode,
bool alienMode, bool evilMode, int debugType)
{
```
- 并更新
cartoon.h中的 C/C++ 声明:
```cpp
void cartoonifyImage(Mat srcColor, Mat dst, bool sketchMode,
bool alienMode, bool evilMode, int debugType);
```
- 编译并运行它 ; 然后尝试按窗口底部的小选项菜单按钮。 您应该发现草图模式是实时的,而绘画模式由于双边过滤器而具有较大的延迟。
从素描图像中减少明显的随机噪声
当前智能手机和平板电脑中的大多数相机都具有明显的图像噪点。 这通常是可以接受的,但是对我们的5 x 5拉普拉斯边缘过滤器有很大的影响。 边缘遮罩(显示为草图模式)通常会具有成千上万个称为“胡椒粉”噪声的黑色小斑点,由白色背景中彼此相邻的几个黑色像素组成。 我们已经在使用中值过滤器,该过滤器通常强度足以消除胡椒噪声,但在我们的情况下可能不够强。 我们的边缘遮罩大部分是纯白色背景(值为 255),带有一些黑色边缘(值为 0)和噪声点(也值为 0)。 我们可以使用标准的闭合形态运算符,但是它将去除很多边缘。 因此,相反,我们将应用自定义过滤器,以删除完全被白色像素包围的小的黑色区域。 这将消除大量噪声,而对实际边缘几乎没有影响。
我们将扫描图像中的黑色像素,并在每个黑色像素处检查其周围5 x 5正方形的边框,以查看所有5 x 5边框像素是否均为白色。 如果它们都是白色,我们知道我们有一个黑色的小岛,因此我们用白色像素填充整个块,以消除黑色岛。 为了简单起见,在我们的5 x 5过滤器中,我们将忽略图像周围的两个边框像素,并保持原样。
下图左侧显示了 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 this pixel value (0 or 255).
// Check if this is a black pixel that is surrounded by
// white 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 know
// the 5x5 borders are already white, we just need to
// fill 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++;
}
}
}
显示应用的 FPS
如果您想在屏幕上显示每秒帧(FPS)的速度(对于像这样的慢速应用来说不太重要,但仍然有用),请执行以下步骤 :
-
将文件
src\org\opencv\samples\imagemanipulations\FpsMeter.java从 OpenCV 中的imagemanipulations示例文件夹(例如C:\OpenCV-2.4.1\samples\android\image-manipulations)复制到src\com\Cartoonifier文件夹。 -
将
FpsMeter.java顶部的包名称替换为com.Cartoonifier。 -
在
CartoonifierViewBase.java文件中,在private byte[] mBuffer;之后声明您的FpsMeter成员变量:private FpsMeter mFps; -
在
mHolder.addCallback(this);之后,在CartoonifierViewBase()构造器中初始化FpsMeter对象:mFps = new FpsMeter(); mFps.init(); -
在
try/catch块之后测量run()中每个帧的 FPS:mFps.measure(); -
在
canvas.drawBitmap()函数后的run()中,将 FPS 绘制到每一帧的屏幕上:mFps.draw(canvas, (canvas.getWidth() - bmp.getWidth()) /2, 0);
使用其他相机分辨率
如果您希望自己的应用运行得更快,并且会影响质量,那么您绝对应该考虑从硬件中请求较小的相机图像,或者在获得图像后缩小图像。 卡通化器所基于的示例代码使用最接近屏幕高度的相机预览分辨率。 因此,如果您的设备具有 5 百万像素的摄像头,并且屏幕仅为640 x 480,则它可能会使用720 x 480的摄像头分辨率,依此类推。 如果要控制选择哪种摄像机分辨率,可以在CartoonifierViewBase.java中的surfaceChanged()函数中将参数修改为setupCamera() 。 例如:
public void surfaceChanged(SurfaceHolder _holder, int format,
int width, int height) {
Log.i(TAG, "Screen size: " + width + "x" + height);
// Use a camera resolution of roughly half the screen height.
setupCamera(width/2, height/2);
}
从摄像机获得最高预览分辨率的一种简单方法是传递一个较大的尺寸,例如10,000 x 10,000,它将选择可用的最大分辨率(请注意,它只会给出最大的预览分辨率,即摄像机的视频分辨率,因此通常比相机的静止图像分辨率要小得多)。 或者,如果您希望它运行得非常快,则传递1 x 1,它将为您找到最低的相机预览分辨率(例如160 x 120)。
自定义应用
现在,您已经创建了整个 Android 卡通化器应用,您应该了解它的工作原理以及哪些部分可以完成操作; 您应该自定义它! 更改 GUI,应用行为和工作流程,卡通化器过滤器常量,外观检测器算法,或用您自己的想法替换卡通化器代码。
您可以通过多种方式来改进皮肤检测算法,例如,使用更复杂的皮肤检测算法(例如,使用这个页面上许多最近的 CVPR 或 ICCV 会议论文的训练过的高斯模型)或通过添加人脸检测(请参见第 8 章“使用 EigenFace 进行人脸识别”的“人脸检测”部分) 检测器,以便它检测用户的脸部位置,而不是要求用户将其脸部放在屏幕中央。 请注意,在某些设备或高分辨率相机上人脸检测可能需要花费几秒钟的时间,因此该方法可能会受到处理速度相对较慢的限制,但是智能手机和平板电脑每年的速度都在显着提高,因此这将不再是一个问题。
加快移动计算机视觉应用速度的最重要方法是尽可能降低相机分辨率(例如,从 0.5 兆像素而不是 5 兆像素),尽可能少地分配和释放图像以及尽可能少地进行图像转换(例如,通过在整个代码中支持 BGRA 图像)。 您还可以从设备的 CPU 供应商(例如 NVIDIA Tegra,Texas Instruments OMAP,Samsung Exynos,Apple Ax 或 QualComm Snapdragon)中寻找优化的图像处理或数学库。 系列(例如,ARM Cortex-A9)。 请记住,您的设备可能存在 OpenCV 的优化版本。
为了使自定义 NDK 和桌面图像处理代码更容易,本书附带了文件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 调试器通常非常困难的移动开发,printf()语句通常在 Android NDK 中不起作用。 但是,ImageUtils中的函数在 Android 和台式机上均可使用。
总结
本章介绍了几种可用于生成各种卡通效果的图像处理过滤器:一种看起来像铅笔素描的普通素描模式,一个看起来像彩色绘画的绘画模式以及一个覆盖在绘画模式之上的草图模式,使图像看起来像卡通。 它还显示可以获得其他有趣的效果,例如可以大大增强噪点边缘的邪恶模式以及将脸部皮肤更改为亮绿色的外星人模式。
有许多商用智能手机应用在用户的脸上执行类似的有趣效果,例如卡通过滤器和换色器。 还有一些使用类似概念的专业工具,例如,使视频平滑的视频后处理工具,旨在通过平滑皮肤同时保持边缘和非皮肤区域的锐化来美化女人的脸,以使自己的脸看起来更年轻。
本章介绍了如何按照建议的准则将应用从桌面应用移植到 Android 移动应用,首先开发可工作的桌面版本,将其移植到移动应用,并创建适合该移动应用的用户界面 。 图像处理代码在两个项目之间共享,以便读者可以修改桌面应用的卡通过滤器,并且通过重建 Android 应用,它也应自动在 Android 应用中显示其修改内容。
使用 OpenCV4Android 所需的步骤会定期更改,并且 Android 开发本身不是静态的; 因此,本章将介绍如何通过向 OpenCV 示例项目之一添加功能来构建 Android 应用。 预计读者可以在以后的 OpenCV4Android 版本中将相同功能添加到等效项目中。
本书包括桌面项目和 Android 项目的源代码。
二、iPhone 或 iPad 上基于标记的增强现实
增强现实(AR)是真实环境的实时视图,其元素由计算机生成的图形增强。 结果,该技术通过增强当前对现实的感知而起作用。 增强通常是实时的,并且在语义环境中具有环境元素。 借助先进的增强现实技术(例如,添加计算机视觉和对象识别),有关用户周围真实世界的信息将变为交互性并可进行数字化处理。 有关环境及其对象的人工信息可以覆盖在现实世界中。
在本章中,我们将为 iPhone/iPad 设备创建一个 AR 应用。 从头开始,我们将创建一个使用标记的应用,以在从相机获取的图像上绘制一些人造物体。 您将学习如何在 XCode IDE 中设置项目并将其配置为在应用中使用 OpenCV。 此外,将说明诸如从内置摄像机捕获视频,使用 OpenGL ES 进行 3D 场景渲染以及构建通用 AR 应用架构等方面。
在开始之前,让我给您简要介绍所需的知识和软件:
- 您将需要一台装有 XCode IDE 的 Apple 计算机。 只有使用 Apple 的 XCode IDE 才能为 iPhone/iPad 开发应用。 这是为此平台构建应用的唯一方法。
- 您将需要 iPhone,iPad 或 iPod Touch 设备的型号。 要在设备上运行应用,您必须每年以 99 美元的价格购买 Apple 开发者证书。 没有此证书,无法在设备上运行开发的应用。
- 您还将需要 XCode IDE 的基本知识。 我们将假定读者具有使用此 IDE 的经验。
- 也需要 Objective-C 和 C++ 编程语言的基本知识。 但是,将详细说明应用源代码的所有复杂部分。
从本章中,您将了解有关标记的更多信息。 说明了完整的检测例程。 阅读完本章后,您将能够编写自己的标记检测算法,根据相机姿势估计 3D 世界中的标记姿势,并使用它们之间的这种转换来可视化任意 3D 对象。
您将在本书的媒体中找到本章的示例项目。 这是创建您的第一个移动增强现实应用的良好起点。
在本章中,我们将介绍以下主题:
- 创建一个使用 OpenCV 的 iOS 项目
- 应用架构
- 标记检测
- 标记识别
- 标记代码识别
- 在 3D 中放置标记
- 渲染 3D 虚拟对象
创建使用 OpenCV 的 iOS 项目
在本节中,我们将为 iPhone/iPad 设备创建一个演示应用,该应用将使用 OpenCV(开源计算机视觉)库来检测相机帧中的标记并在其上渲染 3D 对象。 此示例将展示如何从设备相机访问原始视频数据流,如何使用 OpenCV 库执行图像处理,如何在图像中找到标记以及渲染 AR 叠加层。
我们将首先通过选择 iOS 单视图应用模板来创建一个新的 XCode 项目,如以下屏幕快照所示:

现在,我们必须将 OpenCV 添加到我们的项目中。 这一步是必需的,因为在此应用中,我们将使用该库中的许多功能来检测标记并估计位置。
OpenCV 是用于实时计算机视觉的编程功能库。 它最初由 Intel 开发,现在得到 Willow Garage 和 Itseez 的支持。 该库是用 C 和 C++ 语言编写的。 它还具有官方的 Python 绑定和对 Java 和.NET 语言的非官方绑定。
添加 OpenCV 框架
幸运的是该库是跨平台的,因此可以在 iOS 设备上使用。 从 2.4.2 版本开始,iOS 平台上正式支持 OpenCV 库,您可以从库网站下载发行包。 用于 iOS 的 OpenCV 链接指向压缩的 OpenCV 框架。 如果您不熟悉 iOS 开发,请不要担心。 框架就像一堆文件。 通常,每个框架包都包含一个头文件列表和一个静态链接库列表。 应用框架提供了一种将预编译的库分发给开发人员的简便方法。
当然,您可以从头开始构建自己的库。 OpenCV 文档详细解释了此过程。 为简单起见,我们遵循推荐的方法并使用本章的框架。
下载文件后,我们将其内容提取到项目文件夹中,如以下屏幕截图所示:

要通知 XCode IDE 在构建阶段使用任何框架,请单击项目选项,然后找到构建阶段选项卡。 在这里,我们可以添加或删除构建过程中涉及的框架列表。 单击加号以添加新帧,如以下屏幕截图所示:

从这里,我们可以从标准框架列表中选择 。 但是要添加自定义框架,我们应该单击添加其他按钮。 将显示打开文件对话框。 将其指向项目文件夹中的opencv2.framework,如以下屏幕截图所示:

包含 OpenCV 头文件
现在我们已经将 OpenCV 框架添加到了项目中,一切都差不多了。 最后一件事-让 OpenCV 标头添加到项目的预编译标头中。 预编译头文件是加快编译时间的重要功能。 通过向它们添加 OpenCV 标头,您的所有源代码也会自动包含 OpenCV 标头。 在项目源代码树中找到一个.pch文件,并按以下方式对其进行修改。
以下代码显示了如何在项目源代码树中修改.pch文件:
//
// Prefix header for all source files of the 'Example_MarkerBasedAR'
//
#import <Availability.h>
#ifndef __IPHONE_5_0
#warning "This project uses features only available in iOS SDK 5.0 and later."
#endif
#ifdef __cplusplus
#include <opencv2/opencv.hpp>
#endif
#ifdef __OBJC__
#import <UIKit/UIKit.h>
#import <Foundation/Foundation.h>
#endif
现在,您可以从项目中的任何位置调用任何 OpenCV 函数。
就这样。 我们的项目模板已配置完毕,我们准备进一步进行操作。 免费建议:复制该项目; 这将在您创建下一个时节省您的时间!
应用架构
每个 iOS 应用至少包含UIViewController接口接口的一个实例,该实例处理所有视图事件并管理该应用的业务逻辑。 此类提供了所有 iOS 应用的基本视图管理模型。 视图控制器管理一组视图,这些视图构成了应用用户界面的一部分。 作为应用控制器层的一部分,视图控制器将其工作与模型对象和其他控制器对象(包括其他视图控制器)进行协调,因此您的应用将呈现一个统一的用户界面。
我们将要编写的应用只有一个视图。 这就是为什么我们选择单视图应用模板来创建一个模板的原因。 该视图将用于呈现渲染的图片。 我们的ViewController类将包含每个 AR 应用应具有的三个主要组件(请参见下图):
-
视频来源
-
处理管道
-
可视化引擎
![Application architecture]()
视频源负责将内置摄像机拍摄的新帧提供给用户代码。 这意味着视频源应该能够选择摄像头设备(前置或后置摄像头),调整其参数(例如捕获的视频的分辨率,白平衡和快门速度)以及抓取帧而不会冻结视频源。 主界面。
图像处理例程将封装在[HTG7] MarkerDetector类中。 此类为用户代码提供了非常薄的接口。 通常,它是processFrame 和getResult之类的一组函数。 实际上,这就是ViewController应该知道的全部。 没有强烈的必要性,我们决不能将低层数据结构和算法暴露给视图层。 在我们看来,VisualizationController 包含与增强现实的可视化有关的所有逻辑。 VisualizationController还是隐藏渲染引擎特定实现的外观。 低代码一致性使我们可以自由更改这些组件,而无需重写其余代码。
这种方法使您可以自由地在其他平台和编译器上使用独立模块。 例如,您可以轻松使用MarkerDetector类在 Mac,Windows 和 Linux 系统上开发桌面应用,而无需更改代码。 同样,您可以决定在 Windows 平台上移植VisualizationController并使用 Direct3D 进行渲染。 在这种情况下,您应该只编写新的VisualizationController实现; 其他代码部分将保持不变。
主要处理例程从接收到来自视频源的新帧开始。 这将触发视频源,以通过回调将有关此事件的信息通知用户代码。 ViewController处理此回调并执行以下操作:
- 将新帧发送到可视化控制器。
- 使用我们的管道执行新帧的处理。
- 将检测到的标记发送到可视化阶段。
- 渲染场景。
让我们详细研究这个例程。 AR 场景的渲染包括具有最后接收到的帧的内容的背景图像的绘制; 稍后将绘制人造 3D 对象。 当我们发送新的帧进行可视化时,我们正在将图像数据复制到渲染引擎的内部缓冲区。 这还不是实际的渲染。 我们只是使用新的位图更新文本。
第二步是新帧和标记检测的处理。 我们将图像作为输入,并因此收到检测到的标记的列表。 在上面。 这些标记被传递到可视化控制器,该控制器知道如何处理它们。 让我们看一下显示此例程的以下序列图:

我们通过编写视频捕获组件来开始开发。 此类将负责所有帧捕获,并负责通过用户回调发送捕获的帧的通知。 稍后,我们将编写标记检测算法。 此检测例程是应用的核心。 在程序的这一部分中,我们将使用许多 OpenCV 函数来处理图像,检测图像上的轮廓,找到标记矩形并估计其位置。 之后,我们将集中于使用增强现实技术对结果进行可视化。 将所有这些内容整合在一起之后,我们将完成我们的第一个 AR 应用。 让我们继续前进!
访问相机
没有两个主要内容,就无法创建增强现实应用:视频捕获和 AR 可视化。 视频捕获阶段包括从设备相机接收帧,执行必要的颜色转换并将其发送到处理管道。 由于单帧处理时间对于 AR 应用至关重要,因此捕获过程应尽可能高效。 达到最佳性能的最佳方法是直接访问从相机接收的帧。 从 iOS 版本 4 开始,这成为可能。AVFoundation 框架中的现有 API 提供了直接从内存中的图像缓冲区读取所需的必要功能。
您可以找到很多使用AVCaptureVideoPreviewLayer类和UIGetScreenImage函数从摄像机捕获视频的示例。 此技术用于 iOS 版本 3 和更早版本。 现在它已经过时,并且具有两个主要缺点:
- 缺少对帧数据的直接访问。 要获得位图,您必须创建
UIImage的中间实例,将图像复制到该实例,然后将其取回。 对于 AR 应用来说,这个价格太高了,因为每个毫秒都很重要。 每秒丢失几帧(FPS)会大大降低总体用户体验。 - 要绘制 AR,您必须添加一个透明的叠加视图以显示 AR。 参考 Apple 准则,应避免使用不透明的图层,因为它们对移动处理器来说很难混合。
类别AVCaptureDevice 和AVCaptureVideoDataOutput 允许您配置,捕获和指定 32 bpp BGRA 格式的未处理视频帧。 您还可以设置输出帧的所需分辨率。 但是,它确实会影响整体性能 ,因为帧越大,就需要更多的处理时间和内存。
高性能视频捕获是一个很好的选择。 AVFoundation API 提供了一种更快,更优雅的方法来直接从相机抓取帧。 但首先,让我们看一下下图,其中显示了 iOS 的捕获过程:

AVCaptureSession 是我们应该创建的根捕获对象。 捕获会话需要两个组件-输入和输出。 输入设备可以是物理设备(摄像机)或视频文件(未在图中显示)。 在我们的情况下,它是内置摄像头(正面或背面)。 可以通过以下接口之一显示输出设备:
AVCaptureMovieFileOutputAVCaptureStillImageOutputAVCaptureVideoPreviewLayerAVCaptureVideoDataOutput
AVCaptureMovieFileOutput接口用于将视频录制到文件,AVCaptureStillImageOutput接口用于制作静态图像,AVCaptureVideoPreviewLayer接口用于在屏幕上播放视频预览。 我们对AVCaptureVideoDataOutput界面感兴趣,因为它可以直接访问视频数据。
注意
iOS 平台基于 Objective-C 编程语言构建。 因此,要使用 AVFoundation 框架,我们的类也必须用 Objective-C 编写。 在本节中,所有代码清单均使用 Objective-C++ 语言。
为了封装视频捕获过程,我们创建了VideoSource接口,如以下代码所示:
@protocol VideoSourceDelegate<NSObject>
-(void)frameReady:(BGRAVideoFrame) frame;
@end
@interface VideoSource : NSObject<AVCaptureVideoDataOutputSampleBufferDelegate>
{
}
@property (nonatomic, retain) AVCaptureSession *captureSession;
@property (nonatomic, retain) AVCaptureDeviceInput *deviceInput;
@property (nonatomic, retain) id<VideoSourceDelegate> delegate;
- (bool) startWithDevicePosition:(AVCaptureDevicePosition)devicePosition;
- (CameraCalibration) getCalibration;
- (CGSize) getFrameSize;
@end
在此回调中,我们锁定图像缓冲区以防止被任何新帧修改,获取指向图像数据和帧尺寸的指针。 然后,我们构造临时的 BGRAVideoFrame 对象,该对象通过特殊的委托传递给外部。 该代表具有以下原型:
@protocol VideoSourceDelegate<NSObject>
-(void)frameReady:(BGRAVideoFrame) frame;
@end
在VideoSourceDelegate中,VideoSource接口通知用户代码新帧可用。
下面列出了视频捕获的初始化的分步指南:
- 创建
AVCaptureSession的实例并设置捕获会话质量预设。 - 选择并创建
AVCaptureDevice。 您可以选择前置或后置摄像头,也可以使用默认摄像头。 - 使用创建的捕获设备初始化
AVCaptureDeviceInput并将其添加到捕获会话。 - 创建
AVCaptureVideoDataOutput的实例,并使用视频帧,回调委托的格式对其进行初始化,然后分派队列。 - 将捕获输出添加到捕获会话对象。
- 启动捕获会话。
让我们更详细地解释其中一些步骤。 创建捕获会话后,我们可以指定所需的质量预设,以确保获得最佳性能。 我们不需要处理高清质量的视频,因此640 x 480或更低的帧分辨率是一个不错的选择:
- (id)init
{
if ((self = [super init]))
{
AVCaptureSession * capSession = [[AVCaptureSession alloc] init];
if ([capSession canSetSessionPreset:AVCaptureSessionPreset640x480])
{
[capSession setSessionPreset:AVCaptureSessionPreset640x480];
NSLog(@"Set capture session preset AVCaptureSessionPreset640x480");
}
else if ([capSession canSetSessionPreset:AVCaptureSessionPresetLow])
{
[capSession setSessionPreset:AVCaptureSessionPresetLow];
NSLog(@"Set capture session preset AVCaptureSessionPresetLow");
}
self.captureSession = capSession;
}
return self;
}
注意
始终使用适当的 API 检查硬件功能; 不能保证每个摄像机都可以设置特定的会话预设。
在创建捕获会话之后,我们应该添加捕获输入-AVCaptureDeviceInput的实例将代表物理相机设备。 cameraWithPosition函数是一个辅助函数,可将相机设备返回到请求的位置(前,后或默认):
- (bool) startWithDevicePosition:(AVCaptureDevicePosition)devicePosition
{
AVCaptureDevice *videoDevice = [self cameraWithPosition:devicePosition];
if (!videoDevice)
return FALSE;
{
NSError *error;
AVCaptureDeviceInput *videoIn = [AVCaptureDeviceInput
deviceInputWithDevice:videoDevice error:&error];
self.deviceInput = videoIn;
if (!error)
{
if ([[self captureSession] canAddInput:videoIn])
{
[[self captureSession] addInput:videoIn];
}
else
{
NSLog(@"Couldn't add video input");
return FALSE;
}
}
else
{
NSLog(@"Couldn't create video input");
return FALSE;
}
}
[self addRawViewOutput];
[captureSession startRunning];
return TRUE;
}
请注意错误处理代码。 在重要的事情上要注意返回值,因为使用硬件设置是一个好习惯。 否则,您的代码可能会在意外情况下崩溃,而不会通知用户发生了什么。
我们创建了一个捕获会话,并添加了视频帧的来源。 现在是时候添加一个接收器—一个将接收实际帧数据的对象。 AVCaptureVideoDataOutput类用于处理视频流中的未压缩帧。 摄像机可以提供 BGRA,CMYK 或简单的灰度颜色模型的帧。 出于我们的目的,BGRA 颜色模型最适合所有人,因为我们将使用此框架进行可视化和图像处理。 以下代码显示addRawViewOutput函数:
- (void) addRawViewOutput
{
/*We setupt the output*/
AVCaptureVideoDataOutput *captureOutput = [[AVCaptureVideoDataOutput alloc] init];
/*While a frame is processes in -captureOutput:didOutputSampleBuffer:fromConnection: delegate methods no other frames are added in the queue.
If you don't want this behaviour set the property to NO */
captureOutput.alwaysDiscardsLateVideoFrames = YES;
/*We create a serial queue to handle the processing of our frames*/
dispatch_queue_t queue;
queue = dispatch_queue_create("com.Example_MarkerBasedAR.cameraQueue",
NULL);
[captureOutput setSampleBufferDelegate:self queue:queue];
dispatch_release(queue);
// Set the video output to store frame in BGRA (It is supposed to be faster)
NSString* key = (NSString*)kCVPixelBufferPixelFormatTypeKey;
NSNumber* value = [NSNumber
numberWithUnsignedInt:kCVPixelFormatType_32BGRA];
NSDictionary* videoSettings = [NSDictionary dictionaryWithObject:value
forKey:key];
[captureOutput setVideoSettings:videoSettings];
// Register an output
[self.captureSession addOutput:captureOutput];
}
现在,终于配置了捕获会话。 启动后,它将捕获相机中的帧并将其发送给用户代码。 当新帧可用时,AVCaptureSession对象将执行captureOutput: didOutputSampleBuffer:fromConnection回调。 在此函数中,我们将执行次要的数据转换操作,以更可用的格式获取图像数据并将其传递给用户代码:
- (void)captureOutput:(AVCaptureOutput *)captureOutput
didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer
fromConnection:(AVCaptureConnection *)connection
{
// Get a image buffer holding video frame
CVImageBufferRef imageBuffer = CMSampleBufferGetImageBuffer(sampleBuffer);
// Lock the image buffer
CVPixelBufferLockBaseAddress(imageBuffer,0);
// Get information about the image
uint8_t *baseAddress = (uint8_t *)CVPixelBufferGetBaseAddress(imageBuffer);
size_t width = CVPixelBufferGetWidth(imageBuffer);
size_t height = CVPixelBufferGetHeight(imageBuffer);
size_t stride = CVPixelBufferGetBytesPerRow(imageBuffer);
BGRAVideoFrame frame = {width, height, stride, baseAddress};
[delegate frameReady:frame];
/*We unlock the image buffer*/
CVPixelBufferUnlockBaseAddress(imageBuffer,0);
}
我们获得了一个用于存储帧数据的图像缓冲区的引用。 然后我们将其锁定,以防止被新帧修改。 现在,我们可以独占访问帧数据。 借助 CoreVideo API,我们可以获得图像尺寸,步幅(每行像素数)以及指向图像数据开头的指针。
注意
我提请您注意回调代码中的CVPixelBufferLockBaseAddress / CVPixelBufferUnlockBaseAddress函数调用。 在我们锁定像素缓冲区之前,它可以保证其数据的一致性和正确性。 只有获得锁定后,才能读取像素。 完成后,别忘了解锁它,以使操作系统可以用新数据填充它。
标记检测
标记通常被设计为一个矩形图像,其中包含黑色和白色区域。 由于已知的限制,标记物检测过程很简单。 首先,我们需要在输入图像上找到闭合轮廓并将其内部的图像扭曲成矩形,然后根据我们的标记模型进行检查。
在此示例中,将使用5 x 5标记。 看起来是这样的:

在本书的示例项目中,标记检测例程封装在[HTG2] MarkerDetector类中:
/**
* A top-level class that encapsulate marker detector algorithm
*/
class MarkerDetector
{
public:
/**
* Initialize a new instance of marker detector object
* @calibration[in] - Camera calibration necessary for pose estimation.
*/
MarkerDetector(CameraCalibration calibration);
void processFrame(const BGRAVideoFrame& frame);
const std::vector<Transformation>& getTransformations() const;
protected:
bool findMarkers(const BGRAVideoFrame& frame, std::vector<Marker>&
detectedMarkers);
void prepareImage(const cv::Mat& bgraMat,
cv::Mat& grayscale);
void performThreshold(const cv::Mat& grayscale,
cv::Mat& thresholdImg);
void findContours(const cv::Mat& thresholdImg,
std::vector<std::vector<cv::Point> >& contours,
int minContourPointsAllowed);
void findMarkerCandidates(const std::vector<std::vector<cv::Point> >&
contours, std::vector<Marker>& detectedMarkers);
void detectMarkers(const cv::Mat& grayscale,
std::vector<Marker>& detectedMarkers);
void estimatePosition(std::vector<Marker>& detectedMarkers);
private:
};
为了帮助您更好地了解标记检测例程,将显示视频中一帧的逐步处理。 以从 iPad 相机拍摄的源图像为例:

标记识别
这是标记检测例程的工作流程:
- 将输入图像转换为灰度。
- 执行二进制阈值操作。
- 检测轮廓。
- 搜索可能的标记。
- 检测和解码标记。
- 估计标记 3D 姿势。
灰度转换
必须转换为灰度,因为标记通常只包含黑白块,并且在灰度图像上使用它们更容易操作。 幸运的是,OpenCV 颜色转换非常简单。
请看一下下面的 C++ 代码清单:
void MarkerDetector::prepareImage(const cv::Mat& bgraMat, cv::Mat& grayscale)
{
// Convert to grayscale
cv::cvtColor(bgraMat, grayscale, CV_BGRA2GRAY);
}
此函数会将输入的 BGRA 图像转换为灰度(如果需要,它将分配图像缓冲区)并将结果放入第二个参数中。 所有其他步骤将对灰度图像执行。
图像二值化
二值化操作将图像的每个像素转换为黑色(零强度)或白色(全强度)。 查找轮廓需要此步骤。 有几种阈值方法; 每个方面都有强项和弱项。 最简单,最快的方法是绝对阈值。 在这种方法中,结果值取决于当前像素强度和某个阈值。 如果像素强度大于阈值,则结果将为白色(255);否则,结果为白色(255)。 否则将为黑色(0)。
这种方法有一个很大的缺点-它取决于照明条件和柔和的强度变化。 更优选的方法是自适应阈值。 该方法的主要区别是使用了被检查像素周围给定半径的所有像素。 使用平均强度可获得良好的结果,并确保更可靠的角点检测。
以下代码段显示了MarkerDetector函数:
void MarkerDetector::performThreshold(const cv::Mat& grayscale, cv::Mat& thresholdImg)
{
cv::adaptiveThreshold(grayscale, // Input image
thresholdImg,// Result binary image
255, //
cv::ADAPTIVE_THRESH_GAUSSIAN_C, //
cv::THRESH_BINARY_INV, //
7, //
7 //
);
}
将自适应阈值应用于输入图像后,所得图像看起来类似于以下图像:

每个标记通常看起来像一个带有黑色和白色区域的正方形图形。 因此,定位标记的最佳方法是找到闭合轮廓,并使用 4 个顶点的多边形对其进行近似。
轮廓检测
cv::findCountours函数将检测输入二进制图像上的轮廓:
void MarkerDetector::findContours(const cv::Mat& thresholdImg,
std::vector<std::vector<cv::Point> >& contours,
int minContourPointsAllowed)
{
std::vector< std::vector<cv::Point> > allContours;
cv::findContours(thresholdImg, allContours, CV_RETR_LIST, CV_CHAIN_APPROX_NONE);
contours.clear();
for (size_t i=0; i<allContours.size(); i++)
{
int contourSize = allContours[i].size();
if (contourSize > minContourPointsAllowed)
{
contours.push_back(allContours[i]);
}
}
}
此函数的返回值是一个多边形列表,其中每个多边形代表一个轮廓。 该函数将跳过其周边像素值设置为小于minContourPointsAllowed变量的值的轮廓。 这是因为我们对小轮廓不感兴趣。 (它们可能不包含任何标记,或者由于标记尺寸太小而无法检测到轮廓。)
下图显示了检测到的轮廓的可视化:

候选者搜索
找到轮廓后,执行多边形逼近阶段。 这样做是为了减少描述轮廓形状的点的数量。 过滤掉没有标记的区域是一项很好的质量检查,因为它们总是可以用包含四个顶点的多边形来表示。 如果近似多边形的顶点多于或少于 4 个,则绝对不是我们想要的。 以下代码实现了这个想法:
void MarkerDetector::findCandidates
(
const ContoursVector& contours,
std::vector<Marker>& detectedMarkers
)
{
std::vector<cv::Point> approxCurve;
std::vector<Marker> possibleMarkers;
// For each contour, analyze if it is a parallelepiped likely to be the
marker
for (size_t i=0; i<contours.size(); i++)
{
// Approximate to a polygon
double eps = contours[i].size() * 0.05;
cv::approxPolyDP(contours[i], approxCurve, eps, true);
// We interested only in polygons that contains only four points
if (approxCurve.size() != 4)
continue;
// And they have to be convex
if (!cv::isContourConvex(approxCurve))
continue;
// Ensure that the distance between consecutive points is large enough
float minDist = std::numeric_limits<float>::max();
for (int i = 0; i < 4; i++)
{
cv::Point side = approxCurve[i] - approxCurve[(i+1)%4];
float squaredSideLength = side.dot(side);
minDist = std::min(minDist, squaredSideLength);
}
// Check that distance is not very small
if (minDist < m_minContourLengthAllowed)
continue;
// All tests are passed. Save marker candidate:
Marker m;
for (int i = 0; i<4; i++)
m.points.push_back( cv::Point2f(approxCurve[i].x,approxCurve[i].y) );
// Sort the points in anti-clockwise order
// Trace a line between the first and second point.
// If the third point is at the right side, then the points are anti-
clockwise
cv::Point v1 = m.points[1] - m.points[0];
cv::Point v2 = m.points[2] - m.points[0];
double o = (v1.x * v2.y) - (v1.y * v2.x);
if (o < 0.0) //if the third point is in the left side, then
sort in anti-clockwise order
std::swap(m.points[1], m.points[3]);
possibleMarkers.push_back(m);
}
// Remove these elements which corners are too close to each other.
// First detect candidates for removal:
std::vector< std::pair<int,int> > tooNearCandidates;
for (size_t i=0;i<possibleMarkers.size();i++)
{
const Marker& m1 = possibleMarkers[i];
//calculate the average distance of each corner to the nearest corner
of the other marker candidate
for (size_t j=i+1;j<possibleMarkers.size();j++)
{
const Marker& m2 = possibleMarkers[j];
float distSquared = 0;
for (int c = 0; c < 4; c++)
{
cv::Point v = m1.points[c] - m2.points[c];
distSquared += v.dot(v);
}
distSquared /= 4;
if (distSquared < 100)
{
tooNearCandidates.push_back(std::pair<int,int>(i,j));
}
}
}
// Mark for removal the element of the pair with smaller perimeter
std::vector<bool> removalMask (possibleMarkers.size(), false);
for (size_t i=0; i<tooNearCandidates.size(); i++)
{
float p1 = perimeter(possibleMarkers[tooNearCandidates[i].first
].points);
float p2 =
perimeter(possibleMarkers[tooNearCandidates[i].second].points);
size_t removalIndex;
if (p1 > p2)
removalIndex = tooNearCandidates[i].second;
else
removalIndex = tooNearCandidates[i].first;
removalMask[removalIndex] = true;
}
// Return candidates
detectedMarkers.clear();
for (size_t i=0;i<possibleMarkers.size();i++)
{
if (!removalMask[i])
detectedMarkers.push_back(possibleMarkers[i]);
}
}
现在我们已经获得了可能是标记的平行六面体列表。 要验证它们是否是标记,我们需要执行三个步骤:
- 首先,我们应该删除透视投影,以便获得矩形区域的正视图。
- 然后,我们使用大津算法对图像进行阈值处理。 该算法采用双峰分布,并找到使类别外方差最大化的阈值,同时保持较低的类别内方差。
- 最后,我们执行标记代码的识别。 如果是标记,则具有内部代码。 标记分为
7 x 7的网格,内部7 x 7的单元格包含 ID 信息。 其余部分对应于外部黑色边框。 在这里,我们首先检查外部黑色边框是否存在。 然后,我们读取内部的7 x 7单元并检查它们是否提供有效的代码。 (可能需要旋转代码以获得有效的代码。)
为了获得矩形标记图像,我们必须使用透视变换使输入图像不变形。 可以借助cv::getPerspectiveTransform函数来计算该矩阵。 它从四对对应点中找到透视变换。 第一个参数是图像空间中的标记坐标,第二个点对应于方形标记图像的坐标。 估计的转换会将标记转换为正方形,然后让我们对其进行分析:
cv::Mat canonicalMarker;
Marker& marker = detectedMarkers[i];
// Find the perspective transfomation that brings current marker to rectangular form
cv::Mat M = cv::getPerspectiveTransform(marker.points, m_markerCorners2d);
// Transform image to get a canonical marker image
cv::warpPerspective(grayscale, canonicalMarker, M, markerSize);
图像扭曲使用透视变换将图像变换为矩形形式:

现在我们可以测试图像,以验证它是否是有效的标记图像。 然后,我们尝试使用标记代码提取位掩码。 因为我们期望标记仅包含黑白颜色,所以我们可以执行大津阈值处理以去除灰色像素并仅保留黑白像素:
//threshold image
cv::threshold(markerImage, markerImage, 125, 255, cv::THRESH_BINARY | cv::THRESH_OTSU);

标记代码识别
每个标记都有一个内部代码,该代码由 5 个字(每个 5 位)给出。 使用的编码是对汉明码的略微修改。 总体上,每个字在使用的 5 位中只有 2 位信息。 其他 3 个用于错误检测。 因此,我们最多可以有 1024 个不同的 ID。
与汉明码的主要区别在于第一位(位 3 和 5 的奇偶校验)被反转。 因此,ID 0(在汉明码中为 00000)在我们的代码中变为 10000。 这样做的目的是为了防止整个黑色矩形成为有效的标记 ID,目的是减少环境物体误报的可能性。

计算每个单元的黑白像素数量,我们得到带有标记代码的5 x 5位掩码。 要计算某个图像上的非零像素数,请使用cv::countNonZero函数。 此函数对给定的 1D 或 2D 数组中的非零数组元素进行计数。 cv::Mat类型可以返回子图像视图,即cv::Mat的新实例,其中包含原始图像的一部分。 例如,如果您有一个cv::Mat大小为400 x 400,则下面的代码段将从(10, 10)开始为5 x 5图像块创建一个子矩阵:
cv::Mat src(400,400,CV_8UC1);
cv::Rect r(10,10,50,50);
cv::Mat subView = src(r);
读取标记代码
使用这种技术,我们可以轻松地在标记板上找到黑白单元格:
cv::Mat bitMatrix = cv::Mat::zeros(5,5,CV_8UC1);
//get information(for each inner square, determine if it is black or white)
for (int y=0;y<5;y++)
{
for (int x=0;x<5;x++)
{
int cellX = (x+1)*cellSize;
int cellY = (y+1)*cellSize;
cv::Mat cell = grey(cv::Rect(cellX,cellY,cellSize,cellSize));
int nZ = cv::countNonZero(cell);
if (nZ> (cellSize*cellSize) /2)
bitMatrix.at<uchar>(y,x) = 1;
}
}
看下图的 。 根据相机的角度,同一标记可以有四种可能的表示形式:

由于标记图片有四个可能的方向,因此我们必须找到正确的标记位置。 记住,我们为信息的每两位引入了三个奇偶校验位。 在他们的帮助下,我们可以找到每种可能的标记方向的汉明距离。 正确的标记位置的汉明距离误差为零,而其他旋转不会。
这是一个代码片段,该代码片段将位矩阵旋转四次并找到正确的标记方向:
//check all possible rotations
cv::Mat rotations[4];
int distances[4];
rotations[0] = bitMatrix;
distances[0] = hammDistMarker(rotations[0]);
std::pair<int,int> minDist(distances[0],0);
for (int i=1; i<4; i++)
{
//get the hamming distance to the nearest possible word
rotations[i] = rotate(rotations[i-1]);
distances[i] = hammDistMarker(rotations[i]);
if (distances[i] < minDist.first)
{
minDist.first = distances[i];
minDist.second = i;
}
}
该代码以使汉明距离量度的误差最小的方式找到位矩阵的方向。 对于正确的标记 ID,此误差应为零; 如果不是,则表示我们遇到了错误的标记模式(图像损坏或标记检测错误)。
标记位置优化
找到正确的标记方向后,我们分别旋转标记的角以符合其顺序:
//sort the points so that they are always in the same order
// no matter the camera orientation
std::rotate(marker.points.begin(), marker.points.begin() + 4 - nRotations,
marker.points.end());
在检测到标记并对其 ID 进行解码之后,我们将优化其角点。 当我们估计 3D 标记位置时,此操作将帮助我们进行下一步。 为了找到亚像素精度的角点位置,使用cv::cornerSubPix函数:
std::vector<cv::Point2f> preciseCorners(4 * goodMarkers.size());
for (size_t i=0; i<goodMarkers.size(); i++)
{
Marker& marker = goodMarkers[i];
for (int c=0;c<4;c++)
{
preciseCorners[i*4+c] = marker.points[c];
}
}
cv::cornerSubPix(grayscale, preciseCorners, cvSize(5,5), cvSize(-1,-1), cvTermCriteria(CV_TERMCRIT_ITER,30,0.1));
//copy back
for (size_t i=0;i<goodMarkers.size();i++)
{
Marker&marker = goodMarkers[i];
for (int c=0;c<4;c++)
{
marker.points[c] = preciseCorners[i*4+c];
}
}
第一步是为此函数准备输入数据。 我们将顶点列表复制到输入数组。 然后,我们将cv::cornerSubPix传递给实际图像,点列表以及影响位置改进质量和性能的参数集。 完成后,我们将精炼的位置复制回标记角,如下图所示。

由于其复杂性,我们在标记检测的早期阶段不使用cornerSubPix。 对于大量的点(就计算时间而言)调用此函数非常昂贵。 因此,我们仅对有效标记执行此操作。
将标记放置在 3D 中
增强现实尝试将真实世界的对象与虚拟内容融合在一起。 要将 3D 模型放置在场景中,我们需要了解用于获取视频帧的摄像机的姿势。 我们将在笛卡尔坐标系中使用欧几里得变换来表示这样的姿势。
标记在 3D 中的位置及其在 2D 中的对应投影受以下公式限制:
P = A * [R | T] * M
其中:
M表示 3D 空间中的点[R | T]表示表示欧几里德变换的[3 | 4]矩阵A表示摄像机矩阵或固有参数矩阵P表示M在屏幕空间中的投影
在执行标记检测步骤之后,我们现在知道 2D 中四个标记角的位置(屏幕空间中的投影)。 在下一节中,您将学习如何获取A矩阵和M向量参数,以及如何计算[R | T]变换。
相机校准
每个摄像机镜头都有独特的参数,例如焦距,主点和镜头畸变模型。 查找相机固有参数的过程称为相机校准。 相机校准过程对于增强现实应用非常重要,因为它描述了输出图像上的透视变换和镜头失真。 为了在增强现实中获得最佳用户体验,应该使用相同的透视投影来完成增强对象的可视化。
要校准相机,我们需要特殊的图案图像(棋盘或白色背景上的黑色圆圈)。 从不同的角度来看,正在校准的相机会对此模式拍摄 10-15 张照片。 然后,校准算法会找到最佳的相机固有参数和失真向量:

为了在我们的程序中表示摄像机的校准,我们使用CameraCalibration类:
/**
* A camera calibration class that stores intrinsic matrix and distorsion coefficients.
*/
class CameraCalibration
{
public:
CameraCalibration();
CameraCalibration(float fx, float fy, float cx, float cy);
CameraCalibration(float fx, float fy, float cx, float cy, float
distorsionCoeff[4]);
void getMatrix34(float cparam[3][4]) const;
const Matrix33& getIntrinsic() const;
const Vector4& getDistorsion() const;
private:
Matrix33 m_intrinsic;
Vector4 m_distorsion;
};
校准程序的详细说明不在本章范围之内。 请参考“OpenCV camera_calibration示例”或《OpenCV:在投影关系中估计图像》,以获取其他信息和源代码。
对于此示例 ,我们提供了所有现代 iOS 设备(iPad 2,iPad 3 和 iPhone 4)的内部参数。
标记姿势估计
利用标记角的精确位置,我们可以估计相机和 3D 空间中标记之间的转换。 将该操作称为根据 2D-3D 对应关系的姿势估计。 姿势估计过程会在相机和对象之间找到一个欧几里得变换(仅包含旋转和平移分量)。
让我们看一下下图:

C用于表示相机中心。P1-P4点是世界坐标系中的 3D 点, p1-p4点是它们在相机像面上的投影。 我们的目标是使用本征矩阵和图像平面(P1-P4)上的已知点投影和相机C找到已知标记位置之间的相对转换。 但是,我们在哪里可以获得 3D 空间中标记位置(p1-p4)的坐标? 我们想象他们。 由于我们的标记始终具有正方形形状,并且所有顶点都位于一个平面上,因此我们可以如下定义它们的角:

我们将标记放在 XY 平面(Z 分量为零)中,标记中心对应于(0, 0, 0)点。 这是一个很好的提示,因为在这种情况下,我们的坐标系的起点将位于标记的中心(Z 轴垂直于标记平面)。
要使用已知的 2D-3D 对应关系查找摄像机位置,可以使用cv::solvePnP函数:
void solvePnP(const Mat& objectPoints, const Mat& imagePoints, const Mat&
cameraMatrix, const Mat& distCoeffs, Mat& rvec, Mat& tvec, bool
useExtrinsicGuess=false);
objectPoints数组是对象坐标空间中对象点的输入数组。 可以在此处传递std::vector<cv::Point3f>。 也可以将 OpenCV 矩阵3 x N或N x 3(其中N是点数)作为输入参数传递。 在这里,我们传递 3D 空间(四个点的向量)中标记坐标的列表。
imagePoints数组是相应图像点(或投影)的数组。 此自变量也可以是2 x N或N x 2的std::vector<cv::Point2f>或cv::Mat,其中N是点数。 在这里,我们传递找到的标记角的列表。
cameraMatrix:这是3 x 3摄像机固有矩阵。distCoeffs:这是失真系数(k1, k2, p1, p2, k3)的输入4 x 1、1 x 4、5 x 1或1 x 5向量。 如果为NULL,则所有失真系数均设置为 0。rvec:这是输出旋转向量(与tvec一起)将点从模型坐标系带到摄像机坐标系。tvec:这是输出转换向量。useExtrinsicGuess:如果为true,则该函数将分别使用提供的rvec和tvec向量作为旋转向量和平移向量的初始近似值,并将对其进行进一步优化。
该函数以最小化重投影误差(即观察到的投影imagePoints与投影的objectPoints之间的距离平方的总和)的方式计算相机变换。
估计的变换由旋转(rvec)和平移分量(tvec)定义。 这也称为欧几里得变换或刚性变换。
刚性转换的正式定义是,当作用于任何向量v时,产生以下形式的转换向量T(v)的转换:
T(v) = Rv + t
其中R^T = R^(-1)(即R是正交变换),t是给出原点平移的向量。 此外,适当的刚性转换还具有
It(R) = 1
这意味着R不产生反射,因此代表旋转(保留方向的正交变换)。
为了从旋转向量获得3 x 3旋转矩阵,使用函数cv::Rodrigues。 此函数转换由旋转向量表示的旋转,并返回其等效旋转矩阵。
注意
因为cv::solvePnP根据 3D 空间中的标记姿势找到了相机位置,所以我们必须反转找到的变换。 生成的变换将描述相机坐标系中的标记变换,这对于渲染过程而言更为友好。
这是estimatePosition函数的清单 ,可找到检测到的标记的位置:
void MarkerDetector::estimatePosition(std::vector<Marker>& detectedMarkers)
{
for (size_t i=0; i<detectedMarkers.size(); i++)
{
Marker& m = detectedMarkers[i];
cv::Mat Rvec;
cv::Mat_<float> Tvec;
cv::Mat raux,taux;
cv::solvePnP(m_markerCorners3d, m.points, camMatrix, distCoeff,raux,taux);
raux.convertTo(Rvec,CV_32F);
taux.convertTo(Tvec ,CV_32F);
cv::Mat_<float> rotMat(3,3);
cv::Rodrigues(Rvec, rotMat);
// Copy to transformation matrix
m.transformation = Transformation();
for (int col=0; col<3; col++)
{
for (int row=0; row<3; row++)
{
m.transformation.r().mat[row][col] = rotMat(row,col); // Copy rotation
component
}
m.transformation.t().data[col] = Tvec(col); // Copy translation
component
}
// Since solvePnP finds camera location, w.r.t to marker pose, to get
marker pose w.r.t to the camera we invert it.
m.transformation = m.transformation.getInverted();
}
渲染 3D 虚拟对象
因此,到目前为止,您已经知道如何在图像上找到标记,以计算它们相对于相机在空间中的确切位置。 现在该画点东西了。 如前所述,要渲染场景,我们将使用 OpenGL 函数。 3D 可视化是增强现实的核心部分。 OpenGL 提供了用于创建高质量渲染的所有基本功能。
注意
有大量的商业和开源 3D 引擎(Unity ,虚幻引擎, Ogre 等)。 但是所有这些引擎都使用 OpenGL 或 DirectX 将命令传递给视频卡。 DirectX 是专有 API,仅 Windows 平台支持。 因此,OpenGL 是构建跨平台渲染系统的第一个也是最后一个候选对象。
了解渲染系统的原理将为您提供必要的经验和知识,以供将来使用这些引擎或编写自己的引擎。
创建 OpenGL 渲染层
为了在应用中使用 OpenGL 函数,您应该获得一个 iOS 图形上下文表面,它将向用户呈现渲染的场景。 该上下文通常绑定到用户看到的视图。 以下屏幕快照显示了 XCode 的界面构建器中应用接口的层次结构:

为了封装 OpenGL 上下文初始化逻辑,我们引入EAGLView类:
@class EAGLContext;
// This class wraps the CAEAGLLayer from CoreAnimation into a convenient UIView subclass.
// The view content is basically an EAGL surface you render your OpenGL scene into.
// Note that setting the view non-opaque will only work if the EAGL surface has an alpha channel.
@interface EAGLView : UIView
{
@private
// The OpenGL ES names for the framebuffer and renderbuffer used to render
to this view.
GLuint defaultFramebuffer, colorRenderbuffer;
}
@property (nonatomic, retain) EAGLContext *context;
// The pixel dimensions of the CAEAGLLayer.
@property (readonly) GLint framebufferWidth;
@property (readonly) GLint framebufferHeight;
- (void)setFramebuffer;
- (BOOL)presentFramebuffer;
- (void)initContext;
@end
此类连接到接口定义文件中的视图,因此,在加载NIB文件时,运行时将实例化EAGLView的新实例。 创建后,它将接收来自 iOS 的事件并初始化 OpenGL 渲染上下文。
以下是显示initWithCoder函数的代码清单:
//The EAGL view is stored in the nib file. When it's unarchived it's sent -initWithCoder:.
- (id)initWithCoder:(NSCoder*)coder
{
self = [super initWithCoder:coder];
if (self) {
CAEAGLLayer *eaglLayer = (CAEAGLLayer *)self.layer;
eaglLayer.opaque = TRUE;
eaglLayer.drawableProperties = [NSDictionary dictionaryWithObjectsAndKeys:
[NSNumber numberWithBool:FALSE],
kEAGLDrawablePropertyRetainedBacking,
kEAGLColorFormatRGBA8,
kEAGLDrawablePropertyColorFormat,
nil];
[self initContext];
}
return self;
}
- (void)createFramebuffer
{
if (context && !defaultFramebuffer) {
[EAGLContext setCurrentContext:context];
// Create default framebuffer object.
glGenFramebuffers(1, &defaultFramebuffer);
glBindFramebuffer(GL_FRAMEBUFFER, defaultFramebuffer);
// Create color render buffer and allocate backing store.
glGenRenderbuffers(1, &colorRenderbuffer);
glBindRenderbuffer(GL_RENDERBUFFER, colorRenderbuffer);
[context renderbufferStorage:GL_RENDERBUFFER fromDrawable:(CAEAGLLayer *)self.layer];
glGetRenderbufferParameteriv(GL_RENDERBUFFER, GL_RENDERBUFFER_WIDTH, &framebufferWidth);
glGetRenderbufferParameteriv(GL_RENDERBUFFER, GL_RENDERBUFFER_HEIGHT, &framebufferHeight);
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0,
GL_RENDERBUFFER, colorRenderbuffer);
if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE)
NSLog(@"Failed to make complete framebuffer object %x",
glCheckFramebufferStatus(GL_FRAMEBUFFER));
//glClearColor(0, 0, 0, 0);
NSLog(@"Framebuffer created");
}
}
渲染 AR 场景
正如所见,EAGLView类不包含用于 3D 对象和视频可视化的方法。 这是有目的的。 EAGLView的任务是提供渲染上下文。 职责分离使我们以后可以更改可视化的逻辑。
为了可视化增强现实,我们将创建一个单独的类,称为VisualizationController:
@interface SimpleVisualizationController : NSObject<VisualizationController>
{
EAGLView * m_glview;
GLuint m_backgroundTextureId;
std::vector<Transformation> m_transformations;
CameraCalibration m_calibration;
CGSize m_frameSize;
}
-(id) initWithGLView:(EAGLView*)view calibration:(CameraCalibration) calibration frameSize:(CGSize) size;
-(void) drawFrame;
-(void) updateBackground:(BGRAVideoFrame) frame;
-(void) setTransformationList:(const std::vector<Transformation>&) transformations;
drawFrame函数将 AR 渲染到给定的EAGLView目标视图上。 它执行以下步骤:
- 清除场景。
- 设置正投影以绘制背景。
- 在视口上绘制从相机接收的最新图像。
- 根据相机的固有参数设置透视投影。
- 对于每个检测到的标记,它将坐标系移动到 3D 中的标记位置。 (它将
4 x 4转换矩阵放入 OpenGl 模型视图矩阵。) - 渲染任意 3D 对象。
- 显示帧缓冲区。
准备绘制框架时,将调用drawFrame函数。 当新的相机帧已上载到视频存储器并且标记检测阶段已完成时,就会发生这种情况。
以下代码显示drawFrame函数:
- (void)drawFrame
{
// Set the active framebuffer
[m_glview setFramebuffer];
// Draw a video on the background
[self drawBackground];
// Draw 3D objects on the position of the detected markers
[self drawAR];
// Present framebuffer
bool ok = [m_glview presentFramebuffer];
int glErCode = glGetError();
if (!ok || glErCode != GL_NO_ERROR)
{
std::cerr << "GL error detected. Error code:" << glErCode << std::endl;
}
}
绘制背景非常容易。 我们设置正交投影并使用当前帧中的图像绘制全屏纹理。 这是使用 GLES 1 API 进行此操作的代码清单:
- (void) drawBackground
{
GLfloat w = m_glview.bounds.size.width;
GLfloat h = m_glview.bounds.size.height;
const GLfloat squareVertices[] =
{
0, 0,
w, 0,
0, h,
w, h
};
static const GLfloat textureVertices[] =
{
1, 0,
1, 1,
0, 0,
0, 1
};
static const GLfloat proj[] =
{
0, -2.f/w, 0, 0,
-2.f/h, 0, 0, 0,
0, 0, 1, 0,
1, 1, 0, 1
};
glMatrixMode(GL_PROJECTION);
glLoadMatrixf(proj);
glMatrixMode(GL_MODELVIEW);
glLoadIdentity();
glDisable(GL_COLOR_MATERIAL);
glEnable(GL_TEXTURE_2D);
glBindTexture(GL_TEXTURE_2D, m_backgroundTextureId);
// Update attribute values.
glVertexPointer(2, GL_FLOAT, 0, squareVertices);
glEnableClientState(GL_VERTEX_ARRAY);
glTexCoordPointer(2, GL_FLOAT, 0, textureVertices);
glEnableClientState(GL_TEXTURE_COORD_ARRAY);
glColor4f(1,1,1,1);
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
glDisableClientState(GL_VERTEX_ARRAY);
glDisableClientState(GL_TEXTURE_COORD_ARRAY);
glDisable(GL_TEXTURE_2D);
}
场景中人造对象的渲染有些棘手。 首先,我们必须针对相机固有(校准)矩阵调整 OpenGL 投影矩阵。 没有这一步,我们将有错误的透视图。 错误的视角会使人造物体看起来不自然,就好像它们在“空中飞舞”,而不是真实世界的一部分。 正确的视角对于任何增强现实应用都是必不可少的。
这是一个代码片段,可根据相机的内在函数创建一个 OpenGL 投影矩阵:
- (void)buildProjectionMatrix:(Matrix33)cameraMatrix: (int)screen_width: (int)screen_height: (Matrix44&) projectionMatrix
{
float near = 0.01; // Near clipping distance
float far = 100; // Far clipping distance
// Camera parameters
float f_x = cameraMatrix.data[0]; // Focal length in x axis
float f_y = cameraMatrix.data[4]; // Focal length in y axis (usually the
same?)
float c_x = cameraMatrix.data[2]; // Camera primary point x
float c_y = cameraMatrix.data[5]; // Camera primary point y
projectionMatrix.data[0] = - 2.0 * f_x / screen_width;
projectionMatrix.data[1] = 0.0;
projectionMatrix.data[2] = 0.0;
projectionMatrix.data[3] = 0.0;
projectionMatrix.data[4] = 0.0;
projectionMatrix.data[5] = 2.0 * f_y / screen_height;
projectionMatrix.data[6] = 0.0;
projectionMatrix.data[7] = 0.0;
projectionMatrix.data[8] = 2.0 * c_x / screen_width - 1.0;
projectionMatrix.data[9] = 2.0 * c_y / screen_height - 1.0;
projectionMatrix.data[10] = -( far+near ) / ( far - near );
projectionMatrix.data[11] = -1.0;
projectionMatrix.data[12] = 0.0;
projectionMatrix.data[13] = 0.0;
projectionMatrix.data[14] = -2.0 * far * near / ( far - near );
projectionMatrix.data[15] = 0.0;
}
在将这个矩阵加载到 OpenGL 管道后,该绘制一些对象了。 每个变换可以表示为4 x 4矩阵,并加载到 OpenGL 模型视图矩阵中。 这会将坐标系移动到世界坐标系中的标记位置。
例如,让我们在每个标记的顶部绘制一个坐标轴,以显示其在空间中的方向,并在整个标记上绘制一个带有梯度填充的矩形。 这种可视化将为我们提供可视反馈,表明我们的代码正在按预期工作。
以下是显示drawAR函数的代码段:
- (void) drawAR
{
Matrix44 projectionMatrix;
[self buildProjectionMatrix:m_calibration.getIntrinsic():m_frameSize.width
:m_frameSize.height :projectionMatrix];
glMatrixMode(GL_PROJECTION);
glLoadMatrixf(projectionMatrix.data);
glMatrixMode(GL_MODELVIEW);
glLoadIdentity();
glEnableClientState(GL_VERTEX_ARRAY);
glEnableClientState(GL_NORMAL_ARRAY);
glPushMatrix();
glLineWidth(3.0f);
float lineX[] = {0,0,0,1,0,0};
float lineY[] = {0,0,0,0,1,0};
float lineZ[] = {0,0,0,0,0,1};
const GLfloat squareVertices[] = {
-0.5f, -0.5f,
0.5f, -0.5f,
-0.5f, 0.5f,
0.5f, 0.5f,
};
const GLubyte squareColors[] = {
255, 255, 0, 255,
0, 255, 255, 255,
0, 0, 0, 0,
255, 0, 255, 255,
};
for (size_t transformationIndex=0;
transformationIndex<m_transformations.size(); transformationIndex++)
{
const Transformation& transformation =
m_transformations[transformationIndex];
Matrix44 glMatrix = transformation.getInverted().getMat44();
glLoadMatrixf(reinterpret_cast<const GLfloat*>(&glMatrix.data[0]));
// draw data
glVertexPointer(2, GL_FLOAT, 0, squareVertices);
glEnableClientState(GL_VERTEX_ARRAY);
glColorPointer(4, GL_UNSIGNED_BYTE, 0, squareColors);
glEnableClientState(GL_COLOR_ARRAY);
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
glDisableClientState(GL_COLOR_ARRAY);
float scale = 0.5;
glScalef(scale, scale, scale);
glColor4f(1.0f, 0.0f, 0.0f, 1.0f);
glVertexPointer(3, GL_FLOAT, 0, lineX);
glDrawArrays(GL_LINES, 0, 2);
glColor4f(0.0f, 1.0f, 0.0f, 1.0f);
glVertexPointer(3, GL_FLOAT, 0, lineY);
glDrawArrays(GL_LINES, 0, 2);
glColor4f(0.0f, 0.0f, 1.0f, 1.0f);
glVertexPointer(3, GL_FLOAT, 0, lineZ);
glDrawArrays(GL_LINES, 0, 2);
}
glPopMatrix();
glDisableClientState(GL_VERTEX_ARRAY);
}
如果运行应用,则会得到下图:

尽管的事实是我们没有使用特殊的 3D 渲染引擎进行场景可视化,但我们拥有所有必要的数据来自行完成此操作。 让我们总结一下我们获得的数据:
- 来自相机设备的 BGRA 格式的帧
- 正确的投影矩阵可以为我们提供适合 AR 场景渲染的透视投影
- 找到的标记姿势列表
您可以轻松地将此数据放入任何 3D 引擎,并创建自己的基于标记的成品 AR 应用
如您所见,具有梯度填充和枢轴的四边形正好放置在标记上。 这是增强现实的关键功能-真实图片和人造物体的无缝融合。
总结
在本章中,我们学习了如何为 iPhone/iPad 设备创建移动增强现实应用。 您了解了如何在 XCode 项目中使用 OpenCV 库来创建令人惊叹的最新应用。 使用 OpenCV 可使您的应用在移动设备上执行实时性能的复杂图像处理计算。
从本章中,您还学习了如何执行初始图像处理(以灰色阴影和二值化进行平移),如何在图像中找到闭合的轮廓并使用多边形对其进行近似,如何在图像中找到标记并对其进行解码,如何计算标记在空间中的位置,以及增强现实中 3D 对象的可视化。
参考
- 《ArUco:基于 OpenCV 的增强现实应用的最小库》
- 《OpenCV 摄像机校准和 3D 重建》
- 《OpenCV:估计图像中的投影关系》
- 《计算机视觉中的多视图几何(第二版)》,RI Hartley 和 A. Zisserman,剑桥大学出版社,ISBN 0-521-54051-8
三、无标记增强现实
在本章中,读者将学习如何使用 OpenCV(用于桌面)创建标准的实时项目,以及如何使用实际环境作为输入而不是打印的方形标记来执行无标记增强现实的新方法。 本章将介绍一些无标记 AR 的理论,并展示如何在有用的项目中应用它。
以下是本章将涉及的主题列表:
- 基于标记与无标记的 AR
- 使用特征描述符在视频上查找任意图像
- 模式姿势估计
- 应用基础架构
- 在 OpenCV 中启用对 OpenGL 可视化的支持
- 渲染增强现实
- 示例
在开始之前,让我给您简要列出本章所需的知识以及所需的软件:
- CMake 的基本知识。 CMake 是一个跨平台的开源构建系统,旨在构建,测试和打包软件。 像 OpenCV 库一样,本章的演示项目也使用 CMake 构建系统。 可以从这个页面下载 CMake。
- 还必须具备 C++ 编程语言的基本知识。 但是,将详细说明应用源代码的所有复杂部分。
基于标记的 AR 与无标记的 AR
在上一章中,您学习了如何使用称为标记的特殊图像来增强真实场景。 标记的强方面如下:
- 便宜的检测算法
- 抵抗照明变化
标记还具有几个弱点。 它们如下:
- 如果部分重叠则不起作用
- 标记图片必须为黑白
- 在大多数情况下为正方形(因为易于检测)
- 标记的非审美视觉外观
- 与现实世界没有任何共同点
因此,标记是开始使用增强现实的好点。 但是如果您想要更多,该是时候从基于标记的 AR 过渡到无标记的 AR 了。 无标记 AR 是一种基于识别现实世界中存在的对象的技术。 无标记 AR 的目标示例包括:杂志封面,公司徽标,玩具等。 通常,任何具有足够有关场景其余部分的描述性和区分性信息的对象都可以成为无标记 AR 的目标。
无标记 AR 方法的强项是:
- 可用于检测现实世界中的物体
- 即使目标对象部分重叠也可以
- 可以具有任意形式和纹理(实心或平滑梯度纹理除外)
无需标记的 AR 系统可以使用真实的图像和对象将相机放置在 3D 空间中,并在真实图片的顶部呈现醒目的效果。 无标记 AR 的核心是图像识别和物体检测算法。 与形状和内部结构是固定且已知的标记不同,不能以这种方式定义真实对象。 同样,对象可能具有复杂的形状,需要修改的姿态估计算法才能找到其正确的 3D 变换。
注意
为了让您了解无标记 AR,我们将平面图像作为目标。 具有复杂形状的物体将不在此处详细考虑。 我们将在本章稍后讨论 AR 复杂形状的使用。
无标记的 AR 执行大量的 CPU 计算,因此移动设备通常无法确保平滑的 FPS。 在本章中,我们将针对台式机平台,例如 PC 或 Mac。 为此,我们需要一个跨平台的构建系统。 在本章中,我们使用 CMake 构建系统。
使用特征描述符在视频上查找任意图像
图像识别是一种计算机视觉技术,可在输入图像中搜索特定的位图图案。 即使图像被缩放,旋转或具有与原始图像不同的亮度,我们的图像识别算法也应该能够检测到该图案。
我们如何将图案图像与其他图像进行比较? 由于图案可能会受到透视变换的影响,因此很明显我们无法直接比较图案的像素和测试图像。 在这种情况下,特征点和特征描述符会有所帮助。 没有关于特征是什么的通用或确切定义。 确切的定义通常取决于问题或应用的类型。 通常,特征被定义为图像的“有趣”部分,并且特征被用作许多计算机视觉算法的起点。 在本章中,我们将使用特征点术语,这是由中心点,半径和方向定义的图像的一部分。 每种特征检测算法都尝试检测相同的特征点,而与应用的透视变换无关。
特征提取
特征检测是从输入图像中找到兴趣区域的方法。 有很多特征检测算法,它们搜索边缘,角点或斑点。 在我们的案例中,我们对角点检测感兴趣。 角点检测基于图像边缘的分析。 基于角点的边缘检测算法可搜索图像梯度的快速变化。 通常,它是通过在 X 和 Y 方向上寻找图像梯度的一阶导数的极值来完成的。
特征点方向通常被计算为特定区域中主要图像梯度的方向。 旋转或缩放图像时,特征检测算法会重新计算主梯度的方向。 这意味着无论图像旋转如何,特征点的方向都不会改变。 这样的特征称为旋转不变量。
另外,我还必须提到有关尺寸特征点的几点。 一些特征检测算法使用固定大小的特征,而另一些则分别计算每个关键点的最佳大小。 知道特征尺寸后,我们就可以在缩放图像上找到相同的特征点。 这使特征比例不变。
OpenCV 有几种特征检测算法。 它们都是从基类cv::FeatureDetector派生的。 可以通过两种方式创建特征检测算法:
-
通过对具体特征检测器类构造器的显式调用:
cv::Ptr<cv::FeatureDetector> detector = cv::Ptr<cv::FeatureDetector>(new cv::SurfFeatureDetector()); -
或通过算法名称创建特征检测器:
cv::Ptr<cv::FeatureDetector> detector = cv::FeatureDetector::create("SURF");
两种方法都有其优点,因此请选择最喜欢的一种。 显式类的创建使您可以将其他参数传递给特征检测器构造器,而按算法名称创建则可以更轻松地在运行时切换算法。
要检测特征点,应调用detect方法:
std::vector<cv::KeyPoint> keypoints;
detector->detect(image, keypoints);
检测到的特征点放置在keypoints容器中。 每个关键点都包含其中心,半径,角度和分数,并且与特征点的“质量”或“强度”具有一定的关联性。 每个特征检测算法都有自己的分数计算算法,因此比较由特定检测算法检测到的关键点的分数是有效的。
注意
基于角点的特征检测器使用灰度图像查找特征点。 描述符提取算法也可用于灰度图像。 当然,它们两个都可以隐式地进行颜色转换。 但是在这种情况下,颜色转换将进行两次。 我们可以通过将输入图像进行显式的颜色转换为灰度并将其用于特征检测和描述符提取来提高性能。
如果检测器计算关键点方向和大小,则可以在模式检测中获得最佳结果。 这使关键点对于旋转和缩放不变。 最著名和最强大的关键点检测算法是众所周知的,它们用于 SIFT 和 SURF 特征检测/描述提取。 不幸的是,它们已申请专利。 因此它们并非免费用于商业用途。 但是,它们的实现存在于 OpenCV 中,因此您可以自由地对其进行评估。 但是有很好的免费替代品。 您可以改用 ORB 或 FREAK 算法。 ORB 检测是经过修改的 FAST 特征检测器。 原始的 FAST 检测器速度惊人,但无法计算关键点的方向或大小。 幸运的是,ORB 算法确实可以估计关键点的方向,但是特征尺寸仍然是固定的。 从以下几段中,您将学到处理这些问题的便宜方法。 但是首先,让我解释一下为什么特征点在图像识别中如此重要。
如果处理的图像通常具有每像素 24 位的色深,并且分辨率为640 x 480,则数据为 912 KB。 我们如何在现实世界中找到图案图像? 像素到像素的匹配时间太长,我们也必须处理旋转和缩放。 这绝对不是一个选择。 使用特征点可以解决此问题。 通过检测关键点,我们可以确保返回的特征描述了包含大量信息的图像部分(这是因为基于角的检测器会返回边缘,角和其他清晰的图形)。 因此,要查找两个框架之间的对应关系,我们只需要匹配关键点即可。
从关键点定义的补丁中,我们提取一个称为描述符的向量。 这是特征点的一种表示形式。 从特征点提取描述符的方法有很多。 他们都有自己的优点和缺点。 例如,SIFT 和 SURF 描述符提取算法占用大量 CPU,但是提供具有良好区分性的强大描述符。 在我们的示例项目中,我们使用 ORB 描述符提取算法,因为我们也选择它作为特征检测器。
注意
同时使用来自同一算法的特征检测器和描述符提取器始终是一个好主意,因为它们可以完美地相互配合。
特征描述符表示为固定大小(16 个或更多元素)的向量。 假设我们的图像分辨率为640 x 480像素,并且具有 1,500 个特征点。 然后,将需要1500 * 16 * sizeof(float) = 96 KB(用于 SURF)。 它比原始图像数据小十倍。 而且,使用描述符比使用光栅位图要容易得多。 对于两个特征描述符,我们可以引入相似度评分-一种定义两个向量之间相似度的度量。 通常是其 L2 范数或汉明距离(基于所使用的特征描述符的种类)。
特征描述符提取算法是从cv::DescriptorExtractor基类派生的。 同样,作为特征检测算法,可以通过指定其名称或使用显式的构造器调用来创建它们。
模式对象的定义
为了描述模式对象,我们引入一个名为Pattern的类,该类包含训练图像,特征列表和提取的描述符以及初始模式位置的 2D 和 3D 对应关系:
/**
* Store the image data and computed descriptors of target pattern
*/
struct Pattern
{
cv::Size size;
cv::Mat data;
std::vector<cv::KeyPoint> keypoints;
cv::Mat descriptors;
std::vector<cv::Point2f> points2d;
std::vector<cv::Point3f> points3d;
};
特征点的匹配
查找帧与帧之间的对应关系的过程可以公式化为:从一组描述符中为另一组的每个元素搜索最近邻。 这称为“匹配”过程。 OpenCV 中有两种主要的描述符匹配算法:
- 暴力比赛(
cv::BFMatcher) - 基于 Flann 的匹配器(
cv::FlannBasedMatcher)
暴力匹配器通过尝试每一个(穷举搜索)在第一组中寻找每个描述符,在第二组中寻找最接近的描述符。 cv::FlannBasedMatcher 使用快速近似最近邻搜索算法来查找对应关系(为此,它使用快速第三方库作为近似最近邻库)。
描述符匹配的结果是两组描述符之间的对应关系的列表。 第一组描述符通常称为训练集,因为它与我们的图案图像相对应。 第二个集合称为查询集,因为它属于我们将在其中寻找模式的图像。 找到的正确匹配越多(存在更多与图像对应的图案),图案出现在图像上的机会就越大。
为了提高匹配速度,您可以通过调用match函数来训练匹配器。 训练阶段可用于优化cv::FlannBasedMatcher的性能。 为此,train类将为训练描述符构建索引树。 这将提高大型数据集的匹配速度(例如,如果要从数百张图像中找到匹配项)。 对于cv::BFMatcher,train类什么都不做,因为没有要预处理的东西; 它只是将训练描述符存储在内部字段中。
PatternDetector.cpp
以下代码块使用模式图像训练描述符匹配器:
void PatternDetector::train(const Pattern& pattern)
{
// Store the pattern object
m_pattern = pattern;
// API of cv::DescriptorMatcher is somewhat tricky
// First we clear old train data:
m_matcher->clear();
// That we add vector of descriptors
// (each descriptors matrix describe one image).
// This allows us to perform search across multiple images:
std::vector<cv::Mat> descriptors(1);
descriptors[0] = pattern.descriptors.clone();
m_matcher->add(descriptors);
// After adding train data perform actual train:
m_matcher->train();
}
为了匹配查询描述符,我们可以使用cv::DescriptorMatcher的以下方法之一:
-
要查找最佳匹配的简单列表:
void match(const Mat& queryDescriptors, vector<DMatch>& matches, const vector<Mat>& masks=vector<Mat>() ); -
要为每个描述符找到最接近的
K个匹配项:void knnMatch(const Mat& queryDescriptors, vector<vector<DMatch> >& matches, int k, const vector<Mat>& masks=vector<Mat>(),bool compactResult=false ); -
查找距离不超过指定距离的匹配:
void radiusMatch(const Mat& queryDescriptors, vector<vector<DMatch> >& matches, maxDistance, const vector<Mat>& masks=vector<Mat>(), bool compactResult=false );
异常值移除
在匹配阶段可能发生不匹配。 这是正常的。 匹配中有两种错误:
- 假正面匹配项:当特征点对应关系错误时
- 假负面匹配项:当两个图像上的特征点均可见时,不存在匹配项
假负面匹配显然不好。 但是我们无法处理它们,因为匹配算法拒绝了它们。 因此,我们的目标是尽量减少假正面匹配的次数。 要拒绝错误的对应关系,我们可以使用交叉匹配技术。 想法是使训练描述符与查询集匹配,反之亦然。 仅返回这两个匹配项的普通匹配项。 当有足够的匹配项时,此类技术通常会以最少的异常值产生最佳结果。
交叉匹配过滤器
交叉匹配在cv::BFMatcher类中可用。 要启用交叉检查测试,请在将第二个参数设置为true的情况下创建cv::BFMatcher:
cv::Ptr<cv::DescriptorMatcher> matcher(new cv::BFMatcher(cv::NORM_HAMMING, true));
在以下屏幕截图中可以看到使用交叉检查进行匹配的结果:

比率测试
第二种众所周知的离群值去除技术是比率测试。 我们首先以K = 2进行 KNN 匹配。 每个匹配都返回两个最接近的描述符。 仅当第一次和第二次比赛之间的距离比足够大(比率阈值通常接近 2)时,才返回比赛。
PatternDetector.cpp
以下代码使用比率测试执行鲁棒的描述符匹配 :
void PatternDetector::getMatches(const cv::Mat& queryDescriptors, std::vector<cv::DMatch>& matches)
{
matches.clear();
if (enableRatioTest)
{
// To avoid NaNs when best match has
// zero distance we will use inverse ratio.
const float minRatio = 1.f / 1.5f;
// KNN match will return 2 nearest
// matches for each query descriptor
m_matcher->knnMatch(queryDescriptors, m_knnMatches, 2);
for (size_t i=0; i<m_knnMatches.size(); i++)
{
const cv::DMatch& bestMatch = m_knnMatches[i][0];
const cv::DMatch& betterMatch = m_knnMatches[i][1];
float distanceRatio = bestMatch.distance / betterMatch.distance;
// Pass only matches where distance ratio between
// nearest matches is greater than 1.5
// (distinct criteria)
if (distanceRatio < minRatio)
{
matches.push_back(bestMatch);
}
}
}
else
{
// Perform regular match
m_matcher->match(queryDescriptors, matches);
}
}
比率测试可以删除几乎所有异常值。 但是在某些情况下,假正面匹配项可以通过此测试。 在下一部分中,我们将向您展示如何删除其余的异常值,并仅保留正确的匹配项。
单应性估计
为了进一步改善匹配,我们可以使用随机样本共识(RANSAC)方法执行离群值过滤。 在处理图像(平面对象)时,我们希望它是刚性的,因此可以找到图案图像上的特征点与查询图像上的特征点之间的单应性变换。 同形转换将点从模式带到查询图像坐标系。 为了找到这种转换,我们使用cv::findHomography函数。 它使用 RANSAC 通过探测输入点的子集找到最佳的单应性矩阵。 副作用是,此函数根据计算的单应性矩阵的重投影误差,将每个对应关系标记为离群或离群。
PatternDetector.cpp
以下代码使用单应矩阵估计,并使用 RANSAC 算法过滤掉几何上不正确的匹配项:
bool PatternDetector::refineMatchesWithHomography
(
const std::vector<cv::KeyPoint>& queryKeypoints,
const std::vector<cv::KeyPoint>& trainKeypoints,
float reprojectionThreshold,
std::vector<cv::DMatch>& matches,
cv::Mat& homography
)
{
const int minNumberMatchesAllowed = 8;
if (matches.size() < minNumberMatchesAllowed)
return false;
// Prepare data for cv::findHomography
std::vector<cv::Point2f> srcPoints(matches.size());
std::vector<cv::Point2f> dstPoints(matches.size());
for (size_t i = 0; i < matches.size(); i++)
{
srcPoints[i] = trainKeypoints[matches[i].trainIdx].pt;
dstPoints[i] = queryKeypoints[matches[i].queryIdx].pt;
}
// Find homography matrix and get inliers mask
std::vector<unsigned char> inliersMask(srcPoints.size());
homography = cv::findHomography(srcPoints,
dstPoints,
CV_FM_RANSAC,
reprojectionThreshold,
inliersMask);
std::vector<cv::DMatch> inliers;
for (size_t i=0; i<inliersMask.size(); i++)
{
if (inliersMask[i])
inliers.push_back(matches[i]);
}
matches.swap(inliers);
return matches.size() > minNumberMatchesAllowed;
}
这是使用此技术精炼的比赛的可视化效果:

单应性搜索步骤很重要,因为获得的变换是在查询图像中找到图案位置的关键。
单应性优化
当我们寻找单应性变换时,我们已经具有所有必要的数据以在 3D 中找到它们的位置。 但是,我们可以通过找到更准确的图案角来进一步改善其位置。 为此,我们使用估计的单应性使输入图像变形以获取已找到的图案。 结果应该非常接近源训练图像。 单应性优化可以帮助找到更准确的单应性变换。

然后,我们获得另一个单应性和另一组线性特征。 所得的精确单应性将是第一(H1)和第二(H2)单应性的矩阵乘积。
PatternDetector.cpp
以下代码块包含模式检测例程的最终版本:
bool PatternDetector::findPattern(const cv::Mat& image, PatternTrackingInfo& info)
{
// Convert input image to gray
getGray(image, m_grayImg);
// Extract feature points from input gray image
extractFeatures(m_grayImg, m_queryKeypoints, m_queryDescriptors);
// Get matches with current pattern
getMatches(m_queryDescriptors, m_matches);
// Find homography transformation and detect good matches
bool homographyFound = refineMatchesWithHomography(
m_queryKeypoints,
m_pattern.keypoints,
homographyReprojectionThreshold,
m_matches,
m_roughHomography);
if (homographyFound)
{
// If homography refinement enabled
// improve found transformation
if (enableHomographyRefinement)
{
// Warp image using found homography
cv::warpPerspective(m_grayImg, m_warpedImg, m_roughHomography, m_pattern.size, cv::WARP_INVERSE_MAP | cv::INTER_CUBIC);
// Get refined matches:
std::vector<cv::KeyPoint> warpedKeypoints;
std::vector<cv::DMatch> refinedMatches;
// Detect features on warped image
extractFeatures(m_warpedImg, warpedKeypoints, m_queryDescriptors);
// Match with pattern
getMatches(m_queryDescriptors, refinedMatches);
// Estimate new refinement homography
homographyFound = refineMatchesWithHomography(
warpedKeypoints,
m_pattern.keypoints,
homographyReprojectionThreshold,
refinedMatches,
m_refinedHomography);
// Get a result homography as result of matrix product
// of refined and rough homographies:
info.homography = m_roughHomography * m_refinedHomography;
// Transform contour with precise homography
cv::perspectiveTransform(m_pattern.points2d, info.points2d, info.homography);
}
else
{
info.homography = m_roughHomography;
// Transform contour with rough homography
cv::perspectiveTransform(m_pattern.points2d, info.points2d, m_roughHomography);
}
}
return homographyFound;
}
如果在所有离群值去除阶段之后,匹配项的数量仍然相当大(图案图像中至少 25% 的特征与输入的特征相对应),则可以确定位置正确的图案图像。 如果是这样,我们将进入下一个阶段-相对于相机估计花样姿势的 3D 位置。
全部放在一起
为了容纳特征检测器,描述符提取器和匹配器算法的实例,我们创建了一个类PatternMatcher,该类将封装所有这些数据。 它在特征检测和描述符提取算法,特征匹配逻辑以及控制检测过程的设置上拥有所有权。

该类提供了方法来计算所有必要数据,以根据给定图像构建图案结构:
void PatternDetector::computePatternFromImage(const cv::Mat& image, Pattern& pattern);
该方法在输入图像上找到特征点,并使用指定的检测器和提取器算法提取描述符,然后用此数据填充图案结构以备后用。
当计算出Pattern时,我们可以通过调用train方法来训练一个检测器:
void PatternDetector::train(const Pattern& pattern)
此函数将参数设置为我们将要找到的当前目标模式。 同样,它训练带有模式的描述符集的描述符匹配器。 调用此方法后,我们准备查找训练图像。 模式检测是在最后的公共函数findPattern中完成的。 该方法封装了如前所述的整个例程,包括特征检测,描述符提取以及与异常值过滤的匹配。
让我们再次简要介绍一下我们执行的步骤:
- 将输入图像转换为灰度。
- 使用我们的特征检测算法在查询图像上检测到特征。
- 从输入图像中提取检测到的特征点的描述符。
- 与模式描述符匹配的描述符。
- 使用了交叉检查或比率测试来去除异常值。
- 使用内部匹配找到单应变换。
- 通过使用上一步中的单应性扭曲查询图像来完善单应性。
- 由于粗略和精细单应性的相乘,找到了精确的单应性。
- 将图案角转换为图像坐标系,以获取输入图像上的图案位置。
模式姿势估计
姿势估计的方式类似于上一章中标记姿势的估计方式。 像往常一样,我们需要 2D-3D 对应关系来估计摄像机的外部参数。 我们分配四个 3D 点以与位于 XY 平面(Z 轴向上)中的单位矩形的角协调,而 2D 点对应于图像位图的角。
PatternDetector.cpp
buildPatternFromImage类从输入图像创建Pattern对象,如下所示:
void PatternDetector::buildPatternFromImage(const cv::Mat& image, Pattern& pattern) const
{
int numImages = 4;
float step = sqrtf(2.0f);
// Store original image in pattern structure
pattern.size = cv::Size(image.cols, image.rows);
pattern.frame = image.clone();
getGray(image, pattern.grayImg);
// Build 2d and 3d contours (3d contour lie in XY plane since // it's planar)
pattern.points2d.resize(4);
pattern.points3d.resize(4);
// Image dimensions
const float w = image.cols;
const float h = image.rows;
// Normalized dimensions:
const float maxSize = std::max(w,h);
const float unitW = w / maxSize;
const float unitH = h / maxSize;
pattern.points2d[0] = cv::Point2f(0,0);
pattern.points2d[1] = cv::Point2f(w,0);
pattern.points2d[2] = cv::Point2f(w,h);
pattern.points2d[3] = cv::Point2f(0,h);
pattern.points3d[0] = cv::Point3f(-unitW, -unitH, 0);
pattern.points3d[1] = cv::Point3f( unitW, -unitH, 0);
pattern.points3d[2] = cv::Point3f( unitW, unitH, 0);
pattern.points3d[3] = cv::Point3f(-unitW, unitH, 0);
extractFeatures(pattern.grayImg, pattern.keypoints, pattern.descriptors);
}
角的配置非常有用,因为此图案坐标系将直接放置在 XY 平面中图案位置的中心,而 Z 轴朝相机的方向看。
获取相机固有矩阵
摄像机固有参数可以使用 OpenCV 分发包中名为camera_cailbration.exe的示例程序来计算。 该程序将使用一系列图案图像找到内部镜头参数,例如焦距,主点和畸变系数。 假设从不同的角度来看,我们有一组八个校准图案图像,如下所示:

然后,用于执行校准的命令行语法如下:
imagelist_creator imagelist.yaml *.png
calibration -w 9 -h 6 -o camera_intrinsic.yaml imagelist.yaml
第一条命令将创建 YAML 格式的图像列表,校准工具希望该图像列表作为当前目录中所有 PNG 文件的输入。 您可以使用确切的文件名,例如img1.png,img2.png和img3.png。 然后将生成的文件imagelist.yaml传递到校准应用。 而且,校准工具可以从常规网络摄像头拍摄图像。
我们指定校准图案的尺寸以及将要写入校准数据的输入和输出文件。
校准完成后,您将在 YAML 文件中获得以下结果:
%YAML:1.0
calibration_time: "06/12/12 11:17:56"
image_width: 640
image_height: 480
board_width: 9
board_height: 6
square_size: 1.
flags: 0
camera_matrix: !!opencv-matrix
rows: 3
cols: 3
dt: d
data: [ 5.2658037684199849e+002, 0., 3.1841744018680112e+002, 0.,
5.2465577209994706e+002, 2.0296659047014398e+002, 0., 0., 1\. ]
distortion_coefficients: !!opencv-matrix
rows: 5
cols: 1
dt: d
data: [ 7.3253671786835686e-002, -8.6143199924308911e-002,
-2.0800255026966759e-002, -6.8004894417795971e-004,
-1.7750733073535208e-001 ]
avg_reprojection_error: 3.6539552933501085e-001
我们主要对camera_matrix感兴趣,它是3 x 3相机校准矩阵。 它具有以下表示法:

我们主要对的四个组成部分感兴趣:fx,fy,cx和cy。 有了这些数据,我们可以使用以下代码创建相机校准对象的实例:
CameraCalibration calibration(526.58037684199849e, 524.65577209994706e, 318.41744018680112, 202.96659047014398)
没有正确的相机校准,就不可能创建看起来自然的增强现实。 估计的透视变换将与相机的变换不同。 这将导致增强对象看起来太近或太远。 以下是一个示例屏幕截图,其中相机校准是有意更改的:

如您所见,盒子的透视外观与整体场景有所不同。
为了估计图案位置,我们使用 OpenCV 函数cv::solvePnP解决了 PnP 问题。 您可能熟悉此函数,因为我们也在基于标记的 AR 中使用了它。 我们需要当前图像上图案角的坐标,以及我们先前定义的参考 3D 坐标。
注意
cv::solvePnP函数可以使用四个以上的点。 另外,如果要创建具有复杂形状图案的 AR,它也是关键函数。 想法保持不变-您只需要定义图案的 3D 结构和 2D 查找点对应关系即可。 当然,单应性估计在这里不适用。
我们从训练好的模式对象中获取参考 3D 点,并从PatternTrackingInfo结构中获取其对应的 2D 投影; 摄像机校准存储在PatternDetector专用字段中。
Pattern.cpp
3D 空间中的图案位置由computePose函数估计如下:
void PatternTrackingInfo::computePose(const Pattern& pattern, const CameraCalibration& calibration)
{
cv::Mat camMatrix, distCoeff;
cv::Mat(3,3, CV_32F, const_cast<float*>(&calibration.getIntrinsic().data[0])).copyTo(camMatrix);
cv::Mat(4,1, CV_32F, const_cast<float*>(&calibration.getDistorsion().data[0])).copyTo(distCoeff);
cv::Mat Rvec;
cv::Mat_<float> Tvec;
cv::Mat raux,taux;
cv::solvePnP(pattern.points3d, points2d, camMatrix, distCoeff,raux,taux);
raux.convertTo(Rvec,CV_32F);
taux.convertTo(Tvec ,CV_32F);
cv::Mat_<float> rotMat(3,3);
cv::Rodrigues(Rvec, rotMat);
// Copy to transformation matrix
pose3d = Transformation();
for (int col=0; col<3; col++)
{
for (int row=0; row<3; row++)
{
pose3d.r().mat[row][col] = rotMat(row,col);
// Copy rotation component
}
pose3d.t().data[col] = Tvec(col);
// Copy translation component
}
// Since solvePnP finds camera location, w.r.t to marker pose,
// to get marker pose w.r.t to the camera we invert it.
pose3d = pose3d.getInverted();
}
应用基础结构
到目前为止,我们已经学习了如何检测图案并估计相对于相机的 3D 位置。 现在该展示如何将这些算法应用于实际应用了。 因此,本部分的目标是展示如何使用 OpenCV 从网络摄像机捕获视频并创建 3D 渲染的可视化上下文。
因为我们的目标是展示如何使用无标记 AR 的关键功能,所以我们将创建一个简单的命令行应用,它将能够检测视频序列或静止图像中的任意图案图像。
为了容纳所有图像处理逻辑和中间数据,我们引入了ARPipeline类。 它是一个根对象,其中包含增强现实所需的所有子组件,并在输入帧上执行所有处理例程。 以下是ARPipeline及其子组件的 UML 图:

它包括:
- 相机校准对象
- 模式检测器对象的实例
- 训练过的图案对象
- 模式跟踪的中间数据
ARPipeline.hpp
以下代码包含ARPipeline类的声明:
class ARPipeline
{
public:
ARPipeline(const cv::Mat& patternImage, const CameraCalibration& calibration);
bool processFrame(const cv::Mat& inputFrame);
const Transformation& getPatternLocation() const;
private:
CameraCalibration m_calibration;
Pattern m_pattern;
PatternTrackingInfo m_patternInfo;
PatternDetector m_patternDetector;
};
在ARPipeline构造器中,将初始化一个图案对象,并将校准数据保存到private字段中。 processFrame函数实现模式检测和人的姿势估计例程。 返回值表示模式检测成功。 您可以通过调用getPatternLocation函数获得计算出的花样姿势。
ARPipeline.cpp
以下代码包含类的实现:
ARPipeline::ARPipeline(const cv::Mat& patternImage, const CameraCalibration& calibration)
: m_calibration(calibration)
{
m_patternDetector.buildPatternFromImage (patternImage, m_pattern);
m_patternDetector.train(m_pattern);
}
bool ARPipeline::processFrame(const cv::Mat& inputFrame)
{
bool patternFound = m_patternDetector.findPattern(inputFrame, m_patternInfo);
if (patternFound)
{
m_patternInfo.computePose(m_pattern, m_calibration);
}
return patternFound;
}
const Transformation& ARPipeline::getPatternLocation() const
{
return m_patternInfo.pose3d;
}
在 OpenCV 中启用对 3D 可视化的支持
与上一章一样,我们将使用 OpenGL 渲染 3D 工作。 但是,与必须遵循 iOS 应用架构要求的 iOS 环境不同,我们现在有了更大的自由度。 在 Windows 和 Mac 上,您可以从许多 3D 引擎中进行选择。 在本章中,我们将学习如何使用 OpenCV 创建跨平台的 3D 可视化。 从 2.4.2 版开始,OpenCV 在可视化窗口中具有 OpenGL 的支持。 这意味着您现在可以轻松地在 OpenCV 中渲染任何 3D 内容。
要在 OpenCV 中设置 OpenGL 窗口,您需要做的第一件事就是使用 OpenGL 支持构建 OpenCV。 否则,当您尝试使用 OpenCV 的 OpenGL 相关功能时,将引发异常。 要启用 OpenGL 支持,您应该使用ENABLE_OPENGL=YES标志构建 OpenCV 库。
注意
从当前版本(OpenCV 2.4.2)开始,默认情况下关闭 OpenGL 支持。 我们无法保证,但是将来的版本中可能默认启用 OpenGL。 如果是这样,则无需手动构建 OpenCV。
要在 OpenCV 中设置 OpenGL 窗口,请执行以下操作:
- 从 GitHub 克隆 OpenCV 存储库。 您将需要命令行 Git 工具或计算机上安装的 GitHub 应用来执行此步骤。
- 配置 OpenCV 并为您的 IDE 生成一个工作区。 您将需要 CMake 应用来完成此步骤。 可以从这个页面免费下载 CMake。
要配置 OpenCV,可以按以下方式使用命令行 CMake 命令(从要放置生成的项目的目录中运行):
cmake -D ENABLE_OPENGL=YES <path to the OpenCV source directory>
或者,如果您更喜欢 GUI 风格,请使用 CMake-GUI 进行更加用户友好的项目配置:

为选定的 IDE 生成 OpenCV 工作区之后,打开项目并执行安装目标以构建库并安装它。 完成此过程后,您可以使用刚刚构建的新 OpenCV 库配置示例项目。
使用 OpenCV 创建 OpenGL 窗口
现在,我们已经具有支持 OpenGL 的 OpenCV 二进制文件,是时候创建第一个 OpenGL 窗口了。 OpenGL 窗口的初始化从创建带有 OpenGL 标志的命名窗口开始:
cv::namedWindow(ARWindowName, cv::WINDOW_OPENGL);
ARWindowName是窗口名称的字符串常量。 我们将在此处使用Markerless AR。 该调用将创建一个具有指定名称的窗口。 cv::WINDOW_OPENGL标志表示我们将在此窗口中使用 OpenGL。 然后我们设置所需的窗口大小:
cv::resizeWindow(ARWindowName, 640, 480);
然后,我们为此窗口设置绘图上下文:
cv::setOpenGlContext(ARWindowName);
现在我们的窗口可以使用了。 要在上面绘制内容,我们应该使用以下方法注册一个回调函数:
cv::setOpenGlDrawCallback(ARWindowName, drawAR, NULL);
此回调将在重新绘制窗口上调用。 第一个参数设置窗口名称,第二个参数设置回调函数,第三个可选参数将传递给回调函数。
drawAR函数应具有以下签名:
void drawAR(void* param)
{
// Draw something using OpenGL here
}
要通知系统您要重绘窗口,请使用cv::updateWindow函数:
cv::updateWindow(ARWindowName);
使用 OpenCV 的视频捕获
OpenCV 允许轻松地从几乎每个网络摄像机和视频文件中检索帧。 要从网络摄像头或视频文件捕获视频,我们可以使用cv::VideoCapture类,如第 1 章,“卡通化器和适用于 Android 的换肤工具”。
渲染增强现实
我们引入和ARDrawingContext结构来保存可视化可能需要的所有必要数据:
- 从相机拍摄的最新图像
- 相机校准矩阵
- 3D 中的图案姿势(如果存在)
- 与 OpenGL 相关的内部数据(纹理 ID 等)
ARDrawingContext.hpp
以下代码包含ARDrawingContext类的声明:
class ARDrawingContext
{
public:
ARDrawingContext(const CameraCalibration& c);
bool patternPresent;
Transformation patternPose;
//! Request the redraw of the OpenGl window
void draw();
//! Set the new frame for the background
void updateBackground(const cv::Mat& frame);
private:
//! Draws the background with video
void drawCameraFrame ();
//! Draws the AR
void drawAugmentedScene();
//! Builds the right projection matrix
//! from the camera calibration for AR
void buildProjectionMatrix(const Matrix33& calibration, int w, int h, Matrix44& result);
//! Draws the coordinate axis
void drawCoordinateAxis();
//! Draw the cube model
void drawCubeModel();
private:
bool m_textureInitialized;
unsigned int m_backgroundTextureId;
CameraCalibration m_calibration;
cv::Mat m_backgroundImage;
};
ARDrawingContext.cpp
OpenGL 窗口的初始化在ARDrawingContext类的构造器中完成,如下所示:
ARDrawingContext::ARDrawingContext(std::string windowName, cv::Size frameSize, const CameraCalibration& c)
: m_isTextureInitialized(false)
, m_calibration(c)
, m_windowName(windowName)
{
// Create window with OpenGL support
cv::namedWindow(windowName, cv::WINDOW_OPENGL);
// Resize it exactly to video size
cv::resizeWindow(windowName, frameSize.width, frameSize.height);
// Initialize OpenGL draw callback:
cv::setOpenGlContext(windowName);
cv::setOpenGlDrawCallback(windowName, ARDrawingContextDrawCallback, this);
}
现在我们有了一个单独的类来存储可视化状态,因此我们修改了cv::setOpenGlDrawCallback 调用,并将ARDrawingContext的实例作为参数传递。
修改后的回调函数如下:
void ARDrawingContextDrawCallback(void* param)
{
ARDrawingContext * ctx = static_cast<ARDrawingContext*>(param);
if (ctx)
{
ctx->draw();
}
}
ARDrawingContext负责渲染增强现实。 帧渲染首先通过绘制具有正交投影的背景开始。 然后,使用正确的透视投影和模型转换来渲染 3D 模型。 以下代码包含draw函数的最终版本:
void ARDrawingContext::draw()
{
// Clear entire screen
glClear(GL_DEPTH_BUFFER_BIT | GL_COLOR_BUFFER_BIT);
// Render background
drawCameraFrame();
// Draw AR
drawAugmentedScene();
}
清除屏幕和深度缓冲区后,我们检查用于显示视频的纹理是否已初始化。 如果是这样,我们继续绘制背景,否则我们通过调用glGenTextures创建一个新的 2D 纹理。
为了绘制背景,我们设置了正交投影并绘制了覆盖所有屏幕视口的实心矩形。 该矩形与纹理单元绑定。 该纹理填充有m_backgroundImage对象的内容。 它的内容会预先上传到 OpenGL 内存中。 该函数与上一章的函数相同,因此在此我们将省略其代码。
从相机绘制图片后,我们切换到绘制 AR。 必须设置与我们的相机校准相匹配的正确透视投影。
以下代码显示了如何通过相机校准构建正确的 OpenGL 投影矩阵并渲染场景:
void ARDrawingContext::drawAugmentedScene()
{
// Init augmentation projection
Matrix44 projectionMatrix;
int w = m_backgroundImage.cols;
int h = m_backgroundImage.rows;
buildProjectionMatrix(m_calibration, w, h, projectionMatrix);
glMatrixMode(GL_PROJECTION);
glLoadMatrixf(projectionMatrix.data);
glMatrixMode(GL_MODELVIEW);
glLoadIdentity();
if (isPatternPresent)
{
// Set the pattern transformation
Matrix44 glMatrix = patternPose.getMat44();
glLoadMatrixf(reinterpret_cast<const GLfloat*>(&glMatrix.data[0]));
// Render model
drawCoordinateAxis();
drawCubeModel();
}
}
buildProjectionMatrix函数取自上一章,因此相同。 应用透视投影后,我们将GL_MODELVIEW矩阵设置为模式转换。 为了证明我们的姿势估计正确工作,我们在图案位置绘制了一个单位坐标系。
几乎所有都已完成。 我们创建了一种模式检测算法,然后估计在 3D 空间中发现的模式的姿势,该空间是呈现 AR 的可视化系统。 让我们看一下下面的 UML 序列图,该图演示了我们应用中的帧处理例程:

示例
我们的演示项目支持通过网络摄像机处理静态图像,录制的视频和实时取景。 我们创建了两个函数来帮助我们。
main.cpp
函数processVideo处理视频的处理,函数processSingleImage 用于处理单个图像,如下所示:
void processVideo(const cv::Mat& patternImage, CameraCalibration& calibration, cv::VideoCapture& capture);
void processSingleImage(const cv::Mat& patternImage, CameraCalibration& calibration, const cv::Mat& image);
从函数名称中可以明显看出,第一个函数处理了视频源,第二个函数处理了单个图像(此函数可用于调试目的)。 两者都有图像处理,模式检测,场景渲染和用户交互的非常通用的例程。
processFrame函数包含以下步骤:
/**
* Performs full detection routine on camera frame
.* and draws the scene using drawing context.
* In addition, this function draw overlay with debug information
.* on top of the AR window. Returns true
.* if processing loop should be stopped; otherwise - false.
*/
bool processFrame(const cv::Mat& cameraFrame, ARPipeline& pipeline, ARDrawingContext& drawingCtx)
{
// Clone image used for background (we will
// draw overlay on it)
cv::Mat img = cameraFrame.clone();
// Draw information:
if (pipeline.m_patternDetector.enableHomographyRefinement)
cv::putText(img, "Pose refinement: On ('h' to switch off)", cv::Point(10,15), CV_FONT_HERSHEY_PLAIN, 1, CV_RGB(0,200,0));
else
cv::putText(img, "Pose refinement: Off ('h' to switch
on)", cv::Point(10,15), CV_FONT_HERSHEY_PLAIN, 1, CV_RGB(0,200,0));
cv::putText(img, "RANSAC threshold: " + ToString(pipeline.m_patternDetector.homographyReprojectionThreshold) + "( Use'-'/'+' to adjust)", cv::Point(10, 30), CV_FONT_HERSHEY_PLAIN, 1, CV_RGB(0,200,0));
// Set a new camera frame:
drawingCtx.updateBackground(img);
// Find a pattern and update its detection status:
drawingCtx.isPatternPresent = pipeline.processFrame(cameraFrame);
// Update a pattern pose:
drawingCtx.patternPose = pipeline.getPatternLocation();
// Request redraw of the window:
drawingCtx.updateWindow();
// Read the keyboard input:
int keyCode = cv::waitKey(5);
bool shouldQuit = false;
if (keyCode == '+' || keyCode == '=')
{
pipeline.m_patternDetector.homographyReprojectionThreshold += 0.2f;
pipeline.m_patternDetector.homographyReprojectionThreshold = std::min(10.0f, pipeline.m_patternDetector.homographyReprojectionThreshold);
}
else if (keyCode == '-')
{
pipeline.m_patternDetector.homographyReprojectionThreshold -= 0.2f;
pipeline.m_patternDetector.homographyReprojectionThreshold = std::max(0.0f, pipeline.m_patternDetector.homographyReprojectionThreshold);
}
else if (keyCode == 'h')
{
pipeline.m_patternDetector.enableHomographyRefinement = !pipeline.m_patternDetector.enableHomographyRefinement;
}
else if (keyCode == 27 || keyCode == 'q')
{
shouldQuit = true;
}
return shouldQuit;
}
ARPipeline和ARDrawingContext的初始化是在processSingleImage或processVideo函数中完成的,如下所示:
void processSingleImage(const cv::Mat& patternImage, CameraCalibration& calibration, const cv::Mat& image)
{
cv::Size frameSize(image.cols, image.rows);
ARPipeline pipeline(patternImage, calibration);
ARDrawingContext drawingCtx("Markerless AR", frameSize, calibration);
bool shouldQuit = false;
do
{
shouldQuit = processFrame(image, pipeline, drawingCtx);
} while (!shouldQuit);
}
我们从图案图像和校准参数创建ARPipeline。 然后,我们再次使用校准来初始化ARDrawingContext。 这些步骤之后,将创建 OpenGL 窗口。 然后,我们将查询图像上传到绘画上下文中,并调用ARPipeline.processFrame查找模式。 如果找到了姿势模式,我们将其位置复制到绘图上下文中以进行进一步的帧渲染。 如果未检测到图案,我们将仅渲染相机帧而没有任何 AR。
您可以通过以下方式之一运行演示应用:
-
要在单个图像调用上运行:
markerless_ar_demo pattern.png test_image.png -
要进行录制的视频通话,请执行以下操作:
markerless_ar_demo pattern.png test_video.avi -
要使用网络摄像头的实时馈送运行,请致电:
markerless_ar_demo pattern.png
以下屏幕快照显示了放大单个图像的结果:

总结
在本章中,您了解了特征描述符以及如何使用它们来定义比例尺和旋转不变模式描述。 此描述可用于在其他图像中查找相似的条目。 还解释了大多数流行特征描述符的优缺点。 在本章的后半部分,我们学习了如何将 OpenGL 和 OpenCV 一起用于渲染增强现实。
参考
- 《比例不变关键点的独特图像特征》
- 《SURF:加速鲁棒特征》
Model-Based Object Pose in 25 Lines of Code, Dementhon and L.S Davis, International Journal of Computer Vision, edition 15, pp. 123-141, 1995Linear N-Point Camera Pose Determination, L.Quan, IEEE Trans. on Pattern Analysis and Machine Intelligence, vol. 21, edition. 7, July 1999Random Sample Consensus: A Paradigm for Model Fitting with Applications to Image Analysis and Automated Cartography, M. Fischer and R. Bolles, Graphics and Image Processing, vol. 24, edition. 6, pp. 381-395, June 1981- 《计算机视觉中的多视图几何》,R. Hartley 和 A.Zisserman,剑桥大学出版社
- 《摄像机姿势重新讨论–新的线性算法》,M.Ameller,B.Triggs,L.Quan
Closed-form solution of absolute orientation using unit quaternions, Berthold K. P. Horn, Journal of the Optical Society A, vol. 4, 629–642
四、使用 OpenCV 探索运动结构
在本章中,我们将讨论使用运动结构(SfM),或更好称为从图像中提取的几何结构,图像使用 OpenCV 的 API 通过摄像机运动拍摄。 首先,让我们限制使用单一相机的方法原本就漫长的步伐,通常称为单眼方法,以及离散且稀疏的帧集,而不是连续的视频流。 这两个约束将大大简化我们将在接下来的页面中概述的系统,并帮助我们理解任何 SfM 方法的基础。 为了实现我们的方法,我们将遵循 Hartley 和 Zisserman(以下称为 H 和 Z)的脚步,它们的开创性著作《计算机视觉中的多视图几何》在第 9 至 12 章中进行了介绍。
在本章中,我们涵盖以下内容:
- 运动结构的概念
- 从一对图像估计相机运动
- 重建场景
- 多角度重建
- 优化后的重建
- 可视化 3D 点云
在本章中,我们假设使用的是经过校准的摄像机,该摄像机是事先校准的。 校准是计算机视觉中无处不在的操作,使用命令行工具在 OpenCV 中得到完全支持,并在前面的章节中进行了讨论。 因此,我们假设相机矩阵的固有参数存在于 K 矩阵中,这是校准过程的输出之一。
为了使语言更清晰,从现在开始,我们将摄像机称为场景的单一视图,而不是指用于拍摄图像的光学和硬件。 照相机具有在空间中的位置和观察方向。 在两个摄像头之间,有一个平移元素(在空间中移动)和方向的旋转。
我们还将场景,世界,真实或 3D 中的点的术语统一为同一事物,这是我们现实世界中存在的点。 对于在该位置和时间投影在相机传感器上的某些真实 3D 点,图像或 2D 中的点(即图像坐标中的点)也是如此。
在本章的代码部分中,您会注意到对《计算机视觉中的多视图几何》的引用,例如// HZ 9.12。 这指的是本书第 9 章的方程式 12。 此外,文本仅包含代码摘录,而完整的可运行代码包含在本书随附的材料中。
运动结构的概念
我们应该做的第一个区别是立体(或实际上是任何多视图),使用已校准装备的 3D 重建和 SfM 之间的区别。 尽管有两个或更多摄像机的装备假设我们已经知道摄像机之间的运动是什么,但在 SfM 中我们实际上并不知道该运动,我们希望找到它。 从简单的角度来看,经过校准的装备可以更精确地重建 3D 几何形状,因为在估计摄像机之间的距离和旋转方面没有错误-这是众所周知的。 实现 SfM 系统的第一步是找到摄像机之间的运动。 OpenCV 可以通过多种方式帮助我们获得此运动,特别是使用findFundamentalMat函数。
让我们思考一下选择 SfM 算法背后的目标。 在大多数情况下,我们希望获得场景的几何形状,例如,对象与摄影机有关以及它们的形式是什么。 假设我们已经从合理相似的角度知道了拍摄同一场景的摄像机之间的运动,那么现在我们想重建几何形状。 在计算机视觉术语中,这称为三角剖分,并且有很多解决方法。 这可以通过光线相交来完成,在光线相交中,我们构造了两条光线:一个来自每个相机的投影中心,另一个位于每个像面上。 理想情况下,这些光线在空间中的交集将在每个照相机中成像的现实世界中的一个 3D 点处相交,如下图所示:

实际上,射线相交是高度不可靠的。 H 和 Z 建议不要这样做。 这是因为光线通常不相交,使我们退回到使用连接两条光线的最短线段上的中点。 相反,H 和 Z 建议使用多种方法对 3D 点进行三角剖分,我们将在“重构场景”部分中讨论其中的几种方法。 OpenCV 的当前版本不包含用于三角剖分的简单 API,因此我们将自行编写这一部分。
在学习了如何从两个视图恢复 3D 几何形状之后,我们将看到如何合并同一场景的更多视图以获得更丰富的重建。 那时,在“重建的细化”部分中,大多数 SfM 方法都试图通过束调整来优化摄像机和 3D 点的估计位置束。 OpenCV 在其新的图像拼接工具箱中包含用于捆绑调整的方法。 但是,使用 OpenCV 和 C++ 的好处在于可以轻松集成到管道中的大量外部工具。 因此,我们将看到如何集成外部捆绑器调整器,即简洁的 SSBA 库。
现在,我们已经概述了使用 OpenCV 进行 SfM 的方法的概要,我们将看到如何实现每个元素。
从一对图像估计相机运动
在我们开始实际寻找两个摄像机之间的运动之前,让我们检查一下输入和执行此操作所需的工具。 首先,我们从空间中的不同位置(希望不是非常地多)获得同一场景的两个图像。 这是一项强大的资产,我们将确保使用它。 现在,就工具而言,我们应该看一下对我们的图像,相机和场景施加约束的数学对象。
两个非常有用的数学对象是基本矩阵(用 F 表示)和基本矩阵(用 E 表示)。 它们基本相似,不同之处在于基本矩阵假设使用已校准的摄像机。 对于我们来说就是这种情况,因此我们将选择它。 OpenCV 仅允许我们通过findFundamentalMat函数找到基本矩阵; 但是,使用校准矩阵K从中获取基本矩阵非常简单,如下所示:
Mat_<double> E = K.t() * F * K; //according to HZ (9.12)
基本矩阵是3 x 3大小的矩阵,它在x'Ex = 0的情况下在一个图像中的一个点与另一图像中的一个点之间施加了约束,其中x是图像一中的一个点,x'是图像二中的对应点。 正如我们即将看到的,这非常有用。 我们使用的另一个重要事实是,基本相机是我们需要的,以便为图像恢复两个相机,尽管只是按比例绘制的。 但我们稍后再讲。 因此,如果我们获得基本矩阵,我们就会知道每个摄像机在空间中的位置以及它的位置。 如果我们有足够的约束方程,就可以很容易地计算出矩阵,这仅仅是因为每个方程都可以用来求解矩阵的一小部分。 实际上,OpenCV 允许我们仅使用七个点对来计算它,但是希望我们会有更多对,并获得更可靠的解决方案。
使用丰富特征描述符的点匹配
现在我们将利用我们的约束方程式来计算基本矩阵。 为了获得约束,请记住,对于图像 A 中的每个点,我们必须在图像 B 中找到一个对应点。如何实现这种匹配? 只需使用 OpenCV 广泛的特征匹配框架,该框架在过去几年中已经非常成熟。
特征提取和描述符匹配是计算机视觉中必不可少的过程,并且在许多方法中用于执行各种操作。 例如,检测对象在图像中的位置和方向,或者通过给定查询在大型图像数据库中搜索相似图像。 本质上,提取意味着在图像中选择可使特征良好的点,并为其计算描述符。 描述符是描述图像中特征点周围周围环境的数字向量。 不同的方法为其描述符向量具有不同的长度和数据类型。 匹配是使用其描述符从另一组中找到对应特征的过程。 OpenCV 提供了非常简单而强大的方法来支持特征提取和匹配。 有关特征匹配的更多信息,请参见第 3 章,“无标记增强现实”。
让我们研究一个非常简单的特征提取和匹配方案:
// detectingkeypoints
SurfFeatureDetectordetector();
vector<KeyPoint> keypoints1, keypoints2;
detector.detect(img1, keypoints1);
detector.detect(img2, keypoints2);
// computing descriptors
SurfDescriptorExtractor extractor;
Mat descriptors1, descriptors2;
extractor.compute(img1, keypoints1, descriptors1);
extractor.compute(img2, keypoints2, descriptors2);
// matching descriptors
BruteForceMatcher<L2<float>> matcher;
vector<DMatch> matches;
matcher.match(descriptors1, descriptors2, matches);
您可能已经看过类似的 OpenCV 代码,但让我们快速进行检查。 我们的目标是获得三个元素:两个图像的特征点,它们的描述符以及两组特征之间的匹配。 OpenCV 提供了一系列特征检测器,描述符提取器和匹配器。 在此简单示例中,我们使用SurfFeatureDetector函数获取加速鲁棒特征(SURF)的 2D 位置特征和SurfDescriptorExtractor函数获得 SURF 描述符。 我们使用暴力匹配器来进行匹配,这是匹配两个特征集的最直接的方法,方法是将第一集合中的每个特征与第二集合中的每个特征进行比较(因此称为暴力破解)并获得最佳匹配。
在下一个图像中,我们将在这个页面找到的 Fountain-P11 序列的两个图像上看到特征点的匹配。

实际上,像我们刚刚执行的原始匹配只有在一定程度上才是好的,并且许多匹配可能是错误的。 因此,大多数 SfM 方法都会对匹配项执行某种形式的过滤,以确保正确性并减少错误。 筛选的一种形式是交叉检查筛选,它是内置的 OpenCV 的暴力匹配器。 即,如果第一图像的特征与第二图像的特征相匹配,并且反向检查也将第二图像的特征与第一图像的特征相匹配,则认为匹配为真。 所提供的代码中使用的另一种常见过滤机制是基于以下事实进行过滤:两个图像属于同一场景,并且它们之间具有一定的立体视图关系。 在实践中,过滤器会尝试鲁棒地计算基本矩阵,我们将在“查找相机矩阵”部分中学习该基本矩阵,并保留与该计算相对应的那些特征对,且误差很小。
使用光流的点匹配
使用诸如 SURF 之类的功能丰富的的替代方案是使用光流。 以下信息框简要介绍了光流。 OpenCV 最近扩展了其 API,以从两个图像获取流场,现在它变得更快,功能更强大。 我们将尝试将其用作匹配功能的替代方法。**
注意
光流是将一个图像中的选定点匹配到另一个图像的过程,假定两个图像都是序列的一部分并且彼此相对接近。 大多数光流方法会比较从图像 A 到图像 B 中相同区域的每个点周围的小区域,称为搜索窗口或补丁。遵循计算机视觉中一个非常普遍的规则,即亮度恒定约束(及其他名称) ,图像的小块将不会从一个图像到另一个图像急剧变化,因此它们的相减幅度应接近于零。 除了匹配补丁外,更新的光流方法还使用许多其他方法来获得更好的结果。 一种是使用图像金字塔,图像金字塔的尺寸越来越小,可以进行“从粗到细”的工作,这是计算机视觉中非常有用的技巧。 另一种方法是在流场上定义全局约束,假设这些点彼此靠近并在同一方向上“一起移动”。 可在 Packt 网站上的“使用 Microsoft Kinect 开发流体墙”一章中找到有关 OpenCV 中光流方法的更深入的综述。
通过调用calcOpticalFlowPyrLK函数,在 OpenCV 中使用光流相当容易。 但是,我们希望保持 OF 的结果匹配与使用丰富特征的结果相似,因为将来我们希望这两种方法可以互换。 为此,我们必须安装一种特殊的匹配方法,该方法可以与以前的基于特征的方法互换,该方法将在下面的代码部分中看到:
Vector<KeyPoint>left_keypoints,right_keypoints;
// Detect keypoints in the left and right images
FastFeatureDetectorffd;
ffd.detect(img1, left_keypoints);
ffd.detect(img2, right_keypoints);
vector<Point2f>left_points;
KeyPointsToPoints(left_keypoints,left_points);
vector<Point2f>right_points(left_points.size());
// making sure images are grayscale
Mat prevgray,gray;
if (img1.channels() == 3) {
cvtColor(img1,prevgray,CV_RGB2GRAY);
cvtColor(img2,gray,CV_RGB2GRAY);
} else {
prevgray = img1;
gray = img2;
}
// Calculate the optical flow field:
// how each left_point moved across the 2 images
vector<uchar>vstatus; vector<float>verror;
calcOpticalFlowPyrLK(prevgray, gray, left_points, right_points, vstatus, verror);
// First, filter out the points with high error
vector<Point2f>right_points_to_find;
vector<int>right_points_to_find_back_index;
for (unsigned inti=0; i<vstatus.size(); i++) {
if (vstatus[i] &&verror[i] < 12.0) {
// Keep the original index of the point in the
// optical flow array, for future use
right_points_to_find_back_index.push_back(i);
// Keep the feature point itself
right_points_to_find.push_back(j_pts[i]);
} else {
vstatus[i] = 0; // a bad flow
}
}
// for each right_point see which detected feature it belongs to
Mat right_points_to_find_flat = Mat(right_points_to_find).reshape(1,to_find.size()); //flatten array
vector<Point2f>right_features; // detected features
KeyPointsToPoints(right_keypoints,right_features);
Mat right_features_flat = Mat(right_features).reshape(1,right_features.size());
// Look around each OF point in the right image
// for any features that were detected in its area
// and make a match.
BFMatchermatcher(CV_L2);
vector<vector<DMatch>>nearest_neighbors;
matcher.radiusMatch(
right_points_to_find_flat,
right_features_flat,
nearest_neighbors,
2.0f);
// Check that the found neighbors are unique (throw away neighbors
// that are too close together, as they may be confusing)
std::set<int>found_in_right_points; // for duplicate prevention
for(inti=0;i<nearest_neighbors.size();i++) {
DMatch _m;
if(nearest_neighbors[i].size()==1) {
_m = nearest_neighbors[i][0]; // only one neighbor
} else if(nearest_neighbors[i].size()>1) {
// 2 neighbors – check how close they are
double ratio = nearest_neighbors[i][0].distance / nearest_neighbors[i][1].distance;
if(ratio < 0.7) { // not too close
// take the closest (first) one
_m = nearest_neighbors[i][0];
} else { // too close – we cannot tell which is better
continue; // did not pass ratio test – throw away
}
} else {
continue; // no neighbors... :(
}
// prevent duplicates
if (found_in_right_points.find(_m.trainIdx) == found_in_right_points.end()) {
// The found neighbor was not yet used:
// We should match it with the original indexing
// ofthe left point
_m.queryIdx = right_points_to_find_back_index[_m.queryIdx];
matches->push_back(_m); // add this match
found_in_right_points.insert(_m.trainIdx);
}
}
cout<<"pruned "<< matches->size() <<" / "<<nearest_neighbors.size() <<" matches"<<endl;
函数KeyPointsToPoints和PointsToKeyPoints只是在cv::Point2f和cv::KeyPoint结构之间转换的简便函数。
在上一部分代码中,我们可以看到很多有趣的东西。 首先要注意的是,当我们使用光流时,我们的结果显示了一个特征从图像左侧的位置移动到图像右侧的另一个位置。 但是我们在图像的右侧检测到一组新特征,不一定与光流中从图像流向左侧的特征对齐。 我们必须使其一致。 要找到这些丢失的特征,我们使用 K 最近邻(kNN)半径搜索,这使我们最多获得两个特征,这些特征在距离兴趣点两个像素的半径内。
我们可以看到的另一件事是针对 kNN 的比率测试的实现,这是 SfM 中减少错误的常见做法。 从本质上讲,当我们在左侧图像中的一个特征与右侧图像中的两个特征之间具有匹配项时,它是一种过滤器,可消除混乱的匹配项。 如果右侧图像中的两个特征太靠近,或者它们之间的比率太大(接近 1.0),我们认为它们会造成混淆,请不要使用它们。 我们还安装了重复预防过滤器,以进一步删减匹配项。
下图显示了从一个图像到另一个图像的流场。 左侧图像中的粉红色箭头显示了色块从左侧图像到右侧图像的移动。 在左侧的第二张图像中,我们看到流场的一小部分被放大了。粉红色的箭头再次显示了斑块的运动,我们可以通过查看图块上的两个原始图像分段来看到它是有意义的。 右手边。 左侧图像中的视觉特征沿粉红色箭头方向在图像上向左移动,如下图所示:

使用光流代替丰富特征的优点是该过程通常更快,并且可以容纳更多的匹配点,从而使重建更加密集。 在许多光流方法中,还存在贴片整体运动的整体模型,其中通常不考虑匹配的丰富特征。 使用光流的警告是,它最适用于由相同硬件拍摄的连续图像,而丰富的特征对此几乎是不可知的。 差异是由于以下事实造成的:光流方法通常使用非常基本的特征,例如关键点周围的图像斑块,而高阶更丰富的特征(例如 SURF)会考虑每个关键点的高级信息。 使用光流或丰富的特征能是应用设计人员应根据输入做出的决定。
查找相机矩阵
现在我们已获得关键点之间的匹配,我们可以计算基本矩阵,并从中获得基本矩阵。 但是,我们必须首先将匹配点对准两个数组,其中一个数组中的索引对应于另一个数组中的相同索引。 这是findFundamentalMat函数所必需的。 我们还需要将KeyPoint结构转换为Point2f结构。 我们必须特别注意DMatch的queryIdx和trainIdx成员变量,它们是两个关键点之间匹配的 OpenCV 结构,因为它们必须与我们使用matcher.match()函数的方式保持一致。 以下代码部分显示了如何将匹配项对齐到两个相应的 2D 点集中,以及如何将其用于查找基本矩阵:
vector<Point2f>imgpts1,imgpts2;
for( unsigned inti = 0; i<matches.size(); i++ )
{
// queryIdx is the "left" image
imgpts1.push_back(keypoints1[matches[i].queryIdx].pt);
// trainIdx is the "right" image
imgpts2.push_back(keypoints2[matches[i].trainIdx].pt);
}
Mat F = findFundamentalMat(imgpts1, imgpts2, FM_RANSAC, 0.1, 0.99, status);
Mat_<double> E = K.t() * F * K; //according to HZ (9.12)
稍后我们可能会使用status二元向量来修剪与恢复的基本矩阵对齐的那些点。 有关对基本矩阵进行修剪后的点匹配的说明,请参见下图。 红色箭头标记在找到F矩阵的过程中删除的特征匹配,绿色箭头表示保留的特征匹配。

现在,我们准备找到相机矩阵。 H 和 Z 的书的第 9 章详细描述了此过程。 但是,我们将使用非常简单明了的实现,而 OpenCV 使我们的工作变得非常简单。 但是首先,我们将简要检查我们将要使用的相机矩阵的结构。

这是我们相机的模型,它由两个元素组成:旋转(表示为R)和平移(表示为t)。 有趣的是,它拥有一个非常重要的方程:x = PX,其中x是图像上的 2D 点,X是空间中的 3D 点。 还有更多,但此矩阵为我们提供了图像点和场景点之间非常重要的关系。 因此,既然我们有寻找相机矩阵的动力,我们将看到它是如何实现的。 以下代码部分显示了如何将基本矩阵分解为旋转和平移元素:
SVD svd(E);
Matx33d W(0,-1,0,//HZ 9.13
1,0,0,
0,0,1);
Mat_<double> R = svd.u * Mat(W) * svd.vt; //HZ 9.19
Mat_<double> t = svd.u.col(2); //u3
Matx34d P1( 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));
很简单。 我们要做的就是取之前获得的基本矩阵的奇异值分解(SVD),然后将其乘以一个特殊矩阵W。 无需太深入地研究我们所做的数学运算,我们可以说SVD运算将矩阵E分解为两部分,即旋转元素和平移元素。 实际上,基本矩阵最初是由这两个元素的乘法组成的。 为了满足我们的好奇心,我们可以查看以下基本矩阵方程,该方程出现在文献中:E = [t]xR。 我们看到它由平移元素和旋转元素R(的某种形式)组成。
我们注意到,我们刚才所做的只是给我们一个相机矩阵,那么另一个相机矩阵在哪里? 好吧,我们在一个相机矩阵是固定且规范的(无旋转且无平移)的假设下执行此操作。 下一个相机矩阵也是规范的:

我们从基本矩阵中恢复的另一台摄像机已经相对于固定摄像机移动和旋转。 这也意味着我们从这两个相机矩阵中恢复的任何 3D 点都将在世界原点(0, 0, 0)拥有第一个相机。
但是,这不是完整的解决方案。 H 和 Z 在他们的书中说明了这种分解如何以及为什么实际上具有四个可能的相机矩阵,但是只有其中一个是真实的。 正确的矩阵将产生一个带有正 Z 值的重建点(位于摄像机前面的点)。 但是我们只有在了解了三角剖分和 3D 重构之后才能理解这一点,这将在下一部分中进行讨论。
我们可以想到的另一件事就是错误检查。 很多时候,根据点匹配计算基本矩阵是错误的,这会影响相机矩阵。 用错误的相机矩阵继续进行三角剖分是毫无意义的。 我们可以安装检查以检查旋转元素是否为有效的旋转矩阵。 请记住,旋转矩阵的行列式必须为 1(或 -1),我们可以简单地执行以下操作:
bool CheckCoherentRotation(cv::Mat_<double>& R) {
if(fabsf(determinant(R))-1.0 > 1e-07) {
cerr<<"det(R) != +-1.0, this is not a rotation matrix"<<endl;
return false;
}
return true;
}
现在,我们可以看到所有这些元素如何组合成一个恢复P矩阵的函数,如下所示:
void FindCameraMatrices(const Mat& K,
const Mat& Kinv,
const vector<KeyPoint>& imgpts1,
const vector<KeyPoint>& imgpts2,
Matx34d& P,
Matx34d& P1,
vector<DMatch>& matches,
vector<CloudPoint>& outCloud
)
{
//Find camera matrices
//Get Fundamental Matrix
Mat F = GetFundamentalMat(imgpts1,imgpts2,matches);
//Essential matrix: compute then extract cameras [R|t]
Mat_<double> E = K.t() * F * K; //according to HZ (9.12)
//decompose E to P' , HZ (9.19)
SVD svd(E,SVD::MODIFY_A);
Mat svd_u = svd.u;
Mat svd_vt = svd.vt;
Mat svd_w = svd.w;
Matx33d W(0,-1,0,//HZ 9.13
1,0,0,
0,0,1);
Mat_<double> R = svd_u * Mat(W) * svd_vt; //HZ 9.19
Mat_<double> t = svd_u.col(2); //u3
if (!CheckCoherentRotation(R)) {
cout<<"resulting rotation is not coherent\n";
P1 = 0;
return;
}
P1 = Matx34d(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));
}
至此,我们有了重建场景所需的两台摄像机。 P变量中的第一台标准摄像机和我们计算出的第二台摄像机,在P1变量中形成基本矩阵。 下一部分将揭示我们如何使用这些相机获得场景的 3D 结构。
重建场景
接下来,我们将研究从到目前为止所获得的信息中恢复场景的 3D 结构的问题。 正如我们之前所做的那样,我们应该查看实现此目的所需的工具和信息。 在上一节中,我们从基本矩阵和基本矩阵中获得了两个相机矩阵。 我们已经讨论了这些工具如何对获取空间中点的 3D 位置有用。 然后,我们可以返回匹配点对以将数值数据填充到方程式中。 这些点对在计算所有近似计算得出的误差时也将很有用。
现在是时候看看我们如何使用 OpenCV 执行三角剖分了。 这次,我们将按照 Hartley 和 Sturm 在其文章《三角剖分》中采取的步骤,在本文中他们实现并比较一些三角剖分方法。 我们将实现其线性方法之一,因为使用 OpenCV 进行编码非常简单。
请记住,我们有两个主要的方程式是由 2D 点匹配和P矩阵产生的:x = PX和x'= P'X,其中x和x'匹配 2D 点,而X是由两个摄像机成像的真实世界 3D 点。 如果我们重写方程式,我们可以制定一个线性方程组,该方程组可以解决X的值,这是我们想要找到的。 假设X = (x, y, z, 1)t(对于距离摄像机中心不太近或太远的点的合理假设)会创建形式为AX = B的不均匀线性方程组。我们可以编码并求解该方程组如下:
Mat_<double> LinearLSTriangulation(
Point3d u,//homogenous image point (u,v,1)
Matx34d P,//camera 1 matrix
Point3d u1,//homogenous image point in 2nd camera
Matx34d P1//camera 2 matrix
)
{
//build A matrix
Matx43d A(u.x*P(2,0)-P(0,0),u.x*P(2,1)-P(0,1),u.x*P(2,2)-P(0,2),
u.y*P(2,0)-P(1,0),u.y*P(2,1)-P(1,1),u.y*P(2,2)-P(1,2),
u1.x*P1(2,0)-P1(0,0), u1.x*P1(2,1)-P1(0,1),u1.x*P1(2,2)-P1(0,2),
u1.y*P1(2,0)-P1(1,0), u1.y*P1(2,1)-P1(1,1),u1.y*P1(2,2)-P1(1,2)
);
//build B vector
Matx41d B(-(u.x*P(2,3)-P(0,3)),
-(u.y*P(2,3)-P(1,3)),
-(u1.x*P1(2,3)-P1(0,3)),
-(u1.y*P1(2,3)-P1(1,3)));
//solve for X
Mat_<double> X;
solve(A,B,X,DECOMP_SVD);
return X;
}
这将使我们近似于由两个 2D 点产生的 3D 点。 还有一点要注意的是,二维点用均一坐标表示,这意味着 x 和 y 值后面附加了 1。我们应确保这些点在归一化坐标中,这意味着它们乘以校准矩阵K。 我们可能会注意到,就像在第 9 章中 H 和 Z 所做的那样,我们可以简单地利用 KP 矩阵(K矩阵乘以P矩阵)而不是将每个点乘以矩阵K。 现在在点匹配上编写一个循环,以获取完整的三角剖分,如下所示:
double TriangulatePoints(
const vector<KeyPoint>& pt_set1,
const vector<KeyPoint>& pt_set2,
const Mat&Kinv,
const Matx34d& P,
const Matx34d& P1,
vector<Point3d>& pointcloud)
{
vector<double> reproj_error;
for (unsigned int i=0; i<pts_size; i++) {
//convert to normalized homogeneous coordinates
Point2f kp = pt_set1[i].pt;
Point3d u(kp.x,kp.y,1.0);
Mat_<double> um = Kinv * Mat_<double>(u);
u = um.at<Point3d>(0);
Point2f kp1 = pt_set2[i].pt;
Point3d u1(kp1.x,kp1.y,1.0);
Mat_<double> um1 = Kinv * Mat_<double>(u1);
u1 = um1.at<Point3d>(0);
//triangulate
Mat_<double> X = LinearLSTriangulation(u,P,u1,P1);
//calculate reprojection error
Mat_<double> xPt_img = K * Mat(P1) * X;
Point2f xPt_img_(xPt_img(0)/xPt_img(2),xPt_img(1)/xPt_img(2));
reproj_error.push_back(norm(xPt_img_-kp1));
//store 3D point
pointcloud.push_back(Point3d(X(0),X(1),X(2)));
}
//return mean reprojection error
Scalar me = mean(reproj_error);
return me[0];
}
在下面的图像中,我们将在这个页面上看到来自 Fountain P-11 序列的两个图像的三角剖分结果。 顶部的两个图像是场景的原始两个视图,底部的一对是从这两个视图重建的点云的视图,包括估计的注视着喷泉的摄像机。 我们可以看到红砖墙壁的右侧部分是如何重建的,还有从墙壁突出的喷泉。

但是,正如我们前面所讨论的,我们存在一个问题,那就是重建只是规模上的。 我们应该花一点时间来理解什么是规模化的意思。 我们在两个摄像机之间获得的运动将具有一个任意的度量单位,即不是以厘米或英寸为单位,而仅仅是给定的比例单位。 我们重建的相机将是一个比例尺距离的单位。 如果我们决定以后再恢复更多摄像机,这将产生很大的影响,因为每对摄像机将具有自己的比例单位,而不是通用的单位。
现在,我们将讨论我们设置的错误度量如何帮助我们找到更可靠的重建方法。 首先,我们应该注意的是,重新投影意味着我们仅需获取三角剖分的 3D 点并将其在相机上重新成像即可获得重新投影的 2D 点,然后比较原始 2D 点和重新投影的 2D 点之间的距离。 如果此距离较大,则意味着我们在三角剖分中可能会出错,因此我们可能不希望将此点包括在最终结果中。 我们的全局度量是平均投影距离,可能会提示我们三角剖分的整体效果。 高平均重投影率可能会指出P矩阵存在问题,因此可能会导致基本矩阵或匹配特征点的计算出现问题。
我们应该简要地回到上一节中对相机矩阵的讨论。 我们提到可以通过四种不同的方式来合成相机矩阵P1,但是只有一种合成是正确的。 现在我们知道了如何对一个点进行三角剖分,现在可以添加检查以查看四个相机矩阵中的哪一个有效。 由于这是随书附带的示例代码中介绍的细节,因此我们现在将跳过实现细节。
接下来,我们将看一下恢复在同一场景中看到的更多摄像机,并结合 3D 重建结果。
多角度重建
现在,我们知道如何从两个摄像机恢复运动和场景的几何形状,看起来很简单,只需应用相同的过程即可获得更多摄像机和更多场景点的参数。 实际上,这件事并不是那么简单,因为我们只能得到最大比例的重建,并且每对图片给我们一个不同的比例。
有多种方法可以从多个视图正确地重建 3D 场景数据。 一种方法是切除或相机姿态估计,也称为 N 点透视(PNP),我们尝试使用我们已经找到的场景点求解新相机的位置。 另一种方法是对更多的点进行三角剖分,并查看它们如何适合我们现有的场景几何体。 这将通过迭代最近点(ICP)程序告诉我们新相机的位置。 在本章中,我们将讨论使用 OpenCV 的solvePnP函数实现第一种方法。
我们在这种重建中选择的第一步(通过摄像机后方切除进行增量 3D 重建)是获得基线场景结构。 当我们要基于场景的已知结构寻找任何新相机的位置时,我们需要找到一个初始结构和一个基准来使用。 我们可以使用前面讨论的方法(例如,在第一帧和第二帧之间)通过找到相机矩阵(使用FindCameraMatrices函数)和对几何进行三角剖分(使用TriangulatePoints函数)来获取基线。
找到初始结构后,我们可以继续; 但是,我们的方法需要大量的簿记。 首先,我们应该注意solvePnP函数需要两个对齐的 3D 和 2D 点向量。 对齐的向量表示一个向量中的第i个位置与另一个向量中的第i个位置对齐。 为了获得这些向量,我们需要在我们较早恢复的 3D 点中找到与新帧中 2D 点对齐的那些点。 一种简单的方法是为云中的每个 3D 点附加一个向量,该向量表示它来自的 2D 点。 然后,我们可以使用特征匹配来获得匹配对。
让我们介绍一下 3D 点的新结构,如下所示:
struct CloudPoint {
cv::Point3d pt;
std::vector<int>index_of_2d_origin;
};
它在 3D 点的顶部保持指向每个帧具有的 2D 点的向量内的 2D 点的索引,而 2D 点对这一 3D 点有所贡献。 在对新的 3D 点进行三角剖分时,必须初始化index_of_2d_origin的信息,并记录参与三角剖分的摄像机。 然后,我们可以使用它从我们的 3D 点云追溯到每个帧中的 2D 点,如下所示:
std::vector<CloudPoint> pcloud; //our global 3D point cloud
//check for matches between i'th frame and 0'th frame (and thus the current cloud)
std::vector<cv::Point3f> ppcloud;
std::vector<cv::Point2f> imgPoints;
vector<int> pcloud_status(pcloud.size(),0);
//scan the views we already used (good_views)
for (set<int>::iterator done_view = good_views.begin(); done_view != good_views.end(); ++done_view)
{
int old_view = *done_view; //a view we already used for reconstrcution
//check for matches_from_old_to_working between <working_view>'th frame and <old_view>'th frame (and thus the current cloud)
std::vector<cv::DMatch> matches_from_old_to_working = matches_matrix[std::make_pair(old_view,working_view)];
//scan the 2D-2D matched-points
for (unsigned int match_from_old_view=0; match_from_old_view<matches_from_old_to_working.size(); match_from_old_view++) {
// the index of the matching 2D point in <old_view>
int idx_in_old_view = matches_from_old_to_working[match_from_old_view].queryIdx;
//scan the existing cloud to see if this point from <old_view> exists for (unsigned int pcldp=0; pcldp<pcloud.size(); pcldp++) {
// see if this 2D point from <old_view> contributed to this 3D point in the cloud
if (idx_in_old_view == pcloud[pcldp].index_of_2d_origin[old_view] && pcloud_status[pcldp] == 0) //prevent duplicates
{
//3d point in cloud
ppcloud.push_back(pcloud[pcldp].pt);
//2d point in image <working_view>
Point2d pt_ = imgpts[working_view][matches_from_old_to_working[match_from_old_view].trainIdx].pt;
imgPoints.push_back(pt_);
pcloud_status[pcldp] = 1;
break;
}
}
}
}
cout<<"found "<<ppcloud.size() <<" 3d-2d point correspondences"<<endl;
现在,我们将场景中的 3D 点与新帧中的 2D 点对齐对齐,可以使用它们来恢复相机位置,如下所示:
cv::Mat_<double> t,rvec,R;
cv::solvePnPRansac(ppcloud, imgPoints, K, distcoeff, rvec, t, false);
//get rotation in 3x3 matrix form
Rodrigues(rvec, R);
P1 = cv::Matx34d(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));
请注意,我们在使用solvePnPRansac函数而不是solvePnP函数的,因为它对异常值更鲁棒。 现在我们有了一个新的P1矩阵,我们可以简单地使用我们先前定义的TriangulatePoints函数,并用更多 3D 点填充点云。
在下图中,我们从第四个图像开始,在这个页面处看到 Fountain-P11 场景的增量重建。 左上图是使用四个图像后的重建; 参与的摄像机显示为红色金字塔,白线显示方向。 其他图像显示更多的摄像机如何向云中添加更多的点。

优化后的重建
SfM 方法最重要的部分之一是优化和优化重建的场景,也称为包调整(BA)的过程。 此是优化步骤,其中,我们收集的所有数据均拟合为整体模型。 3D 点的位置和相机的位置都得到了优化,因此重投影误差最小化(即,将近似的 3D 点投影到图像上接近原始 2D 点的位置)。 该过程通常需要求解成千上万个参数的非常大的线性方程。 该过程可能会有些费力,但是我们之前采取的步骤将使与 Bundle Adjuster 的集成变得容易。 以前看起来有些奇怪的某些事情可能会变得清晰起来。 例如,我们为云中的每个 3D 点保留原点 2D 点的原因。
捆绑调整算法的一种实现是简单稀疏捆绑调整(SSBA)库; 我们将选择它作为我们的 BA 优化器,因为它具有简单的 API。 它只需要几个输入参数,就可以从数据结构中轻松创建这些输入参数。 我们将从 SSBA 使用的关键对象是CommonInternalsMetricBundleOptimizer函数,该函数执行优化。 它需要相机参数,3D 点云,与点云中每个点相对应的 2D 图像点,以及观看场景的相机。 到现在为止,这些参数应该很简单。 我们应该注意,BA 的这种方法假定所有图像都是由相同的硬件拍摄的,因此,内部通用的其他操作模式可能不会采用此方法。 我们可以按如下方式进行捆绑调整:
voidBundleAdjuster::adjustBundle(
vector<CloudPoint>&pointcloud,
const Mat&cam_intrinsics,
conststd::vector<std::vector<cv::KeyPoint>>&imgpts,
std::map<int ,cv::Matx34d>&Pmats
)
{
int N = Pmats.size(), M = pointcloud.size(), K = -1;
cout<<"N (cams) = "<< N <<" M (points) = "<< M <<" K (measurements) = "<< K <<endl;
StdDistortionFunction distortion;
// intrinsic parameters matrix
Matrix3x3d KMat;
makeIdentityMatrix(KMat);
KMat[0][0] = cam_intrinsics.at<double>(0,0);
KMat[0][1] = cam_intrinsics.at<double>(0,1);
KMat[0][2] = cam_intrinsics.at<double>(0,2);
KMat[1][1] = cam_intrinsics.at<double>(1,1);
KMat[1][2] = cam_intrinsics.at<double>(1,2);
...
// 3D point cloud
vector<Vector3d >Xs(M);
for (int j = 0; j < M; ++j)
{
Xs[j][0] = pointcloud[j].pt.x;
Xs[j][1] = pointcloud[j].pt.y;
Xs[j][2] = pointcloud[j].pt.z;
}
cout<<"Read the 3D points."<<endl;
// convert cameras to BA datastructs
vector<CameraMatrix> cams(N);
for (inti = 0; i< N; ++i)
{
intcamId = i;
Matrix3x3d R;
Vector3d T;
Matx34d& P = Pmats[i];
R[0][0] = P(0,0); R[0][1] = P(0,1); R[0][2] = P(0,2); T[0] = P(0,3);
R[1][0] = P(1,0); R[1][1] = P(1,1); R[1][2] = P(1,2); T[1] = P(1,3);
R[2][0] = P(2,0); R[2][1] = P(2,1); R[2][2] = P(2,2); T[2] = P(2,3);
cams[i].setIntrinsic(Knorm);
cams[i].setRotation(R);
cams[i].setTranslation(T);
}
cout<<"Read the cameras."<<endl;
vector<Vector2d > measurements;
vector<int> correspondingView;
vector<int> correspondingPoint;
// 2D corresponding points
for (unsigned int k = 0; k <pointcloud.size(); ++k)
{
for (unsigned int i=0; i<pointcloud[k].imgpt_for_img.size(); i++) {
if (pointcloud[k].imgpt_for_img[i] >= 0) {
int view = i, point = k;
Vector3d p, np;
Point cvp = imgpts[i][pointcloud[k].imgpt_for_img[i]].pt;
p[0] = cvp.x;
p[1] = cvp.y;
p[2] = 1.0;
// Normalize the measurements to match the unit focal length.
scaleVectorIP(1.0/f0, p);
measurements.push_back(Vector2d(p[0], p[1]));
correspondingView.push_back(view);
correspondingPoint.push_back(point);
}
}
} // end for (k)
K = measurements.size();
cout<<"Read "<< K <<" valid 2D measurements."<<endl;
...
// perform the bundle adjustment
{
CommonInternalsMetricBundleOptimizeropt(V3D::FULL_BUNDLE_FOCAL_LENGTH_PP, inlierThreshold, K0, distortion, cams, Xs, measurements, correspondingView, correspondingPoint);
opt.tau = 1e-3;
opt.maxIterations = 50;
opt.minimize();
cout<<"optimizer status = "<<opt.status<<endl;
}
...
//extract 3D points
for (unsigned int j = 0; j <Xs.size(); ++j)
{
pointcloud[j].pt.x = Xs[j][0];
pointcloud[j].pt.y = Xs[j][1];
pointcloud[j].pt.z = Xs[j][2];
}
//extract adjusted cameras
for (int i = 0; i< N; ++i)
{
Matrix3x3d R = cams[i].getRotation();
Vector3d T = cams[i].getTranslation();
Matx34d P;
P(0,0) = R[0][0]; P(0,1) = R[0][1]; P(0,2) = R[0][2]; P(0,3) = T[0];
P(1,0) = R[1][0]; P(1,1) = R[1][1]; P(1,2) = R[1][2]; P(1,3) = T[1];
P(2,0) = R[2][0]; P(2,1) = R[2][1]; P(2,2) = R[2][2]; P(2,3) = T[2];
Pmats[i] = P;
}
}
这段代码虽然很长,但主要用于将内部数据结构与 SSBA 的数据结构相互转换,并调用优化过程。
下图显示了 BA 的效果。 从两个角度看,左侧的两个图像是调整前的点云的点,右侧的图像显示了优化的云。 这种变化是非常显着的,并且从不同角度剖分的点之间的许多不对齐现在已得到了巩固。 我们还可以注意到调整是如何更好地重建平面的。

使用 PCL 可视化 3D 点云
在处理 3D 数据时,仅通过查看重投影误差度量或原始点信息就很难快速了解结果是否正确。 另一方面,如果我们查看点云本身,则可以立即验证它是否有意义或是否有错误。 为了可视化,我们将使用一个新兴的 OpenCV 姊妹项目,称为点云库(PCL)。 它带有许多用于可视化和分析点云的工具,例如查找平面,匹配点云,分割对象和消除离群值。 如果我们的目标不是点云,而是一些更高阶的信息(例如 3D 模型),则这些工具非常有用。
首先,我们应该在 PCL 的数据结构中表示我们的云(基本上是 3D 点列表)。 可以按照以下步骤进行:
pcl::PointCloud<pcl::PointXYZRGB>::Ptr cloud;
void PopulatePCLPointCloud(const vector<Point3d>& pointcloud,
const std::vector<cv::Vec3b>& pointcloud_RGB
)
//Populate point cloud
{
cout<<"Creating point cloud...";
cloud.reset(new pcl::PointCloud<pcl::PointXYZRGB>);
for (unsigned int i=0; i<pointcloud.size(); i++) {
// get the RGB color value for the point
Vec3b rgbv(255,255,255);
if (pointcloud_RGB.size() >= i) {
rgbv = pointcloud_RGB[i];
}
// check for erroneous coordinates (NaN, Inf, etc.)
if (pointcloud[i].x != pointcloud[i].x || isnan(pointcloud[i].x) ||
pointcloud[i].y != pointcloud[i].y || isnan(pointcloud[i].y) ||
pointcloud[i].z != pointcloud[i].z || isnan(pointcloud[i].z) ||
fabsf(pointcloud[i].x) > 10.0 ||
fabsf(pointcloud[i].y) > 10.0 ||
fabsf(pointcloud[i].z) > 10.0) {
continue;
}
pcl::PointXYZRGB pclp;
// 3D coordinates
pclp.x = pointcloud[i].x;
pclp.y = pointcloud[i].y;
pclp.z = pointcloud[i].z;
// RGB color, needs to be represented as an integer
uint32_t rgb = ((uint32_t)rgbv[2] << 16 | (uint32_t)rgbv[1] << 8 | (uint32_t)rgbv[0]);
pclp.rgb = *reinterpret_cast<float*>(&rgb);
cloud->push_back(pclp);
}
cloud->width = (uint32_t) cloud->points.size(); // number of points
cloud->height = 1; // a list of points, one row of data
}
为了使可视化效果很好,我们还可以提供颜色数据作为从图像中获取的 RGB 值。 我们还可以使用统计离群值去除(SOR)工具对原始云应用过滤器,以消除可能离群的点:
Void SORFilter() {
pcl::PointCloud<pcl::PointXYZRGB>::Ptr cloud_filtered (new pcl::PointCloud<pcl::PointXYZRGB>);
std::cerr<<"Cloud before SOR filtering: "<< cloud->width * cloud->height <<" data points"<<std::endl;
// Create the filtering object
pcl::StatisticalOutlierRemoval<pcl::PointXYZRGB>sor;
sor.setInputCloud (cloud);
sor.setMeanK (50);
sor.setStddevMulThresh (1.0);
sor.filter (*cloud_filtered);
std::cerr<<"Cloud after SOR filtering: "<<cloud_filtered->width * cloud_filtered->height <<" data points "<<std::endl;
copyPointCloud(*cloud_filtered,*cloud);
}
然后,我们可以使用 PCL 的 API 运行简单的点云可视化程序,如下所示:
Void RunVisualization(const vector<cv::Point3d>& pointcloud,
const std::vector<cv::Vec3b>& pointcloud_RGB) {
PopulatePCLPointCloud(pointcloud,pointcloud_RGB);
SORFilter();
copyPointCloud(*cloud,*orig_cloud);
pcl::visualization::CloudViewer viewer("Cloud Viewer");
// run the cloud viewer
viewer.showCloud(orig_cloud,"orig");
while (!viewer.wasStopped ())
{
// NOP
}
}
下图显示了使用统计异常值消除工具后的输出。 左侧的图像是 SfM 的原始合成云,带有相机位置和该云特定部分的放大视图。 右侧的图像显示了 SOR 操作后的过滤后的云。 我们可以注意到一些杂散点已被删除,留下了更干净的点云:

使用示例代码
我们可以在本书的辅助材料中找到 SfM 的示例代码。 现在,我们将看到如何构建,运行和利用它。 该代码利用了 CMake,这是一个类似于 Maven 或 SCons 的跨平台构建环境。 我们还应确保具有以下所有先决条件才能构建应用:
- OpenCV v2.3 或更高版本
- PCL v1.6 或更高版本
- SSBA v3.0 或更高版本
首先,我们必须建立构建环境。 为此,我们可以创建一个名为build的文件夹,所有与构建相关的文件都将放入该文件夹; 我们现在假定所有命令行操作都在build/文件夹中,尽管即使不使用build文件夹,该过程也是相似的(取决于文件的位置)。
我们应该确保 CMake 可以找到 SSBA 和 PCL。 如果 PCL 安装正确,则应该不会有问题。 但是,我们必须设置正确的位置,才能通过-DSSBA_LIBRARY_DIR =…构建参数找到 SSBA 的预构建二进制文件。 如果使用 Windows 作为操作系统,则可以使用 Microsoft Visual Studio 进行构建。 因此,我们应该运行以下命令:
cmake –G "Visual Studio 10" -DSSBA_LIBRARY_DIR=../3rdparty/SSBA-3.0/build/ ..
如果使用 Linux,Mac OS 或其他类似 Unix 的操作系统,则执行以下命令:
cmake –G "Unix Makefiles" -DSSBA_LIBRARY_DIR=../3rdparty/SSBA-3.0/build/ ..
如果我们更喜欢在 Mac OS 上使用 XCode,请执行以下命令:
cmake –G Xcode -DSSBA_LIBRARY_DIR=../3rdparty/SSBA-3.0/build/ ..
CMake 还具有为 Eclipse,代码块等构建宏的能力。 完成 CMake 的创建环境后,我们就可以开始构建了。 如果我们使用的是类似 Unix 的系统,我们可以简单地执行 make 工具,否则我们应该使用开发环境的构建过程。
构建完成后,我们应该留下一个名为ExploringSfMExec的可执行文件,该可执行文件将运行 SfM 进程。 不带参数运行它会导致以下结果:Usage: ./ExploringSfMExec <path_to_images>。
要对一组图像执行该过程,我们应在驱动器上提供一个位置以查找图像文件。 如果提供了有效位置,则该过程应该开始,并且我们应该在屏幕上看到进度和调试信息。 该过程将结束于图像产生的点云的显示。 按下1和2键将在已调整点云和未调整点云之间切换。
总结
在本章中,我们已经了解了 OpenCV 如何以一种既易于编码又易于理解的方式帮助我们从 Motion 处理结构。 OpenCV 的 API 包含许多有用的功能和数据结构,这些功能和数据结构使我们的生活更轻松,也有助于更清洁的实现。
但是,最新的 SfM 方法要复杂得多。 为了简单起见,我们选择忽略许多问题,通常还会进行许多错误检查。 我们针对 SfM 不同元素选择的方法也可以重新考虑。 首先,H 和 Z 提出了一种高精度的三角剖分方法,该方法可将图像域中的重投影误差降至最低。 一旦了解了多幅图像中特征之间的关系,某些方法甚至会使用 N 视图三角剖分。
如果我们想扩展和加深对 SfM 的了解,一定会从其他开源 SfM 库中受益。 一个特别有趣的项目是 libMV,它实现了大量 SfM 元素,可以互换这些元素以获得最佳结果。 华盛顿大学有很多出色的工作,可以为多种 SfM(Bundler 和 VisualSfM)提供工具。 这项工作启发了微软的在线产品 PhotoSynth。 SfM 的更多实现可随时在线获得,并且仅需搜索即可找到很多实现。
我们尚未深入讨论的另一个重要关系是 SfM 与视觉本地化和映射的关系,在即时定位与地图构建(SLAM)方法中更为人所知。 在本章中,我们处理了给定的图像和视频序列数据集,在这些情况下使用 SfM 是可行的。 但是,某些应用没有预先记录的数据集,因此必须即时引导重建。 这个过程被称为地图构建,它是在我们使用 2D 中的特征匹配和跟踪以及在三角剖分之后创建世界 3D 地图时完成的。
在下一章中,我们将了解如何使用机器学习中的各种技术将 OpenCV 用于从图像中提取车牌号。
参考
Multiple View Geometry in Computer Vision, Richard Hartley and Andrew Zisserman, Cambridge University PressTriangulation, Richard I. Hartley and Peter Sturm, Computer vision and image understanding, Vol. 68, pp. 146-157- http://cvlab.epfl.ch/~strecha/multiview/denseMVS.html
On Benchmarking Camera Calibration and Multi-View Stereo for High Resolution Imagery,C. Strecha, W. von Hansen, L. Van Gool, P. Fua, and U. Thoennessen, CVPR- http://www.inf.ethz.ch/personal/chzach/opensource.html
- http://www.ics.forth.gr/~lourakis/sba/
- http://code.google.com/p/libmv/
- http://www.cs.washington.edu/homes/ccwu/vsfm/
- http://phototour.cs.washington.edu/bundler/
- http://photosynth.net/
- http://en.wikipedia.org/wiki/Simultaneous_localization_and_mapping
- http://pointclouds.org
- http://www.cmake.org
五、使用 SVM 和神经网络识别车牌
本章向我们介绍了创建自动车牌识别(ANPR)应用所需的步骤。 根据不同的情况有不同的方法和技术,例如,IR 摄像机,固定的汽车位置,光线条件等。 我们可以着手构建一个 ANPR 应用,以检测在距离汽车 2-3 米之间,光线不清晰,地面不平行且汽车牌照的透视变形很小的照片中检测汽车牌照的情况。
本章的主要目的是向我们介绍图像分割和特征提取,模式识别基础以及两种重要的模式识别算法支持向量机和人工神经网络。 在本章中,我们将介绍:
- ANPR
- 车牌
- 车牌识别
ANPR 简介
自动车牌识别(ANPR),也称为自动车牌识别(ALPR),自动车辆识别(AVI)或车牌识别(CPR),是一种使用光学字符识别(OCR)和其他方法的监视方法,例如分割和检测来读取车辆牌照。
使用红外(IR)摄像机可以获得 ANPR 系统中的最佳结果,因为检测和 OCR 分割的分割步骤简单,干净,并最大程度地减少了错误。 这是由于光的规律,最基本的是入射角等于反射角; 当我们看到光滑的表面(例如平面镜)时,我们可以看到这种基本反射。 粗糙表面(例如纸张)的反射会导致一种反射类型,即漫反射或散射反射。 大多数车牌都有一个特殊的特性,称为后向反射-车牌的表面是用一种材料制成的,该材料覆盖了成千上万个微小的半球,导致光被反射回光源,如下图所示:

如果我们使用带滤光片的摄像机和结构化的红外光投影仪,我们可以仅检索红外光,然后就可以分割出非常高质量的图像,随后进行检测并识别与独立于任何光线环境的车牌号码,如下图所示:

在本章中,我们不使用 IR 照片; 我们使用普通照片。 这样做是为了避免获得最佳结果,并获得更高水平的检测错误和更高的错误识别率,这与使用红外热像仪所期望的结果相反; 但是,两者的步骤是相同的。
每个国家都有不同的车牌尺寸和规格; 了解这些规格对于获得最佳结果并减少错误很有用。 本章中使用的算法旨在说明 ANPR 的基本知识以及西班牙车牌的规格,但我们可以将其扩展到任何国家或规格。
在本章中,我们将使用西班牙的车牌。 在西班牙,有三种不同尺寸和形状的车牌; 我们将仅使用最常见的(大型)车牌520 x 110毫米。 两组字符之间相隔 41 毫米,然后 14 毫米宽将每个字符分开。 第一组字符有四个数字,第二组字符有三个字母,没有元音 A,E,I,O,U,也没有字母 N 或 Q。 所有字符的尺寸均为520 x 110毫米。
此数据对于字符分割非常重要,因为我们可以同时检查字符和空格以验证是否得到了字符,而没有其他图像分割。 下图是一个这样的车牌的图:

ANPR 算法
在解释 ANPR 代码之前,我们需要定义 ANPR 算法中的主要步骤和任务。 ANPR 分为两个主要步骤:印版检测和印版识别。 印版检测的目的是检测印版在整个相机帧中的位置。 当在图像中检测到印版时,将印版段传递到第二步(印版识别),该步骤使用 OCR 算法确定印版上的字母数字字符。
在下图中,我们可以看到两个主要算法步骤,即印版检测和印版识别。 完成这些步骤后,程序会在摄像机帧上绘制已检测到的印版字符。 这些算法可能会返回不良结果,甚至没有结果:

在上图中显示的每个步骤中,我们将定义模式识别算法中常用的三个附加步骤:
- 分割:此步骤检测并移除图像中每个感兴趣的面片/区域。
- 特征提取:此步骤从每个补丁中提取一组特征。
- 分类:此步骤从印版识别步骤中提取每个字符,或在印版检测步骤中将每个图像块分类为“印版”或“无印版”。
下图显示了整个算法应用中的模式识别步骤:

除了主要的应用(其目的是检测和识别汽车的车牌号)之外,我们还将简要说明另外两个通常不解释的任务:
- 如何训练模式识别系统
- 如何评估这样的系统
但是,这些任务比主应用本身更重要,因为如果我们没有正确地训练模式识别系统,我们的系统可能会失败并且无法正常工作; 不同的模式需要不同类型的训练和评估。 我们需要在不同的环境,条件和具有不同特征的情况下评估我们的系统,以获得最佳结果。 这两个任务有时会一起使用,因为不同的特征会产生不同的结果,我们可以在评估部分中看到这些结果。
车牌检测
在此步骤中,我们必须检测当前相机帧中的所有印版。 为此,我们将其分为两个主要步骤:分割和分割分类。 由于我们将图像块用作向量特征,因此未解释特征步骤。
在第一步(分段)中,我们应用不同的过滤器,形态运算,轮廓算法和验证来检索图像中可能具有印版的那些部分。
在第二步(分类)中,我们将支持向量机(SVM)分类器应用于每个图像补丁,即我们的特征。 在创建我们的主要应用之前,我们使用两种不同的类别进行训练:平板和非平板。 我们处理的平行正面彩色图像的宽度为 800 像素,距离汽车 2–4 米。 这些要求对于确保正确的分割很重要。 如果创建多尺度图像算法,则可以执行检测。
在下一个图像中,我们显示了车牌所涉及的所有过程:
-
Sobel 过滤器
-
门限操作
-
紧密的形态学操作
-
一个填充区域的遮罩
-
可能检测到的印有红色标记的板(特征图像)
-
SVM 分类器后检测到的板
![Plate detection]()
分割
分割是将图像分成多个段的过程。 此过程是为了简化图像以进行分析并使特征提取更加容易。
车牌分割的一个重要特征是,假设图像是正面拍摄的,并且车牌中的垂直边缘数量很多,并且车牌没有旋转并且没有透视失真。 可以在第一个分割步骤中利用此特征,以消除没有任何垂直边缘的区域。
在找到垂直边缘之前,我们需要将彩色图像转换为灰度图像(因为彩色无法帮助我们完成此任务),并消除由相机产生的可能的噪声或其他环境噪声。 我们将应用5 x 5的高斯模糊并去除噪声。 如果不采用噪声消除方法,则可能会产生许多垂直边缘,从而导致检测失败。
//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 过滤器之后,我们应用阈值过滤器来获得具有通过大津方法获得的阈值的二进制图像。大津的算法需要 8 位输入图像,而大津的方法会自动确定最佳阈值:
//threshold image
Mat img_threshold;
threshold(img_sobel, img_threshold, 0, 255, CV_THRESH_OTSU+CV_THRESH_BINARY);
要在threshold函数中定义大津的方法,如果我们将类型参数与CV_THRESH_OTSU值组合,则将忽略阈值参数。
提示
当定义了CV_THRESH_OTSU的值时,阈值函数返回通过大津算法获得的最佳阈值。
通过应用接近的形态学操作,我们可以删除每条垂直边线之间的空白,并连接所有具有大量边的区域。 在此步骤中,我们可能会包含板块。
首先,我们定义要在形态学操作中使用的结构元素。 我们将使用getStructuringElement函数定义尺寸为17 x 3的结构矩形元素。 其他图像尺寸可能有所不同:
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 contour
对于检测到的每个轮廓,提取最小面积的边界矩形。 OpenCV 为此任务打开了minAreaRect函数。 此函数返回称为RotatedRect的旋转矩形类。 然后在每个轮廓上使用向量迭代器,我们可以得到旋转的矩形并在对每个区域进行分类之前进行一些初步验证:
//Start to iterate to each contour found
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 patches 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
circle(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函数从种子点开始将具有颜色的连接分量填充到遮罩图像中,并设置要填充的像素与相邻像素或种子像素之间的最大上下亮度/色差:
int floodFill(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 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函数通过仿射变换(几何中的仿射变换是将平行线转换为平行线的变换)旋转输入图像,在其中设置输入图像和目标图像 ,转换矩阵,输出大小(与本例中的输入相同)以及要使用的插值方法。 如果需要,我们可以定义border方法和border值:
//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 cropped 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 张不带144 x 33像素车牌的图像训练了我们的系统。 我们可以在下图中看到此数据的样本。 这不是一个很大的数据集,但足以满足我们的要求。 在实际应用中,我们需要训练更多数据:

为了轻松了解机器学习的工作原理,我们继续使用分类器算法的图像像素特征(请记住,有更好的方法和功能来训练 SVM,例如主成分分析,傅立叶变换, 纹理分析等)。
我们需要使用DetectRegions类创建图像来训练我们的系统,并将savingRegions变量设置为true,以保存图像。 我们可以使用segmentAllFiles.sh bash 脚本对文件夹下的所有图像文件重复该过程。 可以从本书的源代码中获取。
为了简化操作,我们将所有经过处理和准备的图像训练数据存储到 XML 文件中,以直接与 SVM 功能一起使用。 trainSVM.cpp应用使用文件夹和图像文件数量创建此文件。
提示
带有机器学习 OpenCV 算法的训练数据存储在NxM矩阵中,并具有N个样本和M特征。 每个数据集在训练矩阵中保存为一行。
这些类别存储在N x 1大小的另一个矩阵中,其中每个类别均由浮点数标识。
OpenCV 使用FileStorage类可以轻松地管理 XML 或 JSON 格式的数据文件,该类使我们可以存储和读取 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 参数,这些参数定义了在 SVM 算法中使用的基本参数。 我们将使用CvSVMParams结构进行定义。 它是对训练数据的映射,以提高其与线性可分离数据集的相似度。 该映射包括增加数据的维数,并使用核函数有效地完成了映射。 我们在这里选择CvSVM::LINEAR类型,这意味着没有映射完成:
//Set SVM params
CvSVMParams SVM_params;
SVM_params.kernel_type = CvSVM::LINEAR;
然后,我们创建并训练分类器。 OpenCV 为支持向量机算法定义了CvSVM类,我们使用训练数据,类和参数数据对其进行初始化:
CvSVM svmClassifier(SVM_TrainingData, SVM_Classes, Mat(), Mat(), SVM_params);
我们的分类器已准备好使用 SVM 类的predict函数来预测可能的裁剪图像; 此函数返回类标识符i。 在我们的案例中,我们将板级标记为1,而没有板级标记为0。 然后,对于每个可能是板块的检测区域,我们使用 SVM 将其分类为板块或无板块,并仅保存正确的响应。 以下代码是主应用的一部分,称为在线处理:
vector<Plate> plates;
for(int i=0; i< possible_regions.size(); i++)
{
Mat img=possible_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(possible_regions[i]);
}
车牌识别
车牌识别的第二步旨在通过光学字符识别来检索牌照的字符。 对于每个检测到的印版,我们继续对每个字符的印版进行分割,并使用人工神经网络(ANN)机器学习算法来识别字符。 同样在本节中,我们将学习如何评估分类算法。
OCR 分割
首先,我们获得一个板块图像斑块,作为具有均等直方图的分割 OCR 函数的输入,然后我们需要应用阈值过滤器并将此阈值图像用作查找轮廓算法的输入; 我们可以在下图中看到这个过程:

此分割过程的编码为:
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 contour
我们使用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类的向量中。 此类保存分段的字符图像和需要排序字符的位置,因为“查找轮廓”算法不会按要求的顺序返回轮廓。
特征提取
分割每个字符的下一步是提取用于训练和分类人工神经网络算法的特征。
与 SVM 中使用的印版检测特征提取步骤不同,我们不使用所有图像像素;而是使用所有图像像素。 我们将应用在光学字符识别中使用的更多常见特征,包括水平和垂直累积直方图和低分辨率图像样本。 我们可以在下一张图像中更形象地看到此特征,其中每个图像的分辨率均为5 x 5,并且直方图会累加:

对于每个字符,我们使用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;
}
其他函数使用低分辨率样本图像。 而不是使用整个字符图像,我们创建一个低分辨率字符,例如5 x 5。我们用5 x 5、10 x 10、15 x 15和20 x 20个字符训练系统,然后评估哪个字符返回最佳结果,以便我们可以在系统中使用它。 一旦拥有所有特征,就可以按行创建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);
//Assign 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 由具有输入层,输出层和一个或多个隐藏层的神经元网络组成。 每一层都有一个或多个与上一层和下一层相连的神经元。
以下示例表示一个 3 层感知器(它是将实值向量输入映射到单个二进制值输出的二分类器),具有 3 个输入,2 个输出以及包含 5 个神经元的隐藏层:

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

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

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

我们创建OCR::train函数,以创建所有需要的矩阵,并使用训练数据矩阵,类矩阵以及隐藏层中的隐藏神经元数量来训练我们的系统。 就像我们进行 SVM 训练一样,从 XML 文件加载训练数据。
我们必须定义每层神经元的数量以初始化 ANN 类。 对于我们的示例,我们仅使用一个隐藏层,然后定义一个 1 行 3 列的矩阵。 第一列位置是特征的数量,第二列位置是隐藏层中隐藏的神经元的数量,第三列位置是类别的数量。
OpenCV 为 ANN 定义了一个CvANN_MLP类。 使用create函数,我们可以通过定义层和神经元的数量,激活函数以及alpha和beta参数来启动类:
void OCR::train(Mat TrainData, Mat classes, int nlayers)
{
Mat layerSizes(1,3,CV_32SC1);
layerSizes.at<int>(0)= TrainData.cols;
layerSizes.at<int>(1)= nlayers;
layerSizes.at<int>(2)= numCharacters;
ann.create(layerSizes, CvANN_MLP::SIGMOID_SYM, 1, 1); //ann is
global class variable
//Prepare trainClasses
//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;
}
}
Mat weights( 1, TrainData.rows, CV_32FC1, Scalar::all(1) );
//Learn classifier
ann.train( TrainData, trainClasses, weights );
trained=true;
}
训练后,我们可以使用OCR::classify函数对任何分割的板特征进行分类:
int OCR::classify(Mat f)
{
int result=-1;
Mat output(1, numCharacters, CV_32FC1);
ann.predict(f, output);
Point maxLoc;
double maxVal;
minMaxLoc(output, 0, &maxVal, 0, &maxLoc);
//We need to know where in output is the max val, the x (cols) is
//the class.
return maxLoc.x;
}
CvANN_MLP类使用predict函数对类中的特征向量进行分类。 与 SVM classify函数不同,ANN 的predict函数返回一行,其大小等于类的数量,并且有可能属于每个类的输入特征。
为了获得最佳结果,我们可以使用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文件包含针对5 x 5、10 x 10、15 x 15和20 x 20下采样图像特征的训练数据矩阵。
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 个神经元,并且从缩小的10 x 10图像补丁中提取了字符的特征。
总结
在本章中,我们学习了自动车牌识别程序的工作方式,以及它的两个重要步骤:车牌定位和车牌识别。
在第一步中,我们学习了如何分割图像以寻找可以放置印版的补丁,以及如何使用简单的试探法和支持向量机算法对没有印版的印版进行二分类。
在第二步中,我们学习了如何使用“查找轮廓”算法进行分割,从每个字符中提取特征向量,以及如何使用人工神经网络对字符类中的每个特征进行分类。
我们还学习了如何通过使用随机样本进行训练来评估机器算法,以及如何使用不同的参数和特征对其进行评估。
六、非刚性人脸跟踪
非刚性人脸跟踪是视频流每一帧中一组准密集的人脸特征的估计,这是一个难题,现代方法从许多相关领域借鉴了思想,包括计算机视觉,计算几何 ,机器学习和图像处理。 这里的非刚性指的是以下事实:人脸特征之间的相对距离在面部表情和整个人群之间变化,并且不同于人脸检测和跟踪,后者仅旨在在每个帧中查找面部的位置,而不是配置人脸特征。 非刚性人脸跟踪是一个流行的研究主题,已经有二十多年的历史了,但是直到最近,各种方法才变得足够鲁棒,处理器也足够快,这使得构建商业应用成为可能。
尽管商业级的面部跟踪可能非常复杂,甚至对有经验的计算机视觉科学家来说都是一个挑战,但在本章中,我们将看到可以使用适度的数学工具和 OpenCV 来设计在受限设置下表现良好的面部跟踪器。 线性代数,图像处理和可视化方面的重要功能。 当提前知道要跟踪的人并且可以使用图像和地标形式的训练数据时,尤其如此。 此后描述的技术将作为有用的起点和指南,用于进一步追求更精细的面部跟踪系统。
本章概述如下:
- 概述:本节介绍面部跟踪的简要历史。
- 工具:本节概述了本章中使用的通用结构和约定。 它包括面向对象的设计,数据存储和表示,以及用于数据收集和标注的工具。
- 几何约束:本节描述如何从训练数据中学习面部几何及其变化,并在跟踪过程中利用面部几何约束其解决方案。 这包括将人脸建模为线性形状模型,以及如何将全局转换集成到其表示中。
- 人脸特征检测器:本节介绍如何学习人脸特征的外观,以便在要跟踪面部的图像中检测人脸特征。
- 人脸检测和初始化:本节介绍如何使用人脸检测初始化跟踪过程。
- 人脸跟踪:本节通过图像对齐过程将前面描述的所有组件组合到跟踪系统中。 还讨论了可以期望系统最佳工作的设置。
以下框图说明了系统各个组件之间的关系:

注意
请注意,本章中使用的所有方法都遵循数据驱动的范式,在该模型中,所使用的所有模型都是从数据中学习的,而不是在基于规则的设置中手动设计的。 因此,系统的每个组件都将包含两个组件:训练和测试。 训练从数据中构建模型,测试将这些模型应用于新的看不见的数据。
概述
随着 Cootes 和 Taylor 的活动形状模型(ASM)的出现,非刚性人脸追踪首次在 90 年代中期普及。 从那时起,大量的研究致力于解决通用人脸跟踪的难题,并且对 ASM 提出的原始方法进行了许多改进。 第一个里程碑是 2001 年,同样是 Cootes 和 Taylor,将 ASM 扩展到了活动外观模型(AAM)。 后来,贝克和各大学在 2000 年代中期通过对图像扭曲的原则性处理,使这种方法正式化。 沿着这些思路开展的另一项工作是 Blanz 和 Vetter 的 3D 可变形模型(3DMM),它与 AAM 一样,不仅为图像纹理建模,而且像 ASM 中一样沿对象边界进行轮廓剖析,但是通过使用从面部激光扫描中学到的高度密集的 3D 数据来表示模型,又向前迈了一步。 从 2000 年代中期到后期,人脸跟踪的研究重点从如何对人脸进行参数化转向如何设定和优化跟踪算法的目标。 应用了机器学习社区的各种技术,并获得了不同程度的成功。 自世纪之交以来,焦点再次转移,这一次是为了保证全局解决方案的联合参数和客观设计策略。
尽管对面部跟踪进行了持续的深入研究,但是使用面部跟踪的商业应用相对较少。 尽管有许多免费的源代码包可用于许多常用方法,但爱好者和爱好者的吸收也滞后。 但是,在过去的两年中,由于可能会使用面部跟踪,因此人们对公共领域重新产生了兴趣,并且商业级产品也开始出现。
工具
在深入了解复杂的面部跟踪之前,必须先引入所有面部跟踪方法共有的许多簿记任务和约定。 本节的其余部分将处理这些问题。 有兴趣的读者可能希望在初读时跳过本部分,直接进入有关几何约束的部分。
面向对象设计
与人脸检测和识别一样,人脸跟踪在程序上也包含两个部分:数据和算法。 该算法通常通过参考预存储(即离线)的数据作为指导,对传入(即在线)的数据执行某种操作。 这样,将算法与算法所依赖的数据相结合的面向对象设计是一种方便的设计选择。
在 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类型分别是两个整数和 2D 浮点坐标的向量的 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)标志指定将图像加载为 3 通道彩色图像还是单通道灰度图像。 传递给 OpenCV 的flip函数的标志指定围绕 y 轴的镜像。
要访问与特定索引处的图像对应的点集,get_points函数返回浮点坐标的向量,并可以选择镜像其索引的方式,如下所示:
vector<Point2f>
ft_data::get_points(
const int idx, //index of image corresponding to pointsconst 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 数据集。
该数据集包含 3755 张带有 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表示将annotations.yaml文件(包含作为ft_data对象存储的数据)写入到的文件夹。
提示
鼓励使用 MUCT 数据集来快速介绍本章中描述的面部跟踪代码的功能。
几何约束
在面部跟踪中,几何形状是指一组预定义的点的空间配置,这些点对应于人脸在物理上一致的位置(例如眼角,鼻尖和眉毛边缘)。 这些点的特定选择取决于应用,其中一些应用需要超过 100 个点的密集集合,而其他应用只需要稀疏选择。 但是,人脸跟踪算法的鲁棒性通常会随着点数的增加而提高,因为它们的单独测量可以通过其相对的空间依赖性相互增强。 例如,知道眼角的位置很好地表明了鼻子的位置。 但是,通过增加点数获得的鲁棒性改进存在局限性,在这种情况下,表现通常会在大约 100 点之后停滞。 此外,增加用于描述人脸的点集会使计算复杂度线性增加。 因此,对计算负载有严格限制的应用可以用更少的点实现更好的表现。
在这种情况下,更快的跟踪通常会导致在线设置中的跟踪更加准确。 这是因为,当丢下帧时,帧之间的感知运动会增加,并且用于在每个帧中查找人脸的配置的优化算法必须搜索较大的特征点可能配置空间; 当帧之间的位移变得太大时,该过程通常会失败。 总而言之,尽管有关于如何最佳设计人脸特征点选择以获取最佳表现的通用指南,但该选择应专门针对应用领域。
面部几何形状通常被参数化为两个元素的组合:整体(刚性)变形和局部(非刚性)变形。 全局变换说明了脸部在图像中的整体位置,通常可以无限制地进行更改(即,脸部可以出现在图像中的任何位置)。 这包括图像中人脸的(x, y)位置,平面内头部旋转以及图像中人脸的大小。 另一方面,局部变形可解决不同身份的面部形状之间以及表情之间的差异。 与全局转换相反,这些局部变形通常在很大程度上由于人脸特征的高度结构化配置而受到更大的限制。 全局变换是 2D 坐标的通用函数,适用于任何类型的对象,而局部变形是特定于对象的,必须从训练数据集中学习。
在本节中,我们将描述面部结构的几何模型的构建,在此称为形状模型。 根据应用的不同,它可以捕获单个人的表情变化,整个人群的面部形状之间的差异或两者的组合。 该模型在shape_model.hpp和shape_model.cpp文件中找到的shape_model类中实现。 以下代码段是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是训练过程的参数,可以专门用于手头的数据。
在以下各节中将详细介绍此类的功能,在此首先描述普氏分析法,这是一种用于刚性注册点集的方法,其后是用于表示局部变形的线性模型。 train_shape_model.cpp和visualize_shape_model.cpp文件中的程序分别训练和可视化形状模型。 它们的用法将在本节末尾概述。
普氏分析
为了建立面部形状的变形模型,我们必须首先处理原始的带标注的数据,以删除与整体刚性运动有关的分量。 在 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);
}
此函数可最大程度地减小旋转后的形状和标准形状之间的最小二乘方差。 从数学上讲,可以这样写:

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

下图说明了 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);
尽管默认设置为 0.95 和 20,但是frac(即要保留的变化比例)和kmax(即要保留的特征向量的最大数量)也可以选择设置。 分别在大多数情况下往往效果很好。 最后,在命令行参数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 个像素的平移的系数来计算平移分量(也就是说,模型以均心为中心,显示窗口的大小为300 x 300像素)。
注意
请注意,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);
在此,动画的每个阶段都由五十个增量组成。 然后使用该轨迹为人脸模型制作动画,并在显示窗口中呈现结果,如下所示:
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.hpp和patch_model.cpp文件中找到的patch_model类中实现。 以下代码段是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 方法。 判别方法的优点是模型的全部特征直接针对当前的问题; 将对象的实例与所有其他实例区分开。 在所有判别方法中,最著名的也许就是支持向量机。 尽管这两种范例都可以在许多情况下很好地工作,但是我们将看到,将人脸特征建模为图像块时,判别范例要优越得多。
注意
请注意,EigenFace 和支持向量机方法最初是为分类而不是检测或图像对齐而开发的。 但是,它们的基本数学概念已显示适用于面部跟踪领域。
学习判别式补丁模型
给定带标注的数据集,可以彼此独立地学习特征检测器。 判别补丁模型的学习目标是构造一个图像补丁,当该补丁与包含人脸特征的图像区域互相关时,在特征位置产生强烈的响应,而在其他位置产生较弱的响应。 从数学上讲,这可以表示为:

在这里,P表示补丁模型,I表示第i个训练图像,I(a:b, c:d)表示其左上和右下的矩形区域,角分别位于(a, c)和(b, d)。 周期符号表示内部乘积运算,R表示理想响应图。 该方程式的解决方案是一个补丁模型,该模型生成的响应图平均而言最接近使用最小二乘法标准测量的理想响应图。 理想响应图的一个显而易见的选择是R,除了中心处,其他任何地方都为零(假设训练图像块位于感兴趣的人脸特征的中心)。 实际上,由于图像是手工标记的,因此始终会出现标注错误。 为了解决这个问题,通常将R描述为距中心距离的衰减函数。 一个很好的选择是 2D-Gaussian 分布,它等效于假设标注错误是 Gaussian 分布。 下图显示了该设置的可视化,用于左外眼角:

如先前所写的学习目标是以通常被称为线性最小二乘法的形式。 这样,它提供了封闭形式的解决方案。 但是,此问题的自由度(即变量可以改变以解决问题的方式的数量)等于补丁中的像素数量。 因此,即使对于中等大小的补丁或示例,40 x 40补丁模型也具有 1600 个自由度,但求解最佳补丁模型的计算成本和内存需求可能会令人望而却步。
解决学习问题的有效方法是线性方程组,它是一种称为随机梯度下降的方法。 通过将学习目标可视化为补丁模型自由度上的错误地形,随机梯度下降迭代地估计了地形的梯度方向,并在相反方向上走了一小步。 对于我们的问题,可以通过仅考虑针对训练集中的单个随机选择图像的学习目标的梯度来计算梯度的近似值:

在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的相似度变换的左上(2 x 2)块与变换的比例和旋转分量相对应,保留在传递的仿射变换中 OpenCV 的warpAffine函数。 仿射变换A的最后一列是一种调整,它将在翘曲(即归一化平移)后呈现第i个人脸特征位置在归一化图像中居中的位置。 最后,cv::warpAffine函数具有从图像到参考帧变形的默认设置。 由于计算了相似度转换以将reference形状转换为图像空间标注pt,因此需要设置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)偏移量,以及缩放比例因子,该比例因子调整参考形状的大小以最适合图像中的面部 。 所有这三个分量都是边界框宽度的线性函数。
边界框的宽度和detector_offset变量之间的线性关系是从face_detector::train函数中带标注的数据集中学习的。 通过将训练数据加载到内存中并分配参考形状来开始学习过程:
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;
}
除了簿记操作(例如设置适当的tracking状态并增加跟踪时间)外,跟踪算法的核心是多级拟合过程,该过程在前面的代码片段中突出显示。 在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 用于基本图像处理和线性代数运算的实质功能,即可在受限设置中合理地工作。 可以通过在跟踪器的三个组件(形状模型,特征检测器和拟合算法)的每个组件中采用更复杂的技术来改进此简单的跟踪器。 本节中描述的跟踪器的模块化设计应允许修改这三个组件,而不会实质性破坏其他组件的功能。
参考
Procrustes Problems, Gower, John C. and Dijksterhuis, Garmt B, Oxford University Press, 2004.
七、使用 AAM 和 POSIT 的 3D 头部姿势估计
一个好的计算机视觉算法如果没有强大特征以及广泛的概括性和扎实的数学基础,就不可能完成。 所有这些功能都伴随着 Tim Cootes 主要用活动外观模型开发的工作。 本章将教您如何使用 OpenCV 创建自己的活动外观模型,以及如何使用它来搜索模型在给定框架中的最近位置。 此外,您还将学习如何使用 POSIT 算法以及如何在“摆姿势”的图像中拟合 3D 模型。 使用所有这些工具,您将能够实时跟踪视频中的 3D 模型。 这不是很好吗? 尽管示例着重于头部姿势,但实际上任何可变形模型都可以使用相同的方法。
阅读各节时,您会遇到以下主题:
- 活动外观模型概述
- 活动形状模型概述
- 模型实例化 - 玩转活动外观模型
- AAM 搜索和拟合
- POSIT
以下列表解释了本章将要遇到的术语:
- 活动外观模型(AAM):包含其形状和纹理的统计信息的对象模型。 这是一种捕获对象形状和纹理变化的有效方法。
- 活动形状模型(ASM):对象形状的统计模型。 这对于学习形状变化非常有用。
- 主成分分析(PCA):正交线性变换,将数据转换为新的坐标系,从而使任何数据投影的最大方差都位于第一个坐标上 (称为第一个主成分),第二个坐标上的第二大方差,依此类推。 此过程通常用于降维。 在减小原始问题的范围时,可以使用一种更快拟合的算法。
- Delaunay 三角剖分(DT):对于平面中的一组
P点,是三角剖分,因此P中的任何点都不在三角剖分中任何三角形的外接圆之内。 它倾向于避免骨感三角形。 纹理映射需要三角剖分。 - 仿射变换:可以以矩阵乘法和向量加法形式表示的任何变换。 这可以用于纹理映射。
- 比例正交投影迭代变换(POSIT):一种执行 3D 姿势估计的计算机视觉算法。
活动外观模型概述
简而言之,Active Appearance Models 是结合了纹理和形状的模型参数化,再加上有效的搜索算法,该算法可以准确指出模型在图片框中的位置和位置。 为此,我们将从“活动形状模型”部分开始,将看到它们与界标位置更紧密相关。 以下各节将更好地描述主成分分析和一些动手经验。 然后,我们将能够从 OpenCV 的 Delaunay 函数中获得一些帮助,并学习一些三角剖分。 从那时起,我们将逐步发展到在三角形纹理扭曲部分中应用分段仿射扭曲,在该部分中我们可以从对象的纹理中获取信息。
当我们有足够的背景知识来建立一个好的模型时,我们可以使用模型实例化部分中的技术。 然后,我们将能够通过 AAM 搜索和拟合来解决逆问题。 这些本身对于 2D 甚至 3D 图像匹配而言已经是非常有用的算法。 但是,当一个人能够使它工作时,为什么不将其桥接到 POSIT(比例正交投影迭代变换),这是另一种用于 3D 模型拟合的坚如磐石的算法? 进入 POSIT 部分将为我们提供足够的背景知识,以便在 OpenCV 中使用它,然后在下一部分中,我们将学习如何将头部模型与其耦合。 这样,我们可以使用 3D 模型来拟合已经匹配的 2D 框架。 而且,如果敏锐的读者想知道这将带给我们什么,这仅仅是将 AAM 和 Posit 逐帧组合起来,以通过检测变形模型获得实时 3D 跟踪的问题! 这些详细信息将在“从网络摄像头或视频文件进行跟踪”部分中介绍。
据说一张图片值一千字; 假设我们得到N张图片。 这样,我们之前提到的内容可以在以下屏幕截图中轻松找到:

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

脸部有 76 个地标,取自 MUCT 数据集。 这些地标通常是用手工标记的,它们概述了一些人脸特征,例如嘴轮廓,鼻子,眼睛,眉毛和面部形状,因为它们更易于跟踪。
注意
普氏分析:一种统计形状分析的形式,用于分析一组形状的分布。 通过优化平移,旋转和均匀缩放对象来执行 Procrustes 叠加。
如果我们具有前面提到的图像集,则可以生成形状变化的统计模型。 由于对象上的标记点描述了该对象的形状,因此,如果需要,我们首先使用 Procrustes Analysis 将所有点集对齐到坐标系中,并通过x向量表示每种形状。 然后,我们对数据应用主成分分析(PCA)。 然后,我们可以使用以下公式近似任何示例:
x = x + ps bs
在前面的公式中,x是平均形状, Ps是一组正交变化模式,bs是一组形状参数。 好吧,为了更好地理解它,我们将在本节的其余部分中创建一个简单的应用,该应用将向我们展示如何处理 PCA 和形状模型。
为什么要完全使用 PCA? 因为 PCA 在减少模型参数数量方面将真正为我们提供帮助。 在本章的后面,我们还将看到在给定图像中进行搜索时有多大帮助。 应当为以下引用提供一个网页 URL:
PCA 可以为用户提供较低维度的图片,即从其(在某种意义上)信息最丰富的角度查看时该对象的“阴影”。 这是通过仅使用前几个主要成分来完成的,从而降低了转换数据的维数。
当我们看到如下屏幕截图时,这一点变得很清楚:

前面的屏幕截图显示了以(2, 3)为中心的多元高斯分布的 PCA。 所示的向量是协方差矩阵的特征向量,它们经过偏移,因此其尾部位于均值处。
这样,如果我们想用一个参数来表示模型,那么从特征向量指向屏幕截图右上角的方向是一个好主意。 此外,通过稍微改变参数,我们可以推断数据并获得与所需值相似的值。
感受 PCA
为了了解 PCA 如何帮助我们改善脸部模型,我们将从活动形状模型开始并测试一些参数。
由于人脸检测和跟踪已经研究了一段时间,因此可以在线使用多个面部数据库进行研究。 我们将使用 IMM 数据库中的几个样本。
首先,让我们了解一下 PCA 类在 OpenCV 中的工作方式。 我们可以从文档中得出结论,PCA 类用于计算向量集的特殊基础,该向量集由从向量输入集计算出的协方差矩阵的特征向量组成。 此类还可以使用project和backproject方法在新的坐标空间之间来回转换向量。 通过仅采用其前几个分量,就可以非常精确地近似此新坐标系。 这意味着我们可以用高得多的空间表示原始向量,而该向量要短得多,该向量由子空间中投影向量的坐标组成。
由于我们希望根据几个标量值进行参数化,因此我们将在类中使用的主要方法是backproject方法。 它采用投影向量的主成分坐标并重建原始向量。 如果保留所有分量,我们可以检索原始向量,但如果仅使用几个分量,则差异将很小。 这是使用 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 分析中使用 。 然后,我们将两个参数添加到该文件的第一行,即训练图像的数量和读取的列的数量。 因此,对于k2D 点,该数字将为2 * k。
在以下数据中,我们有此文件的实例,该文件是通过从 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)行中创建的,并且为2 * k值分配了足够的空间。 在两个for循环将数据加载到矩阵中之后,如果我们希望只创建一次,则用数据(一个空矩阵)调用 PCA 构造器,该矩阵可以是我们预先计算的均值向量。 我们还指出,向量将存储为矩阵行,并且我们希望将给定的行数与组件数保持相同,尽管我们可以只使用少数几个。
现在我们已经用训练集填充了 PCA 对象,它具有根据给定参数对形状进行背投影所需的一切。 为此,我们调用PCA.backproject,将参数作为行向量传递,并将反投影的向量接收到第二个参数中。


前两个截屏根据从滑块选择的所选参数显示了两种不同的形状配置。 黄色和绿色形状显示训练数据,而红色形状反映从所选参数生成的形状。
样本程序可用于试验活动形状模型,因为它允许用户为模型尝试不同的参数。 可以注意到,通过滑块仅改变前两个标量值(对应于第一和第二变化模式),我们可以获得的形状非常接近于受过训练的形状。 这种可变性在 AAM 中搜索模型时会有所帮助,因为它提供了插值形状。 在以下各节中,我们将讨论三角剖分,纹理化,AAM 和 AAM 搜索。
三角剖分
由于我们正在寻找的形状可能会变形,例如张开嘴,因此我们需要将纹理映射回平均形状,然后将 PCA 应用于此归一化纹理。 为此,我们将使用三角剖分。 这个概念非常简单:我们将创建包含标注点的三角形,然后从一个三角形映射到另一个三角形。 OpenCV 带有一个方便的函数cvCreateSubdivDelaunay2D,该函数创建一个空的 Delaunay 三角剖分。 您可以认为这是一个很好的三角剖分方法,可以避免出现三角形。
注意
在数学和计算几何学中,平面中点集P的 Delaunay 三角剖分是三角剖分DT(P),因此P位于DT(P)任何三角形的外接圆之内。 Delaunay 三角剖分将三角剖分中所有三角形的最小角度最大化。 他们倾向于避免瘦三角形。 三角剖分以 Boris Delaunay 的名字命名,因为他从 1934 年开始就此主题开展工作。
初始化 Delaunay 剖分后,将使用cvSubdivDelaunay2DInsert将点填充到该剖分中。 以下代码行将阐明直接使用三角剖分会是什么样子:
CvMemStorage* storage;
CvSubdiv2D* subdiv;
CvRect rect = { 0, 0, 640, 480 };
storage = cvCreateMemStorage(0);
subdiv = cvCreateSubdivDelaunay2D(rect,storage);
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;
CvPoint2D32f floatingPoint = cvPoint2D32f(x, y);
cvSubdivDelaunay2DInsert( subdiv, floatingPoint );
}
请注意,我们的点将位于一个矩形框架内,该矩形框架将作为参数传递给cvCreateSubdivDelaunay2D。 为了创建剖分,我们还需要创建和初始化内存存储结构。 这可以在前面的代码的前五行中看到。 然后,为了创建三角剖分,我们需要使用cvSubdivDelaunay2DInsert函数插入点。 这在前面的代码的循环的内部发生。 请注意,这些点应该已经初始化,因为它们通常是我们将用作输入的点。 以下屏幕截图显示了三角剖分的样子:

此屏幕快照是前面代码的一组点的输出,这些点使用 Delaunay 算法产生三角剖分。
尽管剖分创建是 OpenCV 的一个非常方便的功能,但是遍历所有三角形可能并不容易。 以下代码显示了如何遍历剖分的边缘:
void iterate(CvSubdiv2D* subdiv, CvNextEdgeType triangleDirection){
CvSeqReader reader;
CvPoint buf[3];
int i, j, total = subdiv->edges->total;
int elem_size = subdiv->edges->elem_size;
cvStartReadSeq((CvSeq*)(subdiv->edges), &reader, 0);
for(i = 0; i < total; i++){
CvQuadEdge2D* edge = (CvQuadEdge2D*)(reader.ptr);
if(CV_IS_SET_ELEM(edge)){
CvSubdiv2DEdge t = (CvSubdiv2DEdge)edge;
for(j=0;j<3;j++){
CvSubdiv2DPoint* pt = cvSubdiv2DEdgeOrg(t);
if(!pt) break;
buf[j] = cvPoint(cvRound(pt->pt.x), cvRound(pt->pt.y));
t = cvSubdiv2DGetEdge(t, triangleDirection);
}
}
CV_NEXT_SEQ_ELEM(elem_size, reader);
}
}
给定一个剖分,我们初始化其边缘读取器并调用cvStartReadSeq函数。 在 OpenCV 的文档中,我们具有以下引用的定义:
该函数初始化读取器状态。 之后,在向前读取的情况下,可以通过对宏
CV_READ_SEQ_ELEM(read_elem, reader)的后续调用来读取从第一个到最后一个的所有序列元素;对于以下情况,则可以使用CV_REV_READ_SEQ_ELEM(read_elem, reader)来读取: 反向阅读。 两个宏都将序列元素放入read_elem并将读取指针移至下一个元素。
获取以下元素的另一种方法是使用宏CV_NEXT_SEQ_ELEM( elem_size, reader ),如果序列元素较大,则首选此宏。 在这种情况下,我们使用CvQuadEdge2D* edge = (CvQuadEdge2D*)(reader.ptr)访问边缘,这仅仅是从读取器指针到CvQuadEdge2D指针的转换。 宏CV_IS_SET_ELEM仅检查指定的边是否被占用。 给定一条边,为了获得源点,我们需要调用cvSubdiv2DEdgeOrg函数。 为了绕三角形运行,我们反复调用cvSubdiv2DGetEdge并传递三角形方向,例如可以是CV_NEXT_AROUND_LEFT或CV_NEXT_AROUND_RIGHT。
三角形纹理变形
既然我们已经能够遍历剖分的三角形,我们就可以将一个三角形从原始带标注的图像扭曲到生成的变形图像中。 这对于将纹理从原始形状映射到变形的形状很有用。 以下代码将指导该过程:
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数组中包装了目标顶点。 2 x 3 warp_mat矩阵用于获得从源三角形到目标三角形的仿射变换。 可以从 OpenCV 的cvGetAffineTransform文档中引用更多信息:
函数cvGetAffineTransform计算仿射变换的矩阵,使得:

在前面的公式中,目标(i)等于(x'[i], y'[i]),源(i)等于(x[i], y[i]),并且i等于 0、1、2。
提取仿射矩阵后,我们可以将仿射变换应用于源图像。 这是通过warpAffine函数完成的。 由于我们不想在整个图像中都这样做(我们希望将注意力集中在三角形上),因此可以将遮罩用于此任务。 这样,最后一行仅使用我们通过cvFillConvexPoly调用创建的遮罩复制了原始图像中的三角形。
以下屏幕截图显示了将此过程应用于带标注的图像中的每个三角形的结果。 请注意,三角形被映射回到面向观察者的对齐框架。 此过程用于创建 AAM 的统计纹理。

前面的屏幕截图显示了将左侧图像中所有映射的三角形扭曲为平均参考帧的结果。
模型实例化 – 使用活动外观模型
AAM 的一个有趣的方面是它们能够轻松地插值我们训练图像的模型的能力。 通过调整几个形状或模型参数,我们可以适应它们惊人的表示能力。 当我们改变形状参数时,经纱的目的地会根据训练后的形状数据而变化。 另一方面,修改外观参数时,将修改基本形状上的纹理。 我们的翘曲变换将把从基本形状到修改后的目标形状的每个三角形都包含在内,因此我们可以在张口的顶部合成一张张口,如以下屏幕截图所示:

前面的屏幕截图显示了通过在另一个图像上进行活动外观模型实例化而获得的闭合的嘴巴。 它显示了如何将微笑的嘴巴和钦佩的脸相结合,从而推断出训练过的图像。
前面的屏幕快照是通过仅更改形状的三个参数和纹理的三个参数而获得的,这是 AAM 的目标。 已经开发了一个示例应用,可以在这个页面上找到,供读者试用 AAM。 实例化新模型只是滑动方程参数的问题,如在“获得 PCA 的感觉”一节中所定义。 您应该注意,AAM 搜索和拟合依靠这种灵活性来找到与训练模型不同位置的给定捕获模型的最佳匹配。 我们将在下一部分中看到这一点。
AAM 搜索和拟合
通过我们全新的形状和纹理组合模型,我们找到了一种很好的方式来描述面部不仅可以改变形状,而且可以改变外观。 现在,我们要查找哪一组p形状和λ外观参数将使我们的模型尽可能接近给定的输入图像I(x)。 我们自然可以在I(x)的坐标系中计算实例化模型与给定输入图像之间的误差,或者将这些点映射回基础外观并计算出那里的差异。 我们将使用后一种方法。 这样,我们要最小化以下函数:

在前面的等式中,S0表示像素集x等于 AAM 基本网格中的(x, y)T。0(x)是我们的基本网格纹理,Ai(x)是 PCA 的外观图像,W(x; p)是将像素从输入图像返回到基本网格框架的扭曲。
通过多年的研究,已经提出了几种方法来使这种最小化。 第一个想法是使用加法,其中∆pi和∆λi被计算为误差图像的线性函数,然后将形状参数p和外观λ更新为pi ← pi + ∆pi和λi ← λi + Δλi,在第i个迭代中。 尽管有时会发生收敛,但是增量并不总是取决于当前参数,这可能会导致差异。 基于梯度下降算法进行研究的另一种方法非常慢,因此寻求寻找收敛的另一种方法。 代替更新参数,可以更新整个变形。 这样,伊恩·马修斯(Ian Mathews)和西蒙·贝克(Simon Baker)在著名的论文“活动外观模型回顾”中提出了一种合成方法。 可以在本文中找到更多详细信息,但是它对拟合的重要贡献是将最密集的计算带到了预计算步骤,如以下屏幕截图所示:

请注意,更新是根据合成步骤进行的,如步骤(9)所示(请参见上一个屏幕截图)。 本文的公式(40)和(41)可在以下屏幕截图中看到:


尽管刚刚提到的算法从最后一个位置开始的收敛效果非常好,但是当旋转,平移或缩放比例有很大差异时,情况可能并非如此。 通过全局 2D 相似度变换的参数化,我们可以为融合带来更多信息。 这是论文中的公式42,如下所示:

在前面的等式中,四个参数q = (a, b, tx, ty)^T具有以下解释。 第一对(a, b)与标度k和旋转角度有关:a = k cosθ - 1,b = k sinθ。 第二对(tx, ty)是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 是迭代算法,因此终止条件通常是迭代次数或距离参数。 然后,我们调用函数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
CvMatr32f rotation_matrix = new float[9];
CvVect32f 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 算法的输入点和结果:
-
白色圆圈显示输入点,而坐标轴显示结果模型姿势。
-
确保使用通过校准过程获得的相机焦距。 您可能需要检查第 2 章“iPhone 或 iPad 上的基于标记的增强现实”的“相机校准”部分中可用的校准程序之一。 当前 POSIT 的实现仅允许正方形像素,因此在 x 和 y 轴上没有焦距的空间。
-
期望旋转矩阵采用以下格式:
[红色[0]红色[1]红色[2]]
[腐烂[3]腐烂[4]腐烂[5]]
[腐烂[6]腐烂[7]腐烂[8]]
-
平移向量将采用以下格式:
[trans [0]]
[trans [1]]
[trans [2]]
POSIT 和头部模型
为了将 POSIT 用作头部姿势的工具,您将需要使用 3D 头部模型。 科英布拉大学系统与机器人研究所提供了一种,可以在这个页面中找到。 请注意,可以从以下位置获得模型:
float Model3D[58][3]= {{-7.308957,0.913869,0.000000}, ...
该模型可以在以下屏幕截图中看到:

上面的屏幕截图显示了可用于 POSIT 的 58 点 3D 头部模型。
为了使 POSIT 正常工作,必须相应地匹配与 3D 头部模型相对应的点。 请注意,要使 POSIT 工作,至少需要四个非共面 3D 点及其对应的 2D 投影,因此必须将它们作为参数传递,几乎与“POSIT 简介”部分中所述。 注意,该算法在匹配点数方面是线性的。 以下屏幕截图显示了应如何进行匹配:

上面的屏幕截图显示了 3D 头部模型和 AAM 网格的正确匹配点。
从网络摄像头或视频文件进行跟踪
现在,所有工具都已经组装好,可以进行 6 个自由度的头部跟踪,我们可以将其应用于摄像机流或视频文件。 OpenCV 提供了VideoCapture类,可以按以下方式使用(请参阅第 1 章, “卡通化器和 Android 皮肤检测器”中的“访问网络摄像头”部分。 , 更多细节):
#include "cv.h"
#include "highgui.h"
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 特征分类器人脸检测器自动完成(更多信息,请参见第 6 章,“非刚性人脸跟踪”的“人脸检测”部分), 或在 OpenCV 的级联分类器文档中),或者我们可以从先前标注的帧中手动初始化姿势。 也可以使用暴力方法,即为每个矩形运行 AAM 拟合,因为这种方法仅在第一帧期间会非常慢。 请注意,通过初始化,我们的意思是通过其参数找到 AAM 的 2D 界标。
加载完所有内容后,我们可以循环访问由while循环界定的主循环。 在此循环中,我们首先查询下一个抓取的帧,然后运行一个活动外观模型拟合,以便可以在下一帧上找到界标。 由于当前位置在此步骤中非常重要,因此我们将其作为参数传递给伪代码函数performAAMSearch(pose,aam)。 如果找到当前姿势(通过错误图像收敛发出信号),我们将获得下一个界标位置,以便将其提供给 POSIT。 这发生在下面的行applyPOSIT(new2DPose, headModel, mapping)中,新的 2D 姿态作为参数传递,我们先前加载的headModel和映射也是如此。 之后,我们可以在获得的姿势中渲染任何 3D 模型,例如坐标轴或增强现实模型。 当我们具有地标时,可以通过模型参数化来获得更有趣的效果,例如张开嘴或更改眉毛位置。
由于此过程依赖于先前的姿势进行下一次估计,因此我们可能会积累误差并偏离头部位置。 一种解决方法是,每次发生该过程时都要重新初始化,检查给定的错误图像阈值。 另一个要注意的因素是在跟踪时使用过滤器,因为可能会发生抖动。 对于每个平移和旋转坐标的简单均值过滤器可以给出合理的结果。
总结
在本章中,我们讨论了如何将活动外观模型与 POSIT 算法结合起来以获得 3D 头部姿势。 给出了有关如何创建,训练和操作 AAM 的概述,读者可以将此背景用于任何其他领域,例如医学,成像或工业。 除了处理 AAM 外,我们还熟悉 Delaunay 剖分,并学习了如何使用这种有趣的结构作为三角网格。 我们还展示了如何使用 OpenCV 函数在三角形中执行纹理映射。 在 AAM 拟合中提出了另一个有趣的话题。 尽管仅描述了逆成分投影算法,但仅通过使用其输出,我们就可以轻松获得多年研究的结果。
经过足够多的 AAM 理论和实践,我们深入研究了 POSIT,以便将 2D 测量与 3D 测量耦合起来,说明如何使用模型点之间的匹配来拟合 3D 模型。 在本章的结尾,我们展示了如何通过检测来使用在线面部跟踪器中的所有工具,从而产生 6 个自由度的头部姿势(旋转 3 度)和 3 个平移姿势。 可以从这个页面下载本章的完整代码。
参考
Active Appearance Models, T.F. Cootes, G. J. Edwards, and C. J. Taylor, ECCV, 2:484–498, 1998Active Shape Models – Their Training and Application, T.F. Cootes, C.J. Taylor, D.H. Cooper, and J. Graham, Computer Vision and Image Understanding, (61): 38–59, 1995The MUCT Landmarked Face Database, S. Milborrow, J. Morkel, and F. Nicolls, Pattern Recognition Association of South Africa, 2010The IMM Face Database – An Annotated Dataset of 240 Face Images, Michael M. Nordstrom, Mads Larsen, Janusz Sierakowski, and Mikkel B. Stegmann, Informatics and Mathematical Modeling, Technical University of Denmark, 2004Sur la sphère vide, B. Delaunay, Izvestia Akademii Nauk SSSR, Otdelenie Matematicheskikh i Estestvennykh Nauk, 7:793–800, 1934Active Appearance Models for Facial Expression Recognition and Monocular Head Pose Estimation Master Thesis, P. Martins, 2008Active Appearance Models Revisited, International Journal of Computer Vision, Vol. 60, No. 2, pp. 135 - 164, I. Mathews and S. Baker, November, 2004POSIT Tutorial, Javier BarandiaranModel-Based Object Pose in 25 Lines of Code, International Journal of Computer Vision, 15, pp. 123-141, Dementhon and L.S Davis, 1995
八、使用 EigenFace 或 Fisherfaces 的人脸识别
本章将介绍人脸检测和人脸识别的概念,并提供一个项目来检测面部并在再次看到它们时对其进行识别。 人脸识别既是一个热门话题,也是一个困难的话题,许多研究人员致力于人脸识别领域。 因此,本章将介绍人脸识别的简单方法,如果读者想探索更复杂的方法,则可以为他们提供一个良好的开端。
在本章中,我们涵盖以下内容:
- 人脸检测
- 人脸预处理
- 从收集的人脸中训练机器学习算法
- 人脸识别
- 画龙点睛
人脸识别和人脸检测简介
人脸识别是将标签粘贴到已知人脸的过程。 就像人类学会通过看他们的脸来认出家人,朋友和名人一样,计算机也有很多技术可以学会认出一张已知的脸。 这些通常包括四个主要步骤:
-
人脸检测:这是在图像中定位人脸区域的过程(以下屏幕快照中心附近的大矩形)。 此步骤并不关心人是谁,只关心它是人脸。
-
脸部预处理:这是调整脸部图像以使其看起来更清晰和与其他脸部相似的过程(以下屏幕快照顶部中心的小灰度脸部)。
-
收集和学习面部:保存许多预处理过的面部(对于每个应该识别的人),然后学习如何识别它们的过程。
-
人脸识别: 该过程将检查哪些被收集人员与相机中的脸部最相似(以下屏幕截图右上角的小矩形)。
注意
请注意,“脸部识别”一词通常被公众用来查找脸部位置(即,如步骤 1 中所述的脸部检测),但是本书将使用脸部识别的正式定义,参考步骤 4,和脸部检测的正式定义,请参阅步骤 1。
以下屏幕快照显示了最终的 WebcamFaceRec 项目,其中包括位于右上角的一个小矩形,突出显示了所识别的人。 还要注意位于预处理过的脸(矩形的顶部中心的小脸)旁边的置信度条,在这种情况下,这大约表明已识别出正确的人的置信度为 70%。

当前的人脸检测技术在现实环境中非常可靠,而当在现实环境中使用时,当前的人脸识别技术则可靠性低得多。 例如,很容易找到显示人脸识别准确率超过 95% 的研究论文,但是当您自己测试这些算法时,您可能经常会发现准确率低于 50%。 这源于以下事实:当前的人脸识别技术对图像中的确切条件非常敏感,例如照明的类型,照明和阴影的方向,面部的确切方向,面部表情以及当前的情感。 人。 如果在训练(收集图像)和测试(从摄像机图像)时它们都保持恒定,则人脸识别应该可以正常工作,但是如果该人站在房间左侧的灯光下, 训练,然后在用相机进行测试时站在右侧,这可能会产生非常差的结果。 因此,用于训练的数据集非常重要。
脸部预处理(步骤 2)旨在减少这些问题,例如通过确保脸部始终看起来具有相似的亮度和对比度,并可能确保脸部特征始终处于相同位置(例如对齐眼睛) 和/或鼻子到某些位置)。 一个好的人脸预处理阶段将有助于提高整个人脸识别系统的可靠性,因此本章将重点介绍人脸预处理方法。
尽管在媒体安全方面对人脸识别提出了很高的要求,但仅当前的人脸识别方法不可能对任何真正的安全系统都具有足够的可靠性,但是它们可用于不需要高可靠性的目的,例如播放个性化的音乐,适合不同人进入房间或看到您时会说出您名字的机器人。 人脸识别也有各种实用的扩展,例如性别识别,年龄识别和情感识别。
步骤 1:人脸检测
直到 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 |
| 人脸检测器 | 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 的检测器存储在文件夹data\haarcascades中,基于 LBP 的检测器存储在 OpenCV 根文件夹的文件夹data\lbpcascades中,例如C:\opencv\data\lbpcascades\。
对于我们的人脸识别项目,我们要检测正面人脸,所以让我们使用 LBP 人脸检测器,因为它是最快的并且没有专利许可问题。 请注意,OpenCV v2.x 附带的这种经过预训练的 LBP 人脸检测器并未像经过预训练的 Haar 人脸检测器那样进行调整,因此,如果您想要更可靠的人脸检测,则可能需要训练自己的 LBP 人脸检测器或使用 Haar 面部探测器。
加载用于物体或人脸检测的 Haar 或 LBP 检测器
要执行对象或人脸检测,首先必须使用 OpenCV 的CascadeClassifier类加载经过预先训练的 XML 文件,如下所示:
CascadeClassifier faceDetector;
faceDetector.load(faceCascadeFilename);
仅提供不同的文件名即可加载 Haar 或 LBP 检测器。 使用此错误时,一个非常常见的错误是提供了错误的文件夹或文件名,但根据您的构建环境,load()方法将返回false或生成 C++ 异常(并以断言错误退出程序)。 因此,最好用try/catch块围住load()方法,如果出现问题,最好向用户显示错误消息。 许多初学者会跳过检查错误的步骤,但是在某些内容未正确加载时向用户显示帮助消息至关重要,否则,您可能会花很长时间调试代码的其他部分,最后才意识到某些内容未加载。 一条简单的错误消息可以显示如下:
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++ 流运算符抓取帧,如第 1 章,“卡通化器和适用于 Android 的换肤器”中的“网络摄像头”部分中所述。
使用 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()函数将图像缩小到一定的尺寸或比例因子。 人脸检测通常适用于任何大于240 x 240像素的图像(除非您需要检测距离相机较远的人脸),因为它会查找比minFeatureSize大的人脸(通常为20 x 20像素)。 因此,让我们将相机图像缩小到 320 像素宽; 输入是 VGA 网络摄像头还是 5 兆像素高清摄像头都没有关系。 记住并放大检测结果也很重要,因为如果您检测到缩小图像中的脸部,那么结果也会缩小。 请注意,您可以在检测器中使用较大的minFeatureSize值,而不是缩小输入图像。 我们还必须确保图像不会变胖或变薄。 例如,缩小到240 x 240的240 x 240宽屏图像会使人看起来很瘦。 因此,我们必须保持输出的长宽比(宽高比)与输入相同。 让我们计算缩小图像宽度多少,然后对高度也应用相同的比例因子,如下所示:
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()函数轻松进行直方图均衡化,以提高图像的对比度和亮度(如《学习 OpenCV:使用 OpenCV 库的计算机视觉》中所述)。 有时这会使图像看起来很奇怪,但通常它应该提高亮度和对比度,并有助于人脸检测。 equalizeHist()函数的用法如下:
// Standardize the brightness & contrast, such as
// to improve dark images.
Mat equalizedImg;
equalizeHist(inputImg, equalizedImg);
检测脸部
现在,我们已经将图像转换为灰度,缩小图像并均衡了直方图,我们准备使用CascadeClassifier::detectMultiScale()特征检测面部! 我们将许多参数传递给此函数:
minFeatureSize:此参数确定我们关注的最小脸部尺寸,通常为20 x 20或20 x 20像素,但这取决于您的使用情况和图像尺寸。 如果要在网络摄像头或智能手机上执行人脸检测,而面部总是非常靠近相机,则可以将其放大到20 x 20以更快地进行检测,或者要检测较远的面部,例如和朋友一起去海滩,然后将其保留为20 x 20。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;
}
请注意,前面的代码将查找图像中的所有面孔,但是如果您只关心一个面孔,则可以如下更改flag变量:
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.xmlhaarcascade_eye_tree_eyeglasses.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对于较大的搜索区域是最佳的。 请注意,还显示了检测到的面部矩形,以了解将眼睛搜索区域与检测到的面部矩形相比有多大:

当使用上表中给出的眼睛搜索区域时,以下是不同眼睛检测器的近似检测属性:
| 级联分类器 | 可靠性 [1] | 速度 [2] | 发现眼睛 | 眼镜 |
|---|---|---|---|---|
haarcascade_mcs_lefteye.xml |
80% | 18 毫秒 | 打开或关闭 | 否 |
haarcascade_lefteye_2splits.xml |
60% | 7 毫秒 | 打开或关闭 | 否 |
haarcascade_eye.xml |
40% | 5 毫秒 | 只打开 | 否 |
haarcascade_eye_tree_eyeglasses.xml |
15% | 10 毫秒 | 只打开 | 是 |
[1] 可靠性值显示了在没有佩戴眼镜且睁开双眼的情况下,在 LBP 正面检测后双眼检测的频率。 如果眼睛闭合,则可靠性可能会下降,或者如果戴眼镜,则可靠性和速度都会下降。
在 Intel Core i7 2.2 GHz 上,图像缩放到320 x 240像素大小时的速度值(以毫秒为单位)(在 1000 张照片中平均)。 当发现眼睛时,速度通常要比没有发现眼睛时快得多,因为它必须扫描整个图像,但是haarcascade_mcs_lefteye.xml仍然比其他眼睛检测器慢得多。
例如,如果将照片缩小到320 x 240像素,对其进行直方图均衡化,请使用 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 度并沿与摄像机相反的方向行走,然后重复整个例程,以便训练集可以包括许多不同的照明条件。
因此,总的来说,与每个人只有 10 个训练脸相比,每个人拥有 100 个训练脸可能会产生更好的结果,但是如果所有 100 个脸看起来几乎相同,那么它仍然会表现不佳,因为训练集具有足够的多样性来覆盖测试集更重要,而不仅仅是拥有大量的面孔。 因此,为确保训练集中的人脸不太相似,我们应该在每个收集到的人脸之间添加明显的延迟。 例如,如果摄像头以每秒 30 帧的速度运行,那么当人没有时间走动时,它可能会在短短几秒钟内收集 100 张脸,因此最好每秒拍摄一幅脸,同时人们移动他们的脸。 改善训练集变化的另一种简单方法是仅在面部与先前收集的面部明显不同的情况下收集面部。
收集经过预处理的人脸来训练
为了确保收集新面孔之间至少有一秒钟的间隔,我们需要测量已花费了多少时间。 这样做如下:
// 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);
从收集到的人脸中训练人脸识别系统
收集到足以让每个人识别的面孔后,您必须使用适合面孔识别的机器学习算法训练系统以学习数据。 文献中有许多不同的人脸识别算法,其中最简单的是 EigenFace 和人工神经网络。 EigenFace 倾向于比人工神经网络更好地工作,尽管它很简单,但它却几乎可以与许多更复杂的脸部识别算法一起工作,因此,它作为初学者和新算法的基础人脸识别算法已经非常流行。 相比。
建议任何希望进一步研究人脸识别的读者阅读其背后的理论:
- EigenFace(也称为主成分分析(PCA))
- Fisherfaces(也称为线性判别分析(LDA))
- 其他经典的人脸识别算法(这个页面中提供了许多算法)
- 最近的计算机视觉研究论文(例如这个页面上的 CVPR 和 ICCV)中更新的人脸识别算法,因为每年都会发布数百篇人脸识别论文
但是,您无需了解这些算法的原理即可使用本书中所示的这些算法。 感谢 OpenCV 团队和 Philipp Wagner 的libfacerec贡献,OpenCV v2.4.1 提供了cv::Algo rithm作为使用几种不同算法(甚至在运行时可以选择)中的一种来执行人脸识别的简单通用方法。 必须了解它们是如何实现的。 您可以使用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:EigenFace,也称为 PCA,在 1991 年由 Turk 和 Pentland 首次使用。FaceRecognizer.Fisherfaces:Fisherfaces,也称为 LDA,由 Belhumeur,Hespanha 和 Kriegman 于 1997 年发明。FaceRecognizer.LBPH:Ahonen,Hadid 和 Pietikäinen 于 2004 年发明的局部二值模式直方图。
注意
可以在 Philipp Wagner 的网站和这个页面上找到有关这些人脸识别算法实现的更多信息,并附带文档,示例和 Python 等效项。
这些人脸识别算法可通过 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对象。 我们将要使用的人脸识别算法的名称作为字符串传递给此create函数。 如果该算法在 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 和 Fisherfaces,它们的内部数据结构是相同的,因此让我们仅看一下这两个。 它们都基于一维特征向量矩阵,当以 2D 图像查看时,它们看起来有点像人脸,因此在使用 EigenFace 算法时通常将特征向量称为 EigenFace,在使用 Fisherface 算法时通常将其称为 fisherfaces。
简单来说,EigenFace 的基本原理是它将计算出一组特殊图像(EigenFace)和混合比率(特征值),当以不同方式组合时,它们可以生成训练集中的每个图像,但也可以用于区分训练集中的许多人脸图像。 例如,如果训练集中的某些脸部有胡须,而有些脸部没有胡须,那么至少会有一个 EigenFace 出现胡须,因此带有胡须的训练面将具有较高的混合比例。 表示它有胡须,而没有胡须的脸对于该特征向量的混合比率较低。 如果训练集有 5 个人,每个人有 20 张脸,那么将有 100 个 EigenFace 和特征值来区分训练集中的 100 张总脸,实际上,这些将被排序,因此前几个 EigenFace 和特征值将是最关键的区分器,最后几个特征面和特征值只是随机的像素噪声,实际上并不能帮助区分数据。 因此,通常的做法是丢弃一些最后的特征面,而只保留前 50 个左右的特征面。
相比之下,Fisherfaces 的基本原理是,与其为训练集中的每个图像计算一个特殊的特征向量和特征值,不如为每个人计算一个特殊的特征向量和特征值。 因此,在前面的示例中有 5 个人,每个人有 20 张脸,EigenFace 算法将使用 100 个 EigenFace 和特征值,而 Fisherfaces 算法将仅使用 5 个渔夫脸和特征值。
要访问 Eigenfaces 和 Fisherfaces 算法的内部数据结构,我们必须使用cv::Algorithm::get() 函数在运行时获取它们,因为在编译时无法访问它们。 数据结构在内部用作数学计算的一部分,而不是用于图像处理,因此通常将它们存储为浮点数,通常在 0.0 到 1.0 之间,而不是在 0 到 255 之间的 8 位uchar像素,类似于常规图像中的像素。 同样,它们通常是一维行或列矩阵,或者它们构成较大矩阵的许多一维行或列之一。 因此,在显示许多内部数据结构之前,必须将它们重新整形为正确的矩形形状,并将它们转换为 0 到 255 之间的 8 位uchar像素。因为矩阵数据的范围可能是 0.0 到 1.0 或 -1.0 到 1.0 或其他值,您可以将cv::normalize()函数与cv::NORM_MINMAX选项一起使用,以确保无论输入范围是多少,它都输出 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图像或640 x 480矩阵,具体取决于您查看的方式),每个像素具有三个通道,每个通道为 8 位(即 ,是常规的 BGR 图像),并显示每个颜色通道在图像中的最小值和最大值。
注意
通过使用printMat()函数代替printMatInfo()函数,也可以打印图像或矩阵的实际内容。 这对于查看矩阵和多通道浮点矩阵非常方便,因为对于初学者而言,查看起来非常棘手。
ImageUtils 代码主要用于 OpenCV 的 C 接口,但是随着时间的推移,它逐渐包含了更多的 C++ 接口。 可以在这个页面中找到最新版本。
平均人脸
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 的整个范围。
特征值,EigenFace 和 Fisherfaces
让我们查看特征值中的实际组件值(作为文本):
Mat eigenvalues = model->get<Mat>("eigenvalues");
printMat(eigenvalues, "eigenvalues");
对于 EigenFace,每个脸都有一个特征值,因此如果我们有三个人,每个人有四个脸,我们将获得一个列向量,该列向量具有 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
要查看特征向量(作为 EigenFace 或 Fisherface 图像),我们必须从大特征向量矩阵中将它们提取为列。 由于 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 个本征脸(图的左侧)或两个 Fisherface(脸部)。

请注意,EigenFace 和 Fisherfaces 似乎都具有某些人脸特征的相似之处,但它们看起来并不像面孔。 这仅仅是因为从它们中减去了平均脸,所以它们只是显示出每个 EigenFace 与平均脸的差异。 编号会显示它是哪个 EigenFace,因为它们总是从最重要的 EigenFace 到最不重要的 EigenFace 排列,并且如果您有 50 个或更多的 EigenFace,则后面的 EigenFace 通常只会显示随机图像噪声,因此应将其丢弃。
步骤 4:人脸识别
现在,我们已经使用我们的一组训练图像和面部标签训练了 EigenFace 或 Fisherfaces 机器学习算法,我们终于准备好从面部图像中找出一个人是谁! 最后一步称为人脸识别或人脸识别。
人脸识别:从人脸识别人
多亏了 OpenCV 的FaceRecognizer类,我们可以通过在面部图像上调用FaceRecognizer::predict()函数来简单地识别照片中的人,如下所示:
int identity = model->predict(preprocessedFace);
该identity值将是我们在收集用于训练的脸部时最初使用的标签号。 例如,第一个人为 0,第二个人为 1,依此类推。
这种识别的问题在于,即使输入的照片是不知名的人或汽车,它也总是可以预测给定的人之一。 它仍然会告诉您该照片中哪个人是最有可能的人,因此很难相信结果! 解决方案是获取置信度度量,以便我们可以判断结果的可靠性,如果置信度似乎过低,则可以假定它是未知身份的人。
脸部验证:确认它是声称的人
为了确认预测结果是否可靠,或者是否应将其视为陌生人,我们进行了人脸验证(也称为人脸验证),以获取置信度指标,它显示单个人脸图像是否与声称的人相似(相对于我们刚刚进行的人脸识别,即将单张人脸图像与许多人进行比较)。
当您调用predict()函数时,OpenCV 的FaceRecognizer类可以返回置信度量度,但是不幸的是,该置信度度仅基于本征子空间中的距离,因此不是很可靠。 我们将使用的方法是使用特征向量和特征值重建面部图像,并将此重建图像与输入图像进行比较。 如果该人的训练集中包含许多面部,则重构应从学习的特征向量和特征值中很好地进行,但是如果该人在训练集中没有任何面部(或没有相似的面部) 照明和面部表情作为测试图像),则重建后的面部看起来与输入面部非常不同,表明它可能是未知的面部。
记得我们之前说过,EigenFace 和 Fisher 脸算法是基于这样的概念,即图像可以粗略地表示为一组特征向量(特殊面部图像)和特征值(混合比)。 因此,如果我们将所有特征向量与训练集中其中一张脸的特征值结合在一起,则应该获得该原始训练图像的相当接近的副本。 对于与训练集相似的其他图像也是如此—如果将训练后的特征向量与相似测试图像中的特征值结合起来,我们应该能够重建出与测试图像有些相似的图像。
通过使用subspaceProject()函数投影到本征空间,并使用subspaceReconstruct()函数从特征空间投影到图像空间。 诀窍是我们需要将其从浮点行矩阵转换为矩形的 8 位图像(就像我们在显示平均人脸和 EigenFace 时一样),但是我们不想对数据进行归一化,因为它已经处于理想的比例,可以与原始图像进行比较。 如果我们对数据进行归一化,它将具有与输入图像不同的亮度和对比度,并且仅通过使用 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 的值表示这两个图像非常相似。 对于 EigenFace,每个脸部都有一个特征向量,因此重建往往会很好地工作,因此我们通常可以将阈值设为 0.5,但是 Fisherfaces 对于每个人只有一个特征向量,因此重建将无法正常工作,因此需要较高的阈值,例如 0.7。 这样做如下:
similarity = getSimilarity(preprocessedFace,
reconstructedFace);
if (similarity > UNKNOWN_PERSON_THRESHOLD) {
identity = -1; // Unknown person.
}
现在,您可以将身份打印到控制台,或者将其用于您想像不到的任何地方! 请记住,这种脸部识别方法和脸部验证方法仅在您为其训练的某些条件下才是可靠的。 因此,要获得良好的识别准确率,您需要确保每个人的训练集都涵盖了要测试的所有照明条件,面部表情和角度。 面部预处理阶段有助于减少与光照条件和平面内旋转(如果人将头向左或右肩膀倾斜)之间的某些差异,而对于其他差异(例如面外旋转(如果人旋转头)) 朝向左侧或右侧),则只有在训练集中正确覆盖它的情况下它才起作用。
修饰:保存和加载文件
您可能会添加基于命令行的方法,该方法可处理输入文件并将其保存到磁盘,甚至可以执行脸部检测,脸部预处理和/或脸部识别作为 Web 服务,等等。 对于这些类型的项目,使用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。 我们还添加一个删除全部模式以重新启动一个新的人脸识别系统,以及一个Debug按钮,以切换其他调试信息的显示。 我们可以创建一个枚举的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 就会调用该窗口。 如果相机支持,则可能需要将相机分辨率设置为合理的值,例如640 x 480。 这样做如下:
// 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:预处理过的脸部(如果检测到脸部和眼睛)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);
}
}
我们将在窗口的顶部中心覆盖当前的预处理面部,如下所示:
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。 预处理的脸部显示在顶部中心,并且检测到的脸部和眼睛被标记为:

收集模式
当用户单击添加人员按钮以表示他们要开始收集新人的面孔时,我们进入收集模式。 如前所述,我们将人脸收集限制为每秒一张人脸,然后才将其与以前收集的人脸相比发生了显着变化。 记住,我们决定不仅要收集经过预处理的脸部,还要收集经过预处理的脸部的镜像。
在“收集”模式下,我们要显示每个已知人物的最新面孔,并让用户单击其中一个人以向他们添加更多面孔,或单击添加人员按钮向集合中添加新人员。 用户必须单击窗口中间的某个位置才能继续到下一个(训练)模式。
因此,首先我们需要参考为每个人收集的最新面孔。 我们将通过更新大整数preprocessedFaces数组(即所有人的所有面孔的集合)中的m_latestFaces整数数组来完成此操作,该整数数组仅存储每个人的数组索引。 由于我们还将镜像的面存储在该数组中,因此我们要引用倒数第二个面,而不是最后一个面。 该代码应附加到代码上,该代码为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 程序,以允许立即使用人脸识别系统。 您应该能够根据自己的目的修改系统的行为,例如允许自动登录计算机,或者如果您对提高识别可靠性感兴趣,那么您可以阅读有关人脸识别最新进展的会议论文以,来改进程序的每个步骤,直到它足以满足您的特定需求为止。 例如,您可以根据这个页面和这个页面中的方法,改善面部预处理阶段,或使用更高级的机器学习算法,甚至使用更好的面部验证算法。
参考
Rapid Object Detection using a Boosted Cascade of Simple Features, P. Viola and M.J. Jones, Proceedings of the IEEE Transactions on CVPR 2001, Vol. 1, pp. 511-518
An Extended Set of Haar-like Features for Rapid Object Detection, R. Lienhart and J. Maydt, Proceedings of the IEEE Transactions on ICIP 2002, Vol. 1, pp. 900-903
Face Description with Local Binary Patterns: Application to Face Recognition, T. Ahonen, A. Hadid and M. Pietikäinen, Proceedings of the IEEE Transactions on PAMI 2006, Vol. 28, Issue 12, pp. 2037-2041
Learning OpenCV: Computer Vision with the OpenCV Library, G. Bradski and A. Kaehler, pp. 186-190, O'Reilly Media.
Eigenfaces for recognition, M. Turk and A. Pentland, Journal of Cognitive Neuroscience 3, pp. 71-86
Eigenfaces vs. Fisherfaces: Recognition using class specific linear projection, P.N. Belhumeur, J. Hespanha and D. Kriegman, Proceedings of the IEEE Transactions on PAMI 1997, Vol. 19, Issue 7, pp. 711–720
Face Recognition with Local Binary Patterns, T. Ahonen, A. Hadid and M. Pietikäinen, Computer Vision - ECCV 2004, pp. 469–48




浙公网安备 33010602011771号