FlaskとElectronで麻雀リアルタイムシミュレーターを作っていく記録②

[←前回の記事](https://qiita.com/mshok0511/items/42f6ab7891a9c877b297)

[次回の記事→](https://qiita.com/mshok0511/items/c40af9fa3d8bc372c99a)

# 前置き
前回の記事の続きです。ご覧になっていない方は記録①の方からぜひ見ていってください。

麻雀リアルタイムシミュレーター.V1をFlaskとElectronで作った記録になっています。
前回の記事では、アプリを作る上で3つの壁があり、その一つを最初に作った記事を投稿しました。
今回は第２関門であった、「画像からの物体検出」について記述します。

# 第２関門「画像からの物体検出」
物体検知を行うにあたって、なにで物体検出をするか考えました。

まず一つは授業でも触ったことのある**YOLO**です。
YOLOとは、画像認識や物体検出に用いられるAIアルゴリズムの一種で、リアルタイムで高速な物体検出を実現するために開発されたモデルです。

もう一つはOpenCVの**テンプレートマッチ**です。
こちらはAIを使わず、画像内から特定のパターン（テンプレート）を検索して位置を特定する手法のことです。画像処理やコンピュータビジョンの分野で、物体認識や形状の一致確認などに広く使われます。

以上の説明や精度が高くなりやすいことからYOLOを使った物体検出を行うことにしました。

## YOLOで物体検出
雀魂の牌検知モデルくらいネットで探せばいくらでも出てくるっしょ！っと思い色々探してみました。

が、いくら探しても雀魂の牌検知モデルが見つからない...
![pose_zetsubou_man.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3917265/3a3f7735-1646-9502-6ada-2ed42bac4b64.png)

roboflowやGitHubとかにいい感じのモデルがあるんじゃないかという軽はずみな考えでやってたら全然見つからずすごく頭悩ませました......

### FastLabelでアノテーション作成
っていうことで、ないものは仕方ないので自分で作ってみましたよ物体検出モデル。200枚以上の画像から約600個以上のアノテーションを作りました。いやもう本当に眠くなりそうでした。おかげでモデルを作るのに１週間半くらいかかりました。

![スクリーンショット 2024-10-27 202100.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3917265/78c94443-4d82-b8c2-a272-e5864a5a1243.png)

使用したツールは[FastLabel](https://app.fastlabel.ai/)です。
無料でも使えるクラウド型のアノテーションツールで、YOLOのオリジナルモデルを作れます。

### FastLabelを使った理由
なぜFastLabelでやったのかというとlabelImgがあまりにも使いずらかったからです。

labelImgも同じくアノテーションソフトで使ったことがあったのですが、labelImgはアノテーションをずっと作っていると、なぜか途中でソフトが落ちてデータが全部パーになってしまい、最初からやり直すっということを何度もしていました。流石にあまりにもイライラしてしまって別のツールがないか探してみたところFastLabelと出会い、ものすごい使いやすさに感動してこちらで作ることにしました。

### アノテーションデータで学習
こちらの[Pythonによるデータ分析・機械学習ブログ](https://chantastu.hatenablog.com/entry/2024/05/30/201546)を参考にしました。
コマンドで学習させるタイプです。調べてみるとソースコードを書いて学習させることもできるみたいです。

### 作成したオリジナルモデルで物体検出
そしていざッ！
プログラムに組み込んで実行してみました。確信度は0.6で設定。

使用した画像は`cv2.imwrite("{}/{}.png".format(img_dir_name, img_No),img)`で`./static/data/jantama_capture`に保存した確認用のキャプチャ画像です。

そしたらなんてことでしょう。何も検出しませんでした...。

投稿主もこんな状態でした→![main.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3917265/d77d7db3-b15e-2847-d5ca-e9791da0ac3c.png)
まさかと思い、確信度を徐々に下げてみました。

そしたらなんとびっくり、0.01でようやくちょこっと検出し、0.0005から0.001あたりでたくさん検出するようになりました！

**いや低い！！**
いくら何でも低すぎる！なんで確信度1%以下じゃないと検出しないん!?しかも確信度を下げ過ぎてるので誤検出も大量に出ていました...。

出来ることなら、当時のトンデモ誤検出のオンパレード画像をお見せしたいところでしたが、当時は記事を書こうだなんて考えていなかったので、消してしまいました。

原因として自分で考えたのは、

・影や光の強さ、画角などがあまり影響のない雀魂の牌にはYOLOは向いていない
・200枚程度の画像、600個程度しかないアノテーションでは学習が足りなかった
・手牌と捨て牌や鳴き牌を一緒のものとしてアノテーションを行ってしまった

もしかしたら他にも様々な原因があるかと思いますが、どの道YOLOでの物体検出は失敗しました。
ただ、どのようにオリジナルモデルを作るのかという学びを得ることができたので、ある意味貴重な経験であったと思います。

## テンプレートマッチ
YOLOでダメだったので、次はテンプレートマッチでやってみようと思います。
調べてみると、なんとテンプレートマッチで雀魂のプレイ画面から牌検出をしてみた方の記事がありましたので、こちらの[雀魂の画面から画像認識で対戦情報を持ってくる（Vol. 3）の「雀魂の画面から手牌の部分だけを切り出す」](https://qiita.com/xenepic/items/4aa94bece50364002497#%E9%9B%80%E9%AD%82%E3%81%AE%E7%94%BB%E9%9D%A2%E3%81%8B%E3%82%89%E6%89%8B%E7%89%8C%E3%81%AE%E9%83%A8%E5%88%86%E3%81%A0%E3%81%91%E3%82%92%E5%88%87%E3%82%8A%E5%87%BA%E3%81%99)から先の記事を参考にさせていただきました。

スクリーンショットしたゲーム画面から座標指定で手牌画像を切り抜いて、さらにそこから手牌を一つずつに分けて、あらかじめ用意しておいた全種類の牌画像がひとまとまりになった一枚の画像にテンプレートマッチをするという感じです。

試しにやってみたところ、65%の確信度でほぼ正常に検出することに成功しました！

### テンプレートマッチのソースコード

```app.py
# テンプレートマッチング処理関数
def recogPaiImage(paiImage, paiListImage, threshold = 0.65):
    # 雀牌表画像のグレースケール化
    paiListImage_gray = cv2.cvtColor(paiListImage, cv2.COLOR_BGR2GRAY)
    
    # 識別する雀牌画像のグレースケール化
    paiImage_gray = cv2.cvtColor(paiImage, cv2.COLOR_BGR2GRAY)

    # キャプチャ画像に対して、テンプレート画像との類似度を算出する
    res = cv2.matchTemplate(paiListImage_gray, paiImage_gray, cv2.TM_CCOEFF_NORMED)

    # 類似度の高い部分を検出する
    loc_candidate = np.where(res >= threshold)

    if len(loc_candidate[0]) == 0:
        return None

    # マッチング座標の中で最頻値座標を求める
    mode = []
    for loc_it in loc_candidate:
        unique, freq = np.unique(loc_it, return_counts=True)
        mode.append(unique[np.argmax(freq)])

    # 座標を元に牌の種類を識別する
    paiList = (
        ('Manzu1','Manzu2','Manzu3','Manzu4','Manzu5','Manzu6','Manzu7','Manzu8','Manzu9'),
        ('Pinzu1','Pinzu2','Pinzu3','Pinzu4','Pinzu5','Pinzu6','Pinzu7','Pinzu8','Pinzu9'),
        ('Sozu1','Sozu2','Sozu3','Sozu4','Sozu5','Sozu6','Sozu7','Sozu8','Sozu9'),
        ('Ton','Nan','Sya','Pe','Haku','Hatu','Tyun')
    )
    listHeight, listWidth = paiListImage.shape[:2]
    paiKind = int((mode[0]+listHeight/8)/(listHeight/4))
    paiNum = int((mode[1]+listWidth/18)/(listWidth/9))
    return paiList[paiKind][paiNum]

# 手牌画像の切り抜き関数(自摸牌も含む)
def cropMyHandImage(jantamaMainImage):
    height, width = jantamaMainImage.shape[:2]
    myHandLeft = int(width*203/1665)
    myHandRight = int(width*1255/1665)
    myHandTop = int(height*760/938)
    myHandBottom = int(height*870/938)

    myHandImage = jantamaMainImage[myHandTop:myHandBottom, myHandLeft:myHandRight]

    myHandImage = cv2.resize(myHandImage, dsize = (1068,131))

    return myHandImage

# 手牌の切り分け関数
def divideMyHandImage(myHandImage):
    myHandImageList = []
    for i in range(2,1068,82):
        myHandImageList.append(myHandImage[:,i:i+81])
    return myHandImageList
```

参考にした記事とほとんど同じです。切り取る座標を少々変えたぐらいです。
これを前回の記事のソースコードと合わせると

```app.py
from flask import Flask, render_template
from flask_socketio import SocketIO, emit
import numpy as np
import cv2
import os
import mss
import base64
import pygetwindow as gw
from pathlib import Path

# Base64エンコード化関数
def encode_image_to_base64(image):
    # 画像をJPEG形式でエンコード
    _, buffer = cv2.imencode('.jpg', image)
    # バイナリデータをBase64エンコード
    encoded_image = base64.b64encode(buffer).decode('utf-8')
    return encoded_image

#====追記====#

# テンプレートマッチング処理関数
def recogPaiImage(paiImage, paiListImage, threshold = 0.65):
    # 雀牌表画像のグレースケール化
    paiListImage_gray = cv2.cvtColor(paiListImage, cv2.COLOR_BGR2GRAY)
    
    # 識別する雀牌画像のグレースケール化
    paiImage_gray = cv2.cvtColor(paiImage, cv2.COLOR_BGR2GRAY)

    # キャプチャ画像に対して、テンプレート画像との類似度を算出する
    res = cv2.matchTemplate(paiListImage_gray, paiImage_gray, cv2.TM_CCOEFF_NORMED)

    # 類似度の高い部分を検出する
    loc_candidate = np.where(res >= threshold)

    if len(loc_candidate[0]) == 0:
        return None

    # マッチング座標の中で最頻値座標を求める
    mode = []
    for loc_it in loc_candidate:
        unique, freq = np.unique(loc_it, return_counts=True)
        mode.append(unique[np.argmax(freq)])

    # 座標を元に牌の種類を識別する
    paiList = (
        ('Manzu1','Manzu2','Manzu3','Manzu4','Manzu5','Manzu6','Manzu7','Manzu8','Manzu9'),
        ('Pinzu1','Pinzu2','Pinzu3','Pinzu4','Pinzu5','Pinzu6','Pinzu7','Pinzu8','Pinzu9'),
        ('Sozu1','Sozu2','Sozu3','Sozu4','Sozu5','Sozu6','Sozu7','Sozu8','Sozu9'),
        ('Ton','Nan','Sya','Pe','Haku','Hatu','Tyun')
    )
    listHeight, listWidth = paiListImage.shape[:2]
    paiKind = int((mode[0]+listHeight/8)/(listHeight/4))
    paiNum = int((mode[1]+listWidth/18)/(listWidth/9))
    return paiList[paiKind][paiNum]

# 手牌画像の切り抜き関数
def cropMyHandImage(jantamaMainImage):
    height, width = jantamaMainImage.shape[:2]
    myHandLeft = int(width*203/1665)
    myHandRight = int(width*1255/1665)
    myHandTop = int(height*760/938)
    myHandBottom = int(height*870/938)

    myHandImage = jantamaMainImage[myHandTop:myHandBottom, myHandLeft:myHandRight]

    myHandImage = cv2.resize(myHandImage, dsize = (1068,131))

    return myHandImage

# 手牌の切り分け関数
def divideMyHandImage(myHandImage):
    myHandImageList = []
    for i in range(2,1068,82):
        myHandImageList.append(myHandImage[:,i:i+81])
    return myHandImageList

#============#

app = Flask(__name__, instance_relative_config=True)
socketio = SocketIO(app)

img_dir_name = "./static/data/jantama_capture"
dir_path = Path(img_dir_name)
dir_path.mkdir(parents=True, exist_ok=True)
os.makedirs(img_dir_name, exist_ok=True)

@app.route("/")
def index():
    return render_template('index.html')

@socketio.on('start_capture')
def window_capture():
    img_No = 0
    FPS = 14
    #繰り返しスクリーンショットを撮る
    with mss.mss() as sct:
        windows = gw.getWindowsWithTitle("雀魂-じゃんたま-")
        if not windows:
            emit('error', {'error': "雀魂を先に開いてください"})
            return
        else:
            emit('error', {'error': ""})
        
        #キャプチャスタート
        global capturing
        capturing = True

        window = windows[0]
        left, top, width, height = window.left, window.top, window.width, window.height
        monitor = {"top": top, "left": left, "width": width, "height": height}
        while capturing:
            try:
                img_No = img_No + 1
                img = sct.grab(monitor)
                img = np.asarray(img)
                encoded_image = encode_image_to_base64(img)
                emit('new_image', {'img_path': f'data:image/jpeg;base64,{encoded_image}'})

                myHandImage = cropMyHandImage(img)
                myHandImageList = divideMyHandImage(myHandImage)

                # 画像確認用ソースコード
                #====追記====#
                for j in range(len(myHandImageList)):
                    cv2.imwrite("{}/hand{}_{}.png".format(img_dir_name, img_No, j),myHandImageList[j])
                #============#
                cv2.imwrite("{}/{}.png".format(img_dir_name, img_No),img)

                #====追記====#
                paiList = []
                for count in range(13):
                    response = recogPaiImage(myHandImageList[count], paiListImage)
                    if response:
                        paiList.append(response)
                    else:
                        paiList.append("Unknown")
                #============#

                socketio.sleep(1 / FPS)
            except Exception as e:
                emit('error', {'error': f"キャプチャエラー: {e}"})
                continue

@socketio.on('stop_capture')
def capture_stop():
    global capturing
    capturing = False

if __name__ == "__main__":
    socketio.run(app, host="127.0.0.1", port=5000, debug=True, allow_unsafe_werkzeug=True)
```

こんな感じです。テンプレマッチめちゃめちゃ簡単でした。例の如く、切り抜きがうまくできているかの確認用ソースコードも記述しましたが、こちらもなくて大丈夫です。

## 更に追記
[雀魂の画面から画像認識で対戦情報を持ってくる（Vol. 4）](https://qiita.com/xenepic/items/786f110eb5be6ccf246d)のソースコードや、ドラ牌の切り抜きなども追記します。また、cropMyHandImage関数にも少し追加処理と修正を加えます。

最終的なソースコードは以下の通りです。

```app.py
from flask import Flask, render_template
from flask_socketio import SocketIO, emit
import numpy as np
import cv2
import os
import mss
import base64
import pygetwindow as gw
from pathlib import Path

# Base64エンコード化関数
def encode_image_to_base64(image):
    # 画像をJPEG形式でエンコード
    _, buffer = cv2.imencode('.jpg', image)
    # バイナリデータをBase64エンコード
    encoded_image = base64.b64encode(buffer).decode('utf-8')
    return encoded_image

# テンプレートマッチング処理関数
def recogPaiImage(paiImage, paiListImage, threshold = 0.65):
    # 雀牌表画像のグレースケール化
    paiListImage_gray = cv2.cvtColor(paiListImage, cv2.COLOR_BGR2GRAY)
    
    # 識別する雀牌画像のグレースケール化
    paiImage_gray = cv2.cvtColor(paiImage, cv2.COLOR_BGR2GRAY)

    # キャプチャ画像に対して、テンプレート画像との類似度を算出する
    res = cv2.matchTemplate(paiListImage_gray, paiImage_gray, cv2.TM_CCOEFF_NORMED)

    # 類似度の高い部分を検出する
    loc_candidate = np.where(res >= threshold)

    if len(loc_candidate[0]) == 0:
        return None

    # マッチング座標の中で最頻値座標を求める
    mode = []
    for loc_it in loc_candidate:
        unique, freq = np.unique(loc_it, return_counts=True)
        mode.append(unique[np.argmax(freq)])

    # 座標を元に牌の種類を識別する
    paiList = (
        ('Manzu1','Manzu2','Manzu3','Manzu4','Manzu5','Manzu6','Manzu7','Manzu8','Manzu9'),
        ('Pinzu1','Pinzu2','Pinzu3','Pinzu4','Pinzu5','Pinzu6','Pinzu7','Pinzu8','Pinzu9'),
        ('Sozu1','Sozu2','Sozu3','Sozu4','Sozu5','Sozu6','Sozu7','Sozu8','Sozu9'),
        ('Ton','Nan','Sya','Pe','Haku','Hatu','Tyun')
    )
    listHeight, listWidth = paiListImage.shape[:2]
    paiKind = int((mode[0]+listHeight/8)/(listHeight/4))
    paiNum = int((mode[1]+listWidth/18)/(listWidth/9))
    return paiList[paiKind][paiNum]

#====追加と修正====#

# 手牌、自模牌、捨て牌、ドラ牌の画像の切り抜き関数
def cropMyHandImage(jantamaMainImage):
    height, width = jantamaMainImage.shape[:2]
    myHandLeft = int(width*203/1665)
    myHandRight = int(width*1255/1665)
    myHandTop = int(height*760/938)
    myHandBottom = int(height*870/938)
    tsumoLeft = int(width*1278/1665)
    tsumoRight = int(width*1359/1665)

    doraLeft = int(width*34/1665)
    doraRight = int(width*273/1665)
    doraTop = int(height*72/938)
    doraBottom = int(height*127/938)

    myHandImage = jantamaMainImage[myHandTop:myHandBottom, myHandLeft:myHandRight]
    myTsumoImage = jantamaMainImage[myHandTop:myHandBottom, tsumoLeft:tsumoRight]
    doraImage = jantamaMainImage[doraTop:doraBottom, doraLeft:doraRight]

    myHandImage = cv2.resize(myHandImage, dsize = (1068,131))
    myTsumoImage = cv2.resize(myTsumoImage, dsize = (81,131))

    return [myHandImage, myTsumoImage, doraImage]

#=================#

# 手牌の切り分け関数
def divideMyHandImage(myHandImage):
    myHandImageList = []
    for i in range(2,1068,82):
        myHandImageList.append(myHandImage[:,i:i+81])
    return myHandImageList

#====追記====#

# ドラ牌の切り分け関数
def divideDoraImage(doraImage):
    dora_w = 64
    doraImageList = []
    for i in range(4):
        doraImage_resize = cv2.resize(doraImage[:,i + dora_w * i:dora_w * (i + 1)], dsize = (81,131))
        doraImageList.append(doraImage_resize)
    return doraImageList

#============#

app = Flask(__name__, instance_relative_config=True)
socketio = SocketIO(app)

img_dir_name = "./static/data/jantama_capture"
dir_path = Path(img_dir_name)
dir_path.mkdir(parents=True, exist_ok=True)
os.makedirs(img_dir_name, exist_ok=True)

@app.route("/")
def index():
    return render_template('index.html')

@socketio.on('start_capture')
def window_capture():
    img_No = 0
    FPS = 14
    #繰り返しスクリーンショットを撮る
    with mss.mss() as sct:
        windows = gw.getWindowsWithTitle("雀魂-じゃんたま-")
        if not windows:
            emit('error', {'error': "雀魂を先に開いてください"})
            return
        else:
            emit('error', {'error': ""})
        
        #キャプチャスタート
        global capturing
        capturing = True

        window = windows[0]
        left, top, width, height = window.left, window.top, window.width, window.height
        monitor = {"top": top, "left": left, "width": width, "height": height}
        while capturing:
            try:
                img_No = img_No + 1
                img = sct.grab(monitor)
                img = np.asarray(img)
                encoded_image = encode_image_to_base64(img)
                emit('new_image', {'img_path': f'data:image/jpeg;base64,{encoded_image}'})

                #====追加と修正====#
                
                myHandImage, myTsumoImage, doraImage = cropMyHandImage(img)

                #=================#
                myHandImageList = divideMyHandImage(myHandImage)

                paiList = []
                for count in range(13):
                    response = recogPaiImage(myHandImageList[count], paiListImage)
                    if response:
                        paiList.append(response)
                    else:
                        paiList.append("Unknown")

                #====追加と修正====#
                
                tsumo_response = recogPaiImage(myTsumoImage, paiListImage)
                if tsumo_response:
                    tsumopai = tsumo_response
                else:
                    tsumopai = "Unknown"

                doraImageList = divideDoraImage(doraImage)

                doraList = []
                for dora in doraImageList:
                    response = recogPaiImage(dora, paiListImage)
                    if response:
                        doraList.append(response)
                    else:
                        doraList.append("Unknown")

                tehai = list(paiList)
                if tsumopai != "Unknown":
                    tehai.append(tsumopai)

                #=================#

                socketio.sleep(1 / FPS)
            except Exception as e:
                emit('error', {'error': f"キャプチャエラー: {e}"})
                continue

@socketio.on('stop_capture')
def capture_stop():
    global capturing
    capturing = False

if __name__ == "__main__":
    socketio.run(app, host="127.0.0.1", port=5000, debug=True, allow_unsafe_werkzeug=True)
```

だいぶ長くなってきましたね。
追記、修正内容の説明をします。

### cropMyHandImage関数の追記、修正内容

```app.py
# 手牌、自模牌、捨て牌、ドラ牌の画像の切り抜き関数
def cropMyHandImage(jantamaMainImage):
    height, width = jantamaMainImage.shape[:2]
    myHandLeft = int(width*203/1665)
    myHandRight = int(width*1255/1665)
    myHandTop = int(height*760/938)
    myHandBottom = int(height*870/938)
    tsumoLeft = int(width*1278/1665)
    tsumoRight = int(width*1359/1665)

    doraLeft = int(width*34/1665)
    doraRight = int(width*273/1665)
    doraTop = int(height*72/938)
    doraBottom = int(height*127/938)

    myHandImage = jantamaMainImage[myHandTop:myHandBottom, myHandLeft:myHandRight]
    myTsumoImage = jantamaMainImage[myHandTop:myHandBottom, tsumoLeft:tsumoRight]
    doraImage = jantamaMainImage[doraTop:doraBottom, doraLeft:doraRight]

    myHandImage = cv2.resize(myHandImage, dsize = (1068,131))
    myTsumoImage = cv2.resize(myTsumoImage, dsize = (81,131))

    return [myHandImage, myTsumoImage, doraImage]
```
以前までは、手牌と自摸牌をひとまとめで切り抜いていましたが、今回は、手牌、自摸牌を分けつつ、更に画面左上にあるドラ牌の切り抜きもできるようにしました。
これによりreturn値がリスト化していますので、キャプチャ処理内でも
`myHandImage = cropMyHandImage(img)`
↓
`myHandImage, myTsumoImage, doraImage = cropMyHandImage(img)`
に変更されています。

### divideDoraImageの追加

```app.py
# ドラ牌の切り分け関数
def divideDoraImage(doraImage):
    dora_w = 64
    doraImageList = []
    for i in range(4):
        doraImage_resize = cv2.resize(doraImage[:,i + dora_w * i:dora_w * (i + 1)], dsize = (81,131))
        doraImageList.append(doraImage_resize)
    return doraImageList
```
ドラの切り抜きに伴い、追加した関数です。処理内容は`divideMyHandImage`関数とほぼ一緒です。

### 追加処理

return値が増えたことに伴い、以下の処理も追加しています。

```app.py
tsumo_response = recogPaiImage(myTsumoImage, paiListImage)
if tsumo_response:
    tsumopai = tsumo_response
else:
    tsumopai = "Unknown"

doraImageList = divideDoraImage(doraImage)

doraList = []
for dora in doraImageList:
    response = recogPaiImage(dora, paiListImage)
    if response:
        doraList.append(response)
    else:
        doraList.append("Unknown")

tehai = list(paiList)
if tsumopai != "Unknown":
    tehai.append(tsumopai)
```
軽く説明すると、自摸牌またはドラ牌が見つかればそれぞれ検出したものを取得し、なければ`"Unknown"`を代入する処理です。

# 最後に
ここまで読んでくださりありがとうございます。
これで取り敢えず第２関門は通過しました。かなり疲れた覚えがあります。
次は最後の砦にして最難関のだった「何切るシミュレーターとの連携」です。
また何かご指摘やご質問がありましたら気軽にコメントを頂けると幸いです。

[←前回の記事](https://qiita.com/mshok0511/items/42f6ab7891a9c877b297)

[次回の記事→](https://qiita.com/mshok0511/items/c40af9fa3d8bc372c99a)