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

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

今回私は「麻雀リアルタイムシミュレーター」を作成することにしました。
理由としては、何切る問題をリアルタイムで解決したいと思ったからです。

FlaskとElectronでデスクトップアプリとして作り、皆さんご存じの方も多いことでしょう麻雀ゲーム「雀魂」。こちらのウィンドウ画面をリアルタイムでキャプチャして、画面の対戦情報から現在切る牌をシミュレーションしてくれる機能を備えようと思います。
この記事はそんなデスクトップアプリを作った過程を記述していこうと思います。
V1としての麻雀リアルタイムシミュレーターは完成しています。なので、この記事は作っていった過程や悩んだこと、どのように作ったか、今後の課題について書こうと思います。

作成したソースコードは一応[こちら](https://github.com/mshok0511/mahjong-realtime-simulator-app)に公開していますが、現時点(2024/10/25時点)での完成度は形だけギリギリ整っているだけのポンコツなので、大目に見てくれると嬉しいです。これからちゃんと完成していくので、お許しください...。

※このように自分で作ってみたプログラムを公開し、記事にして投稿するのは初めてなので、何かアドバイスやソースコードの書き方、質問などがあればたくさんほしいので、ぜひコメントをお願いします。
# 環境構築
環境構築は以下の記事を参考にしましたのでこちらをご覧ください。
[Flask と Electron で作るデスクトップアプリ ①「導入編」](https://qiita.com/BUU-SAN/items/af3ae2c9835cd06dddc4)
windows11でpython、Node.js、electronをインストールします。記事にも書いていますが、windows10でもできるそうです。
フロントエンドをHTMLで、バックエンドをPythonで、PythonとHTMLのデータのやり取りでJavaScriptを使用しています。
# 麻雀リアルタイムシミュレーター.V1を作る
ここから本格的に作っていきます。V1は主に最低限のプログラムのサイクルが完成するようにします。

## 処理イメージ
処理の流れのイメージとしては、

1. HTML側でスタートボタンを押す
↓
1. pythonがそれを検知し、キャプチャ処理をスタート
↓
1. スクリーンショットから対戦情報を取得
↓
1. 計算実行ボタンが押されたら、対戦情報を基に計算を行う
↓
1. 計算結果をHTMLに送信、ない場合は何もしない
↓
1. 2に戻る

この処理を繰り返し行わせる形になります。

## 制作にあたって出てきた３つの難所
このアプリの作成にあたって、三つのデカい壁を乗り切らないといけなくなりました。

1. ゲーム画面のキャプチャ
1. 画像からの物体検出
1. 何切るシミュレーターとの連携

です

## 第１関門 ゲーム画面のキャプチャ
ゲーム画面のキャプチャは、HTML側でするかpython側でするか迷い、どちらも試してみた結果numpy配列に直接入れることができるpythonで行いました。
mssモジュールとpygetwindowモジュールを使いました。

**mss**は、画面キャプチャを効率的に行うためのライブラリで、システム全体や指定した領域のスクリーンショットを取得するのに役立ちます。他のキャプチャライブラリと比べて高速であり、Windows、macOS、Linuxで動作します。
また**pygetwindow**は、ウィンドウの取得や操作（移動、リサイズ、アクティブ化など）を行うためのモジュールで、Windows、macOS、Linuxで動作します。ウィンドウの操作に関するPythonモジュールで、他のアプリケーションのウィンドウに対して直接操作を行うことができます。

## ソースコード
キャプチャ処理のソースコードは以下の通りです。

### html
```index.html
<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>麻雀リアルタイムシミュレーター</title>
        <script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
        <script src="https://cdn.socket.io/4.0.1/socket.io.min.js"></script>
    </head>
        <h1>シミュレーション結果出力画面</h1>
        <a id="toggleButton" class="tglbtn">Start Capture</a>
        <div id="error"></div>

        <div class="image-wrapper">
            <img id="screenshot" src="" alt="screenshot">
        </div>
    </body>
    <script src="../static/js/script.js"></script>
</html>
```
### JavaScript
```script.js
document.addEventListener('DOMContentLoaded', (event) => {
    const socket = io();
    const toggleButton = document.getElementById("toggleButton");
    var capturing = false;

    toggleButton.addEventListener("click",function(){
        if (capturing) {
            //ストップ処理
            socket.emit('stop_capture');
            toggleButton.innerText = 'Start Capture';
        } else {
            //スタート処理
            socket.emit('start_capture');
            toggleButton.innerText = 'Stop Capture';
        }
        capturing = !capturing;
    });

    socket.on('new_image', function(data) {
        if (capturing) {
            let img = document.getElementById("screenshot");
            img.src = data.img_path
        }
    });

    socket.on('error', function(data) {
        const errorBox = document.getElementById("error");
        errorBox.innerHTML = '<div class="error">' + data.error + '</div>';
    });
});
```

### python
```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

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}'})

                # 画像確認用ソースコード
                cv2.imwrite("{}/{}.png".format(img_dir_name, img_No),img)

                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)
```

HTML側のスタートボタンが押されるとpythonの`def window_capture():`が起動します。`gw.getWindowsWithTitle("雀魂-じゃんたま-")`で「雀魂-じゃんたま-」というタイトルのウィンドウの座標を取得し、`img = sct.grab(monitor)`でスクリーンショットをしています。

その後、Base64でエンコードして、HTML側にエンコード化画像をemitしています。
Base64は画像をSocket.IO通信をするために使用しています。

一応`cv2.imwrite("{}/{}.png".format(img_dir_name, img_No),img)`で`./static/data/jantama_capture`フォルダ内に保存して確認できるようにしています。なので`cv2.imwrite("{}/{}.png".format(img_dir_name, img_No),img)`は不要になったら消しますので、正直なくても大丈夫です。

何はともあれ第１関門はこれで突破です。比較的簡単だったと思います。問題は他の二つなのですが、続きは次回の記事に書こうと思います。

## 最後に
ここまで読んでくださりありがとうございます。
次の記事は、第２関門「画像からの物体検出」について書こうと思います。
何かご指摘があるたびに恐らく記事の内容が少しだけ変わると思います。
こんな書き方でよかったのだろうか...。おそらくかなり伝わりずらい記事だと思いますがそこも含めて、記事に修正が入ると思います

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