はじめに

ARFoundationは、Unityエンジニアにはお馴染みのAR開発ライブラリですね。

ギャップロでも過去に何回か取り上げている内容です。

AR開発を行っていてこう感じたことはありませんか?

  • ハンドトラッキングを行いたい。
  • ヒューマントラッキングを行いたい。
  • オブジェクトトラッキングを行いたい。
  • etc…

見たいな機能を提供してくれているのが、GoogleのMediapipeと呼ばれる、機械学習のフレームワークです。

最近のUnity機能のBarracudaでも推論して動かすことが出来るみたいです。

今回は予め、UnityでMediapipeが動作しているGitのProjectをお借りして、ARFoundationとどのように連携したかを紹介したいと思います。と、言いますのも筆者は機械学習の知見がなくて、、、そのままMediapipeをUnityで動かすことが出来ません。Barracudaを使用するにしても、機械学習の前提知識が必要でした。

開発環境

項目 バージョン
Unity 2021.1.4f1
ARFoundation 4.1.7

記事内で紹介する全てのプロジェクト共通です。

ARFoundationの導入方法はこの記事では省略します。

ARFoundationからカメラの映像を取得する

どのプロジェクトでも共通になりますので、一度作成したら、使い回すようにするのが良いかと思います。

大体どのプロジェクトもWebCameraのテクスチャを使用していますが、それの代わりになるイメージです。

ARCameraManager

/// <summary>
/// ARFoundationのカメラマネージャー
/// </summary>
[SerializeField] 
private ARCameraManager _cameraManager;

private void OnEnable()
{
    //コールバック登録
    _cameraManager.frameReceived += OnCameraFrameReceived;
}

/// <summary>
/// カメラの映像を受信した時
/// </summary>
/// <param name="eventArgs"></param>
unsafe void OnCameraFrameReceived(ARCameraFrameEventArgs eventArgs)
{
   ~省略~
}

ARCameraManager#frameRecivedからカメラ映像のコールバックを取得することが出来ます。 ARFoundationを使用する時は、このタイミングでカメラのテクスチャを取得することが出来ます。

最終的なソースコード

using System;
using Unity.Collections.LowLevel.Unsafe;
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.XR.ARFoundation;
using UnityEngine.XR.ARSubsystems;

namespace Gaprot.Arf
{
    /// <summary>
    /// ARFoundationのカメラ映像を送るクラス
    /// </summary>
    public class ArfCameraTransfar : MonoBehaviour
    {
        /// <summary>
        /// ARFoundationのカメラ
        /// </summary>
        [SerializeField] 
        private ARCameraManager _cameraManager;

        /// <summary>
        /// 表示用のRawImage
        /// </summary>
        [SerializeField] 
        private RawImage _showRawImage;
        
        /// <summary>
        /// カメラからの生映像保持用のテクスチャ
        /// </summary>
        private Texture2D _realDiaplayTexture;

        /// <summary>
        /// カメラのテクスチャ
        /// </summary>
        public Texture2D CameraTexture => _realDiaplayTexture = 
                                                _realDiaplayTexture == null ? 
                                                null : _realDiaplayTexture;
        private void OnEnable()
        {
            _cameraManager.frameReceived += OnCameraFrameReceived;
        }

        private void OnDisable()
        {
            _cameraManager.frameReceived -= OnCameraFrameReceived;
        }

        /// <summary>
        /// カメラの映像を受信した時
        /// </summary>
        /// <param name="eventArgs"></param>
        unsafe void OnCameraFrameReceived(ARCameraFrameEventArgs eventArgs)
        {
            //ネイティブのカメラ画像の取得
            XRCpuImage nativeImage;
            if (!_cameraManager.TryAcquireLatestCpuImage(out nativeImage))
            {
                return;
            }
            
            //テクスチャフォーマットの指定
            var format = TextureFormat.RGBA32;
            
            //画像の生成
            if (   _realDiaplayTexture == null 
                || _realDiaplayTexture.width  != nativeImage.width 
                || _realDiaplayTexture.height != nativeImage.height)
            {
                int width = nativeImage.width;
                int height = nativeImage.height;
                _realDiaplayTexture = new Texture2D(width, height, format, false);
            }
            
            //端末の向きに応じて画像回転 ※縦の時は画像ごとの回転が必要。
            XRCpuImage.Transformation imageTransformation = (Input.deviceOrientation == DeviceOrientation.LandscapeRight)
                ? XRCpuImage.Transformation.MirrorY
                : XRCpuImage.Transformation.MirrorX;
            
            //画像フォーマットに変換し、画像の回転の設定
            var conversionParams = new XRCpuImage.ConversionParams(nativeImage, format, imageTransformation);
            var rawTextureData = _realDiaplayTexture.GetRawTextureData<byte>();
            try
            {
                nativeImage.Convert(conversionParams, new IntPtr(rawTextureData.GetUnsafePtr()), rawTextureData.Length);
            }
            finally
            {
                //ネイティブ画像の破棄
                nativeImage.Dispose();
            }
            _realDiaplayTexture.Apply();

            //テスト用に画面に表示
            if(_showRawImage != null) _showRawImage.texture = _realDiaplayTexture;
        }
    }
}

このソースコードでは横持ちにしか対応していません。

本当は端末を回転した時の対応も必要ですがこの記事では省略しています。

後はCameraTextureを他のMediapipeで利用する時に渡します。

ワールド座標変換

Camera.main.ScreenToWorldPoint(Vector3 Position);

大体のプロジェクトは2D座標を返すと思うので、ワールド座標に変換する必要が出てくるかと思います。

このScreenToWorldPoint()というUnityの便利関数を使うことで行うことが出来ます。

マウスや画面をタップした時の座標取得にも良く使われることが多いと思います。

float xPos = Screen.width  * normalizedX;
float yPos = Screen.height * normalizedY;
float zPos = predictDepth; //独自のZ座標の値
var position = new Vector3(xPos,yPos,zPos);

引数のpositionの渡し方ですが、

XとY座標は正規化された値に、画面の解像度を掛けてあげれば大丈夫です。

Z座標はデプスが取得出来るのならそれに越したことないのですが、大半は取得出来ないと思うので、

仮の予測した値を設定してあげる必要があります。

検知した矩形の大きさや、手の指の長さの合計等を使ってプロジェクトごとに対応する必要があります。

Barracudaを使用したプロジェクトの例

HandPoseBarracudaのプロジェクト(※2021/05/24時点のmaster内容)

Unityエンジニアの方ならどこかで見たことがあるかと思います。KeijiroさんのBarracuada活用のハンドトラッキングのサンプルプロジェクトです。

Burst AOT Settings

BarracudaがBurstに依存しており、そのままではiOS向けのBuildが出来ません。

  • Enable BurstCompilation
  • Enable Optimisations

Project Settings -> Burst AOT Settings を開いて以上赤線の二つのチェックを外してください。

Andoirdでは動かない

※ARFoundationを使用せずに、そのままなら動きます。

BuildFailedException: You have enabled the Vulkan graphics API, which is not supported by ARCore.
UnityEditor.XR.ARCore.ARCorePreprocessBuild.EnsureOnlyOpenGLES3IsUsed () (at Library/PackageCache/com.unity.xr.arcore@4.1.7/Editor/ARCoreBuildProcessor.cs:152)
UnityEditor.XR.ARCore.ARCorePreprocessBuild.OnPreprocessBuild (UnityEditor.Build.Reporting.BuildReport report) (at Library/PackageCache/com.unity.xr.arcore@4.1.7/Editor/ARCoreBuildProcessor.cs:54)
UnityEditor.Build.BuildPipelineInterfaces+<>c__DisplayClass15_0.<OnBuildPreProcess>b__1 (UnityEditor.Build.IPreprocessBuildWithReport bpp) (at <cbc56ce48ec645d2beabdfed6b9ae8ee>:0)
UnityEditor.Build.BuildPipelineInterfaces.InvokeCallbackInterfacesPair[T1,T2] (System.Collections.Generic.List`1[T] oneInterfaces, System.Action`1[T] invocationOne, System.Collections.Generic.List`1[T] twoInterfaces, System.Action`1[T] invocationTwo, System.Boolean exitOnFailure) (at <cbc56ce48ec645d2beabdfed6b9ae8ee>:0)
UnityEngine.GUIUtility:ProcessEvent(Int32, IntPtr, Boolean&)

使用するGraphicsAPIにValkanを指定すると、ARCoreが対応していないためBuildに失敗します。

ERROR: Unable to link compute shader: Conv2dA_NHWC.Conv2DKernelKxK_StrictC16K64_T16x16_R4x4_NHWC

かといって、OpenGLES3を指定すると、次はBarracudaでコンピュートシェーダーが動かないということで、

Android実機でサンプル起動直後にアプリが落ちます。

BarracudaドキュメントのサポートプラットフォームにもValkanを使うと書いてあります。

ライブラリ Graphics API
AR Foundation OpenGLES3
Barracuda Valkan

対応Graphics APIが以上のようになっており、お互いに衝突している感じです。

Unityフォーラムでも議論されており、Auto Graphics APIを試してみましたが、それでも動かなかったです。

サンプルの改修

このプロジェクトではTestAnimatorシーンをベースにARFoundationでも動くように改修しました。

スクリプトはHandAnimator.csに手を入れていきます。

※シーンやスクリプトは複製することを推奨します。

ARFoundationのテクスチャを渡す

void LateUpdate()
{
    // Feed the input image to the Hand pose pipeline.
    _pipeline.UseAsyncReadback = _useAsyncReadback;
    //_webcam.Textureの代わりに、ARFoundationのテクスチャを渡す。
    _pipeline.ProcessImage(_webcam.Texture);
}

HandAnimator.csの67行目ぐらいで、_pipeline.ProcessImage()にWebCamTextureの代わりに、

先述したARFoundationのテクスチャを渡してください。

手の座標をワールド座標に変換する

/// <summary>
/// 手の座標更新
/// </summary>
private void updateHandPose()
{
      for (int i = 0; i < HandPipeline.KeyPointCount; i++)
      {
            //手の各パーツの座標の取得
            var position = _pipeline.GetKeyPoint(i);
            var keyPoint = (HandPipeline.KeyPoint) i;
            //ワールド座標に変換する
            float xPos = Screen.width  * normalize(position.x);
            float yPos = Screen.height * normalize(position.y);
            float zPos = 0.2f + position.z;
            Vector3 cameraPos = new Vector3(xPos, yPos, zPos);
            var screenPosition = Camera.main.ScreenToWorldPoint(cameraPos);
            //それぞれの手のパーツに座標を代入
            _handJoints[keyPoint].transform.position = screenPosition;
       }

       //ローカル関数:座標の正規化
       float normalize(float value)
       {
            float min = -0.5f;
            float max = 0.5f;
            float cValue = Mathf.Clamp(value, min, max);
            return (cValue - min) / (max - min);
       }
}

_pipeline.GetKeyPoint()からまず2Dの座標を取得します。引数には手の対応するインデックスが入ります。

取得したXとY座標は-0.5~0.5の範囲で正規化してから、スクリーンの解像度を掛けています。

正規化の最小値と最大値に関しては、手の位置を0が中央で、左が負数、右が正数を返していたのでこのようにしています。

0.5でなくて、任意の値でも良いです。

Z座標に関してはよしなに奥行きを返してくれていたので、+0.2して、カメラの少し前に映るようにしています。

最終的なソースコード

namespace Gaprot.Arf
{
    /// <summary>
    /// 手の形状の管理
    /// </summary>
    public class HandPoseController : MonoBehaviour
    {
        [SerializeField] private GameObject _jointPrefab;

        [SerializeField] private Transform _handParent;

        [SerializeField] ArfCameraTransfar _cameraTransfar = null;

        [SerializeField] ResourceSet _resources = null;
        [SerializeField] bool _useAsyncReadback = true;

        private HandPipeline _pipeline;

        private Dictionary<HandPipeline.KeyPoint, GameObject> _handJoints =
            new Dictionary<HandPipeline.KeyPoint, GameObject>();
        
        void Start()
        {
            _pipeline = new HandPipeline(_resources);
            initalizeHandJoint();
        }

        private void OnDestroy()
        {
            _pipeline.Dispose();
        }

        private void LateUpdate()
        {
            _pipeline.UseAsyncReadback = _useAsyncReadback;
            var cameraTexture = _cameraTransfar.CameraTexture;
            if (cameraTexture == null) return;
            _pipeline.ProcessImage(_cameraTransfar.CameraTexture);

            //手の座標更新
            updateHandPose();
        }

        /// <summary>
        /// 手のパーツの初期化
        /// </summary>
        private void initalizeHandJoint()
        {
            for (int i = 0; i < HandPipeline.KeyPointCount; i++)
            {
                var go = Instantiate(_jointPrefab, _handParent);
                var keyPoint = (HandPipeline.KeyPoint) i;
                _handJoints.Add(keyPoint, go);
            }
        }

        /// <summary>
        /// 手の座標更新
        /// </summary>
        private void updateHandPose()
        {
            for (int i = 0; i < HandPipeline.KeyPointCount; i++)
            {
                //手の各パーツの座標の取得
                var position = _pipeline.GetKeyPoint(i);
                var keyPoint = (HandPipeline.KeyPoint) i;
                //ワールド座標に変換する
                float xPos = Screen.width  * normalize(position.x);
                float yPos = Screen.height * normalize(position.y);
                float zPos = 0.2f + position.z;
                Vector3 cameraPos = new Vector3(xPos, yPos, zPos);
                var screenPosition = Camera.main.ScreenToWorldPoint(cameraPos);
                //それぞれの手のパーツに座標を代入
                _handJoints[keyPoint].transform.position = screenPosition;
            }

            //ローカル関数:座標の正規化
            float normalize(float value)
            {
                float min = -0.5f;
                float max = 0.5f;
                float cValue = Mathf.Clamp(value, min, max);
                return (cValue - min) / (max - min);
            }
        }
    }
}

このソースコードでは、手のパーツのみ描画し、間のボーンは描画していません。

こんな感じで変換することが出来ました。

より高い精度を求めるのなら、座標変換の計算を直す必要がありますが、

手の当たり判定を取り敢えず取得したい感じならこの程度の精度でも十分ではと思います。

Pluginを使用したプロジェクトの例

MediapipeUnityPluginのプロジェクト(※2021/05/24時点のmaster内容)

このプロジェクトのhomelerさんという方は、自前でMediapipeが動くUnityPluginを作成して下さっています。

ほぼ全てのMediapipeで出来るような機能を実装して下さっていますが、今回はBarracudaと合わせて、ハンドトラッキングの対応のみに絞りたいと思います。

最初にそれぞれの環境でPythonによるNativePluginのビルドが必要なので、ReadMeをよく読んで行ってください。

NativePluginBuild時の環境

項目 バージョン
macOS Big Sur 11.2.3
xcode 12.5 beta 3
android SDK 30
android NDK 21.4.7075529
python 3.9.5
bazel 3.7.2(実行時に指定)

サンプルの改修

このプロジェクトではDesktopDemoシーンをベースにARFoundationでも動くように改修します。

スクリプトは

  • IDemoGraph.cs
  • DemoGraph.cs
  • HandTrackingGraph.cs
  • HandTrackingAnnotationController.cs
  • NodeAnnotationController.cs
  • SceneDirector.cs

以上に手を入れていきます。先述のBarracudaのサンプルとは違い、渡すテクスチャを変えるのみでは出来なかったです。

理由は後述しますが、こちらもAndroidは動きません。(※そのままなら動きます。)

IDemoGraph

Status PushInput(Texture2D texture);
void RenderOutput(Transform screenTransform);

顔検知やハンドトラッキング等のそれぞれの機能を担うインターフェースです。

WebCamTextureを使わずに、ARFoundationのテクスチャを使うために、上記二つの関数を追加しました。

RenderOutput()の引数は最終的には使用していないですが、影響範囲が大きいため敢えて渡しています。

HelloWorldGraph.cs等、使用しないものにもエラーが出ますがよしなに対応してください。

DemoGraph

public virtual Status PushInput(Texture2D texture)
{
      currentTimestamp = GetCurrentTimestamp();
      var imageFrame = new ImageFrame(ImageFormat.Format.SRGBA, 
                                      texture.width, 
                                      texture.height,
                                      4 * texture.width, 
                                      texture.GetRawTextureData<byte>());
      #if UNITY_ANDROID
        //この対応では動きませんでした。
        return gpuHelper.RunInGlContext(() => {
          
          var texture = gpuHelper.CreateSourceTexture(imageFrame);
          var gpuFrame = texture.GetGpuBufferFrame();
          texture.Release();
      
          return graph.AddPacketToInputStream(inputStream, new GpuBufferPacket(gpuFrame, currentTimestamp));
        });
     #endif
      
      var packet = new ImageFramePacket(imageFrame, currentTimestamp);
      return graph.AddPacketToInputStream(inputStream, packet);
}

public virtual void RenderOutput(Transform screenTransform)
{ 
      //継承先で実装。
      Debug.Log("継承先で実装してください。");
}

各機能の親クラスとなるスクリプトです。先程のインターフェースに定義した関数を実装します。

RenderOutput()に関しては継承先で実装するので、特に何もしなくて良いです。

PushInput()は、引数からもらったARFoundationのテクスチャをImageFrameに変換して、

graph.AddPacketToInputStream()に渡すことによって推論を実行しています。

ArgumentException: Object contains non-primitive or non-blittable data.

#if UNITY_ANDROIDの内容ですが、そのままではAndroidで動かずこのようなエラーが出ます。

こちらのIssueに議論されているようなことを行ったのですが、それでも動きませんでした。

HandTrackingGraph

public override void RenderOutput(Transform screenTransform)
{
    var handTrackingValue = FetchNextHandTrackingValue();
    RenderAnnotation(screenTransform, handTrackingValue);
}

ハンドトラッキングの機能を担っているスクリプトです。

インターフェースに定義したRenderOut()の実装をします。

ここでは、推論した結果を元に、手の各パーツや手のひらの矩形の描画を行っています。

元々やっていたことの違いは、WebCamTextureによる処理を省いたのみです。

HandTrackingAnnotationController

public void Draw(Transform screenTransform, List<NormalizedLandmarkList> handLandmarkLists, List<ClassificationList> handednesses,
      List<Detection> palmDetections, List<NormalizedRect> handRects, bool isFlipped = false)
  {
    handLandmarkListsAnnotation.GetComponent<MultiHandLandmarkListAnnotationController>().Draw(screenTransform, handLandmarkLists, isFlipped);
    palmDetectionsAnnotation.GetComponent<DetectionListAnnotationController>().Draw(screenTransform, palmDetections, isFlipped);
    palmRectsAnnotation.GetComponent<RectListAnnotationController>().Draw(screenTransform, handRects, isFlipped);
  }

ここでは手の描画を操作しています。

手の各パーツの描画、手の平の矩形の描画、手全体の矩形の描画を行なっているので、任意で不要なものはコメントアウトしたら良いかと思います。

NodeAnnotationController

public void Draw(Transform screenTransform, NormalizedLandmark point, bool isFlipped = false, float scale = 0.5f) {
      gameObject.transform.position = getWorldPosition();
      gameObject.transform.localScale = scale * Vector3.one;

      //ローカル関数:ワールド座標の取得
      Vector3 getWorldPosition()
      {
          float xPos = Screen.width  * ( 1f - point.X);
          float yPos = Screen.height * ( 1f - point.Y);
          float zPos = 0.5f;
          Vector3 cameraPos = new Vector3(xPos,yPos,zPos);
          return Camera.main.ScreenToWorldPoint(cameraPos);
      }
    }

各機能の座標の設定はここで行われています。

デフォルトは引数のscreenTransformをベースに座標変換していますが、

Barracudaと同じように、カメラベースで座標変換します。

こちらの場合は、正規化された座標が取得出来ますが、座標系が逆になっているので、1から引いて逆にしています。

zPosに関しては丁度よく見えた0.5を入れてるのみです。(本当は手の平の矩形や、指の長さの合計で計算した方が良いです。)

SceneDirector

IEnumerator RunGraph() 
{
    yield return WaitForGraph();

    ~省略~
      
    //カメラの映像が取得できるまで待機
    if(_cameraTransfar.CameraTexture == null) yield return null;

    ~省略~

    graph.StartRun(_cameraTransfar.CameraTexture).AssertOk();

    while (true) {
          yield return new WaitForEndOfFrame();
          graph.PushInput(_cameraTransfar.CameraTexture).AssertOk();
          graph.RenderOutput(_screenTransform);
    }
}

ここでやっと最後です。

全体のシーン管理を行なっているスクリプトです。

インターフェースに定義した今までの実装を呼び出して、ARFoundationのテクスチャを渡しています。

WebCamTextureに関係する実装はよしなに全部削除して下さい。

こちらもこんな感じで、手で当たり判定を取りたいぐらいなら出来ると思います。

やはり精度を高めると座標変換の計算式は改める必要があります。

FPS比較

Barracudaのプロジェクトも自前プラグインのプロジェクトも結果は同じでした。

端末 FPS
iPad Pro 11インチ(第3世代 M1チップ) 60
iPhone12 Pro 30

M1チップ搭載のiPadを触るきっかけが出来たので、動かしてみたところなんと60FPS出てました。

iPhone12 Proで30出てるのも十分すごいですが、M1チップは流石と言わざるをえないですね。

まとめ

自分で作業した所感としては、まだまだ動かすだけでも大変だなという印象を受けました。

実用的に使えるかはプロジェクトによると思います。

それでもUnityでAR x AIを行うのはARFoundationやBarracudaやら出てきて段々手軽にはなってきているとは思います。

これからの進歩にも期待ですね。



ギャップロを運営しているアップフロンティア株式会社では、一緒に働いてくれる仲間を随時、募集しています。 興味がある!一緒に働いてみたい!という方は下記よりご応募お待ちしております。
採用情報をみる