OpenCV 工业视觉学习笔记(四):连通域分析与轮廓提取

前言

在上一篇文章中,学习了腐蚀、膨胀、开运算、闭运算以及顶帽、黑帽等形态学操作。

经过阈值分割和形态学处理后,我们通常会得到一张相对干净的二值图像。但是,仅仅得到二值图还不够。

在实际工业检测中,还需要进一步回答以下问题:

  • 图像中一共有多少个目标?
  • 每个目标位于什么位置?
  • 每个目标的面积是多少?
  • 目标的宽度和高度是多少?
  • 哪些区域是噪点?
  • 哪些区域可能是真正的缺陷?
  • 目标是否包含孔洞?
  • 目标的外轮廓是什么形状?

这些问题通常需要使用两类方法:

  1. 连通域分析;
  2. 轮廓提取。

连通域分析更偏向于对二值区域进行编号和统计,轮廓提取则更偏向于获取目标边界和分析目标形状。

本篇主要学习以下内容:

  1. 什么是连通区域;
  2. 四连通与八连通;
  3. connectedComponents() 的使用;
  4. connectedComponentsWithStats() 的使用;
  5. 连通域面积、位置和中心点统计;
  6. 连通域筛选;
  7. 什么是轮廓;
  8. findContours() 的使用;
  9. 轮廓检索模式;
  10. 轮廓近似方式;
  11. 轮廓面积、周长和外接矩形;
  12. 轮廓层级与孔洞;
  13. 连通域分析与轮廓提取的区别;
  14. 工业缺陷区域筛选的完整流程。

一、什么是连通区域

在一张二值图像中,如果一组白色像素在空间上彼此连接,那么这些像素就可以组成一个连通区域。

假设二值图中存在三个互不相连的白色区域:

区域A      区域B

             区域C

那么可以认为图像中存在三个前景连通域。

连通域分析会为每个区域分配一个独立编号,例如:

背景:0
区域A:1
区域B:2
区域C:3

通过这些编号,可以分别计算每个区域的:

  • 面积;
  • 左上角位置;
  • 宽度;
  • 高度;
  • 中心点;
  • 外接矩形。

因此,连通域分析很适合处理已经完成二值化的目标区域。


二、前景与背景

OpenCV 的连通域分析通常将二值图中的非零像素视为前景。

也就是说:

0:背景
非0:前景

在普通二值图中,一般表现为:

黑色:背景
白色:目标

因此,在执行连通域分析前,最好保证需要检测的目标是白色。

如果目标比背景暗,可以在阈值分割时使用:

threshold(
    gray,
    binary,
    100,
    255,
    THRESH_BINARY_INV
);

这样可以将较暗目标转换为白色区域。

如果黑白方向已经相反,也可以使用:

bitwise_not(binary, binary);

对二值图进行反转。


三、四连通与八连通

判断两个像素是否属于同一个区域时,需要先确定像素之间的连接规则。

常见的连接方式有:

  • 四连通;
  • 八连通。

1. 四连通

四连通只考虑当前像素的上、下、左、右四个方向。

    上
左  当前  右
    下

可以表示为:

0 1 0
1 X 1
0 1 0

其中 X 表示当前像素。

如果两个白色像素只在对角线方向接触,那么在四连通中,它们不属于同一个区域。

例如:

1 0
0 1

这两个白色像素在四连通下会被识别为两个区域。


2. 八连通

八连通除了上、下、左、右,还包括四个对角线方向。

左上  上  右上
左   当前  右
左下  下  右下

可以表示为:

1 1 1
1 X 1
1 1 1

对于下面两个对角线接触的像素:

1 0
0 1

在八连通下,它们会被认为属于同一个区域。


3. 如何选择

如果希望对角线接触的像素也属于同一个目标,可以使用八连通。

如果希望连接关系更加严格,只把上下左右相邻的像素视为一个区域,可以使用四连通。

工业视觉中通常更常用:

8

也就是八连通。

因为实际目标边缘容易出现倾斜或锯齿状结构,如果使用四连通,某些斜线区域可能会被拆分成多个小区域。


四、使用 connectedComponents 进行连通域标记

OpenCV 可以使用 connectedComponents() 对二值图进行连通域编号。

基本代码如下:

Mat labels;

int labelCount = connectedComponents(
    binary,
    labels,
    8,
    CV_32S
);

参数含义如下:

  • binary:输入二值图;
  • labels:输出标签图;
  • 8:使用八连通;
  • CV_32S:标签图使用 32 位有符号整数;
  • labelCount:标签总数量,包括背景。

需要特别注意:

返回值包含背景区域。

如果返回:

labelCount = 4

说明标签分别是:

0、1、2、3

其中:

  • 标签 0 是背景;
  • 标签 1、2、3 是三个前景区域。

因此,实际前景区域数量为:

int foregroundCount = labelCount - 1;

五、labels 标签图是什么

connectedComponents() 输出的 labels 不是普通的显示图像。

它通常是:

CV_32SC1

也就是:

  • 单通道;
  • 每个像素使用 32 位整数;
  • 每个像素保存所属连通域的编号。

例如:

0 0 0 0 0
0 1 1 0 0
0 1 1 0 2
0 0 0 0 2

表示:

  • 数值 0 的位置属于背景;
  • 数值 1 的位置属于第一个连通域;
  • 数值 2 的位置属于第二个连通域。

可以通过以下方式读取某个位置的标签值:

int label = labels.at<int>(row, col);

这里必须使用:

int

而不是 uchar,因为标签图的数据类型通常是 CV_32S


六、给不同连通域着色

标签图不能直接作为普通灰度图显示,否则不同区域的标签值可能只有 0、1、2、3,显示出来几乎都是黑色。

可以为每个标签分配一种颜色。

#include <opencv2/opencv.hpp>
#include <iostream>
#include <vector>

using namespace cv;
using namespace std;

int main()
{
    Mat binary = imread(
        "D:/images/binary.png",
        IMREAD_GRAYSCALE
    );

    if (binary.empty())
    {
        cerr << "图像读取失败" << endl;
        return -1;
    }

    threshold(
        binary,
        binary,
        127,
        255,
        THRESH_BINARY
    );

    Mat labels;

    int labelCount = connectedComponents(
        binary,
        labels,
        8,
        CV_32S
    );

    vector<Vec3b> colors(labelCount);

    colors[0] = Vec3b(0, 0, 0);

    RNG rng(12345);

    for (int label = 1; label < labelCount; label++)
    {
        colors[label] = Vec3b(
            static_cast<uchar>(rng.uniform(0, 256)),
            static_cast<uchar>(rng.uniform(0, 256)),
            static_cast<uchar>(rng.uniform(0, 256))
        );
    }

    Mat colorLabels(
        labels.size(),
        CV_8UC3,
        Scalar(0, 0, 0)
    );

    for (int row = 0; row < labels.rows; row++)
    {
        const int* labelRow = labels.ptr<int>(row);
        Vec3b* colorRow = colorLabels.ptr<Vec3b>(row);

        for (int col = 0; col < labels.cols; col++)
        {
            int label = labelRow[col];
            colorRow[col] = colors[label];
        }
    }

    cout << "前景区域数量:"
         << labelCount - 1
         << endl;

    imshow("二值图", binary);
    imshow("连通域着色", colorLabels);

    waitKey(0);
    destroyAllWindows();

    return 0;
}

运行后,每个连通区域会显示为不同颜色。

这种方式可以直观地检查:

  • 哪些像素被分到同一个区域;
  • 对角线区域是否被连接;
  • 目标是否被错误拆分;
  • 多个目标是否发生粘连。

七、connectedComponents 的局限

connectedComponents() 只完成区域编号。

如果需要获得每个区域的面积、宽度、高度和中心点,还需要自己遍历标签图进行统计。

OpenCV 提供了更加方便的函数:

connectedComponentsWithStats()

它可以在完成标记的同时,直接统计每个区域的属性。


八、connectedComponentsWithStats 函数

基本写法如下:

Mat labels;
Mat stats;
Mat centroids;

int labelCount = connectedComponentsWithStats(
    binary,
    labels,
    stats,
    centroids,
    8,
    CV_32S
);

各参数含义如下:

  • binary:输入二值图;
  • labels:每个像素所属的标签;
  • stats:每个区域的位置和尺寸信息;
  • centroids:每个区域的中心点;
  • 8:八连通;
  • CV_32S:标签矩阵类型;
  • 返回值:标签总数,包括背景。

九、stats 矩阵包含哪些信息

stats 中每一行对应一个标签。

可以使用以下常量读取区域属性:

CC_STAT_LEFT
CC_STAT_TOP
CC_STAT_WIDTH
CC_STAT_HEIGHT
CC_STAT_AREA

它们分别表示:

属性 含义
CC_STAT_LEFT 外接矩形左上角横坐标
CC_STAT_TOP 外接矩形左上角纵坐标
CC_STAT_WIDTH 外接矩形宽度
CC_STAT_HEIGHT 外接矩形高度
CC_STAT_AREA 连通区域像素面积

读取方法如下:

int left = stats.at<int>(
    label,
    CC_STAT_LEFT
);

int top = stats.at<int>(
    label,
    CC_STAT_TOP
);

int width = stats.at<int>(
    label,
    CC_STAT_WIDTH
);

int height = stats.at<int>(
    label,
    CC_STAT_HEIGHT
);

int area = stats.at<int>(
    label,
    CC_STAT_AREA
);

这里的 label 表示区域编号。

标签 0 是背景,因此处理前景时一般从 1 开始遍历:

for (int label = 1; label < labelCount; label++)
{
    // 读取前景区域信息
}

十、centroids 中心点矩阵

centroids 保存每个连通区域的几何中心坐标。

读取方法如下:

double centerX = centroids.at<double>(
    label,
    0
);

double centerY = centroids.at<double>(
    label,
    1
);

其中:

  • 第 0 列是横坐标 x
  • 第 1 列是纵坐标 y

可以构造一个点:

Point center(
    cvRound(centerX),
    cvRound(centerY)
);

需要注意:

centerX 对应列方向
centerY 对应行方向

也就是:

x 对应 cols
y 对应 rows

十一、连通域统计完整示例

下面读取每个区域的面积、位置、宽高和中心点,并将结果绘制到原图上。

#include <opencv2/opencv.hpp>
#include <iostream>
#include <string>

using namespace cv;
using namespace std;

int main()
{
    Mat source = imread(
        "D:/images/test.jpg"
    );

    if (source.empty())
    {
        cerr << "图像读取失败" << endl;
        return -1;
    }

    Mat gray;
    cvtColor(
        source,
        gray,
        COLOR_BGR2GRAY
    );

    Mat binary;
    threshold(
        gray,
        binary,
        0,
        255,
        THRESH_BINARY_INV | THRESH_OTSU
    );

    Mat labels;
    Mat stats;
    Mat centroids;

    int labelCount = connectedComponentsWithStats(
        binary,
        labels,
        stats,
        centroids,
        8,
        CV_32S
    );

    Mat result = source.clone();

    for (int label = 1; label < labelCount; label++)
    {
        int left = stats.at<int>(
            label,
            CC_STAT_LEFT
        );

        int top = stats.at<int>(
            label,
            CC_STAT_TOP
        );

        int width = stats.at<int>(
            label,
            CC_STAT_WIDTH
        );

        int height = stats.at<int>(
            label,
            CC_STAT_HEIGHT
        );

        int area = stats.at<int>(
            label,
            CC_STAT_AREA
        );

        double centerX = centroids.at<double>(
            label,
            0
        );

        double centerY = centroids.at<double>(
            label,
            1
        );

        Rect boundingBox(
            left,
            top,
            width,
            height
        );

        Point center(
            cvRound(centerX),
            cvRound(centerY)
        );

        rectangle(
            result,
            boundingBox,
            Scalar(0, 0, 255),
            2
        );

        circle(
            result,
            center,
            3,
            Scalar(0, 255, 0),
            FILLED
        );

        string text =
            "Area=" + to_string(area);

        putText(
            result,
            text,
            Point(left, max(20, top - 5)),
            FONT_HERSHEY_SIMPLEX,
            0.5,
            Scalar(255, 0, 0),
            1
        );

        cout << "标签:" << label << endl;
        cout << "位置:(" << left
             << ", " << top << ")" << endl;
        cout << "宽度:" << width << endl;
        cout << "高度:" << height << endl;
        cout << "面积:" << area << endl;
        cout << "中心:(" << centerX
             << ", " << centerY << ")" << endl;
        cout << "----------------" << endl;
    }

    cout << "前景区域总数:"
         << labelCount - 1
         << endl;

    imshow("二值图", binary);
    imshow("连通域分析", result);

    waitKey(0);
    destroyAllWindows();

    return 0;
}

十二、使用面积筛选连通域

二值图中经常存在许多小噪点。

可以根据面积去除不符合条件的区域。

例如,只保留面积大于等于 100 的区域:

const int minimumArea = 100;

for (int label = 1; label < labelCount; label++)
{
    int area = stats.at<int>(
        label,
        CC_STAT_AREA
    );

    if (area < minimumArea)
    {
        continue;
    }

    // 处理有效区域
}

也可以同时设置面积上下限:

const int minimumArea = 100;
const int maximumArea = 5000;

if (area < minimumArea ||
    area > maximumArea)
{
    continue;
}

这种方式适合筛选:

  • 小噪点;
  • 大面积背景误分区域;
  • 尺寸明显不符合要求的目标;
  • 不同大小的缺陷类别。

十三、生成筛选后的二值图

有时不仅需要画外接矩形,还需要生成一张只包含有效区域的新二值图。

可以根据标签图进行筛选:

Mat filtered = Mat::zeros(
    binary.size(),
    CV_8UC1
);

for (int label = 1; label < labelCount; label++)
{
    int area = stats.at<int>(
        label,
        CC_STAT_AREA
    );

    if (area < 100)
    {
        continue;
    }

    filtered.setTo(
        255,
        labels == label
    );
}

其中:

labels == label

会生成一张掩膜,表示标签等于当前区域编号的位置。

这样就可以只保留满足面积条件的连通区域。

完整代码:

Mat labels;
Mat stats;
Mat centroids;

int count = connectedComponentsWithStats(
    binary,
    labels,
    stats,
    centroids,
    8,
    CV_32S
);

Mat filtered = Mat::zeros(
    binary.size(),
    CV_8UC1
);

for (int label = 1; label < count; label++)
{
    int area = stats.at<int>(
        label,
        CC_STAT_AREA
    );

    if (area >= 100)
    {
        filtered.setTo(
            255,
            labels == label
        );
    }
}

十四、根据宽度和高度筛选

除了面积,还可以根据外接矩形的宽度和高度筛选区域。

例如:

if (width < 10 || height < 10)
{
    continue;
}

只保留宽度和高度都大于等于 10 像素的目标。

还可以设置范围:

if (width < 10 || width > 100)
{
    continue;
}

if (height < 5 || height > 80)
{
    continue;
}

这种方法适合检测尺寸相对固定的目标,例如:

  • 孔;
  • 焊点;
  • 字符;
  • 圆点;
  • 小零件;
  • 固定尺寸缺陷。

十五、根据宽高比筛选

可以计算区域外接矩形的宽高比:

double aspectRatio =
    static_cast<double>(width) / height;

例如,只保留细长的水平区域:

if (aspectRatio < 3.0)
{
    continue;
}

只保留接近正方形的区域:

if (aspectRatio < 0.8 ||
    aspectRatio > 1.2)
{
    continue;
}

需要注意,如果目标发生旋转,普通外接矩形的宽高比可能发生明显变化。

这种情况下,后面可以使用旋转外接矩形:

minAreaRect()

获得更加合理的方向和尺寸信息。


十六、什么是轮廓

连通域分析关注的是整个白色区域。

轮廓提取关注的是区域边界。

可以将轮廓理解为:

由一系列边界点组成的点集。

例如,一个矩形目标的轮廓可以由边缘上的坐标点组成:

(x1, y1)
(x2, y2)
(x3, y3)
……

OpenCV 中,一个轮廓通常使用:

vector<Point>

表示。

多个轮廓使用:

vector<vector<Point>>

表示。

也就是说:

vector<vector<Point>> contours;

其中:

  • contours[0] 是第一个轮廓;
  • contours[1] 是第二个轮廓;
  • contours[i][j] 是第 i 个轮廓中的第 j 个点。

十七、使用 findContours 提取轮廓

OpenCV 使用 findContours() 提取二值图中的轮廓。

基本写法:

vector<vector<Point>> contours;

findContours(
    binary,
    contours,
    RETR_EXTERNAL,
    CHAIN_APPROX_SIMPLE
);

参数含义:

  • binary:输入二值图;
  • contours:输出轮廓集合;
  • RETR_EXTERNAL:只提取最外层轮廓;
  • CHAIN_APPROX_SIMPLE:压缩轮廓点。

读取到的轮廓数量为:

cout << contours.size() << endl;

需要注意:

findContours() 同样把白色非零区域视为前景。

因此,待检测目标最好是白色区域。


十八、轮廓提取完整示例

#include <opencv2/opencv.hpp>
#include <iostream>
#include <vector>

using namespace cv;
using namespace std;

int main()
{
    Mat source = imread(
        "D:/images/test.jpg"
    );

    if (source.empty())
    {
        cerr << "图像读取失败" << endl;
        return -1;
    }

    Mat gray;
    cvtColor(
        source,
        gray,
        COLOR_BGR2GRAY
    );

    Mat binary;
    threshold(
        gray,
        binary,
        0,
        255,
        THRESH_BINARY_INV | THRESH_OTSU
    );

    vector<vector<Point>> contours;

    findContours(
        binary,
        contours,
        RETR_EXTERNAL,
        CHAIN_APPROX_SIMPLE
    );

    Mat result = source.clone();

    drawContours(
        result,
        contours,
        -1,
        Scalar(0, 0, 255),
        2
    );

    cout << "轮廓数量:"
         << contours.size()
         << endl;

    imshow("二值图", binary);
    imshow("轮廓结果", result);

    waitKey(0);
    destroyAllWindows();

    return 0;
}

十九、drawContours 函数

drawContours() 用于绘制轮廓。

基本代码:

drawContours(
    result,
    contours,
    -1,
    Scalar(0, 0, 255),
    2
);

其中:

  • result:绘制目标图像;
  • contours:轮廓集合;
  • -1:绘制所有轮廓;
  • Scalar(0, 0, 255):红色;
  • 2:线宽为 2。

如果只绘制第一个轮廓:

drawContours(
    result,
    contours,
    0,
    Scalar(0, 255, 0),
    2
);

如果希望把轮廓内部填满:

drawContours(
    result,
    contours,
    -1,
    Scalar(255, 255, 255),
    FILLED
);

轮廓填充可以用于重新生成区域掩膜。


二十、轮廓检索模式

findContours() 的第三个参数决定如何查找轮廓以及是否记录轮廓之间的层级关系。

常见模式包括:

RETR_EXTERNAL
RETR_LIST
RETR_CCOMP
RETR_TREE

二十一、RETR_EXTERNAL

RETR_EXTERNAL

表示只提取最外层轮廓。

假设有一个白色圆环:

外部是白色圆
内部有一个黑色孔

使用 RETR_EXTERNAL 时,只会返回最外面的圆形轮廓,不会返回内部孔洞的轮廓。

它适合:

  • 只关心独立目标的外边界;
  • 不关心目标内部孔洞;
  • 统计最外层目标数量;
  • 普通缺陷区域提取。

使用方式:

findContours(
    binary,
    contours,
    RETR_EXTERNAL,
    CHAIN_APPROX_SIMPLE
);

二十二、RETR_LIST

RETR_LIST

表示提取所有轮廓,但是不建立完整的父子层级关系。

外部轮廓和内部孔洞轮廓都会被提取出来。

它适合:

  • 需要得到所有边界;
  • 不关心轮廓之间的父子关系;
  • 后续只根据面积、周长等特征进行筛选。

二十三、RETR_CCOMP

RETR_CCOMP

会建立两层轮廓结构:

  • 第一层:外部轮廓;
  • 第二层:孔洞轮廓。

如果孔洞内部还有新的目标,该目标会重新作为第一层轮廓处理。

这种模式适合处理:

  • 带孔目标;
  • 二层嵌套结构;
  • 需要区分外轮廓和内孔轮廓的场景。

二十四、RETR_TREE

RETR_TREE

会提取所有轮廓,并建立完整的轮廓层级树。

例如:

大白色区域
  └── 黑色孔洞
        └── 白色小区域
              └── 黑色小孔

使用 RETR_TREE 可以记录完整的父子关系。

它适合:

  • 目标存在多层嵌套;
  • 需要判断孔洞;
  • 需要统计子轮廓;
  • 需要分析复杂区域结构。

二十五、轮廓层级 hierarchy

如果需要获取轮廓层级,可以定义:

vector<Vec4i> hierarchy;

然后调用:

findContours(
    binary,
    contours,
    hierarchy,
    RETR_TREE,
    CHAIN_APPROX_SIMPLE
);

hierarchy[i] 表示第 i 个轮廓的层级信息。

它包含四个整数:

[next, previous, firstChild, parent]

对应:

hierarchy[i][0]:下一个同级轮廓
hierarchy[i][1]:上一个同级轮廓
hierarchy[i][2]:第一个子轮廓
hierarchy[i][3]:父轮廓

如果某个关系不存在,则对应值为:

-1

例如:

int next = hierarchy[i][0];
int previous = hierarchy[i][1];
int firstChild = hierarchy[i][2];
int parent = hierarchy[i][3];

二十六、判断轮廓是否存在孔洞

如果使用:

RETR_TREE

或:

RETR_CCOMP

可以通过子轮廓判断当前外轮廓是否包含孔洞。

例如:

if (hierarchy[i][2] != -1)
{
    cout << "轮廓 "
         << i
         << " 存在子轮廓"
         << endl;
}

这里:

hierarchy[i][2]

表示当前轮廓的第一个子轮廓。

如果不等于 -1,说明当前轮廓内部至少还有一个轮廓。

但要注意:

子轮廓不一定在所有情况下都等同于缺陷孔洞,还需要结合图像黑白关系和层级深度判断。


二十七、轮廓近似方式

findContours() 的第四个参数决定轮廓点如何保存。

常见方式包括:

CHAIN_APPROX_NONE
CHAIN_APPROX_SIMPLE

1. CHAIN_APPROX_NONE

CHAIN_APPROX_NONE

会尽量保存轮廓上的所有边界点。

例如,一条水平直线中可能保存大量连续点:

(10,20)
(11,20)
(12,20)
(13,20)
……

优点:

  • 轮廓点完整;
  • 适合需要逐点分析的情况。

缺点:

  • 点数量多;
  • 占用内存较大;
  • 后续计算量更大。

2. CHAIN_APPROX_SIMPLE

CHAIN_APPROX_SIMPLE

会压缩水平、垂直和斜线段上的冗余点,只保留关键端点。

例如,一个矩形可能只保存四个顶点。

优点:

  • 减少轮廓点数量;
  • 节省内存;
  • 提高后续计算速度;
  • 通常足够满足面积、周长和外接矩形计算。

工业视觉中通常优先使用:

CHAIN_APPROX_SIMPLE

二十八、计算轮廓面积

OpenCV 使用:

contourArea()

计算轮廓面积。

double area = contourArea(
    contours[i]
);

遍历所有轮廓:

for (size_t i = 0; i < contours.size(); i++)
{
    double area = contourArea(
        contours[i]
    );

    cout << "轮廓 "
         << i
         << " 面积:"
         << area
         << endl;
}

可以根据面积筛选轮廓:

if (area < 100)
{
    continue;
}

二十九、连通域面积与轮廓面积为什么不同

连通域的:

CC_STAT_AREA

统计的是区域内白色像素数量。

轮廓的:

contourArea()

根据轮廓点计算几何面积。

两者计算方式不同,因此结果可能存在差异。

尤其对于:

  • 很小的区域;
  • 边缘锯齿明显的区域;
  • 细线目标;
  • 开放轮廓;
  • 像素数量很少的目标;

差异可能更加明显。

因此,在同一套检测标准中,应尽量固定使用一种面积定义,而不是混合使用。


三十、计算轮廓周长

OpenCV 使用:

arcLength()

计算轮廓周长。

double perimeter = arcLength(
    contours[i],
    true
);

第二个参数表示轮廓是否闭合:

true

表示按闭合轮廓计算。

完整写法:

double perimeter = arcLength(
    contours[i],
    true
);

周长可以用于:

  • 判断目标大小;
  • 计算圆度;
  • 分析边缘复杂程度;
  • 过滤细碎轮廓;
  • 进行多边形逼近。

三十一、普通外接矩形 boundingRect

OpenCV 使用:

boundingRect()

计算轮廓的水平外接矩形。

Rect box = boundingRect(
    contours[i]
);

可以读取:

int x = box.x;
int y = box.y;
int width = box.width;
int height = box.height;

绘制矩形:

rectangle(
    result,
    box,
    Scalar(0, 0, 255),
    2
);

普通外接矩形的边始终与图像坐标轴平行。

即使目标发生旋转,矩形仍然保持水平和垂直。


三十二、旋转外接矩形 minAreaRect

对于倾斜目标,可以使用:

minAreaRect()

计算最小面积旋转矩形。

RotatedRect rotatedBox = minAreaRect(
    contours[i]
);

RotatedRect 包含:

  • 中心点;
  • 宽度和高度;
  • 旋转角度。

可以读取:

Point2f center = rotatedBox.center;
Size2f size = rotatedBox.size;
float angle = rotatedBox.angle;

获取四个顶点:

Point2f points[4];
rotatedBox.points(points);

绘制旋转矩形:

for (int j = 0; j < 4; j++)
{
    line(
        result,
        points[j],
        points[(j + 1) % 4],
        Scalar(0, 255, 0),
        2
    );
}

完整示例:

RotatedRect box = minAreaRect(
    contours[i]
);

Point2f vertices[4];
box.points(vertices);

for (int j = 0; j < 4; j++)
{
    line(
        result,
        vertices[j],
        vertices[(j + 1) % 4],
        Scalar(0, 255, 0),
        2
    );
}

三十三、轮廓中心点

可以通过图像矩计算轮廓中心点。

OpenCV 使用:

moments()

获取轮廓矩:

Moments m = moments(
    contours[i]
);

中心点计算方式:

double centerX = m.m10 / m.m00;
double centerY = m.m01 / m.m00;

使用前要判断:

m.m00 != 0

完整代码:

Moments m = moments(
    contours[i]
);

if (abs(m.m00) > 1e-6)
{
    Point center(
        cvRound(m.m10 / m.m00),
        cvRound(m.m01 / m.m00)
    );

    circle(
        result,
        center,
        3,
        Scalar(255, 0, 0),
        FILLED
    );
}

如果轮廓面积为 0,例如轮廓接近一条线,m.m00 可能为 0。

这时不能直接做除法。


三十四、最小外接圆

OpenCV 使用:

minEnclosingCircle()

计算能够包围轮廓的最小圆。

Point2f center;
float radius;

minEnclosingCircle(
    contours[i],
    center,
    radius
);

绘制结果:

circle(
    result,
    center,
    cvRound(radius),
    Scalar(255, 0, 255),
    2
);

最小外接圆适合:

  • 圆形目标定位;
  • 孔洞检测;
  • 颗粒尺寸估计;
  • 圆形焊点分析;
  • 判断目标是否接近圆形。

三十五、多边形逼近 approxPolyDP

轮廓中可能包含大量细小波动点。

可以使用:

approxPolyDP()

对轮廓进行多边形逼近。

vector<Point> approximate;

double perimeter = arcLength(
    contours[i],
    true
);

approxPolyDP(
    contours[i],
    approximate,
    0.02 * perimeter,
    true
);

其中:

0.02 * perimeter

表示允许的逼近误差。

误差越小:

  • 保留的点越多;
  • 轮廓越接近原始边界。

误差越大:

  • 轮廓点越少;
  • 形状被简化得越明显。

可以根据逼近后的顶点数量进行简单形状判断:

if (approximate.size() == 3)
{
    cout << "可能是三角形" << endl;
}
else if (approximate.size() == 4)
{
    cout << "可能是矩形或四边形" << endl;
}

但顶点数量会受到噪声、阈值和逼近误差影响,不能单独作为最终判断依据。


三十六、轮廓筛选完整示例

下面根据轮廓面积筛选目标,并绘制外接矩形、中心点和面积。

#include <opencv2/opencv.hpp>
#include <iostream>
#include <vector>
#include <string>

using namespace cv;
using namespace std;

int main()
{
    Mat source = imread(
        "D:/images/defect.jpg"
    );

    if (source.empty())
    {
        cerr << "图像读取失败" << endl;
        return -1;
    }

    Mat gray;
    cvtColor(
        source,
        gray,
        COLOR_BGR2GRAY
    );

    Mat blurred;
    GaussianBlur(
        gray,
        blurred,
        Size(5, 5),
        0
    );

    Mat binary;
    threshold(
        blurred,
        binary,
        0,
        255,
        THRESH_BINARY_INV | THRESH_OTSU
    );

    Mat kernel = getStructuringElement(
        MORPH_ELLIPSE,
        Size(3, 3)
    );

    morphologyEx(
        binary,
        binary,
        MORPH_OPEN,
        kernel
    );

    vector<vector<Point>> contours;

    findContours(
        binary,
        contours,
        RETR_EXTERNAL,
        CHAIN_APPROX_SIMPLE
    );

    Mat result = source.clone();

    const double minimumArea = 100.0;
    const double maximumArea = 5000.0;

    int validCount = 0;

    for (size_t i = 0; i < contours.size(); i++)
    {
        double area = contourArea(
            contours[i]
        );

        if (area < minimumArea ||
            area > maximumArea)
        {
            continue;
        }

        Rect box = boundingRect(
            contours[i]
        );

        Moments m = moments(
            contours[i]
        );

        Point center(
            box.x + box.width / 2,
            box.y + box.height / 2
        );

        if (abs(m.m00) > 1e-6)
        {
            center.x = cvRound(
                m.m10 / m.m00
            );

            center.y = cvRound(
                m.m01 / m.m00
            );
        }

        rectangle(
            result,
            box,
            Scalar(0, 0, 255),
            2
        );

        circle(
            result,
            center,
            3,
            Scalar(0, 255, 0),
            FILLED
        );

        string text =
            "Area=" +
            to_string(
                static_cast<int>(area)
            );

        putText(
            result,
            text,
            Point(box.x, max(20, box.y - 5)),
            FONT_HERSHEY_SIMPLEX,
            0.5,
            Scalar(255, 0, 0),
            1
        );

        validCount++;
    }

    cout << "原始轮廓数量:"
         << contours.size()
         << endl;

    cout << "有效目标数量:"
         << validCount
         << endl;

    imshow("二值图", binary);
    imshow("检测结果", result);

    waitKey(0);
    destroyAllWindows();

    return 0;
}

三十七、连通域分析与轮廓提取的区别

连通域分析和轮廓提取都可以用于检测二值区域,但它们关注的内容不同。

对比项 连通域分析 轮廓提取
主要对象 完整区域 区域边界
基本输出 标签图 轮廓点集
面积 像素数量 轮廓几何面积
外接矩形 可直接从 stats 获取 通过 boundingRect 计算
中心点 可直接从 centroids 获取 通过 moments 计算
孔洞层级 不直接提供 hierarchy 可以提供
周长 不直接提供 arcLength
形状分析 能力较弱 能力较强
区域重建 根据标签生成 使用 drawContours 填充
常见用途 计数、区域统计 边界、形状和层级分析

三十八、什么时候使用连通域分析

以下情况可以优先使用连通域分析:

  • 只需要统计目标数量;
  • 只关心面积、位置、宽度和高度;
  • 需要快速去除小区域;
  • 不需要分析目标边界形状;
  • 不关心孔洞层级;
  • 需要生成每个目标的标签图。

例如:

  • 统计白点数量;
  • 检测缺陷区域数量;
  • 根据面积过滤噪声;
  • 获取每个目标的中心位置;
  • 统计颗粒面积;
  • 对区域进行编号。

三十九、什么时候使用轮廓提取

以下情况可以优先使用轮廓:

  • 需要计算周长;
  • 需要分析目标形状;
  • 需要获得目标边界点;
  • 需要旋转外接矩形;
  • 需要最小外接圆;
  • 需要判断孔洞和父子关系;
  • 需要进行多边形逼近;
  • 需要计算凸包、圆度或矩形度。

例如:

  • 检测圆孔;
  • 分析矩形工件;
  • 提取字符边界;
  • 检测轮廓缺口;
  • 判断区域是否包含孔洞;
  • 对目标进行形状匹配。

四十、连通域与轮廓是否可以一起使用

可以,而且在工业视觉中很常见。

一种常见思路是:

二值图
  ↓
连通域分析
  ↓
根据面积快速过滤噪点
  ↓
对保留区域提取轮廓
  ↓
计算周长、方向和形状特征

连通域适合快速筛选区域,轮廓适合进一步分析边界。

例如,先使用连通域过滤面积小于 100 的区域:

filtered.setTo(
    255,
    labels == label
);

然后再对 filtered 使用:

findContours()

这样可以减少无效小轮廓,提高后续形状分析的稳定性。


四十一、工业缺陷检测中的常见流程

一个基础的传统视觉缺陷检测流程可以是:

读取图像
    ↓
截取 ROI
    ↓
灰度化
    ↓
滤波降噪
    ↓
阈值分割
    ↓
开运算或闭运算
    ↓
连通域分析
    ↓
面积、宽高筛选
    ↓
轮廓提取
    ↓
周长、方向和形状分析
    ↓
输出缺陷位置和结果

对应代码结构如下:

Mat source = imread(
    "D:/images/defect.jpg"
);

Mat gray;
cvtColor(
    source,
    gray,
    COLOR_BGR2GRAY
);

Mat blurred;
GaussianBlur(
    gray,
    blurred,
    Size(5, 5),
    0
);

Mat binary;
threshold(
    blurred,
    binary,
    0,
    255,
    THRESH_BINARY_INV | THRESH_OTSU
);

Mat kernel = getStructuringElement(
    MORPH_ELLIPSE,
    Size(3, 3)
);

morphologyEx(
    binary,
    binary,
    MORPH_OPEN,
    kernel
);

后面再根据实际需求选择:

connectedComponentsWithStats()

或者:

findContours()

四十二、目标粘连时为什么只得到一个区域

如果两个目标在二值图中已经连接,那么无论使用连通域还是轮廓提取,它们都可能被识别成一个目标。

例如:

目标A████目标B

中间存在白色连接区域时,它们属于同一个连通区域。

可以尝试:

  • 适当腐蚀;
  • 使用开运算;
  • 调整阈值;
  • 改善光照;
  • 使用距离变换;
  • 使用分水岭算法;
  • 根据目标凹陷位置进行分割。

单纯修改 connectedComponents()findContours() 参数,通常不能分开已经连接的区域。


四十三、同一个目标为什么被拆成多个区域

常见原因包括:

  • 阈值过高或过低;
  • 目标内部灰度不均匀;
  • 二值区域存在断裂;
  • 图像噪声较大;
  • 使用四连通导致斜线区域断开;
  • 形态学腐蚀过强。

可以尝试:

  • 改用八连通;
  • 使用闭运算;
  • 适当膨胀;
  • 调整阈值;
  • 先进行滤波;
  • 改善光照均匀性。

四十四、轮廓数量为什么比目标数量多

如果使用:

RETR_LIST

或者:

RETR_TREE

目标内部的孔洞也会被识别为轮廓。

例如,一个白色圆环可能包含:

  • 一个外轮廓;
  • 一个内孔轮廓。

因此,轮廓数量可能是 2,但实际目标数量只有 1。

如果只关心最外层目标,可以使用:

RETR_EXTERNAL

四十五、轮廓面积为什么是零

某些轮廓可能接近:

  • 一条线;
  • 一个像素;
  • 极细小结构;
  • 未形成有效封闭区域的边界。

这时:

contourArea()

可能得到 0 或非常小的值。

如果需要处理线结构,应该更多关注:

arcLength()

而不是面积。


四十六、边界目标的处理

如果目标与图像边缘相交,它的轮廓可能不是一个完整目标。

可以根据外接矩形判断目标是否接触图像边界:

Rect box = boundingRect(
    contours[i]
);

bool touchesBorder =
    box.x <= 0 ||
    box.y <= 0 ||
    box.x + box.width >= binary.cols ||
    box.y + box.height >= binary.rows;

如果不希望处理边界目标:

if (touchesBorder)
{
    continue;
}

这在工业检测中很常见,例如:

  • 缺陷必须完整位于检测区域内部;
  • ROI 边缘存在无效结构;
  • 边缘区域不参与统计;
  • 产品没有完整进入视野。

四十七、根据实际尺寸筛选

连通域面积和轮廓面积默认使用像素作为单位。

例如:

面积 = 500 像素
宽度 = 30 像素
高度 = 20 像素

如果已经完成相机标定,已知:

每个像素对应 0.02 mm

则宽度可以转换为:

double pixelSize = 0.02;

double widthMillimeter =
    width * pixelSize;

面积需要乘以像素尺寸的平方:

double areaMillimeter =
    area * pixelSize * pixelSize;

例如:

一个像素边长 = 0.02 mm
一个像素面积 = 0.02 × 0.02 mm²

因此不能直接使用:

area * pixelSize

进行面积换算。


四十八、完整工业筛选示例

下面使用连通域分析,根据面积、宽度、高度和边界位置筛选缺陷。

#include <opencv2/opencv.hpp>
#include <iostream>
#include <string>

using namespace cv;
using namespace std;

int main()
{
    Mat source = imread(
        "D:/images/defect.jpg"
    );

    if (source.empty())
    {
        cerr << "图像读取失败" << endl;
        return -1;
    }

    Mat gray;
    cvtColor(
        source,
        gray,
        COLOR_BGR2GRAY
    );

    Mat blurred;
    GaussianBlur(
        gray,
        blurred,
        Size(5, 5),
        0
    );

    Mat binary;
    threshold(
        blurred,
        binary,
        0,
        255,
        THRESH_BINARY_INV | THRESH_OTSU
    );

    Mat kernel = getStructuringElement(
        MORPH_ELLIPSE,
        Size(3, 3)
    );

    morphologyEx(
        binary,
        binary,
        MORPH_OPEN,
        kernel
    );

    Mat labels;
    Mat stats;
    Mat centroids;

    int labelCount = connectedComponentsWithStats(
        binary,
        labels,
        stats,
        centroids,
        8,
        CV_32S
    );

    Mat result = source.clone();
    Mat validMask = Mat::zeros(
        binary.size(),
        CV_8UC1
    );

    const int minimumArea = 80;
    const int maximumArea = 5000;
    const int minimumWidth = 5;
    const int minimumHeight = 5;

    int validCount = 0;

    for (int label = 1; label < labelCount; label++)
    {
        int left = stats.at<int>(
            label,
            CC_STAT_LEFT
        );

        int top = stats.at<int>(
            label,
            CC_STAT_TOP
        );

        int width = stats.at<int>(
            label,
            CC_STAT_WIDTH
        );

        int height = stats.at<int>(
            label,
            CC_STAT_HEIGHT
        );

        int area = stats.at<int>(
            label,
            CC_STAT_AREA
        );

        bool touchesBorder =
            left <= 0 ||
            top <= 0 ||
            left + width >= binary.cols ||
            top + height >= binary.rows;

        if (area < minimumArea ||
            area > maximumArea)
        {
            continue;
        }

        if (width < minimumWidth ||
            height < minimumHeight)
        {
            continue;
        }

        if (touchesBorder)
        {
            continue;
        }

        double centerX = centroids.at<double>(
            label,
            0
        );

        double centerY = centroids.at<double>(
            label,
            1
        );

        Rect box(
            left,
            top,
            width,
            height
        );

        Point center(
            cvRound(centerX),
            cvRound(centerY)
        );

        validMask.setTo(
            255,
            labels == label
        );

        rectangle(
            result,
            box,
            Scalar(0, 0, 255),
            2
        );

        circle(
            result,
            center,
            3,
            Scalar(0, 255, 0),
            FILLED
        );

        string text =
            "A=" + to_string(area);

        putText(
            result,
            text,
            Point(left, max(20, top - 5)),
            FONT_HERSHEY_SIMPLEX,
            0.5,
            Scalar(255, 0, 0),
            1
        );

        validCount++;
    }

    cout << "全部前景区域:"
         << labelCount - 1
         << endl;

    cout << "有效区域:"
         << validCount
         << endl;

    imshow("原图", source);
    imshow("二值图", binary);
    imshow("有效区域", validMask);
    imshow("检测结果", result);

    waitKey(0);
    destroyAllWindows();

    return 0;
}

四十九、常见问题

1. 为什么背景也被统计成一个区域

因为连通域函数的返回结果包含标签 0,也就是背景。

因此遍历前景时应从 1 开始:

for (int label = 1;
     label < labelCount;
     label++)
{
}

前景区域数量为:

labelCount - 1

2. 为什么 labels 不能用 uchar 读取

因为标签图通常是:

CV_32S

所以应使用:

labels.at<int>(row, col)

而不是:

labels.at<uchar>(row, col)

3. findContours 应该输入灰度图还是二值图

通常应输入单通道二值图。

也就是:

CV_8UC1

目标区域为非零,背景为 0。

虽然部分模式可以处理其他标签图,但基础使用时应先完成二值化。


4. RETR_EXTERNAL 和 RETR_TREE 怎么选择

只关心最外层目标时使用:

RETR_EXTERNAL

需要分析孔洞和父子层级时使用:

RETR_TREE

如果不确定,普通缺陷检测可以先从 RETR_EXTERNAL 开始。


5. 连通域面积和 contourArea 应该用哪个

如果主要分析二值区域像素数量,使用:

CC_STAT_AREA

如果后面还需要计算周长、形状和轮廓特征,使用:

contourArea()

同一个项目中应统一检测标准。


6. 面积筛选能完全代替形态学去噪吗

不能完全代替。

面积筛选可以去除独立的小区域,但不能解决:

  • 目标内部小孔;
  • 两个目标之间的细连接;
  • 轮廓断裂;
  • 附着在大目标边缘的小突出物。

因此,面积筛选与形态学处理通常需要结合使用。


7. 为什么轮廓点坐标是 Point

OpenCV 中:

Point(x, y)

表示:

  • x:列坐标;
  • y:行坐标。

而访问像素时:

image.at<uchar>(row, col)

参数顺序是:

行、列

也就是:

y、x

两者的顺序不同,需要特别注意。


五十、本篇总结

本篇主要学习了 OpenCV 中的连通域分析与轮廓提取。

需要重点掌握以下内容:

  1. 二值图中的非零像素通常被视为前景;
  2. 四连通只考虑上下左右;
  3. 八连通还包括四个对角线方向;
  4. connectedComponents() 可以为连通区域编号;
  5. 返回的标签数量包含背景;
  6. 背景标签固定为 0;
  7. connectedComponentsWithStats() 可以直接获取面积、位置和尺寸;
  8. stats 可以获取左上角、宽度、高度和面积;
  9. centroids 可以获取区域中心点;
  10. 连通域适合目标计数和区域属性统计;
  11. findContours() 可以提取区域边界点;
  12. RETR_EXTERNAL 只提取最外层轮廓;
  13. RETR_TREE 可以建立完整父子层级;
  14. CHAIN_APPROX_SIMPLE 可以压缩冗余轮廓点;
  15. contourArea() 用于计算轮廓面积;
  16. arcLength() 用于计算轮廓周长;
  17. boundingRect() 用于计算水平外接矩形;
  18. minAreaRect() 用于计算旋转外接矩形;
  19. moments() 可以用于计算轮廓中心;
  20. 连通域面积与轮廓面积的计算方式不同;
  21. 已经粘连的目标不会因为提取方法不同而自动分开;
  22. 工业检测中应结合面积、宽高、位置和形状进行综合筛选。

连通域分析与轮廓提取是传统工业视觉中非常重要的基础。

阈值分割只是将目标从背景中初步分离出来,而真正的目标统计、缺陷过滤和形状判断,往往都是从连通域和轮廓分析开始的。

posted on 2026-08-01 23:09  南小欢  阅读(3)  评论(0)    收藏  举报