【画像認識】AIひろゆきがスプラトゥーン3のキルデス報告してくれるアプリ作った話



# TL;DR
この投稿はOpenCV Advent Calendar 2022の3日目の投稿です！

Python + OpenCVを利用して、ニンテンドーSwitchに接続したキャプチャボードからスプラトゥーンの映像を取得・解析し**ひろゆきがキルデス報告くれるアプリを作りました**。

通話しながらの対抗戦でよくある「スシやり」等のキル報告や、スペシャルの報告を**例のAIひろゆき**が読み上げてくれます。まるでひろゆきと一緒にスプラをプレイしているような感覚を楽しめるというアプリです。ついでに各プレイヤーの生存秒数ゲージ、デス回数カウンター、復帰までの推定予測ガイド等を視覚化するGUIも実装しました

<iframe width="560" height="315" src="https://www.youtube.com/embed/zgMndF3gcMI" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>

3年前にもスプラ2で似たようなプレイヤーのデス検知・カウンターアプリを作りましたが、スプラ3ではブキの分類、SPの検知や音声読み上げという更にブラッシュアップさせることができました。

https://qiita.com/Kazuma_Kikuya/items/4767afe581c94b0c16f1

ソースはGitHubにすべて公開してます。ただしテンプレートマッチング用の画像は含みません。これに関しては後に対応いたします。

https://github.com/kazumax75/splat3_recognition

どういったアルゴリズム、手法、openCVのメソッドの利用方法などを簡単にではありますが本記事で書ける範囲で解説していきたいと思います。


# スプラトゥーンの画面から把握できること
まずスプラトゥーンは4vs4のプレイヤーがそれぞれブキを持ち、インクで塗り合うオンライン対戦ゲームです。対戦中「イカランプ」（記事内ではイカアイコンと呼びます）と呼ばれる画面上部にある、8人のプレイヤーの持ち武器や生存中か、スペシャル使用可能かなどが判別できるイカorタコの形をしたアイコンが表示されます。
![68747470733a2f2f71696974612d696d6167652d73746f72652e73332e61702d6e6f727468656173742d312e616d617a6f6e6177732e636f6d2f302f3239353236332f35366435623932372d333765372d306338612d643561612d3831383932306361303233622e706e67.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/295263/0d8e60b5-9a05-40c6-7f40-c4578c53f6d2.png)
イカアイコンから把握できる事は、
- **誰がデスしたか、デスしてるのか**
- **誰が、なんのスペシャルウェポンが貯まったか（メインブキからスペシャルが把握可能なので）**
- **デス回数**
- **スペシャルウェポン使用回数**
- **スペシャルウェポン抱え落ちしたか**
- **生存秒数**
- **デスしてる秒数　→　復帰までの推定秒数**
- **↑によってWIPEOUT詐欺も見抜ける**。

…等など。デス回数などは人間では一々数えてらんないので把握することが難しいですが、画像認識によって自動カウントすればそんなことも可能です！
またマップ画面から「イカ忍者・ステジャン・対物」積んだブキも把握し読み上げるようにしました。試してませんがマップから現状の塗り面積を割合を推定するという機能も作れそうな気がします。
もう一つ、実装は難しそうですが、**敵のジャンプマーカーのプレイヤー名とマップのプレイヤー名／ブキアイコンを紐づけることで、ジャンプしてくる敵がなんのブキか読み上げとか出来たら理想だったな～～**
# アルゴリズムを考える

## マスク付きテンプレートマッチングで、対戦開始時に8人のブキを分類する

音声で読み上げするにはデスしたアイコンがなんのブキなのか、プログラム側で把握しなければなりません。またブキが把握できれば持っているスペシャルウェポンの名前も把握できます。

**アイコン画像の認識にはマスク付きテンプレートマッチングを利用します**。
バトル開始時に1度だけ、8つのアイコンの座標を切り抜きそれぞれにテンプレートマッチングを行います。

テンプレートマッチングについてのコードはこちらの記事に掲載しています。

https://qiita.com/Kazuma_Kikuya/items/f57c796c8af164d05845


11/30現在、**53種類のブキがあり（ヒーローシュータ含めて、スコープは含めない）テンプレートマッチングの総当りで正確に分類できています**。
8個のアイコンに対して総当たりでマッチングしても、2秒はかかりません。しかし今後のブキ追加で今の3倍増えたとすると精度・速度がどうなるのか、気になるところですね。

## 位置・スケールを考慮したイカアイコンの検出
**対戦開始時を除くと、対戦中は画面上部のイカアイコンのスケールと位置は可変であり、戦況が優勢なチームのアイコンが大きく、劣勢のチームが小さく表示されます**。
戦況に応じて3段階のスケール変更があり、時には黒いバーをはみ出すように表示されます。

でアイコン位置を取得することも
アイコンはスケール変更されるので毎フレームテンプレートマッチング
[前作スプラトゥーン2でこのアプリを作ったとき](https://qiita.com/Kazuma_Kikuya/items/4767afe581c94b0c16f1)はRGB or HSVの範囲を指定することで任意の色の領域を取得できるcv2.inRange()を利用してアイコンの並ぶ下レイヤにある黒いバーを検出し、その検出した矩形領域の間にある画像をアイコンとして認識させました。

**しかし今作はUIがリッチになりすぎて当時の実装をそのまま移植することが難しくなりました。**
![qiita_icon.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/295263/598c54c3-758a-d715-596d-7a9cb1aa0108.png)
主な要因は、イカアイコンがぼんやりと光るようになり、下レイヤのバーに色移りするようになったことです。色移りのせいで、inRange()で指定色範囲のマスク取得が簡単ではなくなりました。かと言ってHSVの範囲を広げることで黒いバーの領域を拾おうとすると、ブキアイコンの一部など他のノイズを多く拾ってしまいます。

しかもスプラのチームカラーは毎回ランダムで、10以上の組み合わせがあったはず……ゲーム側の設定で「青と黄」色固定ができるため色固定を前提に色範囲を指定することにしました。色固定じゃないと、様々なしきい値決めが大変になりそうなので。

### inRange()とconnectedComponentsWithStats()による解析手法

ということで黒いバーに青・黄色掛かったHSV値を設定しinRange()でマスクの取得をするようにしました。
ゲーム画面は1920x1080でキャプチャされていることが前提で、バーの表示位置は固定なので切り抜きは定数で行います。

【bar_img.png】![bar_img.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/295263/6a5de4a0-f181-97f6-6499-bda3b811d4cd.png)

この画像からバーの領域を切り取った画像を作成し、その画像に対してinRange()で黄or青に色移りしたバーの領域を取得します。

・結果。検出領域を緑で塗った
![nofilter inRange.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/295263/7ed28aa7-5c79-efa2-6e4b-8e763dbd57ad.png)
さて、取得したマスクにはバーではない混じってます。アイコンのスケール変更したときに不要なマスクが増えたりもします。これをcv2.connectedComponentsWithStats()による連結成分を分析することで絞り込んでいきます。
この関数についてはこの記事がわかりやすいと思います。

https://qiita.com/wakaba130/items/9d921b8b3eb812e4f197

右端の凹んだ箇所を除くと切り取った画像の最下部に接地しているという法則があるように見えます。（ドライブワイパー等はみ出すブキのせいで、接地面のpx数はブキによって変わります。）

諸々考慮した条件が下記
1. 外接矩形のwidthが指定範囲内
2. 面積が一定px以上
3. 連結成分の外接矩形のtopが一定以下
4. 最下部の面積px数が一定以上

これを満たす連結成分に絞り込み、かつ外接矩形のleft,right情報から矩形マスクを作成して緑で塗りつぶすと、アイコン間のバーの領域だけうまく拾えました。
![img.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/295263/fb8bc28e-6438-5e41-2f91-340efcf3c0ef.png)

コードにすると下記のようになります。
```python
import cv2
import numpy as np

img = cv2.imread(r"bar_img.png") # 記事に貼った画像
# バーの領域のみ切り取る
img = img[75:101, 517:1406].copy()
hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
# 黄色が色移りしたバーの色領域のマスク取得 ※キャプボによって色の出方は変わると思うのでHSV値は適宜変更してください
yellow = cv2.inRange(hsv, (22, 119, 51), (43, 243, 99))
# 青色が色移りしたバーの色領域のマスク取得 ※キャプボによって色の出方は変わると思うのでHSV値は適宜変更してください
blue =   cv2.inRange(hsv, (119, 121, 53), (130, 194, 93))
#2つのマスク足し合わせ
mask = cv2.bitwise_or(yellow, blue)

# 表示用。マスクを入力画像に反映
img2 = img.copy()
img2[mask==255] = (0,255,0)
cv2.imshow("nofilter inRange", img2)

# モルフォロジー変換でノイズ除去
kernel = np.ones((3,2),np.uint8)
mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel)

# labelsに画像、statsに外接矩形、面積が格納されます
_, labels, stats, _ = cv2.connectedComponentsWithStats(mask)

# マスクをクリア
mask.fill(0)
# 連結成分分析を行う。アイコン間のバー領域のみ抽出する
for i, stat in enumerate(stats[1:], 1):
    _bottom = stat[cv2.CC_STAT_TOP] + stat[cv2.CC_STAT_HEIGHT]    
    if stat[cv2.CC_STAT_LEFT] < 847 :
        # 凹みの無いバーの領域の時
        if (
            # 1.外接矩形のwidthが指定範囲内
            5 <= stat[cv2.CC_STAT_WIDTH] <= 31 and 
            # 2.面積が一定px以上
            stat[cv2.CC_STAT_AREA] >= 4*4 and 
            # 3.連結成分の外接矩形のtopが一定以下
            stat[cv2.CC_STAT_TOP] <= 15 and
            # 4.最下部の面積px数が一定以上
            np.count_nonzero((labels[-1:] == i) == True) >= 5
        ):
            # 条件満たせば矩形でマスク塗る
            cv2.rectangle(img, (stat[cv2.CC_STAT_LEFT], 0), (stat[cv2.CC_STAT_LEFT]+stat[cv2.CC_STAT_WIDTH], img.shape[0]), (0, 255, 0), -1)
    else:
        # 右端のバーに凹みがある領域の時
        if (
            # 1.外接矩形のwidthが指定範囲内
            5 <= stat[cv2.CC_STAT_WIDTH] <= 31 and 
            # 2.面積が一定px以上
            stat[cv2.CC_STAT_AREA] >= 3*3 and
            # 3.連結成分の外接矩形のtopが一定以下
            stat[cv2.CC_STAT_TOP] <= 15 and
            # 連結成分の外接矩形のbottomが一定以下
            _bottom >= 26 - 6
        ):
            # 条件満たせば矩形でマスク塗る
            cv2.rectangle(img, (stat[cv2.CC_STAT_LEFT], 0), (stat[cv2.CC_STAT_LEFT]+stat[cv2.CC_STAT_WIDTH], img.shape[0]), (0, 255, 0), -1)

            
# マスクを入力画像に反映
img[mask==255] = (0,255,0)

# 表示
cv2.imshow("img", img)
cv2.waitKey(0)
```
※inrangeの色範囲は、キャプチャボードによって色の出方が変わると思うので適宜変更が必要かと思います。

長くなりすぎるのでここからは割愛して説明すると、
後述するデス状態アイコンを検出し、デスの領域等の「非アイコン領域」のマスクを取得し、上記のマスクの形と足し合わせ、画像の上下に接地した矩形を描画します。するとうまくアイコンのx,y座標を取れるので毎フレームそれを切り抜いています。

優勢・劣勢によるスケール変更にも対応しつつアイコンの状態が算出できるようになります。
![Animation2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/295263/f8aa23ba-194b-e6e6-33e3-2a8e2bc490e2.gif)

超ざっくりとした説明で恐縮なのですが。。。詳細なアルゴリズムは複雑で書ききれないのでgitの全体ソースを参考にしてください。
## デス状態の検出
スプラ2以前では、デスするとプレイヤーのアイコンがパッとバツのアイコンに切り替わりましたが、今作ではバツのアイコンへの切り替わりにエフェクトアニメーションが追加されてしまいました…😂
![Animation.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/295263/b0222c58-e39b-4aca-f7ff-319b5472ebb4.gif)
まずブキアイコンがグレーと黒のシルエットになったあと、「黒、グレー、濃いグレー」の3色の色で点滅と変形をしながらバツのアイコンになります。
ご覧の通り色移りするし、もバツの大きさも優勢劣勢によるスケールに比例します。

単色の記号ならinRangeで簡単に処理できるのでは？と思うかもしれませんがそんな事がなく…薄めのグレーの色が、SPが貯まってるアイコンの模様を誤検出することがあり、輪郭の外接矩形を画像の高さと一致するものに絞っても誤検出するケースが有りました。SP貯まったアイコンの変な模様がぐるぐる回転するあの演出のせいで…

ということで
「ある程度太さを持った、だいたい±45℃の斜線」という条件に絞り込む必要があります。

Hough変換による直線検出を思い浮かべましたが、あれはあれでしきい値設定やノイズに悩まされそうなのでやめました。もう少し簡単な手法として思いついたのが

バツアイコンのバッテンの中心のy座標～18pxの領域を切り取った上で
「切り取った領域の上下に接地する両端の点」である4点を取得し、
2点間の傾き・距離からバツアイコンであることを検出するというロジックです。
![Animation3.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/295263/fad10436-f70c-1290-c63d-e8e6b1d22571.gif)

2点間の直線の知識は高校数学で習いましたね。あの公式を使います。

**傾き = (y2 - y1) / (x2 - x1) 　(x2 - x1)≠0**

https://manabitimes.jp/math/894

エフェクトアニメーションの途中、バツではなく1本の斜め線が表示され、最終的に4点は台形になります。上下両端の4点の、ぞれぞれの線分の傾きを条件にすることでどちらにも対応ができるのです。

点t1,b1の傾きが-1、t2,b2の傾きが-1の時　斜線エフェクト
![naname.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/295263/5030bd27-a9ea-4555-e0ab-837a03db08b2.png)

点t1,b1の傾きが-1、t2,b2の傾きが1の時　バツアイコン
![daikei.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/295263/fcb12e5c-18b1-1a0a-882f-02861133e35e.png)



エフェクトアニメーションの途中、バツではなく1本の斜め線が表示され、最終的に4点は台形になります。傾きを条件にすることでどちらにも対応ができるのです。

さらにt1,t2とb1,b2の距離が一定px以上という条件を加えることで、線の太さにしきい値を設けています。





なお「ブキアイコンがグレーと黒のシルエット」の状態の検出は、inRangeをグレーと黒の2回行いマスクを足し合わせた上で、上下に接地かつ上下の接地面それぞれの長さが一定以上というような条件と面積に条件つけて検出してます。これも説明が長くなるので詳細はgitのソースのほうを参考にしてください🙇
## スペシャルウェポンが貯まってるか検出
イカアイコンが光っている状態の検出はヒストグラム比較による手法で実装しました。

https://ensekitt.hatenablog.com/entry/2018/07/09/200000

まず対戦開始時に切り取った通常アイコンからHSVのS,Vのヒストグラムを取得し、あとは毎フレーム切り抜いた現在アイコンのヒストグラムと比較します。
ヒストグラムが大きく変化した時、ゲーム側で起きている状況は「デス（回線落ち）した・SP貯まった・ホコを持ってアイコンが変わった」のいずれかです。デスの検知は前述の手法で検出しているので、デスでは無い＆ヒストグラム比較値がしきい値を下回るならアイコンが点滅しているとみなし、スペシャルが貯まっていることが検出可能です。

・ヒストグラム比較の実装例
```python
import cv2
import numpy as np

histSize = [50, 50]
ranges = [0, 256] + [0, 256]
channels = [1, 2] # S,Vのヒストグラム

img1 = cv2.imread("img1.png")
img2 = cv2.imread("img2.png")

_hsv = cv2.cvtColor(img1, cv2.COLOR_BGR2HSV)
# img1のヒストグラムの算出
hist1 = cv2.calcHist([_hsv], channels, None, histSize, ranges)

_hsv = cv2.cvtColor(img2, cv2.COLOR_BGR2HSV)
# img2のヒストグラムの算出
hist2 = cv2.calcHist([_hsv], channels, None, histSize, ranges)

# ヒストグラムの比較
res = cv2.compareHist(hist1, hist2, 0)

if abs(res) > 0.95:
    print("類似している")
else:
    print("類似していない")
```

# 動画の解析にdeque(両端キュー)が便利って話
dequeは「デック」と呼ぶようです。両端キューとはスタックとキューをあわせたようなもので「直近nフレーム分の、検出結果の真偽値の状態保持」に利用できます。gitに上げたコードの方で多用してます。
```python
from collections import deque

dq: deque = deque([], 3)
dq.appendleft('hoge_1')
dq.appendleft('foo_2')
dq.appendleft('bar_3')
dq.appendleft('fuga_4')

for val in dq:
    print(val)
# fuga_4
# bar_3
# foo_2
```
初期化時にサイズを決め、appendLeft()でリストの0番目に要素を追加し、以降の要素はインデックスをずらしてくれます。あぶれた要素は削除されます。

これがどう役立つかというと具体的には
- 状態Aが状態Bに切り替わったときになにか処理したい（アイコンが生存→デスになったとき読み上げ通知とか）
- ある状態がnフレーム以上連続したら何かを処理したい
- ある状態が検出され、そのnフレーム後になにか処理したい

等など、ifの条件文次第で上記のような処理を書くことができます。

認識させる画像のノイズによって一瞬チラっと状態AのことをBと誤認識してしまったとしましょう。
これを「AがBに切り替わった」と誤った判定をさせてくない場合、
dequeの並びが[ B, B, B, A , ...]のように「状態Bが3フレーム連続し、その直前がAの時」という条件文にすることで雑に対応できるはずです（当然、フレーム単位での状態認識を正確にすることが一番適切な実装ですが。）
※ただこれはフレーム落ちを考慮していません。
キャプボから取得する画像は、漏れ無く取得できるものなのか、たまたま負荷が増え処理が遅れたときは
フレームを飛ばして取得してしまうことがあるのかそのあたりの仕様は把握してません。
そこも考慮するならdequeサイズを絞り、条件も少し書き方を変えたほうが良いでしょう。

# ソースコードとアプリの動かし方
前提として公開してるソースでは、リサイズを行ってない1920x1080でキャプチャされた映像のみに対応してます。アイコンを切り抜く座標の値等はそれを基準にしています。

既に述べましたが、現状、色覚サポートONの時の黄色・青の背景色のアイコンのみに対応しています。スプラトゥーンのゲーム設定でオプション＞その他＞色覚サポートONにしてください。

テンプレートマッチングに利用するブキアイコンのテンプレート画像は配布していません。
実際にゲームの画像から取得したものを利用しているのですが、それをgit上で配布することは本記事で扱ってるキャプチャと異なり著作権の引用の範囲から外れるためです。

アドベントカレンダーの日付に作業が間に合わなかったのですが…
ゲーム開始を検知し、アイコンを自動で保存するスクリプトを後日配布します。
適当にナワバリバトルでも回していれば自動でブキアイコンが採取できますのでそれを命名規則に沿ってリネームして使ってください。

もう一点、おそらくキャプチャボードによって同じ色に見えてもRGBの値のが微妙に異なることが考えられます。
適切な閾値を探るためのスクリプトも後日配布致します。少々お待ち下さい。🙇

# おわりに
いかがでしたでしょうか？アルゴリズムの考え方、行列演算やマスク処理、モルフォロジー変換、連結成分分析、テンプレートマッチング、ヒストグラムマッチング等一通り触れることが出来、**画像処理の古典的手法を学ぶには相当よく出来たアプリだと思います**。ついでにスプラ攻略に役立ちますしね😋

自分のアイディアを形にできるプログラミングってやっぱり面白いなあと思いますね！

**自分はWeb系主戦場の人間ですが、やっぱり画像処理のプログラミングが一番楽しい。色んな可能性を秘めてるので！**

それはそうとスプラ3は12月からチルシーズン開幕によりブキが大量追加されたので、新たな十数個のマッチング用のテンプレート作らねば。結構手間なんですよね、キャプチャするために何度も対戦しないといけないので


初めてのAdventCalendar参加でした！OpenCV界隈の盛り上がりに少しでも貢献できたらなと思います。読んでいただきありがとうございました！