OpenCV 工业视觉学习笔记(四):连通域分析与轮廓提取
前言
在上一篇文章中,学习了腐蚀、膨胀、开运算、闭运算以及顶帽、黑帽等形态学操作。
经过阈值分割和形态学处理后,我们通常会得到一张相对干净的二值图像。但是,仅仅得到二值图还不够。
在实际工业检测中,还需要进一步回答以下问题:
- 图像中一共有多少个目标?
- 每个目标位于什么位置?
- 每个目标的面积是多少?
- 目标的宽度和高度是多少?
- 哪些区域是噪点?
- 哪些区域可能是真正的缺陷?
- 目标是否包含孔洞?
- 目标的外轮廓是什么形状?
这些问题通常需要使用两类方法:
- 连通域分析;
- 轮廓提取。
连通域分析更偏向于对二值区域进行编号和统计,轮廓提取则更偏向于获取目标边界和分析目标形状。
本篇主要学习以下内容:
- 什么是连通区域;
- 四连通与八连通;
connectedComponents()的使用;connectedComponentsWithStats()的使用;- 连通域面积、位置和中心点统计;
- 连通域筛选;
- 什么是轮廓;
findContours()的使用;- 轮廓检索模式;
- 轮廓近似方式;
- 轮廓面积、周长和外接矩形;
- 轮廓层级与孔洞;
- 连通域分析与轮廓提取的区别;
- 工业缺陷区域筛选的完整流程。
一、什么是连通区域
在一张二值图像中,如果一组白色像素在空间上彼此连接,那么这些像素就可以组成一个连通区域。
假设二值图中存在三个互不相连的白色区域:
区域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 中的连通域分析与轮廓提取。
需要重点掌握以下内容:
- 二值图中的非零像素通常被视为前景;
- 四连通只考虑上下左右;
- 八连通还包括四个对角线方向;
connectedComponents()可以为连通区域编号;- 返回的标签数量包含背景;
- 背景标签固定为 0;
connectedComponentsWithStats()可以直接获取面积、位置和尺寸;stats可以获取左上角、宽度、高度和面积;centroids可以获取区域中心点;- 连通域适合目标计数和区域属性统计;
findContours()可以提取区域边界点;RETR_EXTERNAL只提取最外层轮廓;RETR_TREE可以建立完整父子层级;CHAIN_APPROX_SIMPLE可以压缩冗余轮廓点;contourArea()用于计算轮廓面积;arcLength()用于计算轮廓周长;boundingRect()用于计算水平外接矩形;minAreaRect()用于计算旋转外接矩形;moments()可以用于计算轮廓中心;- 连通域面积与轮廓面积的计算方式不同;
- 已经粘连的目标不会因为提取方法不同而自动分开;
- 工业检测中应结合面积、宽高、位置和形状进行综合筛选。
连通域分析与轮廓提取是传统工业视觉中非常重要的基础。
阈值分割只是将目标从背景中初步分离出来,而真正的目标统计、缺陷过滤和形状判断,往往都是从连通域和轮廓分析开始的。
浙公网安备 33010602011771号