見出し画像

[Unity]MediaPipeUnityPluginを使ってAndroid上でハンドトラッキングとAR機能を使う

はじめに

現状、ARCoreではハンドトラッキングに対応していないため、Android上のUnityアプリでハンドトラッキングの実装はARFoudation以外の方法を検討する必要があります。
今回はAndroid上でハンドトラッキングを行う手段の一つとしてMediaPipeUnityPluginを使ってみました。

※自分でも説明不足な感じがする記事ですが、なんかWindowsとAndroidでもMediaPipeUnityPlugin使えるんだなぐらいの感じで見てください

MediaPipeUnityPluginとは

MediaPipeUnityPluginはC++のMediapipeをネイティブプラグインとしてUnity上で使えるようにしたものです。公式のものではありませんが、チュートリアルやインストール方法などが詳細に書かれています。

MediaPipe

実行環境

OS:Windows 10
Androidバージョン:12
Android:Galaxy S10+
Unityバージョン:2021.3.0f1

インストール

githubのWikiのページに手順があるので、その通りに行います。
ページにも書いてありますが、WindowsではAndroid用のものはビルドできません。そのため、Dockerを使ってLinux環境で実行することになります。代わりにWindows上では動きません。

今回はAndroid上で使用するため、「Docker Linux Container」の方法でビルドしました。
特に問題なくインストールできましたが、かなり時間と容量が食われます。

サンプルシーンの実行

Unityのバージョンは2021.3.3f1を想定されていますので注意してください。
※Android用のビルドをしたので、このプラグインを含むスクリプトの実行はWindowsのエディター上ではできません。動作確認は全てAndroid上で実行することになります。

まずは動作確認としてサンプルシーンの実行を行います。

UnityでMediaPipeUnityPluginを開きます。(gitでクローンしてきたフォルダ)

そのままだとキーストアがどうのこうのでビルドが失敗すると思いますので、ビルド設定→プレイヤー設定→公開設定でキーを作成するようにしてください。

ビルドして実行を選択し、Android上で実行させます。

デフォルトではFaceDetectionが動きますので、適当に顔を映して確かめてみてください。

ハンドトラッキングを行えるようにする

こちらを参考にしてプロジェクトへのインポートまで行ってください。(自分はビルド後のフォルダーでそのまま作業しちゃいました・・・。)

今回はチュートリアルを参考にしてハンドトラッキングを行えるようにしました。

Androidのカメラ画像を拾う

AR機能を使用中はチュートリアルのようにWebCamTextureを使用してカメラ画像を取得できませんでした。なのでARFoundationの機能からカメラ画像を取得するようにしました。

コード(抜粋)

  // カメラ画像確認用の画像  
  [SerializeField] RawImage screen;
  [SerializeField] ARCameraManager cameraManager;
  public Texture2D texture;
  XRCpuImage.ConversionParams conversionParams;
  int size;
  bool isSetConversionParams;

  void Start()
  {
    texture = new Texture2D(640, 480, TextureFormat.RGBA32, false);
    screen.texture = texture;
    arCameraPos = arCamera.transform;

    cameraManager.frameReceived += RefreshCameraFeedTexture;
  }


  void GetConversionParams(XRCpuImage image)
  {
    if (isSetConversionParams)
    {
      return;
    }
    conversionParams = new XRCpuImage.ConversionParams
    {
      // サンプルそのままで使用しました。用途に合わせて変更してください
      inputRect = new RectInt(0, 0, image.width, image.height),
      outputDimensions = new Vector2Int(image.width / 2, image.height / 2),
      outputFormat = TextureFormat.RGBA32,
      transformation = XRCpuImage.Transformation.MirrorY
    };
    size = image.GetConvertedDataSize(conversionParams);
    isSetConversionParams = true;
  }

  unsafe void RefreshCameraFeedTexture(ARCameraFrameEventArgs obj)
  {
    bool getImage = cameraManager.TryAcquireLatestCpuImage(out XRCpuImage image);
    if (!getImage)
    {
      return;
    }
    GetConversionParams(image);

    var buffer = new NativeArray<byte>(size, Allocator.Temp);
    image.Convert(conversionParams, new IntPtr(buffer.GetUnsafePtr()), buffer.Length);
    image.Dispose();

    texture = new Texture2D(
      conversionParams.outputDimensions.x,
      conversionParams.outputDimensions.y,
      conversionParams.outputFormat,
      false);
    screen.texture = texture;
    texture.LoadRawTextureData(buffer);

    texture.Apply();
    buffer.Dispose();
  }

UnityEngine.XR.ARFoundationとUnityEngine.XR.ARSubsystemsの名前空間の指定が必要です。
※UnityEngine.XR.ARFoundationが見つからないエラーが出ましたがこちらの情報からmonoをインストールすると直りました

RawImageはキャンバス上に配置するUIのオブジェクトになります。

アンセーフな関数を実行するため、プレイヤー設定から「アンセーフコードを許可」にチェックをいれておいてください。

この関数を実行すると、カメラ画像が時計回りに90度回転した状態でRawImageに出てきます。画像サイズは縦方向に320、横方向に240ピクセルです(ConversionParamsの設定にもよります。もとは640×480みたいです)。ちなみにWebCamTextureを使用して取得できる画像も90度回転しています。

RawImageとAR機能で映っている動画の範囲を確認すると、WebCamTextureによって4:3の画像を取得し、スマホのサイズに合うようトリミングして表示しているようです。

画像の一部はスマホから見えない

後々調整が必要な場面が出てきます。

Textureからハンドトラッキングを実行するコードを書く

今回出力として求めるものはランドマークのみです。
チュートリアルのコードを軸にハンドトラッキングように変えたものです。

コード

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using Google.Protobuf;
using System.Linq;

using Stopwatch = System.Diagnostics.Stopwatch;

namespace Mediapipe.Unity.Tutorial
{
  public class HandTracking : MonoBehaviour
  {
    // _configAssetに使用するモデルを入力する。
    // サンプルの各シーンの中にあるファイルから選択する。
    [SerializeField] private TextAsset _configAsset;

    // 中指、薬指、親指の検出場所を示す画像用
    [SerializeField] RectTransform middleFinger;
    [SerializeField] RectTransform thirdFinger;
    [SerializeField] RectTransform Thumb;

    // Textureを取得したクラス
    [SerializeField] GameManager gameManager;

    [SerializeField] private int _width;
    [SerializeField] private int _height;
    private CalculatorGraph _graph;
    private WebCamTexture _webCamTexture;
    private Texture2D _inputTexture;
    [SerializeField] float Xratio = 1.065f;
    OutputStream<NormalizedLandmarkListVectorPacket, List<NormalizedLandmarkList>> multiHandLandmarksStream;
    // private ResourceManager _resourceManager;

    IEnumerator GPUInitialize()
    {
      Debug.Log("HandTrackingStep:" + "GPUInitialize");

      yield return GpuManager.Initialize();

      if (!GpuManager.IsInitialized)
      {
        throw new System.Exception("Failed to initialize GPU resources");
      }
    }

    void SetGraph()
    {
      Debug.Log("HandTrackingStep:" + "SetGraph");
      var config = CalculatorGraphConfig.Parser.ParseFromTextFormat(_configAsset.text);

      using (var validatedGraphConfig = new ValidatedGraphConfig())
      {
        validatedGraphConfig.Initialize(config).AssertOk();

        var extensionRegistry = new ExtensionRegistry() { TensorsToDetectionsCalculatorOptions.Extensions.Ext };
        var canonicalizedConfig = validatedGraphConfig.Config(extensionRegistry);

        var tensorsToDetectionsCalculators = canonicalizedConfig.Node.Where((node) => node.Calculator == "TensorsToDetectionsCalculator");

        foreach (var calculator in tensorsToDetectionsCalculators)
        {
          var options = calculator.Options.GetExtension(TensorsToDetectionsCalculatorOptions.Extensions.Ext);
          options.MinScoreThresh = 0.1f; // modify `MinScoreThresh` at runtime
        }
      }
      _graph = new CalculatorGraph(_configAsset.text);
    }

    void SetGpuResources()
    {
      Debug.Log("HandTrackingStep:" + "SetGpuResources");
      _graph.SetGpuResources(GpuManager.GpuResources).AssertOk();
    }

    void SetOutputStream()
    {
      Debug.Log("HandTrackingStep:" + "SetOutputStream");
      multiHandLandmarksStream = new OutputStream<NormalizedLandmarkListVectorPacket, List<NormalizedLandmarkList>>(_graph, "hand_landmarks");
      multiHandLandmarksStream.StartPolling().AssertOk();
    }

    void SetSidePackets()
    {
      Debug.Log("HandTrackingStep:" + "SetSidePackets");
      var sidePacket = new SidePacket();
      sidePacket.Emplace("input_rotation", new IntPacket(0));
      sidePacket.Emplace("input_horizontally_flipped", new BoolPacket(false));
      sidePacket.Emplace("input_vertically_flipped", new BoolPacket(false));
      sidePacket.Emplace("num_hands", new IntPacket(1));
      sidePacket.Emplace("model_complexity", new IntPacket(1));
      _graph.StartRun(sidePacket).AssertOk();
    }

    List<Vector3> GetLandMarks(NormalizedLandmarkList landmarks, List<int> takeList)
    {
      var landmarkList = new List<Vector3>();
      foreach (var takeIndex in takeList)  
      {
        var normalizedLandmark = landmarks.Landmark[takeIndex];
        // landmark調整用。ちょっとずれたりしたので適当な値を掛けています
        Vector3 landmark = new Vector3(((-normalizedLandmark.Y + 0.5f) / 0.6f) * GlobalConst.phoneWidth + GlobalConst.phoneWidth / 2, normalizedLandmark.X * GlobalConst.phoneHeight * Xratio, 0);
        landmarkList.Add(landmark);
      }
      return landmarkList;
    }

    private IEnumerator Start()
    {
      var resourceManager = new StreamingAssetsResourceManager();
      // 必要に応じてファイルを選択する
      // yield return resourceManager.PrepareAssetAsync("");
      var stopwatch = new Stopwatch();

      yield return GPUInitialize();
      SetGraph();
      SetGpuResources();
      SetOutputStream();
      SetSidePackets();
      stopwatch.Start();

      while (true)
      {
        yield return new WaitForSeconds(0.2f);
        _inputTexture = gameManager.texture;

        _height = _inputTexture.height;
        _width = _inputTexture.width;

        var imageFrame = new ImageFrame(ImageFormat.Types.Format.Srgba, _width, _height, _width * 4, _inputTexture.GetRawTextureData<byte>());
        var currentTimestamp = stopwatch.ElapsedTicks / (System.TimeSpan.TicksPerMillisecond / 1000);
        _graph.AddPacketToInputStream("input_video", new ImageFramePacket(imageFrame, new Timestamp(currentTimestamp))).AssertOk();

        yield return new WaitForEndOfFrame();

        if (multiHandLandmarksStream.TryGetNext(out var multiHandLandmarks))
        {
          if (multiHandLandmarks != null && multiHandLandmarks.Count > 0)
          {
            foreach (var normalizedLandmarks in multiHandLandmarks)
            {
              // 12、16、4は中指、薬指、親指の先端と対応しています。
              var landmarks = GetLandMarks(normalizedLandmarks, new List<int> {12, 16, 4});
              middleFinger.position = landmarks[0];
              thirdFinger.position = landmarks[1];
              Thumb.position = landmarks[2];
            }
          }
        }
      }
    }

    private void OnDestroy()
    {
      GpuManager.Shutdown();
      if (_webCamTexture != null)
      {
        _webCamTexture.Stop();
      }

      if (_graph != null)
      {
        try
        {
          _graph.CloseInputStream("input_video").AssertOk();
          _graph.WaitUntilDone().AssertOk();
        }
        finally
        {
          _graph.Dispose();
        }
      }
    }
  }
}

Debug.Logで頻繁にステップを表示していますが、Editor上でテストができないため、「Android Logcat」を使用してどこで失敗したかを分かりやすくしたかったためです。

1秒間に5回の実行にしています。

以下、ハンドトラッキング実装によってチュートリアルのフェイスメッシュと変えなければならなかった点です。

  • _configAssetには「hand_tracking_opengles.txt」を少し修正したものを使用しました(あとで説明します)

  • SetOutputStream関数内でストリーㇺの名前はhand_landmarksにしています。型はOutputStream<NormalizedLandmarkListVectorPacket, List<NormalizedLandmarkList>>です。

  • SetSidePackets関数内ではサイドパケットに5つの値を使用しています。これらは_configAssetに設定したファイルから必要なものを読み取る必要がありました。(あとで説明します)

  • Start関数直後の resourceManager.PrepareAssetAsync("");
    はハンドトラッキングにおいては必要なさそうだったのでコメントアウト

  • 当たり前ですが、ランドマークの何番目の要素がどの部位の情報にあたるかは変わるため、中指、薬指、親指を指定するようにしました。

以上は別のモデルを使用する場合に変更が必要になる点でもあります。

NormalizedLandmarkはランドマークの位置を画像に対して、0~1の間でどの位置にあるかを指します。スマホの画素数を参考にして適切な値を掛ける必要があります。注意すべき点として、スマホの背景として表示されているカメラ画像はInputTextureを切り取った後の画像になります。ランドマークのY方向が0~0.2と0.8~1ぐらいの間はスマホの画面外になるが、InputTextureには映っている状態になるということです。いい感じ調整してあげてください。

hand_tracking_opengles.txtを修正する

修正内容ですが、主に入力にImageFrameを使うはずがGPUBufferが使われている(あるいはその逆)でエラーが発生します。
これには

node: {
  calculator: "GpuBufferToImageFrameCalculator"
  input_stream: "input_video_gpu"
  output_stream: "input_video_cpu"
}

node: {
  calculator: "ImageFrameToGpuBufferCalculator"
  input_stream: "input_video_cpu"
  output_stream: "input_video_gpu"
}

といったようにGPUBufferからImageFrameに変換する計算機を追加する必要があります。

実際のファイルには
FlowLimiterCalculatorの上にGpuBufferToImageFrameCalculator、
FlowLimiterCalculatorの下にImageFrameToGpuBufferCalculator
が来るように追加します。また、input_streamの名前とoutput_streamの名前は適宜変更してください。

また、サイドパケットについてですが、input_side_packetと書かれている列に変数名が書かれていますのでそれをサイドパケットに含めるようにします。

例:input_side_packet: "num_hands" → num_handsを含める

outputstreamの名前や型はoutput_streamと書かれている列から推測しました。(std::vector<NormalizedLandmarkList>)と書いていれば同じ型で大丈夫だと思います。

実行画像

フリー画像を使用してみましたが、指の位置を取れていることが分かります。若干左にずれているので、もう少し調整すると良さそうです。

まとめ

プラグインによってハンドトラッキングがかなり簡単に実装できました。
丁寧なチュートリアルがあったので難しいながらも徐々に理解できました。
まだまだ分からない部分もありますが勉強したいと思います。
プラグインをつくってくださった方々、ありがとうございます。


この記事が気に入ったらサポートをしてみませんか?