Python玩“跳一跳” iOS+Win 硬件实现

Python玩“跳一跳” iOS+Win 硬件实现

1.3 再更: 悄咪咪地加上Arduino版

1.3 又更了: 竟然有1k赞还上了日报,一本满足。昨天研究了一晚上,加入了大家喜爱的OpenCV。现在计算准确率已经很好了,不会出现误差累积。感谢 @船D长用Python+Opencv让电脑帮你玩微信跳一跳 给我的启发,大神的代码简洁优雅,非常受用。

1.2 更:谢谢大家的400赞,非常开心。想说一下,我是因为手边只有树莓派才用树莓派控制舵机的,它毕竟是一台200+的小型计算机,肯定是大材小用了。想要自己动手做一个的知友们可以不用急着买树莓派,给我一两天的时间。我的arduino已经到啦,正在测试!


本项目源码: yangyiLTS/wechat_jump_game_iOS

认真写的一个简介

现在已有的跳一跳辅助原理有以下这些:

外星力量派:

日天派:

  • 直接抓取post请求包修改分数,服务器不对分数进行验证,想改多少改多少

平民方法:

基本步骤:1、获取游戏画面;2、图像分析计算跳跃距离;3、模拟触摸手机屏幕进行游戏。

其中针对不同平台也有不同的实现方案:

  • Android平台:adb工具实现截图和触摸,PC或手机实现图像分析
  • iOS平台+Mac:使用Mac的WDA工具,原理同adb工具。
  • iOS但没有Mac:我的方法可能可以解决这个问题

先上效果

https://www.zhihu.com/video/931216202869583872

基本思路是:

  1. 使用iOS自带Airplay服务将游戏画面投影到电脑上。
  2. 使用Pillow库截取电脑屏幕,获得游戏画面。
  3. 使用OpenCV分析图片,计算出跳跃距离,乘以时间系数获得按压时间。
  4. 将按压时间发送至树莓派/Arduino,树莓派/Arduino控制舵机点击手机屏幕。


运行环境&工具

  • Python 3.6 in Windows
  • Pillow、numpy 、pyfirmata
  • opencv-python
  • 局域网环境
  • iToools Airplayer
  • 树莓派 或 Arduino
  • SG90 舵机
  • 杜邦线、纸板
  • 一小块海绵
  • 橙子或其它多汁水果(可选)


原理&步骤

下载源码

  • 下载wechat_autojump_iOS&Win_opencv.py到Windows。
  • 如果使用树莓派,下载 servo_control.py到树莓派。
  • 如果使用Arduino,下载servo_control_arduino.py到Windows,并且确保windows已经装好Arduino驱动和Arduino IDE。


舵机部分

  • 拿一根杜邦线粘在舵机的摆臂上,并且用纸板固定舵机到合适高度,如图:
  • 取一小块海绵,约10mm*10mm*5mm,不必太精确。海绵中间挖一个小洞。大概是这样:
  • 海绵上滴水浸透,放在手机屏幕上“再来一次”的位置。杜邦线的另一头插进橙子。(触发电容屏需要在屏幕上形成一个电场,我尝试过连接干电池负极的方案,但是效果不理想,最后不得已拿了室友的一个橙子。当然,一直捏着或者含着导线也是可以的。)


如果使用树莓派

  • 舵机连接上树莓派,电源使用5v(Pin #04,Pin #06),舵机控制线接在GPIO18(Pin #12)。
  • 树莓派(OS:Raspbian Jessie)连接上局域网。
  • 打开 servo_control.py,这里需要根据实际安装位置调整舵机高点和低点位置(范围: 2.5~12.5)
servo_down = 3.8
servo_up = 5 
  • 最终效果
  • 海绵放在“再来一次”的位置可以自动重新开始,然后就会一直自动刷分
  • 在wechat_autojump_iOS&Win_opencv.py里

文档的开头需要注释掉这句,这是Arduino使用的

#from servo_control_arduino import arduino_servo_run


设置树莓派的ip地址

ip_addr = '192.168.199.181'


main()函数里面需要选择 send_time()

# #send_time() 为树莓派控制函数
 send_time(t)

 # #arduino_servo_run() 为arduino控制函数
 ##arduino_servo_run(t/1000)
  • 最后树莓派上运行servo_control.py ,监听9999端口,等待Win的计算结果


如果使用Arduino

  • Arduino请选择Arduino UNO或Arduino Mega,因为pyfrimata库不支持Arduino Nano。入门级的Arduino UNO成本在80RMB左右。
  • Arduino需要烧入预置的StandardFirmata程序,在Arduino IDE的自带示例里面可以找到
  • 在工具—端口可以看到当前Arduino连接的串行端口,记下来等下要用到。
  • 安装pyfirmata,cmd运行
pip install pyfirmata
  • 把舵机连接上Arduino,舵机有一个三线的接口。黑色(或棕色)的线是接地线,红线接+5V电压,黄线(或是白色或橙色)接控制信号端。舵机的电源线直接接在Arduino的+5V输出和GND上,控制信号端接在Digital 3输出口(程序设置是3号口,可以修改,但是必须是支持PWM输出的接口)
如果做完下面步骤,程序跑起来之后,发现舵机即使不动也会发出 “滋滋”的声音而且动作缓慢,是因为电脑USB口供电不足所致。这个时候需要对舵机使用外接5V电源,接上5V电源之后,还要把外接电源的地线(负极)跟Arduino的地线(板上的GND口)连在一起。
  • 打开servo_control_arduino.py,这是Arduino的控制脚本

这里填入上一步看到的端口

# 修改串口编号 如果Arduino驱动正确,在Arduino IDE可以看到串口编号
serial_int = 'COM3'


这里根据Arduino的型号选择,不需要的那行注释或删掉

# 如果是Arduino UNO 使用这一行
board = pyfirmata.Arduino(serial_int)

# 如果是Arduino Mega 使用这一行  pyfirmata库暂不支持Nano
board = pyfirmata.ArduinoMega(serial_int)


然后调试一下舵机的最高点和最低点

# 设置舵机的高点和低点  单位:角度
# 范围 0-180°
servo_high = 45
servo_low = 37

舵机要根据实际的安装位置调试,运动幅度不宜太大,直接运行servo_control_arduino.py文件舵机会按设定位置来循环三次,如果舵机运动正常,则Arduino部分工作正常。

  • 打开wechat_autojump_iOS&Win_opencv.py,也有一些地方需要配置

这行代码位于文件开头,确保没有被注释

from servo_control_arduino import arduino_servo_run


到文档的靠后的部分找到main()函数,其中

控制函数选择 arduino_servo_run(),需要把send_time(t)注释掉

# #send_time() 为树莓派控制函数
# send_time(t)

# #arduino_servo_run() 为arduino控制函数
arduino_servo_run(t/1000)


配置完成

橙子有点蔫了。。。


Windows 部分

  • 下载Airplayer(免安装,暂无捆绑)
  • 配置Airplayer,画质什么的统统调到最高。启动iPhone上的Airplay,然后可以在电脑上看到iPhone画面,游戏运行时需要Airplayer全屏显示
  • 安装opencv-python、numpy
pip install numpy
pip install opencv-python


  • 下载wechat_autojump_iOS&Win_opencv.py,我的显示器分辨率是1920*1080,手机是iPhone7。如果使用不同的设备需要注意更改时间系数等参数。
  • 安装Pillow库,本文使用Pillow库的ImageGrab截屏,截屏代码:
im = ImageGrab.grab((654, 0, 1264, 1080) 
im.save('a.png', 'png')
其中(654, 0, 1264, 1080)是截屏的范围,我的显示器分辨率是1080p,截取屏幕中间的部分得到的图片大小是610*1080,但这个时候图片最左边的一列的像素是黑色的。


  • 全部完成后,运行wechat_autojump_iOS&Win_opencv.py


  • 如果搭建完成后发现落点飘到天上去的情况,如图

是因为截图残留的黑边所致,这个黑边出现在截图的左边或者右边都会导致落点的计算偏差,打开screenshot_backups文件夹里面的图片会发现计算的轨迹像上图一样飘到整个图的上方。这时候的解决办法是:

找到pull_screenshot()函数:

# 使用PIL库截取Windows屏幕
def pull_screenshot():
    im = ImageGrab.grab((654, 0, 1264, 1080))
    im.save('a.png', 'png')

代码中(654, 0, 1264, 1080),表示截图的坐标。其中654,1264为截图的左边界和右边界,需要修改这两个边界使截图的尺寸变小。

举个例子,我发现默认参数的情况下出来的截图左边有3个像素的黑边,右边有1个像素的黑边,这个时候截图函数需要改成:

# 使用PIL库截取Windows屏幕
def pull_screenshot():
    im = ImageGrab.grab((657, 0, 1263, 1080)) # 左边增加3个像素,右边减少一个
    im.save('a.png', 'png')

修改完截图函数之后还需要修改默认图片的宽高,位置是在# Magic Number下面:

# Magic Number,不设置可能无法正常执行,请根据具体截图从上到下按需设置
under_game_score_y = 170  # 截图中刚好低于分数显示区域的 Y 坐标
press_coefficient = 2.38 # 长按的时间系数,
piece_base_height_1_2 = 10  # 二分之一的棋子底座高度,可能要调节
# 图片的宽和高
w,h = 610,1080

继续上面的例子,这个时候图片的宽和高需要改成

# 图片的宽和高
w,h = 606,1080  

然后再次运行程序检查截图是否还有黑边。



OpenCV算法详解

  • 本算法主要使用opencv和numpy两个库,首先要导入
import cv2
import numpy as np
  • 使用OpenCV模板匹配,找到棋子

棋子是一个非常特殊的目标,用PS把它抠出来,保存为模板使用OpevCV的模板匹配函数,准确率几乎完美。

meth = eval('cv2.TM_CCORR_NORMED')
piece_template = cv2.imread('piece.png',0) # 棋子模板
# 模板匹配 获取棋子坐标
def find_piece(img):
    res = cv2.matchTemplate(img, piece_template, meth)
    min_val, max_val, min_loc, max_loc = cv2.minMaxLoc(res)
    piece_x, piece_y = max_loc 
    # cv2.matchTemplate函数返回的是模板匹配最大值左上角的坐标
    # 下面修正为棋子底盘中点坐标
    piece_x = int(piece_x + piece_w / 2)
    piece_y = piece_y + piece_h - piece_base_height_1_2 
    return piece_x, piece_y

其中piece.png是棋子模板,长这样:

这个模板是必须的,但是它只适配610*1080的截图尺寸,如果分辨率跟我的有差异,需要另外扣一个模板,保存为png格式。如果对opencv没兴趣的话看到这里就可以跳过了。GitHub上还有其它目标的模板,但是不是一定要重新扣,原因下面讲。

  • 对其它特殊目标尝试模板匹配

我最初的想是对有加分的特殊目标(徐记士多,魔方,下水道,播放器)使用模板匹配,通过函数返回值使主函数增加延时,让我们可以吃到特殊目标的加分。但是后来发现模板匹配的效果不理想,可能是选用的匹配算法问题?或是模板问题?稍后尝试修复。

现在wechat_autojump_iOS&Win_opencv.py文件里有一段代码是进行特殊目标模板匹配的,但是因为我把置信度阈值调得很高(低了又乱匹配),所以匹配成功率非常低。如果不成功,则采用下面的算法寻找目标。

  • 对图片进行边缘检测

接下来继续寻找落点坐标,现在要把图像的边缘提取出来,游戏界面都是纯色,提取边缘非常容易:

因为已经获得了棋子坐标,所以这一步的时候先把棋子范围的像素去掉以免干扰。

代码是

img2 = cv2.GaussianBlur(img2, (3, 3), 0) # 先对图片高斯模糊
img_canny = cv2.Canny(img2, 1, 10) # 执行canny函数

输出的图像已经变成只有边缘的二值图像了

  • 尝试模板匹配小圆点

提取边缘之后尝试对连击之后的小圆点进行模板匹配,但是效果一样不理想,大部分时候会跳过这一步。

  • 找到目标落点

到了最后一步,就是找到棋子的落点,在代码中即board_,board_y。这个点有几个特点:

  1. 落点方块(圆柱)的最高点是整个图的最高点,先定义为board_y_top,这里我们已经排除了分数部分,背景和有可能高过方块的棋子部分。
  2. 落点平面的形状是对称的菱形或者椭圆形,确实有个别特殊的情况我们先不在意这些细节。然后这些形状在垂直方向是轴对称的,所以board_y_top一定在垂直的对称轴上。
  3. 同时落点平面也是水平方向轴对称的,并且从上往下遍历的第一个宽度最大的点是水平对称轴的位置。随便搞个图

然后我的思路是先找board_y_top:

# 遍历起点为分数下沿
board_y_top = under_game_score_y

for i in img_canny[under_game_score_y:]:
    if max(i): # i是一整行像素的list,max(i)返回最大值,一旦最大值存在,则找到了board_y_top
            break
    board_y_top += 1

# board_y_top的像素可能有多个 对它们的坐标取平均值
board_x = int(np.mean(np.nonzero(img_canny[board_y_top]))) 

然后从board_y_top开始找图形的侧边缘,因为是对称图形只要找左右边缘之一就可以了。但是在两个落点非常近的时候,棋子会挡住其中一个边缘,造成影响。所以先根据棋子位置判断棋子在目标落点的左边还是右边,再选择与棋子不同的位置寻找侧边沿

x1 = board_x
    fail_count = 0
    if board_x > piece_x:
        for i in img_canny[board_y_top:board_y_top+80]:
            try:
                x = max(np.nonzero(i)[0])
            except:
                pass
            if x > x1:
                x1 = x
                board_y += 1
                if fail_count < 5 and fail_count != 0:
                    fail_count -= 1
            elif fail_count > 5 and board_y - board_y_bottom >10:
                result = 1
                board_y -= 3
                break
            elif fail_count > 5 and board_y - board_y_bottom <= 10:
                result = 0
                break

            else:
                fail_count += 1

    else:
        for i in img_canny[board_y_top:board_y_top+80]:
            try:
                x = min(np.nonzero(i)[0])
            except:
                pass
            if  x < x1:
                x1 = x
                board_y += 1
                if fail_count < 5 and fail_count != 0:
                    fail_count -= 1
            elif fail_count > 5 and board_y - board_y_bottom > 10:
                board_y -= 3
                result = 1
                break
            elif fail_count > 5 and board_y - board_y_bottom <= 10:
                result = 0
                break
            else:
                fail_count += 1

这段代码非常的不pythonic,得想办法优化,其中零零碎碎的整数是一些容差参数,因为在像素角度不是绝对的圆形和方形,在方型平面上的效果会比圆形平面好,但是总体效果都很不错。

  • 最后,在上面那个算法抽风的情况下,采用原来的旧算法补救,可以说是十分之稳了
if result == 0:
        board_y = piece_y - abs(board_x - piece_x) * math.sqrt(3) / 3

问题&其它

  • 采用新版算法后,计算上的误差已经很小,可以查看screenshot_backups/文件夹,看是否得到正确的计算结果。但是仍然无法一直连续击中中心,这是由于舵机的物理误差引起的,需要调节好时间系数,舵机的高点和低点。如果采用海绵+水的接触方案,注意接触面的高度会因为水的蒸发而改变。评论区也有锡纸接触的方案,就看你们喜欢拉。
  • (1.3 已解决)由于是物理点击屏幕,会产生一定的操作误差。操作误差由时间常数误差、舵机运动时间、杜邦线触点插进海绵的深度等等因素引起。而当前使用的算法在一种情况下会出现误差叠加的问题。
Z形路径误差累积过程
如图:在绿色方块跳至灰色方块的过程中,出现操作误差。连续“Z形”路径中误差会逐渐累积。这个问题在落点方块较小时有一定的发生概率。我尝试过添加一些纠正算法,但效果不明显。这个误差会在Z形路径中断时(出现连续3个落点在一条直线上)自动修正。如果误差较大棋子即将掉落,可以终止程序,手动修改时间系数纠正。
  • 舵机的摆动角度和时间系数没有绝对的数值,需要慢慢尝试,当前使用的时间系数是2.43。
  • 可以使用arduino + pyfirmata组合控制舵机,成本比较低,已经可以用arduino啦~。
  • 这个游戏在跳了200+次之后方块会变的非常小(如题图),已经不是普通人类所能做到的。研究了外挂之后才知道手玩高分有多难,大家还是不要刷分了,会没朋友的。

来自一只正在艰难地转CS的通信狗,并没有二维码。第一次发文章,有很多小问题,欢迎各路大佬指教,给大佬倒茶。

编辑于 2018-02-23