【React×FastAPI×機械学習】キルした時刻を自動予測するゲーム解析用ツールを開発してみた

# はじめに

今回は、スプラトゥーン3において各イベント（スタート、キル、デス、フィニッシュ）が起きた時間を自動で解析するWebアプリの紹介をします。

ソースコードはこちらにあるので興味のある方はぜひ覗いてみてください。

https://github.com/MRyutaro/splatoon3_highlight_collector

# ツールの紹介

このツールでは、スプラトゥーン3において各イベント（スタート、キル、デス、フィニッシュ）が起きた時刻を自動で解析してくれます（下のgifは開発中のものです）。

![サンプルキル集_圧縮.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2906464/056a1c76-51b5-47b0-83dc-b883bc317ddc.gif)

動画をアップロードして解析ボタンを押してしばらくすると、各イベントの名前とその時刻が表示され、時刻をクリックすると動画がその時刻に飛んでイベントの発生を動画で確認することができます。

# 内部設計について

## 全体の構造

![設計書-構造.drawio.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2906464/82a8ce03-5fbe-c55d-f4b9-900ac3468cb2.png)

フロントエンドにReact、バックエンドにFastAPI、動画の解析にランダムフォレストを使っています。

## フロントエンド
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2906464/ba6fa956-6da2-1896-20ce-7fbdb82f54ef.png)

フロントエンドの開発には[React](https://ja.react.dev)を使い、本番環境ではビルドファイルをuvicornで配信するようにしました。
詳しく説明すると長くなるので特徴的な機能だけピックアップして紹介します。

### 解析の進捗状況のリアルタイム表示

![realtime.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2906464/d3d19436-15f2-569f-20bc-73f5a4ce5ca7.gif)

EventSourceを使って、サーバからリアルタイムで情報を受け取るというSSEを実現しました。

### 時刻のクリックによる動画のジャンプ

![サンプルキル集_圧縮.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2906464/056a1c76-51b5-47b0-83dc-b883bc317ddc.gif)

useRefを使ってvideo要素への参照を保持し、時刻をクリックしたときにその時刻を[currentTime](https://developer.mozilla.org/ja/docs/Web/API/HTMLMediaElement/currentTime)に代入することで実現しました。

## バックエンド
[FastAPI](https://fastapi.tiangolo.com)を使いました。
詳しく説明すると長くなるので特徴的な機能だけピックアップして紹介します。

### バックグラウンドタスクの追加

解析には時間がかかるため、クライアントへレスポンスを返してからバックグラウンドで解析の処理をできるようにしたいです。

そこでFastAPIの機能の一部としてデフォルトで用意されているBackgroundTasksを使います。（↓BackgroundTasksについて）

https://fastapi.tiangolo.com/ja/tutorial/background-tasks/

### yieldとSSEによる解析進捗状況の送信

まず、yieldを使ってバックグラウンドタスクからFastAPIへ解析進捗状況のデータを渡します。（↓yieldについて）

https://note.com/ym202110/n/n3301881f23b5

そして次に、SSEを使ってサーバからクライアントへ解析進捗状況を渡しています。（↓SSEについて）

https://qiita.com/highno_RQ/items/0f876a492279d1152942

## 動画の解析
### データ収集
まずは学習に使うデータを集めます。
プレイ動画をキャプチャし、1フレームごとに画像として保存しました。

集めた画像は種類ごとにフォルダに分けました。

最初はラベル付与の手間削減のために、10フレームに1枚の画像を使っていましたがこれだと誤検知が目立ったため、全てのフレームを保存してデータとして使いました。

各イベントの画像の枚数は以下の表の通りです。

| イベント | 枚数 |
|:-:|:-:|
| キル | 1365 |
| デス | 1352 |
| スタート | 11 |
| フィニッシュ | 129 |
| その他 | 10609 |

### 前処理

以下の流れで前処理を行いました。

1. グレースケール変換
1. リサイズ（横の長さ320px、縦の長さ180px）
1. 2次元から1次元にフラット化

### モデルの学習
モデルの学習にはPythonライブラリである[scikit-learn](https://scikit-learn.org/stable/)を使いました。

また、学習モデルには[ランダムフォレスト](https://scikit-learn.org/stable/modules/generated/sklearn.ensemble.RandomForestClassifier.html)を使いました。（↓ランダムフォレストについて）

https://qiita.com/Hawaii/items/5831e667723b66b46fba

ランダムフォレストの各パラメータは以下の表の通りです。
| パラメータ | 値 |
|:-:|:-:|
| n_estimators | 100 |
| random_state | 42 |

各イベントのモデルを作成するために使用したデータの枚数は以下の通りです。
| モデルの種類 | 訓練データの枚数(80%) | テストデータの枚数(20%) |
|:-:|:-:|:-:|
| キル | 9579 | 2395 |
| デス | 9569 | 2392 |
| スタート | 8496 | 2124 |
| フィニッシュ | 8590 | 2148 |

学習したモデルの精度はそれぞれ下の表のようになりました。

| モデルの種類 | 精度 |
|:-:|:-:|
| キル | 約99.92% |
| デス | 約99.83% |
| スタート | 約99.86% |
| フィニッシュ | 約100% |

また、それぞれのモデルがどのあたりを見ているかを可視化したものが以下の画像です。

![kill.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2906464/72e179ef-2514-a875-16aa-702e95456efe.png)
**↑キル**

![death.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2906464/a6171b8f-f12c-fa56-a254-e401cb4669da.png)
**↑デス1**

![death2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2906464/248e89d5-58e0-eddb-ab9a-204b614f9a1b.png)
**↑デス2**

![start.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2906464/ac5ec6bb-06c7-85ba-14fd-aeff1a272ee2.png)
**↑スタート**

![finish.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2906464/47e87b8a-f416-981a-4752-0e3ec2b399c4.png)
**↑フィニッシュ**

キルやフィニッシュはちゃんと見てほしい部分を見てくれており、精度も高いです。
一方でデスとスタートは精度が低く、見ている部分もばらついていることが分かります。

デス・スタートのモデルの精度が低い原因は、
- デス：デスの画像は2種類あるが1種類のモデルにまとめていること
- スタート：訓練データの枚数が少ないこと

かなと考えています。
また、前処理でソーベルフィルタなどを使ってエッジ検出をすればもう少し精度が上がりそうだなと思っています。
これらは今後実験していきたいです。


学習したモデルはpickleファイルとして保存しておき、推論時にそれを読み込んで使えるようにしておきました。（↓pickleファイルについて）

https://qiita.com/KiYuRo/items/3a1da9e6014d8834ea1d

### 推論
クライアントから送られてきたデータをwhile文で1フレームごとに画像として読み込んだものに学習と同じ前処理をかけて、先ほど学習したモデルをここで使用します。

1フレームごとのモデルの多少の誤検知を許容できるようにするために、直前Nフレームの推論結果が全てキルならキルフラグ(`is_killing`)を`True`へ、全てnotキルならキルフラグを`False`に変化させます。
そして、キルフラグが`False`から`True`へ変化したときにキルしたと判定します。
（下の図参照）

![設計書-構造-推論.drawio.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2906464/1a7f8587-f81d-56f9-aca0-ff83d3184f3a.png)

![設計書-構造-推論2.drawio.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2906464/7783a126-2640-c1fe-fa07-3891a8868609.png)

一方で、この処理によって、あるイベントが終了してから再び同じイベントが発生するまでの間隔がNフレーム以内の場合に2つイベントが発生しているにも関わらず1つのイベントだと誤検知してしまうという問題が起こります。（下の図参照）

![設計書-構造-推論3.drawio.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2906464/4bc1e71e-645c-5a19-39e9-920cb7ff90db.png)

今回は同じイベントが続けて起こることは少ない&続けて起こっても問題ないと想定してこれは問題視していません。

# さいごに
最後まで読んでいただき、ありがとうございました。

今回はスプラトゥーン3のハイライトを取得するツールを通して、機械学習をWebアプリの一部として組み込む方法を紹介しました。

外（ユーザ）から見ると機械学習が占める割合は小さいですが、このツールが便利だなと思ってもらえるためにはやっぱり機械学習の部分の精度が重要になってくるので、側の部分と中身の部分のどちらも大切だなと思いました。
今まで学んできたことを総動員して形にできたのはいい経験になったなと思います。
また、一緒に開発してくれた[グレン](https://twitter.com/Guren_FreeLance)にここで感謝を述べたいと思います。

ミスやアドバイスがあればコメントよろしくお願いします！
また、いいねとブックマークもよろしくお願いします！

もしスプラトゥーンをしている方がいれば、実際にこのツールを使ってみた感想を頂けると嬉しいです！

https://github.com/MRyutaro/splatoon3_highlight_collector