图像的形态学变换

在之前的图像处理中我们已经使用到了两个最基本的图像形态学变换:腐蚀和膨胀。但之前的文章并不主要讲解图像形态学变换,所以今天我重新整理了一下相关的知识点,并且运用这两个基本操作,来实现更高级的形态学变换。
在进行腐蚀和膨胀的讲解之前,首先需要注意,腐蚀和膨胀是对白色部分(高亮部分)而言的,不是黑色部分。

源图像

膨胀(Dilating)
膨胀就是图像中的高亮部分进行膨胀,“领域扩张”,效果图拥有比原图更大的高亮区域。以上图为例,进行膨胀处理之后的效果为:

膨胀
可以很明显的看出,在对图像进行膨胀操作之后,图像的白色高亮部分得到扩张,黑色部分自然变小,所以效果就是字体变窄,就如被削掉部分一样。
下面了解一下膨胀的算法原理:
此操作是将图像 A 与任意形状的内核 (B),通常为正方形或圆形,进行卷积。 内核 B 有一个可定义的 锚点, 通常定义为内核中心点,用内核 B 扫描图像的每一个像素,取内核 B 覆盖区域的最大相素值,并代替锚点位置的相素。显然,这一最大化操作将会导致图像中的亮区开始”扩展”。

简单直白一点的解说:遍历原图像的每一个像素,然后用结构元素的中心点对准当前正在遍历的这个像素,然后取当前结构元素所覆盖下的原图对应区域内的所有像素的最大值,用这个最大值替换当前像素值。由于二值图像最大值就是1,所以就是用1替换,即变成了白色前景物体。从而也可以看出,如果当前结构元素覆盖下,全部都是背景,那么就不会对原图做出改动,因为都是0.如果全部都是前景像素,也不会对原图做出改动,因为都是1.只有结构元素位于前景物体边缘的时候,它覆盖的区域内才会出现0和1两种不同的像素值,这个时候把当前像素替换成1就有变化了。因此膨胀看起来的效果就是让前景物体胀大了一圈一样。对于前景物体中一些细小的断裂处,如果结构元素大小相等,这些断裂的地方就会被连接起来。这段话可以用下面这张图解释:

自定义内核B(此处为3X3的结构元素)遍历原图A的每个像素点,同时对比内核B所覆盖区域每个像素点的值并取到最大值,然后把该最大值赋值给当前内核锚点所对应的像素点。操作完毕的结果就如图A+B(外面的粗线红色区域),可以看出,对于原图像A,膨胀之后的白色区域沿边缘扩大了一圈。这就导致高亮区变大,黑色字体部分变小了。

膨胀的数学表达式:

对应的函数及相关代码为:

/*
首先了解一下获取卷积核函数:
Mat getStructuringElement(int shape, Size ksize, Point anchor=Point(-1,-1))
参数详解:
int shape:内核形状
          MORPH_RECT:矩形
          MORPH_ELLIPSE:椭圆
          MORPH_CROSS:十字形
Size ksize:内核尺寸
Point anchor:内核锚点,默认为(-1,-1),表示锚点位于内核中心,一般情况下锚点只是影响了形态学运算结果的偏移
 
2.膨胀操作函数原型:
C++: void dilate(
InputArray src,
OutputArray dst,
InputArray kernel,
Point anchor=Point(-1,-1),
int iterations=1,
int borderType=BORDER_CONSTANT,
const Scalar& borderValue=morphologyDefaultBorderValue()
);
参数详解:
InputArray src:输入图像,即源图像,填Mat类的对象即可。图像通道的数量可以是任意的,但图像深度应为CV_8U,CV_16U,CV_16S,CV_32F或 CV_64F其中之一
OutputArray dst:即目标图像,需要和源图片有一样的尺寸和类型
InputArray kernel:膨胀操作的核。若为NULL时,表示的是使用参考点位于中心3x3的核。我们一般使用函数 getStructuringElement配合这个参数的使用。getStructuringElement函数会返回指定形状和尺寸的结构元素(内核矩阵)
Point anchor=Point(-1,-1):锚的位置,其有默认值(-1,-1),表示锚位于中心,一般使用默认值即可
int iterations=1:迭代使用erode()函数的次数,默认值为1
int borderType=BORDER_CONSTANT:用于推断图像外部像素的某种边界模式。注意它有默认值BORDER_DEFAULT
const Scalar& borderValue=morphologyDefaultBorderValue():当边界为常数时的边界值,有默认值morphologyDefaultBorderValue()
*/

// UIImage -> Mat
UIImageToMat(image, matImg);

// 定义内核
cv::Mat dilateElement = getStructuringElement(cv::MORPH_RECT, cv::Size(10,10));

// 膨胀操作
cv::dilate(matImg, matImg, dilateElement);

// 显示matImg
[self cv_MatToUIImageWithMat:matImg];

OpenCV实现膨胀操作比较简单,直接调用dilate() 函数即可。定义内核则通过getStructuringElement() 函数实现。对于getStructuringElement() 函数我在注释我只做了参数解释,因为我自己还没深入研究,所以这篇就不做过多的讲解,后期时间足够再另写一篇了,下面开始介绍腐蚀操作。

腐蚀(Eroding)
腐蚀在形态学操作家族里是膨胀操作的孪生姐妹。它提取的是内核覆盖下的相素最小值,废话不多说,直接上效果图:

腐蚀
很明显的效果:看到亮区(背景)变细,而黑色区域(字母)则变大了。
还是来张图,比较容易理解:
内核B遍历原图A的每个像素点,取到内核B所覆盖区域像素点最小值,然后把该最大值赋值给当前内核锚点所对应的像素点。操作完毕的结果就如图A-B(里面的粗线红色区域),可以看出,对于原图像A,腐蚀之后的白色区域沿边缘缩小了一圈。这就导致高亮区变小,黑色字体部分变大了。

腐蚀的数学表达式:

对应的函数及相关代码为:

/*
1.获取卷积核函数:
Mat getStructuringElement(int shape, Size ksize, Point anchor=Point(-1,-1))
参数详解:
int shape:内核形状
          MORPH_RECT:矩形
          MORPH_ELLIPSE:椭圆
          MORPH_CROSS:十字形
Size ksize:内核尺寸
Point anchor:内核锚点,默认为(-1,-1),表示锚点位于内核中心,一般情况下锚点只是影响了形态学运算结果的偏移
     
2.膨胀操作函数原型:
C++: void erode(
InputArray src,
OutputArray dst,
InputArray kernel,
Point anchor=Point(-1,-1),
int iterations=1,
int borderType=BORDER_CONSTANT,
const Scalar& borderValue=morphologyDefaultBorderValue()
);
参数详解:
InputArray src:输入图像,即源图像,填Mat类的对象即可。图像通道的数量可以是任意的,但图像深度应为CV_8U,CV_16U,CV_16S,CV_32F或 CV_64F其中之一
OutputArray dst:即目标图像,需要和源图片有一样的尺寸和类型
InputArray kernel:膨胀操作的核。若为NULL时,表示的是使用参考点位于中心3x3的核。我们一般使用函数 getStructuringElement配合这个参数的使用。getStructuringElement函数会返回指定形状和尺寸的结构元素(内核矩阵)
Point anchor=Point(-1,-1):锚的位置,其有默认值(-1,-1),表示锚位于中心,一般使用默认值即可
int iterations=1:迭代使用erode()函数的次数,默认值为1
int borderType=BORDER_CONSTANT:用于推断图像外部像素的某种边界模式。注意它有默认值BORDER_DEFAULT
const Scalar& borderValue=morphologyDefaultBorderValue():当边界为常数时的边界值,有默认值morphologyDefaultBorderValue()
*/
    
// UIImage -> Mat
UIImageToMat(image, matImg);
 
// 定义内核
cv::Mat erodeElement = getStructuringElement(cv::MORPH_RECT, cv::Size(10,10));

// 腐蚀操作
cv::erode(matImg, matImg, erodeElement);

// 显示图像
[self cv_MatToUIImageWithMat:matImg];

与膨胀操作一样,腐蚀操作使用erode函数即可实现,一般我们只需要填前面的三个参数,后面的四个参数都有默认值。而且往往结合getStructuringElement一起使用。

开运算 (Opening)
实现:开运算是通过先对图像腐蚀再膨胀实现的
dst = open( src, element) = dilate( erode( src, element ) )
作用:能够排除小团块物体(物体较背景明亮),可以消除高于邻近点的孤立点,达到去噪作用,可以平滑物体轮廓、断开较窄的狭颈。

下面为左边为原图,右边是采用开运算处理之后的效果图:

观察对比可以发现字体较窄部分出现了断裂。

实现代码:

/*
开运算:开运算是通过先对图像腐蚀再膨胀实现的
作用:能够排除小团块物体(假设物体较背景明亮),可以消除高于邻近点的孤立点,达到去噪作用,可以平滑物体轮廓、断开较窄的狭颈。
在纤细点处分离物体、平滑较大物体的边界的同时并不明显改变其面积
*/
    
// UIImage -> Mat
UIImageToMat(image, matImg);
    
// 先腐蚀
cv::Mat erodeElement = getStructuringElement(cv::MORPH_RECT, cv::Size(10,10));
cv::erode(matImg, matImg, erodeElement);
    
// 再膨胀
cv::Mat dilateElement = getStructuringElement(cv::MORPH_RECT, cv::Size(10,10));
cv::dilate(matImg, matImg, dilateElement);
    
// 显示
[self cv_MatToUIImageWithMat:matImg];

闭运算 (Closing)
实现:闭运算是通过先对图像膨胀再腐蚀实现的
dst = close( src, element) = erode( dilate( src, element ) )
作用:能够排除小型黑洞(黑色区域),可以消除低于邻近点的孤立点,达到去噪作用,可以平滑物体轮廓、弥合较窄的间断和细长的沟壑,消除小孔洞,填补轮廓线中的断裂。

下面为左边为原图,右边是采用闭运算处理之后的效果图:

实现代码:

/*
闭运算:闭运算是通过先对图像膨胀再腐蚀实现的
作用:能够排除小型黑洞(黑色区域),可以消除低于邻近点的孤立点,达到去噪作用,可以平滑物体轮廓、弥合较窄的间断和细长的沟壑,消除小孔洞,填补轮廓线中的断裂。
*/
    
// UIImage -> Mat
UIImageToMat(image, matImg);
    
// 先膨胀
cv::Mat dilateElement = getStructuringElement(cv::MORPH_RECT, cv::Size(10,10));
cv::dilate(matImg, matImg, dilateElement);
    
// 再腐蚀
cv::Mat erodeElement = getStructuringElement(cv::MORPH_RECT, cv::Size(10,10));
cv::erode(matImg, matImg, erodeElement);
    
// 显示
[self cv_MatToUIImageWithMat:matImg];

形态梯度(Morphological Gradient)
实现:膨胀图与腐蚀图之差
dst = morph_{grad}( src, element ) = dilate( src, element ) - erode( src, element )
作用:可以保留物体的边缘轮廓

效果图如下:

刚开始这里出了一点问题,在我使用前面那张图进行形态梯度变换时,显示不出结果图,后面我想了一下,那张图上的两个字体中间间隔太大,进行腐蚀操作时,如果不能保证内核函数getStructuringElement里面的size() 足够大可以让两个字相连接,这样也失去意义了,以上面两个字的那张图为例,如果定义内核的size可以让两个字体相连,那么这张图一大部分都是黑的了,所以这里才换了一张图来做展示。

上面运用膨胀与腐蚀图像形态学实现的是基本形态梯度学操作,结合原图像还可以实现一些复杂的操作。常见的梯度计算有以下四种:

  • 基本梯度:基本梯度是用膨胀后的图像减去腐蚀后的图像得到差值图像,称为梯度图像也是OpenCV中支持的计算形态学梯度的方法,而此方法得到梯度有被称为基本梯度。
  • 内部梯度:用原图像减去腐蚀之后的图像得到差值图像,称为图像的内部梯度
  • 外部梯度:图像膨胀之后再减去原来的图像得到的差值图像,称为图像的外部梯度。
  • 方向梯度:使用X方向与Y方向的直线作为结构元素之后得到图像梯度,X的结构元素分布膨胀与腐蚀得到图像之后求差值得到称为X方向梯度,用Y方向直线做结构分别膨胀与腐蚀之后得到图像求差值之后称为Y方向梯度。

上面已经实现了基本梯度,接下来可以对比下内部梯度,外部梯度以及方向梯度的效果图:

代码实现:

// UIImage -> Mat
UIImageToMat(image, matImg);
    
cv::Mat erodeImg;   // 腐蚀
cv::Mat dilateImg;  // 膨胀
cv::Mat resultImg;  // 结果
    
// 定义内核
cv::Mat element = getStructuringElement(cv::MORPH_RECT, cv::Size(5,5));
    
// 先膨胀
cv::dilate(matImg, dilateImg, element);

// 再腐蚀
cv::erode(matImg, erodeImg, element);

/*
这里要使用到一个像素操作函数:减法函数
void subtract(InputArray src1, InputArray src2, OutputArray dst,InputArray mask=noArray(), int dtype=-1);
src1和src2为相减的参数
dst为src1-src2的结果
后面两个可以不用管
*/
    
// 基本梯度 -- 膨胀图像-腐蚀图像
cv::subtract(dilateImg, erodeImg, resultImg);
    
// 内部梯度 -- 原图像-腐蚀图像
cv::subtract(matImg, erodeImg, resultImg);
    
// 外部梯度 -- 膨胀图-原图像
cv::subtract(dilateImg, matImg, resultImg);
    
    
// 方向梯度 -- 方向梯度是使用X方向与Y方向的直线作为结构元素之后得到图像梯度,X的结构元素分布膨胀与腐蚀得到图像之后求差值得到称为X方向梯度,用Y方向直线做结构分别膨胀与腐蚀之后得到图像求差值之后称为Y方向梯度。
cv::Mat hseElement = getStructuringElement(cv::MORPH_RECT, cv::Size(matImg.cols/16,1));
cv::Mat vseElement = getStructuringElement(cv::MORPH_RECT,cv::Size(1,matImg.rows/16));
    
// X 方向梯度:
cv::erode(matImg, erodeImg, hseElement);
cv::dilate(matImg, dilateImg, hseElement);
cv::subtract(dilateImg, matImg, resultImg);
    
// Y方向梯度:
cv::erode(matImg, erodeImg, vseElement);
cv::dilate(matImg, dilateImg, vseElement);
cv::subtract(dilateImg, matImg, resultImg);
    
// 显示图像
[self cv_MatToUIImageWithMat:resultImg];

顶帽(Top Hat)
实现:原图像与开运算结果图之差
dst = tophat( src, element ) = src - open( src, element )
作用:分离比邻近点亮一些的斑块。当一幅图像具有大幅的背景的时候,而微小物品比较有规律的情况下,可以使用顶帽运算进行背景提取

看一下对比图:

因为开运算带来的结果是放大了裂缝或者局部低亮度的区域,因此,从原图中减去开运算后的图,得到的效果图突出了比原图轮廓周围的区域更明亮的区域,且这一操作和选择的核的大小相关。

代码实现:

cv::Mat openImg;
cv::Mat resultImg;
    
// UIImage -> Mat
UIImageToMat(image, matImg);
    
// 先腐蚀
cv::Mat erodeElement = getStructuringElement(cv::MORPH_RECT, cv::Size(10,10));
cv::erode(matImg, openImg, erodeElement);
    
// 再膨胀
cv::Mat dilateElement = getStructuringElement(cv::MORPH_RECT, cv::Size(10,10));
cv::dilate(openImg, openImg, dilateElement);
    
cv::subtract(matImg, openImg, resultImg);
    
// 显示
[self cv_MatToUIImageWithMat:resultImg];

黑帽(Black Hat)
实现:闭运算结果图与原图像之差
dst = blackhat( src, element ) = close( src, element ) - src
作用:分离比邻近点暗的斑块,突出黑暗的区域
对比效果图:


因为黑帽运算后的效果图突出了比原图轮廓周围的区域更暗的区域,且这一操作和选择的核的大小相关,所以,黑帽运算常用来分离比邻近点暗一些的斑块。

代码实现:

cv::Mat closeImg;
cv::Mat resultImg;
    
// UIImage -> Mat
UIImageToMat(image, matImg);
    
// 先膨胀
cv::Mat dilateElement = getStructuringElement(cv::MORPH_RECT, cv::Size(10,10));
cv::dilate(matImg, closeImg, dilateElement);
    
// 再腐蚀
cv::Mat erodeElement = getStructuringElement(cv::MORPH_RECT, cv::Size(10,10));
cv::erode(closeImg, closeImg, erodeElement);
    
cv::subtract(closeImg, matImg, resultImg);
    
// 显示
[self cv_MatToUIImageWithMat:resultImg];

在学习了腐蚀与膨胀(Erosion 与 Dilation)两个最基本的形态学操作之后,我们可以运用这两个基本操作实现更高级的形态学变换。对于上面5种形态学变化的实现,我只是根据实现原理进行计算变换,而OpenCV已经提供了形态学操作的核心函数:对于后面5中形态学变化的操作,都可以使用这个更高级的形态学变换函数实现。

/*
运行更高级的形态学变换函数:
void morphologyEx(InputArray src, OutputArray dst, int op, InputArray element, Point anchor=Point(-1,-1), int iterations=1, int borderType=BORDER_CONSTANT, const Scalar& borderValue=morphologyDefaultBorderValue() )
参数详解:
InputArray src :源图像
OutputArray dst :目标图像,和源图像有同样的size和type
InputArray element :操作内核
int op :形态操作的类型
         MORPH_OPEN - 开运算:2
         MORPH_CLOSE - 闭运算:3
         MORPH_GRADIENT - 形态梯度:4
         MORPH_TOPHAT - 顶帽:5
         MORPH_BLACKHAT - 黑帽:6
*/
    
// 定义内核
cv::Mat element = getStructuringElement(cv::MORPH_RECT, cv::Size(10,10));
    
// 开运算
cv::morphologyEx(matImg, matImg, CV_MOP_OPEN, element);
    
// 闭运算
cv::morphologyEx(matImg, matImg, CV_MOP_CLOSE, element);
    
// 形态梯度
cv::morphologyEx(matImg, matImg, CV_MOP_GRADIENT, element);
    
// 顶帽
cv::morphologyEx(matImg, matImg, CV_MOP_TOPHAT, element);
    
// 黑帽
cv::morphologyEx(matImg, matImg, CV_MOP_BLACKHAT, element);

运用形态变换函数实现的5种形态变换效果与我上面通过计算实现的效果一致,这里测试了开闭运算:

其实morphologyEx函数其实就是内部一个大switch而已。根据不同的标识符取不同的操作。内部运算就是上面例子中的计算原理。关于图形形态学处理就整理这么多了,如果文中有不足或者错误的地方欢迎留言补充,谢谢。

文章原创,商业转载请联系作者获得授权,非商业转载请注明出处。

参考:
形态学图像处理
更多形态学变换

推荐阅读更多精彩内容