🎴

遊戯王ニューロンOCR

2023/01/28に公開

はじめに

遊戯王マスターデュエルがリリースされてからはや1年経ちました。
公式からの競技イベントは少ないですが、有志の方が大会を開催してくださっています。
一部の大会では、主催者様の定めた特殊なレギュレーションを用いており、参加者のデッキが正しくレギュレーションを守っているか人力でチェックする必要があります。
デッキに含まれるカードリストのテキストデータがあればチェック作業が自動化できそうですが、公式アプリである遊戯王ニューロンはなんとカードのリストを jpeg 画像として出力します。
そこで、この jpeg 画像を読み取り、テキストデータ化するアプリケーションを作成しました。

上図左側:遊戯王ニューロンの出力するカードリスト jpeg 画像
上図右側:本アプリケーションによって出力したテキストデータ

本アプリケーションは、github レポジトリに公開しているので、ご自由にお使いください。
https://github.com/tomokinex/yugioh_neuron_ocr

1. アプリケーションのフロー

まず、OCR ライブラリを用いて、入力画像から文字列を抽出・認識します。
次に、抽出した文字列を、カード名と投入枚数に分離します。
その後、認識したカード名を、スクレイピングによって取得した全カード名のリストとヒューリスティックな調整によって補正します。
最後に、csv 形式として保存します。

2. OCRによる文字認識

2-1. 領域の分割

認識精度の向上と、カード名・投入枚数の分離を用意にするため、領域ごとに画像を切り出します。
切り出しに用いたコードを以下に示します。
特に複雑な処理はしていませんが、基準となる画像サイズにリサイズした後、静的に定めた、モンスター、魔法、罠、EXモンスターの領域ごとに分割します。
分割には、PIL ライブラリを用いました。

from PIL import Image
def image2text(img, size, type):
    img_mons = img.crop(size)
    img_mons.save("temp.jpg")
    result = google_api.detect_text('temp.jpg')
    # 以降、処理が続く

if __name__ == "__main__":
    img = Image.open('textDeckImage.jpg')
    base_width = 1785
    base_height = 2526
    img = img.resize((base_width, base_height))
    mons_names, mons_nums = image2text(img, (206,412, 714,1671), 0)
    magic_names, magic_nums = image2text(img, (715,412, 1225,1671), 1)
    trap_names, trap_nums = image2text(img, (1226,412, 1736,1671), 2)
    exmons_names, exmons_nums = image2text(img, (206,1790, 714, 2420), 0)

    # 以降、処理が続く

入力画像(下図左)とモンスター領域のみを切り出した画像(下図右)の例を下記に示します。
切り出した各画像は、一時的にファイルに保存し、後述する OCR ライブラリに入力していきます。

2-2. OCRによる文字認識

切り出した領域ごとに文字認識を行います。
文字認識をする OCR ライブラリとして、Google Vision API を用いました。
Google Vision API を利用するためには、Google Cloud に登録し、API key をローカルに保存する必要があります。
導入手順の参考サイトを示して、ここでは詳細を割愛します。

文字認識に用いたコードを以下に示します。
Google Vision API では、バイナリ形式の画像ファイルを入力とするため、PILライブラリのImage型をそのまま扱えない点だけ注意です。

def detect_text(path):
    from google.cloud import vision
    import io
    client = vision.ImageAnnotatorClient()

    with io.open(path, 'rb') as image_file:
        content = image_file.read()

    image = vision.Image(content=content)

    response = client.text_detection(image=image)
    texts = response.text_annotations

    if not texts:
        return []
    else:
        return texts[0].description.split("\n")

2-1. で例に挙げたモンスターの切り抜き画像を用いた出力の一部を以下に示します。
description を見ると、すでに高い認識精度で文字が出力されていることがわかります。
また、カード名、投入枚数と改行コード区切りで連続して出力されているため、簡単にカード名と投入枚数を分離できることがわかります。
しかし、機サーキュラー のように一部が欠落していたり、-3 のように不正なカード枚数が出力されています。
以降の節で、これらの補正について説明します。

[locale: "ja"
description: "ドンヨリボー@イグニスター\n増殖するG\n灰流うらら\n斬機シグマ\n斬機マルチプライヤー\n機サーキュラー\nパラレルエクシード\nガッチリ@イグニスター\n-3\n\n3\n3\n1\n2\n3\n3\n1"
bounding_poly {
  vertices {
    x: 2
  }
  vertices {
    x: 477
  }
  vertices {
    x: 477
    y: 324
  }
  vertices {
    x: 2
    y: 324
  }
}

3. カード名リストによる補正

OCRの文字認識だけでカード名を完全に推測することは非常に困難なので、リストから最も近いカード名を選択することで補正をします。
まず、全カードリストを取得するために用いた、スクレイピングのコードを紹介し、その後、補正方法について述べます。

3-1. スクレイピング

カード名リストは、遊戯王カードリスト様から取得しました。
前述したように、推測したカード名は、モンスター、魔法、罠、EXモンスターに分離しているため、補正に用いるリストも分割しています。
下記に、モンスター名を収集するスクレイピングコードを示します。

import requests
from bs4 import BeautifulSoup
import sys
import io
import re
import unicodedata

base_url = "https://yugioh-list.com/searches/result/sort:Card.card_nm_kna/direction:asc/page:"
base_args = "?keyword=&keyword1=1&keyword2=1&keyword3=1&effect=&kind_id1=100&kind_id2=&type_id=&attribute_id=&level_f=&level_t=&level1=1&level2=1&p_blue_f=&p_blue_t=&p_red_f=&p_red_t=&link_num_f=&link_num_t=&link1=0&link2=0&link3=0&link4=0&link5=0&link6=0&link7=0&link8=0&atk_f=&atk_t=&atk_h=&dif_f=&dif_t=&dif_h=&limit_id%5B0%5D=1&limit_id%5B1%5D=2&limit_id%5B2%5D=3&limit_id%5B3%5D=4&avg_point_f=&avg_point_t="
total = 7962
sys.stdout = io.TextIOWrapper(sys.stdout.buffer,  encoding='CP932', errors='ignore')

print("[", end="")
for i in range(int(total/30) + 1):
    html = requests.get(base_url + str(i+1) + base_args)
    soup = BeautifulSoup(html.content, "html.parser")
    table = soup.find_all('td', class_=re.compile("_monstar"))
    for t in table:
        if len(t.contents) <= 1:
            continue
        if not t.contents[1].contents:
            continue
        if i != 0:
            print(',', end="")

        print('["'+ unicodedata.normalize('NFKC', t.contents[1].contents[0].get('alt')) +'"]', end="")

print("]", end="")

こちらのサイトによると、モンスターカードは 7962 枚あり、30枚ごとにページが切り替わって表示されます。
それらのページに個々にアクセスし、モンスター名を抽出します。
下図に示した html によると、<td class "synchro_monstar"> 内の画像部分にカード名があるのがわかります。
他の種類のモンスターを見ると、*_monstar ごとに class が設定されていることがわかったので、この要素を抽出します。
なお、このサイトではアルファベットに全角文字を使用していますが、前述した Google Vision API では通常半角アルファベットが出力されるため、半角文字に変換してから保存しています。

3-2. 文字列比較による補正

補正方法は、カード名と数値に対して独立に行います

カード名に対しては、先ほど取得したカード名リストの中から推測した文字列に対してもっとも距離が近いものを選択します。
文字列間の距離には、Python の標準ライブラリ difflib に含まれるゲシュタルトパターンマッチングを用いました。

import string_comp
import re
import difflib
def string_comp(str1, str2):
    rate = difflib.SequenceMatcher(None, str1, str2).ratio()
    return rate

def refactor_name(str, namelist):
    result_score = 0.85 
    result_word = str

    for n in namelist:
        t = string_comp.string_comp(str, n)
        if t > result_score:
            result_score = t
            result_word = n

    return result_word, result_score

数値に対しては、ややヒューリスティックに補正を行います。
典型的な誤認識パターンには先ほど示した-3 の他にも 「332 などがあります。
遊戯王では、あるカードをデッキに投入できる枚数は1枚から3枚までなので、1, 2, 3 以外の数値は不正となります。
そのため、-3「33 に、3232 と分割すると、正しい結果となります。

補正するコードを以下に示します。
推測した文字列の配列から、数字が含まれるものを対象とし、1文字の場合はそのまま、2文字以上の場合は数値となる部分のみを抽出、分割して保存します。

import re
def refactor_num(array):
    ret_array = []
    for s in array:
        if re.search('[1-3]', s):
            if len(s) == 1:
                ret_array.append(int(s))
            else:
                chars = list(s)
                for char in chars:
                    if char.isdigit():
                        ret_array.append(int(char))

    return ret_array

4. 性能評価

上記の処理の後、補正後の文字列を csv 形式に保存してすべてのフローは完了します。
このアプリケーション全体の実行を、複数の入力画像を用いて検証しました。
冒頭のデッキに加えて、60枚デッキ、ハイランダーデッキ(40枚すべてのカードが異なる)、罠のみのデッキの3種類用意しました。
以下に認識結果を示します。
これら3つのデッキを用いた場合でも、正しくテキストデータを抽出できていることがわかります。


余談

1. OCR ライブラリ選定

Google Vision API の他にも、Tesseract ライブラリを検討していました。
Tesseract ライブラリを用いた場合の文字認識結果を以下に示します。

上図左側が入力画像、上図右側が認識した文字列(注:カード名リストによる補正前)を示します。
グレースケール変換や輝度調整等の前処理によって、モンスター名は正しく認識できていますが、比較的画数の多い漢字が多い魔法カード名の認識精度は非常に低く、ボツとなりました。
参考までに以下にコードを記します。

import pyocr
from PIL import Image, ImageEnhance
import os

def OCR(img):
    TESSERACT_PATH = '\\path\\to\\Tesseract-OCR'
    TESSDATA_PATH = '\\path\\to\\Tesseract-OCRTesseract-OCR\\tessdata'

    os.environ["PATH"] += os.pathsep + TESSERACT_PATH
    os.environ["TESSDATA_PREFIX"] = TESSDATA_PATH

    tools = pyocr.get_available_tools()
    tool = tools[0]

    img = img.convert('L') 
    img = ImageEnhance.Contrast(img).enhance(2.0)
    builder = pyocr.builders.TextBuilder(tesseract_layout=11)

    txt_pyocr = tool.image_to_string(img , lang='jpn', builder=builder)
    txt_pyocr = txt_pyocr.replace(' ', '')
    return txt_pyocr

2. 認識精度が激しく低下するケース

本アプリケーションを実装した後に知ったのですが、遊戯王ニューロンの出力するカードのリスト画像はスマホの端末や転送先によってサイズが異なります。
例えば、同じスマホからでも Discord に転送した場合はおおよそ 1700 x 2500 のサイズに、twitter に転送した場合はおおよそ 480 x 600 のサイズとなります。
本評価では、すべて Discord に転送した画像を用いたものであり、480 x 600 の画像を用いた際では、大きく認識精度が落ちることがわかっています。
特に画数の多い漢字は人間の目で見てもかなり認識しづらいので、ある程度の画像サイズ以上でないと正常に動作しない結果となりました。

Discussion