0 瞎弄

我知道你们喜欢先看效果

手残的我,始终跳不过你们这些超过 50 分的大佬。想起最近在用 Python 学习 ML (Mechine Learning, 机器学习) ,怎么用没学会,倒是里面神经元的概念让我印象深刻。

这个小游戏,不就是你的大脑要花一些神经元,来学习小人到目标的距离感跟按压延迟的关系么?我的大脑看来是不舍得在这上面花费神经元,没跳几步我得注意力就已经飞走了。没有足够的神经元,再快的 ML 也得跪。

我是突破不了50分了,与其花时间继续无脑玩跳一跳,不如,用这些时间,来学习一下 Python 和 OpenCV?

1 搞事情!

思路是酱,先画个大饼

  1. 首先我们需要一台手机来跑微信和跳一跳 (废话)
  2. 蓝后我们需要把手机屏幕录下来传给电脑,并用Python把视频或者图像读取出来 (我印象中,国产的流氓应用市场的PC端能看手机屏幕,所以应该有办法)
  3. 再蓝后,用 Python 和 OpenCV 搞一波图形处理,然后识别目标距离,计算点击时间
  4. 最后,我需要能通过电脑点手机屏幕 (WTF这个怎么搞??)

验证思路,看看手上的佐料够不够

以上思路里面,我主要的知识空缺在于“怎么把手机屏幕传到电脑”和“怎么从电脑发送点击屏幕的指令给手机”。
谷歌百度找了一圈,发现不少软件可以做到,但是大部分软件要求手机有 Root 权限,但是我的手机没 Root,我也不想 Root,因为会影响手机银行之类的软件。只找到一个很邪恶的软件 Vysor 不需要 Root。 而且有免费版,免费的代价是不能自己调整视频画质等参数,还有每隔30分钟手机端自动跳出全屏广告。不过无所谓(然而事实是,我找了几个小时的“绿色”版本。。无果)

Vysor
邪恶的 Vysor

Bingo! 这么一来就可以简单地,手机用USB线连接电脑,电脑上用 Vysor 看手机屏幕。电脑上运行 Python,取得Vysor 的窗口的截图,使用 OpenCV 处理图像,识别“我”和目标位置,计算按压时间。然后用 Python 控制鼠标点击 Vysor 的窗口控制按压时间。

别问我 安卓的ADB调试[1] 是什么, 为什么不用 ADB 做,我不知道我不知道我不知道...

佐料清单:

  • 安卓手机,对,安卓
  • Vysor 软件
  • OpenCV
  • Anaconda (或 numpy)

没装 Anaconda 怎么好意思说自己已经开始学Python了。如果已经有了 Anaconda 再安装一个 OpenCV 就好。具体安装的教程很多
Python 编辑器我用的是 PyCharm。这些都不重要。

分析跳一跳

先简单地分析一下跳一跳的游戏模式:除去特殊物品,视角,天色变化等,是一个非常简单的(可能是线性)模型。输入是“我”到目标中心的距离,输出是按压的时间长度。我们试试用一个线性方程作为他的模型 y=kx+b。哦不,换一种写法吧:
t = k * distance + constant

t: 按压时间长度
k: 时间与距离的比例常量
distance: “我”到目标的距离
constant: 这个参数表示按压时长的死区常量,可能为0,也可能是一个很小的值。

只需要把后面这三个值确定,t就出来了!
只有distance是变量。另外两个是常量,尝试几次就可以得出。

先截个图好吧

要做机器视觉总得有图像吧,没图分析个啥。
用 Python 给游戏屏幕截图很简单,直接使用ImageGrab.grab()就可以。这个函数需要传入截图的位置参数。这里当然是要截游戏窗口,所以输入的应该是游戏窗口的位置,但问题是,怎么确定位置呢?

有几种思路,

  • 先识别 Vysor 的窗口,这样即使位置不固定,也能截对位置。像 QQ 截图一样,鼠标移到窗口上自动捕获。
  • 每次程序运行前手动输入窗口位置
  • 固定窗口位置,保证每次Vysor窗口在同一个位置出现,程序里面提前输入固定截图参数

最偷懒的我,当然选择了最后一种。那怎么固定窗口位置呢,我就把窗口拖到屏幕最左边,然后Windows自动让窗口贴边放大,这样每次窗口位置就固定了。程序就变得很简单了,因为位置固定,直接把位置参数写入程序就好。

我的屏幕是1080P,任务栏在右边,Vysor的手机投影窗口拖到最左边,待窗口自动放大和复位后,参数如下 bbox=(0, 70, 558, 1045)

程序流程上,我把截图保存到电脑里,然后再次打开这个文件取得图片,这样可以留原图调试程序。接下来统一转换了分辨率,这样方便在识别的时候,控制位置参数。再接下来,复制了一张彩图,用于标出辅助线,关键点等等,调试用,然后又做了一张灰图以防备用。

图片获取到此结束。

代码如下:

        # 截取屏幕
        file_name = 'temp.bmp'
        im = ImageGrab.grab(bbox=(0, 70, 558, 1045))  # 魔法参数1
        im.save(file_name,'bmp')
        img = cv2.imread(file_name)
        img = cv2.resize(img, (540, 960))             # 统一转换成 540*960 分辨率
        output_img = img.copy()                       # 复制一张图用来输出
        gray = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY)  # 生成一张灰图备用

以上,并不难

识别“我”

“我”是谁,下面那个傻愣。

识别“我”也不是很难。如果使用 OpenCV 里面简单的机器学习算法,经过一些样本图片的训练,很快就能识别出来。只是机器学习要准备“我”的正例和很多“我”的反例(不包含“我”的图片。我觉得挺麻烦的。除此之外,也可以用颜色识别,全局就这一个颜色的色块。但是“我”的头上和身上是有反光的,并不是单纯的色块,这部分需要处理一下才能用。

说了这么多
额,我选择换另一种方法识别“我”。。的

找人先找头

仔细观察“我” (参考上图或下图右边黑白的那张图,稍后解释),会发现他的头是圆的,而且整张图你找不到第二个那么圆的东西。我试玩了几盘,发现头的位置只出现在很小的一个区域里面,利用位置判断就可以滤除很多干扰项。

在学习 OpenCV 时,霍夫变换 [2] (OpenCV:Hough)是很常用的,也很好用的一个方法。他可以识别直线,识别圆圈。比起使用机器学习,我更愿意选择使用霍夫变换这样的简单的方法。

简单解释一下霍夫变换的原理:比如用霍夫变换找圆,就在一张图像上的某个点,作一个半径为R的圆,然后看看这个图像中,一共有多少个点与圆周相交。如果点数超过一个阀值,便认为这是一个圆,圆的位置便是之前假设的点,半径是那个假设的半径。具体是怎么遍历整张图的,如何假设圆的位置和半径的,我们不需要管。

由于霍夫变换需要输入一张只有边界的图,在使用霍夫变换之前,需要先使用另一种变换叫 Canny 变换[3] 来找到图片里面的各个边界(比如头和背景的分界,物体和背景的分界)。

Canny简单来说就是找边,相邻两个像素之间的色差到了一定斜度后(阈值),Canny就会返回1, 没达到阈值的像素是0. 如果还迷惑,看下图就能明白了,左边是原图,右边是 Canny 变换后得到的边界图。只有物品的边界跟背景之间有色差,这些边界像素点是白色的(1),其他面,或者背景都是黑色的(0)。

实际操作过程

在这里我定义了一个 find_head() 函数,输入图片,函数寻找半径在一定范围内的圆。
找到就返回 位置和半径,找不到返回0。

函数首先对图片进行了一些滤波,用的是膨胀算法,我瞎用的。主要是想滤掉一些干扰识细小的纹理,比如可恶的小板凳和魔方。这些纹理严重影响识别圆的准确率。

然后做了一个 cv2.Canny(), Canny 会返回一张二值图像,这张图像包含了原图的边界。这张图像之后被放入cv2.HoughCircles(), 霍夫变换就会根据边界图像来寻找圆,并返回圆的位置。

如果参数太严格,可能找不到圆,此时需要一个if(circles is not None):的判断,判断是否返回空值。如果返回空值,就降低Hough变换的严格性,再试一次。trial_cnt 表示试了几次, 作为参数之一用来降低严格性,出现在调用 cv2.HoughCircles()来找圆的时候。

Hough 变换也可能返回很多个找到的圆,此时就需要在接下来的代码里面找出,哪一个圆才是头?
这里判断的依据很简单,首先我观察一个区域,头只可能出现在哪个区域内。

其次有可能身子也可能识别为圆(仔细看那张Canny处理后的图,有没有发现“我”的肩膀也挺圆的, 下面那张图识别就悲剧了)。这时候继续遍历下一个圆,取靠近上面的圆(y值较小的圆,屏幕坐标,零点是屏幕的左上)。然后返回这个圆的坐标作为头的坐标。

代码如下:


def find_head(img, output_img):
    # find small circles
    head_found = False
    trial_cnt = 0
    while(head_found == False):
        #processing img
        kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (5, 5))
        closed = cv2.morphologyEx(img, cv2.MORPH_CLOSE, kernel)
        edges = cv2.Canny(closed, 200, 200)             # use full colour img for canny
        circles = cv2.HoughCircles(edges, cv2.HOUGH_GRADIENT, 2, 180 - trial_cnt *10,
                                   param1=100, param2=65, minRadius=12, maxRadius=18)
        gray = cv2.cvtColor(closed, cv2.COLOR_RGB2GRAY)
        # if nothing found, will return None, then try a few time.
        if(circles is not None):
            head_found = True
        else:
            if (trial_cnt > 5):     # return 0 as error.
                return 0, 0, 0
        trial_cnt += 1

    circles = np.uint16(np.around(circles))
    # circle to return
    xo=0
    yo=0
    ro=0
    # find and draw  # 遍历所有找到的
    for i in circles[0, :]:
        x = i[0]
        y = i[1]
        r = i[2]
        # find the head
        if (r < 25 and y > 380 and y < 520 ):
            if((y < yo or yo is  0 )): # 寻找位置最靠上的那个源(有时候身子也会被识别成圆)
                xo=x
                yo=y
                ro=r
    return xo, yo, ro

通过头找“我”的位置

“我”的大小是固定的,QQ截图量一下便知到头到脚的距离。
于是知道了头的位置,脚的位置也懂了。脚的位置就当作“我”的位置了。

不过我还是写了一个函数,输入头的位置,输出脚的位置

def find_foot_loc(x,y,r):
    xo = x
    yo = y + 75 # distance between head to foot
    return (xo, yo)

至此,“我”的位置算是搞定了。

找落点

难死了难死了。

我尝试过 OpenCV 里面自带的 Mechine Learning 来识别目标,不过一张一张截目标图好累,而且目标有大有小,同一种目标还有不同颜色。每个还要手动标目标中心,多麻烦,没开始就放弃了。之后也尝试过将视角拉直后,找目标顶面形状,基本上都是正方形和圆形。圆形同找头,十分好找,正方形很难找。。

按理来说正方形也应该很好找才对,找几个垂直的线就好。
然而我怀疑跳一跳从设计图形界面的时候,就开始针对防视觉识别做了很多功课。很多时候,背景色跟小方块的颜色差别很小,导致很难识别。
在这上面,我很难用统一的 Canny 阈值来识别所有图形的边,因为天色会变,图形颜色有的很深有的很浅,同一套参数有些边太浅识别不出来,有些纹理太重又识别太多干扰信息。此外,便利店,点唱机,小板凳,礼盒,魔方,这几个形状和纹理复杂都是干扰,非常难。就算给图形滤波,膨胀腐蚀去除纹理,帮助也不是很大。

后来,我干脆放弃了识别整个目标,我只识别目标的顶角, 然后再顶角下方偏移一个量作为目标落点,这是很简单的。

实际操作过程:
取得输入的图片,先进行简单的滤波,然后使用阈值比较低的 Canny 变换,虽然目标的花纹也会被找出来,但是顶角一个都没落下就好。

那么顶角怎么找呢,还记得 Canny变换的结果是2值图像么,不是0就是1
那么我图片从上往下找,找到第一个非零点的时候,就把它识别为顶角。Canny 处理一样的背景色,还是很给力的,没有给出太多错误信息。

这里我用到了 points = cv2.findNonZero(edges) 。这段代码会返回所有非零点,但是会按从左往右,从上往下的顺序返回。既下图表示的方向,所以第一个非零点,一般来说就是目标的顶角。

理想与现实还是有差距的

  • 偏移量应该做成变化的,因为开始的图形很大,而到了后期图形会变小,变化的偏移量更容易踩到中心。
  • 100+步的时候,有时候目标靠的很近,目标很小,这时候顶角可能比“我”还“矮”,那此时第一个非零点就不是目标了。这里很好判断,在find_target_loc()里面我传入了一个参数,是之前找到的“我”的坐标。找出来的点,只需要判断一下,这个点是否距离“我”太近, 如果太近,这个非零点便是“我”本身的点之一,应该去找下一个不在“我”附近的点。
  • 有时候,画质差的时候(比如变天)边界会有很多干扰点,所以如果识别到太靠近图片边缘的点,也要排除掉。

找到顶角之后,我就暂时距离顶角偏移一个定值,当做目标点,有时候不是目标中心,不过不要紧,关键是让“我”能继续走下去先。代码里面会依据步数调整这个偏移量。算是大概能用。

说了一大堆,Python 代码只有30行。


def find_target_loc(img, output_img, loc_foot, index = 0):
    edges = cv2.Canny(img, 15, 70)
    points = cv2.findNonZero(edges)   #find all none zero
    #print(points)
    for point in points:
        x = point[0][0]
        y = point[0][1]

        if y < 250:
            continue
        if np.abs(x - loc_foot[0]) < 30: # horizontal distance to foot
            continue
        if loc_foot[1] - y < 30:   # higher than foot
            continue
        if x < 50 or x > 540 - 50: # too close to bondary
            continue
        break

    # Find top tips of the target
    xo = x               # 0~33步 是大图形,偏移量比较多
    yo = y + 45

    if index > 33:       # 33步~75步 是中等图形, 偏移量减小
        yo = y + 35
    if index > 75:       # 75 步以上 都是非常小的图形比较多, 偏移量最小
        yo = y + 20

    cv2.imshow('Canny Line', edges)
    return (xo, yo)

计算距离

“我”和目的地的坐标出来了,接下来是不是明了了!
回头看看我们的线性模型,我们的第一个变量 distance 就有了。

这里直接给出各个常量,反复实践几次就可以将他们确定。我随意调试了几次就出来了。
k = 1/365
constant = 0
于是我们的模型就是:
t = distance / 365
distance 单位是像素 (480x960分辨率)
t 的单位是秒。

直接开平方和,就能计算出 distance

        # calculate distance
        dist = np.sqrt((loc_target[0] - loc_foot[0])*(loc_target[0] - loc_foot[0])     
                       + (loc_target[1] - loc_foot[1])*(loc_target[1] - loc_foot[1]))

在图片里面把距离线段画出来,坐标画出来,效果如下图 (忽略绿线):

上图计算结果是:
像素距离 (distance):263个像素
模型预计的时间(t):0.722s = 722ms
接着是不是就可以愉快地跳跃了?!

慢着,我们好像忘记了什么!请看下图

假设“我”站在上一个目标的中心,而且我们也找到了下一个目标中心。那么在上图的目标平面上画一条与蓝线垂直的 BC。 B,C分别是线段与目标平面边界的交点。然后再画出左右两边到“我”脚下的线 AB 和 AC,玩游戏的直觉上,也就是在“我”的世界里面,三角形ABC是一个等腰三角形,那么 AB 和 BC 应该是相等的吧?

但是!!在我们看到的屏幕上(上面计算基于的二维图像中), AB 并不等于 BC。图片坐标里面,我作出D点,使得AB = AD,这样看就很明显了。如果不信可以用尺子量量屏幕。

这个位置计算,在“我”没有跳到中心,或者识别目标中心有误差的情况下,对距离的计算精度有比较大的影响。但是为毛会有这个误差??

游戏是有视角的,我们并不是从屏幕世界里面的顶端往下看。这个视角我在尝试还原目标顶面的正方形和圆形时,计算了一下,大概是横边不变,纵向除以根号三。也就是图形的长度不变,宽度除以1.73。

下图是视角变换后的图像,此时 AB = BC 了,同时你也会发现,“我”是站在一个真正的四边相等且垂直的“正”方形中心了,而不是原来的平行四边形里面 (看不出来是正方形?可能你已经沉迷游戏无法自拔了,请用尺子和量角器)。如果用变换后的图像进行距离计算,无论“我”在哪,目标识别是否准确,计算出的距离都不会有误差。

至于为什么是根号三,你猜~ 猜出来还能知道相机视角中线与“我”的世界地平面的夹角度数。

变换简单,吹了一大堆,但是我没做。
没做效果也还行。实际尝试很少会遇到过因为视角计算的误差,导致“我”掉下去,最多就是踩不到计算出来的目标点。不想那么麻烦,毕竟连目标中心都是瞎估计的一个基于顶角位置的偏移量,本身就很不精确。

模拟按压和防作弊

终于到最后一步了,按压时长已算出,就差“按”了!想想还有点小激动呢!

这里使用 Python 控制鼠标点击 Vysor 窗口,模拟手指按压手机屏幕。Python 控制鼠标,可以说是非常简单的了,程序引入Win32API 的包,然后就可以使用里面的控制鼠标的函数来移动鼠标指针,并用左键点击屏幕。不多说,直接上代码

我把模拟按压包装了一下做成一个函数,
输入点击的屏幕坐标和按压时长,然后函数执行按压,结束后返回

def click(x,y, t):
    win32api.SetCursorPos((x,y))                                        # 移动鼠标位置
    win32api.mouse_event(win32con.MOUSEEVENTF_LEFTDOWN, x, y, 0, 0)     # 发出按下左键指令
    cv2.waitKey(np.int(t*1000))                                         # 等待我们计算好的延迟时间 *1000 是换算成毫秒
    win32api.mouse_event(win32con.MOUSEEVENTF_LEFTUP,x,y,0,0)           # 发出释放左键指令
    return

随便输入一个 x,y 只要位置在 Vysor 的窗口里面,都会被跳一跳识别。

测试了一下,跑出600多分,完美!
可是!!
第二天起床,尼玛分数被删了。看来是被腾讯发现我这渣渣根本玩不到这么高分,肯定是作弊了!怒删成绩!
一定是我的操作有问题,露出了破绽。

绕过防作弊

想绕过作弊检测,要先知道跳一跳是怎么检测作弊的。
我空想了一会,没有抓包,没有看代码,没有百度谷歌。列出几个比较容易判断作弊的方法:

  • 判断每一次的点击位置是否是固定的,固定的肯定有鬼,因为手指按压总会有位置的偏差
  • 判断每一次反应时间是相同的,相同则很有可能是外挂故意延时等待跳跃的过程结束
  • 判断手机动作传感器,是否检测到手机在移动或者有没有检测到点击屏幕引起的震动。如果整个游戏过程手机平放,没有点击屏幕,肯定是外挂在玩

先针对以上几个,对代码做了一些调整试试看:
点击位置小幅度随机变化一下,点击反应时间也随机,模拟我笨笨的手指和大脑,前两条轻松绕过。至于动作传感器,软件上没办法了,没关系,跑代码的时候拿在手上就好.

随机代码如下:


        # if everything is checked and we are ready to jump!
        fault = 0
        cv2.waitKey(100)                                # 稍微等一下,留时间给 opencv 绘图并输出 output image
        click_x = 288 + random.randint(-60, 60)         # 防作弊,变换点击位置
        click_y = 880 + random.randint(-20, 20)
        click(click_x, click_y, time)
        cv2.waitKey(2000 + random.randint(-100, 300))   # 防作弊,随机等待时间

经过以上修改,之后的测试,800+ 900+ 分数都不会被判断成作弊了(毕竟比人类dalao的成绩还差很远)。

2 完整代码

上面的代码都是一些比较关键的片段,没贴出来的代码是一些逻辑上的操作,比如,错误的处理,还有怎么判断“我”跳死了之后的处理,这些具体实现请看代码注释。

完整代码直接下附件,程序太长不直接贴上来了。代码遵循 GPL v2.0 开源协议。若遇事我不负责,你负责。
Jumper Python 源码下载 或者 GitHub

短视频 :YouTubeYouKu
测试视频:YouTubeYouKu

3 瞎 BB 时间

如果你对伦理或者程序细节感兴趣,欢迎继续听我瞎BB

你(TM)这就是外挂!

额,可能吧,如果这也算外挂,那这么多年来算是我写的第一个外挂了。那我水平也太菜了。

网上有无数的外挂,使用各种方式实现:
有破解了跳一跳与服务器之间通信的包,用的也是python,直接向服务器上传分数(你说这位 dalao 6不6), 参见《微信跳一跳万分攻(作)略(弊)》[4]
或者是 真·机器人 玩跳一跳 《机械手玩 “跳一跳” =w=》[5]
或者一把尺子做的物理外挂!《程序员叫你正确玩耍微信小程序游戏《跳一跳》》[6]

额,我想说,楼上dalao们,都是以专研学习的目的,没几个是像我一样因为手残太菜来而借学习的理由来做的这个“外挂”吧。再说,这套烂代码准确率差到爆,稳定性渣渣,始终没跑超过1000分(250步)。。连人类也超不过。

嗯,我也就是学习一下,又不挣钱

为何这套程序不能让“我”一直跳下去?

从技术上说,主要干扰有几个

  • 这套系统基于视觉识别,准确率本来就很难保证。Google 讲 TensorFlow 的课程中,95% 识别准确率提高到 97%,仅仅多了2个百分点,电脑的计算量就大了四五倍。
  • 因为Vysor软件用的USB传输,通信带宽有限,传送视频时画质太差如果画面变化太大,画质会很渣。所有的图形都毛躁了起来,很容易识别错
  • 代码量太少,判断条件不足。比如识别错“我”的位置,识别错落点,被连击炸起来的水花干扰。这些都是真实遇到的。这里短短200行代码,几个判断,很难保证识别和计算的准确率。想提高准确率,判断的数量估计是要指数倍增加了。
  • Vysor 这个软件是有延迟的,而且延迟不定,导致即使计算很精确,同一个距离,也会出现有时候跳得太远,有时候跳得太近的问题

提升空间

我最希望的还是能用机器学习去做这个事情,毕竟手工编写代码去一个一个条件判断,是非常麻烦的,而且判断的数量多起来后,增加判断的难度高收益低,基本是是事倍功零点零几的情况。

如果基于机器学习,还可以识别特殊目标,魔方,便利店,点唱机等,停留更长时间赚分。

如果能像dalao们一样,用ADB采集屏幕截图,并用ADB来输入触摸点击指令,就可以避免Vysor这个软件画质辣鸡的缺点。

4 结

  • 难度比我想象的大。
  • 程序和思维都很简陋,不过很欣慰它能完整跑起来了。
  • OpenCV 在这里面并没有太大作用,完全可以只用 numpy 来做。

不要期待,没有后续。

Reference:

[1] ‘Android 调试桥 | Android Studio’. [Online]. Available: https://developer.android.com/studio/command-line/adb.html#howadbworks. [Accessed: 20-Jan-2018].

[2]‘Hough transform’, Wikipedia. 19-Jan-2018.

[3]‘Canny算子’, 维基百科,自由的百科全书. 21-Nov-2017.

[4]“微信跳一跳万分攻(作)略(弊).” [Online]. Available: https://zhuanlan.zhihu.com/p/32473340. [Accessed: 22-Jan-2018].

[5]“使用python对微信小游戏跳一跳刷分.” [Online]. Available: https://zhuanlan.zhihu.com/p/32489227. [Accessed: 22-Jan-2018].

[6]“程序员叫你正确玩耍微信小程序游戏《跳一跳》_野生技术协会_科技_bilibili_哔哩哔哩.” [Online]. Available: https://www.bilibili.com/video/av17732221/?from=search&seid=193210722862806512. [Accessed: 22-Jan-2018].