OpenCV 图像分析之 —— 分割

本文最后更新于:2022年7月4日 上午

图像处理中,“分割” 是重要的任务之一,本文记录 OpenCV 关于分割相关的功能。

概述

图像分割是个很大的话题,这里,我们重点研究 OpenCV 中的几种专门实现分割方法的技术实现或者后面要用到的形态学策略。

  • 图像分割现在还没有一种“百试百灵”的解决方案,在计算机视觉研究中,它仍是一个非常活跃的领域。

  • 尽管如此,已经开发出许多可靠的技术,至少在某些特定的领域是可靠的,并且在实践中可以产生非常好的结果。


漫水填充

相比你可能已经接触过的经典计算机绘图程序,OpenCV 中的漫水填充是一种更一般的填充方法。算法首先选取一个种子点,然后将所有与它相似的点包括本身上一种特定的颜色,区别是相邻像素不一定全部上同一种颜色。注19漫水填充的结果是一个单连通区域,如果像素灰度值与任一当前像素差异在指定范围(loDiffupDiff)内或在指定原始种子像素灰度值的指定范围内,cv2.floodFill()函数都将对它进行上色。漫水填充也可以通过可选的掩膜参数进行约束。cv2.floodFill()有两种不同的原型,一种接受一个显式的mask参数,另一个不需要。

cv2.floodFill

填充一个连接组件与给定的颜色。

官方文档

  • 函数 cv2.floodFill() 用指定的颜色从种子点开始填充连接元件。连通性取决于邻近像素的颜色/亮度接近程度。
  • 在(x,y)处的像素被认为属于重新绘制的域,如果:
情况 规则
灰度图像和浮动范围 $ \operatorname{src}\left(x^{\prime}, y^{\prime}\right)-\operatorname{loDiff} \leq \operatorname{src}(x, y) \leq \operatorname{src}\left(x^{\prime}, y^{\prime}\right)+ upDiff$
灰度图像和固定范围 $ \operatorname{src}(\operatorname{seedPoint.} x , seedPoint. y)-\operatorname{loDiff} \leq \operatorname{src}(x, y) \leq \operatorname{src}(\operatorname{seedPoint.} x , seedPoint. y)+ upDiff$
彩色图像和浮动范围 $\begin{array}{c} \operatorname{src}\left(x^{\prime}, y^{\prime}\right)_{r}-\operatorname{loDiff}_{r} \leq \operatorname{src}(x, y)_{r} \leq \operatorname{src}\left(x^{\prime} y^{\prime}\right)_{r}+\operatorname{upDiff}_{r} \\ \operatorname{src}\left(x^{\prime}, y^{\prime}\right)_{g}-\operatorname{loDiff}_{g} \leq \operatorname{src}(x, y)_{g} \leq \operatorname{src}\left(x^{\prime}, y^{\prime}\right)_{g}+\operatorname{upDiff}_{g} \\\operatorname{src}\left(x^{\prime} y^{\prime}\right)_{b}-\operatorname{loDiff}_{b} \leq \operatorname{src}(x, y)_{b} \leq \operatorname{src}\left(x^{\prime}, y^{\prime}\right)_{b}+\operatorname{upDiff}_{b} \end{array} $
彩色图像和固定范围 $ \begin{array}{c}\operatorname{src}(\operatorname{seedPoint.} x \text {, seedPoint. } y)_{r}- loDiff _{r} \leq \operatorname{src}(x, y)_{r} \leq \operatorname{src}(\text { seedPoint. } x \text {, seedPoint. } y)_{r}+ upDiff _{r} \\ \operatorname{src}(\operatorname{seedPoint.} x, \text { seedPoint. } y)_{g}- loDiff _{g} \leq \operatorname{src}(x, y)_{g} \leq \operatorname{src}(\operatorname{seedPoint.} x \text {, seedPoint. } y)_{g}+ upDiff _{g} \\ \operatorname{src}(\operatorname{seedPoint.} x, \operatorname{seedPoint.} y)_{b}-\operatorname{loDiff}_{b} \leq \operatorname{src}(x, y)_{b} \leq \operatorname{src}(\operatorname{seedPoint.} x, \text { seedPoint. } y)_{b}+ upDiff _{b} \end{array}$

其中$ \operatorname{src}\left(x^{\prime}, y^{\prime}\right) $是已知属于该组件的像素邻居之一的值。也就是说,要添加到连接元件中,像素的颜色/亮度应该足够接近:

  1. 在浮动范围的情况下,其中一个已经属于连接元件的邻居的颜色/亮度。

  2. 在固定范围内种子点的颜色/亮度。

  • 使用这些函数可以用指定的颜色就地标记连接的组件,或者构建一个蒙版然后提取轮廓,或者将该区域复制到另一个图像,等等。

  • 函数使用

1
2
3
4
5
6
7
8
9
10
11
12
13
cv2.floodFill(
image, # 输入/输出 源图像,单通道或三通道图像 uint8 或浮点型数据
默认被修改,除非cv2.FLOODFILL_MASK_ONLY 被设置
mask, # 操作掩码应为单通道 8 位图像,比图像宽 2 像素,高 2 像素;
该值既是输入也是输出,需要提前初始化;
其中需要处理的区域设置为 0,不需要处理的区域设置为 1
seedPoint, # 起始点
newVal[, # 重绘域像素的新值。
loDiff[, # 最大低亮度/色差
upDiff[, # 最大高亮度/色差
flags]]] # 可选标记
) ->
retval, image, mask, rect
  • 注意:由于掩码大于填充后的图像,因此图像中的一个像素$ (x,y) $对应于掩码中的像素$ (x+1,y+1)$。

  • flags : FloodFillFlags

    标记 含义
    cv2.FLOODFILL_FIXED_RANGE 如果设置,则考虑当前像素和种子像素之间的差异。否则,考虑相邻像素之间的差异(即范围是浮动的)。
    cv.FLOODFILL_MASK_ONLY 如果设置,该函数不会更改图像(newVal 被忽略),并且仅使用标志位 8-16 中指定的值填充掩码。此选项仅在具有掩码参数的函数变体中才有意义。

    flags可取的值有:cv2.FLOODFILL_FIXED_RANGEcv2.FLOODFILL_MASK_ONLY。除了这些,你还可以为它加数值4或8。这样,你就声明了连通方式采用4连通还是8连通。前一种情况,4连通数组指的是与当前点距离最近的四个邻点(左、右、上和下)。而8连通情况下,对角线上连接的邻点也算在内。

    实际上,flags 是一个位字段的参数。然而,方便的是,4和8是单个位,所以你可以使用“add”或“OR”

    例如,flags=8|cv2.FLOODFILL_MASK_ONLY

  • 示例代码

1
2
3
4
5
image = mt.cv_rgb_imread('img1.jpg', gray=True)
mask = cv2.Canny(image, 50, 150)
mask = cv2.copyMakeBorder(mask,1, 1, 1, 1,cv2.BORDER_CONSTANT, value=1)
retval, image, mask, rect = cv2.floodFill(image, mask, seedPoint=[500, 650], newVal=255, loDiff=5, upDiff=5)
PIS(image, mask)


分水岭算法

在许多实际情况中,我们想分割一幅图像,但没有任何一种单独的背景蒙版的能用。这种情况下一种有效的技巧就是分水岭算法。

  • 这种算法将一幅图像中的线转化成“山”,平坦区域转换成“谷”以用于辅助物体分割。分水岭算法首先计算强度图像的梯度,这有助于在原图中形成没有纹理的谷或盆地(较低点),同时形成线条丰富的山脉或ranges(对应边缘的高脊)。然后从指定的点开始向这些盆地灌水。当图像被“填满”时,所有有标记的区域就被分割开了。这样一来,连通到标记点的盆地就属于这个标记点了,然后就把相应的标记区域从图像中分割出来。

  • 更具体地说,分水岭算法允许使用者(或其他算法)标记已知是对象或背景一部分的对象或背景部分。或者,调用者可以画一条或几条简单的线,这些线条有效地告诉分水岭算法“将这些点组合在一起”。分水岭算法然后通过让标记区域“获取”梯度图中与片段连接的边界确定的峡谷来分割图像。

cv2.watershed

使用分水岭算法执行基于标记的图像分割。

官方文档

  • 在将图像传递给函数之前,您必须用正 (>0) 索引粗略地勾勒出图像标记中所需的区域。因此,每个区域都表示为一个或多个具有像素值 1、2、3 等的连通分量。可以使用 findContoursdrawContours 从二进制掩码中检索此类标记。
  • 标记是未来图像区域的“种子”。标记中的所有其他像素,其与轮廓区域的关系未知,应由算法定义,应设置为 0。在函数输出中,标记中的每个像素都设置为“种子”组件的值,或者在区域之间的边界处设置为 -1。
  • 任何两个相邻的连通分量不一定被分水岭边界(-1 的像素)分开;例如,它们可以在传递给函数的初始标记图像中相互接触。
  • 函数使用
1
2
3
4
5
cv2.watershed(
image, # 输入 uint8 三通道图像
markers # 输入/输出标记的 32 位单通道图像。它应该与 image 具有相同的大小。
) ->
markers
  • 示例图像

  • 示例代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
img = mt.cv_rgb_imread('water.png',)
gray=cv2.cvtColor(img,cv2.COLOR_BGR2GRAY)
ret,thresh=cv2.threshold(gray,0,255,cv2.THRESH_BINARY_INV+cv2.THRESH_OTSU)
kernel=np.ones((3,3),np.uint8)
opening=cv2.morphologyEx(thresh,cv2.MORPH_OPEN,kernel,iterations=2)# 形态开运算
sure_bg=cv2.dilate(opening,kernel,iterations=3)

dist_transform=cv2.distanceTransform(opening,cv2.DIST_L2,5)
ret,sure_fg=cv2.threshold(dist_transform,0.7*dist_transform.max(),255,0)
sure_fg=np.uint8(sure_fg)
unknown=cv2.subtract(sure_bg,sure_fg)
ret,connected=cv2.connectedComponents(sure_fg)
markers=connected+1
markers[unknown==255]=0
input_markers = markers.copy()
cv2.watershed(img,markers)

PIS(
[img, 'raw image'],
[thresh, 'threshold'],
[sure_bg, 'morphology'],
[dist_transform, 'dist_transform'],
[sure_fg, 'center'],
[unknown, 'target_area'],
[connected, 'connected'],
[input_markers, 'input markers'],
[markers, 'result']
)

img[markers==-1]=[0, 0, 0]
PIS(img)


Grabcuts 算法

GrabCut该算法利用了图像中的纹理(颜色)信息和边界(反差)信息,只要小量的用户交互操作即可得到比较好的分割效果。

cv2.grabCut

运行 GrabCut 算法。

官方文档

  • 函数使用
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
cv2.grabCut(
img, # 输入 uint8 三通道图像,在处理的过程中不会被修改。
mask, # 输入/输出 8 位单通道掩码。
当模式设置为 cv2.GC_INIT_WITH_RECT 时,该函数会初始化掩码。如果使用掩码进行初始化,那么mask保存初始化掩码信息。
在执行分割的时候,也可以将用户交互所设定的前景与背景保存到mask中,然后再传入grabCut函数;在处理结束之后,mask中会保存结果。
rect, # 包含分段对象的 ROI,用于限定需要进行分割的图像范围,只有该矩形窗口内的图像部分才被处理。
ROI 之外的像素被标记为“明显背景”。该参数仅在 mode==cv2.GC_INIT_WITH_RECT 时使用。
bgdModel, # 背景模型的临时数组,如果为null,函数内部会自动创建一个bgdModel;
bgdModel 必须是单通道浮点型(CV_32FC1)图像,且行数只能为1,列数只能为13x5;
在处理同一图像时不要修改它。
fgdModel, # 前景模型的临时数组,如果为null,函数内部会自动创建一个fgdModel;
fgdModel必须是单通道浮点型(CV_32FC1)图像,且行数只能为1,列数只能为13x5;
在处理同一图像时不要修改它。
iterCount[, # 算法在返回结果之前应该进行的迭代次数,必须大于0。
请注意,可以通过使用 mode==cv2.GC_INIT_WITH_MASK 或 mode==GC_EVAL 进一步调用来优化结果。
mode] # 操作模式, 可能是 GrabCutModes 之一
) ->
mask, bgdModel, fgdModel

  • 给定一幅输入图像 img, cv2.grabCut()就会计算得到的标签并把它保存在输出数组mask里。mask数组也可以当作输入,用变量mode说明。如果mode包含标签cv2.GC_INIT_WITH_MASK, 那么在mask里的值将被用于初始化图像的标签。掩膜应当是单通道的 uint8 类型的图像,掩膜里的每个像素都应按下表赋值。
枚举值 数值 含义
cv2.GC_BGD 0 确定性背景
cv2.GC_FGD 1 确定性前景
cv2.GC_PR_BGD 2 疑似背景
cv2.GC_PR_FGD 3 疑似前景

如果没有手工标记GCD_BGD或者GCD_FGD,那么结果只会有GCD_PR_BGD或GCD_PR_FGD。

  • 参数rect只适用于你不用掩膜初始化的时候。当模式包含标志cv2.GC_INIT_WITH_RECT时,矩形区域之外的整个区域就被当作是“确定性背景”,而剩下的区域则默认为“疑似前景”。

  • bgdModelfgdModel 本质上是临时缓冲区。当你第一次调用 cv2.grabCut() 时,它们可以是空数组,但如果需要迭代多次并且中间需要重启算法(可能在允许用户提供额外的“确定”像素来指导算法之后),你就需要传入由上一次运行所填充的相同(未修改)缓冲区(除了使用从上一次运行中返回的掩码作为下一次运行的输入)。

  • Grabcuts 算法需要在内部运行几次Graphcuts 算法。在每个这样的运行之间,都要重新计算混合模型。参数itercount一般是10或12,但其大小可能取决于图像的大小和性质。

  • mode: GrabCutModes

    取值 含义
    cv2.GC_INIT_WITH_RECT 该函数使用提供的矩形初始化状态和掩码。之后,它运行算法的 iterCount 迭代。
    cv2.GC_INIT_WITH_MASK 该函数使用提供的掩码初始化状态。请注意,cv2.GC_INIT_WITH_RECTcv2.GC_INIT_WITH_MASK 可以组合使用。然后,使用 cv2.GC_BGD 自动初始化 ROI 之外的所有像素。
    cv2.GC_EVAL 该值意味着算法只是恢复。
    cv2.GC_EVAL_FREEZE_MODEL 该值意味着算法应该只使用固定模型运行 grabCut 算法(单次迭代)
  • 示例代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
img = mt.cv_rgb_imread('img1.jpg')
img = mt.image_resize(img, factor=0.3)
OLD_IMG = img.copy()
mask = np.zeros(img.shape[:2], np.uint8)
SIZE = (1, 65)
bgdModle = np.zeros(SIZE, np.float64)
fgdModle = np.zeros(SIZE, np.float64)
rect = (1, 1, img.shape[1], img.shape[0])
cv2.grabCut(img, mask, rect, bgdModle, fgdModle, iterCount=1, mode=cv2.GC_INIT_WITH_RECT)
img_1 = img * (mask == 3)[:,:,None]
res_dict = dict()
res_dict[1] = img_1
loop_num = 1
iter_each = 1
for index in mt.tqdm(range(7)):
cv2.grabCut(img, mask, rect, bgdModle, fgdModle, iterCount=iter_each, mode=cv2.GC_INIT_WITH_MASK)
loop_num += iter_each
res_dict[loop_num] = (img * (mask == 3)[:,:,None])

res_list = mt.get_list_from_list(list(res_dict), lambda x: [res_dict[x], 'loop_'+str(x)])
res_list = [[OLD_IMG, 'raw image']] + res_list
PIS(*res_list)

Mean-Shift分割算法

Mean-Shift 分割算法找到空间上颜色分布的峰值, 它与mean-shift算法有关。基于颜色分布峰值进行这种分割的函数是cv2.pyrMeanShiftFiltering()

  • meanShfit均值漂移算法是一种通用的聚类算法,它的基本原理是:对于给定的一定数量样本,任选其中一个样本,以该样本为中心点划定一个圆形区域,求取该圆形区域内样本的质心,即密度最大处的点,再以该点为中心继续执行上述迭代过程,直至最终收敛。可以利用均值偏移算法的这个特性,实现彩色图像分割.

cv2.pyrMeanShiftFiltering

Mean-Shift 分割算法

官方文档

  • 函数使用
1
2
3
4
5
6
7
8
cv2.pyrMeanShiftFiltering(
src, # uint8 三通道图像
sp, # 空间窗口半径。
sr[, # 颜色窗口半径。
dst[, # 与源图像格式和大小相同的目标图像。
maxLevel[, # 用于分割的金字塔的最大级别。
termcrit]]] ) -> # 终止标准:何时停止 meanshift 迭代。
dst

在这个过程中,关键参数是spsr的设置,二者设置的值越大,对图像色彩的平滑效果越明显,同时函数耗时也越多。

  • 示例代码
1
2
3
4
5
6
7
8
img = mt.cv_rgb_imread('img1.jpg')
img = mt.image_resize(img, factor=0.3)
res_20_20 = cv2.pyrMeanShiftFiltering(img, sp=20, sr=20, maxLevel=3)
res_20_50 = cv2.pyrMeanShiftFiltering(img, sp=20, sr=50, maxLevel=3)
res_50_20 = cv2.pyrMeanShiftFiltering(img, sp=50, sr=20, maxLevel=3)
res_50_50 = cv2.pyrMeanShiftFiltering(img, sp=50, sr=50, maxLevel=3)
res_80_80 = cv2.pyrMeanShiftFiltering(img, sp=80, sr=80, maxLevel=3)
PIS([img, 'raw img'], [res_20_20, 'sp: 20 sr:20'], [res_20_50, 'sp: 20 sr:50'], [res_50_20, 'sp: 50 sr:20'], [res_50_50, 'sp: 50 sr:50'], [res_80_80, 'sp: 80 sr:80'])

参考资料


OpenCV 图像分析之 —— 分割
https://www.zywvvd.com/notes/study/image-processing/opencv/opencv-segmentation/opencv-segmentation/
作者
Yiwei Zhang
发布于
2022年4月2日
许可协议