📝

Unityを使って2Dゲームの物体検出用データセットを作る

2020/11/07に公開

ゲームエンジンUnityを使って2Dゲームの物体検出用データセットを作ってみたので、その方法について紹介します。今回データセットを作成するのに使用したゲームはUnity Technologiesが無料公開している2D Game Kitを使用します。OSはmacOS Catalinaを使用しているので、Windowsの場合は多少違うところもあると思います。

データセットをUnityで作るアイディアの説明

一般に物体検出用のデータセットを作るためには、1枚ずつ写真をボックスで囲んでタグ付けをするという作業が必要です。ゲームの物体検出も基本的に同じことをする必要がありますが、ソースコードが手元にありビルドできる状況であれば、物体のラベルとその位置を1フレーム毎に保存するコードを追加できるので、タグ付けが要らなくなります。あとはプレイ動画を撮り、ffmpegなどでフレームレートを指定して画像を抽出すれば、ほぼ1フレーム毎の画像が手に入ります。このままだとフレームが飛んだり、Unityの場合はロゴの暗転から表示されたどこからがゲームの1フレーム目かが判断できなかったりして、位置情報との紐付けが出来ないので、画面のどこかにフレーム数の表示を追加しておきます。

以上の方法でフレーム数の分かった画像と、各フレームの物体のラベルと位置情報が書かれたテキストファイルが手に入るので、あとは適切に加工して、データセットを作ります。

プロジェクトをインポートする

『2D Game Kit』の入手方法やカスタムの仕方はチュートリアルが公開されているので、これに従えば問題無く出来ます。データセットを作る目的のためには2番目のチュートリアル"2D Game Kit Walkthrough"の3.まで進めてから、4.、5.をとばして6.で敵を配置できるところまで進めます。

ゲームをカスタマイズする

ここからはチュートリアルから外れて、データセットを作るためにゲームの設定はソースコードに変更を加えていきます。

レンダリングエンジンをMetalに変更する

レンダリングエンジンがOpenGLになっているときは、Metalに切り替えます。チュートリアルに従ってこのプロジェクトを入れると、OpenGLになると思います。変更する理由はOpenGLの方だと処理落ちが激しかったためです。Unity EditorでプレイしているうちはOpenGLでも快適に動いてくれますが、ビルド後に激重になります。

Edit > Project Settings > Playerと選んで、"Other Settings"の"Graphics APIs for Mac"がOpenGL Coreになっている場合はMetalに変えます。OpenGL Coreを消したタイミングで、エディタが再起動します。

フレーム数の表示を追加する

画面の右上にフレーム数を追加します。このフレーム数は後でOCRで読み取って、位置情報と紐付けするので、OCRで確実に読み取れるように数字の背景を黒にします。まず背景ですが、UI > Imageで追加して、Inspectorに表示されている値を次のように変更します。

次にテキストはUI > Textで追加して、名称をFrameに変更します。そしてInspectorに表示されている値を次のように変更します。

以上の変更を保存した後、プレイ状態にすると右上に背景黒で白字の00000000が表示されます。下の画像は数値は違いますが、表示位置としては同じです。

もし完全に数字が隠れていたら、HierarchyのImageとFrameの表示順が次のようになるように変えて下さい。

敵にタグ付けする

敵を配置する方法は上で紹介している公式チュートリアルを見れば分かるので、具体的な方法はここでは説明しませんが、Chomperを数体配置しておきます。敵の位置情報の書き込み時にタグを利用するので、Chomperというタグを追加して、配置したChomperのタグにChomperを指定しておきます。

タグの追加の仕方はEdit > Project Settings > Tags and Layersを選択して、Tagsの項目にある+ボタンを押して追加します。

スクリプトを変更する

スクリプトに変更を加えて、Application.dataPathの下にdataディレクリを作成してプレイキャラと敵の位置情報を4000フレーム目までテキストファイルを保存するようにします。エディタ内で実行したときはApplication.dataPathAssetsになり、ビルド後はappディレクトリ内のContentsになります。

/Assets/2DGamekit/Scripts/Character/MonoBehaviours/EnemyBehaviour.cs
@@ -142,8 +142,19 @@ namespace Gamekit2D
 
             if (meleeDamager)
                 m_LocalDamagerPosition = meleeDamager.transform.localPosition;
+            frame = 0;
+            autoCameraSetup = GameObject.Find("CM vcam1").GetComponent<AutoCameraSetup>();
         }
+        private long frame;
+        public Vector3 size;
+        public Vector3 offset;
+        private AutoCameraSetup autoCameraSetup;
+        void LateUpdate()
+        {
+            frame++;
+            autoCameraSetup.AppendText(frame, this.tag, transform.position, this.size, this.offset, m_SpriteRenderer.flipX);
 
+        }
         void FixedUpdate()
         {
             if (m_Dead)
/Assets/2DGamekit/Scripts/Character/MonoBehaviours/PlayerCharacter.cs
@@ -156,6 +156,35 @@ namespace Gamekit2D
 
             m_StartingPosition = transform.position;
             m_StartingFacingLeft = GetFacing() < 0.0f;
+            frame = 0;
+            autoCameraSetup = GameObject.Find("CM vcam1").GetComponent<AutoCameraSetup>();
+        }
+        private AutoCameraSetup autoCameraSetup;
+        private long frame;
+        public Vector3 offset;
+        public Vector3 size;
+        void LateUpdate()
+        {
+            frame++;
+            autoCameraSetup.AppendText(frame, "person", transform.position, this.size, this.offset, spriteRenderer.flipX);
+            if (PlayerInput.Instance.Pause.Down)
+            {
+                if (!m_InPause)
+                {
+                    if (ScreenFader.IsFading)
+                        return;
+
+                    PlayerInput.Instance.ReleaseControl(false);
+                    PlayerInput.Instance.Pause.GainControl();
+                    m_InPause = true;
+                    Time.timeScale = 0;
+                    UnityEngine.SceneManagement.SceneManager.LoadSceneAsync("UIMenus", UnityEngine.SceneManagement.LoadSceneMode.Additive);
+                }
+                else
+                {
+                    Unpause();
+                }
+            }
         }
 
         void OnTriggerEnter2D(Collider2D other)
/Assets/2DGamekit/Scripts/Utility/AutoCameraSetup.cs
@@ -2,6 +2,9 @@
 using System.Collections.Generic;
 using Cinemachine;
 using UnityEngine;
+using UnityEngine.UI;
+using System;
+using System.IO;
 
 namespace Gamekit2D
 {
@@ -45,5 +48,68 @@ namespace Gamekit2D
                 cam.Follow = cameraFollowGameObject.transform;
             }
         }
-    }
+        private GameObject frameText;
+        private long frame;
+        static private StreamWriter sw;
+        private FileInfo fi;
+        private Camera _mainCamera;
+        private bool isOpen = false;
+        public void StopLogging()
+        {
+            isOpen = false;
+            sw.Close();
+        }
+        public void AppendText(long frame, string label, Vector3 pos, Vector3 size, Vector3 offset, bool flipX)
+        {
+            if (isOpen)
+            {
+                offset = (flipX) ? new Vector3(-offset.x, offset.y, offset.z) : offset;
+                Vector3 screenPos = _mainCamera.WorldToScreenPoint(pos);
+                Vector3 imgSize = _mainCamera.WorldToScreenPoint(size) - _mainCamera.WorldToScreenPoint(Vector3.zero);
+                Vector3 imgOffset = _mainCamera.WorldToScreenPoint(offset) - _mainCamera.WorldToScreenPoint(Vector3.zero);
+                float cx = screenPos.x + imgOffset.x;
+                float cy = Screen.height - screenPos.y - imgOffset.y;
+                int xmin = (int)(cx - imgSize.x / 2) + 1;
+                int ymin = (int)(cy - imgSize.y / 2) + 1;
+                int xmax = (int)(cx + imgSize.x / 2) + 1;
+                int ymax = (int)(cy + imgSize.y / 2) + 1;
+                sw.WriteLine($"{frame} {label} {xmin} {ymin} {xmax} {ymax}");
+                sw.Flush();
+            }
+        }
+        void startLogging()
+        {
+            if (!isOpen)
+            {
+                string dirPath = $"{Application.dataPath}/data";
+                if (!Directory.Exists(dirPath))
+                {
+                    Directory.CreateDirectory(dirPath);
+                }
+                fi = new FileInfo($"{dirPath}/{DateTime.Now:yyyy-MM-dd-HH-mm-ss}.txt");
+                sw = fi.AppendText();
+                isOpen = true;
+            }
+        }
+        void Start()
+        {
+            Application.targetFrameRate = 30;
+            this.frameText = GameObject.Find("Frame");
+            this._mainCamera = GameObject.Find("MainCamera").GetComponent<Camera>();
+            this.startLogging();
+        }
+
+        private void LateUpdate()
+        {
+            if (frame < 4000)
+            {
+                frame++;
+                this.frameText.GetComponent<Text>().text = $"{frame:000000000}";
+            }
+            else
+            {
+                this.StopLogging();
+            }
+        }
+    }
 }

位置情報のフォーマットについて

フレーム番号、ラベル名、x軸の最小値、y軸の最小値、x軸の最大値、y軸の最大値の順に半角スペース1つずつ開けて書き込んでいます。原点が1になっているのはPASCAL VOCのデータセットに合わせているだけで、深い意味はありません。

プレイキャラと敵の各ポーズのサイズを指定する

PlayerCharacter.csEnemyBehaviour.csにあるsizeoffsetを各アニメーションについて指定することで、中心位置からの幅と高さを指定します。Window > Animation > Animationを選んでからHierarchyにあるEllenを選択して、"Add Property"ボタンを押してPlay Character.OffsetPlay Character.Sizeを追加します。キーフレームをずらしながら、ちょうどキャラクタの境界になるようにsizeoffsetの値を調整します。値の調整はちょうどBox Collider 2Dのsizeoffsetを指定したときの境界と同じになるので、スプライトをステージに置いてどのような感じになるか確認しながら調整しました。キーフレーム間の値の変化については、完全には予測できないので、実際どんな感じになるかは予測できませんでした。


スクリプトの実行順序を指定する

現在の状況で実行すると、カメラの移動しているときに位置が微妙にズレます。スクリプトの実行順序を指定して、カメラが移動したあとで位置情報を計算するように変更します。
スクリプトの実行順序はEdit > Project Settings > Script Execution Orderを選択して、次の画像のように実行順序を指定します。

ビルドする

File > Build Settingsを選んで、ビルドのチェックマークを自分で作ったシーン以外を外し、ビルドします。"Compression Method"はLZ4を選んでいますが、必須ではないです。このプロジェクトのビルドですが、"Build and Run"をすると僕の環境ではビルド時にエラーになります。これは環境に依ると思うので、試行錯誤してみてください。

データセットを作る

ゲームの改造は終わったので、データセットを作り方について説明します。データセットのファイル後続は次のようになっています。この後の説明で登場するファイルもこの構造に従って配置していると過程します。各ディレクトリについて説明すると、trainは訓練データを入れているディレクトリです。データセットには検証用データ、テスト用データを配置するディレクトリが同じ階層にありますが、全く同じ構造をしているので、省略しています。train/annotationsには各フレームの位置情報を保存しています。ファイル名はフレーム数になっていて、例えば0032.txtの場合は32フレーム目の位置情報が保存されています。train/imagesには各フレームの画像が保存されています。これも同じくファイル名がフレーム数を表しています。データセットとして必要なディレクトリやファイルはこれで全てですが、中間的に必要なディレクトリやファイルも表示していますが、この後で説明します。

データセットの構造
├── train
│   ├── annotation.txt
│   ├── annotations
│   │   ├── 0031.txt
│   │   ├── 0032.txt
...
│   ├── images
│   │   ├── 0031.jpg
│   │   ├── 0032.jpg
...
│   ├── make_annotations.py
│   ├── make_images.py
│   └── original_images
│       ├── 0001.jpg
│       ├── 0002.jpg
...
└── train.mov

ここまででゲームのビルドは終わっています。まずビルドしたゲームを全画面でQuickTime Playerなどで録画してtrain.movというファイル名で保存します。このとき、appディレクトリ内のContents/dataに位置情報の書き込まれたテキストが保存されているので、annotation.txtという名前で保存します。

train.movからffmpegを使ってゲームのフレームレートで抽出しoriginal_imagesに保存します。コマンドで次のようにします。

ffmpeg -i train.mov -vcodec mjpeg -r 30 train/original_images/%04d.jpg

次に位置情報をフレーム毎に分割します。ただし、画面外にいたりあまりにも見切れている場合は排除します。この操作を行うにはmake_annotations.pyを実行します。ただしプレイキャラが30フレーム程度までannotations.txtに書き込まれないので、それらは手で排除します。

make_annotations.py
import os
import cv2
pfn = 0
lines = []
root_path = os.path.dirname(os.path.abspath(__file__))
w = 3360
h = 2100
pad = 0
for line in open(os.path.join(root_path, 'annotation.txt')):
    fn, label, xmin, ymin, xmax, ymax = line.split(' ')
    fn = int(fn)
    xmin_ = max(int(xmin) - pad - 1, 0) + 1
    ymin_ = max(int(ymin) - pad - 1, 0) + 1
    xmax_ = max(min(int(xmax) + pad - 1, w), 0) + 1
    ymax_ = max(min(int(ymax) + pad - 1, h), 0) + 1
    if pfn > 0 and pfn != fn:
        if len(lines) == 0:
            print(pfn)
        else:
            with open(os.path.join(root_path, 'annotations', '{:04d}.txt'.format(pfn)), 'w') as f:
                f.writelines(lines)
                lines = []
    if xmin_ < xmax_ and ymin_ < ymax_:
        area_ = (xmax_ - xmin_) * (ymax_ - ymin_)
        area = (int(xmax) - int(xmin)) * (int(ymax) - int(ymin))
        if area > 0 and area_ / area > 0.5:
            lines.append("{} {} {} {} {}{}".format(
                label, xmin_, ymin_, xmax_, ymax_, os.linesep))
    pfn = fn
if len(lines) > 0:
    with open(os.path.join(root_path, 'annotations', '{:04d}.txt'.format(pfn)), 'w') as f:
        f.writelines(lines)

annotationsに位置情報がある状態で、make_images.pyを実行します。

make_images.py
import cv2
import matplotlib.pyplot as plt
import os
import numpy as np
import pyocr
import PIL
from glob import glob

CLASSES = ('person', 'Chomper',)
class_to_index = dict(zip(CLASSES, range(len(CLASSES))))
class_to_index

root_path = os.path.dirname(os.path.abspath(__file__))


def get_img_id(img):
    img = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
    x, y = 2930, 0
    h, w = 100, 430
    img = img[y:y + h, x:x + w]
    th = 130
    img = cv2.threshold(
        img, th, 255, cv2.THRESH_BINARY
    )[1]
    img = cv2.bitwise_not(img)
    tool = pyocr.get_available_tools()[0]
    image = PIL.Image.fromarray(np.uint8(img))
    res = tool.image_to_string(image, lang="eng").replace('O', '0').replace(' ', '').replace('Q', '0')
    return int(res)


def get_color(label):
    idx = class_to_index[label]
    if idx == 0:
        return (255, 0, 0)
    if idx == 1:
        return (0, 0, 255)


def write_img2(idx):
    img = cv2.imread(os.path.join(
        root_path, 'original_images', '{:04d}.jpg'.format(idx)))
    img_id = get_img_id(img.copy())
    if os.path.exists(os.path.join(root_path, 'annotations', '{:04d}.txt'.format(img_id))):
        cv2.imwrite(os.path.join(root_path, 'images',
                                 '{:04d}.jpg'.format(img_id)), img)


n = len(glob(os.path.join(root_path, 'original_images', '*.jpg')))
for i in range(n):
    write_img2(i + 1)

以上で、データセットは完成です。opencvとmatplotlibなどでボックスを追加すると次のような感じになります。

学習させた結果

データセットで学習させたら、こんな感じでプレイ動画にボックスを表示することが出来ます。

Discussion