原文:
annas-archive.org/md5/746e42d5d67354a99dd75a9502953e31译者:飞龙
协议:CC BY-NC-SA 4.0
在本章中,我们将介绍以下配方:
-
使用形态学过滤器腐蚀和膨胀图像
-
使用形态学过滤器打开和关闭图像
-
使用形态学过滤器检测边缘和角
-
使用分水岭分割图像
-
使用 MSER 提取特征区域
-
使用 GrabCut 算法提取前景对象
数学形态学是 20 世纪 60 年代为分析和处理离散图像而开发的一种理论。它定义了一系列通过使用预定义的形状元素探测图像来转换图像的算子。这个形状元素与像素邻域的交集方式决定了操作的结果。本章介绍了最重要的形态学算子。它还探讨了使用基于形态学算子的算法进行图像分割和特征检测的问题。
腐蚀和膨胀是最基本的形态学算子。因此,我们将首先介绍这些算子。数学形态学的基本组成部分是结构元素。结构元素可以简单地定义为定义了原点(也称为锚点)的像素配置(以下图中的正方形形状)。应用形态学过滤器包括使用这个结构元素探测图像中的每个像素。当结构元素的原点与给定的像素对齐时,它与图像的交集定义了一个特定形态学操作应用的像素集(以下图中的九个阴影像素)。原则上,结构元素可以是任何形状,但最常见的是使用具有原点在中心的简单形状,如正方形、圆形或菱形(主要是为了效率原因),如下所示:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00050.jpeg
由于形态学过滤器通常在二值图像上工作,我们将使用上一章第一个配方中通过阈值创建的二值图像。然而,由于在形态学中,惯例是用高(白色)像素值表示前景对象,用低(黑色)像素值表示背景对象,因此我们已对图像进行了取反。
在形态学术语中,以下图像被认为是上一章创建的图像的补集:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00051.jpeg
腐蚀和膨胀在 OpenCV 中作为简单的函数实现,分别是cv::erode和cv::dilate。它们的用法简单直接:
// Read input image
cv::Mat image= cv::imread("binary.bmp");
// Erode the image
cv::Mat eroded; // the destination image
cv::erode(image,eroded,cv::Mat());
// Dilate the image
cv::Mat dilated; // the destination image
cv::dilate(image,dilated,cv::Mat());
以下屏幕截图显示了这些函数调用产生的两个图像。第一个屏幕截图显示了腐蚀:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00052.jpeg
第二个屏幕截图显示了膨胀的结果:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00053.jpeg
与所有其他形态学过滤器一样,这个菜谱中的两个过滤器都是根据结构元素定义的每个像素(或邻域)的像素集(或邻域)进行操作的。回想一下,当应用于某个像素时,结构元素的锚点与该像素位置对齐,并且所有与结构元素相交的像素都包含在当前集合中。腐蚀用在定义的像素集中找到的最小像素值替换当前像素。膨胀是互补操作,它用在定义的像素集中找到的最大像素值替换当前像素。由于输入的二值图像只包含黑色(0)和白色(255)像素,每个像素要么被替换为白色像素,要么被替换为黑色像素。
将这两个算子的效果形象化的一种好方法是考虑背景(黑色)和前景(白色)对象。在腐蚀过程中,如果结构元素放置在某个像素位置时接触到背景(即,交集中的一组像素是黑色),那么这个像素将被发送到背景。在膨胀的情况下,如果结构元素在背景像素上接触到前景对象,那么这个像素将被赋予白色值。这解释了为什么腐蚀图像中对象的大小已经减小(形状已经被腐蚀)。注意,一些小对象(可以被认为是“噪声”背景像素)也被完全消除了。同样,膨胀的对象现在更大,它们内部的一些“空洞”也被填充了。默认情况下,OpenCV 使用 3 x 3 的正方形结构元素。当在函数调用中将空矩阵(即cv::Mat())指定为第三个参数时,就得到了这个默认的结构元素,就像在先前的例子中那样。您也可以通过提供一个矩阵来指定您想要的(大小和形状)结构元素,其中非零元素定义了结构元素。在下面的例子中,应用了一个 7 x 7 的结构元素:
cv::Mat element(7,7,CV_8U,cv::Scalar(1));
cv::erode(image,eroded,element);
在这种情况下,效果要破坏性得多,如下面的截图所示:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00054.jpeg
获得相同结果的另一种方法是重复应用相同的结构元素到图像上。这两个函数有一个可选参数可以指定重复的次数:
// Erode the image 3 times.
cv::erode(image,eroded,cv::Mat(),cv::Point(-1,-1),3);
参数cv::Point(-1,-1)表示原点位于矩阵的中心(默认);它可以在结构元素的任何位置定义。得到的图像将与使用 7 x 7 结构元素得到的图像相同。实际上,腐蚀图像两次与腐蚀一个自身膨胀的结构元素相同。这也适用于膨胀。
最后,由于背景/前景的概念是任意的,我们可以做出以下观察(这是腐蚀/膨胀算子的基本属性)。使用结构元素腐蚀前景对象可以看作是图像背景部分的膨胀。换句话说,我们可以得出以下结论:
-
图像的腐蚀等同于补图像膨胀的补集
-
图像的膨胀等同于补图像腐蚀的补集
注意,尽管我们在这里对二值图像应用了形态学滤波器,但这些滤波器也可以用相同的定义应用于灰度图像甚至彩色图像。
此外,请注意,OpenCV 的形态学函数支持就地处理。这意味着您可以使用输入图像作为目标图像,如下所示:
cv::erode(image,image,cv::Mat());
OpenCV 会为您创建所需的临时图像,以确保其正常工作。
-
使用形态学滤波器进行图像的开放和闭合菜谱将腐蚀和膨胀滤波器级联应用以产生新的算子
-
使用形态学滤波器检测边缘和角点菜谱在灰度图像上应用形态学滤波器
之前菜谱向您介绍了两个基本的形态学算子:膨胀和腐蚀。从这些算子中,可以定义其他算子。接下来的两个菜谱将介绍其中的一些。本菜谱中介绍了开放和闭合算子。
为了应用高级形态学滤波器,您需要使用带有适当功能代码的cv::morphologyEx函数。例如,以下调用将应用闭合算子:
cv::Mat element5(5,5,CV_8U,cv::Scalar(1));
cv::Mat closed;
cv::morphologyEx(image,closed,cv::MORPH_CLOSE,element5);
注意,我们使用了 5 x 5 的结构元素来使滤波器效果更加明显。如果我们使用前一道菜谱的二值图像作为输入,我们将获得类似于以下截图所示的图像:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00055.jpeg
类似地,应用形态学开放算子将产生以下截图:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00056.jpeg
上述图像是通过以下代码获得的:
cv::Mat opened;
cv::morphologyEx(image,opened,cv::MORPH_OPEN,element5);
开放和闭合滤波器只是用基本腐蚀和膨胀操作来定义的。闭合定义为图像膨胀的腐蚀。开放定义为图像腐蚀的膨胀。
因此,可以使用以下调用计算图像的闭合:
// dilate original image
cv::dilate(image,result,cv::Mat());
// in-place erosion of the dilated image
cv::erode(result,result,cv::Mat());
开放滤波器可以通过反转这两个函数调用获得。在检查闭合滤波器的结果时,可以看到白色前景对象的小孔已被填充。该滤波器还连接了几个相邻的对象。基本上,任何太小而无法完全包含结构元素的孔洞或间隙都将被滤波器消除。
相反,开运算从场景中消除了几个小物体。所有太小以至于无法包含结构元素的物体都被移除了。
这些过滤器通常用于对象检测。闭运算将错误地分割成更小片段的物体连接起来,而开运算则移除由图像噪声引入的小块。因此,按顺序使用它们是有利的。如果我们连续地对测试二值图像进行闭运算和开运算,我们将获得一个只显示场景中主要物体的图像,如下面的截图所示。如果您希望优先进行噪声过滤,也可以在闭运算之前应用开运算,但这将以消除一些碎片化物体为代价。
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00057.jpeg
注意,对图像多次应用相同的开运算(以及类似地,闭运算)没有任何效果。实际上,由于开运算已经通过填充孔洞,因此再次应用相同的过滤器不会对图像产生任何其他变化。从数学的角度来看,这些算子被称为幂等的。
开运算和闭运算算子通常用于在提取图像的连通组件之前清理图像,如第七章中“提取组件轮廓”菜谱所述,提取线条、轮廓和组件。
形态学过滤器也可以用于检测图像中的特定特征。在本菜谱中,我们将学习如何检测灰度图像中的轮廓和角。
在这个菜谱中,将使用以下图像:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00058.jpeg
图像的边缘可以通过使用cv::morphologyEx函数的适当过滤器来检测。请参阅以下代码:
// Get the gradient image using a 3x3 structuring element
cv::Mat result;
cv::morphologyEx(image,result,
cv::MORPH_GRADIENT,cv::Mat());
// Apply threshold to obtain a binary image
int threshold= 40;
cv::threshold(result, result,
threshold, 255, cv::THRESH_BINARY);
下面的图像是结果:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00059.jpeg
为了使用形态学检测角,我们现在定义一个名为MorphoFeatures的类,如下所示:
class MorphoFeatures
然后使用以下代码在图像上检测角:
// Get the corners
cv::Mat corners;
corners= morpho.getCorners(image);
// Display the corner on the image
morpho.drawOnImage(corners,image);
cv::namedWindow("Corners on Image");
cv::imshow("Corners on Image",image);
在图像中,检测到的角以圆圈的形式显示,如下面的截图所示:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00060.jpeg
要理解形态学算子对灰度图像的影响,可以将图像视为一个拓扑地形,其中灰度级别对应于高度(或海拔)。从这种角度来看,明亮区域对应于山脉,而暗区对应于地形的山谷。此外,由于边缘对应于暗亮像素之间的快速过渡,这些可以想象为陡峭的悬崖。如果在这种地面上应用腐蚀操作符,最终结果将是用一定邻域中的最低值替换每个像素,从而降低其高度。因此,随着山谷的扩张,悬崖将被“腐蚀”。相反,膨胀具有完全相反的效果;也就是说,悬崖将在山谷上方获得地形。然而,在这两种情况下,高原(即强度恒定的区域)将相对保持不变。
这些观察结果导致了一种简单的方法来检测图像的边缘(或悬崖)。这可以通过计算膨胀图像和腐蚀图像之间的差异来实现。由于这两个转换图像主要在边缘位置不同,因此减法将强调图像边缘。这正是当输入cv::MORPH_GRADIENT参数时,cv::morphologyEx函数所做的事情。显然,结构元素越大,检测到的边缘就越粗。这个边缘检测操作符也被称为Beucher梯度(下一章将更详细地讨论图像梯度的概念)。请注意,通过简单地从原始图像减去膨胀图像或从原始图像减去腐蚀图像也可以获得类似的结果。得到的边缘会更细。
角点检测稍微复杂一些,因为它使用了四个不同的结构元素。这个操作符在 OpenCV 中未实现,但我们在这里展示它是如何定义和组合各种形状的结构元素的。其思路是通过使用两个不同的结构元素对图像进行膨胀和腐蚀来封闭图像。这些元素被选择得使得它们不会改变直线边缘,但由于它们各自的效果,角点的边缘将会受到影响。让我们使用以下简单的由单个白色正方形组成的图像来更好地理解这种非对称封闭操作的效果:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00061.jpeg
第一个正方形是原始图像。当使用十字形结构元素膨胀时,正方形的边缘会扩展,除了在角点处,因为十字形没有接触到正方形。这是中间正方形所展示的结果。然后,使用具有菱形形状的结构元素对膨胀后的图像进行腐蚀。这种腐蚀将大多数边缘恢复到原始位置,但由于角点没有被膨胀,所以它们被推得更远。然后得到最右侧的正方形,它(如所见)已经失去了其锐利的角。使用 X 形和正方形结构元素重复相同的程序。这两个元素是先前元素的旋转版本,因此将捕捉到 45 度方向的角。最后,对这两个结果进行差分将提取角特征。
-
第六章中的应用方向滤波器以检测边缘配方,过滤图像描述了执行边缘检测的其他滤波器。
-
第八章,检测兴趣点,介绍了执行角点检测的不同算子。
-
文章《形态梯度》,作者 J.-F. Rivest, P. Soille, 和 S. Beucher,发表于 1992 年 2 月的 ISET 电子成像科学和技术研讨会,SPIE,详细讨论了形态梯度的概念。
-
文章《改进的调节形态角点检测器》,作者 F.Y. Shih, C.-F. Chuang, 和 V. Gaddipati,发表于 2005 年 5 月的《Pattern Recognition Letters》,第 26 卷第 7 期,提供了关于形态角点检测的更多信息。
水印变换是一种流行的图像处理算法,用于快速将图像分割成同质区域。它基于这样的想法:当图像被视为拓扑起伏时,同质区域对应于相对平坦的盆地,这些盆地由陡峭的边缘所限定。由于其简单性,该算法的原始版本往往会过度分割图像,从而产生多个小区域。这就是为什么 OpenCV 提出了该算法的一个变体,该变体使用一组预定义的标记来引导图像段定义。
水印分割是通过使用cv::watershed函数获得的。该函数的输入是一个 32 位有符号整数标记图像,其中每个非零像素代表一个标签。其想法是标记图像中已知属于某个区域的像素。从这个初始标记开始,水印算法将确定其他像素所属的区域。在这个配方中,我们首先创建标记图像作为灰度图像,然后将其转换为整数图像。我们方便地将这一步骤封装到WatershedSegmenter类中。请参考以下代码:
class WatershedSegmenter
cv::Mat process(const cv::Mat &image) {
// Apply watershed
cv::watershed(image,markers);
return markers;
}
这些标记的获取方式取决于应用。例如,一些预处理步骤可能导致了识别一些属于感兴趣对象的像素。然后使用水岭来从初始检测中界定整个对象。在本食谱中,我们将简单地使用本章中使用的二值图像来识别对应原始图像中的动物(这是在第四章,使用直方图计数像素中显示的图像)。因此,从我们的二值图像中,我们需要识别属于前景(动物)的像素和属于背景(主要是草地)的像素。在这里,我们将前景像素标记为标签 255,背景像素标记为标签 128(这种选择完全是任意的;除了 255 之外的其他标签数字也可以工作)。其他像素,即标签未知的那部分像素,被分配值为 0。
到目前为止,二值图像包含太多属于图像各个部分的白色像素。因此,我们将严重侵蚀此图像,仅保留属于重要对象的像素:
// Eliminate noise and smaller objects
cv::Mat fg;
cv::erode(binary,fg,cv::Mat(),cv::Point(-1,-1),4);
结果是以下图像:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00062.jpeg
注意,仍然有一些属于背景(森林)的像素存在。让我们保留它们。因此,它们将被认为是对应于感兴趣对象的。同样,我们通过在原始二值图像上执行大膨胀来选择背景的几个像素:
// Identify image pixels without objects
cv::Mat bg;
cv::dilate(binary,bg,cv::Mat(),cv::Point(-1,-1),4);
cv::threshold(bg,bg,1,128,cv::THRESH_BINARY_INV);
最终的黑色像素对应于背景像素。这就是为什么阈值操作在膨胀之后立即将这些像素的值分配为 128。得到的图像如下:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00063.jpeg
这些图像被组合成标记图像,如下所示:
// Create markers image
cv::Mat markers(binary.size(),CV_8U,cv::Scalar(0));
markers= fg+bg;
注意我们在这里如何使用重载的运算符 + 来组合图像。以下图像将被用作水岭算法的输入:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00064.jpeg
在这个输入图像中,白色区域肯定属于前景对象,灰色区域是背景的一部分,黑色区域具有未知标签。然后通过以下方式获得分割:
// Create watershed segmentation object
WatershedSegmenter segmenter;
// Set markers and process
segmenter.setMarkers(markers);
segmenter.process(image);
标记图像随后被更新,使得每个零像素被分配给输入标签中的一个,而属于找到的边界的像素具有值 -1。标签的最终图像如下所示:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00065.jpeg
边界图像将类似于以下截图:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00066.jpeg
正如我们在前面的菜谱中所做的那样,我们将在分水岭算法的描述中使用拓扑图类比。为了创建分水岭分割,想法是从级别 0 开始逐渐淹没图像。随着“水”的级别逐渐增加(到级别 1、2、3 等等),会形成集水盆地。这些盆地的尺寸也逐步增加,因此,来自两个不同盆地的水最终会汇合。当这种情况发生时,为了保持两个盆地的分离,会创建一个分水岭。一旦水的级别达到最大值,这些创建的盆地和分水岭的集合就形成了分水岭分割。
如预期的那样,淹没过程最初会创建许多小的独立盆地。当所有这些盆地都合并时,会创建许多分水岭线,这导致图像过度分割。为了克服这个问题,已经提出了一种修改后的算法,其中淹没过程从预定义的标记像素集开始。从这些标记创建的盆地按照分配给初始标记的值进行标记。当具有相同标签的两个盆地合并时,不会创建分水岭,从而防止过度分割。这就是调用 cv::watershed 函数时发生的情况。输入标记图像被更新以产生最终的分水岭分割。用户可以输入带有任何数量标签的标记图像,未知标签的像素保留为值 0。标记图像被选择为 32 位有符号整数图像,以便能够定义超过 255 个标签。它还允许将特殊值 -1 分配给与分水岭相关的像素。这是 cv::watershed 函数返回的。
为了便于显示结果,我们引入了两种特殊方法。第一种方法返回标签图像(分水岭值为 0)。这可以通过阈值化轻松完成,如下所示:
// Return result in the form of an image
cv::Mat getSegmentation() {
cv::Mat tmp;
// all segment with label higher than 255
// will be assigned value 255
markers.convertTo(tmp,CV_8U);
return tmp;
}
同样地,第二种方法返回的图像中,分水岭线被赋予值 0,而图像的其余部分为 255。这次使用 cv::convertTo 方法来实现这一结果,如下所示:
// Return watershed in the form of an image
cv::Mat getWatersheds() {
cv::Mat tmp;
// Each pixel p is transformed into
// 255p+255 before conversion
markers.convertTo(tmp,CV_8U,255,255);
return tmp;
}
在转换之前应用的线性变换允许 -1 像素转换为 0(因为 -1255+255=0*)。
值大于 255 的像素被赋予值 255。这是由于在将有符号整数转换为无符号字符时应用的饱和操作。
显然,标记图像可以通过许多不同的方式获得。例如,可以交互式地要求用户在场景的对象和背景上绘制区域。或者,为了识别图像中心位置的对象,也可以简单地输入一个带有中心区域标记的图像,该区域用某种标签标记,而图像的边缘(假设存在背景)用另一个标签标记。这个标记图像可以创建如下:
// Identify background pixels
cv::Mat imageMask(image.size(),CV_8U,cv::Scalar(0));
cv::rectangle(imageMask,cv::Point(5,5),
cv::Point(image.cols-5,
image.rows-5),cv::Scalar(255),3);
// Identify foreground pixels
// (in the middle of the image)
cv::rectangle(imageMask,
cv::Point(image.cols/2-10,image.rows/2-10),
cv::Point(image.cols/2+10,image.rows/2+10),
cv::Scalar(1),10);
如果我们将这个标记图像叠加到测试图像上,我们将获得以下图像:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00067.jpeg
下面的结果是流域图像:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00068.jpeg
-
文章《粘性流域变换》,C. Vachier 和 F. Meyer,数学图像与视觉杂志,第 22 卷,第 2-3 期,2005 年 5 月,提供了关于流域变换的更多信息
-
本章的最后一个配方,使用 GrabCut 算法提取前景对象,介绍了另一种可以将图像分割为背景和前景对象的图像分割算法
在前面的配方中,你学习了如何通过逐渐淹没图像并创建流域来将图像分割成区域。最大稳定极值区域(MSER)算法使用相同的沉浸类比来提取图像中的有意义区域。这些区域也将通过逐级淹没图像来创建,但这次,我们将对在沉浸过程中相对稳定的盆地感兴趣。将观察到这些区域对应于图像中场景对象的某些独特部分。
计算图像 MSER 的基本类是 cv::MSER。可以通过使用默认的空构造函数创建此类的一个实例。在我们的情况下,我们选择通过指定检测到的区域的最小和最大尺寸来初始化它,以限制其数量。然后,我们的调用如下所示:
// basic MSER detector
cv::MSER mser(5, // delta value for extremal region detection
200, // min acceptable area
1500); // max acceptable area
现在,可以通过调用一个函数对象来获得 MSER,指定输入图像和适当的数据结构,如下所示:
// vector of point sets
std::vector<std::vector<cv::Point>> points;
// detect MSER features
mser(image, points);
结果是一个由组成每个区域的像素点表示的区域向量。为了可视化结果,我们在一个空白图像上显示检测到的区域,这些区域将以不同的颜色(随机选择)显示。这是通过以下方式完成的:
// create white image
cv::Mat output(image.size(),CV_8UC3);
output= cv::Scalar(255,255,255);
// random number generator
cv::RNG rng;
// for each detected feature
for (std::vector<std::vector<cv::Point>>::
iterator it= points.begin();
it!= points.end(); ++it)
}
}
注意,MSER 形成区域的一个层次结构。因此,为了使所有这些区域都可见,我们选择在它们包含在更大的区域中时不要覆盖小区域。如果我们在这个图像上检测到 MSER:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00069.jpeg
然后,生成的图像将如下所示(请参考书籍的图形 PDF 以查看此图像的颜色)(参照以下内容):
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00070.jpeg
这些是检测的原始结果。尽管如此,可以观察到这个操作员如何能够从这个图像中提取一些有意义的区域(例如,建筑物的窗户)。
MSER 使用与水算法相同的机制;也就是说,它通过从级别0逐渐淹没图像到级别255来执行。随着水位的升高,你可以观察到那些尖锐界定较暗的区域形成了在一定时间内形状相对稳定的盆地(回忆一下,在沉浸类比中,水位对应于强度级别)。这些稳定的盆地就是 MSER。这些是通过考虑每个级别的连通区域并测量它们的稳定性来检测的。这是通过比较区域的当前面积与当级别下降 delta 值时的先前面积来完成的。当这种相对变化达到局部最小值时,该区域被识别为 MSER。用于测量相对稳定性的 delta 值是cv::MSER类构造函数的第一个参数;其默认值是5。此外,要考虑的区域大小必须在某个预定义的范围内。可接受的区域最小和最大大小是构造函数的下一个两个参数。我们还必须确保 MSER 是稳定的(第四个参数),也就是说,其形状的相对变化足够小。稳定的区域可以包含在更大的区域中(称为父区域)。
要有效,父 MSER 必须与其子足够不同;这是多样性标准,它由cv::MSER构造函数的第五个参数指定。在上一节中使用的示例中,使用了这两个最后参数的默认值。(这些最后两个参数的默认值是 MSER 允许的最大变化为0.25,父 MSER 的最小多样性为0.2。)
MSER 检测器的输出是一个点集的向量。由于我们通常更关注整个区域而不是其单个像素位置,因此通常用描述 MSER 位置和大小的简单几何形状来表示 MSER。边界椭圆是一种常用的表示方法。为了获得这些椭圆,我们将利用两个方便的 OpenCV 函数。第一个是cv::minAreaRect函数,它找到绑定集合中所有点的最小面积矩形。这个矩形由一个cv::RotatedRect实例描述。一旦找到这个边界矩形,就可以使用cv::ellipse函数在图像上绘制内切椭圆。让我们将这个完整的过程封装在一个类中。这个类的构造函数基本上重复了cv::MSER类的构造函数。参考以下代码:
class MSERFeatures {
private:
cv::MSER mser; // mser detector
double minAreaRatio; // extra rejection parameter
public:
MSERFeatures(
// aceptable size range
int minArea=60, int maxArea=14400,
// min value for MSER area/bounding-rect area
double minAreaRatio=0.5,
// delta value used for stability measure
int delta=5,
// max allowed area variation
double maxVariation=0.25,
// min size increase between child and parent
double minDiversity=0.2):
mser(delta,minArea,maxArea,
maxVariation,minDiversity),
minAreaRatio(minAreaRatio) {}
添加了一个额外的参数(minAreaRatio)来消除那些边界矩形面积与所代表 MSER 面积差异很大的 MSER。这是为了去除那些不太有趣的细长形状。
代表性边界矩形的列表是通过以下方法计算的:
// get the rotated bounding rectangles
// corresponding to each MSER feature
// if (mser area / bounding rect area) < areaRatio,
// the feature is rejected
void getBoundingRects(const cv::Mat &image,
std::vector<cv::RotatedRect> &rects)
}
使用以下方法在图像上绘制相应的椭圆:
// draw the rotated ellipses corresponding to each MSER
cv::Mat getImageOfEllipses(const cv::Mat &image,
std::vector<cv::RotatedRect> &rects,
cv::Scalar color=255)
return output;
}
MSER 的检测随后获得如下:
// create MSER feature detector instance
MSERFeatures mserF(200, // min area
1500, // max area
0.5); // ratio area threshold
// default delta is used
// the vector of bounding rotated rectangles
std::vector<cv::RotatedRect> rects;
// detect and get the image
cv::Mat result= mserF.getImageOfEllipses(image,rects);
通过将此函数应用于之前使用的图像,我们将得到以下图像:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00071.jpeg
将此结果与之前的结果进行比较应该会使您相信这种后来的表示更容易解释。注意,子 MSER 和父 MSER 通常由非常相似的椭圆表示。在某些情况下,然后可能会对这些椭圆应用最小变化标准,以消除这些重复的表示。
-
第七章中的计算组件形状描述符配方,提取线条、轮廓和组件将向您展示如何计算连接点集的其他属性
-
第八章,检测兴趣点将解释如何使用 MSER 作为兴趣点检测器
OpenCV 提出了另一个流行的图像分割算法的实现:GrabCut算法。这个算法不是基于数学形态学,但我们在这里介绍它,因为它在用法上与本章 earlier 提到的水系分割算法有一些相似之处。与水系相比,GrabCut 的计算成本更高,但通常会产生更准确的结果。当您想要从静态图像中提取前景对象时(例如,从一个图片中剪切并粘贴对象到另一个图片中),这是最好的算法。
cv::grabCut函数易于使用。您只需要输入一个图像,并标记其中一些像素属于背景或前景。基于这种部分标记,算法将确定整个图像的前景/背景分割。
一种指定输入图像的部分前景/背景标记的方法是在其中定义一个矩形,前景对象包含在这个矩形内:
// define bounding rectangle
// the pixels outside this rectangle
// will be labeled as background
cv::Rect rectangle(5,70,260,120);
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00072.jpeg
然后所有在这个矩形之外的像素将被标记为背景。除了输入图像及其分割图像外,调用cv::grabCut函数还需要定义两个矩阵,这些矩阵将包含算法构建的模型,如下所示:
cv::Mat result; // segmentation (4 possible values)
cv::Mat bgModel,fgModel; // the models (internally used)
// GrabCut segmentation
cv::grabCut(image, // input image
result, // segmentation result
rectangle, // rectangle containing foreground
bgModel,fgModel, // models
5, // number of iterations
cv::GC_INIT_WITH_RECT); // use rectangle
注意我们如何指定使用边界矩形模式,通过将 cv::GC_INIT_WITH_RECT 标志作为函数的最后一个参数(下一节将讨论其他可用模式)。输入/输出分割图像可以具有以下四个值之一:
-
cv::GC_BGD:这是属于背景像素的值(例如,在我们的例子中矩形外的像素) -
cv::GC_FGD:这是属于前景像素的值(在我们的例子中没有这样的像素) -
cv::GC_PR_BGD:这是可能属于背景的像素的值 -
cv::GC_PR_FGD:这是可能属于前景像素的值(即我们例子中矩形内部像素的初始值)
我们通过提取值为 cv::GC_PR_FGD 的像素来得到分割的二值图像。参考以下代码:
// Get the pixels marked as likely foreground
cv::compare(result,cv::GC_PR_FGD,result,cv::CMP_EQ);
// Generate output image
cv::Mat foreground(image.size(),CV_8UC3,
cv::Scalar(255,255,255));
image.copyTo(foreground,// bg pixels are not copied
result);
要提取所有前景像素,即值为 cv::GC_PR_FGD 或 cv::GC_FGD 的像素,可以检查第一个位的值,如下所示:
// checking first bit with bitwise-and
result= result&1; // will be 1 if FG
这之所以可能,是因为这些常量被定义为值 1 和 3,而其他两个(cv::GC_BGD 和 cv::GC_PR_BGD)被定义为 0 和 2。在我们的例子中,得到相同的结果是因为分割图像不包含 cv::GC_FGD 像素(只有 cv::GC_BGD 像素被输入)。
最后,通过以下带有掩膜的复制操作,我们得到前景对象(在白色背景上)的图像:
// Generate output image
cv::Mat foreground(image.size(),CV_8UC3,
cv::Scalar(255,255,255)); // all white image
image.copyTo(foreground,result); // bg pixels not copied
下图是得到的结果:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00073.jpeg
在前面的例子中,GrabCut 算法能够通过简单地指定一个包含这些对象(四只动物)的矩形来提取前景对象。或者,也可以将 cv::GC_BGD 和 cv::GC_FGD 的值分配给分割图像中的某些特定像素,这些像素作为 cv::grabCut 函数的第二个参数提供。然后,指定 GC_INIT_WITH_MASK 作为输入模式标志。这些输入标签可以通过要求用户交互式标记图像的几个元素来获得。也可以将这两种输入模式结合起来。
使用此输入信息,GrabCut 算法通过以下步骤创建背景/前景分割。最初,将前景标签(cv::GC_PR_FGD)暂时分配给所有未标记的像素。根据当前的分类,算法将像素分组为相似颜色的簇(即,背景和前景各有K个簇)。下一步是通过在前景和背景像素之间引入边界来确定背景/前景分割。这是通过一个优化过程来完成的,该过程试图连接具有相似标签的像素,并对在相对均匀强度区域放置边界施加惩罚。此优化问题可以使用图割算法有效地解决,这是一种通过将其表示为应用切割以组成最优配置的连通图来找到问题最优解的方法。获得的分割为像素产生新的标签。然后可以重复聚类过程,并再次找到新的最优分割,依此类推。因此,GrabCut 算法是一个迭代过程,它逐渐改进分割结果。根据场景的复杂性,可以在更多或更少的迭代次数中找到良好的解决方案(在简单情况下,一次迭代就足够了)。
这解释了函数的参数,用户可以指定要应用的迭代次数。算法维护的两个内部模型作为函数的参数传递(并返回)。因此,如果希望通过执行额外的迭代来改进分割结果,可以再次使用上次运行的模型调用该函数。
- 文章《GrabCut:在 ACM Transactions on Graphics (SIGGRAPH) 第 23 卷第 3 期,2004 年 8 月》中,GrabCut:使用迭代图割进行交互式前景提取,由 C. Rother, V. Kolmogorov 和 A. Blake 描述了 GrabCut 算法的详细信息。
在本章中,我们将涵盖以下内容:
-
使用低通滤波器过滤图像
-
使用中值滤波器过滤图像
-
将方向滤波器应用于边缘检测
-
计算图像的拉普拉斯算子
滤波是信号和图像处理中的基本任务之一。它是一个旨在选择性地提取图像中某些方面(在给定应用背景下被认为传达重要信息)的过程。滤波可以去除图像中的噪声,提取有趣的视觉特征,允许图像重采样等。它源于一般的信号与系统理论。我们在此处不会详细讨论这一理论。然而,本章将介绍与滤波相关的一些重要概念,并展示如何在图像处理应用中使用滤波器。但首先,让我们从频域分析的概念进行简要解释。
当我们观察图像时,我们注意到不同的灰度级(或颜色)是如何分布在图像上的。图像之间的差异在于它们有不同的灰度级分布。然而,存在另一种观点,即可以从图像中观察到的灰度级变化。一些图像包含几乎恒定强度的大面积(例如,蓝色的天空),而其他图像的灰度级强度在图像上快速变化(例如,拥挤的繁忙场景中充满了许多小物体)。因此,观察图像中这些变化的频率构成了表征图像的另一种方式。这种观点被称为频域,而通过观察图像的灰度级分布来表征图像被称为空间域。
频域分析将图像分解为其从最低到最高频率的频率内容。图像强度变化缓慢的区域只包含低频,而高频是由强度快速变化产生的。存在几种著名的变换,如傅里叶变换或余弦变换,可以用来明确地显示图像的频率内容。请注意,由于图像是二维实体,它由垂直频率(垂直方向的变化)和水平频率(水平方向的变化)组成。
在频域分析框架下,滤波器是一种操作,它会放大图像中某些频率带的频率,同时阻止(或减少)其他图像频率带。因此,低通滤波器是一种消除图像高频成分的滤波器,而高通滤波器则消除低通成分。本章将介绍一些在图像处理中经常使用的滤波器,并解释它们在应用于图像时的效果。
在这个第一个菜谱中,我们将介绍一些非常基本的低通滤波器。在本章的介绍部分,我们了解到这类滤波器的目的是降低图像变化幅度。实现这一目标的一种简单方法是将每个像素替换为其周围像素的平均值。通过这样做,快速强度变化将被平滑,从而被更渐进的过渡所取代。
cv::blur函数的目标是通过用矩形邻域内计算的每个像素的平均值替换每个像素来平滑图像。这个低通滤波器如下应用:
cv::blur(image,result,
cv::Size(5,5)); // size of the filter
这种类型的滤波器也称为箱形滤波器。在这里,我们使用5x5滤波器来应用它,以便使滤波器效果更明显。请看以下屏幕截图:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00074.jpeg
在前一个图像上应用滤波器的结果是以下屏幕截图:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00075.jpeg
在某些情况下,可能希望给予像素邻域中较近像素更多的重视。因此,可以计算加权平均值,其中附近的像素被分配比远离的像素更大的权重。这可以通过使用遵循高斯函数(一种“钟形”函数)的加权方案来实现。cv::GaussianBlur函数应用这种滤波器,其调用方式如下:
cv::GaussianBlur(image,
result, cv::Size(5,5), // size of the filter
1.5); // parameter controlling
// the shape of the Gaussian
结果随后在以下屏幕截图中显示:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00076.jpeg
如果一个滤波器的应用对应于用一个相邻像素的加权总和替换一个像素,那么这个滤波器被称为线性滤波器。这是均值滤波器的情况,其中一个像素被替换为矩形邻域内所有像素的总和,然后除以这个邻域的大小(以得到平均值)。这就像将每个相邻像素乘以像素总数中的1,然后将所有这些值相加。滤波器的不同权重可以用一个矩阵来表示,该矩阵显示了与考虑的邻域中每个像素位置相关联的乘数。矩阵的中心元素对应于当前应用滤波器的像素。这种矩阵有时被称为核或掩码。对于3x3均值滤波器,相应的核如下:
cv::boxFilter函数使用由许多1组成的正方形核对图像进行滤波。它与均值滤波器类似,但不需要将结果除以系数的数量。
应用线性滤波器相当于将核移动到图像的每个像素上,并将每个对应的像素与其关联的权重相乘。从数学上讲,这种操作称为卷积,可以正式表示如下:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00077.jpeg
前面的双重求和将当前像素 (x,y) 与 K 核的中心对齐,假设该中心位于坐标 (0,0)。
观察本菜谱生成的输出图像,可以观察到低通滤波器的净效应是模糊或平滑图像。这并不奇怪,因为这个滤波器衰减了与物体边缘上可见的快速变化相对应的高频分量。
在高斯滤波器的情况下,与像素关联的权重与其与中心像素的距离成正比。回想一下,1D 高斯函数具有以下形式:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00078.jpeg
归一化系数 A 被选择,使得不同的权重之和为 1。σ(西格玛)值控制着结果高斯函数的宽度。这个值越大,函数就越平坦。例如,如果我们计算区间 [-4, 0, 4] 上 σ = 0.5 的 1D 高斯滤波器的系数,我们得到以下系数:
[0.0 0.0 0.00026 0.10645 0.78657 0.10645 0.00026 0.0 0.0]
对于 σ=1.5,这些系数如下:
[0.00761 0.036075 0.10959 0.21345 0.26666
0.21345 0.10959 0.03608 0.00761 ]
注意,这些值是通过调用 cv::getGaussianKernel 函数并使用适当的 σ 值获得的:
cv::Mat gauss= cv::getGaussianKernel(9, sigma,CV_32F);
高斯函数的对称钟形使其成为滤波的良好选择。参见图下所示:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00079.jpeg
离中心点更远的像素权重较低,这使得像素间的过渡更平滑。这与平坦的均值滤波器形成对比,其中远离中心的像素可能导致当前均值值的突然变化。从频率的角度来看,这意味着均值滤波器并没有移除所有的高频分量。
要在图像上应用 2D 高斯滤波器,可以先对图像行应用 1D 高斯滤波器(以过滤水平频率),然后应用另一个 1D 高斯滤波器于图像列(以过滤垂直频率)。这是可能的,因为高斯滤波器是一个可分离的滤波器(也就是说,2D 核可以分解为两个 1D 滤波器)。可以使用 cv::sepFilter2D 函数应用一个通用的可分离滤波器。也可以直接使用 cv::filter2D 函数应用 2D 核。一般来说,可分离滤波器比不可分离滤波器计算更快,因为它们需要的乘法操作更少。
使用 OpenCV,要应用于图像的高斯滤波器通过提供系数数量(第三个参数,它是一个奇数)和 σ(第四个参数)的值来指定 cv::GaussianBlur。你也可以简单地设置 σ 的值,让 OpenCV 确定适当的系数数量(此时你输入一个值为 0 的滤波器大小)。反之亦然,你可以输入一个大小和一个值为 0 的 σ。将确定最适合给定大小的 σ 值。
当图像缩放时,也会使用低通滤波器;本节解释了原因。图像缩放可能还需要插值像素值;本节也讨论了这一点。
对图像进行下采样
你可能会认为,你可以通过简单地消除图像的一些列和行来减小图像的大小。不幸的是,结果图像看起来不会很好。以下图示通过显示一个测试图像,该图像相对于其原始大小减小了4倍,仅仅保留了每4列和行的1列和行来阐述这一事实。请注意,为了使此图像中的缺陷更加明显,我们通过以两倍更大的像素显示图像来放大图像(下一节将解释如何做到这一点)。请参考以下屏幕截图:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00080.jpeg
显然,我们可以看到图像质量已经下降。例如,原始图像中城堡屋顶的斜边现在在缩小后的图像上看起来像楼梯。图像纹理部分(例如砖墙)的其他锯齿形扭曲也可见。
这些不希望出现的伪影是由一种称为空间混叠的现象引起的,当你试图在无法容纳这些高频成分的过小图像中包含它们时,就会发生这种现象。确实,较小的图像(即像素较少的图像)无法像高分辨率图像那样很好地表示精细纹理和锐利边缘(例如,高清电视与传统电视之间的区别)。由于图像中的精细细节对应于高频,我们在减小图像大小之前需要移除这些高频成分。在本食谱中我们了解到,这可以通过低通滤波器来实现。因此,为了在不添加令人烦恼的伪影的情况下将图像大小减小4倍,你必须首先对原始图像应用低通滤波器,然后再丢弃列和行。以下是使用 OpenCV 进行此操作的步骤:
// first remove high frequency component
cv::GaussianBlur(image,image,cv::Size(11,11),2.0);
// keep only 1 of every 4 pixels
cv::Mat reduced2(image.rows/4,image.cols/4,CV_8U);
for (int i=0; i<reduced2.rows; i++)
for (int j=0; j<reduced2.cols; j++)
reduced2.at<uchar>(i,j)= image.at<uchar>(i*4,j*4);
结果图像如下:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00081.jpeg
当然,图像的一些精细细节已经丢失,但总体而言,图像的视觉质量比之前的情况得到了更好的保留。
OpenCV 还有一个特殊函数也执行图像缩小。这是cv::pyrDown函数:
cv::Mat reducedImage; // to contain reduced image
cv::pyrDown(image,reducedImage); // reduce image size by half
之前的功能使用一个5x5高斯滤波器在减小图像大小之前对其进行低通滤波。存在一个将图像大小加倍的反向cv::pyrUp函数。值得注意的是,在这种情况下,上采样是通过在每两列和行之间插入0值来完成的,然后对扩展的图像应用相同的5x5高斯滤波器(但系数乘以4)。显然,如果您减小图像大小然后再将其放大,您将无法恢复原始图像。在减小大小过程中丢失的内容无法恢复。这两个函数用于创建图像金字塔。这是一个由不同大小图像的堆叠版本组成的数据结构(在这里,每个级别比前一个级别小 2 倍,但减小因子可以更小,例如1.2),通常为了高效图像分析而构建。例如,如果您想在图像中检测一个对象,检测可以首先在金字塔顶部的较小图像上完成,当您定位到感兴趣的对象时,可以通过移动到包含更高分辨率图像版本的金字塔的较低级别来细化搜索。
注意,还有一个更通用的cv::resize函数,允许您指定所需的结果图像大小。您只需指定一个新大小即可,这个大小可以比原始图像小或大:
cv::Mat resizedImage; // to contain resized image
cv::resize(image,resizedImage,
cv::Size(image.cols/4,image.rows/4)); // 1/4 resizing
还可以通过缩放因子来指定缩放。在这种情况下,作为参数给出一个空的大小实例,后跟所需的缩放因子:
cv::resize(image,resizedImage,
cv::Size(), 1.0/4.0, 1.0/4.0); // 1/4 resizing
最后一个参数允许您选择在重采样过程中要使用的插值方法。这将在下一节中讨论。
插值像素值
当图像按分数因子缩放时,执行一些像素插值以在现有像素之间产生新的像素值变得必要。正如在第二章的“重映射图像”食谱中讨论的通用图像重映射,像素插值也是需要的情况。
执行插值的最基本方法是用最近邻策略。必须产生的新的像素网格放置在现有图像之上,每个新像素被分配其原始图像中最接近像素的值。在图像上采样的情况下(即使用比原始网格更密集的新网格),这意味着新网格的多个像素将接收来自相同原始像素的值。
例如,如果我们使用最近邻插值(通过使用插值标志cv::INTER_NEAREST)将上一节中减小大小的图像按3倍缩放,我们得到以下代码:
cv::resize(reduced, newImage,
cv::Size(), 3, 3,cv::INTER_NEAREST);
结果将在以下屏幕截图中显示:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00082.jpeg
在这个例子中,插值相当于简单地每个像素的大小乘以3(这就是我们产生上一节图像的方法)。一个更好的方法是通过组合几个相邻像素的值来插值一个新的像素值。因此,我们可以通过考虑围绕它的四个像素来线性插值像素值,如下面的图所示:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00083.jpeg
这是通过首先垂直插值两个像素值到添加的像素的左侧和右侧来完成的。然后,使用这两个插值像素(在前面的图中以灰色绘制)来水平插值所需位置的像素值。这种双线性插值方案是cv::resize默认使用的方法(也可以通过标志cv::INTER_LINEAR显式指定):
cv::resize(reduced2, newImage,
cv::Size(), 3, 3, cv::INTER_LINEAR);
下面的结果是:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00084.jpeg
还存在其他方法可以产生更好的结果。使用双三次插值,考虑一个4x4像素的邻域来进行插值。然而,由于这种方法使用了更多的像素,并且涉及到立方项的计算,它比双线性插值要慢。
- 第二章中使用邻域访问扫描图像配方的*更多内容…*部分介绍了
cv::filter2D函数。这个函数允许你通过输入你选择的核来对一个图像应用线性滤波器。
本章的第一种配方介绍了线性滤波器的概念。非线性滤波器也存在,并且可以在图像处理中有效地使用。其中一种滤波器就是我们在本配方中介绍的均值滤波器。
由于中值滤波器特别有用,可以用来对抗盐和胡椒噪声(或者在我们的情况下,只有盐),我们将使用在第二章的第一种配方中创建的图像,操作像素,并且在此处重现:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00085.jpeg
中值滤波函数的调用方式与其他滤波器类似:
cv::medianBlur(image,result,5); // size of the filter
结果图像如下:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00086.jpeg
由于中值滤波器不是一个线性滤波器,它不能由核矩阵表示。然而,它也作用于像素的邻域,以确定输出像素值。像素及其邻域形成一组值,正如其名称所暗示的,中值滤波器将简单地计算这组值的中间值,然后当前像素被这个中间值(一组排序后的中间位置的值)所替换。
这解释了为什么该滤波器在消除椒盐噪声方面如此有效。确实,当给定像素邻域中存在异常的黑白像素时,它永远不会被选为中值(而是最大值或最小值),因此它总是被相邻值替换。相比之下,简单的均值滤波器会受到这种噪声的严重影响,如下图中表示我们椒盐噪声损坏图像的均值滤波版本所示:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00087.jpeg
明显地,噪声像素改变了相邻像素的均值。因此,即使经过均值滤波器的模糊处理,噪声仍然可见。
中值滤波器也有保留边缘锐度的优点。然而,它会洗去均匀区域(例如,背景中的树木)的纹理。由于它在图像上产生的视觉影响,中值滤波器通常用于在照片编辑软件工具中创建特殊效果。你应该在彩色图像上测试它,看看它如何产生卡通风格的图像。
本章的第一个配方介绍了使用核矩阵进行线性滤波的想法。所使用的滤波器具有通过移除或衰减其高频成分来模糊图像的效果。在本配方中,我们将执行相反的转换,即放大图像的高频内容。因此,这里引入的高通滤波器将执行边缘检测。
我们将使用的滤波器称为Sobel 滤波器。据说它是一个方向滤波器,因为它只影响垂直或水平图像频率,这取决于使用的滤波器核。OpenCV 有一个函数可以对图像应用Sobel算子。水平滤波器的调用如下:
cv::Sobel(image, // input
sobelX, // output
CV_8U, // image type
1, 0, // kernel specification
3, // size of the square kernel
0.4, 128); // scale and offset
垂直滤波是通过以下(与水平滤波非常相似)的调用实现的:
cv::Sobel(image, // input
sobelY, // output
CV_8U, // image type
0, 1, // kernel specification
3, // size of the square kernel
0.4, 128); // scale and offset
函数提供了几个整型参数,这些将在下一节中解释。请注意,这些参数已被选择以产生输出 8 位图像(CV_8U)的表示。
水平 Sobel 算子的结果如下:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00088.jpeg
由于,如下一节所示,Sobel 算子的核包含正负值,因此 Sobel 滤波器的结果通常在 16 位有符号整数图像(CV_16S)中计算。为了使结果可显示为 8 位图像,如图所示,我们使用了零值对应灰度级 128 的表示方式。负值通过较暗的像素表示,而正值通过较亮的像素表示。垂直 Sobel 图像如下:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00089.jpeg
如果你熟悉照片编辑软件,前面的图像可能会让你想起图像浮雕效果,实际上,这种图像变换通常基于方向滤波器的使用。
这两个结果(垂直和水平)可以组合起来以获得 Sobel 滤波器的范数:
// Compute norm of Sobel
cv::Sobel(image,sobelX,CV_16S,1,0);
cv::Sobel(image,sobelY,CV_16S,0,1);
cv::Mat sobel;
//compute the L1 norm
sobel= abs(sobelX)+abs(sobelY);
Sobel 范数可以通过使用convertTo方法的可选缩放参数方便地在图像中显示,以便获得一个图像,其中零值对应白色,而更高的值则分配更深的灰色阴影:
// Find Sobel max value
double sobmin, sobmax;
cv::minMaxLoc(sobel,&sobmin,&sobmax);
// Conversion to 8-bit image
// sobelImage = -alpha*sobel + 255
cv::Mat sobelImage;
sobel.convertTo(sobelImage,CV_8U,-255./sobmax,255);
结果可以在以下屏幕截图中看到:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00090.jpeg
观察这张图像,现在可以清楚地看到为什么这类算子被称为边缘检测器。然后可以对此图像进行阈值处理,以获得一个二值图,显示图像轮廓。以下代码片段创建了随后的图像:
cv::threshold(sobelImage, sobelThresholded,
threshold, 255, cv::THRESH_BINARY);
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00091.jpeg
Sobel 算子是一个经典的边缘检测线性滤波器,它基于两个简单的3x3核,其结构如下:
如果我们将图像视为一个二维函数,那么 Sobel 算子可以被视为图像在垂直和水平方向上的变化度量。从数学的角度来看,这个度量被称为梯度,它定义为从函数的两个正交方向的一阶导数构成的 2D 向量:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00092.jpeg
因此,Sobel 算子通过在水平和垂直方向上差分像素来给出图像梯度的近似。它在一个围绕感兴趣像素的窗口上操作,以减少噪声的影响。cv::Sobel函数计算图像与 Sobel 核卷积的结果。其完整规范如下:
cv::Sobel(image, // input
sobel, // output
image_depth, // image type
xorder,yorder, // kernel specification
kernel_size, // size of the square kernel
alpha, beta); // scale and offset
因此,您决定是否希望将结果写入无符号字符、有符号整数或浮点图像。当然,如果结果超出了图像像素域,则会应用饱和度。这正是最后两个参数可能很有用的地方。在将结果存储到图像之前,可以将结果通过alpha进行缩放(乘法),并可以添加一个偏移量beta。这就是在前一节中我们如何生成一个将 Sobel 值0表示为中间灰度级别128的图像。每个 Sobel 掩码对应一个方向上的导数。因此,使用两个参数来指定将要应用的核,即x和y方向上的导数阶数。例如,通过指定xorder和yorder参数为1和0,可以得到水平 Sobel 核;而垂直核将通过指定0和1来生成。其他组合也是可能的,但这两个是最常用的(二阶导数的情况将在下一食谱中讨论)。最后,也可以使用大于3x3大小的核。可能的核大小值为1、3、5和7。大小为 1 的核对应于 1D Sobel 滤波器(1x3或3x1)。参见以下*更多内容…*部分,了解为什么使用更大的核可能是有用的。
由于梯度是一个二维向量,它有一个范数和一个方向。梯度向量的范数告诉你变化的幅度是多少,它通常被计算为欧几里得范数(也称为L2 范数):
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00093.jpeg
然而,在图像处理中,这个范数通常被计算为绝对值的和。这被称为L1 范数,它给出的值接近 L2 范数,但计算成本更低。这正是我们在本食谱中做的事情:
//compute the L1 norm
sobel= abs(sobelX)+abs(sobelY);
梯度向量始终指向最陡变化的方向。对于图像来说,这意味着梯度方向将与边缘垂直,指向暗到亮的方向。梯度角方向由以下公式给出:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00094.jpeg
通常,对于边缘检测,只计算范数。但是,如果您需要范数和方向,则可以使用以下 OpenCV 函数:
// Sobel must be computed in floating points
cv::Sobel(image,sobelX,CV_32F,1,0);
cv::Sobel(image,sobelY,CV_32F,0,1);
// Compute the L2 norm and direction of the gradient
cv::Mat norm, dir;
cv::cartToPolar(sobelX,sobelY,norm,dir);
默认情况下,方向是以弧度计算的。只需添加一个额外的参数true,就可以以度为单位计算它们。
通过对梯度幅度的阈值处理,我们得到了一个二值边缘图。选择合适的阈值并非易事。如果阈值设置得太低,过多的(粗的)边缘会被保留;而如果我们选择一个更严格的(更高的)阈值,则会得到断裂的边缘。为了说明这种权衡情况,可以将前面的二值边缘图与以下使用更高阈值值得到的图进行比较:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00095.jpeg
要同时获得低阈值和高阈值的最佳效果,可以使用滞后阈值的概念。这将在下一章中解释,其中我们将介绍 Canny 算子。
其他梯度算子也存在。我们将在本节中介绍其中的一些。在应用导数滤波器之前,也可以先应用高斯平滑滤波器。正如本节所解释的,这会使它对噪声的敏感性降低。
梯度算子
为了估计像素位置的梯度,Prewitt 算子定义了以下核:
Roberts 算子基于以下简单的2x2核:
当需要更精确的梯度方向估计时,首选 Scharr 算子:
注意,可以通过使用CV_SCHARR参数调用cv::Sobel函数来使用 Scharr 核:
cv::Sobel(image,sobelX,CV_16S,1,0, CV_SCHARR);
或者,等价地,你可以调用cv::Scharr函数:
cv::Scharr(image,scharrX,CV_16S,1,0,3);
所有这些方向滤波器都试图估计图像函数的一阶导数。因此,在滤波器方向上存在大强度变化区域时,会获得高值,而平坦区域产生低值。这就是为什么计算图像导数的滤波器是高通滤波器。
高斯导数
导数滤波器是高通滤波器。因此,它们倾向于放大图像中的噪声和小的、高度对比的细节。为了减少这些高频元素的影响,在应用导数滤波器之前先平滑图像是一个好习惯。你可能会认为这将是两个步骤,即先平滑图像然后计算导数。然而,仔细观察这些操作可以发现,可以通过适当选择平滑核将这两个步骤合并为一个。我们之前了解到,图像与滤波器的卷积可以表示为项的求和。有趣的是,一个著名的数学性质是,项的求和的导数等于项的导数的求和。
因此,而不是在平滑的结果上应用导数,可以首先对核进行微分,然后将其与图像卷积。由于高斯核是连续可导的,它是一个特别合适的选择。这就是当你用不同核大小调用cv::sobel函数时所做的事情。该函数将计算具有不同σ值的高斯导数核。例如,如果我们选择 x 方向的7x7Sobel 滤波器(即kernel_size=7),则会得到以下结果:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00096.jpeg
如果你将此图像与之前显示的图像进行比较,可以看出许多细微的细节已被移除,从而使得对更显著的边缘更加重视。请注意,我们现在有一个带通滤波器,高频率通过高斯滤波器移除,低频率通过 Sobel 滤波器移除。
- 第七章中的使用 Canny 算子检测图像轮廓菜谱展示了如何使用两个不同的阈值值获得二值边缘图
拉普拉斯是另一种基于图像导数计算的带通线性滤波器。如将解释,它计算二阶导数以测量图像函数的曲率。
OpenCV 函数cv::Laplacian计算图像的拉普拉斯变换。它与cv::Sobel函数非常相似。实际上,它使用相同的基函数cv::getDerivKernels来获取其核矩阵。唯一的区别是,由于这些是定义上的二阶导数,因此没有导数阶数参数。
对于这个算子,我们将创建一个简单的类,它将封装一些与拉普拉斯相关的有用操作。基本方法如下:
class LaplacianZC {
private:
// laplacian
cv::Mat laplace;
// Aperture size of the laplacian kernel
int aperture;
public:
LaplacianZC() : aperture(3) {}
// Set the aperture size of the kernel
void setAperture(int a) {
aperture= a;
}
// Compute the floating point Laplacian
cv::Mat computeLaplacian(const cv::Mat& image) {
// Compute Laplacian
cv::Laplacian(image,laplace,CV_32F,aperture);
return laplace;
}
在这里,对浮点图像进行拉普拉斯变换的计算。为了得到结果图像,我们执行缩放,如前一个菜谱所示。这种缩放基于拉普拉斯变换的最大绝对值,其中值0被分配为灰度级128。我们类的一个方法允许获得以下图像表示:
// Get the Laplacian result in 8-bit image
// zero corresponds to gray level 128
// if no scale is provided, then the max value will be
// scaled to intensity 255
// You must call computeLaplacian before calling this
cv::Mat getLaplacianImage(double scale=-1.0)
// produce gray-level image
cv::Mat laplaceImage;
laplace.convertTo(laplaceImage,CV_8U,scale,128);
return laplaceImage;
}
使用这个类,从7x7核计算得到的拉普拉斯图像如下:
// Compute Laplacian using LaplacianZC class
LaplacianZC laplacian;
laplacian.setAperture(7); // 7x7 laplacian
cv::Mat flap= laplacian.computeLaplacian(image);
laplace= laplacian.getLaplacianImage();
结果图像如下:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00097.jpeg
形式上,二维函数的拉普拉斯定义为其二阶导数的和:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00098.jpeg
在其最简单形式中,它可以近似为以下 3×3 核:
对于 Sobel 算子,也可以使用更大的核来计算拉普拉斯变换,并且由于此算子对图像噪声更加敏感,因此这样做是可取的(除非计算效率是关注点)。由于这些较大的核是使用高斯函数的二阶导数计算的,因此相应的算子通常被称为高斯拉普拉斯(LoG)。请注意,拉普拉斯的核值总和总是0。这保证了拉普拉斯在强度恒定的区域为零。实际上,由于拉普拉斯测量图像函数的曲率,它应该在平坦区域等于0。
初看,拉普拉斯的效果可能难以解释。从核的定义来看,很明显,任何孤立的像素值(即与邻居非常不同的值)都会被算子放大。这是算子对噪声高度敏感的结果。然而,观察图像边缘周围的拉普拉斯值更有趣。图像中边缘的存在是不同灰度强度区域之间快速转换的结果。沿着边缘(例如,由暗到亮的转换引起的)跟踪图像函数的演变,可以观察到灰度值的上升必然意味着从正曲率(当强度值开始上升时)到负曲率(当强度即将达到其高平台时)的逐渐过渡。因此,正负拉普拉斯值(或反之)之间的转换是边缘存在的好指标。另一种表达这个事实的方式是说,边缘将位于拉普拉斯函数的零交叉处。我们将通过观察测试图像中的一个小型窗口中的拉普拉斯值来阐述这个想法。我们选择一个对应于城堡塔楼底部部分创建的边缘。在以下图像中,画了一个白色方框来显示这个感兴趣区域的精确位置:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00099.jpeg
现在,观察这个窗口内的拉普拉斯值(7x7核),我们得到以下图示:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00100.jpeg
如图中所示,如果你仔细追踪拉普拉斯的零交叉(位于不同符号的像素之间),你会得到一条与图像窗口中可见的边缘相对应的曲线。在先前的图中,我们在与所选图像窗口中可见的塔楼边缘相对应的零交叉处画了虚线。这意味着,原则上,你甚至可以以亚像素的精度检测图像边缘。
跟随拉普拉斯图像中的零交叉曲线是一项精细的任务。然而,可以使用简化的算法来检测近似零交叉位置。这个算法首先在0处对拉普拉斯算子进行阈值处理,以便在正负值之间获得一个分区。这两个分区之间的轮廓对应于我们的零交叉。因此,我们使用形态学操作来提取这些轮廓,即我们从拉普拉斯图像中减去膨胀图像(这是在第五章,使用形态学操作转换图像中的“使用形态学滤波器检测边缘和角点”食谱中介绍的 Beucher 梯度)。此算法通过以下方法实现,该方法生成零交叉的二值图像:
// Get a binary image of the zero-crossings
// laplacian image should be CV_32F
cv::Mat getZeroCrossings(cv::Mat laplace) {
// threshold at 0
// negative values in black
// positive values in white
cv::Mat signImage;
cv::threshold(laplace,signImage,0,255,cv::THRESH_BINARY);
// convert the +/- image into CV_8U
cv::Mat binary;
signImage.convertTo(binary,CV_8U);
// dilate the binary image of +/- regions
cv::Mat dilated;
cv::dilate(binary,dilated,cv::Mat());
// return the zero-crossing contours
return dilated-binary;
}
结果是以下二值图:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00101.jpeg
如您所见,拉普拉斯算子的零交叉检测到所有边缘。没有区分强边缘和弱边缘。我们还提到,拉普拉斯算子对噪声非常敏感。最后,其中一些边缘是由于压缩伪影造成的。所有这些因素都解释了为什么操作器检测到这么多边缘。实际上,拉普拉斯算子通常与其他操作器结合使用来检测边缘(例如,可以在强梯度幅度的零交叉位置声明边缘)。我们还将学习在第八章,检测兴趣点中,拉普拉斯算子和其他二阶算子对于在多个尺度上检测兴趣点非常有用。
拉普拉斯算子是一个高通滤波器。可以通过组合低通滤波器来近似它。但在那之前,让我们先谈谈图像增强,这是一个我们在第二章,操作像素中已经讨论过的主题。
使用拉普拉斯算子增强图像对比度
通过从图像中减去其拉普拉斯算子,可以增强图像的对比度。这就是我们在第二章,操作像素中提到的“使用邻域访问扫描图像”的食谱中所做的。在那里,我们介绍了以下核:
这等于 1 减去拉普拉斯核(即原始图像减去其拉普拉斯算子)。
高斯差分
本章第一道菜谱中介绍的高斯滤波器提取图像的低频部分。我们了解到,通过高斯滤波器过滤的频率范围取决于参数σ,它控制滤波器的宽度。现在,如果我们从两个不同带宽的高斯滤波器对图像进行过滤后得到的两个图像相减,那么得到的图像将只包含一个滤波器保留而另一个没有保留的更高频率部分。这种操作称为高斯差分(DoG),其计算方法如下:
cv::GaussianBlur(image,gauss20,cv::Size(),2.0);
cv::GaussianBlur(image,gauss22,cv::Size(),2.2);
// compute a difference of Gaussians
cv::subtract(gauss22, gauss20, dog, cv::Mat(), CV_32F);
// Compute the zero-crossings of DoG
zeros= laplacian.getZeroCrossings(dog);
此外,我们还计算了 DoG 算子的零交叉,并获得了以下截图:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00102.jpeg
实际上,可以证明,通过适当选择σ值,DoG 算子可以构成 LoG 滤波器的一个良好近似。此外,如果你从σ值递增序列的连续对值中计算一系列高斯差分,你将获得图像的尺度空间表示。这种多尺度表示很有用,例如,对于尺度不变图像特征检测,正如将在第八章“检测兴趣点”中解释的那样。
- 第八章中“检测兴趣点”的检测尺度不变特征菜谱使用拉普拉斯和高斯差分进行尺度不变特征的检测
在本章中,我们将介绍以下内容:
-
使用 Canny 算子检测图像轮廓
-
使用霍夫变换检测图像中的线条
-
将线条拟合到一组点
-
提取组件的轮廓
-
计算组件的形状描述符
为了对图像进行基于内容的分析,必须从构成图像的像素集合中提取有意义的特征。轮廓、线条、块等是基本图像原语,可以用来描述图像中包含的元素。本章将教你如何提取这些重要图像特征。
在上一章中,我们学习了如何检测图像的边缘。特别是,我们向您展示了通过在梯度幅度上应用阈值,可以获得图像主要边缘的二值图。边缘携带重要的视觉信息,因为它们界定了图像元素。因此,它们可以用于,例如,物体识别。然而,简单的二值边缘图有两个主要缺点。首先,检测到的边缘过于粗厚;这使得识别对象的边界更加困难。其次,更重要的是,通常很难找到一个足够低的阈值来检测图像的所有重要边缘,同时这个阈值又足够高,以避免包含太多不重要的边缘。这是一个权衡问题,Canny 算法试图解决这个问题。
Canny 算法通过 OpenCV 中的cv::Canny函数实现。正如将解释的那样,此算法需要指定两个阈值。因此,函数调用如下:
// Apply Canny algorithm
cv::Mat contours;
cv::Canny(image, // gray-level image
contours, // output contours
125, // low threshold
350); // high threshold
看看下面的截图:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00103.jpeg
当算法应用于前面的截图时,结果如下:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00104.jpeg
注意,为了获得前面截图所示的图像,我们必须反转黑白值,因为正常的结果是通过非零像素来表示轮廓。因此,显示的图像仅仅是 255-轮廓。
Canny 算子通常基于在第六章中介绍的 Sobel 算子,即滤波图像,尽管也可以使用其他梯度算子。这里的关键思想是使用两个不同的阈值来确定哪个点应属于轮廓:一个低阈值和一个高阈值。
应选择低阈值,使其包括所有被认为是属于显著图像轮廓的边缘像素。例如,使用前一小节示例中指定的低阈值,并将其应用于 Sobel 算子的结果,可以得到以下边缘图:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00105.jpeg
如所示,界定道路的边缘非常清晰。然而,由于使用了宽容的阈值,检测到的边缘比理想情况下需要的更多。因此,第二个阈值的作用是定义属于所有重要轮廓的边缘。它应该排除所有被认为是异常值的边缘。例如,对应于我们示例中使用的较高阈值的 Sobel 边缘图如下:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00106.jpeg
现在我们有一个包含断裂边缘的图像,但可见的边缘肯定属于场景的重要轮廓。Canny 算法通过仅保留低阈值边缘图中存在连续边缘路径的边缘点,并将这些边缘点连接到属于高阈值边缘图的边缘,来结合这两个边缘图以产生一个最优的轮廓图。它通过仅保留高阈值图中的所有边缘点,同时移除低阈值图中所有孤立的边缘点链来实现。所获得的解决方案构成了一个良好的折衷方案,只要指定了适当的阈值值,就可以获得高质量的轮廓。这种基于使用两个阈值来获得二值图的策略被称为滞后阈值化,可以在任何需要从阈值化操作中获得二值图的环境中使用。然而,这是以更高的计算复杂度为代价的。
此外,Canny 算法还采用了一种额外的策略来提高边缘图的质量。在应用阈值化之前,所有梯度方向上梯度幅度不是最大的边缘点都被移除。回想一下,梯度方向始终垂直于边缘。因此,该方向上的梯度局部最大值对应于轮廓的最大强度点。这解释了为什么 Canny 轮廓图中会得到细边缘。
- J. Canny 的经典文章,边缘检测的计算方法,IEEE Transactions on Pattern Analysis and Image Understanding,第 18 卷,第 6 期,1986 年
在我们人造的世界中,平面和线性结构无处不在。因此,直线在图像中经常可见。这些是有意义的特征,在物体识别和图像理解中起着重要作用。Hough 变换是一个经典算法,常用于在图像中检测这些特定的特征。它最初是为了检测图像中的线而开发的,正如我们将看到的,它还可以扩展到检测其他简单的图像结构。
使用 Hough 变换,线使用以下方程表示:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00107.jpeg
ρ参数是线与图像原点(左上角)之间的距离,θ是垂直于线的角度。在这种表示下,图像中可见的线具有θ角度,介于0和π弧度之间,而ρ半径可以有一个最大值,等于图像对角线的长度。例如,考虑以下一组线:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00108.jpeg
垂直线(例如,线1)的θ角度值等于零,而水平线(例如,线5)的θ值等于π/2。因此,线3的θ角度等于π/4,线4大约在0.7π。为了能够用*[0, π]区间的θ表示所有可能的线,半径值可以取负值。这就是线2的情况,其θ值为0.8π*,而ρ值为负。
OpenCV 为线检测提供了两种 Hough 变换的实现。基本版本是cv::HoughLines。它的输入是一个包含一组点(由非零像素表示)的二值图,其中一些点排列成线。通常,这是一个从 Canny 算子获得的边缘图。cv::HoughLines函数的输出是一个cv::Vec2f元素的向量,每个元素都是一个浮点数的对,它代表检测到的线的参数,(ρ, θ)。以下是一个使用此函数的示例,我们首先应用 Canny 算子以获得图像轮廓,然后使用 Hough 变换检测线:
// Apply Canny algorithm
cv::Mat contours;
cv::Canny(image,contours,125,350);
// Hough transform for line detection
std::vector<cv::Vec2f> lines;
cv::HoughLines(test,lines,
1,PI/180, // step size
60); // minimum number of votes
参数 3 和 4 对应于线搜索的步长。在我们的例子中,函数将通过步长 1 搜索所有可能半径的线,并通过步长 π/180 搜索所有可能的角度。最后一个参数的作用将在下一节中解释。使用这种特定的参数值,在先前的食谱中的道路图像上检测到 15 条线。为了可视化检测结果,将这些线绘制在原始图像上是有趣的。然而,需要注意的是,此算法检测图像中的线,而不是线段,因为每条线的端点没有给出。因此,我们将绘制贯穿整个图像的线。为此,对于垂直方向的线,我们计算它与图像水平极限(即第一行和最后一行)的交点,并在这两个点之间绘制一条线。我们以类似的方式处理水平方向的线,但使用第一列和最后一列。使用 cv::line 函数绘制线。请注意,即使点坐标超出图像范围,此函数也能很好地工作。因此,没有必要检查计算出的交点是否在图像内。然后通过迭代线向量来绘制线,如下所示:
std::vector<cv::Vec2f>::const_iterator it= lines.begin();
while (it!=lines.end()) else { // ~horizontal line
// point of intersection of the
// line with first column
cv::Point pt1(0,rho/sin(theta));
// point of intersection of the line with last column
cv::Point pt2(result.cols,
(rho-result.cols*cos(theta))/sin(theta));
// draw a white line
cv::line(image, pt1, pt2, cv::Scalar(255), 1);
}
++it;
}
以下结果如下:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00109.jpeg
如所示,霍夫变换简单地寻找图像中边缘像素的对齐。这可能会由于偶然的像素对齐或当几条线穿过相同的像素对齐时产生一些错误检测。
为了克服这些问题,并允许检测线段(即带有端点),已经提出了一种变换的变体。这是概率霍夫变换,它在 OpenCV 中作为 cv::HoughLinesP 函数实现。我们在这里使用它来创建我们的 LineFinder 类,该类封装了函数参数:
class LineFinder {
private:
// original image
cv::Mat img;
// vector containing the endpoints
// of the detected lines
std::vector<cv::Vec4i> lines;
// accumulator resolution parameters
double deltaRho;
double deltaTheta;
// minimum number of votes that a line
// must receive before being considered
int minVote;
// min length for a line
double minLength;
// max allowed gap along the line
double maxGap;
public:
// Default accumulator resolution is 1 pixel by 1 degree
// no gap, no minimum length
LineFinder() : deltaRho(1), deltaTheta(PI/180),
minVote(10), minLength(0.), maxGap(0.) {}
查看相应的设置方法:
// Set the resolution of the accumulator
void setAccResolution(double dRho, double dTheta) {
deltaRho= dRho;
deltaTheta= dTheta;
}
// Set the minimum number of votes
void setMinVote(int minv) {
minVote= minv;
}
// Set line length and gap
void setLineLengthAndGap(double length, double gap) {
minLength= length;
maxGap= gap;
}
使用前面的方法,执行霍夫线段检测的方法如下:
// Apply probabilistic Hough Transform
std::vector<cv::Vec4i> findLines(cv::Mat& binary) {
lines.clear();
cv::HoughLinesP(binary,lines,
deltaRho, deltaTheta, minVote,
minLength, maxGap);
return lines;
}
此方法返回一个 cv::Vec4i 向量,其中包含每个检测到的线段的起始点和终点坐标。然后可以使用以下方法在图像上绘制检测到的线:
// Draw the detected lines on an image
void drawDetectedLines(cv::Mat &image,
cv::Scalar color=cv::Scalar(255,255,255)) {
// Draw the lines
std::vector<cv::Vec4i>::const_iterator it2=
lines.begin();
while (it2!=lines.end()) {
cv::Point pt1((*it2)[0],(*it2)[1]);
cv::Point pt2((*it2)[2],(*it2)[3]);
cv::line( image, pt1, pt2, color);
++it2;
}
}
现在,使用相同的输入图像,可以使用以下序列检测线:
// Create LineFinder instance
LineFinder finder;
// Set probabilistic Hough parameters
finder.setLineLengthAndGap(100,20);
finder.setMinVote(60);
// Detect lines and draw them
std::vector<cv::Vec4i> lines= finder.findLines(contours);
finder.drawDetectedLines(image);
cv::namedWindow("Detected Lines with HoughP");
cv::imshow("Detected Lines with HoughP",image);
以下代码给出以下结果:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00110.jpeg
霍夫变换的目标是在二值图像中找到所有通过足够多点的线。它通过考虑输入二值图中的每个单独的像素点并识别所有通过它的可能线来实现。当相同的线通过许多点时,这意味着这条线足够重要,值得考虑。
Hough 变换使用一个二维累加器来计数给定线条被识别的次数。这个累加器的大小由指定的步长(如前所述)确定,这些步长是采用的线条表示法中*(ρ, θ)参数的步长。为了说明变换的工作原理,让我们创建一个 180×200 的矩阵(对应于θ的步长为π/180*,ρ的步长为1):
// Create a Hough accumulator
// here a uchar image; in practice should be ints
cv::Mat acc(200,180,CV_8U,cv::Scalar(0));
这个累加器是不同*(ρ, θ)值的映射。因此,这个矩阵的每个条目对应一条特定的线条。现在,如果我们考虑一个点,比如说坐标为(50,30)的点,那么通过遍历所有可能的θ*角度(步长为π/180)并计算相应的(四舍五入的)ρ值,我们可以识别通过这个点的所有线条:
// Choose a point
int x=50, y=30;
// loop over all angles
for (int i=0; i<180; i++) {
double theta= i*PI/180.;
// find corresponding rho value
double rho= x*std::cos(theta)+y*std::sin(theta);
// j corresponds to rho from -100 to 100
int j= static_cast<int>(rho+100.5);
std::cout << i << "," << j << std::endl;
// increment accumulator
acc.at<uchar>(j,i)++;
}
累加器中对应于计算出的*(ρ, θ)*对的条目随后增加,表示所有这些线条都通过图像的一个点(或者说,每个点为一系列可能的候选线条投票)。如果我们以图像的形式显示累加器(反转并乘以100以使1的计数可见),我们得到以下结果:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00111.jpeg
前面的曲线表示通过考虑点的所有线条的集合。现在,如果我们用,比如说,点(30,10)重复相同的练习,我们现在有以下累加器:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00112.jpeg
如所示,两条结果曲线在一点相交:这一点对应于通过这两点的直线。累加器中相应的条目获得两票,表明有两条线通过这条线。如果对二进制图的所有点重复相同的过程,那么沿给定直线对齐的点将多次增加累加器的公共条目。最后,你只需要识别这个累加器中接收了大量投票的局部极大值,以检测图像中的线条(即点对齐)。cv::HoughLines函数中指定的最后一个参数对应于线条必须接收的最小投票数才能被视为检测到。例如,我们将此值降低到50,如下所示:
cv::HoughLines(test,lines,1,PI/180,50);
之前代码的结果是,将接受更多线条,如前一小节所示,如下面的截图所示:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00113.jpeg
概率 Hough 变换对基本算法进行了一些修改。首先,它不是系统地按行扫描图像,而是在二值图中随机选择点。每当累加器的某个条目达到指定的最小值时,就会沿着相应的行扫描图像,并移除所有通过该行的点(即使它们还没有投票)。这种扫描还确定了将被接受的段长度。为此,算法定义了两个额外的参数。一个是段被接受的最小长度,另一个是形成连续段所允许的最大像素间隙。这一额外步骤增加了算法的复杂性,但部分地通过减少参与投票过程中的点数来补偿,因为其中一些点被线扫描过程消除了。
Hough 变换也可以用来检测其他几何实体。实际上,任何可以用参数方程表示的实体都是 Hough 变换的良好候选者。
检测圆
在圆的情况下,相应的参数方程如下:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00114.jpeg
该方程包括三个参数(圆半径和中心坐标),这意味着需要一个三维累加器。然而,通常发现,随着累加器维度的增加,Hough 变换的可靠性降低。确实,在这种情况下,每个点都会增加累加器的大量条目,因此,局部峰值的精确定位变得更加困难。已经提出了不同的策略来克服这个问题。OpenCV 实现的 Hough 圆检测所使用的策略是两遍扫描。在第一遍扫描中,使用二维累加器来找到候选圆的位置。由于圆周上点的梯度应该指向半径方向,因此对于每个点,只增加累加器沿梯度方向的条目(基于预定义的最小和最大半径值)。一旦检测到可能的圆心(即,已收到预定义数量的投票),在第二遍扫描中构建一个可能的半径的一维直方图。该直方图中的峰值对应于检测到的圆的半径。
实现上述策略的 cv::HoughCircles 函数集成了 Canny 检测和 Hough 变换。其调用方式如下:
cv::GaussianBlur(image,image,cv::Size(5,5),1.5);
std::vector<cv::Vec3f> circles;
cv::HoughCircles(image, circles, CV_HOUGH_GRADIENT,
2, // accumulator resolution (size of the image / 2)
50, // minimum distance between two circles
200, // Canny high threshold
100, // minimum number of votes
25, 100); // min and max radius
注意,在调用cv::HoughCircles函数之前,始终建议您对图像进行平滑处理,以减少可能导致多个错误圆检测的图像噪声。检测的结果以cv::Vec3f实例的向量给出。前两个值是圆心坐标,第三个是半径。
在编写本书时,CV_HOUGH_GRADIENT参数是唯一可用的选项。它对应于两遍圆检测方法。第四个参数定义了累加器分辨率。它是一个除数因子;例如,指定值为 2 会使累加器的大小是图像的一半。下一个参数是两个检测到的圆之间的最小像素距离。另一个参数对应于 Canny 边缘检测器的高阈值。低阈值值始终设置为这个值的一半。第七个参数是在第一次遍历期间,一个中心位置必须收到的最小投票数,才能被认为是第二次遍历的候选圆。最后,最后两个参数是检测到的圆的最小和最大半径值。如所见,该函数包含许多参数,这使得调整变得困难。
一旦获得检测到的圆的向量,可以通过遍历该向量并使用找到的参数调用cv::circle绘图函数,在图像上绘制这些圆:
std::vector<cv::Vec3f>::
const_iterator itc= circles.begin();
while (itc!=circles.end()) {
cv::circle(image,
cv::Point((*itc)[0], (*itc)[1]), // circle centre
(*itc)[2], // circle radius
cv::Scalar(255), // color
2); // thickness
++itc;
}
以下是在测试图像上使用所选参数获得的结果:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00115.jpeg
-
由 C. Galambos、J. Kittler 和 J. Matas 撰写的文章《Gradient-based Progressive Probabilistic Hough Transform by C. Galambos, J. Kittler, and J. Matas, IEE Vision Image and Signal Processing, vol. 148 no 3, pp. 158-165, 2002》是关于 Hough 变换的众多参考文献之一,描述了 OpenCV 中实现的概率算法
-
由 H.K. Yuen、J. Princen、J. Illingworth 和 J. Kittler 撰写的文章《Comparative Study of Hough Transform Methods for Circle Finding, Image and Vision Computing, vol. 8 no 1, pp. 71-77, 1990》描述了使用 Hough 变换进行圆检测的不同策略
在某些应用中,不仅检测图像中的直线,而且获得直线位置和方向的准确估计可能很重要。本食谱将向您展示如何找到最适合给定一组点的直线。
首件事是识别图像中似乎沿直线排列的点。让我们使用前面菜谱中检测到的其中一条线。使用cv::HoughLinesP检测到的线包含在名为lines的std::vector<cv::Vec4i>中。为了提取似乎属于这些线中的第一条线的点集,我们可以这样做。我们在黑色图像上画一条白色线,并与用于检测我们的线的 Canny 边缘图像相交。这可以通过以下语句简单地实现:
int n=0; // we select line 0
// black image
cv::Mat oneline(contours.size(),CV_8U,cv::Scalar(0));
// white line
cv::line(oneline,
cv::Point(lines[n][0],lines[n][1]),
cv::Point(lines[n][2],lines[n][3]),
cv::Scalar(255),
3); // line width
// contours AND white line
cv::bitwise_and(contours,oneline,oneline);
结果是一个只包含可以与指定直线相关联的点的图像。为了引入一些容差,我们画了一条具有一定厚度(这里,3)的线。因此,定义的邻域内的所有点都被接受。以下是通过以下图像获得的(为了更好的观看效果进行了反转):
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00116.jpeg
然后,可以通过以下双重循环将此集合中点的坐标插入到std::vector的cv::Point对象中(也可以使用浮点坐标,即cv::Point2f):
std::vector<cv::Point> points;
// Iterate over the pixels to obtain all point positions
for( int y = 0; y < oneline.rows; y++ )
}
}
通过调用cv::fitLine OpenCV 函数可以轻松找到最佳拟合线:
cv::Vec4f line;
cv::fitLine(points,line,
CV_DIST_L2, // distance type
0, // not used with L2 distance
0.01,0.01); // accuracy
前面的代码以单位方向向量(cv::Vec4f的前两个值)和直线上一点的坐标(cv::Vec4f的最后两个值)的形式给出了线方程参数。对于我们的例子,这些值是方向向量为(0.83, 0.55),点坐标为(366.1, 289.1)。最后两个参数指定了线参数的请求精度。
通常,线方程将用于计算某些属性(校准是一个需要精确参数表示的好例子)。为了说明,并确保我们计算了正确的线,让我们在图像上绘制估计的线。在这里,我们简单地绘制了一个长度为100像素、厚度为3像素的任意黑色线段:
int x0= line[2]; // a point on the line
int y0= line[3];
int x1= x0+100*line[0]; // add a vector of length 100
int y1= y0+100*line[1]; // using the unit vector
// draw the line
cv::line(image,cv::Point(x0,y0),cv::Point(x1,y1),
0,3); // color and thickness
结果可以在以下屏幕截图中看到:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00117.jpeg
将直线拟合到一组点在数学中是一个经典问题。OpenCV 的实现是通过最小化每个点到直线的距离之和来进行的。提出了几种距离函数,其中最快的选择是使用欧几里得距离,它由CV_DIST_L2指定。这个选择对应于标准的最小二乘法直线拟合。当包含异常值(即不属于直线的点)在点集中时,可以选择对远点影响较小的其他距离函数。最小化是基于 M-估计技术,该技术通过迭代求解具有与到直线的距离成反比的权重的加权最小二乘问题。
使用这个函数,还可以将线拟合到 3D 点集。在这种情况下,输入是一个cv::Point3i或cv::Point3f对象的集合,输出是一个std::Vec6f实例。
cv::fitEllipse函数将椭圆拟合到一组 2D 点。这返回一个包含椭圆的旋转矩形(一个cv::RotatedRect实例)。在这种情况下,你会写下以下内容:
cv::RotatedRect rrect= cv::fitEllipse(cv::Mat(points));
cv::ellipse(image,rrect,cv::Scalar(0));
cv::ellipse函数是你用来绘制计算出的椭圆的函数。
图像通常包含对象的表示。图像分析的一个目标就是识别和提取这些对象。在目标检测/识别应用中,第一步通常是生成一个二值图像,显示某些感兴趣的对象可能的位置。无论这个二值图是如何获得的(例如,从我们在第四章中做的直方图反向投影,使用直方图计数像素,或者从我们将要在第十一章中学习的运动分析,处理视频序列),下一步就是从这组 1 和 0 中提取对象。
例如,考虑我们在第五章中操作的二值形式的水牛图像,使用形态学操作变换图像,如图所示:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00118.jpeg
我们通过简单的阈值操作,然后应用开闭形态学过滤器获得了这张图像。这个方法将向你展示如何从这样的图像中提取对象。更具体地说,我们将提取连通组件,即在二值图像中由一组连通像素组成的形状。
OpenCV 提供了一个简单的函数,用于提取图像中连通组件的轮廓。这就是cv::findContours函数:
// the vector that will contain the contours
std::vector<std::vector<cv::Point>> contours;
cv::findContours(image,
contours, // a vector of contours
CV_RETR_EXTERNAL, // retrieve the external contours
CV_CHAIN_APPROX_NONE); // all pixels of each contours
输入显然是二值图像。输出是一个轮廓向量,每个轮廓由一个cv::Point对象的向量表示。这解释了为什么输出参数被定义为std::vector实例的std::vector实例。此外,还指定了两个标志。第一个标志表示只需要外部轮廓,即对象中的孔将被忽略(*还有更多…*部分将讨论其他选项)。第二个标志用于指定轮廓的格式。在当前选项下,向量将列出轮廓中的所有点。使用CV_CHAIN_APPROX_SIMPLE标志,只包括水平、垂直或对角轮廓的端点。其他标志将给出更复杂的轮廓链近似,以获得更紧凑的表示。根据前面的图像,通过contours.size()获得了九个连通组件。
幸运的是,有一个非常方便的函数可以在图像上绘制这些组件的轮廓(这里是一个白色图像):
// draw black contours on a white image
cv::Mat result(image.size(),CV_8U,cv::Scalar(255));
cv::drawContours(result,contours,
-1, // draw all contours
0, // in black
2);// with a thickness of 2
如果这个函数的第三个参数是负值,那么将绘制所有轮廓。否则,可以指定要绘制的轮廓的索引。结果如下面的截图所示:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00119.jpeg
轮廓是通过一个简单的算法提取的,该算法包括系统地扫描图像,直到遇到一个组件。从这个组件的起始点开始,跟随其轮廓,标记其边界的像素。当轮廓完成时,扫描从最后一个位置重新开始,直到找到新的组件。
然后可以单独分析识别出的连通组件。例如,如果对感兴趣对象预期的大小有先验知识,就有可能消除一些组件。接下来,我们可以为组件的周长设置一个最小值和最大值。这是通过迭代轮廓向量并消除无效组件来实现的:
// Eliminate too short or too long contours
int cmin= 50; // minimum contour length
int cmax= 1000; // maximum contour length
std::vector<std::vector<cv::Point>>::
iterator itc= contours.begin();
// for all contours
while (itc!=contours.end())
注意,这个循环可以更高效,因为std::vector实例中的每次擦除操作都是 O(N)。然而,考虑到这个向量的体积很小,总体成本并不高。这次,我们在原始图像上绘制剩余的轮廓,并得到以下结果:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00120.jpeg
我们非常幸运地找到了一个简单的标准,使我们能够识别出这幅图像中所有感兴趣的对象。在更复杂的情况下,需要对组件属性进行更精细的分析。这正是下一个菜谱的目标。
使用cv::findContours函数,还可以将所有闭合轮廓包括在二值图中,包括由组件中的孔形成的轮廓。这是通过在函数调用中指定另一个标志来实现的:
cv::findContours(image,
contours, // a vector of contours
CV_RETR_LIST, // retrieve all contours
CV_CHAIN_APPROX_NONE); // all pixels of each contours
使用这个调用,获得了以下轮廓:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00121.jpeg
注意在背景森林中添加的额外轮廓。这些轮廓也可以组织成层次结构。主要组件是父元素,其中的孔是它的子元素,如果这些孔中有组件,它们就变成先前子元素的子元素,依此类推。这个层次结构是通过使用 CV_RETR_TREE 标志获得的,如下所示:
std::vector<cv::Vec4i> hierarchy;
cv::findContours(image,
contours, // a vector of contours
hierarchy, // hierarchical representation
CV_RETR_TREE, // retrieve all contours in tree format
CV_CHAIN_APPROX_NONE); // all pixels of each contours
在这种情况下,每个轮廓都有一个对应于相同索引的层次元素,由四个整数组成。前两个整数给出了同一级别的下一个和前一个轮廓的索引,后两个整数给出了此轮廓的第一个子元素和父元素的索引。负索引表示轮廓列表的结束。CV_RETR_CCOMP 标志类似,但将层次限制在两个级别。
连通组件通常对应于图片场景中物体的图像。为了识别这个物体,或者将其与其他图像元素进行比较,对组件进行一些测量以提取其某些特征可能是有用的。在这个菜谱中,我们将查看 OpenCV 中可用的某些形状描述符,这些描述符可以用来描述连通组件的形状。
当涉及到形状描述时,许多 OpenCV 函数都是可用的。我们将应用其中的一些函数在我们前面菜谱中提取的组件上。特别是,我们将使用我们之前识别的四个水牛对应的四个轮廓的向量。在下面的代码片段中,我们在轮廓(contours[0] 到 contours[3])上计算形状描述符,并在轮廓图像(厚度为 1)上绘制结果(厚度为 2)。此图像在本节末尾显示。
第一个是边界框,它应用于右下角的组件:
// testing the bounding box
cv::Rect r0= cv::boundingRect(contours[0]);
// draw the rectangle
cv::rectangle(result,r0, 0, 2);
最小包围圆类似。它应用于右上角的组件:
// testing the enclosing circle
float radius;
cv::Point2f center;
cv::minEnclosingCircle(contours[1],center,radius);
// draw the circle
cv::circle(result,center,
static_cast<int>(radius),cv::Scalar(0),2);
组件轮廓的多边形逼近计算如下(在左侧组件):
// testing the approximate polygon
std::vector<cv::Point> poly;
cv::approxPolyDP(contours[2],poly,5,true);
// draw the polygon
cv::polylines(result, poly, true, 0, 2);
注意多边形绘制函数 cv::polylines。它的工作方式与其他绘图函数类似。第三个布尔参数用于指示轮廓是否闭合(如果是,则最后一个点与第一个点相连)。
凸包是另一种多边形逼近形式(在左侧第二个组件):
// testing the convex hull
std::vector<cv::Point> hull;
cv::convexHull(contours[3],hull);
// draw the polygon
cv::polylines(result, hull, true, 0, 2);
最后,计算矩矩是另一个强大的描述符(质心被绘制在所有组件内部):
// testing the moments
// iterate over all contours
itc= contours.begin();
while (itc!=contours.end()) {
// compute all moments
cv::Moments mom= cv::moments(cv::Mat(*itc++));
// draw mass center
cv::circle(result,
// position of mass center converted to integer
cv::Point(mom.m10/mom.m00,mom.m01/mom.m00),
2,cv::Scalar(0),2); // draw black dot
}
结果图像如下:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00122.jpeg
一个组件的边界框可能是表示和定位图像中组件的最紧凑方式。它定义为完全包含形状的最小直立矩形。比较框的高度和宽度可以给出关于物体垂直或水平尺寸的指示(例如,可以通过高度与宽度的比例来区分汽车图像和行人图像)。最小外接圆通常在只需要近似组件大小和位置时使用。
当需要操作一个更紧凑且类似于组件形状的表示时,组件的多边形近似非常有用。它通过指定一个精度参数来创建,该参数给出了形状与其简化多边形之间可接受的最大距离。它是cv::approxPolyDP函数的第四个参数。结果是cv::Point的向量,对应于多边形的顶点。为了绘制这个多边形,我们需要遍历这个向量,并通过在它们之间画线将每个点与下一个点连接起来。
凸包,或称为凸包络,是一个形状的最小凸多边形,它包围了该形状。可以将其想象成如果将一个弹性带围绕该形状放置时,弹性带所形成的形状。正如所见,凸包轮廓将在形状轮廓的凹处偏离原始轮廓。
这些位置通常被指定为凸性缺陷,并且有一个特殊的 OpenCV 函数可用于识别它们:cv::convexityDefects函数。其调用方式如下:
std::vector<cv::Vec4i> defects;
cv::convexityDefects(contour, hull, defects);
contour和hull参数分别代表原始和凸包轮廓(均以std::vector<cv::Point>实例表示)。输出是一个包含四个整数元素的向量。前两个整数是轮廓上点的索引,界定缺陷;第三个整数对应于凹处的最远点,最后,最后一个整数对应于这个最远点与凸包之间的距离。
矩是形状结构分析中常用的数学实体。OpenCV 定义了一个数据结构,用于封装形状的所有计算出的矩。它是cv::moments函数返回的对象。这些矩共同代表了一个物体形状的紧凑描述。它们在字符识别等应用中常用。我们简单地使用这个结构来获取由前三个空间矩计算出的每个组件的质量中心。
可以使用可用的 OpenCV 函数计算其他结构属性。cv::minAreaRect 函数计算最小包含旋转矩形(这在第五章, 使用形态学操作变换图像, 在 使用 MSER 提取特征区域 菜谱中已被使用)。cv::contourArea 函数估计轮廓的面积(即轮廓内的像素数量)。cv::pointPolygonTest 函数确定一个点是否在轮廓内部或外部,而 cv::matchShapes 测量两个轮廓之间的相似度。所有这些属性度量可以有效地组合起来,以执行更高级的结构分析。
四边形检测
在第五章, 使用形态学操作变换图像 中介绍的 MSER 特征是一个有效工具,用于从图像中提取形状。考虑到前一章中获得的 MSER 结果,我们现在将构建一个算法来检测图像中的四边形组件。对于当前图像,这种检测将使我们能够识别建筑物的窗户。可以通过以下方式轻松获得 MSER 图像的二值版本:
// create a binary version
components= components==255;
// open the image (white background)
cv::morphologyEx(components,components,
cv::MORPH_OPEN,cv::Mat(),
cv::Point(-1,-1),3);
此外,我们还使用形态学过滤器清理了图像。然后图像如下:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00123.jpeg
下一步是获取轮廓:
//invert image (background must be black)
cv::Mat componentsInv= 255-components;
// Get the contours of the connected components
cv::findContours(componentsInv,
contours, // a vector of contours
CV_RETR_EXTERNAL, // retrieve the external contours
CV_CHAIN_APPROX_NONE);
最后,我们遍历所有轮廓并大致用多边形来近似它们:
// white image
cv::Mat quadri(components.size(),CV_8U,255);
// for all contours
std::vector<std::vector<cv::Point>>::iterator
it= contours.begin();
while (it!= contours.end())
++it;
}
四边形是指具有四条边的多边形。检测到的如下:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00124.jpeg
要检测矩形,你可以简单地测量相邻边之间的角度,并拒绝那些角度与 90 度偏差太大的四边形。
在本章中,我们将介绍以下食谱:
-
检测图像中的角点
-
快速检测特征
-
检测尺度不变特征
-
在多个尺度上检测 FAST 特征
在计算机视觉中,兴趣点的概念——也称为关键点或特征点——已被广泛用于解决物体识别、图像配准、视觉跟踪、3D 重建等问题。这个概念依赖于这样的想法:与其将图像作为一个整体来看待,不如选择图像中的某些特殊点,并对它们进行局部分析。只要在感兴趣的图像中检测到足够数量的此类点,并且这些点是区分性和稳定的特征,可以精确地定位,这种方法就会很有效。
由于它们用于分析图像内容,特征点理想情况下应该在相同的场景或物体位置被检测到,无论图像是从哪个视角、尺度或方向拍摄的。视域不变性是图像分析中一个非常理想化的属性,并且一直是许多研究的对象。正如我们将看到的,不同的检测器有不同的不变性属性。本章重点介绍关键点提取过程本身。接下来的两章将展示如何在不同的上下文中使用兴趣点,例如图像匹配或图像几何估计。
当在图像中搜索有趣的特征点时,角点被证明是一个有趣的解决方案。它们确实是易于在图像中定位的局部特征,并且此外,它们在人造物体场景中应该很丰富(它们是由墙壁、门、窗户、桌子等产生的)。角点也很有趣,因为它们是二维特征,可以精确地定位(甚至达到亚像素精度),因为它们位于两条边的交汇处。这与位于均匀区域或物体轮廓上的点以及在其他相同物体的图像上难以精确重复定位的点形成对比。Harris 特征检测器是检测图像中角点的经典方法。我们将在本食谱中探讨这个算子。
用于检测 Harris 角的基本 OpenCV 函数被称为cv::cornerHarris,使用起来非常简单。你可以在输入图像上调用它,结果是一个浮点图像,它给出了每个像素位置的角点强度。然后对这个输出图像应用一个阈值,以获得一组检测到的角点。这可以通过以下代码实现:
// Detect Harris Corners
cv::Mat cornerStrength;
cv::cornerHarris(image, // input image
cornerStrength, // image of cornerness
3, // neighborhood size
3, // aperture size
0.01); // Harris parameter
// threshold the corner strengths
cv::Mat harrisCorners;
double threshold= 0.0001;
cv::threshold(cornerStrength,harrisCorners,
threshold,255,cv::THRESH_BINARY);
这是原始图像:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00125.jpeg
结果是一个二值映射图像,如以下截图所示,为了更好的观看效果已进行反转(即,我们使用了cv::THRESH_BINARY_INV而不是cv::THRESH_BINARY来获取检测到的角点为黑色):
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00126.jpeg
从前面的函数调用中,我们可以观察到这个兴趣点检测器需要几个参数(这些将在下一节中解释),这些参数可能会使其难以调整。此外,获得的角点图包含许多角像素簇,这与我们希望检测良好定位点的愿望相矛盾。因此,我们将尝试通过定义我们自己的类来检测 Harris 角来改进角检测方法。
该类封装了 Harris 参数及其默认值和相应的 getter 和 setter 方法(此处未显示):
class HarrisDetector
要在图像上检测 Harris 角,我们进行两个步骤。首先,计算每个像素的 Harris 值:
// Compute Harris corners
void detect(const cv::Mat& image) {
// Harris computation
cv::cornerHarris(image,cornerStrength,
neighbourhood,// neighborhood size
aperture, // aperture size
k); // Harris parameter
// internal threshold computation
cv::minMaxLoc(cornerStrength,
0&maxStrength);
// local maxima detection
cv::Mat dilated; // temporary image
cv::dilate(cornerStrength,dilated,cv::Mat());
cv::compare(cornerStrength,dilated,
localMax,cv::CMP_EQ);
}
接下来,根据指定的阈值值获取特征点。由于 Harris 的可能值范围取决于其参数的特定选择,因此阈值被指定为质量水平,该水平定义为图像中计算出的最大 Harris 值的分数:
// Get the corner map from the computed Harris values
cv::Mat getCornerMap(double qualityLevel) {
cv::Mat cornerMap;
// thresholding the corner strength
threshold= qualityLevel*maxStrength;
cv::threshold(cornerStrength,cornerTh,
threshold,255,cv::THRESH_BINARY);
// convert to 8-bit image
cornerTh.convertTo(cornerMap,CV_8U);
// non-maxima suppression
cv::bitwise_and(cornerMap,localMax,cornerMap);
return cornerMap;
}
此方法返回检测到的特征的二值角点图。将 Harris 特征的检测分为两种方法,这使得我们可以使用不同的阈值(直到获得适当数量的特征点)来测试检测,而无需重复昂贵的计算。也可以以std::vector的cv::Point形式获取 Harris 特征:
// Get the feature points from the computed Harris values
void getCorners(std::vector<cv::Point> &points,
double qualityLevel)
// Get the feature points from the computed corner map
void getCorners(std::vector<cv::Point> &points,
const cv::Mat& cornerMap)
}
}
}
本类通过添加一个非极大值抑制步骤来提高 Harris 角检测,这一步骤将在下一节中解释。现在可以使用cv::circle函数在图像上绘制检测到的点,如下所示的方法演示:
// Draw circles at feature point locations on an image
void drawOnImage(cv::Mat &image,
const std::vector<cv::Point> &points,
cv::Scalar color= cv::Scalar(255,255,255),
int radius=3, int thickness=1) {
std::vector<cv::Point>::const_iterator it=
points.begin();
// for all corners
while (it!=points.end()) {
// draw a circle at each corner location
cv::circle(image,*it,radius,color,thickness);
++it;
}
}
使用此类,Harris 点的检测如下完成:
// Create Harris detector instance
HarrisDetector harris;
// Compute Harris values
harris.detect(image);
// Detect Harris corners
std::vector<cv::Point> pts;
harris.getCorners(pts,0.02);
// Draw Harris corners
harris.drawOnImage(image,pts);
这导致以下图像:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00127.jpeg
为了定义图像中角点的概念,Harris 特征检测器检查围绕潜在兴趣点的小窗口中方向强度的平均变化。如果我们考虑一个位移向量(u,v),平均强度变化由以下公式给出:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00128.jpeg
求和是在考虑的像素周围定义的邻域内进行的(这个邻域的大小对应于cv::cornerHarris函数的第三个参数)。然后可以计算所有可能方向上的平均强度变化,这导致了一个角点的定义:即在一个方向上平均变化高,而在另一个方向上也高的点。从这个定义出发,Harris 测试如下进行。我们首先获得最大平均强度变化的方向。接下来,我们检查正交方向上的平均强度变化是否也高。如果是这样,那么我们就有一个角点。
从数学上讲,这个条件可以通过使用前一个公式的泰勒展开来近似测试:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00129.jpeg
然后将其重写为矩阵形式:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00130.jpeg
这个矩阵是一个协方差矩阵,它表征了所有方向上强度变化的速度。这个定义涉及到图像的第一导数,这些导数通常使用 Sobel 算子来计算。这是 OpenCV 实现的情况,它是函数的第四个参数,对应于用于计算 Sobel 滤波器的孔径。可以证明,协方差矩阵的两个特征值给出了最大平均强度变化和正交方向上的平均强度变化。然后,如果这两个特征值较低,我们处于一个相对均匀的区域。如果一个特征值较高而另一个较低,我们必须处于一个边缘。最后,如果两个特征值都较高,那么我们处于一个角落位置。因此,一个点要被接受为角落的条件是它必须在高于给定阈值的点上具有协方差矩阵的最小特征值。
哈里斯角算法的原始定义使用特征分解理论的一些性质,以避免显式计算特征值的成本。这些性质如下:
-
矩阵的特征值之积等于其行列式
-
矩阵的特征值之和等于矩阵的对角线之和(也称为矩阵的迹)
因此,我们可以通过计算以下分数来验证矩阵的特征值是否较高:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00131.jpeg
可以很容易地验证,只有当两个特征值都较高时,这个分数才会确实较高。这是cv::cornerHarris函数在每个像素位置计算的分数。k的值被指定为函数的第五个参数。确定这个参数的最佳值可能很困难。然而,在实践中,已经看到在0.05和0.5范围内的值通常给出良好的结果。
为了提高检测结果,前一小节中描述的类别增加了一个额外的非极大值抑制步骤。这里的目的是排除相邻的其他哈里斯角。因此,为了被接受,哈里斯角不仅必须有一个高于指定阈值的分数,而且它还必须是一个局部最大值。这个条件是通过使用一个简单的技巧来测试的,该技巧包括在detect方法中膨胀哈里斯分数的图像:
cv::dilate(cornerStrength,dilated,cv::Mat());
由于膨胀将每个像素值替换为定义的邻域中的最大值,唯一不会修改的点就是局部最大值。这正是以下等式测试所验证的:
cv::compare(cornerStrength,dilated,
localMax,cv::CMP_EQ);
因此,localMax矩阵仅在局部极大值位置为真(即非零)。然后我们使用它在我们自己的getCornerMap方法中来抑制所有非极大特征(使用cv::bitwise_and函数)。
可以对原始的 Harris 角算法进行更多改进。本节描述了 OpenCV 中找到的另一个角检测器,它将 Harris 检测器扩展到使角在图像中分布得更均匀。正如我们将看到的,此操作符在 OpenCV 2 通用接口中为特征检测器提供了一个实现。
跟踪的良好特征
随着浮点处理器的出现,为了避免特征值分解而引入的数学简化变得可以忽略不计,因此,基于显式计算的特征值,可以基于 Harris 角进行检测。原则上,这种修改不应显著影响检测结果,但它避免了使用任意的k参数。请注意,存在两个函数允许您显式获取 Harris 协方差矩阵的特征值(和特征向量);这些是cv::cornerEigenValsAndVecs和cv::cornerMinEigenVal。
第二种修改解决了特征点聚类的问题。事实上,尽管引入了局部极大值条件,但兴趣点在图像中往往分布不均,在高度纹理的位置出现集中。解决这个问题的一个方法是强制两个兴趣点之间保持最小距离。这可以通过以下算法实现。从具有最强 Harris 分数的点(即具有最大最小特征值)开始,只有当兴趣点位于已接受点至少给定距离之外时,才接受兴趣点。这个解决方案在 OpenCV 的cv::goodFeaturesToTrack函数中实现,因此得名,因为检测到的特征可以用作视觉跟踪应用中的良好起始集。其调用方式如下:
// Compute good features to track
std::vector<cv::Point2f> corners;
cv::goodFeaturesToTrack(image, // input image
corners, // corner image
500, // maximum number of corners to be returned
0.01, // quality level
10); // minimum allowed distance between points
除了质量级别阈值值和兴趣点之间可容忍的最小距离之外,该函数还使用可以返回的最大点数(这是可能的,因为点按强度顺序接受)。前面的函数调用产生以下结果:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00132.jpeg
这种方法增加了检测的复杂性,因为它需要根据 Harris 分数对兴趣点进行排序,但它也明显改善了点在图像中的分布。请注意,此函数还包括一个可选标志,请求使用经典角评分定义(使用协方差矩阵的行列式和迹)来检测 Harris 角。
特征检测器的通用接口
OpenCV 2 为它的不同兴趣点检测器引入了一个通用接口。这个接口允许在同一个应用程序中轻松测试不同的兴趣点检测器。
接口定义了一个cv::Keypoint类,它封装了每个检测到的特征点的属性。对于哈里斯角,仅关键点的位置及其响应强度是相关的。检测尺度不变特征菜谱将讨论可以与关键点关联的其他属性。
cv::FeatureDetector抽象类基本上强制存在一个具有以下签名的detect操作:
void detect( const Mat& image, vector<KeyPoint>& keypoints,
const Mat& mask=Mat() ) const;
void detect( const vector<Mat>& images,
vector<vector<KeyPoint> >& keypoints,
const vector<Mat>& masks=
vector<Mat>() ) const;
第二种方法允许在图像向量中检测兴趣点。该类还包括其他可以读取和写入检测到的点的文件的方法。
cv::goodFeaturesToTrack函数有一个名为cv::GoodFeaturesToTrackDetector的包装类,它继承自cv::FeatureDetector类。它可以像我们处理 Harris 角类那样使用,如下所示:
// vector of keypoints
std::vector<cv::KeyPoint> keypoints;
// Construction of the Good Feature to Track detector
cv::Ptr<cv::FeatureDetector> gftt=
new cv::GoodFeaturesToTrackDetector(
500, // maximum number of corners to be returned
0.01, // quality level
10); // minimum allowed distance between points
// point detection using FeatureDetector method
gftt->detect(image,keypoints);
结果与之前获得的结果相同,因为包装器最终调用的函数是相同的。注意我们使用了 OpenCV 2 的智能指针类(cv::Ptr),正如在第一章中解释的那样,玩转图像,当引用计数降至零时,会自动释放指向的对象。
-
描述哈里斯算子的经典文章由 C. Harris 和 M.J. Stephens 撰写*,A combined corner and edge detector, Alvey Vision Conference, pp. 147–152, 1988*
-
J. Shi 和 C. Tomasi 的论文Good features to track, Int. Conference on Computer Vision and Pattern Recognition, pp. 593-600, 1994介绍了这些特征
-
K. Mikolajczyk 和 C. Schmid 的论文Scale and Affine invariant interest point detectors, International Journal of Computer Vision, vol 60, no 1, pp. 63-86, 2004提出了一种多尺度且仿射不变性的哈里斯算子
哈里斯算子提出了基于两个垂直方向上强度变化率的角(或更一般地说,兴趣点)的正式数学定义。尽管这是一个合理的定义,但它需要计算图像导数,这是一个代价高昂的操作,尤其是考虑到兴趣点检测通常只是更复杂算法的第一步。
在这个菜谱中,我们介绍另一个特征点算子,称为FAST(加速段测试特征)。这个算子是专门设计用来快速检测图像中的兴趣点的;是否接受或拒绝一个关键点的决定仅基于少数像素的比较。
使用 OpenCV 2 通用接口进行特征点检测使得部署任何特征点检测器变得容易。本食谱中介绍的是 FAST 检测器。正如其名所示,它被设计得很快,以便计算以下内容:
// vector of keypoints
std::vector<cv::KeyPoint> keypoints;
// Construction of the Fast feature detector object
cv::Ptr<cv::FeatureDetector> fast=
new cv::FastFeatureDetector(
40); // threshold for detection
// feature point detection
fast->detect(image,keypoints);
注意,OpenCV 还提供了一个通用的函数来在图像上绘制关键点:
cv::drawKeypoints(image, // original image
keypoints, // vector of keypoints
image, // the output image
cv::Scalar(255,255,255), // keypoint color
cv::DrawMatchesFlags::DRAW_OVER_OUTIMG); //drawing flag
通过指定选择的绘制标志,关键点被绘制在输入图像上,从而产生以下输出结果:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00133.jpeg
一个有趣的选项是为关键点颜色指定一个负值。在这种情况下,将为每个绘制的圆圈选择不同的随机颜色。
与哈里斯点检测器的情况一样,FAST 特征算法源自对“角”的定义。这次,这个定义是基于假设特征点周围的图像强度。是否接受关键点的决定是通过检查以候选点为中心的像素圆来做出的。如果在圆周上找到一个连续点弧,其长度大于圆周长度的 3/4,并且其中所有像素与中心点的强度显著不同(都是较暗或较亮),则宣布存在一个关键点。
这是一个可以快速计算的简单测试。此外,在其原始公式中,该算法使用了一个额外的技巧来进一步加快处理速度。确实,如果我们首先测试圆周上相隔 90 度的四个点(例如,顶部、底部、右侧和左侧点),可以很容易地证明,为了满足之前表达的条件,至少有三个这些点必须与中心像素一样亮或一样暗。
如果不是这种情况,则可以立即拒绝该点,而无需检查圆周上的其他点。这是一个非常有效的测试,因为在实践中,大多数图像点都会被这个简单的 4 比较测试所拒绝。
在原则上,检查像素圆的半径可以是该方法的一个参数。然而,在实践中发现,半径为3既可以得到良好的结果,又具有高效率。因此,圆周上需要考虑的像素有16个,如下所示:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00134.jpeg
预测试中使用的四个点是1、5、9和13像素,并且需要连续的较暗或较亮的点的数量是12。然而,观察发现,通过将连续段长度减少到9,可以获得更好的检测角在图像间的重复性。这个变体通常被称为FAST-9角检测器,这正是 OpenCV 所使用的。请注意,存在一个cv::FASTX函数,它提出了 FAST 检测器的另一种变体。
要被认为是明显更暗或更亮,一个点的强度必须至少比中心像素的强度高出一个给定的量;这个值对应于函数调用中指定的阈值参数。这个阈值越大,检测到的角点就越少。
至于 Harris 特征,通常对已找到的角点执行非极大值抑制会更好。因此,需要定义一个角点强度度量。可以考虑几种替代度量,而保留的是以下度量。角点的强度由中心像素与识别出的连续弧上的像素之间的绝对差之和给出。请注意,该算法也可以通过直接函数调用获得:
cv::FAST(image, // input image
keypoints, // output vector of keypoints
40, // threshold
false); // non-max suppression? (or not)
然而,由于其灵活性,建议使用cv::FeatureDetector接口。
此算法导致非常快的兴趣点检测,因此在速度是关注点时,这是首选的特征。例如,在实时视觉跟踪或对象识别应用中,必须跟踪或匹配实时视频流中的多个点时,情况就是这样。
为了提高特征点的检测,OpenCV 提供了额外的工具。确实,有多个类适配器可用,以便更好地控制关键点的提取方式。
适应性特征检测
如果你希望更好地控制检测到的点的数量,有一个特殊的cv::FeatureDetector类的子类,称为cv::DynamicAdaptedFeatureDetector,可供使用。这允许你指定可以检测到的兴趣点数量的区间。在 FAST 特征检测器的情况下,使用方法如下:
cv::DynamicAdaptedFeatureDetector fastD(
new cv::FastAdjuster(40), // the feature detector
150, // min number of features
200, // max number of features
50); // max number of iterations
fastD.detect(image,keypoints); // detect points
然后将迭代检测兴趣点。每次迭代后,都会检查检测到的点的数量,并根据需要调整检测器的阈值以产生更多或更少的点;这个过程会重复,直到检测到的点的数量符合指定的区间。指定一个最大迭代次数,以避免该方法在多次检测上花费太多时间。为了以通用方式实现此方法,所使用的cv::FeatureDetector类必须实现cv::AdjusterAdapter接口。这个类包括一个tooFew方法和一个tooMany方法,这两个方法都修改检测器的内部阈值以产生更多或更少的关键点。还有一个good谓词方法,如果检测器的阈值还可以调整,则返回true。使用cv::DynamicAdaptedFeatureDetector类可以是一个获得适当数量特征点的良好策略;然而,你必须理解,为了这个好处,你必须付出性能代价。此外,没有保证你确实能在指定的迭代次数内获得所需数量的特征。
你可能已经注意到,我们传递了一个参数,即动态分配对象的地址,以指定适配器类将使用的特征检测器。你可能想知道是否需要在某个时候释放分配的内存以避免内存泄漏。答案是无需释放,这是因为指针被转移到cv::Ptr<FeatureDetector>参数,该参数会自动释放指向的对象。
网格适应特征检测
第二个有用的类适配器是cv::GridAdaptedFeatureDetector类。正如其名所示,它允许你在图像上定义一个网格。然后,这个网格的每个单元格都被限制只能包含最大数量的元素。这里的想法是将检测到的关键点集在图像上以更好的方式分布。在检测图像中的关键点时,确实常见到在特定纹理区域有大量兴趣点的集中。例如,在教堂图像的两个塔上,检测到了一个非常密集的 FAST 点集。这个类适配器可以这样使用:
cv::GridAdaptedFeatureDetector fastG(
new cv::FastFeatureDetector(10), // the feature detector
1200, // max total number of keypoints
5, // number of rows in grid
2); // number of cols in grid
fastG.detect(image,keypoints);
类适配器简单地通过使用提供的cv::FeatureDetector对象在每个单独的单元格上检测特征点来继续进行。还指定了最大点数。为了不超过指定的最大值,只保留每个单元格中最强的点。
金字塔适应特征检测
cv::PyramidAdaptedFeatureDetector适配器通过在图像金字塔上应用特征检测器来继续进行。结果被组合在关键点的输出向量中。这可以通过以下方式实现:
cv::PyramidAdaptedFeatureDetector fastP(
new cv::FastFeatureDetector(60), // the feature detector
3); // number of levels in the pyramid
fastP.detect(image,keypoints);
每个点的坐标都指定在原始图像坐标中。此外,cv::Keypoint类的特殊size属性被设置为,在原始分辨率的一半处检测到的点被赋予的尺寸是原始图像中检测到的点尺寸的两倍。cv::drawKeypoints函数中有一个特殊标志,它将以与关键点size属性相等的半径绘制关键点。
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00135.jpeg
- E. Rosten 和 T. Drummond的论文《高速角点检测中的机器学习》,载于欧洲计算机视觉会议,第 430-443 页,2006,详细描述了 FAST 特征算法及其变体。
特征检测的视角不变性在本章的引言中被提出作为一个重要概念。虽然方向不变性,即即使图像旋转也能检测到相同点的能力,已经被迄今为止提出的简单特征点检测器相对较好地处理,但尺度变化的不变性更难实现。为了解决这个问题,计算机视觉中引入了尺度不变特征的概念。这里的想法是,无论物体在何种尺度下被拍摄,都要保持关键点的检测一致性,并且每个检测到的特征点都要关联一个尺度因子。理想情况下,对于在两张不同图像上以不同尺度特征化的同一物体点,两个计算尺度因子的比率应该对应它们各自尺度的比率。近年来,已经提出了几种尺度不变特征,本菜谱介绍了其中之一,即SURF特征。SURF 代表加速鲁棒特征,正如我们将看到的,它们不仅是尺度不变特征,而且计算效率也非常高。
SURF 特征检测器在 OpenCV 中通过cv::SURF函数实现。也可以通过cv::FeatureDetector使用它,如下所示:
// Construct the SURF feature detector object
cv::Ptr<cv::FeatureDetector> detector = new cv::SURF(2000.); // threshold
// Detect the SURF features
detector->detect(image,keypoints);
要绘制这些特征,我们再次使用带有DRAW_RICH_KEYPOINTS标志的cv::drawKeypoints OpenCV 函数,这样我们就可以可视化相关的尺度因子:
// Draw the keypoints with scale and orientation information
cv::drawKeypoints(image, // original image
keypoints, // vector of keypoints
featureImage, // the resulting image
cv::Scalar(255,255,255), // color of the points
cv::DrawMatchesFlags::DRAW_RICH_KEYPOINTS); //flag
检测到特征的结果图像如下:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00136.jpeg
如前一个菜谱中所述,使用DRAW_RICH_KEYPOINTS标志得到的关键点圆圈的大小与每个特征的计算尺度成正比。SURF 算法还与每个特征关联一个方向,以使它们对旋转不变。这个方向通过每个绘制圆圈内的辐射线表示。
如果我们以不同的尺度拍摄同一物体的另一张照片,特征检测的结果如下:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00137.jpeg
通过仔细观察两张图像上检测到的关键点,可以看出对应圆圈大小的变化通常与尺度变化成正比。例如,考虑教堂右上角窗户的底部部分。在两张图像中,都检测到了该位置的 SURF 特征,并且两个对应的不同大小的圆圈包含相同的视觉元素。当然,并非所有特征都如此,但正如我们在下一章将要发现的,重复率足够高,可以保证两张图像之间良好的匹配。
在第六章,“图像滤波”中,我们了解到可以使用高斯滤波器估计图像的导数。这些滤波器使用一个σ参数,该参数定义了核的孔径(大小)。正如我们所见,这个σ参数对应于构建滤波器使用的高斯函数的方差,并且它隐式地定义了导数评估的尺度。确实,具有较大σ值的滤波器会平滑掉图像的更细的细节。这就是为什么我们可以说它在一个更粗的尺度上操作。
现在,如果我们使用高斯滤波器在不同尺度上计算给定图像点的拉普拉斯算子,那么会得到不同的值。观察不同尺度因子下滤波器响应的变化,我们得到一条曲线,最终在σ值处达到最大值。如果我们从两个不同尺度拍摄的同物图像中提取这个最大值,这两个σ最大值之比将对应于拍摄图像的尺度比。这个重要的观察结果是尺度不变特征提取过程的核心。也就是说,尺度不变特征应该在空间空间(在图像中)和尺度空间(从不同尺度应用导数滤波器获得)中的局部最大值中被检测到。
SURF 通过以下步骤实现这一想法。首先,为了检测特征,计算每个像素处的 Hessian 矩阵。这个矩阵衡量函数的局部曲率,定义为以下:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00138.jpeg
这个矩阵的行列式给出了这个曲率的强度。因此,定义角点为具有高局部曲率(即,在多个方向上变化很大)的图像点。由于它由二阶导数组成,这个矩阵可以使用不同尺度的高斯拉普拉斯核来计算,例如σ。因此,Hessian 成为一个关于三个变量的函数,即H(x,y,σ)。因此,当这个 Hessian 的行列式在空间和尺度空间中都达到局部最大值时(即,需要进行3x3x3非最大值抑制),就宣布存在一个尺度不变特征。请注意,为了被视为一个有效点,这个行列式必须具有由cv::SURF类的构造函数的第一个参数指定的最小值。
然而,在不同尺度上计算所有这些导数在计算上代价高昂。SURF 算法的目标是尽可能使这个过程高效。这是通过使用仅涉及少量整数加法的近似高斯核来实现的。这些核具有以下结构:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00139.jpeg
左侧的核心用于估计混合的二阶导数,而右侧的核心用于估计垂直方向上的二阶导数。这个二阶核心的旋转版本用于估计水平方向上的二阶导数。最小的核心大小为9x9像素,对应于σ≈1.2。为了获得尺度空间表示,依次应用不同大小的核心。可以由 SURF 类的附加参数指定应用的确切滤波器数量。默认情况下,使用 12 种不同大小的核心(最大到99x99)。请注意,使用积分图像保证了每个滤波器每个波峰内部的和可以通过仅使用三个与滤波器大小无关的加法来计算。
一旦确定了局部极大值,通过在尺度和图像空间中进行插值,就可以获得每个检测到的兴趣点的精确位置。结果是具有亚像素精度的特征点集,并且与一个尺度值相关联。
SURF 算法被开发为一个效率更高的变体,称为另一个著名的尺度不变特征检测器SIFT(尺度不变特征变换)。
SIFT 特征检测算法
SIFT 同样在图像和尺度空间中检测特征作为局部极大值,但使用拉普拉斯滤波器响应而不是海森矩阵。这个拉普拉斯在不同的尺度(即σ的增大值)上使用高斯滤波器的差值来计算,如第六章中所述,过滤图像。为了提高效率,每次σ的值加倍时,图像的大小就减少两倍。每个金字塔层对应一个八度,每个尺度是一个层。通常每个八度有三个层。
下图展示了两个八度的金字塔,其中第一个八度的四个高斯滤波图像产生了三个 DoG 层:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00140.jpeg
OpenCV 有一个类可以检测这些特征,其调用方式与 SURF 类似:
// Construct the SIFT feature detector object
detector = new cv::SIFT();
// Detect the SIFT features
detector->detect(image,keypoints);
在这里,我们使用所有默认参数来构建检测器,但你也可以指定所需的 SIFT 点数(保留最强的点),每个八度的层数,以及σ的初始值。结果是类似于使用 SURF 获得的结果:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00141.jpeg
然而,由于特征点的计算基于浮点核心,SIFT 在空间和尺度上的特征定位方面通常被认为更准确。同样地,它也更耗费计算资源,尽管这种相对效率取决于每个特定的实现。
最后一点,你可能已经注意到,SURF 和 SIFT 类被放置在 OpenCV 分布的非自由软件包中。这是因为这些算法已经获得专利,因此,它们在商业应用中的使用可能受到许可协议的约束。
-
第六章中的计算图像拉普拉斯算子配方,过滤图像,为你提供了更多关于高斯拉普拉斯算子和高斯差分使用的细节
-
第九章中的描述局部强度模式配方,描述和匹配兴趣点,解释了如何描述这些尺度不变特征以实现鲁棒的图像匹配
-
H. Bay, A. Ess, T. Tuytelaars 和 L. Van Gool 在计算机视觉与图像理解,第 110 卷,第 3 期,第 346-359 页,2008 年上发表的SURF:加速鲁棒特征文章描述了 SURF 特征算法
-
D. Lowe 在国际计算机视觉杂志,第 60 卷,第 2 期,2004 年,第 91-110 页上发表的从尺度不变特征中提取独特图像特征的开创性工作描述了 SIFT 算法
FAST 被引入作为一种快速检测图像中关键点的方法。与 SURF 和 SIFT 相比,重点是设计尺度不变特征。最近,新引入了兴趣点检测器,旨在实现快速检测和尺度变化的不变性。本配方介绍了二值鲁棒不变可缩放关键点(BRISK)检测器。它基于我们在本章前面的配方中描述的 FAST 特征检测器。另一种称为ORB(方向性 FAST 和旋转 BRIEF)的检测器也将在本配方的末尾讨论。这两个特征点检测器构成了在需要快速且可靠的图像匹配时的优秀解决方案。当它们与相关的二进制描述符一起使用时,效率尤其高,这将在第九章,描述和匹配兴趣点中讨论。
按照我们在前面的配方中所做的,使用 BRISK 检测关键点的过程使用了cv::FeatureDetector抽象类。我们首先创建检测器的实例,然后在图像上调用detect方法:
// Construct the BRISK feature detector object
detector = new cv::BRISK();
// Detect the BRISK features
detector->detect(image,keypoints);
图像结果显示了在多个尺度上检测到的关键点:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00142.jpeg
BRISK 不仅是一个特征点检测器;该方法还包括描述每个检测到的关键点邻域的程序。这一第二个方面将是下一章的主题。在这里,我们描述了如何使用 BRISK 在多个尺度上快速检测关键点。
为了在不同尺度上检测兴趣点,该方法首先通过两个下采样过程构建一个图像金字塔。第一个过程从原始图像大小开始,并在每一层(或八分音符)上将它减半。其次,通过将原始图像以 1.5 的因子下采样,在中间层之间创建层,然后通过连续的半采样从这个减少的图像生成额外的层。
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00143.jpeg
然后将 FAST 特征检测器应用于这个金字塔的所有图像。关键点提取基于与 SIFT 相似的标准。首先,一个可接受的兴趣点在将其强度与其八个空间邻居之一比较时必须是局部最大值。如果是这样,该点随后将与上层和下层相邻点的分数进行比较;如果其分数在尺度上更高,则它被接受为兴趣点。BRISK 的一个关键方面在于金字塔的不同层具有不同的分辨率。该方法需要在尺度和空间上进行插值,以精确地定位每个关键点。这种插值基于 FAST 关键点分数。在空间上,插值在 3 x 3 邻域内进行。在尺度上,它通过沿尺度轴通过当前点及其上层和下层两个相邻局部关键点拟合一个一维抛物线来计算;这种尺度上的关键点定位在先前的图中进行了说明。因此,即使 FAST 关键点检测是在离散图像尺度上进行的,与每个关键点相关联的检测尺度也是连续值。
cv::BRISK 类提出了两个可选参数来控制关键点的检测。第一个参数是一个阈值值,它接受 FAST 关键点,第二个参数是将在图像金字塔中生成的八分音符的数量:
// Construct another BRISK feature detector object
detector = new cv::BRISK(
20, // threshold for FAST points to be accepted
5); // number of octaves
BRISK 并不是 OpenCV 中唯一被提出的多尺度快速检测器。ORB 特征检测器也能进行高效的关键点检测。
ORB 特征检测算法
ORB 代表 Oriented FAST and Rotated BRIEF。这个缩写的第一部分指的是关键点检测部分,而第二部分指的是 ORB 提出的描述符。在这里,我们主要关注检测方法;描述符将在下一章中介绍。
与 BRISK 类似,ORB 首先创建一个图像金字塔。这个金字塔由多个层组成,每一层是前一层通过一定比例因子(通常是8个比例和1.2的比例因子减小;这些是cv::ORB函数中的参数)下采样得到的。然后接受得分最高的N个关键点,其中关键点得分由本章第一道菜中定义的 Harris 角点度量来定义(该方法作者发现 Harris 得分是一个更可靠的度量)。
ORB 检测器的原始特点在于每个检测到的兴趣点都与一个方向相关联。正如我们将在下一章中看到的,这些信息将有助于对齐在不同图像中检测到的关键点的描述符。在第七章的《计算组件形状描述符》菜谱中,我们介绍了图像矩的概念,特别是我们展示了如何从组件的前三个矩计算其质心。ORB 建议我们使用围绕关键点的圆形邻域的质心的方向。由于,根据定义,FAST 关键点总是有一个偏心的质心,连接中心点和质心的线的角度总是定义良好的。
ORB 特征检测如下:
// Construct the ORB feature detector object
detector = new cv::ORB(200, // total number of keypoints
1.2, // scale factor between layers
8); // number of layers in pyramid
// Detect the ORB features
detector->detect(image,keypoints);
此调用产生以下结果:
https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv-cv-app-prog-cb/img/00144.jpeg
如所见,由于关键点在每个金字塔层上独立检测,检测器倾向于在不同尺度上重复检测相同的特征点。
-
第九章的《使用二进制特征描述关键点》菜谱解释了如何使用简单的二进制描述符进行这些特征的效率鲁棒匹配
-
文章《BRISK:基于二值鲁棒不变可伸缩关键点的算法》由 S. Leutenegger、M. Chli 和 R. Y. Siegwart 在 2011 年的《IEEE 国际计算机视觉会议,第 2448-2555 页》中描述了 BRISK 特征算法
-
文章《ORB:SIFT 或 SURF 的有效替代方案》由 E. Rublee、V. Rabaud、K. Konolige 和 G. Bradski 在 2011 年的《IEEE 国际计算机视觉会议,第 2564-2571 页》中描述了 ORB 特征算法