原文:
annas-archive.org/md5/b2288be5348a598b7d3a1975121fa894
译者:飞龙
第三章. 使用机器学习识别面部表情
自九十年代初以来,自动面部表情识别引起了广泛关注,尤其是在人机交互领域。随着计算机开始成为我们生活的一部分,它们需要变得越来越智能。表情识别系统将增强人类与计算机之间的智能交互。
虽然人类可以轻易地识别面部表情,但一个可靠的表情识别系统仍然是一个挑战。在本章中,我们将介绍使用 OpenCV 库中的各种算法的基本面部表情实现,包括使用 ml 模块进行特征提取和分类。
在本章中,我们将简要介绍以下主题:
-
一个简单的识别人类面部表情的架构
-
OpenCV 库中的特征提取算法
-
学习和测试阶段,使用各种机器学习算法
介绍面部表情识别
自动面部表情识别是一个有趣且具有挑战性的问题,并在许多领域如人机交互、人类行为理解和数据驱动动画中具有几个重要应用。与面部识别不同,面部表情识别需要区分不同个体中相同的表情。当一个人可能以不同的方式表现出相同的表情时,问题变得更加困难。
当前用于测量面部表情的方法可以分为两种类型:静态图像和图像序列。在静态图像方法中,系统分别分析每个图像帧中的面部表情。在图像序列方法中,系统试图捕捉图像帧序列中面部上看到的运动和变化的时序模式。最近,注意力已经转向图像序列方法。然而,这种方法比静态方法更困难,需要更多的计算。在本章中,我们将遵循静态图像方法,并使用 OpenCV 3 库比较几种算法。
自动面部表情识别的问题包括三个子问题:
-
在图像中找到面部区域:面部在图像中的精确位置对于面部分析非常重要。在这个问题中,我们希望在图像中找到面部区域。这个问题可以被视为一个检测问题。在我们的实现中,我们将使用 OpenCV 的 objdetect 模块中的级联分类器来检测面部。然而,级联分类器容易产生对齐错误。因此,我们应用 flandmark 库从面部区域中提取面部特征点,并使用这些特征点来提取精确的面部区域。
注意
Flandmark 是一个开源的 C 库,实现了人脸关键点检测器。你可以在以下章节中了解更多关于 flandmark 的信息。基本上,你可以使用你想要的任何库来提取关键点。在我们的实现中,我们将使用这个库来降低复杂性,同时将库集成到我们的项目中。
-
从人脸区域提取特征:给定人脸区域,系统将提取面部表情信息作为一个特征向量。特征向量编码了从输入数据中提取的相关信息。在我们的实现中,特征向量是通过结合特征 2d 模块中的特征检测器和核心模块中的 kmeans 算法获得的。
-
将特征分类到情感类别:这是一个分类问题。系统使用分类算法将之前步骤中提取的特征映射到情感类别(如快乐、中性或悲伤)。这是本章的主要内容。我们将评估 ml 模块中的机器学习算法,包括神经网络、支持向量机和 K-Nearest-Neighbor。
在以下章节中,我们将向你展示实现面部表情系统的完整过程。在下一节中,你将找到几种提高系统性能的方法来满足你的需求。
面部表情数据集
为了简化章节,我们将使用数据集来演示过程,而不是使用实时摄像头。我们将使用标准数据集,日本女性面部表情(JAFFE)。数据集中有 10 个人的 214 张图片。每个人有每种表情的三张图片。数据集包括以下图所示的七个表情(快乐、悲伤、愤怒、厌恶、恐惧、惊讶和中性):
注意
你需要从以下链接下载数据集:www.kasrl.org/jaffe.html
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv3-bp/img/00032.jpeg
JAFFE 数据集的样本图像。
在图像中找到人脸区域
在本节中,我们将向你展示在图像中检测人脸的基本方法。我们将使用 OpenCV 中的级联分类器来检测人脸位置。这种方法可能存在对齐错误。为了获得精确的位置,我们还将提供另一种使用面部关键点来查找人脸区域的高级方法。在我们的实现中,我们只使用人脸区域。然而,许多研究人员使用面部关键点来提取面部组件,如眼睛和嘴巴,并对这些组件分别进行操作。
注意
如果你想了解更多,你应该检查本章中的面部关键点部分。
使用人脸检测算法提取人脸区域
在我们的实现中,我们将在 objdetect 模块中使用基于 Haar 特征的级联分类器。在 OpenCV 中,你也可以使用基于 LBP 的级联提取人脸区域。基于 LBP 的级联比基于 Haar 的级联更快。使用预训练模型,基于 LBP 的级联性能低于基于 Haar 的级联。然而,训练一个基于 LBP 的级联以获得与基于 Haar 的级联相同的性能是可能的。
注意
如果你想要详细了解目标检测,你应该查看第五章,工业应用中的通用目标检测。
检测人脸的代码非常简单。首先,你需要将预训练的级联分类器加载到你的 OpenCV 安装文件夹中:
CascadeClassifier face_cascade;
face_cascade.load("haarcascade_frontalface_default.xml");
然后,以彩色模式加载输入图像,将图像转换为灰度,并应用直方图均衡化以增强对比度:
Mat img, img_gray;
img = imread(imgPath[i], CV_LOAD_IMAGE_COLOR);
cvtColor(img, img_gray, CV_RGB2GRAY);
equalizeHist(img_gray, img_gray);
现在,我们可以在图像中找到人脸。detectMultiScale
函数将所有检测到的人脸存储在向量中,作为 Rect(x, y, w, h):
vector<Rect> faces;
face_cascade.detectMultiScale( img_gray, faces, 1.1, 3 );
在此代码中,第三个参数 1.1 是缩放因子,它指定了在每次缩放时图像大小将如何调整。以下图显示了使用缩放因子的缩放金字塔。在我们的案例中,缩放因子是1.1
。这意味着图像大小减少了 10%。这个因子越低,我们找到人脸的机会就越大。缩放过程从原始图像开始,直到图像分辨率在 X 或 Y 方向达到模型维度为止。然而,如果缩放级别太多,计算成本会很高。因此,如果你想减少缩放级别,可以将缩放因子增加到1.2
(20%)、1.3
(30%)或更高。如果你想增加缩放级别,可以将缩放因子减少到1.05
(5%)或更高。第四个参数3
是每个候选位置应具有的最小邻居数,才能成为人脸位置。
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv3-bp/img/00033.jpeg
图像尺度金字塔
如果我们将邻居数设置为零,以下图显示了人脸检测的结果:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv3-bp/img/00034.jpeg
所有的人脸区域候选者
最后,人脸区域的位置可以按以下方式获得:
int bbox[4] = { faces[i].x, faces[i].y, faces[i].x + faces[i].width, faces[i].y + faces[i].height };
faces 向量中的每个元素都是一个Rect
对象。因此,我们可以通过faces[i].x
和faces[i].y
获取顶点的位置。右下角的位置是faces[i].x + faces[i].width
和faces[i].y + faces[i].height
。这些信息将被用作面部特征点处理过程的初始位置,如以下章节所述。
从人脸区域提取面部特征点
面部检测器的一个缺点是结果可能存在错位。错位可能发生在缩放或平移过程中。因此,所有图像中提取的面部区域不会彼此对齐。这种错位可能导致识别性能不佳,尤其是在使用 DENSE 特征时。借助面部特征点,我们可以对所有的提取面部进行对齐,使得每个面部组件在数据集中位于相同的位置。
许多研究人员利用面部特征点与其他情绪识别方法进行分类。
我们将使用 flandmark 库来找到眼睛、鼻子和嘴巴的位置。然后,我们将使用这些面部特征点来提取精确的面部边界框。
介绍 flandmark 库
Flandmark 是一个开源的 C 语言库,实现了面部特征点检测器。
注意
您可以在以下网址访问 flandmark 库的主页:cmp.felk.cvut.cz/~uricamic/flandmark/
。
给定一张人脸图像,flandmark 库的目标是估计一个代表面部组件位置的 S 形。S 形中的面部形状是一个表示为(x, y)位置的数组:S = [x[0]y[0]x[1]y[1]…x[n]y[n]]。
flandmark 中的预训练模型包含八个点,如图所示:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv3-bp/img/00035.jpeg
8 个特征点模型以及每个特征点的对应索引。
在我们的实现中,我们使用 flandmark,因为它很容易集成到 OpenCV 项目中。此外,flandmark 库在许多场景中都非常稳健,即使当人戴着眼镜时也是如此。在以下图中,我们展示了在一个人戴着深色眼镜的图像上使用 flandmark 库的结果。红色点表示面部特征点。
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv3-bp/img/00036.jpeg
在下一节中,我们将向您展示如何在我们的项目中下载和使用 flandmark。
下载和编译 flandmark 库
Flandmark 是用 C 语言实现的,可以轻松集成到我们的项目中。然而,我们需要修改库源代码中的某些头文件,以便与 OpenCV 3 兼容。以下是从下载和编译库的步骤:
-
前往 flandmark 库的主页并遵循 GitHub 链接:
github.com/uricamic/flandmark
-
使用以下命令将库克隆到您的本地机器上:
git clone http://github.com/uricamic/flandmark
-
将
libflandmark
文件夹复制到您的项目文件夹中。 -
将数据文件夹中的
flandmark_model.dat
复制到您的项目文件夹中。 -
编辑
libflandmark
中的liblbp.h
文件并更改:#include "msvc-compat.h"
将
#include <stdint.h>
-
编辑
libflandmark
中的flandmark_detector.h
文件并更改:#include "msvc-compat.h" #include <cv.h> #include <cvaux.h>
将
#include <stdint.h> #include "opencv2/opencv.hpp" #include "opencv2/objdetect/objdetect.hpp" #include "opencv2/highgui/highgui.hpp" #include "opencv2/imgproc/imgproc.hpp" #include <iostream> #include <stdio.h> using namespace std; using namespace cv;
-
编辑您项目文件夹中的
CMakeLists.txt
以添加 flandmark 库:add_subdirectory(libflandmark) include_directories("${PROJECT_SOURCE_DIR}/libflandmark")
-
将可执行文件链接到 flandmark 静态库。
-
将 flandmark 头文件添加到您的源代码中:
#include "flandmark_detector.h"
使用 flandmark 检测面部特征点
完成上述步骤后,提取面部组件的过程非常简单。
首先,我们创建一个FLANDMARK_Model
变量来加载预训练模型:
FLANDMARK_Model * model = flandmark_init("flandmark_model.dat");
然后,我们将地标数量保存到num_of_landmark
变量中,并创建一个数组来存储输出结果:
int num_of_landmark = model->data.options.M;
double *points = new double[2 * num_of_landmark];
最后,对于每个面部区域,我们创建一个整数数组来存储面部位置,并使用flandmark_detect
函数在points
数组中获得最终结果:
int bbox[4] = { faces[i].x, faces[i].y, faces[i].x + faces[i].width, faces[i].y + faces[i].height };
flandmark_detect(new IplImage(img_gray), bbox, model, points);
flandmark_detect
函数的第一个参数是IplImage
,因此我们需要将我们的灰度图像传递给IplImage
构造函数。
在图像中可视化地标
此步骤是可选的。你不需要在这个部分实现代码。然而,我们建议你尝试并理解结果。以下代码在图像上绘制了地标的位置:
for(int j = 0 ; j < num_of_landmark; j++){
Point landmark = Point((int)points[2 * j], (int)points[2* j + 1]);
circle(img, landmark, 4, Scalar(255, 255, 255), -1);
}
以下图显示了使用上述代码的多个结果示例:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv3-bp/img/00037.jpeg
一些在 JAFFE 图像上的 flandmark 结果示例
提取面部区域
现在我们有了眼睛、鼻子和嘴的位置。提取面部区域非常容易。
首先,我们计算左眼的中心为点 2 和点 6 的中点:
Point centerLeft = Point( (int) (points[2 * 6] + points[2 * 2]) / 2, (int) (points[2 * 6 + 1] + points[2 * 2 + 1]) / 2 );
第二,眼区域宽度是点 2 和点 6 的 x 坐标之差:
int widthLeft = abs(points[2 * 6] - points[2 * 2]);
然后,我们找到右眼的中点和宽度:
Point centerRight = Point( (int) (points[2 * 1] + points[2 * 5]) / 2, (int) (points[2 * 1 + 1] + points[2 * 5 + 1]) / 2 );
int widthRight = abs(points[2 * 1] - points[2 * 5]);
我们可以假设面部宽度略大于眼睛之间的距离,面部高度大于面部宽度,因此我们可以得到眉毛。我们可以使用以下代码获得良好的面部位置:
int widthFace = (centerLeft.x + widthLeft) - (centerRight.x - widthRight);
int heightFace = widthFace * 1.2;
最后,可以使用以下代码提取面部区域:
Mat face = img(Rect( centerRight.x - widthFace/4 , centerRight.y - heightFace/4, widthFace, heightFace ));
以下图显示了从我们的实现中提取的一些图像:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv3-bp/img/00038.jpeg
从 JAFFE 图像中提取的一些面部区域示例
软件使用指南
我们已经实现了从 JAFFE 数据集中提取面部组件的软件。你可以按照以下方式使用代码:
-
下载源代码。打开终端,切换到源代码文件夹。
-
使用以下命令使用
cmake
构建软件:mkdir build && cd build && cmake .. && make
-
你可以使用 facial_components 工具,如下所示:
./facial_components -src <input_folder> -dest <out_folder>
注意
基于 OpenCV 3 的本章软件可以在以下位置找到:github.com/OpenCVBlueprints/OpenCVBlueprints/
为了简化过程,我们将图像路径保存在一个.yaml
文件中,list.yml
。此.yaml
文件的结构很简单。首先,我们将图像数量保存到num_of_image
变量中。之后,我们保存所有图像的路径,如下面的截图所示:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv3-bp/img/00039.jpeg
list.yml 文件的图像
特征提取
给定一个包含面部区域的数据集,我们可以使用特征提取来获取特征向量,它提供了表情中最重要信息。以下图显示了我们在实现中用于提取特征向量的过程:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv3-bp/img/00040.jpeg
特征提取过程
为了理解本章,您需要了解表情图像的特征表示是图像特征在 k 个簇(在我们的实现中 k = 1000)上的分布。我们已经实现了一些在 OpenCV 中受支持的常见特征类型,例如 SIFT、SURF,以及一些高级特征,如 DENSE-SIFT、KAZE、DAISY。由于这些图像特征是在图像的关键点(如角点)上计算的,除了 DENSE 情况外,图像特征的数量可能在图像之间有所不同。然而,我们希望每个图像都有一个固定的特征大小来进行分类,因为我们将在以后应用机器学习分类技术。重要的是,图像的特征大小必须相同,这样我们才能比较它们以获得最终结果。因此,我们应用聚类技术(在我们的情况下是 kmeans)将图像特征空间分离成 k 个簇。每个图像的最终特征表示是图像特征在 k 个桶上的直方图。此外,为了减少最终特征的维度,我们在最后一步应用主成分分析。
在接下来的章节中,我们将逐步解释这个过程。在本节的末尾,我们将向您展示如何使用我们的实现来获取数据集的最终特征表示。
从面部组件区域提取图像特征
在这一点上,我们将假设您已经拥有了数据集中每个图像的面部区域。下一步是从这些面部区域中提取图像特征。OpenCV 提供了许多知名的关键点检测和特征描述算法的良好实现。
注意
每个算法的详细解释超出了本章的范围。
在本节中,我们将向您展示如何在我们的实现中使用这些算法中的一些。
我们将使用一个函数,该函数接受当前区域、特征类型,并返回一个矩阵,其中包含作为行的图像特征:
Mat extractFeature(Mat face, string feature_name);
在这个extractFeature
函数中,我们将从每个 Mat 中提取图像特征并返回描述符。extractFeature
的实现很简单,如下所示:
Mat extractFeature(Mat img, string feature_name){
Mat descriptors;
if(feature_name.compare("brisk") == 0){
descriptors = extractBrisk(img);
} else if(feature_name.compare("kaze") == 0){
descriptors = extractKaze(img);
} else if(feature_name.compare("sift") == 0){
descriptors = extractSift(img);
} else if(feature_name.compare("dense-sift") == 0){
descriptors = extractDenseSift(img);
} else if(feature_name.compare("daisy") == 0){
descriptors = extractDaisy(img);
}
return descriptors;
}
在上面的代码中,我们为每个特征调用相应的函数。为了简单起见,我们每次只使用一个特征。在本章中,我们将讨论两种类型的特征:
-
贡献特征:SIFT、DAISY 和 DENSE SIFT。在 OpenCV 3 中,SIFT 和 SURF 的实现已被移动到 opencv_contrib 模块。
注意
这些特征是受专利保护的,如果您想在商业应用中使用它们,则必须付费。
在本章中,我们将使用 SIFT 特征及其变体,DENSE SIFT。
注意
如果你想要使用 opencv_contrib 模块,我们建议你查看进一步阅读部分,并查看编译 opencv_contrib 模块部分。
-
高级功能:BRISK 和 KAZE。这些特征在性能和计算时间上都是 SIFT 和 SURF 的良好替代品。DAISY 和 KAZE 仅在 OpenCV 3 中可用。DAISY 在 opencv_contrib 中,KAZE 在主要的 OpenCV 仓库中。
贡献功能
让我们先看看 SIFT 特征。
为了在 OpenCV 3 中使用 SIFT 特征,你需要将 opencv_contrib 模块与 OpenCV 一起编译。
注意
我们将假设你已经遵循了进一步阅读部分中的说明。
提取 SIFT 特征的代码非常简单:
Mat extractSift(Mat img){
Mat descriptors;
vector<KeyPoint> keypoints;
Ptr<Feature2D> sift = xfeatures2d::SIFT::create();
sift->detect(img, keypoints, Mat());
sift->compute(img, keypoints, descriptors);
return descriptors;
}
首先,我们使用xfeatures2d::SIFT::create()
创建Feature2D
变量,并使用detect
函数来获取关键点。检测函数的第一个参数是我们想要处理的图像。第二个参数是一个存储检测到的关键点的向量。第三个参数是一个掩码,指定了查找关键点的位置。我们希望在图像的每个位置都找到关键点,所以我们在这里传递一个空的 Mat。
最后,我们使用compute
函数在这些关键点上提取特征描述符。计算出的描述符存储在descriptors
变量中。
接下来,让我们看看 SURF 特征。
获取 SURF 特征的代码与 SIFT 特征的代码大致相同。我们只是将命名空间从 SIFT 更改为 SURF:
Mat extractSurf(Mat img){
Mat descriptors;
vector<KeyPoint> keypoints;
Ptr<Feature2D> surf = xfeatures2d::SURF::create();
surf->detect(img, keypoints, Mat());
surf->compute(img, keypoints, descriptors);
return descriptors;
}
现在让我们转向 DAISY。
DAISY 是旋转不变 BRISK 描述符和 LATCH 二进制描述符的改进版本,与较重且较慢的 SURF 相当。DAISY 仅在 OpenCV 3 的 opencv_contrib 模块中可用。实现 DAISY 特征的代码与 Sift 函数相当相似。然而,DAISY 类没有detect
函数,因此我们将使用 SURF 来检测关键点,并使用 DAISY 来提取描述符:
Mat extractDaisy(Mat img){
Mat descriptors;
vector<KeyPoint> keypoints;
Ptr<FeatureDetector> surf = xfeatures2d::SURF::create();
surf->detect(img, keypoints, Mat());
Ptr<DescriptorExtractor> daisy = xfeatures2d::DAISY::create();
daisy->compute(img, keypoints, descriptors);
return descriptors;
}
现在是时候看看密集 SIFT 特征了。
密集特征在每个图像的位置和尺度上收集特征。有很多应用都使用了密集特征。然而,在 OpenCV 3 中,提取密集特征的接口已被移除。在本节中,我们展示了使用 OpenCV 2.4 源代码中的函数提取关键点向量的简单方法。
提取密集 Sift 函数的函数与 Sift 函数类似:
Mat extractDenseSift(Mat img){
Mat descriptors;
vector<KeyPoint> keypoints;
Ptr<Feature2D> sift = xfeatures2d::SIFT::create();
createDenseKeyPoints(keypoints, img);
sift->compute(img, keypoints, descriptors);
return descriptors;
}
我们可以不用detect
函数,而是使用createDenseKeyPoints
函数来获取关键点。之后,我们将这个密集关键点向量传递给计算函数。createDenseKeyPoints
的代码是从 OpenCV 2.4 源代码中获得的。你可以在 OpenCV 2.4 仓库中的modules/features2d/src/detectors.cpp
找到这段代码:
void createDenseFeature(vector<KeyPoint> &keypoints, Mat image, float initFeatureScale=1.f, int featureScaleLevels=1,
float featureScaleMul=0.1f,
int initXyStep=6, int initImgBound=0,
bool varyXyStepWithScale=true,
bool varyImgBoundWithScale=false){
float curScale = static_cast<float>(initFeatureScale);
int curStep = initXyStep;
int curBound = initImgBound;
for( int curLevel = 0; curLevel < featureScaleLevels; curLevel++ )
{
for( int x = curBound; x < image.cols - curBound; x += curStep )
{
for( int y = curBound; y < image.rows - curBound; y += curStep )
{
keypoints.push_back( KeyPoint(static_cast<float>(x), static_cast<float>(y), curScale) );
}
}
curScale = static_cast<float>(curScale * featureScaleMul);
if( varyXyStepWithScale ) curStep = static_cast<int>( curStep * featureScaleMul + 0.5f );
if( varyImgBoundWithScale ) curBound = static_cast<int>( curBound * featureScaleMul + 0.5f );
}
}
高级功能
OpenCV 3 捆绑了许多新的和高级特性。在我们的实现中,我们只会使用 BRISK 和 KAZE 特征。然而,OpenCV 中还有许多其他特性。
让我们熟悉一下 BRISK 的特点。
BRISK 是一个新特性,是 SURF 的一个很好的替代品。自 2.4.2 版本以来,它已被添加到 OpenCV 中。BRISK 采用 BSD 许可,因此你不必担心专利问题,就像 SIFT 或 SURF 一样。
Mat extractBrisk(Mat img){
Mat descriptors;
vector<KeyPoint> keypoints;
Ptr<DescriptorExtractor> brisk = BRISK::create();
brisk->detect(img, keypoints, Mat());
brisk->compute(img, keypoints, descriptors);
return descriptors;
}
注意
关于这些内容有一篇有趣的文章,三个描述符的较量:SURF、FREAK 和 BRISK,可在computer-vision-talks.com/articles/2012-08-18-a-battle-of-three-descriptors-surf-freak-and-brisk/
找到。
让我们继续前进,看看 KAZE 特征。
KAZE 是 OpenCV 3 中的一个新特性。它在许多场景下产生最佳结果,尤其是在图像匹配问题上,并且与 SIFT 相当。KAZE 位于 OpenCV 仓库中,因此你不需要 opencv_contrib 就可以使用它。除了高性能之外,使用 KAZE 的另一个原因是它是开源的,你可以在任何商业应用中自由使用它。使用此特性的代码非常简单:
Mat extractKaze(Mat img){
Mat descriptors;
vector<KeyPoint> keypoints;
Ptr<DescriptorExtractor> kaze = KAZE::create();
kaze->detect(img, keypoints, Mat());
kaze->compute(img, keypoints, descriptors);
return descriptors;
}
注意
KAZE、SIFT 和 SURF 之间的图像匹配比较可在作者仓库中找到:github.com/pablofdezalc/kaze
为每种特征类型可视化关键点
在以下图中,我们可视化每种特征类型的关键点位置。我们在每个关键点处画一个圆圈;圆圈的半径指定了提取关键点的图像的缩放比例。你可以看到这些特征中的关键点和相应的描述符是不同的。因此,系统的性能将根据特征的质量而变化。
注意
我们建议您参考评估部分以获取更多详细信息。
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv3-bp/img/00041.jpeg
特征提取过程
计算特征表示在 k 个簇上的分布
如果你已经遵循了之前的伪代码,你现在应该有一个描述符向量。你可以看到描述符的大小在不同图像之间是不同的。由于我们希望每个图像的特征表示具有固定的大小,我们将计算特征表示在 k 个簇上的分布。在我们的实现中,我们将在核心模块中使用 kmeans 聚类算法。
将图像特征空间聚类成 k 个簇
首先,我们假设所有图像的描述符都被添加到一个向量中,称为features_vector
。然后,我们需要创建一个Mat rawFeatureData
,它将包含所有图像特征作为行。在这种情况下,num_of_feature
是每张图像中的特征总数,image_feature_size
是每个图像特征的大小。我们根据实验选择簇的数量。我们开始于 100,并在几次迭代中增加数量。这取决于特征和数据类型,因此您应该尝试更改此变量以适应您的具体情况。大量簇的一个缺点是,使用 kmeans 的计算成本会很高。此外,如果簇的数量太大,特性向量将过于稀疏,这可能不利于分类。
Mat rawFeatureData = Mat::zeros(num_of_feature, image_feature_size, CV_32FC1);
我们需要将描述符向量(代码中的features_vector
)中的数据复制到imageFeatureData
:
int cur_idx = 0;
for(int i = 0 ; i < features_vector.size(); i++){
features_vector[i].copyTo(rawFeatureData.rowRange(cur_idx, cur_idx + features_vector[i].rows));
cur_idx += features_vector[i].rows;
}
最后,我们使用kmeans
函数对数据进行聚类,如下所示:
Mat labels, centers;
kmeans(rawFeatureData, k, labels, TermCriteria( TermCriteria::EPS+TermCriteria::COUNT, 100, 1.0), 3, KMEANS_PP_CENTERS, centers);
让我们讨论kmeans
函数的参数:
double kmeans(InputArray data, int K, InputOutputArray bestLabels, TermCriteria criteria, int attempts, int flags, OutputArray centers=noArray())
-
InputArray data: 它包含所有样本作为行。
-
int K: 分割样本的簇数(在我们的实现中 k = 1000)。
-
InputOutputArray bestLabels: 包含每个样本簇索引的整数数组。
-
TermCriteria criteria: 算法终止准则。这包含三个参数(
type
、maxCount
、epsilon
)。 -
Type: 终止准则的类型。有三种类型:
-
COUNT: 在迭代次数达到一定数量(
maxCount
)后停止算法。 -
EPS: 如果达到指定的精度(epsilon),则停止算法。
-
EPS+COUNT: 如果满足 COUNT 和 EPS 条件,则停止算法。
-
-
maxCount: 这是最大迭代次数。
-
epsilon: 这是停止算法所需的精度。
-
int attempts: 这是算法以不同初始质心执行次数的数量。算法返回具有最佳紧致性的标签。
-
int flags: 此标志指定初始质心如何随机。通常使用
KMEANS_RANDOM_CENTERS
和KMEANS_PP_CENTERS
。如果您想提供自己的初始标签,应使用KMEANS_USE_INITIAL_LABELS
。在这种情况下,算法将在第一次尝试中使用您的初始标签。对于进一步的尝试,将应用KMEANS_*_CENTERS
标志。 -
OutputArray centers: 它包含所有簇质心,每行一个质心。
-
double compactness: 这是函数返回的值。这是每个样本到对应质心的平方距离之和。
为每个图像计算最终特征
现在我们已经为数据集中的每个图像特征有了标签。下一步是为每个图像计算一个固定大小的特征。考虑到这一点,我们遍历每个图像,创建一个包含 k 个元素的特性向量,其中 k 是簇的数量。
然后,我们遍历当前图像中的图像特征,并增加特征向量的第 i 个元素,其中 i 是图像特征的标签。
想象一下,我们正在尝试根据 k 个质心来制作特征的历史图表示。这种方法看起来像是一个词袋方法。例如,图像 X 有 100 个特征,图像 Y 有 10 个特征。我们无法比较它们,因为它们的大小不同。然而,如果我们为它们中的每一个都制作一个 1,000 维度的历史图,它们的大小就相同了,我们就可以轻松地比较它们。
维度降低
在本节中,我们将使用主成分分析(PCA)来降低特征空间的维度。在上一个步骤中,我们为每个图像有 1,000 维的特征向量。在我们的数据集中,我们只有 213 个样本。因此,进一步分类器倾向于在高维空间中过拟合训练数据。因此,我们希望使用 PCA 来获取最重要的维度,这个维度具有最大的方差。
接下来,我们将向您展示如何在我们的系统中使用 PCA。
首先,我们假设您可以将所有特征存储在一个名为featureDataOverBins
的 Mat 中。这个 Mat 的行数应等于数据集中的图像数量,列数应为 1,000。featureDataOverBins
中的每一行都是图像的一个特征。
第二,我们创建一个 PCA 变量:
PCA pca(featureDataOverBins, cv::Mat(), CV_PCA_DATA_AS_ROW, 0.90);
第一个参数是包含所有特征的数据。我们没有预先计算的平均向量,因此第二个参数应该是一个空的 Mat。第三个参数表示特征向量以矩阵行存储。最后一个参数指定 PCA 应保留的方差百分比。
最后,我们需要将所有特征从 1,000 维特征空间投影到一个较低的空间。投影后,我们可以将这些特征保存以供进一步处理。
for(int i = 0 ; i < num_of_image; i++){
Mat feature = pca.project(featureDataOverBins.row(i));
// save the feature in FileStorage
}
新特征的数量可以通过以下方式获得:
int feature_size = pca.eigenvectors.rows;
软件使用指南
我们已经实现了先前的过程来为数据集提取固定大小的特征。使用该软件相当简单:
-
下载源代码。打开终端并将目录更改为源代码文件夹。
-
使用以下命令使用
cmake
构建软件:mkdir build && cd build && cmake .. && make
-
您可以使用以下方式使用
feature_extraction
工具:./feature_extraction -feature <feature_name> -src <input_folder> -dest <output_folder>
feature_extraction
工具在输出文件夹中创建一个 YAML 文件,该文件包含数据集中每个图像的特征和标签。可用的参数有:
-
feature_name
: 这可以是 sift、surf、opponent-sift 或 opponent-surf。这是在特征提取过程中使用的特征类型的名称。 -
input_folder
: 这是指向面部组件位置的绝对路径。 -
output_folder
: 这是指向您希望保存输出文件的文件夹的绝对路径。
输出文件的结构相当简单。
我们存储了特征的大小、聚类中心、图像数量、训练和测试图像数量、标签数量以及相应的标签名称。我们还存储了 PCA 均值、特征向量和特征值。以下图显示了 YAML 文件的一部分:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv3-bp/img/00042.jpeg
features.yml 文件的一部分
对于每个图像,我们存储三个变量,如下所示:
-
image_feature_<idx>
:这是一个包含图像 idx 特征的 Mat -
image_label_<idx>
:这是图像 idx 的标签 -
image_is_train_<idx>
:这是一个布尔值,指定图像是否用于训练。
分类
一旦你从数据集的所有样本中提取了特征,就到了开始分类过程的时候了。这个分类过程的目标是学习如何根据训练示例自动进行准确的预测。对此问题有许多方法。在本节中,我们将讨论 OpenCV 中的机器学习算法,包括神经网络、支持向量机和 k-最近邻。
分类过程
分类被认为是监督学习。在分类问题中,需要一个正确标记的训练集。在训练阶段产生一个模型,该模型在预测错误时进行纠正。然后,该模型用于其他应用的预测。每次你有更多训练数据时,都需要对模型进行训练。以下图显示了分类过程概述:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv3-bp/img/00043.jpeg
分类过程概述
选择要使用的机器学习算法是一个关键步骤。对于分类问题,有很多解决方案。在本节中,我们列出了 OpenCV 中的一些流行机器学习算法。每种算法在分类问题上的性能可能会有所不同。你应该进行一些评估,并选择最适合你问题的算法以获得最佳结果。这是非常重要的,因为特征选择可能会影响学习算法的性能。因此,我们还需要评估每个学习算法与每个不同的特征选择。
将数据集分割成训练集和测试集
将数据集分成两部分,即训练集和测试集,这是非常重要的。我们将使用训练集进行学习阶段,测试集用于测试阶段。在测试阶段,我们希望测试训练好的模型如何预测未见过的样本。换句话说,我们希望测试训练模型的泛化能力。因此,测试样本与训练样本不同是很重要的。在我们的实现中,我们将简单地将数据集分成两部分。然而,如果你使用进一步阅读部分中提到的 k 折交叉验证会更好。
没有一种准确的方法可以将数据集分成两部分。常见的比例是 80:20 和 70:30。训练集和测试集都应该随机选择。如果它们有相同的数据,评估将是误导性的。基本上,即使你在测试集上达到了 99%的准确率,模型在真实世界中也无法工作,因为真实世界中的数据与训练数据不同。
在我们的特征提取实现中,我们已经随机分割了数据集,并将选择保存在 YAML 文件中。
注意
k 折交叉验证在“进一步阅读”部分的末尾有更详细的解释。
支持向量机
支持向量机(SVM)是一种适用于分类和回归的监督学习技术。给定标记的训练数据,SVM 的目标是生成一个最佳超平面,该超平面仅根据测试样本的属性预测测试样本的目标值。换句话说,SVM 基于标记的训练数据生成一个从输入到输出的函数。
例如,假设我们想要找到一条线来分离两组 2D 点。以下图显示了该问题的几个解决方案:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv3-bp/img/00044.jpeg
有很多超平面可以解决问题
SVM 的目标是找到一个超平面,该超平面最大化到训练样本的距离。这些距离仅计算最接近超平面的向量。以下图显示了分离两组 2D 点的最佳超平面:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv3-bp/img/00045.jpeg
一个最大化到训练样本距离的最佳超平面。R 是最大间隔
在接下来的几节中,我们将向您展示如何使用支持向量机(SVM)来训练和测试面部表情数据。
训练阶段
训练 SVM 中最困难的部分之一是参数选择。没有对 SVM 工作原理的深入了解,无法解释所有内容。幸运的是,OpenCV 实现了trainAuto
方法来自动估计参数。如果你对 SVM 有足够的了解,你应该尝试使用自己的参数。在本节中,我们将介绍trainAuto
方法,以向您概述 SVM。
SVM 本质上是构建二进制(2 类)分类中最佳超平面的技术。在我们的面部表情问题中,我们想要对七个表情进行分类。一对一和一对多是我们可以使用 SVM 的两种常见方法。一对一方法为每个类别训练一个 SVM。在我们的例子中有七个 SVM。对于类别 i,所有标签为 i 的样本被视为正样本,其余样本被视为负样本。当数据集样本在类别之间不平衡时,这种方法容易出错。一对多方法为每个不同类别的成对训练一个 SVM。总的 SVM 数量是 N(N-1)/2* 个 SVM。这意味着在我们的例子中有 21 个 SVM。
在 OpenCV 中,你不必遵循这些方法。OpenCV 支持训练一个多类 SVM。然而,为了获得更好的结果,你应该遵循上述方法。我们仍然将使用一个多类 SVM。训练和测试过程将更简单。
接下来,我们将演示我们的实现来解决面部表情问题。
首先,我们创建一个 SVM 的实例:
Ptr<ml::SVM> svm = ml::SVM::create();
如果你想要更改参数,你可以在 svm
变量中调用 set
函数,如下所示:
svm->setType(SVM::C_SVC);
svm->setKernel(SVM::RBF);
-
类型:它是 SVM 公式的类型。有五个可能的值:
C_SVC
、NU_SVC
、ONE_CLASS
、EPS_SVR
和NU_SVR
。然而,在我们的多类分类中,只有C_SVC
和NU_SVC
是合适的。这两个之间的区别在于数学优化问题。目前,我们可以使用C_SVC
。 -
核函数:它是 SVM 核的类型。有四个可能的值:
LINEAR
、POLY
、RBF
和SIGMOID
。核函数是一个将训练数据映射到更高维空间的功能,使得数据线性可分。这也被称为 核技巧。因此,我们可以使用核的支持在非线性情况下使用 SVM。在我们的情况下,我们选择最常用的核函数,RBF。你可以在这几个核函数之间切换并选择最佳选项。
你也可以设置其他参数,如 TermCriteria、Degree、Gamma。我们只是使用默认参数。
第二,我们创建一个 ml::TrainData
变量来存储所有训练集数据:
Ptr<ml::TrainData> trainData = ml::TrainData::create(train_features, ml::SampleTypes::ROW_SAMPLE, labels);
-
train_features
:它是一个 Mat,其中每行包含一个特征向量。train_features
的行数是训练样本的数量,列数是一个特征向量的大小。 -
SampleTypes::ROW_SAMPLE
:它指定每个特征向量位于一行。如果你的特征向量位于列中,你应该使用 COL_SAMPLE。 -
train_labels
:它是一个 Mat,其中包含每个训练特征的标签。在 SVM 中,train_labels
将是一个 Nx1 矩阵,N 是训练样本的数量。每行的值是对应样本的真实标签。在撰写本文时,train_labels
的类型应该是CV_32S
。否则,你可能会遇到错误。以下是我们创建train_labels
变量的代码:Mat train_labels = Mat::zeros( labels.rows, 1, CV_32S); for(int i = 0 ; i < labels.rows; i ++){ train_labels.at<unsigned int>(i, 0) = labels.at<int>(i, 0); }
最后,我们将 trainData
传递给 trainAuto
函数,以便 OpenCV 可以自动选择最佳参数。trainAuto
函数的接口包含许多其他参数。为了保持简单,我们将使用默认参数:
svm->trainAuto(trainData);
测试阶段
在我们训练了 SVM 之后,我们可以将一个测试样本传递给 svm
模型的预测函数,并接收一个标签预测,如下所示:
float predict = svm->predict(sample);
在这种情况下,样本是一个特征向量,就像训练特征中的特征向量一样。响应是样本的标签。
多层感知器
OpenCV 实现了最常见的人工神经网络类型,即多层感知器(MLP)。一个典型的 MLP 由一个输入层、一个输出层和一个或多个隐藏层组成。它被称为监督学习方法,因为它需要期望的输出来进行训练。有了足够的数据,MLP,如果给定足够的隐藏层,可以近似任何函数到任何期望的精度。
具有一个隐藏层的多层感知器可以表示如下图所示:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv3-bp/img/00046.jpeg
单隐藏层感知器
MLP 如何学习的一个详细解释和证明超出了本章的范围。其思想是每个神经元的输出是前一层神经元的函数。
在上述单隐藏层 MLP 中,我们使用以下符号:
输入层:x[1] x[2]
隐藏层:h[1] h[2] h[3]
输出层:y
每个神经元之间的每个连接都有一个权重。上图所示的权重是当前层中的神经元 i(即 i = 3)和前一层中的神经元 j(即 j = 2)之间的权重,表示为 w[ij]。每个神经元都有一个权重为 1 的偏置值,表示为 w[i,bias]。
神经元 i 的输出是激活函数f的结果:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv3-bp/img/00047.jpeg
激活函数有很多种类型。在 OpenCV 中,有三种类型的激活函数:恒等函数、Sigmoid 和高斯。然而,在撰写本文时,高斯函数并不完全受支持,恒等函数也不常用。我们建议您使用默认的激活函数,即 Sigmoid。
在接下来的章节中,我们将向您展示如何训练和测试一个多层感知器。
训练阶段
在训练阶段,我们首先定义网络,然后训练网络。
定义网络
在我们的面部表情问题中,我们将使用一个简单的四层神经网络。该网络有一个输入层、两个隐藏层和一个输出层。
首先,我们需要创建一个矩阵来保存层的定义。这个矩阵有四行一列:
Mat layers = Mat(3, 1, CV_32S);
然后,我们为每一层分配神经元数量,如下所示:
layers.row(0) = Scalar(feature_size);
layers.row(1) = Scalar(20);
layers.row(2) = Scalar(num_of_labels);
在这个网络中,输入层的神经元数量必须等于每个特征向量的元素数量,输出层的神经元数量是面部表情标签的数量(feature_size
等于train_features.cols
,其中train_features
是包含所有特征的 Mat,num_of_labels
在我们的实现中等于 7)。
我们实现中的上述参数并非最优。您可以尝试为不同数量的隐藏层和神经元数量尝试不同的值。请记住,隐藏神经元的数量不应超过训练样本的数量。在隐藏层中神经元数量和网络的层数选择上非常困难。如果您做一些研究,您会发现一些经验规则和诊断技术。选择这些参数的最佳方式是实验。基本上,层和隐藏神经元越多,网络的能力就越强。然而,更多的能力可能会导致过拟合。最重要的规则之一是训练集中的示例数量应大于网络中的权重数量。根据我们的经验,您应该从一个包含少量神经元的隐藏层开始,并计算泛化误差和训练误差。然后,您应该修改神经元数量并重复此过程。
注意
记得在更改参数时制作图表以可视化误差。请记住,神经元的数量通常介于输入层大小和输出层大小之间。经过几次迭代后,您可以决定是否添加额外的层。
然而,在这种情况下,我们没有太多数据。这使得网络难以训练。我们可能不会添加神经元和层来提高性能。
训练网络
首先,我们创建一个网络变量,ANN_MLP,并将层定义添加到网络中:
Ptr<ml::ANN_MLP> mlp = ml::ANN_MLP::create();
mlp->setLayerSizes(layers);
然后,我们需要为训练算法准备一些参数。MLP 的训练有两种算法:反向传播算法和 RPROP 算法。RPROP 是默认的训练算法。RPROP 有很多参数,所以我们为了简单起见将使用反向传播算法。
下面是我们为反向传播算法设置参数的代码:
mlp->setTrainMethod(ml::ANN_MLP::BACKPROP);
mlp->setActivationFunction(ml::ANN_MLP::SIGMOID_SYM, 0, 0);
mlp->setTermCriteria(TermCriteria(TermCriteria::EPS+TermCriteria::COUNT, 100000, 0.00001f));
我们将TrainMethod
设置为BACKPROP
以使用反向传播算法。选择 Sigmoid 作为激活函数。在 OpenCV 中有三种激活类型:IDENTITY
、GAUSSIAN
和SIGMOID
。您可以查看本节概述以获取更多详细信息。
最后一个参数是TermCriteria
。这是算法终止标准。您可以在前一个部分中关于 kmeans 算法的解释中看到这个参数的解释。
接下来,我们创建一个TrainData
变量来存储所有训练集。其接口与 SVM 部分相同。
Ptr<ml::TrainData> trainData = ml::TrainData::create(train_features, ml::SampleTypes::ROW_SAMPLE, train_labels);
train_features
是一个 Mat,它存储了所有训练样本,就像在 SVM 部分中做的那样。然而,train_labels
是不同的:
-
train_features
:这是一个 Mat,它包含每个特征向量作为一行,就像我们在 SVM 中做的那样。train_features
的行数是训练样本的数量,列数是一个特征向量的大小。 -
train_labels
:这是一个包含每个训练特征标签的 Mat。与 SVM 中的 Nx1 矩阵不同,MLP 中的train_labels
应该是一个 NxM 矩阵,N 是训练样本的数量,M 是标签的数量。如果第 i 行的特征被分类为标签 j,则train_labels
的位置 (i, j) 将是 1。否则,值将是零。创建train_labels
变量的代码如下:Mat train_labels = Mat::zeros( labels.rows, num_of_label, CV_32FC1); for(int i = 0 ; i < labels.rows; i ++){ int idx = labels.at<int>(i, 0); train_labels.at<float>(i, idx) = 1.0f; }
最后,我们使用以下代码训练网络:
mlp->train(trainData);
训练过程需要几分钟才能完成。如果你有大量的训练数据,可能需要几小时。
测试阶段
一旦我们训练了我们的 MLP,测试阶段就非常简单。
首先,我们创建一个 Mat 来存储网络的响应。响应是一个数组,其长度是标签的数量。
Mat response(1, num_of_labels, CV_32FC1);
然后,我们假设我们有一个名为 sample 的 Mat,它包含一个特征向量。在我们的面部表情案例中,其大小应该是 1x1000。
我们可以调用 mlp
模型的 predict
函数来获取响应,如下所示:
mlp->predict(sample, response);
输入样本的预测标签是响应数组中最大值的索引。你可以通过简单地遍历数组来找到标签。这种类型响应的缺点是,如果你想为每个响应应用一个 softmax
函数以获得概率,你必须这样做。在其他神经网络框架中,通常有一个 softmax 层来处理这种情况。然而,这种类型响应的优点是保留了每个响应的大小。
K-Nearest Neighbors (KNN)
K-Nearest Neighbors (KNN) 是一种非常简单的机器学习算法,但在许多实际问题中表现良好。KNN 的思想是将未知样本分类为 k 个最近已知样本中最常见的类别。KNN 也被称为非参数懒惰学习算法。这意味着 KNN 对数据分布没有任何假设。由于它只缓存所有训练示例,因此训练过程非常快。然而,测试过程需要大量的计算。以下图示展示了 KNN 在二维点情况下的工作原理。绿色点是一个未知样本。KNN 将在空间中找到 k 个最近的已知样本(本例中 k = 5)。有三个红色标签的样本和两个蓝色标签的样本。因此,预测的标签是红色。
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv3-bp/img/00048.jpeg
解释 KNN 如何预测未知样本的标签
训练阶段
KNN 算法的实现非常简单。我们只需要三行代码来训练一个 KNN 模型:
Ptr<ml::KNearest> knn = ml::KNearest::create();
Ptr<ml::TrainData> trainData = ml::TrainData::create(train_features, ml::SampleTypes::ROW_SAMPLE, labels);
knn->train(trainData);
上述代码与 SVM 相同:
-
train_features
:这是一个包含每个特征向量作为行的 Mat。train_features
中的行数是训练样本的数量,列数是一个特征向量的大小。 -
train_labels
: 这是一个包含每个训练特征标签的 Mat。在 KNN 中,train_labels
是一个 Nx1 的矩阵,N 是训练样本的数量。每一行的值是对应样本的真实标签。这个 Mat 的类型应该是CV_32S
。
测试阶段
测试阶段非常直接。我们只需将一个特征向量传递给knn
模型的findNearest
方法,就可以获得标签:
Mat predictedLabels;
knn->findNearest(sample, K, predictedLabels);
第二个参数是最重要的参数。它是用于分类可能使用的最大邻居数。理论上,如果有无限数量的样本可用,更大的 K 总是意味着更好的分类。然而,在我们的面部表情问题中,我们总共有 213 个样本,其中大约有 170 个样本在训练集中。因此,如果我们使用大的 K,KNN 最终可能会寻找非邻居的样本。在我们的实现中,K 等于 2。
预测标签存储在predictedLabels
变量中,可以按以下方式获取:
float prediction = bestLabels.at<float>(0,0);
正态贝叶斯分类器
正态贝叶斯分类器是 OpenCV 中最简单的分类器之一。正态贝叶斯分类器假设来自每个类别的特征向量是正态分布的,尽管不一定独立。这是一个有效的分类器,可以处理多个类别。在训练步骤中,分类器估计每个类别的分布的均值和协方差。在测试步骤中,分类器计算特征向量到每个类别的概率。在实践中,我们然后测试最大概率是否超过阈值。如果是,样本的标签将是具有最大概率的类别。否则,我们说我们无法识别该样本。
OpenCV 已经在 ml 模块中实现了这个分类器。在本节中,我们将向您展示如何在我们的面部表情问题中使用正态贝叶斯分类器的代码。
训练阶段
实现正态贝叶斯分类器的代码与 SVM 和 KNN 相同。我们只需要调用create
函数来获取分类器并开始训练过程。所有其他参数与 SVM 和 KNN 相同。
Ptr<ml::NormalBayesClassifier> bayes = ml::NormalBayesClassifier::create();
Ptr<ml::TrainData> trainData = ml::TrainData::create(train_features, ml::SampleTypes::ROW_SAMPLE, labels);
bayes->train(trainData);
测试阶段
使用正态贝叶斯分类器测试样本的代码与之前的方法略有不同:
-
首先,我们需要创建两个 Mat 来存储输出类别和概率:
Mat output, outputProb;
-
然后,我们调用模型的
predictProb
函数:bayes->predictProb(sample, output, outputProb);
-
计算出的概率存储在
outputProb
中,相应的标签可以检索如下:unsigned int label = output.at<unsigned int>(0, 0);
软件使用指南
我们已经实现了上述过程,使用训练集进行分类。使用该软件相当简单:
-
下载源代码。打开终端,切换到源代码文件夹。
-
使用以下命令使用
cmake
构建软件:mkdir build && cd build && cmake .. && make
-
您可以使用以下方式使用
train
工具:./train -algo <algorithm_name> -src <input_features> -dest <output_folder>
train
工具执行训练过程并在控制台上输出准确率。学习到的模型将被保存到输出文件夹中,以便进一步使用,文件名为 model.yml
。此外,特征提取的 kmeans 中心信息和 pca 信息也将保存在 features_extraction.yml
文件中。可用的参数包括:
-
algorithm_name
:这可以是mlp
、svm
、knn
、bayes
。这是学习算法的名称。 -
input_features
:这是prepare_dataset
工具的 YAML 特征文件的绝对路径。 -
output_folder
:这是您希望保存输出模型的文件夹的绝对路径。
评估
在本节中,我们将展示我们面部表情识别系统的性能。在我们的测试中,我们将保持每个学习算法的参数相同,仅更改特征提取。我们将使用聚类数量等于 200、500、1,000、1,500、2,000 和 3,000 来评估特征提取。
下表显示了系统在聚类数量等于 200、500、1,000、1,500、2,000 和 3,000 时的准确率。
表 1:系统在 1,000 个聚类下的准确率(%)
K = 1000 | MLP | SVM | KNN | Normal Bayes |
---|---|---|---|---|
SIFT | 72.7273 | 93.1818 | 81.8182 | 88.6364 |
SURF | 61.3636 | 79.5455 | 72.7273 | 79.5455 |
BRISK | 61.3636 | 65.9091 | 59.0909 | 68.1818 |
KAZE | 50 | 79.5455 | 61.3636 | 77.2727 |
DAISY | 59.0909 | 77.2727 | 65.9091 | 81.8182 |
DENSE-SIFT | 20.4545 | 45.4545 | 43.1818 | 40.9091 |
表 2:系统在 500 个聚类下的准确率(%)
K = 500 | MLP | SVM | KNN | Normal Bayes |
---|---|---|---|---|
SIFT | 56.8182 | 70.4545 | 75 | 77.2727 |
SURF | 54.5455 | 63.6364 | 68.1818 | 79.5455 |
BRISK | 36.3636 | 59.0909 | 52.2727 | 52.2727 |
KAZE | 47.7273 | 56.8182 | 63.6364 | 65.9091 |
DAISY | 54.5455 | 75 | 63.6364 | 75 |
DENSE-SIFT | 27.2727 | 43.1818 | 38.6364 | 43.1818 |
表 3:系统在 200 个聚类下的准确率(%)
K = 200 | MLP | SVM | KNN | Normal Bayes |
---|---|---|---|---|
SIFT | 50 | 68.1818 | 65.9091 | 75 |
SURF | 43.1818 | 54.5455 | 52.2727 | 63.6364 |
BRISK | 29.5455 | 47.7273 | 50 | 54.5455 |
KAZE | 50 | 59.0909 | 72.7273 | 59.0909 |
DAISY | 45.4545 | 68.1818 | 65.9091 | 70.4545 |
DENSE-SIFT | 29.5455 | 43.1818 | 40.9091 | 31.8182 |
表 4:系统在 1,500 个聚类下的准确率(%)
K = 1500 | MLP | SVM | KNN | Normal Bayes |
---|---|---|---|---|
SIFT | 45.4545 | 84.0909 | 75 | 79.5455 |
SURF | 72.7273 | 88.6364 | 79.5455 | 86.3636 |
BRISK | 54.5455 | 72.7273 | 56.8182 | 68.1818 |
KAZE | 45.4545 | 79.5455 | 72.7273 | 77.2727 |
DAISY | 61.3636 | 88.6364 | 65.9091 | 81.8182 |
DENSE-SIFT | 34.0909 | 47.7273 | 38.6364 | 38.6364 |
表 5:系统在 2,000 个聚类下的准确率(%)
K = 2000 | MLP | SVM | KNN | Normal Bayes |
---|---|---|---|---|
SIFT | 63.6364 | 88.6364 | 81.8182 | 88.6364 |
SURF | 65.9091 | 84.0909 | 68.1818 | 81.8182 |
BRISK | 47.7273 | 68.1818 | 47.7273 | 61.3636 |
KAZE | 47.7273 | 77.2727 | 72.7273 | 75 |
DAISY | 77.2727 | 81.8182 | 72.7273 | 84.0909 |
DENSE-SIFT | 38.6364 | 45.4545 | 36.3636 | 43.1818 |
表 6:具有 3,000 个簇的系统准确率(%)
K = 3000 | MLP | SVM | KNN | 正常贝叶斯 |
---|---|---|---|---|
SIFT | 52.2727 | 88.6364 | 77.2727 | 86.3636 |
SURF | 59.0909 | 79.5455 | 65.9091 | 77.2727 |
BRISK | 52.2727 | 65.9091 | 43.1818 | 59.0909 |
KAZE | 61.3636 | 81.8182 | 70.4545 | 84.0909 |
DAISY | 72.7273 | 79.5455 | 70.4545 | 68.1818 |
DENSE-SIFT | 27.2727 | 47.7273 | 38.6364 | 45.4545 |
使用不同学习算法的评估
我们可以用上述结果创建图表,比较以下图中特征与学习算法之间的性能。我们可以看到,在大多数情况下,SVM 和正常贝叶斯比其他算法有更好的结果。在 1,000 个簇的情况下,SVM 和 SIFT 的最佳结果是 93.1818%。MLP 在几乎所有情况下都有最低的结果。一个原因是 MLP 需要大量数据以防止过拟合。我们只有大约 160 个训练图像。然而,每个样本的特征大小在 100 到 150 之间。即使有两个隐藏神经元,权重的数量也大于样本的数量。KNN 似乎比 MLP 表现更好,但无法击败 SVM 和正常贝叶斯。
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv3-bp/img/00049.jpeg
不同簇数量下特征性能与机器算法之间的关系
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv3-bp/img/00050.jpeg
不同机器算法下特征性能与聚类中心数量之间的关系
使用不同特征的评估
在图中,不同簇数量下特征性能与机器算法之间的关系,我们评估了六个特征。在大多数情况下,SIFT 给出了最佳结果。DAISY 与 SIFT 相当。在某些情况下,KAZE 也给出了良好的结果。由于结果较差,DENSE-SIFT 不是我们面部表情问题的好选择。此外,DENSE 特征的计算成本非常高。总之,SIFT 仍然是最佳选择。然而,SIFT 受专利保护。您可能想看看 DAISY 或 KAZE。我们建议您在自己的数据上评估并选择最合适的特征。
使用不同簇数量的评估
在图中,“不同机器算法下特征性能与聚类数量的影响”,我们绘制了一个图表来可视化聚类数量对性能的影响。如您所见,不同特征之间的聚类数量不同。在 SIFT、KAZE 和 BRISK 中,最佳聚类数量是 1,000。然而,在 SURF、DAISY 和 DENSE-SIFT 中,1,500 是更好的选择。基本上,我们不希望聚类数量太大。在 kmeans 中的计算成本随着聚类数量的增加而增加,尤其是在 DENSE-SIFT 中。
系统概述
在本节中,我们将解释如何在您的应用程序中应用训练好的模型。给定一张人脸图像,我们分别检测和处理每个面部。然后,我们找到特征点并提取面部区域。从图像中提取特征并传递给 kmeans 以获得一个 1,000 维的特征向量。对该特征向量应用 PCA 以降低其维度。使用学习到的机器学习模型来预测输入面部表情。
下图显示了预测图像中面部表情的完整过程:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv3-bp/img/00051.jpeg
预测新图像中面部表情的过程
进一步阅读
我们介绍了一个基本的面部表情系统。如果您对这个主题真正感兴趣,您可能想阅读本节以获取更多关于如何提高系统性能的指导。在本节中,我们将向您介绍编译 opencv_contrib
模块、Kaggle 面部表情数据集和 k-交叉验证方法。我们还将提供一些关于如何获得更好的特征提取的建议。
编译 opencv_contrib 模块
在本节中,我们将介绍在基于 Linux 的系统中编译 opencv_contrib
的过程。如果您使用 Windows,可以使用具有相同选项的 Cmake GUI。
首先,将 opencv
仓库克隆到您的本地机器上:
git clone https://github.com/Itseez/opencv.git --depth=1
第二,将 opencv_contrib
仓库克隆到您的本地机器上:
git clone https://github.com/Itseez/opencv_contrib --depth=1
切换到 opencv
文件夹并创建一个构建目录:
cd opencv
mkdir build
cd build
使用 opencv_contrib 支持从源代码构建 OpenCV。您应将 OPENCV_EXTRA_MODULES_PATH
修改为您的机器上 opencv_contrib
的位置:
cmake -D CMAKE_BUILD_TYPE=RELEASE -D CMAKE_INSTALL_PREFIX=/usr/local -D OPENCV_EXTRA_MODULES_PATH=~/opencv_contrib/ ..
make -j4
make install
Kaggle 面部表情数据集
Kaggle 是一个优秀的数据科学家社区。Kaggle 主办了许多比赛。2013 年,有一个面部表情识别挑战。
注意
目前,您可以通过以下链接访问完整数据集:
www.kaggle.com/c/challenges-in-representation-learning-facial-expression-recognition-challenge/
数据集包含 48x48 像素的灰度人脸图像。共有 28,709 个训练样本,3,589 个公开测试图像和 3,589 个最终测试图像。数据集包含七种表情(愤怒、厌恶、恐惧、快乐、悲伤、惊讶和中性)。获胜者获得了 69.769 %的分数。由于这个数据集非常大,所以我们认为我们的基本系统可能无法直接使用。我们相信,如果您想使用这个数据集,您应该尝试提高系统的性能。
面部特征点
在我们的面部表情系统中,我们使用人脸检测作为预处理步骤来提取面部区域。然而,人脸检测容易发生错位,因此特征提取可能不可靠。近年来,最常见的方法之一是使用面部特征点。在这种方法中,检测面部特征点并用于对齐面部区域。许多研究人员使用面部特征点来提取面部组件,如眼睛、嘴巴等,并分别进行特征提取。
什么是面部特征点?
面部特征点是面部组件的预定义位置。下面的图显示了 iBUG 组的一个 68 点系统的示例 (ibug.doc.ic.ac.uk/resources/facial-point-annotations
)
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv3-bp/img/00052.jpeg
iBUG 组的一个 68 个特征点系统的示例
你如何检测面部特征点?
在面部区域内检测面部特征点有几种方法。我们将为您提供一些解决方案,以便您能够轻松开始您的项目。
-
主动形状模型:这是解决此问题最常见的方法之一。您可能会发现以下库很有用:
-
Cao 等人通过显式回归进行人脸对齐:这是关于面部特征点的最新工作之一。这个系统非常高效且非常准确。您可以在以下超链接中找到开源实现:
github.com/soundsilence/FaceAlignment
你如何使用面部特征点?
您可以使用面部特征点以多种方式。我们将为您提供一些指南:
-
您可以使用面部特征点将面部区域对齐到共同的标准,并提取特征向量,就像我们在基本面部表情系统中做的那样。
-
您可以在不同的面部组件(如眼睛和嘴巴)中分别提取特征向量,并将它们组合成一个特征向量进行分类。
-
您可以使用面部特征点的位置作为特征向量,并忽略图像中的纹理。
-
您可以为每个面部组件构建分类模型,并以加权方式组合预测结果。
提高特征提取
特征提取是面部表情分析中最重要的部分之一。最好为你的问题选择合适的特征。在我们的实现中,我们只在 OpenCV 中使用了少数几个特征。我们建议你尝试 OpenCV 中所有可能的特征。以下是 Open CV 支持的特性列表:BRIEF, BRISK, FREAK, ORB, SIFT, SURF, KAZE, AKAZE, FAST, MSER, 和 STAR。
社区中还有其他一些非常适合你问题的优秀特性,例如 LBP, Gabor, HOG 等。
K 折交叉验证
K 折交叉验证是估计分类器性能的常用技术。给定一个训练集,我们将将其划分为 k 个分区。对于 k 次实验中的每个折 i,我们将使用不属于折 i 的所有样本来训练分类器,并使用折 i 中的样本来测试分类器。
K 折交叉验证的优势在于数据集中的所有示例最终都会用于训练和验证。
将原始数据集划分为训练集和测试集是很重要的。然后,训练集将用于 k 折交叉验证,而测试集将用于最终测试。
交叉验证结合了每个实验的预测误差,从而得到模型更准确的估计。它非常有用,尤其是在我们训练数据不多的情况下。尽管计算时间较长,但如果你想提高系统的整体性能,使用复杂特征是一个很好的主意。
摘要
本章展示了 OpenCV 3 中面部表情系统的完整过程。我们走过了系统的每一步,并为每一步提供了许多替代方案。本章还基于特性和学习算法对结果进行了评估。
最后,本章为你提供了一些进一步改进的提示,包括一个面部表情挑战,面部特征点方法,一些特性建议,以及 k 折交叉验证。
第四章:使用 Android Studio 和 NDK 实现全景图像拼接应用程序
全景是应用开发中的一个有趣的主题。在 OpenCV 中,拼接模块可以轻松地从一系列图像中创建全景图像。拼接模块的一个好处是图像序列不必按顺序排列,可以是任何方向。然而,在 OpenCV Android SDK 中,拼接模块不存在。因此,我们必须在 C++接口中使用拼接模块。幸运的是,Android 提供了原生开发工具包(NDK)来支持 C/C++的本地开发。在本章中,我们将指导您通过从 Java 捕获相机帧并在 OpenCV C++中使用 NDK 处理数据的步骤。
在本章中,您将学习:
-
如何制作一个完整的全景拼接应用程序
-
如何使用 Java 本地接口(JNI)在 Android Studio 中使用 OpenCV C++
-
如何使用拼接模块创建全景图像
介绍全景的概念
全景图像比普通图像提供了更广阔的视野,并允许他们完全体验场景。通过将全景范围扩展到 360 度,观众可以模拟转动他们的头部。全景图像可以通过拼接一系列重叠的图像来创建。
以下图展示了使用我们的应用程序捕捉的全景图像的演示。
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv3-bp/img/00053.jpeg
在水平方向上捕捉的全景图像
为了捕捉全景图像,您必须在场景的不同角度捕捉许多图像,如下面的图所示。例如,您在房间的左侧拍摄第一张照片。然后,您将手机直接移动到新的角度开始捕捉。所有图像将被拼接在一起以创建全景图像。
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv3-bp/img/00054.jpeg
展示如何平移手机以创建全景图像的插图
通常,全景应用程序仅支持水平方向上的图像捕捉。使用 OpenCV 的拼接模块,我们可以通过在两个方向上捕捉更多图像来扩展图像的高度。以下图显示了可以通过在水平和垂直方向上改变相机视图来捕捉的图像。
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv3-bp/img/00055.jpeg
在两个方向上捕捉的全景图像
在本章中,我们将使用 OpenCV 3.0.0 在 Android 中实现全景应用程序。本章包含两个主要部分:
-
Android 部分:我们将使用 Android Studio 实现用户界面。在本章中,我们只实现了带有两个按钮(捕获和保存)的全景捕获活动。当捕捉到全景时,我们将将其保存到手机的内部存储中。
-
OpenCV 部分:我们将展示如何将 OpenCV 集成到 Android Studio 中,使用 NDK/JNI,并实现从 Android 部分捕获的一系列图像创建全景图像的代码。
在接下来的章节中,我们将展示在 Android Studio 中创建用户界面的过程。如果您想回顾 OpenCV 代码,您可以前往将 OpenCV 集成到 Android Studio部分,稍后再回来。
安卓部分 - 应用程序用户界面
在本节中,我们将向您展示一个基本用户界面,用于捕获并将全景保存到内部存储。基本上,用户将看到相机预览图像的全屏。当用户按下捕获按钮时,应用程序将捕获当前场景并将捕获的图像放置在当前视图之上的叠加层上。因此,用户知道他们刚刚捕获了什么,并且可以改变手机位置以捕获下一张图像。
以下是在用户打开应用程序后以及用户捕获图像时的应用程序截图:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv3-bp/img/00056.jpeg
用户捕获图像前后的用户界面示例
设置活动布局
首先,我们将使用 Android Studio 创建一个新的 Android 项目,其中包含一个空白活动。然后,我们将编辑app/src/main/res/layout/activity_main.xml
中的 MainActivity 布局 xml,如下所示:
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<SurfaceView
android:id="@+id/surfaceView"
android:layout_width="match_parent"
android:layout_height="match_parent"
/>
<SurfaceView
android:id="@+id/surfaceViewOnTop"
android:layout_width="match_parent"
android:layout_height="match_parent"
/>
<Button
android:id="@+id/capture"
android:text="Capture"
android:layout_width="wrap_content"
android:layout_height="70dp"
android:layout_alignParentBottom="true"
android:layout_centerHorizontal="true"
android:layout_marginBottom="10dp"
android:padding="10dp"
android:textColor="#FFF"
android:background="@android:color/holo_blue_dark"
/>
<Button
android:id="@+id/save"
android:text="Save"
android:layout_width="wrap_content"
android:layout_height="70dp"
android:padding="10dp"
android:textColor="#FFF"
android:background="@android:color/holo_purple"
android:layout_marginRight="10dp"
android:layout_alignTop="@+id/capture"
android:layout_alignParentRight="true"
android:layout_alignParentEnd="true" />
</RelativeLayout>
在此布局 xml 文件中,我们有两个 SurfaceView——一个用于相机预览,一个用于叠加层。我们还有一个按钮用于捕获图像,另一个按钮用于将全景图像保存到内部存储。
捕获相机帧
在本节中,我们将实现捕获相机帧并在 ID 为surfaceView
的 SurfaceView 上查看的过程。
在MainActivity
类的开头,我们将创建一些对象以与布局一起使用:
public class MainActivity extends ActionBarActivity {
private Button captureBtn, saveBtn; // used to interact with capture and save Button in UI
private SurfaceView mSurfaceView, mSurfaceViewOnTop; // used to display the camera frame in UI
private Camera mCam;
private boolean isPreview; // Is the camera frame displaying?
private boolean safeToTakePicture = true; // Is it safe to capture a picture?
在前面的代码中,我们创建了两个按钮和两个SurfaceViews
以与用户界面交互。我们还创建了一个 Camera 对象mCam
以打开相机。在我们的实现中,我们将使用 Android 方法打开相机并获取视频帧。OpenCV 还提供了一些其他打开相机的方法。然而,我们发现它们可能不会在所有 Android 设备上很好地工作,所以我们更喜欢使用 Android 方法中的相机。在本章中,我们只需要 Android API 中的 Camera 对象。这种方法的优势是您可以预期它在几乎任何 Android 设备上都能工作。缺点是您必须进行一些从相机字节数组到 Android Bitmap 的转换,以在 UI 上显示,并将 OpenCV Mat 用于图像处理。
注意
如果您想体验 OpenCV 类与相机交互,您可能想查看本书的第七章 7.陀螺仪视频稳定,陀螺仪视频稳定。
在onCreate
函数中,我们按以下方式设置这些对象:
@Override
protected void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
isPreview = false;
mSurfaceView = (SurfaceView)findViewById(R.id.surfaceView);
mSurfaceView.getHolder().addCallback(mSurfaceCallback);
mSurfaceViewOnTop = (SurfaceView)findViewById(R.id.surfaceViewOnTop);
mSurfaceViewOnTop.setZOrderOnTop(true); // necessary
mSurfaceViewOnTop.getHolder().setFormat(PixelFormat.TRANSPARENT);
captureBtn = (Button) findViewById(R.id.capture);
captureBtn.setOnClickListener(captureOnClickListener);
saveBtn = (Button) findViewById(R.id.save);
saveBtn.setOnClickListener(saveOnClickListener);
}
首先,我们将isPreview
初始化为 false,并将布局中的mSurfaceView
分配给SurfaceView
。然后,我们获取mSurfaceView
的持有者并向其添加一个回调。变量mSurfaceCallback
是我们稍后将要创建的SurfaceHolder.Callback
实例。我们还把mSurfaceViewOnTop
分配给布局中的另一个SurfaceView
,因为我们希望这个SurfaceView
成为相机视图的叠加层。我们需要设置 Z 顺序为 true,并将持有者格式设置为TRANSPARENT
。最后,我们设置捕获和保存按钮,并设置相应的OnClickListener
。在下一部分,我们将专注于在SurfaceView
上显示相机帧。因此,我们将创建一个基本的OnClickListener
,如下所示:
View.OnClickListener captureOnClickListener = new View.OnClickListener() {
@Override
public void onClick(View v) {
}
};
View.OnClickListener saveOnClickListener = new View.OnClickListener() {
@Override
public void onClick(View v) {
}
};
使用 Camera API 获取相机帧
正如我们之前所说的,我们将使用 Android API 在 Android 中获取相机帧。目前,有两个版本的 Camera API,即android.hardware.Camera
和android.hardware.camera2
。我们将使用android.hardware.Camera
,因为它支持大多数 Android 4.4 及以下版本的设备。在 Android 5.0 及以后的版本中,此 API 已被弃用,并由 camera2 替代。我们仍然可以在 Android 5.0 中使用android.hardware.Camera
。如果您想针对 Android 5.0,我们建议您在您的应用程序中尝试使用 camera2。
为了使用相机,我们需要在AndroidManifest.xml
中添加以下行以获取对相机的权限。此外,我们还请求写入存储的权限,因为我们将会将全景图像保存到内部存储。
<uses-feature android:name="android.hardware.camera" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
我们希望将mSurfaceView
设置为显示相机帧,因此我们将在mSurfaceView
的回调中设置相机参数。我们需要创建变量mSurfaceCallback
,如下所示:
SurfaceHolder.Callback mSurfaceCallback = new SurfaceHolder.Callback(){
@Override
public void surfaceCreated(SurfaceHolder holder) {
try {
// Tell the camera to display the frame on this surfaceview
mCam.setPreviewDisplay(holder);
} catch (IOException e) {
e.printStackTrace();
}
}
@Override
public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
// Get the default parameters for camera
Camera.Parameters myParameters = mCam.getParameters();
// Select the best preview size
Camera.Size myBestSize = getBestPreviewSize( myParameters );
if(myBestSize != null){
// Set the preview Size
myParameters.setPreviewSize(myBestSize.width, myBestSize.height);
// Set the parameters to the camera
mCam.setParameters(myParameters);
// Rotate the display frame 90 degree to view in portrait mode
mCam.setDisplayOrientation(90);
// Start the preview
mCam.startPreview();
isPreview = true;
}
}
@Override
public void surfaceDestroyed(SurfaceHolder holder) {
}
};
在此代码中,我们在surfaceCreated
函数中调用setPreviewDisplay
方法,告诉相机在mSurfaceView
上显示相机帧。之后,在surfaceChanged
函数中,我们设置相机参数,将显示方向更改为 90 度并开始预览过程。getBestPreviewSize
函数是一个获取具有最大像素数的预览尺寸的函数。getBestPreviewSize
很简单,如下所示:
private Camera.Size getBestPreviewSize(Camera.Parameters parameters){
Camera.Size bestSize = null;
List<Camera.Size> sizeList = parameters.getSupportedPreviewSizes();
bestSize = sizeList.get(0);
for(int i = 1; i < sizeList.size(); i++){
if((sizeList.get(i).width * sizeList.get(i).height) >
(bestSize.width * bestSize.height)){
bestSize = sizeList.get(i);
}
}
return bestSize;
}
最后,我们需要在onResume
中添加一些代码来打开相机,并在onPause
中释放相机:
@Override
protected void onResume() {
super.onResume();
mCam = Camera.open(0); // 0 for back camera
}
@Override
protected void onPause() {
super.onPause();
if(isPreview){
mCam.stopPreview();
}
mCam.release();
mCam = null;
isPreview = false;
}
在这个时候,我们可以在真实设备上安装并运行应用程序。以下图显示了在运行 Android 5.1.1 的 Nexus 5 上我们的应用程序的截图:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv3-bp/img/00057.jpeg
Nexus 5 在 Android 5.1.1 上运行 Camera 预览模式的截图
在我们的应用程序中,我们不希望布局旋转,因此我们将活动方向设置为纵向模式。这是可选的。如果您想这样做,您只需在AndroidManifest.xml
中更改您的活动,如下所示:
<activity
android:screenOrientation="portrait"
android:name=".MainActivity"
android:label="@string/app_name" >
实现捕获按钮
在本节中,我们将向您展示如何实现捕获按钮的 OnClickListener
。当用户点击捕获按钮时,我们希望应用程序能够拍摄当前场景的图片。使用 Camera API,我们可以使用 takePicture
函数来捕获图片。这个函数的好处是输出图像的分辨率非常高。例如,当我们的应用程序在 Nexus 5 上运行时,尽管预览大小是 1920x1080,但捕获图像的分辨率是 3264x2448。我们将 captureOnClickListener
改动如下:
View.OnClickListener captureOnClickListener = new View.OnClickListener() {
@Override
public void onClick(View v) {
if(mCam != null && safeToTakePicture){
// set the flag to false so we don't take two picture at a same time
safeToTakePicture = false;
mCam.takePicture(null, null, jpegCallback);
}
}
};
在 onClick
函数中,我们检查相机是否已初始化,并且标志 safeToTakePicture
是 true
。然后,我们将标志设置为 false
,这样我们就不可能在同一时间拍摄两张图片。Camera 实例的 takePicture
函数需要三个参数。第一个和第二个参数分别是快门回调和原始数据回调。这些函数在不同的设备上可能被调用得不同,所以我们不想使用它们,并将它们设置为 null。最后一个参数是当相机以 JPEG 格式保存图片时被调用的回调。
Camera.PictureCallback jpegCallback = new Camera.PictureCallback() {
public void onPictureTaken(byte[] data, Camera camera) {
// decode the byte array to a bitmap
Bitmap bitmap = BitmapFactory.decodeByteArray(data, 0, data.length);
// Rotate the picture to fit portrait mode
Matrix matrix = new Matrix();
matrix.postRotate(90);
bitmap = Bitmap.createBitmap(bitmap, 0, 0, bitmap.getWidth(), bitmap.getHeight(), matrix, false);
// TODO: Save the image to a List to pass them to OpenCV method
Canvas canvas = null;
try {
canvas = mSurfaceViewOnTop.getHolder().lockCanvas(null);
synchronized (mSurfaceViewOnTop.getHolder()) {
// Clear canvas
canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR);
// Scale the image to fit the SurfaceView
float scale = 1.0f * mSurfaceView.getHeight() / bitmap.getHeight();
Bitmap scaleImage = Bitmap.createScaledBitmap(bitmap, (int)(scale * bitmap.getWidth()), mSurfaceView.getHeight() , false);
Paint paint = new Paint();
// Set the opacity of the image
paint.setAlpha(200);
// Draw the image with an offset so we only see one third of image.
canvas.drawBitmap(scaleImage, -scaleImage.getWidth() * 2 / 3, 0, paint);
}
} catch (Exception e) {
e.printStackTrace();
} finally {
if (canvas != null) {
mSurfaceViewOnTop.getHolder().unlockCanvasAndPost(canvas);
}
}
// Start preview the camera again and set the take picture flag to true
mCam.startPreview();
safeToTakePicture = true;
}
};
首先,onPictureTaken
提供了捕获图像的字节数组,因此我们希望将其解码为 Bitmap 实例。因为相机传感器以横幅模式捕获图像,所以我们希望应用一个旋转矩阵来获得竖幅模式的图像。然后,我们希望将此图像保存以传递一系列图像到 OpenCV 粘合模块。由于此代码需要 OpenCV 库,我们将稍后实现这部分。之后,我们将获得叠加 SurfaceView
的画布,并尝试在屏幕上绘制图像。以下是在预览层之上的叠加层的演示。最后,我们将再次启动预览过程,并将 safeToTakePicture
标志设置为 true
。
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv3-bp/img/00058.jpeg
用户在 Nexus 5(运行 Android 5.1.1)上捕获图像后的应用程序截图
实现保存按钮
目前,保存按钮相当简单。我们将假设当用户点击保存按钮时,我们将启动一个新线程来执行图像处理任务:
View.OnClickListener saveOnClickListener = new View.OnClickListener() {
@Override
public void onClick(View v) {
Thread thread = new Thread(imageProcessingRunnable);
thread.start();
}
};
在 imageProcessingRunnable
中,我们希望在处理开始时显示一个处理对话框,并在一切完成后关闭对话框。为了实现这一点,我们首先创建一个 ProgressDialog
实例:
ProgressDialog ringProgressDialog;
然后,imageProcessingRunnable
的实现如下:
private Runnable imageProcessingRunnable = new Runnable() {
@Override
public void run() {
showProcessingDialog();
// TODO: implement OpenCV parts
closeProcessingDialog();
}
};
我们将简单地调用 showProcessingDialog
来显示进度对话框,并调用 closeProcessingDialog
来关闭对话框。中间的步骤相当复杂,需要大量的 OpenCV 函数,所以我们将其留到后面的部分。显示和关闭进度对话框的函数如下:
private void showProcessingDialog(){
runOnUiThread(new Runnable() {
@Override
public void run() {
mCam.stopPreview();
ringProgressDialog = ProgressDialog.show(MainActivity.this, "", "Panorama", true);
ringProgressDialog.setCancelable(false);
}
});
}
private void closeProcessingDialog(){
runOnUiThread(new Runnable() {
@Override
public void run() {
mCam.startPreview();
ringProgressDialog.dismiss();
}
});
}
在 showProcessingDialog
中,我们将停止相机预览以减少设备上的不必要的计算成本,而在 closeProcessingDialog
中,我们将再次启动相机预览,以便用户可以捕获更多的全景图像。我们必须将这些代码放在 runOnUiThread
中,因为这些代码与 UI 元素交互。
在下一节中,我们将向您展示如何使用 OpenCV 实现应用程序的剩余部分。
将 OpenCV 集成到 Android Studio 中
在本节中,我们将向您展示如何使用原生开发套件将 OpenCV 集成到 Android Studio 中,并使用 C++ 中的 OpenCV 粘合模块创建最终的全景图像。我们还将使用 OpenCV Android SDK Java 进行一些计算,以展示 Java 和 C++ 接口之间的交互过程。
将 OpenCV Android SDK 编译到 Android Studio 项目
正式来说,OpenCV Android SDK 是一个 Eclipse 项目,这意味着我们无法简单地将其用于我们的 Android Studio 项目。我们需要将 OpenCV Android SDK 转换为 Android Studio 项目,并将其作为模块导入到我们的应用程序中。
注意
我们假设您已从 opencv.org/downloads.html
下载了最新的 OpenCV for Android。在撰写本文时,我们现在有 OpenCV for Android 3.0.0。
让我们将下载的文件解压到您喜欢的路径,例如,/Volumes/Data/OpenCV/OpenCV-android-sdk
。
然后,我们需要打开一个新的 Android Studio 窗口并选择导入项目(Eclipse ADT、Gradle 等等)。在弹出的窗口中,你应该选择 java
文件夹在 OpenCV-android-sdk/sdk/java
,然后点击确定。
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv3-bp/img/00059.jpeg
导入项目可视化
在下一个窗口中,我们将选择一个路径来存储新的 OpenCV SDK 项目。在我们的例子中,我们选择 /Volumes/Data/OpenCV/opencv-java
并点击下一步。
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv3-bp/img/00060.jpeg
选择导入目标可视化
在最后一个窗口中,我们只需点击完成并等待 Android Studio 完成 Gradle 构建过程。基本上,Gradle 是 Android Studio 的默认构建系统。在这一步,我们想确保 OpenCV SDK 可以成功编译。一个常见错误是您尚未下载所需的 Android SDK。错误信息非常直接。您可以按照消息解决问题。在我们的例子中,正如以下截图所示,没有问题。
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv3-bp/img/00061.jpeg
构建竞争可视化
此时,我们可以关闭此项目并打开我们的全景项目。
设置 Android Studio 以与 OpenCV 一起工作
为了在我们的项目中使用 OpenCV,我们需要将 OpenCV Android SDK 导入到我们的项目中。有了这个 SDK,我们可以使用 OpenCV Java API 并轻松执行图像处理任务。此外,我们必须进一步操作,告诉 Android Studio 编译 OpenCV C++ 代码以在 Native Development Kit (NDK) 中使用 OpenCV。我们将把这个部分分成三个小节:导入 Android SDK、创建 Java-C++ 交互和编译 OpenCV C++。
导入 OpenCV Android SDK
我们假设你已经打开了全景项目。我们需要按照以下方式导入上一节中转换的 OpenCV Android SDK:
文件 | 新建 | 导入模块
在 新建模块 窗口中,我们将选择源目录到转换后的项目。在我们的例子中,我们将选择 /Volumes/Data/OpenCV/opencv-java
。然后,我们将勾选导入复选框,将模块名称更改为 :opencv-java
,如以下截图所示,然后点击 完成:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv3-bp/img/00062.jpeg
新建模块窗口
接下来,我们需要修改 app
文件夹中的 build.gradle
文件,在 dependencies
部分添加一行:
dependencies {
compile fileTree(dir: 'libs', include: ['*.jar'])
compile 'com.android.support:appcompat-v7:22.1.1'
compile project(":opencv-java")
}
最后,我们必须通过按钮 Sync Project with Gradle Files 同步项目。
注意
如果你只需要 OpenCV Java 接口而不想使用 OpenCV C++,你必须将 OpenCV-android-sdk/sdk/native/libs
中的 libs
文件夹复制到你的 app/src/main
文件夹。然后,你必须将以下 loadLibrary
代码添加到你的类文件中:
static {
//If you use OpenCV 2.*, use "opencv_java"
System.loadLibrary("opencv_java3");
}
使用 Java Native Interface (JNI) 创建 Java 和 C++ 交互
在我们开始编译过程之前,我们将创建一个名为 NativePanorama.java
的类文件,并向 NativePanorama
类添加一个方法:
public class NativePanorama {
public native static void processPanorama(long[] imageAddressArray, long outputAddress);
}
processPanorama
方法将接收每个图像的长地址数组以及一个输出图像的长地址。
你必须重新构建项目才能遵循接下来的步骤。详细说明将在下一段落中:
-
使用
javah
命令行创建 C++ 头文件 -
在
jni
文件夹中为新建的头文件创建一个.cpp
文件以实现 C++ 中的函数
你可能会注意到 processPanorama
方法之前的 native
关键字。这意味着我们将使用此方法在我们的应用程序中实现 Java 和 C++ 之间的交互。因此,我们需要创建一些头文件和源文件来实现我们的 C++ 代码。我们必须遵循 Java Native Interface (JNI) 来使用 C++ 代码,所以这个过程可能有点复杂,并且超出了本书的范围。
在以下部分,我们将向您展示使用 OpenCV C++ 的步骤。
注意
如果你想了解 JNI,你可能想查看以下 JNI 文档:
docs.oracle.com/javase/7/docs/technotes/guides/jni/
还可以查看 API 指南中提供的 JNI 小贴士,地址如下:
developer.android.com/training/articles/perf-jni.html
首先,我们将使用终端中的 javah
命令为 processPanorama
方法创建相应的 C++ 头文件。为了做到这一点,您需要打开您的机器上的终端,然后切换到 Android 应用程序中的 app/src/main
文件夹,并运行以下命令:
javah -d jni -classpath ../../build/intermediates/classes/debug/ com.example.panorama.NativePanorama
您只需要验证包名和类文件名 NativePanorama
。命令在终端上不会显示任何内容,如图所示。如果您遇到以下错误:错误:找不到类文件 ‘com.example.panorama.NativePanorama’,您可能需要重新构建项目。
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv3-bp/img/00063.jpeg
使用 javah 命令后的终端
javah
命令的结果是,我们现在在 app/src/main
文件夹中有一个名为 jni
的文件夹,其中包含一个名为 com_example_panorama_NativePanorama.h
的文件。这个头文件包含一个用于与 Java 接口工作的函数。当调用 processPanorama
时,这个函数将在 C++ 中运行。
接下来,我们将在 jni
文件夹中创建一个名为 com_example_panorama_NativePanorama.cpp
的源文件。我们建议您将头文件中的函数声明复制到源文件中,并添加以下参数名称:
#include "com_example_panorama_NativePanorama.h"
JNIEXPORT void JNICALL Java_com_example_panorama_NativePanorama_processPanorama
(JNIEnv * env, jclass clazz, jlongArray imageAddressArray, jlong outputAddress){
}
剩下的唯一事情是我们需要编译 OpenCV C++ SDK,以便在先前的源文件中使用它。
使用 NDK/JNI 编译 OpenCV C++
为了在 C++ 代码中使用 OpenCV,我们需要编译 OpenCV C++,并使用 Android.mk
文件作为构建文件来构建和链接我们的 C++ 文件与 OpenCV 库。然而,Android Studio 默认不支持 Android.mk
。我们需要做很多事情才能实现这一点。
首先,我们将打开 local.properties
文件,并将 ndk.dir
设置为 Android NDK 文件夹的路径。在我们的例子中,local.properties
将如下所示:
sdk.dir=/Users/quanhua92/Library/Android/sdk
ndk.dir=/Users/quanhua92/Software/android-ndk-r10e
注意
您可以在以下位置获取 Android NDK:developer.android.com/ndk/index.html
其次,我们打开应用程序文件夹中的 build.gradle
文件,并在顶部添加以下行:
import org.apache.tools.ant.taskdefs.condition.Os
然后,我们需要在 defaultConfig
标签和 buildType
标签之间添加以下代码,以创建一个新的 Gradle 任务来构建 C++ 代码。
// begin NDK OPENCV
sourceSets.main {
jni.srcDirs = [] //disable automatic ndk-build call
}
task ndkBuild(type: Exec, description: 'Compile JNI source via NDK') {
def rootDir = project.rootDir
def localProperties = new File(rootDir, "local.properties")
Properties properties = new Properties()
localProperties.withInputStream { instr ->
properties.load(instr)
}
def ndkDir = properties.getProperty('ndk.dir')
if (Os.isFamily(Os.FAMILY_WINDOWS)) {
commandLine "$ndkDir\\ndk-build.cmd",
'NDK_PROJECT_PATH=build/intermediates/ndk',
'NDK_LIBS_OUT=src/main/jniLibs',
'APP_BUILD_SCRIPT=src/main/jni/Android.mk',
'NDK_APPLICATION_MK=src/main/jni/Application.mk'
} else {
commandLine "$ndkDir/ndk-build",
'NDK_PROJECT_PATH=build/intermediates/ndk',
'NDK_LIBS_OUT=src/main/jniLibs',
'APP_BUILD_SCRIPT=src/main/jni/Android.mk',
'NDK_APPLICATION_MK=src/main/jni/Application.mk'
}
}
tasks.withType(JavaCompile) {
compileTask -> compileTask.dependsOn ndkBuild
}
//end
您可能想查看以下图中的我们的 build.gradle
的截图。
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv3-bp/img/00064.jpeg
我们的 build.gradle 截图
接下来,我们在 jni
文件夹中创建一个名为 Application.mk
的文件,并将以下行放入其中:
APP_STL := gnustl_static
APP_CPPFLAGS := -frtti -fexceptions
APP_ABI := all
APP_PLATFORM := android-16
最后,我们在 jni
文件夹中创建一个名为 Android.mk
的文件,并按照以下设置来使用 OpenCV 在我们的 C++ 代码中。您可能需要将 OPENCVROOT
变量更改为您机器上 OpenCV-android-sdk 的位置:
LOCAL_PATH := $(call my-dir)
include $(CLEAR_VARS)
#opencv
OPENCVROOT:= /Volumes/Data/OpenCV/OpenCV-android-sdk
OPENCV_CAMERA_MODULES:=on
OPENCV_INSTALL_MODULES:=on
OPENCV_LIB_TYPE:=SHARED
include ${OPENCVROOT}/sdk/native/jni/OpenCV.mk
LOCAL_SRC_FILES := com_example_panorama_NativePanorama.cpp
LOCAL_LDLIBS += -llog
LOCAL_MODULE := MyLib
include $(BUILD_SHARED_LIBRARY)
使用前面的Android.mk
,Android Studio 会将 OpenCV 构建到libopencv_java3.so
中,并将我们的 C++代码构建到app/src/main/jniLibs
文件夹中的libMyLib.so
中。我们必须打开我们的MainActivity.java
并加载这个库,以便在我们的应用程序中使用,如下所示:
public class MainActivity extends ActionBarActivity {
static{
System.loadLibrary("opencv_java3");
System.loadLibrary("MyLib");
}
注意
如果您使用 OpenCV Android SDK 版本 2.*,您应该加载opencv_java
而不是opencv_java3
。
实现 OpenCV Java 代码
在本节中,我们将向您展示 OpenCV 在 Java 端,为 OpenCV C++端的拼接模块准备数据。
首先,当用户按下捕获按钮时,我们将创建一个列表来存储所有捕获的图像:
private List<Mat> listImage = new ArrayList<>();
然后,在jpegCallback
变量的onPictureTaken
方法中,我们想要将捕获的 Bitmap 转换为 OpenCV Mat 并存储在这个listImage
列表中。您需要在 Canvas 绘制部分之前添加这些行:
Mat mat = new Mat();
Utils.bitmapToMat(bitmap, mat);
listImage.add(mat);
最后,当用户点击保存按钮时,我们希望将listImage
中图像的地址发送到 OpenCV C++代码以执行拼接过程。
在imageProcessingRunnable
中,我们将在调用showProcessingDialog
函数之后添加以下代码:
try {
// Create a long array to store all image address
int elems= listImage.size();
long[] tempobjadr = new long[elems];
for (int i=0;i<elems;i++){
tempobjadr[i]= listImage.get(i).getNativeObjAddr();
}
// Create a Mat to store the final panorama image
Mat result = new Mat();
// Call the OpenCV C++ Code to perform stitching process
NativePanorama.processPanorama(tempobjadr, result.getNativeObjAddr());
// Save the image to external storage
File sdcard = Environment.getExternalStorageDirectory();
final String fileName = sdcard.getAbsolutePath() + "/opencv_" + System.currentTimeMillis() + ".png";
Imgcodecs.imwrite(fileName, result);
runOnUiThread(new Runnable() {
@Override
public void run() {
Toast.makeText(getApplicationContext(), "File saved at: " + fileName, Toast.LENGTH_LONG).show();
}
});
listImage.clear();
} catch (Exception e) {
e.printStackTrace();
}
在前面的代码中,我们将创建一个长数组来存储每个 Mat 图像的本地地址。然后,我们将传递这个长数组和一个Mat result
的本地地址,以存储全景图像。OpenCV C++代码将运行以执行拼接模块的拼接。之后,我们将结果保存到外部存储,并做一个简单的 toast 提示用户全景已保存。最后,我们清除listImage
列表以开始新的部分。
实现 OpenCV C++代码
在此刻,我们想要在 OpenCV C++中实现processPanorama
。实现非常简单;我们只需编辑com_example_panorama_NativePanorama.cpp
文件,如下所示:
#include "com_example_panorama_NativePanorama.h"
#include "opencv2/opencv.hpp"
#include "opencv2/stitching.hpp"
using namespace std;
using namespace cv;
JNIEXPORT void JNICALL Java_com_example_panorama_NativePanorama_processPanorama
(JNIEnv * env, jclass clazz, jlongArray imageAddressArray, jlong outputAddress){
// Get the length of the long array
jsize a_len = env->GetArrayLength(imageAddressArray);
// Convert the jlongArray to an array of jlong
jlong *imgAddressArr = env->GetLongArrayElements(imageAddressArray,0);
// Create a vector to store all the image
vector< Mat > imgVec;
for(int k=0;k<a_len;k++)
{
// Get the image
Mat & curimage=*(Mat*)imgAddressArr[k];
Mat newimage;
// Convert to a 3 channel Mat to use with Stitcher module
cvtColor(curimage, newimage, CV_BGRA2RGB);
// Reduce the resolution for fast computation
float scale = 1000.0f / curimage.rows;
resize(newimage, newimage, Size(scale * curimage.rows, scale * curimage.cols));
imgVec.push_back(newimage);
}
Mat & result = *(Mat*) outputAddress;
Stitcher stitcher = Stitcher::createDefault();
stitcher.stitch(imgVec, result);
// Release the jlong array
env->ReleaseLongArrayElements(imageAddressArray, imgAddressArr ,0);
}
在前面的代码中,我们将图像地址的长数组转换为图像并推入一个名为imgVec
的向量中。我们还调整了图像大小以加快计算速度。拼接模块非常容易使用。
首先,我们将创建一个Stitcher
实例。
Stitcher stitcher = Stitcher::createDefault();
然后,我们使用这个拼接器来拼接我们的 Mat 向量图像。全景图像将被保存到一个结果 Mat 中。
下面的截图显示了使用默认配置处理的全景图像示例:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv3-bp/img/00065.jpeg
在走廊中捕获的示例图像
应用程序展示
在本节中,我们展示了使用我们的应用程序捕获的一些全景图像。您可以看到,该应用程序能够处理水平和垂直方向的全景图像。
首先,这是从建筑物的五楼捕获的图像。我们通过窗户玻璃拍照,所以光线很暗。然而,全景效果很好,因为有很多细节,所以特征匹配器可以做得很好。
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv3-bp/img/00066.jpeg
应用程序捕获的示例图像
下面的截图是在傍晚时分从阳台拍摄的。全景图的左上角区域不好,因为这个区域只包含一面空墙和天空。因此,图像之间可比较的特征太少。因此,最终全景图在这个区域并不完美。
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv3-bp/img/00067.jpeg
应用在傍晚捕获的样本图像
下面的截图是通过窗户拍摄的。图像的下半部分很好。然而,由于缺乏特征,天空仍然存在一些问题,就像之前的图像一样。
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv3-bp/img/00068.jpeg
应用在傍晚时分捕获的另一张样本图像
以下图像是在下午拍摄的建筑前庭院中的。光线很好,有很多细节,所以最终全景图完美无瑕。
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv3-bp/img/00069.jpeg
应用在下午捕获的样本图像
这张图片与上一张图片拍摄于同一时期。然而,这张图片以广泛的视角捕捉,每个拍摄角度的光线都不同。因此,最终全景图的照明并不一致。
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv3-bp/img/00070.jpeg
应用在下午捕获的另一张样本图像
进一步改进
在本节中,我们将展示一些在创建功能齐全的全景应用时可以考虑的改进。
首先,你可以为用户创建一个更好的用户界面来捕捉全景图像。当前的用户界面没有显示应用程序可以双向捕捉图像。建议使用 Android API 中的运动传感器(加速度计和陀螺仪)来获取设备的旋转并调整叠加图像的位置。
注意
运动传感器 API 文档可在developer.android.com/guide/topics/sensors/sensors_motion.html
找到。
其次,当前应用程序调整捕获的图像大小以减少计算时间。你可能想更改 Stitcher 的一些参数以获得更好的性能。我们建议你查看拼接模块的文档以获取更多详细信息。在我们的实现中,我们将使用 Stitcher 类进行简化。然而,OpenCV 仓库中有一个详细的示例在samples/cpp/stitching_detailed.cpp
,其中展示了许多选项来提高最终全景图的稳定性和质量。
注意
使用拼接模块的详细示例可在github.com/Itseez/opencv/blob/master/samples/cpp/stitching_detailed.cpp
找到。
第三,您可以更改我们应用程序的逻辑以执行实时拼接。这意味着每当捕获到两张图像时,我们就制作一张拼接图像。然后,我们借助 360 度用户界面展示结果,以便用户知道如果有的话,哪个是缺失的区域。
摘要
本章展示了在 Android Studio 中使用的完整全景应用程序,其中 OpenCV 3 在 Java 接口和 C++接口中均得到使用,并得到了原生开发工具包(NDK)的支持。本章还介绍了如何使用 OpenCV 库与 Android Camera API 结合。此外,本章还展示了使用 OpenCV 3 拼接模块的一些基本实现,以执行图像拼接。
第五章:工业应用中的通用目标检测
本章将向您介绍通用目标检测的世界,并更详细地探讨工业应用与标准学术研究案例相比的优势。正如许多人所知,OpenCV 3 包含著名的Viola 和 Jones 算法(作为 CascadeClassifier 类嵌入),该算法专门设计用于鲁棒的人脸检测。然而,相同的接口可以有效地用于检测任何满足您需求的物体类别。
注意
关于 Viola 和 Jones 算法的更多信息可以在以下出版物中找到:
使用简单特征的提升级联进行快速目标检测,Viola P. 和 Jones M.,(2001)。在计算机视觉和模式识别,2001 (CVPR 2001)。IEEE 计算机学会会议论文集,第 1 卷,第 I-511 页。IEEE。
本章假设您对 OpenCV 3 的级联分类接口有基本了解。如果没有,以下是一些理解此接口和提供的参数及软件的基本用法的好起点:
-
docs.opencv.org/master/modules/objdetect/doc/cascade_classification.html
-
docs.opencv.org/master/doc/tutorials/objdetect/cascade_classifier/cascade_classifier.html
注意
或者,您可以简单地阅读 PacktPub 出版的关于此主题更详细讨论的书籍之一,例如第三章,训练一个智能警报器来识别恶棍和他的猫,来自 Joseph Howse 的OpenCV for Secret Agents一书。
在本章中,我将带您了解使用 Viola 和 Jones 人脸检测框架进行通用目标检测时的重要元素。您将学习如何调整您的训练数据以适应您的特定设置,如何使您的目标检测模型具有旋转不变性,并且您将找到关于如何通过智能地使用环境参数和情境知识来提高检测器精度的指南。我们将更深入地探讨实际的物体类别模型,并解释发生了什么,结合一些用于可视化目标检测实际过程的智能工具。最后,我们将探讨 GPU 的可能性,这将导致更快的处理时间。所有这些都将结合代码示例和通用目标检测的示例用例。
识别、检测和分类之间的区别
为了完全理解这一章,重要的是你要理解基于级联分类的 Viola 和 Jones 检测框架实际上是一种物体分类技术,并且它与物体识别的概念有很大不同。这导致计算机视觉项目中常见的错误,即人们在事先没有充分分析问题的情况下,错误地决定使用这项技术来解决问题。考虑以下图所示的设置,它由一个连接相机的计算机组成。计算机内部有四个物体的描述(飞机、杯子、汽车和蛇)。现在,我们考虑向系统的相机提供三个新图像的情况。
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv3-bp/img/00071.jpeg
简单的计算机视觉设置
如果图像 A 呈现给系统,系统会创建给定输入图像的描述,并尝试将其与计算机内存数据库中图像的描述相匹配。由于特定的杯子略微旋转,杯子内存图像的描述符将与内存中其他物体图像的匹配更接近,因此这个系统能够成功识别出已知的杯子。这个过程被称为物体识别,并应用于我们知道我们想要在输入图像中找到的确切物体的情况。
“物体识别的目标是匹配(识别)特定的物体或场景。例如,识别特定的建筑,如比萨斜塔,或特定的画作,如蒙娜丽莎。即使物体在比例、相机视角、光照条件和部分遮挡等方面发生变化,也能识别出该物体。” | ||
---|---|---|
–安德烈亚·韦达利和安德鲁·齐斯泽曼 |
然而,这项技术有一些缺点。如果一个物体呈现给系统,而图像数据库中没有该物体的描述,系统仍然会返回最接近的匹配,因此结果可能会非常误导。为了避免这种情况,我们倾向于在匹配质量上设置一个阈值。如果没有达到阈值,我们就简单地不提供匹配。
当图像 B 呈现给相同的系统时,我们会遇到一个新的问题。给定输入图像与内存中杯子图像的差异如此之大(不同大小、不同形状、不同图案等),以至于图像 B 的描述符将不会与内存中杯子的描述相匹配,这是物体识别的一个大缺点。当图像 C 呈现给系统时,问题甚至进一步加剧。在那里,计算机内存中的已知汽车被呈现给相机系统,但它呈现的设置和背景与内存中的完全不同。这可能导致背景对物体描述符的影响如此之大,以至于物体不再被识别。
物体检测更进一步;它试图通过学习更具体的物体描述而不是仅仅学习图像本身的描述来在变化的设置中找到给定的物体。在可检测的物体类别变得更加复杂,并且物体在多个输入图像中的变化很大——我们不再谈论单个物体检测,而是关于检测一个物体类别——这就是物体分类发挥作用的地方。
在物体分类中,我们试图学习一个通用的模型来处理物体类别中的大量变化,如下面的图所示:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv3-bp/img/00072.jpeg
一个具有大量变化的物体类别示例:汽车和椅子/沙发
在这样一个单一物体类别中,我们试图应对不同的变化形式,如下面的图所示:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv3-bp/img/00073.jpeg
单个物体类别内的变化:光照变化、物体姿态、杂乱、遮挡、类内外观和视角
如果您计划使用 Viola 和 Jones 物体检测框架,确保您的应用程序实际上属于第三种和后续情况非常重要。在这种情况下,您想要检测的物体实例事先是未知的,并且它们具有很大的类内方差。每个物体实例可能在形状、颜色、大小、方向等方面有所不同。Viola 和 Jones 算法将所有这些方差建模成一个单一的对象模型,该模型能够检测到该类别的任何给定实例,即使该物体实例之前从未见过。这正是物体分类技术强大的地方,它们在给定的一组物体样本上很好地泛化,以学习整个物体类别的具体特征。
这些技术使我们能够为更复杂的类别训练物体检测器,因此物体分类技术在工业应用中非常理想,例如物体检查、物体拣选等,在这些应用中,通常使用的基于阈值的分割技术似乎由于设置中的这种大范围变化而失败。
如果您的应用程序不处理这些困难情况中的物体,那么考虑使用其他技术,如物体识别,如果它符合您的需求的话!
在我们开始实际工作之前,让我花点时间向您介绍在物体检测应用中常见的几个基本步骤。注意所有步骤并确保不要试图跳过其中的一些步骤以节省时间是非常重要的。这些都会影响物体检测接口的最终结果:
-
数据收集:这一步包括收集构建和测试您的对象检测器所需的数据。数据可以从视频序列到由网络摄像头捕获的图像等多种来源获取。这一步还将确保数据格式正确,以便准备好传递到训练阶段。
-
实际模型训练:在这一步,您将使用第一步收集到的数据来训练一个能够检测该模型类的对象模型。在这里,我们将研究不同的训练参数,并专注于定义适合您应用的正确设置。
-
对象检测:一旦您有一个训练好的对象模型,您就可以使用它来尝试在给定的测试图像中检测对象实例。
-
验证:最后,通过将每个检测与测试数据的手动定义的地面真实值进行比较,验证第三步的检测结果非常重要。我们将讨论各种用于效率和精度验证的选项。
让我们继续详细解释第一步,即数据收集,这也是本章的第一个子主题。
智能选择和准备特定于应用的训练数据
在本节中,我们将讨论根据情境背景需要多少训练样本,并强调在准备正训练样本的注释时的一些重要方面。
让我们先定义对象分类的原则及其与训练数据的关系,如图所示:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv3-bp/img/00074.jpeg
一个对象模型的正负训练数据的示例
算法的想法是,它接受一组正对象实例,这些实例包含您想要检测的对象的不同表现形式(这意味着在不同光照条件下、不同尺度、不同方向、小的形状变化等对象实例),以及一组负对象实例,这些实例包含您不希望模型检测到的所有内容。然后,这些实例被巧妙地组合成一个对象模型,并用于检测如图所示输入图像中的新对象实例。
训练数据量
许多对象检测算法高度依赖于大量的训练数据,或者至少这是预期的。这种范式是由于学术研究案例的出现,主要关注非常具有挑战性的案例,如行人和汽车检测。这些都是存在大量类内差异的对象类别,导致:
-
一个非常大的正负训练样本集,每个集合可能包含数千甚至数百万个样本。
-
移除所有污染训练集而不是帮助它的信息,例如颜色信息,而仅仅使用对这类类内变异性更鲁棒的特征信息,如边缘信息和像素强度差异。
因此,训练出的模型能够在几乎所有可能的情况下成功检测行人和汽车,但缺点是训练它们需要几周的处理时间。然而,当你观察更具有工业特定性的案例,例如从箱子中挑选水果或从传送带上抓取物体时,你会发现与这些极具挑战性的学术研究案例相比,物体和背景的变异性相当有限。这是一个我们可以利用的事实。
我们知道,最终对象模型的准确性高度依赖于所使用的训练数据。在需要检测器在所有可能情况下工作的案例中,提供大量数据似乎是合理的。复杂的学习算法将决定哪些信息是有用的,哪些不是。然而,在更受限的案例中,我们可以通过考虑我们的对象模型实际上需要做什么来构建对象模型。
例如,Facebook DeepFace 应用程序,使用神经网络方法在所有可能的情况下检测人脸,使用了 440 万张标记人脸。
注意
关于 DeepFace 算法的更多信息可以在以下找到:
Deepface: Closing the gap to human-level performance in face verification, Taigman Y., Yang M., Ranzato M. A., and Wolf L. (2014, June). In Computer Vision and Pattern Recognition (CVPR), 2014, IEEE Conference on (pp. 1701-1708).
因此,我们建议通过遵循一系列简单规则,只为你的对象模型使用有意义的正负样本训练样本:
-
对于正样本,仅使用自然发生样本。市面上有许多工具可以创建人工旋转、平移和倾斜的图像,将小型的训练集变成大型的训练集。然而,研究表明,这样得到的检测器性能不如简单地收集覆盖你应用实际情况的正样本。更好的做法是使用一组质量上乘的高质量对象样本,而不是使用大量低质量且不具有代表性的样本。
-
对于负样本,有两种可能的方法,但两者都始于这样一个原则:你收集负样本的情况是你检测器将要使用的情况,这与通常训练对象检测的方法非常不同,后者只是使用一大组不包含对象的随机样本作为负样本。
-
要么将摄像头对准你的场景,开始随机抓取帧以从负窗口中采样。
-
或者利用您的正图像的优势。裁剪实际的物体区域并将像素变为黑色。使用这些掩码图像作为负训练数据。请记住,在这种情况下,背景信息和窗口中实际发生的物体的比例需要足够大。如果您的图像充满了物体实例,裁剪它们将导致相关背景信息的完全丢失,从而降低负训练集的区分力。
-
-
尽量使用一个非常小的负样本集合。如果你的情况下只有 4 或 5 种背景情况可能发生,那么就没有必要使用 100 个负图像。只需从这五个具体案例中采样负窗口。
以这种方式高效地收集数据确保您最终会得到一个针对您特定应用的非常健壮的模型!然而,请记住,这也带来了一些后果。生成的模型将不会对训练时的情况之外的情景具有鲁棒性。然而,在训练时间和减少训练样本需求方面的好处完全超过了这一缺点。
备注
基于 OpenCV 3 的负样本生成软件可以在github.com/OpenCVBlueprints/OpenCVBlueprints/tree/master/chapter_5/source_code/generate_negatives/
找到。
您可以使用负样本生成软件生成如图所示的样本,其中草莓的物体注释被移除并用黑色像素替换。
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv3-bp/img/00075.jpeg
负图像生成工具输出示例,其中注释被裁剪并用黑色像素替换
如您所见,物体像素与背景像素之间的比例仍然足够大,以确保模型不会仅基于这些黑色像素区域训练其背景。请记住,通过简单地收集负图像来避免使用这些黑色像素化图像的方法,始终是更好的。然而,许多公司忘记了数据收集的这个重要部分,最终导致没有对应用有意义的负数据集。我进行的几个测试证明,使用来自应用随机帧的负数据集比基于黑色像素裁剪的图像具有更强的负区分力。
为正样本创建物体注释文件
在准备您的正数据样本时,花些时间在注释上是很重要的,这些注释是您物体实例在较大图像中的实际位置。没有适当的注释,您将永远无法创建出优秀的物体检测器。市面上有许多注释工具,但我基于 OpenCV 3 为您制作了一个,它允许您快速遍历图像并在其上添加注释。
备注
基于 OpenCV 3 的对象标注软件可以在github.com/OpenCVBlueprints/OpenCVBlueprints/tree/master/chapter_5/source_code/object_annotation/
找到。
OpenCV 团队非常友好,将此工具集成到主仓库的“apps”部分。这意味着,如果您在安装过程中构建并安装了 OpenCV 应用,则可以使用以下命令访问该工具:
/opencv_annotation -images <folder location> -annotations <output file>
使用该软件相当简单:
-
首先,在 GitHub 文件夹中的特定项目内运行 CMAKE 脚本。运行 CMAKE 后,软件将通过可执行文件提供访问。这种方法适用于本章中的每个软件组件。运行 CMAKE 界面相当简单:
cmakemake ./object_annotation -images <folder location> -annotations <output file>
-
这将生成一个需要一些输入参数的可执行文件,包括正图像文件的位置和输出检测文件。
注意
请记住,始终分配所有文件的绝对路径!
-
首先,将您的正图像文件夹的内容解析到文件中(通过在对象标注文件夹内使用的提供的
folder_listing
软件),然后执行标注命令:./folder_listing –folder <folder> -images <images.txt>
-
文件夹列表工具应该生成一个文件,其外观与以下所示完全相同:https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv3-bp/img/00076.jpeg
文件夹列表工具生成的正样本文件示例
-
现在,使用以下命令启动标注工具:
./object_annotation –images <images.txt> -annotations <annotations.txt>
-
这将启动软件,并在窗口中显示第一张图像,准备应用标注,如图所示:https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv3-bp/img/00077.jpeg
对象标注工具的示例
-
您可以从选择对象的左上角开始,然后移动鼠标直到达到对象的右下角,这可以在前图的左侧部分看到。然而,该软件允许您从每个可能的角落开始标注。如果您对选择不满意,则重新应用此步骤,直到标注符合您的需求。
-
一旦您同意所选的边界框,请按确认选择的按钮,默认为键 C。这将确认标注,将其颜色从红色变为绿色,并将其添加到标注文件中。请确保只有当您 100%确信选择时才接受标注。
-
对同一图像重复前面的两个步骤,直到您已标注图像中的每个对象实例,如前例图像的右侧部分所示。然后按保存结果的按钮,默认为 N 键。
-
最后,您将得到一个名为
annotations.txt
的文件,它结合了图像文件的存储位置以及训练图像中出现的所有对象实例的地面真实位置。
注意
如果您想调整所有单独操作所需的按钮,那么请打开object_annotation.cpp
文件,浏览到第 100 行和第 103 行。在那里,您可以调整分配给要用于操作的按钮的 ASCII 值。
您可以在www.asciitable.com/
找到分配给键盘键的所有 ASCII 码的概述。
软件输出的结果是在每个正样本文件夹中,一个*.txt
文件的对象检测列表,其结构如下所示(以下图所示):
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv3-bp/img/00078.jpeg
物体标注工具的示例
它从文件夹中每个图像的绝对文件位置开始。有选择不使用相对路径,因为这样文件将完全依赖于其存储的位置。然而,如果您知道自己在做什么,那么相对于可执行文件使用相对文件位置应该可以正常工作。使用绝对路径使其更通用且更安全。文件位置后面跟着该特定图像的检测数量,这使我们事先知道可以期待多少个地面真实对象。对于每个对象,存储到顶左角的(x, y)坐标与边界框的宽度和高度相结合。这为每个图像继续进行,每次检测输出文件中出现新行。
小贴士
对于进一步模型训练来说,将来自其他标注系统的每一组地面真实值首先转换为这种格式非常重要,以确保 OpenCV 3 中嵌入的级联分类软件能够良好工作。
当处理包含对象实例的正训练图像时,第二个需要注意的点是在实际放置对象实例边界框的方式上。一个良好且准确标注的地面真实集将始终为您提供更可靠的对象模型,并将产生更好的测试和准确度结果。因此,我建议在为您的应用程序进行物体标注时注意以下要点:
-
确保边界框包含整个对象,同时尽可能避免尽可能多的背景信息。对象信息与背景信息的比率应始终大于 80%。否则,背景可能会提供足够多的特征来训练您的模型,最终结果将是您的检测器模型专注于错误图像信息。
-
Viola 和 Jones 建议使用基于 24x24 像素模型的平方标注,因为它适合人脸的形状。然而,这并不是强制性的!如果你的目标类别更像是矩形,那么你应该标注矩形边界框而不是正方形。观察到人们倾向于将矩形形状的对象推入平方模型尺寸,然后 wonder 为什么它没有正确工作。以铅笔检测器为例,模型尺寸将更像是 10x70 像素,这与实际的铅笔尺寸相关。
-
尝试做简洁的图像批次。最好是重启应用程序 10 次,而不是在即将完成一组 1000 张带有相应标注的图像时系统崩溃。如果软件或你的计算机失败了,它确保你只需要重新做一小部分。
将你的正样本数据集解析到 OpenCV 数据向量
在 OpenCV 3 软件允许你训练级联分类器目标模型之前,你需要将你的数据推送到一个 OpenCV 特定的数据向量格式。这可以通过使用提供的 OpenCV 样本创建工具来完成。
注意
样本创建工具可以在github.com/Itseez/opencv/tree/master/apps/createsamples/
找到,并且如果 OpenCV 安装正确,它应该会自动构建,这使得可以通过opencv_createsamples
命令使用。
通过应用以下命令行界面指令创建样本向量非常简单直接:
./opencv_createsamples –info annotations.txt –vec images.vec –bg negatives.txt –num amountSamples –w model_width –h model_height
https://github.com/OpenCVBlueprints/OpenCVBlueprints/tree/master/chapter_5/source_code/average_dimensions/.
average haverage] are [60 60].If we would use those [60 60] dimensions, then we would have a model that can only detect apples equal and larger to that size. However, moving away from the tree will result in not a single apple being detected anymore, since the apples will become smaller in size.Therefore, I suggest reducing the dimensions of the model to, for example, [30 30]. This will result in a model that still has enough pixel information to be robust enough and it will be able to detect up to half the apples of the training apples size.Generally speaking, the rule of thumb can be to take half the size of the average dimensions of the annotated data and ensure that your largest dimension is not bigger than 100 pixels. This last guideline is to ensure that training your model will not increase exponentially in time due to the large model size. If your largest dimension is still over 100 pixels, then just keep halving the dimensions until you go below this threshold.
你现在已经准备好了你的正样本训练集。你最后应该做的事情是创建一个包含负图像的文件夹,从这些图像中你将随机采样负窗口,并对其应用文件夹列出功能。这将生成一个负数据引用文件,该文件将由训练界面使用。
训练目标模型时的参数选择
一旦你构建了一个不错的训练样本数据集,它已经准备好处理,那么是时候启动 OpenCV 3 的级联分类器训练软件了,它使用 Viola 和 Jones 级联分类器框架来训练你的目标检测模型。训练本身是基于应用提升算法在 Haar 小波特征或局部二值模式特征上。OpenCV 界面支持多种提升类型,但为了方便,我们使用常用的 AdaBoost 界面。
注意
如果你想了解特征计算的详细技术细节,请查看以下详细描述它们的论文:
-
HAAR: Papageorgiou, Oren 和 Poggio, “一个用于目标检测的通用框架”, 国际计算机视觉会议, 1998 年。
-
LBP: T. Ojala, M. Pietikäinen, and D. Harwood (1994), “基于 Kullback 分布判别的纹理度量性能评估”,第 12 届 IAPR 国际模式识别会议(ICPR 1994)论文集,第 1 卷,第 582 - 585 页。
本节将更详细地讨论训练过程的几个部分。它将首先阐述 OpenCV 如何运行其级联分类过程。然后,我们将深入探讨所有提供的训练参数以及它们如何影响训练过程和结果的准确性。最后,我们将打开模型文件,更详细地查看其中可以找到的内容。
训练对象模型时涉及的训练参数
-numNeg: This is the amount of negative samples used at each stage. However, this is not the same as the amount of negative images that were supplied by the negative data. The training samples negative windows from these images in a sequential order at the model size dimensions. Choosing the right amount of negatives is highly dependent on your application.
* If your application has close to no variation, then supplying a small number of windows could simply do the trick because they will contain most of the background variance.
* On the other hand, if the background variation is large, a huge number of samples would be needed to ensure that you train as much random background noise as possible into your model.
* A good start is taking a ratio between the number of positive and the number of negative samples equaling 0.5, so double the amount of negative versus positive windows.
* Keep in mind that each negative window that is classified correctly at an early stage will be discarded for training in the next stage since it cannot add any extra value to the training process. Therefore, you must be sure that enough unique windows can be grabbed from the negative images. For example, if a model uses 500 negatives at each stage and 100% of those negatives get correctly classified at each stage, then training a model of 20 stages will need 10,000 unique negative samples! Considering that the sequential grabbing of samples does not ensure uniqueness, due to the limited pixel wise movement, this amount can grow drastically.
`-numStages`: This is the amount of weak classifier stages, which is highly dependent on the complexity of the application.
* The more stages, the longer the training process will take since it becomes harder at each stage to find enough training windows and to find features that correctly separate the data. Moreover, the training time increases in an exponential manner when adding stages.
* Therefore, I suggest looking at the reported acceptance ratio that is outputted at each training stage. Once this reaches values of 10^(-5), you can conclude that your model will have reached the best descriptive and generalizing power it could get, according to the training data provided.
* Avoid training it to levels of 10^(-5) or lower to avoid overtraining your cascade on your training data. Of course, depending on the amount of training data supplied, the amount of stages to reach this level can differ a lot.
`-bg`: This refers to the location of the text file that contains the locations of the negative training images, also called the negative samples description file.`-vec`: This refers to the location of the training data vector that was generated in the previous step using the create_samples application, which is built-in to the OpenCV 3 software.`-precalcValBufSize` and `-precalcIdxBufSize`: These parameters assign the amount of memory used to calculate all features and the corresponding weak classifiers from the training data. If you have enough RAM memory available, increase these values to 2048 MB or 4096 MB, which will speed up the precalculation time for the features drastically.`-featureType`: Here, you can choose which kind of features are used for creating the weak classifiers.
* HAAR wavelets are reported to give higher accuracy models.
* However, consider training test classifiers with the LBP parameter. It decreases training time of an equal sized model drastically due to the integer calculations instead of the floating point calculations.
`-minHitRate`: This is the threshold that defines how much of your positive samples can be misclassified as negatives at each stage. The default value is 0.995, which is already quite high. The training algorithm will select its stage threshold so that this value can be reached.
* Making it 0.999, as many people do, is simply impossible and will make your training stop probably after the first stage. It means that only 1 out of 1,000 samples can be wrongly classified over a complete stage.
* If you have very challenging data, then lowering this, for example, to 0.990 could be a good start to ensure that the training actually ends up with a useful model.
`-maxFalseAlarmRate`: This is the threshold that defines how much of your negative samples need to be classified as negatives before the boosting process should stop adding weak classifiers to the current stage. The default value is 0.5 and ensures that a stage of weak classifier will only do slightly better than random guessing on the negative samples. Increasing this value too much could lead to a single stage that already filters out most of your given windows, resulting in a very slow model at detection time due to the vast amount of features that need to be validated for each window. This will simply remove the large advantage of the concept of early window rejection.
在尝试训练一个成功的分类器时,前面讨论的参数是最重要的几个需要深入挖掘的。一旦这个方法有效,你可以通过查看提升方法形成其弱分类器的方式,进一步提高你分类器的性能。这可以通过-maxDepth
和-maxWeakCount
参数来实现。然而,对于大多数情况,使用树桩弱分类器(单层决策树)对单个特征进行操作是开始的最佳方式,确保单阶段评估不会过于复杂,因此在检测时间上更快。
级联分类过程的详细说明
一旦你选择了正确的训练参数,你就可以开始级联分类器的训练过程,这将构建你的级联分类器对象检测模型。为了完全理解构建你的对象模型所涉及的级联分类过程,了解 OpenCV 如何基于提升过程进行对象模型的训练是非常重要的。
在我们这样做之前,我们将快速浏览一下提升原理的一般概述。
注意
关于提升原理的更多信息可以在 Freund Y., Schapire R., and Abe N (1999)的《提升简明介绍》中找到。Journal-Japanese Society For Artificial Intelligence, 14(771-780), 1612
提升背后的思想是,你有一个非常大的特征池,可以将其塑造成分类器。使用所有这些特征来构建单个分类器意味着你的测试图像中的每一个窗口都需要处理所有这些特征,这将花费非常长的时间,并使检测变慢,尤其是当你考虑到测试图像中可用的负窗口数量时。为了避免这种情况,并尽可能快地拒绝尽可能多的负窗口,提升选择那些最能区分正负数据的特征,并将它们组合成分类器,直到分类器在负样本上的表现略好于随机猜测。这一步被称为弱分类器。提升重复此过程,直到所有这些弱分类器的组合达到算法所需的确切精度。这种组合被称为强分类器。这个过程的主要优势是,大量的负样本将在少数早期阶段被丢弃,只需评估一小组特征,从而大大减少检测时间。
现在,我们将尝试使用 OpenCV 3 中嵌入的级联训练软件生成的输出,来解释整个过程。以下图示说明了如何从一组弱分类器阶段构建一个强大的级联分类器。
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv3-bp/img/00080.jpeg
结合弱分类器阶段和早期拒绝错误分类窗口,形成了著名的级联结构
级联分类器训练过程遵循迭代过程来训练后续阶段的弱分类器(1…N)。每个阶段由一组弱分类器组成,直到达到该特定阶段的特定标准。以下步骤概述了在 OpenCV 3 中根据输入参数和提供的数据在训练每个阶段时发生的情况。如果你对每个后续步骤的更具体细节感兴趣,那么请阅读 Viola 和 Jones 的研究论文(你可以在本章的第一页查看引用)。这里描述的所有步骤都会在达到强分类器所需的确切精度之前,对每个阶段重复进行。以下图示显示了这样一个阶段输出的样子:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv3-bp/img/00081.jpeg
分类器阶段训练的一个示例输出
步骤 1 – 抓取正面和负样本
你会注意到训练的第一步是抓取当前阶段的训练样本——首先是从你提供的数据向量中获取的正面样本,然后是从你提供的负图像中随机获取的负样本窗口。这两个步骤的输出如下:
POS:number_pos_samples_grabbed:total_number_pos_samples_needed NEG:number_neg_samples_grabbed:acceptanceRatioAchieved
如果找不到更多的正样本,将生成错误并停止训练。当你开始丢弃不再有用的正样本时,所需的样本总数会增加。当前阶段的负样本抓取可能比正样本抓取花费更长的时间,因为所有被前阶段正确分类的窗口都被丢弃,并搜索新的窗口。随着阶段数量的增加,这会变得更加困难。只要抓取的样本数量持续增加(是的,这可能会非常慢,所以请耐心等待),你的应用程序仍在运行。如果找不到更多的负样本,应用程序将结束训练,你需要降低每个阶段的负样本数量或添加额外的负图像。
在抓取负窗口后,报告了前一个阶段实现的接受率。这个值表明,到目前为止训练的模型是否足够强大,可以用于你的检测目的!
第 2 步 - 训练数据的积分图像和所有可能特征的预计算
一旦我们有了正负样本窗口大小的样本,预计算将计算窗口大小内所有可能的单个特征,并将其应用于每个训练样本。这可能会花费一些时间,具体取决于你的模型大小和训练样本的数量,特别是当你知道一个 24x24 像素的模型可以产生超过 16,000 个特征时。如前所述,分配更多的内存可以有所帮助,或者你可以选择选择 LBP 特征,其计算速度相对于 HAAR 特征要快得多。
所有特征都是在原始输入窗口的积分图像表示上计算的。这样做是为了加快特征的计算速度。Viola 和 Jones 的论文详细解释了为什么使用这种积分图像表示。
计算出的特征被倒入一个大的特征池中,提升过程可以从这个池中选择训练弱分类器所需的特征。这些弱分类器将在每个阶段中使用。
第 3 步 - 启动提升过程
现在,级联分类器训练已准备好进行实际的提升过程。这发生在几个小步骤中:
-
特征池中所有的可能弱分类器都在被计算。由于我们使用的是基于单个特征的 stumps(基本弱分类器)来构建决策树,因此弱分类器的数量与特征的数量相同。如果你愿意,你可以选择使用预定义的最大深度来训练实际的决策树,但这超出了本章的范围。
-
每个弱分类器都会被训练,以最小化训练样本上的误分类率。例如,当使用 Real AdaBoost 作为提升技术时,Gini 指数会被最小化。
注意
关于用于训练样本误分类率的 Gini 指数的更多信息,可以在以下内容中找到:
Gastwirth, J. L. (1972). The estimation of the Lorenz curve and Gini index. The Review of Economics and Statistics, 306-316.
-
具有最低误分类率的弱分类器被添加到当前阶段的下一个弱分类器。
-
基于已经添加到阶段的弱分类器,算法计算整体阶段阈值,该阈值被设置为保证所需的命中率。
-
现在,样本的权重根据它们在上一次迭代中的分类进行调整,这将产生下一轮迭代中的一组新的弱分类器,因此整个过程可以再次开始。
-
在单个阶段内组合弱分类器(这在训练输出中可视化)时,提升过程确保:
-
整体阶段阈值不会低于由训练参数选择的最低命中率。
-
与前一个阶段相比,负样本上的误报率降低。
-
-
此过程持续进行,直到:
-
在负样本上的误接受率低于设定的最大误报率。然后,过程简单地开始为检测模型训练新的阶段弱分类器。
-
达到了所需的阶段误报率,即
maxFalseAlarmRate^#stages
。这将导致模型训练结束,因为模型满足我们的要求,并且无法再获得更好的结果。这种情况不会经常发生,因为这个值下降得相当快,经过几个阶段后,这意味着你正确分类了超过 99%的正负样本。 -
命中率下降到阶段特定的最小命中率,即
minHitRate^#stages
。在这个阶段,太多的正样本被错误分类,并且你的模型的最大性能已经达到。
-
第 4 步 – 将临时结果保存到阶段文件
在训练每个阶段后,关于弱分类器和阈值的特定阶段细节被存储在数据文件夹中,在一个单独的 XML 文件中。如果达到了所需的阶段数,则将这些子文件合并成一个单一的级联 XML 文件。
然而,每个阶段都单独存储的事实意味着你可以在任何时候停止训练,并通过简单地重新启动训练命令来创建一个中间的对象检测模型,只需将-numStages
参数更改为你想要检查模型性能的阶段值。当你想要在一个验证集上执行评估以确保你的模型不会开始过度拟合训练数据时,这是理想的!
结果对象模型被详细解释
观察到许多使用 OpenCV 3 中嵌入的级联分类器算法的用户不知道存储在 XML 文件中的对象模型内部结构的含义,这有时会导致对算法的错误理解。本节将解释训练对象模型的每个内部部分。我们将讨论基于树桩型弱分类器的模型,但这个想法对于任何其他类型的弱分类器在阶段内部都是相同的,例如决策树。最大的不同是,与使用树桩特征相比,模型内部的权重计算变得更加复杂。至于每个阶段内部的弱分类器结构,我们将讨论基于 HAAR 和 LBP 特征的情况,因为这两个是 OpenCV 中用于训练级联分类器的最常用的特征。
注意
将用于解释所有内容的两个模型可以在以下位置找到
存储的每个 XML 模型的第一个部分描述了指定模型自身特征和一些重要训练参数的参数。随后,我们可以找到所使用的训练类型,目前仅限于提升,以及用于构建弱分类器的特征类型。我们还有将要训练的对象模型的宽度和高度,提升过程的参数,包括使用的提升类型、选定的最小命中比率和选定的最大误接受率。它还包含有关如何构建弱分类器阶段的信息,在我们的案例中,作为称为树桩的单个特征深度树的组合,每个阶段最多有 100 个弱分类器。对于基于 HAAR 小波模型,我们可以看到使用了哪些特征,仅限于基本的垂直特征或组合旋转 45 度的集合。
在训练特定参数之后,事情开始变得有趣。在这里,我们可以找到更多关于级联分类器对象模型实际结构的信息。描述了阶段的数量,然后通过迭代,模型总结了由提升过程生成的每个单独阶段的训练结果和阈值。对象模型的基本结构如下所示:
<stages>
<_>
<maxWeakCount></maxWeakCount>
<stageThreshold</stageThreshold>
<weakClassifiers>
<!-- tree 0 -->
<_>
<internalNodes></internalNodes>
<leafValues></leafValues></_>
<!-- tree 1 -->
<_>
<internalNodes></internalNodes>
<leafValues></leafValues></_>
<!-- tree 2 -->
… … …
<!-- stage 1 -->
… … …
</stages>
<features>
… … …
</features>
我们为每个阶段开始时使用一个空的迭代标签。在每一个阶段,定义了所使用的弱分类器的数量,在我们的情况下,这显示了在阶段内部使用了多少个单层决策树(树桩)。阶段阈值定义了窗口最终阶段得分的阈值。这是通过使用每个弱分类器对窗口进行评分,然后对整个阶段的评分结果进行求和和加权生成的。对于每个单个弱分类器,我们收集基于决策节点和所使用的层的内部结构。现有的值是用于创建决策树和叶值的提升值,这些叶值用于对由弱分类器评估的窗口进行评分。
内部节点结构的具体细节对于 HAAR 小波和基于特征的模型是不同的。叶评分的存储是相同的。然而,内部节点的值指定了与代码底部部分的关系,该部分包含实际的特征区域,并且对于 HAAR 和 LBP 方法都是不同的。这两种技术之间的差异可以在以下部分看到,为两种模型抓取第一阶段的第一个树和特征集的一部分。
HAAR-like wavelet feature models
以下是从基于 HAAR 小波特征的模型中提取的两个代码片段,包含内部节点结构和特征结构:
<internalNodes>
0 -1 445 -1.4772760681807995e-02
</internalNodes>
… … …
<_>
<rects>
<_>23 10 1 3 -1.</_>
<_>23 11 1 1 3.</_>
</rects>
<tilted>0</tilted>
</_>
对于内部节点,每个节点有四个值:
-
节点左和节点右:这些值表示我们有一个有两个叶子的树桩。
-
节点特征索引:这指向该节点在模型特征列表中的位置所使用的特征索引。
-
节点阈值:这是设置在该弱分类器特征值上的阈值,该阈值是从训练阶段的全部正负样本中学习的。由于我们正在查看基于树桩的弱分类器的模型,这也是阶段阈值,它在提升过程中设置。
基于 HAAR 的模型中的特征由一组矩形描述,这些矩形最多可以是三个,以便从窗口中计算每个可能的特征。然后,有一个值表示特征本身是否倾斜超过 45 度。对于每个矩形,即部分特征值,我们有:
-
矩形的定位,由矩形的左上角 x 和 y 坐标以及矩形的宽度和高度定义。
-
该特定部分特征的权重。这些权重用于将两个部分特征矩形组合成一个预定义的特征。这些权重使我们能够用比实际必要的更少的矩形来表示每个特征。以下图示展示了这一例子:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv3-bp/img/00082.jpeg
一个三矩形特征可以通过两个矩形加权组合来表示,从而减少了额外面积计算的需求。
特征和最终是通过首先将矩形内所有像素的值相加,然后乘以权重因子来计算的。最后,将这些加权和组合在一起,得到最终的特征值。请记住,为单个特征检索到的所有坐标都与窗口/模型大小相关,而不是整个处理过的图像。
局部二进制模式模型
以下是从基于 LBP 特征模型的两个代码片段,包含内部节点结构和特征结构:
<internalNodes>
0 -1 46 -67130709 -21569 -1426120013 -1275125205 -21585
-16385 587145899 -24005
</internalNodes>
… … …
<_>
<rect>0 0 3 5</rect>
</_>
NoteThe software for visualizing Haar wavelet or LBP models can be found at [`github.com/OpenCVBlueprints/OpenCVBlueprints/tree/master/chapter_5/source_code/visualize_models/`](https://github.com/OpenCVBlueprints/OpenCVBlueprints/tree/master/chapter_5/source_code/visualize_models/).
该软件接受多个输入参数,例如模型位置、需要可视化的图像以及需要存储结果的输出文件夹。然而,为了正确使用该软件,有一些需要注意的点:
-
模型需要基于 HAAR 小波或 LBP 特征。已删除,因为此功能不再支持 OpenCV 3。
-
您需要提供一个用于可视化的实际模型检测图像,并将其调整到模型尺度或训练数据中的正训练样本。这是为了确保您的模型特征放置在正确的位置。
-
在代码中,您可以调整可视化尺度,一个用于您模型的视频输出,另一个用于表示阶段的图像。
以下两个图分别展示了 Haar 小波和 LBP 特征基于的前脸模型的可视化结果,两者都包含在 OpenCV 3 仓库下的数据文件夹中。可视化图像分辨率低的原因非常明显。训练过程是在模型尺度上进行的;因此,我想从一个相同大小的图像开始,以说明物体的具体细节被移除,而物体类别的普遍特性仍然存在,以便能够区分类别。
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv3-bp/img/00084.jpeg
Haar 小波和局部二进制模式特征的前脸模型视频可视化的一组帧
例如,可视化也清楚地表明,LBP 模型需要更少的特征和因此更少的弱分类器来成功分离训练数据,这使得检测时间更快。
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv3-bp/img/00085.jpeg
Haar 小波和局部二进制模式特征的前脸模型第一阶段的可视化
使用交叉验证以实现最佳模型
确保在您的训练数据中获取最佳模型,可以通过应用交叉验证方法,如留一法来完成测试数据。其背后的思想是将训练集和测试集结合起来,并从更大的集中改变所使用的测试集。对于每个随机测试集和训练集,您将构建一个单独的模型,并使用本章进一步讨论的精确度-召回率进行评估。最后,提供最佳结果的模型可以采用作为最终解决方案。因此,它可以减轻由于训练集中未表示的新实例而导致的错误的影响。
注意
关于交叉验证主题的更多信息,可以在 Kohavi R. (1995, 八月) 的研究中找到,该研究探讨了在 Ijcai (第 14 卷,第 2 期,第 1137-1145 页) 中使用交叉验证和自助法进行准确度估计和模型选择。
使用场景特定知识和约束来优化检测结果
一旦您的级联分类器对象模型训练完成,您就可以使用它来检测新输入图像中相同对象类的实例,这些图像被提供给系统。然而,一旦应用了您的对象模型,您会发现仍然存在误报检测和未检测到的对象。本节将介绍一些技术,通过例如使用场景特定知识来移除大多数误报检测,以改善您的检测结果。
使用检测命令的参数来影响您的检测结果
如果将对象模型应用于给定的输入图像,必须考虑几个因素。让我们首先看看检测函数以及可以用来过滤检测输出的某些参数。OpenCV 3 提供了三个可能的接口。我们将讨论使用每个接口的优点。
接口 1:
void CascadeClassifier::detectMultiScale(InputArray image, vector<Rect>& objects, double scaleFactor=1.1, int minNeighbors=3, int flags=0, Size minSize=Size(), Size maxSize=Size())
第一个接口是最基本的。它允许您快速评估在给定测试图像上的训练模型。在这个基本界面上有几个元素,可以让您操作检测输出。我们将更详细地讨论这些参数,并强调在选择正确值时需要注意的一些要点。
scaleFactor 是用于将原始图像降级以创建图像金字塔的尺度步长,这使我们能够仅使用单个尺度模型执行多尺度检测。一个缺点是这不允许检测比对象尺寸更小的对象。使用 1.1 的值意味着在每一步中,尺寸相对于前一步减少了 10%。
-
增加此值将使您的检测器运行更快,因为它需要评估的尺度级别更少,但会带来丢失位于尺度步骤之间的检测的风险。
-
减少值会使您的探测器运行得更慢,因为需要评估更多的尺度级别,但会增加检测之前遗漏的对象的机会。此外,它会在实际对象上产生更多的检测,从而提高确定性。
-
请记住,添加尺度级别也会导致更多的假阳性检测,因为这些与图像金字塔的每一层都有关。
另一个有趣的参数是minNeighbors
参数。它描述了由于滑动窗口方法而发生的重叠检测的数量。任何与其他检测重叠超过 50%的检测将被合并为一个非极大值抑制。
-
将此值设为 0 意味着您将获得通过完整级联的所有检测生成的检测。然而,由于滑动窗口方法(以 8 像素的步长)以及级联分类器的性质(它们在对象参数上训练以更好地泛化对象类别),对于单个窗口,许多检测将发生。
-
添加一个值意味着您想要计算应该有多少个窗口,至少那些通过非极大值抑制组合在一起的窗口,以保持检测。这很有趣,因为实际对象应该产生比假阳性更多的检测。因此,增加这个值将减少假阳性检测的数量(它们重叠检测的数量很少)并保持真实检测(它们有大量的重叠检测)。
-
一个缺点是,在某个点上,实际对象由于检测确定性较低和重叠窗口较少而消失,而一些假阳性检测可能仍然存在。
使用minSize
和maxSize
参数有效地减少尺度空间金字塔。在一个工业设置中,例如,固定相机位置,如传送带设置,在大多数情况下可以保证对象将具有特定的尺寸。在这种情况下添加尺度值并定义尺度范围将大大减少单个图像的处理时间,通过去除不需要的尺度级别。作为额外的好处,所有那些不需要的尺度上的假阳性检测也将消失。如果您留这些值为空,算法将从头开始构建图像金字塔,以输入图像尺寸为基础,以等于尺度百分比的步长进行下采样,直到其中一个维度小于最大对象维度。这将成为图像金字塔的顶部,也是检测算法在检测时间开始运行对象检测器的位置。
界面 2:
void CascadeClassifier::detectMultiScale(InputArray image, vector<Rect>& objects, vector<int>& numDetections, double scaleFactor=1.1, int minNeighbors=3, int flags=0, Size minSize=Size(), Size maxSize=Size())
第二个接口通过添加numDetections
参数进行了一些小的改进。这允许你将minNeighbors
的值设置为 1,将重叠窗口的合并视为非极大值抑制,同时返回合并的重叠窗口的值。这个值可以看作是你检测的置信度分数。值越高,检测越好或越确定。
接口 3:
void CascadeClassifier::detectMultiScale(InputArray image, std::vector<Rect>& objects, std::vector<int>& rejectLevels, std::vector<double>& levelWeights, double scaleFactor=1.1, int minNeighbors=3, int flags=0, Size minSize=Size(), Size maxSize=Size(), bool outputRejectLevels=false )
这个接口的一个缺点是,100 个单个检测置信度非常低的窗口可以简单地否定一个单个检测置信度非常高的检测。这就是第三个接口可以为我们提供解决方案的地方。它允许我们查看每个检测窗口的个体分数(由分类器最后阶段的阈值值描述)。然后你可以抓取所有这些值,并设置这些个体窗口的置信度分数阈值。在这种情况下应用非极大值抑制时,所有重叠窗口的阈值值会合并。
小贴士
请记住,如果你想在 OpenCV 3.0 中尝试第三个接口,你必须将参数outputRejectLevels
设置为true
。如果不这样做,那么包含阈值分数的水平权重矩阵将不会被填充。
注意
可以在以下链接找到展示对象检测两种最常用接口的软件:github.com/OpenCVBlueprints/OpenCVBlueprints/tree/master/chapter_5/source_code/detect_simple
和 github.com/OpenCVBlueprints/OpenCVBlueprints/tree/master/chapter_5/source_code/detect_score
。OpenCV 的检测接口经常变化,因此可能已经存在这里未讨论的新接口。
提高对象实例检测并减少误报检测
一旦你为你的应用选择了最合适的方法来检索对象检测,你就可以评估你算法的正确输出。在训练对象检测器后,最常见的两个问题是:
-
未检测到的对象实例。
-
过多的误报检测。
第一个问题的原因可以通过查看我们基于该对象类的正样本训练数据训练的通用对象模型来解释。这让我们得出结论,训练要么:
-
没有足够的正样本训练数据,这使得无法很好地泛化到新的对象样本。在这种情况下,重要的是要将那些误检作为正样本添加到训练集中,并使用额外数据重新训练你的模型。这个原则被称为“强化学习”。
-
我们过度训练了模型以适应训练集,这再次减少了模型的泛化能力。为了避免这种情况,逐步减少模型的大小和复杂性。
第二个问题相当普遍,并且经常发生。不可能提供足够的负样本,同时确保在第一次运行时不会有任何负窗口仍然可能产生正检测。这主要是因为我们人类很难理解计算机如何根据特征来识别对象。另一方面,在训练对象检测器时,不可能一开始就掌握所有可能的场景(光照条件、生产过程中的交互、相机上的污垢等)。您应该将创建一个良好且稳定的模型视为一个迭代过程。
注意
避免光照条件影响的处理方法可以是,通过为每个样本生成人工暗淡和人工明亮图像来使训练集翻倍。然而,请记住本章开头讨论的人工数据的缺点。
为了减少误报检测的数量,我们通常需要添加更多的负样本。然而,重要的是不要添加随机生成的负窗口,因为这些窗口为模型带来的额外知识在大多数情况下只是微小的。添加有意义的负窗口,以提高检测器的质量会更好。这被称为使用自举过程的硬负样本挖掘。原理相当简单:
-
首先,根据您的初始正负窗口样本训练集训练第一个对象模型。
-
现在,收集一组负图像,这些图像要么针对您的应用特定(如果您想训练针对您设置的特定对象检测器)或者更通用(如果您希望您的对象检测器能在多种条件下工作)。
-
在这组负图像上运行您的检测器,使用低置信度阈值并保存所有找到的检测。从提供的负图像中裁剪它们,并重新调整大小以适应对象模型尺寸。
-
现在,重新训练您的对象模型,但将所有找到的窗口添加到您的负训练集中,以确保您的模型现在将使用这些额外知识进行训练。
这将确保您的模型精度根据负图像的质量以公平和合理的方式提高。
小贴士
当添加找到的额外且有用的负样本时,请将它们添加到background.txt
文件的顶部!这迫使 OpenCV 训练界面首先获取这些更重要的负样本,然后再采样所有标准负训练图像!确保它们具有精确的模型大小,这样它们就只能作为一次负训练样本使用。
获得旋转不变性对象检测
TipSoftware for performing rotation invariant object detection based on the described third approach can be found at [`github.com/OpenCVBlueprints/OpenCVBlueprints/tree/master/chapter_5/source_code/rotation_invariant_detection/`](https://github.com/OpenCVBlueprints/OpenCVBlueprints/tree/master/chapter_5/source_code/rotation_invariant_detection/).
这种方法的最大优点是,你只需要训练一个单方向模型,可以将你的时间投入到更新和调整这个单一模型,使其尽可能高效。另一个优点是,你可以通过提供一些重叠来结合不同旋转的所有检测,然后通过智能地移除在多个方向上没有检测到的假阳性,来增加检测的确定性。所以基本上,这是一种在方法的好处和缺点之间进行权衡。
然而,这种方法仍然存在一些缺点:
-
你需要将多尺度检测器应用于你的 3D 表示矩阵的每一层。这肯定会增加与单方向对象检测相比的对象实例搜索时间。
-
你将在每个方向上创建假阳性检测,这些检测也将被扭曲回,从而增加假阳性检测的总数。
让我们更深入地看看用于执行此旋转不变性的源代码部分,并解释实际上发生了什么。第一个有趣的部分可以在创建旋转图像的 3D 矩阵时找到:
// Create the 3D model matrix of the input image
Mat image = imread(input_image);
int steps = max_angle / step_angle;
vector<Mat> rotated_images;
cvtColor(rotated, rotated, COLOR_BGR2GRAY);
equalizeHist( rotated, rotated );
for (int i = 0; i < steps; i ++){
// Rotate the image
Mat rotated = image.clone();
rotate(image, (i+1)*step_angle, rotated);
// Preprocess the images
// Add to the collection of rotated and processed images
rotated_images.push_back(rotated);
}
基本上,我们做的是读取原始图像,创建一个包含每个旋转输入图像的 Mat 对象向量的数组,并在其上应用旋转函数。正如你将注意到的,我们立即应用所有需要的预处理步骤,以便使用级联分类器接口进行高效的对象检测,例如将图像渲染为灰度值并应用直方图均衡化,以应对光照变化。
旋转函数在这里可以看到:
void rotate(Mat& src, double angle, Mat& dst)
{
Point2f pt(src.cols/2., src.rows/2.);
Mat r = getRotationMatrix2D(pt, angle, 1.0);
warpAffine(src, dst, r, cv::Size(src.cols, src.rows));
}
此代码首先根据我们想要旋转的角度(以度为单位)计算一个旋转矩阵,然后根据这个旋转矩阵应用仿射变换。记住,以这种方式旋转图像可能会导致边缘对象的信息丢失。此代码示例假设你的对象将出现在图像的中心,因此这不会影响结果。你可以通过在原始图像周围添加黑色边框来避免这种情况。图像的宽度和高度相等,这样图像信息损失最小。这可以通过在读取原始输入图像后立即添加以下代码来完成:
Size dimensions = image.size();
if(dimensions.rows > dimensions.cols){
Mat temp = Mat::ones(dimensions.rows, dimensions.rows, image.type()) * 255;
int extra_rows = dimensions.rows - dimensions.cols;
image.copyTo(temp(0, extra_rows/2, image.rows, image.cols));
image = temp.clone();
}
if(dimensions.cols > dimensions.rows){
Mat temp = Mat::ones(dimensions.cols, dimensions.cols, image.type()) * 255;
int extra_cols = dimensions.cols - dimensions.rows;
image.copyTo(temp(extra_cols/2, 0, image.rows, image.cols));
image = temp.clone();
}
此代码将简单地根据最大尺寸将原始图像扩展到匹配一个正方形区域。
最后,在 3D 图像表示的每一层上,都会执行检测,并使用与扭曲原始图像类似的方法将找到的检测扭曲回原始图像:
-
将旋转图像中找到的四个检测到的角落点添加到一个用于旋转扭曲的矩阵中(代码行 95-103)。
-
根据当前旋转图像的角度应用逆变换矩阵(代码行 106-108)。
-
最后,在旋转的四矩阵点信息上绘制一个旋转矩形(代码行 111-128)。
下图显示了将旋转不变性人脸检测应用于具有多个方向的人脸图像的确切结果。
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv3-bp/img/00086.jpeg
以以下角度步长开始进行旋转不变性人脸检测:[1 度,10 度,25 度,45 度]
我们看到四次建议的技术被应用于相同的输入图像。我们调整了参数以观察对检测时间和返回的检测的影响。在所有情况下,我们都从 0 到 360 度进行了搜索,但在 3D 旋转矩阵的每个阶段之间改变了角度步长,从 0 到 45 度。
应用角度步长 | 执行所有检测的总时间 |
---|---|
1 度 | 220 秒 |
10 度 | 22.5 秒 |
25 度 | 8.6 秒 |
45 度 | 5.1 秒 |
如我们所见,当增加角度步长时,检测时间会大幅减少。知道一个物体模型本身至少可以覆盖总共 20 度,我们可以轻松地减小步长以显著减少处理时间。