OpenCV2 计算机视觉应用编程秘籍:6~10

发布时间 2023-04-19 11:16:59作者: ApacheCN

原文:OpenCV2 Computer Vision Application Programming Cookbook

协议:CC BY-NC-SA 4.0

译者:飞龙

本文来自【ApacheCN 计算机视觉 译文集】,采用译后编辑(MTPE)流程来尽可能提升效率。

当别人说你没有底线的时候,你最好真的没有;当别人说你做过某些事的时候,你也最好真的做过。

六、过滤图像

在本章中,我们将介绍:

  • 使用低通过滤器过滤图像
  • 使用中值过滤器过滤图像
  • 应用方向过滤器检测边缘
  • 计算图像的拉普拉斯算子

简介

滤波是信号和图像处理的基本任务之一。 它是一个过程,旨在有选择地提取图像的某些方面,这些方面被认为在给定应用的上下文中传达了重要信息。 过滤可以消除图像中的噪点,提取有趣的视觉特征,允许图像重采样等。 它起源于一般的信号和系统理论。 在此我们将不详细介绍该理论。 但是,本章将介绍一些与过滤有关的重要概念,并说明如何在图像处理应用中使用过滤器。 但首先,让我们先简要介绍一下频域分析的概念。

当我们查看图像时,我们观察到不同的灰度(或颜色)如何分布在图像上。 图像彼此不同,因为它们具有不同的灰度分布。 但是存在另一种可以分析图像的观点。 我们可以查看图像中存在的灰度变化。 一些图像包含几乎恒定强度的大区域(例如,蓝天),而在其他图像中,灰度强度在整个图像上变化很快(例如,繁忙的场景中挤满了许多小物体)。 因此,观察图像中这些变化的频率构成了表征图像的另一种方式。 这种观点被称为频域,而通过观察其灰度分布来表征图像的特性被称为空间域

频域分析将图像分解为从最低频率到最高频率的频率内容。 低频对应于图像强度缓慢变化的区域,而高频则是由强度的快速变化产生的。 存在几种众所周知的变换,例如傅立叶变换或余弦变换,可用于显式显示图像的频率内容。 注意,由于图像是二维实体,因此它由垂直频率(即垂直方向上的变化)和水平频率(水平方向上的变化)组成。

在频域分析框架下,过滤器是一种操作,可放大图像的某些频段,同时阻止(或减少)其他图像频段。 因此,低通过滤器是消除了图像的高频成分的过滤器,反之,高通过滤器是消除了低通成分的过滤器。 本章将介绍一些在图像处理中经常使用的过滤器,并说明它们在应用于图像时的效果。

使用低通过滤器过滤图像

在第一个秘籍中,我们将介绍一些非常基本的低通过滤器。 在本章的介绍部分中,我们了解到此类过滤器的目的是减小图像变化的幅度。 实现此目标的一种简单方法是用周围像素的平均值替换每个像素。 这样,快速的强度变化将被消除,从而被更渐进的过渡所替代。

操作步骤

cv::blur函数的目的是通过将每个像素替换为在矩形邻域上计算的平均像素值来使图像平滑。 该低通过滤器的应用如下:

   cv::blur(image,result,cv::Size(5,5));

这种过滤器也称为盒式过滤器。 在这里,我们通过使用5x5过滤器应用了该过滤器,以使过滤器的效果更加明显。 将其应用于下图时:

How to do it...

结果是:

How to do it...

在某些情况下,可能希望对像素附近的较近像素给予更多重视。 因此,可以计算加权平均值,在该加权平均值中,附近像素的权重大于远处像素的权重。 这可以通过使用遵循高斯函数(“钟形”函数)的加权方案来实现。 cv::GaussianBlur函数应用了这样的过滤器,其调用方式如下:

   cv::GaussianBlur(image,result,cv::Size(5,5),1.5);

结果如下图所示:

How to do it...

工作原理

如果过滤器的应用对应于用相邻像素的加权和替换像素,则称该过滤器为线性的。 在盒式过滤器中就是这种情况,其中一个像素被一个矩形邻域中的所有像素之和替换,然后除以该邻域的大小(以获得平均值)。 这就像在像素总数上将每个相邻像素乘以 1,然后将所有这些值相加。 可以使用矩阵表示过滤器的不同权重,该矩阵示出与所考虑的邻域中的每个像素位置相关联的乘法因子。 矩阵的中心元素,与当前应用了过滤器的像素相对应。 这样的矩阵有时称为掩码。 对于3x3盒式过滤器,相应的核为:

1/9 1/9 1/9
1/9 1/9 1/9
1/9 1/9 1/9

然后,应用线性过滤器对应于在图像的每个像素上移动核,并将每个相应像素乘以其关联的权重。 在数学上,此操作称为卷积

查看此秘籍中产生的输出图像,可以观察到低通过滤器的最终效果是使图像模糊或平滑。 这并不奇怪,因为此过滤器会衰减与在对象边缘可见的快速变化相对应的高频分量。

在高斯过滤器的情况下,与像素关联的权重与其与中心像素的距离成正比。 回想一下,一维高斯函数具有以下形式:

How it works...

选择归一化系数A,以使不同的权重之和为 1。 σ值控制所得高斯函数的宽度。 该值越大,函数越平坦。 例如,如果我们计算间隔为[-4,...,0,...4]σ=0.5的一维高斯过滤器的系数,则可以获得:

[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);

要在图像上应用 2D 高斯过滤器,只需先在图像行上应用 1D 高斯过滤器(它将过滤水平频率),然后在图像列上应用相同的 1D 高斯过滤器(以过滤垂直频率)。 这是可能的,因为高斯过滤器是可分离过滤器(即 2D 核可以分解为两个 1D 过滤器)。 函数cv::sepFilter2D可用于应用通用的可分离过滤器。 也可以使用cv::filter2D函数直接应用 2D 核。

使用 OpenCV,通过向cv::GaussianBlur提供系数的数量(第三参数,奇数)和σ的值(第四参数)来指定要应用于图像的高斯过滤器。 您也可以简单地设置σ的值,然后让 OpenCV 确定适当的系数数(然后为过滤器大小输入值 0)。 当您为σ输入大小和值 0 时,也可能相反。 将确定最适合给定大小的σ值。 但是,建议您输入两个值以更好地控制过滤器效果。

更多

调整图像大小时,也会使用低通过滤器。 假设您希望将图像的大小减少 2 倍。您可能会认为,只需消除图像的偶数行和列即可完成此操作。 不幸的是,生成的图像看起来不会很好。 例如,原始图像中的倾斜边缘将在缩小图像上显示为阶梯。 其他锯齿形失真也将在图像的曲线和纹理部分上可见。

这些不良伪像是由一种称为空间混叠的现象引起的,当您试图在图像中包含太小而无法包含高频分量时,就会出现这种现象。 实际上,较小的图像(即像素较少的图像)不能像高分辨率的图像(想像高清电视与传统电视之间的差异)一样好表现出精细的纹理和清晰的边缘。 由于图像中的精细细节对应于高频,因此我们需要在减小图像尺寸之前去除图像中那些较高频率的成分。 我们从此秘籍中学到,可以通过低通过滤器来完成此操作。 因此,要在不增加烦人的伪影的情况下将图像尺寸减小一半,必须首先对原始图像应用低通过滤器,然后将一列和两列扔掉。 这正是cv::pyrDown函数的作用:

cv::Mat reducedImage;  // to contain reduced image
cv::pyrDown(image,reducedImage); // reduce image size by half

该相机使用5x5高斯过滤器对图像进行低通。 还存在使图像尺寸加倍的倒数cv::pyrUp函数。 当然,如果先缩小图像再放大,您将无法恢复确切的原始图像。 在缩编过程中丢失的内容无法恢复。 这两个函数用于创建图像金字塔。 这是一种由不同大小的图像的堆叠版本构成的数据结构(通常每个级别是前一级别的大小的一半),通常是为了进行有效的图像分析而构建的。 例如,如果希望检测图像中的物体,则可以首先在金字塔顶部的小图像上完成检测,并且在找到感兴趣的物体时,可以通过移到更低的金字塔等级来细化搜索,它包含图像高分辨率版本。

请注意,还有一个更通用的cv:resize函数,可让您指定所需的结果图像尺寸。 您只需指定一个可以小于或大于原始图像的新尺寸来调用它:

cv::Mat resizedImage;  // to contain resized image
cv::resize(image,resizedImage,
    cv::Size(image.cols/3,image.rows/3)); // 1/3 resizing

其他选项可用于根据比例因子指定调整大小,或选择要在重新采样过程中使用的特定插值方法。

另见

函数cv::boxFilter过滤具有仅由 1s 构成的正方形核的图像。 它类似于均值过滤器,但不将结果除以系数数量。

在第 2 章“使用访问邻居扫描图像”的“更多”部分中介绍了cv::filter2D函数。 此函数可让您通过输入所选的核将线性过滤器应用于图像。

使用中值过滤器过滤图像

本章的第一个秘籍介绍了线性过滤器的概念。 还存在可以有利地用于图像处理中的非线性过滤器。 这样的过滤器之一就是我们在本秘籍中介绍的中值过滤器。

由于中值过滤器对于抵御椒盐噪声特别有用,因此我们将使用在第 2 章的第一个秘籍中创建的图像,该图像在此处复制:

Filtering images using a median filter

操作步骤

对中值过滤函数的调用与其他过滤器类似:

   cv::medianBlur(image,result,5);

生成的图像如下:

How to do it...

工作原理

由于中值过滤器不是线性过滤器,因此不能用核矩阵表示。 但是,它也可以在像素附近进行操作,以确定输出像素值。 像素及其邻域形成一组值,顾名思义,中值过滤器将仅计算该组的中值,然后将当前像素替换为该中值。

这就解释了为什么过滤器在消除盐和胡椒噪声方面如此有效。 实际上,当给定像素邻域中存在离群的黑色或白色像素时,永远不会选择该像素作为中位值(相当大或最小值),因此总是将其替换为邻近值。 相比之下,简单的均值过滤器将受到此类噪声的极大影响,因为在下图可以观察到的噪声代表了我们的椒盐图像的均值滤波版本:

How it works...

显然,噪点像素会移动相邻像素的平均值。 结果,即使噪声已被均值过滤器模糊,仍然可见。

中值过滤器还具有保留边缘清晰度的优点。 但是,它会洗刷均匀区域中的纹理(例如,背景中的树木)。

应用方向过滤器检测边缘

本章的第一篇文章介绍了使用核矩阵进行线性过滤的思想。 所使用的过滤器具有消除或衰减高频成分而使图像模糊的效果。 在本秘籍中,我们将执行相反的变换,即放大图像的高频内容。 结果,此处介绍的高通过滤器将执行边缘检测

操作步骤

我们将在这里使用的过滤器称为 Sobel 过滤器。 之所以称为定向过滤器,是因为它仅影响垂直或水平图像频率,具体取决于所使用的过滤器核。 OpenCV 具有将 Sobel 运算符应用于图像的函数。 水平过滤器的名称如下:

   cv::Sobel(image,sobelX,CV_8U,1,0,3,0.4,128);

通过以下(和非常类似的)调用来实现垂直过滤:

   cv::Sobel(image,sobelY,CV_8U,0,1,3,0.4,128);

为该函数提供了几个整数参数,这些将在下一部分中进行说明。 只需注意,已选择这些来生成输出的 8 位图像(CV_8U)表示。

水平 Sobel 运算符的结果如下:

How to do it...

在该表示中,零值对应于灰度级 128。负值由较暗的像素表示,而正值由较亮的像素表示。 垂直的 Sobel 图像为:

How to do it...

如果您熟悉照片编辑软件,则前面的图像可能会让您想起图像浮雕效果,,实际上,这种图像转换通常基于定向过滤器的使用。

由于其核包含正值和负值,因此通常在 16 位带符号整数图像(CV_16S)中计算 Sobel 过滤器的结果。 然后将两个结果(垂直和水平)进行组合以获得 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);

使用convertTo方法的可选重新缩放参数,可以方便地在图像中显示 Sobel 范数,以获得零值对应于白色,较高的值分配为较深的灰色阴影的图像:

   // 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);

结果可以在下图中看到:

How to do it...

查看此图像,现在很清楚为什么将这种运算符称为边缘检测器。 然后可以对该图像进行阈值处理以获得显示图像轮廓的二进制图。 以下代码段创建了以下图像:

   cv::threshold(sobelImage, sobelThresholded, 
                      threshold, 255, cv::THRESH_BINARY);

How to do it...

工作原理

Sobel 运算符是经典的边缘检测线性过滤器,它基于简单的3x3核,其结构如下:

-1 0 1
-2 0 2
-1 0 1
-1 -2 -1
0 0 0
1 2 1

如果我们将图像视为二维函数,则可以将 Sobel 运算符视为图像在垂直和水平方向上变化的度量。 用数学术语来说,此度量称为梯度,它定义为由函数在两个正交方向上的一阶导数构成的 2D 向量:

How it works...

因此,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 遮罩都对应一个方向的导数。 因此,使用两个参数来指定要应用的核,即xy方向上的导数顺序。

例如,水平 Sobel 核是通过将x阶和y阶指定 1 和 0 来获得的,而垂直核将用 0 和 1 生成。其他组合也可以,但是这两个是最常使用的(在下一秘籍中讨论二阶导数的情况)。 最后,也可以使用大小大于3x3的核。 值 1、3、5 和 7 是核大小的可能选择。 大小为 1 的核对应于 1D Sobel 过滤器(1x33x1)。

由于梯度是 2D 向量,因此它具有范数和方向。 梯度向量的范数告诉您变化的幅度是多少,通常将其计算为欧几里得范数(也称为 L2 范数):

How it works...

但是,在图像处理中,我们通常将此规范计算为绝对值的总和。 这称为 L1 范数,它给出的值接近 L2 范数,但计算成本却低得多。 这就是我们在本秘籍中所做的,即:

   //compute the L1 norm
   sobel= abs(sobelX)+abs(sobelY);

梯度向量始终指向最陡峭变化的方向。 对于图像,这意味着梯度方向将与边缘正交,指向从暗到亮的方向。 梯度角方向由下式给出:

How it works...

通常,对于边缘检测,仅计算范数。 但是,如果您既需要规范又需要方向,那么可以使用以下 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作为附加参数,以度为单位进行计算即可。

通过在梯度幅度上应用阈值获得了二进制边缘图。 选择正确的阈值并不是一项显而易见的任务。 如果阈值太低,将保留太多(较厚)边缘,而如果我们选择更严格(较高)的阈值,则将获得折断边缘。 为了说明这种折衷情况,请将前面的二进制边缘图与使用较高阈值获得的以下内容进行比较:

How it works...

一种可能的替代方法是使用滞后阈值的概念。 我们将在下一章介绍 Canny 运算符的地方对此进行解释。

更多

还存在其他梯度运算符。 例如, Prewitt 运算符定义以下核:

-1 0 1
-1 0 1
-1 0 1
-1 -1 -1
0 0 0
1 1 1

Roberts 运算符基于以下简单的2x2核:

1 0
0 -1
0 1
-1 0

当需要更精确的梯度方向估计时,首选 Scharr 运算符:

-3 0 3
-10 0 10
-3 0 3
-3 -10 -3
0 0 0
3 10 3

请注意,可以通过使用CV_SCHARR参数来将 Scharr 核与cv::Sobel函数结合使用:

   cv::Sobel(image,sobelX,CV_16S,1,0, CV_SCHARR);

或者等效地,通过调用函数cv::Scharr

   cv::Scharr(image,scharrX,CV_16S,1,0,3);

所有这些定向过滤器都试图估计图像函数的一阶导数。 因此,在存在沿过滤器方向的较大强度变化的区域获得高值,而平坦区域产生较低的值。 这就是为什么计算图像导数的过滤器是高通过滤器的原因。

另见

第 7 章中的“使用 Canny 运算符检测边缘”,通过使用两个不同的阈值获得二进制边缘图。

计算图像的拉普拉斯算子

拉普拉斯算子是另一种基于图像导数计算的高通线性过滤器。 如将要解释的,它计算二阶导数以测量图像函数的曲率。

操作步骤

OpenCV 函数cv::Laplacian计算图像的拉普拉斯算子。 它与cv::Sobel函数非常相似。 实际上,它使用相同的基本函数cv::getDerivKernels以获得其核矩阵。 唯一的区别是没有导数阶参数,因为根据定义,这些参数是二阶导数。

对于此运算符,我们将创建一个简单的类,该类将封装一些与 Laplacian 相关的有用操作。 基本方法是:

class LaplacianZC {

  private:

     // original image
     cv::Mat img;

     // 32-bit float image containing the 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);

        // Keep local copy of the image
        // (used for zero-crossings)
        img= image.clone();

        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) {

        if (scale<0) {

           double lapmin, lapmax;
           cv::minMaxLoc(laplace,&lapmin,&lapmax);

           scale= 127/ std::max(-lapmin,lapmax);
        }

        cv::Mat laplaceImage;
        laplace.convertTo(laplaceImage,CV_8U,scale,128);

        return laplaceImage;
     }

使用此类,从7x7核计算出的拉普拉斯图像如下:

   // Compute Laplacian using LaplacianZC class
   LaplacianZC laplacian;
   laplacian.setAperture(7);
   cv::Mat flap= laplacian.computeLaplacian(image);
   laplace= laplacian.getLaplacianImage();

生成的图像如下:

How to do it...

工作原理

正式地,将 2D 函数的拉普拉斯算子定义为其第二个导数的和:

How it works...

以其最简单的形式,它可以由以下3x3核近似:

0 1 0
1 -4 1
0 1 0

至于 Sobel 运算符,也可以使用更大的核来计算拉普拉斯算子,并且由于该运算符对图像噪声更加敏感,因此希望这样做(除非考虑到计算效率)。 请注意,拉普拉斯算子的核值总和为 0。这保证了在恒定强度的区域中拉普拉斯算子将为零。 确实,由于拉普拉斯算子测量图像函数的曲率,因此在平坦区域上它应等于 0。

乍一看,拉普拉斯算子的作用可能难以解释。 根据核的定义,很明显,运算符会放大任何孤立的像素值(该值与相邻像素值非常不同)。 这是操作人员对噪声的高度敏感性的结果。 但是,查看图像边缘周围的拉普拉斯值更有趣。 图像中存在边缘是不同灰度强度的区域之间快速过渡的结果。 随着图像函数沿边缘的演变(例如,由从暗到亮的过渡引起),人们可以观察到灰度级提升必然意味着从正曲率逐渐过渡(当强度值开始上升时) )变为负曲率(强度即将达到其高平稳期时)。 因此,正和负拉普拉斯值之间(或相反)之间的过渡构成边缘存在的良好指示。 表达这一事实的另一种方式是说边缘将位于拉普拉斯函数的零交叉点 。 我们将通过在测试图像的一个小窗口中查看拉普拉斯算子的值来说明这一想法。 我们选择一个对应于由城堡之一的塔的屋顶的底部创建的边缘的边缘。 下图绘制了一个白框,以显示该兴趣区域的确切位置:

How it works...

现在查看此窗口中的拉普拉斯值(7x7核),我们有:

How it works...

如图所示,如果您仔细遵循拉普拉斯算子的零交叉点(位于不同符号的像素之间),则会获得一条与图像窗口中可见边缘相对应的曲线。 在上方,我们沿着零交叉点绘制了虚线,这些零点对应于在所选图像窗口中可见的塔架边缘。 这意味着原则上甚至可以亚像素精度检测图像边缘。

在拉普拉斯图像中遵循零交叉曲线是一项艰巨的任务。 但是,可以使用简化的算法来检测近似的零交叉位置。 这一过程如下。 扫描拉普拉斯图像,并将当前像素与其左侧的像素进行比较。 如果两个像素的符号不同,则在当前像素处声明零交叉,如果不是,则对紧接上方的像素重复相同的测试。 该算法通过以下方法实现,该方法生成零交叉的二进制图像:

     // Get a binary image of the zero-crossings
     // if the product of the two adjascent pixels is
     // less than threshold then this zero-crossing 
     // will be ignored
     cv::Mat getZeroCrossings(float threshold=1.0) {

        // Create the iterators
        cv::Mat_<float>::const_iterator it= 
           laplace.begin<float>()+laplace.step1();
        cv::Mat_<float>::const_iterator itend= 
           laplace.end<float>();
        cv::Mat_<float>::const_iterator itup= 
           laplace.begin<float>();

        // Binary image initialize to white
        cv::Mat binary(laplace.size(),CV_8U,cv::Scalar(255));
        cv::Mat_<uchar>::iterator itout= 
           binary.begin<uchar>()+binary.step1();

        // negate the input threshold value
        threshold *= -1.0;

        for ( ; it!= itend; ++it, ++itup, ++itout) {

           // if the product of two adjascent pixel is
           // negative then there is a sign change
           if (*it * *(it-1) < threshold)
              *itout= 0; // horizontal zero-crossing
           else if (*it * *itup < threshold)
              *itout= 0; // vertical zero-crossing
        }

        return binary;
     }

还引入了一个附加阈值,以确保当前的拉普拉斯值足够显着,可以视为边缘。 结果是以下二进制映射:

How it works...

如您所见,拉普拉斯算子的零交叉点检测所有边缘。 在强边缘和弱边缘之间没有区别。 我们还提到拉普拉斯算子对噪声非常敏感。 这两个事实解释了为什么运算符会检测到如此多的边缘。

更多

可以通过从图像中减去拉普拉斯算子来增强图像的对比度。 这是我们在第 2 章“通过访问邻居扫描图像”秘籍中的方法,我们在其中介绍了核:

0 -1 0
-1 5 -1
0 -1 0

等于 1 减去 Laplacian 核(即原始图像减去其 Laplacian)。

另见

第 8 章中的方法“检测尺度不变特征 SURF”,使用拉普拉斯算子进行尺度不变特征的检测。

七、提取直线,轮廓和零件

在本章中,我们将介绍:

  • 使用 Canny 运算符检测图像轮廓
  • 使用霍夫变换检测图像中的直线
  • 将线拟合到一组点
  • 提取组件的轮廓
  • 计算组件的形状描述符

简介

为了对图像执行基于内容的分析,必须从构成图像的像素集合中提取有意义的特征。 轮廓,线条,斑点等是定义图像内容的基本图像元素。 本章将教您如何提取其中一些重要的图像特征。

使用 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

当应用于下图时:

How to do it...

结果如下:

How to do it...

请注意,要获得如前面的屏幕快照中所示的图像,我们必须反转黑白值,因为正常结果以非零像素表示轮廓。 倒置表示形式更易于在页面上打印,其生成方式如下:

   cv::Mat contoursInv; // inverted image
   cv::threshold(contours,contoursInv,
                 128,   // values below this
                 255,   // becomes this
                 cv::THRESH_BINARY_INV);

工作原理

尽管可以使用其他梯度运算符,但 Canny 运算符通常基于 Sobel 运算符。 此处的关键思想是使用两个不同的阈值以确定哪个点应属于轮廓:一个低阈值和一个高阈值。

选择低阈值的方式应使其包括被认为属于重要图像轮廓的所有边缘像素。 例如,使用上一部分示例中指定的低阈值,并将其应用于 Sobel 运算符的结果,可获得以下边缘图:

How it works...

可以看出,描绘道路的边缘非常清晰。 但是,由于使用了允许阈值,因此还检测到了比理想情况更多的边缘。 然后,第二个阈值的作用是定义属于所有重要轮廓的边缘。 它应排除所有被视为离群值的边缘。 例如,与我们的示例中使用的高阈值相对应的 Sobel 边缘图为:

How it works...

现在,我们有一个包含断边的图像,但是可见的断点当然属于场景的重要轮廓。 Canny 算法将这两个边缘图组合在一起,以生成轮廓的“最佳”图。 它仅通过保留低阈值边缘图的边缘点(存在连续的边缘路径)并将该边缘点链接到属于高阈值边缘图的边缘来进行操作。 因此,保留了高阈值图的所有边缘点,同时移除了低阈值图中的所有孤立的边缘点链。 所获得的解决方案构成了良好的折衷,只要指定了适当的阈值,就可以获取高质量的轮廓。 基于使用两个阈值以获得二进制图的该策略称为滞后阈值,可用于需要从阈值操作获得二进制图的任何上下文中。 但是,这是以较高的计算复杂度为代价的。

另外,Canny 算法使用额外的策略来改善边缘图的质量。 在应用滞后阈值之前,应去除所有梯度幅度在梯度方向上不是最大的边缘点。 回想一下,梯度方向始终垂直于边缘。 因此,在该方向上的梯度的局部最大值对应于轮廓的最大强度点。 这解释了为什么在 Canny 等高线图中获得细边的原因。

另见

The classic article by J. Canny, A computational approach to edge detection, IEEE Transactions on Pattern Analysis and Image Understanding, vol. 18, issue 6, 1986.

使用霍夫变换检测图像中的直线

在我们的人造世界中,平面和线性结构比比皆是。 结果,在图像中经常可见直线。 这些有意义的特征在对象识别和图像理解中起着重要作用。 因此,检测图像中的这些特定特征很有用。 霍夫变换是实现此目标的经典算法。 它最初是为检测图像中的线条而开发的,并且正如我们将看到的,它也可以扩展为检测其他简单图像结构。

准备

使用霍夫变换时,使用以下公式表示线:

Getting ready

参数ρ是线与图像原点之间的距离(左上角),θ是垂直于线的角度。 在此表示下,图像中可见的线在0π弧度之间具有θ角,而半径ρ可以具有等于图像对角线长度的最大值。 例如,考虑以下几行:

Getting ready

像线 1 一样的垂直线的θ角度值等于零,而水平线(例如,线 5)的θ值等于π / 2。 因此,线 3 具有等于π / 4的角度θ,并且线 4 大约为0.7π。 为了能够在间隔[0, π]中用θ表示所有可能的线,可以将半径值设为负数。 第 2 行的情况是θ值等于0.8π,而ρ的值为负。

操作步骤

OpenCV 为行检测提供了霍夫变换的两种实现。 基本版本是cv::HoughLines。 它的输入是一个二进制映射,其中包含一组点(用非零像素表示),其中一些点对齐形成线。 通常,它是例如从 Canny 运算符获得的边缘图。 cv::HoughLines函数的输出是cv::Vec2f元素的向量,每个元素都是一对浮点值,它们代表检测到的线的参数(ρ, θ)。 这是使用此函数的示例,其中我们首先应用 Canny 运算符来获取图像轮廓,然后使用霍夫变换检测线:

   // Apply Canny algorithm
   cv::Mat contours;
   cv::Canny(image,contours,125,350);
   // Hough tranform for line detection
   std::vector<cv::Vec2f> lines;
   cv::HoughLines(test,lines,
        1,PI/180,  // step size
        80);       // minimum number of votes

参数 3 和 4 对应于行搜索的步长。 在我们的示例中,该函数将按1搜索所有可能半径的线,并按π/180搜索所有可能角度的线。 下一部分将说明最后一个参数的作用。 通过这种特殊的参数值选择,可以在先前秘籍的道路图像上检测到 15 条线。 为了可视化检测结果,有趣的是在原始图像上绘制这些线。 但是,重要的是要注意该算法检测图像中的线,而不是线段,因为未给出每条线的终点。 因此,我们将绘制横贯整个图像的线。 为此,对于一条几乎垂直的线,我们计算其与图像的水平界限的交点(即第一行和最后一行),并在这两点之间绘制一条线。 我们几乎以水平线进行类似处理,但使用第一列和最后一列。 使用cv::line函数绘制线。 请注意,即使点坐标超出图像限制,此函数也可以正常使用。 因此,不需要检查计算出的交点是否落在图像内。 然后通过如下迭代线向量来绘制线:

   std::vector<cv::Vec2f>::const_iterator it= lines.begin();
   while (it!=lines.end()) {

      float rho= (*it)[0];   // first element is distance rho
      float theta= (*it)[1]; // second element is angle theta

      if (theta < PI/4\. 
           || theta > 3.*PI/4.) { // ~vertical line

         // point of intersection of the line with first row
         cv::Point pt1(rho/cos(theta),0);        
         // point of intersection of the line with last row
         cv::Point pt2((rho-result.rows*sin(theta))/
                                  cos(theta),result.rows);
         // draw a white line
         cv::line( image, pt1, pt2, cv::Scalar(255), 1); 

      } 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;
   }

然后获得以下结果:

How to do it...

可以看出,霍夫变换只是在寻找图像中边缘像素的对齐方式。 由于偶发的像素对齐,这可能会产生一些错误的检测,或者当几条线穿过像素的相同对齐时,可能会导致多次检测。

为了克服这些问题中的某些问题,并允许检测线段(即带有端点),提出了一种变换形式。 这是概率霍夫变换,在 OpenCV 中作为函数cv::HoughLinesP实现。 我们在这里使用它来创建封装函数参数的LineFinder类:

class LineFinder {

  private:

     // original image
     cv::Mat img;

     // vector containing the end points 
     // 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 mimimum 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(80);

   // 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);

得到以下结果:

How to do it...

工作原理

霍夫变换的目的是找到二进制图像中经过足够数量点的所有线。 它通过考虑输入二进制图中的每个像素点并标识通过它的所有可能的行来进行。 当同一条线穿过许多点时,这意味着该条线的重要性足以考虑。

霍夫变换使用二维累加器,以便计算识别给定行的次数。 该累加器的大小由所采用的线表示形式的[ρ, θ]参数的指定步长(如前一节所述)定义。 为了说明转换的功能,让我们通过200矩阵创建180θ对应π / 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*cos(theta)+y*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 可见),我们将获得:

How it works...

该曲线表示通过考虑点的所有线的集合。 现在,如果我们重复相同的练习,比方说(30, 10),那么我们现在有了以下累加器:

How it works...

可以看出,两条结果曲线在一个点处相交。 对应于经过这两个点的线的点。 累加器的相应条目将获得两票,表明有两分通过这条线。 如果对二进制图的所有点重复相同的过程,则沿给定线对齐的点将多次增加累加器的公共项。 最后,只需要在此累加器中标识出获得大量票数的局部最大值即可检测图像中的线(即点对齐)。 在cv::HoughLines函数中指定的最后一个参数对应于必须将行视为被检测到的最小投票数。 例如,如果我们将此值降低为 60,即:

   cv::HoughLines(test,lines,1,PI/180,60);

然后,前面部分的示例将接受更多行,如下所示:

How it works...

概率霍夫变换对基本算法没有多大修改。 首先,不是系统地逐行扫描图像,而是在二进制映射图中以随机顺序选择点。 每当累加器的输入达到指定的最小值时,就会沿着相应的线扫描图像,并删除通过该图像的所有点(即使尚未投票)。 该扫描还确定了将被接受的片段的长度。 为此,算法定义了两个附加参数。 一个是要接受的段的最小长度,另一个是允许形成连续段的最大像素间隙。 这个额外的步骤增加了算法的复杂性,但是由于以下事实这一事实可以部分弥补这一点:投票过程中涉及的点更少,因为其中一些点被线扫描过程消除了。

更多

霍夫变换还可以用于检测其他几何实体。 实际上,可以由参数方程式表示的任何实体都是霍夫变换的良好候选者。

检测圆形

对于圆形,相应的参数方程为:

Detecting circles

该方程式包含三个参数(圆半径和中心坐标),这意味着将需要一个 3 维累加器。 但是,通常发现,随着其累加器维数的增加,霍夫变换的可靠性降低。 实际上,在这种情况下,对于每个点,累加器的大量条目将增加,因此,局部峰的精确定位变得更加困难。 因此,已经提出了不同的策略以克服该问题。 在霍夫圆检测的 OpenCV 实现中使用的一个使用两次通过。 在第一遍过程中,使用二维累加器查找候选圆的位置。 由于圆圆周上的点的坡度应指向半径方向,因此,对于每个点,仅累加器中沿坡度方向的条目会增加(基于预定义的最小和最大半径值)。 一旦检测到可能的圆心(即已收到预定义数量的选票),便会在第二遍过程中建立可能半径的一维直方图。 该直方图中的峰值对应于检测到的圆的半径。

实现上述策略的函数cv::HoughCircles集成了 Canny 检测和霍夫变换。 它被称为如下:

   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;   
   }

这是在带有所选参数的测试图像上获得的结果:

Detecting circles

广义霍夫变换

对于某些形状,很难找到紧凑的参数表示形式,例如三角形,八边形,多边形,对象轮廓等。 但是,仍然可以使用霍夫变换在图像中定位这些形状。 原理保持不变。 创建一个二维累加器,它代表目标形状的所有可能位置。 因此,必须在形状上定义一个参考点,并且图像上的每个特征点都会为可能的参考点位置投票。 由于点可以在形状轮廓上的任何位置,因此所有可能的参考位置的轨迹都将跟踪累加器中的形状,该形状是目标形状的镜像。 同样,图像中属于相同形状的点将在累加器中与该形状位置相对应的交点处产生一个峰值。

下图中对此进行了说明,其中感兴趣的形状是一个三角形(如右图所示),在该三角形的左下角定义了参考。 在累加器上显示了一个特征点,该特征点将增加绘制位置处的所有条目,因为它们对应于穿过该特征点的三角形参考点的可能位置:

Generalized Hough transform

这种方法通常称为广义霍夫变换。 显然,它没有考虑可能的比例变化或形状旋转。 这将需要在更高维度上进行搜索。

另见

The article 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. It is one of the numerous reference on the Hough transform and describes the probabilistic algorithm implemented in OpenCV.

The article by H.K. Yuen, J. Princen, J. Illingworth, and J Kittler, Comparative Study of Hough Transform Methods for Circle Finding, Image and Vision Computing, vol. 8 no 1, pp. 71-77, 1990 that describes different strategies for circle detection using the Hough transform.

将直线拟合到一组点

在某些应用中,重要的是不仅要检测图像中的线条,而且要获得线条位置和方向的准确估计。 此秘籍将向您展示如何找到最适合给定点集的线。

操作步骤

首先要做的是识别图像中似乎沿直线对齐的点。 然后,让我们使用在前面的秘籍中检测到的行之一。 假设使用cv::HoughLinesP检测到的行包含在称为linesstd::vector中。 例如,要提取看似属于的那组点,我们可以按以下步骤进行。 我们在黑色图像上绘制一条白线,并将其与用于检测线条的轮廓的 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),
            5);
   // contours AND white line
   cv::bitwise_and(contours,oneline,oneline);

结果是仅包含可能与指定线关联的点的图像。 为了引入一些公差,我们绘制了一定厚度的线(此处为 5)。 因此,将接受所定义邻域内的所有点。 这是获得的图像(为了更好地观看,将其反转):

How to do it...

然后可以通过以下双重循环将此集合中点的坐标插入cv::Pointstd::vector中(也可以使用浮点坐标,即cv::Point2f):

   std::vector<cv::Point> points;

   // Iterate over the pixels to obtain all point positions
   for( int y = 0; y < oneline.rows; y++ ) {    
      // row y

      uchar* rowPtr = oneline.ptr<uchar>(y);

      for( int x = 0; x < oneline.cols; x++ ) {
         // column x 

         // if on a contour
         if (rowPtr[x]) {

            points.push_back(cv::Point(x,y));
         }
      }
    }

通过调用 OpenCV 函数cv::fitLine可以轻松找到最合适的线:

   cv::Vec4f line;
   cv::fitLine(cv::Mat(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)。 最后两个参数指定线参数的要求精度。 注意,std::vector中包含的输入点根据函数需要在cv::Mat中传输。

通常,线方程将用于某些属性的计算中(在需要精确参数表示的情况下,校准是一个很好的例子)。 作为说明,并确保我们计算出正确的线,让我们在图像上绘制估计的线。 在这里,我们简单地绘制一个任意的黑色段,长度为 200 像素,厚度为 3 像素:

   int x0= line[2];        // a point on the line
   int y0= line[3];
   int x1= x0-200*line[0]; // add a vector of length 200
   int y1= y0-200*line[1]; // using the unit vector
   image= cv::imread("../road.jpg",0);
   cv::line(image,cv::Point(x0,y0),cv::Point(x1,y1),
            cv::Scalar(0),3);

结果如下图所示:

How to do it...

工作原理

将线拟合到一组点是数学中的经典问题。 OpenCV 的实现通过最小化每个点到线的距离之和来进行。 提出了几种距离函数,最快的选择是使用由CV_DIST_L2指定的欧几里德距离。 此选择对应于标准最小二乘法线拟合。 当离群点(即不属于该线的点)可能包括在点集中时,可以选择对远点影响较小的其他距离函数。 最小化基于 M 估计器技术,该技术迭代解决权重与距线的距离成反比的加权最小二乘问题。

使用此函数,还可以将线拟合到 3D 点集。 在这种情况下,输入为cv::Point3icv::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是用于绘制计算的椭圆的函数。

提取组件的轮廓

图像通常包含对象的表示。 图像分析的目标之一是识别并提取这些对象。 在对象检测/识别应用中,第一步是生成一个二进制图像,该图像显示某些感兴趣的物体可能位于何处。 无论如何获得此二进制映射(例如,可以像我们在第 4 章中所做的那样从直方图反向投影中获得,或者从运动分析中获得(如我们将在第 10 章中学习的一样) ),下一步是提取此 1 和 0 集合中包含的对象。 例如,考虑一下我们在第 5 章中处理过的二进制形式的水牛图像,如下所示:

Extracting the components' contours

我们从一个简单的阈值操作中获得了这张图像,接着是打开和关闭形态过滤器的应用。 此秘籍将向您展示如何提取此类图像的对象。 更具体地说,我们将提取连通分量,即由二进制图像中一组连接的像素组成的形状。

操作步骤

OpenCV 提供了一个简单的函数,可以提取图像的已连接组件的轮廓。 它是cv::findContours函数:

   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::Points的向量表示。 这解释了为什么将输出参数定义为std::vectorsstd::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
      cv::Scalar(0), // in black
      2); // with a thickness of 2

如果此函数的第三个参数为负值,则绘制所有轮廓。 否则,可以指定要绘制轮廓的索引。 结果显示在以下屏幕截图中:

How to do it...

工作原理

轮廓是通过简单的算法提取的,该算法包括系统扫描图像直到命中组件。 从组件的此起点开始,遵循其轮廓,在其边界上标记像素。 轮廓完成后,扫描将在最后一个位置继续进行,直到找到新的零件。

然后可以分别分析识别出的连接组件。 例如,如果可以获得有关感兴趣对象的预期大小的一些先验知识,则可以消除某些组件。 然后让我们为组件的周长使用最小值和最大值。 这是通过迭代轮廓向量并消除无效分量来完成的:

   // Eliminate too short or too long contours
   int cmin= 100;  // minimum contour length
   int cmax= 1000; // maximum contour length
   std::vector<std::vector<cv::Point>>::
              const_iterator itc= contours.begin();
   while (itc!=contours.end()) {

      if (itc->size() < cmin || itc->size() > cmax)
         itc= contours.erase(itc);
      else 
         ++itc;
   }

注意,由于std::vector中的每个擦除操作均为O(N),因此可以更有效地进行此循环。 但是考虑到此向量的大小,此操作并不太昂贵。 这次我们在原始图像上绘制其余轮廓,并获得以下结果:

How it works...

我们很幸运地找到了一个简单的标准,该标准使我们能够识别出该图像中所有感兴趣的物体。 在更复杂的情况下,需要对组件的属性进行更精细的分析。 这是下一个秘籍的对象。

更多

使用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

通过此调用,可获得以下轮廓:

There's more...

注意在背景林中添加的额外轮廓。 也可以将这些轮廓组织成一个层次。 主要组件是父组件,其中的孔是其子组件,如果这些孔中有组件,它们将成为先前子组件的子组件,依此类推。 通过使用标志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(cv::Mat(contours[0]));
   cv::rectangle(result,r0,cv::Scalar(0),2);

最小包围圈类似。 它应用于右上角的组件:

   // testing the enclosing circle 
   float radius;
   cv::Point2f center;
   cv::minEnclosingCircle(cv::Mat(contours[1]),center,radius);
   cv::circle(result,cv::Point(center),
              static_cast<int>(radius),cv::Scalar(0),2);

组件轮廓的多边形近似计算如下(在左侧组件上):

   // testing the approximate polygon
   std::vector<cv::Point> poly;
   cv::approxPolyDP(cv::Mat(contours[2]),poly,
                    5,     // accuracy of the approximation
                    true); // yes it is a closed shape

在图像上绘制结果需要做更多的工作:

   // Iterate over each segment and draw it
   std::vector<cv::Point>::const_iterator itp= poly.begin();
   while (itp!=(poly.end()-1)) {
      cv::line(result,*itp,*(itp+1),cv::Scalar(0),2);
      ++itp;
   }
   // last point linked to first point
   cv::line(result,
            *(poly.begin()),
            *(poly.end()-1),cv::Scalar(20),2);

凸包是多边形近似的另一种形式:

   // testing the convex hull
   std::vector<cv::Point> hull;
   cv::convexHull(cv::Mat(contours[3]),hull);

最后,矩的计算是另一个强大的描述符:

   // 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
   }

生成的图像如下:

How to do it...

工作原理

组件的边界框可能是在图像中表示和定位组件的最紧凑的方式。 定义为完全包含形状的最小尺寸的直立矩形。 比较盒子的高度和宽度会给出有关对象垂直或水平方向的指示(例如,将汽车的图像与行人的图像区分开)。 当仅需要组件尺寸和位置时,通常使用最小包围圈。

当人们想要操纵类似于组件形状的更紧凑的表示形式时,组件的多边形近似很有用。 通过指定精度参数创建该参数,该精度参数给出了形状与其简化的多边形之间的最大可接受距离。 它是cv::approxPolyDP函数中的第四个参数。 结果是对应于多边形顶点的cv::Point向量。 要绘制此多边形,我们需要遍历向量,并通过在它们之间画一条线将每个点与下一个点链接。

形状的凸包或凸包络是包含形状的最小凸多边形。 可以将其可视化为松紧带在组件周围放置时的形状。

矩是形状结构分析中常用的数学实体。 OpenCV 定义了一个数据结构,该数据结构封装了形状的所有计算矩。 它是cv::moments函数返回的对象。 我们简单地使用这种结构来获得每个分量的质心,这里是从前三个空间矩计算得出的。

更多

其他结构特性可以使用可用的 OpenCV 函数来计算。 函数cv::minAreaRect计算最小的封闭旋转矩形。 函数cv::contourArea估计轮廓的面积(内部像素数)。 函数cv::pointPolygonTest确定一个点在轮廓内部还是外部,cv::matchShapes测量两个轮廓之间的相似度。

八、检测和匹配兴趣点

在本章中,我们将介绍:

  • 检测哈里斯角点
  • 检测 FAST 特征
  • 检测尺度不变的 SURF 特征
  • 描述 SURF 特征

简介

在计算机视觉中,兴趣点的概念也被称为关键点特征点, 在对象识别,图像配准,视觉跟踪,3D 重建等方面存在许多问题。 它依赖于这样的想法:与其在整体上查看图像,不如在图像中选择一些特殊点并对这些点执行局部分析可能会比较有利。 只要在感兴趣的图像中检测到足够数量的此类点并且这些点是可以准确定位的独特且稳定的特征,这些方法就可以很好地工作。 本章将介绍一些兴趣点检测器,并向您展示如何在图像匹配中使用它们。

检测哈里斯角点

在图像中搜索有趣的特征点时,出现角点是一个有趣的解决方案。 它们确实是可以轻松定位在图像中的局部特征,此外,它们应在人造对象的场景中比比皆是(它们是由墙,门,窗户,桌子等产生的)。 角点也很有趣,因为它们是二维特征,因为它们位于两个边缘的交界处,所以可以准确地定位(即使以亚像素精度)。 这与位于物体的均匀区域或轮廓上的点相反,并且很难将其精确地重复定位在同一物体的其他图像上。

哈里斯特征检测器是检测图像角点的经典方法。 我们将在本秘籍中探讨该运算符。

操作步骤

用于检测哈里斯角点的基本 OpenCV 函数称为cv::cornerHarris ,易于使用。 您在输入图像上调用它,结果是浮点图像,该图像给出了每个像素位置的角点强度。 然后按顺序将阈值应用于此输出图像,以获得一组检测到的角点。 这是通过以下代码完成的:

   // Detect Harris Corners
   cv::Mat cornerStrength;
   cv::cornerHarris(image,cornerStrength,
                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);

这是原始图片:

How to do it...

结果是下面的屏幕快照中显示的二进制映射图像,该图像被反转以更好地查看(也就是说,我们使用cv::THRESH_BINARY_INV而不是cv::THRESH_BINARY将检测到的角变成黑色):

How to do it...

从前面的函数调用中,我们观察到该兴趣点检测器需要几个参数(这些参数将在下一节中进行说明),这可能会使调整变得困难。 另外,所获得的角图包含许多角像素群集,这与我们希望检测定位良好的点的事实相矛盾。 因此,我们将尝试通过定义自己的类来检测哈里斯角点来改进角点检测方法。

该类使用其默认值以及相应的获取器和设置器方法(此处未显示)封装哈里斯参数。

class HarrisDetector {

  private:

     // 32-bit float image of corner strength
     cv::Mat cornerStrength;
     // 32-bit float image of thresholded corners
     cv::Mat cornerTh;
     // image of local maxima (internal)
     cv::Mat localMax;
     // size of neighborhood for derivatives smoothing
     int neighbourhood; 
     // aperture for gradient computation
     int aperture; 
     // Harris parameter
     double k;
     // maximum strength for threshold computation
     double maxStrength;
     // calculated threshold (internal)
     double threshold;
     // size of neighborhood for non-max suppression
     int nonMaxSize; 
     // kernel for non-max suppression
     cv::Mat kernel;

  public:

     HarrisDetector() : neighbourhood(3), aperture(3), 
                        k(0.01), maxStrength(0.0), 
                        threshold(0.01), nonMaxSize(3) {

        // create kernel used in non-maxima suppression
        setLocalMaxWindowSize(nonMaxSize);
     }

为了检测图像上的哈里斯角,我们执行两个步骤。 首先,计算每个像素的哈里斯值:

     // 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
        double minStrength; // not used
        cv::minMaxLoc(cornerStrength,
             &minStrength,&maxStrength);

        // local maxima detection
        cv::Mat dilated;  // temporary image
        cv::dilate(cornerStrength,dilated,cv::Mat());
        cv::compare(cornerStrength,dilated,
                    localMax,cv::CMP_EQ);
     }

接下来,基于指定的阈值获得特征点。 由于哈里斯的可能值范围取决于其参数的特定选择,因此将阈值指定为质量级别,该质量级别定义为图像中计算出的最大哈里斯值的一部分:

     // 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;
     }

此方法返回检测到的特征的二进制角图。 哈里斯特征的检测已被分成两种方法,这一事实使我们能够以不同的阈值(直到获得适当数量的特征点)测试检测,而无需重复进行昂贵的计算。 也可能以cv::Pointstd::vector形式获得哈里斯特征:

     // Get the feature points from the computed Harris values
     void getCorners(std::vector<cv::Point> &points, 
                     double qualityLevel) {

        // Get the corner map
        cv::Mat cornerMap= getCornerMap(qualityLevel);
        // Get the corners
        getCorners(points, cornerMap);
     }

     // Get the feature points from the computed corner map
     void getCorners(std::vector<cv::Point> &points, 
                     const cv::Mat& cornerMap) {

        // Iterate over the pixels to obtain all features
        for( int y = 0; y < cornerMap.rows; y++ ) {

           const uchar* rowPtr = cornerMap.ptr<uchar>(y);

           for( int x = 0; x < cornerMap.cols; x++ ) {

              // if it is a feature point
              if (rowPtr[x]) {

                 points.push_back(cv::Point(x,y));
              }
           } 
        }
     }

此类通过添加非最大值抑制步骤来改进哈里斯角的检测,这将在下一部分中进行说明。 现在可以使用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=2) {

        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;
        }
     }

使用此类,哈里斯点的检测如下完成:

   // Create Harris detector instance
   HarrisDetector harris;
    // Compute Harris values
   harris.detect(image);
    // Detect Harris corners
   std::vector<cv::Point> pts;
   harris.getCorners(pts,0.01);
   // Draw Harris corners
   harris.drawOnImage(image,pts);

结果如下图:

How to do it...

工作原理

为了定义图像中角点的概念,哈里斯在假定的兴趣点周围的小窗口中查看了平均方向强度变化。 如果我们考虑位移向量(u, v),则平均强度变化由下式给出:

How it works...

该求和是在所考虑像素周围的定义邻域上进行的(该邻域的大小对应于cv::cornerHarris函数中的第三个参数)。 然后可以在所有可能的方向上计算该平均强度变化,这导致将角定义为在多个方向上平均变化高的点。 根据这个定义,哈里斯检验如下进行。 我们首先获得最大平均强度变化的方向。 接下来,检查正交方向上的平均强度变化是否也很高。 如果是这样,那么我们有一个角落。

从数学上讲,可以使用泰勒展开式通过使用前面公式的近似值来测试此条件:

How it works...

然后以矩阵形式重写:

How it works...

该矩阵是协方差矩阵,它描述了各个方向上强度变化的速率。 此定义涉及通常使用 Sobel 运算符计算的图像的一阶导数。 这是 OpenCV 实现的情况,函数的第四个参数与用于计算 Sobel 过滤器的孔径相对应。 可以看出,协方差矩阵的两个特征值给出了最大的平均强度变化和正交方向的平均强度变化。 然后得出结论,如果这两个特征值较低,则我们处于相对同质的区域。 如果一个特征值高而另一个特征值低,则我们必须处于边缘。 最后,如果两个特征值都很高,那么我们将处于角点位置。 因此,要被接受为角点的条件是协方差矩阵的最小特征值高于给定阈值。

哈里斯角点算法的原始定义使用了特征分解理论的某些属性,以避免显式计算特征值的成本。 这些属性是:

  • 矩阵特征值的乘积等于其行列式
  • 矩阵的特征值之和等于矩阵对角线的总和(也称为矩阵的迹线

然后,我们可以通过计算以下分数来验证两个特征值是否较高:

How it works...

仅当两个特征值也都很高时,才能轻松验证该分数确实很高。 这是由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函数的 )。

更多

可以对原始的哈里斯角点算法进行其他改进。 本节描述了 OpenCV 中的另一个角检测器,该角检测器扩展了哈里斯检测器,以使其角更均匀地分布在整个图像上。 正如我们将看到的,该运算符在新的 OpenCV 2 通用接口中用于特征检测器。

值得跟踪的良好特征

随着浮点处理器的出现,为避免特征值分解而引入的数学简化变得可以忽略不计,因此,可以基于显式计算的特征值进行哈里斯的检测。 原则上,此修改不应显着影响检测结果,但应避免使用任意k参数。

第二修改解决了特征点聚类的问题。 实际上,尽管引入了局部极大值条件,兴趣点仍倾向于在整个图像上分布不均,从而在高度纹理化的位置显示出浓度。 该问题的解决方案是在两个兴趣点之间施加最小距离。 这可以通过以下算法来实现。 从具有最强哈里斯分数的点(即具有最大最小特征值)开始,仅当兴趣点至少位于距已接受点的给定距离处时,才接受它们。 此解决方案在 OpenCV 中通过函数cv::goodFeaturesToTrack来实现,因为它检测到的特征可以用作视觉跟踪应用中的良好起点。 它被称为如下:

   // Compute good features to track
   std::vector<cv::Point2f> corners;
   cv::goodFeaturesToTrack(image,corners,
      500,   // maximum number of corners to be returned
      0.01,   // quality level
      10);   // minimum allowed distance between points

除了质量级别阈值和兴趣点之间的最小容许距离外,该函数还使用要返回的最大点数(这是可能的,因为按强度顺序接受点)。 前面的函数调用产生以下结果:

Good features to track

这种方法增加了检测的复杂性,因为它要求按照兴趣点的哈里斯分数对兴趣点进行排序,但同时也明显改善了兴趣点在图像上的分布。 请注意,此函数还包括一个可选标志,以请求使用经典角点分数定义(使用协方差矩阵的行列式和轨迹)检测哈里斯角。

特征检测器通用接口

OpenCV 2 已为其不同的兴趣点检测器引入了新的通用接口。 该接口可轻松测试同一应用中的不同兴趣点检测器。

该接口定义了一个Keypoint类,该类封装了每个检测到的特征点的属性。 对于哈里斯角点,仅关键点的位置相关。 秘籍“检测尺度不变的 SURF 点”将讨论可能与关键点相关的其他属性。

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::GoodFeatureToTrackDetector的包装类,该包装类继承自cv::FeatureDetector类。 它的使用方式类似于我们对哈里斯Corners类所做的方式,即:

   // vector of keypoints
   std::vector<cv::KeyPoint> keypoints;
   // Construction of the Good Feature to Track detector 
   cv::GoodFeaturesToTrackDetector gftt(
      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);

结果与之前获得的结果相同,因为包装器最终会调用相同的函数。

另见

The classical article describing the Harris operator: C. Harris and M.J. Stephens, A combined corner and edge detector, by Alvey Vision Conference, pp. 147–152, 1988.

The article by J. Shi and C. Tomasi, Good features to track, Int. Conference on Computer Vision and Pattern Recognition, pp. 593-600, 1994 which introduced these features.

The article by K. Mikolajczyk and C. Schmid, Scale and Affine invariant interest point detectors, International Journal of Computer Vision, vol 60, no 1, pp. 63-86, 2004, which proposes a multi-scale and affine-invariant Harris operator.

检测 FAST 特征

哈里斯运算符基于两个垂直方向上的强度变化率,为角(或更一般地为兴趣点)提出了一个正式的数学定义。 尽管这构成了一个良好的定义,但它需要计算图像导数,这是一项昂贵的操作,尤其是考虑到兴趣点检测通常只是更复杂算法中的第一步这一事实。

在本秘籍中,我们介绍了另一个特征点运算符。 经过专门设计的该工具可以快速检测图像中的兴趣点。 接受或不接受关键点的决定仅基于几个像素比较。

操作步骤

使用 OpenCV 2 通用接口进行特征点检测可轻松部署任何特征点检测器。 本秘籍中介绍的一种是 FAST 检测器。 顾名思义,它旨在快速计算:

   // vector of keypoints
   std::vector<cv::KeyPoint> keypoints;
   // Construction of the Fast feature detector object 
   cv::FastFeatureDetector fast(
           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

通过指定所选的绘制标记,可以在输出图像上绘制关键点,从而产生以下结果:

How to do it...

一个有趣的选项是为关键点颜色指定一个负值。 在这种情况下,将为每个绘制的圆选择不同的随机颜色。

工作原理

与哈里斯点的情况一样,FAST(加速分段测试的特征)特征算法从构成“角点”的定义中得出。 这次,此定义基于假定特征点周围的图像强度。 接受关键点的决定是通过检查以候选点为中心的像素圆来完成的。 如果发现长度大于圆周长 3/4 的连续点弧,其中所有像素均与中心点的强度明显不同,则声明关键点。

这是一个可以快速计算的简单测试。 此外,该算法使用了其他技巧来进一步加快处理速度。 确实,如果我们首先测试圆上相隔 90o 的四个点(例如,顶部,底部,右侧和左侧点),则可以很容易地证明,要满足上述条件,这些点中的至少三个必须都比中央像素更亮或更暗。 如果不是这种情况,则可以立即拒绝该点,而无需检查圆周上的其他点。 这是一种非常有效的测试,因为在实践中,大多数图像点将被此简单的 4 比较测试所拒绝。

原则上,检查像素圆的半径应该是该方法的参数。 但是,已经发现,实际上,半径为 3 既可以得到良好的结果,又可以得到很高的效率。 然后,在圆的圆周上要考虑 16 个像素,如下所示:

16 1 2
15 3
14 4
13 0 5
12 6
11 7
10 9 8

用于预测试的四个点是像素 1、5、9 和 13。

至于哈里斯特征,通常最好在发现的角点处执行非最大值抑制。 因此,需要定义角点强度度量。 可以考虑几种替代方法,以下是保留的一种方法。 角点的强度由中心像素与所标识的连续弧上的像素之间的绝对差之和得出。

该算法可实现非常快的兴趣点检测,因此在考虑速度时应使用该算法。 例如,在视觉跟踪应用中经常是这种情况,在视觉跟踪应用中,必须在具有高帧速率的视频序列中跟踪几个点。

另见

The article by E. Rosten and T. Drummond, Machine learning for high-speed corner detection, in In European Conference on Computer Vision, pp. 430-443, 2006 that describes the FAST feature algorithm in detail.

检测尺度不变的 SURF 特征

当尝试在不同图像上匹配特征时,我们经常会遇到缩放比例变化的问题。 即,可以在距感兴趣对象不同距离处拍摄要分析的不同图像,因此,将以不同大小对这些对象进行拍照。 如果我们尝试使用固定大小的邻域匹配来自两个图像的相同特征,则由于缩放比例的变化,它们的强度模式将不匹配。

为了解决这个问题,计算机视觉中引入了尺度不变特征的概念。 这里的主要思想是使比例因子与每个检测到的特征点相关。 近年来,已经提出了几种尺度不变的特征,该秘籍提出了其中之一,即 SURF 特征。 SURF 代表加速鲁棒特征,正如我们将看到的那样,它们不仅是尺度不变的特征,而且还具有非常高效地进行计算的优势。

操作步骤

SURF 特征的 OpenCV 实现也使用cv::FeatureDetector接口。 因此,这些特征的检测与我们在本章前面的秘籍中展示的类似:

   // vector of keypoints
   std::vector<cv::KeyPoint> keypoints;
   // Construct the SURF feature detector object
   cv::SurfFeatureDetector surf(
       2500.); // threshold 
   // Detect the SURF features
   surf.detect(image,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

通过绘图函数生成的具有检测到的特征的最终图像为:

How to do it...

从前面的屏幕截图中可以看出,由于使用DRAW_RICH_KEYPOINTS标志而产生的关键点圆的大小与每个特征的计算比例成比例。 SURF 算法还将方向与每个特征相关联,以使它们旋转不变。 该方向由每个绘制的圆内的径向线表示。

如果我们以相同比例但以不同比例拍摄另一张照片,则特征检测将导致:

How to do it...

通过仔细观察检测到的关键点,可以看出相应圆的大小变化与比例变化成比例。 例如,考虑右上方窗口的底部。 在两幅图像中,在该位置均检测到 SURF 特征,并且两个相应的圆圈(大小不同)包含相同的视觉元素。 当然,并非所有特征都如此,但是正如我们将在下一章中发现的那样,重复率足够高,可以在两个图像之间实现良好的匹配。

工作原理

在第 6 章中,我们了解了可以使用高斯过滤器来估计图像的图像导数。 这些过滤器使用σ参数来定义核的孔径(大小)。 如我们所见,此σ对应于用于构造过滤器的高斯函数的方差,然后隐式定义了评估导数的标度。 实际上,具有较大σ值的过滤器可以平滑图像的精细细节。 这就是为什么我们可以说它的运行规模更大的原因。

现在,如果我们使用不同比例的高斯过滤器计算给定图像点的拉普拉斯,那么将获得不同的值。 查看不同比例因子的过滤器响应的演变,我们获得了一条曲线,该曲线最终在σ值处达到最大值。 如果我们针对以两个不同比例拍摄的同一物体的两个图像提取该最大值,则这两个σ最大值的比率将对应于拍摄图像的比例。 这一重要观察是尺度不变特征提取过程的核心。 也就是说,应将尺度不变特征检测为空间空间(图像中)和尺度空间(从不同尺度应用的导数过滤器获得)的局部最大值。

SURF 通过以下步骤来实现此想法。 首先,为了检测特征,在每个像素处计算 Hessian 矩阵。 该矩阵测量函数的局部曲率,并定义为:

How it works...

该矩阵的行列式给出了该曲率的强度。 因此,该想法是将角定义为具有高局部曲率(即,在一个以上方向上的高变化)的图像点。 由于它是由二阶导数组成的,因此可以使用不同比例σ的拉普拉斯高斯核来计算该矩阵。 然后,该 Hessian 成为三个变量的函数:H(x, y, σ)。 因此,当该 Hessian 的行列式在空间和尺度空间均达到局部最大值时(即需要执行 3x3x3 非极大值抑制),便声明了尺度不变特征。 但是,该行列式必须具有cv::SurfFeatureDetector类类的构造器中第一个参数所指定的最小值。

所有这些导数在不同尺度下的计算在计算上是昂贵的。 SURF 算法的目标是使此过程尽可能高效。 这是通过使用仅包含少量整数加法的近似高斯核来实现的。 它们具有以下结构:

How it works...

左侧的核用于估计混合的二阶导数,而右侧的核用于估计垂直方向的二阶导数。 该第二核的旋转形式估计水平方向的二阶导数。 最小的核的大小为9x9像素,对应于σ≈1.2。 逐渐增加了大小的核。 可以通过 SURF 类的其他参数指定所应用的确切过滤器数量。 默认情况下,使用 12 种不同大小的核(最大大小为99x99)。 请注意,使用积分图像的事实保证了可以通过仅使用 3 个加法来计算每个瓣内的和,而与过滤器的大小无关。

一旦确定了局部最大值,就可以通过比例尺和图像空间中的插值获得每个检测到的兴趣点的精确位置。 然后,结果是一组以亚像素精度定位的特征点,并且与之关联了比例值。

更多

SURF 算法已被开发为另一种著名的尺度不变特征检测器 SIFT(用于尺度不变特征变换)的有效变体。 SIFT 还可以将特征检测为图像和比例尺空间中的局部最大值,但使用 Laplacian 过滤器响应而不是 Hessian 行列式。 使用不同的高斯过滤器来计算不同比例的拉普拉斯算子。 OpenCV 有一个包装器类,用于检测这些特征,并且其调用方式与 SURF 特征类似:

   // vector of keypoints
   std::vector<cv::KeyPoint> keypoints;
   // Construct the SURF feature detector object
   cv::SiftFeatureDetector sift(
      0.03,  // feature threshold
      10.);  // threshold to reduce
              // sensitivity to lines
   // Detect the SURF features
   sift.detect(image,keypoints);

结果也非常相似:

There's more...

但是,由于特征点的计算基于浮点核,因此通常认为在空间和比例尺上的特征定位方面更准确。 尽管出于同样的原因,它在计算上也更加昂贵。

另见

The article SURF: Speeded Up Robust Features by H. Bay, A. Ess, T. Tuytelaars and L. Van Gool in Computer Vision and Image Understanding, vol. 110, No. 3, pp. 346--359, 2008 that describes the SURF features.

The pioneer work by D. Lowe, Distinctive Image Features from Scale Invariant Features in International Journal of Computer Vision, Vol. 60, No. 2, 2004, pp. 91-110, describing the SIFT algorithm.

描述 SURF 特征

在前面的秘籍中讨论的 SURF 算法为每个检测到的特征定义位置和比例。 该比例因子可用于定义特征点周围的窗口大小,以使定义的邻域将包含相同的视觉信息,而不管特征所属的对象已被描绘成什么比例。 另外,包含在该邻域中的视觉信息可用于表征特征点,以使其与其他特征区分开。

本秘籍将向您展示如何使用紧凑的描述符来描述特征点的邻域。 在特征匹配中,特征描述符通常是描述特征点的 N 维向量,理想情况下以不变的方式改变光照和较小的透视变形。 另外,可以使用简单的距离度量(例如,欧几里得距离)来比较好的描述符。 因此,它们构成了用于特征匹配算法的强大工具。

操作步骤

以下代码是一种类似于用于特征检测的模式。 OpenCV 2 提出了一个通用类,该通用类定义了一个公共接口,用于提取可用的各种特征点描述符。 为了遵循前面的方法,这里我们使用 SURF 算法中提出的方法。 根据从特征检测获得的cv::Keypoint实例的std::vector,获得以下描述符:

   // Construction of the SURF descriptor extractor 
   cv::SurfDescriptorExtractor surfDesc;
   // Extraction of the SURF descriptors
   cv::Mat descriptors1;
   surfDesc.compute(image1,keypoints1,descriptors1);

结果是一个矩阵(即cv::Mat实例),它将包含与关键点向量中的元素数量一样多的行。 这些行中的每行都是一个 N 维描述符向量。 对于 SURF 描述符,默认情况下,其大小为 64。此向量表示特征点周围的强度模式。 两个特征点越相似,它们的描述符向量应该越接近。

这些描述符在图像匹配中特别有用。 例如,假设要对同一场景的两个图像进行匹配。 这可以通过首先检测每个图像上的特征,然后提取这些特征的描述符来完成。 然后将第一图像中的每个特征描述符向量与第二图像中的所有特征描述符进行比较。 然后将获得最佳分数(即,两个向量之间的最小距离)的对作为该特征的最佳匹配。 对第一张图片中的所有特征重复此过程。 这是已在 OpenCV 中实现为cv::BruteForceMatcher的最基本方案。 它的用法如下:

   // Construction of the matcher 
   cv::BruteForceMatcher<cv::L2<float>> matcher;
   // Match the two image descriptors
   std::vector<cv::DMatch> matches;
   matcher.match(descriptors1,descriptors2, matches);

此类是cv::DescriptorMatcher类的子类,为不同的匹配策略定义了公共接口。 结果是cv::DMatch实例的向量,该向量是用来表示匹配对的结构。 本质上,cv::DMatch数据结构包含一个第一索引,该索引指向描述符的第一向量中的元素,以及一个第二索引,其指向描述符第二向量中的匹配特征。 它还包含一个表示两个匹配描述符之间距离的实数值。 此距离值用于比较两个cv::DMatch实例的operator<定义。

为了可视化匹配操作的结果,OpenCV 提供了一种绘制函数,该函数可以生成由两个输入图像连接而成的图像,并且在其上的匹配点由一条线链接。 在前面的秘籍中,我们为第一个图像获得了 340 个 SURF 点。 然后,暴力破解方法将产生相同数量的比赛。 在图像上绘制所有这些线会使结果不可读。 因此,我们将仅显示距离最小的 25 个匹配项。 通过使用std::nth_element可以轻松实现此目的,该工具将按排序顺序将第n个元素放置在第n个位置,而所有较小的元素都放置在该元素之前。 完成此操作后,将清除载体的剩余元素:

   std::nth_element(matches.begin(),    // initial position
       matches.begin()+24, // position of the sorted element
       matches.end());     // end position
   // remove all elements after the 25th
   matches.erase(matches.begin()+25, matches.end()); 

回想一下前面的代码是有效的,因为在cv::DMatch类中定义了operator<。 然后,可以通过以下调用将这 25 个匹配项可视化:

   cv::Mat imageMatches;
   cv::drawMatches(
     image1,keypoints1, // 1st image and its keypoints
     image2,keypoints2, // 2nd image and its keypoints
     matches,            // the matches
     imageMatches,      // the image produced
     cv::Scalar(255,255,255)); // color of the lines

产生以下图像:

How to do it...

可以看出,大多数匹配正确地将左侧的点与右侧的相应图像点链接在一起。 由于观察到的建筑物具有对称的立面,这使得某些局部匹配不明确(最上面的匹配是特征不正确的示例之一),因此人们可能会注意到一些错误。

工作原理

好的特征描述符必须对光照的微小变化,视点以及图像噪声的存在保持不变。 因此,它们通常基于局部强度差异。 SURF 描述符就是这种情况,它在关键点周围的较大邻域内应用以下简单核:

How it works...

第一个简单地测量水平方向上的局部强度差异(称为dx),第二个简单地测量垂直方向上的局部强度差异(称为dy)。 用于提取描述符向量的邻域的大小定义为特征比例因子的 20 倍(即20σ)。 然后将这个正方形区域分成4x4个较小的正方形子区域。 对于每个子区域,在5x5规则间隔的位置(核大小为)计算核响应dxdy。 将所有这些响应汇总如下,以便为每个子区域提取四个描述符值:

How it works...

由于存在4x4 = 16子区域,因此我们总共有 64 个描述符值。 注意,为了更加重视靠近关键点的相邻像素值,核响应由以关键点位置为中心的高斯加权(σ = 3.3)。

dxdy响应也用于估计特征的方向。 这些值是在半径为的圆形邻域中,以规则的σ间隔定期计算的(核尺寸为)。 对于给定的方向,将某个角度间隔(π / 3)内的响应求和,并将给出最长向量的方向定义为主导方向。

利用 SURF 特征和描述符,可以实现尺度不变的匹配。 以下示例显示了包含两张不同比例图像的匹配对中的 25 个最佳匹配:

How it works...

更多

SIFT 算法还定义了自己的描述符。 它基于在考虑的关键点的尺度上计算出的梯度大小和方向。 对于 SURF 描述符,将关键点的缩放邻域划分为4x4子区域。 对于这些区域中的每个区域,都将构建一个 8 方向梯度直方图(由其大小和以关键点为中心的全局高斯窗口加权)。 因此,描述符向量由这些直方图的条目组成。 每个直方图有4x4区域和 8 个箱子,这导致长度为 128 的描述符。

对于特征检测,SURF 和 SIFT 描述符之间的差异主要是速度和准确率。 由于 SURF 描述符主要基于强度差异,因此它们的计算速度更快。 但是,通常认为 SIFT 描述符在找到正确的匹配特征时更为准确。

另见

有关 SURF 和 SIFT 特征的更多信息,请参考前面的秘籍。

九、估计图像中的投影关系

在本章中,我们将介绍:

  • 校准相机
  • 计算图像对的基本矩阵
  • 使用随机样本共识来匹配图像
  • 计算两个图像之间的单应性

简介

通常使用数码相机生成图像,该数码相机通过将光线投射到穿过其镜头的图像传感器上来捕获场景。 通过将 3D 场景的投影投影到 2D 平面上而形成图像的事实强加了一个场景与其图像之间以及同一场景的不同图像之间的重要关系。 投影几何是用于以数学术语描述和表征图像形成过程的工具。 在本章中,您将学习多视图图像中存在的一些基本投影关系,以及如何在计算机视觉编程中使用它们。 我们还将继续在上一章的最终秘籍中发起的关于两视图特征匹配的讨论。 您将学习改善匹配结果的新策略。 但是在开始秘籍之前,让我们探索与场景投影和图像形成有关的基本概念。

图像形成

从根本上说,自摄影开始以来,用于生成图像的过程就没有改变。 摄像机通过正面光圈捕获来自观察场景的光,并且捕获的光线撞击图像平面(或图像传感器)位于相机背面。 另外,镜头用于聚集来自不同场景元素的光线。 下图说明了此过程:

Image formation

这里,do是从镜头到被观察物体的距离,di是从镜头到像平面的距离,f镜头的焦距。 这些数量与所谓的薄透镜公式相关:

Image formation

在计算机视觉中,可以通过多种方式简化此相机模型。 首先,我们可以通过考虑具有无限小光圈的相机来忽略镜头的影响,因为从理论上讲,这不会改变图像。 因此,仅考虑中央射线。 其次,由于大多数时候我们都有do >> di,所以我们可以假设图像平面位于焦距处。 最后,我们可以从系统的几何形状中注意到,平面上的图像是反转的。 通过简单地将像平面放置在镜头前,我们可以获得相同但直立的图像。 显然,这在物理上是不可行的,但是从数学角度来看,这是完全等效的。 这种简化的模型通常称为针孔照相机模型,其表示如下:

Image formation

从该模型,并使用相似三角形的定律,我们可以轻松得出基本的投影方程:

Image formation

因此,物体(高度为ho)的图像大小(hi)与其与相机的距离(do)成反比,而距离自然是真的。 该关系允许将 3D 场景点的图像的位置预测到相机的图像平面上。

校准相机

从本章的介绍中,我们了解到,在针孔模型下,相机的基本参数是其焦距和像平面的大小(定义相机的视场)。 同样,由于我们正在处理数字图像,因此图像平面上的像素数量是相机的另一个重要特征。 最后,为了能够在像素坐标中计算图像的场景点的位置,我们需要另外一条信息。 考虑到来自与图像平面正交的焦点的线,我们需要知道该线在哪个像素位置刺穿图像平面。 该点称为主要点。 逻辑上假设该主点位于图像平面的中心可能是合乎逻辑的,但实际上,这一点可能相差几个像素,具体取决于相机的制造精度。

相机校准是获取不同相机参数的过程。 显然可以使用相机制造商提供的规格,但是对于某些任务(例如 3D 重建),这些规格不够准确。 相机校准将通过向相机显示已知图案并分析获得的图像来进行。 然后,优化过程将确定解释观测结果的最佳参数值。 这是一个复杂的过程,但是由于 OpenCV 校准功能的可用性而变得容易。

操作步骤

为了校准摄像机,这个想法是向该摄像机显示一组场景点,这些场景点的 3D 位置已知。 然后,您必须确定这些点在图像上的投影位置。 显然,为了获得准确的结果,我们需要观察以下几点。 实现此目的的一种方法是拍摄具有许多已知 3D 点的场景图片。 一种更方便的方法是从一组某些 3D 点的不同视点拍摄几张图像。 这种方法比较简单,但除了计算内部摄像机参数外,还需要计算每个摄像机视图的位置,这是可行的。

OpenCV 建议使用棋盘图案来生成校准所需的 3D 场景点集。 该图案在每个正方形的角上创建点,并且由于该图案是平坦的,因此我们可以自由地假定板位于Z = 0,并且 X 和 Y 轴与网格对齐。 在这种情况下,校准过程仅包括从不同角度向摄像机显示棋盘图案。 这是校准图案图像的一个示例:

How to do it...

令人高兴的是,OpenCV 具有自动检测此棋盘图案角的函数。 您只需提供图像和所用棋盘的大小(垂直和水平内角点的数量)即可。 该函数将返回这些棋盘角在图像上的位置。 如果函数无法找到模式,则仅返回false

    // output vectors of image points
    std::vector<cv::Point2f> imageCorners;
    // number of corners on the chessboard
    cv::Size boardSize(6,4);
    // Get the chessboard corners
    bool found = cv::findChessboardCorners(image, 
                                 boardSize, imageCorners);

请注意,如果需要调整算法,则此函数接受其他参数,此处不再讨论。 还有一个函数可以用棋盘上的线依次画出检测到的角:

        //Draw the corners
        cv::drawChessboardCorners(image, 
                    boardSize, imageCorners, 
                    found); // corners have been found

在此处看到获得的图像:

How to do it...

连接这些点的线显示了在检测到的点的向量中列出这些点的顺序。 现在要校准相机,我们需要输入一组此类图像点以及相应 3D 点的坐标。 让我们将校准过程封装在CameraCalibrator类中:

class CameraCalibrator {

    // input points:
    // the points in world coordinates
    std::vector<std::vector<cv::Point3f>> objectPoints;
    // the point positions in pixels
    std::vector<std::vector<cv::Point2f>> imagePoints;
    // output Matrices
    cv::Mat cameraMatrix;
    cv::Mat distCoeffs;
   // flag to specify how calibration is done
   int flag;
   // used in image undistortion 
    cv::Mat map1,map2; 
   bool mustInitUndistort;

  public:
   CameraCalibrator() : flag(0), mustInitUndistort(true) {};

如前所述,如果我们方便地将参考框架放置在棋盘上,则可以轻松确定棋盘图案上点的 3D 坐标。 完成此操作的方法将棋盘图像文件名的向量作为输入:

// Open chessboard images and extract corner points
int CameraCalibrator::addChessboardPoints(
         const std::vector<std::string>& filelist, 
         cv::Size & boardSize) {

   // the points on the chessboard
    std::vector<cv::Point2f> imageCorners;
    std::vector<cv::Point3f> objectCorners;

   // 3D Scene Points:
   // Initialize the chessboard corners 
   // in the chessboard reference frame
   // The corners are at 3D location (X,Y,Z)= (i,j,0)
   for (int i=0; i<boardSize.height; i++) {
      for (int j=0; j<boardSize.width; j++) {

         objectCorners.push_back(cv::Point3f(i, j, 0.0f));
      }
    }

    // 2D Image points:
    cv::Mat image; // to contain chessboard image
    int successes = 0;
    // for all viewpoints
    for (int i=0; i<filelist.size(); i++) {

        // Open the image
        image = cv::imread(filelist[i],0);

        // Get the chessboard corners
        bool found = cv::findChessboardCorners(
                        image, boardSize, imageCorners);

        // Get subpixel accuracy on the corners
        cv::cornerSubPix(image, imageCorners, 
                  cv::Size(5,5), 
                  cv::Size(-1,-1), 
         cv::TermCriteria(cv::TermCriteria::MAX_ITER +
                          cv::TermCriteria::EPS, 
             30,      // max number of iterations 
             0.1));  // min accuracy

        //If we have a good board, add it to our data
        if (imageCorners.size() == boardSize.area()) {

            // Add image and scene points from one view
            addPoints(imageCorners, objectCorners);
            successes++;
        }

    }

   return successes;
}

第一个循环输入棋盘的 3D 坐标,此处以任意正方形尺寸单位指定。 相应的图像点是cv::findChessboardCorners函数提供的图像点。 这适用于所有可用的视点。 此外,为了获得更准确的图像点位置,可以使用函数cv::cornerSubPix,顾名思义,这些图像点将以亚像素精度定位。 由cv::TermCriteria对象指定的终止标准定义了最大迭代次数和子像素坐标中的最小精度。 达到这两个条件中的第一个条件将停止角点优化过程。

成功检测到一组棋盘角后,会将这些点添加到图像和场景点的向量中:

// Add scene points and corresponding image points
void CameraCalibrator::addPoints(const std::vector<cv::Point2f>& imageCorners, const std::vector<cv::Point3f>& objectCorners) {

   // 2D image points from one view
   imagePoints.push_back(imageCorners);          
   // corresponding 3D scene points
   objectPoints.push_back(objectCorners);
}

向量包含std::vector实例。 实际上,每个向量元素都是一个视图中点的向量。

一旦处理了足够数量的棋盘图像(因此有大量 3D 场景点/ 2D 图像点对应关系可用),我们就可以开始计算校准参数:

// Calibrate the camera
// returns the re-projection error
double CameraCalibrator::calibrate(cv::Size &imageSize)
{
   // undistorter must be reinitialized
   mustInitUndistort= true;

   //Output rotations and translations
    std::vector<cv::Mat> rvecs, tvecs;

   // start calibration
   return 
     calibrateCamera(objectPoints, // the 3D points
               imagePoints,  // the image points
               imageSize,    // image size
               cameraMatrix, // output camera matrix
               distCoeffs,   // output distortion matrix
               rvecs, tvecs, // Rs, Ts 
               flag);        // set options
}

实际上,十至二十个棋盘图像就足够了,但是这些图像必须是从不同角度,不同深度拍摄的。 此函数的两个重要输出是相机矩阵和失真参数。 相机矩阵将在下一节中介绍。 现在,让我们考虑一下失真参数。 到目前为止,我们已经提到了使用针孔相机模型可以忽略镜头的影响。 但这只有在用于捕获图像的镜头不会引入太严重的光学畸变的情况下才有可能。 不幸的是,低质量的镜头或焦距非常短的镜头经常出现这种情况。 您可能已经注意到,在我们用于示例的图像中,所示的棋盘图案明显失真。 矩形板的边缘在图像中弯曲。 还应注意,随着我们远离图像中心,这种失真变得更加重要。 这是使用鱼眼镜头观察到的典型失真,称为径向失真。 普通数码相机中使用的镜头不会表现出如此高的畸变程度,但是在此处使用的镜头中,这些畸变肯定不能忽略。

通过引入适当的模型可以补偿这些变形。 这个想法是用一组数学方程来表示由透镜引起的畸变。 一旦建立,这些方程式然后可以被还原以便消除图像上可见的失真。 幸运的是,可以在校准阶段与其他相机参数一起获得将校正失真的变换的确切参数。 完成此操作后,来自新校准相机的任何图像都可以保持不失真:

// remove distortion in an image (after calibration)
cv::Mat CameraCalibrator::remap(const cv::Mat &image) {

   cv::Mat undistorted;

   if (mustInitUndistort) { // called once per calibration

    cv::initUndistortRectifyMap(
      cameraMatrix,  // computed camera matrix
      distCoeffs,    // computed distortion matrix
      cv::Mat(),     // optional rectification (none) 
      cv::Mat(),     // camera matrix to generate undistorted
            image.size(),  // size of undistorted
            CV_32FC1,      // type of output map
            map1, map2);   // the x and y mapping functions

    mustInitUndistort= false;
   }

   // Apply mapping functions
   cv::remap(image, undistorted, map1, map2, 
      cv::INTER_LINEAR); // interpolation type

   return undistorted;
}

结果如下图:

How to do it...

如您所见,一旦图像变形,我们将获得一个常规的透视图。

工作原理

为了解释校准结果,我们需要回到介绍针孔相机模型的简介中的图。 更具体地说,我们想证明位置(X, Y, Z)的 3D 中的点与其在像素坐标中指定的摄像机上的图像(x, y)之间的关系。 让我们通过添加一个参考帧重绘此图,该参考帧位于投影的中心,如下所示:

How it works...

请注意,Y 轴指向下方,以获取与将图像原点放在左上角的常规约定兼容的坐标系。 先前我们了解到,点(X, Y, Z)将以(fX / Z, fY / Z)投影到图像平面上。 现在,如果要将此坐标转换为像素,则需要将 2D 图像位置分别除以像素宽度(px)和高度(py)。 我们注意到,通过将以世界单位给出的焦距f(通常是米或毫米)除以px,可以得到以(水平)像素表示的焦距。 然后让我们将此术语定义为fx。 类似地,将fy = f / py定义为以垂直像素为单位表示的焦距。 因此,完整的投影方程为:

How it works...

How it works...

回想一下(u0, v0)是添加到结果中的主要点,以便将原点移动到图像的左上角。 通过引入齐次坐标,这些方程可以矩阵形式重写,其中 2D 点由 3 个向量表示,而 3D 点由 4 个向量表示(额外坐标只是任意比例因子,从齐次 3 向量中提取 2D 坐标时需要删除)。 这是重写的射影方程式:

How it works...

第二矩阵是简单投影矩阵。 第一矩阵包括所有摄像机参数,这些参数称为摄像机的固有参数。 此3x3矩阵是cv::calibrateCamera函数返回的输出矩阵之一。 还有一个称为cv::calibrationMatrixValues的函数,可在给定校准矩阵的情况下返回固有参数的值。

更一般而言,当参考系不在相机的投影中心时,我们将需要添加旋转(3x3矩阵)和平移向量(3x1矩阵)。 这两个矩阵描述了必须应用于 3D 点的刚性变换,以便将其带回到相机参考系。 因此,我们可以用最一般的形式重写投影方程:

How it works...

请记住,在我们的校准示例中,参考框架位于棋盘上。 因此,必须为每个视图计算一个刚性变换(旋转和平移)。 这些在cv::calibrateCamera函数的输出参数列表中。 旋转和平移分量通常称为校准的外在参数,每个视图的旋转和平移分量都不同。 对于给定的相机/镜头系统,固有参数保持恒定。 通过基于 20 个棋盘图像的校准获得的测试相机的固有参数为fx = 167fy = 178u0 = 156v0 = 119。 这些结果是通过cv::calibrateCamera通过优化过程而获得的,该优化过程旨在找到使 3D 场景点的投影所计算出的预测图像点位置与实际图像点位置之间的差异最小的内在和外在参数, 如图所示。 校准期间指定的所有点的该差的总和称为重投影误差

为了纠正失真,OpenCV 使用多项式函数,将其应用于图像点,以将其移动到其未失真的位置。 默认情况下,使用 5 个系数。 也可以提供由 8 个系数组成的模型。 一旦获得了这些系数,就可以计算 2 个映射函数(一个x坐标,一个y坐标),这些映射函数将给出图像点在上的新的未失真位置。 图像失真。 这是通过函数cv::initUndistortRectifyMap计算的,函数cv::remap将输入图像的所有点重新映射到新图像。 注意,由于非线性变换,输入图像的某些像素现在落在输出图像的边界之外。 您可以扩大输出图像的大小以补偿这种像素损失,但是现在您将获得在输入​​图像中没有值的输出像素(然后它们将显示为黑色像素)。

更多

当知道相机固有参数的良好估计时,将其输入到cv::calibrateCamera函数可能会比较有利。 然后将它们用作优化过程中的初始值。 为此,您只需要添加标志CV_CALIB_USE_INTRINSIC_GUESS并将这些值输入到校准矩阵参数中即可。 还可以为主要点(CV_CALIB_FIX_PRINCIPAL_POINT)施加固定值,该值通常被假定为中心像素。 您还可以为焦距fxfyCV_CALIB_FIX_RATIO)设置固定比例,在这种情况下,您假设像素为正方形。

计算图像对的基本矩阵

先前的秘籍向您展示了如何恢复单个摄像机的投影方程。 在本秘籍中,我们将探讨在观看同一场景的两幅图像之间存在的投影关系。 这两个图像可以通过以下方式获得:在两个不同的位置移动相机以从两个视点拍摄照片,或者使用两个相机,每个相机拍摄不同的场景图像。 当这两个摄像机由刚性基准线分开时,我们使用术语立体视觉

准备

现在让我们考虑两台摄像机观察给定的场景点,如下所示:

Getting ready

我们了解到,通过跟踪将 3D 点X与相机中心连接的线,可以找到 3D 点X的图像x。 相反,我们在位置x处观察到的图像点可以位于 3D 空间中此线上的任何位置。 这意味着如果我们要在另一幅图像中找到给定图像点的对应点,则需要沿着这条线在第二个图像平面上的投影进行搜索。 该假想线称为点x对极线。 它定义了一个基本约束,必须满足两个相应的点,即,在另一个视图中,给定点的匹配必须位于该点的对极线上。 该极线的确切方向取决于两个摄像机的位置。 实际上,对极线的位置是两视图系统的几何特征。

可以从此两视图系统的几何结构得出的另一个观察结果是,所有对极线都通过同一点。 该点对应于一个摄像机中心在另一摄像机上的投影。 这个特殊点称为极点

从数学上可以看出,可以使用3x3矩阵来表示像点与其对应的对极线之间的关系,如下所示:

Getting ready

在射影几何中,一条 2D 线也由一个 3 向量表示。 它对应于满足等式l1' x' + l2' y' + l3' = 0的 2D 点集合(x', y')(上标表示这条线属于第二个图像)。 因此,称为基本矩阵的矩阵F将一个视图中的 2D 图像点映射到另一视图中的对极线。

操作步骤

可以通过求解一组方程来估计图像对的基本矩阵,该方程组涉及两个图像之间的一定数量的已知匹配点。 此类匹配的最小数量为 7。 使用上一章中的图像对,我们可以手动选择七个良好的匹配项(如以下屏幕截图所示)。 这些将通过cv::findFundementalMat OpenCV 函数用于计算基本矩阵。

How to do it...

如果我们在每个图像中都有图像点作为cv::keypoint实例,则首先需要将它们转换为cv::Point2f才能与cv::findFundementalMat一起使用。 为此,可以使用 OpenCV 函数:

   // Convert keypoints into Point2f
   std::vector<cv::Point2f> selPoints1, selPoints2;
   cv::KeyPoint::convert(keypoints1,selPoints1,pointIndexes1);
   cv::KeyPoint::convert(keypoints2,selPoints2,pointIndexes2);

两个向量selPoints1selPoints2包含两个图像中的对应点。 keypoints1keypoints2是在上一章中检测到的选定Keypoint实例。 然后,对cv::findFundementalMat函数的调用如下:

   // Compute F matrix from 7 matches
   cv::Mat fundemental= cv::findFundamentalMat(
      cv::Mat(selPoints1), // points in first image
      cv::Mat(selPoints2), // points in second image
      CV_FM_7POINT);       // 7-point method

视觉上验证基本矩阵有效性的一种方法是绘制某些选定点的对极线。 另一个 OpenCV 函数允许计算给定点集的对极线。 一旦计算出它们,就可以使用cv::line函数绘制它们。 以下代码行完成了这两个步骤(即,从左侧的点计算并绘制右侧图像中的对极线):

   // draw the left points corresponding epipolar 
   // lines in right image 
   std::vector<cv::Vec3f> lines1; 
   cv::computeCorrespondEpilines(
      cv::Mat(selPoints1), // image points 
      1,                   // in image 1 (can also be 2)
      fundemental, // F matrix
      lines1);     // vector of epipolar lines

   // for all epipolar lines
   for (vector<cv::Vec3f>::const_iterator it= lines1.begin();
       it!=lines1.end(); ++it) {

          // draw the line between first and last column
          cv::line(image2,
            cv::Point(0,-(*it)[2]/(*it)[1]),
            cv::Point(image2.cols,-((*it)[2]+
                      (*it)[0]*image2.cols)/(*it)[1]),
                      cv::Scalar(255,255,255));
   }

然后在以下屏幕截图中看到结果:

How to do it...

请记住,子极位于所有子极线的交点,并且是另一个相机中心的投影。 在前面的图像上可以看到该极点。 通常,极线在图像边界之外相交。 如果同时拍摄了两个图像,则该位置将是第一个摄像机可见的位置。 观察图像对,并花点时间说服自己这确实是有道理的。

工作原理

前面我们已经解释了,对于一个图像中的一个点,基本矩阵给出了在另一视图中应找到其对应点的直线方程。 如果点p的对应点(用均匀坐标表示)是p',并且如果F是两个视图之间的基本矩阵,那么由于p'位于对极线Fp上,我们具有:

How it works...

该方程式表示两个对应点之间的关系,被称为对极约束。 使用该方程式,可以使用已知匹配来估计矩阵的项。 由于F矩阵的条目被赋予比例因子,因此仅需要估计八个条目(第九个可以任意设置为 1)。 每次比赛都会贡献一个方程式。 因此,通过八个已知匹配,可以通过求解线性方程组的结果来完全估计矩阵。 将CV_FM_8POINT标志与cv::findFundamentalMat函数结合使用时,便会执行此操作。 注意,在这种情况下,可以(最好)输入八个以上的匹配项。 然后可以在均方意义上求解获得的线性方程组的超定系统。

为了估计基本矩阵,还可以利用附加约束。 在数学上,F矩阵将 2D 点映射到 1D 直线铅笔(即,在公共点处相交的线)。 所有这些对极线都经过此唯一点(极点)的事实对矩阵施加了约束。 该约束将估计基本矩阵所需的匹配数减少到七个。 不幸的是,在这种情况下,方程组变为非线性,最多包含三个可能的解。 可以使用CV_FM_7POINT标志在 OpenCV 中调用F矩阵估计的七匹配解决方案。 这就是我们在上一节的示例中所做的。

最后,我们应该提到,在图像中选择合适的匹配集对于获得基本矩阵的准确估计很重要。 通常,匹配项应在整个图像上很好地分布,并包括场景中不同深度的点。 否则,解决方案将变得不稳定或导致简并的配置 。

另见

The book by R. Hartley and A. Zisserman, Multiple View Geometry in Computer Vision, Cambridge University Press, 2004 is the most complete reference on projective geometry in computer vision.

下一个秘籍将显示可与 OpenCV 基本矩阵估计一起使用的附加标志。

使用随机样本共识匹配图像

当两台摄像机观察同一场景时,它们会看到相同的元素,但视点不同。 我们已经在上一章中研究了特征点匹配问题。 在本秘籍中,我们将回到这个问题,并将学习如何利用两个视图之间的对极约束来更可靠地匹配图像特征。

我们遵循的原理很简单:当我们在两个图像之间匹配特征点时,我们仅接受落在相应对极线上的那些匹配。 但是,为了能够检查这种情况,必须知道基本矩阵,并且我们需要良好的匹配才能估计此矩阵。 这似乎是鸡与蛋的问题。 我们在本秘籍中提出一种解决方案,其中将共同计算基本矩阵和一组良好的匹配项。

操作步骤

目的是能够获得两个视图之间的一组良好匹配。 因此,将使用先前秘籍中引入的对极约束来验证所有找到的特征点对应关系。 我们首先定义一个类,该类将封装将提出的解决方案的不同元素:

class RobustMatcher {

  private:

     // pointer to the feature point detector object
     cv::Ptr<cv::FeatureDetector> detector;
     // pointer to the feature descriptor extractor object
     cv::Ptr<cv::DescriptorExtractor> extractor;
     float ratio; // max ratio between 1st and 2nd NN
     bool refineF; // if true will refine the F matrix
     double distance; // min distance to epipolar
     double confidence; // confidence level (probability)

  public:

     RobustMatcher() : ratio(0.65f), refineF(true),
                       confidence(0.99), distance(3.0) {     

        // SURF is the default feature
        detector= new cv::SurfFeatureDetector();
        extractor= new cv::SurfDescriptorExtractor();
     }

注意我们如何使用通用的cv::FeatureDetectorcv::DescriptorExtractor接口,以便用户可以提供任何特定的实现。 默认情况下,此处使用SURF函数和描述符,但可以使用适当的设置器方法指定其他函数:

     // Set the feature detector
     void setFeatureDetector(
            cv::Ptr<cv::FeatureDetector>& detect) {

        detector= detect;
     }

     // Set the descriptor extractor
     void setDescriptorExtractor(
            cv::Ptr<cv::DescriptorExtractor>& desc) {

        extractor= desc;
     }

主要方法是我们的​​match方法,该方法返回匹配项,检测到的关键点和估计的基本矩阵。 现在,我们将探索该方法分五个不同的步骤(在以下代码的注释中明确指出):

     // Match feature points using symmetry test and RANSAC
     // returns fundemental matrix
     cv::Mat match(cv::Mat& image1, 
                   cv::Mat& image2, // input images
        // output matches and keypoints 
        std::vector<cv::DMatch>& matches, 
        std::vector<cv::KeyPoint>& keypoints1,   
        std::vector<cv::KeyPoint>& keypoints2) {

      // 1a. Detection of the SURF features
      detector->detect(image1,keypoints1);
      detector->detect(image2,keypoints2);

      // 1b. Extraction of the SURF descriptors
      cv::Mat descriptors1, descriptors2;
      extractor->compute(image1,keypoints1,descriptors1);
      extractor->compute(image2,keypoints2,descriptors2);

      // 2\. Match the two image descriptors

      // Construction of the matcher 
      cv::BruteForceMatcher<cv::L2<float>> matcher;

      // from image 1 to image 2
      // based on k nearest neighbours (with k=2)
      std::vector<std::vector<cv::DMatch>> matches1;
      matcher.knnMatch(descriptors1,descriptors2, 
         matches1, // vector of matches (up to 2 per entry) 
         2);        // return 2 nearest neighbours

      // from image 2 to image 1
      // based on k nearest neighbours (with k=2)
      std::vector<std::vector<cv::DMatch>> matches2;
      matcher.knnMatch(descriptors2,descriptors1, 
         matches2, // vector of matches (up to 2 per entry) 
         2);        // return 2 nearest neighbours

      // 3\. Remove matches for which NN ratio is 
      // > than threshold

      // clean image 1 -> image 2 matches
      int removed= ratioTest(matches1);
      // clean image 2 -> image 1 matches
      removed= ratioTest(matches2);

      // 4\. Remove non-symmetrical matches
       std::vector<cv::DMatch> symMatches;
      symmetryTest(matches1,matches2,symMatches);

      // 5\. Validate matches using RANSAC
      cv::Mat fundemental= ransacTest(symMatches, 
                  keypoints1, keypoints2, matches);

      // return the found fundemental matrix
      return fundemental;
   }

第一步只是检测特征点并计算其描述符。 接下来,我们像上一章一样使用cv::BruteForceMatcher类进行特征匹配。 但是,这一次,我们为每个特征找到了两个最佳匹配点(而不仅仅是我们在前面的秘籍中所做的最佳匹配点)。 这是通过cv::BruteForceMatcher::knnMatch方法(k = 2)完成的。 此外,我们在两个方向上执行此匹配,即,对于第一张图像中的每个点,我们在第二张图像中找到两个最佳匹配,然后对第二张图像的特征点执行相同的操作,在第一张图片中找到它们的两个最佳匹配。

因此,对于每个特征点,我们在另一个视图中都有两个候选匹配。 根据描述符之间的距离,这是最好的两个。 如果此测量距离对于最佳匹配而言非常低,而对于第二最佳匹配而言则更大,则我们可以放心地将第一匹配视为好匹配,因为它无疑是最佳选择。 相反,如果两个最佳匹配的距离相对较近,则选择一个或另一个可能会出错。 在这种情况下,我们应该拒绝两个匹配项。 在这里,我们通过验证最佳匹配的距离与第二最佳匹配的距离之比不大于给定阈值来执行此测试:

     // Clear matches for which NN ratio is > than threshold
     // return the number of removed points 
     // (corresponding entries being cleared, 
     // i.e. size will be 0)
     int ratioTest(std::vector<std::vector<cv::DMatch>>
                                                &matches) {

      int removed=0;

        // for all matches
      for (std::vector<std::vector<cv::DMatch>>::iterator 
              matchIterator= matches.begin();
           matchIterator!= matches.end(); ++matchIterator) {

             // if 2 NN has been identified
             if (matchIterator->size() > 1) {

                // check distance ratio
                if ((*matchIterator)[0].distance/
                    (*matchIterator)[1].distance > ratio) {

                   matchIterator->clear(); // remove match
                   removed++;
                }

             } else { // does not have 2 neighbours

                matchIterator->clear(); // remove match
                removed++;
             }
      }

      return removed;
     }

从以下示例可以看出,此过程将消除大量不明确的匹配项。 在这里,以SURF阈值(=10)为低阈值,我们最初检测到 1,600 个特征点(黑色圆圈),其中只有 55 个在比例测试(白色圆圈)中幸存下来:

How to do it...

链接匹配点的白线表明,即使我们有大量好的匹配项,也仍然存在大量错误的匹配项。 因此,将执行第二次测试以过滤我们更多的错误匹配。 请注意,比率测试也适用于第二个匹配集。

现在,我们有两个相对较好的匹配集,一个从第一张图片到第二张图片,另一个从第二张图片到第一张图片。 现在,从这些集合中提取与这两个集合一致的匹配项。 这是对称匹秘籍案提出,要接受一个匹配对,两个点必须是另一个的最佳匹配特征:

     // Insert symmetrical matches in symMatches vector
     void symmetryTest(
        const std::vector<std::vector<cv::DMatch>>& matches1,
        const std::vector<std::vector<cv::DMatch>>& matches2,
        std::vector<cv::DMatch>& symMatches) {

      // for all matches image 1 -> image 2
      for (std::vector<std::vector<cv::DMatch>>::
              const_iterator matchIterator1= matches1.begin();
          matchIterator1!= matches1.end(); ++matchIterator1) {

         // ignore deleted matches
         if (matchIterator1->size() < 2) 
            continue;

         // for all matches image 2 -> image 1
         for (std::vector<std::vector<cv::DMatch>>::
           const_iterator matchIterator2= matches2.begin();
            matchIterator2!= matches2.end(); 
            ++matchIterator2) {

            // ignore deleted matches
            if (matchIterator2->size() < 2) 
               continue;

            // Match symmetry test
            if ((*matchIterator1)[0].queryIdx == 
                (*matchIterator2)[0].trainIdx  && 
                (*matchIterator2)[0].queryIdx == 
                (*matchIterator1)[0].trainIdx) {

                // add symmetrical match
                  symMatches.push_back(
                    cv::DMatch((*matchIterator1)[0].queryIdx,        
                              (*matchIterator1)[0].trainIdx,
                              (*matchIterator1)[0].distance));
                  break; // next match in image 1 -> image 2
            }
         }
      }
     }

在我们的测试对中,有 31 个匹配项在此对称测试中幸存下来。 现在,最后一个测试包括一个附加的过滤测试,这次将使用基本矩阵以拒绝不遵循对极约束的匹配项。 此测试基于RANSAC方法,即使在匹配集中仍然存在异常值,该方法也可以计算基本矩阵(此方法将在以下部分中进行说明):

     // Identify good matches using RANSAC
     // Return fundemental matrix
     cv::Mat ransacTest(
         const std::vector<cv::DMatch>& matches,
         const std::vector<cv::KeyPoint>& keypoints1, 
         const std::vector<cv::KeyPoint>& keypoints2,
         std::vector<cv::DMatch>& outMatches) {

      // Convert keypoints into Point2f   
      std::vector<cv::Point2f> points1, points2;   
      for (std::vector<cv::DMatch>::
            const_iterator it= matches.begin();
          it!= matches.end(); ++it) {

          // Get the position of left keypoints
          float x= keypoints1[it->queryIdx].pt.x;
          float y= keypoints1[it->queryIdx].pt.y;
          points1.push_back(cv::Point2f(x,y));
          // Get the position of right keypoints
          x= keypoints2[it->trainIdx].pt.x;
          y= keypoints2[it->trainIdx].pt.y;
          points2.push_back(cv::Point2f(x,y));
       }

      // Compute F matrix using RANSAC
      std::vector<uchar> inliers(points1.size(),0);
      cv::Mat fundemental= cv::findFundamentalMat(
         cv::Mat(points1),cv::Mat(points2), // matching points
          inliers,      // match status (inlier or outlier)  
          CV_FM_RANSAC, // RANSAC method
          distance,     // distance to epipolar line
          confidence);  // confidence probability

      // extract the surviving (inliers) matches
      std::vector<uchar>::const_iterator 
                        itIn= inliers.begin();
      std::vector<cv::DMatch>::const_iterator 
                        itM= matches.begin();
      // for all matches
      for ( ;itIn!= inliers.end(); ++itIn, ++itM) {

         if (*itIn) { // it is a valid match

            outMatches.push_back(*itM);
         }
      }

      if (refineF) {
      // The F matrix will be recomputed with 
      // all accepted matches

         // Convert keypoints into Point2f 
         // for final F computation   
         points1.clear();
         points2.clear();

         for (std::vector<cv::DMatch>::
                const_iterator it= outMatches.begin();
             it!= outMatches.end(); ++it) {

             // Get the position of left keypoints

             float x= keypoints1[it->queryIdx].pt.x;
             float y= keypoints1[it->queryIdx].pt.y;
             points1.push_back(cv::Point2f(x,y));
             // Get the position of right keypoints
             x= keypoints2[it->trainIdx].pt.x;
             y= keypoints2[it->trainIdx].pt.y;
             points2.push_back(cv::Point2f(x,y));
         }

         // Compute 8-point F from all accepted matches
         fundemental= cv::findFundamentalMat(
            cv::Mat(points1),cv::Mat(points2), // matches
            CV_FM_8POINT); // 8-point method
      }

      return fundemental;
     }

该代码有点长,因为在F矩阵计算之前需要将关键点转换为cv::Point2f

通过以下调用来启动使用我们的RobustMatcher类别的完整匹配过程:

   // Prepare the matcher
   RobustMatcher rmatcher;
   rmatcher.setConfidenceLevel(0.98);
   rmatcher.setMinDistanceToEpipolar(1.0);
   rmatcher.setRatio(0.65f);
   cv::Ptr<cv::FeatureDetector> pfd= 
          new cv::SurfFeatureDetector(10); 
   rmatcher.setFeatureDetector(pfd);

   // Match the two images
   std::vector<cv::DMatch> matches;
   std::vector<cv::KeyPoint> keypoints1, keypoints2;
   cv::Mat fundemental= rmatcher.match(image1,image2,
                         matches, keypoints1, keypoints2);

结果是 23 个匹配项,其对应的对极线显示在以下屏幕截图中:

How to do it...

How to do it...

工作原理

在前面的秘籍中,我们了解到可以从多个特征点匹配中估计与图像对关联的基本矩阵。 显然,确切地说,此匹配集必须仅由良好匹配组成。 然而,在实际环境中,不可能保证通过比较检测到的特征点的描述符而获得的匹配集将是完全准确的。 这就是为什么介绍了一种基于 RANSAC随机采样共识)策略的基本矩阵估计方法的原因。

RANSAC 算法旨在从可能包含多个异常值的数据集中估计给定的数学实体。 想法是从集合中随机选择一些数据点,然后仅使用这些数据点进行估计。 选择的点数应该是估计数学实体所需的最小点数。 在基本矩阵的情况下,该最小数目为 8 个匹配对(实际上,可以是 7 个匹配,但是 8 点线性算法的计算速度更快)。 一旦从这些随机的 8 个匹配项中估计出了基本矩阵,就将对照该集合中得出的对极约束,测试匹配集中所有其他匹配项。 识别出满足此约束的所有匹配项(即,对应特征与其对极线相距不远的匹配项)。 这些匹配形成计算出的基本矩阵的支持集

RANSAC 算法背后的中心思想是,支持集越大,计算出的矩阵就是正确矩阵的可能性就越高。 显然,如果一个(或多个)随机选择的匹配项是错误的匹配项,则计算出的基本矩阵也将是错误的,并且其支持集会很小。 重复此过程多次,最后,将保留最大支持的矩阵为最可能的矩阵。

因此,我们的目标是几次选择八场随机比赛,以便最终选择八场好的比赛,这应该为我们提供大量支持。 根据整个数据集中错误匹配的次数,选择一组八个正确匹配的概率将有所不同。 但是,我们知道,选择的次数越多,在这些选择中至少有一个好的匹配项的置信度就越高。 更准确地说,如果我们假设匹配集由 n% 个正常值(良好匹配)组成,那么我们选择 8 个良好匹配的概率为8n。 因此,选择包含至少一个错误匹配的概率为1-n8。 如果我们进行k个选择,则拥有一个仅包含良好匹配项的随机集的概率为1 - (1 - 8n) k。 这就是置信概率c,我们希望该概率尽可能高,因为我们需要至少一组良好的匹配项才能获得正确的基本矩阵。 因此,在运行 RANSAC 算法时,需要确定为了获得给定置信度而需要进行的选择数k

在 Ransacs 中使用cv::findFundamentalMat函数时,会提供两个额外的参数。 第一个是置信度,它确定要进行的迭代次数。 第二个是到一个极点到极线的最大距离。 点与它的极线之间的距离大于指定极对的距离的所有匹配对将被报告为异常值。 因此,该函数还返回char值的std::vector值,指示相应的匹配已被识别为异常值(0)或异常值(1)。

初始匹配集中的匹配越好,RANSAC 为您提供正确的基本矩阵的可能性就越高。 这就是为什么我们在调用cv::findFundamentalMat函数之前对匹配集应用了多个过滤器的原因。 显然,您可以决定跳过本秘籍中建议的一个或另一个步骤。 这只是在计算复杂性,最终匹配数以及所需的置信度之间取得平衡的问题,即所获得的匹配集将仅包含精确匹配。

计算两个图像之间的单应性

本章的第二个秘籍向您展示了如何从一组匹配项中计算图像对的基本矩阵。 存在可以根据匹配对计算的另一个数学实体:单应性。 像基本矩阵一样,单应性是具有特殊属性的3x3矩阵,正如我们在本秘籍中将看到的,它适用于特定情况下的两视图图像。

准备

让我们再次考虑在本章第一个方法中介绍的 3D 点及其在相机上的图像之间的投影关系。 基本上,我们了解到此关系由3x4矩阵表示。 现在,如果我们考虑一个场景的两个视图被纯旋转分开的特殊情况,那么可以观察到,外部矩阵的第四列将全部为 0(即平移为空)。 结果,在这种特殊情况下的投影关系变成3x3矩阵。 此矩阵称为单应性,它表示在特殊情况下(此处为纯旋转),一个视图中某个点的图像与另一视图中同一点的图像有关。 通过线性关系:

Getting ready

在齐次坐标中,该关系保持在此处由标量值s表示的比例因子上。 一旦估计了此矩阵,就可以使用该关系将一个视图中的所有点转移到第二个视图中。 注意,由于纯旋转的单应性关系的副作用,在这种情况下基本矩阵变得不确定。

操作步骤

假设我们有两个图像,它们被纯旋转分开。 这两个图像可以使用我们的RobustMatcher类进行匹配,除了我们跳过 RANSAC 验证步骤(在match方法中标识为步骤 5)之外,因为该步骤涉及基本矩阵估计。 相反,我们将应用 RANSAC 步骤,该步骤将包括基于匹配集(显然包含大量异常值)的单应性估计。 这是通过使用cv::findHomography函数与cv::findFundementalMat函数非常相似来完成的:

   // Find the homography between image 1 and image 2
   std::vector<uchar> inliers(points1.size(),0);
   cv::Mat homography= cv::findHomography(
      cv::Mat(points1), // corresponding 
      cv::Mat(points2), // points
      inliers,      // outputted inliers matches 
      CV_RANSAC,   // RANSAC method
      1.);         // max distance to reprojection point

回想一下,仅当两个图像被纯旋转分开时,单应性才会存在,以下两个图像就是这种情况:

How to do it...

How to do it...

通过以下循环在这些图像上绘制了符合找到的单应性的所得内线:

   // Draw the inlier points
   std::vector<cv::Point2f>::const_iterator itPts=  
                                            points1.begin();
   std::vector<uchar>::const_iterator itIn= inliers.begin();
   while (itPts!=points1.end()) {

      // draw a circle at each inlier location
      if (*itIn) 
          cv::circle(image1,*itPts,3,
                    cv::Scalar(255,255,255),2);
      ++itPts;
      ++itIn;
   }

   itPts= points2.begin();
   itIn= inliers.begin();
   while (itPts!=points2.end()) {

      // draw a circle at each inlier location
      if (*itIn) 
         cv::circle(image2,*itPts,3,
                    cv::Scalar(255,255,255),2);
      ++itPts;
      ++itIn;
   }

如上一节所述,一旦计算了单应性,就可以将图像点从一个图像转移到另一个图像。 实际上,您可以对图像的所有像素执行此操作,结果将是将该图像转换为另一个视图。 有一个 OpenCV 函数可以完全做到这一点:

   // Warp image 1 to image 2
   cv::Mat result;
   cv::warpPerspective(image1, // input image
      result,         // output image
      homography,      // homography
      cv::Size(2*image1.cols,
                 image1.rows)); // size of output image

一旦获得了这个新图像,就可以将其附加到另一个图像上以扩展视图(因为两个图像现在是从同一角度来看):

   // Copy image 1 on the first half of full image
   cv::Mat half(result,cv::Rect(0,0,image2.cols,image2.rows));
   image2.copyTo(half); // copy image2 to image1 roi

结果如下图:

How to do it...

工作原理

当通过单应性图将两个视图关联时,可以确定在一个图像上找到给定场景点的另一图像上的位置。 对于超出图像边界的点,此属性特别有趣。 实际上,由于第二个视图显示的场景的一部分在第一幅图像中不可见,因此可以使用单应性以通过在另一幅图像中读取其他像素的色值来扩展图像。 这就是我们能够创建新图像的方式,该图像是我们第二幅图像的扩展,其中在右侧添加了额外的列。

cv::findHomography计算的单应性是将第一图像中的点映射到第二图像中的点的单应性。 实际上,为了将图像 1 的点转移到图像 2 所需要的是逆单应性。 这正是函数cv::warpPerspective在默认情况下所做的,也就是说,它使用提供的单应性的倒数作为输入来获取输出图像每个点的颜色值。 当输出像素转移到输入图像外部的点时,黑色值(0)会简单地分配给该像素。 请注意,如果要在像素传输过程中使用直接单应性而不是反向单应性,则可以将可选标志cv::WARP_INVERSE_MAP指定为cv::warpPerspective中的可选第五个参数。

更多

平面的两个视图之间也存在单应性。 可以像在纯旋转情况下一样,通过再次查看相机投影方程式来证明这一点。 观察平面时,我们可以不失一般性地设置平面的参考框架,以使其所有点的Z坐标等于 0。这还将取消其中一列3x4投影矩阵的结果,得出3x3矩阵:单应性。 这意味着,例如,如果您从建筑物的平面立面的不同角度来看有几张图片,则可以计算这些图像之间的单应性,并通过将图像包裹起来并将它们组装在一起来构建立面的大型马赛克,我们在这个秘籍中实现了它。

计算单应性至少需要两个视图之间的四个匹配点。 函数cv::getPerspectiveTransform允许从四个对应点进行这种转换。

十、处理视频序列

在本章中,我们将介绍:

  • 读取视频序列
  • 处理视频帧
  • 编写视频序列
  • 跟踪视频中的特征点
  • 提取视频中的前景对象

简介

视频信号构成了丰富的视觉信息源。 它们由一系列图像组成,这些图像称为,以固定的时间间隔(指定为帧频)拍摄,并显示运动场景。 随着功能强大的计算机的出现,现在可以对视频序列进行高级视觉分析,并且有时以接近或什至比实际视频帧速率更快的速率进行。 本章将向您展示如何读取,处理和存储视频序列。

我们将看到,一旦提取了视频序列的各个帧,就可以将本书中介绍的不同图像处理功能应用于它们中的每个。 此外,我们还将研究一些算法,这些算法对视频序列进行时间分析,比较相邻帧以跟踪对象,或者随着时间的推移累积图像统计信息以提取前景对象。

读取视频序列

要处理视频序列,我们需要能够读取其每个帧。 OpenCV 建立了一个易于使用的框架,可以从视频文件甚至从 USB 摄像机执行帧提取。 此秘籍向您展示如何使用它。

操作步骤

基本上,要读取视频序列的帧,您要做的就是创建cv::VideoCapture类的实例。 然后,您创建一个循环,该循环将提取并读取每个视频帧。 这是一个基本的main函数,可以简单地显示视频序列的帧:

int main()
{
   // Open the video file
   cv::VideoCapture capture("../bike.avi");
   // check if video successfully opened
   if (!capture.isOpened())
      return 1;

   // Get the frame rate
   double rate= capture.get(CV_CAP_PROP_FPS);

   bool stop(false);
   cv::Mat frame; // current video frame
   cv::namedWindow("Extracted Frame");

   // Delay between each frame in ms
   // corresponds to video frame rate
   int delay= 1000/rate;

   // for all frames in video
   while (!stop) {

      // read next frame if any
      if (!capture.read(frame))
         break;

      cv::imshow("Extracted Frame",frame);

      // introduce a delay
      // or press key to stop
      if (cv::waitKey(delay)>=0)
            stop= true;
   }

   // Close the video file.
   // Not required since called by destructor
   capture.release();
 }

将显示一个窗口,视频将在该窗口上播放,如下所示:

How to do it...

工作原理

要打开视频,您只需要指定视频文件名。 这可以通过在cv::VideoCapture对象的构造器中提供文件名来完成。 如果已经创建了cv::VideoCapture,也可以使用open方法。 视频成功打开后(可以通过isOpened方法验证),就可以开始提取帧了。 通过使用带有适当标志的get方法,也可以在cv::VideoCapture对象中查询与视频文件关联的信息。 在前面的示例中,我们使用CV_CAP_PROP_FPS标志获得了帧速率。 由于它是泛型函数,因此即使在某些情况下会期望使用其他类型,它也总是返回double。 例如,将获得视频文件中的帧总数(作为整数),如下所示:

long t= static_cast<long>(
              capture.get(CV_CAP_PROP_FRAME_COUNT));

查看 OpenCV 文档中可用的不同标志,以了解可以从视频中获取哪些信息。

还有set方法,允许您向cv::VideoCapture实例实例输入一些参数。 例如,可以使用CV_CAP_PROP_POS_FRAMES请求移至特定帧:

// goto frame 100
double position= 100.0; 
capture.set(CV_CAP_PROP_POS_FRAMES, position);

您还可以使用CV_CAP_PROP_POS_MSEC指定位置(以毫秒为单位),或使用CV_CAP_PROP_POS_AVI_RATIO指定视频内部的相对位置(其中 0.0 对应于视频的开头,而 1.0 对应于视频的结尾)。 如果请求的参数设置成功,则该方法返回true。 请注意,获取或设置特定视频参数的可能性在很大程度上取决于用于压缩和存储视频序列的编解码器。 如果您无法使用某些参数,则可能仅仅是由于您使用的是特定的编解码器。

一旦成功打开捕获的视频(通过isOpened方法验证),就可以像上一节中的示例一样,通过重复调用read方法依次获取帧。 可以等效地调用重载的读取运算符:

capture >> frame;

也可以调用两个基本方法:

capture.grab();
capture.retrieve(frame);

还请注意,在我们的示例中,如何在显示每一帧时引入延迟。 使用cv::waitKey函数完成此操作。 在这里,我们将延迟设置为与输入视频帧速率相对应的值(如果fps是每秒的帧数,则 1000/fps 是两帧之间的延迟,以 ms 为单位)。 您显然可以更改此值以较慢或较快的速度显示视频。 但是,如果要显示视频帧,则要确保窗口有足够的刷新时间,插入这样的延迟很重要(由于这是低优先级的过程,因此,如果 CPU 太忙)。 cv::waitKey函数还允许我们通过按任意键来中断读取过程。 在这种情况下,函数将返回所按下键的 ASCII 码。 请注意,如果指定给cv::waitKey函数的延迟为 0,则它​​将无限期地等待用户按下某个键。 当某人想通过逐帧检查结果来跟踪过程时,此函数非常有用。

最后一条语句调用release方法,该方法将关闭视频文件。 但是,由于release也由cv::VideoCapture析构器调用,因此不需要此调用。

重要的是要注意,为了打开指定的视频文件,您的计算机必须安装相应的编解码器,否则cv::VideoCapture将无法理解输入文件。 通常,如果您能够使用计算机上的视频播放器(例如 Windows Media Player)打开视频文件,则 OpenCV 也应该能够读取此文件。

更多

您还可以读取连接到计算机的相机(例如 USB 相机)的视频流捕获。 在这种情况下,您只需为打开函数指定一个 ID 号(一个整数)而不是一个文件名即可。 为 ID 指定 0 将打开已安装的默认摄像机。 在这种情况下, cv::waitKey函数停止处理的作用变得至关重要,因为将无限次读取来自摄像机的视频流。

另见

本章中的秘籍“编写视频序列”具有有关视频编解码器的更多信息。

ffmpeg.org网站提供了一个完整的开源和跨平台解决方案,用于音频/视频读取,记录,转换和流传输。 用于操纵视频文件的 OpenCV 类是在此库之上构建的。

Xvid.org网站提供了一个基于 MPEG-4 标准的开源视频编解码器库,用于视频压缩。 Xvid 还有一个竞争对手 DivX ,它提供专有但免费的编解码器和软件工具。

处理视频帧

在本秘籍中,我们的目标是对视频序列的每个帧应用某种处理函数。 为此,我们将 OpenCV 视频捕获框架封装到我们自己的类中。 除其他事项外,此类可让我们指定每次提取新帧时都会调用的函数。

操作步骤

我们想要的是能够指定在视频序列的每个帧处调用的处理函数(回调函数)。 可以将此函数定义为接收cv::Mat实例并输出已处理的帧。 因此,我们将其设计为具有以下签名:

void processFrame(cv::Mat& img, cv::Mat& out);

作为此类处理函数的示例,请考虑以下用于计算输入图像的 Canny 边缘的简单函数:

void canny(cv::Mat& img, cv::Mat& out) {

   // Convert to gray
   if (img.channels()==3)
      cv::cvtColor(img,out,CV_BGR2GRAY);
   // Compute Canny edges
   cv::Canny(out,out,100,200);
   // Invert the image
   cv::threshold(out,out,128,255,cv::THRESH_BINARY_INV);
}

然后,让我们定义可以与回调函数相关联的视频处理类。 使用此类,过程将是创建一个类实例,指定一个输入视频文件,将回调函数附加到它,然后启动该过程。 以编程方式,这些步骤将使用我们建议的类来完成,如下所示:

   // Create instance
   VideoProcessor processor;
   // Open video file
   processor.setInput("../bike.avi");
   // Declare a window to display the video
   processor.displayInput("Current Frame");
   processor.displayOutput("Output Frame");
   // Play the video at the original frame rate
   processor.setDelay(1000./processor.getFrameRate());
   // Set the frame processor callback function
   processor.setFrameProcessor(canny);
   // Start the process
   processor.run();

既然我们已经定义了如何使用此类,那么让我们描述一下它的实现。 正如人们可能期望的那样,该类包含几个成员变量,这些成员变量控制视频帧处理的不同方面:

class VideoProcessor {

  private:

     // the OpenCV video capture object
     cv::VideoCapture capture;
     // the callback function to be called 
     // for the processing of each frame
     void (*process)(cv::Mat&, cv::Mat&);
     // a bool to determine if the 
     // process callback will be called
     bool callIt;
     // Input display window name
     std::string windowNameInput;
     // Output display window name
     std::string windowNameOutput;
     // delay between each frame processing
     int delay;
     // number of processed frames 
     long fnumber;
     // stop at this frame number
     long frameToStop;
     // to stop the processing
     bool stop;

  public:

     VideoProcessor() : callIt(true), delay(0), 
             fnumber(0), stop(false), frameToStop(-1) {}

第一个成员变量是cv::VideoCapture对象,第二个成员变量是process函数指针,它将指向回调函数。 可以使用相应的设置器方法指定:

     // set the callback function that 
     // will be called for each frame
     void setFrameProcessor(
         void (*frameProcessingCallback)
                        (cv::Mat&, cv::Mat&)) {

        process= frameProcessingCallback;
     }

并且以下方法是打开视频文件:

     // set the name of the video file
     bool setInput(std::string filename) {

      fnumber= 0;
      // In case a resource was already 
      // associated with the VideoCapture instance
      capture.release();
      images.clear();

      // Open the video file
      return capture.open(filename);
     }

在处理帧时显示它们通常很有趣。 因此,使用两种方法来创建显示窗口:

     // to display the processed frames
     void displayInput(std::string wn) {

        windowNameInput= wn;
        cv::namedWindow(windowNameInput);
     }

     // to display the processed frames
     void displayOutput(std::string wn) {

        windowNameOutput= wn;
        cv::namedWindow(windowNameOutput);
     }

     // do not display the processed frames
     void dontDisplay() {

        cv::destroyWindow(windowNameInput);
        cv::destroyWindow(windowNameOutput);
        windowNameInput.clear();
        windowNameOutput.clear();
     }

如果未调用这两种方法中的任何一种,则将不会显示相应的帧。 主要方法称为run,它是一种包含帧提取循环的方法:

     // to grab (and process) the frames of the sequence
     void run() {

        // current frame
        cv::Mat frame;
        // output frame
        cv::Mat output;

        // if no capture device has been set
        if (!isOpened())
           return;

        stop= false;

        while (!isStopped()) {

           // read next frame if any
           if (!readNextFrame(frame))
              break;

           // display input frame
           if (windowNameInput.length()!=0) 
              cv::imshow(windowNameInput,frame);

            // calling the process function
           if (callIt) {

            // process the frame
            process(frame, output);        
            // increment frame number
             fnumber++;

           } else {

            output= frame;
           }

           // display output frame
           if (windowNameOutput.length()!=0) 
              cv::imshow(windowNameOutput,output);

           // introduce a delay
           if (delay>=0 && cv::waitKey(delay)>=0)
            stopIt();

           // check if we should stop
           if (frameToStop>=0 && 
                 getFrameNumber()==frameToStop)
              stopIt();
        }
     }

     // Stop the processing
     void stopIt() {

        stop= true;
     }

     // Is the process stopped?
     bool isStopped() {

        return stop;
     }

     // Is a capture device opened?
     bool isOpened() {

        capture.isOpened();
     }

     // set a delay between each frame
     // 0 means wait at each frame
     // negative means no delay
     void setDelay(int d) {

        delay= d;
     }

此方法使用读取帧的私有方法:

     // to get the next frame 
     // could be: video file or camera
     bool readNextFrame(cv::Mat& frame) {

           return capture.read(frame);
     }

人们可能还希望简单地打开并播放视频文件(而无需调用回调函数)。 因此,我们有两种方法可以指定是否要调用回调函数:

     // process callback to be called
     void callProcess() {

        callIt= true;
     }

     // do not call process callback
     void dontCallProcess() {

        callIt= false;
     }

最后,该类还提供了在特定帧号处停止的可能性:

     void stopAtFrameNo(long frame) {

        frameToStop= frame;
     }

     // return the frame number of the next frame
     long getFrameNumber() {

         // get info of from the capture device
           long fnumber= static_cast<long>(
                   capture.get(CV_CAP_PROP_POS_FRAMES));
          return fnumber; 
     }

如果使用此类运行本节开头介绍的代码段,则两个窗口将以原始帧速率(由setDelay方法引入的延迟的后果)播放输入视频和输出结果。 ),如以下两个示例所示。 这是输入视频的一帧:

How to do it...

相应的输出帧如下:

How to do it...

工作原理

正如我们在其他秘籍中所做的那样,我们的目标是创建一个封装视频处理算法的通用功能的类。 在此类中,视频捕获循环是通过run方法实现的。 它包含帧提取循环,该循环首先调用cv::VideoCapture OpenCV 类的read方法。 执行了一系列操作,但是在调用每个操作之前,将进行检查以确定是否已请求该操作。 仅当指定了输入窗口名称(使用displayInput方法)时,才会显示输入窗口。 仅当已指定回调函数(使用setFrameProcessor)时,才调用该回调函数。 仅当定义了输出窗口名称(使用displayOutput )时,才会显示输出窗口。 仅当指定了延迟时才引入延迟(使用setDelay方法)。 最后,检查当前帧号是否已定义停止帧(使用stopAtFrameNo )。

该类还包含许多获取器和设置器方法,它们基本上只是cv::VideoCapture框架的常规setget方法的包装。

更多

我们的VideoProcessor类用于促进视频处理模块的部署。 几乎没有其他改进。

处理图像序列

有时,输入序列由一系列分别存储在不同文件中的图像组成。 我们的班级可以很容易地修改以适应这种输入。 您只需要添加一个成员变量,该变量将包含图像文件名的向量及其相应的迭代器:

     // vector of image filename to be used as input
     std::vector<std::string> images; 
     // image vector iterator
     std::vector<std::string>::const_iterator itImg;

新的setInput方法用于指定要读取的文件名:

     // set the vector of input images
     bool setInput(const std::vector<std::string>& imgs) {

      fnumber= 0;
      // In case a resource was already 
      // associated with the VideoCapture instance
      capture.release();

      // the input will be this vector of images
      images= imgs;
      itImg= images.begin();

      return true;
     }

isOpened方法变为:

     // Is a capture device opened?
     bool isOpened() {

        return capture.isOpened() || !images.empty();
     }

最后一个需要修改的方法是专用readNextFrame方法,该方法将从视频或文件名的向量中读取,具体取决于已指定的输入。 测试是如果图像文件名的向量不为空,那是因为输入是图像序列。 调用带有视频文件名的setInput会清除此引导程序:

     // to get the next frame 
     // could be: video file; camera; vector of images
     bool readNextFrame(cv::Mat& frame) {

        if (images.size()==0)
           return capture.read(frame);

        else {

           if (itImg != images.end()) {

              frame= cv::imread(*itImg);
              itImg++;
              return frame.data != 0;

           } else {

              return false;
           }
        }
     }

使用帧处理器类

在面向对象的上下文中,使用框架处理类代替框架处理函数可能更有意义。 实际上,一个类将使程序员在定义视频处理算法时具有更大的灵活性。 因此,我们可以定义一个接口,希望在VideoProcessor内部使用的任何类都需要实现:

// The frame processor interface
class FrameProcessor {

  public:
   // processing method
   virtual void process(cv:: Mat &input, cv:: Mat &output)= 0;
};

设置器方法允许您输入指向VideoProcessor框架的FrameProcessor实例的指针:

     // set the instance of the class that 
     // implements the FrameProcessor interface
     void setFrameProcessor(FrameProcessor* frameProcessorPtr)
     {

        // invalidate callback function
        process= 0;
        // this is the frame processor instance 
        // that will be called
        frameProcessor= frameProcessorPtr;
        callProcess();
     }

指定帧处理器类实例后,它将使之前可能已经设置的任何帧处理函数失效。 现在,如果指定了帧处理函数,则同样适用:

     // set the callback function that will 
     // be called for each frame
     void setFrameProcessor(
        void (*frameProcessingCallback)(cv::Mat&, cv::Mat&)) {

        // invalidate frame processor class instance
        frameProcessor= 0;
        // this is the frame processor function that 
        // will be called
        process= frameProcessingCallback;
        callProcess();
     }

并修改了run方法的while循环以考虑到此修改:

        while (!isStopped()) {

           // read next frame if any
           if (!readNextFrame(frame))
              break;

           // display input frame
           if (windowNameInput.length()!=0) 
              cv::imshow(windowNameInput,frame);

 // ** calling the process function or method **
 if (callIt) {

 // process the frame
 if (process) // if call back function
 process(frame, output);
 else if (frameProcessor) 
 // if class interface instance
 frameProcessor->process(frame,output);
 // increment frame number
 fnumber++;

 } else {

 output= frame;
 }

           // display output frame
           if (windowNameOutput.length()!=0) 
              cv::imshow(windowNameOutput,output);

           // introduce a delay
           if (delay>=0 && cv::waitKey(delay)>=0)
            stopIt();

           // check if we should stop
           if (frameToStop>=0 && 
                getFrameNumber()==frameToStop)
              stopIt();
        }

另见

本章视频中的秘籍“跟踪特征点”说明了如何使用FrameProcessor类接口。

编写视频序列

在先前的秘籍中,我们学习了如何读取视频文件并提取其帧。 此秘籍将向您展示如何编写帧并因此创建视频文件。 这将使我们能够完成典型的视频处理链:读取输入的视频流,处理其帧,然后将结果存储在视频文件中。

操作步骤

让我们扩展VideoProcessor类,以使其具有写入视频文件的能力。 这是使用 OpenCV cv::VideoWriter类完成的。 因此,它的一个实例被添加为我们的类的成员(加上其他一些成员变量):

class VideoProcessor {

  private:

...
     // the OpenCV video writer object
     cv::VideoWriter writer;
     // output filename
     std::string outputFile;
     // current index for output images
     int currentIndex;
     // number of digits in output image filename
     int digits;
     // extension of output images
     std::string extension;

一种额外的方法用于指定(并打开)输出视频文件:

     // set the output video file
     // by default the same parameters than 
     // input video will be used
     bool setOutput(const std::string &filename, 
                    int codec=0, double framerate=0.0, 
                    bool isColor=true) {

        outputFile= filename;
        extension.clear();

        if (framerate==0.0) 
           framerate= getFrameRate(); // same as input

        char c[4];
        // use same codec as input
        if (codec==0) { 
           codec= getCodec(c);
        }

        // Open output video
        return writer.open(outputFile, // filename
           codec,          // codec to be used 
           framerate,      // frame rate of the video
           getFrameSize(), // frame size
           isColor);       // color video?
     }

打开视频文件后,可以通过重复调用cv::VideoWriter类的write方法来向其中添加帧。 与前面的秘籍一样,我们还希望为用户提供将帧写为单独图像的可能性。 因此,专用writeNextFrame方法处理以下两种可能的情况:

     // to write the output frame 
     // could be: video file or images
     void writeNextFrame(cv::Mat& frame) {

        if (extension.length()) { // then we write images

           std::stringstream ss;
           // compose the output filename
           ss << outputFile << std::setfill('0') 
               << std::setw(digits) 
               << currentIndex++ << extension;
           cv::imwrite(ss.str(),frame);

        } else { // then write to video file

           writer.write(frame);
        }
     }

对于输出由单个图像文件组成的情况,我们需要一个附加的设置器方法:

     // set the output as a series of image files
     // extension must be ".jpg", ".bmp" ...
     bool setOutput(const std::string &filename, // prefix
        const std::string &ext, // image file extension 
        int numberOfDigits=3,   // number of digits
        int startIndex=0) {     // start index

        // number of digits must be positive
        if (numberOfDigits<0)
           return false;

        // filenames and their common extension
        outputFile= filename;
        extension= ext;

        // number of digits in the file numbering scheme
        digits= numberOfDigits;
        // start numbering at this index
        currentIndex= startIndex;

        return true;
     }

然后,将新步骤添加到run方法的视频捕获循环中:

        while (!isStopped()) {

           // read next frame if any
           if (!readNextFrame(frame))
              break;

           // display input frame
           if (windowNameInput.length()!=0) 
              cv::imshow(windowNameInput,frame);

            // calling the process function or method
           if (callIt) {

            // process the frame
            if (process)
                process(frame, output);
            else if (frameProcessor) 
               frameProcessor->process(frame,output);
            // increment frame number
            fnumber++;

           } else {

            output= frame;
           }

 // ** write output sequence **
 if (outputFile.length()!=0)
 writeNextFrame(output);

           // display output frame
           if (windowNameOutput.length()!=0) 
              cv::imshow(windowNameOutput,output);

           // introduce a delay
           if (delay>=0 && cv::waitKey(delay)>=0)
            stopIt();

           // check if we should stop
           if (frameToStop>=0 &&
                  getFrameNumber()==frameToStop)
              stopIt();
        }
     }

然后,将编写一个简单的程序来读取视频,对其进行处理并将结果写入视频文件:

   // Create instance
   VideoProcessor processor;

   // Open video file
   processor.setInput("../bike.avi");
   processor.setFrameProcessor(canny);
   processor.setOutput("../bikeOut.avi");
   // Start the process
   processor.run();

如果要将结果另存为一系列图像,则可以通过以下命令更改前面的语句:

   processor.setOutput("../bikeOut",".jpg");

使用默认数字位数(3)和起始索引(0),这将创建文件bikeOut000.jpgbikeOut001.jpgbikeOut002.jpg,依此类推。

工作原理

将视频写入文件后,将使用编解码器将其保存。 编解码器是一种能够对视频流进行编码和解码的软件模块。 编解码器同时定义了文件格式和用于存储信息的压缩方案。 显然,已使用给定编解码器编码的视频必须使用相同的编解码器进行解码。 因此,已将四字符代码引入唯一标识的编解码器。 这样,当软件工具需要编写视频文件时,它将通过读取指定的四字符代码来确定要使用的编解码器。

顾名思义,四字符代码由四个 ASCII 字符组成,也可以通过将它们附加在一起将其转换为整数。 使用打开的cv::VideoCapture实例的get方法的CV_CAP_PROP_FOURCC标志,可以获得打开的视频文件的此代码。 我们可以在VideoProcessor类中定义一个方法,以返回输入视频的四字符代码:

     // get the codec of input video
     int getCodec(char codec[4]) {

        // undefined for vector of images
        if (images.size()!=0) return -1;

        union { // data structure for the 4-char code
           int value;
           char code[4]; } returned;

        // get the code
        returned.value= static_cast<int>(
                           capture.get(CV_CAP_PROP_FOURCC));

        // get the 4 characters
        codec[0]= returned.code[0];
        codec[1]= returned.code[1];
        codec[2]= returned.code[2];
        codec[3]= returned.code[3];

        // return the int value corresponding to the code
        return returned.value;
     }

get方法始终返回double,然后将其转换为整数。 该整数表示可使用union数据结构从中提取四个字符的代码。 如果我们打开测试视频序列,则从以下语句开始:

   char codec[4];
   processor.getCodec(codec);
   std::cout << "Codec: " << codec[0] << codec[1] 
             << codec[2] << codec[3] << std::endl;

我们获得:

Codec : XVID

写入视频文件时,必须使用其四个字符的代码指定编解码器。 这是cv::VideoWriter类的open方法中的第二个参数。 例如,您可以使用与输入视频相同的视频(这是setOutput方法中的默认选项)。 您还可以传递值 -1,该方法将弹出一个窗口,要求您从可用编解码器列表中选择一个,如下所示:

How it works...

您将在此窗口中看到的列表与计算机上已安装的编解码器的列表相对应。 然后,所选编解码器的代码将自动发送到open方法。

跟踪视频中的特征点

本章是关于读取,写入和处理视频序列的。 目的是能够分析完整的视频序列。 例如,在本秘籍中,您将学习如何对序列进行时间分析,以便跟踪特征点在帧之间移动的情况。

操作步骤

要开始跟踪过程,首先要做的是检测初始帧中的特征点。 然后,您尝试在下一帧中跟踪这些点。 您必须找到这些点现在在此新框架中的位置。 显然,由于我们正在处理视频序列,因此很有可能在其上找到了特征点的对象已经移动(该运动也可能是由于摄像机的运动引起的)。 因此,必须在一个点的先前位置附近搜索,以便在下一帧中找到它的新位置。 这就是完成cv::calcOpticalFlowPyrLK函数的功能。 您在第一张图像中输入两个连续的帧和一个特征点向量,该函数将返回新点位置的向量。 要跟踪完整序列上的点,请逐帧重复此过程。 请注意,当您沿着序列中的点进行跟踪时,不可避免地会失去对其中某些点的跟踪,从而跟踪的特征点的数量将逐渐减少。 因此,不时检测新特征可能是一个好主意。

现在,我们将利用先前秘籍中定义的框架,并定义一个类,该类实现在本章“处理视频帧”秘籍中引入的FrameProcessor接口。 此类的数据属性包括执行特征点检测及其跟踪所需的变量:

class FeatureTracker : public FrameProcessor {

   cv::Mat gray;         // current gray-level image
   cv::Mat gray_prev;      // previous gray-level image
   // tracked features from 0->1
   std::vector<cv::Point2f> points[2]; 
   // initial position of tracked points
   std::vector<cv::Point2f> initial;   
   std::vector<cv::Point2f> features;  // detected features
   int max_count;     // maximum number of features to detect
   double qlevel;    // quality level for feature detection
   double minDist;   // min distance between two points
   std::vector<uchar> status; // status of tracked features
   std::vector<float> err;    // error in tracking

  public:

   FeatureTracker() : max_count(500), 
                      qlevel(0.01), minDist(10.) {}

接下来,我们定义process方法,该方法将为序列的每个帧调用。 基本上,我们需要进行如下操作。 首先,必要时检测特征点。 接下来,跟踪这些点。 您拒绝无法追踪或不再想要追踪的点。 现在您可以处理成功跟踪的点了。 最后,当前帧及其点成为前一帧,并为下一次迭代提供点。 这是操作方法:

   void process(cv:: Mat &frame, cv:: Mat &output) {

      // convert to gray-level image
      cv::cvtColor(frame, gray, CV_BGR2GRAY); 
      frame.copyTo(output);

      // 1\. if new feature points must be added
      if(addNewPoints())
      {
         // detect feature points
         detectFeaturePoints();
         // add the detected features to 
         // the currently tracked features
         points[0].insert(points[0].end(),
                          features.begin(),features.end());
         initial.insert(initial.end(),
                        features.begin(),features.end());
      }

      // for first image of the sequence
      if(gray_prev.empty())
           gray.copyTo(gray_prev);

      // 2\. track features
      cv::calcOpticalFlowPyrLK(
         gray_prev, gray, // 2 consecutive images
         points[0], // input point positions in first image
         points[1], // output point positions in the 2nd image
         status,    // tracking success
         err);      // tracking error

      // 2\. loop over the tracked points to reject some
      int k=0;
      for( int i= 0; i < points[1].size(); i++ ) {

         // do we keep this point?
         if (acceptTrackedPoint(i)) {

            // keep this point in vector
            initial[k]= initial[i];
            points[1][k++] = points[1][i];
         }
      }

      // eliminate unsuccesful points
        points[1].resize(k);
      initial.resize(k);

      // 3\. handle the accepted tracked points
      handleTrackedPoints(frame, output);

      // 4\. current points and image become previous ones
      std::swap(points[1], points[0]);
      cv::swap(gray_prev, gray);
   }

该方法利用了其他四个工具方法。 您应该很容易地更改任何这些方法,以便为自己的跟踪器定义新的行为。 这些方法中的第一个检测特征点。 请注意,我们已经在第 8 章的第一个秘籍中讨论了cv::goodFeatureToTrack函数:

   // feature point detection
   void detectFeaturePoints() {

      // detect the features
      cv::goodFeaturesToTrack(gray, // the image 
         features,   // the output detected features
         max_count,  // the maximum number of features 
         qlevel,     // quality level
         minDist);   // min distance between two features
   }

第二个确定是否应检测到新的特征点:

   // determine if new points should be added
   bool addNewPoints() {

      // if too few points
      return points[0].size()<=10;
   }

第三个基于应用定义的标准拒绝某些跟踪点。 在这里,我们决定拒绝不移动的点(除了cv::calcOpticalFlowPyrLK函数无法跟踪的点):

   // determine which tracked point should be accepted
   bool acceptTrackedPoint(int i) {

      return status[i] &&
         // if point has moved
         (abs(points[0][i].x-points[1][i].x)+
         (abs(points[0][i].y-points[1][i].y))>2);
   }

最后,第四种方法通过在当前帧上绘制所有被跟踪的点,并用一条线将它们连接到其初始位置(即第一次检测到它们的位置)来处理被跟踪的特征点:

   // handle the currently tracked points
   void handleTrackedPoints(cv:: Mat &frame, 
                            cv:: Mat &output) {

      // for all tracked points
      for(int i= 0; i < points[1].size(); i++ ) {

         // draw line and circle
         cv::line(output, 
                  initial[i],  // initial position 
                  points[1][i],// new position 
                  cv::Scalar(255,255,255));
         cv::circle(output, points[1][i], 3, 
                    cv::Scalar(255,255,255),-1);
      }
   }

然后将编写一个简单的main函数来跟踪视频序列中的特征点,如下所示:

int main()
{
   // Create video procesor instance
   VideoProcessor processor;

   // Create feature tracker instance
   FeatureTracker tracker;

   // Open video file
   processor.setInput("../bike.avi");

   // set frame processor
   processor.setFrameProcessor(&tracker);

   // Declare a window to display the video
   processor.displayOutput("Tracked Features");

   // Play the video at the original frame rate
   processor.etDelayetDelay(1000./processor.getFrameRate());

   // Start the process
   processor.run();
}

生成的程序将显示跟踪的特征随时间的变化。 例如,这是两个不同时刻的两个这样的帧。 在此视频中,摄像机是固定的。 因此,年轻的自行车手是唯一的运动对象。 这是视频开头的一帧:

How to do it...

几秒钟后,我们得到以下帧:

How to do it...

工作原理

要从一帧到另一帧跟踪特征点,我们必须在下一帧中找到特征点的新位置。 如果我们假设特征点的强度从一帧到下一帧都没有变化,那么我们正在寻找位移(u, v)使得:

How it works...

其中ItIt + 1分别是当前帧和下一瞬间的帧。 这种恒定强度的假设通常适用于两个接近瞬间拍摄的图像中的小位移。 然后,我们可以使用泰勒展开来通过包含图像导数的方程式近似该方程式:

How it works...

后面的方程式将我们引到另一个方程式(由于恒定强度假设的结果):

How it works...

这个众所周知的约束是基本的光流约束方程。 所谓的 Lukas-Kanade 特征跟踪算法通过进行额外的假设来利用它。 特征点附近的所有点的位移都相同。 因此,我们可以对所有这些点施加唯一的(u, v)未知位移的光流约束。 与未知数(2)相比,这给了我们更多的方程式,因此我们可以在均方意义上求解该方程式。 在实践中,它是迭代解决的,并且 OpenCV 实现还提供了以不同分辨率执行此估计的可能性,以使搜索更有效且更能容忍更大的位移。 默认情况下,图像级别数为 3,窗口大小为 15。显然,可以更改这些参数。 您还可以指定终止条件,该条件定义了停止迭代搜索的条件。 cv::calcOpticalFlowPyrLK的第六个参数包含可用于评估跟踪质量的残留均方误差。 第五个参数包含二进制标志,这些标志告诉我们跟踪相应点是否被视为成功。

上面的描述代表了 Lukas-Kanade 跟踪器背后的基本原理。 当前的实现包含其他优化和改进,以使该算法在计算大量特征点的位移时更加高效。

另见

本书的第 8 章讨论了特征点检测。

The classic article by B. Lucas and T. Kanade, An iterative image registration technique with an application to stereo vision in Int. Joint Conference in Artificial Intelligence, pp. 674-679, 1981, that describes the original feature point tracking algorithm.

The article by J. Shi and C. Tomasi, Good Features to Track in IEEE Conference on Computer Vision and Pattern Recognition, pp. 593-600, 1994, that describes an improved version of the original feature point tracking algorithm.

提取视频中的前景对象

当固定摄像机观察到场景时,背景大部分保持不变。 在这种情况下,有趣的元素是在此场景内演化的运动对象。 为了提取这些前景对象,我们需要构建背景模型,然后将该模型与当前帧进行比较以检测任何前景对象。 这就是我们在本秘籍中要做的。 前景提取是智能监控应用中的基本步骤。

操作步骤

如果我们可以使用场景背景的图像(即一个不包含前景对象的帧),那么通过一个简单的图像差异就可以很容易地提取当前帧的前景:

   // compute difference between current image and background
   cv::absdiff(backgroundImage,currentImage,foreground);

然后,将其差异足够大的每个像素声明为前景像素。 但是,在大多数情况下,此背景图片并不容易获得。 确实,要保证给定图像和繁忙场景中不存在前景物体可能很困难,因此这种情况很少发生。 此外,背景场景通常随时间变化,例如因为照明条件可能发生变化(例如,从日出到日落),或者因为可以在背景中添加或移除新对象。

因此,有必要动态建立背景场景的模型。 这可以通过观察场景一段时间来完成。 如果我们假设大多数情况下背景在每个像素位置都是可见的,那么简单地计算所有观测值的平均值可能是一个很好的策略。 但这由于许多原因是不可行的。 首先,这将需要在计算背景之前存储大量图像。 其次,当我们累积图像以计算平均图像时,将不会进行前景提取。 该解决方案还提出了应累积何时和多少图像以计算可接受的背景模型的问题。 另外,给定像素正在观察前景对象的图像将对平均背景的计算产生影响。

更好的策略是通过定期更新来动态构建背景模型。 这可以通过计算所谓的移动平均值(也称为滑动平均值)来实现。 这是一种计算时间信号平均值的方法,该方法考虑了收到的最新值。 如果pt是给定时间t的像素值,而μt-1是当前平均值,则使用以下公式更新该平均值:

How to do it...

参数α称为学习率,它定义了当前值对当前估计平均值的影响。 该值越大,移动平均值将越快地适应观测值的变化。 要建立背景模型,只需计算输入帧中每个像素的移动平均值。 然后,仅基于当前图像和背景模型之间的差异来决定是否声明前景像素。

然后让我们构建一个实现此想法的类:

class BGFGSegmentor : public FrameProcessor {

   cv::Mat gray;         // current gray-level image
   cv::Mat background;      // accumulated background
   cv::Mat backImage;      // background image
   cv::Mat foreground;      // foreground image
   // learning rate in background accumulation
   double learningRate;    
   int threshold;         // threshold for foreground extraction

  public:

   BGFGSegmentor() : threshold(10), learningRate(0.01) {}

然后,主要过程包括将当前帧与背景模型进行比较,然后更新此模型:

   // processing method
   void process(cv:: Mat &frame, cv:: Mat &output) {

      // convert to gray-level image
      cv::cvtColor(frame, gray, CV_BGR2GRAY); 

      // initialize background to 1st frame
      if (background.empty())
         gray.convertTo(background, CV_32F);

      // convert background to 8U
      background.convertTo(backImage,CV_8U);

      // compute difference between image and background
      cv::absdiff(backImage,gray,foreground);

      // apply threshold to foreground image        
      cv::threshold(foreground,output,
                    threshold,255,cv::THRESH_BINARY_INV);

      // accumulate background
      cv::accumulateWeighted(gray, background, 
                             learningRate, output);
   }

使用我们的视频处理框架,前景提取程序将按以下方式构建:

int main()
{
   // Create video procesor instance
   VideoProcessor processor;

   // Create background/foreground segmentor 
   BGFGSegmentor segmentor;
   segmentor.setThreshold(25);

   // Open video file
   processor.setInput("../bike.avi");

   // set frame processor
   processor.setFrameProcessor(&segmentor);

   // Declare a window to display the video
   processor.displayOutput("Extracted Foreground");

   // Play the video at the original frame rate
   processor.setDelay(1000./processor.getFrameRate());

   // Start the process
   processor.run();
}

将显示的结果二进制前景图像之一是:

How to do it...

工作原理

通过cv::accumulateWeighted函数可以轻松地计算图像的运行平均值,该函数将运行平均值公式应用于图像的每个像素。 请注意,生成的图像必须是浮点图像。 这就是为什么我们必须先将背景模型转换为背景图像,然后再将其与当前帧进行比较。 一个简单的阈值绝对差(由cv::absdiff ,然后由cv::threshold计算)提取前景图像。 请注意,然后我们将前景图像用作cv::accumulateWeighted的遮罩,以避免更新声明为前景的像素。 之所以可行,是因为我们的前景图像在前景像素处被定义为假(即 0)(这也解释了为什么前景对象在结果图像中显示为黑色像素)。

最后,应注意,为简单起见,由我们的程序构建的背景模型是基于提取帧的灰度版本的。 维持彩色背景将需要计算每个像素每个通道的移动平均值。 通常,这种额外的计算不会显着改善结果。 而是,主要困难是确定阈值的适当值,该阈值将为给定视频提供良好的结果。

更多

前面提取场景中前景对象的简单方法非常适合显示相对稳定背景的简单场景。 但是,在许多情况下,背景场景可能在某些区域中的不同值之间波动,从而导致频繁的虚假前景检测。 例如,这可能是由于移动的背景对象(例如,树叶)或炫耀效果(例如,在水面上)引起的。 为了解决这个问题,已经引入了更复杂的背景建模方法。

高斯方法的混合

这些算法之一是高斯方法的混合。 它以与本秘籍中介绍的方法类似的方式进行,但有以下补充:

首先,该方法每个像素维护一个以上的模型(即,多个运行平均值)。 这样,如果背景像素在两个值之间波动,那么将存储两个移动平均值。 仅当新像素值不属于任何维护的模型时,才将其声明为前景。

其次,不仅要维护每个模型的运行平均值,还要维护运行方差。 这一计算如下:

The Mixture of Gaussian method

计算的平均值和方差形成高斯模型,从中可以估计给定像素值属于该高斯模型的概率。 由于现在将阈值表示为概率而不是绝对差,因此这使确定合适的阈值更加容易。 另外,在背景值具有较大波动的区域中,将需要更大的差异来声明前景对象。

实际上,当给定的高斯模型没有足够频繁地命中时,它被排除为背景模型的一部分。 相反,当发现像素值不在当前维护的背景模型之外(即,它是前景像素)时,将创建一个新的高斯模型。 如果将来,如果此新模型频繁使用,那么它将与背景相关联。

这种更复杂的算法显然比我们简单的背景/前景分割器更复杂。 幸运的是,存在一个称为cv::BackgroundSubtractorMOG的 OpenCV 实现,它被定义为更通用的cv::BackgroundSubtractor类的子类。 与默认参数一起使用时,此类非常易于使用:

int main()
{
   // Open the video file
    cv::VideoCapture capture("../bike.avi");
   // check if video successfully opened
   if (!capture.isOpened())
      return 0;

   // current video frame
   cv::Mat frame; 
   // foreground binary image
   cv::Mat foreground;

   cv::namedWindow("Extracted Foreground");

   // The Mixture of Gaussian object
   // used with all default parameters
   cv::BackgroundSubtractorMOG mog;

   bool stop(false);
   // for all frames in video
   while (!stop) {

      // read next frame if any
      if (!capture.read(frame))
         break;

      // update the background
      // and return the foreground
      mog(frame,foreground,0.01);

      // Complement the image        
      cv::threshold(foreground,foreground,
                    128,255,cv::THRESH_BINARY_INV);

      // show foreground
      cv::imshow("Extracted Foreground",foreground);

      // introduce a delay
      // or press key to stop
      if (cv::waitKey(10)>=0)
            stop= true;
   }
}

可以看出,只需创建类实例并调用同时更新背景并返回前景图像的方法即可(额外的参数是学习率)。 显示的细分之一将是:

The Mixture of Gaussian method

每个像素可能的高斯模型的数量构成此类的参数之一。

另见

The article by C. Stauffer and W.E.L. Grimson, Adaptive background mixture models for real-time tracking, in Conf. on Computer Vision and Pattern Recognition, 1999, for a more complete description of the Mixture of Gaussian algorithm.