物体検出の出力が遅かったのでグリッドコンピューティングで実装した話

to3oi.hatenablog.com

以前作成したこの記事では物体検出の精度はまあまあいいものの、それにかかる時間が遅く、プレイ体験としては悪かった。
原因はCPU使用率が常に100%に張り付いていることだった。
新しくPCを組む時間も予算もないので、既存のものを使用して、なんとかCPU使用率100%を回避しつつ、1秒間にある程度の回数、物体検出をできるようにしたいというのが今回の目標です。

実際に使用していたPCのスペック

  • Core i7 9700K
  • RTX2080 Super
  • メモリ 32GB

原因究明

家の環境での物体検出は特に遅くなく、問題ありませんでした。
そこで、それぞれの環境の違いを見比べていった結果、今回書いたコードではスレッド数が足りてないという結論に至りました。
具体的には家のPCはAMD Ryzen 7 5700X 8-Core Processorでスレッド数が16あり、実際に使用していたPCでCore i7 9700Kでスレッド数が8で、家のPCより少なかったです。

解決策

幸い、このPCは何台か確保しており、同時に動かすことで対応できそうという結論に至りました。

そこで、まず以下のような構成でパソコンを配置して通信することにしました。
MasterPCではKinectの画像処理をしてClientPCにUDPで送信します。
ClientPCでは送られてきた画像に対して物体検出をし、その結果をMasterPCにUDPで返します。
というようなことをすれば、なんとなく動きそうな予感がしたので、実際に作成してみました。

通信は以下の順で実行することで動くと予想しました。 ※()内の数字はポート番号

ただし、通信周りは高校の頃勉強していた基本情報技術者の知識と2023年(今年)の3月頃から作っていたUnity上でUDPTCP/IPの通信を楽にするアセットUnityEasyNetの知識しかなかったので、実際に動くかわからない状態でスタートしました。
github.com

問題点

最初は上の画像のようにTCP/IPで通信する処理で実装していましたが、UDPで受け取りを開始し、最初の受信をした際エラーで落ちるという問題が起きました。
今回は時間がなく解決策を聞けそうな人もいなかったので、UDPに置き換えて実装することにしました。


TCP/IPでエラーが出ること以外は既存のコードもあるので特に問題なく実装(移植)できました。

グリッドコンピューティング

どのあたりがグリッドコンピューティングかという話

MasterPCからClientPCに画像データをUDPで送信するときに送信済みのClientPCのIPを保持しておき、物体検出の結果が返ってくるまでは再度画像を送信しないという処理を入れています。
これによりClientPCは同時に複数の画像の物体検出をすることはなくなり、ClientPC 1の物体検出結果を待っている間にClientPC 2に画像を送り物体検出をすると言うのを繰り返すことになります。
そしてこのあとClientPC1台あたりの物体検出にかかる時間を割り出し、連続した画像をClientPCに送るのではなく、ClientPC 1に画像を送り返ってくるまでの中間でClientPC 2に画像を送り物体検出の結果をなめらかにする処理を入れようとしましたが、ClientPCを2台接続した時点でほぼ毎フレーム物体検出の結果が返ってくるようになったので未実装で終わりました。

結果

最終的に30FPS以上出せるようになりました。

以下のリポジトリにそれぞれのコードがあります。 github.com github.com

Azure Kinect DKで入力処理を作成した時の話

導入

今回、制作中のゲームのInput周りの開発をしました。

ゲーム自体の内容には触れませんが、イメージでは4人のプレイヤーが机を囲むように座り、デジタルボードゲームを現実でプレイするゲームです。

この時プレイヤーは入力機器としてそれぞれ違う道具を持っておりInput情報として、その道具が存在するデジタルボードゲーム上の座標とその種類が必要になってきます。

また、今回のゲームを作るときに決まっていることは以下の3点でした。

この時点で座標を取る方法として考えたものは以前、HoloDiveというゲームで使用していたvuforiaというサービスでした。 しかし、HoloDiveでは会場のライトが強くカメラの映像が白飛びしてうまく物体検出ができない問題がありました。 今回プロジェクションマッピングで投影している机の上に存在するオブジェクトを認識するのはとても不可能に近いと考えました。

Azure Kinect DKには通常のRGBカメラ以外にToFで測定する深度カメラと近赤外線のNIRが実装されています。 learn.microsoft.com

そこで今回、会場のライトやプロジェクターの光量に影響しない方法として深度カメラと近赤外線カメラを使用して物体検出することにしました。

計画

①NIRの最大距離より外側にプレイヤーが持つ道具が来るようにAzure Kinect DKを配置する

②プレイヤーが持つ道具に識別できる固有のマークを反射材で付ける

③近赤外線の光を②で付けた反射材で反射させ、IR画像にプレイヤーがマークのみが映るようにする このとき画像を白黒にする

④Depth画像から机よりカメラ側の物体を白黒で映す

⑤③と④の画像でBitwiseAndをかけ余計なものが映ることを極力少なくする

以下の画像を参考に作成する

Kinectの配置

マスクの処理

DepthとIRの白黒画像を使用してOpenCVのBitwiseAndをかけてResultの画像を生成します。 実際にはこの段階で物体検出モデルにかけます。

使用する机がちょうど正方形の机なので上下左右の方向から一定の値だった場合物体検出で返された値は使用しないというようなMaskの計算をします。 ただし、実際に調整する際に直感的ではないのでResultにはMaskの値が大体どのくらいかを表示するために乗算した画像を表示しています。

このMaskのみOpenCVではなくGraphicsのFillRectangleを使用していますが勉強のために使用してます。

以下の記事はUnityでこのMaskの処理をしようとしていたときの記事ですがやっていることは一緒です。 qiita.com

CustomVisionでONNXモデルを作成する

今回ONNXモデルを作成するのに使用したのはCustomVisionです。 www.customvision.ai 使用理由の前に使用する条件として - 物体検出モデルが作成できる - WindowsのPC上で実行できる というものがありました。 CustomVisionでは物体検出モデルが作成でき、そのモデルをONNXなど複数の形式で書き出すことができました。 また、今回モデルの作成を個人でしていたらとても時間がかかるので比較的時間のあったプランナーに手伝ってもらうためにも何かしらのサービスを使用することは必要でした。 結果条件にあったCustomVisionを使用することになりました。 ただし、書き出されるモデルがどのYOLOモデルなのかが分からずとても時間を消費しました。

書き出したONNXモデルをNetronで表示したときに入力が3x416x416だったので試しに以下のリポジトリのコードを使用して実行したときに多少修正は必要ですが実行できたのでこのリポジトリを使用して作成していくことにしました。

github.com

UIとかこだわり

実際にゲームをプレイしてもらう会場での準備時間はとても少ないので、その場でコードの修正することはできる限り避けてすべてUIにある数値を微調整することで調整を終わらせたいというのが理想でした。

この結果を実現するためにしたことが以下の3つです。 - 実際にKinectで描画している画面の表示 - 物体検出の出力結果の表示 - 値の保存と読み込み

これを反映させたものがこの画像になります。 左側のウィンドウがMaskの値を入力や通信の開始をするもので、右のコンソールに物体検出の結果が表示されます。 また、ソフトの起動と終了時に値の保存と読み込みをするようにしました。

使用方法としては最初にSendTypeKinectRunを押し、MaskとPositionOffsetの値で実際の座標と合うよう調整します。 ConnectIP送信先IPアドレスを追加し、UDPConnectStartを押すことで物体検出で返された座標とラベルを送信します。 DebugSenderでは通信のテスト時に適当な値が欲しい時が多々あったので円を描くように座標を送信するようにしました。

以下に実際のコードがあります。 実行にはAzureKinectDKが必要です。 github.com

FBXに含まれるAnimation Clipの一括変換 (Unity)

以前以下の記事を書いたのだが、一つ一つ同じ設定をしてたら時間が足りないので一括で設定を変更できるコードを書いた qiita.com

使用条件

  • MixamoでダウンロードしたアニメーションのFBXであること
  • Assets/Resources/Avatar/直下にMixamoでダウンロードしたキャラクターのFBXが存在すること、またファイル名がcharacterであること
  • characterの設定を以下のように変更すること
    Mixamo_character_rig

今回MixamoのアニメーションをVRMで再生するときに足の向きがおかしくなる現象に遭遇したのでそれの対応も含めている 以下のサイトを参考にさせていただきました

MixamoアニメーションがUnityで動かない?足がおかしくなる?時の対処法【アバターの適用が重要】 | YouDoYou Blog ~スマートかつ快適に創造を楽しむ~

解決法としてはMixamoでダウンロードしたアニメーションのFBXのRigに対してMixamoで出力したキャラクターについているAvaterを設定することで正しく動く

使用方法

設定を変更したいFBXを選択してメニューバーにあるAssets/Set Animation Optionsを選択することで変更可能

Assets/Set LoopではアニメーションクリップのLoop Timeにチェックを入れることができる

using UnityEngine;
using UnityEditor;

public class AnimationProcessor : AssetPostprocessor
{
    static void SetAnimationImporterSettings(ModelImporter importer)
    {
        //rigの設定
        importer.animationType = ModelImporterAnimationType.Human;
        importer.avatarSetup = ModelImporterAvatarSetup.CopyFromOther;
        var avatar = Resources.Load<GameObject>("Avatar/character");
        if (avatar != null)
        {
            importer.sourceAvatar = avatar.GetComponent<Animator>().avatar;
        }
        //animationClipの設定
        var clips = importer.clipAnimations;

        if (clips.Length == 0) clips = importer.defaultClipAnimations;

        foreach (var clip in clips)
        {
            
            clip.name = System.IO.Path.GetFileNameWithoutExtension(importer.assetPath);
            clip.keepOriginalOrientation = true;
            clip.keepOriginalPositionY = true;
            clip.keepOriginalPositionXZ = true;

            clip.lockRootRotation = true;
            clip.lockRootHeightY = true;
            clip.lockRootPositionXZ = false;
            
            
            clip.lockRootHeightY = true;
            clip.heightFromFeet = false;
            clip.keepOriginalPositionY = true;
            
            clip.heightOffset = 0f;
            clip.cycleOffset = 0f;
            clip.rotationOffset = 0f;
        }

        importer.clipAnimations = clips;
    }
    
    static void SetAnimationLoopSettings(ModelImporter importer)
    {
        //animationClipの設定
        var clips = importer.clipAnimations;

        if (clips.Length == 0) clips = importer.defaultClipAnimations;

        foreach (var clip in clips)
        {
            clip.loop = true;
        }
        importer.clipAnimations = clips;
    }

    void OnPreprocessAnimation()
    {
        // 自動的に実行
        //SetAnimationImporterSettings(assetImporter as ModelImporter);
    }

    [MenuItem("Assets/Set Animation Options", true)]
    static bool SetAnimationOptionsValidate()
    {
        return Selection.GetFiltered(typeof(GameObject), SelectionMode.Assets).Length > 0;
    }

    [MenuItem("Assets/Set Animation Options")]
    static void SetAnimationOptions()
    {
        var filtered = Selection.GetFiltered(typeof(GameObject), SelectionMode.Assets);
        foreach (var go in filtered)
        {
            var path = AssetDatabase.GetAssetPath(go);
            var importer = AssetImporter.GetAtPath(path);
            SetAnimationImporterSettings(importer as ModelImporter);
            AssetDatabase.ImportAsset(path);
        }

        Selection.activeObject = null;
    }
    
    [MenuItem("Assets/Set Loop", true)]
    static bool SetLoopValidate()
    {
        return Selection.GetFiltered(typeof(GameObject), SelectionMode.Assets).Length > 0;
    }

    [MenuItem("Assets/Set Loop")]
    static void SetLoop()
    {
        var filtered = Selection.GetFiltered(typeof(GameObject), SelectionMode.Assets);
        foreach (var go in filtered)
        {
            var path = AssetDatabase.GetAssetPath(go);
            var importer = AssetImporter.GetAtPath(path);
            SetAnimationLoopSettings(importer as ModelImporter);
            AssetDatabase.ImportAsset(path);
        }

        Selection.activeObject = null;
    }
}