1.问题描述
本文章实现了通过读取摄像头所拍摄的图像,实时检测图像中的网球并推算其距离、确定其方位。核心问题是如何从摄像头拍摄的画面中检测出网球,并排除干扰项。此外,为了将该方法运用在嵌入式系统上,系统的计算复杂度应当尽量减少,避免影响实时性。
2.实现方法
对于网球这样的球体单色目标,可以选择霍夫变换进行圆检测,也可以通过色彩分割将网球从视频帧中分割出来。如果背景复杂,障碍物多,也可以选择训练一个专门识别网球的模型,比如常见的YOLO,这样可以提高识别的准确率。但这种方法的缺点是难以部署。下面,我将分别详细介绍这几种方法。
2.1 霍夫变换检测网球
霍夫变换检测网球的基本思想是,网球从每个方向看去基本都可以认为是圆,而任何一个圆,都可以从二维坐标系下的表达式映射到三维空间中去。也就是说,一个圆在笛卡尔坐标系下的表达式:
可以映射为三维坐标系下的一个点(a,b,r)。那么,将一张图像映射于该三维坐标系后,该坐标系下的一个峰值可能就意味着一个圆。该方法的具体数学原理不再进行赘述,我们的首要目标是了解它的用法。如果想要了解更多有关霍夫变换的原理问题,可以搜索其他文章。
2.1.1 HoughCircles参数与返回值介绍
1、函数参数
在OpenCV中,有一个函数 cv2.HoughCircles() 就是使用的霍夫变换来检测圆。该函数共有8个参数。分别是:
image:8bit、单通道灰度图像
method:Hough变换方法,但目前只支持 cv2.HOUGH_GRADIENT
dp:累加器图像的分辨率。例如,当dp的值为1时,累加器将与源图像有相同的分辨率;当dp值设置为2时,累加器的高度和宽度都变为源图像的一半。dp的值不能小于1。
min_dist:能区分两个不同圆之间的最小值。如果该值过小,可能有很多不存在的圆被错误地检出。如果该值过大,可能会丢失一些圆。
param1:Canny边缘检测的阈值上限,下限为该值的一半。即超过阈值上限为边缘点,低于阈值下限则抛弃。中间部分视边缘是否相连而定。
param2:检测出圆质量的阈值。该值越大,检测出的圆就越接近标准的圆。该值越小,则就更容易有错误的圆被检测出来。
min_radius:限制能检测出圆的最小半径。低于该值的圆将被全部抛弃。
max_radius:限制能检测出圆的最大半径。高于该值的圆将被全部抛弃。
2、返回值
函数会返回一个numpy数组,包含了检测出所有圆的圆心坐标和半径信息。要调用出这些信息,可以通过分割数组的方式实现。例如:
circles=cv2.HoughCircles(gray,cv2.HOUGH_GRADIENT,1,100,param1=50,param2=70,minRadius=1,maxRadius=200)
for i in circles[0,:]:
cv2.circle(frame, (i[0],i[1]),i[2],(0,255,0),2)
分割后,得到的数组第一位为x坐标,第二位为y坐标,第三位为半径。得到这些数据后,结合cv2.circle()方法可以在图像上绘制圆,以便显示检测结果。
2.1.2 算法流程
先大致介绍算法流程,代码会放在2.1.3中。
(1)初始化一个VideoCapture对象,使用read()方法从摄像头中读取一帧图像;
(2)使用cvtColor()方法将图像从BGR色域转为灰度图像;
(3)使用高斯模糊对图像进行滤波,核的大小可以视情况而定。我是用的是(7,7)的核;
(4)使用HoughCircles()方法检测圆。
(5)获取圆心坐标与圆的半径,将圆在源图像上标注出来。
2.1.3 代码应用实例
由于代码是在虚拟机上的Thonny里编写的,我不知道怎么调成中文,因此注释是英文写的。另外,该程序本来是用作捡球车控制的,因此会在控制台中输出球体相对于摄像头的方向和距离。
import cv2
import numpy as np
import math
try:
cap = cv2.VideoCapture(0)
except:
exit()
NoneType = None
RotateAngle = 0
halfWidth = 0
StdDiameter = 6.70
StdPing = 4
FixValue = 5
while(True):
ret, frame = cap.read()
halfWidth = frame.shape[1]/2
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
gray = cv2.GaussianBlur(gray, (7, 7), 0)
circles = cv2.HoughCircles(gray,cv2.HOUGH_GRADIENT,1,100,param1=50,param2=70,minRadius=1,maxRadius=200)
gray = cv2.Canny(gray, 100, 300)
binary, contours, hierarchy = cv2.findContours(gray, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)
cv2.imshow('edge', binary)
if type(circles) != type(NoneType):
TimeStamp = 0
Reset = False
for i in circles[0,:]:
cv2.circle(frame, (i[0],i[1]),i[2],(0,255,0),2)
cv2.circle(frame, (i[0],i[1]),2,(0,0,255),3)
tanTheta = (i[0]-halfWidth)/(1.43*halfWidth)
RotateAngle = math.atan(tanTheta)
Distance = 850/i[2]
FixAngle = math.atan(Distance*math.sin(RotateAngle)/(Distance*math.cos(RotateAngle)+FixValue))
Angle = FixAngle*180/math.pi
if RotateAngle < 0:
print('Rotate Angle is ',abs(Angle),' Left')
else:
print('Rotate Angle is ',abs(Angle),' Right')
print('Distance is ',Distance,' cm')
cv2.imshow('frame', frame)
if cv2.waitKey(1) & 0xFF == ord('q'):
break
这段代码中几个需要注意的地方:
(1)代码中的Canny边缘检测和cv2.findContours()方法是用于对比效果的。可以不加这两行代码;
(2)其中选择加入NoneType这个变量可能会看起来比较笨,但是HoughCircles()方法未检测到圆时,会产生一个None的返回值。如果不对circles判断一下是否有返回numpy数组,程序就会在执行到for循环时报错。因为NoneType是没法进行分割的。
(3)输出的球体方向和距离是由摄像头的视野范围决定的,该程序中的数据只能用于我所用的摄像头,对于其他摄像头输出的结果可能是无意义的。如果想要输出正确结果需要针对自己的摄像头进行参数的调整。
使用该段代码的效果如上图。可见,霍夫变换方法的效果还算不错,能稳定地检测出轮廓清晰的球体,但是也能看出其显著的缺点。正如上图所示,我们看到左侧的网球没有被检测出来,而且下方的乒乓球也会被检测出来。因此,它的主要问题就是,对于圆形的物体来者不拒,甚至衣服上的圆形花纹也会被它标注出来。这显然不是我们希望的。
另外,这种方法还要求物体有清晰的轮廓,否则成功检测到它的概率会很低。最致命的问题是,当这一程序遇到条纹状物体时,会极大增加错检率,并且程序会因为大量计算导致每秒钟能处理的帧数降低到0.1~1。这是使用霍夫变换没有办法避免的问题。
2.2 颜色分割法检测网球
颜色分割法的思想是,网球基本可以看做一个单色物体,只要把源图像中仅包含网球的颜色块分割出来,就能得到一幅只包含网球的图像(得到的是二值化的掩膜,mask。后面会详细说明),而排除其他颜色的物体的干扰。
2.2.1 OpenCV中的HSV颜色空间
在现实中,网球不同部分受到的光照是不一样的,这就导致它在BGR颜色空间下并非单色,难以分割。因此我们使用这种方法时,需要在HSV颜色空间下进行。HSV颜色空间也是用三个值来表示一个颜色。分别是:
色调(Hue):用角度度量,范围为0°~360°,其中0°为红色,120°为绿色,240°为蓝色。值得注意的是,在OpenCV中,该属性的范围缩小了一倍,为0至180。
饱和度(Saturation):饱和度S表示颜色接近光谱色的程度。一种颜色,可以看成是某种光谱色与白色混合的结果。其中光谱色所占的比例愈大,颜色接近光谱色的程度就愈高,颜色的饱和度也就愈高。在OpenCV中其取值范围是0至255,值越大,颜色越饱和。通俗地讲,这个值越小越发白。
明度(Value):明度表示颜色明亮的程度,通常取值范围为0%(黑)到100%(白)。在OpenCV中,该值的取值范围是0至255。
将图像转换到HSV颜色空间后,网球的色调峰值就会集中于某一个很短的区间,因为网球的明处和暗处只存在饱和度和明度的差别。只要我们保留这一色调区间,并调整饱和度和明度,就能将网球从图像中提取出来。
2.2.2 算法流程
先大致介绍算法流程,代码会放在2.2.3中。
(1)初始化一个VideoCapture对象,使用read()方法从摄像头中读取一帧图像;
(2)使用cv2.cvtColor()方法将图像从BGR颜色空间转换到HSV颜色空间;
(3)使用(3,3)大小的核进行高斯模糊,并使用cv2.inRange()方法分割出网球;(cv2.inRange()方法能够将给定的上下阈值中间的色彩分离出来,分离出的这部分为白色,其他部分为黑色)
(4)使用cv2.morphologyEx()方法做一次闭运算,减小掩膜中的干扰项造成的孔洞;(闭运算先膨胀后腐蚀,可以弥合小孔洞)
(5)使用cv2.findContours()方法取得(4)步后所得的掩膜中所有轮廓;
(6)使用cv2.boundingRect()方法取得所有轮廓对应的外接矩形的坐标和宽高值;
(7)使用cv2.countArea()方法计算轮廓所包含的面积,并将它与设定的检出圆阈值作比较,高于该值认为是一个网球,否则舍弃。将检测出的网球标记在源图像上。
2.2.3 代码应用实例
为了保证代码的规范性,我将代码分成了两个文件 main.py 和 function.py,其中,main.py直观地展示了这一方法的流程,function.py 中则定义了主程序中所用函数。 main.py
import function as tennis
import cv2
cap = cv2.VideoCapture(0)
while(True):
ret, src = cap.read()
contours = tennis.FindBallContours(src)
src = tennis.DrawRectangle(src, contours)
cv2.imshow('src',src)
if cv2.waitKey(1) & 0xFF == ord('e'):
break
funciton.py
import cv2
import numpy as np
tennisLowDist = np.array([20, 50, 140])
tennisHighDist = np.array([40, 200, 255])
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE,(5,5))
radius = 0
def FindBallContours(src):
img = cv2.cvtColor(src, cv2.COLOR_BGR2HSV)
img = cv2.GaussianBlur(img,(3,3),0)
mask = cv2.inRange(img, tennisLowDist, tennisHighDist)
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel)
image, contours, hierarchy= cv2.findContours(mask,cv2.RETR_EXTERNAL,cv2.CHAIN_APPROX_SIMPLE)
return contours
def DrawRectangle(src, contours):
for c in contours:
x,y,w,h = cv2.boundingRect(c)
if y!=0:
if w>h:
radius = w
else: radius = h
area = cv2.contourArea(c)
if area > w*h*0.75*0.5 and 0.7 < w/h < 1.5 and area > 100:
cv2.rectangle(src,(x,y),(x+radius,y+radius),(0,0,255),2)
else:
continue
return src
这段代码的效果如图所示: 左侧是在源图像中标注出网球后的效果,右图是在HSV颜色空间提取出网球后的掩膜。
识别效果发布于B站:https://www.bilibili.com/video/BV1BS4y1C7LG/
这段代码需要注意的地方:
(1)if area > w*h*0.75*0.5 这一行代码是通过色块面积占外接矩形面积的百分比来推算是不是网球,同时也限制面积的大小避免噪声被当做网球检测出来。可以通过改变这一行代码中的条件调整误识别的概率,使其性能符合要求。
(2)闭运算可以视情况决定是否使用。对于网球上的LOGO使用闭运算是一种非常有效的方法。而对于纯色的网球,包括该程序扩展的应用场景,则不一定需要使用闭运算。
相比于第一种霍夫变换的方法,这一方法的性能更加稳定,此外经过闭运算后可以保证网球在一定的遮挡下也能被检测出来。但这种方法仍然不是最完美的方法。它受限于摄像头的好坏,如果摄像头性能不佳,网球的颜色可能完全变为白色,导致网球中一大块颜色变为白色,无法在HSV颜色空间中分离出来,造成检测失败。而且,分割时用到的阈值需要在不断的调试中进行调整。目前,针对我的摄像头,这一阈值是H(20,40),S(50,200),V(140,255)。
|