之前已经熟悉了树莓派系统、Python语言和OpenCV库,而且组装了自己的玩具小车,我想结合OpenCV机器视觉算法和小车做一个小项目,因为我桌上有个网球,所以就想到实现小车通过机器视觉跟踪网球的功能,大概效果就是小车可以通过摄像头检测到是否存在网球,如果存在就跟踪网球,这里所谓的让小车跟踪网球,指的是让小车始终正对网球并保持一定距离。
要实现这个目标,花费了我近两个月的时间,主要是要花时间去了解相关的机器视觉算法和熟悉OpenCV的使用,主流的图像处理和机器学习算法在OpenCV中都有现成的接口可以调用,因此对于应用者来说,虽然需要了解这些算法,但不需要太深入,知道这些算法的意义、可以灵活运用就可以了。我参考的书是《OpenCV3计算机视觉 Python语言实现(原书第2版)》,这本书很薄,想从这本书上深入学习相关算法是不可能的,但可以把它当成路线指导,自己去搜索资料来配合阅读。
在正式编写程序之前,我总结出下面这些要事先准备的东西:
①熟悉树莓派操作系统Raspbian,熟悉Python的语法,熟悉用ssh远程访问树莓派和传输文件;
②OpenCV的安装和使用。在树莓派上的OpenCV安装可参考这篇文章:https://www.cnblogs.com/zjutlitao/archive/2018/01/12/8261688.html,这篇博文中是使用了Python虚拟环境在Python3中使用OpenCV,其实如果不用Python虚拟环境的话直接编译OpenCV源码也是可以的,只不过可能只能用Python2调用OpenCV了。至于OpenCV的使用,关键是要熟悉OpenCV下文件IO、图像格式转换、捕获摄像头和窗口显示等操作,另外也需要熟悉OpenCV依赖的numpy模块的使用。
③了解相关图像算法。关键词:色彩空间;图像特征;目标检测、分类器、Haar级联;目标跟踪、均值漂移、CAMShift。
④UDP传输视频帧,这是为了可以实时查看图像情况,具体内容见之前的文章。
⑤树莓派GPIO的控制,这是为了控制电机,具体内容见之前的文章。
当然,事先得组装好小车,并配有一个USB摄像头(我的是罗技C270i),其实物图如下图所示。


整个程序就是几种机器视觉算法的综合运用,总体的算法流程如下图所示:

这里涉及到的算法要从复杂的数学公式中理解原理还是相当有难度的,我在翻阅资料时基本是跳过了里面的数学验证,只是大概了解原理和步骤。
OpenCV中可以使用CascadeClassifier对象进行尺度不变的Haar级联分类或跟踪,Haar级联器在人脸检测中是最常用的,不过我们可以通过训练自己的分类器来检测特定物品。调用CascadeClassifier对象的detectMultiScale方法就可以检测图像中的目标了,使用起来只是几行代码的事,不过其原理涉及到特征提取、滑动窗口、分类器和非最大抑制等多个概念。
理论上,只用Haar级联检测就可以实现目标跟踪了,即对每一帧图像进行Haar级联检测,不过实际应用起来有两点需要考虑:
1、Haar级联检测计算量比较大,如果是在PC上倒还感觉不到慢,但在树莓派上运行就有些吃力了,对每一帧都进行这么大的计算,就会显得非常卡;
2、Haar级联检测并不能保证一定能检测到目标,以我要检测的网球为例,虽然网球的造型已经是比较简单了,但是它的各个面看起来都是有些差异的,而且有的面上还有文字,要使自己训练的分类器能几乎完全识别网球的各个面,就必须对网球的各个面采集样本图像,这个工作量太大了。
考虑到这些,在检测到网球后,使用CAMShift来对网球目标进行跟踪。CAMShift是均值漂移MeanShift的改进算法,其改进之处主要在于可以自动调节跟踪窗口的尺寸。均值漂移的基本思想是通过迭代寻找概率函数离散样本的最大密度,其最大的优点就是计算速度快,当然它的缺点主要是比较依赖目标的颜色特征,不过好在网球的颜色是比较独特和单一的。
用CAMShift可以得到包含目标的矩形框,计算矩形框的中心点,将其和屏幕中心比较,判断小车应该左转还是右转;计算矩形框的尺寸,将其和设定的值进行比较,判断小车应该前进还是后退。
为了使用Haar级联器检测网球,需要自己手动采集素材并训练模型,这个过程其实是最耗时的。训练方法可以参考这篇博文http://blog.topspeedsnail.com/archives/10511,或者是OpenCV的官方说明https://docs.opencv.org/2.4.13/doc/user_guide/ug_traincascade.html?highlight=opencv_createsamples。
首先采集大量的负样本图片,一般要大于1000,而且负样本最好是在要测试的场地采集。下面这些图是我采集的负样本图像,都是从我所在的办公室采集的。首先采集大量的负样本图片,一般要大于1000,而且负样本最好是在要测试的场地采集。下面这些图是我采集的负样本图像,都是从我所在的办公室采集的。(可以在小车上写个程序,让小车自动采集周围的图片)

而采集正样本图像即网球更累,好在可以使用opencv_createsamples工具结合负样本快速生成大量正样本,因此只需要对网球几个面进行采集就行。下面是采集的网球图像。

而利用opencv_createsamples将网球嵌入负样本图像得到的正样本如下图所示。

接着使用opencv_traincascade 工具进行Haar级联器的训练,做好心理准备,因为训练时间真是长,在树莓派下,大约1000的正样本和1000的负样本,设定训练层数为15,,大概花了8到9个小时。(执行opencv_traincascade 命令时,可能会出现“Insufficient memory(Failed to allocate xxx bytes)”的错误,这通常是由于precalcValBuSize和precalcIdBufSize参数设置的太大造成的,指定这两个参数为1就行了)

训练完成后,得到保存了级联器数据的cascade.xml文件,然后就可以载入这个文件测试分类器的效果了,用下面的代码进行测试(事先截取一些实验图片):
import cv2ball_haar=cv2.CascadeClassifier('cascade.xml')img=cv2.imread('test_image_0.jpg')gray_img=cv2.cvtColor(img,cv2.COLOR_BGR2GRAY)balls=ball_haar.detectMultiScale(gray_img,1.3,5)for x,y,w,h in balls:cv2.rectangle(img,(x,y),(x+w,y+h),(0,255,0),2)cv2.imshow('img',img)cv2.waitKey()cv2.destroyAllWindows()
上面代码里最核心的就是detectMultiScale函数,它有两个主要的参数scaleFactor和minNeighbors,scaleFactor是图像的缩放因子,而minNeighbors表示构成检测目标的相邻矩形的最小个数(个人感觉这个参数可能与非极大值抑制有关),上面的代码将它们分别设置为1.3和5,检测结果如图所示:

可以看到网球的确被检测到了,但是有些干扰也被认为是网球了,可见分类器的虚警率较高。对此可以通过将minNeighbors调大来改善检测结果,将其设置为25后结果如下:

可见检测效果得到了很大的提升。当然,提高检测效果的最好方法还是增大训练样本数和样本多样性。
编写主程序,其代码和相应的注释如下:
import cv2import numpy as npimport socketimport tracebackimport Motorimport timeWIDTH=640HIGHT=480center_x=WIDTH/2center_y=HIGHT/2def getCenter(points):'''计算目标窗口的中心'''return ((points[0][0]+points[2][0])/2,(points[0][1]+points[2][1])/2)def getOffset(point):'''计算目标窗口中心在水平位置上与屏幕中心的偏差大小'''return point[0]-center_xdef getSize(point):'''计算目标窗口的对角线长度,作为度量尺度'''return np.sqrt(np.sum(np.square(point[0]-point[2])))'''初始化socket,用于发送实时视频帧'''HOST='192.168.191.1'PORT=9999server=socket.socket(socket.AF_INET,socket.SOCK_DGRAM)server.connect((HOST,PORT))'''初始化电机控制模块'''motor=Motor.Motor()motor.setup()interval=0.01limit_offset=40limit_size_down=200limit_size_up=250'''先使用Haar级联器检测是否有网球'''print('load the cascade...')ball_cascade=cv2.CascadeClassifier('cascade.xml')ball_x=0ball_y=0ball_width=0ball_height=0try:print('now detect the ball...')while True:ret,frame=cap.read()if ret is False:continuegray=cv2.cvtColor(frame,cv2.COLOR_BGR2GRAY)balls=ball_cascade.detectMultiScale(gray,1.3,25)if balls is not None and len(balls)>0:width=balls[:,2]index=np.argsort(width)[-1](x,y,w,h)=balls[index]ball_x=xball_y=yball_width=wball_height=himg=cv2.rectangle(frame,(x,y),(x+w,y+h),(255,0,0),2)breakret,imgencode=cv2.imencode('.jpg',frame,[cv2.IMWRITE_JPEG_QUALITY,50])server.sendall(imgencode)ret,imgencode=cv2.imencode('.jpg',frame,[cv2.IMWRITE_JPEG_QUALITY,50])server.sendall(imgencode)'''进行CAMShift跟踪'''print('prepare for camshift...')track_window=(ball_x,ball_y,ball_width,ball_height)roi=frame[ball_y:ball_y+ball_height,ball_x:ball_x+ball_width]hsv_roi=cv2.cvtColor(roi,cv2.COLOR_BGR2HSV)mask=cv2.inRange(hsv_roi,np.array((30.,0.,0.)),np.array((70.,180.,180.)))roi_hist=cv2.calcHist([hsv_roi],[0],mask,[180],[0,180])cv2.normalize(roi_hist,roi_hist,0,255,cv2.NORM_MINMAX)term_crit=(cv2.TERM_CRITERIA_EPS | cv2.TERM_CRITERIA_COUNT,10,1)print('now track the ball...')while True:ret,frame=cap.read()if ret is False:continuehsv=cv2.cvtColor(frame,cv2.COLOR_BGR2HSV)dst=cv2.calcBackProject([hsv],[0],roi_hist,[0,180],1)ret,track_window=cv2.CamShift(dst,track_window,term_crit)pts=cv2.boxPoints(ret)pts=np.int0(pts)img=cv2.polylines(frame,[pts],True,(255,0,0),2)img=cv2.circle(img,(center_x,center_y),8,(0,0,255),-1)(point_ball_x,point_ball_y)=getCenter(pts)img=cv2.circle(img,(point_ball_x,point_ball_y),8,(0,255,0),-1)offset=getOffset((point_ball_x,point_ball_y))img=cv2.putText(img,'offset: %d' % offset,(point_ball_x,point_ball_y-10),cv2.FONT_HERSHEY_SIMPLEX,0.6,(0,0,0),1,cv2.LINE_AA)size=getSize(pts)img=cv2.putText(img,'size: %f' % size,(point_ball_x,point_ball_y+10),cv2.FONT_HERSHEY_SIMPLEX,0.6,(0,0,0),1,cv2.LINE_AA)ret,imgencode=cv2.imencode('.jpg',img,[cv2.IMWRITE_JPEG_QUALITY,50])server.sendall(imgencode)'''控制小车运动'''if offset>limit_offset:motor.right(interval)elif offset<(-limit_offset):motor.left(interval)else:if size<limit_size_down:motor.ahead(interval)elif size>limit_size_up:motor.rear(interval)except Exception,e:print(traceback.print_exc())cap.release()motor.stop()
控制小车运动时,小车的运动幅度不能太大,否则很容易使网球脱离屏幕范围造成误检,因为CAMShift算法在没有真正的目标时也是有返回值的。因此对小车运动间隔interval不能太大或太小,具体值需要看测试情况。
此外,还需要在电脑上运行一个视频帧接收程序来查看实时图像,代码如下:
import cv2import numpyimport socketimport structHOST='192.168.191.1'PORT=9999buffSize=65535server=socket.socket(socket.AF_INET,socket.SOCK_DGRAM)server.bind((HOST,PORT))print('now waiting for frames...')while True:data,address=server.recvfrom(buffSize)if len(data)==1 and data[0]==1:server.close()cv2.destroyAllWindows()exit()try:data=bytearray(data)print('have received one frame')data=numpy.array(data)imgdecode=cv2.imdecode(data,1)cv2.imshow('frames',imgdecode)if cv2.waitKey(1)==27:breakexcept:continueserver.close()cv2.destroyAllWindows()
下面是拍摄的测试视频,里面把实时图像也嵌了进去:

实际效果还是不错的,当然网球的滚动不能太快,手也尽量不要太靠近网球防止带来干扰。
其实也可以这样设计,小车把图像上传到电脑由电脑来计算,这样就可以比较快速地只用Haar级联进行目标跟踪,不过我的电脑始终装不上opencv_contrib模块。
