ノベルゲーム風の画面をカメラ映像とマイク入力からリアルタイムで作成する

この記事は、EEIC Advent Calendar 2022 の 22 日目の記事として書かれました。

こんにちは。むなです。このアドカレの初日ぶりですね。

EEIC では 3 年後期に「電気電子情報実験・演習第二」という科目が開講されています。この実験は 30 種目くらいある中からいくつか選んで参加するのですが、この記事では、その中の「OpenCV/OpenGL による映像処理」という種目で私が作成したアプリケーションについて書いていきます。アドカレ初日にお雑煮の記事を投げたらちょっと浮いてしまったので、技術系っぽい記事も一つくらい投げておこうかなと。


実験概要

この実験種目は1ヶ月くらいかけて行い、OpenCV/OpenGLに関する基礎知識を3週間ほどで詰め込んだ後に1週間で自由課題の制作に取り組みます。そしてその自由課題のレギュレーションはなんと「OpenCVOpenGLの両方、もしくはいずれか一方を利用していること」というもののみ。驚きの緩さです。私はノベルゲームが好きなので、それに因んだものを作ることにしました。


制作物のコード

https://github.com/Muna-akki/NovelGameMaker
まだるっこしい説明はいらないという人は、上記のリポジトリにコード一式を置いてあるので手元に持ってきて素材を用意すれば実際に動かせると思います(環境依存のエラーは吐くかもですが)。README.mdに大体書いてあります。


制作物の概要

今回の制作物がどのようなものかは、以下の動画を見てもらうのが早いと思います。この動画を作った後にコードを書き直したため挙動が少し実際のコードとは違うかもですが、概ねこのような形で動きます。(この動画を撮った時は一週間で書いたコードを動かしていたので挙動が不安定&この記事を書くにあたってコードを一新したので......)

 


動画内でも説明している通り、このアプリケーションは以下のように動きます。キーボード操作を排除したあたりがこだわりポイントです。

  • カメラに映る顔の位置によって、画面内のキャラクターの立ち位置を決定
  • カメラに映る表情によってキャラクターの表情を決定
  • カメラに映る手が立てている指の本数によって背景が変化
  • カメラに映る手の裏表でキャラクター変更を制御
  • マイクに入力された音声をセリフとして表示
  • 「さようなら」「さよなら」を認識すると別れの挨拶を発声(この部分はLinuxではうまく動かない可能性が高いです)


以降では、このアプリケーションを作成する道筋に沿って書いていきたいと思います。ただし、全てに対して深入りして説明しようとするととんでもない長さになるので、詳細な部分は省略します(その結果、ライブラリ紹介コーナーみたいになりましたが)。また、作り方に興味がなければ読み飛ばして問題ないです。


1. 素材集め

これを実現させるにあたって、まず最初のハードルは画像素材を入手する部分です。自分で絵が描ければよかったのですが、できないものはしょうがないのでフリー素材を探します(絵が描けたとしても一週間でコードも絵もは無理だろうというのはありますが)。ということで、以下のサイトから素材を借用しています。ありがとうございます。何か問題がありましたらご連絡ください。

キャラクター立ち絵

らぬきの立ち絵保管庫:http://ranuking.ko-me.com/

背景

KNT graphics:矢神ニーソ:http://kntgraphics.web.fc2.com/

メッセージウインドウ/システムアイコン

空想曲線:https://kopacurve.blog.fc2.com/

フォント

ふぉんときゅーとがーる。:http://font.cutegirl.jp/



2. カメラからの画像取得

本実験のテーマであるOpenCVの出番ですね。コード内ではcapture.pyあたりに大体まとめてあります。

import cv2 as cv
cap = cv.VideoCapture(0)

こんな感じでカメラから画像を取得する準備をして、カメラ幅やフレームレートなどの各種定数を cap にセットしたのち、

ret, input_frame = cap.read()

でフレームを取得します。input_frame は所詮 python の配列でしかないので、さまざまな処理が簡単に行えます。
フレームを一回取得するごとに画像認識等を走らせ出力画像を更新することで、リアルタイムでノベルゲーム風の画面を生成していきます。


3. 出力画像の準備

出力として表示されるノベルゲーム風画面の用意をします。これはcreateoutput.pyあたりにまとまってます。といっても、やることは

  • 使用する画像のパスを指定
  • 画像を読み込み、使用可能にする

くらいです。ここで用意した画像を下から背景→立ち絵→メッセージウインドウ→テキストの順で重ね、重ねる位置や画像の種類、テキストを変更することで出力を変えます。透過込みでPNG画像を重ねるのはこのサイトを、画像に日本語を重ねるのはこのサイトを参考にしました。 出力はまたしてもOpenCV

cv.imshow(output_window_name, output_frame)

とすれば画面上にウインドウが出てきて output_frame に格納した画像が表示されます。


4. 取得した画像で顔認識

機能の一つ目、「カメラに映る顔の位置によって、画面内のキャラクターの立ち位置を決定」の部分です。これはface.pyあたりにまとまってます。今回、顔の位置を判定するために使用するのはカスケード分類器です。Haar-cascadeOpenCVにデフォルトで搭載されている顔判定で、入力画像のどこに顔が存在するかを非常に高速で判定してくれます。このサイトあたりから調べるとなんとなく原理はわかるんじゃないかなと思います。

# 分類のためのモデルの読み込み
cascade_frontalface = cv.CascadeClassifier("model/haarcascade_frontalface_alt.xml")
# 顔の位置判定
faces = cascade_frontalface.detectMultiScale(入力画像, 1.1, 2, cv.CASCADE_SCALE_IMAGE, (20,20))

この時、高速化のために入力画像は先述の input_frame を白黒に変換して縮小したものを使用しています(縮小も白黒化も OpenCV 内に関数があります)。いくつか設定してある定数は顔検出の精度等を制御するパラメータで、実際使う環境でいい感じになるように調整します。詳しくはググってください。

ともあれ、これで faces の中に検出された顔の座標等が格納されるので、その情報を用いて出力画像を更新します。今回は顔の中心の x 座標がカメラ画像の幅に対してどのあたりの位置にあるかを3パターン(右、中央、左)で分け、キャラクターを背景に重ねる位置を変更しています。こうすることで顔がカメラに映る位置を変えるたびに、生成された画像内でキャラの立ち位置が変化します。


5. 表情分析

次は「カメラに映る表情によってキャラクターの表情を決定」の部分です。これもやはりface.pyあたりにまとまっています。ここでは、DeepFaceというライブラリを使います。Facebook製らしい。

from deepface import DeepFace
# 入力画像はinput_frameを縮小したもの
result_emotion = DeepFace.analyze(入力画像, actions=['emotion'])
dominant_emotion = result_emotion['dominant_emotion']

これで dominant_emotion に'happy'や'angry'など表情分析の結果が得られます。そしてこの結果をもとに、どの表情のキャラクターを描画するかを選べばいいわけです。めちゃくちゃ簡単ですね。

ただこの表情分析、全ての入力フレームに対して実行すると重いです。諸事情(後述)あって並列化をまともにしていないのはあるにしても、リアルタイムというには遅延がありすぎました。そのため、今回のコードでは表情分析は 11 フレームに 1 回だけ行っています(Haar-cancade と DeepFace を併用しているのはこれが理由)。なお、公式の README には「リアルタイム分析なら DeepFace.stream()を使え」と書いてあるんですが、これカメラからフレーム取ってきてその上に判定結果を重ねるところまで一気にやってそうなんですよね。他の諸々と競合しそうなのでやめました。

ちなみに、表情認識で一番難しいのは「そう認識されそうな顔をする」という点です。上の動画で変顔大会みたいになっていることからも分かるように、普通に生活してたら「明確に怒っていると判定できる顔」とかそうそうしないので、かなり意識的に表情を作る必要があります。もし毎フレーム表情分析にかけていたら常にそういう表情を継続しなくてはならないので、かなり顔が疲れる事態になっていた気がします。怪我の功名?


6. 手の認識

「カメラに映る手が立てている指の本数によって背景が変化」「カメラに映る手の裏表でキャラクター変更を制御」の部分です。これはhand.pyにまとまってます。ここで使うのはGoogleが開発したMediaPipeです。これを使えば、わけがわからないくらい色々できます。さすがGoogleって感じ。公式から出てる資料が充実しているのもありがたい。このサイトのコード中に書いてある、"To improve performance, optionally mark the image as not writeable to pass by reference." というコメントがPythonらしくなさを感じて好き。

import mediapipe as mp
mp_hands = mp.solutions.hands
hands = mp_hands.Hands(max_num_hands=1, min_detection_confidence=0.7, min_tracking_confidence=0.5)
# 入力画像はinput_frameを縮小・左右反転したもの
results = hands.process(入力画像)

これで results に、画像から検出された手の位置情報などが格納されます。この時、例えば「人差し指の先端は画面内のこの位置」というように、手の各部分に対して位置情報が得られます。そのため、ここから手の各部の座標を比較すれば「指は立てられているか」「手の表裏どちらをカメラに向けているか」を判定できます。あとは「指が 1 本だけ立っていればこの背景」など、自分の設定した条件に従って、キャラや背景を変化させていけばいいです。

手の認識も、表情と同じく 11 フレームに 1 回だけ行っています。これは、全フレームで認識すると、手の表裏によるキャラの切り替えがうまく働かなくなってしまうからです(手の裏を向けた途端にキャラが延々と切り替わり続ける)。


7. 音声認識

「マイクに入力された音声をセリフとして表示」の部分です。voice.pyあたりにまとまっています。音声認識系のライブラリにも色々ありますが、今回はVOSKを選びました。他の候補としては「SpeechRecognitionGoogleAPIを叩く」というのもありましたが、ノベルゲーム風を名乗るならオフラインで動いて欲しいですし、また、言葉の切れ目ごとに認識結果を得ていたのではリアルタイム性も損なわれます。VOSKになると認識精度は多少落ちますが、オフラインで動き、しかも言葉の途中でも認識結果のテキストを取得できるため、よりリアルタイムに近いです。公式のGitHubにサンプルコードがあるのでそれを参考にやっていきます。

from vosk import Model, KaldiRecognizer
import queue
import sounddevice as sd
import json

q = queue.Queue()

# オーディオのコールバック関数
def callback(self, indata, frames, time, status):
    """This is called (from a separate thread) for each audio block."""
    if status:
        print(status, file=sys.stderr)
    q.put(bytes(indata))

samplerate = 44100
model = Model(lang="ja")
rec = KaldiRecognizer(model, samplerate)
message = ""
with sd.RawInputStream(
        samplerate=samplerate,
        blocksize=8000, device=None,
        dtype="int16", channels=1,
        callback=callback
        ):
     while True:
        # 音声認識結果を取得
        data = q.get()
        translate_flag = rec.AcceptWaveform(data)
        # 発話の区切れ目かどうかで結果取得方法が異なる
        if(translate_flag):
            json_dict = json.loads(rec.Result())
            message = json_dict["text"]
        else:
            json_dict = json.loads(rec.PartialResult())
            message = json_dict["partial"]

これで、マイクからの入力を音声認識した結果が message に得られます。あとはこの message を整形(余分な空白の除去や適切な改行の付与)して出力に反映させれば良いです。


8. 音声合成

「『さようなら』『さよなら』を認識すると別れの挨拶を発声」の部分です。終了時にキャラが何か喋るのはノベルゲームあるあるですね。これはutils.pyあたりに書いてあります。先ほどの音声認識の結果、messageに入っているのが「さようなら」あるいは「さよなら」だった場合、音声合成で別れの挨拶を発話させます。この音声合成には、pyttsx3というライブラリを用います。これもオフラインで動作し、ここに使い方が大体書いてあります。

import pyttsx3
ph = "さよなら。またね。" # 発話させたいフレーズ
engine = pyttsx3.init()
engine.setProperty("rate",100) # 発話速度の設定
engine.say(ph)
engine.runAndWait()



ここまででしてようやく、リアルタイムで得た情報を出力に反映させることができます。タイトルだけは一丁前にカッコつけましたが、「実際やっていることはライブラリを使いまくっただけじゃないか」というのは禁句です。ライブラリを組み合わせて使うだけで誰でもこういうものが作れてしまうのは、かなり時代の進歩を感じますね。


改善すべき点

今はフレームごとに全ての処理を直列に行っていますが、特に画像認識系は普通に並列でやるべきな気がしますね。入力画像に処理結果を重ねる部分の制御が面倒だったのと、そこまでやってる時間がなかったので単純に直列にしてます。あと、背景とキャラ切り替えの制御はもっとスマートにできる気がするので、時間ができた時にでも調整したいです。



ここまで読んでいただきありがとうございました。OpenCV/GL実験は自由に色々作ることができ、みんなの発表を聞くだけでも楽しいので来年以降受けられる人はぜひ受けましょう!


参考資料

https://blanktar.jp/blog/2015/02/python-opencv-overlay https://qiita.com/mo256man/items/b6e17b5a66d1ea13b5e3 https://qiita.com/FukuharaYohei/items/ec6dce7cc5ea21a51a82 https://github.com/serengil/deepface https://laboratory.kazuuu.net/detecting-facial-expressions-using-pythons-deepface-module/ https://google.github.io/mediapipe/solutions/hands.html#python-solution-api https://alphacephei.com/vosk/ https://pypi.org/project/SpeechRecognition/ https://github.com/alphacep/vosk-api/blob/master/python/example/test_microphone.py