原文:
annas-archive.org/md5/96ae7a0eb0ade91db24b62767897b433译者:飞龙
第五章:反向投影和直方图
在上一章中,我们学习了众多计算机视觉算法和 OpenCV 函数,这些函数可用于准备图像以供进一步处理或以某种方式修改它们。我们学习了如何在图像上绘制文本和形状,使用平滑算法对其进行过滤,对其进行形态学变换,并计算其导数。我们还学习了图像的几何和杂项变换,并应用色图来改变图像的色调。
在本章中,我们将学习一些更多用于准备图像以供进一步处理、推理和修改的算法和函数。这一点将在本章后面进一步阐明,在我们学习了计算机视觉中的直方图之后。我们将介绍直方图的概念,然后通过实际示例代码学习它们是如何计算和利用的。本章我们将学习的另一个极其重要的概念被称为反向投影。我们将学习如何使用直方图的反向投影来创建原始图像的修改版本。
除了它们的常规用途外,本章我们将学习的概念和算法在处理一些最广泛使用的目标检测和跟踪算法时也是必不可少的,这些算法将在接下来的章节中学习。
在本章中,我们将涵盖以下内容:
-
理解直方图
-
直方图反向投影
-
直方图比较
-
直方图均衡化
技术要求
-
用于开发 C++或 Python 应用程序的 IDE
-
OpenCV 库
请参阅第二章,使用 OpenCV 入门,获取有关如何设置个人计算机并使其准备好使用 OpenCV 库开发计算机视觉应用程序的更多信息。
您可以使用以下 URL 下载本章的源代码和示例:
github.com/PacktPublishing/Hands-On-Algorithms-for-Computer-Vision/tree/master/Chapter05
理解直方图
在计算机视觉中,直方图简单地说是表示像素值在像素可能接受值范围内的分布的图表,或者说,是像素的概率分布。嗯,这可能不像你期望的那样清晰,所以让我们以单通道灰度图像作为一个简单的例子来描述直方图是什么,然后扩展到多通道彩色图像等。我们已经知道,标准灰度图像中的像素可以包含 0 到 255 之间的值。考虑到这一点,一个类似于以下图表的图形,它描述了任意图像中每个可能的灰度像素值的像素数量比,就是该给定图像的直方图:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-algo-cv/img/00055.gif
考虑到我们刚刚学到的内容,可以很容易地猜测,例如,三通道图像的直方图将是由三个图表表示的,每个通道的值分布,类似于我们刚才看到的单通道灰度图像的直方图。
您可以使用 OpenCV 库中的 calcHist 函数计算一个或多个图像的直方图,这些图像可以是单通道或多通道。此函数需要提供一些参数,必须仔细提供才能产生所需的结果。让我们通过几个示例来看看这个函数是如何使用的。
以下示例代码(随后是对所有参数的描述)演示了如何计算单个灰度图像的直方图:
Mat image = imread("Test.png");
if(image.empty())
return -1;
Mat grayImg;
cvtColor(image, grayImg, COLOR_BGR2GRAY);
int bins = 256;
int nimages = 1;
int channels[] = {0};
Mat mask;
int dims = 1;
int histSize[] = { bins };
float rangeGS[] = {0, 256};
const float* ranges[] = { rangeGS };
bool uniform = true;
bool accumulate = false;
Mat histogram;
calcHist(&grayImg,
nimages,
channels,
mask,
histogram,
dims,
histSize,
ranges,
uniform,
accumulate);
从前面的代码中,我们可以推断出以下内容:
-
grayImg是想要计算其直方图的输入灰度图像,而histogram将包含结果。 -
nimages必须包含我们想要计算直方图的图像数量,在这个例子中,只有一个图像。 -
channels是一个数组,它应该包含我们想要计算其直方图的每个图像中通道的零基于索引号。例如,如果我们想要计算多通道图像中第一个、第二个和第四个通道的直方图,channels数组必须包含 0、1 和 3 的值。在我们的例子中,channels只包含0,因为我们正在计算灰度图像中唯一通道的直方图。 -
mask,这是许多其他 OpenCV 函数的共同参数,是一个用于屏蔽(或忽略)某些像素,或者换句话说,防止它们参与计算结果的参数。在我们的情况下,只要我们不在图像的某个部分上工作,mask必须包含一个空矩阵。 -
dims,或维度参数,对应于我们正在计算的直方图的结果维度。它必须不大于CV_MAX_DIM,在当前 OpenCV 版本中为 32。我们大多数情况下将使用1,因为我们期望我们的直方图是一个简单的数组形状矩阵。因此,结果直方图中每个元素的索引号将对应于箱子号。 -
histSize是一个数组,它必须包含每个维度中直方图的大小。在我们的例子中,由于维度是1,histSize必须包含一个单一值。在这种情况下,直方图的大小与直方图中的箱子数量相同。在前面的示例代码中,bins用于定义直方图中的箱子数量,并且它也被用作单一的histSize值。将bins想象为直方图中的像素组数量。这将在稍后的示例中进一步阐明,但就目前而言,重要的是要注意,bins的值为256将导致包含所有可能的单个像素值计数的直方图。 -
当计算图像的直方图时,
ranges必须包含对应于每个可能值范围的上下限值对。在我们的示例中,这意味着单个范围(0,256)中的一个值,这是我们提供给此参数的值。 -
uniform参数用于定义直方图的均匀性。请注意,如果直方图是非均匀的,与我们的示例中展示的不同,则ranges参数必须包含所有维度的下限和上限。 -
accumulate参数用于决定在计算直方图之前是否应该清除它,或者将计算出的值添加到现有的直方图中。当你需要使用多张图像计算单个直方图时,这非常有用。
我们将在本章提供的示例中尽可能多地介绍这里提到的参数。然而,你也可以参考calcHist函数的在线文档以获取更多信息。
显示直方图
显然,尝试使用如imshow之类的函数显示结果直方图是徒劳的,因为存储的直方图的原始格式类似于一个具有bins行数的单列矩阵。直方图的每一行,或者说每个元素,对应于落入该特定 bin 的像素数。考虑到这一点,我们可以使用第四章,绘图、滤波和变换中的绘图函数绘制计算出的直方图。
下面是一个示例,展示了我们如何将前面代码样本中计算出的直方图显示为具有自定义大小和属性的图形:
int gRows = 200; // height
int gCol = 500; // width
Scalar backGroundColor = Scalar(0, 255, 255); // yellow
Scalar graphColor = Scalar(0, 0, 0); // black
int thickness = 2;
LineTypes lineType = LINE_AA;
Mat theGraph(gRows, gCol, CV_8UC(3), backGroundColor);
Point p1(0,0), p2(0,0);
for(int i=0; i<bins; i++)
{
float value = histogram.at<float>(i,0);
value = maxVal - value; // invert
value = value / maxVal * theGraph.rows; // scale
line(theGraph,
p1,
Point(p1.x,value),
graphColor,
thickness,
lineType);
p1.y = p2.y = value;
p2.x = float(i+1) * float(theGraph.cols) / float(bins);
line(theGraph,
p1, p2,
Scalar(0,0,0));
p1.x = p2.x;
}
在前面的代码中,gRow和gCol分别代表结果的图形的高度和宽度。其余参数要么是自解释的(如backgroundColor等),或者你在前面的章节中已经了解过它们。注意histogram中的每个值是如何用来计算需要绘制的线的位置的。在前面的代码中,maxVal简单地用于将结果缩放到可见范围。以下是maxVal本身是如何计算的:
double maxVal = 0;
minMaxLoc(histogram,
0,
&maxVal,
0,
0);
如果你需要刷新关于minMaxLoc函数如何使用的记忆,请参阅第三章,数组和矩阵操作。在我们的示例中,我们只需要直方图中最大元素的值,所以我们通过将它们传递零来忽略其余参数。
以下是前面示例代码的结果:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-algo-cv/img/00056.jpeg
你可以通过提供的backGroundColor或graphColor参数轻松更改背景或图形颜色,或者通过更改thickness参数使图形更细或更粗,等等。
直方图的解释非常重要,尤其是在摄影和照片编辑应用中,因此能够可视化它们对于更容易解释结果至关重要。例如,在前例中,可以从结果直方图中轻松看出,源图像包含比亮色更多的暗色调。我们将在稍后看到更多关于暗色和亮色图像的示例,但在那之前,让我们看看改变桶数会如何影响结果。
下面的直方图是之前示例中相同图像的结果,从左到右分别使用 150、80 和 25 个桶进行柱状图可视化:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-algo-cv/img/00057.jpeg
你可以很容易地注意到,桶值越低,像素越聚集在一起。尽管这看起来更像是相同数据(从左到右)的较低分辨率,但实际上使用更少的桶值将相似像素聚集在一起是更好的选择。请注意,前例中的柱状图可视化是通过将前例代码中的for循环替换为以下代码产生的:
Point p1(0,0), p2(0, theGraph.rows-1);
for(int i=0; i<bins; i++)
{
float value = histogram.at<float>(i,0);
value *= 0.95f; // 5% empty at top
value = maxVal - value; // invert
value = value / (maxVal) * theGraph.rows; // scale
p1.y = value;
p2.x = float(i+1) * float(theGraph.cols) / float(bins);
rectangle(theGraph,
p1,
p2,
graphColor,
CV_FILLED,
lineType);
p1.x = p2.x;
}
这两种可视化(图形或柱状图)各有其优缺点,当你尝试计算不同类型图像的直方图时,这些优缺点将更加明显。让我们尝试计算一张彩色图像的直方图。我们需要计算各个通道的直方图,正如之前提到的。以下是一个示例代码,展示了如何进行操作:
Mat image = imread("Test.png");
if(image.empty())
{
cout << "Empty input image!";
return -1;
}
Mat imgChannels[3];
Mat histograms[3];
split(image, imgChannels);
// each imgChannels element is an individual 1-channel image
CvHistGraphColor, and running it would produce a result similar to what is seen in the following diagram:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-algo-cv/img/00058.jpeg
如前例代码所示,split函数被用来从我们的源彩色图像(默认为 BGR)中创建三个单独的图像,每个图像包含一个单独的通道。前例代码中提到的带有注释行的代码部分,实际上是一个for循环,它遍历imgChannels的元素,并使用与之前看到的完全相同的代码绘制每个图表,但每个图表都有其独特的颜色,该颜色是通过循环中的以下代码计算的:
Scalar graphColor = Scalar(i == 0 ? 255 : 0,
i == 1 ? 255 : 0,
i == 2 ? 255 : 0);
根据i的值,graphColor被设置为蓝色、绿色或红色,因此产生了之前图片中显示的直方图。
除了解释图像内容或查看像素值在图像中的分布情况外,直方图还有许多用途。在接下来的章节中,我们将学习关于反投影和其他算法,这些算法用于在我们的应用中利用直方图。
直方图的反投影
从上一节开头的直方图定义开始考虑,可以说图像上直方图的反向投影意味着用其每个像素的概率分布值替换它们。这在某种程度上(并不完全)是计算图像直方图的逆操作。当我们对图像上的直方图进行反向投影时,我们实际上是用直方图来修改图像。让我们首先看看如何使用 OpenCV 执行反向投影,然后深入了解其实际应用。
您可以使用 calcBackProject 函数来计算图像上直方图的反向投影。此函数需要与 calcHist 函数类似的参数集。让我们看看它是如何调用的,然后进一步分解其参数:
calcBackProject(&image,
nimages,
channels,
histogram,
backProj,
ranges,
scale,
uniform);
calcBackProject 函数中的 nimages、channels、ranges 和 uniform 参数的使用方式与 calcHist 函数中的使用方式完全相同。image 必须包含输入图像,而 histogram 需要通过先前的 calcHist 函数调用或任何其他方法(甚至手动)来计算。结果将通过使用 scale 参数进行缩放,最后将保存在 backProj 中。重要的是要注意,histogram 中的值可能超过正确的可显示范围,因此在进行反向投影后,结果 backProj 对象将无法正确显示。为了解决这个问题,我们需要首先确保 histogram 被归一化到 OpenCV 的可显示范围。以下代码必须在执行前面的 calcBackProject 调用之前执行,以便结果 backProj 可显示:
normalize(histogram,
histogram,
0,
255,
NORM_MINMAX);
以下图像展示了使用其原始直方图(未修改的直方图)进行反向投影的结果。右侧的图像是反向投影算法的结果:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-algo-cv/img/00059.jpeg
根据直方图和反向投影的定义,可以说前一个反向投影结果图像中的较暗区域包含比原始图像中更不常见的像素,反之亦然。此算法可用于(甚至滥用)使用修改后的或手动制作的直方图来改变图像。这种技术通常用于创建仅提取包含给定颜色或强度的图像部分的掩码。
下面是一个示例,演示了如何使用直方图和反向投影的概念来检测图像中位于可能像素值最亮 10% 范围内的像素:
int bins = 10; // we need 10 slices
float rangeGS[] = {0, 256};
const float* ranges[] = { rangeGS };
int channels[] = {0};
Mat histogram(bins, 1, CV_32FC1, Scalar(0.0));
histogram.at<float>(9, 0) = 255.0;
calcBackProject(&imageGray,
1,
channels,
histogram,
backProj,
ranges);
注意,直方图是手动形成的,有 10 个桶,而不是从原始图像中计算得出。然后,最后一个桶,或者说直方图的最后一个元素,被设置为 255,这意味着绝对白色。显然,如果没有这样做,我们就需要执行归一化以确保反向投影的结果在可显示的颜色范围内。
以下图像展示了在执行上述代码片段时,在之前示例中的相同样本图像上的结果:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-algo-cv/img/00060.jpeg
提取的掩码图像可以用于进一步修改图像,或者在具有独特颜色的对象的情况下,可以用于检测和跟踪该对象。检测和跟踪算法将在接下来的章节中详细讲解,但我们将学习如何具体使用对象的颜色。
学习更多关于反向投影
首先,让我们回顾一下 HSV 颜色空间在处理图像中像素的实际颜色值方面比标准的 RGB(或 BGR 等)颜色空间更适合。你可能需要回顾第一章,计算机视觉简介,以了解更多关于这一现象的信息。我们将利用这一简单事实来找到图像中具有特殊颜色的区域,无论其颜色强度、亮度等。因此,我们需要首先将图像转换为 HSV 颜色空间
让我们用一个示例案例来简化这一点。假设我们想要替换图像中的特定颜色,同时保留高光、亮度等。为了能够执行此类任务,我们需要能够准确检测给定的颜色,并确保我们只更改检测到的像素中的颜色,而不是它们的亮度和其他类似属性。以下示例代码演示了我们可以如何使用手动形成的色调通道直方图及其反向投影来提取具有特定颜色的像素,在这个例子中,假设颜色是蓝色:
- 要执行此类操作,我们需要首先读取一个图像,将其转换为 HSV 颜色空间,并提取色调通道,换句话说,就是第一个通道,如下所示:
Mat image = imread("Test.png");
if(image.empty())
{
cout << "Empty input image!";
return -1;
}
Mat imgHsv, hue;
vector<Mat> hsvChannels;
cvtColor(image, imgHsv, COLOR_BGR2HSV);
split(imgHsv, hsvChannels);
hue = hsvChannels[0];
- 现在我们已经将色调通道存储在
hue对象中,我们需要形成色调通道的适当直方图,其中只包含具有蓝色颜色的像素。色调值可以在0到360(度)之间,蓝色的色调值为240。因此,我们可以使用以下代码创建一个直方图,用于提取具有蓝色颜色的像素,偏移量(或阈值)为50像素:
int bins = 360;
int blueHue = 240;
int hueOffset = 50;
Mat histogram(bins, 1, CV_32FC1);
for(int i=0; i<bins; i++)
{
histogram.at<float>(i, 0) =
(i > blueHue - hueOffset)
&&
(i < blueHue + hueOffset)
?
255.0 : 0.0;
}
上述代码像一个简单的阈值,其中直方图中索引为240(加减50)的所有元素都被设置为255,其余的设置为零。
- 通过可视化手动创建的色调通道直方图,我们可以更好地了解将要使用它提取的确切颜色。以下代码可以轻松地可视化色调直方图:
double maxVal = 255.0;
int gW = 800, gH = 100;
Mat theGraph(gH, gW, CV_8UC3, Scalar::all(0));
Mat colors(1, bins, CV_8UC3);
for(int i=0; i<bins; i++)
{
colors.at<Vec3b>(i) =
Vec3b(saturate_cast<uchar>(
(i+1)*180.0/bins), 255, 255);
}
cvtColor(colors, colors, COLOR_HSV2BGR);
Point p1(0,0), p2(0,theGraph.rows-1);
for(int i=0; i<bins; i++)
{
float value = histogram.at<float>(i,0);
value = maxVal - value; // invert
value = value / maxVal * theGraph.rows; // scale
p1.y = value;
p2.x = float(i+1) * float(theGraph.cols) / float(bins);
rectangle(theGraph,
p1,
p2,
Scalar(colors.at<Vec3b>(i)),
CV_FILLED);
p1.x = p2.x;
}
在进行下一步之前,让我们分析前面的示例代码。它几乎与可视化灰度直方图或单个红、绿或蓝通道直方图完全相同。然而,关于前面代码的有趣事实是我们在哪里形成colors对象。colors对象将是一个简单的向量,包含色调光谱中所有可能的颜色,但根据我们的 bin 数量。注意我们是如何在 OpenCV 中使用saturate_cast函数来确保色调值饱和到可接受的范围。S 和 V 通道简单地设置为它们可能的最大值,即 255。在colors对象正确创建后,我们使用了之前相同的可视化函数。然而,由于 OpenCV 默认不显示 HSV 颜色空间中的图像(你可以在大多数图像显示函数和库中预期这种行为),我们需要将 HSV 颜色空间转换为 BGR 才能正确显示颜色。
尽管色调可以取(0,360)范围内的值,但无法将其存储在单字节 C++类型(如uchar)中,这些类型能够存储(0,255)范围内的值。这就是为什么在 OpenCV 中,色调值被认为是在(0,180)范围内,换句话说,它们只是简单地除以二。
以下图像描述了如果我们尝试使用imshow函数显示theGraph时前面示例代码的结果:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-algo-cv/img/00061.jpeg
如果我们使用其相应的直方图来计算图像的反向投影,我们将从中提取这些颜色。这个颜色范围是通过我们在形成直方图时所做的简单阈值(在循环中)创建的。显然,如果你将直方图的全部值设置为255.0而不是仅蓝色范围,你将得到整个颜色光谱。以下是一个简单的示例:
Mat histogram(bins, 1, CV_32FC1);
for(int i=0; i<bins; i++)
{
histogram.at<float>(i, 0) = 255.0;
}
可视化输出将是以下内容:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-algo-cv/img/00062.jpeg
现在,让我们回到我们原始的仅蓝色直方图,并继续进行剩余的步骤。
- 我们已经准备好计算我们示例的第一步中提取的色调通道上的直方图的反向投影。以下是操作方法:
int nimages = 1;
int channels[] = {0};
Mat backProject;
float rangeHue[] = {0, 180};
const float* ranges[] = {rangeHue};
double scale = 1.0;
bool uniform = true;
calcBackProject(&hue,
nimages,
channels,
histogram,
backProject,
ranges,
scale,
uniform);
这与我们创建灰度通道的反向投影非常相似,但在这个例子中,范围被调整为正确表示色调通道的可能值,即0到180。
以下图像显示了此类反向投影的结果,其中提取了蓝色像素:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-algo-cv/img/00063.jpeg
注意,具有灰度颜色(包括白色和黑色)的像素也可能具有与我们想要提取的色调值相似的价值,但由于改变它们的色调值不会对它们的颜色产生任何影响,因此我们可以在我们的示例案例中简单地忽略它们。
- 使用
calcBackProject函数提取像素后,我们需要调整这些像素的色调。我们只需遍历像素并将它们的第一个通道以任何期望的值进行偏移。显然,结果必须在显示之前转换为 BGR 格式。以下是具体步骤:
int shift = -50;
for(int i=0; i<imgHsv.rows; i++)
{
for(int j=0; j<imgHsv.cols; j++)
{
if(backProject.at<uchar>(i, j))
{
imgHsv.at<Vec3b>(i,j)[0] += shift;
}
}
}
Mat imgHueShift;
cvtColor(imgHsv, imgHueShift, CV_HSV2BGR);
在前面的示例中,我们使用了-50的shift值,这将导致蓝色像素变成绿色,同时保持其亮度,依此类推。使用不同的shift值会导致不同的颜色替换蓝色像素。以下是两个示例:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-algo-cv/img/00064.jpeg
在前面的示例中,我们学到的内容是许多基于颜色的检测和跟踪算法的基础,正如我们将在接下来的章节中学习的那样。能够正确提取特定颜色的像素,无论其亮度如何变化,都非常有用。当使用色调而不是红、绿或蓝通道时,颜色的亮度变化就是当某个颜色的物体上的光照改变,或者在白天和夜晚时发生的情况。
在进入本章的最后一部分之前,值得注意的是,我们用于显示假设色调通道手动制作的直方图的完全相同的可视化方法也可以用于可视化从图像计算出的颜色直方图。让我们通过一个示例来看看如何实现。
在前面的示例中,在初始步骤之后,我们不是手动形成直方图,而是简单地使用calcHist算法进行计算,如下所示:
int bins = 36;
int histSize[] = {bins};
int nimages = 1;
int dims = 1;
int channels[] = {0};
float rangeHue[] = {0, 180};
const float* ranges[] = {rangeHue};
bool uniform = true;
bool accumulate = false;
Mat histogram, mask;
calcHist(&hue,
nimages,
channels,
mask,
histogram,
dims,
histSize,
ranges,
uniform,
accumulate);
改变 bin 大小的影响与我们在灰度图和单通道直方图中看到的影响相似,即它将附近的值分组在一起。然而,在可视化色调通道时,附近的色调值将被分组在一起,这导致色调直方图更好地表示图像中的相似颜色。以下示例图像展示了前面可视化结果,但使用了不同的bins值。从上到下,计算每个直方图所使用的bins值分别是 360、100、36 和 7。注意,随着 bins 值的减小,直方图的分辨率降低:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-algo-cv/img/00065.jpeg
选择合适的 bins 值完全取决于你处理的对象类型以及你对相似颜色的定义。从前面的图像中可以看出,显然当我们需要至少一些相似颜色的分组时,选择一个非常高的 bin 值(例如 360)是没有用的。另一方面,选择一个非常小的 bin 大小可能会导致颜色极端分组,计算反向投影时不会产生准确的结果。请确保明智地选择 bins 值,并根据不同的主题进行变化。
比较直方图
可以通过比较直方图来获取对图像内容的某些洞察。OpenCV 允许使用名为compareHist的方法比较直方图,这需要首先设置比较方法。以下示例代码展示了如何使用此函数计算使用之前对calcHist函数的调用计算的两个直方图之间的比较结果:
HistCompMethods method = HISTCMP_CORREL;
double result = compareHist(histogram1, histogram2, method);
在前面的例子中,histogram1和histogram2仅仅是两个不同图像的直方图,或者是一个图像的不同通道。另一方面,method必须包含来自HistCompMethods枚举的有效条目,它定义了compareHist函数使用的比较算法,并且可以是以下方法中的任何一个:
-
HISTCMP_CORREL,用于相关方法 -
HISTCMP_CHISQR,用于卡方方法 -
HISTCMP_INTERSECT,用于交集方法 -
HISTCMP_BHATTACHARYYA,用于 Bhattacharyya 距离方法 -
HISTCMP_HELLINGER,与HISTCMP_BHATTACHARYYA相同 -
HISTCMP_CHISQR_ALT,用于替代卡方方法 -
HISTCMP_KL_DIV,用于 Kullback-Leibler 散度方法
您可以参考最新的 OpenCV 文档以获取有关每种方法的数学细节以及它们如何以及使用哪些直方图属性的信息。同样,这也适用于任何方法的解释结果。让我们通过一个示例来看看这意味着什么。使用以下示例代码,我们可以输出所有直方图比较方法的结果:
cout << "HISTCMP_CORREL: " <<
compareHist(histogram1, histogram2, HISTCMP_CORREL)
<< endl;
cout << "HISTCMP_CHISQR: " <<
compareHist(histogram1, histogram2, HISTCMP_CHISQR)
<< endl;
cout << "HISTCMP_INTERSECT: " <<
compareHist(histogram1, histogram2, HISTCMP_INTERSECT)
<< endl;
cout << "HISTCMP_BHATTACHARYYA: " <<
compareHist(histogram1, histogram2, HISTCMP_BHATTACHARYYA)
<< endl;
cout << "HISTCMP_HELLINGER: " <<
compareHist(histogram1, histogram2, HISTCMP_HELLINGER)
<< endl;
cout << "HISTCMP_CHISQR_ALT: " <<
compareHist(histogram1, histogram2, HISTCMP_CHISQR_ALT)
<< endl;
cout << "HISTCMP_KL_DIV: " <<
compareHist(histogram1, histogram2, HISTCMP_KL_DIV)
<< endl;
我们使用本章中一直使用的示例图像来计算histogram1和histogram2,换句话说,如果我们比较一个直方图与一个等直方图,这里是我们会得到的结果:
HISTCMP_CORREL: 1
HISTCMP_CHISQR: 0
HISTCMP_INTERSECT: 426400
HISTCMP_BHATTACHARYYA: 0
HISTCMP_HELLINGER: 0
HISTCMP_CHISQR_ALT: 0
HISTCMP_KL_DIV: 0
注意基于距离和发散的方法返回零值,而相关方法返回一值,对于完全相关。前面输出中的所有结果都表示等直方图。让我们通过计算以下两个图像的直方图来进一步说明:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-algo-cv/img/00066.gif
如果左边的图像用于创建histogram1,右边的图像用于创建histogram2,或者换句话说,一个任意亮图像与一个任意暗图像进行比较,以下结果将会产生:
HISTCMP_CORREL: -0.0449654
HISTCMP_CHISQR: 412918
HISTCMP_INTERSECT: 64149
HISTCMP_BHATTACHARYYA: 0.825928
HISTCMP_HELLINGER: 0.825928
HISTCMP_CHISQR_ALT: 1.32827e+06
HISTCMP_KL_DIV: 3.26815e+06
重要的是要注意,在某些情况下,传递给compareHist函数的直方图的顺序很重要,例如当使用HISTCMP_CHISQR作为方法时。以下是histogram1和histogram2以相反顺序传递给compareHist函数的结果:
HISTCMP_CORREL: -0.0449654
HISTCMP_CHISQR: 3.26926e+06
HISTCMP_INTERSECT: 64149
HISTCMP_BHATTACHARYYA: 0.825928
HISTCMP_HELLINGER: 0.825928
HISTCMP_CHISQR_ALT: 1.32827e+06
HISTCMP_KL_DIV: 1.15856e+07
比较直方图非常有用,尤其是在我们需要更好地了解各种图像之间的变化时。例如,比较来自摄像机的连续帧的直方图可以给我们一个关于这些连续帧之间强度变化的想法。
直方图均衡化
使用我们迄今为止学到的函数和算法,我们可以增强图像的强度分布,换句话说,调整过暗或过亮图像的亮度,以及其他许多操作。在计算机视觉中,直方图均衡化算法出于完全相同的原因被使用。此算法执行以下任务:
-
计算图像的直方图
-
归一化直方图
-
计算直方图的积分
-
使用更新的直方图修改源图像
除了积分部分,它只是简单地计算所有箱中值的总和之外,其余的都是在本章中以某种方式已经执行过的。OpenCV 包含一个名为 equalizeHist 的函数,该函数执行所有提到的操作,并生成一个具有均衡直方图的图像。让我们首先看看这个函数是如何使用的,然后尝试一个示例来看看我们自己的效果。
以下示例代码展示了如何使用 equalizeHist 函数,这个函数极其容易使用,并且不需要任何特殊参数:
Mat equalized;
equalizeHist(gray, equalized);
让我们考虑以下图像,它极度过度曝光(或明亮),以及其直方图,如右侧所示:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-algo-cv/img/00067.gif
使用 equalizeHist 函数,我们可以得到对比度和亮度更好的图像。以下是前面示例图像在直方图均衡化后的结果图像和直方图:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-algo-cv/img/00068.gif
当我们不得不处理可能过度曝光(太亮)或欠曝光(太暗)的图像时,直方图均衡化非常有帮助。例如,X 射线扫描图像,其中细节只有在使用强背光增加对比度和亮度时才可见,或者当我们与可能具有强烈光线变化的视频帧一起工作时,这些都是可以使用直方图均衡化来确保其余算法始终处理相同的,或者只是略微不同的亮度对比度级别的情况。
摘要
我们从学习直方图开始本章,了解它们是什么,以及如何使用 OpenCV 库计算它们。我们学习了直方图的箱大小及其如何影响直方图中值的准确性或分组。我们继续学习使用我们在第四章绘图、过滤和转换中学到的函数和算法来可视化直方图。在经过各种可视化类型后,我们学习了反向投影以及如何使用直方图更新图像。我们学习了检测具有特定颜色的像素以及如何移动色调值,从而仅改变这些特定像素的颜色。在本章的最后部分,我们学习了比较直方图和直方图均衡化算法。我们进行了可能的直方图比较场景的动手示例,并增强了曝光过度的图像的对比度和亮度。
直方图及其如何通过反向投影增强和修改图像是计算机视觉主题之一,不能轻易跳过或错过,因为它构成了许多图像增强算法和照片编辑应用中的技术基础,或者,正如我们将在接下来的章节中看到的,它是某些最重要的实时检测和跟踪算法的基础。在本章中,我们学习了直方图和反向投影的一些最实用的用例,但如果你开始构建使用直方图的现实生活项目,这些算法肯定还有更多内容。
在下一章中,我们将使用在本章和前几章中学到的所有概念来处理视频和视频帧,以检测具有特定颜色的对象,实时跟踪它们,或在视频中检测运动。
问题
-
计算三通道图像中第二个通道的直方图。使用可选的箱大小和 0 到 100 的范围作为第二个通道的可能值。
-
创建一个直方图,可用于与
calcBackProject函数一起使用,以从灰度图像中提取最暗的像素。考虑最暗的 25% 可能的像素值作为我们想要提取的灰度强度。 -
在上一个问题中,如果我们需要排除而不是提取最暗和最亮的 25% 的像素,在蒙版中会怎样?
-
红色的色调值是多少?应该移动多少才能得到蓝色?
-
创建一个色调直方图,可用于从图像中提取红色像素。考虑将 50 作为被认为是红色的像素的偏移量。最后,可视化计算出的色调直方图。
-
计算直方图的积分。
-
对彩色图像执行直方图均衡化。注意,
equalizeHist函数仅支持单通道 8 位灰度图像的直方图均衡化。
进一步阅读
-
通过示例学习 OpenCV 3.x 与 Python 第二版 (
www.packtpub.com/application-development/opencv-3x-python-example-second-edition) -
使用 OpenCV 3 和 Qt5 进行计算机视觉 (
www.packtpub.com/application-development/computer-vision-opencv-3-and-qt5)
第六章:视频分析——运动检测和跟踪
作为一名计算机视觉开发者,你绝对无法避免处理来自存储的视频文件或摄像头以及其他类似来源的视频流。将视频帧视为单独的图像是处理视频的一种方法,令人惊讶的是,这并不需要比你所学到的更多的努力或算法知识。例如,你可以对视频应用平滑滤波器,或者说,对一组视频帧应用,就像你在对单个图像应用时一样。这里的唯一技巧是,你必须按照第二章“OpenCV 入门”中描述的方法从视频中提取每一帧。然而,在计算机视觉中,有一些算法旨在与连续的视频帧一起工作,并且它们操作的结果不仅取决于单个图像,还取决于对前一个帧进行相同操作的结果。我们刚才提到的两种算法类型将是本章的主要内容。
在上一章学习了直方图和反向投影图像之后,我们已准备好去应对用于实时检测和跟踪物体的计算机视觉算法。这些算法高度依赖于我们对第五章“反向投影和直方图”中所有主题的牢固理解。基于此,我们将从几个简单的例子开始本章,这些例子展示了如何使用我们迄今为止学到的计算机视觉算法来处理视频文件或摄像头捕获的帧,然后我们将继续学习关于两种最著名的对象检测和跟踪算法——均值漂移和 CAM 漂移算法。接着,我们将学习如何使用卡尔曼滤波器来校正我们的对象检测和跟踪算法的结果,以及如何去除结果中的噪声以获得更好的跟踪结果。本章的结尾,我们将学习运动分析和背景/前景提取。
本章将涵盖以下内容:
-
如何在视频上应用过滤器并执行此类操作
-
使用均值漂移算法检测和跟踪对象
-
使用 CAM 漂移算法检测和跟踪对象
-
使用卡尔曼滤波器提高跟踪结果并去除噪声
-
使用背景和前景提取算法
技术要求
-
用于开发 C++或 Python 应用程序的 IDE
-
OpenCV 库
有关如何设置个人计算机并使其准备好使用 OpenCV 库开发计算机视觉应用程序的更多信息,请参阅第二章“OpenCV 入门”。
您可以使用以下 URL 下载本章的源代码和示例:github.com/PacktPublishing/Hands-On-Algorithms-for-Computer-Vision/tree/master/Chapter06。
处理视频
要能够在视频上使用我们迄今为止学到的任何算法,我们需要能够读取视频帧并将它们存储在Mat对象中。本书的前几章我们已经学习了如何处理视频文件、摄像头和 RTSP 流。因此,在此基础上,利用我们之前章节中学到的知识,我们可以使用以下类似的代码,以便将颜色图应用于计算机默认摄像头的视频流:
VideoCapture cam(0);
// check if camera was opened correctly
if(!cam.isOpened())
return -1;
// infinite loop
while(true)
{
Mat frame;
cam >> frame;
if(frame.empty())
break;
applyColorMap(frame, frame, COLORMAP_JET);
// display the frame
imshow("Camera", frame);
// stop camera if space is pressed
if(waitKey(10) == ' ')
break;
}
cam.release();
正如我们在第二章“使用 OpenCV 入门”中学到的,我们只需创建一个VideoCapture对象,并从默认摄像头(索引为零)读取视频帧。在前面的示例中,我们添加了一行代码,用于在提取的视频帧上应用颜色图。尝试前面的示例代码,你会看到COLORMAP_JET颜色图被应用于摄像头的每一帧,就像我们在第四章“绘制、过滤和变换”中学到的那样,最终结果实时显示。按下空格键将停止视频处理。
同样,我们可以根据按下的特定键实时执行不同的视频处理算法。以下是一个示例,只需替换前面代码中的for循环,除非按下J或H键,否则将显示原始视频:
int key = -1;
while(key != ' ')
{
Mat frame;
cam >> frame;
if(frame.empty())
break;
switch (key)
{
case 'j': applyColorMap(frame, frame, COLORMAP_JET);
break;
case 'h': applyColorMap(frame, frame, COLORMAP_HOT);
break;
}
imshow("Camera", frame);
int k = waitKey(10);
if(k > 0)
key = k;
}
除非按下提到的任何键,否则将显示摄像头的原始输出视频。按下J键将触发COLORMAP_JET,而按下H键将触发COLORMAP_HOT颜色图应用于摄像头帧。与前面的示例类似,按下空格键将停止过程。此外,按下除空格、J或H以外的任何键将导致显示原始视频。
前面示例中的applyColorMap函数只是一个随机算法,用于描述实时处理视频所使用的技巧。你可以使用本书中学到的任何在单个图像上执行的算法。例如,你可以编写一个程序对视频执行平滑滤波,或傅里叶变换,甚至编写一个实时显示色调通道直方图的程序。用例无限,然而,用于所有在单个图像上执行单个完整操作算法的方法几乎相同。
除了对单个视频帧执行操作外,还可以执行依赖于任意数量连续帧的操作。让我们看看如何使用一个非常简单但极其重要的用例来完成这项操作。
假设我们想要找到在任何给定时刻从摄像机读取的最后 60 帧的平均亮度。当帧的内容非常暗或非常亮时,这个值在自动调整视频亮度时非常有用。实际上,大多数数码相机的内部处理器以及您口袋里的智能手机通常会执行类似的操作。您可以尝试打开智能手机上的相机,将其对准光源,或太阳,或者进入一个非常黑暗的环境。以下代码演示了如何计算最后 60 帧的平均亮度并将其显示在视频的角落:
VideoCapture cam(0);
if(!cam.isOpened())
return -1;
vector<Scalar> avgs;
int key = -1;
while(key != ' ')
{
Mat frame;
cam >> frame;
if(frame.empty())
break;
if(avgs.size() > 60) // remove the first item if more than 60
avgs.erase(avgs.begin());
Mat frameGray;
cvtColor(frame, frameGray, CV_BGR2GRAY);
avgs.push_back( mean(frameGray) );
Scalar allAvg = mean(avgs);
putText(frame,
to_string(allAvg[0]),
Point(0,frame.rows-1),
FONT_HERSHEY_PLAIN,
1.0,
Scalar(0,255,0));
imshow("Camera", frame);
int k = waitKey(10);
if(k > 0)
key = k;
}
cam.release();
对于大多数情况,这个示例代码与我们本章前面看到的示例非常相似。这里的主要区别在于,我们将使用 OpenCV 的均值函数计算的最后 60 帧的平均值存储在一个Scalar对象的vector中,然后我们计算所有平均值的总平均值。然后使用putText函数将计算出的值绘制在输入帧上。以下图像显示了执行前面的示例代码时显示的单个帧:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-algo-cv/img/00069.jpeg
注意图像左下角显示的值,当视频帧的内容变暗时,该值将开始减小,当内容变亮时,该值将增加。基于这个结果,您可以,例如,改变亮度值或警告您的应用程序用户内容太暗或太亮,等等。
本章初始部分的例子旨在教会您如何使用在前几章中学到的算法处理单个帧,以及一些简单的编程技术,这些技术用于根据连续帧计算一个值。在本章接下来的部分,我们将学习一些最重要的视频处理算法,特别是目标检测和跟踪算法,这些算法依赖于我们在本节以及本书前几章中学到的概念和技术。
理解均值漂移算法
均值漂移算法是一个迭代算法,可以用来找到密度函数的最大值。将前面的句子粗略地翻译成计算机视觉术语,可以表达为以下内容——均值漂移算法可以使用反向投影图像在图像中找到对象。但它是如何在实际中实现的呢?让我们一步一步地来探讨。以下是使用均值漂移算法找到对象的单个操作步骤,按顺序如下:
-
图像的后投影是通过使用修改后的直方图来创建的,以找到最有可能包含我们的目标对象的像素。(过滤后投影图像以去除不想要的噪声也是常见的操作,但这是一种可选操作,用于提高结果。)
-
需要一个初始搜索窗口。这个搜索窗口在经过多次迭代后,将包含我们的目标对象,这一点我们将在下一步说明。每次迭代后,搜索窗口都会通过算法进行更新。搜索窗口的更新是通过在后投影图像中计算搜索窗口的质量中心,然后将当前搜索窗口的中心点移动到窗口的质量中心来实现的。以下图片展示了搜索窗口中质量中心的概念以及移动过程:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-algo-cv/img/00070.gif
前一张图中箭头两端的两个点对应于搜索窗口中心和质量中心。
- 正如任何迭代算法一样,均值漂移算法需要一些终止条件来在结果符合预期或达到一个可接受的结果时停止算法。因此,迭代次数和 epsilon 值被用作终止条件。无论是达到算法中的迭代次数,还是找到一个小于给定 epsilon 值的位移距离(收敛),算法都将停止。
现在,让我们通过使用 OpenCV 库来实际看看这个算法是如何应用的。OpenCV 中的 meanShift 函数几乎完全按照前面步骤中描述的那样实现了均值漂移算法。这个函数需要一个后投影图像、一个搜索窗口和终止条件,并在以下示例中展示了其用法:
Rect srchWnd(0, 0, 100, 100);
TermCriteria criteria(TermCriteria::MAX_ITER
+ TermCriteria::EPS,
20, // number of iterations
1.0 // epsilon value
);
// Calculate back-projection image
meanShift(backProject,
srchWnd,
criteria);
srchWnd 是一个 Rect 对象,它只是一个必须包含初始值并随后由 meanShift 函数更新和使用的矩形。backProjection 必须包含一个适当的后投影图像,该图像可以通过我们在第五章 5.3 后投影和直方图中学习到的任何方法计算得出。TermCriteria 类是 OpenCV 中的一个类,它被需要类似终止条件的迭代算法使用。第一个参数定义了终止条件的类型,可以是 MAX_ITER(与 COUNT 相同)、EPS 或两者兼具。在前面的例子中,我们使用了 20 次迭代的终止条件和 1.0 的 epsilon 值,当然,这个值可以根据环境和应用进行更改。这里需要注意的最重要的一点是,更多的迭代次数和更低的 epsilon 值可以产生更准确的结果,但这也可能导致性能变慢,反之亦然。
上述示例只是展示了如何调用meanShift函数。现在,让我们通过一个完整的动手实践示例来学习我们的第一个实时目标跟踪算法:
- 我们将要创建的跟踪示例结构与本章之前的示例非常相似。我们需要使用
VideoCapture类在计算机上打开一个视频或摄像头,然后开始读取帧,如下所示:
VideoCapture cam(0);
if(!cam.isOpened())
return -1;
int key = -1;
while(key != ' ')
{
Mat frame;
cam >> frame;
if(frame.empty())
break;
int k = waitKey(10);
if(k > 0)
key = k;
}
cam.release();
再次,我们使用了waitKey函数来停止循环,如果按下空格键。
- 我们将假设我们的目标对象是绿色的。因此,我们将形成一个只包含绿色色调的色调直方图,如下所示:
int bins = 360;
int grnHue = 120; // green color hue value
int hueOffset = 50; // the accepted threshold
Mat histogram(bins, 1, CV_32FC1);
for(int i=0; i<bins; i++)
{
histogram.at<float>(i, 0) =
(i > grnHue - hueOffset)
&&
(i < grnHue + hueOffset)
?
255.0 : 0.0;
}
这需要在进入过程循环之前完成,因为我们的直方图将在整个过程中保持不变。
- 在进入实际过程循环和跟踪代码之前,还有一件事需要注意,那就是终止条件,它将在整个过程中保持不变。以下是创建所需终止条件的步骤:
Rect srchWnd(0,0, 100, 100);
TermCriteria criteria(TermCriteria::MAX_ITER
+ TermCriteria::EPS,
20,
1.0);
当使用 Mean Shift 算法跟踪对象时,搜索窗口的初始值非常重要,因为这个算法总是对要跟踪的对象的初始位置做出假设。这是 Mean Shift 算法的一个明显缺点,我们将在本章后面讨论 CAM Shift 算法及其在 OpenCV 库中的实现时学习如何处理它。
- 在我们用于跟踪代码的
while循环中读取每一帧之后,我们必须使用我们创建的绿色色调直方图来计算输入帧的后投影图像。以下是操作步骤:
Mat frmHsv, hue;
vector<Mat> hsvChannels;
cvtColor(frame, frmHsv, COLOR_BGR2HSV);
split(frmHsv, hsvChannels);
hue = hsvChannels[0];
int nimages = 1;
int channels[] = {0};
Mat backProject;
float rangeHue[] = {0, 180};
const float* ranges[] = {rangeHue};
double scale = 1.0;
bool uniform = true;
calcBackProject(&hue,
nimages,
channels,
histogram,
backProject,
ranges,
scale,
uniform);
您可以参考第五章,后投影和直方图,以获取更多关于计算后投影图像的详细说明。
- 调用
meanShift函数,使用后投影图像和提供的终止条件来更新搜索窗口,如下所示:
meanShift(backProject,
srchWnd,
criteria);
- 为了可视化搜索窗口,或者说跟踪对象,我们需要在输入帧上绘制搜索窗口矩形。以下是使用矩形函数进行此操作的方法:
rectangle(frame,
srchWnd, // search window rectangle
Scalar(0,0,255), // red color
2 // thickness
);
我们也可以对后投影图像结果做同样的事情,然而,首先我们需要将后投影图像转换为 BGR 颜色空间。请记住,后投影图像的结果包含了一个与输入图像深度相同的单通道图像。以下是我们在后投影图像上搜索窗口位置绘制红色矩形的步骤:
cvtColor(backProject, backProject, COLOR_GRAY2BGR);
rectangle(backProject,
srchWnd,
Scalar(0,0,255),
2);
- 使用B和V键在背投影和原始视频帧之间切换。以下是操作步骤:
switch(key)
{
case 'b': imshow("Camera", backProject);
break;
case 'v': default: imshow("Camera", frame);
break;
}
让我们尝试运行我们的程序,看看它在稍微受控的环境中执行时表现如何。以下图片展示了搜索窗口的初始位置和我们的绿色目标对象,在原始帧视图和后投影视图中:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-algo-cv/img/00071.jpeg
移动物体将导致meanShift函数更新搜索窗口,从而跟踪物体。以下是另一个结果,展示了物体被跟踪到视图的右下角:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-algo-cv/img/00072.jpeg
注意到角落处可以看到的一小部分噪声,这将被meanShift函数处理,因为质量中心不太受其影响。然而,如前所述,对后投影图像进行某种类型的过滤以去除噪声是个好主意。例如,在噪声类似于我们在后投影图像中看到的情况下,我们可以使用GaussianBlur函数,或者更好的是erode函数,以去除后投影图像中的不需要的像素。有关如何使用过滤函数的更多信息,您可以参考第四章,绘图、过滤和转换。
在此类跟踪应用中,我们通常需要观察、记录或以任何方式处理在任意给定时刻之前以及所需时间段内感兴趣对象所走过的路线。这可以通过使用搜索窗口的中心点简单地实现,如下面的示例所示:
Point p(srchWnd.x + srchWnd.width/2,
srchWnd.y + srchWnd.height/2);
route.push_back(p);
if(route.size() > 60) // last 60 frames
route.erase(route.begin()); // remove first element
显然,route是一个Point对象的vector。在调用meanShift函数后,需要更新route,然后我们可以使用以下对polylines函数的调用,以便在原始视频帧上绘制route:
polylines(frame,
route, // the vector of Point objects
false, // not a closed polyline
Scalar(0,255,0), // green color
2 // thickness
);
以下图片展示了在从相机读取的原始视频帧上显示跟踪路线(最后 60 帧)的结果:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-algo-cv/img/00073.jpeg
现在,让我们解决我们在使用meanShift函数时观察到的一些问题。首先,手动创建色调直方图并不方便。一个灵活的程序应该允许用户选择他们想要跟踪的对象,或者至少允许用户方便地选择感兴趣对象的颜色。同样,关于搜索窗口的大小及其初始位置也是如此。有几种方法可以处理这些问题,我们将通过一个实际示例来解决这个问题。
当使用 OpenCV 库时,您可以使用setMouseCallback函数来自定义输出窗口上鼠标点击的行为。这可以与一些简单的方法结合使用,例如bitwise_not,以模拟用户易于使用的对象选择。从其名称可以猜出,setMouseCallback设置一个回调函数来处理给定窗口上的鼠标点击。
以下回调函数与在此定义的变量结合使用,可以创建一个方便的对象选择器:
bool selecting = false;
Rect selection;
Point spo; // selection point origin
void onMouse(int event, int x, int y, int flags, void*)
{
switch(event)
{
case EVENT_LBUTTONDOWN:
{
spo.x = x;
spo.y = y;
selection.x = spo.x;
selection.y = spo.y;
selection.width = 0;
selection.height = 0;
selecting = true;
} break;
case EVENT_LBUTTONUP:
{
selecting = false;
} break;
default:
{
selection.x = min(x, spo.x);
selection.y = min(y, spo.y);
selection.width = abs(x - spo.x);
selection.height = abs(y - spo.y);
} break;
}
}
event包含来自MouseEventTypes枚举的一个条目,它描述了是否按下了鼠标按钮或释放了鼠标按钮。基于这样一个简单的事件,我们可以决定用户何时实际上在屏幕上选择一个可见的对象。这如下所示:
if(selecting)
{
Mat sel(frame, selection);
bitwise_not(sel, sel); // invert the selected area
srchWnd = selection; // set the search window
// create the histogram using the hue of the selection
}
这为我们应用提供了巨大的灵活性,代码也必定能够与任何颜色的对象一起工作。请确保查看在线 Git 仓库中本章的示例代码,以获取一个完整的项目示例,该项目使用了本章迄今为止我们学到的所有主题。
在 OpenCV 库中,选择图像上的对象或区域的一种方法是使用selectROI和selectROIs函数。这些函数允许用户通过简单的鼠标点击和拖动在图像上选择矩形(或多个矩形)。请注意,selectROI和selectROIs函数比使用回调函数处理鼠标点击更容易使用,但它们提供的功能、灵活性和定制程度并不相同。
在进入下一节之前,让我们回顾一下meanShift不处理被跟踪对象大小的增加或减少,也不关心对象的方向。这些问题可能是导致开发更复杂版本的均值漂移算法的主要原因,这是我们将在本章接下来学习的内容。
使用连续自适应均值(CAM)Shift
为了克服均值漂移算法的局限性,我们可以使用其改进版本,这被称为连续自适应均值,或简称CAM算法。OpenCV 在名为CamShift的函数中实现了 CAM 算法,其使用方式几乎与meanShift函数相同。CamShift函数的输入参数与meanShift相同,因为它也使用一个反向投影图像来根据给定的终止条件更新搜索窗口。此外,CamShift还返回一个RotatedRect对象,它包含搜索窗口及其角度。
不使用返回的RotatedRect对象,你可以简单地用CamShift替换任何对meanShift函数的调用,唯一的区别是结果将是尺度不变的,这意味着如果对象更近(或更大),搜索窗口会变大,反之亦然。例如,我们可以将先前的均值漂移算法示例代码中对meanShift函数的调用替换为以下内容:
CamShift(backProject,
srchWnd,
criteria);
以下图像展示了在上一节示例中将meanShift函数替换为CamShift的结果:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-algo-cv/img/00074.jpeg
注意,现在结果具有尺度不变性,尽管我们除了替换提到的函数外没有做任何改变。当对象远离相机或变得较小时,我们仍然使用相同的 Mean Shift 算法来计算其位置,然而,这次搜索窗口被调整大小以适应对象的精确大小,并计算了旋转,这是我们之前没有使用的。为了能够使用对象的旋转值,我们首先需要将CamShift函数的结果存储在RotatedRect对象中,如下面的示例所示:
RotatedRect rotRect = CamShift(backProject,
srchWnd,
criteria);
要绘制RotatedRect对象,换句话说,是一个旋转矩形,你必须使用RotatedRect的points方法首先提取旋转矩形的4个组成点,然后使用线函数将它们全部绘制出来,如下面的示例所示:
Point2f rps[4];
rotRect.points(rps);
for(int i=0; i<4; i++)
line(frame,
rps[i],
rps[(i+1)%4],
Scalar(255,0,0),// blue color
2);
你还可以使用RotatedRect对象来绘制一个被旋转矩形覆盖的旋转椭圆。以下是方法:
ellipse(frame,
rotRect,
Scalar(255,0,0),
2);
以下图像显示了使用RotatedRect对象同时绘制旋转矩形和椭圆的结果,覆盖在跟踪对象上:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-algo-cv/img/00075.jpeg
在前面的图像中,红色矩形是搜索窗口,蓝色矩形是结果旋转矩形,绿色椭圆是通过使用结果旋转矩形绘制的。
总结来说,我们可以认为CamShift在处理大小和旋转各不相同的目标方面比meanShift更适合,然而,在使用CamShift算法时,仍然有一些可能的改进可以实施。首先,初始窗口大小仍然需要设置,但由于CamShift负责处理大小变化,因此我们可以直接将初始窗口大小设置为整个图像的大小。这将帮助我们避免处理搜索窗口的初始位置和大小。如果我们还能使用磁盘上预先保存的文件或任何类似的方法来创建感兴趣对象的直方图,那么我们将拥有一个即插即用的对象检测器和跟踪器,至少对于我们的感兴趣对象与周围环境颜色明显不同的所有情况来说是这样的。
通过使用inRange函数对用于计算直方图的 HSV 图像的 S 和 V 通道施加阈值,可以实现对基于颜色的检测和跟踪算法的一个巨大改进。原因是,在我们的例子中,我们只是简单地使用了色调(或H,或第一个)通道,并且没有考虑到可能具有与我们的感兴趣对象相同色调的非常暗或非常亮的像素的高可能性。这可以通过在计算要跟踪的对象的直方图时使用以下代码来完成:
int lbHue = 00 , hbHue = 180;
int lbSat = 30 , hbSat = 256;
int lbVal = 30 , hbVal = 230;
Mat mask;
inRange(objImgHsv,
Scalar(lbHue, lbSat, lbVal),
Scalar(hbHue, hbSat, hbVal),
mask);
calcHist(&objImgHue,
nimages,
channels,
mask,
histogram,
dims,
histSize,
ranges,
uniform);
在前面的示例代码中,以lb和hb开头的变量指的是允许通过inRange函数的值的下限和上限。objImgHsv显然是一个包含我们感兴趣的对象或包含我们感兴趣对象的 ROI 的Mat对象。objImgHue是objImgHsv的第一个通道,它是通过之前的split函数调用来提取的。其余的参数没有什么新东西,你已经在之前的函数调用中使用过它们了。
结合本节中描述的所有算法和技术可以帮助你创建一个对象检测器,甚至是一个可以实时工作且速度惊人的面部检测器和跟踪器。然而,你可能仍然需要考虑会干扰的噪声,尤其是在跟踪过程中,由于基于颜色或直方图跟踪器的本质,跟踪几乎是不可避免的。解决这些问题的最广泛使用的方法是本章下一节的主题。
使用卡尔曼滤波进行跟踪和噪声降低
卡尔曼滤波是一种流行的算法,用于降低信号的噪声,例如我们前面章节中使用的跟踪算法的结果。为了更精确,卡尔曼滤波是一种估计算法,用于根据之前的观察预测信号的下一个状态。深入探讨卡尔曼滤波的定义和细节需要单独的一章,但我们将尝试通过几个实际操作的例子来了解这个简单而极其强大的算法是如何在实际中应用的。
对于第一个例子,我们将编写一个程序来跟踪鼠标光标在画布上移动,或者 OpenCV 窗口中的移动。卡尔曼滤波是通过 OpenCV 中的KalmanFilter类实现的,它包括了所有(以及更多)的卡尔曼滤波实现细节,我们将在本节中讨论这些细节。
首先,KalmanFilter必须使用一定数量的动态参数、测量参数和控制参数进行初始化,以及卡尔曼滤波本身所使用的基础数据类型。我们将忽略控制参数,因为它们超出了我们示例的范围,所以我们将它们简单地设置为零。至于数据类型,我们将使用默认的 32 位浮点数,或者用 OpenCV 类型表示为CV_32F。在二维运动中,例如我们的例子,动态参数对应于以下内容:
-
X,或 x 方向的位置
-
Y,或 y 方向的位置
-
X’,或 x 方向的速度
-
Y’,或 y 方向的速度
参数的高维性也可以被使用,这会导致前面的列表后面跟着 X’'(x 方向上的加速度)等等。
关于测量参数,我们只需有 X 和 Y,它们对应于我们第一个例子中的鼠标位置。牢记关于动态和测量参数的讨论,以下是初始化一个适合在二维空间跟踪点的 KalmanFilter 类实例(对象)的方法:
KalmanFilter kalman(4, // dynamic parameters: X,Y,X',Y'
2 // measurement parameters: X,Y
);
注意,在这个例子中,控制参数和类型参数被简单地忽略,并设置为它们的默认值,否则我们就可以像下面这样编写相同的代码:
KalmanFilter kalman(4, 2, 0, CV_32F);
KalmanFilter 类在使用之前需要设置一个转换矩阵才能正确使用。这个转换矩阵用于计算(并更新)参数的估计或下一个状态。在我们的例子中,我们将使用以下转换矩阵来跟踪鼠标位置:
Mat_<float> tm(4, 4); // transition matrix
tm << 1,0,1,0, // next x = 1X + 0Y + 1X' + 0Y'
0,1,0,1, // next y = 0X + 1Y + 0X' + 1Y'
0,0,1,0, // next x'= 0X + 0Y + 1X' + 0Y
0,0,0,1; // next y'= 0X + 0Y + 0X' + 1Y'
kalman.transitionMatrix = tm;
完成这个例子所需的步骤后,明智的做法是返回这里并更新转换矩阵中的值,并观察卡尔曼滤波器的行为。例如,尝试更新对应于估计的 Y(在注释中标记为 next y)的矩阵行,你会注意到跟踪的位置 Y 值会受到它的影响。尝试通过实验所有转换矩阵中的值来更好地理解其影响。
除了转换矩阵之外,我们还需要注意动态参数状态和测量的初始化,这些在我们的例子中是初始鼠标位置。以下是初始化这些值的方法:
Mat_<float> pos(2,1);
pos.at<float>(0) = 0;
pos.at<float>(1) = 0;
kalman.statePre.at<float>(0) = 0; // init x
kalman.statePre.at<float>(1) = 0; // init y
kalman.statePre.at<float>(2) = 0; // init x'
kalman.statePre.at<float>(3) = 0; // init y'
你稍后会看到,KalmanFilter 类需要一个向量而不是 Point 对象,因为它也设计用于处理更高维度。因此,在执行任何计算之前,我们将更新前面代码片段中的 pos 向量,以包含最后一个鼠标位置。除了我们刚才提到的初始化之外,我们还需要初始化卡尔曼滤波器的测量矩阵。这就像下面这样完成:
setIdentity(kalman.measurementMatrix);
OpenCV 中的 setIdentity 函数简单地用于使用缩放后的单位矩阵初始化矩阵。如果只向 setIdentity 函数提供一个参数作为矩阵,它将被设置为单位矩阵;然而,如果还提供了一个额外的 Scalar,则单位矩阵的所有元素都将乘以(或缩放)给定的 Scalar 值。
最后一个初始化是过程噪声协方差。我们将为此使用一个非常小的值,这会导致跟踪具有自然运动感觉,尽管在跟踪时会有一点过冲。以下是初始化过程噪声协方差矩阵的方法:
setIdentity(kalman.processNoiseCov,
Scalar::all(0.000001));
在使用 KalmanFilter 类之前,初始化以下矩阵也是常见的做法:
-
controlMatrix(如果控制参数计数为零则不使用) -
errorCovPost -
errorCovPre -
gain -
measurementNoiseCov
使用前面提到的所有矩阵将为 KalmanFilter 类提供大量的定制,但也需要大量关于所需噪声滤波和跟踪类型以及滤波器将实现的环境的知识。这些矩阵及其使用在控制理论和控制科学中有着深厚的根源,这是一个另一个书籍的主题。请注意,在我们的示例中,我们将简单地使用提到的矩阵的默认值,因此我们完全忽略了它们。
在我们的使用卡尔曼滤波器的跟踪示例中,接下来我们需要的是设置一个窗口,我们可以在这个窗口上跟踪鼠标移动。我们假设鼠标在窗口上的位置是检测和跟踪的对象的位置,我们将使用我们的 KalmanFilter 对象来预测和去噪这些检测,或者用卡尔曼滤波算法的术语来说,我们将纠正这些测量。我们可以使用 namedWindow 函数使用 OpenCV 创建一个窗口。因此,可以使用 setMouseCallback 函数为与该特定窗口的鼠标交互分配回调函数。以下是我们可以这样做的方法:
string window = "Canvas";
namedWindow(window);
setMouseCallback(window, onMouse);
我们将Canvas这个词用于窗口,但显然你可以使用你喜欢的任何其他名字。onMouse 是将被分配以响应与该窗口的鼠标交互的回调函数。它被定义如下:
void onMouse(int, int x, int y, int, void*)
{
objectPos.x = x;
objectPos.y = y;
}
Point object that is used to store the last position of the mouse on the window. It needs to be defined globally in order to be accessible by both onMouse and the main function in which we'll use the KalmanFilter class. Here's the definition:
Point objectPos;
现在,对于实际的跟踪,或者使用更准确的术语,纠正包含噪声的测量值,我们需要使用以下代码,后面将跟随必要的解释:
vector<Point> trackRoute;
while(waitKey(10) < 0)
{
// empty canvas
Mat canvas(500, 1000, CV_8UC3, Scalar(255, 255, 255));
pos(0) = objectPos.x;
pos(1) = objectPos.y;
Mat estimation = kalman.correct(pos);
Point estPt(estimation.at<float>(0),
estimation.at<float>(1));
trackRoute.push_back(estPt);
if(trackRoute.size() > 100)
trackRoute.erase(trackRoute.begin());
polylines(canvas,
trackRoute,
false,
Scalar(0,0,255),
5);
imshow(window, canvas);
kalman.predict();
}
在前面的代码中,我们使用 trackRoute 向量记录过去 100 帧的估计值。按下任何键将导致 while 循环,从而程序返回。在循环内部,以及我们实际使用 KalmanFilter 类的地方,我们简单地按以下顺序执行以下操作:
-
创建一个空的
Mat对象,用作绘制画布,以及跟踪将发生的窗口的内容 -
读取
objectPos,它包含鼠标在窗口上的最后位置,并将其存储在pos向量中,该向量可用于KalmanFilter类 -
使用
KalmanFilter类的正确方法读取估计值 -
将估计结果转换回可用于绘制的
Point对象 -
将估计点(或跟踪点)存储在
trackRoute向量中,并确保trackRoute向量中的项目数量不超过100,因为这是我们想要记录估计点的帧数 -
使用多段线函数绘制路线,将路线存储为
Point对象在trackRoute中 -
使用
imshow函数显示结果 -
使用预测函数更新
KalmanFilter类的内部矩阵
尝试执行跟踪程序并在显示的窗口中移动鼠标光标。你会注意到一个平滑的跟踪结果,它使用以下截图中的粗红色线条绘制:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-algo-cv/img/00076.gif
注意到鼠标光标的位置在跟踪之前,鼠标移动的噪声几乎被完全消除。尝试可视化鼠标移动以更好地比较KalmanFilter结果和实际测量结果是个好主意。只需在前面代码中trackRoute绘制点之后添加以下代码:
mouseRoute.push_back(objectPos);
if(mouseRoute.size() > 100)
mouseRoute.erase(mouseRoute.begin());
polylines(canvas,
mouseRoute,
false,
Scalar(0,0,0),
2);
显然,在进入while循环之前,你需要定义mouseRoute向量,如下所示:
vector<Point> mouseRoute;
让我们尝试使用这个小的更新来运行相同的应用程序,并看看结果如何相互比较。以下是另一个截图,展示了实际鼠标移动和校正后的移动(或跟踪,或滤波,具体取决于术语)在同一窗口中的结果:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-algo-cv/img/00077.jpeg
在前面的结果中,箭头只是用来表示一个极其嘈杂的测量(鼠标移动或检测到的对象位置,具体取决于应用)的整体方向,它用细黑色线条绘制,而使用卡尔曼滤波算法校正的结果,在图像中用粗红色线条绘制。尝试移动鼠标并直观地比较结果。还记得我们提到的KalmanFilter内部矩阵以及你需要根据用例和应用设置它们的值吗?例如,更大的过程噪声协方差会导致去噪更少,从而滤波更少。让我们尝试将过程噪声协方差值设置为0.001,而不是之前的0.000001值,再次运行相同的程序,并比较结果。以下是设置过程噪声协方差的方法:
setIdentity(kalman.processNoiseCov,
Scalar::all(0.001));
现在,再次运行程序,你可以很容易地注意到,当你将鼠标光标在窗口周围移动时,去噪现象减少:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-algo-cv/img/00078.gif
到目前为止,你可能已经猜到,将过程噪声协方差设置得极高,其结果几乎与完全不使用滤波器相同。这就是为什么设置卡尔曼滤波算法的正确值极其重要,也是它如此依赖于应用的原因。然而,有方法可以通过编程设置大多数这些参数,甚至在跟踪过程中动态地调整以实现最佳结果。例如,使用一个能够确定任何给定时刻可能噪声量的函数,我们可以动态地将过程噪声协方差设置为高或低值,以实现更少或更多的去噪。
现在,让我们使用KalmanFilter来执行一个现实生活中的跟踪校正,使用CamShift函数对物体进行跟踪,而不是鼠标移动。需要注意的是,应用的逻辑完全相同。我们需要初始化一个KalmanFilter对象,并根据噪声量等设置其参数。为了简单起见,你可以从与之前示例中设置跟踪鼠标光标相同的参数集开始,然后尝试调整它们。我们需要从创建与之前章节中编写的相同跟踪程序开始。然而,在调用CamShift(或meanShift函数)来更新搜索窗口后,而不是显示结果,我们将使用KalmanFilter类进行校正以去噪结果。以下是使用类似示例代码的执行方式:
CamShift(backProject,
srchWnd,
criteria);
Point objectPos(srchWnd.x + srchWnd.width/2,
srchWnd.y + srchWnd.height/2);
pos(0) = objectPos.x;
pos(1) = objectPos.y;
Mat estimation = kalman.correct(pos);
Point estPt(estimation.at<float>(0),
estimation.at<float>(1));
drawMarker(frame,
estPt,
Scalar(0,255,0),
MARKER_CROSS,
30,
2);
kalman.predict();
你可以参考本章在线源代码仓库中的完整示例项目,其中包含前面的代码,它与之前章节中看到的内容几乎相同,只是简单的事实是使用了KalmanFilter来校正检测和跟踪到的物体位置。正如你所看到的,objectPos,之前是从鼠标移动位置读取的,现在被设置为搜索窗口的中心点。之后,调用correct函数进行估计,并通过绘制绿色十字标记显示校正后的跟踪结果。除了使用卡尔曼滤波器算法的主要优势,即有助于去除检测和跟踪结果中的噪声外,它还可以帮助处理检测暂时丢失或不可能的情况。虽然从技术上讲,检测丢失是噪声的极端情况,但我们试图从计算机视觉的角度指出这种差异。
通过分析几个示例,并在自己的项目中尝试使用卡尔曼滤波器来帮助解决问题,并为其尝试不同的参数集,你将立即理解它在需要纠正测量(包含噪声)的实用算法时的长期受欢迎。在本节中我们学到的关于卡尔曼滤波器的使用是一个相当简单的情况(这对于我们的用例已经足够了),但重要的是要注意,相同的算法可以用于去噪更高维度的测量,以及具有更多复杂性的情况。
如何提取背景/前景
在图像中分割背景和前景内容是视频和运动分析中最重要的话题之一,在这个领域已经进行了大量的研究,以提供一些非常实用且易于使用的算法,我们将在本章的最后部分学习这些算法。当前版本的 OpenCV 包括两种背景分割算法的实现。
为了使用更简短、更清晰且与 OpenCV 函数和类更兼容的术语,我们将背景/前景提取和背景/前景分割简单地称为背景分割。
OpenCV 默认提供了以下两个算法用于背景分割:
-
BackgroundSubtractorKNN -
BackgroundSubtractorMOG2
这两个类都是BackgroundSubtractor的子类,它包含了一个合适的背景分割算法所必需的所有接口,我们将在稍后讨论。这仅仅允许我们使用多态性在产生相同结果且用法非常相似的算法之间切换。BackgroundSubtractorKNN类实现了 K 最近邻背景分割算法,适用于前景像素计数较低的情况。另一方面,BackgroundSubtractorMOG2实现了基于高斯混合的背景分割算法。你可以参考 OpenCV 在线文档以获取有关这些算法内部行为和实现的详细信息。阅读这些算法的相关文章也是一个好主意,特别是如果你正在寻找自己的自定义背景分割算法的话。
除了我们之前提到的算法外,还有许多其他算法可以使用 OpenCV 进行背景分割,这些算法包含在额外的模块bgsegm中。我们将省略这些算法,因为它们的用法与我们将在本节中讨论的算法非常相似,而且它们在 OpenCV 中默认不存在。
背景分割的一个示例
让我们从BackgroundSubtractorKNN类和一个实际操作示例开始,看看背景分割算法是如何使用的。你可以使用createBackgroundSubtractorKNN函数创建一个BackgroundSubtractorKNN类型的对象。以下是方法:
int history = 500;
double dist2Threshold = 400.0;
bool detectShadows = true;
Ptr<BackgroundSubtractorKNN> bgs =
createBackgroundSubtractorKNN(history,
dist2Threshold,
detectShadows);
要理解BackgroundSubtractorKNN类中使用的参数,首先需要注意的是,此算法使用像素历史记录中的采样技术来创建一个采样背景图像。换句话说,history参数用于定义用于采样背景图像的先前帧数,而dist2Threshold参数是像素当前值与其在采样背景图像中对应像素值的平方距离的阈值。"detectShadows"是一个自解释的参数,用于确定在背景分割过程中是否检测阴影。
现在,我们可以简单地使用bgs从视频中提取前景掩码,并使用它来检测运动或物体进入场景。以下是方法:
VideoCapture cam(0);
if(!cam.isOpened())
return -1;
while(true)
{
Mat frame;
cam >> frame;
if(frame.empty())
break;
Mat fgMask; // foreground mask
bgs->apply(frame,
fgMask);
Mat fg; // foreground image
bitwise_and(frame, frame, fg, fgMask);
Mat bg; // background image
bgs->getBackgroundImage(bg);
imshow("Input Image", frame);
imshow("Background Image", bg);
imshow("Foreground Mask", fgMask);
imshow("Foreground Image", fg);
int key = waitKey(10);
if(key == 27) // escape key
break;
}
cam.release();
让我们快速回顾一下之前代码中新的、可能不是那么明显的一部分。首先,我们使用 BackgroundSubtractorKNN 类的 apply 函数执行背景/前景分割操作。此函数还为我们更新了内部采样背景图像。之后,我们使用 bitwise_and 函数与前景掩码结合来提取前景图像的内容。要检索采样背景图像本身,我们只需使用 getBackgroundImage 函数。最后,我们显示所有结果。以下是一些示例结果,描述了一个场景(左上角),提取的背景图像(右上角),前景掩码(左下角),以及前景图像(右下角):
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-algo-cv/img/00079.jpeg
注意,进入场景的手的阴影也被背景分割算法捕获。在我们的示例中,我们使用 apply 函数时省略了 learningRate 参数。此参数可以用来设置学习背景模型更新的速率。值为 0 表示模型将完全不会更新,这在背景在已知时间段内保持不变的情况下非常有用。值为 1.0 表示模型会非常快地更新。正如我们的示例中那样,我们跳过了此参数,导致它使用 -1.0,这意味着算法本身将决定学习率。另一个需要注意的重要事项是,apply 函数的结果可能会产生一个非常嘈杂的掩码,这可以通过使用简单的模糊函数,如 medianBlur,来平滑,如下所示:
medianBlur(fgMask,fgMask,3);
使用 BackgroundSubtractorMOG2 类与 BackgroundSubtractorKNN 类非常相似。以下是一个示例:
int history = 500;
double varThreshold = 16.0;
bool detectShadows = true;
Ptr<BackgroundSubtractorMOG2> bgs =
createBackgroundSubtractorMOG2(history,
varThreshold,
detectShadows);
注意,createBackgroundSubtractorMOG2 函数的使用方式与我们之前看到的使用方式非常相似,用于创建 BackgroundSubtractorMOG2 类的实例。这里唯一的区别是 varThreshold 参数,它对应于用于匹配像素值和背景模型的方差阈值。使用 apply 和 getBackgroundImage 函数在这两个背景分割类中是相同的。尝试修改这两个算法中的阈值值,以了解更多关于参数视觉效果的细节。
背景分割算法在视频编辑软件中具有很大的潜力,甚至可以在背景变化不大的环境中检测和跟踪对象。尝试将它们与本章之前学到的算法结合使用,构建利用多个算法以提高结果跟踪算法。
摘要
OpenCV 中的视频分析模块是一系列极其强大的算法、函数和类的集合,我们在本章中已经了解到了它们。从视频处理的整体概念和基于连续视频帧内容的简单计算开始,我们继续学习了均值漂移算法及其如何通过后投影图像跟踪已知颜色和规格的对象。我们还学习了均值漂移算法的更复杂版本,称为连续自适应均值漂移,或简称 CAM Shift。我们了解到这个算法也能够处理不同尺寸的对象并确定它们的朝向。在跟踪算法的学习过程中,我们了解了强大的卡尔曼滤波器及其在去噪和校正跟踪结果中的应用。我们使用卡尔曼滤波器来跟踪鼠标移动并校正均值漂移和 CAM Shift 算法的跟踪结果。最后,我们学习了实现背景分割算法的 OpenCV 类。我们编写了一个简单的程序来使用背景分割算法并输出计算出的背景和前景图像。到目前为止,我们已经熟悉了一些最流行和最广泛使用的计算机视觉算法,这些算法允许实时检测和跟踪对象。
在下一章中,我们将学习许多特征提取算法、函数和类,以及如何使用特征根据图像的关键点和描述符检测对象或从中提取有用的信息。
问题
-
本章中所有涉及摄像头的示例在出现单个失败或损坏的帧导致检测到空帧时都会返回。需要什么样的修改才能在停止过程之前允许预定义的尝试次数?
-
我们如何调用
meanShift函数以 10 次迭代和 0.5 的 epsilon 值执行均值漂移算法? -
我们如何可视化跟踪对象的色调直方图?假设使用
CamShift进行跟踪。 -
在
KalmanFilter类中设置过程噪声协方差,以便滤波值和测量值重叠。假设只设置了过程噪声协方差,在所有可用的KalmanFilter类行为控制矩阵中。 -
假设窗口中鼠标的Y位置用于描述从窗口左上角开始的填充矩形的长度,该矩形的宽度等于窗口宽度。编写一个卡尔曼滤波器,可以用来校正矩形的长度(单个值)并去除鼠标移动中的噪声,这将导致填充矩形的视觉平滑缩放。
-
创建一个
BackgroundSubtractorMOG2对象来提取前景图像内容,同时避免阴影变化。 -
编写一个程序,使用背景分割算法显示当前(而不是采样)的背景图像。
第七章:目标检测 – 特征和描述符
在上一章中,我们学习了视频处理以及如何将之前章节中的操作和算法应用于从摄像头或视频文件中读取的帧。我们了解到每个视频帧都可以被视为一个单独的图像,因此我们可以轻松地在视频中应用类似于图像的算法,如滤波。在了解了如何使用作用于单个帧的算法处理视频后,我们继续学习需要一系列连续视频帧来执行目标检测、跟踪等操作的视频处理算法。我们学习了如何利用卡尔曼滤波的魔法来提高目标跟踪结果,并以学习背景和前景提取结束本章。
上一章中我们学习到的目标检测(和跟踪)算法在很大程度上依赖于物体的颜色,这已被证明并不太可靠,尤其是如果我们处理的对象和环境在光照方面没有得到控制。我们都知道,在阳光、月光下,或者如果物体附近有不同颜色的光源,如红灯,物体的亮度和颜色可以轻易(有时极其)改变。这些困难是为什么当使用物体的物理形状和特征作为目标检测算法的基础时,物体的检测更加可靠。显然,图像的形状与其颜色无关。一个圆形物体在白天或夜晚都会保持圆形,因此能够提取此类物体形状的算法在检测该物体时将更加可靠。
在本章中,我们将学习如何使用计算机视觉算法、函数和类来检测和识别具有其特征的物体。我们将学习到许多可用于形状提取和分析的算法,然后我们将继续学习关键点检测和描述符提取算法。我们还将学习如何将两个图像中的描述符进行匹配,以检测图像中已知形状的物体。除了我们刚才提到的主题外,本章还将包括用于正确可视化关键点和匹配结果的所需函数。
在本章中,你将学习以下内容:
-
模板匹配用于目标检测
-
检测轮廓并用于形状分析
-
计算和分析轮廓
-
使用霍夫变换提取直线和圆
-
检测、描述和匹配特征
技术要求
-
用于开发 C++ 或 Python 应用的集成开发环境 (IDE)
-
OpenCV 库
请参考第二章,《使用 OpenCV 入门》,以获取更多关于如何设置个人电脑并使其准备好使用 OpenCV 库开发计算机视觉应用程序的信息。
您可以使用以下网址下载本章的源代码和示例:
github.com/PacktPublishing/Hands-On-Algorithms-for-Computer-Vision/tree/master/Chapter07.
对象检测的模板匹配
在我们开始形状分析和特征分析算法之前,我们将学习一种易于使用且功能强大的对象检测方法,称为模板匹配。严格来说,这个算法不属于使用任何关于对象形状知识的算法类别,但它使用了一个先前获取的对象模板图像,该图像可以用来提取模板匹配结果,从而识别出已知外观、大小和方向的对象。您可以使用 OpenCV 中的matchTemplate函数执行模板匹配操作。以下是一个演示matchTemplate函数完整使用的示例:
Mat object = imread("Object.png");
Mat objectGr;
cvtColor(object, objectGr, COLOR_BGR2GRAY);
Mat scene = imread("Scene.png");
Mat sceneGr;
cvtColor(scene, sceneGr, COLOR_BGR2GRAY);
TemplateMatchModes method = TM_CCOEFF_NORMED;
Mat result;
matchTemplate(sceneGr, objectGr, result, method);
method必须是从TemplateMatchModes枚举中的一项,可以是以下任何值:
-
TM_SQDIFF -
TM_SQDIFF_NORMED -
TM_CCORR -
TM_CCORR_NORMED -
TM_CCOEFF -
TM_CCOEFF_NORMED
关于每种模板匹配方法的详细信息,您可以参考 OpenCV 文档。对于我们的实际示例,以及了解matchTemplate函数在实际中的应用,重要的是要注意,每种方法都会产生不同类型的结果,因此需要对结果进行不同的解释,我们将在本节中学习这些内容。在前面的例子中,我们试图通过使用对象图像和场景图像来检测场景中的对象。让我们假设以下图像是我们将使用的对象(左侧)和场景(右侧):
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-algo-cv/img/00080.jpeg
模板匹配中的一个非常简单的想法是我们正在寻找场景图像右侧的一个点,该点有最高可能性包含左侧的图像,换句话说,就是模板图像。matchTemplate函数,根据所使用的方法,将提供一个概率分布。让我们可视化matchTemplate函数的结果,以更好地理解这个概念。另一个需要注意的重要事情是,我们只能通过使用以_NORMED结尾的任何方法来正确可视化matchTemplate函数的结果,这意味着它们包含归一化的结果,否则我们必须使用归一化方法来创建一个包含 OpenCV imshow函数可显示范围内的值的输出。以下是实现方法:
normalize(result, result, 0.0, 1.0, NORM_MINMAX, -1);
此函数调用将result中的所有值转换为0.0和1.0的范围,然后可以正确显示。以下是使用imshow函数显示的结果图像的外观:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-algo-cv/img/00081.jpeg
如前所述,matchTemplate函数的结果及其解释完全取决于所使用的模板匹配方法。在我们使用TM_SQDIFF或TM_SQDIFF_NORMED方法进行模板匹配的情况下,我们需要在结果中寻找全局最小点(前一张图中的箭头所示),它最有可能是包含模板图像的点。以下是我们在模板匹配结果中找到全局最小点(以及全局最大点等)的方法:
double minVal, maxVal;
Point minLoc, maxLoc;
minMaxLoc(result, &minVal, &maxVal, &minLoc, &maxLoc);
由于模板匹配算法仅与固定大小和方向的物体工作,我们可以假设一个矩形,其左上角等于minLoc点,且大小等于模板图像,是我们对象的最佳可能边界矩形。我们可以使用以下示例代码在场景图像上绘制结果,以便更好地比较:
Rect rect(minLoc.x,
minLoc.y,
object.cols,
object.rows);
Scalar color(0, 0, 255);
int thickness = 2;
rectangle(scene,
rect,
color,
thickness);
以下图像展示了使用matchTemplate函数执行的对象检测操作的结果:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-algo-cv/img/00082.jpeg
如果我们使用TM_CCORR、TM_CCOEFF或它们的归一化版本,我们必须使用全局最大点作为包含我们的模板图像可能性最高的点。以下图像展示了使用matchTemplate函数的TM_CCOEFF_NORMED方法的结果:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-algo-cv/img/00083.jpeg
如您所见,结果图像中最亮的点对应于场景图像中模板图像的左上角。
在结束我们的模板匹配课程之前,我们也要注意,模板匹配结果的宽度和高度小于场景图像。这是因为模板匹配结果图像只能包含模板图像的左上角,因此从场景图像的宽度和高度中减去模板图像的宽度和高度,以确定模板匹配算法中结果图像的宽度和高度。
检测角点和边缘
并非总是可以仅仅通过像素级比较图像并决定一个物体是否存在于图像中,或者一个物体是否具有预期的形状,以及许多我们甚至无法一一列举的类似场景。这就是为什么更智能的方法是寻找图像中的有意义特征,然后根据这些特征的性质进行解释。在计算机视觉中,特征与关键点同义,所以如果我们在这本书中交替使用它们,请不要感到惊讶。实际上,关键词汇更适合描述这个概念,因为图像中最常用的特征通常是图像中的关键点,在这些点上颜色强度发生突然变化,这可能在图像中形状和物体的角点和边缘处发生。
在本节中,我们将了解一些最重要且最广泛使用的特征点检测算法,即角点和边缘检测算法,这些算法是我们在本章中将学习的几乎所有基于特征的物体检测算法的基础。
学习 Harris 角点检测算法
最著名的角点和边缘检测算法之一是 Harris 角点检测算法,该算法在 OpenCV 的cornerHarris函数中实现。以下是该函数的使用方法:
Mat image = imread("Test.png");
cvtColor(image, image, COLOR_BGR2GRAY);
Mat result;
int blockSize = 2;
int ksize = 3;
double k = 1.0;
cornerHarris(image,
result,
blockSize,
ksize,
k);
blockSize决定了 Harris 角点检测算法将计算 2 x 2 梯度协方差矩阵的正方形块的宽度和高度。ksize是 Harris 算法内部使用的 Sobel 算子的核大小。前一个示例演示了最常用的 Harris 算法参数集之一,但有关 Harris 角点检测算法及其内部数学的更详细信息,您可以参考 OpenCV 文档。需要注意的是,前一个示例代码中的result对象除非使用以下示例代码进行归一化,否则是不可显示的:
normalize(result, result, 0.0, 1.0, NORM_MINMAX, -1);
这里是前一个示例中 Harris 角点检测算法的结果,当使用 OpenCV 的imshow函数进行归一化和显示时:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-algo-cv/img/00084.jpeg
OpenCV 库还包括另一个著名的角点检测算法,称为Good Features to Track(GFTT)。您可以使用 OpenCV 中的goodFeaturesToTrack函数来使用 GFTT 算法检测角点,如下面的示例所示:
Mat image = imread("Test.png");
Mat imgGray;
cvtColor(image, imgGray, COLOR_BGR2GRAY);
vector<Point2f> corners;
int maxCorners = 500;
double qualityLevel = 0.01;
double minDistance = 10;
Mat mask;
int blockSize = 3;
int gradientSize = 3;
bool useHarrisDetector = false;
double k = 0.04;
goodFeaturesToTrack(imgGray,
corners,
maxCorners,
qualityLevel,
minDistance,
mask,
blockSize,
gradientSize,
useHarrisDetector,
k);
如你所见,这个函数需要一个单通道图像,因此,在执行任何其他操作之前,我们已经将我们的 BGR 图像转换为灰度图。此外,这个函数使用maxCorners值来根据它们作为候选者的强度限制检测到的角点的数量,将maxCorners设置为负值或零意味着应返回所有检测到的角点,如果你在寻找图像中的最佳角点,这不是一个好主意,所以请确保根据你将使用它的环境设置一个合理的值。qualityLevel是接受检测到的角点的内部阈值值。minDistance是返回角点之间允许的最小距离。这是另一个完全依赖于该算法将用于的环境的参数。你已经在上一章和前一章的先前算法中看到了剩余的参数。重要的是要注意,此函数还结合了 Harris 角点检测算法,因此,通过将useHarrisDetector设置为true,结果特征将使用 Harris 角点检测算法计算。
你可能已经注意到,goodFeaturesToTrack函数返回一组Point对象(确切地说是Point2f对象)而不是Mat对象。返回的corners向量仅包含使用 GFTT 算法在图像中检测到的最佳可能角点,因此我们可以使用drawMarker函数来正确地可视化结果,如下面的例子所示:
Scalar color(0, 0, 255);
MarkerTypes markerType = MARKER_TILTED_CROSS;
int markerSize = 8;
int thickness = 2;
for(int i=0; i<corners.size(); i++)
{
drawMarker(image,
corners[i],
color,
markerType,
markerSize,
thickness);
}
这是前面例子中使用goodFeaturesToTrack函数检测角点得到的结果:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-algo-cv/img/00085.jpeg
你也可以使用GFTTDetector类以与goodFeaturesToTrack函数类似的方式检测角点。这里的区别在于返回的类型是KeyPoint对象的向量。许多 OpenCV 函数和类使用KeyPoint类来返回检测到的关键点的各种属性,而不是仅对应于关键点位置的Point对象。让我们通过以下内容来看看这意味着什么:
Ptr<GFTTDetector> detector =
GFTTDetector::create(maxCorners,
qualityLevel,
minDistance,
blockSize,
gradientSize,
useHarrisDetector,
k);
vector<KeyPoint> keypoints;
detector->detect(image, keypoints);
传递给GFTTDetector::create函数的参数与我们使用goodFeaturesToTrack函数时使用的参数没有不同。你也可以省略所有给定的参数,只需简单地写下以下内容即可使用所有参数的默认和最佳值:
Ptr<GFTTDetector> detector = GFTTDetector::create();
但让我们回到之前的例子中的KeyPoint类和detect函数的结果。回想一下,我们使用循环遍历所有检测到的点并在图像上绘制它们。如果我们使用GFTTDetector类,则不需要这样做,因为我们可以使用现有的 OpenCV 函数drawKeypoints来正确地可视化所有检测到的关键点。以下是这个函数的使用方法:
Mat outImg;
drawKeypoints(image,
keypoints,
outImg);
drawKeypoints 函数遍历 keypoints 向量中的所有 KeyPoint 对象,并在 image 上使用随机颜色绘制它们,然后将结果保存到 outImg 对象中,我们可以通过调用 imshow 函数来显示它。以下图像是使用前面示例代码调用 drawKeypoints 函数的结果:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-algo-cv/img/00086.jpeg
如果我们想使用特定颜色而不是随机颜色,可以为 drawKeypoints 函数提供一个额外的(可选的)颜色参数。此外,我们还可以提供一个标志参数,用于进一步增强检测到的关键点的可视化结果。例如,如果标志设置为 DRAW_RICH_KEYPOINTS,则 drawKeypoints 函数还将使用每个检测到的关键点中的大小和方向值来可视化更多关键点属性。
每个 KeyPoint 对象可能包含以下属性,具体取决于用于计算它的算法:
-
pt:一个包含关键点坐标的Point2f对象。 -
size:有意义的关键点邻域的直径。 -
angle:关键点的方向,以度为单位,如果不适用则为 -1。 -
response:由算法确定的关键点强度。 -
octave:从其中提取关键点的八度或金字塔层。使用八度可以让我们处理来自同一图像但在不同尺度上的关键点。通常设置此值的算法需要输入八度参数,该参数用于定义用于提取关键点的图像的八度(或尺度)数。 -
class_id:这个整数参数可以用来分组关键点,例如,当关键点属于单个对象时,它们可以具有相同的可选class_id值。
除了 Harris 和 GFTT 算法之外,您还可以使用 FastFeatureDetector 类的 FAST 角点检测算法,以及 AgastFeatureDetector 类的 AGAST 角点检测算法(基于加速段测试的自适应和通用角点检测),这与我们使用 GFTTDetector 类的方式非常相似。重要的是要注意,所有这些类都属于 OpenCV 库中的 features2d 模块,并且它们都是 Feature2D 类的子类,因此它们都包含一个静态的 create 函数,用于创建它们对应类的实例,以及一个 detect 函数,可以用来从图像中提取关键点。
下面是一个使用所有默认参数的 FastFeatureDetector 的示例代码:
int threshold = 10;
bool nonmaxSuppr = true;
int type = FastFeatureDetector::TYPE_9_16;
Ptr<FastFeatureDetector> fast =
FastFeatureDetector::create(threshold,
nonmaxSuppr,
type);
vector<KeyPoint> keypoints;
fast->detect(image, keypoints);
如果检测到太多的角点,请尝试增加 threshold 值。同时,请确保查看 OpenCV 文档以获取有关 FastFeatureDetector 类中使用的 type 参数的更多信息。如前所述,您可以直接省略前面示例代码中的所有参数,以使用所有参数的默认值。
使用 AgastFeatureDetector 类与使用 FastFeatureDetector 非常相似。以下是一个示例:
int threshold = 10;
bool nonmaxSuppr = true;
int type = AgastFeatureDetector::OAST_9_16;
Ptr<AgastFeatureDetector> agast =
AgastFeatureDetector::create(threshold,
nonmaxSuppr,
type);
vector<KeyPoint> keypoints;
agast->detect(image, keypoints);
在继续学习边缘检测算法之前,值得注意的是 OpenCV 还包含 AGAST 和 FAST 函数,可以直接使用它们对应的算法,避免处理创建实例来使用它们;然而,使用这些算法的类实现具有使用多态在算法之间切换的巨大优势。以下是一个简单的示例,演示了我们可以如何使用多态从角点检测算法的类实现中受益:
Ptr<Feature2D> detector;
switch (algorithm)
{
case 1:
detector = GFTTDetector::create();
break;
case 2:
detector = FastFeatureDetector::create();
break;
case 3:
detector = AgastFeatureDetector::create();
break;
default:
cout << "Wrong algorithm!" << endl;
return 0;
}
vector<KeyPoint> keypoints;
detector->detect(image, keypoints);
在前面的示例中,algorithm 是一个可以在运行时设置的整数值,它将改变分配给 detector 对象的角点检测算法的类型,该对象具有 Feature2D 类型,换句话说,是所有角点检测算法的基类。
边缘检测算法
既然我们已经了解了角点检测算法,让我们来看看边缘检测算法,这在计算机视觉中的形状分析中至关重要。OpenCV 包含了许多可以用于从图像中提取边缘的算法。我们将要学习的第一个边缘检测算法被称为 线段检测算法,可以通过使用 LineSegmentDetector 类来实现,如下面的示例所示:
Mat image = imread("Test.png");
Mat imgGray;
cvtColor(image, imgGray, COLOR_BGR2GRAY);
Ptr<LineSegmentDetector> detector = createLineSegmentDetector();
vector<Vec4f> lines;
detector->detect(imgGray,
lines);
如您所见,LineSegmentDetector 类需要一个单通道图像作为输入,并生成一个 vector 的线条。结果中的每条线都是 Vec4f,即代表 x1、y1、x2 和 y2 值的四个浮点数,换句话说,就是构成每条线的两个点的坐标。您可以使用 drawSegments 函数来可视化 LineSegmentDetector 类的 detect 函数的结果,如下面的示例所示:
Mat result(image.size(),
CV_8UC3,
Scalar(0, 0, 0));
detector->drawSegments(result,
lines);
为了更好地控制结果线条的可视化,您可能需要手动绘制线条向量,如下面的示例所示:
Mat result(image.size(),
CV_8UC3,
Scalar(0, 0, 0));
Scalar color(0,0,255);
int thickness = 2;
for(int i=0; i<lines.size(); i++)
{
line(result,
Point(lines.at(i)[0],
lines.at(i)[1]),
Point(lines.at(i)[2],
lines.at(i)[3]),
color,
thickness);
}
以下图像展示了前面示例代码中使用的线段检测算法的结果:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-algo-cv/img/00087.jpeg
如需了解更多关于如何自定义 LineSegmentDetector 类的行为的详细信息,请确保查看 createLineSegmentDetector 的文档及其参数。在我们的示例中,我们简单地省略了所有输入参数,并使用默认值设置了 LineSegmentDetector 类的参数。
LineSegmentDetector 类的另一个功能是比较两组线条以找到非重叠像素的数量,同时将比较结果绘制在输出图像上以进行可视化比较。以下是一个示例:
vector<Vec4f> lines1, lines2;
detector->detect(imgGray1,
lines1);
detector->detect(imgGray2,
lines2);
Mat resultImg(imageSize, CV_8UC3, Scalar::all(0));
int result = detector->compareSegments(imageSize,
lines1,
lines2,
resultImg);
在前面的代码中,imageSize 是一个 Size 对象,它包含从其中提取线条的输入图像的大小。结果是包含比较函数或 compareSegments 函数结果的整数值,在像素完全重叠的情况下将为零。
下一个边缘检测算法可能是计算机视觉中最广泛使用和引用的边缘检测算法之一,称为 Canny 算法,在 OpenCV 中具有相同名称的函数。Canny 函数的最大优点是其输入参数的简单性。让我们首先看看它的一个示例用法,然后详细说明其细节:
Mat image = imread("Test.png");
double threshold1 = 100.0;
double threshold2 = 200.0;
int apertureSize = 3;
bool L2gradient = false;
Mat edges;
Canny(image,
edges,
threshold1,
threshold2,
apertureSize,
L2gradient);
阈值值(threshold1 和 threshold2)是用于阈值化输入图像的下限和上限值。apertureSize 是内部 Sobel 运算符的孔径大小,而 L2gradient 用于在计算梯度图像时启用或禁用更精确的 L2 范数。Canny 函数的结果是一个灰度图像,其中包含检测到边缘处的白色像素和其余像素的黑色像素。这使得 Canny 函数的结果在需要此类掩模的地方非常适合,或者,如你稍后所看到的,提取轮廓的合适点集。
以下图像描述了前面示例中使用的 Canny 函数的结果:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-algo-cv/img/00088.jpeg
如我们之前提到的,Canny 函数的结果适合用作需要二值图像的算法的输入,换句话说,是一个只包含绝对黑色和绝对白色像素值的灰度图像。我们将学习下一个算法,其中必须使用先前 Canny 函数的结果作为输入,它被称为 霍夫变换。霍夫变换可以用于从图像中提取线条,并在 OpenCV 库中的 HoughLines 函数中实现。
下面是一个完整的示例,展示了如何在实际中使用 HoughLines 函数:
- 调用
Canny函数检测输入图像中的边缘,如下所示:
Mat image = imread("Test.png");
double threshold1 = 100.0;
double threshold2 = 200.0;
int apertureSize = 3;
bool L2gradient = false;
Mat edges;
Canny(image,
edges,
threshold1,
threshold2,
apertureSize,
L2gradient);
- 调用
HoughLines函数从检测到的边缘中提取线条:
vector<Vec2f> lines;
double rho = 1.0; // 1 pixel, r resolution
double theta = CV_PI / 180.0; // 1 degree, theta resolution
int threshold = 100; // minimum number of intersections to "detect" a line
HoughLines(edges,
lines,
rho,
theta,
threshold);
- 使用以下代码在标准坐标系中提取点,并在输入图像上绘制它们:
Scalar color(0,0,255);
int thickness = 2;
for(int i=0; i<lines.size(); i++)
{
float rho = lines.at(i)[0];
float theta = lines.at(i)[1];
Point pt1, pt2;
double a = cos(theta);
double b = sin(theta);
double x0 = a*rho;
double y0 = b*rho;
pt1.x = int(x0 + 1000*(-b));
pt1.y = int(y0 + 1000*(a));
pt2.x = int(x0 - 1000*(-b));
pt2.y = int(y0 - 1000*(a));
line( image, pt1, pt2, color, thickness);
}
以下图像从左到右描述了前面示例的结果,首先是原始图像,然后是使用 Canny 函数检测到的边缘,接着是使用 HoughLines 函数检测到的线条,最后是输出图像:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-algo-cv/img/00089.jpeg
为了避免处理坐标系统变化,你可以使用 HoughLinesP 函数直接提取形成每个检测到的线条的点。以下是一个示例:
vector<Vec4f> lines;
double rho = 1.0; // 1 pixel, r resolution
double theta = CV_PI / 180.0; // 1 degree, theta resolution
int threshold = 100; // minimum number of intersections to "detect" a line
HoughLinesP(edges,
lines,
rho,
theta,
threshold);
Scalar color(0,0,255);
int thickness = 2;
for(int i=0; i<lines.size(); i++)
{
line(image,
Point(lines.at(i)[0],
lines.at(i)[1]),
Point(lines.at(i)[2],
lines.at(i)[3]),
color,
thickness);
}
Hough 变换非常强大,OpenCV 包含更多 Hough 变换算法的变体,我们将留给您使用 OpenCV 文档和在线资源去发现。请注意,使用 Canny 算法是 Hough 变换的前提,正如您将在下一节中看到的,也是处理图像中物体形状的许多算法的前提。
轮廓计算和分析
图像中形状和物体的轮廓是一个重要的视觉属性,可以用来描述和分析它们。计算机视觉也不例外,因此计算机视觉中有相当多的算法可以用来计算图像中物体的轮廓或计算它们的面积等。
以下图像展示了从两个 3D 物体中提取的两个轮廓:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-algo-cv/img/00090.jpeg
OpenCV 包含一个名为 findContours 的函数,可以用来从图像中提取轮廓。此函数必须提供一个合适的二值图像,其中包含轮廓的最佳候选像素;例如,Canny 函数的结果是一个不错的选择。以下示例演示了计算图像轮廓所需的步骤:
- 使用
Canny函数找到边缘,如下所示:
Mat image = imread("Test.png");
Mat imgGray;
cvtColor(image, imgGray, COLOR_BGR2GRAY);
double threshold1 = 100.0;
double threshold2 = 200.0;
int apertureSize = 3;
bool L2gradient = false;
Mat edges;
Canny(image,
edges,
threshold1,
threshold2,
apertureSize,
L2gradient);
- 使用
findContours函数通过检测到的边缘来计算轮廓。值得注意的是,每个轮廓都是一个Point对象的vector,因此所有轮廓都是一个vector的vector的Point对象,如下所示:
vector<vector<Point> > contours;
int mode = CV_RETR_TREE;
int method = CV_CHAIN_APPROX_TC89_KCOS;
findContours(edges,
contours,
mode,
method);
在前面的例子中,轮廓检索模式设置为 CV_RETR_TREE,轮廓近似方法设置为 CV_CHAIN_APPROX_TC89_KCOS。请确保自己查看所有可能的模式和方法的列表,并比较结果以找到最适合您用例的最佳参数。
- 可视化检测到的轮廓的常见方法是通过使用
RNG类,即随机数生成器类,为每个检测到的轮廓生成随机颜色。以下示例演示了如何结合使用RNG类和drawContours函数来正确可视化findContours函数的结果:
RNG rng(12345); // any random number
Mat result(edges.size(), CV_8UC3, Scalar(0));
int thickness = 2;
for( int i = 0; i< contours.size(); i++ )
{
Scalar color = Scalar(rng.uniform(0, 255),
rng.uniform(0,255),
rng.uniform(0,255) );
drawContours(result,
contours,
i,
color,
thickness);
}
以下图像展示了 Canny 和 findContours 函数的结果:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-algo-cv/img/00091.jpeg
注意右侧图像中的不同颜色,它们对应于使用 findContours 函数检测到的完整轮廓。
计算轮廓之后,我们可以使用轮廓分析函数进一步修改它们或分析图像中物体的形状。让我们从 contourArea 函数开始,该函数可以用来计算给定轮廓的面积。以下是该函数的使用方法:
double area = contourArea(contour);
你可以使用面积作为阈值来忽略不符合某些标准的检测到的轮廓。例如,在前面的示例代码中,我们使用了drawContours函数,我们可以去除面积小于某个预定义阈值值的轮廓。以下是一个示例:
for( int i = 0; i< contours.size(); i++ )
{
if(contourArea(contours[i]) > thresholdArea)
{
drawContours(result,
contours,
i,
color,
thickness);
}
}
将contourArea函数的第二个参数(这是一个布尔参数)设置为true会导致考虑轮廓的朝向,这意味着你可以根据轮廓的朝向得到正或负的面积值。
另一个非常有用的轮廓分析函数是pointPolygonTest函数。从其名称可以猜到,这个函数用于执行点在多边形内测试,换句话说,就是点在轮廓内测试。以下是该函数的使用方法:
Point pt(x, y);
double result = pointPolygonTest(contours[i], Point(x,y), true);
如果结果是零,这意味着测试点正好在轮廓的边缘上。一个负的结果意味着测试点在轮廓外部,而一个正的结果意味着测试点在轮廓内部。这个值本身是测试点到最近的轮廓边缘的距离。
要检查一个轮廓是否是凸的,你可以使用isContourConvex函数,如下例所示:
bool isIt = isContourConvex(contour);
能够比较两个轮廓是处理轮廓和形状分析时最基本的需求之一。你可以使用 OpenCV 中的matchShapes函数来比较并尝试匹配两个轮廓。以下是该函数的使用方法:
ShapeMatchModes method = CONTOURS_MATCH_I1;
double result = matchShapes(cntr1, cntr2, method, 0);
method可以取以下任何值,而最后一个参数必须始终设置为零,除非使用的方法有特殊指定:
-
CONTOURS_MATCH_I1 -
CONTOURS_MATCH_I2 -
CONTOURS_MATCH_I3
关于前面列出的轮廓匹配方法之间的数学差异的详细信息,你可以参考 OpenCV 文档。
能够找到轮廓的边界等同于能够正确地定位它,例如,找到一个可以用于跟踪或执行其他计算机视觉算法的区域。假设我们有一个以下图像及其使用findContours函数检测到的单个轮廓:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-algo-cv/img/00092.jpeg
有了这个轮廓,我们可以执行我们所学到的任何轮廓和形状分析算法。此外,我们可以使用许多 OpenCV 函数来定位提取的轮廓。让我们从boundingRect函数开始,该函数用于找到包含给定点集或轮廓的最小直立矩形(Rect对象)。以下是该函数的使用方法:
Rect br = boundingRect(contour);
下图是使用前一个示例代码中的boundingRect函数获取的直立矩形的绘制结果:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-algo-cv/img/00093.jpeg
类似地,你可以使用minAreaRect函数来找到包含给定点集或轮廓的最小旋转矩形。以下是一个示例:
RotatedRect br = minAreaRect(contour);
你可以使用以下代码来可视化结果旋转矩形:
Point2f points[4];
br.points(points);
for (int i=0; i<4; i++)
line(image,
points[i],
points[(i+1)%4],
Scalar(0,0,255),
2);
您可以使用ellipse函数绘制椭圆,或者两者都做,结果将类似于以下内容:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-algo-cv/img/00094.jpeg
除了用于寻找轮廓的最小垂直和旋转边界矩形的算法外,您还可以使用minEnclosingCircle和minEnclosingTriangle函数来找到给定点集或轮廓的最小边界圆和矩形。以下是如何使用这些函数的示例:
// to detect the minimal bounding circle
Point2f center;
float radius;
minEnclosingCircle(contour, center, radius);
// to detect the minimal bounding triangle
vector<Point2f> triangle;
minEnclosingTriangle(contour, triangle);
轮廓的可能用例清单没有尽头,但在进入下一部分之前,我们将列举其中的一些。您可以尝试将轮廓检测和形状分析算法与阈值算法或后投影图像结合使用,例如,以确保您的跟踪算法除了像素的颜色和强度值外,还使用形状信息。您还可以使用轮廓来计数和分析生产线上的物体形状,在那里背景和视觉环境更加可控。
本章的最后部分将教授您如何使用特征检测、描述符提取和描述符匹配算法来检测已知对象,但具有旋转、缩放甚至透视不变性。
检测、描述和匹配特征
正如我们在本章前面学到的,可以使用各种特征提取(检测)算法从图像中提取特征或关键点,其中大多数依赖于检测强度发生显著变化的点,例如角点。检测正确的关键点等同于能够正确确定哪些图像部分有助于识别它。但仅仅一个关键点,或者说图像中一个显著点的位置,本身并没有什么用处。有人可能会争辩说,图像中关键点位置的集合就足够了,但即便如此,另一个外观完全不同的物体也可能在图像中具有相同位置的关键点,比如说,偶然之间。
这就是特征描述符,或者简单地称为描述符,发挥作用的地方。从名称中你可以猜到,描述符是一种算法依赖的方法,用于描述一个特征,例如,通过使用其相邻像素值、梯度等。有许多不同的描述符提取算法,每个都有自己的优缺点,逐一研究它们并不会带来太多成果,尤其是对于一本实践性书籍来说,但值得注意的是,大多数算法只是从一组关键点中生成一个描述符向量。从一组关键点中提取了一组描述符后,我们可以使用描述符匹配算法来从两张不同的图像中找到匹配的特征,例如,一个物体的图像和该物体存在的场景图像。
OpenCV 包含大量的特征检测器、描述符提取器和描述符匹配器。OpenCV 中所有的特征检测器和描述符提取器算法都是Feature2D类的子类,它们位于features2d模块中,该模块默认包含在 OpenCV 包中,或者位于xfeatures2d(额外模块)模块中。您应谨慎使用这些算法,并始终参考 OpenCV 文档,因为其中一些实际上是受专利保护的,并且在使用商业项目时需要从其所有者处获得许可。以下是 OpenCV 默认包含的一些主要特征检测器和描述符提取器算法列表:
-
BRISK(二值鲁棒可伸缩关键点)
-
KAZE
-
AKAZE(加速 KAZE)
-
ORB,或方向 BRIEF(二值鲁棒独立基本特征)
所有这些算法都在 OpenCV 中实现了与标题完全相同的类,再次强调,它们都是Feature2D类的子类。它们的使用非常简单,尤其是在没有修改任何参数的情况下。在所有这些算法中,您只需使用静态create方法来创建其实例,调用detect方法来检测关键点,最后调用computer来提取检测到的关键点的描述符。
对于描述符匹配算法,OpenCV 默认包含以下匹配算法:
-
FLANNBASED -
BRUTEFORCE -
BRUTEFORCE_L1 -
BRUTEFORCE_HAMMING -
BRUTEFORCE_HAMMINGLUT -
BRUTEFORCE_SL2
您可以使用DescriptorMatcher类,或其子类,即BFMatcher和FlannBasedMatcher,来执行各种匹配算法。您只需使用这些类的静态create方法来创建其实例,然后使用match方法来匹配两组描述符。
让我们通过一个完整的示例来回顾本节中讨论的所有内容,因为将特征检测、描述符提取和匹配分开是不可能的,它们都是一系列过程的一部分,这些过程导致在场景中检测到具有其特征的对象:
- 使用以下代码读取对象的图像,以及将要搜索对象的场景:
Mat object = imread("object.png");
Mat scene = imread("Scene.png");
在以下图片中,假设左边的图像是我们正在寻找的对象,右边的图像是包含该对象的场景:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-algo-cv/img/00095.jpeg
- 从这两张图像中提取关键点,现在它们存储在
object和scene中。我们可以使用上述任何一种算法进行特征检测,但让我们假设我们在这个例子中使用 KAZE,如下所示:
Ptr<KAZE> detector = KAZE::create();
vector<KeyPoint> objKPs, scnKPs;
detector->detect(object, objKPs);
detector->detect(scene, scnKPs);
- 我们有了对象图像和场景图像的关键点。我们可以继续使用
drawKeypoints函数来查看它们,就像我们在本章中之前学到的。自己尝试一下,然后使用相同的KAZE类从关键点中提取描述符。以下是操作方法:
Mat objDesc, scnDesc;
detector->compute(object, objKPs, objDesc);
detector->compute(scene, scnKPs, scnDesc);
objDesc和scnDesc对应于从物体和场景图像中提取的关键点的描述符。如前所述,描述符是算法相关的,要解释它们中的确切值需要深入了解用于提取它们的特定算法。确保参考 OpenCV 文档以获取更多关于它们的知识,然而,在本步骤中,我们将简单地使用穷举匹配器算法来匹配从两张图像中提取的描述符。以下是操作方法:
Ptr<BFMatcher> matcher = BFMatcher::create();
vector<DMatch> matches;
matcher->match(objDesc, scnDesc, matches);
BFMatcher类是DescriptorMatcher类的子类,实现了穷举匹配算法。描述符匹配的结果存储在一个DMatch对象vector中。每个DMatch对象包含匹配特征所需的所有必要信息,从物体描述符到场景描述符。
- 您现在可以尝试使用
drawMatches函数可视化匹配结果,如下所示:
Mat result;
drawMatches(object,
objKPs,
scene,
scnKPs,
matches,
result,
Scalar(0, 255, 0), // green for matched
Scalar::all(-1), // unmatched color (not used)
vector<char>(), // empty mask
DrawMatchesFlags::NOT_DRAW_SINGLE_POINTS);
如您所见,一些匹配的特征显然是不正确的,一些位于场景图像的顶部,还有一些位于底部:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-algo-cv/img/00096.jpeg
- 可以通过在
DMatch对象的distance值上设置阈值来过滤掉不良匹配。阈值取决于算法和图像内容类型,但在我们的示例案例中,使用 KAZE 算法,0.1的值似乎对我们来说足够了。以下是过滤阈值以从所有匹配中获取良好匹配的方法:
vector<DMatch> goodMatches;
double thresh = 0.1;
for(int i=0; i<objDesc.rows; i++)
{
if(matches[i].distance < thresh)
goodMatches.push_back(matches[i]);
}
if(goodMatches.size() > 0)
{
cout << "Found " << goodMatches.size() << " good matches.";
}
else
{
cout << "Didn't find a single good match. Quitting!";
return -1;
}
以下图像展示了在goodMatches向量上drawMatches函数的结果:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-algo-cv/img/00097.jpeg
- 显然,经过过滤的匹配结果现在要好得多。我们可以使用
findHomography函数来找到物体图像到场景图像之间良好的匹配关键点的变换。以下是操作方法:
vector<Point2f> goodP1, goodP2;
for(int i=0; i<goodMatches.size(); i++)
{
goodP1.push_back(objKPs[goodMatches[i].queryIdx].pt);
goodP2.push_back(scnKPs[goodMatches[i].trainIdx].pt);
}
Mat homoChange = findHomography(goodP1, goodP2);
- 正如我们在前面的章节中已经看到的,
findHomography函数的结果可以用来变换一组点。我们可以利用这个事实,使用物体图像的四个角来创建四个点,然后使用perspectiveTransform函数变换这些点,以获取场景图像中这些点的位置。以下是一个示例:
vector<Point2f> corners1(4), corners2(4);
corners1[0] = Point2f(0,0);
corners1[1] = Point2f(object.cols-1, 0);
corners1[2] = Point2f(object.cols-1, object.rows-1);
corners1[3] = Point2f(0, object.rows-1);
perspectiveTransform(corners1, corners2, homoChange);
- 变换后的点可以用来绘制四条线,以定位场景图像中检测到的物体,如下所示:
line(result, corners2[0], corners2[1], Scalar::all(255), 2);
line(result, corners2[1], corners2[2], Scalar::all(255), 2);
line(result, corners2[2], corners2[3], Scalar::all(255), 2);
line(result, corners2[3], corners2[0], Scalar::all(255), 2);
如果您打算在drawMatches图像结果上绘制定位物体的四条线,那么也很重要的是要更改结果点的x值,以考虑物体图像的宽度。以下是一个示例:
for(int i=0; i<4; i++)
corners2[i].x += object.cols;
以下图像展示了我们检测操作的最终结果:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-algo-cv/img/00098.jpeg
确保亲自尝试其余的特征检测、描述符提取和匹配算法,并比较它们的结果。同时,尝试测量每个算法的计算时间。例如,你可能注意到 AKAZE 比 KAZE 快得多,或者 BRISK 更适合某些图像,而 KAZE 或 ORB 则更适合其他图像。如前所述,基于特征的目标检测方法在尺度、旋转甚至透视变化方面更加可靠。尝试不同视角的同一物体,以确定你自己的项目和用例的最佳参数和算法。例如,这里有一个演示 AKAZE 算法的旋转和尺度不变性的另一个示例,以及暴力匹配:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-algo-cv/img/00099.jpeg
注意,生成前面输出的源代码是使用本节中我们讨论的完全相同的指令集创建的。
概述
我们从学习模板匹配算法和目标检测算法开始这一章,尽管它很受欢迎,但它缺乏一些适当目标检测算法的最基本方面,如尺度和旋转不变性;此外,它是一个纯像素级目标检测算法。在此基础上,我们学习了如何使用全局最大值和最小值检测算法来解释模板匹配算法的结果。然后,我们学习了关于角点和边缘检测算法,或者换句话说,检测图像中点和重要区域的算法。我们学习了如何可视化它们,然后转向学习轮廓检测和形状分析算法。本章的最后部分包括了一个完整的教程,介绍如何在图像中检测关键点,从这些关键点中提取描述符,并使用匹配器算法在场景中检测目标。我们现在熟悉了一整套算法,可以用来分析图像,不仅基于它们的像素颜色和强度值,还基于它们的内容和现有的关键点。
本书最后一章将带我们了解 OpenCV 中的计算机视觉和机器学习算法,以及它们如何被用于检测使用先前存在的一组图像来检测对象,以及其他许多有趣的人工智能相关主题。
问题
-
模板匹配算法本身不具有尺度和旋转不变性。我们如何使其对于以下情况成立?a) 模板图像的尺度加倍,b) 模板图像旋转 90 度?
-
使用
GFTTDetector类通过 Harris 角点检测算法检测关键点。你可以为角点检测算法设置任何值。 -
Hough 变换也可以用于检测图像中的圆,使用
HoughCircles函数。在 OpenCV 文档中搜索它,并编写一个程序来检测图像中的圆。 -
在图像中检测并绘制凸轮廓。
-
使用
ORB类在两张图像中检测关键点,提取它们的描述符,并将它们进行匹配。 -
哪种特征描述符匹配算法与 ORB 算法不兼容,以及原因是什么?
-
您可以使用以下 OpenCV 函数和提供的示例来计算执行任意多行代码所需的时间。用它来计算您计算机上匹配算法所需的时间:
double freq = getTickFrequency();
double countBefore = getTickCount();
// your code goes here ..
double countAfter = getTickCount();
cout << "Duration: " <<
(countAfter - countBefore) / freq << " seconds";
第八章:计算机视觉中的机器学习
在前面的章节中,我们学习了关于目标检测和跟踪的许多算法。我们学习了如何结合直方图和反向投影图像使用基于颜色的算法,如均值漂移和 CAM 漂移,以极快的速度在图像中定位目标。我们还学习了模板匹配及其如何用于在图像中找到具有已知像素模板的对象。所有这些算法都以某种方式依赖于图像属性,如亮度或颜色,这些属性很容易受到环境光照变化的影响。基于这些事实,我们继续学习基于图像中显著区域知识的算法,称为关键点或特征。我们学习了关于边缘和关键点检测算法以及如何提取这些关键点的描述符。我们还学习了描述符匹配器以及如何使用从感兴趣对象图像和搜索该对象的场景中提取的描述符的良好匹配来检测图像中的对象。
在本章中,我们将迈出一大步,学习可以用于从大量对象图像中提取模型并随后使用该模型在图像中检测对象或简单地分类图像的算法。这些算法是机器学习算法和计算机视觉算法的交汇点。任何熟悉人工智能和一般机器学习算法的人都将很容易继续本章的学习,即使他们不熟悉本章中介绍的精确算法和示例。然而,对于那些对这类概念完全陌生的人来说,可能需要再找一本书,最好是关于机器学习的,以便熟悉我们将在本章学习的算法,例如支持向量机(SVM)、人工神经网络(ANN)、级联分类和深度学习。
在本章中,我们将探讨以下内容:
-
如何训练和使用 SVM 进行分类
-
使用 HOG 和 SVM 进行图像分类
-
如何训练和使用 ANN 进行预测
-
如何训练和使用 Haar 或 LBP 级联分类器进行实时目标检测
-
如何使用第三方深度学习框架中的预训练模型
技术要求
-
用于开发 C++或 Python 应用程序的 IDE
-
OpenCV 库
有关如何设置个人计算机并使其准备好使用 OpenCV 库开发计算机视觉应用程序的更多信息,请参阅第二章,OpenCV 入门。
您可以使用此 URL 下载本章的源代码和示例:github.com/PacktPublishing/Hands-On-Algorithms-for-Computer-Vision/tree/master/Chapter08。
支持向量机
简单来说,支持向量机(SVMs)用于从标记的训练样本集中创建一个模型,该模型可以用来预测新样本的标签。例如,假设我们有一组属于两个不同组的样本数据。我们训练数据集中的每个样本都是一个浮点数向量,它可以对应于任何东西,例如二维或三维空间中的一个简单点,并且每个样本都标记为一个数字,如 1、2 或 3。有了这样的数据,我们可以训练一个 SVM 模型,用来预测新的二维或三维点的标签。让我们再考虑另一个问题。想象一下,我们有了来自世界所有大陆城市的 365 天的温度数据,365 天的温度值向量被标记为 1 代表亚洲,2 代表欧洲,3 代表非洲等等。我们可以使用这些数据来训练一个 SVM 模型,用来预测新的温度值向量(365 天)所属的大陆,并将它们与标签关联起来。尽管这些例子在实践上可能没有用,但它们描述了 SVM 的概念。
我们可以使用 OpenCV 中的 SVM 类来训练和使用 SVM 模型。让我们通过一个完整的示例详细说明 SVM 类的使用方法:
- 由于 OpenCV 中的机器学习算法包含在
ml命名空间下,我们需要确保在我们的代码中包含这些命名空间,以便其中的类可以轻松访问,以下是如何做到这一点的代码:
using namespace cv;
using namespace ml;
- 创建训练数据集。正如我们之前提到的,训练数据集是一组浮点数向量(样本)的集合,每个向量都被标记为该向量的类别 ID 或类别。让我们从样本开始:
const int SAMPLE_COUNT = 8;
float samplesA[SAMPLE_COUNT][2]
= { {250, 50},
{125, 100},
{50, 50},
{150, 150},
{100, 250},
{250, 250},
{150, 50},
{50, 250} };
Mat samples(SAMPLE_COUNT, 2, CV_32F, samplesA);
在这个例子中,我们八个样本的数据集中的每个样本包含两个浮点值,这些值可以用图像上的一个点来表示,该点具有 x 和 y 值。
- 我们还需要创建标签(或响应)数据,显然它必须与样本长度相同。以下是它:
int responsesA[SAMPLE_COUNT]
= {2, 2, 2, 2, 1, 2, 2, 1};
Mat responses(SAMPLE_COUNT, 1, CV_32S, responsesA);
如您所见,我们的样本被标记为 1 和 2 的值,因此我们期望我们的模型能够区分给定两组样本中的新样本。
- OpenCV 使用
TrainData类来简化训练数据集的准备和使用。以下是它的使用方法:
Ptr<TrainData> data;
SampleTypes layout = ROW_SAMPLE;
data = TrainData::create(samples,
layout,
responses);
在前面的代码中,layout 被设置为 ROW_SAMPLE,因为我们的数据集中的每一行包含一个样本。如果数据集的布局是垂直的,换句话说,如果数据集中的每个样本是 samples 矩阵中的一列,我们需要将 layout 设置为 COL_SAMPLE。
- 创建实际的
SVM类实例。这个类在 OpenCV 中实现了各种类型的 SVM 分类算法,并且可以通过设置正确的参数来使用。在这个例子中,我们将使用SVM类最基本(也是最常见)的参数集,但要能够使用此算法的所有可能功能,请确保查阅 OpenCVSVM类文档页面。以下是一个示例,展示了我们如何使用 SVM 执行线性n-类分类:
Ptr<SVM> svm = SVM::create();
svm->setType(SVM::C_SVC);
svm->setKernel(SVM::LINEAR);
svm->setTermCriteria(
TermCriteria(TermCriteria::MAX_ITER +
TermCriteria::EPS,
100,
1e-6));
- 使用
train(或trainAuto)方法训练 SVM 模型,如下所示:
if(!svm->train(data))
{
cout << "training failed" << endl;
return -1;
}
根据我们训练样本数据集中的数据量,训练过程可能需要一些时间。在我们的例子中,应该足够快,因为我们只是使用了一小部分样本来训练模型。
- 我们将使用 SVM 模型来实际预测新样本的标签。记住,我们训练集中的每个样本都是一个图像中的 2D 点。我们将找到图像中宽度为
300像素、高度为300像素的每个 2D 点的标签,然后根据其预测标签是1还是2,将每个像素着色为绿色或蓝色。以下是方法:
Mat image = Mat::zeros(300,
300,
CV_8UC3);
Vec3b blue(255,0,0), green(0,255,0);
for (int i=0; i<image.rows; ++i)
{
for (int j=0; j<image.cols; ++j)
{
Mat_<float> sampleMat(1,2);
sampleMat << j, i;
float response = svm->predict(sampleMat);
if (response == 1)
image.at<Vec3b>(i, j) = green;
else if (response == 2)
image.at<Vec3b>(i, j) = blue;
}
}
- 显示预测结果,但要能够完美地可视化 SVM 算法的分类结果,最好绘制我们用来创建 SVM 模型的训练样本。让我们使用以下代码来完成它:
Vec3b black(0,0,0), white(255,255,255), color;
for(int i=0; i<SAMPLE_COUNT; i++)
{
Point p(samplesA[i][0],
samplesA[i][1]);
if (responsesA[i] == 1)
color = black;
else if (responsesA[i] == 2)
color = white;
circle(image,
p,
5,
color,
CV_FILLED);
}
两种类型的样本(1和2)在结果图像上被绘制为黑色和白色的圆圈。以下图表展示了我们刚刚执行的完整 SVM 分类的结果:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-algo-cv/img/00100.gif
这个演示非常简单,实际上,SVM 可以用于更复杂的分类问题,然而,它实际上展示了 SVM 最基本的一个方面,即分离被标记为相同的数据组。正如您在前面的图像中可以看到,将蓝色区域与绿色区域分开的线是能够最有效地分离图像上黑色点和白色点的最佳单一线。
您可以通过更新标签,或者换句话说,更新前一个示例中的响应来实验这种现象,如下所示:
int responsesA[SAMPLE_COUNT]
= {2, 2, 2, 2, 1, 1, 2, 1};
现在尝试可视化结果会产生类似以下内容,它再次描绘了分离两组点的最有效线:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-algo-cv/img/00101.gif
您可以非常容易地向数据添加更多类别,或者换句话说,为您的训练样本集添加更多标签。以下是一个示例:
int responsesA[SAMPLE_COUNT]
= {2, 2, 3, 2, 1, 1, 2, 1};
我们可以通过添加黄色,例如,为第三类区域添加颜色,为属于该类的训练样本添加灰色点来再次尝试可视化结果。以下是使用三个类别而不是两个类别时相同 SVM 示例的结果:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-algo-cv/img/00102.gif
如果你回想起之前的 365 天示例,很明显我们也可以向 SVM 模型添加更多的维度,而不仅仅是类别,但使用像前面示例那样的简单图像来可视化结果将是不可能的。
在继续使用 SVM 算法进行实际目标检测和图像分类之前,值得注意的是,就像任何其他机器学习算法一样,数据集中样本数量的增加将导致分类效果更好、准确率更高,但也会使模型训练所需的时间更长。
使用 SVM 和 HOG 进行图像分类
方向梯度直方图(HOG)是一种算法,可以用来描述图像,使用从该图像中提取的对应于方向梯度值的浮点描述符向量。HOG 算法非常流行,并且详细阅读它以了解其在 OpenCV 中的实现方法是非常有价值的,但出于本书和特别是本节的目的,我们只需提到,当从具有相同大小和相同 HOG 参数的图像中提取时,浮点描述符的数量始终相同。为了更好地理解这一点,请回忆一下,使用我们在上一章中学习的特征检测算法从图像中提取的描述符可能具有不同数量的元素。然而,HOG 算法在参数不变的情况下,对于同一大小的图像集,总是会生成相同长度的向量。
这使得 HOG 算法非常适合与 SVM 结合使用,以训练一个可以用于图像分类的模型。让我们通过一个例子来看看它是如何实现的。想象一下,我们有一组图像,其中包含一个文件夹中的交通标志图像,另一个文件夹中则包含除该特定交通标志之外的所有图像。以下图片展示了我们的样本数据集中的图像,它们之间用一条黑色线分隔:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-algo-cv/img/00103.jpeg
使用与前面样本相似的图像,我们将训练 SVM 模型以检测图像是否是我们正在寻找的交通标志。让我们开始吧:
- 创建一个
HOGDescriptor对象。HOGDescriptor,或称 HOG 算法,是一种特殊的描述符算法,它依赖于给定的窗口大小、块大小以及各种其他参数;为了简化,我们将避免除窗口大小之外的所有参数。在我们的例子中,HOG 算法的窗口大小是128像素乘以128像素,如下所示:
HOGDescriptor hog;
hog.winSize = Size(128, 128);
样本图像应该与窗口大小相同,否则我们需要使用resize函数确保它们在后续操作中调整到 HOG 窗口大小。这保证了每次使用 HOG 算法时描述符大小的一致性。
- 正如我们刚才提到的,如果图像大小是恒定的,那么使用
HOGDescriptor提取的描述符的向量长度将是恒定的,并且假设图像大小与winSize相同,你可以使用以下代码来获取描述符长度:
vector<float> tempDesc;
hog.compute(Mat(hog.winSize, CV_8UC3),
tempDesc);
int descriptorSize = tempDesc.size();
我们将在读取样本图像时使用 descriptorSize。
- 假设交通标志的图像存储在一个名为
pos(表示正面)的文件夹中,其余的图像存储在一个名为neg(表示负面)的文件夹中,我们可以使用glob函数来获取这些文件夹中图像文件的列表,如下所示:
vector<String> posFiles;
glob("/pos", posFiles);
vector<String> negFiles;
glob("/neg", negFiles);
- 创建缓冲区以存储来自
pos和neg文件夹的正负样本图像的 HOG 描述符。我们还需要一个额外的缓冲区来存储标签(或响应),如下所示:
int scount = posFiles.size() + negFiles.size();
Mat samples(scount,
descriptorSize,
CV_32F);
Mat responses(scount,
1,
CV_32S);
- 我们需要使用
HOGDescriptor类从正图像中提取 HOG 描述符并将它们存储在samples中,如下所示:
for(int i=0; i<posFiles.size(); i++)
{
Mat image = imread(posFiles.at(i));
if(image.empty())
continue;
vector<float> descriptors;
if((image.cols != hog.winSize.width)
||
(image.rows != hog.winSize.height))
{
resize(image, image, hog.winSize);
}
hog.compute(image, descriptors);
Mat(1, descriptorSize, CV_32F, descriptors.data())
.copyTo(samples.row(i));
responses.at<int>(i) = +1; // positive
}
需要注意的是,我们为正样本的标签(响应)添加了 +1。当我们对负样本进行标记时,我们需要使用不同的数字,例如 -1。
- 在正样本之后,我们将负样本及其响应添加到指定的缓冲区中:
for(int i=0; i<negFiles.size(); i++)
{
Mat image = imread(negFiles.at(i));
if(image.empty())
continue;
vector<float> descriptors;
if((image.cols != hog.winSize.width)
||
(image.rows != hog.winSize.height))
{
resize(image, image, hog.winSize);
}
hog.compute(image, descriptors);
Mat(1, descriptorSize, CV_32F, descriptors.data())
.copyTo(samples.row(i + posFiles.size()));
responses.at<int>(i + posFiles.size()) = -1;
}
- 与上一节中的示例类似,我们需要使用
samples和responses来形成一个TrainData对象,以便与train函数一起使用。以下是实现方式:
Ptr<TrainData> tdata = TrainData::create(samples,
ROW_SAMPLE,
responses);
- 现在,我们需要按照以下示例代码训练 SVM 模型:
Ptr<SVM> svm = SVM::create();
svm->setType(SVM::C_SVC);
svm->setKernel(SVM::LINEAR);
svm->setTermCriteria(
TermCriteria(TermCriteria::MAX_ITER +
TermCriteria::EPS,
10000,
1e-6));
svm->train(tdata);
训练完成后,SVM 模型就准备好使用与 HOG 窗口大小相同的图像(在这种情况下,128 x 128 像素)进行分类了,使用 SVM 类的 predict 方法。以下是操作方法:
Mat image = imread("image.jpg");
if((image.cols != hog.winSize.width)
||
(image.rows != hog.winSize.height))
{
resize(image, image, hog.winSize);
}
vector<float> descs;
hog.compute(image, descs);
int result = svm->predict(descs);
if(result == +1)
{
cout << "Image contains a traffic sign." << endl;
}
else if(result == -1)
{
cout << "Image does not contain a traffic sign." << endl;
}
在前面的代码中,我们简单地读取一个图像并将其调整到 HOG 窗口大小。然后我们使用 HOGDescriptor 类的 compute 方法,就像我们在训练模型时做的那样。但是,这次我们使用 predict 方法来找到新图像的标签。如果 result 等于 +1,这是我们训练 SVM 模型时为交通标志图像分配的标签,那么我们知道该图像是交通标志的图像,否则不是。
结果的准确性完全取决于你用于训练 SVM 模型的数据的数量和质量。实际上,每个机器学习算法都是如此。你训练模型越多,它就越准确。
这种分类方法假设输入图像与训练图像具有相同的特征。这意味着,如果图像包含交通标志,它将被裁剪得与用于训练模型的图像相似。例如,如果你使用包含我们正在寻找的交通标志图像的图像,但包含得更多,那么结果可能是不正确的。
随着训练集中数据量的增加,训练模型将需要更多时间。因此,每次你想使用模型时避免重新训练模型是很重要的。SVM类允许你使用save和load方法保存和加载 SVM 模型。以下是如何保存训练好的 SVM 模型以供以后使用并避免重新训练的方法:
svm->save("trained_svm_model.xml");
文件将使用提供的文件名和扩展名(XML 或 OpenCV 支持的任何其他文件类型)保存。稍后,使用静态load函数,你可以创建一个包含确切参数和训练模型的 SVM 对象。以下是一个示例:
Ptr<SVM> svm = SVM::load("trained_svm_model.xml ");
尝试使用SVM类和HOGDescriptor来训练模型,这些模型可以使用存储在不同文件夹中的各种对象的图像检测和分类更多类型。
使用人工神经网络训练模型
ANN 可以使用一组样本输入和输出向量来训练模型。ANN 是一种高度流行的机器学习算法,是许多现代人工智能算法的基础,这些算法用于训练用于分类和关联的模型。特别是在计算机视觉中,ANN 算法可以与广泛的特征描述算法一起使用,以了解物体的图像,甚至不同人的面部,然后用于在图像中检测它们。
你可以使用 OpenCV 中的ANN_MLP类(代表人工神经网络——多层感知器)在你的应用程序中实现 ANN。这个类的使用方法与SVM类非常相似,所以我们将给出一个简单的示例来学习差异以及它在实际中的应用,其余的我们将留给你自己探索。
在 OpenCV 中,创建训练样本数据集对所有机器学习算法都是一样的,或者更准确地说,对所有StatsModel类的子类都是这样。ANN_MLP类也不例外,因此,就像SVM类一样,我们首先需要创建一个TrainData对象,该对象包含我们在训练我们的 ANN 模型时需要使用的所有样本和响应数据,如下所示:
SampleTypes layout = ROW_SAMPLE;
data = TrainData::create(samples,
layout,
responses);
在前面的代码中,samples和responses都是Mat对象,它们包含的行数等于我们数据集中所有训练数据的数量。至于它们的列数,让我们回忆一下,ANN 算法可以用来学习输入和输出数据向量之间的关系。这意味着训练输入数据(或samples)中的列数可以不同于训练输出数据(或responses)中的列数。我们将samples中的列数称为特征数,将responses中的列数称为类别数。简单来说,我们将使用训练数据集来学习特征与类别之间的关系。
在处理完训练数据集之后,我们需要使用以下代码创建一个ANN_MLP对象:
Ptr<ANN_MLP> ann = ANN_MLP::create();
我们跳过了所有自定义设置,使用了默认参数集。如果您需要使用完全自定义的ANN_MLP对象,您需要在ANN_MLP类中设置激活函数、终止标准以及各种其他参数。要了解更多信息,请确保参考 OpenCV 文档和关于人工神经网络的网络资源。
在人工神经网络(ANN)算法中设置正确的层大小需要经验和依赖具体的使用场景,但也可以通过几次试错来设置。以下是您如何设置 ANN 算法中每一层的数量和大小,特别是ANN_MLP类:
Mat_<int> layers(4,1);
layers(0) = featureCount; // input layer
layers(1) = classCount * 4; // hidden layer 1
layers(2) = classCount * 2; // hidden layer 2
layers(3) = classCount; // output layer
ann->setLayerSizes(layers);
在前面的代码中,layers对象中的行数表示我们希望在 ANN 中拥有的层数。layers对象中的第一个元素应包含数据集中的特征数量,而layers对象中的最后一个元素应包含类的数量。回想一下,特征的数量等于samples的列数,类的数量等于responses的列数。layers对象中的其余元素包含隐藏层的尺寸。
通过使用train方法来训练 ANN 模型,如下面的示例所示:
if(!ann->train(data))
{
cout << "training failed" << endl;
return -1;
}
训练完成后,我们可以像之前看到的那样使用save和load方法,以保存模型供以后使用,或从保存的文件中重新加载它。
使用ANN_MLP类与SVM类类似。以下是一个示例:
Mat_<float> input(1, featureCount);
Mat_<float> output(1, classCount);
// fill the input Mat
ann->predict(input, output);
为每个问题选择合适的机器学习算法需要经验和知识,了解项目将如何被使用。支持向量机(SVM)相当简单,适用于我们需要对数据进行分类以及在相似数据组的分割中,而人工神经网络(ANN)可以很容易地用来近似输入和输出向量集之间的函数(回归)。确保尝试不同的机器学习问题,以更好地理解何时以及在哪里使用特定的算法。
级联分类算法
级联分类是另一种机器学习算法,可以用来从许多(数百甚至数千)正负图像样本中训练模型。正如我们之前解释的,正图像指的是我们感兴趣的对象(如人脸、汽车或交通信号)中的图像,我们希望我们的模型学习并随后进行分类或检测。另一方面,负图像对应于任何不包含我们感兴趣对象的任意图像。使用此算法训练的模型被称为级联分类器。
级联分类器最重要的方面,正如其名称所暗示的,是其学习检测对象的级联性质,使用提取的特征。在级联分类器中最广泛使用的特征,以及相应的级联分类器类型,是 Haar 和局部二进制模式(LBP)。在本节中,我们将学习如何使用现有的 OpenCV Haar 和 LBP 级联分类器在实时中检测面部、眼睛等,然后学习如何训练我们自己的级联分类器以检测任何其他对象。
使用级联分类器进行目标检测
要在 OpenCV 中使用先前训练的级联分类器,你可以使用CascadeClassifier类及其提供用于从文件加载分类器或执行图像中的尺度不变检测的简单方法。OpenCV 包含许多用于实时检测面部、眼睛等对象的预训练分类器。如果我们浏览到 OpenCV 的安装(或构建)文件夹,它通常包含一个名为etc的文件夹,其中包含以下子文件夹:
-
haarcascades -
lbpcascades
haarcascades包含预训练的 Haar 级联分类器。另一方面,lbpcascades包含预训练的 LBP 级联分类器。与 LBP 级联分类器相比,Haar 级联分类器通常速度较慢,但在大多数情况下也提供了更好的准确性。要了解 Haar 和 LBP 级联分类器的详细信息,请务必参考 OpenCV 文档以及关于 Haar 小波、Haar-like 特征和局部二进制模式的相关在线资源。正如我们将在下一节中学习的,LBP 级联分类器的训练速度也比 Haar 分类器快得多;只要有足够的训练数据样本,你就可以达到这两种分类器类型相似的准确性。
在我们刚才提到的每个分类器文件夹下,你可以找到许多预训练的级联分类器。你可以使用CascadeClassifier类的load方法加载这些分类器,并准备它们进行实时目标检测,如下面的示例所示:
CascadeClassifier detector;
if(!detector.load("classifier.xml"))
{
cout << "Can't load the provided cascade classifier." << endl;
return -1;
}
在成功加载级联分类器之后,你可以使用detectMultiScale方法在图像中检测对象,并返回一个包含检测到的对象边界框的向量,如下面的示例所示:
vector<Rect> objects;
detector.detectMultiScale(frame,
objects);
for(int i=0; i< objects.size(); i++)
{
rectangle(frame,
objects [i],
color,
thickness);
}
color和thickness之前已定义,用于影响为每个检测到的对象绘制的矩形,如下所示:
Scalar color = Scalar(0,0,255);
int thickness = 2;
尝试加载haarcascade_frontalface_default.xml分类器,它位于haarcascades文件夹中,这是 OpenCV 预安装的,以测试前面的示例。尝试使用包含面部图像的图像运行前面的代码,结果将类似于以下内容:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-algo-cv/img/00104.gif
级联分类器的准确度,就像任何其他机器学习模型一样,完全取决于训练样本数据集的质量和数量。正如之前提到的,级联分类器在实时对象检测中非常受欢迎。为了能够在任何计算机上查看级联分类器的性能,你可以使用以下代码:
double t = (double)getTickCount();
detector.detectMultiScale(image,
objects);
t = ((double)getTickCount() - t)/getTickFrequency();
t *= 1000; // convert to ms
上述代码的最后一行用于将时间测量的单位从秒转换为毫秒。你可以使用以下代码在输出图像上打印结果,例如在左下角:
Scalar green = Scalar(0,255,0);
int thickness = 2;
double scale = 0.75;
putText(frame,
"Took " + to_string(int(t)) + "ms to detect",
Point(0, frame.rows-1),
FONT_HERSHEY_SIMPLEX,
scale,
green,
thickness);
这将生成一个包含类似以下示例中的文本的输出图像:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-algo-cv/img/00105.jpeg
尝试使用 OpenCV 附带的不同预训练级联分类器,并检查它们之间的性能。一个非常明显的观察结果是 LBP 级联分类器的检测速度显著更快。
在前面的示例中,我们只使用了CascadeClassifier类中detectMultiScale方法所需的默认参数集,然而,为了修改其行为,以及在某些情况下显著提高其性能,你将需要调整更多一些参数,如下面的示例所示:
double scaleFactor = 1.1;
int minNeighbors = 3;
int flags = 0; // not used
Size minSize(50,50);
Size maxSize(500, 500);
vector<Rect> objects;
detector.detectMultiScale(image,
objects,
scaleFactor,
minNeighbors,
flags,
minSize,
maxSize);
scaleFactor参数用于指定每次检测后图像的缩放。这意味着内部对图像进行缩放并执行检测。这实际上就是多尺度检测算法的工作方式。在对象中搜索图像,其大小通过给定的scaleFactor减小,然后再次进行搜索。重复进行尺寸减小,直到图像大小小于分类器大小。然后返回所有尺度中所有检测的结果。scaleFactor参数必须始终包含一个大于 1.0 的值(不等于且不低于)。为了在多尺度检测中获得更高的灵敏度,你可以设置一个值,如 1.01 或 1.05,这将导致检测时间更长,反之亦然。minNeighbors参数指的是将彼此靠近或相似的检测分组以保留检测到的对象。
在 OpenCV 的较新版本中,flags参数被简单地忽略。至于minSize和maxSize参数,它们用于指定图像中对象可能的最小和最大尺寸。这可以显著提高detectMultiScale函数的准确性和速度,因为不在给定尺寸范围内的检测到的对象将被简单地忽略,并且仅重新缩放直到达到minSize。
detectMultiScale还有两个其他变体,我们为了简化示例而跳过了,但你应该亲自检查它们,以了解更多关于级联分类器和多尺度检测的信息。确保还要在网上搜索其他计算机视觉开发者提供的预训练分类器,并尝试将它们用于你的应用程序中。
训练级联分类器
如我们之前提到的,如果你有足够的正负样本图像,你也可以创建自己的级联分类器来检测任何其他对象。使用 OpenCV 训练分类器涉及多个步骤和多个特殊的 OpenCV 应用程序,我们将在本节中介绍这些内容。
创建样本
首先,你需要一个名为opencv_createsamples的工具来准备正图像样本集。另一方面,负图像样本在训练过程中自动从包含任意图像的提供的文件夹中提取,这些图像不包含感兴趣的对象。opencv_createsamples应用程序可以在 OpenCV 安装的bin文件夹中找到。它可以用来创建正样本数据集,要么使用感兴趣对象的单个图像并对其应用扭曲和变换,要么使用之前裁剪或注释的感兴趣对象的图像。让我们首先了解前一种情况。
假设你有一个交通标志(或任何其他对象)的以下图像,并且你想使用它创建一个正样本数据集:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-algo-cv/img/00106.jpeg
你还应该有一个包含负样本源的文件夹。如我们之前提到的,你需要一个包含任意图像的文件夹,这些图像不包含感兴趣的对象。让我们假设我们有一些类似于以下图像,我们将使用这些图像来创建负样本:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-algo-cv/img/00107.jpeg
注意,负图像的大小和宽高比,或者使用正确的术语,背景图像的大小,并不重要。然而,它们必须至少与最小可检测对象(分类器大小)一样大,并且它们绝不能包含感兴趣对象的图像。
要训练一个合适的级联分类器,有时你需要数百甚至数千个以不同方式扭曲的样本图像,这并不容易创建。实际上,收集训练数据是创建级联分类器中最耗时的步骤之一。opencv_createsamples应用程序可以通过对创建分类器的对象的前一个图像应用扭曲和使用背景图像来生成正样本数据集,从而帮助解决这个问题。以下是如何使用它的一个示例:
opencv_createsamples -vec samples.vec -img sign.png -bg bg.txt
-num 250 -bgcolor 0 -bgthresh 10 -maxidev 50
-maxxangle 0.7 -maxyangle 0.7 -maxzangle 0.5
-w 32 -h 32
以下是前面命令中使用的参数描述:
-
vec用于指定要创建的正样本文件。在这种情况下,它是samples.vec文件。 -
img用于指定用于生成样本的输入图像。在我们的例子中,它是sign.png。 -
bg用于指定背景的描述文件。背景的描述文件是一个简单的文本文件,其中包含所有背景图像的路径(背景描述文件中的每一行包含一个背景图像的路径)。我们创建了一个名为bg.txt的文件,并将其提供给bg参数。 -
num参数确定您想使用给定的输入图像和背景生成的正样本数量;在我们的例子中是 250。当然,您可以使用更高的或更低的数字,这取决于您所需的准确性和训练时间。 -
bgcolor可以用来用灰度强度定义背景颜色。正如您可以在我们的输入图像(交通标志图像)中看到的那样,背景颜色是黑色,因此此参数的值为零。 -
bgthresh参数指定了接受的bgcolor参数的阈值。这在处理某些图像格式中常见的压缩伪影的情况下特别有用,可能会造成相同颜色略有不同的像素值。我们为这个参数使用了 10 的值,以允许对背景像素的一定程度的容忍度。 -
maxidev可以用来设置在生成样本时前景像素值的最大强度偏差。值为 50 表示前景像素的强度可以在其原始值 +/- 50 之间变化。 -
maxxangle,maxyangle, 和maxzangle分别对应在创建新样本时在 x、y 和 z 方向上允许的最大旋转角度。这些值以弧度为单位,我们提供了 0.7、0.7 和 0.5。 -
w和h参数定义了样本的宽度和高度。我们为它们都使用了 32,因为我们想要训练分类器的对象适合正方形形状。这些相同的值将在稍后训练分类器时使用。此外,请注意,这将是您训练的分类器中可以检测到的最小尺寸。
除了前面列表中的参数外,opencv_createsamples 应用程序还接受一个 show 参数,可以用来显示创建的样本,一个 inv 参数可以用来反转样本的颜色,以及一个 randinv 参数可以用来设置或取消样本中像素的随机反转。
执行前面的命令将通过旋转和强度变化对前景像素进行操作,从而生成指定数量的样本。以下是一些生成的样本:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-algo-cv/img/00108.gif
现在我们有了由 opencv_createsamples 生成的正样本向量文件,以及包含背景图像和背景描述文件(前一个示例中的 bg.txt)的文件夹,我们可以开始训练我们的级联分类器了。但在那之前,让我们也了解一下创建正样本向量的第二种方法,即从包含我们感兴趣对象的各个标注图像中提取它们。
第二种方法涉及使用另一个官方 OpenCV 工具,该工具用于在图像中注释正样本。这个工具被称为 opencv_annotation,它可以方便地标记包含我们的正样本(换句话说,即我们打算为它们训练级联分类器的对象)的多个图像中的区域。opencv_annotation 工具在手动注释对象后生成一个注释文本文件,可以用 opencv_createsamples 工具生成适合与 OpenCV 级联训练工具一起使用的正样本向量,我们将在下一节中学习该工具。
假设我们有一个包含类似以下图片的文件夹:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-algo-cv/img/00109.jpeg
所有这些图像都位于一个文件夹中,并且它们都包含我们正在寻找的交通标志(感兴趣的对象)的一个或多个样本。我们可以使用以下命令启动 opencv_annotation 工具并手动注释样本:
opencv_annotation --images=imgpath --annotations=anno.txt
在前面的命令中,imgpath 必须替换为包含图片的文件夹路径(最好是绝对路径,并使用正斜杠)。anno.txt 或任何其他提供的文件名将被填充注释结果,这些结果可以用 opencv_createsamples 生成正样本向量。执行前面的命令将启动 opencv_annotation 工具并输出以下文本,描述如何使用该工具及其快捷键:
* mark rectangles with the left mouse button,
* press 'c' to accept a selection,
* press 'd' to delete the latest selection,
* press 'n' to proceed with next image,
* press 'esc' to stop.
在前面的输出之后,将显示一个类似于以下窗口:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-algo-cv/img/00110.jpeg
你可以使用鼠标左键突出显示一个对象,这将导致绘制一个红色矩形。按下 C 键将完成注释,它将变成红色。继续对同一图像中的其余样本(如果有)进行此过程,然后按 N 键转到下一图像。在所有图像都注释完毕后,你可以通过按 Esc 键退出应用程序。
除了 -images 和 -annotations 参数之外,opencv_annotation 工具还包括一个可选参数,称为 -maxWindowHeight,可以用来调整大于指定尺寸的图片大小。在这种情况下,调整因子可以通过另一个名为 -resizeFactor 的可选参数来指定。
由 opencv_annotation 工具创建的注释文件将看起来像以下这样:
signs01.jpg 2 145 439 105 125 1469 335 185 180
signs02.jpg 1 862 468 906 818
signs03.jpg 1 1450 680 530 626
signs04.jpg 1 426 326 302 298
signs05.jpg 0
signs06.jpg 1 1074 401 127 147
signs07.jpg 1 1190 540 182 194
signs08.jpg 1 794 460 470 488
注释文件中的每一行都包含一个图像的路径,后面跟着该图像中感兴趣对象的数量,然后是这些对象的边界框的 x、y、宽度和高度值。你可以使用以下命令使用这个注释文本文件生成样本向量:
opencv_createsamples -info anno.txt -vec samples.vec -w 32 -h 32
注意这次我们使用了带有 -info 参数的 opencv_createsamples 工具,而当我们使用这个工具从图像和任意背景中生成样本时,这个参数是不存在的。我们现在已经准备好训练一个能够检测我们创建的样本的交通标志的分类器。
创建分类器
我们将要学习的最后一个工具叫做 opencv_traincascade,正如你可以猜到的,它用于训练级联分类器。如果你有足够的样本和背景图像,并且如果你已经按照前述章节描述的那样处理了样本向量,那么你唯一需要做的就是运行 opencv_traincascade 工具并等待训练完成。让我们看看一个示例训练命令,然后详细说明参数:
opencv_traincascade -data classifier -vec samples.vec
-bg bg.txt -numPos 200 -numNeg 200 -w 32 -h 32
这是最简单的开始训练过程的方式,并且只使用必须的参数。在这个命令中使用的所有参数都是自解释的,除了 -data 参数,它必须是一个现有的文件夹,该文件夹将用于在训练过程中创建所需的文件,并且最终训练好的分类器(称为 cascade.xml)将在这个文件夹中创建。
numPos 不能包含高于你的 samples.vec 文件中正样本数量的数字,然而,numNeg 可以包含基本上任何数字,因为训练过程将简单地尝试通过提取提供的背景图像的部分来创建随机负样本。
opencv_traincascade 工具将在设置为 -data 参数的文件夹中创建多个 XML 文件,这个文件夹在训练过程完成之前不得修改。以下是每个文件的简要描述:
-
params.xml文件将包含用于训练分类器的参数。 -
stage#.xml文件是在每个训练阶段完成后创建的检查点。如果训练过程因意外原因而终止,可以使用它们稍后继续训练。 -
cascade.xml文件是训练好的分类器,并且是训练工具最后创建的文件。你可以复制这个文件,将其重命名为方便的名字(例如trsign_classifier.xml或类似的名字),然后使用我们之前章节中学到的CascadeClassifier类,来执行多尺度目标检测。
opencv_traincascade 是一个极其可定制和灵活的工具,你可以轻松修改其许多可选参数,以确保训练好的分类器符合你的需求。以下是其中一些最常用参数的描述:
-
可以使用
numStages来设置用于训练级联分类器的阶段数。默认情况下,numStages等于 20,但你可以减小这个值以缩短训练时间,同时牺牲准确性,或者相反。 -
precalcValBufSize和precalcIdxBufSize参数可以用来增加或减少在级联分类器训练过程中用于各种计算的记忆量。你可以修改这些参数以确保训练过程以更高的效率进行。 -
featureType是训练工具最重要的参数之一,它可以用来设置训练分类器的类型为HAAR(如果忽略则为默认值)或LBP。如前所述,LBP 分类器比 Haar 分类器训练得更快,它们的检测速度也显著更快,但它们缺乏 Haar 级联分类器的准确性。有了适当数量的训练样本,你可能能够训练出一个在准确性方面可以与 Haar 分类器相媲美的 LBP 分类器。
要获取参数及其描述的完整列表,请确保查阅 OpenCV 在线文档。
使用深度学习模型
近年来,深度学习领域取得了巨大的进步,或者更准确地说,是 深度神经网络(DNN),越来越多的库和框架被引入,它们使用深度学习算法和模型,特别是用于计算机视觉目的,如实时目标检测。你可以使用 OpenCV 库的最新版本来读取最流行的 DNN 框架(如 Caffe、Torch 和 TensorFlow)的预训练模型,并将它们用于目标检测和预测任务。
OpenCV 中的 DNN 相关算法和类都位于 dnn 命名空间下,因此,为了能够使用它们,你需要在你的代码中确保包含以下内容:
using namespace cv;
using namespace dnn;
我们将逐步介绍在 OpenCV 中加载和使用 TensorFlow 库的预训练模型进行实时目标检测。这个例子演示了如何使用由第三方库(本例中为 TensorFlow)训练的深度神经网络模型的基礎。所以,让我们开始吧:
- 下载一个可用于目标检测的预训练 TensorFlow 模型。对于我们的示例,请确保从搜索中下载
ssd_mobilenet_v1_coco的最新版本,从官方 TensorFlow 模型在线搜索结果中下载。
注意,这个链接未来可能会发生变化(可能不会很快,但提一下是值得的),所以,如果发生这种情况,你需要简单地在网上搜索 TensorFlow 模型动物园,在 TensorFlow 的术语中,这是一个包含预训练目标检测模型的动物园。
- 在下载
ssd_mobilenet_v1_coco模型包文件后,您需要将其解压到您选择的文件夹中。您将得到一个名为frozen_inference_graph.pb的文件,以及一些其他文件。在 OpenCV 中进行实时对象检测之前,您需要从该模型文件中提取一个文本图文件。此提取可以通过使用名为tf_text_graph_ssd.py的脚本完成,这是一个默认包含在 OpenCV 安装中的 Python 脚本,可以在以下路径找到:
opencv-source-files/samples/dnntf_text_graph_ssd.py
您可以使用以下命令执行此脚本:
tf_text_graph_ssd.py --input frozen_inference_graph.pb
--output frozen_inference_graph.pbtxt
注意,此脚本的正确执行完全取决于您是否在计算机上安装了正确的 TensorFlow。
- 您应该有
frozen_inference_graph.pb和frozen_inference_graph.pbtxt文件,这样我们就可以在 OpenCV 中使用它们来检测对象。因此,我们需要创建一个 DNNNetwork对象并将模型文件读入其中,如下例所示:
Net network = readNetFromTensorflow(
"frozen_inference_graph.pb",
"frozen_inference_graph.pbtxt");
if(network.empty())
{
cout << "Can't load TensorFlow model." << endl;
return -1;
}
- 在确保模型正确加载后,您可以使用以下代码在从摄像头读取的帧、图像或视频文件上执行实时对象检测:
const int inWidth = 300;
const int inHeight = 300;
const float meanVal = 127.5; // 255 divided by 2
const float inScaleFactor = 1.0f / meanVal;
bool swapRB = true;
bool crop = false;
Mat inputBlob = blobFromImage(frame,
inScaleFactor,
Size(inWidth, inHeight),
Scalar(meanVal, meanVal, meanVal),
swapRB,
crop);
network.setInput(inputBlob);
Mat result = network.forward();
值得注意的是,传递给blobFromImage函数的值完全取决于模型,如果您使用的是本例中的相同模型,则应使用完全相同的值。blobFromImage函数将创建一个 BLOB,适用于与深度神经网络预测函数一起使用,或者更准确地说,是与forward函数一起使用。
- 在检测完成后,您可以使用以下代码提取检测到的对象及其边界矩形,所有这些都放入一个单独的
Mat对象中:
Mat detections(result.size[2],
result.size[3],
CV_32F,
result.ptr<float>());
- 可以遍历
detections对象以提取具有可接受检测置信水平的单个检测,并在输入图像上绘制结果。以下是一个示例:
const float confidenceThreshold = 0.5f;
for(int i=0; i<detections.rows; i++)
{
float confidence = detections.at<float>(i, 2);
if(confidence > confidenceThreshold)
{
// passed the confidence threshold
}
}
置信度,即detections对象每行的第三个元素,可以调整以获得更准确的结果,但0.5对于大多数情况或至少作为开始来说应该是一个合理的值。
- 在检测通过置信度标准后,我们可以提取检测到的对象 ID 和边界矩形,并在输入图像上绘制,如下所示:
int objectClass = (int)(detections.at<float>(i, 1)) - 1;
int left = static_cast<int>(
detections.at<float>(i, 3) * frame.cols);
int top = static_cast<int>(
detections.at<float>(i, 4) * frame.rows);
int right = static_cast<int>(
detections.at<float>(i, 5) * frame.cols);
int bottom = static_cast<int>(
detections.at<float>(i, 6) * frame.rows);
rectangle(frame, Point(left, top),
Point(right, bottom), Scalar(0, 255, 0));
String label = "ID = " + to_string(objectClass);
if(objectClass < labels.size())
label = labels[objectClass];
int baseLine = 0;
Size labelSize = getTextSize(label, FONT_HERSHEY_SIMPLEX,
0.5, 2, &baseLine);
top = max(top, labelSize.height);
rectangle(frame,
Point(left, top - labelSize.height),
Point(left + labelSize.width, top + baseLine),
white,
CV_FILLED);
putText(frame, label, Point(left, top),
FONT_HERSHEY_SIMPLEX, 0.5, red);
在前面的示例中,objectClass指的是检测到的对象的 ID,它是检测对象每行的第二个元素。另一方面,第三、第四、第五和第六个元素对应于每个检测对象边界框的左、上、右和下值。其余的代码只是绘制结果,这留下了labels对象。labels是一个string值的vector,可以用来检索每个对象 ID 的可读文本。这些标签,类似于我们在本例中使用的其余参数,是模型相关的。例如,在我们的示例案例中,标签可以在以下位置找到:
github.com/tensorflow/models/blob/master/research/object_detection/data/mscoco_label_map.pbtxt
我们已经将其转换为以下标签向量,用于前面的示例:
const vector<string> labels = { "person", "bicycle" ...};
以下图像展示了在 OpenCV 中使用预训练的 TensorFlow 模型进行目标检测的结果:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-algo-cv/img/00111.jpeg
使用深度学习已被证明非常高效,尤其是在我们需要实时训练和检测多个对象时。确保参考 TensorFlow 和 OpenCV 文档以获取有关如何使用预训练模型或如何为没有已训练模型的物体训练和重新训练 DNN 模型的更多信息。
摘要
我们通过学习 SVM 模型及其如何训练以对相似数据组进行分类来开始这本书的最后一章。我们学习了 SVM 如何与 HOG 描述符结合使用,以了解一个或多个特定对象,然后在新的图像中检测和分类它们。在了解 SVM 模型之后,我们转向使用 ANN 模型,在输入和输出训练样本的多个列的情况下,这些模型提供了更多的功能。本章还包括了如何训练和使用 Haar 和 LBP 级联分类器的完整指南。我们现在熟悉了使用官方 OpenCV 工具从头开始准备训练数据集,然后使用该数据集训练级联分类器的方法。最后,我们通过学习在 OpenCV 中使用预训练的深度学习目标检测模型来结束这一章和这本书。
问题
-
在
SVM类中,train和trainAuto方法之间的区别是什么? -
展示线性与直方图交集之间的区别。
-
如何计算 HOG 窗口大小为 128 x 96 像素的 HOG 描述符大小(其他 HOG 参数保持不变)?
-
如何更新现有的已训练
ANN_MLP,而不是从头开始训练? -
使用
opencv_createsamples创建来自单个公司标志图像的正样本向量所需的命令是什么?假设我们想要有 1,000 个样本,宽度为 24,高度为 32,并且使用默认的旋转和反转参数。 -
训练用于之前问题中的公司标志的 LBP 级联分类器所需的命令是什么?
-
在
opencv_traincascade中训练级联分类器的默认阶段数是多少?我们如何更改它?增加和减少阶段数远超过其默认值有什么缺点?
1215

被折叠的 条评论
为什么被折叠?



