OpenCV计算机视觉开发实践:基于Qt C++ - 商品搜索 - 京东
学习基于Qt C++开发OpenCV应用,建议先掌握Qt C++编程。
《Qt 6.x从入门到精通》(朱文伟)【摘要 书评 试读】- 京东图书
9.9.1 基本概念
在进行图像处理时,我们可能会遇到这样的问题:如何提取图像的一部分区域,或者如何将所需区域用某种颜色(与其他区域的颜色不同)进行标记。这种情况在图像处理领域被称为图像分割。
图9-19所示的就是图像分割的一个应用。从图像的前后对比可以看到,人物通过算法被很清晰地分割了出来,方便后续的识别跟踪。
图像分割是按照一定的原则,将一幅图像分为若干个互不相交的小局域的过程,它是图像处理中最基础的研究领域之一。目前有很多图像分割方法,其中分水岭算法是一种基于区域的图像分割算法。分水岭算法因其实现方便,已经在医疗图像、模式识别等领域得到了广泛的应用。
在分割的过程中,分水岭算法会把跟临近像素间的相似性作为重要的参考依据,从而将在空间位置上相近并且灰度值相近的像素点连接起来,构成一个封闭的轮廓。封闭性是分水岭算法的一个重要特征。其他图像分割方法,如阈值、边缘检测等都不会考虑像素在空间关系上的相似性和封闭性这一概念,像素间互相独立,没有统一性。分水岭算法较其他分割方法更具有思想性,更符合人眼对图像的印象。
图9-19
分水岭比较经典的计算方法是L.Vincent于1991年在IEEE Transactions on Pattern Analysis and Machine Intelligence(简称PAMI)上提出的。传统的分水岭分割方法是一种基于拓扑理论的数学形态学分割技术,其基本思想是将图像视作地理测量中的拓扑地貌。在这一模型中,图像中每个像素的灰度值表示该点的海拔高度。每一个局部极小值及其影响区域被称为集水盆地,而集水盆地的边界则形成分水岭。
分水岭的概念和形成过程可以通过模拟浸入过程来说明。具体来说,在每个局部极小值的表面刺穿一个小孔,然后将整个模型缓慢浸入水中。随着浸入的加深,每个局部极小值的影响区域逐渐向外扩展,当两个集水盆地汇合时,会在汇合处构筑大坝,从而形成分水岭,如图9-20所示。
图9-20
然而,基于梯度图像的直接分水岭算法容易导致图像的过分割,产生这一现象的原因主要是:由于输入的图像存在过多的极小区域而产生许多小的集水盆地,从而导致分割后的图像不能将图像中有意义的区域表示出来。因此必须对分割结果的相似区域进行合并。
分水岭算法的基本原理就是把图像比喻成一个平面,图像灰度值高的区域被看作山峰,灰度值低的地方被看作山谷。不同区域的山谷可以用不同颜色来标记,但是随着标记区域的不断扩大,会出现一种现象:不同山谷的交汇处区域会出现颜色错乱现象。为了防止这一现象的出现,能做的就是把高峰变得更高(改变灰度值),然后用颜色标记,如此反复,最后完成所有山谷的颜色分割。以上就是分水岭算法的基本原理。说得简单一点,就是根据图像相邻的像素插值将图像分成不同区域,再用分水岭算法将不同区域染成不同颜色。
9.9.2 wathershed函数
OpenCV中的分水岭算法cv::wathershed利用的不是原算法,而是在原算法基础上进行了改进,加上了一步预处理(因为原算法经常会造成图像过度分割):在分割之前先要设置哪些山谷会出现汇合,哪些不会。如果我们能够确定该点代表的是要分割的对象,就用某种颜色或者灰度值标签标记它;如果不是,就用另一种颜色去标记它。随后的过程就是分水岭算法。当所有山谷区域都分割完毕之后,得到的边界对象值设置为-1。
OpenCV的分水岭算法使用了一系列预定义标记来引导图像分割。要使用OpenCV的分水岭算法cv::wathershed,需要输入一个标记图像,图像的像素值为32位有符号正数(CV_32S类型),每个非零像素代表一个标签。它的原理是对图像中部分像素做标记,表明它的所属区域是已知的。分水岭算法可以根据这个初始标签确定其他像素所属的区域。传统的基于梯度的分水岭算法和改进后基于标记的分水岭算法示意图如图9-21所示。
图9-21
从图9-22可以看出,传统的基于梯度的分水岭算法由于局部最小值过多,造成分割后的分水岭较多。而基于标记的分水岭算法,水淹过程从预先定义好的标记图像(像素)开始,较好地克服了过度分割的不足。本质上讲,基于标记点的改进算法是利用先验知识来帮助分割的一种方法。因此,改进算法的关键在于如何获得准确的标记图像,即如何将前景物体与背景准确地标记出来。
OpenCV中分水岭算法的函数是watershed,该函数声明如下:
void cv::watershed ( InputArray image, InputOutputArray markers );
其中参数 image必须是一个8位三通道彩色图像矩阵序列;参数markers表示必须包含种子点信息。在执行分水岭函数watershed之前,必须对第二个参数markers进行处理,它应该包含不同区域的轮廓,每个轮廓有唯一的编号,轮廓的定位可以通过Opencv中的findContours方法实现。这个是执行分水岭算法的要求,算法会根据markers传入的轮廓作为种子(也就是所谓的注水点),对图像上其他的像素点根据分水岭算法规则进行判断,并对每个像素点的区域归属进行划定,直到处理完图像上所有像素点。而区域与区域之间的分界处的值被置为“-1”,以便区分。
简单概括一下,就是第二个入参markers必须包含种子点信息。OpenCV官方例程中使用鼠标划线标记,其实就是在定义种子,只不过需要手动操作,而使用findContours可以自动标记种子点。分水岭算法完成之后并不会直接生成分割后的图像,还需要进一步显示处理,如此看来,只有两个参数的watershed其实并不简单。
下边通过图示来看一下watershed函数的第二个参数markers在算法执行前后发生了什么变化,原图如图9-22所示。
图9-22
经过灰度化、滤波、Canny边缘检测、findContours轮廓查找、轮廓绘制等步骤后,终于得到了符合OpenCV要求的merkers,我们把merkers转换成8bit单通道灰度图,看看它里边到底是什么内容。分水岭算法运算前的merkers如图9-23所示。findContours检测到的轮廓(即分水岭算法运算后)如图9-24所示。
图9-23
图9-24
从效果上看,分水岭算法运算前的merkers图像基本上跟检测到的轮廓是一样的,也是简单地勾勒出了物体的外形。但如果仔细观察就能发现,图像上不同线条的灰度值是不同的,底部略暗,越往上灰度越高。由于这幅图像边缘比较少,对比不是很明显,下面再来看一幅轮廓数量较多的图。分水岭算法运算前的merkers如图9-25所示。findContours检测到的轮廓如图9-26所示。
对比这两幅图可以很明显地看到,分水岭算法运算前的merkers图像从底部往上,线条的灰度值越来越高,并且由于底部部分线条的灰度值太低,已经观察不到了。相互连接在一起的线条灰度值是一样的,这些线条和不同的灰度值又能说明什么呢?答案是每一个线条代表了一个种子,线条的不同灰度值其实代表了对不同注水种子的编号,有多少不同灰度值的线条,就有多少个种子,图像最后分割后就有多少个区域。
图9-25
图9-26
再来看一下执行完分水岭方法之后merkers里边的内容发生了什么变化,如图9-27所示。
图9-27
可以看到,执行完watershed之后,merkers里边被分割出来的区域已经非常明显了,空间上临近并且灰度值上相近的区域被划分为一个区域,灰度值是一样的,不同区域被划分开。这其实就是分水岭对图像的分割效果了。
使用watershed函数实现图像自动分割的基本步骤如下:
(1)图像灰度化、滤波、Canny边缘检测。
(2)查找轮廓,并且把轮廓信息按照不同的编号绘制到watershed的第二个入参merkers上,相当于标记注水点。
(3)watershed分水岭运算。
(4)绘制分割出来的区域,还可以使用随机颜色填充,或者跟原始图像融合一下,以得到更好的显示效果。
以下是Opencv分水岭算法watershed实现的完整过程。
【例9.9】watershed分水岭分割
新建一个控制台工程,工程名是test。
打开main.cpp,并输入如下代码:
#include "opencv2/imgproc.hpp"
#include "opencv2/highgui.hpp"
#include <opencv2/imgproc/types_c.h>
#include <iostream>
using namespace std;
using namespace cv;
#include "opencv2/imgproc/imgproc.hpp"
#include "opencv2/highgui/highgui.hpp"
#include <iostream>
using namespace cv;
using namespace std;
Vec3b RandomColor(int value); // 生成随机颜色函数
int main(int argc, char* argv[])
{
Mat image = imread("girl.jpg"); // 载入RGB彩色图像,另外一幅图是test2.jpg
imshow("Source Image", image);
// 灰度化,滤波,Canny边缘检测
Mat imageGray;
cvtColor(image, imageGray, CV_RGB2GRAY); // 灰度转换
GaussianBlur(imageGray, imageGray, Size(5, 5), 2); // 高斯滤波
imshow("Gray Image", imageGray);
Canny(imageGray, imageGray, 80, 150);
imshow("Canny Image", imageGray);
// 查找轮廓
vector<vector<Point>> contours;
vector<Vec4i> hierarchy;
findContours(imageGray, contours, hierarchy, RETR_TREE, CHAIN_APPROX_SIMPLE, Point());
Mat imageContours = Mat::zeros(image.size(), CV_8UC1); // 轮廓
Mat marks(image.size(), CV_32S); // Opencv分水岭第二个矩阵参数
marks = Scalar::all(0);
int index = 0;
int compCount = 0;
for (; index >= 0; index = hierarchy[index][0], compCount++)
{
// 对marks进行标记,对不同区域的轮廓进行编号,相当于设置注水点,有多少轮廓,就有多少注水点
drawContours(marks, contours, index, Scalar::all(compCount + 1), 1, 8, hierarchy);
drawContours(imageContours, contours, index, Scalar(255), 1, 8, hierarchy);
}
// 我们来看一下传入的矩阵marks里有什么
Mat marksShows;
convertScaleAbs(marks, marksShows);
imshow("marksShow", marksShows);
imshow("contour", imageContours);
watershed(image, marks);
// 我们再来看一下执行分水岭算法之后的矩阵marks里有什么
Mat afterWatershed;
convertScaleAbs(marks, afterWatershed);
imshow("After Watershed", afterWatershed);
// 对每一个区域进行颜色填充
Mat PerspectiveImage = Mat::zeros(image.size(), CV_8UC3);
for (int i = 0; i < marks.rows; i++)
{
for (int j = 0; j < marks.cols; j++)
{
int index = marks.at<int>(i, j);
if (marks.at<int>(i, j) == -1)
{
PerspectiveImage.at<Vec3b>(i, j) = Vec3b(255, 255, 255);
}
else
{
PerspectiveImage.at<Vec3b>(i, j) = RandomColor(index);
}
}
}
imshow("After ColorFill", PerspectiveImage);
// 将分割并填充颜色的结果跟原始图像融合
Mat wshed;
addWeighted(image, 0.4, PerspectiveImage, 0.6, 0, wshed);
imshow("AddWeighted Image", wshed);
waitKey();
return 0;
}
Vec3b RandomColor(int value) // 生成随机颜色函数
{
value = value % 255; // 生成0~255的随机数
RNG rng;
int aa = rng.uniform(0, value);
int bb = rng.uniform(0, value);
int cc = rng.uniform(0, value);
return Vec3b(aa, bb, cc);
}
保存工程并运行,第一幅图像分割效果如图9-28所示。按比例跟原始图像融合,如图9-29所示。
图9-28
图9-29
第二幅图像(test2.jpg)的分割效果如图9-30所示。
图9-30
按比例跟原始图像融合如图9-31所示。
图9-31