一、前言
写这个只是为了练习java跟opencv来做图像识别,并不是以刷分作为初衷,而且分数高了也提交不上去,会说存在可疑操作,不知道它的检测机制是怎样的,可能是触摸坐标还有间隔时间一直没变的原因。
二、所需的工具
1.ADB工具(Android Debug Bridge tools),安卓调试桥工具,用这个就可以在电脑命令行输入命令,完成触摸,按压,截图等等一系列操作。
1)如果你电脑安装有Android SDK,可以去SDK的文件夹找一下,比如我的是在C盘:C:\Android\android-sdk\platform-tools,里面就有adb.exe。复制这个路径,到系统环境变量中找到Path添加进去就行了(不知道怎么配置环境变量可以去百度一哈)。
2)如果你电脑没有安装Android SDK的话,直接去http://adbshell.com/downloads下载ADB kits就好了,下载下来打开其实就相当于解压了,随便选个地方放好文件夹之后,同样选择adb.exe所在的路径,复制去配置环境变量。
3)配置完环境变量以后,win+R输入cmd,回车打开命令行窗口,输入adb再回车,如果能看到下面这些说明就是配置成功了,同时可以看到我的版本号是1.0.39,最好是32以上吧,我试过1.0.26的发现并不能正常使用。
4)接下来就可以用USB线连接电脑和手机了,首先手机要打开开发者选项(怎么打开不同的手机可能不一样,不懂就问下百度吧),勾选允许USB调试,此时会提示你授权电脑连接,确认即可。如果USB插上电脑时,手机只能看到正在充电,而没有显示USB连接方式(让你选媒体设备(MTP)、相机(PTP)之类的)时,可以换一条数据线试一下。以上都没问题的话,就可以在命令行输入adb devices了,显示如下:。一串数字字母+device说明就是成功连接了,如果只显示List of devices attached的话,或者device显示的是offline的话,尝试检查下驱动或者检查一下adb版本是否太低或者是否打开了开发者模式且允许USB调试。上述都成功的话就可以试一下使用adb命令了,如截图:adb shell screencap /sdcard/1/test/1.png 后面的路径表示截图存储的路径,回车执行完在手机的文件管理器中,sdcard一般是手机的内部存储,可以把它理解为根目录了,1/test下面就有一张1.png的图片了。
2. opencv(一个开源的计算机视觉库)。opencv可以去官网下载,不过速度堪忧,这里贴个我的网盘,版本是3.2.0,最新的是3.4。下载完后双击打开解压到任意目录就好了。
3. eclipse(编写java代码的IDE)。这里就不说我们安装java环境和eclipse了。主要讲一下eclipse怎么配置opencv。
1)打开eclipse,选择windows->Preferences->Java->Build Path->User libraries,点击右边的new,填入一个名字,我写的是opencv-3.2
。
点击ok,选中刚才创建的,点击右边的Add External JARs...,选择你解压的opencv的目录下的build\java\opencv-320.jar。然后就会是这样子的
,接着选中Native library location,点击右边的Edit,同样打开你opencv解压的目录下的build\java\,如果你电脑是32位的就选择x86文件夹,64位就选x64文件,接着点Apply and Close关闭窗口即可。
2)新建一个java工程,在工程上右键选择,Build Path->configure Build Path,选择上边的Libraries,再选择右边的Add Library,选中User Library,点击next,就能看到刚才添加的opencv-3.2了,打上勾,点击finish就好了。至此配置完成。
3)新建一个类测试一下opencv,添加以下代码,能输出矩阵说明opencv配置就没问题了:
import org.opencv.core.Mat; import org.opencv.core.Core; import org.opencv.core.CvType; import org.opencv.core.Scalar; class SimpleSample { public static void main(String[] args) { System.loadLibrary(Core.NATIVE_LIBRARY_NAME); Mat m = new Mat(5, 10, CvType.CV_8UC1, new Scalar(0)); System.out.println("OpenCV Mat: " + m); Mat mr1 = m.row(1); mr1.setTo(new Scalar(1)); Mat mc5 = m.col(5); mc5.setTo(new Scalar(5)); System.out.println("OpenCV Mat data:\n" + m.dump()); } }
三、实现思想
- 我们知道跳一跳是根据我们按压屏幕时间长短来决定弹跳距离的,按压屏幕时间越长跳的越远,所以按压屏幕时间是与距离成正比的,即两者之间肯定有一个系数使得按压时间=起点到落地点的距离*系数,这里我把这个系数称为弹跳系数。
- 为得到按压时间我们必须先知道起点和落地点的距离和弹跳系数,弹跳系数可以在得出距离之后通过手动修改得到一个比较合适的值,我们接下来讲怎么得到起点和落地点之间的距离。
- 首先找到起点。由于小人是固定不变的,找到小人就找到了起点,这时候就用到了opencv的模板匹配了,通过opencv的模板匹配找到小人的起点,注意这里是小人的起点,就是小人在图片中最先匹配到的地方(图中绿色矩形左上角),有了这个点再加上一定的高度宽度就能得到起点坐标(图中蓝色的点)。
- 找到落地点的方法有两种,第一种是找到小白点,我们知道有时候在一个落地的地方中心会出现一个小白点,如果我们能找到这个小白点的坐标,那么就能准确定位到落地点的坐标,这时候同样利用到opencv的模板匹配来匹配小白点,然而小白点并不是经常有的,而且也比较难匹配到,我们就有了第二种方法,边缘检测,经过边缘检测的图如下,可以明显的看到物体的轮廓,我们只要确定落脚区域,计算出落脚区域的中心点,就是我们需要的落脚点。此时我们要知道,落脚位置肯定位于屏幕的上半部分,且不会上半部分中分数所占的部分,这样我们就能缩小一大部分的区域了。
四、详细步骤及代码编写:
1. 新建com.main包,在这个包下面新建Main.java类文件,并编写main方法入口,建议在进行接下来的第二步时,把每个步骤逐步的在main方法调用,有助于我们查看程序的执行过程并及时发现错误,不要等代码全部写完再来调用。
2. 新建com.util包,在这个包下面新建MyOpencv.java类文件,用于封装使用到的opencv的方法,便于我们调用。(顺便提一下这里小人图片和小白点最好从手机截,不要在电脑上打开图片再截,这样准确一点,还有手机如果有悬浮球的话在运行的时候关掉,不然也会导致误判)
1) 首先来看一下我们需要给MyOpencv这个类添加什么属性
String bodyImg; //小人图片径,用于匹配 String sourceImg; //每一次起跳前的截图 String whiteDotImg; //小白点图片,能匹配到就是终点 Point bodyPoint; //小人的左上角坐标 Point startPoint; //起点,即小人的中心 Point endPoint; //终点,下一个落点的中心 long distance; //起点到终点的距离 int presstime; //按压的时间, private Mat body; //Mat存放小人图片的像素矩阵 private Mat source; //截图的像素矩阵 private Mat result; //模板匹配结果存放 private Mat imgCanny; //边缘检测像素图,灰度图 private MyFrame frame; //最终做成了一个窗体,MyFrame继承JFrame,下个步骤再讲这个
2) 构造方法,不多说,常规初始化
public MyOpencv(String bodyImg,String sourceImg,String whiteDotImg,MyFrame frame) { this.bodyImg = bodyImg; this.sourceImg = sourceImg; this.whiteDotImg = whiteDotImg; this.startPoint = new Point(0,0); this.endPoint = new Point(0,0); this.bodyPoint = new Point(0,0); this.distance = 0; this.presstime = 0; this.frame = frame; }
3) 接着我们来找起跳点,先找到小人
//采用图像模板匹配,模板为小人,匹配小人在截图中的位置,从而得到起点坐标 public void matchBody() { //这句话一定要加,而且要在最前面,不然后面的操作都会报错!!! System.loadLibrary(Core.NATIVE_LIBRARY_NAME); //像素矩阵初始化,分别读入小人图片还有上传上来的整张截图 this.body = Imgcodecs.imread(this.bodyImg); this.source = Imgcodecs.imread(this.sourceImg); //result存放模板匹配后的结果,先初始化为0,大小和截图大小一样 this.result = Mat.zeros(source.rows(), source.cols(),source.type()); //这里就是模板匹配了,w3school有相关介绍 https://www.w3cschool.cn/opencv/opencv-pswj2dbc.html Imgproc.matchTemplate(source, body, result, Imgproc.TM_CCOEFF_NORMED); //在给定的矩阵中寻找最大和最小值(包括它们的位置),minMaxLocResult 返回的 maxLoc为匹配的模板在截图中的左上位置 MinMaxLocResult minMaxLocResult = Core.minMaxLoc(result); //bodyPoint表示小人在截图中的左上角坐标 this.bodyPoint = new Point(minMaxLocResult.maxLoc.x, minMaxLocResult.maxLoc.y); System.out.println("小人坐标起点:"+this.bodyPoint); //根据小人的高度和宽度以及在截图中的位置,粗略得到小人的中心点,即起点 Point point = new Point(this.bodyPoint.x + body.width()/2, this.bodyPoint.y + 0.8*body.height()); this.setStartPoint(point); System.out.println("起点坐标:"+this.startPoint); //复制一份截图,添加小人的标记和起点标记 Mat mark = this.source.clone(); //绘制一个矩形把小人框起来 Imgproc.rectangle(mark, bodyPoint, new Point((int)(this.bodyPoint.x+this.body.cols()), (int)(this.bodyPoint.y+this.body.rows())),new Scalar(0,255,0),5); //绘制一个点,表示标出来的起点 Imgproc.circle(mark,startPoint , 10,new Scalar(255,0,0), -1); //将标记好的图写出,生成mark.jpg Imgcodecs.imwrite("images/mark.jpg", mark); //在Main.java的main方法里面创建一个MyOpencv对象,调用这个方法,就可以在这个工程文件夹下的images/里面看到mark.jpg的图片了,即匹配结果,看看效果吧 }
4)通过上面的方法已经找到了起点,我们在前面提到在有小白点的前提下我们直接找到小白点就能确定终点,找不到还得做进一步的边缘检测,所以我们先找小白点
public boolean findWhiteDot() { //找小白点同样也是利用和找小人一样的方法:模板匹配 //读入小白点的像素矩阵 Mat whiteDot = Imgcodecs.imread(this.whiteDotImg); //读入截图的像素矩阵 source = Imgcodecs.imread(this.sourceImg); //匹配结果的像素矩阵,初始化为0,大小和截图大小一致 Mat dotResult = Mat.zeros(this.source.rows(), this.source.cols(),this.source.type()); Imgproc.matchTemplate(this.source, whiteDot, dotResult, Imgproc.TM_CCOEFF_NORMED); MinMaxLocResult dotLocResult = Core.minMaxLoc(dotResult); //当匹配值大于0.8时就可以认为我们找到小白点,获取终点坐标,返回值为真 if(dotLocResult.maxVal > 0.8) { System.out.println("小白点匹配值:"+dotLocResult.maxVal); System.out.println("找到小白点"); this.endPoint.x = dotLocResult.maxLoc.x + whiteDot.width()/2; this.endPoint.y = dotLocResult.maxLoc.y + whiteDot.height()/2; //找到小白点时同样做标记输出到images文件夹 Mat imgResult = source.clone(); Imgproc.circle(imgResult, endPoint, 10, new Scalar(255, 255, 0),-1); Imgcodecs.imwrite("images/dotLocResult.jpg", imgResult); return true; } //没找到小白点返回false return false; }
5)当findWhiteDot()返回false时说明没找到小白点,那就要做边缘检测了
public void edgeDetection() { //初始化矩阵为0,宽高和原截图一样 Mat imgBlur = Mat.zeros(source.rows(), source.cols(),CvType.CV_32FC1); //对图片进行高斯模糊处理,去除噪点 Imgproc.GaussianBlur(source, imgBlur, new Size(5,5), 0); //将经过高斯模糊处理的图片写出到文件夹 Imgcodecs.imwrite("images/imgBlur.jpg", imgBlur); //初始化矩阵为0,宽高和原截图一样 this.imgCanny = Mat.zeros(this.source.rows(), this.source.cols(),CvType.CV_32FC1); //高斯模糊处理后的图片进行边缘检测处理 Imgproc.Canny(imgBlur, this.imgCanny, 1, 10); //因为后面要对图片进行裁剪,裁剪后可能会有小人的一部分身体出现,所以要先把图片中的小人抹掉,以免裁剪后出现小人影响计算 //像素点为0为黑色,我们知道小人的起点坐标和大小,所以在这个范围内将像素点全部置0即可抹去小人 for(int i = (int)this.bodyPoint.x; i <= (int)(this.bodyPoint.x+this.body.width()); i++) { for(int j = (int)this.bodyPoint.y; j <= (int)(this.bodyPoint.y+this.body.height()); j++) { this.imgCanny.put(j, i, 0);//注意这里的i和j,x坐标在像素图中是列数,y是行数 } } //边缘检测后的图片输出到文件夹 Imgcodecs.imwrite("images/imgCanny.jpg", this.imgCanny); }
6)边缘检测之后我们只要对图片进行裁剪,再进行计算,就能得到终点了,先讲下计算思路
这是一张将原截图经过边缘检测后并且裁剪了该截图从上往下数1/4处到1/2处之后的图片,我们很准确的得到了小人下一个落点的方块,为了直观的展示怎么计算落地点,我手动标注了绿色的线和红色的点(所以线画的有点歪),很明显,C点就是我们要求的落地点,A点是这个圆形最顶部的点,B点是这个圆形最右边的点,不难看出,C点的横坐标坐标恰巧就是A点的横坐标,C点的纵坐标就是B点的纵坐标,在这张图中,只有白色和黑色(白色是255,黑色是0),所以我们遍历整张像素图,找到第一个值为255的点(即A点),再找到值为255的点中横坐标最大的点(即B点),就能得出C点的坐标了。当然这个坐标不是最终的坐标,因为上半部分还有一部分被我们裁剪掉了,C点的纵坐标加回去这个裁剪掉的高度,就是在原图中真正的落地点(终点)坐标了,看下代码:
public void findEndPoint() { //先对边缘检测后的图片进行剪切 //剪切是因为落地点只会在图片的上半部分,减少搜索范围 //而且是在分数的下边,所以可以划分出一个大概的区域(从坐标(0,图片高度的1/4处)开始的宽为原图片的宽,长度为原图片的1/4的矩形) Mat imgCut = new Mat(this.imgCanny, new Rect(0, (int)Math.round(source.height()*0.25), source.width(), (int)Math.round(source.height()*0.25))); //复制一份,输出到文件夹 Mat imgCopy = imgCut.clone(); Imgcodecs.imwrite("images/imgCut.jpg", imgCopy); imgCopy.convertTo(imgCopy, CvType.CV_64FC3); //不做这一步下面的计算会报错 int size = (int) (imgCopy.total() * imgCopy.channels()); //像素点总数*通道数,灰度图通道数为1 System.out.println("裁剪后的图片宽高:"+imgCopy.rows()+","+imgCopy.cols()); System.out.println("像素点数量:"+size); System.out.println("通道数:"+imgCopy.channels()); double data[] = new double[size]; //存储像素点的一维数组 imgCopy.get(0, 0, data); //将从(0,0)像素点的值存放到数组中,只有两种值,0为黑色,255为白色 //不出错的话裁剪后的图基本只剩终点所在的方块,从上至下,从左至右 //找到第一个白色点A(x1,y1),以及横坐标最大的白色点B(x2,y2),终点的坐标即为(x1,y2) Point point = new Point(0,0); int maxX = 0; //存储最大x坐标 for(int i = 0; i < imgCopy.rows();i++) { for(int j = 0; j < imgCopy.cols(); j++) { //坐标在一维数组的下标转换(i,j)→[i*每行的列数+当前所在的列数j] if((int)data[i*imgCopy.cols()+j] == 255&&point.x == 0) { point.x = j; } if((int)data[i*imgCopy.cols()+j] == 255 && j>maxX && point.x!=0) { point.y = i; maxX = j; } } } //标记在裁剪后的图片中的终点,这时得到的坐标并不是真正的终点坐标,因为是以裁剪后的图片左上角为起点 Imgproc.circle(imgCopy, endPoint,10,new Scalar(255,0,255), -1); Imgcodecs.imwrite("images/imgCut.jpg", imgCopy); point.y += this.source.height()*0.25; //y坐标应加上裁剪的1/4,横坐标不变 this.setEndPoint(point); Imgproc.circle(this.imgCanny, this.endPoint,10,new Scalar(255,0,255), -1); Imgcodecs.imwrite("images/imgCanny.jpg", imgCanny); }
7)至此所有关于图像处理的函数封装书写完毕,接下来调用
public void setDistance() { //求出起点和终点的距离 this.distance = Math.round(Math.sqrt(Math.pow(this.startPoint.x-this.endPoint.x, 2) +Math.pow(this.startPoint.y-this.endPoint.y, 2))); } public void setPretime(double x) { //设置按压时间,x为前面提到的弹跳系数 this.presstime = (int)(this.distance*x); } public void go() { this.matchBody(); this.edgeDetection(); if(!findWhiteDot()) { this.findEndPoint(); } this.setDistance(); //传入的参数是弹跳系数,来自于窗体输入框中的值,方便程序运行时用户根据情况实时改变这个弹跳系数,默认是1.35 this.setPretime(Double.parseDouble(this.frame.getTextField().getText())); }
3. 窗体代码
package com.frame; import java.awt.BorderLayout; import java.awt.Button; import java.awt.GridLayout; import java.awt.Label; import java.awt.TextArea; import java.awt.TextField; import java.awt.event.MouseAdapter; import java.awt.event.MouseEvent; import java.awt.event.WindowAdapter; import java.awt.event.WindowEvent; import java.awt.image.PixelGrabber; import java.io.IOException; import java.util.Timer; import java.util.TimerTask; import javax.swing.JFrame; import javax.swing.JPanel; import org.opencv.core.Point; import com.util.MyOpencv; public class MyFrame extends JFrame{ private Label label; private TextField textField; private Button startButton; private Button stopButton; private JPanel panel; private TextArea textArea; //截图和上传图片的adb命令 private String cmdScreen = new String("adb shell screencap /sdcard/1.png"); private String cmdPull = new String("adb pull /sdcard/1.png D:\\opencv\\WeixinJump\\images"); private Point p1,p2; MyOpencv myOpencv = new MyOpencv("images/body.jpg", "images/1.png","images/whiteDot.jpg",this); private Timer timer; public MyFrame() { this.setTitle("微信跳一跳"); this.setSize(300,300); this.setLocationRelativeTo(null); panel = new JPanel(new GridLayout(2, 2)); this.add(panel,BorderLayout.CENTER); this.timer = new Timer(); this.label = new Label("弹跳系数:"); this.textField = new TextField("1.35"); this.textArea = new TextArea("分辨率1080*1920弹跳系数:1.35"); this.startButton = new Button("开始"); this.stopButton = new Button("停止"); this.panel.add(label); this.panel.add(textField); this.panel.add(startButton); this.panel.add(stopButton); this.add(textArea,BorderLayout.SOUTH); this.setVisible(true); this.addWindowListener(new WindowAdapter() { @Override public void windowClosing(WindowEvent e) { // TODO Auto-generated method stub System.exit(0); } }); this.startButton.addMouseListener(new MouseAdapter() { @Override public void mouseClicked(MouseEvent e) { timer = new Timer(); //设置了一个定时器,后面参数1000和5000的意思启动时延迟1秒执行,每5秒执行一次,就能实现一直跳了 timer.schedule(new TimerTask() { @Override public void run() { // TODO Auto-generated method stub execAdbCmd(cmdScreen); //截取当前手机屏幕并保存在手机中 execAdbCmd(cmdPull); //将手机中的截图上传到电脑的文件夹中 //调用opencv封装好的方法 myOpencv.go(); //这里获取起点的坐标,在起点坐标附近再随机产生一个坐标,保证每次触摸坐标都不一样,不过这样做还是会被判做操作异常 p1 = myOpencv.getStartPoint(); p2 = new Point(p1.x+Math.random()*10,p1.y+Math.random()*15); System.out.println("触摸坐标为"+p1+" "+p2); //按压屏幕的adb指令 String cmd = String.format("adb shell input swipe %d %d %d %d %d ", (int)p1.x,(int)p1.y,(int)p2.x,(int)p2.y,myOpencv.getPresstime()); execAdbCmd(cmd); System.out.println("ok"); } }, 1000,5000); } }); this.stopButton.addMouseListener(new MouseAdapter() { @Override public void mouseClicked(MouseEvent e) { try { timer.cancel(); System.out.println("定时器取消"); } catch (Exception e2) { e2.printStackTrace(); } } }); } public void execAdbCmd(String cmd) { try { Process proc = Runtime.getRuntime().exec(cmd); proc.waitFor(); System.out.println("execute..."); } catch (Exception e1) { // TODO Auto-generated catch block e1.printStackTrace(); } } public TextField getTextField() { return textField; } public void setTextField(TextField textField) { this.textField = textField; } }
8. main方法就简单啦,new一个窗体出来就好了
package com.main; import com.frame.MyFrame; public class Main { public static void main(String[] args) { new MyFrame(); } }
界面效果如图:
五、总结
1. 项目存在一个会失败的地方,自己玩过跳一跳的都会知道,跳一跳会出现一个音乐盒,落在上面停留一会就可以+30分,同时这个音乐盒会飘出来一串音符,并且是连续的,如图,处理过之后的图片是这样的,很明显可以看到,如果按照之前定位终点的办法,这里就会把终点定位到这个音符上面去了,导致游戏结束,当然触发这个的概率不是很高,以后有学到什么新的办法再来解决这个问题吧。
2. 总体来说知道了图片处理的思路,做出这样一个基于opencv的跳一跳辅助并不是很难,虽然保证不了百分百准确落在中心点(很大一点原因是因为计算时取整导致了坐标有些偏移),但有了这个跳一千分以上问题也不大,现在检测也很厉害了,超过三四百分就提交不上去了,不过我们是重在学习嘛~就这样了。