PythonにBrain Warsをプレイさせてみた2

# 概要
[前回の投稿](https://qiita.com/drafts/72990f9ddd17428f2a65/edit)の続編。今回は違うゲームをプレイしてみた。

# 今回プレイするゲーム
今回は`High or Low`をプレイしてみた。表示された数字が、１つ前の数字よりも大きければ上にSwap、小さければ下にSwapするゲーム
<img src="https://qiita-image-store.s3.amazonaws.com/0/185448/c88ff617-bfbe-6a4f-8194-5d87b684fecb.png" width=40%>

# プログラム動作
こんな感じで動作します。
<img src="https://qiita-image-store.s3.amazonaws.com/0/185448/d6ebc869-1136-1c15-5f56-da486c2f15ac.gif" width=60%>



流れはざっくりとこんな感じ↓
1. カードが表示されている領域をキャプチャ
2. 数字の部分を抽出
3. 事前にTrainingしておいた分類器で数字(0 ~ 9)の判定
4. 判定結果をもとに、上 or 下方向にスワップ
5. 1~4をゲームが終わるまで繰り返す

# 解説
どのようにプログラムを実装したか順番に解説していく。

## 環境
- OS: Ubuntu 18.04.1 LTS
- Python version: 3.6.5


## 使用した主なライブラリと役割
1. `mss`: スクリーンキャプチャ
2. `opencv`: 数字部分の抽出や座標計算
3. `scikit-learn`: 数字の分類
4. `pyautogui`: スワップ自動化


## 1. カード表示領域のキャプチャ
前回と同じく、[`mss`](https://python-mss.readthedocs.io/index.html)を使用した。

## 2. 数字部分の抽出
`OpenCV`の`findContours`と`boundingRect`を使って、あとは適当に閾値を定めてやれば、下図のように数字の部分のみを抽出することができる。抽出した領域は、10 x 10 に resize してから、(1, 100) に reshape する。reshape後の vector を分類器に渡して、数字の判定を行う。最初は Google の[Tesseract OCR](https://opensource.google.com/projects/tesseract)を試めしてみたのだが、上手く行かなかった（"44"が"AA"と認識されてしまっていた、、、）ので、`k-nearest neighbors`に切り替えた。数字の形状が毎回大きく変わるわけではないので、精度的には全く問題無かった。
<img src="https://qiita-image-store.s3.amazonaws.com/0/185448/6e421a04-67f6-1bef-90ca-f64ab4b5b94e.jpeg" width=30%>


## 3. Training Data収集
自ら何度かプレイして、Training用の画像を収集。[この記事](https://stackoverflow.com/questions/9413216/simple-digit-recognition-ocr-in-opencv-python)を参考にして、収集した画像のラベリングを行った。

■ラベリングの流れ
1. 数字領域を抽出
2. 10 x 10にresize
3. (1, 100)にreshapeして、保存用の配列に格納
4. 対応する数字をキーボードから入力して、こちらも保存用の配列に格納
5. 最後に`np.savetxt`で保存

<img src="https://qiita-image-store.s3.amazonaws.com/0/185448/64d76a0a-aba1-d0fb-55c8-a7c5d97bb8b8.gif" width=50%>

Training用の画像
<img src="https://qiita-image-store.s3.amazonaws.com/0/185448/89e61954-bad9-712b-b8f0-ad582c24cca8.png" width=50%>

ラベリングに使ったコード

```python
import os
import cv2
import glob
import sys
import numpy as np

def main():
	samples =  np.empty((0, 100))
	responses = []
	keys = [i for i in range(48, 58)]
	
	for i, img_path in enumerate(glob.glob('samples/*.jpg')):
		img = cv2.imread(img_path)
		img_gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
		_, img_bin = cv2.threshold(img_gray, 235, 255, cv2.THRESH_BINARY)
		_, cnts, _ = cv2.findContours(img_bin, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)
		cnts = sorted(cnts, key=lambda x: cv2.boundingRect(x)[0])

		for cnt in cnts:
			if cv2.contourArea(cnt) > 100:
				x, y, w, h = cv2.boundingRect(cnt)
				if h > 40 and h < 90:
					img_rect = cv2.rectangle(img.copy(), (x, y), (x + w, y + h), (0, 0, 255), 2)
					roi = img_gray[y:y + h, x:x + w]
					roi_resized = cv2.resize(roi, (10, 10))
					print(i)
					cv2.imshow('norm', img_rect)
					key = cv2.waitKey()
					if key == 27:  # (escape to quit)
						cv2.destroyAllWindows()
						exit()
					elif key in keys:
						responses.append(int(chr(key)))
						sample = roi_resized.reshape((1, 100))
						samples = np.append(samples, sample, 0)

	responses = np.array(responses, np.float32)
	responses = responses.reshape((responses.size, 1))
	print('Training complete')
	np.savetxt('samples.data', samples)
	np.savetxt('labels.data', responses)


if __name__ == '__main__':
	main()

```
## 4. 分類器のモデル
モデルには`scikit-learn`の`KNeighborsClassifier`を使い、`3`で作ったデータでTrainingさせた。


## 5. スワップ
分類器の結果をもとに、スワップ方向（上 or 下）を決定して、`pyautogui`を使ってスワップを実行する。


## コード

```python
import mss
import numpy as np
import time
import cv2
import pyautogui as pag
from sklearn.neighbors import KNeighborsClassifier


def grab_screen(bbox):
	"""Capture the specified area on the screen"""
	with mss.mss() as sct:
		left, top, width, height = bbox
		grab_area = {'left': left, 'top': top, 'width': width, 'height': height}
		img = sct.grab(grab_area)
		return np.array(img)[:, :, :3]  # convert BGRA to BGR


def build_knn():
	"""Create and train knn classifier"""
	samples = np.loadtxt('samples.data', np.uint8)
	labels = np.loadtxt('labels.data', np.float32)
	knn = KNeighborsClassifier(n_neighbors=3)
	knn.fit(samples, labels)
	return knn


def process_img(img):
	"""Extract number regions"""
	img_gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
	img_blur = cv2.GaussianBlur(img_gray, (5, 5), 0)
	_, img_bin = cv2.threshold(img_blur, 235, 255, cv2.THRESH_BINARY)
	_, cnts, _ = cv2.findContours(img_bin, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)
	cnts = sorted(cnts, key=lambda x: cv2.boundingRect(x)[0], reverse=True)

	img_rect = img.copy()
	rois = np.empty((0, 100))
	for cnt in cnts:
		x, y, w, h = cv2.boundingRect(cnt)
		if h > 40 and h < 90:
			roi = img_bin[y:y + h, x:x + w]
			roi = cv2.resize(roi, (10, 10)).reshape(1, 100)
			rois = np.append(rois, roi, 0)
			img_rect = cv2.rectangle(img_rect.copy(), (x, y), (x + w, y + h), (0, 0, 255), 2)
	return rois


def main():
	grab_area = (231, 410, 446 - 231, 617 - 410)
	cx = grab_area[0] - 20
	cy = grab_area[1] + grab_area[3] / 2
	pag.moveTo(cx, cy)
	knn = build_knn()
	num_prev = 0

	while True:
		img = grab_screen(grab_area)
		rois = process_img(img)
		if len(rois) == 0:
			print('Done!')
			break
		nums = knn.predict(rois)
		num_now = (nums * np.logspace(0, 1, len(nums), base=10)).sum().astype(np.uint8)
		print(num_now)
		if num_now >= num_prev:
			pag.mouseDown(button='left')
			pag.mouseUp(button='left', x=cx, y=cy - 50)
		else:
			pag.mouseDown(button='left')
			pag.mouseUp(button='left', x=cx, y=cy + 50)

		pag.moveTo(cx, cy)
		num_prev = num_now
		time.sleep(0.4)


if __name__ == '__main__':
	main()

```