您要求的解决方案过于复杂,无法通过一个函数或特定算法来解决。事实上,这个问题可以分解成更小的步骤,每个步骤都有自己的算法和解决方案。我不会为您提供免费的、完整的、复制粘贴的解决方案,而是为您提供问题的大致轮廓并发布我设计的解决方案的一部分。这些是我建议的步骤:
-
识别并提取图像中的所有箭头斑点,并一一处理。
-
尝试找到箭头的端点。那是终点和起点(或“尾”和“尖端”)
-
撤消旋转,这样您就可以始终拉直箭头,无论它们的角度如何。
-
此后,箭头将始终指向一个方向。这种规范化让我们自己轻松进行分类。
处理后,您可以将图像传递给 Knn 分类器、支持向量机甚至(如果您愿意在这方面称其为“大炮”)问题)一个 CNN(在这种情况下,您可能不需要撤消旋转 - 只要您有足够的训练样本)。您甚至不必计算特征,因为将原始图像传递给 SVM 可能就足够了。但是,对于每个箭头类,您需要不止一个训练样本。
好吧,让我们看看。首先,让我们从输入中提取每个箭头。这是使用cv2.findCountours 完成的,这部分非常简单:
# Imports:
import cv2
import math
import numpy as np
# image path
path = "D://opencvImages//"
fileName = "arrows.png"
# Reading an image in default mode:
inputImage = cv2.imread(path + fileName)
# Grayscale conversion:
grayscaleImage = cv2.cvtColor(inputImage, cv2.COLOR_BGR2GRAY)
grayscaleImage = 255 - grayscaleImage
# Find the big contours/blobs on the binary image:
contours, hierarchy = cv2.findContours(grayscaleImage, cv2.RETR_CCOMP, cv2.CHAIN_APPROX_SIMPLE)
现在,让我们查看contours 并一一处理。让我们计算箭头的(非旋转)bounding box 和该子图像的crop。现在,请注意可能会出现一些噪音。在这种情况下,我们不会处理那个 blob。我应用区域过滤器来绕过小区域的斑点。像这样:
# Process each contour 1-1:
for i, c in enumerate(contours):
# Approximate the contour to a polygon:
contoursPoly = cv2.approxPolyDP(c, 3, True)
# Convert the polygon to a bounding rectangle:
boundRect = cv2.boundingRect(contoursPoly)
# Get the bounding rect's data:
rectX = boundRect[0]
rectY = boundRect[1]
rectWidth = boundRect[2]
rectHeight = boundRect[3]
# Get the rect's area:
rectArea = rectWidth * rectHeight
minBlobArea = 100
我们设置minBlobArea 并处理该轮廓。 Crop 轮廓高于该区域阈值时的图像:
# Check if blob is above min area:
if rectArea > minBlobArea:
# Crop the roi:
croppedImg = grayscaleImage[rectY:rectY + rectHeight, rectX:rectX + rectWidth]
# Extend the borders for the skeleton:
borderSize = 5
croppedImg = cv2.copyMakeBorder(croppedImg, borderSize, borderSize, borderSize, borderSize, cv2.BORDER_CONSTANT)
# Store a deep copy of the crop for results:
grayscaleImageCopy = cv2.cvtColor(croppedImg, cv2.COLOR_GRAY2BGR)
# Compute the skeleton:
skeleton = cv2.ximgproc.thinning(croppedImg, None, 1)
这里发生了一些事情。在我crop 当前箭头的ROI 之后,我扩展了该图像的边框。我存储此图像的深层副本以供进一步处理,最后,我计算skeleton。在骨架化之前完成边界扩展,因为如果轮廓太接近图像限制,算法会产生伪影。在各个方向填充图像可以防止这些伪影。 skeleton 是我寻找箭头终点和起点的方式所必需的。更多的是后者,这是第一个裁剪和填充的箭头:
这是skeleton:
请注意,轮廓的“厚度”被归一化为 1 个像素。这很酷,因为这是我在接下来的处理步骤中所需要的:寻找起点/终点。这是通过应用convolution 和kernel 来完成的,该kernel 旨在识别二值图像上的一个像素宽的端点。详情请参阅this post。我们将准备kernel 并使用cv2.filter2d 得到卷积:
# Threshold the image so that white pixels get a value of 0 and
# black pixels a value of 10:
_, binaryImage = cv2.threshold(skeleton, 128, 10, cv2.THRESH_BINARY)
# Set the end-points kernel:
h = np.array([[1, 1, 1],
[1, 10, 1],
[1, 1, 1]])
# Convolve the image with the kernel:
imgFiltered = cv2.filter2D(binaryImage, -1, h)
# Extract only the end-points pixels, those with
# an intensity value of 110:
binaryImage = np.where(imgFiltered == 110, 255, 0)
# The above operation converted the image to 32-bit float,
# convert back to 8-bit uint
binaryImage = binaryImage.astype(np.uint8)
卷积后,所有端点的值为110。将这些像素设置为255,而将其余像素设置为黑色,则生成以下图像(经过适当的转换):
那些微小的像素对应于箭头的“尾部”和“尖端”。请注意,每个“箭头部分”不止一个点。这是因为箭头的端点不能完美地以一个像素结束。例如,在尖端的情况下,端点将比尾部更多。这是我们稍后将利用的特性。现在,注意这一点。有多个终点,但我们只需要一个起点和一个终点。我将使用K-Means 将点分组到两个集群中。
使用K-means 还可以让我确定哪些端点属于尾部,哪些属于尖端,因此我将始终知道箭头的方向。滚吧:
# Find the X, Y location of all the end-points
# pixels:
Y, X = binaryImage.nonzero()
# Check if I got points on my arrays:
if len(X) > 0 or len(Y) > 0:
# Reshape the arrays for K-means
Y = Y.reshape(-1,1)
X = X.reshape(-1,1)
Z = np.hstack((X, Y))
# K-means operates on 32-bit float data:
floatPoints = np.float32(Z)
# Set the convergence criteria and call K-means:
criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 10, 1.0)
_, label, center = cv2.kmeans(floatPoints, 2, None, criteria, 10, cv2.KMEANS_RANDOM_CENTERS)
注意数据类型。如果我打印标签和中心矩阵,我会得到这个(第一个箭头):
Center:
[[ 6. 102. ]
[104. 20.5]]
Labels:
[[1]
[1]
[0]]
center 告诉我每个集群的中心(x,y)——这就是我最初寻找的两个点。 label 告诉我原始数据属于哪个 cluster。如您所见,最初有 3 个点。其中2个点(属于箭头尖端的点)区域分配给cluster 1,而剩余的端点(箭头尾部)分配给cluster 0。在centers 矩阵中,中心按簇号排序。也就是说——第一个中心是cluster 0 的中心,而第二个集群是cluster 1 的中心。使用此信息,我可以轻松查找对大多数点进行分组的集群 - 这将是箭头的尖端,而其余的将是尾部:
# Set the cluster count, find the points belonging
# to cluster 0 and cluster 1:
cluster1Count = np.count_nonzero(label)
cluster0Count = np.shape(label)[0] - cluster1Count
# Look for the cluster of max number of points
# That cluster will be the tip of the arrow:
maxCluster = 0
if cluster1Count > cluster0Count:
maxCluster = 1
# Check out the centers of each cluster:
matRows, matCols = center.shape
# We need at least 2 points for this operation:
if matCols >= 2:
# Store the ordered end-points here:
orderedPoints = [None] * 2
# Let's identify and draw the two end-points
# of the arrow:
for b in range(matRows):
# Get cluster center:
pointX = int(center[b][0])
pointY = int(center[b][1])
# Get the "tip"
if b == maxCluster:
color = (0, 0, 255)
orderedPoints[0] = (pointX, pointY)
# Get the "tail"
else:
color = (255, 0, 0)
orderedPoints[1] = (pointX, pointY)
# Draw it:
cv2.circle(grayscaleImageCopy, (pointX, pointY), 3, color, -1)
cv2.imshow("End Points", grayscaleImageCopy)
cv2.waitKey(0)
这是结果;箭头终点的尖端始终为红色,尾部的终点始终为蓝色:
现在,我们知道了箭头的方向,让我们计算一下角度。我将从0 到360 测量这个角度。角度始终是水平线和尖端之间的角度。所以,我们手动计算角度:
# Store the tip and tail points:
p0x = orderedPoints[1][0]
p0y = orderedPoints[1][1]
p1x = orderedPoints[0][0]
p1y = orderedPoints[0][1]
# Compute the sides of the triangle:
adjacentSide = p1x - p0x
oppositeSide = p0y - p1y
# Compute the angle alpha:
alpha = math.degrees(math.atan(oppositeSide / adjacentSide))
# Adjust angle to be in [0,360]:
if adjacentSide < 0 < oppositeSide:
alpha = 180 + alpha
else:
if adjacentSide < 0 and oppositeSide < 0:
alpha = 270 + alpha
else:
if adjacentSide > 0 > oppositeSide:
alpha = 360 + alpha
现在您有了角度,并且始终在相同的参考之间测量该角度。很酷,我们可以像下面这样撤消原始图像的旋转:
# Deep copy for rotation (if needed):
rotatedImg = croppedImg.copy()
# Undo rotation while padding output image:
rotatedImg = rotateBound(rotatedImg, alpha)
cv2. imshow("rotatedImg", rotatedImg)
cv2.waitKey(0)
else:
print( "K-Means did not return enough points, skipping..." )
else:
print( "Did not find enough end points on image, skipping..." )
这会产生以下结果:
无论其原始角度如何,箭头始终指向右上角。如果您想在自己的类中对每个箭头进行分类,请将其用作一批训练图像的归一化。
现在,您注意到我使用了一个函数来旋转图像:rotateBound。这个函数是taken from here。此函数在旋转后正确填充图像,因此您最终不会得到错误裁剪的旋转图像。
这是rotateBound的定义和实现:
def rotateBound(image, angle):
# grab the dimensions of the image and then determine the
# center
(h, w) = image.shape[:2]
(cX, cY) = (w // 2, h // 2)
# grab the rotation matrix (applying the negative of the
# angle to rotate clockwise), then grab the sine and cosine
# (i.e., the rotation components of the matrix)
M = cv2.getRotationMatrix2D((cX, cY), -angle, 1.0)
cos = np.abs(M[0, 0])
sin = np.abs(M[0, 1])
# compute the new bounding dimensions of the image
nW = int((h * sin) + (w * cos))
nH = int((h * cos) + (w * sin))
# adjust the rotation matrix to take into account translation
M[0, 2] += (nW / 2) - cX
M[1, 2] += (nH / 2) - cY
# perform the actual rotation and return the image
return cv2.warpAffine(image, M, (nW, nH))
这些是其余箭头的结果。尖端(始终为红色)、尾部(始终为蓝色)及其“投影归一化” - 始终指向右侧:
剩下的就是收集样本你的不同箭头类,设置一个分类器,训练 使用您的样本并使用来自我们检查的最后一个处理块的拉直图像对其进行 测试。
一些评论:一些箭头,比如没有填充的箭头,没有通过端点识别部分,因此没有产生足够的点进行聚类。该箭头被算法绕过。问题比最初更难,对吧?我建议对该主题进行一些研究,因为无论任务看起来多么“简单”,最终,它都会由自动化的“智能”系统执行。而且这些系统在一天结束时并不是真的那么聪明。