December 20, 2021

対戦ゲームの動画記録から特定のイベントを自動的に探してクリップを生成する画像処理作った

素人発想・玄人実行

はじめに

作ったもの:スマブラSPとスプラトゥーン2の対戦動画からのダイジェスト動画の生成

YouTubeの配信アーカイブとかから以下のような撃墜集を簡単に作るための画像処理系を作りました.

スマブラSP 撃墜集

スプラトゥーン2 キル集

モチベーション

筆者はNintendo Switchの『大乱闘スマッシュブラザーズ SPECIAL』(以下,スマブラSPと略記)というゲーム作品をよく遊んでいます. スマブラSPは任天堂や他のゲーム開発元の作品の代表的なキャラクターが一堂に会して戦うクロスオーバーの対戦アクションゲームで, スマブラシリーズの5作目です.今年の10月にDLCファイターも全員出揃って賑わっていますね. 自分がメインで使ってるキャラクターはリヒターとしずえさんです.変なキャラしか上達しませんでした.

スマブラSPは大乱闘のモードで複数人でわいわい遊ぶパーティゲームですが, 自分はオンラインで1on1をひたすらやるという偏った遊び方をしています. 大乱闘で勝ち続けてもVIPマッチ上位帯は1on1やってる人しかいないので仕方が無いですね.優先ルールの闇です. もちろん,対戦ゲームは楽しむことが一番なのですが,戦うからにはやはり勝ちたいものです.

しかし,スマブラSPの「ガチ対戦」と呼ばれてるもの(1on1・試合時間7:00・3ストック・アイテム無し・一部のステージ固定1)を突き詰めるのはかなり大変です. 何よりキャラクターがあまりにも多すぎる,89体て2.単純に試合数を重ねる練習方法では上達に時間がかかってしまいます. そのため,上手な人の試合を見て真似したり自分の試合を振り返るなど,練習の質を上げることが大切になります.

スマブラSPには試合中の操作を保存・再生するリプレイ機能やリプレイを動画化する機能もありますが, Switch上で動画管理するのって面倒臭いんですよね. なので,自分は代わりにYouTubeの限定公開の配信で試合内容を記録しておいて, 暇なときに配信アーカイブを見返しています.PCのストレージを圧迫しないので便利です.

でもYouTubeのアーカイブ見返すのも面倒臭いんですよね.一時間の練習をまた一時間かけて見るのは嫌です. なので,試合開始や撃墜シーンのタイミングを記録しておいてそこだけ見るようにしたいけどその記録すら面倒臭い. もうね,全てが面倒臭いです.

そのため,画像処理によって興味のあるシーンのタイミングを記録し, 動画のクリップを自動で作成してくれるツールを開発しました. 今後の面倒を減らすために面倒の先払いをするのが我々コンピュータサイエンス屋の仕事のやり方です. 今回はゲームにおける撃墜やキルのシーンに話題を絞っていますが, 問題をきちんと定義できるなら他の映像にも応用可能なように設計をしています.

また,この記事では問題の定式化の話がメインなのでOpenCVの話はあんまり出てこないです. Advent Calendar的にどうなのと思われそうですが, OpenCVの助けを借りられるからこそ我々はこのふざけた問題の本質に本気で向き合えるのです. OpenCVの全てのコントリビュータに――感謝.巨人の肩の上で存分に踊らせていただきます.

ゲーム画像解析の偉大なる先人たち

インスピレーションを得ました.

問題設定

解きたい問題

スマブラSPの試合のリプレイを再生しているところをYouTube配信のアーカイブとして記録し, そのアーカイブから撃墜の瞬間をプログラムで自動的に探す問題を解きます. (普通にオンライン対戦の様子を記録したアーカイブでもいいですが, プレイヤー名が出るのが嫌だったのでリプレイを使っています. この記事で紹介する方法をオンライン対戦の動画に適用することは可能です.)

解析するYouTubeの配信アーカイブの詳細

動画は以下の図のような構成になっています:

img

リプレイ選択画面ではリプレイの一覧が表示されており,その中から適当な試合を選んで再生します. 試合が終わった後は続けて次のリプレイを再生するか一度リプレイ選択画面に戻って他のリプレイを選択して再生する, というのを配信の終わりまで繰り返します. 各シーンの長さは不均一でリプレイ選択画面間の試合数もバラバラになっています (流しっぱなしのときと自分で操作して流してるときがある).

リプレイ選択画面の例:

リプレイ選択画面

試合中の画面の例:

試合中

録画と計算環境

以下の環境でやりました:

  • PC: Windows 10 Home, AMD Ryzen 7 2700U with Radeon Vega Mobile Gfx 2.20GHz, RAM 8.00GB
  • ソフトウェア:OBS Studio 26.0.2
  • キャプチャボード:Elgato Game Capture HD60 S

1080p 60FPSの映像を720p 30FPSでYouTubeに配信してアーカイブとして記録し, youtube-dlでダウンロードして使用しました. PCのスペックが足りてないので画質等色々妥協しました. でもこの記事を読んでくださってる皆さんはここで妥協せず, キャプチャ環境とじっくり向き合って良い映像を撮る努力をしてください. 撮像系をバカにした画像処理プロジェクトは炎上する,コンピュータビジョン屋の常識です.

また,リプレイを再生したままSwitchを放置するので, Switchの設定画面から「テレビの画面焼けを軽減」の設定をオフにしておくと画面の暗転を防げます. 動画に後から編集でBGMをつけたいならスマブラSPの設定でBGMを切っておくのも良いでしょう.

制約

NVIDIA GPUのようなクソデカ計算資源があることを前提としない方法で解きます. 世界的な半導体不足のこのご時世ですからね. ゲームのレンダリングのための貴重な計算資源をこんな仕事に使ってはいけません. 深層学習とか以ての外ですよ.

方法

実行環境

  • Python 3.7.2
  • OpenCV 4.5.1 (opencv-python)
  • FFmpeg
  • その他NumpyやPandasなど色々

記事用にPythonのバージョン調べたらめちゃ古くて草. 皆さんはちゃんとバージョンアップしましょうね.

コード

こちらのリポジトリで公開しています. Pythonモジュール化までしたいですが色々体裁整えるのだるくてほったらかしています.

https://github.com/eqs/qac2021-gsa

アプローチ:テンプレートマッチングによる「撃墜シーンらしさ」スコア計算と状態機械によるタイミング検出

次のような2段階の手順で解きます

  1. 動画の各フレームについて「撃墜シーンらしさ」のスコアを計算し,実数値の時系列に変換
  2. スコアの系列において,値が低い状態から高い状態へ変化する瞬間を撃墜シーンの開始とみなして記録

1.はSIFT (Scale-Invariant Feature Transform) を用いたテンプレートマッチングによって解きます. ゲームのUIは通常の物体認識と違って見た目の揺らぎがほとんど無いため(YouTube配信の圧縮ノイズはある), 機械学習などは使わなくてもそれなりに解くことが可能です. 2.は1.で計算したスコアによって状態を変える状態機械を作ることで解きました. スコアはどんなに頑張っても100%の精度にならなくてノイズが乗るのでそこの曖昧さをうまく扱えるかが難しいポイントですね.

また,スマブラSPなど特定のゲームに限定しない汎用のツールにしたかったので, スマブラSP用にチューニングした画像処理アルゴリズムを作る事は避け, 興味のあるタスク用のテンプレート画像だけ用意すれば適用可能な方法にしました.

撃墜シーン検出におけるテンプレート画像の準備

スマブラSPのストック制1on1対戦では相手を撃墜した瞬間に, 下記のような残りストック数を知らせるUIが表示されます3

burst1

また,どちらか一方のプレイヤーの最終ストックが無くなった瞬間には下記のような 「GAME SET」の文字が表示されます(英語版だと「GAME!」):

burst2

これら2種類のUIのどちらかが表示されたタイミングを検出します. これらの画像はテンプレートとして使うには文字以外の表示が色々邪魔なので RoI (Region of Interest) を表すマスク画像を作成して必要な部分のみに注目するようにします.

burst1-mask

burst2-mask

テンプレート画像にマスク画像を重ねると以下のようになります(RoIを赤,そうでない部分は青で表示)4

burst1-blend

burst2-blend

マスク画像の作成のためにOpenCVのcv2.selectROIs関数を利用した簡易のツールを作りました.

今回は必要ないのでやっていませんが, 画像上の離れた位置にある複数のUIをまとめて捉えるマスクや矩形でないマスクも使うことが可能です.

SIFT: Scale-Invariant Feature Transform

SIFTは複数の画像間の対応位置を求めるのに使われる, 特徴点(キーポイント)と特徴記述子を抽出する方法のひとつです. 先ほど作成したテンプレート画像と録画した映像のフレームの特徴点の対応点数をみることによって, 興味のあるUIが出現しているかどうかを調べます.

SIFTに関しては原著論文かOpenCVのドキュメントが良い読み物だと思います. OpenCVの日本語訳の方は古いバージョンが元になってますが, 特許に関する記述が2021年現在と異なるのを除けばこちらでも大丈夫だと思います5

SIFTは画像のスケールおよび回転の変化に対して不変, つまり画像間でそのような違いがあっても特徴記述子はほぼ同じになるように設計されています. 回転不変性はゲームのUI検出においてはオーバースペック気味ですが, スケール不変性は便利そうだったのでSIFTを使うことにしました6

SIFTによる特徴点マッチングで撃墜シーンを判定するクラス

SIFTによる特徴点の計算は画像全体に対して行いますが,マッチングにはマスクに含まれる特徴点だけを利用します. 特徴点マッチングを行うクラスのコードは次のようになります.簡単な解説をコメントとして入れています:

import numpy as np
import cv2


class BaseDetector(object):
    def __call__(self, frame: np.array) -> float:
        # 画像からスコアを計算して返す関数
        raise NotImplementedError()


class SIFTObjectDetector(BaseDetector):
    def __init__(self, template_img: np.ndarray, template_mask: np.ndarray,
                 target_mask: np.ndarray = None, testing_ratio: float = 0.75):

        self.template_img = template_img
        self.template_mask = template_mask

        # ターゲットのマスク target_mask はテンプレート作成時と探索時で
        # 対象とする範囲が異なるときに設定する(後半のスプラトゥーン2の項を参照)

        if target_mask is None:
            # ターゲットのマスクが指定されてない場合はテンプレートのものを流用
            self.target_mask = template_mask.copy()
        else:
            self.target_mask = target_mask
        self.testing_ratio = testing_ratio

        # SIFT準備
        self.sift = cv2.SIFT_create()
        self.matcher = cv2.BFMatcher()

        # テンプレートにおけるSIFT記述子を先に計算しておく
        self.template_kps, self.template_des = self.sift.detectAndCompute(
            self.template_img,
            self.template_mask.astype(np.uint8)
        )

    def __call__(self, target_img):

        target_img_gray = cv2.cvtColor(target_img, cv2.COLOR_BGR2GRAY)

        # ターゲットにおけるSIFT記述子を計算
        target_kps, target_des = self.sift.detectAndCompute(
            target_img_gray,
            self.target_mask.astype(np.uint8)
        )

        # テンプレートのキーポイントのうち,ターゲットとマッチした割合を返す
        matches = self.matcher.knnMatch(target_des, self.template_des, k=2)
        good_matches = [m for m, n in matches
                        if m.distance < self.testing_ratio*n.distance]
        return len(good_matches) / len(self.template_kps)

SIFTObjectDetector の使い方を表した簡易のコードを以下に示しています. SIFTObjectDetector のインスタンスを作った後,動画から読み込んだフレームを引数にして呼び出すと float型の非負の実数値scoreを戻り値として返してくれます. scoreは値が大きいほど「撃墜シーンらしさ」が高いと評価する指標です:

# 前節で作成したテンプレート画像とマスク画像を持つ検出器を作る
detector = SIFTDetector(template_img, template_mask)

# 検出器に動画のフレームを渡して「撃墜シーンらしさ」を計算
score = detector(target_img)

マスク画像はOpenCVのSIFTのクラス内で利用されます. テンプレート画像全体で特徴点を計算した後,マスク画像によって使用する特徴点をROI内のみに制限します.

また,SIFTによる検出器 SIFTObjectDetectorBaseDetector という抽象クラスの派生クラスとして実装しています. その他の検出手法,例えば輝度値の二乗誤差ベースのテンプレートマッチングを実装してそちらに差し替えてみる, といった試行錯誤がしやすくなるようこのようなクラス構成にしています.

複数の検出器の出力を統合する

最初に2種類のテンプレート画像を用意すると言っていたのにひとつの SIFTObjectDetector では1種類のテンプレートとマスク画像の組しか扱えません. なので,複数の SIFTObjectDetector の統合する検出器 Compose を以下のように作ります:

from typing import List


class Compose(BaseDetector):
    def __init__(self,
                 detectors: List[BaseDetector],
                 return_max: bool = True):
        self.detectors = detectors
        self.return_max = return_max

    def __call__(self, frame):
        scores = []
        for detector in self.detectors:
            score = detector(frame)
            scores.append(score)
        return np.max(scores) if self.return_max else np.min(scores)

Compose は複数の BaseDetector を内部に保持していて, __call__ が呼び出されたときにすべての検出器でスコアを計算し,その最大値を返却します.

detector1 = SIFTObjectDetector(template1_img, template1_mask)  # 残りストック数のUIを検出するDetector
detector2 = SIFTObjectDetector(template2_img, template2_mask)  # "GAME SET" のUIを検出するDetector

detector = Compose([detector1, detector2])  # 撃墜の瞬間を検出するDetector
score = detector(target_img)

これにより,撃墜の瞬間のマスク画像をひとつの検出器インスタンスで取り扱えるようになりました.

動画に対するスコア計算の適用

実装したSIFTベースの検出器を動画に適用するコード例は以下のようになります. 実際に作ったツールでは高速化のために色々処理を追加してるのでもっと複雑なコードになってますが 7, やってることは下記コードとほとんど等価です:

# 検出器準備
detector1 = SIFTObjectDetector(...)
detector2 = SIFTObjectDetector(...)
detector = Compose([detector1, detector2])

# 記録用リスト
scores = []

while True:

    # 動画からフレームの読み込み
    ok, frame = video.read()

    if not ok:
        # フレームが無ければ終了
        break

    score = detector(frame)

    # 検出器が出したスコアを記録する処理
    scores.append(score)

以下の図は,40分ほどの対戦動画についてスコアの計算を行い(30FPSの動画,10フレーム間隔)リスト scores の中身をプロットしたものです. 縦軸が「撃墜シーンらしさ」のスコアで横軸が時間(フレーム番号)です. 動画の最初の方にテンプレート画像の元になったフレームがあるのでそこに大きなピークが出ていて以後小さめのピークが何度も現れていることがわかります8

sift-score

状態機械による撃墜シーンの開始タイミング検出

これまでに実装した処理によって, 動画の各フレームについて「撃墜シーンらしさ」を評価するスコアを計算できるようになりました. しかし,本当に欲しいのはスコアではなく撃墜シーンが始まったタイミングです. それらの違いを下図に示しています:

scores-and-timing

ここでは,スコア列をタイミング列に変換する問題を考えます. 実際に得られた信号は上図上側の例のような信号よりもさらに汚く扱いにくいため, 閾値処理をかけて0/1のみをとる信号に変換しておきます.

scores-thresholded

これでスコアの立ち上がりが検出できそう!!!!な気がしますがそう簡単にはいきません. 現実の信号はめちゃめちゃ汚いものです. 閾値処理を行ったとしても信号を拡大してみると非常に短い立上り/立下りの信号が出ていることがあります:

noisy-sequence

信号の中にそびえ立つ細長いインパルス,まるでリヒターの上スマッシュのようですね. スマブラSPの撃墜演出は(どちらのテンプレートの場合においても)2-3秒間は連続して表示されるので一瞬だけ1/0が変化するのは不自然なのがわかると思います. このようなノイズを除くために次のような状態機械を考えます:

state-machine

だいぶオリジナル記法が入った数学的にきちんとしてない状態機械ですがうまく解釈してやってください. 「イベント無し」が開始状態で,各種記号や変数の意味は以下の通りです:

  • $1$:撃墜のUIが表示されている
  • $0$:撃墜のUIが表示されていない
  • 条件式 [*]:条件式に従って状態遷移する際に処理*を行う
  • min_interval:各イベント間の間隔の最小フレーム数
  • min_event_length:各イベントが発生中の状態が持続する最小フレーム数

状態機械の挙動を事細かに説明するのは野暮ったいのでやりません. 主張したいことを端的に伝えられてこその図式ですからね.決して面倒臭かったわけではありません. 設計時の考えだけ簡単に述べると,

  • 入力系列は例えば $000001111011110000000000100000001111110000000$ のように $0$ の方が多くて時々 $1$ が連続する2値系列になってるはず
  • ノイズの無い理想的な信号であれば「イベント無し」と「イベント中」の2状態で充分
  • ノイズによる曖昧さを吸収するために中間の状態「イベント候補」「イベント終了準備」を用意
    • 「イベント候補」  :記号$1$が来てもそれがイベントの始まりとは限らない(ノイズかもしれない)ので開始時間だけ記録して様子見をするための状態
    • 「イベント終了準備」:記号$0$が来てもそれがイベントの終わりとは限らない(ノイズかもしれない)ので終了時間だけ記録して様子見をするための状態

という感じです.「イベントの開始/終了かと思ったけどやっぱ違ってました~」という気持ちの現れを状態機械に込めています.感情のある機械,つまり人工知能ですね.シンギュラリティです.

先ほどの状態機械を実装したものが以下の通りです. 0/1の個数をどの状態でも数えている等,細かい差異はありますが上記の状態機械を機械的にコードに落としただけです:

class State(Enum):
    NO_EVENT = 0
    EVENT_CANDIDATE = 1
    ON_EVENT = 2
    END_PREP = 3


def segment_sequence(timing_seq, frame_interval=1,
                     min_event_length=10, min_interval=120):
    """イベントの系列から動画の開始・終了フレームを生成する"""

    state = State.NO_EVENT
    prev_state = State.NO_EVENT

    start_time = 0
    end_time = 0
    zero_count = 0
    one_count = 0

    segments = []

    for t, s in enumerate(timing_seq):
        if s:
            one_count = one_count + frame_interval
        else:
            zero_count = zero_count + frame_interval

        if state == State.NO_EVENT:
            if s:
                state = State.EVENT_CANDIDATE
                start_time = t
        elif state == State.EVENT_CANDIDATE:
            if one_count > min_event_length:
                state = State.ON_EVENT
                logging.info(f'Start: {start_time}')
            elif zero_count > min_interval:
                state = State.NO_EVENT
        elif state == State.ON_EVENT:
            if not s:
                state = State.END_PREP
                end_time = t
        elif state == State.END_PREP:
            if zero_count > min_interval:
                state = State.NO_EVENT
                logging.info(f'End: {end_time}')
                segments.append((start_time * frame_interval,
                                 end_time * frame_interval))
            elif s:
                state = State.ON_EVENT
        else:
            raise RuntimeError

        # 状態が切り替わったならカウンタを初期化
        if state != prev_state:
            one_count = 0
            zero_count = 0
        prev_state = state

    return segments

上記segment_sequence関数を動画から計算したスコアに適用すると 以下のような時間セグメントの一覧が得られます(単位は秒に変換してあります):

start_sec,end_sec
83.6666791332336,94.00001264789881
129.33335201051725,139.33335214651615
248.3333681943878,258.666701709053
314.00004379164966,324.33337730631484
389.33338737023433,399.6667208848996
470.66673176481254,480.66673190081144
...

また,コードには示していませんでしたが単位の変換ついでに start_sec から-8秒,end_sec から+2秒してあります. 撃墜前後の様子が知りたいのに撃墜演出UIが出てるとこだけ切り取っても仕方ないですからね. ただし,セグメントの長さを調整したことによってセグメントの重複ができてしまうことがあるのでそれらをマージする処理をさらに追加しても良いと思います.自分は動画作り直すの面倒臭くてやりませんでしたが.

FFmpegによる動画編集

開始時間と終了時間の表さえ作れればあとは簡単ですね.FFmpegで適当に動画を切り出すだけです. 下記関数でFFmpegのコマンド列を作り,batファイルかシェルスクリプト化して実行すればOKです.

def to_ffmpeg_command(video_path, segments_path):
    data = pd.read_csv(segments_path)
    for k, row in data.iterrows():
        start, end = row
        print(f'ffmpeg -y'
              f' -i {video_path} -ss {start} -to {end}'
              f' -vcodec libx264 -pix_fmt yuv420p outputs/output{k}.mp4')

FFmpegによって下記のように動画クリップが大量に作られます:

videos

サムネがスマちしきのやつ(output6.mp4output18.mp4)は明らかに誤検出ですね. また,個人的にお気に入りだった撃墜択(台乗せ聖水上スマとか)が見当たらなかったので検出ミスもありますね. 多分2-3試合ぐらい丸ごと抜けていると思います. このようなエラーの程度はきちんと評価すべきなので40分の動画中の撃墜シーン数をきちんと数えて混同行列作ろうと思います.気が向いたら.

切り取った動画はFFmpegでサッとくっつけました. 下記のようにファイル名を並べたテキストファイル input.txt を用意してFFmpegのフィルタに渡せばできます:

file 'output0.mp4'
file 'output1.mp4'
file 'output2.mp4'
...
ffmpeg -f concat -i input.txt output_concat.mp4

くっつけた結果が記事冒頭に貼った動画と同じこちら:

応用編:スプラトゥーン2のキル検出

スプラトゥーン2ユーザの皆様お待たせいたしました,スプラトゥーン2のキル集の話です. スプラトゥーン2自体の説明は省きます. かわいいイカとタコが地面をインクで塗り合う三人称視点のシューティングゲームです.

ここでは(YouTube使わずに)録画した一試合分のガチマッチの動画において自分が相手をキルした瞬間の前後の映像を切り出す問題を考えます.

キル検出に必要な材料はスマブラSPの話題の中でほとんど揃っていて, SIFTObjectDetector に渡すテンプレート画像の作り方を少し工夫すれば終わりです. 面倒の先払いがここで効いてくるわけですね.

スプラトゥーン2において相手チームのキャラを倒した瞬間の画像はイカの通りです:

splatoon2-kill

たまたま味方がガチヤグラに乗った瞬間なのでややこしいですがキルにまつわる画面の変化は

  • 相手を倒した位置にマーカーが出る(この画像では左上)
  • 画面上部タイム横のブキのマーカーが暗くなって×がつく(この画像では緑チームの一番左)
  • 画面下に「〇〇〇〇をたおした!」の表示が出る

の3つです.3つ目を調べるのが明らかに簡単そうですね. 名前は当然相手によって変わるので,下記画像のように「をたおした!」だけを覆うようにマスクを作ります9

splatoon2-template

ここで注意しないといけないのが「をたおした!」表示の以下の特徴です:

  1. 相手の名前の長さによって「をたおした!」の位置が水平方向にブレる
    • 相手の名前と「をたおした!」をくっつけた文字列が中央揃えで表示
    • 暗いストライプ模様の背景の幅は固定でこれの外には出ない
  2. 複数の相手を短時間に連続で倒すと「をたおした!」の表示が垂直方向に並ぶ
    • テンプレートの位置に「をたおした!」は無いが上の方に「をたおした!」が現れている状態がある
  3. 表示は透明状態からフェードインしつつ,画面左からスライドしながら出現する

これらの状況に対応するため,下記のようにテンプレート画像上のマスクと違う範囲を探索するためのマスクを用意します. SIFTの平行移動に対する不変性がここで活きるわけですね. また,テンプレート画像はひとつなので Compose クラスは使いません.

splatoon2-target

中央から右にかけて水平方向に広くとることで1.に対応,垂直方向に広くとることで2.に対応. 左側を範囲に含めないことで3.に対応しています. 一見3.は害が無さそうに見えますが,フェードイン中の「をたおした!」は検出精度が安定しにくく, 一度のキルが複数のキルとして分割されてしまうことがあったのでこのように対応しています.

4分弱の試合について上記テンプレートによるスコア計算を行った結果が次のようになります:

splatoon2-score

スマブラSPのときと違って一試合分を配信ではなく普通に録画して解析したせいかスコアのお行儀がとても良いですね. この試合中にとった5回のキルがスコアの立ち上がりとして綺麗に現れています.

残りの工程は2値化のための閾値と状態機械のパラメタ min_interval および min_event_length を除いてスマブラSPと全く同じです. 解析の結果できたのが記事冒頭に貼ったのと同じこちらの動画です:

筆者は普段オーバーフロッシャ―かエクスプロッシャーを使っていますが,今回は前線でキルをとる映像が欲しかったのでバケットスロッシャーソーダを使いました.

映像を見てると立ち回りの反省点も見えてきますね. 映像中2つめのキルの後,ヤグラを戻そうとしてピンチになってますね. 実はこの後ちゃんと生還していますがかなり怪しい立ち回りでした. でもジャンプしてきた味方をスペシャル切って守ったのはえらいと思います. 映像中3つめの.52ガロンベッチューとの対面はこちらがスペシャルを切った瞬間を狙われて慌ててボムで応戦してますがスペシャルでインク回復してるのでメインで詰めた方が良かったですね. クイックボム使い慣れてないのがバレバレです.相手の弾ブレとミスに助けられました.

さいごに

スマブラSPとスプラトゥーン2の対戦動画から撃墜/キルの瞬間を検出する画像処理系について述べました. テンプレート画像と動画フレームからSIFTによって特徴点を検出・比較することで類似度の時系列を求め, 自前で設計した状態機械でタイミングの系列に変換してFFmpegによる動画編集に活用しました. 今回はタイミング検出と動画編集のみに留まりましたが, 手法を少し改良すればスマブラSPで試合に登場したキャラの種類を特定したり, スプラトゥーン2でキルやデス数・カウントの変化といった戦況に関わる情報を時間的に可視化するなど, よりリッチな解析も目指せると思います. スマブラSPの方の検出精度の評価が充分にできなかったのでそちらもいずれやりたいですね.

コンピュータビジョンは画像や映像に写った対象に関する情報をコンピュータに抽出させる, いわばコンピュータに視覚を持たせることを目的とする学問分野です. 近年はどこもかしこもディープラーニングで個人的に空気感に馴染めなくなってきましたが問題解決のためにソフトウェア・ハードウェアの両面からタックルするのは中々やりがいがあります. この記事によって画像に興味を持ってコンピュータビジョン堕ちする方が少しでも増えたら幸いです. OpenCVのチュートリアルなんかはコンピュータビジョンの入門記事としてかなり優れてるんじゃないですかね (OpenCV Advent Calendarっぽい発言):

OpenCV-Pythonのチュートリアル https://docs.opencv.org/4.5.4/d6/d00/tutorial_py_root.html

じゃあな

richiter


  1. どのユーザコミュニティでも使われてるのは,終点・戦場・小戦場・すま村・村と街・ポケモンスタジアム2・カロスポケモンリーグ ↩︎

  2. ダッシュファイターを別キャラクターとして,ポケモントレーナーを3体,ホムラ/ヒカリを2体とカウントした場合 ↩︎

  3. 3人以上の対戦ではこの演出が無いので問題設定がかなり難しくなります ↩︎

  4. 青と赤の領域の間にうっすら白い線が見えますが,Matplotlibのimshowのアンチエイリアス処理によるものだと思うので無視してください ↩︎

  5. 2020年に期限が切れてopencv_contribのリポジトリからメインリポジトリに移動した https://twitter.com/icoxfog417/status/1238262158363971584 ↩︎

  6. 記事中では扱いませんでしたが,スプラトゥーン2でデスしたときの「○○○○でやられた!」の表示にスケール変化のアニメーションがついているのでスケールに対する不変性か充分なロバスト性が必要になります. ↩︎

  7. スレッド処理でI/Oの並列化したり,フレームを間引いています ↩︎

  8. スコアが1.0のフレームが存在しないのは10フレーム間隔で処理を行ったことでテンプレートと完全に一致する画像が処理の対象にならなかったためと思われます ↩︎

  9. 表示左にあるタコアイコンはプレイヤーがイカかタコかに依存します(自分はタコ).普通そんなに頻繁に変えないと思うのでここもマスクに含めても良いかもしれません.インクっぽいアイコンは試合ごとに色が変化してて,明度の変化が特徴点検出に影響しそうなのでこちらは入れない方がいいと思います. ↩︎

© eqs 2021