Unity Sentis を使ってMoveNetを動かしてみる

# はじめに

Unity Sentis を用いることで、ゲームやアプリケーションに AI モデルを統合できるようになります。
Unityさんは推しているようではありますが、新しいライブラリであることもあり、情報量は少ないです。

https://unity.com/ja/products/sentis

2024年5月30日現在、Qiitaで Unity Sentis と検索しても２件しかヒットしません。
以前は Unity Barracuda を使用することで実現できましたが、Unity6以降は Unity Sentis が主流になるようです。そのため、これから使われていくことでしょう。その際の参考となれば幸いです。

今回は、Google が公開している機械学習モデルである、MoveNet を Unity Sentis を用いて動かしてみようと思います。

:::note warn

このページの内容はオープンベータ版の Sentis 1.4.0-pre.3 でのものです。
そのため、最新版・正式版での実装方法とは異なる可能性があります。

:::

# 開発環境

Unity : 2023.2.18f1
Sentis : Sentis 1.4.0-pre.3

# GitHubレポジトリ

今回の内容は GitHub で公開しています。

https://github.com/Yupopyoi/MoveNet-UnitySentis

# MoveNetとは

https://www.tensorflow.org/hub/tutorials/movenet?hl=ja

を見てください。モデルは

https://www.kaggle.com/models/google/movenet

で入手できます。

# 1. Sentis のインストール方法

公式を参考してインストールしてください。

https://docs.unity3d.com/Packages/com.unity.sentis@1.4/manual/install.html

<details><summary>インストール方法（上記サイトより引用）</summary>

>>To add the Sentis package to a Unity project:
>>1. Create a new Unity project or open an existing one.
>>2. To open the Package Manager, navigate to Window > Package Manager.
>>3. Click + and select Add package by name....
>>4. Enter ```com.unity.sentis```.
>>5. Click Add to add the package to your project.

(1) 新しくUnityプロジェクトを作成するか、すでに制作したプロジェクトを開きます。
(2) Package Manager を開きます。
　  画面上部の Window タブから Package Manager を選択します。
(3) + ボタン（ウインドウ左上）を押し、Add package by name を選択します。
(4) ```com.unity.sentis``` と入力します。
(5) "Add" をクリックして完了です。

</details>

# 2. プログラム解説

# 2.1. Detector.cs

実際に機械学習モデルに画像データをインプットして、推測結果を取得します。
Unity.Sentis を用いた機械学習モデル実行プログラムの要です。

:::note 
最低限、ここだけ見ればOKです。
:::

<details><summary>全文</summary>

```cs:Detector.cs

using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using UnityEngine;
using Unity.Sentis;

namespace MoveNet
{
    //Execute inferences using machine learning model
    public class Detector : IDisposable
    {
        public const int CLASSIFICATION_NUMBER = 17;

        public Detector(ResourceSet resources)
          => AllocateObjects(resources);

        public void Run(Texture source, float Threshold = 0.5f)
        {
            // Not used in this sample
            if (Threshold < 0.0f) _scoreThreshold = 0.0f;
            else if (Threshold > 1.0f) _scoreThreshold = 1.0f;
            else _scoreThreshold = Threshold;

            RunModel(source);
        }

        public IEnumerable<Detection> Detections()
        {
            /*
            Return Detections Result (IEnumerable<Detection>)

            Length of the IEnumerable = 17 (Classification)

            return [struct Detection (position_x, position_y, score)] * 17

            Data structure of <Detection> : See Detection.cs
            */
            return _detectionCache.Cached(new ReadOnlyCollection<float>(_detections), _scoreThreshold);
        }

        public float Threshold => _scoreThreshold;

        public void Dispose()
        {
            _worker?.Dispose();
            _worker = null;

            _buffers.preprocess?.Dispose();
            _buffers.preprocess = null;
        }

        // -------------------------------------------------------------------------------------

        ResourceSet _resources;
        Config _config;
        IWorker _worker;

        (GraphicsBuffer preprocess,
         RenderTexture processedImage) _buffers;

        DetectionCache _detectionCache;
        static float _scoreThreshold;
        static float[] _detections;

        void AllocateObjects(ResourceSet resources)
        {
            // NN model loading
            var nnmodel = ModelLoader.Load(resources.model);

            // Edit a Model
            var editedModel = Functional.Compile(
                RGB =>
                {
                    var sRGB = Functional.Pow(RGB, Functional.Tensor(1 / 2.2f));

                    // Transform values from the range [0, 1] to the range [0, 255].
                    var RGB_255 = Functional.Mul(sRGB, Functional.Tensor(255.0f));

                    return nnmodel.ForwardWithCopy(RGB_255)[0];
                },
                nnmodel.inputs[0]);

            // Private object initialization
            _resources = resources;
            _config = new Config(editedModel);
            _worker = WorkerFactory.CreateWorker(BackendType.GPUCompute, editedModel);

            _buffers.preprocess = new GraphicsBuffer(
                GraphicsBuffer.Target.Structured, _config.InputFootprint, sizeof(float));

            _buffers.processedImage = new RenderTexture(_config.InputWidth, _config.InputWidth, 3);
            _buffers.processedImage.enableRandomWrite = true;
            _buffers.processedImage.Create();

            _detectionCache = new DetectionCache(CLASSIFICATION_NUMBER);
        }

        void RunModel(Texture source)
        {
            // Preprocessing (Sampling)
            var pre = _resources.preprocess;
            pre.SetInt("Size", _config.InputWidth);
            pre.SetTexture(0, "Image", source);
            pre.SetBuffer(0, "Tensor", _buffers.preprocess); 
            pre.SetTexture(0, "processedImage", _buffers.processedImage);
            pre.Dispatch(0, _config.InputWidth / 8, _config.InputWidth / 8, 1);

            float[] ft = new float[_config.InputFootprint];
            _buffers.preprocess.GetData(ft);

            // NN worker invocation
            TensorShape newShape = new TensorShape(_config.InputWidth, _config.InputWidth, 3);
            using (var tensor = new TensorFloat(newShape, ft))
            {
                tensor.Reshape(newShape);

                _worker.Execute(tensor);
            }

            var output = _worker.PeekOutput(_config.OutputName) as TensorFloat;
            output.CompleteOperationsAndDownload();
            _detections = output.ToReadOnlyArray();

            output.Dispose();
            _detectionCache.Invalidate();
        }
    }
}//namespace MoveNet


```

</details>

<details><summary>コンストラクタ</summary>

ResourceSet (機械学習モデルとコンピュートシェーダーを保持) を受け取り、機械学習実行の準備を行います。
ResourceSet については、[ResourceSet.cs](https://github.com/Yupopyoi/MoveNet-UnitySentis/blob/main/Assets/Scripts/MoveNet/ResourceSet/ResourceSet.cs) を参照してください。

```cs:Detector.cs(抜粋)
void AllocateObjects(ResourceSet resources)
        {
            // NN model loading
            var nnmodel = ModelLoader.Load(resources.model);

            // Edit a Model
            var editedModel = Functional.Compile(
                RGB =>
                {
                    var sRGB = Functional.Pow(RGB, Functional.Tensor(1 / 2.2f));

                    // Transform values from the range [0, 1] to the range [0, 255].
                    var RGB_255 = Functional.Mul(sRGB, Functional.Tensor(255.0f));

                    return nnmodel.ForwardWithCopy(RGB_255)[0];
                },
                nnmodel.inputs[0]);

            // Private object initialization
            _resources = resources;
            _config = new Config(editedModel);
            _worker = WorkerFactory.CreateWorker(BackendType.GPUCompute, editedModel);

            _buffers.preprocess = new GraphicsBuffer(
                GraphicsBuffer.Target.Structured, _config.InputFootprint, sizeof(float));

            _buffers.processedImage = new RenderTexture(_config.InputWidth, _config.InputWidth, 3);
            _buffers.processedImage.enableRandomWrite = true;
            _buffers.processedImage.Create();

            _detectionCache = new DetectionCache(CLASSIFICATION_NUMBER);
        }
```

<b>機械学習モデルのロード</b>

```cs:
var nnmodel = ModelLoader.Load(resources.model);
```

[ModelLoader](https://docs.unity3d.com/Packages/com.unity.sentis@1.4/api/Unity.Sentis.ModelLoader.html) クラスの Load(ModelAsset) を用いてモデルのロードを行います。

<br>
<b>機械学習モデルの編集</b>

機械学習モデルの編集を行います。この際、[Functional](https://docs.unity3d.com/Packages/com.unity.sentis@1.4/api/Unity.Sentis.Functional.html) クラスと[FunctionalExtensions](https://docs.unity3d.com/Packages/com.unity.sentis@1.4/api/Unity.Sentis.FunctionalExtensions.html) クラスを使用します。
Unity 上での画像データにおける RGB の値が 0 から 1 なのに対し、[MoveNet](https://storage.googleapis.com/movenet/MoveNet.SinglePose%20Model%20Card.pdf)  ではインプット画像の RGB の値は 0 から 255 となっています。
なので、MoveNet の一番最初に、入力された RGB の値を255倍する層を追加します。

どうやら、画像データを変換するのではなく、機械学習モデル自体に変更を加えるみたいです。機械学習モデルはそのままにして、画像データの RGB 値を 255倍 すればいいのではとは思いましたが、そうではないようです。

なお、機械学習モデルにおけるインプット画像の RGB の値が 0 から 1 の場合は、この部分は不要になります。 

ForwardWithCopy()とは別にForward()という関数もありますが、Forward()はモデルを破壊的に編集すると書かれています。ForwardWithCopy()では、モデルをコピーして新しく作り直します。今回はあくまで試用なので、計算速度について深く考えはせずForwardWithCopy()を使用しました。

```cs:
var editedModel = Functional.Compile(
                RGB =>
                {
                    var sRGB = Functional.Pow(RGB, Functional.Tensor(1 / 2.2f));

                    // Transform values from the range [0, 1] to the range [0, 255].
                    var RGB_255 = Functional.Mul(sRGB, Functional.Tensor(255.0f));

                    return nnmodel.ForwardWithCopy(RGB_255)[0];
                },
                nnmodel.inputs[0]);
```

<b>バッファの初期化</b>

コンピュートシェーダーを使用するための準備を行います。

なお、processedImage は必須ではありません。機械学習モデルに入力される画像の確認のために作成しました。コンピュートシェーダーで RWTexture を使う際には enableRandomWrite = true と設定します。

```cs:
_buffers.preprocess = new GraphicsBuffer(
                GraphicsBuffer.Target.Structured, _config.InputFootprint, sizeof(float));
```

```cs:
_buffers.processedImage = new RenderTexture(_config.InputWidth, _config.InputWidth, 3);
_buffers.processedImage.enableRandomWrite = true;
_buffers.processedImage.Create();
```
</details>

<details><summary>実行部 ( RunModel() )</summary>

<b>画像サンプリング</b>

画像から各ピクセルのRGBデータを取得する部分においては [コンピュートシェーダー](https://docs.unity3d.com/ja/2023.2/Manual/class-ComputeShader.html) を用います。
ここでは、コンピュートシェーダーに必要な値を渡し、サンプリングを実行します。

 _config.InputWidth は、機械学習モデルに入力する画像の横（縦）のピクセル数です。
 なお、コンピュートシェーダー(Preprocess.compute) では [numthreads(8, 8, 1)] と指定しているので、各軸におけるスレッドグループの数としては、_config.InputWidth / 8 と 8 で割った数となります。

```cs:Detector.cs(抜粋)
var pre = _resources.preprocess;

pre.SetInt("Size", _config.InputWidth);
pre.SetTexture(0, "Image", source);
pre.SetBuffer(0, "Tensor", _buffers.preprocess); 
pre.SetTexture(0, "processedImage", _buffers.processedImage);
pre.Dispatch(0, _config.InputWidth / 8, _config.InputWidth / 8, 1);
```

<br>

Dispatch 後は GetData() でサンプリング結果の取得を行います。
_config.InputFootprint は、機械学習モデルに入力する画像の横の長さ×縦の長さ×３チャンネル（RGB）です。この辺はもう少し良い書き方があるかもしれません。

```cs:Detector.cs(抜粋)

float[] ft = new float[_config.InputFootprint];
_buffers.preprocess.GetData(ft);

```

先ほど取得したサンプリング結果を、機械学習モデルの入力に合うよう Reshape し、実行します。この際には、[IWorker](https://docs.unity3d.com/Packages/com.unity.sentis@1.4/api/Unity.Sentis.IWorker.html) を使用します。
その後、float型配列である _detections にデータをコピーします。

```cs:Detector.cs(抜粋)
// NN worker invocation
TensorShape newShape = new TensorShape(_config.InputWidth, _config.InputWidth, 3);
using (var tensor = new TensorFloat(newShape, ft))
{
    tensor.Reshape(newShape);

    _worker.Execute(tensor);
}

var output = _worker.PeekOutput(_config.OutputName) as TensorFloat;
output.CompleteOperationsAndDownload();
_detections = output.ToReadOnlyArray();

output.Dispose();
_detectionCache.Invalidate();

```

</details>

<details><summary>結果取得用関数 ( Detections() ) </summary>

他のクラスへ推論結果を渡すための public 関数です。
[DetectionCache](https://github.com/Yupopyoi/MoveNet-UnitySentis/blob/main/Assets/Scripts/MoveNet/Detector/DetectionCache.cs) を用いて推論結果を加工し、各キーポイントごとの推論結果を返します。各キーポイントにおける推論結果は [Detection](https://github.com/Yupopyoi/MoveNet-UnitySentis/blob/main/Assets/Scripts/MoveNet/Detector/Detection.cs) 構造体として返されます。 Detection 構造体は x座標、y座標、スコア、ラベル番号、ラベル名を保持しています。 

```cs:Detector.cs(抜粋)
 public IEnumerable<Detection> Detections()
{
    return _detectionCache.Cached(new ReadOnlyCollection<float>(_detections), _scoreThreshold);
}

```

</details>

## 2.2. Preprocess.compute 

色空間の変更を行い、各ピクセルのRGB値を RWStructuredBuffer に詰め込みます。
色空間については次のサイトを参考にさせていただきました。

https://light11.hatenadiary.com/entry/2020/02/25/204432

<details><summary>全文</summary>

```hlsl:
#pragma kernel Preprocess

sampler2D Image;
RWStructuredBuffer<float> Tensor;
RWTexture2D<float3> processedImage;
uint Size;

#define FLT_EPSILON 1.192092896e-07

float3 PositivePow(float3 base, float3 power)
{
    return pow(max(abs(base), float3(FLT_EPSILON, FLT_EPSILON, FLT_EPSILON)), power);
}

float3 LinearToSRGB(float3 color)
{
    float3 sRGBLo = color * 12.92;
    float3 sRGBHi = (PositivePow(color, float3(1.0 / 2.4, 1.0 / 2.4, 1.0 / 2.4)) * 1.055) - 0.055;
    float3 sRGB = (color <= 0.0031308) ? sRGBLo : sRGBHi;
    return sRGB;
}

[numthreads(8, 8, 1)]
void Preprocess(uint2 id : SV_DispatchThreadID)
{
    // UV
    float2 uv = float2(0.5 + id.x, 0.5 + id.y) / Size;

    // UV gradients
    float2 duv_dx = float2(1.0 / Size, 0);
    float2 duv_dy = float2(0, -1.0 / Size);

    // Texture sample
    float3 rgb = tex2D(Image, uv, duv_dx, duv_dy).rgb;
    rgb = LinearToSRGB(rgb);
    
    // Tensor element output
    uint offs = (id.y * Size + id.x) * 3;
    Tensor[offs + 0] = rgb.r;
    Tensor[offs + 1] = rgb.g;
    Tensor[offs + 2] = rgb.b;
    
    processedImage[id] = float3(rgb.r, rgb.g, rgb.b);
}

```

</details>

## 2.3. DetectionExecutor.cs

MonoBehaviourを継承しているクラスで、推論実行のエントリーポイントになります。
画面上の Start ボタンをクリックすることで、public void Run() が呼ばれ、推論が実行されます。

また、public IEnumerable<Detection> LatestDetection() を呼ぶことで、最新の推論結果を取得できます。（ビデオでも推論を可能に）

<details><summary>全文</summary>

```cs:DetectionExecutor.cs
using MoveNet;
using System.Collections.Generic;
using UnityEngine;

//Execute inference & Intermediary for delivery of result
public class DetectionExecutor : MonoBehaviour
{
    Detector _detector;
    [SerializeField] ResourceSet _resources = null;
    [SerializeField] Video.VideoGrapher _grapher = null;

    int _imageWidth;
    int _imageHeight;

    public int ImageWidth => _imageWidth;
    public int ImageHeight => _imageHeight;

    void Start()
    {
        _detector = new Detector(_resources);
    }

    public void Run()
    {
        _imageWidth = _grapher.ImageWidth;
        _imageHeight = _grapher.ImageHeight;
        _detector.Run(_grapher.CameraTexture);
    }

    public IEnumerable<Detection> LatestDetection() => _detector.Detections();

    void OnDisable() => _detector.Dispose();
}
```
</details>

## 2.4. MarkerVisualizer.cs

これもMonoBehaviourを継承しているクラスで、マーカー表示のエントリーポイントになります。各キーポイントの推論結果を、各マーカーに振り分けて伝達する役割を担っています。
SetAttributes() は Marker クラスで定義されています。

<details><summary>一部抜粋</summary>

```cs:MarkerVisualizer.cs
public void DisplayMarkers()
{
    var latestDetections = executor.LatestDetection();

    var i = 0;
    foreach (var detection in latestDetections)
    {
        if (i == _markers.Length) break;

        Debug.Log(detection.ToString());
        _markers[i++].SetAttributes(detection, executor.ImageWidth, executor.ImageHeight, _rawImageWidth);
    }

    for (; i < _markers.Length; i++) _markers[i].Hide();
}

```
</details>

## 2.5. Marker.cs

マーカーを入力画像（の表示）上に配置します。

<details><summary>全文</summary>

```cs:Marker.cs

using Unity.VisualScripting;
using UnityEngine;
using UnityEngine.UI;
using TMPro;
using UnityEngine.XR;

namespace MoveNet
{
    sealed class Marker : MonoBehaviour
    {
        RectTransform _xform;
        Image _panel;
        TextMeshProUGUI _label;

        public void Setup()
        {
            _xform = GetComponent<RectTransform>();
            _panel = GetComponentInChildren<Image>();
            _label = GetComponentInChildren<TextMeshProUGUI>();
        }

        public void SetAttributes(in Detection d, int w, int h, float rawImageWidth)
        {
            // Local Position
            // The range of values for d.x and d.y is [0.0, 1.0]
            // Match this to the size of the RawImage.
            var x = (d.x * 2 - 1) * rawImageWidth * 0.5f;
            var y = (d.y * 2 - 1) * rawImageWidth * 0.5f;

            _xform.localPosition = new Vector2(x, y);

            // Label
            _label.text = $"{d.labelName}";

            // Panel color
            var hue = d.labelIndex * 0.073f % 1.0f;
            var color = Color.HSVToRGB(hue, 1, 1);
            color.a = 0.4f;
            _panel.color = color;

            // Enable
            gameObject.SetActive(true);
        }

        public void Hide() => gameObject.SetActive(false);
    }
}
```

</details>

# 結果と感想

インスペクターから適当に画像を入力すると、いい感じにマーカーが配置されていることが確認できました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3607016/805479af-8a9d-0225-dc6d-72b559224aa1.png)

Right と Left がこれでいいのかは疑問です。L と出ている方は実際には右側（右腕、右足、etc.）です。MoveNet の仕様なのでしょうか。

ひとまず動いたので今回は良しとします。
それにしても、情報がまだ少ないので、実装するのは結構骨が折れる作業です。まだよく分かっていないところも多いので、もう少し公式のサンプルが増えると良いのですが...

より良い書き方・最新版での変更点等ありましたら、気軽にコメントを残していってください。