Splatoon3のイカランプのブキを画像認識で判定してみた

# TL;DR
opencvのエッジ抽出＋テンプレートマッチングでSplatoon3の全ブキ143種類を画像認識しました。
おおよそ判定できたはず。

# 前置き
初投稿です。大目に見ていただければ。

# Splatoon3について
この記事を読んでいる方はご存知かと思いますが、簡単に説明します。
Splatoon3はイカやタコがインクを塗ったり撃ったりして戦うシューティングゲームです。   
2015年から続くシリーズ3作目ということで、今年で10周年です。

https://www.nintendo.com/jp/switch/av5ja/index.html

# イカランプについて
試合中の画面上部にある、時間表示を間にして左右に4つずつある表示のことです。  
実際に公式でイカランプと呼ばれていたかは不明ですが、この記事では以降イカランプと呼びます。  
![イカランプ](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2702620/bd52d481-fecf-86ef-0210-fe7d6e032b3e.png)
（図中の赤い部分）

このイカランプ、試合中のHUDとしてはかなり重要なものとなっており、以下の情報をリアルタイムで表示してくれます。
* 使用武器（メインウェポン）
* 生存状態
* スペシャルウェポンが溜まっているか
* 試合状況が優勢か劣勢か
* （ガチアサリの時のみ）アサリの保持数及びガチアサリの保持の有無
（ガチルールだとカウントやエリアの状況、ヤグラやホコの位置も表示されるが、今回は対象外）

# 目的
イカランプが表示する上記情報を取得する。    
そのための第一歩として、まずはイカランプからメインウェポンを判定する。  
メインを判定した後はサブとスペシャルを表示する。（マップを開かずに確認できるように）
ゆくゆくは生存状態やスペシャルが溜まったことを検知してアラート出すとかやりたい。


# 環境
今回実施した環境。と言ってもキャプチャーボードとPythonとそこそこのCPUがあれば問題ないと思います。
* Intel Core i7-13700k(BIOSにてIntel問題解消済み)
* RAM64GB
* Windows10
* Python3.11
* キャプチャボード（MonsterX U3.0R）（ゲーム画面が取れれば何でもいいと思います）



# イカランプのブキの判定
## メインウェポンについて
スプラトゥーン3のメインウェポンは現時点で **65種類（スコープ含む）** あり、メインウェポンの性能は同じだが、サブウェポンとスペシャルウェポンの組み合わせが異なる「亜種」と呼ばれるブキが同数の **65種類** あります。   
さらにオフライン用のコンテンツであるヒーローモードやDLCのサイドオーダーをクリアすることで入手できるレプリカが **13種類** あります。     
合計して、現時点での判定したいブキは **143種類** となります。

## ブキ判定の難しさ
イカランプからブキを認識する際の課題として以下があります。
* 亜種の存在
* インクの色の組み合わせが多い
* 元画像の画質が（恐らく）高い
* イカランプからブキがはみ出す
* イカとタコが混在する


### 亜種の存在
前述のとおり、メインウェポンの性能が同じで、サブウェポンとスペシャルウェポンの組み合わせが異なるブキがあります。大体はメインウェポンが無印の色違いだったり、柄が入っていたりしますが、大きな特徴としてブキアイコンの右下に何かしらのマークがつきます。  
![無印](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2702620/0f0ab103-964d-dc7f-b9cd-02249fcfd6b7.png)![亜種](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2702620/65df1743-858e-5728-37fc-639bcee1a43c.png)  
メインウェポンの形は全く同じなので、メインウェポンの形だけで画像認識を行うと亜種と無印を誤認識する恐れがあります。


### インクの色の組み合わせが多い
ブキ判定をやろうと思ったときに[こちらの記事](https://qiita.com/Kazuma_Kikuya/items/71644455015a30a01571)を見つけましたが、前提として色覚サポートをオンにして色を固定する必要がありました。  
せっかくカラフルな色で遊べるのに色（しかも青色はかなり見づらい）を限定するのもなので、色覚サポートはオフのまま認識したいです。
したがって、組み合わせがいくつあるか分からないですが、最低でも5種類以上はある（フェス時はさらに増える）色の組み合わせを網羅するか、色によらない画像認識を行う必要が出てきます。

### 元画像の画質が高い
説明が難しいですが、恐らくイカランプに表示されるメインウェポンの画像はスプラトゥーン3のソフト内にある画像データを縮小して使っています（多分）。（ブキ屋やカスタマイズ画面に出てくるブキの画像と元は恐らく同じ）
問題となる点は、~~無駄~~に画質が良いスプラトゥーン3においてイカランプ内に表示される画像の境界がにじんでしまうことにあります。また、縮小されているため元の画像のドット単位の情報はつぶれてしまいます。   
つまり、ブキの境界を見ようとした場合、インクの色に影響され「このピクセルがこの色であればこの人はこのブキを使っている」といった判定ができないことになります。

### イカランプからブキがはみ出す
そのままですが、ブキがイカランプからはみ出してしまっています。ブキによってはみ出す部分が異なるため、イカランプの形をまず認識してそれからブキ判定、といった処理が組めません。    
![ブキがはみ出している](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2702620/d52ca53d-215c-9ddf-926a-8c86e862b06f.png)

### イカとタコが混在する
前作のスプラトゥーン2から、プレイヤーの自機にオクトリングが選べるようになりました。これが何故かイカランプにも反映されており、しかもタコの方がランプの面積がイカよりも大きく、イカだとはみ出ていたブキがタコだとはみ出ないなど、画像認識するには難しくなります。  
![イカランプ](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2702620/d23f2b2c-0cc9-9887-f597-3872c7148e6f.png)

## 画像認識方法
方法としてはなるべく簡易なもので、亜種も含めて同じ処理で出したいという思いがあり、今回はエッジを抽出したテンプレートマッチングを行うことにしました。  
イカランプの各々の画像からエッジを抽出し、あらかじめ用意した全ブキのエッジ画像とのテンプレートマッチングを行い、最も一致度が高いものをそのプレイヤーが使っているブキと判定します。

## 実装

<details><summary>長いので省略（クリックで開く）</summary>

### キャプチャボードから画像取得
キャプチャボードによって微妙に異なるかもしれません。
```capture.py
import cv2

def capture_from_device(device_index=0):
    """
    キャプチャーボードから映像を取得する
    :param device_index: キャプチャーボードのデバイスインデックス（通常は0または1）
    :return: ビデオキャプチャオブジェクト
    """
    cap = cv2.VideoCapture(device_index, cv2.CAP_DSHOW)
    if not cap.isOpened():
        raise ValueError(f"キャプチャーボードが見つかりません（デバイスインデックス: {device_index}）")
    cap.set(3, 1920)
    cap.set(4, 1080)
    return cap

```

### フレームからイカランプの切り出し
固定値で切り出します。
```splamp.py
import cv2

def get_splamp(frame):
    """
    :param frame: 入力画像(1920*1080)
    """
    # イカランプを切り出す
    return frame[25:115, 500:1420].copy()
```

### エッジ抽出
opencvのcvtColorでエッジ検出を行います。パラメータは固定でもよかったと思いますが、インクの色に左右されても困るので自動で決定します。

```get_edge.py
import cv2
import numpy as np


def get_edge(image, sigma=0.33):
    """
    入力画像をエッジ画像に変える
    """
    image = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
    # 画像の中央値を取得
    v = np.median(image)

    # 閾値を計算
    lower = int(max(0, (1.0 - sigma) * v))
    upper = int(min(255, (1.0 + sigma) * v))

    # Cannyエッジ検出
    edges = cv2.Canny(image, lower, upper)
    return edges

```

### テンプレート画像の取得
メモリープレイヤーが実際の試合中と同じイカランプを表示してくれるので、適当に試合しつつまだ追加してないブキのメモプを見ながら取得していきます。
優劣がついて大きさが変わるとうまく取れないので、その前に取得します。
プレイヤーごとの切り出し位置と切り出す画像サイズをピクセル単位で合わせています。
そのうえで何度か画像を取得し、エッジ抽出した画像たちのアンドを最後に取ります。こうすることでノイズを低減しています。
後でも出てきますが、画像のパターンは多い方がいいのでイカランプの敵味方を入れ替えて画像を取得しなおしたりします。

こんな画像が取れます。
スプラシューター
![SplaShooter.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2702620/cb4167f6-e7bb-7a4b-a26c-94c5f1fcf1f4.png)
スプラシューターコラボ
![SplaShooterCollabo.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2702620/7e4d0cd5-256a-e004-a1b1-39867389c4f4.png)


```get_wepon_icon.py
import cv2
import os
import shutil
import numpy as np
from capture import capture_from_device as capture
from splamp import *
from get_edge import get_edge


def edit_splamp_solo_img(path):
    """
    切り出した1人分のイカランプから余計な部分（イカタコの境界線）を除去する
    """
    img = cv2.imread(path, cv2.IMREAD_COLOR)
    # アルファチャンネルを追加
    if img.shape[2] == 3:
        alpha_channel = np.full((img.shape[0], img.shape[1]), 255, dtype=np.uint8)
        img = cv2.merge((img, alpha_channel))

    img[78:, :, :] = 0
    img[:35, :, :] = 0
    img = img[35:78, :, :]
    cv2.imwrite(path, img)


"""
メモプを再生し、's'を何度か押してエッジ画像（とついでにカラー画像）をいくつか取得
※優劣がついて拡大縮小される前に行うこと
複数のエッジのANDを取り、ブレを吸収した画像（and_edge.png）を作る
"""
if __name__ == "__main__":
    save_dir = "./tests/caps"
    save_cnt = 0
    if os.path.exists(save_dir):
        shutil.rmtree(save_dir)
    if not os.path.exists(save_dir):
        os.makedirs(save_dir)
    cap = capture(0)
    while True:
        ret, frame = cap.read()
        splamp = get_splamp(frame)
        edhe_splamp = get_edge(splamp)
        cv2.imshow("edhe_frame", edhe_splamp)
        cv2.imshow("Original Frame", frame)

        cv2.imshow("Splamp", splamp)
        k = cv2.waitKey(1) & 0xFF
        if k == ord("q"):
            break
        elif k == ord("s"):
            cv2.imwrite(save_dir + f"/cap{save_cnt}.png", splamp)
            cv2.imwrite(save_dir + f"/cap_edge{save_cnt}.png", edhe_splamp)
            i = 42, 130, 219, 307, 560, 648, 737, 825
            e = 96, 184, 273, 361, 614, 702, 791, 879
            l = 54
            splamp_solo = [splamp[:, i[n] : e[n]] for n in range(8)]
            edhe_splamp_solo = [edhe_splamp[:, i[n] : e[n]] for n in range(8)]
            [cv2.imwrite(save_dir + f"/splamp{n}_{save_cnt}.png", splamp_solo[n]) for n in range(8)]
            [cv2.imwrite(save_dir + f"/splamp_edge{n}_{save_cnt}.png", edhe_splamp_solo[n]) for n in range(8)]

            save_cnt += 1
        elif k == ord("d"):
            cv2.imwrite(save_dir + "/cap_full.png", frame)

    cv2.destroyAllWindows()

    edge = cv2.imread(save_dir + f"/cap_edge0.png")
    color = cv2.imread(save_dir + f"/cap0.png")
    for i in range(save_cnt - 1):
        color &= cv2.imread(save_dir + f"/cap{i+1}.png")
        edge &= cv2.imread(save_dir + f"/cap_edge{i+1}.png")
    cv2.imwrite(save_dir + "/and_edge.png", edge)
    cv2.imwrite(save_dir + "/and_color.png", color)

    for n in range(8):
        edit_splamp_solo_img(save_dir + f"/splamp_edge{n}_0.png")
        edit_splamp_solo_img(save_dir + f"/splamp{n}_0.png")
        edge = cv2.imread(save_dir + f"/splamp_edge{n}_0.png")
        color = cv2.imread(save_dir + f"/splamp{n}_0.png")

        for i in range(save_cnt - 1):
            edit_splamp_solo_img(save_dir + f"/splamp_edge{n}_{i+1}.png")
            edit_splamp_solo_img(save_dir + f"/splamp{n}_{i+1}.png")
            edge = cv2.imread(save_dir + f"/splamp_edge{n}_{i+1}.png")
            color = cv2.imread(save_dir + f"/splamp{n}_{i+1}.png")
        cv2.imwrite(save_dir + f"/and_splamp_edge_{n}.png", edge)
        cv2.imwrite(save_dir + f"/and_splamp_color_{n}.png", color)

```

### テンプレートマッチング
マッチング対象範囲はテンプレート画像取得の範囲と同じです。
8人分で全ブキをマッチングするので1秒くらいかかります。

```detect_wepon.py
import cv2
import numpy as np
from get_edge import get_edge


def detect_weapon_from_splamp(frame):
    """
    イカランプからブキを特定する
    全ブキでテンプレートマッチングを行うため1フレ以内には終わらない。
    ブキ自体は試合中には変わらないこともあり、試合開始時にのみ特定することを想定
    （優勢劣勢時の大きさ変動には対応しない）
    """
    frame = get_splamp(frame)
    weapons = []

    i = 42, 130, 219, 307, 560, 648, 737, 825
    e = 96, 184, 273, 361, 614, 702, 791, 879
    l = 54
    for n in range(8):
        weapons.append(detect_weapon(frame[35 : 78 + 1, i[n] : e[n] + 1]))

    return weapons


def detect_weapon(splamp):
    """
    イカランプからブキを特定する(1人ごと)
    （優勢劣勢時の大きさ変動には対応しない）
    （ガチホコは別途）
    """
    edhe_splamp = get_edge(splamp)
    now_val = 0.0
    now_weapon = None
    for name, img in templates.items():
        edhe_template = get_edge(img["template"])
        result = cv2.matchTemplate(edhe_splamp, edhe_template, cv2.TM_CCOEFF_NORMED, mask=img["mask"])
        min_val, max_val, min_loc, max_loc = cv2.minMaxLoc(result)
        print(name, max_val)
        if 1.0 < max_val:  # infになった時にやり直す
            continue
        if now_val < max_val:
            now_val = max_val
            now_weapon = name
    return now_weapon, "{:.2f}".format(now_val)

import os
def init():
    """
    テンプレートマッチング用の画像を取り込む
    """
    template_dir = "./icons/templates"
    global templates
    templates = {}
    for img in os.listdir(template_dir):
        if img.split(".")[-1] != "png":
            continue
        templates[img.split(".png")[0]] = {}
        template = cv2.imread(template_dir + "/" + img)

        mask = np.full((template.shape[0], template.shape[1]), 255, dtype=np.uint8)
        templates[img.split(".png")[0]]["template"] = template
        templates[img.split(".png")[0]]["mask"] = mask

```
</details>

# テンプレート画像取得の長き戦い
適当に試合に潜り、初見のブキを見つけ次第メモプから画像を作っていきましたが、全然見ないブキ（竹、和傘、オーダー系）は自分で実際に持って取りました。
また、ピクセル単位で固定してイカランプの位置が微妙に異なっているのか、取得したテンプレート画像を使ってもうまく判別してくれない時がありました（大体亜種と無印の混同）。そのような場合は間違えた位置に対して画像を取り直し、元の画像とのアンドを取ることで精度を上げていくことで解決しました。
最終的に、よく見るブキの判別率は9割以上はあると思います。


# 実際に判定してみた
前述のプログラムを使って判定した結果です。
サブとスペシャルは別途組み合わせをデータ化したものから拾ってきています。
ブキ名の横は一致度です。大体0.2出てればよい方です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2702620/aca2d1b9-66ad-44c9-9e6c-1abbe8abda3b.png)

```判定結果
Gallon52 0.20 {'sub': 'SpashShield', 'special': 'MegaphoneLaser51ch'}
OrderShooterReplica 0.39 {'sub': 'SuctionBomb', 'special': 'UltraShoot'}
HokusaiHue 0.32 {'sub': 'JumpBeakon', 'special': 'RainStorm'}
PrimeShooter 0.25 {'sub': 'LineMarker', 'special': 'CrabTank'}
SplaShooter 0.21 {'sub': 'SuctionBomb', 'special': 'UltraShoot'}
DentalWiperMint 0.23 {'sub': 'SuctionBomb', 'special': 'GreatBarrier'}
HotBlasterCustom 0.19 {'sub': 'PointSensor', 'special': 'UltraLanding'}
SharpMarker 0.19 {'sub': 'QuickBomb', 'special': 'CrabTank'}
```

別ウインドウで検知結果とサブとスペシャルを出すことで当初の目的であったサブとスペシャルの確認にいちいちマップを開かなくてもよいようになりました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2702620/30aaa128-31e3-33df-a93a-d030c35d0f4c.png)




# 終わりに
なるべく楽に判定する方法を選んだつもりでしたが、ブキの種類が多いことと、一発で判定できないことがあり、なかなかに苦労しました。
次はデスの検知とスペシャルの検知をやってみたいと思います。