4、轮廓发现基础:findContours函数详解、轮廓检索模式、轮廓近似方法

好,咱们进入轮廓分析的第一课。

轮廓发现,说白了就是在一张图里找到那些「连成一片」的边界点。我刚开始学OpenCV时,总觉得这玩意儿跟边缘检测差不多。后来踩了坑才明白——边缘检测给的是像素级的梯度响应,而轮廓给的是完整的、有序的边界点集合。你想想看,一个是散兵游勇,一个是整编部队,区别大了去了。

4.1 findContours 函数:轮廓发现的入口

这个函数,是OpenCV里最常用的轮廓提取工具。我个人习惯把它叫做「轮廓收割机」。它的签名长这样:

contours, hierarchy = cv2.findContours(image, mode, method[, offset])

参数不多,但每个都有讲究:

  • image:输入图像。必须是8位单通道二值图。注意,不是灰度图,是二值图!我见过有人直接拿彩色图往里塞,结果报错一脸懵。
  • mode:轮廓检索模式。这个咱们下面细聊。
  • method:轮廓近似方法。也放后面讲。
  • offset:偏移量。每个轮廓点都加上这个偏移,适合在ROI里提取轮廓后映射回原图。

返回值有两个:

  • contours:一个Python列表。每个元素是一个轮廓,类型是Numpy数组,形状是(N, 1, 2)。N是轮廓上的点数。
  • hierarchy:层级信息。后面讲轮廓检索模式时会用到。
小提示: OpenCV 3.x 之后,findContours 不再修改原图。如果你需要保留原二值图,记得先 copy 一份。

4.2 轮廓检索模式:你想怎么找?

这个参数决定了轮廓之间的「父子关系」怎么处理。我当年做文档扫描时,就因为选错了模式,多出来一堆没用的内轮廓,调试了半天。

常用的模式有四种:

模式 含义 适用场景
RETR_EXTERNAL 只检测最外层轮廓 物体外边界,忽略内部空洞
RETR_LIST 检测所有轮廓,不建立层级关系 简单场景,不需要父子信息
RETR_CCOMP 检测所有轮廓,组织为两层结构 内外嵌套,比如带孔的物体
RETR_TREE 检测所有轮廓,建立完整的树形层级 复杂嵌套,需要完整拓扑关系

嗯,这里要注意:RETR_EXTERNAL 是我用得最多的模式。为什么?因为很多场景下,我们只关心物体的外边界,内部的孔洞反而会干扰分析。比如检测传送带上的零件,你只需要外轮廓就够了。

那什么时候用 RETR_TREE 呢?我举个例子。有一次做手写数字识别,数字「8」有两个内轮廓(上下两个圈)。如果用 RETR_EXTERNAL,只能拿到外圈,内部信息全丢了。这时候 RETR_TREE 就派上用场了,它能告诉你哪个轮廓是父,哪个是子。

核心理解: hierarchy 的格式是 [Next, Previous, First_Child, Parent]。每个轮廓对应一个这样的四元组。如果某个值是 -1,表示不存在对应的关系。

4.3 轮廓近似方法:压缩还是保留?

这个参数决定了轮廓点怎么存储。说白了,就是「要不要省点内存」。

两种常用方法:

  • CHAIN_APPROX_NONE:存储所有边界点。一个像素都不少。
  • CHAIN_APPROX_SIMPLE:只存储关键点。比如一条直线段,只存两个端点。

你猜哪个更常用?

我告诉你,CHAIN_APPROX_SIMPLE 是默认选择。为什么?因为大多数情况下,我们不需要每个像素点。比如一个矩形轮廓,用 NONE 会存几百个点,用 SIMPLE 只存4个角点。处理速度差了一个数量级。

但是!有一种情况必须用 NONE——当你需要精确的边界像素坐标时。我记得有一次做细胞图像分析,细胞的边缘非常不规则,用 SIMPLE 近似后丢失了太多细节,导致后续的面积计算偏差很大。后来换成 NONE,问题就解决了。

避坑指南: 我曾经在嵌入式设备上跑轮廓检测,内存只有256MB。一开始用了 CHAIN_APPROX_NONE,结果一张1080p的图像直接爆内存。后来改成 SIMPLE,内存占用降了80%。所以,资源受限的场景,务必用 SIMPLE。

4.4 实战:从二值图到轮廓

光说不练假把式。咱们走一遍完整流程:

import cv2
import numpy as np

# 1. 读取图像并转灰度
img = cv2.imread('shapes.png')
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)

# 2. 二值化
_, binary = cv2.threshold(gray, 127, 255, cv2.THRESH_BINARY)

# 3. 发现轮廓
contours, hierarchy = cv2.findContours(
    binary, 
    cv2.RETR_EXTERNAL, 
    cv2.CHAIN_APPROX_SIMPLE
)

# 4. 在原图上绘制轮廓
cv2.drawContours(img, contours, -1, (0, 255, 0), 2)

# 5. 打印轮廓信息
print(f"发现 {len(contours)} 个轮廓")
for i, cnt in enumerate(contours):
    area = cv2.contourArea(cnt)
    print(f"轮廓 {i}: 面积 = {area:.2f}, 点数 = {len(cnt)}")

cv2.imshow('Result', img)
cv2.waitKey(0)

这段代码,我几乎每个项目都会用到。它就是一个轮廓分析的「起手式」。

你可能会问:为什么先二值化?因为 findContours 要求输入是二值图。灰度图里像素值有0到255,算法没法判断哪些是「前景」哪些是「背景」。二值化之后,255是前景(白色),0是背景(黑色),轮廓就是黑白交界的地方。

个人经验: 二值化的阈值选择很关键。我一般先用 Otsu 自动阈值,如果效果不好再手动调。Otsu 适合直方图呈双峰分布的图像,比如文档扫描、零件检测这类场景。

4.5 轮廓与边缘检测的区别

这个问题,我面试时经常问候选人。很多人答不上来。

边缘检测(比如 Canny)输出的是二值边缘图,每个边缘像素是独立的,没有顺序信息。而轮廓是有序的点集,你可以沿着轮廓走一圈,知道哪里是起点、哪里是终点。

举个例子:

  • Canny 告诉你「这里有边缘」
  • findContours 告诉你「这个边缘是一个闭合的圆,周长是100像素」

所以,轮廓分析能做很多边缘检测做不到的事:计算面积、周长、形状匹配、凸包检测……这些咱们后面几章会一一展开。

一句话总结: 边缘检测是「找点」,轮廓分析是「找结构」。两者配合使用,效果最佳。

4.6 本章小结

这一章咱们把 findContours 的底裤都扒干净了:

  • 函数签名和参数含义
  • 四种检索模式的区别和选择
  • 两种近似方法的取舍
  • 完整的实战代码
  • 轮廓与边缘检测的本质区别

下一章,咱们会深入轮廓的几何属性——面积、周长、外接矩形、最小外接圆……这些都是实际项目里天天用的东西。到时候我会分享一些「坑」和「捷径」,保证让你少走弯路。

嗯,今天就到这儿。动手试试上面的代码,换几张自己的图片跑一跑。有问题随时翻回来看看。