OpenCV Recipes:形状检测与图像分割

In this post, we are going to learn about shape analysis and image segmentation.

轮廓分析与形状匹配

轮廓分析是计算机视觉领域中非常有用的工具。在现实世界中,我们需要处理许多形状,而轮廓分析有助于我们使用各种算法分析这些形状。当我们将图像转换为灰度并对其进行阈值处理时,我们会留下一串线条和轮廓。一旦我们了解了不同形状的特性,我们将能够从图像中提取详细信息。

假设我们想识别下图中的回旋镖形状:

为了做到这一点,我们首先需要知道普通回旋镖是什么样子的:

现在,使用上面的图像作为参考,我们能确定原始图像中的哪种形状对应于回旋镖吗?你可能注意到了,我们不能使用简单的基于相关性的方法,因为形状都是扭曲的。这意味着我们精确匹配的方法很难奏效!我们需要了解形状的特征,并匹配相应的特征来识别回旋镖的形状。OpenCV 提供了几个形状匹配工具,我们可以用它们来实现这一点。如果您想了解更多信息,请访问此文档了解更多信息。

匹配是基于 Hu 矩的概念,而 Hu 矩又与图像矩相关。你可以参考此文了解更多关于矩的信息。图像矩的概念是指形状内的像素加权与幂次之和。

在上述等式中,p 表示轮廓内的像素,w 表示权重,N 表示轮廓内的点数,k 表示幂,I 表示矩。根据 w 和 k 所选择的值,我们可以提取不同的轮廓特征。

也许最简单的例子是计算轮廓的面积。为此,我们需要计算该区域内的像素数量,只需将 w 设为 1,k 设置为 0。矩的不同计算方式能帮助我们理解不同的形状。

如果我们匹配这些形状,你会看到:

让我们来看一下代码:

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
import cv2
import numpy as np
import sys

# Extract all the contours from the image
def get_all_contours(img):
ref_gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
ret, thresh = cv2.threshold(ref_gray, 127, 255, 0)

# Find all the contours in the thresholded image. The values
# for the second and third parameters are restricted to a
# certain number of possible values.
im2, contours, hierarchy = cv2.findContours(thresh.copy(), cv2.RETR_LIST,
cv2.CHAIN_APPROX_SIMPLE)

return contours

# Extract reference contour from the image
def get_ref_contour(img):
contours = get_all_contours(img)

# Extract the relevant contour based on area ratio. We use the
# area ratio because the main image boundary contour is
# extracted as well and we don't want that. This area ratio
# threshold will ensure that we only take the contour inside the image.
for contour in contours:
area = cv2.contourArea(contour)
img_area = img.shape[0] * img.shape[1]
if 0.05 < area/float(img_area) < 0.8:
return contour


if __name__=='__main__':
# Boomerang reference image
img1 = cv2.imread(sys.argv[1])

# Input image containing all the different shapes
img2 = cv2.imread(sys.argv[2])

# Extract the reference contour
ref_contour = get_ref_contour(img1)

# Extract all the contours from the input image
input_contours = get_all_contours(img2)

closest_contour = None
min_dist = None
contour_img = img2.copy()
cv2.drawContours(contour_img, input_contours, -1, color=(0,0,0),
thickness=3)

cv2.imshow('Contours', contour_img)
# Finding the closest contour
for contour in input_contours:
# Matching the shapes and taking the closest one using
# Comparison method CV_CONTOURS_MATCH_I3 (second argument)
ret = cv2.matchShapes(ref_contour, contour, 3, 0.0)
# print("Contour %d matchs in %f" % (i, ret))
if min_dist is None or ret < min_dist:
min_dist = ret
closest_contour = contour

cv2.drawContours(img2, [closest_contour], 0 , color=(0,0,0),
thickness=3)
cv2.imshow('Best Matching', img2)
cv2.waitKey()

matchShapes方法的使用可能不同于 Hu 不变量(CV_CONTOUR_MATCH_I1,2,3 ),其中由于轮廓的大小、方向或旋转,每个方法可能会产生不同的最佳匹配形状。

逼近轮廓

在现实生活中,我们遇到的许多轮廓都是有噪声的。这意味着轮廓看起来不光滑,因此我们的分析受到阻力。那么,我们如何处理这个问题呢?解决这个问题的一个方法是获取轮廓上的所有点,然后用光滑的多边形近来似该轮廓。

让我们再考虑回旋镖图像。如果使用各种阈值近似轮廓,你将看到轮廓改变了它们的形状。从系数 0.05 开始:

如果减少这个系数,轮廓会变得更平滑。让我们把它定为 0.01 :

如果你把它做得很小,比如说 0.00001,那么它看起来会像原始图像:

代码如下所示:

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
32
33
34
35
36
37
import sys
import cv2
import numpy as np

# Extract all the contours from the image
def get_all_contours(img):
ref_gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
ret, thresh = cv2.threshold(ref_gray, 127, 255, 0)

# Find all the contours in the thresholded image. The values
# for the second and third parameters are restricted to a
# certain number of possible values.
im2, contours, hierarchy = cv2.findContours(thresh.copy(), cv2.RETR_LIST,
cv2.CHAIN_APPROX_SIMPLE)

return contours

if __name__=='__main__':
# Input image containing all the different shapes
img1 = cv2.imread(sys.argv[1])
# Extract all the contours from the input image
input_contours = get_all_contours(img1)

contour_img = img1.copy()
smoothen_contours = []
factor = 0.00001

# Finding the closest contour
for contour in input_contours:
epsilon = factor * cv2.arcLength(contour, True)
smoothen_contours.append(cv2.approxPolyDP(contour, epsilon, True))

cv2.drawContours(contour_img, smoothen_contours, -1, color=(0,0,0),
thickness=3)

cv2.imshow('Contours', contour_img)
cv2.waitKey()

识别切开的比萨饼

这个标题可能有点误导,因为我们不会谈论比萨饼切片。但是让我们假设你处于这样一种情况:你有一个包含不同形状、不同类型的比萨饼图像。现在,有人从其中一个比萨饼上切了一片。我们如何自动识别这一点?

我们不能采取之前的方法,因为不知道最后的形状是什么样子,所以没有模板可用,我们不能基于任何先验信息构建模板。我们所知道只有从一个比萨饼上切了一片。让我们考虑以下图像:

我们需要利用这些形状的一些特性来识别切片比萨。你可能注意到,可以在这些形状内取任意两点,并在两点间连一条线,这条线将永远位于该形状内。这种形状叫做凸形。

如果你看切开的比萨饼形,你会发现任选两点,并不能保证连线在形状内部,如下图所示:

所以,我们只需要检测图像中的非凸形,就可以完成。

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
import sys
import cv2
import numpy as np

# Extract all the contours from the image
def get_all_contours(img):
ref_gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
ret, thresh = cv2.threshold(ref_gray, 127, 255, 0)

# Find all the contours in the thresholded image. The values
# for the second and third parameters are restricted to a
# certain number of possible values.
im2, contours, hierarchy = cv2.findContours(thresh.copy(), cv2.RETR_LIST,
cv2.CHAIN_APPROX_SIMPLE)

return contours


if __name__=='__main__':
img = cv2.imread(sys.argv[1])

# Iterate over the extracted contours
# Using previous get_all_contours() method
for contour in get_all_contours(img):
# Extract convex hull from the contour
hull = cv2.convexHull(contour, returnPoints=False)

# Extract convexity defects from the above hull
# Being a convexity defect the cavities in the hull segments
defects = cv2.convexityDefects(contour, hull)

if defects is None:
continue

# Draw lines and circles to show the defects
for i in range(defects.shape[0]):
start_defect, end_defect, far_defect, _ = defects[i,0]
start = tuple(contour[start_defect][0])
end = tuple(contour[end_defect][0])
far = tuple(contour[far_defect][0])
cv2.circle(img, far, 5, [128,0,0], -1)
cv2.drawContours(img, [contour], -1, (0,0,0), 3)

cv2.imshow('Convexity defects',img)
cv2.waitKey(0)
cv2.destroyAllWindows()

要了解 convexityDefects 如何工作的更多信息,请访问此页面。如果运行前面的代码,您将会看到如下内容:

可以发现如果仅仅运行凸性检测器,将不起作用。

这时候就需要用到轮廓逼近:

1
2
3
factor = 0.01
epsilon = factor * cv2.arcLength(contour, True)
contour = cv2.approxPolyDP(contour, epsilon, True)

如果使用平滑轮廓运行前面的代码,输出将如下所示:

如何审查形状?

假设你想遮挡一个特定的形状,你会如何实现?你可能会说,你将使用形状匹配来识别形状,然后把它屏蔽掉。但是问题是我们没有任何可用的模板。那么,我们该怎么做呢?形状分析有多种形式,我们需要根据情况构建算法。让我们考虑下图:

假设我们想识别所有回旋镖形状,在不使用任何模板的情况下将其屏蔽掉。正如你所看到的,在这张图片中还有各种各样的奇怪形状,回旋镖的形状并不十分光滑。我们需要确定能够将回旋镖形状与其他形状区分开来的属性,这里可以考虑凸包。如果取形状的面积与凸包的面积之比,就可以得到一个有区分度的度量。这种度量在形状分析中被称为 solidity factor。这种度量对于回旋镖形状来说会有一个较低的值,因为会遗漏空白区域,如下图所示:

其中黑边代表凸包。一旦我们计算出所有形状的这些值,我们如何将它们分开?我们能不能用一个固定的阈值来检测回旋镖形状?答案是不能,因为你永远不知道以后会遇到什么样的形状。因此,更好的方法是使用 K 均值聚类(K-means clustering)。K-means 是一种无监督学习技术,可以将输入数据分成 K 类。

我们想要将形状分成两组,即回旋镖形状和其他形状。如果检测到形状并将其遮挡,那么结果将如下所示:

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
import sys
import cv2
import numpy as np

# Extract all the contours from the image
def get_all_contours(img):
ref_gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
ret, thresh = cv2.threshold(ref_gray, 127, 255, 0)

# Find all the contours in the thresholded image. The values
# for the second and third parameters are restricted to a
# certain number of possible values.
im2, contours, hierarchy = cv2.findContours(thresh.copy(), cv2.RETR_LIST,
cv2.CHAIN_APPROX_SIMPLE)

return contours


if __name__=='__main__':
# Input image containing all the shapes
img = cv2.imread(sys.argv[1])

img_orig = np.copy(img)
input_contours = get_all_contours(img)
solidity_values = []

# Compute solidity factors of all the contours
for contour in input_contours:
area_contour = cv2.contourArea(contour)
convex_hull = cv2.convexHull(contour)
area_hull = cv2.contourArea(convex_hull)
solidity = float(area_contour)/area_hull
solidity_values.append(solidity)

# Clustering using KMeans
criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 10, 1.0)
flags = cv2.KMEANS_RANDOM_CENTERS
solidity_values = np.array(solidity_values).reshape(
(len(solidity_values),1)).astype('float32')
compactness, labels, centers = cv2.kmeans(solidity_values, 2, None,
criteria, 10, flags)

closest_class = np.argmin(centers)
output_contours = []
for i in solidity_values[labels==closest_class]:
index = np.where(solidity_values==i)[0][0]
output_contours.append(input_contours[index])

cv2.drawContours(img, output_contours, -1, (0,0,0), 3)
cv2.imshow('Output', img)

# Censoring
for contour in output_contours:
rect = cv2.minAreaRect(contour)
box = cv2.boxPoints(rect)
box = np.int0(box)
cv2.drawContours(img_orig, [box], 0, (0,0,0), -1)

cv2.imshow('Censored', img_orig)
cv2.waitKey()

什么是图像分割?

图像分割是将图像分成其组成部分的过程。这是现实世界中许多计算机视觉应用的重要一步。分割图像有许多不同的方法。当我们分割一幅图像时,我们会根据各种度量来分离,如颜色、纹理、位置等。让我们来看看这里流行的一些方法。

首先,我们将研究一种叫做 GrabCut 的技术。这是一种基于 graph-cuts 的图像分割方法。在 graph-cuts 方法中,我们将整个图像视为一个图,然后根据该图边缘的强度来分割该图。我们通过将每个像素视为一个节点来构建图,并且在节点之间构建边,其中边权重是这两个节点的像素值的函数。然后通过最小化该图的吉布斯(Gibbs)能量来分割该图。这类似于寻找最大熵分割。

让我们考虑以下图像:

选出兴趣区域:

得到的分割后的图像:

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
import sys
import cv2
import numpy as np

# Draw rectangle based on the input selection
def draw_rectangle(event, x, y, flags, params):
global x_init, y_init, drawing, top_left_pt, bottom_right_pt, img_orig

# Detecting mouse button down event
if event == cv2.EVENT_LBUTTONDOWN:
drawing = True
x_init, y_init = x, y

# Detecting mouse movement
elif event == cv2.EVENT_MOUSEMOVE:
if drawing:
top_left_pt, bottom_right_pt = (x_init,y_init), (x,y)
img[y_init:y, x_init:x] = 255 - img_orig[y_init:y, x_init:x]
cv2.rectangle(img, top_left_pt, bottom_right_pt, (0,255,0), 2)

# Detecting mouse button up event
elif event == cv2.EVENT_LBUTTONUP:
drawing = False
top_left_pt, bottom_right_pt = (x_init,y_init), (x,y)
img[y_init:y, x_init:x] = 255 - img[y_init:y, x_init:x]
cv2.rectangle(img, top_left_pt, bottom_right_pt, (0,255,0), 2)
rect_final = (x_init, y_init, x-x_init, y-y_init)

# Run Grabcut on the region of interest
run_grabcut(img_orig, rect_final)

# Grabcut algorithm
def run_grabcut(img_orig, rect_final):
# Initialize the mask
mask = np.zeros(img_orig.shape[:2],np.uint8)

# Extract the rectangle and set the region of
# interest in the above mask
x,y,w,h = rect_final
mask[y:y+h, x:x+w] = 1

# Initialize background and foreground models
bgdModel = np.zeros((1,65), np.float64)
fgdModel = np.zeros((1,65), np.float64)

# Run Grabcut algorithm
cv2.grabCut(img_orig, mask, rect_final, bgdModel, fgdModel, 5,
cv2.GC_INIT_WITH_RECT)

# Extract new mask
mask2 = np.where((mask==2)|(mask==0),0,1).astype('uint8')

# Apply the above mask to the image
img_orig = img_orig*mask2[:,:,np.newaxis]

# Display the image
cv2.imshow('Output', img_orig)


if __name__=='__main__':
drawing = False
top_left_pt, bottom_right_pt = (-1,-1), (-1,-1)

# Read the input image
img_orig = cv2.imread(sys.argv[1])
img = img_orig.copy()

cv2.namedWindow('Input')
cv2.setMouseCallback('Input', draw_rectangle)

while True:
cv2.imshow('Input', img)
c = cv2.waitKey(1)
if c == 27:
break

cv2.destroyAllWindows()

背后细节

该算法估计物体和背景的颜色分布,将图像的颜色分布表示为高斯混合马尔可夫随机场(Gaussian Mixture Markov Random Field,GMMRF)。可以参考论文来了解更多关于 GMMRF 的信息。我们需要物体和背景的颜色分布,因为我们将利用这些知识来分离物体。该信息用于通过将最小割(min-cut)算法应用于马尔可夫随机场来找到最大熵分割。一旦我们有了这个,我们就使用 graph-cuts 优化方法来推断标签。

分水岭算法

OpenCV 带有分水岭算法的默认实现,该理论认为任何灰度图像都可以被视为地形表面,其中高强度代表山峰和丘陵,低强度代表山谷。这个算法非常有名,有很多实现。

GreatX wechat
关注我的公众号,推送优质文章。