程序员玩连连看的正确姿势

too young too simple

一、绝地反击

最近女票迷上了某平台的连连看对战小游戏,于是免不了要找哥哥我 PK 一翻,虽然是被迫卷入战争,但是以朕惊世骇俗的智商,那当然是胜券在握啦~~
没曾想,几个回合下来,竟被啪啪啪打脸,快把这个月的口粮都输光了(每把5块钱啊,肉疼!!)
哎,完全拼手速是没有希望的了,得想办法让连连看自动打

“连连看”都不会打的直男们,赶紧去怼一局

二、可行性

前段时间跳一跳火起来的时候,有人就通过 adb 截屏并发送到电脑分析,再求得距离然后计算出按键时长,最后通过 adb shell 自动按键,从而获得完美跳跳分,这一招用在连连看是否管用呢?
理论上,靠谱,分解如下:

  1. adb 截图传到电脑
  2. 将连连看的点击区域识别为一个二维矩阵,每一种小动物用一个数字表示
  3. 对二维矩阵求解,计算出每个位置的点击顺序数组
  4. 通过 adb shell 一把梭,一次性点掉所有

酱紫如果顺利的话并且不被女票发现,赢回三个月的口粮都很有希望呀~~

三、实施步骤

技术选型

从上一节的分析来看,方案的实施涉及到很多图片的分析处理,Python 可以方便的调用很多图片库,而且网上也有很多作业可以抄,所以选择基于 Python 来做

环境搭建

没有很具体的安装步骤,需要的咨询谷歌哥

1) 安装 adb 环境。安装完成后,用数据线连接一台 android 手机,执行一些简单的 adb 命令预热下

// 是否连接上
adb devices

// 可否截屏保存
adb shell /system/bin/screencap -p /sdcard/screenshot.png
adb pull /sdcard/screenshot.png /yourDocuments/screenshot.png

// 可否点击屏幕
adb shell input tap 100 100

2)安装 python 和相关的图片库,在安装 openCv 的时候还踩了个大坑,记录了下,仅供参考

图片处理

1)截屏保存
在终端,执行如上的两个 adb 命令就可以截屏保存了,也就是说,这里需要一个可以调用终端命令,同时可以等待返回的 Python 方法:

// 执行终端命令的方法
def sh(command):
    p = subprocess.Popen(command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
    print p.stdout.read()

// 截屏保存
sh('adb shell /system/bin/screencap -p /sdcard/screenshot.png')
sh('adb pull /sdcard/screenshot.png /yourDocuments/screenshot.png')

2)裁剪有效区域、再等比切出小动物头像
如果不要求很通用只是对你的手机有效的话,那么只需要将第一步截下来的屏幕用工具来量一量(如 Mark Man),就可以用如下方式裁剪出有效区域

from PIL import Image
def cut (im, x, y, w, h, name):
  region = im.crop((x, y, x+w, y+h))
  region.save("./screenshot/" + name + ".png")

# 有效点击区域裁剪 (不通用的做法是,把这个矩形的坐标量出来)
gx = 43
gy = 401
gw = 993
gh = 1420
cut(Image.open("./screenshot/screenshot.png"), gx, gy, gw, gh, 'main')
有效区域

如果要做得通用一些,就需要计算图片的比例了(只用于打败女票的,完全没必要嘛)

然后再按照10行7列切成小块,并且根据二维数组的下标命名

# -*-coding:utf-8-*-
from PIL import Image
import cutImg
def cut ():
  im = Image.open("./screenshot/main.png")
  # 图片的宽度和高度
  img_size = im.size
  width = img_size[0]
  height = img_size[1]
  distanceW = width / 7
  distanceH = height / 10
  print(distanceW, distanceH)
  x = 0
  y = 0
  for num in range(0, 10):
    for i in range(0, 7):
      x = distanceW * i
      y = distanceH * num
      name = str(num) + str(i)
      cutImg.cut(im, x + 15, y + 15, distanceW - 20, distanceH - 20, name)
  return [distanceW, distanceH]

小动物头像

3)解析小动物头像输出数字二维矩阵(第一回合)

这一步着实需要下功夫,还踩了不少坑~~

首先想到的是通过求解图片的 hash 值,利用 hash 值来比对图片的相似度(例如感知 hash 算法)。网上有各种求 hash 值的算法,实现起来倒也简单,但是,比较的正确率只能达到百分之七、八十(这样我们分析出的点击路径,肯定打不过啦!!),主要是这些小动物头像在 hash 算法下显得都太相似了,拿感知哈希算法来说:
a) 缩小图片尺寸
b) 转为灰度图片
c) 计算灰度平均值
d) 比较像素的灰度
e) 计算哈希值
f) 对比图片指纹

小猪头

小猴头

想象一下,上面的小猪头和小猴头经过如上的变换后,还有多少差异呢?

转念一想,这个问题在机器学习领域,不过是那种最最简单的分类问题,so,完全可以先训练一个模型出来

4)解析小动物头像输出数字二维矩阵(第一回合)
Turicreate 是苹果开源的基于 python 机器学习框架,特点是轻量(只是分类相似的图片而已,当然是越简单越好),先安装之

然后将上面写好的截屏裁剪代码多执行几次,手工分类,准备好训练数据:


分类存储

0

给每种小动物创建一个文件夹,再将所有该种类的动物装进去

开始训练,并保存模型:

#!/usr/bin/env python
#encoding=utf-8
import turicreate as tc
img_folder = 'data'
// 导入数据
data = tc.image_analysis.load_images(img_folder, with_path=True)
// 使用文件名来做标签
data['label'] = data['path'].apply(lambda path: path.split('/')[len(path.split('/')) - 2])
data.save('doraemon-walle.sframe')
// 百分之八十的数据用于训练,百分之二十用于测试
train_data, test_data = data.random_split(0.8, seed=2)
// 开始训练模型
model = tc.image_classifier.create(train_data, target='label')
// 测试模型
predictions = model.predict(test_data)
metrics = model.evaluate(test_data)
// 输出测试结果
print(metrics['accuracy'])
model.save('my_model_file')

执行到倒数第二行的时候,顺利输出1.0(百分百的正确率有木有):


正确率100%

使用训练好的模型,输出二维矩阵:

import turicreate as tc
loaded_model = tc.load_model('my_model_file')
def getDataset():
  data = tc.image_analysis.load_images('screenshot', with_path=True)
  arr = loaded_model.predict(data)
  result = []
  temp = []
  for index in range(len(arr)):
    if (index % 7 == 0):
      temp = []
    if ((index + 1) % 7 == 0):
      result.append(temp)
    // f 为 0,标记为未删除
    temp.append({'v': int(arr[index]), 'f': 0})
  return result

路径求解

1)判断两个动物图标可连
需要满足如下条件:
a) 相同的图标
b) 两种直接存在一条通路,它是一条只经过没有图案的地方、且转折点不超过2个的折线
具体代码实现可以看看这篇博文的分析(虽然是 C 版),这里我就不贴了,繁琐占篇幅

2) 搜索路径,最简单粗暴的一种做法
(1)从矩阵中挑出一个未被标记为删除的元素,(2)再从矩阵中余下的不被标记删除的元素寻找一个跟它一样的元素,判断是否可以相连,是则将两个元素标记为删除,并将点击坐标压入坐标数组,否则重复(2),(3)重复(1),知道找到所有的点击坐标点
但是这种做法是 O(nXn),很遗憾,暂时也没有想到更好的办法,只是想到了一个小小的优化策略,开始先遍历一轮,将所有挨着的相同图标消掉(显而易见的事情当然要先办啦),减小 N,节省一下算法的时间

然后在“盲狙”的过程中,因为循环停止的条件是找到所有的坐标点,假如游戏给了个无解的矩阵,或者咱们图片识别错了导致无解,就会陷入死循环(虽然这样的概率极低,没遇到过),所以要做一下循环保护

# 遍历消除(盲狙)
def commonBuild():
  global data
  global pos
  for num in range(0, 10):
      for i in range(0, 7):
        item = data[num][i]
        if (item['f'] == 1):
          continue
        for ix in range(0, 10):
          if (item['f'] == 1):
            break
          for iy in range(0, 7):
            item1 = data[ix][iy]
            if (item1['f'] == 1 or item1['v'] != item['v'] or (ix == num and iy == i)):
              continue
            if (remove.canRemove(num, i, ix, iy, data) == 1):
              item['f'] = 1
              item1['f'] = 1
              pos.append(getPos(i, num))
              pos.append(getPos(iy, ix))
              break
// 达到 70 也即所有的坐标都找到即停止
// 否则也最多循环十次
count = 0
while (len(pos) < 70 and count < 10):
  count = count + 1
  commonBuild()
print(count)

很幸运,经过优化后的算法,基本上每次 count 都输出为 1,不需要遍历太多次。假如真的出现了无解矩阵,循环了 10 次退出了,那该如何是好呢?这个时候自己将机器没有打完的点掉也应该没有难度了

adb 一把梭

克服艰难险阻把坐标数组计算出来之后,后面的事情就简单了,执行 adb 命令一把梭

for index in range(len(pos)):
  command = 'adb shell input tap ' + str(pos[index][0]) + ' ' + str(pos[index][1])
  print(command)
  os.system(command)

四、后来,我赢了么?

然而并没有!!!
因为每条 adb 命令的执行间隔基本差不多要到 1 秒,逐条执行完之后黄花菜都凉了,要知道正常人打完一局也就 30、40 秒,作为机器人,这打完居然要 1 分多钟,真是弱智机器人

尝试将命令写入一个 sh 文件,然后通过 adb shell 执行批处理文件,稍微快了一点点,但是依然还需要几十秒(在此之前还尝试写一个堡垒 app 来一次性接收坐标,然后再 android 系统中执行命令,都木有用)。然后优化分析算法的动力都木有了

不过话说回来,跟女票打游戏,还要用赢的么?

推荐阅读更多精彩内容