Skip to main content

スマホゲーム「CUE!」のストーリーをOpenCVとOCR(Vision API)で書き起こす

概要

はじめに

次世代声優育成スマホゲームCUE!のゲームシナリオをOpenCVとOCR(Google CloudのVision API)を使って自動で書き起こしてみました。

プレイヤーが声優事務所のマネージャーとなり、事務所に所属する16人の新人声優を育てていくというゲームです。

美晴さんはほんわかおっとりしていながらも、周りの子たちをよく見ていて支えになるお姉さんです。

CUE!にはシナリオパートがあり、キャラクター同士やキャラクターとプレイヤーの掛け合いを見ることができます。CUE!ではシナリオは上に挙げたような画像で、画像下部の台詞がテロップのように流れながらキャラクターがしゃべります。

サービス開始日から遊んでいたアプリだったので、セリフを使って何か分析したりモデルを組んだりしたいと思ったのですが、そもそもセリフのテキストデータがないため自分でセリフを書き起こしてデータセットを作る必要がありました。

セリフのスクリーンショットを用意しなくとも、スマートフォンやタブレットでストーリーを流しっぱなしにしたまま画面を録画した動画をインプットにできると楽です。そのためセリフ画像を切り出す所もコードで対応することにしました。

CUE!はもうサービスが終了してしまったゲームであり、手元にはサービス提供中にストーリーを撮った動画が残っているという事情もあります。

やったこと

CUE!のストーリーを録画した動画のmp4ファイルをインプットとし、各セリフの発話キャラクターとセリフ内容を列に、セリフの数だけ行を持つようなcsvファイルを出力しました。

インプットに使用する動画はストーリーごとに1本ずつ分かれた動画であり、スマートフォンかタブレットの画面録画か、あるいはキャプチャボードを用いてスマートフォンやタブレットを接続したPCから録画するかのどちらかを想定しています。

技術構成

  • Step1: 動画から静止画を切り出す
    • FFmpeg
  • Step2: 切り出した画像から、セリフが載っている画像だけを漏れなくダブりなく取り出す
    • セリフはテロップのように流れるので、Step1で切り出した画像には、キャラクターが話し終わる途中でセリフが切れてしまっている画像が含まれていたり、セリフが全て写っている画像がダブっていたりします。また、セリフが含まれていない画像もあります。これらを取り除きます。
    • Python + OpenCV
  • Step3: 残った画像について、超解像で画像を拡大する
    • waifu2x-caffe
  • Step4: 前処理する
    • OpenCVの画像処理ではバイラテラルフィルタと収縮を使用
    • Python + OpenCV
  • Step5: OCRでテキストを取り出す
    • Python + Google Cloud Vision API

CUE!以外のスマホゲームでも同様のロジックで文字起こしができると思います。(ただし画像処理部分のコードはゲームに応じて描き直す必要があります)

環境

  • ハード
    • Core i9-9900K
    • NVIDIA GeForce RTX 2060 Super
  • ソフト
    • Windows10
    • Python 3.10.0
    • opencv-python 4.5.5.64
    • waifu2x-caffe 1.2.0.4

Step1 動画から静止画を切り出す

FFmpegをコマンドラインから使えるようにダウンロードして設定した後、以下をコマンドプロンプトかbashで実行すると動画1秒につき15枚のjpg画像が切り出されます。

1時間の動画であれば15×60×60=54000枚の画像が出力されます。動画の解像度や画質にもよりますが、画像1枚で数百KB程度になりますので、ローカルのストレージに十分な容量を確保する必要があります。

# 切り出した静止画を保存するフォルダは、事前に作成しておくこと
cd 切り出した静止画を保存するフォルダパス
# `-q:v 1`は最高画質で保存する
# image_{7桁の連番}.jpgで保存される
ffmpeg -i "動画のファイルパス" -r 15 -q:v 1 image_%07d.jpg -vcodec jpg

Step2 画像を漏れなくダブりなく取り出す

課題

Step1で静止画を切り出すことができましたが、得られたキャプチャ画像には問題が3つあります。

  1. セリフが表示されていない幕間の場面をキャプチャしている
  2. セリフが全て表示し終わる前にキャプチャしているため、セリフが途中で切れている
  3. セリフが全て表示し終わってからキャプチャしているが、同じセリフが写っているキャプチャが何枚もある

1と2の画像は全て削除して、3のキャプチャはセリフごとに1枚だけ残したいです。

参考: 2の例

解決策

まず問題1についてですが、画像を見て分かるように、セリフが映っている画像では、セリフの長方形の領域の背景はほぼ白です。一方で、セリフが表示されていない画像はそうではありません。

このことを活かし、まずセリフの領域を切り出し、とりあえず雑にセリフの領域の左上の隅からx軸方向に2px、y軸方向に2pxの1ピクセルと、右下の隅からx軸方向に-2px、y軸方向に-2pxの1ピクセルの2点を取り出し、この2点がいずれも白色でなければその画像にはセリフのボックスが含まれていないとみなしてその画像を削除することにしました。

セリフのボックスの位置は全ての画像で固定です。よって、GIMPなどのマウスカーソルを載せた場所の座標を取得できる画像ビューアを用いて事前にボックスの四隅の座標を調べておき、その座標を指定することで元の画像からセリフのボックスを切り出すことができます。

白色かどうかの判定ですが、真っ白を表す(R, G, B) = (255, 255, 255)と比較してCIEDE2000の色差が5以上であれば白色ではないとみなすことにしました。CIEDE2000での色差は、skimage.color.deltaE_ciede2000で求めることができます。

次に問題2と3の解決方法についてです。

今、セリフの領域を切り出して二値化した画像を用意し、この画像内で「最も右側の黒色の画素(=行列値が0)のx座標」を考えてみます。ただしセリフは最大で2行あるため、セリフのボックスを上下2分割し、下段のセリフを上段のセリフの右端にくっつけた画像で考えます。

元々のセリフのボックスの領域はこれですが、

大津の二値化を行ってから上下を横にくっつけたこちらの画像を用いて考えます。

以下、画像iの「最も右側の黒色のピクセルのx座標」を$x_{i}$と表します。ただし、iは動画の初めから終わりまで順番に並んでいるものとします。

$x_{i}$を求める関数は下のような感じで書けます。

def calc_max_serifu_px(img: np.ndarray):
    # x = x' (0 <= x' <= img.shape[1]) の直線上に0である画素が2点以上あれば、
    # x = x'には黒色の画素があるとみなす
    # (1点だけだとノイズの可能性があるため2点とした)
    idx_text_pixel = (np.sum(img == 0, axis=0) >= 2).astype(np.int16)
    idx = 0 if np.all(idx_text_pixel == 0) else np.where(idx_text_pixel == 1)[0][-1]
    return idx
calc_max_serifu_px(new_img_text)
1573

一つ前の画像を見ると、確かにx座標が1570px程度の所までセリフがあります。

先程の2の状態では、セリフはテロップのように流れるため、$x_{i}$は広義の単調増加(単調非減少)です。3の状態に移ると、セリフは全て流れ切っているので$x_{i}$は横ばいです。次のセリフの2の状態に移ると、$x_{i}$はその前の$x_{i-1}$と比べ、大きく変動します。そこから再び$x_{i}$は広義の単調増加となります。これが繰り返されます。

いま取り出したい「ユニークなセリフの画像」は、次の台詞の2の状態に移る直前の3の状態の画像ですから、すなわち以下の2つの条件を満たすiを見つければよいということになります。

  • $|x_{i-j}-x_{i-(j-1)}|, |x_{i-(j-1)}-x_{i-(j-2)}|, \dots, |x_{i-1}-x_{i}|$の平均が$a$以下($a$は$a \geq 0$の定数)
    • すなわち、$x_{i-j}, x_{i-(j-1)}, \dots, x_{i}$は横ばいということ
  • $|x_{i}-x_{i+1}| \geq b$($b$は$b \geq 0$の定数)

j, a, bは事前に決める必要がありますが、色々試した結果$j = 4, a = 2, b = 5$としました。この数値はStep1で動画から静止画を取り出す際の、1秒あたり何枚の画像を取り出すかに影響を受けます。

以上2つのロジックによって、セリフが映っていない画像は削除した上で、ユニークなセリフの画像のみを取り出すことができます。ストーリーを収録したインプットの動画を1時間とすると、Step1で得られた54000枚の画像から、Step2で500枚程度まで減らすことができました。

Step3 画像を超解像で拡大する

Step2で残った画像全てについて、以降のステップでOCRで画像を読み取りますが、その前に超解像でノイズ除去・拡大し、OpenCVでさらにノイズ除去などの前処理を行う必要があります。Step3は前者、Step4は後者です。

超解像というとOpenCVのcv2.dnn_superresを使う手もありますが、waifu2xのcaffe実装であるlltcggie / waifu2x-caffeを用いました。

上のリンクからダウンロード後GUI版を起動し、Step2までで取り出された画像を超解像にかけます。設定値は下記の通りです。

  • 出力拡張子: jpg
  • 出力画質: 95
  • 変換モード: ノイズ除去(自動判別)と拡大
  • ノイズ除去レベル: レベル3
  • 拡大サイズ: 拡大率で指定(2.0)
  • モデル: 2次元イラスト(UpRGBモデル)(TTAモードを使わない)
  • 分割サイズ: 128, バッチサイズ: 8

ノイズを除去したうえで縦横2倍に拡大しました。GPUを使って5万枚の画像を4時間程度で処理できました。

Step4 前処理する

import cv2
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from google.cloud import vision
from google.oauth2 import service_account

# 表示用の関数
def view(img: np.ndarray) -> None:
    plt.imshow(cv2.cvtColor(img,cv2.COLOR_BGR2RGB))

img = cv2.imread("image_waifu2x.jpg")
view(img)

この画像から名前の部分とセリフ部分を切り出します。

# 画像上にマウスポインタを載せるとその場所の座標を
# 取得できる画像ビューア(GIMPなど)を使用し、適当な座標を調べます
name_topleft = [155*2, 550*2]
name_bottomright = [340*2, 580*2]
text_topleft = [155*2, 595*2]
text_bottomright = [1085*2, 670*2]

name_x1, name_y1 = name_topleft[0], name_topleft[1]
name_x2, name_y2 = name_bottomright[0], name_bottomright[1]
text_x1, text_y1 = text_topleft[0], text_topleft[1]
text_x2, text_y2 = text_bottomright[0], text_bottomright[1]

img_name = img[name_y1:name_y2, name_x1:name_x2]
img_text = img[text_y1:text_y2, text_x1:text_x2]
view(img_name)

view(img_text)

名前部分とセリフ部分にバイラテラルフィルタ -> 二値化 -> 収縮をかけます。

文字の縁のノイズを除去するためにバイラテラルフィルタをかけるとともに、文字が比較的太く、線と線がつながりやすく見えるため、収縮をかけて線同士を離します。

def preprocess_img_name(img: np.ndarray) -> np.ndarray:
    # パラメータは適当(目視でよさそうなパラメータを適当に採用した)
    img = cv2.bilateralFilter(img, d=10, sigmaColor=20, sigmaSpace=20)
    img = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
    _, img = cv2.threshold(img, 0, 255, cv2.THRESH_OTSU)
    # パラメータは適当
    img = cv2.erode(img, kernel=np.ones((2, 2), np.uint8),iterations=1)
    img = cv2.bitwise_not(img)
    return img

def preprocess_img_text(img: np.ndarray) -> np.ndarray:
    img = cv2.bilateralFilter(img, d=10, sigmaColor=20, sigmaSpace=20)
    img = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
    img = cv2.bitwise_not(img)
    _, img = cv2.threshold(img, 0, 255, cv2.THRESH_OTSU)
    img = cv2.erode(img, kernel=np.ones((2, 2), np.uint8), iterations=1)
    img = cv2.bitwise_not(img)
    return img

img_name_preprocessed = preprocess_img_name(img_name)
img_text_preprocessed = preprocess_img_text(img_text)

名前部分とセリフ部分の横幅を揃えて、一枚の画像に結合します。

img_name_preprocessed = cv2.copyMakeBorder(img_name_preprocessed, 10, 10, name_x1 - text_x1, text_x2-name_x2, cv2.BORDER_CONSTANT, value=(255, 255, 255))
img_text_preprocessed = cv2.copyMakeBorder(img_text_preprocessed, 10, 10, 0, 0, cv2.BORDER_CONSTANT, value=(255, 255, 255))
img_preprocessed = cv2.vconcat([img_name_preprocessed, img_text_preprocessed])

view(img_preprocessed)

Step5 OCRにかける

ここまで前処理した画像を、Vision APIを用いてOCRにかけます。月間1000コールまで無料、それ以上は500万コールまでは1000コールごとに1.5ドルとリーズナブルです。

class VisionApi:
    def __init__(self, credential_path: str) -> None:
        self.credentials = service_account.Credentials.from_service_account_file(credential_path)
        self.client = vision.ImageAnnotatorClient(credentials=self.credentials)
    def ocr(self, img: np.ndarray) -> str:
        content = cv2.imencode(".png", img)[1].tobytes()
        vision_img = vision.Image(content=content)
        response = self.client.document_text_detection(image=vision_img)
        text = response.full_text_annotation.text
        return text

# このcredentialのjsonファイルはGCPに登録するとダウンロードできます
va = VisionApi("../python-ocr.json")
texts = va.ocr(img_preprocessed)
texts
'夜峰美晴\nわたし達の声を通して、素敵な風を吹かせられたらいいなって・・・\nそれが、わたし達の進む道・・・・・・、 なんじゃないかな?'

“名前(改行記号)セリフ1行目(改行記号)セリフ2行目”の文字列で返ってきていることを利用し、名前とセリフに分けてpandas.DataFrameにします。

def parse_to_df(texts: list[str]) -> pd.DataFrame:
    texts = [i for i in texts.splitlines()]
    if len(texts) == 0:
        name = ""
        line = ""
    elif len(texts) == 1:
        name = texts[0]
        line = ""
    else:
        name, line = texts[0], texts[1:]
    df = pd.DataFrame([{"name": name, "line": line}])
    return df

df = parse_to_df(texts)
res = (
  df
  # たまにセリフがない画像があるので、セリフがない場合は除外する
  .loc[lambda d: ~pd.isna(d.line)]
  # line列はlist(行が1行だけなら要素数は1、2行なら2)なので、改行を[br]で繋ぐ
  .assign(line_joined = lambda d: d.line.map(lambda x: "[br]".join(x)))
)
print(res.name.to_list())
['夜峰美晴']
print(res.line_joined.to_list())
['わたし達の声を通して、素敵な風を吹かせられたらいいなって・・・[br]それが、わたし達の進む道・・・・・・、 なんじゃないかな?']

割といい感じに取れていますね!

実際はこの後、セリフ部分のテキストの正規化が必要になります。というのも、以下のような誤認識が頻発するためです。

  • 句読点の後にスペースが入る
  • 句読点がカンマやピリオドとして認識される
  • …(三点リーダ)が誤認識される
    • ・(中黒)として認識される
    • その他、中黒に似た記号として認識される
      • 三点リーダ1個が中黒か中黒に似た記号3個として認識されたりします。
      • 2個や4個として認識されたりもします。
  • 単語の一部文字だけ全く違う文字として認識される

特に記号は難易度が高いですね…。上の画像においても、読点の後に半角スペースが入っていたり、1個の三点リーダが中黒や半角中黒(UnicodeでU+FF65)3個として認識されていたり、また三点リーダが1個減っていたりします。NFKC正規化などで一旦普通の中黒に置き換えてから三点リーダに置換する必要があります。

おわりに

OCR自体はAPIに投げるだけなので楽ですね。時間がかかったのはStep2の漏れなくダブりなく画像を取り出すロジックの考案とStep4の前処理のロジック、Step5のOCRの後のテキストの正規化でした。