使用 OpenCV 进行轮廓检测

871 阅读16分钟

我正在参加「掘金·启航计划」 使用轮廓检测​​,我们可以检测对象的边界,并轻松在图像中定位它们。它通常是许多有趣应用的第一步,例如图像前景提取、简单图像分割、检测和识别。

什么是轮廓

当我们连接物体边界上的所有点时,我们就得到了轮廓。通常,特定轮廓是指具有相同颜色和强度的边界像素。OpenCV 使在图像中查找和绘制轮廓变得非常容易。它提供了两个简单的功能:

  1. findContours()
  2. drawContours()

此外,它还有两种不同的轮廓检测算法:

  1. CHAIN_APPROX_SIMPLE
  2. CHAIN_APPROX_NONE

我们将在下面的示例中详细介绍这些内容。下图显示了这些算法如何检测简单物体的轮廓。

image.png

在 OpenCV 中检测和绘制轮廓的步骤

OpenCV 使这成为一项相当简单的任务。只需按照以下步骤操作:

  1. 读取图像并将其转换为灰度格式

读取图像并将图像转换为灰度格式。将图像转换为灰度非常重要,因为它为下一步准备图像。将图像转换为单通道灰度图像对于阈值处理很重要,而阈值处理又是轮廓检测算法正常工作所必需的。

  1. 应用二进制阈值

在查找轮廓时,首先始终对灰度图像应用二值阈值处理或 Canny 边缘检测。在这里,我们将应用二进制阈值。

这会将图像转换为黑白图像,突出显示感兴趣的对象,以便于轮廓检测算法。阈值处理将图像中对象的边界变成完全白色,所有像素都具有相同的强度。该算法现在可以从这些白色像素中检测对象的边界。

注意:值为 0 的黑色像素被视为背景像素并被忽略。

此时,可能会出现一个问题。如果我们使用 R(红色)、G(绿色)或 B(蓝色)等单通道而不是灰度(阈值)图像会怎么样?在这种情况下,轮廓检测算法将无法正常工作。正如我们之前讨论的,该算法寻找边界和相似强度的像素来检测轮廓。二值图像比单个 (RGB) 颜色通道图像提供的信息要好得多。在博客的后面部分,我们将得到仅使用单个 R、G 或 B 通道而不是灰度和阈值图像时的结果图像。

  1. 找到轮廓

使用该**findContours()** 函数检测图像中的轮廓。

  1. 在原始 RGB 图像上绘制轮廓。

识别出轮廓后,使用该**drawContours()** 函数将轮廓叠加在原始 RGB 图像上。

当我们开始编码时,上述步骤将变得更加有意义,并且变得更加清晰。

使用 OpenCV 查找和绘制轮廓

首先导入 OpenCV,并读取输入图像,使用**cvtColor()** 函数将原始 RGB 图像转换为灰度图像。使用该**threshold()** 函数对图像应用二进制阈值。任何值大于 150 的像素都将设置为值 255(白色)。生成的图像中的所有剩余像素将设置为 0(黑色)。阈值 150 是一个可调参数,因此您可以尝试一下。 

imshow() 阈值化后,使用如下所示的函数 可视化二值图像。

image.png 注意下面的图片 它是原始 RGB 图像的二进制表示。你可以清楚地看到笔、平板电脑和手机的边框都是白色的。轮廓算法会将这些视为对象,并找到这些白色对象边界周围的轮廓点。 

请注意背景是全黑的,包括手机的背面。算法将忽略此类区域。将每个对象周边的白色像素作为相似强度像素,该算法将根据相似性度量将它们连接起来形成轮廓。

image.png 应用阈值函数后得到的二值图像。

使用 CHAIN_APPROX_NONE 绘制轮廓

现在,让我们使用该方法查找并绘制轮廓**CHAIN_APPROX_NONE**。 

从功能开始**findContours()** 。它具有三个必需参数,如下所示。对于可选参数,请参阅此处的文档页面。

  • image:上一步得到的二值输入图像。
  • mode:这是轮廓检索模式。我们将其提供为**RETR_TREE,** 这意味着算法将从二值图像中检索所有可能的轮廓。还有更多轮廓检索模式可用,我们也将讨论它们。您可以在此处了解有关这些选项的更多详细信息。 
  • method:这定义了轮廓近似方法。在这个例子中,我们将使用**CHAIN_APPROX_NONE。虽然比 稍慢CHAIN_APPROX_SIMPLE**,但我们将在这里使用此方法来存储所有轮廓点。 

这里值得强调的是,**mode指的是要检索的轮廓的类型,而method**指的是存储轮廓内的哪些点。我们将在下面更详细地讨论两者。 

很容易在同一图像上可视化和理解不同方法的结果。 

因此,在下面的代码示例中,我们复制了原始图像,然后演示了方法(不想编辑原始图像)。 

接下来,使用该**drawContours()** 函数将轮廓叠加在 RGB 图像上。该函数有四个必需参数和几个可选参数。下面的前四个参数是必需的。对于可选参数,请参阅此处的文档页面。

  • image:这是要在其上绘制轮廓的输入 RGB 图像。
  • contours:表示**contoursfindContours()** 函数中获取。
  • contourIdx:获取的轮廓中列出了轮廓点的像素坐标。使用此参数,您可以指定此列表中的索引位置,准确指示要绘制的轮廓点。提供负值将绘制所有轮廓点。
  • color:表示要绘制的轮廓点的颜色。我们用绿色绘制这些点。
  • thickness:这是轮廓点的厚度。

Python:

image.png 执行上述代码将生成并显示如下所示的图像。我们还将图像保存到磁盘。

image.png 使用覆盖在输入图像上的 CHAIN_APPROX_NONE 检测到的轮廓。 下图显示了原始图像(左侧)以及叠加轮廓的原始图像(右侧)。

image.png 原始图像和在其上绘制轮廓的图像。 正如您在上图中看到的,算法生成的轮廓可以很好地识别每个对象的边界。然而,如果你仔细观察手机,你会发现它包含不止一个轮廓。与相机镜头和光线相关的圆形区域已确定了单独的轮廓。沿着手机边缘的部分区域还有“次要”轮廓。 

请记住,轮廓算法的准确性和质量在很大程度上取决于所提供的二值图像的质量(再次查看上一节中的二值图像,您可以看到与这些辅助轮廓相关的线条)。一些应用需要高质量的轮廓。在这种情况下,在创建二值图像时尝试不同的阈值,看看是否可以改善生成的轮廓。 

还有其他方法可用于在生成轮廓之前从二进制图中消除不需要的轮廓。您还可以使用与我们将在此处讨论的轮廓算法相关的更高级功能。

使用单通道:红色、绿色或蓝色

仅供参考,以下是在检测轮廓时分别使用红色、绿色和蓝色通道时的一些结果。我们在之前的轮廓检测步骤中讨论过这一点。以下是与上面相同的图像的Python代码

Python:

image.png 下图显示了所有三个独立颜色通道的轮廓检测结果。

image.png 使用蓝色、绿色和红色单通道而不是灰度阈值图像时的轮廓检测结果。 在上图中我们可以看到轮廓检测算法无法正确找到轮廓。这是因为它无法正确检测物体的边界,而且像素之间的强度差异也没有很好地定义。这就是我们更喜欢使用灰度和二值阈值图像来检测轮廓的原因。

使用 CHAIN_APPROX_SIMPLE 绘制轮廓

现在让我们了解一下该**CHAIN_APPROX_SIMPLE算法的工作原理以及它与该CHAIN_APPROX_NONE**算法的不同之处。

这是它的代码:

Python:

image.png 这里唯一的区别是我们指定了methodfor而不是**findContours()** as 。CHAIN_APPROX_SIMPLE CHAIN_APPROX_NONE

该**CHAIN_APPROX_SIMPLE**  算法沿轮廓压缩水平、垂直和对角线段,并仅留下它们的端点。这意味着直线路径上的任何点都将被忽略,我们将只剩下端点。例如,考虑沿着矩形的轮廓。除四个角点外的所有轮廓点都将被忽略。此方法比 更快,CHAIN_APPROX_NONE 因为该算法不存储所有点,使用更少的内存,因此执行时间更少。

下图显示了结果。

image.png 使用覆盖在输入图像上的 CHAIN_APPROX_SIMPLE 检测到的轮廓。 功劳归于该**drawContours()** 功能。尽管该**CHAIN_APPROX_SIMPLE** 方法通常会产生较少的点,但该**drawContours()** 函数会自动连接相邻点,即使它们不在列表中,也将它们连接起来**contours**。

那么,我们如何确认该**CHAIN_APPROX_SIMPLE**算法确实有效呢?

  • 最直接的方法是手动循环轮廓点,并使用 OpenCV 在检测到的轮廓坐标上画一个圆。 
  • 此外,我们使用不同的图像,实际上可以帮助我们可视化算法的结果。

image.png 用于演示 CHAIN_APPROX_SIMPLE 轮廓检测算法的新图像。

下面的代码使用上图来可视化算法的效果**CHAIN_APPROX_SIMPLE**。除了两个额外的for循环和一些变量名称之外,几乎所有内容都与前面的代码示例中的相同。 

  • 第一个**for循环循环遍历列表中存在的每个轮廓区域contours**。 
  • 第二个循环遍历该区域中的每个坐标。
  • 然后,我们使用**circle()** OpenCV 中的函数在每个坐标点上绘制一个绿色圆圈。
  • 最后,我们将结果可视化并将其保存到磁盘。

image.png 执行上面的代码,产生以下结果:

image.png 观察使用CHAIN_APPROX_SIMPLE进行轮廓检测时,书本的四个角上只有四个轮廓点。书的垂直和水平直线完全被忽略。

观察输出图像,即上图右侧 请注意,书本的垂直和水平边仅包含书本角上的四个点。另请注意,字母和鸟是用离散点而不是线段表示的。

轮廓层次结构

层次结构表示轮廓之间的父子关系。您将看到每种轮廓检索模式如何影响图像中的轮廓检测,并产生分层结果。

亲子关系

图像中轮廓检测算法检测到的对象可以是:  

  • 图像中分散的单个对象(如第一个示例),或者
  • 物体和形状在彼此内部 

在大多数情况下,当一个形状包含多个形状时,我们可以安全地得出结论:外部形状是内部形状的父级。

看一下下图,它包含几个简单的形状,有助于演示轮廓层次结构。

image.png 具有简单线条和形状的图像

现在参见下图,其中与图 10 中每个形状相关的轮廓已被识别。图 11 中的每个数字 ****都有其意义。 

  • 根据轮廓层次结构和父子关系,所有单独的数字,即 1、2、3 和 4 都是单独的对象。
  • 我们可以说 3a 是 3 的子级。请注意,3a 表示轮廓 3 的内部部分。
  • 轮廓 1、2 和 4 都是父形状,没有任何关联的子形状,因此它们的编号是任意的。换句话说,轮廓 2 可能被标记为 1,反之亦然。

image.png 显示不同形状之间的父子关系的数字。

轮廓关系表示

您已经看到该**findContours()** 函数返回两个输出:轮廓列表和层次结构。现在让我们详细了解轮廓层次输出。

轮廓层次结构表示为一个数组,该数组又包含四个值的数组。它表示为:

Next, Previous, First_Child, Parent]

那么,所有这些值意味着什么?

Next 表示图像中的下一个轮廓,处于同一层次级别。所以,

  • 对于轮廓 1,同一层级的下一个轮廓为 2。此处,**Next**将为 2。 
  • 因此,轮廓3不具有与其自身处于相同层级的轮廓。因此,它的**Next**值将为-1。

Previous 表示同一层级的前一个轮廓。这意味着轮廓 1 的**Previous**值始终为 -1。

First_Child 表示我们当前正在考虑的轮廓的第一个子轮廓。 

  • 轮廓 1 和 2 根本没有子轮廓。因此,它们的索引值为**First_Child** -1。 
  • 但轮廓 3 有一个孩子。因此,对于轮廓 3,First_Child 位置值将是 3a 的索引位置。

Parent 表示当前轮廓的父轮廓的索引位置。 

  • 显而易见,轮廓 1 和 2 没有任何**Parent**轮廓。 
  • 对于轮廓 3a,它将**Parent**是轮廓 3
  • 对于轮廓 4,父轮廓是轮廓 3a

上面的解释很有道理,但是我们如何实际可视化这些层次结构数组呢?最好的方法是:

  • 使用带有线条和形状的简单图像(如上图所示)
  • 使用不同的检索模式检测轮廓和层次结构
  • 然后打印这些值以可视化它们

不同的轮廓检索技术

到目前为止,我们使用了一种特定的检索技术**RETR_TREE** 来查找和绘制轮廓, 但 OpenCV 中还有三种轮廓检索技术,即RETR_LISTRETR_EXTERNALRETR_CCOMP

现在,让我们使用图 10 中的图像来回顾这四种方法中的每一种,以及获取轮廓的相关代码。

以下代码从磁盘读取图像,将其转换为灰度,并应用二进制阈值。 Python:

image.png

RETR_LIST

轮廓检索方法RETR_LIST不会在提取的轮廓之间创建任何父子关系。因此,对于检测到的所有轮廓区域,First_Child 和**Parent**索引位置值始终为 -1。

所有轮廓都将具有其对应的**PreviousNext**轮廓,如上所述。 

查看该**RETR_LIST**方法是如何在代码中实现的。

Python:

image.png 执行上述代码会产生以下输出:

LIST: [[[ 1 -1 -1 -1] [ 2  0 -1 -1] [ 3  1 -1 -1] [ 4  2 -1 -1] [-1  3 -1 -1]]]

您可以清楚地看到,所有检测到的轮廓区域的第 3 和第 4 索引位置均为 -1,正如预期的那样。

RETR_EXTERNAL

轮廓RETR_EXTERNAL检索方法是一种非常有趣的方法。它仅检测父轮廓,并忽略任何子轮廓。因此,所有内部轮廓(如 3a 和 4)上都不会绘制任何点。 

Python:

image.png 上面的代码产生以下输出:

EXTERNAL: [[[ 1 -1 -1 -1] [ 2  0 -1 -1] [-1  1 -1 -1]]]

image.png 使用 RETR_EXTERNAL 模式检测和绘制轮廓 上面的输出图像仅显示轮廓 1、2 和 3 上绘制的点。轮廓 3a 和 4 被省略,因为它们是子轮廓。

RETR_CCOMP

不像检索图像中的所有轮廓。除此之外,它还将 2 级层次结构应用于图像中的所有形状或对象。RETR_EXTERNAL,RETR_CCOMP

这意味着:

  • 所有外部轮廓的层次结构级别均为 1
  • 所有内部轮廓的层次结构级别均为 2

但是如果我们有一个轮廓位于另一个层次结构级别为 2 的轮廓内怎么办?就像我们在轮廓 3a 之后有轮廓 4 一样。

在这种情况下:

  •  同样,等高线 4 将具有层次结构级别 1。
  •  如果轮廓 4 内有任何轮廓,则它们的层次结构级别为 2。

在下图中,轮廓已根据其层级编号,如上所述。

image.png 使用 RETR_CCOMP 检索方法时,图像在轮廓中显示不同的层级 上图显示层次结构级别分别为HL-1 或 HL-2级别 1 和 2。现在,让我们再看看代码和输出层次结构数组。

Python:

image.png 执行上述代码会产生以下输出:

CCOMP: [[[ 1 -1 -1 -1] [ 3  0  2 -1] [-1 -1 -1  1] [ 4  1 -1 -1] [-1  3 -1 -1]]]

在这里,我们看到,根据轮廓检索方法,随着检测到所有轮廓,所有**NextPreviousFirst_Child、 和关系都得以保留。Parent正如预期的那样,Previous第一个轮廓区域的 是 -1。没有 的轮廓Parent也**具有值 -1

RETR_TREE

就像也检索所有轮廓一样。它还创建了一个完整的层次结构,级别不限于 1 或 2。每个轮廓可以有自己的层次结构,与其所在的级别以及它所具有的相应父子关系一致。RETR_CCOMP, RETR_TREE

image.png 使用 RETR_TREE 轮廓检索模式时的层次结构级别。

从上图可以清楚地看出:

  •  轮廓 1、2 和 3 位于同一级别,即级别 0。
  •  轮廓 3a 位于层次结构级别 1,因为它是轮廓 3 的子级。
  •  轮廓4是一个新的轮廓区域,因此其层次级别为2。

以下代码使用**RETR_TREE**模式来检索轮廓。

Python:

image.png 执行上述代码会产生以下输出:

TREE: [[[ 3 -1  1 -1] [-1 -1  2  0] [-1 -1 -1  1] [ 4  0 -1 -1] [-1  3 -1 -1]]]

最后,让我们看看使用RETR_TREE模式时绘制的所有轮廓的完整图像。

image.png 使用RETR_TREE检索模式时的轮廓检测。

所有轮廓均按预期绘制,轮廓区域清晰可见。您还可以推断轮廓 3 和 3a 是两个独立的轮廓,因为它们具有不同的轮廓边界和区域。同时,很明显轮廓 3a 是轮廓 3 的子轮廓。 

现在您已经熟悉了 OpenCV 中提供的所有轮廓算法及其各自的输入参数和配置,请进行实验并亲眼看看它们是如何工作的。

不同轮廓检索方法的运行时间比较

仅仅了解轮廓检索方法是不够的。您还应该了解它们的相对处理时间。下表比较了上述每种方法的运行时间。

轮廓检索方法花费时间(以秒为单位)
RETR_LIST0.000382
RETR_EXTERNAL0.000554
RETR_CCOMP0.001845
RETR_TREE0.005594

比较不同方法的推理速度

从上表中得出一些有趣的结论: 

  • **RETR_LIST并且RETR_EXTERNAL执行时间最短,因为RETR_LIST没有定义任何层次结构并且RETR_EXTERNAL**仅检索父轮廓
  • **RETR_CCOMP**执行时间第二长。它检索所有轮廓并定义两级层次结构。 
  • **RETR_TREE**执行时间最长,因为它会检索所有轮廓,并为每个父子关系定义独立的层次结构级别。 

尽管上述时间可能看起来并不重要,但重要的是要了解可能需要大量轮廓处理的应用程序的差异。还值得注意的是,该处理时间可能会有所不同,具体取决于它们提取的轮廓以及它们定义的层次结构级别。

概括

了解应用程序如何使用轮廓进行移动性检测和分割。接下来,我们演示了四种不同检索模式和两种轮廓近似方法的使用。您还学会了绘制轮廓。我们最后讨论了轮廓层次结构,以及不同的轮廓检索模式如何影响图像上轮廓的绘制。