OpenCV中的图像处理 —— 霍夫线 / 圈变换 + 图像分割(分水岭算法) + 交互式前景提取(GrabCut算法)
OpenCV中的图像处理 —— 霍夫线 / 圈变换 + 图像分割(分水岭算法) + 交互式前景提取(GrabCut算法)
🌎上一节我们介绍了OpenCV中傅里叶变换和模板匹配,这一部分我们来聊一聊霍夫线/圈变换的原理和应用、使用分水岭算法实现图像分割和使用GrabCut算法实现交互式前景提取
🏠哈喽大家好,这里是ErrorError!,一枚某高校大二本科在读的♂同学,希望未来在机器视觉领域能够有所成就,很荣幸能够在华为云结识众多志同道合和在各方面都有所造诣的小伙伴,我们一起加油吧~💖
1. 霍夫线变换
1.1 HoughLines工作原理
经过上一节中”模板匹配”的了解,是不是发现我们有点儿目标检测的雏形了呢?这一部分说的霍夫线变换也是一个不断深入的关键点。如果可以如果可以用数学形式表示形状,则霍夫变换是一种检测任何形状的流行技术,即使形状有些破损或变形,也可以检测出形状,我们将看到它如何作用于一条线
🚀霍夫线眼中的线:通常一条线可以表示为y=mx+cy=mx+c或以参数形式表示为ρ=xcosθ+ysinθ,其中ρ是从原点到该线的垂直距离,而θ是由该垂直线和水平轴形成的角度以逆时针方向测量(该方向随我们如何表示坐标系而变化),因此,如果线在原点下方通过,则它将具有正的ρ且角度小于180,如果线在原点上方,则将角度取为小于180,而不是大于180的角度,ρ取负值,任何垂直线将具有0度,水平线将具有90度
🚀霍夫线怎么处理线:任何一条线都可以用(ρ,θ)这两个术语表示。因此,首先创建2D数组或累加器(以保存两个参数的值),并将其初始设置为0。让行表示ρ,列表示θ,阵列的大小取决于所需的精度。假设我们希望角度的精度为1度,则需要180列。对于ρ,最大距离可能是图像的对角线长度。因此,以一个像素精度为准,行数可以是图像的对角线长度
🚀放在实际图像中:假设有一个100*100的图像,中间有一条水平线。取直线的第一点,且我们知道它的坐标(x,y)
值。现在在线性方程式中,将值θ= 0,1,2,… 180放进去,然后检查得到ρ。对于每对(ρ,θ),在累加器中对应的(ρ,θ)单元格将值增加1。
现在,对行的第二个点执行与上述相同的操作,递增(ρ,θ)对应的单元格中的值,这一次操作使单元格(50,90)=2
。实际上,我们正在对(ρ,θ)值进行投票。我们对线路上的每个点都继续执行此过程。在每个点上,单元格(50,90)都会增加或投票,而其他单元格可能会或可能不会投票。这样一来,最后,单元格(50,90)的投票数将最高。因此,如果我们在累加器中搜索最大票数,则将获得(50,90)值,该值表示该图像中的一条线与原点的距离为50,角度为90度
1.2 OpenCV中的霍夫曼变换
OpenCV把上述所有的霍夫曼变换过程都封装在了函数cv.HoughLines()里,它返回的是一个math:(rho,theta)值的数组,ρ以像素为单位,θ以弧度为单位。这个函数包括4个参数,第一个即二进制原图,因此在使用霍夫曼变换之前我们会先使用阈值或Canny边缘检测,第二、第三个参数是ρ和θ的精度,第四个参数是阈值,它意味着行的最低投票,票数取决于线上的点数,因此这个阈值也表示检测到的最小线长
import cv2 as cv
import numpy as np
img = cv.imread(cv.samples.findFile(r'E:\image\test19.png'))
gray = cv.cvtColor(img, cv.COLOR_BGR2GRAY)
edges = cv.Canny(gray, 50, 150, apertureSize=3)
lines = cv.HoughLines(edges, 1, np.pi / 180, 100)
for line in lines:
rho, theta = line[0]
a = np.cos(theta)
b = np.sin(theta)
x0 = a * rho
y0 = b * rho
x1 = int(x0 + 1000 * (-b))
y1 = int(y0 + 1000 * (a))
x2 = int(x0 - 1000 * (-b))
y2 = int(y0 - 1000 * (a))
cv.line(img, (x1, y1), (x2, y2), (0, 0, 255), 2)
cv.imshow('houghlines.jpg', img)
cv.waitKey(0)
1.3 概率霍夫线变换
在霍夫线变换中即使对于带有两个参数的行,也需要大量计算,概率霍夫变换是我们看到的霍夫变换的优化,它没有考虑所有要点。取而代之的是,它仅采用随机的点子集,足以进行线检测,只是我们必须降低阈值
OpenCV的实现基于Matas,J.和Galambos,C.和Kittler, J.V.使用渐进概率霍夫变换对行进行的稳健检测[145]。使用的函数是cv.HoughLinesP()。它有两个新的属性:1. - minLineLength - 最小行长,小于此长度的线段将被拒绝;2. - maxLineGap - 线段之间允许将它们视为一条线的最大间隙
import cv2 as cv
import numpy as np
img = cv.imread(cv.samples.findFile(r'E:\image\test19.png'))
gray = cv.cvtColor(img, cv.COLOR_BGR2GRAY)
edges = cv.Canny(gray, 50, 150, apertureSize=3)
lines = cv.HoughLinesP(edges, 1, np.pi / 180, 100, minLineLength=100, maxLineGap=10)
for line in lines:
x1, y1, x2, y2 = line[0]
cv.line(img, (x1, y1), (x2, y2), (0, 255, 0), 2)
cv.imshow('houghlines.jpg', img)
cv.waitKey(0)
2. 霍夫圈变换
上面说完了霍夫线变换,现在挨到霍夫圈变换了,也就是说霍夫线变换面向的是图像中的线,而圈变换面向的就是圆咯
圆在数学上表示为(x−xcenter)^ 2+(y−ycenter)^2= r^2,其中(xcenter,ycenter)(xcenter,ycenter)是圆的中心,rr是圆的半径,从等式中,我们可以看到我们有3个参数,因此我们需要3D累加器进行霍夫变换,这将非常低效。因此,OpenCV使用更加技巧性的方法,即使用边缘的梯度信息的Hough梯度方法,我们在这里使用的函数是cv.HoughCircles()
这个函数参数有点儿多,我们有必要说说,它的原型是cv2.HoughCircles(image, method, dp, minDist, circles, param1, param2, minRadius, maxRadius),第一个参数为原图像(灰度图),第二个参数是检测方法,第三个参数为检测内侧圆心的累加器图像的分辨率于输入图像之比的倒数,如dp=1,累加器和输入图像具有相同的分辨率,如果dp=2,累计器便有输入图像一半那么大的宽度和高度,第四个参数表示两个圆之间圆心的最小距离
param1与param2有默认值100,它们是method设置的检测方法的对应的参数,对当前唯一的方法霍夫梯度法cv2.HOUGH_GRADIENT,param1表示传递给canny边缘检测算子的高阈值,而低阈值为高阈值的一半,param2表示在检测阶段圆心的累加器阈值,它越小,就越可以检测到更多根本不存在的圆,而它越大的话,能通过检测的圆就更加接近完美的圆形了
minRadius和maxRadius有默认值0,分别表示圆半径的最小值和最大值
import numpy as np
import cv2 as cv
img = cv.imread(r'E:\image\test20.png', 0)
img = cv.medianBlur(img, 5)
cimg = cv.cvtColor(img, cv.COLOR_GRAY2BGR)
circles = cv.HoughCircles(img, cv.HOUGH_GRADIENT, 1, 100, param1=100, param2=30, minRadius=100, maxRadius=200)
circles = np.uint16(np.around(circles))
for i in circles[0, :]:
# 绘制外圆
cv.circle(cimg, (i[0], i[1]), i[2], (0, 255, 0), 2)
# 绘制圆心
cv.circle(cimg, (i[0], i[1]), 2, (0, 0, 255), 3)
cv.imshow('detected circles', cimg)
cv.waitKey(0)
3. 图像分割与分水岭算法
3.1 分水岭算法
🚀算法思想:任何灰度图像都可以看作是一个地形表面,其中高强度表示山峰,低强度表示山谷,我们用不同颜色的**水(标签)**填充每个孤立的山谷(局部最小值)。随着水位的上升,根据附近的山峰(坡度),来自不同山谷的水明显会开始合并,颜色也不同。为了避免这种情况,我们要在水融合的地方建造屏障。继续填满水,建造障碍,直到所有的山峰都在水下。然后我们创建的屏障将返回你的分割结果
但是这种方法会由于图像中的噪声或其他不规则性而产生过度分割的结果。因此OpenCV实现了一个基于标记的分水岭算法,我们可以指定哪些是要合并的山谷点,哪些不是。这是一个交互式的图像分割。我们所做的是给我们知道的对象赋予不同的标签。用一种颜色(或强度)标记我们确定为前景或对象的区域,用另一种颜色标记我们确定为背景或非对象的区域,最后用0
标记我们不确定的区域。这是我们的标记。然后应用分水岭算法。然后我们的标记将使用我们给出的标签进行更新,对象的边界值将为-1
3.2 图像分割的实现
有一张布满硬币的白纸,部分硬币之间相互接触,我们将这张图作为源图像,我们先从寻找硬币的近似估计开始,因此我们要使用阈值化(Otsu的二值化)
import numpy as np
import cv2 as cv
from matplotlib import pyplot as plt
img = cv.imread(r'E:\image\test21.png')
gray = cv.cvtColor(img,cv.COLOR_BGR2GRAY)
ret, thresh = cv.threshold(gray,0,255,cv.THRESH_BINARY_INV+cv.THRESH_OTSU)
然后由于分水岭算法对噪声非常敏感,所有我们要去除图像中的所有白点噪声,为此我们可以使用形态学扩张,如果要去除硬币对象中的小孔,我们可以使用形态学侵蚀,在进行完这些操作后,我们可以十分确信靠近对象中心的区域是前景,离对象中心很远的就是背景,现在我们唯一不确定的就是硬币的边界区域
接下来我们需要提取我们可确认为硬币的区域(因为侵蚀会去除边界像素),如果硬币之间不接触那么我们之前的操作完全没问题,但是事实是他们接触了,因此我们更好的选择是找到距离变换并应用适当的阈值。此时我们需要确定一定不是硬币的区域,形态学扩张可以满足我们的需求
剩下的区域是我们不知道的区域,无论是硬币还是背景。分水岭算法应该找到它。这些区域通常位于前景和背景相遇(甚至两个不同的硬币相遇)的硬币边界附近。可以通过从sure_bg
区域中减去sure_fg
区域来获得
# 噪声去除
kernel = np.ones((3,3),np.uint8)
opening = cv.morphologyEx(thresh,cv.MORPH_OPEN,kernel, iterations = 2)
# 确定背景区域
sure_bg = cv.dilate(opening,kernel,iterations=3)
# 寻找前景区域
dist_transform = cv.distanceTransform(opening,cv.DIST_L2,5)
ret, sure_fg = cv.threshold(dist_transform,0.7*dist_transform.max(),255,0)
# 找到未知区域
sure_fg = np.uint8(sure_fg)
unknown = cv.subtract(sure_bg,sure_fg)
✏️代码解析:第3行的cv.morphologyEx()函数是高级形态学转换函数,其功能取决于第二个参数的选取,具体内容请移步 OpenCV-Python——第13章:图像的形态学操作(腐蚀,膨胀,开运算,闭运算…)
第5行cv.dilate()函数即形态学膨胀功能函数
现在我们已经得到了硬币区域并且将它们分割了,在某些情况下,我们只对前景分割感兴趣,而对是否接触或分离接触并不感兴趣,所以这个时候我们不用使用距离变换,只需要侵蚀就可以满足我们的需求了(侵蚀是一种提取确定前景区域的重要方法)
现在我们可以创建标记了,使用cv.connectedComponents()就很不错,它用0标记图像的背景,然后其他对象用从1开始的整数标记,标记完成后使用分水岭方法cv.watershed()完成图像分割
# 类别标记
ret, markers = cv.connectedComponents(sure_fg)
# 为所有的标记加1,保证背景是0而不是1
markers = markers+1
# 现在让所有的未知区域为0
markers[unknown==255] = 0
markers = cv.watershed(img,markers)
img[markers == -1] = [255,0,0]
标记完成后使用分水岭方法cv.watershed()完成图像分割
plt.subplot(241), plt.imshow(cv2.cvtColor(src, cv2.COLOR_BGR2RGB)),
plt.title('Original'), plt.axis('off')
plt.subplot(242), plt.imshow(thresh, cmap='gray'),
plt.title('Threshold'), plt.axis('off')
plt.subplot(243), plt.imshow(sure_bg, cmap='gray'),
plt.title('Dilate'), plt.axis('off')
plt.subplot(244), plt.imshow(dist_transform, cmap='gray'),
plt.title('Dist Transform'), plt.axis('off')
plt.subplot(245), plt.imshow(sure_fg, cmap='gray'),
plt.title('Threshold'), plt.axis('off')
plt.subplot(246), plt.imshow(unknown, cmap='gray'),
plt.title('Unknow'), plt.axis('off')
plt.subplot(247), plt.imshow(np.abs(markers), cmap='jet'),
plt.title('Markers'), plt.axis('off')
plt.subplot(248), plt.imshow(cv2.cvtColor(img, cv2.COLOR_BGR2RGB)),
plt.title('Result'), plt.axis('off')
plt.show()
代码资源参考自:OpenCV-Python——第22章:分水岭算法实现图像分割
4. 使用GrabCut算法实现交互式前景提取
4.1 GrabCut算法
🚀起源:GrabCut算法由英国微软研究院的Carsten Rother,Vladimir Kolmogorov和Andrew Blake设计,在他们的论文“GrabCut”中:使用迭代图割的交互式前景提取
🚀算法步骤:最初用户在前景区域周围绘制一个矩形(前景区域应完全位于矩形内部),然后算法会对其进行迭代分割,以获得最佳结果。做完了但在某些情况下,分割可能不会很好,例如,可能已将某些前景区域标记为背景,反之亦然。在这种情况下,需要用户进行精修。只需在图像错误分割区域上画些笔画,笔画会对算法说: “嘿,该区域应该是前景,你将其标记为背景,在下一次迭代中对其进行校正”或与背景相反,然后在下一次迭代中,我们将获得更好的结果
🚀算法原理:用户输入矩形后,此矩形外部的所有内容都将作为背景(这是在矩形应包含所有对象之前提到的原因),而矩形内的所有内容都是未知的。任何指定前景和背景的用户输入都被视为硬标签,这意味着它们在此过程中不会更改。计算机根据我们提供的数据进行初始标记,它标记前景和背景像素(或对其进行硬标记)
- 现在使用**高斯混合模型(GMM)**对前景和背景进行建模。 根据我们提供的数据,GMM可以学习并创建新的像素分布。也就是说,未知像素根据颜色统计上与其他硬标记像素的关系而被标记为可能的前景或可能的背景(就像聚类一样)
- 根据此像素分布构建图形,图中的节点为像素。添加了另外两个节点,即“源”节点和“接收器”节点。每个前景像素都连接到源节点,每个背景像素都连接到接收器节点
- 通过像素是前景/背景的概率来定义将像素连接到源节点/末端节点的边缘的权重。像素之间的权重由边缘信息或像素相似度定义。如果像素颜色差异很大,则它们之间的边缘将变低
- 然后使用mincut算法对图进行分割。它将图切成具有最小成本函数的两个分离的源节点和宿节点。成本函数是被切割边缘的所有权重的总和。剪切后,连接到“源”节点的所有像素都变为前景,而连接到“接收器”节点的像素都变为背景
- 继续该过程,直到分类收敛为止
4.2 使用OpenCV进行GrabCut算法
OpenCV提供了函数cv.grabCut(),这个函数的参数同样有些多,我们接着摊开聊聊,其中包括7个参数,第一个参数**- img -即源图像,第二个参数- mask -是掩码图像,在其中我们会指定哪些区域为背景,第三个参数- rect -是它的矩形坐标,格式为(x, y, w, h),其中包括前景对象,第四第五个参数 - bdgModel, fgdModel - 是算法内部使用的数组,我们只需要创建两个大小为(1,65)的np.float64类型零数组,第六个参数- iterCount -是算法应运行的迭代次数,第七个参数- model -**应该是cv.GC_INIT_WITH_RECT或cv.GC_INIT_WITH_MASK或两者结合,决定我们要绘制矩形还是最终的修饰笔触,废话不多说上实例
import numpy as np
import cv2 as cv
from matplotlib import pyplot as plt
img = cv.imread(r'E:\image\test22.png')
mask = np.zeros(img.shape[:2], np.uint8)
bgdModel = np.zeros((1, 65), np.float64)
fgdModel = np.zeros((1, 65), np.float64)
rect = (50, 50, 450, 290)
cv.grabCut(img, mask, rect, bgdModel, fgdModel, 5, cv.GC_INIT_WITH_RECT)
mask2 = np.where((mask == 2) | (mask == 0), 0, 1).astype('uint8')
img = img * mask2[:, :, np.newaxis]
plt.imshow(img), plt.colorbar(), plt.show()
有时候我们分割手部骨骼,发现少了一根手指头,而且个别手指还没显示完全,我们这时就需要用画笔精修了,在paint应用程序中打开输入图像,并在图像中添加了另一层,使用画笔中的画笔工具,在新图层上用白色标记了错过的前景,而用白色标记了不需要的背景(例如logo,地面等),然后用灰色填充剩余的背景,然后将该mask图像加载到OpenCV中,编辑我们在新添加的mask图像中具有相应值的原始mask图像
# newmask是我手动标记过的mask图像
newmask = cv.imread('newmask.png',0)
# 标记为白色(确保前景)的地方,更改mask = 1
# 标记为黑色(确保背景)的地方,更改mask = 0
mask[newmask == 0] = 0
mask[newmask == 255] = 1
mask, bgdModel, fgdModel = cv.grabCut(img,mask,None,bgdModel,fgdModel,5,cv.GC_INIT_WITH_MASK)
mask = np.where((mask==2)|(mask==0),0,1).astype('uint8')
img = img*mask[:,:,np.newaxis]
plt.imshow(img),plt.colorbar(),plt.show()
(注:文章内容参考OpenCV4.1中文官方文档)
如果文章对您有所帮助,记得一键三连支持一下哦👍+⭐️+📝
- 点赞
- 收藏
- 关注作者
评论(0)