📊

【後編】マリオカート自動集計Botを作成した話

2024/12/13に公開

はじめに

株式会社ハックツ エンジニアのあいでんです。
本記事は【前編】マリオカート自動集計Botを作成した話の続編となります。前回はOBS WebSocketChatGPTを活用することで、ゲーム画面を取得して文字起こしを行う機能を作成しました。

現状のままでは文字起こしを手動で発火させる必要があり、アプリとしてとても使いづらいです。
完全自動化を実現するためには、OBS WebSocketから取得した画像がリザルト画面を含んでいるかを検知して、リザルト画面が含まれている時にだけChatGPTで文字起こしができる必要があります。

本編では完全自動化を目指して、テンプレートマッチングを導入した事例を紹介いたします。
https://github.com/keisuke071411/mk8dx-template-matching

概要

今回のゴール

  • 配信画面がリザルトを表示しているかを検知する
  • 配信画面の状況に応じて、処理を制御する

完成イメージ

テンプレートマッチングとは

テンプレートマッチングとは、入力画像の中からテンプレート画像と最も類似する部分を探し出す手法のことで、入力画像とテンプレート画像との 類似度 を算出することができます。

集計Botの完全自動化を目指すためにはマリオカート8DXのゲーム画面がリザルトを表示していることを検知する必要があるため、今回はテンプレートマッチングによって算出される類似度を用いてその実現を試みました。

テンプレートマッチングにはPythonOpenCVを使用しました。
https://pypi.org/project/opencv-python/

テンプレートマッチング:導入編

まずはライブラリをinstallします。
Python環境の構築は、別の記事をご参照ください🙇

pip install opencv-python

「コース」という差分にできるだけ影響されないようにするために、まずはリザルト部分だけをトリミングします。これにより、いろんなコースのリザルト画面でも安定した類似度を算出できることを図ります。

screenshot[80:80+920, 832:832+1000]

さらにグレースケールすることで情報量を減らします。
画像をグレースケールに変換するためにcv2.cvtColor()を使用します。cv2.cvtColor()の第二引数にcv2.COLOR_BGR2GRAYを指定することでグレースケールに変換できます。

cv2.cvtColor(screenshot_img, cv2.COLOR_BGR2GRAY)

最後にcv2.matchTemplateでテンプレートマッチングを行います。第三引数にテンプレートマッチングの計算方法を指定するのですが、詳しいことは全くわからないのでオーソドックスなもの(たぶん)を指定しました。気になる方は参考文献の記事をご参照ください🙇

minMaxLoc()を使用することでテンプレートマッチングの結果から最小および最大の信頼度と位置座標が取得できます。今回は信頼度だけわかればいいのでmax_valを参照します。

補足

negative_thresholdも設定しているのは、稀にマイナスの値にふれることがあるためです。
この辺りは理解が乏しいため、有識者の方がいればご教授ください🙇

# 一致とみなす閾値
positive_threshold = 0.8  # 0.8 ~ 1.0 の範囲
negative_threshold = -0.8 # -0.8 ~ -1.0 の範囲

# テンプレートマッチング
result = cv2.matchTemplate(screenshot, template, cv2.TM_CCOEFF_NORMED)
min_val, max_val, min_loc, max_loc = cv2.minMaxLoc(result)

print(f"max_val: {max_val}")

# max_val が指定された範囲にあるかどうかを判定
if positive_threshold <= max_val <= 1.0 or -1.0 <= max_val <= negative_threshold:
  print("Success: リザルト画面が確認されました。")
  return True
else:
  print("No match: リザルト画面ではありません。")
  return False

実際にテンプレートマッチングをする素材の画像は以下のようになっています。

テンプレート画像はリザルトが表示されているものを無作為に選出し、インプット画像はデモを撮影したときに素材となった画像です。インプット画像Aはリザルト画面ではないもの、インプット画像Bはリザルト画面を採用しています。上段が加工前、下段が加工後の画像です。

インプット画像Aの類似度は低く、インプット画像Bの類似度は高いことを期待しています。

テンプレート画像 インプット画像A インプット画像B

結果は、、、まずまずといったところですね!
0に近いほど類似度が低く、1.0に近いほど類似度が高いため、期待通りの結果を得られています。

インプット画像Aの結果 インプット画像Bの結果

テンプレートマッチング:精度向上編 - ケース1 -

期待する結果は得られたものの精度としてはイマイチなので、一致度が0.8 ~ 1.0の範囲になるようにテンプレートマッチングの精度向上を図ります。リザルト画面が表示されているにも関わらず一致度が低い要因として、リザルト画面の背景がコースごとや撮影時のシーンが大きな影響をしていると考えました。

そこで、リザルト画面が表示されていると確実にわかる範囲かつ、背景が影響を与えないように対象範囲を絞ることにしました。

テンプレート画像 インプット画像A インプット画像B

この画像を先ほどと同様にテンプレートマッチングを行います。

結果は、、、まさかのどちらも一致度が下がってしまいました。
インプット画像Aに関しては理想的な結果と言えますが、インプット画像Bに関しては期待する結果を得られませんでした。

インプット画像A インプット画像B

テンプレートマッチング:精度向上編 - ケース2 -

そこで、リザルト画面が表示されていると確実にわかる範囲かつ、背景が影響を与えないように対象範囲を絞ることにしました。

テンプレート画像 インプット画像A インプット画像B

この画像を先ほどと同様にテンプレートマッチングを行います。

結果は、、、うまくいきました!
インプット画像Aに関しては一致度が低いまま、インプット画像Bに関しては一致度を上げることに成功しました。

インプット画像A インプット画像B

テンプレートマッチング:精度向上編 - ケース3 -

期待する精度まであと少し…!
と言いたいところですが、これには2つの問題がありました。

1つ目は、結果の安定性です。「1位」の表示は画面上部に表示される関係上、看板に描かれているテキストや複雑な絵が背景と被ることがままあり、結果のバラつきがありました。

2つ目は、文字起こしができない画像を検出してしまうことです。OBS WebSocketで取得した画像をテンプレートマッチングする処理は数秒単位で定期実行する仕様になっています。また、リザルト画面だと判定された画像は、それを素材として文字起こしを行います。以下のような画像の際に「1位」の表示自体はされているため高い一致度を出してしまいますが、実際に文字起こしができないということがありました。


リザルト表示中に取得された場合

この2つの問題を解決するために、「12位」の表示をテンプレートマッチングの素材としました。

テンプレート画像 インプット画像A インプット画像B

「12位」の表示は画面下部に表示される関係上、先ほどの懸念点はほぼほぼ解消されました。また、マリオカートというゲームの性質上、画面下部に表示されているのは基本的に地面であるため単色であることが多く、結果の安定性が大きく上がりました。

さらに、「12位」が表示されているタイミングでは期待するリザルトがしっかりと表示されているため、実際に文字起こしができなくなるということも無くなりました。

結果は、、、この通り大成功!!!
インプット画像Aに関しては0に近く、インプット画像Bに関してはほぼ1.0に近い一致度です。

インプット画像A インプット画像B

最終的なコードはこんな感じ。

main.py
# 一致とみなす閾値
positive_threshold = 0.8  # 0.8 ~ 1.0 の範囲
negative_threshold = -0.8 # -0.8 ~ -1.0 の範囲

# キャプチャされたスクリーンショットがリザルト画面と一致するかチェック
def check_result_screen(screenshot_img, template_img):
    # 画像をグレースケール
    screenshot = cv2.cvtColor(screenshot_img, cv2.COLOR_BGR2GRAY)
    template = cv2.cvtColor(template_img, cv2.COLOR_BGR2GRAY)
    
    # 画像をリサイズ
    template = cv2.resize(template, (1920, 1080))

    # トリミング
    screenshot = screenshot[935:935+60, 832:832+100]
    template = template[935:935+60, 832:832+100]

    # テンプレートマッチング
    result = cv2.matchTemplate(screenshot, template, cv2.TM_CCOEFF_NORMED)
    min_val, max_val, min_loc, max_loc = cv2.minMaxLoc(result)

    print(f"max_val: {max_val}")

    # max_val が指定された範囲にあるかどうかを判定
    if positive_threshold <= max_val <= 1.0 or -1.0 <= max_val <= negative_threshold:
        return True
    else:
        return False

テンプレートマッチングの結果に応じて処理を制御する

欲しい機能が全て整ったので、これらを使ってアプリの完成を目指します!
前回の記事から新たに追加した機能だけを、ここではかいつまんでご紹介いたします。詳しいコードは下記からご参照ください。
https://github.com/keisuke071411/mk8dx-bot

まずはChatGPTに文字起こしをしてもらうため、対象の画像をFirestorageにアップロードし、URLを取得します。Firebaseの設定等は別記事をご参照ください🙇

/hooks/useTeamScoreList.ts
  // 画像をアップロードする
  const uploadImage = async (blob: Blob) => {
    try {
      // BlobをFile形式に変換(ファイル名とtypeが必要)
      const file = new File([blob], "image.jpg", { type: "image/jpeg" });

      // 画像を圧縮する
      const compressedFile = await imageCompression(file, IMAGE_OPTIONS);

      const now = new Date();
      const year = now.getFullYear();
      const month = String(now.getMonth() + 1).padStart(2, "0");

      const storageRef = ref(
        storage,
        `images/${year}${month}/${Date.now()}.jpg`,
      );
      const res = await uploadBytes(storageRef, compressedFile);

      if (!res || !res.metadata.fullPath) {
        throw new Error("Upload Error");
      }

      const imageUrl = await getDownloadURL(
        ref(storage, res.metadata.fullPath),
      );

      return imageUrl;
    } catch (e) {
      console.error(e);
    }
  };

ChatGPTに文字起こしをしてもらった結果を元に、チームごとの合計点を算出します。

/hooks/useTeamScoreList.ts
  // チームごとの合計点数を計算する関数
  const calculateTeamScores = (
    prev: TeamScore[],
    results: RaceResult[],
  ): TeamScore[] => {
    const updatedTeamScoresMap = prev.reduce(
      (accumulator, { team, score }) => {
        accumulator[team] = score;
        return accumulator;
      },
      {} as Record<string, number>,
    );

    // 新しい結果を加算
    for (const { team, rank } of results) {
      const point =
        RACE_POINT_SHEET.find(({ rank: r }) => r === rank)?.point ?? 0;

      // チームがまだ存在しない場合は初期化
      if (!updatedTeamScoresMap[team]) {
        updatedTeamScoresMap[team] = 0;
      }

      // 既存の点数に新しい点数を加算
      updatedTeamScoresMap[team] += point;
    }

    // MapをTeamScore形式の配列に変換して返す
    return Object.entries(updatedTeamScoresMap).map(([team, score]) => ({
      team,
      score,
    }));
  };

テンプレートマッチング機能は、リザルト画面を検出できなかった場合は同じ処理を1秒後に再実行します。リザルト画面を検出した場合は次のレースが開催される(大体2分後)まで処理を一時的に中断します。

page.tsx
  const fetchData = useCallback(async () => {
    const res = await captureScreenshotByObs();
    const blob = base64toBlob(res, "image/jpg");

    const formData = new FormData();
    formData.append("file", blob);

    const response = await fetch(
      process.env.NEXT_PUBLIC_API_BACKEND_URL as string,
      {
        method: "POST",
        body: formData,
      },
    );

    const result = await response.json();

    // 結果に基づき再度fetchDataを呼び出す
    if (result === "fail") {
      console.info("Fail - 1秒後に再試行");
      setTimeout(fetchData, 1000);
    } else if (result === "success") {
      console.info("Success - 次回呼び出しは2分後");
      setTimeout(fetchData, 120000);
      await getRaceResult(blob);
    }
  }, [getRaceResult, localIp, password]);

これでマリオカート自動集計Botが完成です!
あとはデプロイ時に発行されたURLをOBSのソースに添付するだけで動き始めます。

まとめ

OBS WebSocket, ChatGPT, テンプレートマッチングを駆使して、マリオカート自動集計Botを開発しました!

今後は私が開発したものを一般公開し、皆さんに使っていただきたいなと考えています。
現状はアプリがOBSに依存しており、個々人のlocalIPobs passwordが必要なため、そういったセキュリティ的な?問題を解消していきたいです。
(queryに指定すればいけそうとは思っている。漏れたらやばそうだけど)

もし、ご存知の方はコメントをいただけると嬉しいです🙏

参考文献

https://qiita.com/pachi-dragon/items/394b26b1621de92bfd98

https://zenn.dev/kiwichan101kg/articles/67c8871e1da0c8

Hackz Inc.

Discussion