使用 OpenCV 测量物体尺寸
你是否曾经遇到过这样的问题:想要知道计算器的精确尺寸,但手头又没有专业的测量工具?别担心,今天我们就来教大家一个简单又实用的方法,通过一张A4纸就能估算出计算器的宽度和高度,精确到毫米哦!
该算法的主要思想其实非常简单。请看下面图 1 中我们要处理的样本图像。
图 1.本教程中使用的示例图像。
本教程的目的是估算计算器的宽度和高度(以毫米为单位)。为此,我们需要一个已知尺寸的物体作为参考长度。这基本上就是我们使用白纸作为对象背景的原因。纸张尺寸为 A4,这意味着它的宽度和高度分别为 210 毫米和 297 毫米。然后,我们可以借助这些数字获得估计的计算器尺寸。
在开始之前,我们这篇文章分为几个章节:
-
导入模块和参数初始化
-
图像加载和预处理
-
寻找纸张轮廓
-
透视变换
-
寻找物体轮廓
-
边长计算
-
将所有内容放在一个函数中
现在,我们从第一个开始。
1. 导入模块及参数初始化
和其他 Python 项目一样,我要做的第一件事就是导入所有必需的模块。在本例中,我们的大部分工作将使用 和 来完成cv2
,numpy
而matplotlib
仅用于显示图像。
# Codeblock 1``import cv2``import numpy as np``import matplotlib.pyplot as plt
由于模块已导入,我们将初始化一些参数,这些参数是未来计算所需的,您可以在下面的 Codeblock 2 中看到。变量SCALE
主要用于我们不希望生成的图像太小。同时,和PAPER_W
表示PAPER_H
纸张宽度和高度(以毫米为单位)。
# Codeblock 2``SCALE = 3``PAPER_W = 210 * SCALE``PAPER_H = 297 * SCALE
就这样。第一章到此结束,因为这部分没有什么可说的了。
2.图像加载和预处理
之后,我们将创建一个名为和的函数load_image()
,show_image()
我认为这两个函数的名称是不言自明的。它们的详细信息可以在 Codeblock 3 中看到。您可以在下面看到我定义了参数scale
(小写),其中我的目的是使输入图像变得有点小,因为原始图像分辨率非常高。但是,从技术上讲,您也可以通过传递大于 1 的值来使其更大。
show_image()
另一方面,该函数实现cv2.cvtColor()
了将颜色通道从 BGR 转换为 RGB 的功能。这种转换是必要的,因为 Matplotlib 在颜色通道顺序方面与 OpenCV 的工作方式不同。
# Codeblock 3``def load_image(path, scale=0.7):
img = cv2.imread(path)
img_resized = cv2.resize(img, (0,0), None, scale, scale)``return img_resized`` ``def show_image(img):
img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
plt.figure(figsize=(6,8))
plt.xticks([])
plt.yticks([])
plt.imshow(img)
plt.show()
由于上面的两个函数已经初始化,现在我们可以使用它来实际加载图像。这里我决定将参数保留scale
为其默认值(0.7),这将导致加载的图像大小为 1120×840 px(原始大小为 1600×1200 px)。
# Codeblock 4``img_original = load_image(path='images/1.jpeg')``show_image(img_original)``print(img_original.shape)
图 2. 要处理的图像尺寸为 1120 x 840 像素。
我上面显示的图像存储在 中img_original
。接下来要做的步骤是使用一系列图像处理技术对该图像进行预处理,即灰度转换(#1
)、模糊(#2
)、Canny 边缘检测(#3
)、扩张(#5
)和闭合(#6
)。所有这些步骤都包含在preprocess_image()
Codeblock 5 中的函数中。
# Codeblock 5``def preprocess_image(img, thresh_1=57, thresh_2=232):
img_gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) #1
img_blur = cv2.GaussianBlur(img_gray, (5,5), 1) #2
img_canny = cv2.Canny(img_blur, thresh_1, thresh_2) #3``
kernel = np.ones((3,3)) #4
img_dilated = cv2.dilate(img_canny, kernel, iterations=1) #5
img_closed = cv2.morphologyEx(img_dilated, cv2.MORPH_CLOSE,`` kernel, iterations=4) #6``
img_preprocessed = img_closed.copy()``
img_each_step = {'img_dilated': img_dilated,
'img_canny' : img_canny,
'img_blur' : img_blur,
'img_gray' : img_gray}`` ``return img_preprocessed, img_each_step
在标记为 的行中,和#2
的参数分别表示高斯滤波器大小和高斯分布标准差。同时,行中的和是 Canny 边缘检测器用来捕获弱边缘和强边缘的两个阈值。在本例中,我决定将 设置为 57 和232,这些数字是通过反复试验确定的。接下来,我们初始化一个 3×3 内核,其中内核的所有元素都设置为 1(标记为 的行)。然后,这个全 1 内核将在扩张和闭合过程中用作结构元素。(5,5)``1``thresh_1``thresh_2``#3``thresh_1``thresh_2``#4
该preprocess_image()
函数返回两个值:完全预处理的图像(存储在 中img_preprocessed
)和每个预处理阶段的结果(img_each_step
以字典形式存储在 中)。该函数的输出如图 3 所示。要进入下一个过程的图像是img_preprocessed
(最右边)。
# Codeblock 6img_preprocessed, img_each_step = preprocess_image(img_original)show_image(img_each_step['img_gray'])show_image(img_each_step['img_blur'])show_image(img_each_step['img_canny'])show_image(img_each_step['img_dilated'])show_image(img_preprocessed)
图 3. 从左到右的图像转换序列:灰度、模糊、边缘检测、扩张和闭合。
3. 寻找纸张轮廓
获取预处理后的图像后,接下来要做的是使用 Codeblock 7 中显示的代码查找轮廓。您可以在那里看到我们使用cv2.findContours()
( ) 来执行此操作#1
。我们还将其cv2.RETR_EXTERNAL
作为参数的输入参数传递,因为我们只对捕获图像中检测到的最外层轮廓(即纸张)感兴趣。现在,计算器对象将被忽略。然后,将使用( )mode
绘制检测到的轮廓本身。cv2.drawContours()``#2
# Codeblock 7def find_contours(img_preprocessed, img_original, epsilon_param=0.04):contours, hierarchy = cv2.findContours(image=img_preprocessed, mode=cv2.RETR_EXTERNAL, method=cv2.CHAIN_APPROX_NONE) #1img_contour = img_original.copy()cv2.drawContours(img_contour, contours, -1, (203,192,255), 6) #2polygons = []for contour in contours:epsilon = epsilon_param * cv2.arcLength(curve=contour, closed=True) #3polygon = cv2.approxPolyDP(curve=contour, epsilon=epsilon, closed=True) #4polygon = polygon.reshape(4, 2) #5polygons.append(polygon)for point in polygon: img_contour = cv2.circle(img=img_contour, center=point, radius=8, color=(0,240,0), thickness=-1) #6return polygons, img_contour不仅如此,这里我还尝试使用cv2.approxPolyDP()(#4)来近似轮廓形状。这行代码的目的是获取纸张轮廓的四个角,然后将坐标存储在其中polygon。我们需要注意的一件事是epsilon行中使用的参数#3。较小的 epsilon 值往往会导致检测到更多的角,而另一方面,较大的 epsilon 会导致函数cv2.approxPolyDP()捕获多边形的一般形状,即较少的角。注意 epsilon 值很重要,因为我们需要确保函数将准确捕获四个角点。
需要执行数组重塑 ( #5
),因为 的原始输出cv2.approxPolyDP()
格式为(number of corners, 1, 2)
,其中中间轴在我们的例子中无关紧要。因此,我们可以放心地将其丢弃。除了函数之外find_contours()
,我们还将使用cv2.circle()
( #6
) 显示角。然后,此函数将返回角坐标本身 ( polygon
) 和具有突出显示轮廓的图像 ( img_contour
)。
下面的代码块 8 显示了我如何调用该find_contours()
函数。在这里我决定将设置epsilon_param
为 0.04。如果您使用自己的图片,则可能需要更改此值,特别是如果光照特性与我的图像不同。下面代码块的输出(显示在图 4 中)显示了轮廓和角落的样子。我也打印了存储在中的坐标polygon[0]
。如果您想知道,使用索引器 0 是因为该find_contours()
函数本质上能够捕获多个轮廓,而在这种情况下,我们唯一的外部轮廓是纸张本身。除此之外,可能值得注意的是,存储在中的坐标polygons
是(x,y)
格式,而不是(y,x)
。
# Codeblock 8polygons, img_contours = find_contours(img_preprocessed, img_original,epsilon_param=0.04)show_image(img_contours)polygons[0]
图 4. 轮廓(粉色)和角落(绿色)的样子。
4.透视变换
由于已经检测到纸张角坐标,现在我们要根据这四个点来扭曲图像。这个过程的目的是在我们开始寻找物体轮廓之前将纸张拉直。
重新排序坐标
说到拉直过程,我们需要知道的一件事是,由于函数的性质,检测到的多边形角可能没有排序cv2.approxPolyDP()
。为了使这四个点都有序,我们需要创建一个专门的函数来执行此操作。我正在谈论的函数称为,reorder_coords()
其详细信息可在 Codeblock 9 中看到。
# Codeblock 9``def reorder_coords(polygon):
rect_coords = np.zeros((4, 2))`` ``add = polygon.sum(axis=1)
rect_coords[0] = polygon[np.argmin(add)] # Top left
rect_coords[3] = polygon[np.argmax(add)] # Bottom right``
subtract = np.diff(polygon, axis=1)
rect_coords[1] = polygon[np.argmin(subtract)] # Top right
rect_coords[2] = polygon[np.argmax(subtract)] # Bottom left`` ``return rect_coords
上述函数的工作原理是将两个坐标数字相加和相减,得到 argmin 和 argmax。这些代码行神奇地将左上角、右上角、左下角和右下角分别放在索引 0、1、2 和 3 处。然后,我们可以将此函数应用于存储在中的点,polygon[0]
如 Codeblock 10 所示。
# Codeblock 10``rect_coords = np.float32(reorder_coords(polygons[0]))``rect_coords
图 5. 我们使用reorder_coords()函数排序的纸张的四个角点。
稍后,上方重新排序的纸张角坐标(rect_coords
)将作为变换的源,而变换目标(paper_coords
)由实际纸张大小决定,即PAPER_W
和PAPER_H
。查看 Codeblock 11 以了解我如何手动排列 存储的点。这里要记住的一件事是,目标坐标的顺序需要与源坐标的顺序完全匹配,这就是我们实现重新排序源坐标的功能paper_coords
的原因。reorder_coords()
# Codeblock 11``paper_coords = np.float32([[0,0], # Top left
[PAPER_W,0], # Top right
[0,PAPER_H], # Bottom left
[PAPER_W,PAPER_H]]) # Bottom right``paper_coords
图 6.用于图像转换的目标坐标。
除了代码块 10 和 11 之外,您可能还注意到我将数组数据类型转换为 float32。事实上,我尝试使用其他数据类型,例如 int32 和 float64,结果发现这两种数据类型都导致cv2.getPerspectiveTransform()
后续代码块中的函数返回错误。
创建变换矩阵
此时,我们已经得到了rect_coords
和paper_coords
。我们现在可以使用它们来扭曲原始图像,使用cv2.getPerspectiveTransform()
和,cv2.warpPerspective()
如下面的代码块 12 所示。为了让代码更简洁,我将它们放在另一个名为的函数中warp_image()
。
# Codeblock 12``def warp_image(rect_coords, paper_coords, img_original, pad=5):`` ``matrix = cv2.getPerspectiveTransform(src=rect_coords,
dst=paper_coords) #1``img_warped = cv2.warpPerspective(img_original, matrix,``(PAPER_W, PAPER_H)) #2`` ``warped_h = img_warped.shape[0]``warped_w = img_warped.shape[1]``img_warped = img_warped[pad:warped_h-pad, pad:warped_w-pad] #3`` ``return img_warped
让我们深入研究上述函数。我们首先使用cv2.getPerspectiveTransform()
( #1
) 创建所谓的变换矩阵。变换矩阵本身的尺寸为 3 × 3,它存储了有关如何将图像从一个角度变换到另一个角度的信息。然后,该矩阵将实际用于使用cv2.warpPerspective()
( #2
) 变换原始图像。然后,我们将变换后的图像存储在名为 的变量中img_warped
。除了函数之外warp_image()
,我们还将丢弃图像边界附近的区域 ( #3
),因为生成的图像可能在边缘处包含一个狭窄的不需要的黑色区域。
函数的使用warp_image()
在 Codeblock 13 中演示,其中输出显示在图 7 中。
# Codeblock 13``img_warped = warp_image(rect_coords, paper_coords, img_original)``show_image(img_warped)``print(img_warped.shape)
图 7.扭曲的图像。
5. 查找物体轮廓
由于原始图像已根据纸张形状进行了扭曲,接下来我将执行完全相同的过程以检测计算器对象,即图像预处理和轮廓搜索。由于要执行的过程与之前的过程相同,因此我们可以简单地重用我们之前定义的函数。在这种情况下,我们的内部轮廓(即计算器按钮)将被忽略,这要归功于cv2.RETR_EXTERNAL
我们在 Codeblock 7 中实现的轮廓。
现在,Codeblock 14 和 15 展示了如何进行预处理和轮廓查找。
# Codeblock 14``img_warped_preprocessed, _ = preprocess_image(img_warped)``show_image(img_warped_preprocessed)
图 8.已预处理的扭曲图像。
# Codeblock 15``polygons_warped, img_contours_warped = find_contours(img_warped_preprocessed,
img_warped,``epsilon_param=0.04)``show_image(img_contours_warped)``polygons_warped[0]
图 9. 检测到的轮廓(粉色)和角落(绿色)。
由于上面两个Codeblocks中的代码都已经运行完毕,现在所有角点坐标(绿色圆圈)都存储在 中polygons_warped
,这四个坐标值其实就是用来估算边长的坐标值。
6. 边长计算
在这种情况下,我们假设纸张上方的所有物体都是矩形。因此,可以通过计算物体左上角和左下角之间的欧几里得距离来估计物体的高度(Codeblock 16 #2
)。同时,可以通过计算物体左上角和右上角之间的相同距离度量来获得宽度(#3
)。此外,我们需要记住,返回的检测到的角find_contours()
仍然是无序的。因此,我们需要reorder_coords()
事先调用( )。我想在 Codeblock 16 中强调的最后一件事是,估计的高度和宽度现在存储在按相应顺序#1
命名的数组中( )sizes``#4
# Codeblock 16``def calculate_sizes(polygons_warped):`` ``rect_coords_list = []``for polygon in polygons_warped:``rect_coords = np.float32(reorder_coords(polygon)) #1``rect_coords_list.append(rect_coords)`` ``heights = []``widths = []``for rect_coords in rect_coords_list:``height = cv2.norm(rect_coords[0], rect_coords[2], cv2.NORM_L2) #2``width = cv2.norm(rect_coords[0], rect_coords[1], cv2.NORM_L2) #3`` ``heights.append(height)``widths.append(width)`` ``heights = np.array(heights).reshape(-1,1)``widths = np.array(widths).reshape(-1,1)`` ``sizes = np.hstack((heights, widths)) #4`` ``return sizes, rect_coords_list`` ``sizes, rect_coords_list = calculate_sizes(polygons_warped)``sizes``
图 10. 计算器的高度(索引 0)和宽度(索引 1)(以像素为单位)。
转换为毫米
但是,我们需要记住,图 10 中的输出仍然是像素数。要将这些值转换为毫米,我们需要为此创建一个单独的函数,我将其命名为convert_to_mm()
(Codeblock 17)。此函数通过取纸张长度(以毫米为单位)和像素(以像素为单位)的比率来工作。然后,我们可以通过将比率值(和)与仍以像素为单位的计算器大小(和#1
)#2
相乘来获得实际的毫米长度。scale_h``scale_w``#3``#4
# Codeblock 17``def convert_to_mm(sizes_pixel, img_warped):``warped_h = img_warped.shape[0]``warped_w = img_warped.shape[1]`` ``scale_h = PAPER_H / warped_h #1``scale_w = PAPER_W / warped_w #2`` ``sizes_mm = []`` ``for size_pixel_h, size_pixel_w in sizes_pixel:``size_mm_h = size_pixel_h * scale_h / SCALE #3``size_mm_w = size_pixel_w * scale_w / SCALE #4`` ``sizes_mm.append([size_mm_h, size_mm_w])`` ``return np.array(sizes_mm)`` ``sizes_mm = convert_to_mm(sizes, img_warped)``sizes_mm
图 11. 已转换为毫米的计算器高度和宽度。
到目前为止,我们已经成功获得了边缘的长度(以毫米为单位)。现在,我们将以文本的形式显示这些值,这些文本写在扭曲的图像上。为此完成的整个过程都放在函数内部write_size()
。初始化函数后,我们可以直接调用它,如#1
代码块 18 中所示。此代码的输出如图 12 所示。
看起来我们的代码如预期那样工作了。
# Codeblock 18``def write_size(rect_coords_list, sizes, img_warped):``
img_result = img_warped.copy()`` ``for rect_coord, size in zip(rect_coords_list, sizes):``
top_left = rect_coord[0].astype(int)
top_right = rect_coord[1].astype(int)
bottom_left = rect_coord[2].astype(int)``
cv2.line(img_result, top_left, top_right, (255,100,50), 4)
cv2.line(img_result, top_left, bottom_left, (100,50,255), 4)``
cv2.putText(img_result, f'{np.int32(size[0])} mm',`` (bottom_left[0]-20, bottom_left[1]+50), `` cv2.FONT_HERSHEY_DUPLEX, 1, (100,50,255), 1)``
cv2.putText(img_result, f'{np.int32(size[1])} mm',`` (top_right[0]+20, top_right[1]+20), `` cv2.FONT_HERSHEY_DUPLEX, 1, (255,100,50), 1)`` ``return img_result`` ``img_result = write_size(rect_coords_list, sizes_mm, img_warped) #1``show_image(img_result)``print(img_result.shape)
图 12. 在图像上添加文字。
7. 将所有内容放在一个函数中
由于我试图解释此项目中代码使用的每个部分,因此我们上述执行的所有步骤似乎很长。事实上,到目前为止您看到的所有代码都可以包装在一个函数中,我measure_size()
在 Codeblock 19 中将其命名为该函数。使用此函数,我们可以简单地将要处理的图像与参数一起传递,以完成我们刚刚完成的所有工作。
# Codeblock 19``def measure_size(path, img_original_scale=0.7,``PAPER_W=210, PAPER_H=297, SCALE=3,
paper_eps_param=0.04, objects_eps_param=0.05,
canny_thresh_1=57, canny_thresh_2=232):`` ``PAPER_W = PAPER_W * SCALE``PAPER_H = PAPER_H * SCALE``
# Loading and preprocessing original image.``img_original = load_image(path=path, scale=img_original_scale)``img_preprocessed, img_each_step = preprocess_image(img_original,
thresh_1=canny_thresh_1,
thresh_2=canny_thresh_2)``
# Finding paper contours and corners.``polygons, img_contours = find_contours(img_preprocessed,
img_original,
epsilon_param=paper_eps_param)``
# Reordering paper corners.``rect_coords = np.float32(reorder_coords(polygons[0]))``
# Warping image according to paper contours.``paper_coords = np.float32([[0,0],
[PAPER_W,0],
[0,PAPER_H],``[PAPER_W,PAPER_H]])``img_warped = warp_image(rect_coords, paper_coords, img_original)``
# Preprocessing the warped image.``img_warped_preprocessed, _ = preprocess_image(img_warped)``
# Finding contour in the warped image.``polygons_warped, img_contours_warped = find_contours(img_warped_preprocessed,
img_warped,``epsilon_param=objects_eps_param)``
# Edge langth calculation.``sizes, rect_coords_list = calculate_sizes(polygons_warped)``sizes_mm = convert_to_mm(sizes, img_warped)``img_result = write_size(rect_coords_list, sizes_mm, img_warped)`` ``return img_result
一旦measure_size()
创建了函数,我们现在就可以在几个测试用例上测试它。图 13 所示的输出表明,我们估算物体大小的方法对于不同的矩形物体非常有效。
# Codeblock 20``show_image(measure_size('images/1.jpeg'))``show_image(measure_size('images/2.jpeg'))``show_image(measure_size('images/3.jpeg'))``show_image(measure_size('images/4.jpeg'))``show_image(measure_size('images/5.jpeg'))``show_image(measure_size('images/6.jpeg', objects_eps_param=0.1))
图 13.其他图像上的结果。
一些局限
尽管在许多情况下我们的方法都运行正常,但这并不一定意味着我们的工作是完美的,即使在受控环境中也是如此。如果你仔细观察,你可能会注意到大多数预测的角点实际上并不位于实际的角点位置,这可能会导致测量结果有点不准确。这主要是因为角点的确定高度依赖于二值图像(即闭运算后的图像)的质量。这意味着在这种情况下输入参数值的确定非常关键。
此外,如果我们查看图 13 中从右侧开始的第二张图像,您会发现对象尺寸没有打印出来。这可能是因为由于对象和纸张之间的细微颜色差异,轮廓检测不太好。此外,在我们的最后一张测试图像(最右边的一张)中,尽管对象的整体外观呈矩形,但我们的实现似乎难以准确测量具有弧形角的对象的尺寸。