前言
继续学习图像里面的形态学知识——结构元、腐蚀、膨胀、开运算、闭运算、击中/不击中变换。以及部分基本形态学算法,包括边界提取、空洞填充、连通分量的提取、凸壳、细化、粗化、骨架、裁剪、形态学重建。
其实就是对冈萨雷斯的《数字图像处理》中第9章节《形态学处理》的简要理解。
如果你认为腐蚀是减小白色区域,膨胀是扩充白色区域,请务必看本博客,注意不同结构元的结果。
参考博客:
OpenCV
官方的形态学运算文档
冈萨雷斯的《数字图像处理》第9章
某位大佬的形态学总结
理论与实践
结构元
结构元实际就是一个自定义的矩阵,在书中通常称为集合,是研究一幅图像中感兴趣特性所用的小集合或者子图像。结构元通常有反射和平移两个操作。假设一个集合(结构元)定义为B,那么:
- 反射:定义为B^\hat{B}B^,是B中的坐标(x,y)(x,y)(x,y)被(−x,−y)(-x,-y)(−x,−y)替代。
- 平移:定义为(B)z(B)_z(B)z,是B中的坐标(x,y)(x,y)(x,y)被(x+z1,x+z2)(x+z_1,x+z_2)(x+z1,x+z2)替代。
同时结构元还有一个原点,这在opencv
中叫anchor
,后面腐蚀膨胀的操作都是更改原点对应的原图像素。
【注】不要小看结构元,其设计直接影响到最终效果,这也是为什么开头说“腐蚀减小白色区域,膨胀扩充白色区域”是错误观点,因为一切以公式和结构元为准。依据不同的任务设计不同的结构元才是我们关注的点,比如垂直方向的细节需要细化或者粗化,应该用什么结构元采用什么操作。
腐蚀
操作
将结构元在目标图像上从左往右从上往下平移,平移过程中结构元中值为1的位置对应的图像像素都是1,则结构元原点对应位置的像素为1,否则为0。注意,平移的起点以结构元原点(中心)为准,所以一般来说需要对图像做padding,这样才能保证平移的起始位置让结构元原点对齐图像的左上角第一个像素。
公式
若结构元为E,图像为A,那么腐蚀的公式表示就是
A⊖E={z∣(E)z⊆A}A\ominus E=\{z|(E)_z\subseteq A\} A⊖E={z∣(E)z⊆A}
作用
将小于结构元的图像细节从图像中滤除了,腐蚀缩小或者细化了二值图像中的物体。禁止说消除或减小白色区域,说的时候可以加个可能,因为结构元对结果会有很大的影响。
实现
代码表示就是:
opencv
的调用方法:
result = cv2.erode(src,kernel,iterations=1,borderType=cv2.BORDER_CONSTANT,borderValue=1)
使用numpy
复现:
def erod(img,kernel):ksize = kernel.shapecenter=(int(ksize[0]/2),int(ksize[1]/2))img_pad = cv2.copyMakeBorder(src,center[0],center[0],center[1],center[1],borderType=cv2.BORDER_CONSTANT,value=0)new_img = np.zeros_like(img)ele_idx = np.argwhere(kernel==1)for i in range(img.shape[0]):for j in range(img.shape[1]):block = img_pad[i:i+ksize[0],j:j+ksize[1]]if(block[ele_idx[...,0],ele_idx[...,1]].all()==1):new_img[i,j] = 1else:new_img[i,j] = 0return img_pad,new_img
随便贴两个结果,建议手推一遍
【注】很明显,第一张图的结构元对图像的腐蚀得到的结果仅仅是将图像向右平移一个像素,并没有出现减小白色区域的效果。
膨胀
操作
将结构元在目标图像上从左往右从上往下平移,平移过程中结构元中值为1的位置对应的图像像素至少有一个为1,则结构元原点对应位置的像素为1,否则为0。
公式
若结构元为E,图像为A,那么膨胀的公式表示就是
A⊕E={z∣[(E)z∩A≠∅]}A\oplus E = \{z|[(E)_z\cap A\neq \varnothing]\} A⊕E={z∣[(E)z∩A=∅]}
作用
增长或粗化二值图像中的物体,通常可以用于桥接裂缝。
实现
def dilate(img,kernel): ksize = kernel.shapecenter=(int(ksize[0]/2),int(ksize[1]/2))img_pad = cv2.copyMakeBorder(src,center[0],center[0],center[1],center[1],borderType=cv2.BORDER_CONSTANT,value=0)new_img = np.zeros_like(img)ele_idx = np.argwhere(kernel==1)for i in range(img.shape[0]):for j in range(img.shape[1]):block = img_pad[i:i+ksize[0],j:j+ksize[1]]if(block[ele_idx[...,0],ele_idx[...,1]].any()==1):new_img[i,j] = 1else:new_img[i,j] = 0return img_pad,new_img
【注】看第一幅图的腐蚀结果和膨胀结果,惊不惊喜意不意外刺不刺激,竟然一模一样,是否颠覆了自己对腐蚀和膨胀的认知。但是如果你按照公式手推一遍,会发现完全没毛病。
开运算
操作
先进行腐蚀,再进行膨胀
公式
A∘B=(A⊖B)⊕BA\circ B=(A\ominus B)\oplus B A∘B=(A⊖B)⊕B
作用
平滑物体轮廓,断开较窄的狭颈并消除细的突出物。
实现
kernel = np.ones((7,7),np.uint8)
# 自带的
img_open1 = cv2.morphologyEx(img_bin, cv2.MORPH_OPEN, kernel)
# 先腐蚀后膨胀
open_tmp = cv2.erode(img_bin,kernel)
img_open2 = cv2.dilate(open_tmp,kernel)
可以发现,白色线条部件了,而且五角星的五个角更加平滑。此时注意云朵并没有任何变化。
闭运算
操作
先进行膨胀,再进行腐蚀
公式
A∙B=(A⊕B)⊖BA\bullet B=(A\oplus B)\ominus B A∙B=(A⊕B)⊖B
作用
同样能够平滑轮廓,弥合较窄的间断和细长的沟壑,消除小孔洞,填补轮廓线中的断裂。
实现
## 闭运算
kernel = np.ones((7,7),np.uint8)
#自带
img_close1 = cv2.morphologyEx(img_bin, cv2.MORPH_CLOSE, kernel)
close_tmp = cv2.dilate(img_bin,kernel)
img_close2 = cv2.erode(close_tmp,kernel)
发现左下角图像的内部黑线没了,而且云朵的轮廓被平滑了,并且尾巴连在一起了,说明能够弥补断裂部分。
【注意】开运算平滑的轮廓是指白色区域向黑色区域的凸出尖角,而闭运算的平滑轮廓是指黑色区域向白色区域凸出的尖角,也就是它俩的白色尖角一个凸一个凹。
击中和不击中
操作
如果图像中有A、B、C三个形状,D为其中一个形状如B被小窗口包围的图像,击中和不击中操作就是:
- 用D对图像进行腐蚀
- 用D中B的补集对D中ABC集合的补集进行腐蚀
- 对上述两个腐蚀操作的结果图像进行求交集
即可利用D击中图像中的B。
公式
设A为某个图像中所有形状的集合,B为某个形状和局部背景的集合,则利用B在A中的匹配为:
A⊛B=(A⊖B)∩(Ac⊖Bc)A\circledast B = (A\ominus B)\cap (A^c\ominus B^c) A⊛B=(A⊖B)∩(Ac⊖Bc)
这样就可以用B中的形状命中A中的某个形状。
作用
一般作为形状检测的基本工具,但是测试的时候感觉局限性太大了,形状大小稍微有变动就有可能击不中。书中也有讲,使用与物体有关的结构元和与北京有关的结构元基于一个假设定义——仅当两个或多个物体形成相脱离(断开)的集合时,物体才是可分得。所以要求每个物体(形状)至少被一个像素宽的背景围绕。当不关心背景,只关注由0和1组成的某些模式感兴趣的时候,击中或不击中就变成了腐蚀操作;腐蚀是匹配的集合。
实现
还是上面的那张图,但是我们想击中五角星
## 按步骤实现
tmp1 = cv2.erode(img_bin,kernel)
tmp2 = 255.0 - cv2.erode(255.0-img_bin,255.0-kernel)
result = cv2.bitwise_and(np.asarray(tmp1,dtype=np.uint8),np.asarray(tmp2,dtype=np.uint8))
plt.figure(figsize=(16,16))
plt.subplot(131)
plt.imshow(tmp1,cmap='gray')
plt.subplot(132)
plt.imshow(tmp2,cmap='gray')
plt.subplot(133)
plt.imshow(result,cmap='gray')
因为被击中的地方只有一个像素,所以需要提取一下位置
pos=[]
for i in range(result.shape[0]):for j in range(result.shape[1]):if(result[i,j]==255 and np.sum(result[i-1:i+2,j-1:j+2])==255):pos.append([i,j])
for i in range(len(pos)):cv2.circle(img,(pos[i][1],pos[i][0]),5,(0,255,0),-1)
plt.imshow(img)
边界提取
非常简单,就是腐蚀一下,与原图相减即可。公示表示就是,如果A为原图,B为结构元,则A的边界就是
β(A)=A−(A⊖B)\beta(A) = A-(A\ominus B) β(A)=A−(A⊖B)
孔洞填充
操作
孔洞的定义是被前景包围的一个背景区域,比如放在灯泡下的一个玻璃球,表面通常会有一个代表光反射的白色的点,与周围玻璃格格不入。孔洞填充基于集合膨胀、求补和交集的算法。
若A中有一些孔洞,并且我们知道每个孔洞中某个像素位置,那么基于当前孔洞,首先建立一个纯黑色的背景图,将此位置的像素置为1,不断去膨胀这张图,同时与原图的补集与膨胀图的交集,当此交集不变的时候,就是对当前孔洞填充完毕。
公式
设A为某个具有孔洞的图,B为结构元,XkX_kXk为第kkk次膨胀的结果
Xk=(Xk−1⊕B)∩AcX_k = (X_{k-1}\oplus B)\cap A^c Xk=(Xk−1⊕B)∩Ac
其中k=0k=0k=0时,即初始的时候,膨胀图为只有当前孔洞某个位置为1,其它均为0的图片。
作用
能够填充图中指定位置的孔洞
实现
hole_pos = (72,82)
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3))
Xprev = np.zeros_like(img_bin)
Xprev[hole_pos[1],hole_pos[0]]=255
Xcurrent = cv2.bitwise_and(cv2.dilate(Xprev,kernel),np.array(255-img_bin,dtype='uint8'))
while(not (Xprev==Xcurrent).all()):Xprev = XcurrentXcurrent = cv2.bitwise_and(cv2.dilate(Xprev,kernel),np.array(255-img_bin,dtype='uint8'))
连通分量
与孔洞填充的逻辑刚好相反,填充空洞需要对原图取反求交集,但是提取连通分量则是直接对原图求交集。公式如下:
Xk=(Xk−1⊕B)∩AX_k = (X_{k-1}\oplus B)\cap A Xk=(Xk−1⊕B)∩A
代码实现
pos = (47,68)
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3))
Xprev = np.zeros_like(img_bin)
Xprev[pos[1],pos[0]]=255
Xcurrent = cv2.bitwise_and(cv2.dilate(Xprev,kernel),img_bin)
while(not (Xprev==Xcurrent).all()):Xprev = XcurrentXcurrent = cv2.bitwise_and(cv2.dilate(Xprev,kernel),img_bin)
凸壳
操作
如果一个形状的任意两个点连接的直线段都在该形状内部,则称该形状是凸形的。任意集合S的凸壳H是包含于S的最小凸集,集合差H-S称为S的凸缺。
书中介绍了一个简单的获取凸壳的形态学算法:定义结构元,然后执行击中或不击中操作:
Xk=(Xk−1⊛B)∪AX_k = (X_{k-1}\circledast B)\cup A Xk=(Xk−1⊛B)∪A
其中X0=AX_0=AX0=A,收敛即为xk=xk−1x_k=x_{k-1}xk=xk−1。
使用四个结构元执行上述四个操作,得到四个收敛图,最后求并集,就得到了A的凸壳。
这个操作其实可以直接用轮廓检测中的凸包函数convexHull
得到,就不做实现了。
细化
结构元B对图像A的细化可利用击中或不击中变换表示为:
A⊗B=A−(A⊛B)A\otimes B = A-(A\circledast B) A⊗B=A−(A⊛B)
粗化
粗化是细化的形态学对偶,直接定义:
A⋅B=A∪(A⊛B)A\cdot B = A\cup(A\circledast B) A⋅B=A∪(A⊛B)
骨架提取
图形A的骨架可以用腐蚀和开操作来表达:
S(A)=⋃k=0KSk(A)S(A) = \bigcup\limits_{k=0}^K S_k(A) S(A)=k=0⋃KSk(A)
其中,
Sk(A)=(A⊖kB)−(A⊖kB)∘BS_k(A) = (A\ominus kB) - (A\ominus kB)\circ B Sk(A)=(A⊖kB)−(A⊖kB)∘B
式中,B是一个结构元,而(A⊖kB)(A\ominus kB)(A⊖kB)表示对A的连续k次腐蚀
(A⊖kB)=(((⋯(A⊖B)⊖B)⊖⋯)⊖B)(A\ominus kB)=(((\cdots(A\ominus B)\ominus B)\ominus\cdots)\ominus B) (A⊖kB)=(((⋯(A⊖B)⊖B)⊖⋯)⊖B)
K是A被腐蚀为空集前的最后一次迭代步骤,也就是:
K=max{k∣(A⊖kB)≠∅}K = \max \{k|(A\ominus kB)\neq \varnothing\} K=max{k∣(A⊖kB)=∅}
实现
#https://theailearner.com/tag/thinning-opencv/
kernel = cv2.getStructuringElement(cv2.MORPH_CROSS,(3,3))
thin = np.zeros(img_bin.shape,dtype='uint8')img1 = img_bin.copy()
while (cv2.countNonZero(img1)!=0):erode = cv2.erode(img1,kernel)opening = cv2.morphologyEx(erode,cv2.MORPH_OPEN,kernel)subset = erode - openingthin = cv2.bitwise_or(subset,thin)img1 = erode.copy()
也可以使用opencv-contrib
实现的Zhang-Suen:A Fast Parallel Algorithm for Thinning Digital Patterns的细化算法:
thinned = cv2.ximgproc.thinning(img_bin,cv2.ximgproc.THINNING_ZHANGSUEN)
代码实现步骤和理论详解可以看论文或者一个大佬的实现,或者看我的本篇博客对应的github即可。
形态学重建
上面的形态学操作都是只涉及一幅图像和一个结构元;而形态学重建则是非常强力的形态学变换,涉及两幅图像和一个结构元。一幅图像是标记,表示变换的起点,而另一幅图像是模板,约束改变换。
令FFF表示标记图像,GGG表示模板图像,书中定义一个前提F⊆GF\subseteq GF⊆G,那么形态学重建涉及到的概念有:
-
测地膨胀
DG(n)={F,n=0(F⊕B)∩G,n=1DG(1)[DG(n−1)(F)],n≥1D_G^{(n)}=\begin{cases} F,\quad n=0\\ (F\oplus B)\cap G,\quad n=1\\ D^{(1)}_G\left[D^{(n-1)}_G(F) \right],\quad n\geq 1 \end{cases} DG(n)=⎩⎪⎪⎨⎪⎪⎧F,n=0(F⊕B)∩G,n=1DG(1)[DG(n−1)(F)],n≥1
这个交集,能够保证模板GGG限制FFF的膨胀,也就是说对传统的膨胀加了约束。## 测地膨胀 def D(n,F,B,G):if(n==0):return Fif(n==1):return cv2.bitwise_and(cv2.dilate(F,B),G)#cv2.bitwise_andreturn D(1,D(n-1,F,B,G),B,G)
-
测地腐蚀
EG(n)={F,n=0(F⊖B)∪G,n=1EG(1)[EG(n−1)(F)],n≥1E_G^{(n)}=\begin{cases} F,\quad n=0\\ (F\ominus B)\cup G,\quad n=1\\ E^{(1)}_G\left[E^{(n-1)}_G(F) \right],\quad n\geq 1 \end{cases} EG(n)=⎩⎪⎪⎨⎪⎪⎧F,n=0(F⊖B)∪G,n=1EG(1)[EG(n−1)(F)],n≥1
这个并集能够保证测地腐蚀始终大于或者等于模板图像,也就是对传统的腐蚀加入了约束。## 测地腐蚀 def E(n,F,B,G):if(n==0):return Fif(n==1):return cv2.bitwise_or(cv2.erode(F,B),G)return E(1,E(n-1,F,B,G),B,G)
由于约束的存在,上述两个操作一定会有收敛的时候。
对应的形态学重建也就有两种:
-
使用膨胀的重建
RDG(F)=DG(k)(F)R_D^G(F)=D^{(k)}_G(F) RDG(F)=DG(k)(F)
迭代k次,直到收敛条件达到DG(k)(F)=DG(k+1)(F)D_G^{(k)}(F)=D_G^{(k+1)}(F)DG(k)(F)=DG(k+1)(F)## 膨胀重建 def RD(input_img,kernel,template):prevD = D(1,input_img,kernel,template)i=2while(1):currD = D(i,input_img,kernel,template)if((prevD==currD).all()):return currDelse:prevD = currDi=i+1
-
使用腐蚀的重建
RGE(F)=EGk(F)R_G^E(F) = E_G^k(F) RGE(F)=EGk(F)
同样是迭代k此,直到收敛EG(k)=EG(k+1)(F)E_G^{(k)}=E_G^{(k+1)}(F)EG(k)=EG(k+1)(F)
书中有一个例子是重建开操作:可正确恢复腐蚀后所保留的物体形状。一般重建开操作的定义是先对图像进行nnn此腐蚀,再进行膨胀重建,公式表示就是
OR(n)(F)=RFD[F⊖nB]O_R^{(n)}(F) = R_F^D\left[F\ominus nB\right] OR(n)(F)=RFD[F⊖nB]
利用重建开操作,提取图中的长垂直的字符,注意这里实现的时候有个坑,腐蚀的时候书中指明使用(51,1)(51,1)(51,1)的结构元,但是重建开操作的时候,结构元不要用这么细长的一个。
kernel_erode = cv2.getStructuringElement(cv2.MORPH_RECT,(1,51))
kernel_rec = cv2.getStructuringElement(cv2.MORPH_RECT,(3,3))
img_erode = cv2.erode(img_bin,kernel_erode)
img_rec = RD(img_erode,kernel_rec,img_bin)
最后一行的两幅图分别是开运算和重建开运算的结果,可以发现重建开运算很好的保留了竖长的字符。
后记
本片博客最重要的结论就是:腐蚀和膨胀的结果并非和网上说的单纯的减小或者增加白色区域的面积,实际上应该是结构元的设计对最终腐蚀和膨胀的结果有很大的影响,有些结构元可能导致腐蚀操作中,图像某些局部区域被膨胀,反之亦然,也可能有些结构元对你的图像并无得任何效果。
博客会更新到微信公众号中对应的图像基础知识列表中,代码也在公众号简介的github
中(CSDN
博客右侧也有github
地址),有兴趣点一波关注啵~~