原文:Building Computer Vision Projects with OpenCV 4 and C++

协议:CC BY-NC-SA 4.0

五、自动光学检查、对象分割和检测

第 4 章深入研究直方图和过滤器中,我们了解了直方图和过滤器,它们使我们能够理解图像操作并创建照片应用。

在本章中,我们将介绍目标分割和检测的基本概念。 这意味着隔离图像中出现的对象以供将来处理和分析。

本章介绍以下主题:

  • 去噪
  • 灯光/背景移除基础知识
  • 阈值设置
  • 用于对象分割的连通分量
  • 寻找轮廓以进行对象分割

许多行业使用复杂的计算机视觉系统和硬件。 计算机视觉试图发现问题并将生产过程中产生的错误降至最低,从而提高最终产品的质量。

在此区域中,此计算机视觉任务的名称为自动光学检测(AOI)。 这个名字出现在印刷电路板制造商的检查中,一个或多个摄像头扫描每个电路,以检测关键故障和质量缺陷。 这个术语被用于其他制造业,这样他们就可以使用光学摄像系统和计算机视觉算法来提高产品质量。 如今,光学检测根据需要使用不同的摄像机类型(红外或 3D 摄像机),复杂的算法被用于数千个行业的不同目的,如缺陷检测、分类等。

技术要求

本章要求熟悉基本的 C++ 编程语言。 本章中使用的所有代码都可以从以下 giHub 链接下载:https://github.com/PacktPublishing/Building-Computer-Vision-Projects-with-OpenCV4-and-CPlusPlus/tree/master/Chapter05。 该代码可以在任何操作系统上执行,尽管它只在 Ubuntu 上进行了测试。

请查看以下视频,了解实际操作中的代码:
http://bit.ly/2DRbMbz

隔离场景中的对象

在本章中,我们将介绍 AOI 算法的第一步,并尝试分离场景中的不同部分或对象。 我们将以三种对象类型(螺丝、密封环和螺母)的对象检测和分类为例,在本章和第 6 章学习对象分类中对其进行开发。

假设我们在一家生产这三种产品的公司。 它们都在同一条载带上。 我们的目标是检测载带中的每个物体,并对每个物体进行分类,以便机器人将每个物体放到正确的架子上:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/281eaf92-e813-4226-a3af-54f76527aad8.png

在本章中,我们将学习如何隔离每个对象并检测其在图像中的位置(以像素为单位)。 在下一章中,我们将学习如何对每个孤立的物体进行分类,以识别它是螺母、螺丝还是密封圈。

在下面的屏幕截图中,我们显示了我们想要的结果,其中左侧图像中有几个对象。 在右图中,我们用不同的颜色绘制了每一个,显示了不同的特征,如面积、高度、宽度和轮廓大小:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/31c4d62a-34ed-4f72-9f34-d72c2284d9a0.png

为了达到这一结果,我们将遵循不同的步骤,使我们能够更好地理解和组织我们的算法。 我们可以在下图中看到这些步骤:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/b92f8c9b-3ba0-415f-9729-ce2eb0ffde38.png

我们的申请将分为两章。 在本章中,我们将开发和理解预处理和分割步骤。 在第 6 章学习对象分类中,我们将提取每个分割对象的特征,并训练我们的机器学习系统/算法如何识别每个对象类。

我们的预处理步骤将分为另外三个子集:

  • 噪声消除
  • 光移除
  • 二值化

在分段步骤中,我们将使用两种不同的算法:

  • 轮廓检测
  • 连通分量提取(标记)

我们可以在下图中看到这些步骤和应用流程:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/d2986d6e-caa4-4199-8616-6c27108224d9.png

现在,是开始预处理步骤的时候了,这样我们就可以通过去除噪声和光照效果来获得最佳的二值化图像。 这最大限度地减少了任何可能的检测错误。

为 AOI 创建应用

要创建我们的新应用,我们需要一些输入参数。 当用户执行应用时,除了要处理的输入图像外,所有这些都是可选的。 输入参数如下:

  • 要处理的输入图像
  • 光像图案
  • 轻操作,用户可以在差或除操作之间进行选择
  • 如果用户将0设置为值,则应用差值运算
  • 如果用户将1设置为值,则应用除法运算
  • 分段,用户可以在具有或不具有统计数据的连接组件之间进行选择,并查找等高线方法
  • 如果用户将1设置为输入值,则应用分段的连通分量方法
  • 如果用户将2设置为输入值,则应用带有统计区域的连通分量方法
  • 如果用户将3设置为输入值,则会应用查找等值线方法进行分段

要启用此用户选择,我们将使用带有以下键的command line parser类:

// OpenCV command line parser functions 
// Keys accepted by command line parser 
const char* keys = 
{ 
  "{help h usage ? | | print this message}" 
   "{@image || Image to process}" 
   "{@lightPattern || Image light pattern to apply to image input}" 
   "{lightMethod | 1 | Method to remove background light, 0 difference, 1 div }" 
   "{segMethod | 1 | Method to segment: 1 connected Components, 2 connected components with stats, 3 find Contours }" 
}; 

我们将通过检查参数在main函数中使用command line parser类。 在阅读视频和摄像机部分的第 2 章OpenCV基础简介中解释了CommandLineParser

int main(int argc, const char** argv) 
{ 
  CommandLineParser parser(argc, argv, keys); 
  parser.about("Chapter 5\. PhotoTool v1.0.0"); 
  //If requires help show 
  if (parser.has("help")) 
  { 
      parser.printMessage(); 
      return 0; 
  } 

  String img_file= parser.get<String>(0); 
  String light_pattern_file= parser.get<String>(1); 
  auto method_light= parser.get<int>("lightMethod"); 
  auto method_seg= parser.get<int>("segMethod"); 

  // Check if params are correctly parsed in his variables 
  if (!parser.check()) 
  { 
      parser.printErrors(); 
      return 0; 
  } 

解析命令行用户数据后,我们需要检查输入图像是否已正确加载。 然后,我们加载图像并检查其是否包含数据:

// Load image to process 
  Mat img= imread(img_file, 0); 
  if(img.data==NULL){ 
    cout << "Error loading image "<< img_file << endl; 
    return 0; 
  } 

现在,我们准备创建我们的 AOI 细分流程。 我们将从预处理任务开始。

对输入图像进行预处理

本节介绍在对象分割/检测上下文中可以应用于图像预处理的一些最常见的技术。 预处理是我们在开始工作并从中提取所需信息之前对新图像所做的第一个更改。 通常,在预处理步骤中,我们会尽量减少由相机镜头引起的图像噪声、光线条件或图像变形。 这些步骤在检测图像中的对象或片段时将误差降至最低。

去噪

如果我们不去除噪声,我们可以检测到比我们预期更多的目标,因为噪声通常表示为图像中的小点,并且可以被分割为一个目标。 传感器和扫描仪电路通常会产生此噪声。 这种亮度或颜色的变化可以用不同的类型来表示,例如高斯噪声、尖峰噪声和散粒噪声。

可以使用不同的技术来消除噪音。 这里,我们将使用平滑操作,但根据噪声类型的不同,有些比另一些要好。 中值滤波器通常用于去除胡椒噪声;例如,请考虑下图:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/5f8547a4-97cd-4b35-ab3f-418c1a044998.png

前一幅图像是带有盐和胡椒噪声的原始输入。 如果我们应用中间模糊,我们会得到一个很棒的结果,其中会丢失一些小细节。 例如,我们丢失了螺钉的边缘,但我们保持了完美的边缘。 请参见下图中的结果:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/81ba5bdd-4fe2-4096-b876-79a264593bd9.png

如果我们应用盒过滤器或高斯过滤器,噪声不会被去除,而是变得平滑,对象的细节也会丢失和平滑。 有关结果,请参见下图:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/3f63820d-d5bb-4a37-a38a-1417afaa050b.png

OpenCV 提供了medianBlur函数,它需要三个参数:

  • 具有134通道图像的输入图像。 当内核大小大于5时,图像深度只能为CV_8U

  • 输出图像,它是应用与输入相同类型和深度的中间模糊的结果图像。

  • 内核大小,它是大于1的奇数孔径大小,例如 3、5、7 等等。

以下代码用于消除噪音:

  Mat img_noise; 
  medianBlur(img, img_noise, 3); 

使用用于分割的光图案去除背景

在这一部分中,我们将开发一个基本算法,使我们能够使用灯光模式删除背景。 这种预处理可以给我们更好的分割效果。 无噪声的输入图像如下:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/d58d7a47-461c-4730-b2db-8b8f42e75ea7.png

如果我们应用一个基本阈值,我们将获得如下图像结果:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/f094e1c1-a98d-49f4-96ed-a351af611573.png

我们可以看到上面的图像伪像有很多白噪声。 如果我们应用光模式和背景去除技术,我们可以得到令人惊叹的结果,我们可以看到在图像的顶部没有像以前的阈值操作那样的伪影,当我们需要分割的时候,我们会得到更好的结果。 我们可以在下图中看到背景去除和阈值处理的结果:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/2cbc71ad-c97e-4593-b7f6-89439d40a694.png

现在,我们如何才能将光线从我们的图像中移除呢? 这很简单:我们只需要一张没有任何物体的场景照片,从与拍摄其他图像完全相同的位置和照明条件下拍摄;这是 AOI 中的一种非常常见的技术,因为外部条件是受监督和众所周知的。 本例的图像结果类似于下图:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/cbcd7c67-e5c7-4b15-889f-9144c8b696b5.png

现在,使用一个简单的数学运算,我们可以移除这个光图案。 删除它有两个选项:

  • 差异 / 不同 / 争执
  • 分歧 / 除 / 部分 / 部门

差异选项是最简单的方法。 如果我们具有光图案L和图像画面I,则由此产生的移除R是它们之间的差值:

R= L-I 

这种划分比较复杂,但同时也很简单。 如果我们具有光图案矩阵L和图像画面矩阵I,则结果去除R如下:

R= 255*(1-(I/L)) 

在这种情况下,我们将图像除以光图案,并假设如果我们的光图案是白色的,并且对象比背景载体带更暗,则图像像素值始终等于或低于光像素值。 我们从I/L得到的结果介于01之间。 最后,我们将该除法的结果倒置以得到相同的颜色方向范围,并将其乘以255以得到0-255范围内的值。

在我们的代码中,我们将使用以下参数创建一个名为removeLight的新函数:

  • 用于移除灯光/背景的输入图像
  • 光图案,Mat
  • 一种方法,用0值表示差,1表示除法

结果是一个没有光/背景的新图像矩阵。 下面的代码通过使用灯光图案实现背景的移除:

Mat removeLight(Mat img, Mat pattern, int method) 
{ 
  Mat aux; 
  // if method is normalization 
  if(method==1) 
  { 
    // Require change our image to 32 float for division 
    Mat img32, pattern32; 
    img.convertTo(img32, CV_32F); 
    pattern.convertTo(pattern32, CV_32F); 
    // Divide the image by the pattern 
    aux= 1-(img32/pattern32); 
    // Convert 8 bits format and scale
    aux.convertTo(aux, CV_8U, 255); 
  }else{ 
    aux= pattern-img; 
  } 
  return aux; 
} 

让我们来探讨一下这个问题。 创建aux变量保存结果后,我们选择用户选择的方法并将参数传递给函数。 如果选择的方法是1,则应用除法。

除法需要 32 位浮点型图像,这样我们就可以划分图像,而不是将数字截断为整数。 第一步是将图像和光图案垫转换为 32 位浮点数。 要转换此格式的图像,可以使用Mat类的convertTo函数。 此函数接受四个参数;输出转换的图像和要转换为所需参数的格式,但您可以定义 alpha 和 beta 参数,这些参数允许您缩放和移动下一个函数后面的值,其中O是输出图像,I是输入图像:

O(xy)=cast<Type>(α*I(xy)+β)

下面的代码将图像更改为 32 位浮点:

// Required to change our image to 32 float for division 
Mat img32, pattern32; 
img.convertTo(img32, CV_32F); 
pattern.convertTo(pattern32, CV_32F); 

现在,我们可以对我们的矩阵执行如上所述的数学运算,方法是将图像除以图案并反转结果:

// Divide the image by the pattern 
aux= 1-(img32/pattern32); 

现在,我们有了结果,但需要将其返回到 8 位深度图像,然后像前面一样使用 Convert 函数转换图像的mat,并使用 alpha 参数从0缩放到255

// Convert 8 bits format 
aux.convertTo(aux, CV_8U, 255); 

现在,我们可以将aux变量与结果一起返回。 对于差分方法,开发非常容易,因为我们不需要转换图像;我们只需要应用模式和图像之间的差异并返回它。 如果我们不假设图案等于或大于图像,则需要进行几次检查并截断值,这些值可以小于0或大于255

aux= pattern-img; 

以下图像是将图像灯光图案应用于我们的输入图像的结果:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/b80f7abd-d551-4741-9dd0-26c786906b7d.png

在我们得到的结果中,我们可以检查光线渐变和可能的伪影是如何被去除的。 但是当我们没有灯光/背景图案时会发生什么呢? 有几种不同的技术可以实现这一点;我们将在这里介绍最基本的一种。 使用滤镜,我们可以创建一个可以使用的滤镜,但有更好的算法来了解图像的背景,其中碎片出现在不同的区域。 这项技术有时需要背景估计图像初始化,我们的基本方法可以很好地发挥作用。 这些高级技术将在第 8 章视频监控、背景建模和形态运算中进行探讨。 为了估计背景图像,我们将使用具有较大内核大小的模糊来应用于我们的输入图像。 这是在光学字符识别*(*OCR)中使用的常用技术,其中字母相对于整个文档较薄且较小,允许我们对图像中的光图案进行近似。 我们可以在左手图像中看到灯光/背景图案重建,在右手图像中可以看到地面实况:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/fe2720f9-e701-42be-8c36-bb70c2085a63.png

我们可以看到灯光图案有一些细微的差异,但这一结果足以去除背景。 当使用不同的图像时,我们也可以在下图中看到结果。 在下图中,描述了应用原始输入图像和使用前一种方法计算的估计背景图像之间的图像差的结果:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/e7f7eccd-0439-4af3-80d6-e7b018363dad.png

calculateLightPattern函数创建此灯光图案或背景近似值:

Mat calculateLightPattern(Mat img) 
{ 
  Mat pattern; 
  // Basic and effective way to calculate the light pattern from one image 
  blur(img, pattern, Size(img.cols/3,img.cols/3)); 
  return pattern; 
} 

此基本函数通过使用相对于图像大小较大的内核大小来对输入图像应用模糊。 从代码来看,它是原来宽度和高度的**。**

**# 阈值设置

在去除背景之后,我们只需要对图像进行二值化,以便将来进行分割。 我们要用 Threshold 来做这件事。 Threshold是一个简单的函数,它将每个像素的值设置为最大值(例如 255)。 如果像素的值大于阈值值,或者如果像素的值小于阈值值,则它将被设置为最小值(0):

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/00c05937-dde7-419e-8b58-988c688d97ea.png

现在,我们将使用两个不同的threshold值来应用threshold函数:当我们移除灯光/背景时,我们将使用 30threshold值,因为所有不感兴趣的区域都是黑色的。 这是因为我们应用了背景移除。 当我们不使用灯光移除方法时,我们还将使用中值threshold(140),因为我们使用的是白色背景。 最后一个选项用于允许我们在删除和不删除背景的情况下检查结果:

  // Binarize image for segment 
  Mat img_thr; 
  if(method_light!=2){ 
   threshold(img_no_light, img_thr, 30, 255, THRESH_BINARY); 
  }else{ 
   threshold(img_no_light, img_thr, 140, 255, THRESH_BINARY_INV); 
  } 

现在,我们将继续我们应用中最重要的部分:分割。 这里我们将使用两种不同的方法或算法:连通分量和查找轮廓。

分割我们的输入图像

现在,我们将介绍两种分割阈值图像的技术:

  • 连接的组件
  • 查找等高线

使用这两种技术,我们可以提取图像中出现目标对象的每个感兴趣区域(ROI)。 在我们的例子中,这些是螺母、螺丝和环。

连通分量算法

连通分量算法是一种非常常用的算法,用于分割和识别二值图像中的部分。 连通分量是一种迭代算法,其目的是使用八个或四个连通性像素来标记图像。 如果两个像素具有相同的值并且是相邻像素,则这两个像素是相连的。 在图像中,每个像素都有八个相邻像素:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/8fe23da4-9a99-47f4-8599-6429da876f48.png

四连通性意味着,如果2457邻居的值与中心像素相同,则它们只能连接到中心。 通过八个连接,如果12345678邻居的值与中心像素相同,则可以连接它们。 我们可以从四连通性算法和八连通性算法中看出以下示例的不同之处。 我们将把每种算法应用于下一幅二值化图像。 我们使用了一幅小的9x9图像,并放大显示了连接组件的工作原理以及四连接和八连接之间的区别:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/5a81d547-9b10-475e-8550-02f8af6685e2.png

四连通性算法检测到两个对象;我们可以在左图中看到这一点。 八连通性算法只检测一个对象(右侧图像),因为两个对角线像素是相连的。 八连通性处理对角线连通性,这是与四连通性相比的主要区别,因为在四连通性中只考虑垂直和水平像素。 我们可以在下图中看到结果,其中每个对象都有不同的灰色值:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/e7f73902-b25a-4f55-bdc9-ec5099c0fff8.png

OpenCV 为我们带来了具有两种不同功能的连通分量算法:

  • connectedComponents(图像,标签,连接性=8,类型=CV_32S)
  • connectedComponentsWithStats(图像,标签,统计信息,质心,连接性=8,类型=CV_32S)

这两个函数都返回一个带有检测到的标签数量的整数,其中 Label0表示背景。 这两个函数之间的区别基本上在于返回的信息。 让我们检查一下每一台的参数。 connectedComponents函数为我们提供以下参数:

  • Image:要标记的输入图像。
  • 标签:与输入图像大小相同的输出垫,其中每个像素都有其标签值,其中所有 OS 表示背景,值为1的像素表示第一个连接的组件对象,依此类推。
  • 连接性:表示我们要使用的连接性的两个可能值84
  • 类型:我们要使用的标签图像的类型。 只允许两种类型:CV32_SCV16_U。 默认情况下,这是CV32_S
  • connectedComponentsWithStats函数还定义了两个参数。 以下是统计数据和质心:
    • Stats:这是一个输出参数,为我们提供每个标签的以下统计值(包括背景):
      • CC_STAT_LEFT:连接组件对象最左侧的x坐标
      • CC_STAT_TOP:连接的组件对象的最上面的y坐标
      • CC_STAT_WIDTH:由其边界框定义的连接组件对象的宽度
      • CC_STAT_HEIGHT:由其边界框定义的连接组件对象的高度
      • CC_STAT_AREA:连接组件对象的像素数(面积)
    • 质心:质心指向每个标签的浮动类型,包括考虑用于另一个连接组件的背景。

在我们的示例应用中,我们将创建两个函数,以便可以应用这两个 OpenCV 算法。 然后,我们将在具有基本连通分量算法的带有彩色对象的新图像中向用户显示所获得的结果。 如果我们使用 stats 方法选择连通组件,我们将在每个对象上绘制返回此函数的相应计算区域。

让我们定义连通组件函数的基本绘图:

void ConnectedComponents(Mat img) 
{ 
  // Use connected components to divide our image in multiple connected component objects
     Mat labels; 
     auto num_objects= connectedComponents(img, labels); 
  // Check the number of objects detected 
     if(num_objects < 2 ){ 
        cout << "No objects detected" << endl; 
        return; 
      }else{ 
       cout << "Number of objects detected: " << num_objects - 1 << endl; 
      } 
  // Create output image coloring the objects 
     Mat output= Mat::zeros(img.rows,img.cols, CV_8UC3); 
     RNG rng(0xFFFFFFFF); 
     for(auto i=1; i<num_objects; i++){ 
        Mat mask= labels==i; 
        output.setTo(randomColor(rng), mask); 
      } 
     imshow("Result", output); 
} 

首先,我们调用 OpenCVconnectedComponents函数,该函数返回检测到的对象数量。 如果对象的数量少于两个,这意味着只检测到背景对象,然后我们不需要绘制任何东西,就可以完成。 如果算法检测到多个对象,我们会显示控制台上检测到的对象数量:

  Mat labels; 
  auto num_objects= connectedComponents(img, labels); 
  // Check the number of objects detected 
  if(num_objects < 2){ 
    cout << "No objects detected" << endl; 
    return; 
  }else{ 
    cout << "Number of objects detected: " << num_objects - 1 << endl;

现在,我们要用不同的颜色在新图像中绘制所有检测到的对象。 在此之后,我们需要创建一个具有相同输入大小和三个通道的新黑色图像:

Mat output= Mat::zeros(img.rows,img.cols, CV_8UC3); 

我们将循环遍历除0值之外的每个标签,因为这是背景:

for(int i=1; i<num_objects; i++){ 

要从标签图像中提取每个对象,我们可以使用比较为每个i标签创建一个蒙版,并将其保存在新图像中:

    Mat mask= labels==i; 

最后,我们使用mask为输出图像设置伪随机颜色:

    output.setTo(randomColor(rng), mask); 
  } 

在循环所有图像之后,我们的输出中有所有检测到的不同颜色的对象,我们只需在窗口中显示输出图像:

imshow("Result", output); 

这是使用不同颜色或灰度值绘制每个对象的结果:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/328c60fd-3b54-48c2-b3b2-a4e31cbf1871.png

现在,我们将解释如何将连通分量与statsOpenCV 算法一起使用,并在生成的图像中显示更多信息。 以下函数实现此功能:

void ConnectedComponentsStats(Mat img) 
{ 
  // Use connected components with stats 
  Mat labels, stats, centroids; 
  auto num_objects= connectedComponentsWithStats(img, labels, stats, centroids); 
  // Check the number of objects detected 
  if(num_objects < 2 ){ 
    cout << "No objects detected" << endl; 
    return; 
  }else{ 
    cout << "Number of objects detected: " << num_objects - 1 << endl; 
  } 
  // Create output image coloring the objects and show area 
  Mat output= Mat::zeros(img.rows,img.cols, CV_8UC3); 
  RNG rng( 0xFFFFFFFF ); 
  for(auto i=1; i<num_objects; i++){ 
    cout << "Object "<< i << " with pos: " << centroids.at<Point2d>(i) << " with area " << stats.at<int>(i, CC_STAT_AREA) << endl; 
    Mat mask= labels==i; 
    output.setTo(randomColor(rng), mask); 
    // draw text with area 
    stringstream ss; 
    ss << "area: " << stats.at<int>(i, CC_STAT_AREA); 

    putText(output,  
      ss.str(),  
      centroids.at<Point2d>(i),  
      FONT_HERSHEY_SIMPLEX,  
      0.4,  
      Scalar(255,255,255)); 
  } 
  imshow("Result", output); 
} 

让我们来理解一下这段代码。 正如我们在非统计函数中所做的那样,我们调用了 Connected Components 算法,但在这里,我们使用stats函数来执行此操作,以检查我们是否检测到多个对象:

Mat labels, stats, centroids; 
  auto num_objects= connectedComponentsWithStats(img, labels, stats, centroids); 
  // Check the number of objects detected 
  if(num_objects < 2){ 
    cout << "No objects detected" << endl; 
    return; 
  }else{ 
    cout << "Number of objects detected: " << num_objects - 1 << endl; 
  }

现在,我们又有了两个输出结果:统计数据和质心变量。 然后,对于每个检测到的标签,我们将通过命令行显示质心和区域:

for(auto i=1; i<num_objects; i++){ 
    cout << "Object "<< i << " with pos: " << centroids.at<Point2d>(i) << " with area " << stats.at<int>(i, CC_STAT_AREA) << endl; 

您可以检查对 stats 变量的调用,以使用列常量stats.at<int>(I, CC_STAT_AREA)提取区域。 现在,像以前一样,我们在输出图像上绘制标有i的对象:

Mat mask= labels==i; 
output.setTo(randomColor(rng), mask); 

最后,在每个分割对象的质心位置,我们希望在生成的图像上绘制一些信息(如面积)。 为此,我们使用putText函数的 STATS 和质心变量。 首先,我们必须创建一个stringstream,以便可以添加统计区域信息:

// draw text with area 
stringstream ss; 
ss << "area: " << stats.at<int>(i, CC_STAT_AREA); 

然后,我们需要使用putText,使用质心作为文本位置:

putText(output,  
  ss.str(),  
  centroids.at<Point2d>(i),  
  FONT_HERSHEY_SIMPLEX,  
  0.4,  
  Scalar(255,255,255)); 

此函数的结果如下所示:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/146a592f-3304-4484-9b59-003a5e038e9f.png

FindContours 算法

在分割对象时,findContours算法是最常用的 OpenCV 算法之一。 这是因为此算法是从 1.0 版开始包含在 OpenCV 中的,它为开发人员提供了更多信息和描述符,包括形状、拓扑组织等:

void findContours(InputOutputArray image, OutputArrayOfArrays contours, OutputArray hierarchy, int mode, int method, Point offset=Point()) 

下面我们来解释一下每个参数:

  • 图像:输入二进制图像。
  • 轮廓:轮廓的输出,其中每个检测到的轮廓都是点的矢量。
  • 层次:这是保存等高线层次的可选输出向量。 这是图像的拓扑结构,在这里我们可以得到每个轮廓之间的关系。 层次表示为四个索引的向量,它们是(下一个轮廓、上一个轮廓、第一个子轮廓、父轮廓)。 在给定的等高线与其他等高线没有关系的情况下,给出负指数。 更详细的解释可以在https://docs.opencv.org/3.4/d9/d8b/tutorial_py_contours_hierarchy.html找到。
  • 模式:此方法用于检索轮廓:
    • RETR_EXTERNAL仅检索外部轮廓。
    • RETR_LIST检索所有等高线,而不建立层次。
    • RETR_CCOMP检索具有两个层次(外部和孔)的所有等高线。 如果另一个对象在一个洞内,则将其放在层次的顶部。
    • RETR_TREE检索所有等高线,在等高线之间创建完整层次。
  • 方法:这允许我们使用近似方法检索轮廓的形状:
    • 如果设置了CV_CHAIN_APPROX_NONE,则不会对等高线应用任何近似,并存储等高线的点。
    • CV_CHAIN_APPROX_SIMPLE压缩所有水平、垂直和对角线段,仅存储起点和终点。
    • CV_CHAIN_APPROX_TC89_L1CV_CHAIN_APPROX_TC89_KCOS应用特尔钦近似算法。
  • 偏移:这是一个可选的点值,用于移动所有等高线。 当我们在 ROI 中工作并需要检索全球位置时,这是非常有用的。

Note: The input image is modified by the findContours function. Create a copy of your image before sending it to this function if you need it.

现在我们已经知道了findContours函数的参数,让我们将其应用到我们的示例中:

void FindContoursBasic(Mat img) 
{ 
  vector<vector<Point> > contours; 
  findContours(img, contours, RETR_EXTERNAL, CHAIN_APPROX_SIMPLE); 
  Mat output= Mat::zeros(img.rows,img.cols, CV_8UC3); 
  // Check the number of objects detected 
  if(contours.size() == 0 ){ 
    cout << "No objects detected" << endl; 
    return; 
  }else{ 
    cout << "Number of objects detected: " << contours.size() << endl; 
  } 
  RNG rng(0xFFFFFFFF); 
  for(auto i=0; i<contours.size(); i++){ 
    drawContours(output, contours, i, randomColor(rng)); 
    imshow("Result", output); 
  }
} 

让我们逐行解释我们的实现。

在我们的例子中,我们不需要任何层次结构,所以我们只需要检索所有可能对象的外部轮廓。 为此,我们可以使用RETR_EXTERNAL模式,并使用CHAIN_APPROX_SIMPLE方法进行基本轮廓编码:

vector<vector<Point> > contours; 
vector<Vec4i> hierarchy; 
findContours(img, contours, RETR_EXTERNAL, CHAIN_APPROX_SIMPLE); 

就像我们之前看到的连通组件示例一样,我们首先检查我们检索到了多少轮廓。 如果没有,则退出我们的函数:

// Check the number of objects detected 
  if(contours.size() == 0){ 
    cout << "No objects detected" << endl; 
    return; 
  }else{ 
    cout << "Number of objects detected: " << contours.size() << endl; 
  }

最后,我们为每个检测到的物体画出轮廓线。 我们用不同的颜色将其绘制在输出图像中。 为此,OpenCV 提供了一个函数来绘制查找等高线图像的结果:

for(auto i=0; i<contours.size(); i++) 
    drawContours(output, contours, i, randomColor(rng)); 
  imshow("Result", output); 
} 

drawContours函数允许以下参数:

  • Image:绘制轮廓的输出图像。
  • 轮廓:轮廓的向量。
  • 等高线索引:指示要绘制的等高线的数字。 如果该值为负,则绘制所有等高线。
  • 颜色:绘制轮廓的颜色。
  • 厚度:如果为负值,则用所选颜色填充轮廓。
  • Line type:这指定我们是要使用抗锯齿绘制,还是要使用其他绘制方法。
  • Hierarchy:这是一个可选参数,只有在要绘制一些等高线时才需要。
  • 最大级别:这是一个可选参数,只有当 Hierarchy 参数可用时才会考虑该参数。 如果设置为0,则只绘制指定的等高线。 如果为1,该函数还会绘制当前等高线和嵌套的等高线。 如果将其设置为2,则算法将绘制所有指定的等高线层次。
  • 偏移量:这是用于移动等高线的可选参数。

我们的示例结果如下图所示:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/4556a9b7-4ec8-46bf-8596-62e84032ce7f.png

简略的 / 概括的 / 简易判罪的 / 简易的

在本章中,我们探讨了在摄像机拍摄不同对象的受控情况下对象分割的基础知识。 在这里,我们学习了如何去除背景和光线,以便更好地对图像进行二值化,从而将噪声降至最低。 在对图像进行二值化之后,我们了解了三种不同的算法,这些算法可用于分割和分离图像中的每个对象,从而使我们能够隔离每个对象以操作或提取特征。

我们可以在下图中看到整个过程:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/c0f5d22e-3be2-4d0c-af16-5d39839b899e.png

最后,我们提取了一幅图像上的所有对象。 您需要这样做才能继续下一章,在下一章中,我们将提取每个对象的特征来训练机器学习系统。

在下一章中,我们将预测图像中任何物体的类别,然后呼叫机器人或任何其他系统来挑选它们中的任何一个,或者检测不在正确载体带上的物体。 然后,我们将研究如何通知一个人来取它。**

六、学习对象分类

第 5 章自动光学检测、对象分割和检测中,我们介绍了对象分割和检测的基本概念。 这指的是隔离图像中出现的对象以供将来处理和分析。 本章介绍如何对这些孤立对象中的每一个进行分类。 为了使我们能够对每个对象进行分类,我们必须训练我们的系统能够学习所需的参数,以便它决定将哪个特定标签分配给检测到的对象(取决于在训练阶段考虑的不同类别)。

本章介绍了机器学习的基本概念,用于对具有不同标签的图像进行分类。 为此,我们将基于第 5 章自动光学检测、对象分割和检测的分割算法创建一个基本应用。 该分割算法提取包含未知对象的图像部分。 对于每个检测到的目标,我们将提取不同的特征,这些特征将使用机器学习算法进行分类。 最后,我们将显示使用我们的用户界面获得的所有结果,以及在输入图像中检测到的每个对象的标签。

本章涉及不同的主题和算法,包括以下内容:

  • 机器学习概念简介
  • 常用机器学习算法和过程
  • 特征提取
  • 支持向量机(SVM)
  • 训练和预测

技术要求

本章要求熟悉基本的 C++ 编程语言。 本章中使用的所有代码都可以从以下 gihub 链接下载:*https://github.com/PacktPublishing/Building-Computer-Vision-Projects-with-OpenCV4-and-CPlusPlus/tree/master/Chapter06。这段代码可以在任何操作系统上执行,尽管它只在 Ubuntu 上测试过。

请查看以下视频,了解实际操作中的代码:
http://bit.ly/2KGD4CO

机器学习概念简介

机器学习是由Arthur Samuel在 1959 年定义的一个概念,它是一个让计算机在没有明确编程的情况下能够学习的研究领域。 Tom M.Mitchel为机器学习提供了一个更正式的定义,在该定义中,他将样本的概念与经验数据、标签和算法的性能测量联系起来。

The machine learning definition by Arthur Samuel is referenced in Some Studies in Machine Learning Using the Game of Checkers in IBM Journal of Research and Development (Volume3, Issue: 3), p. 210. It was also referenced in The New Yorker and Office Management in the same year. 
The more formal definition from Tom M. Mitchel is referenced in Machine Learning Book, McGray Hill 1997: (http://www.cs.cmu.edu/afs/cs.cmu.edu/user/mitchell/ftp/mlbook.html).

机器学习涉及人工智能中的模式识别和学习理论,与计算统计学相关。 它还用于数百个应用,如光学字符识别(OCR)、垃圾邮件过滤、搜索引擎和数以千计的计算机视觉应用,例如我们将在本章开发的示例,其中机器学习算法试图对输入图像中出现的对象进行分类。

根据机器学习算法从输入数据中学习的方式,我们可以将其分为三类:

  • 监督学习:计算机从一组标记的数据中学习。 这里的目标是了解允许计算机映射数据和输出标签结果之间关系的模型和规则的参数。

  • 无监督学习:不给出标签,计算机试图发现给定数据的输入结构。

  • 强化学习:计算机与动态环境交互,达到他们的目标并从他们的错误中学习。

根据我们希望从机器学习算法中获得的结果,我们可以将结果分类如下:

  • 分类:输入的空间可以分为四个个类,给定样本的预测结果就是这些训练类中的一个。 这是最常用的类别之一。 一个典型的例子可能是电子邮件垃圾邮件过滤,其中只有两类:垃圾邮件和非垃圾邮件。 或者,我们可以使用 OCR,其中只有 N 个字符可用,并且每个字符是一个类。
  • 回归:输出是连续值,而不是像分类结果那样的离散值。 回归的一个例子可能是根据房子的大小、建造年限和位置对房价进行预测。
  • 聚类:输入将被分成 N 个组,这通常是使用无监督训练来完成的。
  • 密度估计:找出输入的(概率)分布。

在我们的示例中,我们将使用有监督的学习和分类算法,其中使用带有标签的训练数据集来训练模型,并且模型的预测结果是可能的标签之一。 在机器学习中,有几种途径和方法可以做到这一点。 其中一些比较流行的方法包括:支持向量机(SVM)、人工神经网络(ANN)、聚类、k 近邻、决策树和深度学习。 几乎所有这些方法和途径都在 OpenCV 中得到了支持、实现和良好的文档记录。 在本章中,我们将解释支持向量机。

OpenCV 机器学习算法

OpenCV 实现了其中八种机器学习算法。 它们都继承自StatModel类:

  • 人工神经网络

  • 随机树

  • 期望最大化

  • K-近邻

  • Logistic 回归

  • 正态贝叶斯分类器

  • 支持向量机

  • 随机梯度下降支持向量机

版本 3 支持基础级别的深度学习,但版本 4 更稳定且更受支持。 我们将在接下来的章节中详细探讨深度学习。

To get more information about each algorithm, read the OpenCV document page for machine learning at http://docs.opencv.org/trunk/dc/dd6/ml_intro.html.

下图显示了机器学习类层次结构:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/0406da9c-9a16-4f15-8334-a5e78d124e47.png

StatModel类是所有机器学习算法的基类。 这提供了预测和所有读写功能,这些功能对于保存和读取我们的机器学习参数和训练数据非常重要。

在机器学习中,训练方法是最耗时、最耗费计算资源的部分。 对于大型数据集和复杂的机器学习结构,培训可能需要几秒钟到几周或几个月的时间。 例如,在深度学习中,拥有超过 10 万个图像数据集的大型神经网络结构可能需要很长时间才能进行训练。 对于深度学习算法,通常使用并行硬件处理,如采用 CUDA 技术的 GPU,以减少训练期间的计算时间,或者使用大多数新的芯片设备,如 Intel Movidius。 这意味着我们不能在每次运行我们的应用时训练我们的算法,因此建议用已经学习的所有参数保存我们训练过的模型。 在以后的执行中,我们只需从保存的模型中加载/读取,而无需训练,除非我们需要使用更多样本数据更新我们的模型。

StatModel是除深度学习方法以外的所有机器学习类(如 SVM 或 ANN)的基类。StatModel基本上是一个虚拟类,它定义了两个最重要的函数-trainpredicttrain方法是负责使用训练数据集学习模型参数的主要方法。 此版本有以下三种可能的调用:

bool train(const Ptr<TrainData>& trainData, int flags=0 ); 
bool train(InputArray samples, int layout, InputArray responses); 
Ptr<_Tp> train(const Ptr<TrainData>& data, int flags=0 ); 

列车功能具有以下参数:

  • TrainData:可以从TrainData类加载或创建的培训数据。 这个类是 OpenCV 3 中的新增类,帮助开发人员从机器学习算法中创建训练数据和摘要。 这样做是因为不同的算法需要不同类型的阵列结构来进行训练和预测,例如 ANN 算法。
  • samples:训练数组样本的数组,例如机器学习算法要求的格式的训练数据。
  • layoutROW_SAMPLE(训练样本为矩阵行)或COL_SAMPLE(训练样本为矩阵列)。
  • responses:与样本数据关联的响应向量。
  • flags:每个方法定义的可选标志。

最后一个 Train 方法创建并训练_TP类类型的模型。 唯一接受的类是实现不带参数或全部使用默认参数值的静态 Create 方法的类。

predict方法要简单得多,并且只有一个可能的调用:

float StatModel::predict(InputArray samples, OutputArray results=noArray(), int flags=0) 

预测函数具有以下参数:

  • samples:用于预测模型结果的输入样本可以由任意数量的数据组成,无论是单个数据还是多个数据。
  • results:每个输入行样本的结果(由来自先前训练的模型的算法计算)。
  • flags:这些可选标志取决于型号。 某些模型(如 Boost)由 SVM 的StatModel::RAW_OUTPUT标志识别,该标志使方法返回原始结果(总和),而不是类标签。

StatModel类为其他非常有用的方法提供了一个接口:

现在,我们将介绍一个在计算机视觉应用中使用机器学习的基本应用是如何构建的。

计算机视觉与机器学习工作流

具有机器学习的计算机视觉应用具有共同的基本结构。 此结构分为不同的步骤:

  1. 预处理
  2. 分段
  3. 特征提取
  4. 分类结果
  5. P****主进程

这些在几乎所有的计算机视觉应用中都很常见,而其他的则被省略了。 在下图中,您可以看到涉及的不同步骤:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/0420a0ae-6b12-40cf-af93-a16e92b95c38.png

几乎所有的计算机视觉应用都是从应用于输入图像的预处理开始的,包括去除光线和噪声、过滤、模糊等。在对输入图像应用所有所需的预处理之后,第二步是分割。 在这一步中,我们必须提取图像中的感兴趣区域,并将每个区域分离为唯一的感兴趣对象。 例如,在人脸检测系统中,我们必须将人脸与场景中的其他部分分开。 在检测到图像内部的所有对象后,我们继续下一步。 在这里,我们必须提取每个对象的特征;这些特征通常是对象的特征向量。 特征描述我们的对象,可以是对象的面积、轮廓、纹理图案、像素等。

现在,我们有了对象的描述符,也称为特征向量或特征集。 描述符是描述对象的特征,我们使用它们来训练或预测模型。 要做到这一点,我们必须创建一个包含数千幅图像的大型特征数据集。 然后,我们在我们选择的列车模型函数中使用提取的特征(图像/对象特征),例如面积、大小和纵横比。 在下图中,我们可以看到如何将数据集送入机器学习算法来训练和生成****模型

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/3042ee1f-c724-44d9-914d-f382ed2cf933.png

当我们用数据集训练时,模型学习能够预测何时具有未知标签的新特征向量作为我们算法的输入所需的所有参数。 在下图中,我们可以看到未知特征向量如何使用生成的模型预测,从而返回分类和结果或回归:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/ddc3e777-6359-470e-9f9b-0bfbae50cad8.png

在预测结果后,有时需要对输出数据进行后处理,例如合并多个分类以减少预测误差或合并多个标签。 光学字符识别中的一个示例是分类结果是根据每个预测字符,并且通过结合字符识别结果来构造一个词。 这意味着我们可以创建一种后处理方法来纠正检测到的单词中的错误。通过对计算机视觉的机器学习的这个小介绍,我们将实现我们自己的应用,使用机器学习来对幻灯片中的对象进行分类。 我们将使用支持向量机作为我们的分类方法,并解释如何使用它们。 其他机器学习算法的使用方式非常相似。 OpenCV 文档在以下链接中提供了有关所有机器学习算法的详细信息:https://docs.opencv.org/master/dd/ded/group__ml.html

物体自动检测分类示例

第 5 章自动光学检查、对象分割和检测中,我们查看了一个自动对象检查分段的示例,其中载体带包含三种不同类型的对象:螺母、螺丝和环。 有了计算机视觉,我们将能够识别其中的每一个,这样我们就可以向机器人发送通知,或者把每个机器人放在不同的盒子里。 以下是载带的基本示意图:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/9bfe6d8e-13bf-48fa-83b3-cb16df9a7bf3.png

第 5 章自动光学检测对象分割中,我们对输入图像进行预处理并提取感兴趣区域,使用不同的技术分离每个对象。 现在,我们将应用本示例中前面几节中解释的所有概念来提取特征并对每个对象进行分类,从而允许机器人将每个对象放入不同的盒子中。 在我们的应用中,我们不会只显示每个图像的标签,但我们可以将图像中的位置和标签发送给其他设备,如机器人。 此时,我们的目标是提供具有不同对象的输入图像,允许计算机检测对象并在每个图像上显示对象的名称,如下图所示。 然而,为了了解整个过程的步骤,我们将通过创建一个曲线图来训练我们的系统,以显示我们要使用的功能分布,并用不同的颜色将其可视化。 我们还将展示预处理后的输入图像,以及得到的输出分类结果。 最终结果看起来如下:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/a28e6100-2ad6-4886-af59-30291047ea19.png

对于我们的示例应用,我们将遵循以下步骤:

  1. 对于每个输入图像:

    • 对图像进行预处理
    • 分割图像
  2. 对于图像中的每个对象:

    • 提取要素
    • 将特征添加到带有相应标签(螺母、螺钉、环)的训练特征向量
  3. 创建一个 SVM 模型。

  4. 用训练后的特征向量训练支持向量机模型。

  5. 对输入图像进行预处理,对每个分割对象进行分类。

  6. 分割输入图像。

  7. 对于检测到的每个对象:

    • 提取要素
    • 用支持向量机进行预测
    • 模型 / 模式 / 模范 / 时装模特儿
    • 在输出图像中绘制结果

对于预处理和分割,我们将使用第 5 章自动光学检测对象分割和检测中的代码。 然后我们将解释如何提取特征并创建训练预测我们的模型所需的向量。

特征提取

接下来我们需要做的是提取每个对象的特征。 为了理解特征向量的概念,我们将在示例中提取非常简单的特征,因为这足以获得良好的结果。 在其他解决方案中,我们可以获得更复杂的特征,如纹理描述符、轮廓描述符等。在我们的示例中,我们只有图像中不同位置和方向的螺母、环和螺钉。 同一对象可以位于图像和方向的任何位置,例如,螺钉或螺母。 我们可以在下图中看到不同的方向:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/976b4119-460a-449d-8f38-272470d50551.png

我们将探索一些特征或特征,这些特征可以提高我们机器学习算法的准确性。 我们的不同对象(螺母、螺丝和环)的这些可能特征如下:

  • 对象的面积
  • 长宽比,即宽度除以边界矩形的高度
  • 孔洞的数量
  • 等高线边数

这些特征可以很好地描述我们的对象,如果我们全部使用,分类误差会很小。 但是,在我们实现的示例中,我们只打算使用前两个特征(面积和纵横比)来学习,因为我们可以在 2D 图形中绘制这些特征,并显示这些值能够正确地描述我们的对象。 我们还可以说明,在图形情节中,我们可以在视觉上区分一种对象和另一种对象。 为了提取这些特征,我们将使用黑/白 ROI 图像作为输入,其中只有一个对象以白色显示,背景为黑色。 该输入是第 5 章自动光学检测对象分割和检测的分割结果。 我们将使用findCountours算法分割对象,并为此创建ExtractFeatures函数,如下面的代码所示:

vector< vector<float> > ExtractFeatures(Mat img, vector<int>* left=NULL, vector<int>* top=NULL) 
{ 
  vector< vector<float> > output; 
  vector<vector<Point> > contours; 
  Mat input= img.clone(); 

  vector<Vec4i> hierarchy; 
  findContours(input, contours, hierarchy, RETR_CCOMP, CHAIN_APPROX_SIMPLE); 
  // Check the number of objects detected 
  if(contours.size() == 0){ 
    return output; 
  } 
  RNG rng(0xFFFFFFFF); 
  for(auto i=0; i<contours.size(); i++){ 

    Mat mask= Mat::zeros(img.rows, img.cols, CV_8UC1); 
    drawContours(mask, contours, i, Scalar(1), FILLED, LINE_8, hierarchy, 1); 
    Scalar area_s= sum(mask); 
    float area= area_s[0]; 

    if(area>500){ //if the area is greater than min. 

      RotatedRect r= minAreaRect(contours[i]); 
      float width= r.size.width; 
      float height= r.size.height; 
      float ar=(width<height)?height/width:width/height; 

      vector<float> row; 
      row.push_back(area); 
      row.push_back(ar); 
      output.push_back(row); 
      if(left!=NULL){ 
          left->push_back((int)r.center.x); 
      } 
      if(top!=NULL){ 
          top->push_back((int)r.center.y); 
      } 

      // Add image to the multiple image window class, See the class on full github code   
      miw->addImage("Extract Features", mask*255); 
      miw->render(); 
      waitKey(10); 
    } 
  } 
  return output; 
} 

让我们解释一下我们用来提取特征的代码。 我们将创建一个函数,该函数将一张图像作为输入,并返回图像中检测到的每个对象的左位置和顶部位置的两个向量作为参数。 这些数据将用于在每个对象上绘制相应的标签。 函数的输出是浮点数向量的向量。 换句话说,它是一个矩阵,其中每一行都包含检测到的每个对象的特征。

首先,我们必须创建将在查找轮廓算法分割中使用的输出向量变量和轮廓变量。 我们还必须创建输入图像的副本,因为前面的findCoutoursOpenCV 函数修改输入图像:

  vector< vector<float> > output; 
  vector<vector<Point> > contours; 
  Mat input= img.clone(); 
  vector<Vec4i> hierarchy; 
  findContours(input, contours, hierarchy, RETR_CCOMP, CHAIN_APPROX_SIMPLE); 

现在,我们可以使用findContours函数来检索图像中的每个对象。 如果没有检测到任何轮廓,则返回一个空的输出矩阵,如下面的代码片断所示:

if(contours.size() == 0){ 
    return output; 
  } 

如果检测到所有对象,对于每个轮廓,我们将在黑色图像(零值)上绘制白色对象。 这将使用1值来完成,就像遮罩图像一样。 以下代码生成蒙版图像:

for(auto i=0; i<contours.size(); i++){ 
    Mat mask= Mat::zeros(img.rows, img.cols, CV_8UC1); 
    drawContours(mask, contours, i, Scalar(1), FILLED, LINE_8, hierarchy, 1); 

使用1的值在形状内部绘制非常重要,因为我们可以通过将轮廓内的所有值相加来计算面积,如以下代码所示:

    Scalar area_s= sum(mask); 
    float area= area_s[0]; 

这个区域是我们的第一个特色。 我们将使用该值作为筛选器,以删除我们必须避免的所有可能的小对象。 面积小于我们考虑的最小阈值面积的所有对象都将被丢弃。 通过过滤器后,我们创建第二个特征和对象的纵横比。 这是指宽度或高度的最大值除以宽度或高度的最小值。 此功能可以很容易地区分螺丝和其他物体。 下面的代码描述了如何计算纵横比:

if(area>MIN_AREA){ //if the area is greater than min. 
      RotatedRect r= minAreaRect(contours[i]); 
      float width= r.size.width; 
      float height= r.size.height; 
      float ar=(width<height)?height/width:width/height; 

现在我们有了特征,我们只需将它们添加到输出向量中。 为此,我们将创建一个浮点数的行向量并将这些值相加,然后将此行添加到输出向量,如以下代码所示:

vector<float> row; 
row.push_back(area); 
row.push_back(ar); 
output.push_back(row);

如果传递了 Left 和 top 参数,则将左上角的值相加以输出参数:

  if(left!=NULL){ 
      left->push_back((int)r.center.x); 
  }
  if(top!=NULL){ 
      top->push_back((int)r.center.y); 
  } 

最后,我们将在窗口中显示检测到的对象以供用户反馈。 处理完图像中的所有对象后,我们将返回输出特征向量,如以下代码片段所述:

      miw->addImage("Extract Features", mask*255); 
      miw->render(); 
      waitKey(10); 
    } 
  } 
  return output; 

现在我们已经提取了每个输入图像的特征,我们可以继续下一步。

训练支持向量机模型

现在我们将使用监督学习,然后获得每个对象的一组图像及其对应的标签。 数据集中没有图像的最小数量;如果我们为训练过程提供更多的图像,我们将获得更好的分类模型(在大多数情况下)。 然而,对于简单的分类器,训练简单的模型就足够了。 为此,我们创建了三个文件夹(screwnutring),每种类型的所有图像都放在一起。对于文件夹中的每个图像,我们必须提取特征,将它们添加到train特征矩阵中,同时创建一个新的向量,每行的标签对应于每个训练矩阵。 为了评估我们的系统,我们将根据测试和培训将每个文件夹拆分成多个图像。 我们将留下大约 20 个图像用于测试,其余的用于培训。 然后,我们将创建两个标签向量和两个矩阵,用于训练和测试。

让我们进入我们的代码内部。 首先,我们必须创建我们的模型。 我们将在所有函数中声明该模型,以便能够将其作为全局变量访问。 OpenCV 使用Ptr模板类进行指针管理:

Ptr<SVM> svm;

在声明指向新的 SVM 模型的指针之后,我们将创建它并训练它。 为此,我们创建了trainAndTest函数。 完整的功能代码如下:

void trainAndTest() 
{ 
  vector< float > trainingData; 
  vector< int > responsesData; 
  vector< float > testData; 
  vector< float > testResponsesData; 

  int num_for_test= 20; 

  // Get the nut images 
  readFolderAndExtractFeatures("../data/nut/nut_%04d.pgm", 0, num_for_test, trainingData, responsesData, testData, testResponsesData); 
  // Get and process the ring images 
  readFolderAndExtractFeatures("../data/ring/ring_%04d.pgm", 1, num_for_test, trainingData, responsesData, testData, testResponsesData); 
  // get and process the screw images 
  readFolderAndExtractFeatures("../data/screw/screw_%04d.pgm", 2, num_for_test, trainingData, responsesData, testData, testResponsesData); 

  cout << "Num of train samples: " << responsesData.size() << endl; 

  cout << "Num of test samples: " << testResponsesData.size() << endl; 

  // Merge all data  
  Mat trainingDataMat(trainingData.size()/2, 2, CV_32FC1, &trainingData[0]); 
  Mat responses(responsesData.size(), 1, CV_32SC1, &responsesData[0]); 

  Mat testDataMat(testData.size()/2, 2, CV_32FC1, &testData[0]); 
  Mat testResponses(testResponsesData.size(), 1, CV_32FC1, &testResponsesData[0]); 

  Ptr<TrainData> tdata= TrainData::create(trainingDataMat, ROW_SAMPLE, responses);

  svm = cv::ml::SVM::create();
  svm->setType(cv::ml::SVM::C_SVC);
  svm->setNu(0.05); 
  svm->setKernel(cv::ml::SVM::CHI2);
  svm->setDegree(1.0);
  svm->setGamma(2.0);
  svm->setTermCriteria(TermCriteria(TermCriteria::MAX_ITER, 100, 1e-6));
  svm->train(tdata); 

  if(testResponsesData.size()>0){ 
    cout << "Evaluation" << endl; 
    cout << "==========" << endl; 
    // Test the ML Model 
    Mat testPredict; 
    svm->predict(testDataMat, testPredict); 
    cout << "Prediction Done" << endl; 
    // Error calculation 
    Mat errorMat= testPredict!=testResponses; 
    float error= 100.0f * countNonZero(errorMat) / testResponsesData.size(); 
    cout << "Error: " << error << "%" << endl; 
    // Plot training data with error label 
    plotTrainData(trainingDataMat, responses, &error); 

  }else{ 
    plotTrainData(trainingDataMat, responses); 
  } 
} 

现在,让我们解释一下代码。 首先,我们必须创建存储训练和测试数据所需的变量:

  vector< float > trainingData; 
  vector< int > responsesData; 
  vector< float > testData; 
  vector< float > testResponsesData; 

正如我们前面提到的,我们必须读取每个文件夹中的所有图像,提取特征,并将它们保存在我们的培训和测试数据中。 为此,我们将使用readFolderAndExtractFeatures函数,如下所示:

  int num_for_test= 20; 
  // Get the nut images 
  readFolderAndExtractFeatures("../data/nut/tuerca_%04d.pgm", 0, num_for_test, trainingData, responsesData, testData, testResponsesData); 
  // Get and process the ring images 
  readFolderAndExtractFeatures("../data/ring/arandela_%04d.pgm", 1, num_for_test, trainingData, responsesData, testData, testResponsesData); 
  // get and process the screw images 
  readFolderAndExtractFeatures("../data/screw/tornillo_%04d.pgm", 2, num_for_test, trainingData, responsesData, testData, testResponsesData); 

readFolderAndExtractFeatures函数使用VideoCaptureOpenCV 函数读取文件夹中的所有图像,包括视频和摄像头帧。 对于读取的每个图像,我们提取特征并将它们添加到相应的输出向量中:

bool readFolderAndExtractFeatures(string folder, int label, int num_for_test,  
  vector<float> &trainingData, vector<int> &responsesData,   
  vector<float> &testData, vector<float> &testResponsesData) 
{ 
  VideoCapture images; 
  if(images.open(folder)==false){ 
    cout << "Can not open the folder images" << endl; 
    return false; 
  } 
  Mat frame; 
  int img_index=0; 
  while(images.read(frame)){ 
    //// Preprocess image 
    Mat pre= preprocessImage(frame); 
    // Extract features 
    vector< vector<float> > features= ExtractFeatures(pre); 
    for(int i=0; i< features.size(); i++){ 
      if(img_index >= num_for_test){ 
        trainingData.push_back(features[i][0]); 
        trainingData.push_back(features[i][1]); 
        responsesData.push_back(label);     
      }else{ 
        testData.push_back(features[i][0]); 
        testData.push_back(features[i][1]); 
        testResponsesData.push_back((float)label);     
      } 
    } 
    img_index++ ; 
  } 
  return true;   
} 

在用特征和标签填充所有矢量之后,我们必须将矢量转换为 OpenCVMat格式,以便可以将其发送到训练函数:

// Merge all data  
Mat trainingDataMat(trainingData.size()/2, 2, CV_32FC1, &trainingData[0]); 
Mat responses(responsesData.size(), 1, CV_32SC1, &responsesData[0]); 
Mat testDataMat(testData.size()/2, 2, CV_32FC1, &testData[0]); 
Mat testResponses(testResponsesData.size(), 1, CV_32FC1, &testResponsesData[0]); 

现在,我们准备创建和训练我们的机器学习模型。 如前所述,我们将使用支持向量机来实现这一点。 首先,我们将设置基本模型参数,如下所示:

// Set up SVM's parameters 
svm = cv::ml::SVM::create();
svm->setType(cv::ml::SVM::C_SVC);
svm->setNu(0.05);
svm->setKernel(cv::ml::SVM::CHI2);
svm->setDegree(1.0);
svm->setGamma(2.0);
svm->setTermCriteria(TermCriteria(TermCriteria::MAX_ITER, 100, 1e-6));

我们现在将定义要使用的 SVM 类型和内核,以及停止学习过程的标准。 在我们的例子中,我们将使用最大迭代次数,在 100 次迭代时停止。 有关每个参数及其作用的更多信息,请查看位于以下链接的 OpenCV 文档:和 https://docs.opencv.org/master/d1/d2d/classcv_1_1ml_1_1SVM.html。 创建设置参数后,我们将通过调用train方法并使用trainingDataMat和响应矩阵作为TrainData对象来创建模型:

  // Train the SVM 
  svm->train(tdata); 

我们使用测试向量(将num_for_test变量设置为大于0)来获得模型的近似误差。 为了得到误差估计,我们将对所有测试向量特征进行预测,以获得 SVM 预测结果,并将这些结果与原始标签进行比较:

if(testResponsesData.size()>0){ 
    cout << "Evaluation" << endl; 
    cout << "==========" << endl; 
    // Test the ML Model 
    Mat testPredict; 
    svm->predict(testDataMat, testPredict); 
    cout << "Prediction Done" << endl; 
    // Error calculation 
    Mat errorMat= testPredict!=testResponses; 
    float error= 100.0f * countNonZero(errorMat) / testResponsesData.size(); 
    cout << "Error: " << error << "%" << endl; 
    // Plot training data with error label 
    plotTrainData(trainingDataMat, responses, &error); 

  }else{ 
    plotTrainData(trainingDataMat, responses); 
  } 

我们通过使用testDataMat特性和一个新的Mat预测结果来使用predict函数。 predict函数使得同时进行多个预测成为可能,即给出一个矩阵作为结果,而不是只给出一行或向量。预测之后,我们只需计算testPredict与我们的testResponses(原始标签)的差值。 如果有差异,我们只需要数一下有多少,然后除以测试的总数,就可以计算出误差。

We can use the new TrainData class to generate the feature vectors, samples, and split our train data between test and train vectors.

最后,我们将在 2D 绘图中显示训练数据,其中y轴是纵横比特征,x轴是对象的面积。 每个点都有不同的颜色和形状(十字形、正方形和圆形)来显示每种不同的对象,我们可以清楚地看到下图中的对象组:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/d2a6e95f-dcae-4d00-b9be-ca716c002118.png

我们现在非常接近完成我们的应用示例。 在这一点上,我们已经训练了 SVM 模型;我们现在可以将其用于分类,以检测新传入和未知特征向量的类型。 下一步是预测具有未知对象的输入图像。

输入图像预测

现在我们准备解释主函数,该函数加载输入图像并预测其中出现的对象。 我们将使用类似以下图片的内容作为输入图像。 在这里,多个不同的物体出现在图像中。 我们没有这些的标签或名称,但计算机必须能够识别它们:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/4e73b0df-745d-49e4-8930-3be0fd31402b.png

与所有训练图像一样,我们必须加载并预处理输入图像,如下所示:

  1. 首先,我们加载图像并将其转换为灰色值。
  2. 然后,我们使用preprocessImage函数应用预处理任务(如我们在第 5 章自动光学检查离子、对象分割和检测中了解到的)
    Mat pre= preprocessImage(img); 
  1. 现在,我们将使用前面描述的ExtractFeatures来提取图像中出现的所有对象的向量特征以及每个对象的左上角位置:
    // Extract features 
    vector<int> pos_top, pos_left; 
    vector< vector<float> >
    features=ExtractFeatures(pre, &pos_left,     &pos_top); 
  1. 我们将检测到的每个对象存储为特征行,然后将每行转换为一行和两个特征的Mat
     for(int i=0; i< features.size(); i++){ 
         Mat trainingDataMat(1, 2, CV_32FC1, &features[i][0]);
  1. 之后,我们可以使用我们的StatModel支持向量机的predict函数对单个目标进行预测,预测的浮动结果就是检测到的目标的标签。 然后,要完成应用,我们必须在输出图像上绘制检测到并分类的每个对象的标签:
     float result= svm->predict(trainingDataMat); 
  1. 我们将使用stringstream存储文本,使用Scalar存储每个不同标签的颜色:
     stringstream ss; 
     Scalar color; 
     if(result==0){ 
       color= green; // NUT 
       ss << "NUT"; 
     }else if(result==1){ 
       color= blue; // RING 
       ss << "RING" ; 
     }else if(result==2){ 
       color= red; // SCREW 
       ss << "SCREW"; 
     } 
  1. 我们还将使用在ExtractFeatures函数中检测到的位置在每个对象上绘制标签文本:
     putText(img_output,  
           ss.str(),  
           Point2d(pos_left[i], pos_top[i]),  
           FONT_HERSHEY_SIMPLEX,  
           0.4,  
           color); 
  1. 最后,我们将在输出窗口中绘制结果:
       miw->addImage("Binary image", pre); 
       miw->addImage("Result", img_output); 
       miw->render(); 
       waitKey(0); 

我们的应用的最终结果显示了一个由四个屏幕组成的平铺窗口。 这里,左上角是输入训练图像,右上角是剧情训练图像,左下角是分析预处理图像的输入图像,右下角是预测的最终结果:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/90496da6-b911-4085-b9a4-a68635503497.png

简略的 / 概括的 / 简易判罪的 / 简易的

在本章中,我们学习了机器学习的基础知识,并将其应用到一个小示例应用中。 这让我们了解了可以用来创建我们自己的机器学习应用的基本技术。机器学习很复杂,每个用例都涉及不同的技术(监督学习、无监督学习、聚类等)。 我们还学习了如何使用支持向量机创建最典型的机器学习应用-监督学习应用。监督机器学习中最重要的概念如下:您必须有适当数量的样本或数据集,您必须准确地选择描述我们对象的特征(有关图像特征的更多信息,请转到第 8 章视频监控背景建模和形态运算) 你必须选择一个能给出最好预测的模型。

如果我们没有得到正确的预测,我们必须检查这些概念中的每一个来发现问题所在。

在下一章中,我们将介绍背景减去方法,这些方法在视频监控应用中非常有用,因为在视频监控应用中,背景没有给我们任何有趣的信息,必须将其丢弃,以便我们可以分割图像来检测和分析图像对象。

七、检测人脸部位和覆盖面具

第 6 章学习对象分类中,我们了解了对象分类以及如何使用机器学习来实现对象分类。 在本章中,我们将学习如何检测和跟踪不同的人脸部位。 我们将从了解人脸检测管道及其构建方式开始讨论。 然后,我们将使用此框架来检测人脸部分,如眼睛、耳朵、嘴巴和鼻子。 最后,我们将学习如何在直播视频中将有趣的面具覆盖在这些人脸部位上。

学完本章后,我们应该熟悉以下主题:

  • 了解哈尔叶栅
  • 整体图像以及我们为什么需要它们
  • 构建通用人脸检测流水线
  • 检测和跟踪来自网络摄像头的实时视频流中的人脸、眼睛、耳朵、鼻子和嘴巴
  • 在视频中自动将口罩、太阳镜和滑稽的鼻子叠加在人的脸上

技术要求

本章要求基本熟悉 C++ 编程语言。 本章中使用的所有代码都可以从以下 gihub 链接下载:*https://github.com/PacktPublishing/Building-Computer-Vision-Projects-with-OpenCV4-and-CPlusPlus/tree/master/Chapter07。*这些代码可以在任何操作系统上执行,尽管它只在 Ubuntu 上测试过。

请查看以下视频,了解实际操作中的代码:
http://bit.ly/2SlpTK6

了解哈尔叶栅

Haar 级联是基于 Haar 特征的级联分类器。 什么是级联分级机? 它只是一组可用于创建强分类器的弱分类器的串联。 我们所说的分类器是什么意思? 弱分类器是性能有限的分类器。 他们没有能力正确地对所有东西进行分类。 如果你让问题变得非常简单,他们可能会表现在可以接受的水平。 另一方面,强分类器非常擅长对我们的数据进行正确的分类。 在接下来的几个段落中,我们将看到这一切是如何结合在一起的。 Haar 级联的另一个重要部分是Haar 特性。 这些特征是矩形和图像上这些区域的差异的简单总和。 让我们考虑下图:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/be96e5d1-f7cc-4367-b344-2344f2c9d0c0.png

如果要计算 ABCD 区域的 Haar 特征,只需计算该区域的白色像素和蓝色像素之间的差值。 正如我们从这四个图中看到的,我们使用不同的模式来构建 Haar 特性。 还有很多其他的模式也在使用。 我们在多个尺度上这样做,以使系统尺度不变。 当我们说多个比例时,我们只是缩小图像以再次计算相同的特征。 这样,我们就可以使其对给定对象的大小变化具有健壮性。

As it turns out, this concatenation system is a very good method for detecting objects in an image. In 2001, Paul Viola and Michael Jones published a seminal paper where they described a fast and effective method for object detection. If you are interested in learning more about it, you can check out their paper at http://www.cs.ubc.ca/~lowe/425/slides/13-ViolaJones.pdf.

让我们更深入地了解他们到底做了什么。 他们基本上描述了一种使用增强型简单分类器级联的算法。 这个系统被用来构建一个性能非常好的强大分类器。 为什么他们用这些简单的量词,而不是复杂的量词,因为复杂的量词可以更准确? 嗯,使用这项技术,他们能够避免必须建立一个可以高精度执行的单一分类器的问题。 这些单步分类器往往比较复杂且计算密集。 他们的技术如此有效的原因是因为简单的量词可能是较弱的学习者,这意味着他们不需要变得复杂。 考虑构建表检测器的问题。 我们想要构建一个系统,该系统将自动学习表格的外观。 基于这些知识,它应该能够识别在任何给定图像中是否有表。 要建立这个系统,第一步是收集图像,这些图像可以用来训练我们的系统。 在机器学习领域,有很多技术可以用来训练这样的系统。 请记住,如果我们希望我们的系统运行良好,我们需要收集大量的表格和非表格图像。 在机器学习术语中,表格图像称为样本,非表格图像称为样本。 我们的系统将摄取这些数据,然后学习区分这两个类别。为了建立一个实时系统,我们需要保持分类器的精致和简单。 唯一令人担忧的是,简单的分类器不是很准确。 如果我们试图使它们更准确,那么这个过程最终将是计算密集型的,因此速度很慢。 这种精度和速度之间的权衡在机器学习中非常常见。 因此,我们通过连接一组弱分类器来创建一个强而统一的分类器来克服这个问题。 我们不需要弱分类器非常准确。 为了确保整体分类器的质量,Viola 和 Jones 在级联步骤中描述了一种巧妙的技术。 你可以通读这篇论文来了解整个系统。

现在我们已经了解了一般流程,让我们看看如何构建一个可以在实时视频中检测人脸的系统。 第一步是从所有图像中提取特征。 在这种情况下,算法需要这些功能来学习和理解人脸的样子。 他们在论文中使用了 Haar 特征来构建特征向量。 一旦我们提取了这些特征,我们就将它们通过一系列分类器。 我们只检查所有不同的矩形子区域,并不断丢弃其中没有人脸的区域。 这样,我们就可以快速得到最终答案,看看给定的矩形是否包含人脸。

什么是整体图像?

为了提取这些 Haar 特征,我们必须计算包含在图像的许多矩形区域中的像素值的总和。 为了使其具有比例不变性,我们需要在多个比例下计算这些面积(对于不同的矩形大小)。 如果实现得很幼稚,这将是一个计算非常密集的过程;因为我们将不得不迭代每个矩形的所有像素,包括多次读取相同的像素(如果它们包含在不同的重叠矩形中)。 如果你想构建一个可以实时运行的系统,你不能在计算上花费这么多时间。 在面积计算过程中,我们需要找到一种方法来避免这种巨大的冗余,因为我们在相同的像素上迭代了多次。 为了避免这种情况,我们可以使用一种叫做积分图像的东西。 这些图像可以在线性时间初始化(通过在图像上仅迭代两次),然后通过仅读取四个值来提供任意大小的任意矩形内的像素总和。 为了更好地理解它,我们来看下图:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/fcc7d5ec-5fb2-4754-97e1-9715daf3d335.png

如果我们想要计算图表中任何矩形的面积,我们不必遍历该区域中的所有像素。 让我们考虑一个由图像中左上角的点和任意点 P 组成的矩形作为对角点。 设 AP表示该矩形的面积。 例如,在上图中,AB表示将左上点和B作为对角点形成的 5x2 矩形的面积。 为了清楚起见,让我们看一下下图:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/870b00bd-37e3-4957-b162-3fa378e15e8c.png

让我们考虑一下上图中的左上角正方形。 蓝色像素表示左上角像素和点A之间的区域。 这由 AA表示。 其余的图表由各自的名称表示:AB、AC和 AD。 现在,如果我们要计算矩形的面积,如上图所示,我们将使用以下公式:

矩形面积ABCD=AC-(AB+AD-AA)

这个特别的配方有什么特别之处? 众所周知,从图像中提取 Haar 特征包括计算这些求和,我们将不得不对图像中许多不同尺度的矩形进行求和。 很多这样的计算都是重复的,因为我们会一遍又一遍地重复相同的像素。 速度如此之慢,以至于建立一个实时系统是不可行的。 因此,我们需要这个公式。 如您所见,我们不必多次迭代相同的像素。 如果我们要计算任何矩形的面积,前面公式右侧的所有值在积分图像中都很容易得到。 我们只需选取正确的值,将它们替换到前面的方程式中,然后提取特征即可。

在直播视频中叠加口罩

OpenCV 提供了一个很好的人脸检测框架。 我们只需要加载级联文件,并使用它来检测图像中的人脸。 当我们从网络摄像头捕捉到视频流时,我们可以将滑稽的面具覆盖在我们的脸上。 它看起来如下所示:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/0f7a664d-669d-4ba2-baea-9af29b67c959.png

让我们看一下代码的主要部分,看看如何在输入视频流的人脸覆盖这个蒙版。 在随本书提供的可下载代码包中提供了完整的代码:

#include "opencv2/core/utility.hpp"
#include "opencv2/objdetect/objdetect.hpp"
#include "opencv2/imgproc.hpp"
#include "opencv2/highgui.hpp"

using namespace cv;
using namespace std;

...

int main(int argc, char* argv[]) 
{ 
    string faceCascadeName = argv[1]; 

    // Variable declaration and initialization 
    ...
    // Iterate until the user presses the Esc key 
    while(true) 
    { 
        // Capture the current frame 
        cap >> frame; 

        // Resize the frame 
        resize(frame, frame, Size(), scalingFactor, scalingFactor, INTER_AREA); 

        // Convert to grayscale 
        cvtColor(frame, frameGray, COLOR_BGR2GRAY); 

        // Equalize the histogram 
        equalizeHist(frameGray, frameGray); 

        // Detect faces 
        faceCascade.detectMultiScale(frameGray, faces, 1.1, 2, 0|HAAR_SCALE_IMAGE, Size(30, 30) ); 

让我们停下来看看这里发生了什么。 我们开始读取网络摄像头的输入帧,并根据我们选择的大小调整它的大小。 捕获的帧是彩色图像,人脸检测工作在灰度图像上进行。 因此,我们将其转换为灰度并均衡直方图。 为什么我们需要使直方图均衡? 我们需要这样做,以补偿任何问题,如照明或饱和度。 如果图像太亮或太暗,检测效果会很差。 因此,我们需要均衡直方图,以确保我们的图像具有健康的像素值范围:

        // Draw green rectangle around the face 
        for(auto& face:faces) 
        { 
            Rect faceRect(face.x, face.y, face.width, face.height); 

            // Custom parameters to make the mask fit your face. You may have to play around with them to make sure it works. 
            int x = face.x - int(0.1*face.width); 
            int y = face.y - int(0.0*face.height); 
            int w = int(1.1 * face.width); 
            int h = int(1.3 * face.height); 

            // Extract region of interest (ROI) covering your face 
            frameROI = frame(Rect(x,y,w,h));

在这一点上,我们知道脸在哪里了。 因此,我们提取感兴趣的区域以在正确的位置覆盖蒙版:

            // Resize the face mask image based on the dimensions of the above ROI 
            resize(faceMask, faceMaskSmall, Size(w,h)); 

            // Convert the previous image to grayscale 
            cvtColor(faceMaskSmall, grayMaskSmall, COLOR_BGR2GRAY); 

            // Threshold the previous image to isolate the pixels associated only with the face mask 
            threshold(grayMaskSmall, grayMaskSmallThresh, 230, 255, THRESH_BINARY_INV); 

我们分离出与面罩相关的像素。 我们希望以这样一种方式覆盖蒙版,使其看起来不像一个矩形。 我们希望得到覆盖对象的精确边界,以便它看起来很自然。 现在让我们继续覆盖面具:

            // Create mask by inverting the previous image (because we don't want the background to affect the overlay) 
            bitwise_not(grayMaskSmallThresh, grayMaskSmallThreshInv); 

            // Use bitwise "AND" operator to extract precise boundary of face mask 
            bitwise_and(faceMaskSmall, faceMaskSmall, maskedFace, grayMaskSmallThresh); 

            // Use bitwise "AND" operator to overlay face mask 
            bitwise_and(frameROI, frameROI, maskedFrame, grayMaskSmallThreshInv); 

            // Add the previously masked images and place it in the original frame ROI to create the final image 
            add(maskedFace, maskedFrame, frame(Rect(x,y,w,h))); 
        } 

    // code dealing with memory release and GUI 

    return 1; 
} 

代码里发生了什么?

首先要注意的是,这段代码有两个输入参数-face ascade XML文件和掩码图像。 您可以使用resources文件夹下提供的haarcascade_frontalface_alt.xmlfacemask.jpg文件。 我们需要一个可用于检测图像中人脸的分类器模型,OpenCV 提供了一个可用于此目的的预构建 XML 文件。 我们使用faceCascade.load()函数加载 XML 文件,并检查文件是否加载正确。 我们启动视频捕获对象来捕获来自网络摄像头的输入帧。 然后我们将其转换为灰度以运行检测器。 函数的作用是提取输入图像中所有人脸的边界。 我们可能需要根据需要缩小图像,因此此函数中的第二个参数可以解决这一问题。 此比例因子是我们在每个比例下进行的跳跃;由于我们需要在多个比例下查找面,因此下一个大小将是当前大小的 1.1 倍。 最后一个参数是阈值,它指定保留当前矩形所需的相邻矩形的数量。 它可以用来增加人脸检测器的鲁棒性。 我们开始while循环,并在每一帧中持续检测人脸,直到用户按下Esc键。 一旦我们检测到一张脸,我们需要在它上面覆盖一个面具。 我们可能需要稍微修改一下尺寸,以确保面罩合身。 此自定义稍有主观性,它取决于所使用的遮罩。 现在我们已经提取了感兴趣的区域,我们需要将遮罩放置在该区域的顶部。 如果我们用白色背景覆盖面具,它看起来会很奇怪。 我们必须提取蒙版的精确曲线边界,然后将其覆盖。 我们希望头骨蒙版像素是可见的,其余区域应该是透明的。

正如我们所看到的,输入掩码的背景是白色的。 因此,我们通过对蒙版图像应用阈值来创建蒙版。 使用试错法,我们可以看到阈值240运行良好。 在图像中,强度值大于240的所有像素将变为0,所有其他像素将变为255。 就感兴趣的区域而言,我们必须将该区域内的所有像素都涂黑。 要做到这一点,我们只需使用刚刚创建的蒙版的反面。 在最后一步中,我们只需添加掩码版本即可生成最终输出图像。

戴上你的太阳镜

既然我们了解了如何检测人脸,我们就可以将这个概念推广到检测人脸的不同部位。 我们将在直播视频中使用眼睛探测器覆盖太阳镜。 重要的是要理解 Viola-Jones 框架可以应用于任何对象。 准确性和健壮性将取决于对象的唯一性。 例如,人脸有非常独特的特征,所以很容易训练我们的系统变得健壮。 另一方面,像毛巾这样的物体太通用了,没有明显的特征,所以很难构建一个健壮的毛巾检测器。 一旦你建造了眼睛探测器并覆盖了眼镜,它看起来就像这样:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/7a39d1f6-ea84-42df-938f-9a5137fc31da.png

让我们看一下代码的主要部分:

...
int main(int argc, char* argv[]) 
{ 
    string faceCascadeName = argv[1]; 
    string eyeCascadeName = argv[2]; 

    // Variable declaration and initialization
    ....
    // Face detection code 
    ....
    vector<Point> centers; 
    ....     
    // Draw green circles around the eyes 
    for( auto& face:faces ) 
    { 
        Mat faceROI = frameGray(face[i]); 
        vector<Rect> eyes; 

        // In each face, detect eyes eyeCascade.detectMultiScale(faceROI, eyes, 1.1, 2, 0 |CV_HAAR_SCALE_IMAGE, Size(30, 30)); 

正如我们在这里看到的,我们只在脸部区域运行眼睛检测器。 我们不需要在整张图片中搜索眼睛,因为我们知道眼睛总是在一张脸上:

            // For each eye detected, compute the center 
            for(auto& eyes:eyes) 
            { 
                Point center( face.x + eye.x + int(eye.width*0.5), face.y + eye.y + int(eye.height*0.5) ); 
                centers.push_back(center); 
            } 
        } 

        // Overlay sunglasses only if both eyes are detected 
        if(centers.size() == 2) 
        { 
            Point leftPoint, rightPoint; 

            // Identify the left and right eyes 
            if(centers[0].x < centers[1].x) 
            { 
                leftPoint = centers[0]; 
                rightPoint = centers[1]; 
            } 
            else 
            { 
                leftPoint = centers[1]; 
                rightPoint = centers[0]; 
            } 

只有当我们找到这两只眼睛时,我们才能探测到它们并将它们储存起来。 然后我们使用它们的坐标来确定哪个是左眼,哪个是右眼:

            // Custom parameters to make the sunglasses fit your face. You may have to play around with them to make sure it works. 
            int w = 2.3 * (rightPoint.x - leftPoint.x); 
            int h = int(0.4 * w); 
            int x = leftPoint.x - 0.25*w; 
            int y = leftPoint.y - 0.5*h; 

            // Extract region of interest (ROI) covering both the eyes 
            frameROI = frame(Rect(x,y,w,h)); 

            // Resize the sunglasses image based on the dimensions of the above ROI 
            resize(eyeMask, eyeMaskSmall, Size(w,h)); 

在前面的代码中,我们调整了太阳镜的大小,以适应网络摄像头上我们的脸的比例。 让我们检查一下剩余的代码:

            // Convert the previous image to grayscale 
            cvtColor(eyeMaskSmall, grayMaskSmall, COLOR_BGR2GRAY); 

            // Threshold the previous image to isolate the foreground object 
            threshold(grayMaskSmall, grayMaskSmallThresh, 245, 255, THRESH_BINARY_INV); 

            // Create mask by inverting the previous image (because we don't want the background to affect the overlay) 
            bitwise_not(grayMaskSmallThresh, grayMaskSmallThreshInv); 

            // Use bitwise "AND" operator to extract precise boundary of sunglasses 
            bitwise_and(eyeMaskSmall, eyeMaskSmall, maskedEye, grayMaskSmallThresh); 

            // Use bitwise "AND" operator to overlay sunglasses 
            bitwise_and(frameROI, frameROI, maskedFrame, grayMaskSmallThreshInv); 

            // Add the previously masked images and place it in the original frame ROI to create the final image 
            add(maskedEye, maskedFrame, frame(Rect(x,y,w,h))); 
        } 

        // code for memory release and GUI 

    return 1; 
} 

查看代码内部

您可能已经注意到,代码流看起来与我们在直播视频中的覆盖面膜部分中讨论的人脸检测代码类似。 我们加载了人脸检测级联分类器和眼睛检测级联分类器。 那么,为什么我们在检测眼睛的时候需要加载人脸级联分类器呢? 嗯,我们并不真的需要使用人脸检测器,但它可以帮助我们限制对眼睛位置的搜索。 我们知道眼睛总是位于某人的脸上,所以我们可以将眼睛检测限制在人脸区域。 第一步是检测脸部,然后在这个区域运行我们的眼睛探测器代码。 由于我们将在较小的地区开展业务,因此速度会更快,效率也会更高。

对于每一帧,我们从检测人脸开始。 然后我们继续对这个区域进行手术来检测眼睛的位置。 在这一步之后,我们需要覆盖太阳镜。 要做到这一点,我们需要调整太阳镜图像的大小,以确保它适合我们的脸。 为了获得合适的比例,我们可以考虑被检测的两只眼睛之间的距离。 只有当我们察觉到两只眼睛时,我们才会戴上太阳镜。 这就是为什么我们首先运行眼睛探测器,收集所有的中心,然后覆盖太阳镜。 一旦我们有了这个,我们只需要盖上太阳镜面具。 用于遮罩的原理与我们用于覆盖面膜的原理非常相似。 您可能需要定制太阳镜的大小和位置,具体取决于您想要的。 你可以玩不同类型的太阳镜,看看它们是什么样子。

跟踪鼻子、嘴巴和耳朵

既然您知道了如何使用该框架跟踪不同的东西,那么您也可以尝试跟踪您的鼻子、嘴巴和耳朵了。 让我们用鼻子探测器覆盖一个滑稽的鼻子:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/0e21d6b6-9acb-4906-bf30-bc9097a5c0b2.png

您可以参考代码文件了解该检测器的完整实现。 haarcascade_mcs_nose.xmlhaarcascade_mcs_mouth.xmlhaarcascade_mcs_leftear.xmlhaarcascade_mcs_rightear.xml级联文件可用于跟踪不同的人脸分。 和他们一起玩耍,试着把胡子或德古拉耳朵盖在自己身上。

简略的 / 概括的 / 简易判罪的 / 简易的

在这一章中,我们讨论了 Haar 级联和积分像。 我们了解了人脸检测管道是如何构建的。 我们学习了如何检测和跟踪实时视频流中的人脸。 我们讨论了使用人脸检测框架来检测各种人脸部位,如眼睛、耳朵、鼻子和嘴巴。 最后,我们学习了如何利用人脸部分检测的结果在输入图像上叠加蒙版。

在下一章中,我们将学习视频监控、背景去除和形态学图像处理。

八、视频监控、背景建模和形态学操作

在本章中,我们将学习如何在静态摄像机拍摄的视频中检测运动对象。 这在视频监控系统中被广泛使用。 我们将讨论可用于构建此系统的不同特征。 我们将学习背景建模,并了解如何使用它来构建实时视频中的背景模型。 一旦我们这样做了,我们将组合所有的块来检测视频中感兴趣的对象。

在本章结束时,您应该能够回答以下问题:

  • 什么是天真的背景减法?
  • 什么是帧差?
  • 我们如何构建背景模型?
  • 我们如何识别静态视频中的新对象?
  • 什么是形态学图像处理?它与背景建模有什么关系?
  • 如何使用形态运算符实现不同的效果?

技术要求

本章要求熟悉 C++ 编程语言的基础知识。 本章使用的所有代码都可以从以下 giHub 链接下载:https://github.com/PacktPublishing/Building-Computer-Vision-Projects-with-OpenCV4-and-CPlusPlus/tree/master/Chapter08。 该代码可以在任何操作系统上执行,尽管它只在 Ubuntu 上进行了测试。

请查看以下视频,了解实际操作中的代码:

http://bit.ly/2SfqzRo

理解背景减法

背景减除在视频监控中非常有用。 基本上,背景减去技术在我们必须检测静态场景中的移动对象的情况下执行得非常好。 这对视频监控有什么用处? 视频监控的过程涉及到处理持续不断的数据流。 数据流不断涌入,我们需要对其进行分析以识别任何可疑活动。 让我们考虑一下酒店大堂的例子。 所有的墙壁和家具都有固定的位置。 如果我们建立一个背景模型,我们就可以用它来识别大厅里的可疑活动。 我们利用了背景场景保持静态的事实(在本例中恰好是这样)。 这有助于我们避免任何不必要的计算开销。 顾名思义,该算法的工作原理是检测图像的每个像素并将其分配给两类,背景(假定是静态的和稳定的)或前景,并从当前帧中减去它以获得前景图像部分,其中包括运动对象,如人物、汽车等。 在静态假设下,前景对象将自然地对应于在背景前面移动的对象或人。

为了检测运动目标,我们需要建立背景模型。 这与直接帧差不同,因为我们实际上是在对背景建模,并使用此模型来检测移动对象。 当我们说我们正在对背景建模时,我们基本上是在构建一个可以用来表示背景的数学公式。 这比简单的帧差分技术要好得多。 该技术尝试检测场景的静态部分,然后在背景模型的构建统计公式中包括小的更新。 然后使用该背景模型来检测背景像素。 因此,这是一种可以根据场景进行调整的自适应技术。

朴素背景减法

让我们从头开始讨论吧。 背景减去过程是什么样子的? 请考虑下图:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/d7b2457d-8007-4ec0-9015-2db936b52551.png

上图表示背景场景。 现在,让我们在此场景中引入一个新对象:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/d2b28c66-191c-4f1b-a8fd-d21de66676b0.png

正如我们所看到的,场景中有一个新对象。 因此,如果我们计算这张图像和我们的背景模型之间的差异,您应该能够识别电视遥控器的位置:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/1bef167b-be5f-459f-b66f-70e38c1ef125.png

整个流程如下所示:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/f4c00cc5-3fe2-4587-8eba-07f8a3e38538.png

它工作得好吗?

我们称它为幼稚方法是有原因的! 它在理想的条件下工作,正如我们所知,在现实世界中没有什么是理想的。 它在计算给定对象的形状方面做得相当不错,但它是在一些约束条件下这样做的。 这种方法的主要要求之一是目标的颜色和强度应该与背景的颜色和强度有足够的不同。 影响这种算法的一些因素包括图像噪声、照明条件和相机的自动对焦。

一旦一个新物体进入我们的场景并停留在那里,就很难检测到它前面的新物体。 这是因为我们没有更新背景模型,而新对象现在是背景的一部分。 请考虑下图:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/560632e2-556d-4100-8753-f78059df3980.png

现在,假设一个新对象进入我们的场景:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/86f24a30-4cba-4ba6-b0c8-5ed29d01b37f.png

我们检测到这是一个新物体,这很好! 假设另一个对象进入场景:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/0fe9b547-ea42-4968-b7cf-85b118ea1a3f.png

很难识别这两个不同对象的位置,因为它们的位置是重叠的。 以下是减去背景并应用阈值后得到的结果:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/f66adada-3016-444e-9cdf-18d83f3ad513.png

在这种方法中,我们假设背景是静态的。 如果背景的某些部分开始移动,这些部分将开始被检测为新对象。 因此,即使是微小的移动,比如飘动的旗帜,也会导致我们的检测算法出现问题。 这种方法对照明的变化也很敏感,并且不能处理任何摄像机移动。 不用说,这是一种微妙的方法! 我们需要一种能够处理现实世界中所有这些事情的东西。

帧差分

我们知道,我们不能保持可用于检测对象的静态背景图像模式。 解决此问题的方法之一是使用帧差。 这是我们可以用来查看视频的哪些部分正在移动的最简单的技术之一。 当我们考虑实时视频流时,连续帧之间的差异提供了大量信息。 这个概念相当简单! 我们只获取连续帧之间的差异,并显示它们之间的差异。

如果我快速移动我的笔记本电脑,我们可以看到类似这样的情况:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/7b97e885-6b05-4f67-b3c6-fdf9d9931e3a.png

让我们移动物体,看看会发生什么,而不是笔记本电脑。 如果我迅速摇头,它会是这样的:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/01e28f11-33b5-42ef-879e-f9e8b46423ae.png

正如您从前面的图像中看到的,只有视频的移动部分会高亮显示。 这为我们提供了一个很好的起点来查看视频中哪些区域在移动。 让我们看一下计算帧差的函数:

Mat frameDiff(Mat prevFrame, Mat curFrame, Mat nextFrame)
{
    Mat diffFrames1, diffFrames2, output;

    // Compute absolute difference between current frame and the next
    absdiff(nextFrame, curFrame, diffFrames1);

    // Compute absolute difference between current frame and the previous 
    absdiff(curFrame, prevFrame, diffFrames2);

    // Bitwise "AND" operation between the previous two diff images
    bitwise_and(diffFrames1, diffFrames2, output);

    return output;
}

帧差异相当简单! 您可以计算当前帧和上一帧之间以及当前帧和下一帧之间的绝对差异。 然后,我们获取这些帧差异,并应用按位运算符。 这将突出显示图像中的移动部分。 如果只计算当前帧和前一帧之间的差异,则往往会产生噪波。 因此,我们需要在连续的帧差之间使用按位 AND 运算符,以便在看到移动对象时获得一定的稳定性。

让我们看一下可以从网络摄像头中提取并返回帧的函数:

Mat getFrame(VideoCapture cap, float scalingFactor)
{
    Mat frame, output;

    // Capture the current frame
    cap >> frame;

    // Resize the frame
    resize(frame, frame, Size(), scalingFactor, scalingFactor, INTER_AREA);

    // Convert to grayscale
    cvtColor(frame, output, COLOR_BGR2GRAY);

    return output;
}

正如我们所看到的,这是相当直截了当的。 我们只需要调整帧的大小并将其转换为灰度。 现在我们已经准备好了助手函数,让我们来看一下 Main 函数,看看它们是如何组合在一起的:

int main(int argc, char* argv[])
{
    Mat frame, prevFrame, curFrame, nextFrame;
    char ch;

    // Create the capture object
    // 0 -> input arg that specifies it should take the input from the webcam
    VideoCapture cap(0);

    // If you cannot open the webcam, stop the execution!
    if(!cap.isOpened())
        return -1;

    //create GUI windows
    namedWindow("Frame");

    // Scaling factor to resize the input frames from the webcam
    float scalingFactor = 0.75;

    prevFrame = getFrame(cap, scalingFactor);
    curFrame = getFrame(cap, scalingFactor);
    nextFrame = getFrame(cap, scalingFactor);

    // Iterate until the user presses the Esc key
    while(true)
    {
        // Show the object movement
        imshow("Object Movement", frameDiff(prevFrame, curFrame, nextFrame));

        // Update the variables and grab the next frame
        prevFrame = curFrame;
        curFrame = nextFrame;
        nextFrame = getFrame(cap, scalingFactor);

        // Get the keyboard input and check if it's 'Esc'
        // 27 -> ASCII value of 'Esc' key
        ch = waitKey( 30 );
        if (ch == 27) {
            break;
        }
    }
    // Release the video capture object
    cap.release();

    // Close all windows
    destroyAllWindows();

    return 1;
}

它的效果如何?

正如我们所看到的,帧差异解决了我们早先面临的几个重要问题。 它可以快速适应光线变化或相机移动。 如果对象进入帧并停留在那里,则在以后的帧中将不会检测到该对象。 这种方法的主要关注点之一是检测颜色均匀的物体。 它只能检测颜色均匀的物体的边缘。 原因是该对象的很大一部分将导致非常低的像素差异:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/7693e483-6a97-41b9-b2d5-eed251806930.png

假设这个物体稍微移动了一下。 如果我们将此帧与上一帧进行比较,它将如下所示:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/a385ce9c-c74e-4621-8a4c-5b82716aa3de.png

因此,我们在该对象上标记的像素非常少。 另一个令人担忧的问题是,很难检测到物体是朝向相机还是远离相机。

混合高斯方法

在我们讨论高斯的混合(MOG)之前,让我们先看看什么是混合模型。 混合模型只是一种统计模型,可以用来表示我们数据中的子总体的存在。 我们并不真正关心每个数据点属于什么类别。 我们所需要做的就是确定数据中包含多个组。 如果我们用高斯函数来表示每个子总体,那么它就叫做高斯混合。 让我们来看一下下面的照片:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/10e09a07-4487-44db-84cb-2f0eb228c24f.png

现在,随着我们在这个场景中收集更多的帧,图像的每个部分都将逐渐成为背景模型的一部分。 这也是我们在帧差部分前面讨论的内容。 如果场景是静态的,模型会自动调整以确保更新背景模型。 应该表示前景对象的前景蒙版在这一点上看起来像黑色图像,因为每个像素都是背景模型的一部分。

OpenCV has multiple algorithms implemented for the Mixture of Gaussians approach. One of them is called MOG and the other is called MOG2: refer to this link for a detailed explanation: http://docs.opencv.org/master/db/d5c/tutorial_py_bg_subtraction.html#gsc.tab=0. You will also be able check out the original research papers that were used to implement these algorithms.

让我们等待一段时间,然后在场景中引入一个新对象。 让我们使用 MOG2 方法来看看新的前景遮罩是什么样子:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/ae8535f1-dd6a-42f6-8dd7-af878a3ae0ad.png

如您所见,新对象正在被正确识别。 让我们看一下代码中有趣的部分(您可以在.cpp文件中获得完整的代码):

int main(int argc, char* argv[])
{

    // Variable declaration and initialization
    ....
    // Iterate until the user presses the Esc key
    while(true)
    {
        // Capture the current frame
        cap >> frame;

        // Resize the frame
        resize(frame, frame, Size(), scalingFactor, scalingFactor, INTER_AREA);

        // Update the MOG2 background model based on the current frame
        pMOG2->apply(frame, fgMaskMOG2);

        // Show the MOG2 foreground mask
        imshow("FG Mask MOG 2", fgMaskMOG2);

        // Get the keyboard input and check if it's 'Esc'
        // 27 -> ASCII value of 'Esc' key
        ch = waitKey( 30 );
        if (ch == 27) {
            break;
        }
    }

    // Release the video capture object
    cap.release();

    // Close all windows
    destroyAllWindows();

    return 1;
}

代码里发生了什么?

让我们快速浏览一下代码,看看那里发生了什么。 我们使用混合高斯模型来创建背景减去对象。 此对象表示当我们遇到来自网络摄像头的新帧时将更新的模型。 我们初始化了两个背景减去模型-BackgroundSubtractorMOGBackgroundSubtractorMOG2。 它们代表了用于背景减去的两种不同算法。 第一个是指P的论文。 KadewTraKuPongRBowden,,标题为一种改进的自适应背景混合模型,用于阴影检测的实时跟踪。 你可以在http://personal.ee.surrey.ac.uk/Personal/R.Bowden/publications/avbs01/avbs01.pdf上查看。 第二篇引用了Z的论文。 Zivkovic,,题为改进的自适应高斯混合模型背景减除.。 您可以在这里查看:http://www.zoranz.net/Publications/zivkovic2004ICPR.pdf
我们开始一个无限的while循环,并连续读取网络摄像头的输入帧。 对于每个帧,我们都会更新背景模型,如以下各行所示:

pMOG2->apply(frame, fgMaskMOG2);

在这些步骤中更新背景模型。 现在,如果新对象进入场景并停留在那里,它将成为背景模型的一部分。 这帮助我们克服了朴素背景减去模型的最大缺点之一。

形态学图像处理

正如我们前面讨论的,背景减除方法受许多因素的影响。 它们的准确性取决于我们如何捕捉数据以及如何处理这些数据。 影响这些算法的最大因素之一是噪声水平。 当我们说噪声时,我们谈论的是诸如图像中的颗粒性和孤立的黑/白像素之类的东西。 这些问题往往会影响我们算法的质量。 这就是形态学图像处理发挥作用的地方。 形态学图像处理在许多实时系统中得到了广泛的应用,以保证输出的质量。 形态学图像处理是指对图像中特征的形状进行处理;例如,可以使形状变粗或变薄。 形态运算符不取决于像素在图像中的排序方式,而取决于它们的值。 这就是为什么它们非常适合处理二值图像中的形状。 形态学图像处理也可以应用于灰度图像,但像素值不会有太大影响。

潜在的原则是什么?

形态运算符使用结构元素修改图像。 什么是结构元素? 结构元素基本上是一个小形状,可以用来检查图像中的一个小区域。 它被定位在图像中的所有像素位置,以便可以检查该邻域。 我们基本上是取一个小窗口,并将其覆盖在一个像素上。 根据响应,我们在该像素位置采取适当的操作。

让我们考虑以下输入图像:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/dec264c3-e2c9-4155-a697-64701866ce62.png

我们将对这张图像应用一系列形态学操作,以查看形状是如何变化的。

瘦身造型

我们使用一种称为侵蚀的操作来实现这种效果。 这是通过剥离图像中所有形状的边界层来使形状变薄的操作:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/2a424078-3d94-4b24-b0a1-c2f1fb3e25c2.png

让我们看看执行形态侵蚀的函数:

Mat performErosion(Mat inputImage, int erosionElement, int erosionSize)
{

    Mat outputImage;
    int erosionType;

    if(erosionElement == 0)
        erosionType = MORPH_RECT;
    else if(erosionElement == 1)
        erosionType = MORPH_CROSS;
    else if(erosionElement == 2)
        erosionType = MORPH_ELLIPSE;

    // Create the structuring element for erosion
    Mat element = getStructuringElement(erosionType, Size(2*erosionSize + 1, 2*erosionSize + 1), Point(erosionSize, erosionSize));

    // Erode the image using the structuring element
    erode(inputImage, outputImage, element);

    // Return the output image
    return outputImage;
}

您可以查看.cpp文件中的完整代码,以了解如何使用此函数。 我们基本上使用内置的 OpenCV 函数构建结构化元素。 此对象用作探针,根据特定条件修改每个像素。 这些条件指的是图像中特定像素周围发生的情况。 例如,它是否被白色像素包围? 或者它被黑色像素包围? 一旦我们有了答案,我们就会采取适当的行动。

加厚形状

我们使用一种称为膨胀的操作来实现增厚。 这是通过将边界层添加到图像中的所有形状来使形状更厚的操作:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/00a1949e-bc61-473a-a7ff-621f49f78ac3.png

下面是执行此操作的代码:

Mat performDilation(Mat inputImage, int dilationElement, int dilationSize)
{
    Mat outputImage;
    int dilationType;

    if(dilationElement == 0)
        dilationType = MORPH_RECT;
    else if(dilationElement == 1)
        dilationType = MORPH_CROSS;
    else if(dilationElement == 2)
        dilationType = MORPH_ELLIPSE;

    // Create the structuring element for dilation
    Mat element = getStructuringElement(dilationType, Size(2*dilationSize + 1, 2*dilationSize + 1), Point(dilationSize, dilationSize));

    // Dilate the image using the structuring element
    dilate(inputImage, outputImage, element);

    // Return the output image
    return outputImage;
}

其他形态运算符

下面是其他一些有趣的形态运算符。 让我们先看一下输出图像。 我们可以查看本节末尾的代码。

形态开口

这是打开形状的操作。 该运算符经常用于去除图像中的噪声。 基本上就是先侵蚀后膨胀。 形态开口通过将小对象放置在背景中,将它们从图像的前景中移除:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/139422de-1a7f-4d07-b46e-66a90f1a98a5.png

以下是执行形态打开的函数:

Mat performOpening(Mat inputImage, int morphologyElement, int morphologySize)
{

    Mat outputImage, tempImage;
    int morphologyType;

    if(morphologyElement == 0)
        morphologyType = MORPH_RECT;
    else if(morphologyElement == 1)
        morphologyType = MORPH_CROSS;
    else if(morphologyElement == 2)
        morphologyType = MORPH_ELLIPSE;

    // Create the structuring element for erosion
    Mat element = getStructuringElement(morphologyType, Size(2*morphologySize + 1, 2*morphologySize + 1), Point(morphologySize, morphologySize));

    // Apply morphological opening to the image using the structuring element
    erode(inputImage, tempImage, element);
    dilate(tempImage, outputImage, element);

    // Return the output image
    return outputImage;
}

正如我们在这里看到的,我们对图像应用侵蚀膨胀来执行形态学打开。

形态闭合

这是通过填充间隙来关闭形状的操作,如下面的屏幕截图所示。 此操作也可用于噪声去除。 它基本上是先膨胀后侵蚀的过程。 此操作通过将背景中的小对象更改为前景来移除前景中的小洞:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/4b1c042b-d2ae-40ce-aeb5-fd0132edb607.png

让我们快速了解一下执行形态闭合的函数:

Mat performClosing(Mat inputImage, int morphologyElement, int morphologySize)
{

    Mat outputImage, tempImage;
    int morphologyType;

    if(morphologyElement == 0)
        morphologyType = MORPH_RECT;
    else if(morphologyElement == 1)
        morphologyType = MORPH_CROSS;
    else if(morphologyElement == 2)
        morphologyType = MORPH_ELLIPSE;

    // Create the structuring element for erosion
    Mat element = getStructuringElement(morphologyType, Size(2*morphologySize + 1, 2*morphologySize + 1), Point(morphologySize, morphologySize));

    // Apply morphological opening to the image using the structuring element
    dilate(inputImage, tempImage, element);
    erode(tempImage, outputImage, element);

    // Return the output image
    return outputImage;
}

绘制边界

我们使用形态梯度来实现这一点。 这是通过计算图像的膨胀和侵蚀之间的差异来在形状周围绘制边界的操作:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/71000e21-3a57-4fdc-9076-7b71f3a07540.png

让我们看一下执行形态渐变的函数:

Mat performMorphologicalGradient(Mat inputImage, int morphologyElement, int morphologySize)
{
    Mat outputImage, tempImage1, tempImage2;
    int morphologyType;

    if(morphologyElement == 0)
        morphologyType = MORPH_RECT;
    else if(morphologyElement == 1)
        morphologyType = MORPH_CROSS;
    else if(morphologyElement == 2)
        morphologyType = MORPH_ELLIPSE;

    // Create the structuring element for erosion
    Mat element = getStructuringElement(morphologyType, Size(2*morphologySize + 1, 2*morphologySize + 1), Point(morphologySize, morphologySize));

    // Apply morphological gradient to the image using the structuring element
    dilate(inputImage, tempImage1, element);
    erode(inputImage, tempImage2, element);

    // Return the output image
    return tempImage1 - tempImage2;
}

Top Hat 变换

该变换从图像中提取更精细的细节。 这就是输入图像和其形态开口之间的差异。 这为我们提供了图像中比结构元素更小、比周围环境更亮的对象。 根据结构元素的大小,我们可以提取给定图像中的各种对象:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/b307977b-3e53-4ad8-bb45-76102f0578c6.png

如果你仔细观察输出的图像,你可以看到那些黑色的矩形。 这意味着结构元素能够适应那里,所以这些区域变暗了。 下面是函数:

Mat performTopHat(Mat inputImage, int morphologyElement, int morphologySize)
{

    Mat outputImage;
    int morphologyType;

    if(morphologyElement == 0)
        morphologyType = MORPH_RECT;
    else if(morphologyElement == 1)
        morphologyType = MORPH_CROSS;
    else if(morphologyElement == 2)
        morphologyType = MORPH_ELLIPSE;

    // Create the structuring element for erosion
    Mat element = getStructuringElement(morphologyType, Size(2*morphologySize + 1, 2*morphologySize + 1), Point(morphologySize, morphologySize));

    // Apply top hat operation to the image using the structuring element
    outputImage = inputImage - performOpening(inputImage, morphologyElement, morphologySize);

    // Return the output image
    return outputImage;
}

黑帽变换

该变换还可以从图像中提取更精细的细节。 这就是图像的形态闭合和图像本身之间的区别。 这为我们提供了图像中比结构元素更小、比其周围环境更暗的对象:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/fd5545fe-fe97-4c5d-a472-7c0eadba58a2.png

让我们看一下执行 Black Hat 转换的函数:

Mat performBlackHat(Mat inputImage, int morphologyElement, int morphologySize)
{
    Mat outputImage;
    int morphologyType;

    if(morphologyElement == 0)
        morphologyType = MORPH_RECT;
    else if(morphologyElement == 1)
        morphologyType = MORPH_CROSS;
    else if(morphologyElement == 2)
        morphologyType = MORPH_ELLIPSE;

    // Create the structuring element for erosion
    Mat element = getStructuringElement(morphologyType, Size(2*morphologySize + 1, 2*morphologySize + 1), Point(morphologySize, morphologySize));

    // Apply black hat operation to the image using the structuring element
    outputImage = performClosing(inputImage, morphologyElement, morphologySize) - inputImage;

    // Return the output image
    return outputImage;
}

简略的 / 概括的 / 简易判罪的 / 简易的

在本章中,我们学习了用于背景建模和形态学图像处理的算法。 我们讨论了朴素的背景减法及其局限性。 我们研究了如何使用帧差来获取运动信息,以及当我们想要跟踪不同类型的对象时,它是如何受到限制的。 这导致了我们对高斯混合的讨论。 我们讨论了这个公式以及我们如何实现它。 然后,我们讨论了可以用于各种目的的形态学图像处理,并介绍了不同的操作来展示用例。

在下一章中,我们将讨论目标跟踪以及可以用来实现目标跟踪的各种技术。

九、学习对象跟踪

在上一章中,我们学习了视频监控、背景建模和形态学图像处理。 我们讨论了如何使用不同的形态运算符将很酷的视觉效果应用到输入图像中。 在本章中,我们将学习如何跟踪实况视频中的对象。 我们将讨论可用于跟踪的对象的不同特征。 我们还将学习目标跟踪的不同方法和技术。 目标跟踪广泛应用于机器人、自动驾驶汽车、车辆跟踪、运动中的运动员跟踪和视频压缩。

在本章结束时,您将了解以下内容:

  • 如何跟踪特定颜色的对象
  • 如何构建交互式对象跟踪器
  • 什么是拐角探测器?
  • 如何检测要跟踪的好特征
  • 如何构建基于光流的特征跟踪器

技术要求

本章要求熟悉 C++ 编程语言的基础知识。 本章中使用的所有代码都可以从以下 gihub 链接下载:*https://github.com/PacktPublishing/Building-Computer-Vision-Projects-with-OpenCV4-and-CPlusPlus/tree/master/Chapter09。*这些代码可以在任何操作系统上执行,尽管它只在 Ubuntu 上测试过。

请查看以下视频,了解实际操作中的代码:
http://bit.ly/2SidbMc

跟踪特定颜色的对象

为了建立一个好的目标跟踪器,我们需要了解哪些特征可以用来使我们的跟踪健壮和准确。 所以,让我们朝这个方向迈出一小步,看看我们是否能利用色彩空间信息来设计出一个好的视觉跟踪器。 要记住的一件事是,颜色信息对照明条件很敏感。 在真实的应用中,您必须进行一些预处理来处理这些问题。 但现在,让我们假设其他人正在做这件事,我们得到的是清晰的彩色图像。

有许多不同的色彩空间,选择一个好的色彩空间将取决于用户使用的不同应用。 虽然 RGB 是计算机屏幕上的原生表示形式,但它对人类来说不一定是理想的。 当涉及到人类时,我们根据颜色的色调更自然地给颜色命名,这就是为什么色调饱和值(HSV)可能是信息最丰富的颜色空间之一。 它与我们感知颜色的方式密切相关。 色调指的是光谱,饱和度指的是特定颜色的强度,值指的是该像素的亮度。 这实际上是用柱面格式表示的。 你可以在http://infohost.nmt.edu/tcc/help/pubs/colortheory/web/hsv.html找到一个简单的解释。 我们可以将图像的像素放到 HSV 颜色空间中,然后使用该颜色空间来测量在该颜色空间中的距离和在该空间中的阈值,以跟踪给定的对象。

请考虑视频中的以下帧:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/a36e48c0-d47c-40b1-a530-5ef4402b3d84.png

如果通过颜色空间滤镜运行它并跟踪该对象,您将看到如下所示:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/5ca4cb32-de67-43cd-86cf-4b9202396394.png

正如我们在这里看到的,我们的跟踪器根据颜色特征识别视频中的特定对象。 为了使用这个跟踪器,我们需要知道目标对象的颜色分布。 下面是跟踪彩色对象的代码,它只选择具有特定给定色调的像素。 代码有很好的注释,因此请阅读每个术语的解释以了解发生了什么:

int main(int argc, char* argv[]) 
{ 
   // Variable declarations and initializations 

    // Iterate until the user presses the Esc key 
    while(true) 
    { 
        // Initialize the output image before each iteration 
        outputImage = Scalar(0,0,0); 

        // Capture the current frame 
        cap >> frame; 

        // Check if 'frame' is empty 
        if(frame.empty()) 
            break; 

        // Resize the frame 
        resize(frame, frame, Size(), scalingFactor, scalingFactor, INTER_AREA); 

        // Convert to HSV colorspace 
        cvtColor(frame, hsvImage, COLOR_BGR2HSV); 

        // Define the range of "blue" color in HSV colorspace 
        Scalar lowerLimit = Scalar(60,100,100); 
        Scalar upperLimit = Scalar(180,255,255); 

        // Threshold the HSV image to get only blue color 
        inRange(hsvImage, lowerLimit, upperLimit, mask); 

        // Compute bitwise-AND of input image and mask 
        bitwise_and(frame, frame, outputImage, mask=mask); 

        // Run median filter on the output to smoothen it 
        medianBlur(outputImage, outputImage, 5); 

        // Display the input and output image 
        imshow("Input", frame); 
        imshow("Output", outputImage); 

        // Get the keyboard input and check if it's 'Esc' 
        // 30 -> wait for 30 ms 
        // 27 -> ASCII value of 'ESC' key 
        ch = waitKey(30); 
        if (ch == 27) { 
            break; 
        } 
    } 

    return 1; 
} 

构建交互式对象跟踪器

基于颜色空间的跟踪器让我们可以自由地跟踪彩色对象,但我们也受到预定义颜色的限制。 如果我们只是想随机挑选一个对象呢? 我们如何构建一个能够学习所选对象的特征并自动跟踪它的对象跟踪器? 这就是c****连续自适应 Mean Shift(CAMShift)算法出现的地方。 它基本上是均值漂移算法的改进版本。

均值漂移的概念实际上很好很简单。 假设我们选择了一个感兴趣的区域,并希望我们的对象跟踪器跟踪该对象。 在该区域中,我们根据颜色直方图选取一组点,并计算空间点的质心。 如果质心位于该区域的中心,我们就知道该物体没有移动。 但是如果质心不在这个区域的中心,那么我们就知道物体在朝某个方向移动。 质心的移动控制对象移动的方向。 因此,我们将对象的边界框移动到一个新位置,以便新的质心成为该边界框的中心。 因此,这种算法被称为均值移位,因为均值(质心)在移动。 这样,我们就可以随时了解对象的当前位置。

但是 Mean Shift 的问题是边界框的大小不允许改变。 当您将对象从相机移开时,该对象在人眼看来会变小,但 Mean Shift 不会考虑这一点。 在整个跟踪会话中,边界框的大小将保持不变。 因此,我们需要使用 CAMShift。 CAMShift 的优点是它可以根据对象的大小调整边界框的大小。 除此之外,它还可以跟踪物体的方位。

让我们考虑下面的帧,其中对象被高亮显示:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/032e06d9-6566-452d-9c03-3bda991d66d8.png

现在我们已经选择了对象,该算法计算直方图反投影并提取所有信息。 什么是直方图反投影? 这只是一种识别图像是否符合我们直方图模型的方法。 我们计算特定物体的直方图模型,然后使用该模型在图像中找到该物体。 让我们移动对象,看看它是如何被跟踪的:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/efbfef21-6e14-4705-bd2f-9f8e740a8608.png

看起来这个物体被追踪得相当好。 让我们更改方向,看看追踪是否保持不变:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/b6b58289-ac76-4c6e-9d1c-a1c72b5eb462.png

正如我们所看到的,边界椭圆已经改变了它的位置和方向。 让我们改变对象的视角,看看它是否仍然能够跟踪它:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/be2bc4ef-2890-47e6-9509-756efe461454.png

我们还是很棒的! 边界椭圆更改了纵横比,以反映对象现在看起来倾斜的事实(因为透视变换)。 让我们看看代码中的用户界面功能:

Mat image; 
Point originPoint; 
Rect selectedRect; 
bool selectRegion = false; 
int trackingFlag = 0; 

// Function to track the mouse events 
void onMouse(int event, int x, int y, int, void*) 
{ 
    if(selectRegion) 
    { 
        selectedRect.x = MIN(x, originPoint.x); 
        selectedRect.y = MIN(y, originPoint.y); 
        selectedRect.width = std::abs(x - originPoint.x); 
        selectedRect.height = std::abs(y - originPoint.y); 

        selectedRect &= Rect(0, 0, image.cols, image.rows); 
    } 

    switch(event) 
    { 
        case EVENT_LBUTTONDOWN: 
            originPoint = Point(x,y); 
            selectedRect = Rect(x,y,0,0); 
            selectRegion = true; 
            break; 

        case EVENT_LBUTTONUP: 
            selectRegion = false; 
            if( selectedRect.width > 0 && selectedRect.height > 0 ) 
            { 
                trackingFlag = -1; 
            } 
            break; 
    } 
} 

此函数基本上捕获在窗口中选择的矩形的坐标。 用户只需用鼠标点击并拖动即可。 OpenCV 中有一组内置函数可以帮助我们检测这些不同的鼠标事件。

以下是基于 CAMShift 执行对象跟踪的代码:

int main(int argc, char* argv[]) 
{ 
    // Variable declaration and initialization 
    ....
    // Iterate until the user presses the Esc key 
    while(true) 
    { 
        // Capture the current frame 
        cap >> frame; 

        // Check if 'frame' is empty 
        if(frame.empty()) 
            break; 

        // Resize the frame 
        resize(frame, frame, Size(), scalingFactor, scalingFactor, INTER_AREA); 

        // Clone the input frame 
        frame.copyTo(image); 

        // Convert to HSV colorspace 
        cvtColor(image, hsvImage, COLOR_BGR2HSV);

现在我们有了 HSV 图像等待处理。 让我们继续看看如何使用我们的阈值来处理此图像:

        if(trackingFlag) 
        { 
            // Check for all the values in 'hsvimage' that are within the specified range 
            // and put the result in 'mask' 
            inRange(hsvImage, Scalar(0, minSaturation, minValue), Scalar(180, 256, maxValue), mask); 

            // Mix the specified channels 
            int channels[] = {0, 0}; 
            hueImage.create(hsvImage.size(), hsvImage.depth()); 
            mixChannels(&hsvImage, 1, &hueImage, 1, channels, 1); 

            if(trackingFlag < 0) 
            { 
                // Create images based on selected regions of interest 
                Mat roi(hueImage, selectedRect), maskroi(mask, selectedRect); 

                // Compute the histogram and normalize it 
                calcHist(&roi, 1, 0, maskroi, hist, 1, &histSize, &histRanges); 
                normalize(hist, hist, 0, 255, NORM_MINMAX); 

                trackingRect = selectedRect; 
                trackingFlag = 1; 
            } 

正如我们在这里看到的,我们使用 HSV 图像来计算区域的直方图。 我们使用我们的阈值在 HSV 谱中定位所需的颜色,然后在此基础上过滤掉图像。 让我们继续看看如何计算直方图反投影:

            // Compute the histogram backprojection 
            calcBackProject(&hueImage, 1, 0, hist, backproj, &histRanges); 
            backproj &= mask; 
            RotatedRect rotatedTrackingRect = CamShift(backproj, trackingRect, TermCriteria(TermCriteria::EPS | TermCriteria::COUNT, 10, 1)); 

            // Check if the area of trackingRect is too small 
            if(trackingRect.area() <= 1) 
            { 
                // Use an offset value to make sure the trackingRect has a minimum size 
                int cols = backproj.cols, rows = backproj.rows; 
                int offset = MIN(rows, cols) + 1; 
                trackingRect = Rect(trackingRect.x - offset, trackingRect.y - offset, trackingRect.x + offset, trackingRect.y + offset) & Rect(0, 0, cols, rows); 
            } 

现在我们准备好显示结果了。 使用旋转的矩形,让我们围绕感兴趣的区域绘制一个椭圆:

            // Draw the ellipse on top of the image 
            ellipse(image, rotatedTrackingRect, Scalar(0,255,0), 3, LINE_AA); 
        } 

        // Apply the 'negative' effect on the selected region of interest 
        if(selectRegion && selectedRect.width > 0 && selectedRect.height > 0) 
        { 
            Mat roi(image, selectedRect); 
            bitwise_not(roi, roi); 
        } 

        // Display the output image 
        imshow(windowName, image); 

        // Get the keyboard input and check if it's 'Esc' 
        // 27 -> ASCII value of 'Esc' key 
        ch = waitKey(30); 
        if (ch == 27) { 
            break; 
        } 
    } 

    return 1; 
} 

使用 Harris 角点检测器检测点

角点检测是一种用于检测图像中的兴趣点的技术。 这些兴趣点在计算机视觉术语中也称为特征点,或简称为特征。 拐角基本上是两条边的交集。 兴趣点基本上是可以在图像中唯一检测到的东西。 角是兴趣点的一种特殊情况。 这些兴趣点帮助我们描述图像的特征。 这些点在诸如目标跟踪、图像分类和视觉搜索等应用中被广泛使用。 既然我们知道角点很有趣,让我们看看如何检测它们。

在计算机视觉中,有一种流行的角点检测技术,称为 Harris 角点检测器。 我们基本上是基于灰度图像的偏导数构造一个 2x2 矩阵,然后对特征值进行分析。 这到底是什么意思? 好吧,让我们仔细分析一下,这样我们就能更好地理解它。 让我们考虑一下图像中的一个小补丁。 我们的目标是确定这个补丁中是否有角落。 因此,我们考虑所有的邻域面片,并计算我们的面片与所有邻域面片之间的亮度差。 如果各个方向的差异都很大,那么我们就知道我们的地块有一个角落。 这是对实际算法的过度简化,但它涵盖了要点。 如果你想了解基本的数学细节,你可以在http://www.bmva.org/bmvc/1988/avc-88-023.pdf查看HarrisStephens的原文。 拐角是指沿两个方向的强度差异很大的点。

如果我们运行 Harris 角检测器,它将如下所示:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/12eae1a2-b90f-4e7e-a2a5-806428ba89d1.png

正如我们所看到的,电视遥控器上的绿色圆圈是检测到的角落。 这将根据您为检测器选择的参数进行更改。 如果修改参数,您可以看到可能会检测到更多的点。 如果您将其设置为严格,则可能无法检测到软角。 让我们看一下检测哈里斯拐角的代码:

int main(int argc, char* argv[])
{
// Variable declaration and initialization

// Iterate until the user presses the Esc key
while(true)
{
    // Capture the current frame
    cap >> frame;

    // Resize the frame
    resize(frame, frame, Size(), scalingFactor, scalingFactor, INTER_AREA);

    dst = Mat::zeros(frame.size(), CV_32FC1);

    // Convert to grayscale
    cvtColor(frame, frameGray, COLOR_BGR2GRAY );

    // Detecting corners
    cornerHarris(frameGray, dst, blockSize, apertureSize, k, BORDER_DEFAULT);

    // Normalizing
    normalize(dst, dst_norm, 0, 255, NORM_MINMAX, CV_32FC1, Mat());
    convertScaleAbs(dst_norm, dst_norm_scaled);

我们将图像转换为灰度图像,并使用参数检测角点。 您可以在.cpp文件中找到完整的代码。 这些参数在将要检测的点数中起着重要作用。 您可以在https://docs.opencv.org/master/dd/d1a/group__imgproc__feature.html#gac1fc3598018010880e370e2f709b4345查看cornerHarris()的 OpenCV 文档。

我们现在有了我们需要的所有信息。 让我们继续在我们的角落周围画圈,以显示结果:

        // Drawing a circle around each corner
        for(int j = 0; j < dst_norm.rows ; j++)
        {
            for(int i = 0; i < dst_norm.cols; i++)
            {
                if((int)dst_norm.at<float>(j,i) > thresh)
                {
                    circle(frame, Point(i, j), 8, Scalar(0,255,0), 2, 8, 0);
                }
            }
        }

        // Showing the result
        imshow(windowName, frame);

        // Get the keyboard input and check if it's 'Esc'
        // 27 -> ASCII value of 'Esc' key
        ch = waitKey(10);
        if (ch == 27) {
            break;
        }
    }

    // Release the video capture object
    cap.release();

    // Close all windows
    destroyAllWindows();

    return 1;
}

正如我们所看到的,这段代码接受一个输入参数:blockSize。 根据您选择的大小,性能会有所不同。 从值 4 开始,试着使用它,看看会发生什么。

要跟踪的良好功能

Harris 角点检测器在很多情况下都表现良好,但仍有改进的余地。 大约在HarrisStephensShiiTomasi最初的论文发表 6 年后,他们将其称为跟踪的良好特征。 你可以在这里阅读原文:http://www.ai.mit.edu/courses/6.891/handouts/shi94good.pdf。 他们使用了不同的评分函数来提高整体质量。 使用这种方法,我们可以在给定的图像中找到 N 个最强的角点。 当我们不想使用每个角落来从图像中提取信息时,这是非常有用的。 正如我们所讨论的,一个好的兴趣点检测器在目标跟踪、目标识别和图像搜索等应用中非常有用。

如果将 Shii-Tomasi 角点检测器应用于图像,您将看到如下所示:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/ceec9d76-ca6f-41e3-9908-ce3b0431a312.png

正如我们在这里看到的,帧中的所有重要点都被捕捉到了。 让我们看一下跟踪这些功能的代码:

int main(int argc, char* argv[]) 
{ 
    // Variable declaration and initialization 

    // Iterate until the user presses the Esc key 
    while(true) 
    { 
        // Capture the current frame 
        cap >> frame; 

        // Resize the frame 
        resize(frame, frame, Size(), scalingFactor, scalingFactor, INTER_AREA); 

        // Convert to grayscale 
        cvtColor(frame, frameGray, COLOR_BGR2GRAY ); 

        // Initialize the parameters for Shi-Tomasi algorithm 
        vector<Point2f> corners; 
        double qualityThreshold = 0.02; 
        double minDist = 15; 
        int blockSize = 5; 
        bool useHarrisDetector = false; 
        double k = 0.07; 

        // Clone the input frame 
        Mat frameCopy; 
        frameCopy = frame.clone(); 

        // Apply corner detection 
        goodFeaturesToTrack(frameGray, corners, numCorners, qualityThreshold, minDist, Mat(), blockSize, useHarrisDetector, k); 

正如我们所看到的,我们提取了帧,并使用goodFeaturesToTrack来检测角点。 重要的是要理解,检测到的角点数量将取决于我们选择的参数。 你可以在http://docs.opencv.org/2.4/modules/imgproc/doc/feature_detection.html?highlight=goodfeaturestotrack#goodfeaturestotrack上找到详细的解释。 让我们继续在这些点上画圆圈以显示输出图像:

        // Parameters for the circles to display the corners 
        int radius = 8;      // radius of the circles 
        int thickness = 2;   // thickness of the circles 
        int lineType = 8; 

        // Draw the detected corners using circles 
        for(size_t i = 0; i < corners.size(); i++) 
        { 
            Scalar color = Scalar(rng.uniform(0,255), rng.uniform(0,255), rng.uniform(0,255)); 
            circle(frameCopy, corners[i], radius, color, thickness, lineType, 0); 
        } 

        /// Show what you got 
        imshow(windowName, frameCopy); 

        // Get the keyboard input and check if it's 'Esc' 
        // 27 -> ASCII value of 'Esc' key 
        ch = waitKey(30); 
        if (ch == 27) { 
            break; 
        } 
    } 

    // Release the video capture object 
    cap.release(); 

    // Close all windows 
    destroyAllWindows(); 

    return 1; 
}

该程序接受一个输入参数:numCorners。 该值指示要跟踪的最大角点数。 从值100开始,试着使用它,看看会发生什么。 如果增加此值,您将看到检测到更多的特征点。

基于特征的跟踪

基于特征的跟踪是指在视频中的连续帧上跟踪单个特征点。 这里的优点是我们不必在每一帧中都检测特征点。 我们只能探测到他们一次,然后继续追踪他们。 这比在每一帧上运行检测器效率更高。 我们使用一种叫做光流的技术来跟踪这些特征。 光流是计算机视觉中最流行的技术之一。 我们选择一组特征点,并通过视频流跟踪它们。 当我们检测特征点时,我们计算位移向量,并显示这些关键点在连续帧之间的运动。 这些矢量称为运动矢量。 与前一帧相比,特定点的运动矢量基本上只是一条指示该点移动位置的方向线。 使用不同的方法来检测这些运动矢量。 最流行的两个算法是Lucas-Kanade方法和Farneback算法。

卢卡斯-卡纳德法

稀疏光流跟踪采用 Lucas-Kanade 方法。 我们所说的稀疏是指特征点的数量相对较少。 您可以在这里参考他们的原文:http://cseweb.ucsd.edu/classes/sp02/cse252/lucaskanade81.pdf。 我们从提取特征点开始这个过程。 对于每个特征点,我们以特征点为中心创建 3 x 3 面片。 这里的假设是,每个面片中的所有点都会有类似的运动。 我们可以根据手头的问题调整此窗口的大小。

对于当前帧中的每个特征点,我们将周围的 3x3 面片作为参考点。 对于此补丁,我们在前一帧的邻域中查找,以获得最佳匹配。 这个邻域通常大于 3x3,因为我们希望得到最接近所考虑的面片的面片。 现在,从上一帧中匹配面片的中心像素到当前帧中正在考虑的面片的中心像素的路径将成为运动向量。 我们对所有的特征点都这样做,并提取所有的运动矢量。

让我们考虑以下框架:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/427162ae-a630-4bd0-845a-75605ec51b50.png

我们需要添加一些我们想要跟踪的点。 只需用鼠标单击此窗口上的一系列点即可:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/0ac4f874-b07c-4acd-8552-2dfd16054be8.png

如果我移动到不同的位置,您将看到这些点仍在一个小误差范围内被正确跟踪:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/e6a5dcb2-6aa1-42a4-adc1-1e5b07165208.png

让我们加很多分,看看会发生什么:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/7404ce82-8d63-4cf8-b1ae-ecf289adfbb7.png

正如我们所看到的,它将继续跟踪这些点。 但是,你会注意到,一些点会因为突出程度或移动速度等因素而丢失。 如果你想玩这个游戏,你可以继续给它加更多的分数。 您还可以让用户在输入视频中选择感兴趣的区域。 然后,可以从该感兴趣区域提取特征点,并通过绘制边界框追踪对象。 这将是一次有趣的练习!

以下是执行基于 Lucas-Kanade 的跟踪的代码:

int main(int argc, char* argv[]) 
{ 
    // Variable declaration and initialization 

    // Iterate until the user hits the Esc key 
    while(true) 
    { 
        // Capture the current frame 
        cap >> frame; 

        // Check if the frame is empty 
        if(frame.empty()) 
            break; 

        // Resize the frame 
        resize(frame, frame, Size(), scalingFactor, scalingFactor, INTER_AREA); 

        // Copy the input frame 
        frame.copyTo(image); 

        // Convert the image to grayscale 
        cvtColor(image, curGrayImage, COLOR_BGR2GRAY); 

        // Check if there are points to track 
        if(!trackingPoints[0].empty()) 
        { 
            // Status vector to indicate whether the flow for the corresponding features has been found 
            vector<uchar> statusVector; 

            // Error vector to indicate the error for the corresponding feature 
            vector<float> errorVector; 

            // Check if previous image is empty 
            if(prevGrayImage.empty()) 
            { 
                curGrayImage.copyTo(prevGrayImage); 
            } 

            // Calculate the optical flow using Lucas-Kanade algorithm 
            calcOpticalFlowPyrLK(prevGrayImage, curGrayImage, trackingPoints[0], trackingPoints[1], statusVector, errorVector, windowSize, 3, terminationCriteria, 0, 0.001); 

我们使用当前图像和前一图像来计算光流信息。 不用说,输出的质量将取决于所选择的参数。 有关参数的更多详细信息,请访问http://docs.opencv.org/2.4/modules/video/doc/motion_analysis_and_object_tracking.html#calcopticalflowpyrlk。 为了提高质量和健壮性,我们需要过滤掉彼此非常接近的点,因为它们不会添加新的信息。 让我们继续这样做:


            int count = 0; 

            // Minimum distance between any two tracking points 
            int minDist = 7; 

            for(int i=0; i < trackingPoints[1].size(); i++) 
            { 
                if(pointTrackingFlag) 
                { 
                    // If the new point is within 'minDist' distance from an existing point, it will not be tracked 
                    if(norm(currentPoint - trackingPoints[1][i]) <= minDist) 
                    { 
                        pointTrackingFlag = false; 
                        continue; 
                    } 
                } 

                // Check if the status vector is good 
                if(!statusVector[i]) 
                    continue; 

                trackingPoints[1][count++ ] = trackingPoints[1][i]; 

                // Draw a filled circle for each of the tracking points 
                int radius = 8; 
                int thickness = 2; 
                int lineType = 8; 
                circle(image, trackingPoints[1][i], radius, Scalar(0,255,0), thickness, lineType); 
            } 

            trackingPoints[1].resize(count); 
        } 

我们现在有了跟踪点。 下一步是细化这些点的位置。 在这种情况下,Refined的确切含义是什么? 为了提高计算速度,需要进行一定程度的量化。 用外行的话说,你可以把它看作是四舍五入。 现在我们有了大致的区域,我们可以细化该区域内点的位置,以获得更准确的结果。 让我们继续这样做:


        // Refining the location of the feature points 
        if(pointTrackingFlag && trackingPoints[1].size() < maxNumPoints) 
        { 
            vector<Point2f> tempPoints; 
            tempPoints.push_back(currentPoint); 

            // Function to refine the location of the corners to subpixel accuracy. 
            // Here, 'pixel' refers to the image patch of size 'windowSize' and not the actual image pixel 
            cornerSubPix(curGrayImage, tempPoints, windowSize, Size(-1,-1), terminationCriteria); 

            trackingPoints[1].push_back(tempPoints[0]); 
            pointTrackingFlag = false; 
        } 

        // Display the image with the tracking points 
        imshow(windowName, image); 

        // Check if the user pressed the Esc key 
        char ch = waitKey(10); 
        if(ch == 27) 
            break; 

        // Swap the 'points' vectors to update 'previous' to 'current' 
        std::swap(trackingPoints[1], trackingPoints[0]); 

        // Swap the images to update previous image to current image 
        cv::swap(prevGrayImage, curGrayImage); 
    } 

    return 1; 
} 

Farneback 算法

冈纳·法内巴克(Gunnar Farneback)提出了这种光流算法,用于密集跟踪。 密集跟踪在机器人、增强现实和 3D 地图中被广泛使用。 您可以在这里查看原文:http://www.diva-portal.org/smash/get/diva2:273847/FULLTEXT01.pdf。 Lucas-Kanade 方法是一种稀疏技术,这意味着我们只需要处理整个图像中的一些像素。 另一方面,Farneback 算法是一种密集技术,需要我们处理给定图像中的所有像素。 因此,很明显,这是一种权衡。 密集技术更精确,但速度更慢。 稀疏技术的精确度较低,但速度更快。 对于实时应用,人们倾向于使用稀疏技术。 对于时间和复杂性不是一个因素的应用,人们倾向于使用密集技术来提取更精细的细节。

在他的论文中,Farneback 描述了一种基于多项式展开的两帧密集光流估计方法。 我们的目标是估计这两个帧之间的运动,这基本上是一个分三步走的过程。 在第一步中,用多项式逼近两帧中的每个邻域。 在这种情况下,我们只对二次多项式感兴趣。 下一步是通过全局位移构造新的信号。 现在每个邻域都用一个多项式来近似,我们需要看看如果这个多项式经过理想的平移会发生什么。 最后一步是通过将这些二次多项式的产量中的系数相等来计算全局位移。

现在,这怎么可能呢? 如果你仔细想想,我们假设一个完整的信号是一个单一的多项式,并且这两个信号之间存在全局平移。 这不是一个现实的场景! 那么,我们在找什么呢? 嗯,我们的目标是找出这些误差是否足够小,这样我们就可以建立一个有用的算法来跟踪这些特征。

让我们看一张静态图像:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/545cab44-63df-43a8-ad6f-fbad88aae123.png

如果我横向移动,我们可以看到运动矢量指向水平方向。 它只是简单地跟踪我的头部运动:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/d48b5460-1737-40f3-a09e-31a7a388f285.png

如果我离开网络摄像头,您可以看到运动矢量指向垂直于图像平面的方向:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/4934cb3a-934b-43cd-a71e-1f1c67746ab7.png

以下是使用 Farneback 算法进行基于光流的跟踪的代码:

int main(int, char** argv) 
{ 
    // Variable declaration and initialization 

    // Iterate until the user presses the Esc key 
    while(true) 
    { 
        // Capture the current frame 
        cap >> frame; 

        if(frame.empty()) 
            break; 

        // Resize the frame 
        resize(frame, frame, Size(), scalingFactor, scalingFactor, INTER_AREA); 

        // Convert to grayscale 
        cvtColor(frame, curGray, COLOR_BGR2GRAY); 

        // Check if the image is valid 
        if(prevGray.data) 
        { 
            // Initialize parameters for the optical flow algorithm 
            float pyrScale = 0.5; 
            int numLevels = 3; 
            int windowSize = 15; 
            int numIterations = 3; 
            int neighborhoodSize = 5; 
            float stdDeviation = 1.2; 

            // Calculate optical flow map using Farneback algorithm 
            calcOpticalFlowFarneback(prevGray, curGray, flowImage, pyrScale, numLevels, windowSize, numIterations, neighborhoodSize, stdDeviation, OPTFLOW_USE_INITIAL_FLOW); 

正如我们所看到的,我们使用 Farneback 算法来计算光流矢量。 当涉及到跟踪质量时,calcOpticalFlowFarneback的输入参数很重要。 您可以在http://docs.opencv.org/3.0-beta/modules/video/doc/motion_analysis_and_object_tracking.html上找到有关这些参数的详细信息。 让我们继续在输出图像上绘制这些向量:

            // Convert to 3-channel RGB 
            cvtColor(prevGray, flowImageGray, COLOR_GRAY2BGR); 

            // Draw the optical flow map 
            drawOpticalFlow(flowImage, flowImageGray); 

            // Display the output image 
            imshow(windowName, flowImageGray); 
        } 

        // Break out of the loop if the user presses the Esc key 
        ch = waitKey(10); 
        if(ch == 27) 
            break; 

        // Swap previous image with the current image 
        std::swap(prevGray, curGray); 
    } 

    return 1; 
} 

我们使用一个名为drawOpticalFlow的函数来绘制这些光流矢量。 这些矢量表示运动的方向。 让我们看一下该函数,看看我们是如何绘制这些向量的:

// Function to compute the optical flow map 
void drawOpticalFlow(const Mat& flowImage, Mat& flowImageGray) 
{ 
    int stepSize = 16; 
    Scalar color = Scalar(0, 255, 0); 

    // Draw the uniform grid of points on the input image along with the motion vectors 
    for(int y = 0; y < flowImageGray.rows; y += stepSize) 
    { 
        for(int x = 0; x < flowImageGray.cols; x += stepSize) 
        { 
            // Circles to indicate the uniform grid of points 
            int radius = 2; 
            int thickness = -1; 
            circle(flowImageGray, Point(x,y), radius, color, thickness); 

            // Lines to indicate the motion vectors 
            Point2f pt = flowImage.at<Point2f>(y, x); 
            line(flowImageGray, Point(x,y), Point(cvRound(x+pt.x), cvRound(y+pt.y)), color); 
        } 
    } 
} 

简略的 / 概括的 / 简易判罪的 / 简易的

在本章中,我们学习了目标跟踪。 我们学习了如何使用 HSV 色彩空间来跟踪特定颜色的对象。 我们讨论了用于对象跟踪的群集技术,以及如何使用 CAMShift 算法构建交互式对象跟踪器。 我们研究了角点探测器,以及如何在现场视频中跟踪角点。 我们讨论了如何使用光流跟踪视频中的特征。 最后,我们理解了 Lucas-Kanade 和 Farneback 算法背后的概念,然后实现了它们。

在下一章中,我们将讨论分割算法以及如何将其用于文本识别。

Logo

火山引擎开发者社区是火山引擎打造的AI技术生态平台,聚焦Agent与大模型开发,提供豆包系列模型(图像/视频/视觉)、智能分析与会话工具,并配套评测集、动手实验室及行业案例库。社区通过技术沙龙、挑战赛等活动促进开发者成长,新用户可领50万Tokens权益,助力构建智能应用。

更多推荐