玉手箱の空欄推測問題をPythonで解いてみた 〜文字認識編（pyocr）〜

# はじめに
玉手箱の空欄推測問題を解いてみたの文字認識編です．[前回](https://qiita.com/sekishoku/items/4d92856139d1a5a943ea)のGUI編で値の入力をするインターフェースは作りましたが，一々値を入力するのも面倒くさいので，どうせなら表を読み取って自動で値を入力するようにしてしまいます．

# 文字認識部分
Pythonには既に文字認識を行えるpyocrというライブラリが存在するので，それを使うことにしました．少しインストール手順が面倒くさいです．

インストールしてしまえば後は簡単で，ほんの数行で画像から文字列を抽出してくれます．pyocrに登録されているエンジンを使って，それに画像を与えるだけです．認識する言語も指定できlang引数で指定します．今回は日本語を想定しているので，jpnを与えています．
```Python
import pyocr
from PIL import Image

engine = pyocr.get_available_tools()[0]
img = Image.open('test.png')
txt = engine.image_to_string(img, lang="jpn")
```

下の図のような表を与えると，
<img width="600" alt="tama.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/407395/2f4fa4a2-6dc0-8c0c-354c-feed079dcd8a.png">
(https://jyosiki.com/spi/tama31_a.html より引用)
と返してくれます．若干認識精度が低いのがネックですね．あとはうまく文字列処理をして数字のみを抜き取ります．
```Python
A 市      B 市      G 市      D 市      E 市
運賃 (円)            500    520    560    585 | ?
フェリーの本数(本/日)        10        12         8         9        11
乗客数(人/日)             412      520      410      440      471
フェリー乗車時間(分)          57       56       55       57       55
```

# 画像取得部分
次に，画像認識部分に送るための表がある領域の画像を取ってくる部分を実装します．この図のような感じで，画面のスクリーンショットから表がある場所を矩形で範囲選択して，その部分を画像として取ってきます．
<img width="600" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/407395/0f4caf15-1d90-19eb-adc0-c39ec20edadd.png">

そのためには，GUIとして表示させているものとは別に，画面のスクリーンショットのみ表示させるためのウインドウを作ります．
```Python
sub_window = tk.Toplevel()
```
このように呼び出すことで，新しく作ったウインドウをGUIのウインドウと連動させることができるようになります．

## スクリーンショット取得・表示
まず，pyautoguiというライブラリで画面のスクリーンショットを取ります．これを適切な大きさに縮小した後，tkinterのCanvasに表示させます．このCanvasはお絵描きができるものだと思ってください．矩形を描画するためにここに表示します．
```Python
import tkinter as tk
import pyautogui


raw_screenshot = pyautogui.screenshot()
resized_screenshot = \
	raw_screenshot.resize(
		size=(raw_screenshot.width // self.SCALE_RATIO, 
			raw_screenshot.height // self.SCALE_RATIO),
		resample=Image.BILINEAR
	)

screenshot_tk = ImageTk.PhotoImage(resized_screenshot)
canvas = tk.Canvas(
	sub_window,
	width=resized_screenshot.width,
	height=resized_screenshot.height,
)
canvas.create_image(0, 0, image=screenshot_tk, anchor=tk.NW)
canvas.pack()
```

## 領域選択
領域を選択できるようにするために，Canvasに対する操作に対してどのような処理を行うかを定義していきます．それぞれ，マウスをクリックしたとき，マウスを動かしたたとき，マウスのボタンを離したときの処理です．
``` Python
canvas.bind("<ButtonPress-1>", start_point_get)
canvas.bind("<Button1-Motion>", draw_rect)
canvas.bind("<ButtonRelease-1>", release_action)
```

それぞれの処理は以下のようになります．マウスがクリックされたときクリックの開始位置を記録，描画する矩形の初期化を行います．マウスを動かす処理では，マウスの位置を更新し，マウスのクリック開始位置と現在のマウス位置から矩形を描画します．マウスのボタンが離されると離した位置でマウスの最終位置が固定されます．これらCanvasの処理の関数はそれぞれ引数にeventをもたせる必要があり，ここからマウスの現在位置を取得することができます．
```Python
def start_point_get(event):
	canvas.delete("rect")
	canvas.create_rectangle(
		event.x, event.y,
		event.x+1, event.y + 1,
		outline="red", tag="rect"
	)
	start_x, start_y = event.x, event.y

def draw_rect(event):
	if event.x < 0:
		end_x = 0
	else:
		end_x = min(resized_screenshot.width, event.x)
	if event.y < 0:
		end_y = 0
	else:
		end_y = min(resized_screenshot.height, event.y)

	canvas.coords("rect", start_x, start_y, end_x, end_y)

def release_action(event):
	start_x, start_y, end_x, end_y = [
		round(n * SCALE_RATIO) for n in canvas.coords("rect")
	]
	start_x, end_x = min(start_x, end_x), max(start_x, end_x)
	start_y, end_y = min(start_y, end_y), max(start_y, end_y)
```
## 画像のトリミング
最後に選択した範囲の画像をトリミングする処理を実装します．まず，選択した範囲をReturnキーを押すことで確定させるために，Canvasに処理を定義します．ここで，Canvasは本来キーを押す処理には対応していないため，別個canvas.focus_set()をして，キー入力を受け付けるようにしなければなりません．
```Python
canvas.focus_set()
canvas.bind("<Return>", get_screenshot)
```
トリミング処理はcrop()メソッドを使って矩形の位置からそのまま取ってきます．最後にスクリーンショットを表示しているウインドウを閉じる処理を行っています．
```Python
def get_screenshot(event):
	cropped_screenshot = \
		raw_screenshot.crop((start_x, start_y, end_x, end_y))
	sub_window.destroy()
```

# コード全体
以上の処理をまとめたコード全体は以下のようになります．全体を一つのクラスとしてまとめています．
```Python
from PIL import Image, ImageTk
import pyocr
import numpy as np
import tkinter as tk
import pyautogui
from utils import is_float


class TableReader:
	SCALE_RATIO = 2

	def __init__(self, frontend):
		self.frontend = frontend
		self.engine = pyocr.get_available_tools()[0]

	def read_table(self):
		self.sub_window = tk.Toplevel()
		self.sub_window.title('Press Enter to confirm')

		self.raw_screenshot = pyautogui.screenshot()
		self.resized_screenshot = \
			self.raw_screenshot.resize(
				size=(self.raw_screenshot.width // self.SCALE_RATIO, 
					self.raw_screenshot.height // self.SCALE_RATIO),
				resample=Image.BILINEAR
			)

		screenshot_tk = ImageTk.PhotoImage(self.resized_screenshot)
		self.canvas = tk.Canvas(
			self.sub_window,
			width=self.resized_screenshot.width,
			height=self.resized_screenshot.height,
		)
		self.canvas.create_image(0, 0, image=screenshot_tk, anchor=tk.NW)
		self.canvas.pack()

		self.canvas.bind("<ButtonPress-1>", self.start_point_get)
		self.canvas.bind("<Button1-Motion>", self.draw_rect)
		self.canvas.bind("<ButtonRelease-1>", self.release_action)
		self.canvas.focus_set()
		self.canvas.bind("<Return>", self.get_screenshot)
		self.canvas.bind("<Escape>", self.destroy_window)
		self.canvas.bind("q", self.destroy_window)
		self.sub_window.mainloop()
	
	def start_point_get(self, event):
		self.canvas.delete("rect")

		self.canvas.create_rectangle(
			event.x, event.y,
			event.x+1, event.y + 1,
			outline="red", tag="rect"
		)
		self.start_x, self.start_y = event.x, event.y

	def draw_rect(self, event):
		if event.x < 0:
			self.end_x = 0
		else:
			self.end_x = min(self.resized_screenshot.width, event.x)
		if event.y < 0:
			self.end_y = 0
		else:
			self.end_y = min(self.resized_screenshot.height, event.y)

		self.canvas.coords("rect", self.start_x, self.start_y, self.end_x, self.end_y)
	
	def release_action(self, event):
		self.start_x, self.start_y, self.end_x, self.end_y = [
			round(n * self.SCALE_RATIO) for n in self.canvas.coords("rect")
		]
		self.start_x, self.end_x = min(self.start_x, self.end_x), max(self.start_x, self.end_x)
		self.start_y, self.end_y = min(self.start_y, self.end_y), max(self.start_y, self.end_y)

	def get_screenshot(self, event):
		cropped_screenshot = \
			self.raw_screenshot.crop((self.start_x, self.start_y, self.end_x, self.end_y))
		
		txt = self.engine.image_to_string(cropped_screenshot, lang="jpn")
		lines = txt.split('\n')
		table = []
		for line in lines:
			values = line.split()
			row = []
			for value in values:
				value = value.replace(',', '')
				if is_float(value):
					row.append(float(value))
				elif value == '?':
					row.append(np.nan)

			if len(row) > 0:
				table.append(row)

		self.frontend.update_table(table)
		
		self.destroy_window()

	def destroy_window(self, event=None):
		self.sub_window.destroy()
```

# おわりに
自動で値を読み取れるとなんか強そうな感じが出ますね．

画像取得に関してはこの[記事](https://qiita.com/8_hisakichi_8/items/47f6d37e6f425f29c8a8)をとても参考にしました．

[玉手箱の空欄推測問題をPythonで解いてみた](https://qiita.com/sekishoku/items/fa1f2d852523cb6f3835)
[玉手箱の空欄推測問題をPythonで解いてみた 〜GUI編（Tkinter）〜](https://qiita.com/sekishoku/items/4d92856139d1a5a943ea)
[玉手箱の空欄推測問題をPythonで解いてみた 〜文字認識編（pyocr）〜](https://qiita.com/sekishoku/items/532e34981da9126b737e)
[玉手箱の空欄推測問題をPythonで解いてみた 〜ソルバー編〜](https://qiita.com/sekishoku/items/9bb316e982769fa800c3)