🦾

【Python】MoviePy × 文字認識(Tesseract OCR )を用いた Apex Legendsの動画解析

2022/12/14に公開

本記事が対象とする人:

  • MoviePyとOpenCVを用いたpythonでの動画編集・解析がしてみたい方
  • 画像中の文字を認識し、テキストとして読み取る技術(本記事ではTesseract OCRを使用)を
    使ってみたい方
  • 他の人の個人開発を見るのが好きな方

はじめに

背景

現在、e-スポーツが話題となっており、競技人口や大会を視聴する人も増えています!
高校での部活動としても取り組み始められており、これからもどんどんe-スポーツに触れる人が増えていくのではないかと思います!

  • e-スポーツの競技人口に関するサイト

https://www.appbank.net/gamingpc/2992

  • 全国のe-スポーツを学べる・部活動ができる高校に関するサイト

https://esports.bcnretail.com/highschool/210610_000237.html

また、現在IT技術やビッグデータの利活用により、競技の戦略や選手の成長のサポートを行う仕組みが確立されつつあります(個人的には世界バレーなどで監督が手にタブレット端末を持っているのすごく印象に残っています。)

  • バレーボールのデータ分析に特化したソフトウェア「Data Volley」

https://unlimited.volleyball.ne.jp/data-volley/

そこで、盛り上がりを見せているe-スポーツをデータ分析・プログラミング技術を用いて支援できる仕組みを構築することで新たな形で盛り上がりに貢献することができるのではないかと思い、今回はApex Legendsのプレイ動画を対象に動画解析アプリを作りました!

概要

アプリ全体の概要は以下のようになっています。

  1. Apex Legendsのプレイ動画から戦闘シーン
    (銃を撃っているシーン)を自動検出←本記事で解説
  2. 自動抽出された動画から人物検知により、敵の位置を算出し、
    エイムの傾向を可視化

本記事では、文字認識OCR技術を用いた戦闘シーンの自動抽出について説明します。

戦闘シーンの自動抽出について

戦闘シーン作成の流れ

  1. 文字認識(OCR)を用いた弾数検出
  2. 弾数の変化を元に戦闘シーンか否かを判別
  3. 判別された結果を元にMoviePyで戦闘シーン動画の作成

作成された動画について

元動画とアプリにより作成された戦闘シーンの動画を置いているので確認してみてください!

  • 元動画

https://youtu.be/bvp6arRxLlo

  • 戦闘シーンの動画

https://youtu.be/YBuYf5_HH5I

これから具体的な手法について解説していきます。

使用したライブラリ(必要に応じてインストールしてください):

import cv2
import pyocr
import pyocr.builders
import numpy as np
from PIL import Image
import time
from tqdm import tqdm
from moviepy.editor import *

文字認識(OCR)を用いた弾数検出

画像に対してOCRの活用方法

文字認識(OCR)とは、画像中の文字を認識し、テキストとして読み取る技術のことで、
今回はこの技術をゲームの画面に対して適応してみることにしました。
今回の開発では、 オープンソースとして開発されているOCRエンジンのTesseractを使用しました。
OCRの利用イメージは以下のようになっています。

弾数部分のみを読み取り、その部分に対してOCRを用いて弾数を読み取るという仕組みになっています。
(認識範囲については手作業で座標を取得しています)

参考にしたサイト

https://rightcode.co.jp/blog/information-technology/python-tesseract-image-processing-ocr

  • OCRを使用するためのコード(他のタスクに流用できる部分になっていると思います)
def get_num(frame):
    """
    画像を編集する部分
    """
        # BGRをRGBに変換←ここの部分グレースケールなど工夫することで精度に変化が出ます
    bgr2rgb = cv2.cvtColor(frame,cv2.COLOR_BGR2RGB)
    """
    OCRに投げるための処理
    """
    # ocrに渡せるpillowの形に変換
    to_PIL = Image.fromarray(bgr2rgb)
    # ocrのツールの読込
    tools = pyocr.get_available_tools()
    tool = tools[0]
    lang = 'eng'
    # pyocrを用いて画像から文字を取得
    text = tool.image_to_string(
    to_PIL,
    lang=lang,
    builder=pyocr.builders.TextBuilder(tesseract_layout=6)
    )
    # 文字が取得できたかどうか(できなかったら'-'を挿入)
    if text == '':
        text = '-'
    else:
        text = int(text)
    
    return text

動画にOCRを適応する方法

次にOCRを用いて動画から数字を取得する仕組みについて説明します。
動画データの読み込みには、OpenCVを利用しました。
動画からデータを取得する方法は以下のようになっています。
処理の速度を上げるために1フレームごとに判定するのではなく、
1秒に1回(今回の場合30フレームに1回)文字認識の処理を行うようにしています。
また、戦闘を行なっているかどうかの判定を行うために、数値(number)の変化のみではなく、
フレーム間の誤差(error)を利用することにしました。
戦闘を行なっているかどうか(tf)は、あらかじめ0で初期化しておき、次の判定部分で戦闘をしている場合を1、戦闘をしていない場合を0で状態を管理しています。
動画内での時間(time_stamp)はmoviePyを利用するとき、時間指定を行うために利用しています。

  • 動画から1フレームごとに取り出して、画像を処理するための雛形
    (他のタスクでも応用できる部分だと思います)
# while文の場合
read_file = cv2.VideoCapture('動画のファイル')  # 動画の読み込み
while True:
    ret, frame = read_file.read()
    """
    ここにframe(1フレームの画像)に対して行う処理を記述することで
    動画を処理することが可能になります!
    ここにリサイズの処理などを入れるといいかもしれないです!
    """
    if not ret:
        break

read_file.release()

# for文の場合
read_file = cv2.VideoCapture('動画のファイル')  # 動画の読み込み
frame_num = int(read_file.get(cv2.CAP_PROP_FRAME_COUNT)) # 動画のフレーム数の取得
for i in range(frame_num):
    ret, frame = read_file.read()
    """
    ここにframe(1フレームの画像)に対して行う処理を記述することで
    動画を処理することが可能になります!
    ここにリサイズの処理などを入れるといいかもしれないです!
    """
read_file.release()

今回私は、for文で処理を行いました。

OCRを用いて動画から数字や各種情報を取得するコード
def make_data(read_name):
    # データを格納するための配列
    battle_data = []
    # 認識範囲
    xmin,xmax = 1145,1190
    ymin,ymax = 640,666
    
    # 動画データの読み込み
    read_file = cv2.VideoCapture(read_name)

    # 各種変数の初期化
    frame_cnt = 0    # フレーム数の初期化
    frame_num = int(read_file.get(cv2.CAP_PROP_FRAME_COUNT))    # 動画全体のフレーム数の取得
    fps = read_file.get(cv2.CAP_PROP_FPS)    # 動画のFPSの取得
    time_stamp = 0    # タイムスタンプの取得
    add_time = 1/fps    # 1フレームごとに進む時間の取得
    tf = 0        # 真偽値
    error = 0    # errorの初期化
    """
    映像から1フレームずつ画像を取得するコード
    """
    for i in tqdm(range(frame_num)):
        ret, frame = read_file.read()
        # 初期化 1次元の[数字,フレーム数,真偽値]
        num = []
        if not ret:
            break
	# 画像の前処理
        frame = cv2.resize(frame,dsize=(1280,720))  #解像度の変更(結構重要な部分!!!)
        bullet_num = frame[ymin:ymax,xmin:xmax]    # 弾数が減っている部分を切り出している
        detframe = cv2.bitwise_not(bullet_num)    # 色の反転

        # 関数get_numを呼び出して数字をカウントしていく(30フレームおきに)
        if i%30 == 0:
	    # OCRによる数値の取得
            number = get_num(detframe)        
            # 誤差についての計算
            if frame_cnt != 0:
                # 誤差の算出
                error = detframe.astype(int) - before_frame.astype(int)
                error = error.max()
            
        # 一次元配列に格納
	num = [number,frame_cnt,tf,error,time_stamp]
        # 二次元配列に格納
        battle_data.append(num)
        # 各種データの更新
        frame_cnt+=1    # 現在のフレーム数の更新
        time_stamp+=add_time    # nフレーム目の時間を記録
        before_frame = detframe    # 誤差取得のための前フレームの情報を保持

    read_file.release()
    
    return battle_data

出力されるデータ(battle_data)のイメージはこのようになっています。
出力されたデータ(加工データ)を元にOCRで得た数値の変化と誤差の変化を考慮した戦闘判定を行います。

[7, 0, 0, 0, 0]
[7, 1, 0, 0, 0.03333333333333333]
[7, 2, 0, 0, 0.06666666666666667]
[7, 3, 0, 0, 0.1]
[7, 4, 0, 0, 0.13333333333333333]
[7, 5, 0, 0, 0.16666666666666666]
・・・
[7, 20, 0, 0, 0.6666666666666666]
[7, 21, 0, 0, 0.7]
[7, 22, 0, 0, 0.7333333333333333]
[7, 23, 0, 0, 0.7666666666666666]
[7, 24, 0, 0, 0.7999999999999999]

弾数の変化を元に戦闘シーンか否かを判別

次にApex Legendsの動画から作成された加工データを元にOCRで得た数値の変化と誤差の変化を考慮した戦闘判定を行います。イメージは以下のようになっています。

動画に対し、戦闘時のラベルを付与した教師あり学習などを行うことで精度が上げられる部分であると思うのですが、ラベル付けは非常にコストがかかるタスクになるため今回はルールベースでの判定をしました。
戦闘判定はルールベースで以下の条件の場合戦闘していると仮定しました。

  • 5秒間(150フレーム)の間にOCRで取得した数値に動きがあった
  • 前フレームとの誤差が5以上であった
    これら2つの条件を満たした場合、戦闘が始まったと仮定し、打ち始めすぐに真偽値を変更(戦闘していると判定)のではなく、0.5秒の前の位置(15フレーム前)から真偽値を変更するような処理を行なっています。
戦闘シーン判定アルゴリズム
def battle_cut(db):    # db=[num,frame_num,tf]
    # frame数,現在のフレームをカウントする変数
    frame_len = len(db)
    frame_cnt = 0
    battle_true = 1    # battleしていると判断したものはtfを1に変換する
    keep_num = 0    # 数字を保持しておくための変数
    stay_cnt = 150    # カットするためのフレーム数の定義7フレーム動いていないと撃っていないと判断する
    start_late = 15  # 打ち始めを考慮したフレーム数
    # battle判定
    while True:
        if frame_cnt >= frame_len:
            break
        keep_num = db[frame_cnt][0]        # 現在のフレームの時の弾数を格納しておく
        start_point = 0    # 打ち始めるポイント
        conti_cnt=0
        if db[frame_cnt][0] != "-" and db[frame_cnt][3] > 5:        # 数字を検知していないところはカット
            while True:    # 同じ数字が何回続いたかをカウントする
                conti_cnt+=1
                if frame_cnt+conti_cnt >=frame_len:
                    break
                if keep_num != db[frame_cnt+conti_cnt][0]:
                    break
            start_point = frame_cnt-start_late
            for i in range(stay_cnt+start_late):
                if frame_cnt+i >=frame_len:
                    break
                db[start_point+i][2] = int(1)
            frame_cnt+=conti_cnt
        else:
            frame_cnt+=1
    return db

出力されるデータのイメージは以下のようになります。

[7, 0, 0, 0, 0]
[7, 1, 0, 0, 0.03333333333333333]
[7, 2, ※1, 0, 0.06666666666666667]     ※tfの部分(3列目)が1に変化する
[7, 3, 1, 0, 0.1]
[7, 4, 1, 0, 0.13333333333333333]
[7, 5, 1, 0, 0.16666666666666666]
・・・
[7, 20, 1, 0, 0.6666666666666666]
[7, 21, 1, 0, 0.7]
[7, 22, 1, 0, 0.7333333333333333]
[7, 23, 0, 0, 0.7666666666666666]
[7, 24, 0, 0, 0.7999999999999999]

判別された結果を元にMoviePyで戦闘シーン動画の作成

戦闘シーンであると判別された結果(tf)と動画の時間(time_stamp)を用いて、動画を切り抜くプログラムを作成します。イメージは以下のようになります。
加工データの戦闘シーンかどうかの真偽値(tf)と動画内での時間(time_stamp)を用いて、元データから戦闘シーンを切り抜く処理を実装しています。

本コードで主に用いているMoviePyについていくつか説明します。
OpenCVで動画を読み込んだ際に、音声を反映することができなかったため、動画を作成する処理にはMoviePyを利用しました。

  • 映像データの読み込みについて
    MoviePyでは映像と音声を別々で読み込み、それぞれの映像データに音声データを
    連結するという処理しています。
# 映像
clip = VideoFileClip("動画ファイル") 
# 音声
audio = AudioFileClip("動画ファイル")
# 映像と音声の結び付け
clip = clip.set_audio(audio)
  • 複数の動画を連結するための処理
    clip.subclip(開始時間,終了時間)で動画内での切り抜きを作成することができます。
    本記事で、切り抜かれた動画を順次連結することで動画を作成するような仕組みにしています。
    今回のタスク以外でも利用できる技術であると思うので、ぜひ活用してみてください!
"""
基本的な連結方法
"""
# 最終的に連結されて作成されるデータの初期化
fin = clip.subclip(0,0.0000001)
clip_tmp = clip.subclip(開始時間,終了時間)
fin = concatenate_videoclips([fin,clip_tmp])
"""
繰り返しの処理内で順次連結する場合(今回のタスクで用いている手法)
"""
# 最終的に連結されて作成されるデータの初期化
fin = clip.subclip(0,0.0000001)
for i in range(100):  # 任意の繰り返し文
    if i % 2 == 0:       # 任意の条件式
	clip_tmp = clip.subclip(開始時間,終了時間)
	fin = concatenate_videoclips([fin,clip_tmp])
元データから戦闘シーンを切り抜くためのコード
# 動画を作成する関数
def capture(read_name,cut_data,write_name):
    # 映像
    clip = VideoFileClip(read_name) 
    # 音声
    audio = AudioFileClip(read_name)
    
    # 映像と音声の結びつけ
    clip = clip.set_audio(audio)
    
    start_time = 0
    flag = 0
    stop_time =0
    cut_cnt = 0
    
    fin = clip.subclip(0,0.0000001)
    
    for i in cut_data:
        if i[2] == 1 and flag == 0:
            start_time = i[4]
            flag = 1
        if i[2] == 0 and flag == 1:
            stop_time = i[4]
            flag = 0
            clip_tmp = clip.subclip(start_time,stop_time)
            fin = concatenate_videoclips([fin,clip_tmp])
            
    fin.write_videofile(write_name,
		           codec='libx264',
		           audio_codec='aac',
		           temp_audiofile='temp-audio.m4a',
		           remove_temp=True)
    
    return

おわりに

本記事では、MoviePyと文字認識OCRを用いて、Apex Legendsの動画から戦闘シーンを
自動抽出するプログラムについて紹介させていただきました!
リロード時や武器切り替えなどで誤判定をしてしまうことなどまだまだ課題がありますが、
一応形になるものができたのではないかと思います!
次回、後半の人物検知を用いたエイムの可視化について紹介できればと思います!
長くなりましたが、最後まで読んでいただきありがとうございました!
技術的な情報は少ないですが、少しでも面白いなって思っていただけたり、
私自身、個人的に作ってみた系のブログを読むと開発のモチベーションが上がるので、
モチベ向上のきっかけくらいになれたら幸いです!

また本記事は、以下のリポジトリに沿った内容になっているので、手元で動かしてみたいという方は
ファイルをダウンロードしてください!
(マウント等のコードは記載していませんが、Colabだと比較的簡単に動かせると思います!)

https://github.com/kmsz46/apexlegends_aimchecker

続きはこちらです!

https://zenn.dev/ganbarimasu/articles/227a8f29fff8bb

Discussion