リモート開発メインのソフトウェア開発企業のエンジニアブログです

OpenCVでなにか作る

前回記事にしたOpenCVの続編。

OpenCVについてとざっくりとした環境は前回の記事を参照してください。

前半は文字検出と画像検出についてで、後半は今までの機能を利用してなにか動くものを作ろうという内容になります。

前回と同様に軽い解説とコード例を記載しています。

準備

前回インストールしたものは既にインストールされているという前提で書かれております。

  • pip install pyocr
  • brew install tesseract
  • brew install tesseract-lang

後半のアプリを実行する場合は以下もインストールが必要。

  • pip install pyautogui
  • pip install ‘ocrd-fork-pylsd == 0.0.3’
    • この pylsd というライブラリはPython3に対応していないためこのフォークを利用してます。
    • 既に pip install pylsd でpylsdをインストールしてしまっていると名前衝突が起きてしまうことがあるので削除を推奨します。

文字の検出と抽出

import sys
import pyocr
import pyocr.builders
import cv2
from PIL import ImageGrab

def imageToText(src):
    # image_to_stringを使いたいので呼び出す
    tools = pyocr.get_available_tools()
    if len(tools) == 0:
        print("No OCR tool found")
        sys.exit(1)
    tool = tools[0]
    # 文字の検出
    return tool.image_to_string(
     	# image_to_stringを使うためにPILを使って画像を読み込む
        ImageGrab.open(src),
        # 検出文字列 eng jpn eng+jpn etc...
        lang='jpn',
        # 検出方針
        builder=pyocr.builders.WordBoxBuilder(tesseract_layout=6)
    )
# 文字検出させたい画像のパス
img_path = 'moji_test.png'
# 文字検出
out = imageToText(img_path)
# 画像読み込み
img = cv2.imread(img_path)

sentence = []
for d in out:
    # 検出した文字を繋げる
    sentence.append(d.content)
    # 検出した文字を赤線で囲う
    cv2.rectangle(img, d.position[0], d.position[1], (0, 0, 255), 2)
# 結果が横一列になって見づらいので句点で改行して表示
print("".join(sentence).replace("。","。\n"))
# 文字を赤線で囲った結果を表示・出力
cv2.imshow("img", img)
cv2.imwrite("output.png", img)
cv2.waitKey(0)
cv2.destroyAllWindows()

文字の検出自体はライブラリ(pyocr)のimage_to_stringが勝手にやるので簡単ですが、実際に使おうとすると正確に検出させるための前準備や適性な検出方針の選択で苦戦しました。

検出方針(tesseract_layout)は0〜10の指定ができます。

こちらの記事の方がまとめてくれてました。http://tanaken-log.blogspot.com/2012/08/imagemagick-tesseract.html

画像検出(テンプレートマッチング)

テンプレートマッチングとは画像の中から指定の画像に類似したものが存在するか検出するパターンマッチング処理です。

例えば大量の正方形といくつかの星が描いてある絵があったとして、その絵から星を検出させるテンプレートマッチングを行うと星の描いてある座標が全て取得できます。

import numpy as np
import cv2

# 検出対象の画像を読み込む
img = cv2.imread('sample.png')
img_gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)

# 画像を読み込む
template = cv2.imread('template.png')
template_gray = cv2.cvtColor(template, cv2.COLOR_BGR2GRAY)

# テンプレートマッチング実行
res = cv2.matchTemplate(img_gray, template_gray, cv2.TM_CCOEFF_NORMED)

# 類似度の高い部分を検出する(一致率8割以上の類似で検出を許容)
threshold = 0.8
loc = np.where(res >= threshold)
# マッチした箇所の座標を確認
print(loc)

まずOpenCVの matchTemplate の第1引数に調べたい画像、第2引数に検出したい画像(テンプレート)を入れます。

第3引数はメソッドの指定らしいですが cv2.TM_CCOEFF_NORMED 以外わからなかったのでこのまま使ってます。

テンプレートマッチングの結果は(正確にはよくわからないのですが)一致率とその箇所の座標が返ってきます。

そこから np.where で一致率8割以上のものだけに絞ります。

結果は array[ array[x1, x2, x3…], array[y1, y2, y3…]] という感じでそれぞれ検知した結果のX座標とY座標を別の配列に入れているため結構分かりにくく出力されます。

cv2.minMaxLoc(res) とすれば類似度が最も高いものだけを抽出することもできます。

こちらは座標と一致率だけを返すので分かりやすいです。

なにか作ってみる

前回の記事からOpenCVに備わった機能や関連するライブラリの機能などを試してみました。

これらの機能から何が作れるのかを考えたところ、タイピングゲームの画面をキャプチャーしながら文字を自動検出させて入力させる機能を思いついたので作って見ました。

使わせていただいたのはECCタイピング其の弐というタイピングゲームです。

タイピングする文章が3回に分けて出題される非常にシンプルなゲームです。

こちらのゲームで表示された文章を自動認識して自動でキーボード入力を行わせました。

動作の流れ

このアプリの動作は以下の処理がループします。

① 全画面スクリーンショットを取る

② ゲーム開始を検出して課題が表示される座標を検出する(開始してなければ①に戻る)

③ 全画面スクリーンショットを取る

④ ②で取得した座標に合わせてスクリーンショットを切り抜く

⑤ 切り抜かれた画像を加工してアルファベットを認識させやすくする

⑥ 文字認識で文字列を検出(認識に失敗した場合は文字認識の設定を変更して再度実行する)

⑦ 検出したアルファベットのキーを叩く

⑧ ③に戻る

アプリを実行すると上記の流れがwhileで永久ループします。

各処理について

各処理についてですが、今までの内容で触れてない機能や少し悩ましかった部分以外のみ詳細に書きます。

①の全画面スクリーンショットについてはなんでもいいです。

PILでもscreencaptureでもpyautoguiでもmssでもなんでも。

②のゲーム開始検知はテンプレートマッチングを利用して判別します。

ゲームを開始すると画面に巻物のようなものが表示されて中に課題が現れます。

この巻物の左端の部分をテンプレートとして用意しておき、これが検出されたらゲーム開始と判別します。

検出時にそのX座標とY座標を取得して課題の文章が出る座標を算出します。(PC版なので必ず同じ座標に表示されます)

③の全画面スクリーンショットですが、こちらはゲームプレイ中に何度も行うため出来るだけ早くスクリーンショットを取得したいです。

Pythonでスクリーンショットを取る方法は色々あるのでメジャーっぽい3つの方法でどれが一番動作が早いかを検証しました。

import time
import pyautogui
from PIL import ImageGrab
import mss

def test1():
    pyautogui.screenshot()

def test2():
    ImageGrab.grab()

def test3():
    output_filename = "screenshot.png"
    with mss.mss() as mss_instance:
        mss_instance.shot(output=output_filename)

# テスト開始
start_time = time.perf_counter()
test1()
end_time = time.perf_counter()
# 経過時間を出力(秒)
elapsed_time = end_time - start_time
print(elapsed_time)
# 同じようにtest2と3を試す

結果
test1 = 0.519052232
test2 = 0.5200520899999999
test3 = 0.34940009100000013

何度試してもmssを利用したスクショが一番早かったためこちらを採用しました。

(実際完成させたコードの中では①やちょっとした処理にPILのスクショを利用していますが、速度に関係しない部分だったので手抜きしました)

④はそのスクショを②で取得した座標に合わせて切り抜きます。

課題のアルファベットが表示される箇所はテンプレートの箇所から何px先に表示されるか決まっているのでその分XとYに足した座標を指定して切り抜けば課題のアルファベットだけが載った画像が出来上がります。

⑤ではOpenCVがその画像をより正確に文字検出できるようにするために無駄な色を削除して白と黒だけにします。

色の二値化は今まで触れていませんでしたが、OpenCVの cv2.threshold でできます。

簡単に言うと指定したしきい値と最大のしきい値で画像を2つの色に絞るという機能です。

詳細はこちら http://labs.eecs.tottori-u.ac.jp/sd/Member/oyamada/OpenCV/html/py_tutorials/py_imgproc/py_thresholding/py_thresholding.html

⑥は文字検出です。この記事内で書いた内容そのままです。

⑦はpyautoguiを利用してキーボード入力します。

このライブラリはキーボード操作やマウス操作などをPythonが行なってくれるものです。

⑥で検出したアルファベットをそのままキーボードで入力します。

ここでフォーカスがブラウザに当たっていないと違う場所でキーボード操作を行なってしまうので注意です。

⑧は③に戻ります。入力が終わったのでまたスクショから入ります。

動かした結果

この動画ではQuickTime Playerで画面録画しながら実行してるせいか結構時間がかかってしまっていますが、録画せずに実行するとこの3倍くらい早いです。

Mackbook Airのスペックが低いためですかね…

コード

勢いで作ってリファクタリングしてません。

暇な方はどうぞ。

import numpy as np
import sys
import re
import cv2
import pyocr
import pyocr.builders
import pyautogui
from PIL import ImageGrab
import PIL.Image
import mss

# cv2形式に変換
def pil2cv(image):
    new_image = np.array(image, dtype=np.uint8)
    if new_image.ndim == 2: # モノクロ
        pass
    elif new_image.shape[2] == 3: # カラー
        new_image = cv2.cvtColor(new_image, cv2.COLOR_RGB2BGR)
    elif new_image.shape[2] == 4: # 透過
        new_image = cv2.cvtColor(new_image, cv2.COLOR_RGBA2BGRA)
    return new_image

# ゲーム終了検出
def shutdownCheck():
    template = cv2.imread('img/finish.png')
    # スクリーンショット取得
    ss = ImageGrab.grab()
    # スクリーンショットをMAT型に変換
    img = pil2cv(ss)
    # テンプレートの座標取得
    locs = matchLoc(img, template)
    if len(locs[0]) > 0:
        print('ゲーム終了を検知')
        sys.exit(0)
    print('待機')

# マッチした座標を返す
def matchLoc(image, template):
    # グレースケール化
    image_gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
    template_gray = cv2.cvtColor(template, cv2.COLOR_BGR2GRAY)
    # テンプレートマッチング
    res = cv2.matchTemplate(image_gray, template_gray, cv2.TM_CCOEFF_NORMED)
    # 類似度の高い部分を検出する
    threshold = 0.9
    loc = np.where(res >= threshold)
    # マッチした箇所の座標を確認
    return loc

# テンプレートから座標を取得
def getLocs():
    # テンプレート読み込みSS
    template = cv2.imread('img/left_template.png')
    while True:
        # スクリーンショット取得
        ss = ImageGrab.grab()
        # スクリーンショットをMAT型に変換
        image = pil2cv(ss)
        # テンプレートの座標取得
        locs = matchLoc(image, template)
        if len(locs[0]) > 0:
            return [locs[1][0], locs[0][0] + 430]
        else:
            print('テンプレートマッチ待機')

# SSから座標を切り抜いて保存
def getImage(locs):
    # 全画面スクショ
    output_filename = "tmp.jpg"
    with mss.mss() as mss_instance:
        mss_instance.shot(output=output_filename)
    # スクショの読み込み
    img_read = cv2.imread('tmp.jpg')
    # 文字が表示される場所だけ切り抜く
    img = img_read[locs[1]: locs[1] + 70, locs[0] + 120: locs[0] + 1690]
    # グレースケール化
    img_gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
    # 二値化(白黒)
    th, im_gray_th_otsu = cv2.threshold(img_gray, 100, 255, cv2.THRESH_BINARY)
    # 保存
    cv2.imwrite('tmp.jpg', im_gray_th_otsu)

# 文字列の抜き出し
def imageToText():
    img = PIL.Image.open("tmp.jpg")
    for i in [3, 7, 8, 9, 10]:
        dst = tool.image_to_string(
            img,
            lang='eng',
            builder=pyocr.builders.TextBuilder(tesseract_layout=i)
        )
        if dst != '' or dst == 'ne':  # 真っ白をなぜか「ne」と検知してしまうため
            break
        else:
            # 終了確認
            shutdownCheck()
            print('検出失敗につきプロパティを変更')
    # 正規表現で置換と小文字に変換
    return re.sub(r'[^a-z-!?,]', '', dst).lower()

# 文字検出用前準備
tools = pyocr.get_available_tools()
if len(tools) == 0:
    print("toolエラー")
    sys.exit(1)
tool = tools[0]
print("準備完了")
# SS範囲取得
locs = getLocs()
before = ''
# ループ
while True:
    # 座標のSSを保存
    getImage(locs)
    # 文字の検出
    words = imageToText()
    # 検出文字無し
    if words == '' or words == 'ne':
        print('空検出のため待機')
        # 終了確認
        shutdownCheck()
    # 文字の検出に成功
    elif words != '' and words == before:
        # キーの打ち込み
        pyautogui.typewrite(words)
        print(words)
        before = words

感想

楽しかったです。(小学生並の感想)

画像認識系のプログラムは難しいのだろうと元々思っていましたが、想像していた箇所とは違うところで苦戦しました。

ライブラリに機能が充実しているので画像認識や文字検出などの高度なプログラムはほぼこちらで関与しなくて良いのは素晴らしいのですが、結局画像も文字も無駄認識や誤認識を防ぐためにどのように工夫すれば良いのかがやってみないと分からないので、どんな単純なアプリだとしても簡単には作ることはできなそうです。

ちょっとしたテンプレートマッチング1つ取っても素人と経験者では考慮や対策が大きく変わりそうです。

きちんと作ることができれば人間の作業をたくさん担ってくれそうなので、いつか業務でも個人的にも利用する日が来るかもですねー。

← 前の投稿

[AWS] EventBridge Rules による ECS Scheduled Task はエラー時リトライできない

次の投稿 →

OpenCVで画像処理を試す

コメントを残す