VRChat用のフルトラをWEBカメラとMediaPipeで作ってみた

WEBカメラでVRChatのフルトラをやってみたかったので技術検証してみた。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/88918/25bc5c71-e050-b583-1c51-402d71e18e73.gif" width="500px">

## 概要

VRChatとはVR空間上で自身の分身となる3Dのアバターを動かし他のユーザーと交流をすることが出来るソーシャルアプリです。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/88918/8af2c8ec-9172-d948-686c-677daa896703.png" width="500px">

このVRChatでは様々なトラッキングツールを使用して現実世界の自身の身体の動きをVR空間上の自身のアバターに反映する事が出来ます。

最低限の環境だとVRヘッドセットと左右のコントローラーの位置関係で頭と腕の動きをアバターに反映する事ができます。これは **3点トラッキング** と呼ばれるもので一般的なVRヘッドセット単体では概ね、この3点トラッキングになります。 これに対して **Vive Tracker** や **mocopi** 等の追加のハードウェアにより腕、胸、腰、足に装着し体の動きをトラッキングさせる事によりアバターの動きをより表現力のあるものにすることが出来ます。これが所謂 **フルトラ (フルトラッキング、英語は Full Body Tracking, FBT)** と呼ばれるものになります。

ただ、このVive Trackerやmocopi等の機材は数万から十数万の投資が必要であり、これをより安い費用で実現出来ないかと考え調べるうちにWEBカメラを使用しカメラに写った骨格情報をVRChatに反映するというアプローチを取っているアプリを見つけました。

- [Device4VR](https://www.driver4vr.com/)
  - PC, Android, iOSに対応したカメラによるボディートラッキングツール
  - 一部機能は有料
- [Mediapipe-VR-Fullbody-Tracking](https://github.com/ju1ce/Mediapipe-VR-Fullbody-Tracking)
  - MediaPipeと呼ばれるGoogleが開発したニューラルネットワークによる姿勢推定を使用したボディートラッキングツール
  - 無料

これらを見ていると折角なら自分で自作してみたくなったので、この記事ではWEBカメラによるフルトラツールの開発にはどのような実装が必要なのかの技術検証を行ったので、そのノウハウを紹介したいと思います。

ただし、こちらで紹介する実装は **最低限の実装** にはなってくるので **WEBカメラを使用したVRChat用のフルトラツールのHello World的なもの** として読み進めていただければ幸いです。

### 使用する機材

今回の技術検証で使用する機材は下記の2つになります。

#### VRヘッドセット

[Meta Quest 3](https://www.meta.com/jp/quest/quest-3/?srsltid=AfmBOorMWRR-k0CTNcDzh6NGu5OTd-ml-eu-vAGXKTBVvfCcM5oD1qWt)

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/88918/c500fdbf-5e00-263b-8ff6-53622293f16b.jpeg" width="500px">

言わずと知れたFacebookやInstagramの開発で有名なMeta社が販売するVRヘッドセット

#### WEBカメラ

Amazonで2000円程度で売られている格安のWEBカメラ

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/88918/b85b58d0-62eb-598d-d87b-319261f3e023.jpeg" width="500px">

姿勢の推論が出来るだけの画質があれば良いので高品質なものは不要となります。

### 使用するアバター

[【オリジナル3Dモデル】シアン - Cian #Cian3D](https://septem47.booth.pm/items/5482062) PC番 (VRM同梱)

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/88918/447df500-f406-53cf-9a79-fdfe9958f461.png" width="500px">

検証に使用するアバターは [septem47](https://septem47.booth.pm/) 様が販売されている、こちらのアバターを購入し使用させていただきました。

## フルトラの実装に使用したライブラリやVRChatの仕様等

WEBカメラによるフルトラの実装にあたり次のライブラリやVRChatの使用等を使用しました。

### MediaPipe

[MediaPipe ソリューション ガイド  |  Google AI Edge  |  Google AI for Developers](https://ai.google.dev/edge/mediapipe/solutions/guide?hl=ja)

MediaPipeとは **Googleが開発しているアプリケーションに人工知能 (AI) と機械学習 (ML) を組み込むためのライブラリー及びにツール郡** となります。

このMediaPipeの出来ることとしてはオブジェクト検知、画像分類、ジェスチャー認識、手や顔、姿勢等のランドマーク検出、テキスト分類、言語検出、音声分類、画像の生成、LLM推論等が出来ます。

その中で今回の実装で使用するものとしては **姿勢ランドマーク検出** の機能を使用します。

[姿勢ランドマーク検出ガイド  |  Google AI Edge  |  Google AI for Developers](https://ai.google.dev/edge/mediapipe/solutions/vision/pose_landmarker?hl=ja)

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/88918/fa4d0db6-71c7-3d8c-cae3-075d5e670b14.png" width="500px">

参照画像：[姿勢ランドマーク検出ガイド  |  Google AI Edge  |  Google AI for Developers](https://ai.google.dev/edge/mediapipe/solutions/vision/pose_landmarker?hl=ja)

この姿勢ランドマーク検出の機能としては静止画や動画からニューラルネットワークによる推論を用いることにより **体の33個の部位のランドマーク** を取得することの出来る機能になります。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/88918/1e5f9988-aa78-4788-a1aa-f60e149346aa.png" width="350px">

参照画像：[姿勢ランドマーク検出ガイド  |  Google AI Edge  |  Google AI for Developers](https://ai.google.dev/edge/mediapipe/solutions/vision/pose_landmarker?hl=ja)

### VRChatとOSC (Open Sound Control)

OSC (Open Sound Control) とは電子楽器や音楽演奏データをネットワーク経由でリアルタイムに送受信するためのプロトコルになります。しかし、その汎用性から、しばしば音楽関係以外のアプリケーションでも使用されることがあるらしいです。

VRChatも、この例に漏れず、このプロトコルを使用してデータを送受信する仕様があります。

[VRChat - OSC Overview](https://docs.vrchat.com/docs/osc-overview)

VRChatでは上記のような仕様が公開されており、OSCを利用してアバターのパラメータやコントローラーの情報、トラッキング、アイトラッキング等のデータを送受信する仕様が定義されており、この仕様を利用しMediaPipeから取得したデータを加工しOSCを用いVRChatにトラッキングデータを送信することでフルトラを実装出来るのではないかと考えました。

#### OSCトラッキング

[VRChat - OSC Trackers](https://docs.vrchat.com/docs/osc-trackers)

上記のページで定義されている仕様を読む限りでは **OSC経由で送信できるトラッキングデータは8点** になります。VRヘッドセットと左右のコントローラーの3点に加え、こちらの8点トラッキングの仕様を加えることでVRChat上では3+8で **最大11点** のトラッキングでフルトラを実装する事が出来ます。

こちらのOSC経由で送信に仕様を要約すると下記のようになります。

OSCアドレス

```
/tracking/trackers/1/position
/tracking/trackers/1/rotation
/tracking/trackers/2/position
/tracking/trackers/2/rotation
/tracking/trackers/3/position
/tracking/trackers/3/rotation
/tracking/trackers/4/position
/tracking/trackers/4/rotation
/tracking/trackers/5/position
/tracking/trackers/5/rotation
/tracking/trackers/6/position
/tracking/trackers/6/rotation
/tracking/trackers/7/position
/tracking/trackers/7/rotation
/tracking/trackers/8/position
/tracking/trackers/8/rotation
/tracking/trackers/head/position
/tracking/trackers/head/rotation
```

上記のアドレスに対し3つのX, Y, Zの順番でfloat値を入力します。これらの値の仕様としては下記のようになります。

- position
  - Unityと同じ座標系
  - +Yが上
  - 1.0の数値を1m
  - 左手座標系
- rotation
  - 回転の表現方法はオイラー角となりZ, X, Yの順番
  - 単位は度 (degree)
  - 回転値はワールド座標系

サポートされている部位は腰、胸、足✕2、股✕2、肘✕2となります。

また1から8までのどの番号がどの部位になるのかは決まっておらずVRChat内でキャリブレーションした際にOSCにより入力された座標のIKポインターとVRChat上のアバターのトラッカーに追従する部位が自動的に対応付けられる仕様なのでどの番号にどの部位を割り当てるかは送信元のアプリケーションの任意となります。

そしてheadのposition, rotationに関しては入力があれば、その数値を元にトラッカーの座標、回転角の数値をシフトさせてくれる仕様になっています。

### その他、導入したライブラリやフレームワーク

- OpenCV
  - 画像処理用、画像解析のためのライブラリ
  - カメラへのアクセスと姿勢ランドマークをプロットした画像を表示するために使用
- Matplotlib
  - グラフ描画のためのライブラリ
  - 姿勢ランドマークの3次元的なプロットを表示するために使用
- NumPy
  - 数値計算を効率的に行うためのライブラリ
  - 姿勢ランドマークの数値をベクトル、行列を用いて変換処理を行うために使用
- SciPy
  - 数値解析を行うためのライブラリ
  - NumPyの汎用的なベクトル、行列の計算機能ではサポートされていない3次元座標の特性に特化した計算処理を行うために使用
- python-osc
  - OSCのプロトコルで通信を行うためのライブラリ
  - PythonからVRChatへトラッキングデータを送信するために使用
- Flask
  - 軽量なWebアプリケーションを実装するためのフレームワーク
  - アプリケーションを作る上で必要なUIを作成するのが手間だったのでWeb APIで簡易的なインタフェースを作成するために使用

## 実装

今回、WEBカメラとMediaPipeを用いたVRChat用のフルトラツールのサンプルプログラムはこちらになります。

https://github.com/redlily/training_vrchat_tracking

開発環境

- Python 3.12
- Poetry
- Pyenv (Optional)

実行方法

``` Shell
$ poetry install
$ poetry run python xvr/vr_chat_client.py
```

使い方

1. このサンプルアプリを起動
1. VRヘッドセットを装着しVRChatを起動
1. VRChat内でOSCを有効化
1. WEBカメラの画角内に入り直立します。 
この時、カメラに向かって正対している事が望ましいです。
1. VRヘッドセットからリモートデスクトップからブラウザーを起動、またはヘッドセット内のブラウザーを起動
1. カメラキャリブレーション用のURLにアクセスしてキャリブレーションを行います。 
サンプルアプリとブラウザが同一マシーンで動作している場合は http://127.0.0.1:5000/calibration にアクセスし、異なる場合はURLのIPアドレス部分を正しいものに書き換えてアクセスしてください。
1. VRChat内でもキャリブレーションを行います。  
IKポイントの位置に問題がある場合は「OSC トラッカー を中央に配置」を実行してみてください。

### WEBカメラからの画像取得と姿勢ランドマークをカメラ画像の上にプロット

手始めにWEBカメラから画像を取得し、その情報をMediaPipeに流し込み、姿勢ランドマークを推論し、その結果をカメラ画像の上にプロットしてみます。

https://github.com/google-ai-edge/mediapipe/blob/master/docs/solutions/pose.md

実装は上記のサンプルプログラムから必要な部分を抜粋しました。

こちらの実装は非常に簡単なプログラムとなっており、たった数十行のソースコードでWEBカメラから動画データの取得を行い、MediaPipeにその画像データを入力、推論された姿勢ランドマークを元の画像データにプロットし、その結果をウィンドウで確認できるというプログラムになっております。

```Python
import cv2
import mediapipe as mp
import numpy as np
mp_drawing = mp.solutions.drawing_utils
mp_drawing_styles = mp.solutions.drawing_styles
mp_pose = mp.solutions.pose

# For webcam input:
cap = cv2.VideoCapture(0)
with mp_pose.Pose(
    min_detection_confidence=0.5,
    min_tracking_confidence=0.5) as pose:
  while cap.isOpened():
    success, image = cap.read()
    if not success:
      print("Ignoring empty camera frame.")
      # If loading a video, use 'break' instead of 'continue'.
      continue

    # To improve performance, optionally mark the image as not writeable to
    # pass by reference.
    image.flags.writeable = False
    image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
    results = pose.process(image)

    # Draw the pose annotation on the image.
    image.flags.writeable = True
    image = cv2.cvtColor(image, cv2.COLOR_RGB2BGR)
    mp_drawing.draw_landmarks(
        image,
        results.pose_landmarks,
        mp_pose.POSE_CONNECTIONS,
        landmark_drawing_spec=mp_drawing_styles.get_default_pose_landmarks_style())
    # Flip the image horizontally for a selfie-view display.
    cv2.imshow('MediaPipe Pose', cv2.flip(image, 1))
    if cv2.waitKey(5) & 0xFF == 27:
      break
cap.release()
```

このプログラムでWEBカメラを用いて姿勢ランドマークの推論を行う素材として下記の画像のフィギュアを使用します。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/88918/0653f24a-812b-bc5b-256d-f08df23e0e4f.jpeg" width="500px">

このフィギュアに対しWEBカメラで撮影しMediaPipeにより推論されたランドマークを画像にプロットし表示すると下記の画像のような結果を得ることが出来ます。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/88918/3a509bf8-56e0-9b56-f183-a6187cecc84f.png" width="500px">

このプログラムの中で姿勢ランドマーク等の推論は下記のコードで実行されます。

```Python
results = pose.process(image)
```

推論の結果は下記のデータ構造で返されます。

```
PoseLandmarkerResult:
  Landmarks:
    Landmark #0:
      x            : 0.638852
      y            : 0.671197
      z            : 0.129959
      visibility   : 0.9999997615814209
      presence     : 0.9999984502792358
    Landmark #1:
      x            : 0.634599
      y            : 0.536441
      z            : -0.06984
      visibility   : 0.999909
      presence     : 0.999958
    ... (33 landmarks per pose)
  WorldLandmarks:
    Landmark #0:
      x            : 0.067485
      y            : 0.031084
      z            : 0.055223
      visibility   : 0.9999997615814209
      presence     : 0.9999984502792358
    Landmark #1:
      x            : 0.063209
      y            : -0.00382
      z            : 0.020920
      visibility   : 0.999976
      presence     : 0.999998
    ... (33 world landmarks per pose)
  SegmentationMasks:
    ... (pictured below)
```

ここで取得出来る姿勢ランドマークには二種類あり **正規化された座標を収納した、Landmarks** と **実空間の座標を収納した、WorldLanmarks** になり、その特性は下記のようになります。

正規化された座標 (Landmarks)

- x, yは0.0～1.0の範囲で正規化された値、画像の幅と高さので正規化されます。つまりカメラの画像の座標と対応付けられる値になります。
- zは腰を中心として値が小さいほどカメラに近く、値が大きいほどカメラから遠ざかる値になります。z値のスケールはx値とほぼ同じスケールになります。

実空間の座標 (WorldLanmarks)

- x, y, zは腰を原点とした実世界の座標 (メートル単位) の値となります。

LandmarksとWorldLandmarksは何れも撮影対象の腰を中心とした座標になりこれを考慮してVRChat用のトラッキングデータとして加工して送信する実装を考える必要があります。

### 姿勢ランドマークをMatplotlibで3Dでプロット

前項でMediaPipeを用いて取得したランドマークの座標情報をVRChat用の座標に変換する処理を実装する前に3D空間にボーン情報をプロットしたほうが深度情報 (z値) も確認しやすくなり、VRChat用の座標変換のイメージも湧きやすくなるのでMatplotlibで3D空間でのプロットを実装してみたいと思います。

```Python
# 腰を原点としたワールド座情系のポーズ座標
pose_world_points = [np.array([0, 0, 0], dtype=np.float32) for i in range(33)]


def update_pose(pose_landmarks, pose_world_landmarks):
    """
    ポーズの更新を行う
    """
    if pose_landmarks is not None:
        for i in range(33):
            landmark = pose_landmarks.landmark[i]
            world_landmark = pose_world_landmarks.landmark[i]
            if landmark.visibility:
                pose_points[i] = np.array(
                    [landmark.x, landmark.y, landmark.z],
                    dtype=np.float32)
                pose_world_points[i] = np.array(
                    [world_landmark.x, world_landmark.y, world_landmark.z],
                    dtype=np.float32)


def run_analyze_pose():
    """
    ポーズの解析を行う
    """
    mp_pose = mp.solutions.pose
    cap = cv2.VideoCapture(0)
    with mp_pose.Pose(
            min_detection_confidence=0.5,
            min_tracking_confidence=0.5) as pose:
        while cap.isOpened():
            success, image = cap.read()
            if not success:
                print("Ignoring empty camera frame.")
                # If loading a video, use 'break' instead of 'continue'.
                continue

            image.flags.writeable = False
            image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
            results = pose.process(image)

            if results.pose_landmarks and results.pose_world_landmarks:
                update_pose(results.pose_landmarks, results.pose_world_landmarks)

        cap.release()

landmark_groups = [
    [8, 6, 5, 4, 0, 1, 2, 3, 7],  # 目
    [10, 9],  # 口
    [11, 13, 15, 17, 19, 15, 21],  # 右手
    [11, 23, 25, 27, 29, 31, 27],  # 右半身
    [12, 14, 16, 18, 20, 16, 22],  # 左手
    [12, 24, 26, 28, 30, 32, 28],  # 左半身
    [11, 12],  # 肩
    [23, 24],  # 腰
]

fig = plt.figure()
ax = fig.add_subplot(111, projection="3d")


def update_plot():
    """
    プロットの更新を行う
    """
    while True:
        ax.cla()

        if calibration_enabled:
            ax.set_xlim3d(-1, 1)
            ax.set_ylim3d(-1, 1)
            ax.set_zlim3d(0, 2)
        else:
            ax.set_xlim3d(-1, 1)
            ax.set_ylim3d(-1, 1)
            ax.set_zlim3d(1, -1)

        for group in landmark_groups:
            x = [pose_points[i][0] for i in group]
            y = [pose_points[i][1] for i in group]
            z = [pose_points[i][2] for i in group]

            ax.plot(x, z, y)

        plt.pause(0.05)


if __name__ == '__main__':
    threading.Thread(target=run_analyze_pose).start()
    update_plot()
```

これを実行した結果が下記の画像のようになります。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/88918/d8b14743-71b2-94df-d102-0fc65f288b21.png" width="500px">

こちらのプログラムですが描画はメインスレッドで行い、WEBカメラでの撮影と姿勢ランドマークの推論は別スレッドで処理していますがランドマークを収納した変数は排他制御なしにアクセスしていますが厳密な描画を求めていないので、それらの実装は省略しています。

### 姿勢ランドマークの座標情報を加工してVRChatに送信する

#### 座標変換

実はMediaPipeから出力される座標データをそのままVRChatに送信しても、そのデータは意図どおりにVRChatに反映されません。MediaPipeから出力された座標データを無加工でそのままVRChatに送信した場合、VRChat内でキャリブレーションを行おうとしたIK用のポインターが下の画像のように全く意図しない場所に表示され、キャリブレーションをそのまま続行しても意図通りに動作しません。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/88918/dd5ee530-62f1-37a4-eeee-9fc11d5d79c7.png" width="500px">

それらの要因としては3つあります。

まず1つ目は **MediaPipeのWorldLandmarksを座標情報としてしようした場合はその座標系は+Yが上方向、+Zが奥方向、+Xが右方向の右手座標系** となり、それに対して **VRChatの標系はUnityと同様の+Yが上方向、+Zが奥方向、+Xが左方向の左手座標系** になるのでX軸の符号を入れ替える変換が必要になります。

そして2つ目は **MediaPipeのWorldLandmarksはメートル単位で座標情報を出力してくれるのですがその数値の正確性には多少の誤差** があり、これを撮影対象者の身長を基準にして補正するなどの実装が必要になります。

最後の3つ目は **WEBカメラが撮影対象を見上げたり見下ろしたりするような形で配置されていたり、WEBカメラを横に90度傾けてたりするような配置を行った場合** は座標情報もそのWEBカメラの傾きに従って出力されてくるのでそれに従って回転を補正する必要があります。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/88918/04516056-ceea-d083-fd08-6a65a6ca6259.png" width="500px">

それらの要因を補正するには下記のような実装を行います。

```Python
# カメラ座標系のポーズ座標
pose_points = [np.array([0, 0, 0], dtype=np.float32) for i in range(33)]

# 腰を原点としたワールド座情系のポーズ座標
pose_world_points = [np.array([0, 0, 0], dtype=np.float32) for i in range(33)]

# 補正されたVRC用の座標系のポーズ座標
pose_virtual_points = [np.array([0, 0, 0], dtype=np.float32) for i in range(33)]


def update_pose(pose_landmarks, pose_world_landmarks, image_size):
    """
    ポーズの更新を行う
    """
    if pose_landmarks is not None:
        for i in range(33):
            landmark = pose_landmarks.landmark[i]
            world_landmark = pose_world_landmarks.landmark[i]
            if landmark.visibility:
                pose_points[i] = np.array(
                    [landmark.x, landmark.y, landmark.z],
                    dtype=np.float32)
                pose_world_points[i] = np.array(
                    [world_landmark.x, world_landmark.y, world_landmark.z],
                    dtype=np.float32)

    global pose_virtual_points
    if calibration_enabled:
        pose_virtual_points = [calibration_matrix @ np.append(pose_world_points[i], 1.0)
                               for i in range(33)]
    else:
        pose_virtual_points = pose_world_points


# 座標補正の行列
calibration_matrix = np.eye(4, dtype=np.float32)


def update_calibration_parameter():
    """
    キャリブレーション用のパラメータの更新を行う
    """
    global calibration_enabled
    calibration_enabled = True

    top_position = (pose_world_points[7] + pose_world_points[8]) / 2
    bottom_position = (pose_world_points[29] + pose_world_points[30]) / 2

    # Y軸傾きの補正値算出
    y_axis = np.array([0, 1, 0], dtype=np.float32)
    y_slop = (top_position - bottom_position) / np.linalg.norm(top_position - bottom_position)
    y_slop_cos = y_axis @ y_slop
    y_slop_axis = np.cross(y_slop, y_axis)
    y_slop_sin = np.linalg.norm(y_slop_axis)
    y_slop_axis /= y_slop_sin
    ys_x, ys_y, ys_z = y_slop_axis
    ys_c = y_slop_cos
    ys_s = y_slop_sin
    ys_t = 1.0 - ys_c
    y_slop_mat = np.eye(4, dtype=np.float32)
    y_slop_mat[:3, :3] = np.array([
        [ys_t * ys_x * ys_x + ys_c, ys_t * ys_x * ys_y - ys_s * ys_z, ys_t * ys_x * ys_z + ys_s * ys_y],
        [ys_t * ys_x * ys_y + ys_s * ys_z, ys_t * ys_y * ys_y + ys_c, ys_t * ys_y * ys_z - ys_s * ys_x],
        [ys_t * ys_x * ys_z - ys_s * ys_y, ys_t * ys_y * ys_z + ys_s * ys_x, ys_t * ys_z * ys_z + ys_c]
    ], dtype=np.float32)

    # スケールを調整する
    height = np.linalg.norm(top_position - bottom_position)
    scale_mat = np.eye(4, dtype=np.float32)
    scale_mat[0, 0] = 1.7 / height
    scale_mat[1, 1] = 1.7 / height
    scale_mat[2, 2] = 1.7 / height

    # 座標系変換
    modify_coordination_system_mat = np.eye(4, dtype=np.float32)
    modify_coordination_system_mat[0, 0] = -1

    global calibration_matrix
    calibration_matrix = modify_coordination_system_mat @ scale_mat @ y_slop_mat
```

実装内容とそれらの使用方法としてはWEBカメラを設置し撮影対象者には基準となる場所に棒立ちしてもらい、その段階でupdate_calibration_parameterメソッドの呼び出します。

そのメソッドは撮影対象者を基準としてWEBカメラの座標系の補正やスケールの補正、傾きを補正を行うための行列を生成し、最終的にupdate_poseメソッド内でMediaPipeから受け取った座標に対し生成された行列により座標の補正をかける処理となります。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/88918/348f1c77-cfbc-51e7-2ae0-7b89b17ce24c.png" width="500px">

このときupdate_calibration_parameterメソッドをVRヘッドセットを被ったままでもVRヘッドセットからブラウザを用いて呼び出し易くするためFlaskを使用してこのメソッドを呼び出すWeb APIを用意しWebブラウザーを通じて呼び出し出来るようにします。

```Python
web_app = Flask(__name__)


@web_app.route("/calibration")
def web_calibration():
    update_calibration_parameter()
    return "Success"


def run_flask():
    web_app.run(debug=True, use_reloader=False)


if __name__ == '__main__':
    threading.Thread(target=run_flask).start()
```

このプログラムとWebブラウザーが同一マシーン上で動作している場合は `http://127.0.0.1:5000/calibration` にアクセスし異なる場合でもURLのIPアドレスを適切なものに変更しアクセスすることでupdate_calibration_parameterメソッドを実行出来るようになります。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/88918/7f40eacb-dceb-3c0d-d37c-28155c71352d.png" width="500px">

#### 回転パラメータの必要性

このVRChat用のOSCパラメータですが座標 (position) のパラメータだけでもトラッキングデータとしてアバターを動かすことが出来ます。

それならなぜ回転 (rotation) パラメータが必要なのか？ このパラメータを欠落させると何が起こるのか？ という疑問が出てきます。

それは回転パラメータがない状態だとトラッカーと紐付いているアバターの各パーツには回転情報が無いのでVRChatでキャリブレーションした際の向き各パーツの回転が固定されてしまいで現実世界で肩の向き、腰の向き、足の向き、腕の向きを変えてもアバターの各パーツは回転せず非常に不自然な動きとなってしまいます。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/88918/89e3b7e9-6c18-8776-05be-cc3b4216e1c0.png" width="350px">

しかしMediaPipeには体の各パーツの向きや回転等の情報は全く含まれていません。そこでそれらの回転パラメータはMediaPipeが出力したランドマークの情報から推定する必要があります。

具体的な実装方法の例としては関節の曲がり具合から外積、内積、正規化を駆使して各パーツのローカルなX軸, Y軸, Z軸の直行するベクトルを算出し、それらの軸ベクトルから回転行列を生成しそこからZ, X, Yのオーダーのオイラー角に変換するといった処理になります。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/88918/1d84f4bc-e456-4659-9e4d-14d96372b60f.png" width="350px">

一例として上記の画像にあたる左肘の回転行列を算出する実装を下記に示します。

```Python
# 左肘の回転を計算
left_elbow_axis_x = left_shoulder_position - left_elbow_position
left_elbow_axis_x /= np.linalg.norm(left_elbow_axis_x)
left_wrist_axis_x = left_elbow_position - left_wrist_position
left_wrist_axis_x /= np.linalg.norm(left_wrist_axis_x)
if left_elbow_axis_x @ left_wrist_axis_x > 0.9:
    # 肘がほとんど伸び切っている場合、手のひらの向きを腕のY軸ベクトルとして代替
    left_elbow_axis_y = np.cross(pose_virtual_points[17][:3], pose_virtual_points[19][:3])
else:
    # 肘が曲がっている場合
    left_elbow_axis_y = np.cross(left_elbow_axis_x, left_wrist_axis_x)
left_elbow_axis_z = np.cross(left_elbow_axis_x, left_elbow_axis_y)
left_elbow_axis_z /= np.linalg.norm(left_elbow_axis_z)
left_elbow_axis_y = np.cross(left_elbow_axis_z, left_elbow_axis_x)
pose_virtual_transforms["left_elbow"]["rotation"] = np.array(
    [left_elbow_axis_x, left_elbow_axis_y, left_elbow_axis_z], dtype=np.float32).T
pose_virtual_transforms["left_elbow"]["position"] = left_elbow_position
```

他の部位は実装が長くなるのでサンプルプログラムの方をご参照ください。

#### OSCによるVRChatへのデータ送信処理

OSCによるVRChatへの姿勢データの送信の実装は `python-osc` を用いて実装を行います。

送信先のIPアドレスはサンプルプログラムとSteam版やQuest Link版のVRChatを使用している等でVRChatが同一マシーンで動作している場合は **127.0.0.1** の **9000番** ポートに向けてデータの送信を行い、Quest単体版のVRChatを使用している場合はQuestが接続しているWi-FiのIPアドレスを調べ、そのアドレスの9000番ポートに向けてデータの送信を行います。

```Python
# VRChatに転送するトランスフォーム情報
pose_virtual_transforms = {
    "head": {
        "path": "head",
        "enable": True,
        "position": np.array([0, 0, 0], dtype=np.float32), # 位置
        "rotation": np.eye(3, dtype=np.float32), # 回転行列
    },
    "chest": {
      ...略
    },
    "hip": {
      ...略
    },
    ...略
}

# VRChat用のOSCクライアント
vrchat_client = udp_client.SimpleUDPClient("127.0.0.1", 9000)


def send_pose_to_vrchat():
    """
    VRChatにポーズを送信する
    """
    for key in pose_virtual_transforms:
        value = pose_virtual_transforms[key]
        if value["enable"]:
            position = value["position"]
            
            rotation_mat = value["rotation"]
            rotation_rot = Rotation.from_matrix(rotation_mat)
            rotation_zxy = rotation_rot.as_euler("zxy", degrees=True)
            
            vrchat_client.send_message(
                f"/tracking/trackers/{value["path"]}/position",
                position.tolist())
            vrchat_client.send_message(
                f"/tracking/trackers/{value["path"]}/rotation",
                [rotation_zxy[1], rotation_zxy[2], rotation_zxy[0]])
```

## あとがき

WEBカメラとMediaPipeを使用したある程度、VRChat上で動作する実装解説はいかがだったでしょうか？

この記事の筆者である私ですが1ヶ月ほど前に友人宅で初めてMeta Quest 3でVRChatを触らせていただき、その自由さと没入感に感銘を受けて自分でもQuest3を購入しVRChatに没頭していきました。

しかし、このアプリに慣れてくるとVRヘッドセットとコントローラの3点トラッキングでは思ったほど体を自由に動かせないことに気づき、より体を自由に動かしてみたいと欲が出てき始めトラッキングツールを探し始めたのですが有名どころのトラッカーは中々良いお値段し躊躇ってしまったわけです。

そこで代替出来る安価なトラッキングツールを探していたところOSSでMediaPipeとWEBカメラでVRChat用のフルトラが出来るアプリを見つけました。これを見て「これなら私でも作れるのでは？」と思い立って作ったのが始まりです。

技術調査も兼ねて最低限ではあるものの、ある程度 (WEBカメラのトラッキングにしては) 、精度良く動作するサンプルプログラムが出来上がった段階でそのノウハウを記事化したくもなったのでその記録としてこれを残します。

今後はこのノウハウを礎に誰もが使えるような形にプログラムを実行形式に固めて配布したり、Android, iOSアプリへの移植して配布したり、アルゴリズムの改修による精度やパフォーマンスの向上を図っていき、私のようなVRChatのフルトラ難民を救っていきたいなと思う所存ですｗ

## 参考

- [MediaPipe ソリューション ガイド  |  Google AI Edge  |  Google AI for Developers](https://ai.google.dev/edge/mediapipe/solutions/guide?hl=ja)
- [VRChat - OSC Overview](https://docs.vrchat.com/docs/osc-overview)