[跳一跳] Nodejs + Opencv 版

7,907 阅读8分钟

赤裸裸的来蹭下热点。 微信跳一跳小游戏,风格简约,忍不住动心思自动跳一跳。代码阅读起来太费劲,决定写一篇文章描述一下自己的代码。

仅供练习nodejs技能,勿讨论作弊手段。

最终效果

内容

  • 使用的开箱即用工具

  • 游戏目标分析

  • 设备数据(借助别人github repo,非ADB)

    • 手机屏幕图像获取,同屏显示
    • 手机触摸事件发送
  • 图像处理

  • 技能点:

    • Electron-vue
    • Vue directives
    • Promise、 async/await
    • Nodejs Socket
    • koa + websocket
    • Opencv4nodejs


使用的开箱即用工具

  • Opencv4Nodejs nodejs 调用 opencv 库
  • openstf/minicap socket方式安卓设备屏幕截图图像流。Android 5.0 以上,stream输出帧率与设备一致。
  • openstf/minitouch 安卓设备 sendevent 替代者,实时性高。
  • electron-vue 使用electron直接与socket交互,并使用vue显示屏幕。

游戏目标分析

游戏中,小人蓄力时长决定弹跳距离,成功跳到下一个墩子,即加分。
目标即获取小人位置,获取目标点位置然后计算距离。
在做的过程中,发现,人物弹跳方向为斜向30度,未跳到中心点的情况下,偏移位置似乎不会导致游戏失败。
于是游戏目标简化为搜索小人位置,与搜索墩子中心点横坐标。
墩子中心点横坐标,与墩子顶点横坐标基本一致,只有一个长方形墩子不一致。
小人的圆形头部图像不变,使用opencv模板识别,直接能够准确搜索到人头位置。 所以游戏目标再简化为:

  1. 求弹跳的时间距离曲线。
  2. 求小人坐标。
  3. 求顶点坐标。

设备数据

手机屏幕图像获取,同屏显示

openstf/minicap,openstf/minitouch部署到安卓设备,然后通过adb启动socket,再通过adb连接socket,后续请求与发送数据不需要再次创建adb连接,实时性较好。

openstf 工具使用示意图

启动Socket : /src/renderer/util/adbkit.js#L77 async function startMinicap :

...
let command = util.format(
    'LD_LIBRARY_PATH=%s exec %s %s',
    path.dirname('/data/local/tmp/minicap.so'),
    '/data/local/tmp/minicap',
    `-P 1080x1920@360x640/${orientation} -S -Q ${quality}`
  )
  // `-P 540x960@360x640/${orientation} -S -Q ${quality}`
  status.tryingStart = true
  let stdout = await client.shell(device.id, command)
...

stdout 为标准输出的socket对象,后续加一个200ms内无错误即resolve的Promise,令startMinicap可正确await。
连接Socket,获取Stream/src/renderer/util/getStream.js#L6 async function liveStream:

...
var { err, stream } = await client
    .openLocal(device.id, 'localabstract:minicap')
    .timeout(10000)
    .then(out => ({ stream: out }))
    .catch(err => ({ err }))
...

获取stream ,然后使用on readable 事件取屏幕每帧图片,格式为jpeg压缩。

...
stream.on('readable', tryRead)
...

function tryRead #L50,其逻辑为解析stream每次读取到的buffer,按条件拼成jpeg raw buffer 。
此处可简单做限图像刷新频率处理 #L154

Vue 中使用 canvas 显示buffer图像

显示图像,可以方便的反馈判别结果。
上一步的socket,可以在electron中轻松import,并可以方便的将每一个framebuffer 赋值给 vm.screendata 。 使用vue监听screendata,即可实时将screendata显示到canvas中。
这里用到 vue 的 directives 。

<canvas v-screen='screendata' id='screen' :width="canvasWidth" :height="canvasHeight" :style="canvasStyle"></canvas>

MirrorScreen.vue#L584

...
directives: {
   screen(el, binding, vNode) {
     // console.info('[canvas Screen]')
     if (!binding.value) return
     // console.info('render an image ---- ', +new Date())
     let BLANK_IMG = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=='
     var g = el.getContext('2d')
     var blob = new Blob([binding.value], { type: 'image/jpeg' })
     var URL = window.URL || window.webkitURL
     var img = new Image()
     img.onload = () => {
       vNode.context.canvasWidth = img.width
       vNode.context.canvasHeight = img.height
       g.drawImage(img, 0, 0)
       // firstImgLoad = true
       img.onload = null
       img.src = BLANK_IMG
       img = null
       u = null
       blob = null
     }
     var u = URL.createObjectURL(blob)
     img.src = u
   },
    ...
}
...

使用 URL.createObjectURL为img生成一个src地址,然后将img画到canvas中。 定义 directives 时,vNode需要手动传入,不能直接用this

【
    此处,假装一个动态GIF: 
    stream.on('readable',function tryRead(){
        ...
        framedata = chunk.read()
        callback(framedata)
        ...
    })
    function callback (framedata){
      vm.screendata = framedata
    } 
    每一个framedata 赋给 vm.screendata, Canvas上显示的图像刷新一下。
 】

代码中同样使用directives做了一个辅助线层,用来显示辅助线,以及找到的点。

设备触摸事件发送
按照屏幕stream的方式,取得minitouch的socket,对socket按照minitouch README中格式进行write,即可完成触摸事件的模拟。
触摸时长的控制,通过控制touchdown与touchup的时间长度调节。兼容设备触摸事件,设定每超过200ms,进行原地touchmove一下。代码MirrorScreen.vue#L221
时间调节,通过async / await 实现。标准的api应用,似乎没什么可说的。

敲下地面
到此,准备好的工具,能够提供给我截图,画点,精确ms时长蓄力,于是我采集到了一些数据:

X = [0,50,100,150,200,250,300,700,1000]
Y = [0,33, 69, 90,144,177,207,516, 753]

线性回归

得到方程式,准确度非极致,但能够使用了。

f(x) = -6.232e-08 x^3 + 0.0001559 x^2 + 0.6601 x - 0.7638

图像处理

首先, open4nodejs 的使用。 opencv4nodejs 的README讲得挺全的。
最开始搜索node版opencv时,发现有2.4版本有3.0版本。这个repo使用的3.0版本,安装起来也很顺利。
README中,不同通道数的图像,根据坐标获取图像的颜色信息,创建一个形状等,描述的都很清楚。

找顶点的方式,想到了使用漫水法填充背景色,然后二值化+反色取到最靠上的顶点。
实际过程中会遇到:

  1. 小人比新出现的墩子高,或者小人跳到中心出现的波纹和加分字体比新墩子高。
    所以,加一步,用背景色覆盖小人及其上方部分。
  2. 墩子白色,或者浅绿色,与背景接近,使用OSTU二值化,效果不理想。 所以,加一步,
    设定颜色范围为80~255,
    如果有灰度值大于235(接近白色)的都直接变成80(底边界值)。 创建一个灰度化算法,与背景色在通道上差异较大者,远离背景灰度。通过buffer取10个像素RGB三个通道的平均背景色,然后每个元素与之做差求平方和。减少渐变影响,差在13以内,置为0。

然后,用此灰度图像,对背景进行漫水填充,闽值40,使用 BINARY_INV 方式,处理得到二值图。然后逐行搜索,找到顶点所在行。然后用数组方法,根据方差,对该行元素进行简易分类,得到最长连续像素范围,取中间值,即为顶点横坐标。

处理过程:

  1. 从frame中截取待处理区域
    原图
  2. 将小人用背景色覆盖
    用背景色绘制矩形,覆盖小人。
    背景色覆盖小人

    黑色正方形为最终找到的顶点位置。
  3. 使用自定义的灰度方法,将图片增强灰度化
    灰度化

    grayExt2.js#L8
  4. 高斯模糊+漫水填充背景。


高斯模糊能简易去除噪点儿影响

  1. 二值化
    图中最顶上一行,不规则。遇到顶点时,可能被消除。 所以取横坐标时,从最上一行向下数n=3行,来计算。得到结果如前边图像所示。有偏差,但在可接受范围内。

同样方式可以识别小药瓶:

识别小药瓶

识别小人的位置

使用opencv的templateMatch方法,可快速得到结果 findTarget2.js#L11

... 
let ballMat = cv.imread(path.resolve(__dirname, '..', 'ball.jpg'), 0)  # 小人头部为固定图片
...
let { maxLoc: ballPoint } = colorMat
    .bgrToGray()
    .matchTemplate(ballMat, 3)
    .minMaxLoc()
...

结果中取maxLoc即可得到小人底座位置存入变量ballPoint。每次取小球位置太准确了,以至于没有写异常捕捉。

其他技术点

  • 使用 electron-vue 创建直接与socket交互的应用,并对外提供socket,用来获取当前图像。
  • 使用 koa + vue,创建一个手动分析当前图像的web界面。opencv在此server中。
  • 图片分析,取最大连续分类的算法: findTopXY.js#L24~L56 使用了数组方法,对当前行元素进行了简单的分类。
  • electron-vue 每次调试会刷新,容易造成多次启动安卓二进制文件造成adb卡死,遂将部分逻辑放在外部server中。server间交互使用socket。这里使用 new Promise(r=>{cachedArray.push(r)}).then(...) 的方式,变种使用promise,完成socket返回数据之后继续执行代码逻辑。实现先蓄力,然后 n 毫秒之后返回处理结果,再判定弹跳时间。

TODO

  • [ ] 整理server,使用此辅助完全非开箱即用。
    含有buffer内容的数据传输,改为flatbuffer方式。

不足与总结

这个辅助应用,是自己把所了解的技能连续堆积完成的,比demo大了。
此工具完全非开箱即用: electron 部分opencv部分

不足

  • 中心位置跳偏,没有做修正。
  • webpack 掌握欠缺,未配置 koa 热部署
  • 使用图像处理取得顶点位置花费的时间,似乎比将每个墩子顶面截图使用templateMatch方法还要长。
  • 缺少代码组织套路,代码可读性待提高。

总结

熟练了socket的使用、buffer的操作,熟悉了opencv的基本使用、vue directives的使用。尝试了使用python。

最后。
实时性效果,坊一个以前的没有opencv的自动极速变色龙的视频:
youtu.be/7YSpqiYZJ0w