本章包括以下内容:
- 用形态学滤波器腐蚀和膨胀图像;
- 用形态学滤波器开启和闭合图像;
- 在灰度图像中应用形态学运算;
- 用分水岭算法实现图像分割;
- 用MSER 算法提取特征区域。
5.2 用形态学滤波器腐蚀和膨胀图像
形态学滤波器通常作用于二值图像。我们习惯用高像素值(白色)表示前景物体,用低像素值(黑色)表示背景物体。
在形态学术语中,下面的图像称为第4 章所建图像的补码。
result.jpgOpenCV 用简单的函数实现了腐蚀和膨胀运算,它们分别是cv:erode 和cv:dilate,用法也很简单:
// 读取输入图像
cv::Mat image= cv::imread("binary.bmp");
// 腐蚀图像
// 采用默认的3×3 结构元素
cv::Mat eroded; // 目标图像
cv::erode(image,eroded,cv::Mat());
// 膨胀图像
cv::Mat dilated; // 目标图像
cv::dilate(image,dilated,cv::Mat());
这些函数生成的两幅图像如下所示。
result.jpg result.jpg腐蚀就是把当前像素替换成所定义像素集合中的最小像素值;
膨胀是腐蚀的反运算,它把当前像素替换成所定义像素集合中的最大像素值。
腐蚀时,如果结构元素放到某个像素位置时碰到了背景(即交集中有一个像素是黑色的),那么这个像素就
变为背景;膨胀时,如果结构元素放到某个背景像素位置时碰到了前景物体,那么这个像素就被标为白色。
正因如此,图像腐蚀后物体尺寸会缩小(形状被腐蚀),而图像膨胀后物体会扩大。
在腐蚀图像中,有些面积较小的物体(可看作背景中的“噪声”像素)会彻底消失。与之类似,膨胀后的物体会变大,而物体中一些“空隙”会被填满。
OpenCV 默认使用3×3 正方形结构元素。在调用函数时,参考前面的例子将第三个参数指定为空矩阵(即cv::Mat()),就能得到默认的结构元素。你也可以通过提供一个矩阵来指定结构元素的大小(以及形状),矩阵中的非零元素将构成结构元素。下面的例子使用7×7 的结构元素:
// 用更大的结构元素腐蚀图像
// 创建7×7 的mat 变量,其中全部元素都为1
cv::Mat element(7,7,CV_8U,cv::Scalar(1));
// 用这个结构元素腐蚀图像
cv::erode(image,eroded,element);
这次的结果更有破坏性,如下图所示。
result.jpg还有一种方法也能得到类似的结果,就是在图像上反复应用同一个结构元素。这两个函数都有一个用于指定重复次数的可选参数:
// 腐蚀图像三次
cv::erode(image,eroded,cv::Mat(),cv::Point(-1,-1),3);
参数cv::Point(-1,-1)表示原点是矩阵的中心点(默认值),也可以定义在结构元素上的其他位置。由此得到的图像与使用7×7 结构元素得到的图像是一样的。实际上,对图像腐蚀两次相当于对结构元素自身膨胀后的图像进行腐蚀。这个规则也适用于膨胀。
最后,鉴于前景/背景概念有很大的随意性,我们可得到以下的实验结论(这是腐蚀/膨胀运算的基本性质)。用结构元素腐蚀前景物体可看作对图像背景部分的膨胀,也就是说:
- 腐蚀图像相当于对其反色图像膨胀后再取反色;
- 膨胀图像相当于对其反色图像腐蚀后再取反色。
另外,OpenCV 的形态学函数支持就地处理。这意味着输入图像和输出图像可以采用同一个变量。
cv::erode(image,image,cv::Mat());
5.3 用形态学滤波器开启和闭合图像
本节将讲解开启和闭合运算。
应用较高级别的形态学滤波器,需要用cv::morphologyEx 函数,例如下面的调用方法将适用于闭合运算:
// 闭合图像
cv::Mat element5(5, 5, CV_8U, cv::Scalar(1));
cv::Mat closed;
cv::morphologyEx(image, closed, // 输入和输出的图像
cv::MORPH_CLOSE, // 运算符
element5); // 结构元素
注意,为了让滤波器的效果更加明显,这里使用了5×5 的结构元素。如果输入上节的二值图像,将得到如下所示的图像。
result.jpg与之类似,应用形态学开启运算后将得到如下图像。
cv::Mat opened;
cv::morphologyEx(image, opened, cv::MORPH_OPEN, element5);
result.jpg
开启和闭合滤波器的定义只与基本的腐蚀和膨胀运算有关:闭合的定义是对图像先膨胀后腐蚀,开启的定义是对图像先腐蚀后膨胀。
因此可以用以下方法对图像做闭合运算:
// 膨胀原图像
cv::dilate(image, result, cv::Mat());
// 就地腐蚀膨胀后的图像
cv::erode(result, result, cv::Mat());
调换这两个函数的调用次序,就能得到开启滤波器。
查看闭合滤波器的结果,可看到白色的前景物体中的小空隙已经被填满。闭合滤波器也会把邻近的物体连接起来。基本上,所有小到不能容纳完整结构元素的空隙或间隙都会被闭合滤波器消除。
与闭合滤波器相反,开启滤波器消除了背景中的几个小物体。所有小到不能容纳完整结构元素的物体都会被移除。
这些滤波器常用于目标检测。闭合滤波器可把错误分裂成小碎片的物体连接起来,而开启滤波器可以移除因图像噪声产生的斑点。因此最好按一定的顺序调用这些滤波器。如果优先考虑过滤噪声,可以先开启后闭合,但这样做的坏处是会消除掉部分物体碎片。
先使用开启滤波器,再使用闭合滤波器,会得到如下结果。
result.jpg注意,对一幅图像进行多次同样的开启运算是没有作用的(闭合运算也一样)。事实上,因为第一次使用开启滤波器时已经填充了空隙,再使用同一个滤波器将不会使图像产生变化。
5.4 在灰度图像中应用形态学运算
本节将介绍两种形态学运算,将它们应用于灰度图像上可以检测图像的特征。
形态学梯度运算可以提取出图像的边缘,具体方法为使用cv::morphologyEx 函数,代码如下所示:
// 用3×3 结构元素得到梯度图像
cv::Mat result;
cv::morphologyEx(image, result,
cv::MORPH_GRADIENT, cv::Mat());
得到图像中物体的轮廓(为方便观察,对图像做了反色处理)。
result.jpg另一种很实用的形态学运算是顶帽(hat-top)变换,它可以从图像中提取出局部的小型前景物体。为了说明该运算的效果,我们用本书中一页的照片做试验。由图可知,页面的光照并不均匀。通过使用cv::morphologyEx 函数并采用正确的参数,可以调用黑帽变换提取出页面上的文字(作为前景物体):
// 使用7×7 结构元素做黑帽变换
cv::Mat element7(7, 7, CV_8U, cv::Scalar(1));
cv::morphologyEx(image, result, cv::MORPH_BLACKHAT, element7);
运行结果如下图所示(反色处理),它可以从图像中提取出大部分文字。
result.jpg理解形态学运算在灰度图像上的效果有一个好办法,就是把图像看作是一个拓扑地貌,不同的灰度级别代表不同的高度(或海拔)。基于这种观点,明亮的区域代表高山,黑暗的区域代表深谷;边缘相当于黑暗和明亮像素之间的快速过渡,因此可以比作陡峭的悬崖。
腐蚀这种地形的最终结果是:每个像素被替换成特定邻域内的最小值,从而降低它的高度。结果是悬崖“缩小”,山谷“扩大”。膨胀的效果刚好相反,即悬崖“扩大”,山谷“缩小”。但不管哪种情况,平地(即强度值固定的区域)都会相对保持不变。
根据这个结论,可以得到一种检测图像边缘(或悬崖)的简单方法,即通过计算膨胀后的图像与腐蚀后的图像之间的的差距得到边缘。因为这两种转换后图像的差别主要在边缘地带,所以相减后会突出边缘。
在cv::morphologyEx 函数中输入cv::MORPH_GRADIENT 参数,即可实现此功能。显然,结构元素越大,检测到的边缘就越宽。这种边缘检测运算称为Beucher 梯度。
注意还有两种简单的方法能得到类似结果,即用膨胀后的图像减去原始图像,或者用原始图像减去腐蚀后的图像,那样得到的边缘会更窄。
顶帽运算也基于图像比对,它使用了开启和闭合运算。因为灰度图像进行形态学开启运算时会先对图像进行腐蚀,局部的尖锐部分会被消除,其他部分则将保留下来。因此,原始图像和经过开启运算的图像的比对结果就是局部的尖锐部分。这些尖锐部分就是我们需要提取的前景物体。
对于本书的照片来说,前景物体就是页面上的文字。因为书本为白底黑字,所以我们采用它的互补运算,即黑帽算法。它将对图像做闭合运算,然后从得到的结果中减去原始图像。这里采用7×7 的结构元素,它足够大了,能确保移除文字。
5.5 用分水岭算法实现图像分割
分水岭变换是一种流行的图像处理算法,用于快速将图像分割成多个同质区域。
它基于这样的思想:如果把图像看作一个拓扑地貌,那么同类区域就相当于陡峭边缘内相对平坦的盆地。分
水岭算法通过逐步增高水位,把地貌分割成多个部分。OpenCV 提出了该算法的改进版本,使用一系列预定义标记来引导图像分割的定义方式。
使用分水岭分割法需要调用cv::watershed 函数。该函数的输入对象是一个标记图像,图像的像素值为32 位有符号整数,每个非零像素代表一个标签。它的原理是对图像中部分像素做标记,表明它们的所属区域是已知的。分水岭算法可根据这个初始标签确定其他像素所属的区域。
本节将先建立一个标记图像作为灰度图像,然后将其转换成整型图像。我们把这个步骤封装进WatershedSegmenter 类,它包括指定标记图像和计算分水岭的方法:
class WatershedSegmenter {
private:
cv::Mat markers;
public:
void setMarkers(const cv::Mat& markerImage) {
// 转换成整数型图像
markerImage.convertTo(markers, CV_32S);
}
cv::Mat process(const cv::Mat& image) {
// 应用分水岭
cv::watershed(image, markers);
return markers;
}
不同应用程序获得标记的方式各不相同。例如,可在预处理过程中识别出一些属于某个感兴趣物体的像素。然后,根据初始检测结果,使用分水岭算法划出整个物体的边缘。
binary.jpg本节将利用本章一直使用的二值图像,识别出对应原始图像中的动物。因此,我们需要从二值图像中识别出属于前景(动物)的像素以及属于背景(主要是草地)的像素。这里把前景像素标记为255,把背景像素标记为128(该数字是随意选择的,任何不等于255 的数字都可以)。其他像素的标签是未知的,标记为0。
现在,这个二值图像包含了属于图像不同部分的白色像素,因此要对图像做深度腐蚀运算,只保留明显属于前景物体的像素:
// 消除噪声和细小物体
cv::Mat fg;
cv::erode(image, fg, cv::Mat(), cv::Point(-1, -1), 4);
得到的图像如下所示。
result.jpg注意,仍然有少量属于背景(森林)的像素保留了下来,不用管它们,可将它们看作感兴趣物体。与之类似,我们可以通过对原二值图像做一次大幅度的膨胀运算来选中一些背景像素:
// 标识不含物体的图像像素
cv::Mat bg;
cv::dilate(binary,bg,cv::Mat(),cv::Point(-1,-1),4);
cv::threshold(bg,bg,1,128,cv::THRESH_BINARY_INV);
得到的黑色像素对应背景像素。因此在膨胀后,要立即通过阈值化运算把它们赋值为128。得到的图像如下所示。
result.jpg合并这两幅图像,得到标记图像,代码为:
// 创建标记图像
cv::Mat markers(binary.size(),CV_8U,cv::Scalar(0));
markers= fg+bg;
下面的图像将被输入分水岭算法。
result.jpg毫无疑问,在这个输入图像中,白色区域属于前景物体,灰色区域属于背景,而黑色区域带有未知标签。分水岭算法的作用就是明确地划分前景和背景,并对黑色区域的像素做出标记(属于前景还是背景)。可用下面的方法来分割图像:
// 创建分水岭分割类的对象
WatershedSegmenter segmenter;
// 设置标记图像,然后执行分割过程
segmenter.setMarkers(markers);
segmenter.process(image);
上面的代码会修改标记图像,每个值为0 的像素都会被赋予一个输入标签,而边缘处的像素被赋值为-1,得到的标签图像如图所示。
result.jpg边缘图像如图所示。
result.jpg跟前面几节一样,我们在描述分水岭算法时用拓扑地图来做类比。用分水岭算法分割图像的原理是从高度0 开始逐步用洪水淹没图像。当“水”的高度逐步增加时(到1、2、3 等),会形成聚水的盆地。随着盆地面积逐步变大,两个盆地的水最终会汇合到一起。这时就要创建一个分水岭,用来分割这两个盆地。当水位达到最大高度时,创建的盆地和分水岭就组成了分水岭分割图。
可以想象,在水淹过程的开始阶段会创建很多细小的独立盆地。当所有盆地汇合时,就会创建很多分水岭线条,导致图像被过度分割。要解决这个问题,就要对这个算法进行修改,使水淹过程从一组预先定义好的标记像素开始。每个用标记创建的盆地,都按照初始标记的值加上标签。如果两个标签相同的盆地汇合,就不创建分水岭,以避免过度分割。调用cv::watershed 函数时就执行了这些过程。输入的标记图像会被修改,用以生成最终的分水岭分割图。输入的标记图像可以含有任意数值的标签,未知标签的像素值为0。标记图像的类型选用32 位有符号整数,以便定义超过255 个的标签。另外,可以把分水岭的对应像素设为特殊值-1。
为了方便显示结果,我们采用两种特殊方法。第一种方法返回由标签组成的图像(包含值为0 的分水岭)。该方法通过阈值化很容易实现,代码如下所示:
// 以图像的形式返回结果
cv::Mat getSegmentation() {
cv::Mat tmp;
// 所有标签值大于255 的区段都赋值为255
markers.convertTo(tmp, CV_8U);
return tmp;
}
与之类似,第二种方法返回一幅图像,图像中分水岭线条赋值为0,其他部分赋值为255。这次用cv::convertTo 方法来获得结果,代码如下所示:
// 以图像的形式返回分水岭
cv::Mat getWatersheds() {
cv::Mat tmp;
// 在变换前,把每个像素p 转换为255p+255
markers.convertTo(tmp, CV_8U, 255, 255);
return tmp;
}
在变换前对图像做线性转换,使值为-1 的像素变为0(因为-1*255+255=0)。
值大于255 的像素赋值为255。这是因为将有符号整数转换成无符号字符型时,应用了饱和度运算。
5.6 用MSER 算法提取特征区域
最大稳定外部区域(MSER)算法也用相同的水淹类比,以便从图像中提取有意义的区域。创建这些区域时也使用逐步提高水位的方法,但是这次我们关注的是在水淹过程中的某段时间内,保持相对稳定的盆地。可以发现,这些区域对应着图像中某些物体的特殊部分。
计算图像MSER 的基础类是cv::MSER。cv::MSER 类的实例可以通过create 方法创建。我们在初始化时指定被检测区域的最小和最大尺寸,以便限制被检测特征的数量,调用方式如下:
// 基本的MSER 检测器
cv::Ptr<cv::MSER> ptrMSER = cv::MSER::create(
5, // 局部检测时使用的增量值
200, // 允许的最小面积
2000); // 允许的最大面积
现在可以通过调用detectRegions 方法来获得MSER,指定输入图像和一个相关的输出数据结构,代码如下所示:
// 点集的容器
std::vector<std::vector<cv::Point> > points;
// 矩形的容器
std::vector<cv::Rect> rects;
// 检测MSER 特征
ptrMSER->detectRegions(image, points, rects);
检测结果放在两个容器中。第一个是区域的容器,每个区域用组成它的像素点表示;第二个是矩形的容器,每个矩形包围一个区域。为了呈现结果,创建一个空白图像,在图像上用不同的颜色显示检测到的区域(颜色是随机选择的)。用以下代码实现:
// 创建白色图像
cv::Mat output(image.size(), CV_8UC3);
output = cv::Scalar(255, 255, 255);
// OpenCV 随机数生成器
cv::RNG rng;
// 针对每个检测到的特征区域,在彩色区域显示MSER
// 反向排序,先显示较大的MSER
for (std::vector<std::vector<cv::Point> >::reverse_iterator
it = points.rbegin();
it != points.rend(); ++it) {
// 生成随机颜色
cv::Vec3b c(rng.uniform(0, 254),
rng.uniform(0, 254), rng.uniform(0, 254));
// 针对MSER 集合中的每个点
for (std::vector<cv::Point>::iterator itPts = it->begin();
itPts != it->end(); ++itPts) {
// 不重写MSER 的像素
if (output.at<cv::Vec3b>(*itPts)[0] == 255) {
output.at<cv::Vec3b>(*itPts) = c;
}
}
}
注意,MSER 会形成层叠区域。为了显示全部区域,如果一个较大区域内包含了较小的区域,就不能覆盖它。可以从下图中检测出MSER。
building.jpg结果如下图所示。
result.jpg图中没有显示全部区域,但是可以看出,通过这种方法能从图片中提取到一些有意义的区域(例如建筑物的窗户)。
MSER 的原理与分水岭算法相同,即高度为0~255,逐渐淹没图像。随着水位的升高,颜色较黑并且边界陡峭的区域会形成盆地 ,并且在一段时间内有相对稳定的形状(用水位表示颜色,水位高低代表了像素值的强度)。这些稳定的盆地就是MSER。
检测它们的方法是,观察每个水位连通的区域(即盆地)并测量它们的稳定性。测量稳定性的方法是:计算区域的当前面积以及该区域原先的面积(比当前水位低一个特定值的时候),并比较这两个面积。如果相对变化达到局部最小值,就认为这个区域是MSER。
增量值将作为cv::MSER 类构造函数的第一个参数,用以测量相对稳定性,默认值为5。另外要注意,区域面积必须在预定义的范围内。构造函数中后面两个参数就是允许的最小和最大区域尺寸。另外必须确保MSER 是稳定的(第四个参数),即形状的相对变化必须足够小。
MSER 检测器首先输出一个包含像素集的容器,每个像素集构成一个区域。因为我们需要找出整个区域的位置,而不是里面的单个像素,所以通常用包含了被检测区域的几何形状表示一个MSER。
检测过程中输出的第二项是一系列矩形,画出所有矩形就能表示检测的结果。但是这样会画出许多矩形,使结果很不直观(区域之间还会互相包含,结果更加混乱)。这个例子主要想检测出大楼中的窗户,因此要提取出所有包含垂直矩形的区域。实现方法是将每个矩形的面积与检测到的对应区域进行比较,如果两者一致(这里用的判断标准是两者比例超过0.6),那么它就是一个MSER。测试代码如下所示:
// 提取并显示矩形的MSER
std::vector<cv::Rect>::iterator itr = rects.begin();
std::vector<std::vector<cv::Point> >::iterator itp = points.begin();
for (; itr != rects.end(); ++itr, ++itp) {
// 检查两者比例
if (static_cast<double>(itp->size()) / itr->area() > 0.6)
cv::rectangle(image, *itr, cv::Scalar(255), 2);
}
提取到的MSER 如下图所示。
result.jpg
网友评论