UnityとAIで実現する！MoveNet + Sentisでモーショントラッキング その1

# はじめに
**モーショントラッキング**というのをご存じでしょうか？ゲーム業界ではしばしばリアルさを追求するために利用される技術です。
そんな多岐にわたる分野でリアルな動きをデジタル空間に反映させるために活用されている技術ですが、「もし、もっと手軽にモーショントラッキングができたら、個人でももっと面白いものが作れるんじゃないか？」と思いました。高価な機材や専門的な知識なしに、誰でも気軽に自分の動きをデジタル空間に反映させられるような仕組みがあれば、ゲームだけでなく、フィットネスアプリやインタラクティブアート、果ては医療にまで生かせるのではと、さまざまな分野で新しい表現が生まれるはずだと考えていました。
そんな中、画像処理を大学で学ぶうちにこれを使えば高価な機材なしに誰でもモーショントラッキングを利用できるのではと感じました。そして、目に留まったのがGoogleの開発した**MoveNet**でした。このAIモデルは、Webカメラの映像から人物の姿勢をリアルタイムで高精度に推定できるというものです。しかも、軽量で動作が速い。これなら「手軽に」という私の理想に近づけるのではないかと直感しました。
しかし、MoveNetはそのままではUnityでは使えません。そこで次に注目したのが、Unityが提供する**Sentis**というパッケージです。これは、AIモデルをUnityプロジェクト内で直接実行できるという画期的なものでした。MoveNetで姿勢推定を行い、SentisでUnityに取り込む。この組み合わせが、まさに私の求めていた「手軽なモーショントラッキング」を実現できるのではと感じました。

:::note warn
Sentisは2.0で**破壊的変更**を含んでいます。Qiitaなどの既存のコードはほとんど動きません。GeminiやChatGPTなどもほぼ役に立ちません。また、Sentis2.1.3では**公式のサンプルが警告を量産**します。私が浅いのかもしれませんが公式ドキュメント通りに書いてエラー出ることもありました。
UnityCommunityで質問して何とかなった部分もありましたが、Sensitを使うときは十分に覚悟して使いましょう。
:::

# 開発環境
* ソフト等
    * Unity6.0(6000.0.49f1)
    * Sentis 2.1.3
    * MoveNet 4
    * Python 3.11.3
    * pip 25.1.1
* ハード
    * Intel🄬 Core™i5-13400
    * NVIDIA GeForce RTX3060
    * Memory 16GBx2(32GB)
    * GooglePixel 6a(今回はWebカメラとして利用)

# MoveNetとは
**MoveNet**は、Googleが開発したオープンソースの姿勢推定モデルです。簡単に言えば、カメラに映った人物の動きを認識し、その体の各関節（腕、足、首など）の位置、つまり「キーポイント」を検出してくれるAIです。
MoveNetの最大の特徴は、その軽量性と高精度を両立している点にあります。一般的なWebカメラの映像からでも、リアルタイムに滑らかに人物の姿勢を追跡できるため、ゲームやアプリケーションへの組み込みが非常にしやすい設計になっています。

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

# Sentisとは
**Sentis**は、Unityが提供するオンデバイスAI推論ライブラリで、一言でいえば「Unityの中でAIモデルを動かすためのツール」です。通常、AIモデルをアプリケーションで利用するには、専用のフレームワークを導入したり、外部のAPIサービスと連携したりといった手間がかかります。
Sentisを使えば、AIモデルをまるでUnityのアセットのようにプロジェクトにインポートし、C#スクリプトから直接推論を実行できます。

# 利用したMoveNetモデル
MoveNetにはいくつかのモデルバリエーションが存在します。今回は、その中でも代表的な「**SinglePose**」モデルを使用します。これは、画像内に単一の人物が写っている場合に特化したモデルで、シンプルかつ効率的に姿勢推定を行うことができます。モーショントラッキングにピッタリですね。

**MoveNet SinglePose Lightning(V4)**

https://tfhub.dev/google/movenet/singlepose/lightning/4

特徴: 高速な推論に特化しており、比較的低い精度で動作します。Webカメラからのリアルタイム処理に非常に適しています。

**MoevNet SinglePose Thunder(V4)**

https://tfhub.dev/google/movenet/singlepose/thunder/4

特徴: Lightningよりも高い精度を提供しますが、その分推論速度は若干遅くなります。より正確なキーポイント検出が必要な場合に有効です。

# ONNX変換
上記のURLで取得するだけだと実は動きません。Sentisは**ONNX**形式をサポートしているので、変換が必要です。
また、floatに変換していますが、いかんせん使い始めで私もあまり詳しくないので、取りあえず動けばいいやの精神でやります。なので今回使用するAIモデルのinputを変換floatに変換してあげないといけません。

以下のコマンドで変換用ライブラリをインストールしておきます
```bash
pip install tensorflow tensorflow-hub tf2onnx onnx
```

**変換用Pythonコード**
Lightningは通常192x192の入力に対して、Thunderは256x256です。なので、URLとshapeだけ変えれば他は同じです。(出力される名前も違うけどご自身でわかりやすいやつに変えてください。)
<details>
<summary>
MoveNet SinglePose Lightning (V4)
</summary>

```python
import tensorflow as tf
import tensorflow_hub as hub
import tf2onnx
import onnx
import numpy as np

print(f"TensorFlow version: {tf.__version__}")
print(f"tf2onnx version: {tf2onnx.__version__}")
print(f"ONNX version: {onnx.__version__}")

# 1. TensorFlow Hub から MoveNet モデルをロード
model_url = "https://tfhub.dev/google/movenet/singlepose/lightning/4"
print(f"Loading MoveNet model from: {model_url}")
movenet_loaded_model = hub.load(model_url)
movenet_infer = movenet_loaded_model.signatures['serving_default']

# 2. float32 入力を持つ新しいKerasモデルラッパーを作成
def create_float_input_movenet_model(movenet_signature):
    input_shape = (192, 192, 3)

    input_tensor_float = tf.keras.layers.Input(shape=input_shape, dtype=tf.float32, name='input_image_float')

    # 前処理レイヤー:
    # 1. float [0,1] のピクセル値を float [0,255] にスケール
    scaled_input = input_tensor_float * 255.0
    
    # 2. float [0,255] を int32 にキャスト (Lambda レイヤーでラップ)
    casted_input = tf.keras.layers.Lambda(lambda x: tf.cast(x, dtype=tf.int32), name='cast_to_int32')(scaled_input)

    # 元のMoveNetモデルにキャストした入力を渡して推論を実行
    # tf.function (movenet_signature) の呼び出しを Lambda レイヤーでラップする
    outputs = tf.keras.layers.Lambda(
        lambda x: movenet_signature(input=x)['output_0'], # ここでキーワード引数 'input' を使用
        name='movenet_inference_layer'
    )(casted_input) # casted_input をこのLambdaレイヤーに渡す

    model = tf.keras.Model(inputs=input_tensor_float, outputs=outputs)
    return model

print("Creating Keras wrapper model for float32 input...")
keras_movenet_float_input = create_float_input_movenet_model(movenet_infer)
print("Keras model created.")

# 3. KerasモデルをONNX形式に変換
onnx_model_path = "movenet_lightning_float_input.onnx"

input_spec = [tf.TensorSpec(shape=[1, 192, 192, 3], dtype=tf.float32, name='input_image_float')]

print(f"Converting Keras model to ONNX: {onnx_model_path}")
model_proto, _ = tf2onnx.convert.from_keras(
    keras_movenet_float_input,
    input_signature=input_spec,
    opset=13,
    output_path=onnx_model_path
)

print(f"ONNX model saved to: {onnx_model_path}")

# 4. Optional: Verify the ONNX model
try:
    onnx_model = onnx.load(onnx_model_path)
    print("\n--- ONNX Model Input Verification ---")
    for input_node in onnx_model.graph.input:
        print(f"Input Name: {input_node.name}")
        print(f"Input Type: {onnx.helper.tensor_dtype_to_string(input_node.type.tensor_type.elem_type)}")
        shape_dims = [d.dim_value for d in input_node.type.tensor_type.shape.dim]
        print(f"Input Shape: {shape_dims}")
except Exception as e:
    print(f"Error verifying ONNX model: {e}")
```

</details>
<details>
<summary>
MoveNet SinglePose Thunder (V4)
</summary>

```python
import tensorflow as tf
import tensorflow_hub as hub
import tf2onnx
import onnx
import numpy as np

print(f"TensorFlow version: {tf.__version__}")
print(f"tf2onnx version: {tf2onnx.__version__}")
print(f"ONNX version: {onnx.__version__}")

# 1. TensorFlow Hub から MoveNet モデルをロード
model_url = "https://tfhub.dev/google/movenet/singlepose/thunder/4"
print(f"Loading MoveNet model from: {model_url}")
movenet_loaded_model = hub.load(model_url)
movenet_infer = movenet_loaded_model.signatures['serving_default']

# 2. float32 入力を持つ新しいKerasモデルラッパーを作成
def create_float_input_movenet_model(movenet_signature):
    input_shape = (256, 256, 3)

    input_tensor_float = tf.keras.layers.Input(shape=input_shape, dtype=tf.float32, name='input_image_float')

    # 前処理レイヤー:
    # 1. float [0,1] のピクセル値を float [0,255] にスケール
    scaled_input = input_tensor_float * 255.0
    
    # 2. float [0,255] を int32 にキャスト (Lambda レイヤーでラップ)
    casted_input = tf.keras.layers.Lambda(lambda x: tf.cast(x, dtype=tf.int32), name='cast_to_int32')(scaled_input)

    # 元のMoveNetモデルにキャストした入力を渡して推論を実行
    # tf.function (movenet_signature) の呼び出しを Lambda レイヤーでラップする
    outputs = tf.keras.layers.Lambda(
        lambda x: movenet_signature(input=x)['output_0'], # ここでキーワード引数 'input' を使用
        name='movenet_inference_layer'
    )(casted_input) # casted_input をこのLambdaレイヤーに渡す

    model = tf.keras.Model(inputs=input_tensor_float, outputs=outputs)
    return model

print("Creating Keras wrapper model for float32 input...")
keras_movenet_float_input = create_float_input_movenet_model(movenet_infer)
print("Keras model created.")

# 3. KerasモデルをONNX形式に変換
onnx_model_path = "movenet_thunder_float_input.onnx"

# input_spec の変更
input_spec = [tf.TensorSpec(shape=[1, 256, 256, 3], dtype=tf.float32, name='input_image_float')] # 192 -> 256

print(f"Converting Keras model to ONNX: {onnx_model_path}")
model_proto, _ = tf2onnx.convert.from_keras(
    keras_movenet_float_input,
    input_signature=input_spec,
    opset=13,
    output_path=onnx_model_path
)

print(f"ONNX model saved to: {onnx_model_path}")

# 4. Optional: Verify the ONNX model
try:
    onnx_model = onnx.load(onnx_model_path)
    print("\n--- ONNX Model Input Verification ---")
    for input_node in onnx_model.graph.input:
        print(f"Input Name: {input_node.name}")
        print(f"Input Type: {onnx.helper.tensor_dtype_to_string(input_node.type.tensor_type.elem_type)}")
        shape_dims = [d.dim_value for d in input_node.type.tensor_type.shape.dim]
        print(f"Input Shape: {shape_dims}")
except Exception as e:
    print(f"Error verifying ONNX model: {e}")
```

</details>

# 終わりに
まあまあ長くなるので、今回はここまでにします。
~~また次回を書いていますので、UPされていなかった場合は気長にお待ちください。~~
2025/5/24 その2をUPしました

https://qiita.com/tome_Horizon/items/9ee959c654d74d083d0f