本章主要介绍图像处理中一个比较基础的操作:Canny边缘发现、轮廓发现 和 绘制轮廓。概念不难,主要是结合OpenCV 4.5+的API相关操作,为往下 "基于距离变换的分水岭图像分割" 做知识储备。
在讲述轮廓之前,要花点时间学学边缘检测提取的一个著名算法——Canny边缘提取算法。该算法检测出边相对于其他边缘检测算法的效果显著不同就是,Canny 检测出的边是比较细且清晰。该算法相比之前学习的Sobel和Laplace而言,它是一个应用方法,是真正的做到“提取”边缘这个操作;而Sobel和Laplace只是提留在图像像素的集合中。
Canny 算法的边缘检测到提取,主要有如下几个步骤:
1、灰度化cvColor与高斯滤波GaussianBlur
将图像变为灰度图像,减少通道,高斯滤波的作用是平滑图像,减少噪声,不让Canny算法检测时,误认为是边缘,所以在一开始就使用这个高斯滤波来减少比较突出的地方,简单的来说就是过滤掉图像上不合适的地方。
2、计算图像的梯度和梯度方向Sobel/Scharr
图像的边缘是灰度值急剧变化的位置。比如在灰度图像中它只有明暗的变化,当某个地方的强度变化比较的剧烈,那么它就会形成一个边缘。明暗变化较大的地方,梯度变化也会很大。
3、非极大值抑制
这一步是要做什么的呢?经过上面的操作,我们只是对图像进行了一个增强,而并不是找到真正的边缘。而且经过1-2步骤发现的边缘是一个范围信号,但是边缘只能是一条或者一簇,所以我需要对非边缘的个体进行压制除掉?怎么做呢,就是在边缘的梯度方向上,不是给定的最大值的话,那就去掉不要。(如下图,左边是Sobel求梯度方向,右边是常用的抑制范围选取)
但是这个最大的阈值该如何设置?过高将许多原本是线的位置未设置,过低就会细碎边,我们希望效果是找到清晰、连续的边缘线。
4、双阈值筛选边缘连接
经过非极大值抑制后图像检测出边还是有许多灰色而且不算清晰。所以接下来设置双阈值,规定上下阈值,所谓双阈值就是有两个阈值分别是低阈值和高阈值。如果像素点灰度值是大于最大阈值就直接将其更新为 255 。如果像素点的灰度值小于最小阈值就将其灰度值更新为 0。如果像素点灰度值是处于最大阈值到最小阈值之间,就看其 8 邻域中是否有大于最大阈值的值,如果有也就将其归为 255,也可以取 8 领域的平均值。
Canny边缘检测算法基本上就是经过以上的步骤。OpenCV有对应优化的Canny方法,一起看看如何使用,往下在学发现轮廓的时候就要用到。
CV_EXPORTS_W void Canny(
InputArray image, // 8-bit输入图像
OutputArray edges, // 输出的边缘图像,一般都是二值图像,背景是黑色
double threshold1, // 低阈值,常取高阈值的1/2或者1/3
double threshold2, // 高阈值
int apertureSize = 3, // Sobel算子的size,取值3代表是3x3
bool L2gradient = false // 选择true用L2=sqrt{(dI/dx)^2 + (dI/dy)^2}求梯度方向,
// 默认false用L1=|dI/dx|+|dI/dy|计算
);
有时候,轮廓和边缘的概念是非常相似的,在单一物体对象上表面的轮廓就相当于其边缘特征。但是多个物体对象叠加之后,轮廓和边缘就不再能这样相提并论了。(如上图示)
轮廓是在边缘的基础上,构成一张轮廓的拓扑图,然后利用不同的拓扑算法去寻找和构建轮廓。所以边缘提取的阈值选定会影响最终轮廓发现的结果。
说完边缘与轮廓的关系与区别之后。那么在OpenCV中,轮廓的发现绘制与绘制要如何实现呢?
这里先介绍cv::findCountours 和 cv::drawContours这两个api
CV_EXPORTS_W void findContours(
InputArray image, // 输入图像,二值图,一般就是Canny的输出,8-bit
OutputArrayOfArrays contours, // 全部发现的轮廓对象,就是一个二维数组,图结构,往下细说
OutputArray hierarchy, // 轮廓图的拓扑结构,可选输出,最终的轮廓发现就是基于这个拓扑结构实现
int mode, // 寻找轮廓的模式,一般返回RETR_TREE树模式
int method, // 轮廓发现的方法,一般使用CHAIN_APPROX_SIMPLE简单方式
Point offset = Point() // 轮廓像素偏移,默认(0,0)没偏移
);
CV_EXPORTS_W void drawContours(
InputOutputArray image, // 绘制的目标图像
InputArrayOfArrays contours, // 全部轮廓对象,就是findContours的第二个输出参数
int contourIdx, // 轮廓索引号,contours的第一维索引
const Scalar& color, // 绘制颜色
int thickness = 1, // 绘制线宽
int lineType = LINE_8, // 绘制线类型
InputArray hierarchy = noArray(), // 拓扑结构图,findContours的第三个可选输出参数
int maxLevel = INT_MAX, // 最大层数,0只绘制当前的,1包含内部轮廓,2所有轮廓
Point offset = Point() // 轮廓偏移
);
接着来一段案例代码,讲讲findContours的第二、第三参数如何理解。
int main()
{
//读取测试图片
src = imread("F:\\other\\learncv\\bottle.png");
namedWindow(titleStr + "src", WINDOW_AUTOSIZE);
imshow(titleStr + "src", src);
//rgb转gray
cvtColor(src, gray, COLOR_BGR2GRAY);
namedWindow(titleStr + "circles", WINDOW_AUTOSIZE);
namedWindow(titleStr + "contours", WINDOW_AUTOSIZE);
createTrackbar("边缘检测阈值", titleStr + "src", &threshold_value, threshold_max, Callback_Contours);
waitKey(0);
return 0;
}
void Callback_Contours(int pos, void* userdata) {
Mat canny_img;
Canny(gray, canny_img, threshold_value, threshold_value * 2.0, 3, false);
vector> contours;
vector hierarchy;
findContours(canny_img, contours, hierarchy, RETR_TREE, CHAIN_APPROX_SIMPLE, Point(0, 0));
Mat dst1 = Mat::zeros(gray.size(), CV_8UC3);
Mat dst2 = Mat::zeros(gray.size(), CV_8UC3);
for (size_t i = 0; i < contours.size(); i++) {
drawContours(dst1, contours, i, colorWhite, 1, LINE_8, hierarchy, 0, Point(0, 0));
vector contourPoints = contours[i];
for (size_t j = 0; j < contourPoints.size(); j++) {
circle(dst2, contourPoints[j], 1, colorWhite);
}
}
imshow(titleStr + "contours", dst1);
imshow(titleStr + "circles", dst2);
}
以上代码运行的效果,Canny边缘阈值在100~200之间。 其中我把Canny的第二个输出参数contours也以点的方式绘制出来,对应的是图最左边,中间部分是drawContours绘制的轮廓,右边是原图。放大可以清楚观察到 “荷花” 二字上方,荷花瓣的位置,明显看出点的方式是断断续续的中间留有很大一部分的空白,而绘制轮廓后是能把它们连接成一条线。这是因为drawContours会根据contours二维数据的第一维去判断这些是不是属于同一线段。Debug调试就可以知道contours[i]的每一层长度都是不一样的。
至于第三个参数 hierarchy 轮廓拓扑关系,此参数输出的内容 与 第四个参数 mode 寻找轮廓的模式,有莫大的关系,详细看看查阅以下这个同学的详细分析。
(十二) findContours函数的hierarchy详解_findcontours hierarchy_恒友成的博客-CSDN博客获取对象的轮廓,一般最好先对图像进行灰度化再进行阈值处理,然后用来检测轮廓。_findcontours hierarchyhttps://blog.csdn.net/lx_ros/article/details/126258801
Ok,That’s All.