2026年8月7日に、5本の記事を1本にまとめました

UDP送信・Unity受信・表情の反映・Pythonの起動・Blenderモデルの持ち込みまで、順を追って書いていた5本を1本にしています。

⚠ MediaPipe側の書き方は 0.10.31(2025年12月)で変わりましたmp.solutions は削除)。受け渡している座標の形は変わっていないので、この記事のUnity側の内容はそのまま使えます。移行のしかたは こちら、いまの取り方は こちら にまとめています。

この記事について

スポンサーリンク

MediaPipeで顔と手の位置が取れるようになったので、次はその座標をUnityに渡して、画面の中のアバターを動かすところを作りました。

やってみると、座標を渡すこと自体より「2つのプログラムをどう繋ぐか」のほうが手がかかりました。この記事では、その繋ぎ方と、途中でつまずいた点をまとめます😊

全体の構成

全体の流れ 別々のプログラムなので、間をUDPで繋ぐ カメラ映像 OpenCV MediaPipe(Python) 顔478点・手21点を取る UDP JSONで送る Unity アバターを動かす 終了の合図は Unity → Python(別ポート)

MediaPipeはPython、アバターはUnity(C#)です。別々のプログラムなので、間でデータを受け渡す必要があります。今回はUDPという通信の仕組みを使いました。

なぜUDPで繋ぐのか

通信の方法はいくつかありますが、この用途ではUDPが向いています。

TCPUDP
届いたかの確認する(再送もする)しない
順番守られる守られない
速さやや遅い速い

トラッキングのデータは1秒間に30回も送られてくる、そのつど新しい値です。1フレームぶん落ちても次がすぐ来るので、再送を待つより、落として次を使うほうが自然になります。これがUDPを選んだ理由です。

送るデータの形を決める

送るのはJSONにしました。人が読めるので、うまく動かないときに中身を確かめやすいためです。

{
  "face": {
    "top":    [0.512, 0.201, -0.03],
    "bottom": [0.508, 0.712,  0.01],
    "left":   [0.331, 0.455, -0.02],
    "right":  [0.694, 0.451, -0.02],
    "blendshapes": { "eyeBlinkLeft": 0.41, "mouthSmileLeft": 0.31 }
  },
  "righthand": [[0.61, 0.72, 0.0], [0.63, 0.69, -0.01]],
  "lefthand":  []
}

顔は輪郭4点(上下左右)と表情の度合い、手は21点の座標を送ります。顔の478点をすべて送る必要はなく、向きを計算できる4点があれば足りました。送る量が減るとそのぶん軽くなります。

Python側:ランドマークを送る

import json
from socket import socket, AF_INET, SOCK_DGRAM

ADDRESS = "127.0.0.1"
PORT = 65500

def udp_send(data):
    payload = json.dumps(data)
    with socket(AF_INET, SOCK_DGRAM) as s:
        s.sendto(payload.encode("utf-8"), (ADDRESS, PORT))

Python側は数行です。127.0.0.1 は「同じパソコンの中」を指す住所で、ポート番号(ここでは65500)が待ち合わせ場所になります。

★詰まらせないために別スレッドで送る

最初は、MediaPipeから結果が返ってきたところでそのまま送信していました。ところがこれだと、送信に時間がかかったときに次の映像の処理まで止まってしまいます

import queue
import threading

send_queue = queue.Queue()

def on_result(result, output_image, timestamp_ms):
    """MediaPipeから呼ばれる。ここでは詰めるだけにする"""
    send_queue.put(build_json(result))     # ★重い処理はしない

def sender():
    """別スレッドで、詰まれたものを順に送る"""
    while True:
        data = send_queue.get()
        if data is None:
            break
        udp_send(data)

threading.Thread(target=sender, daemon=True).start()

★結果を受け取る場所では、詰めるだけにする
MediaPipeから呼ばれる関数の中で重い処理をすると、映像の処理そのものが遅くなります。キューに詰めるだけにして、送信は別スレッドに任せる——この形にしてから安定しました。

Unity側:受け取る

using System.Collections.Concurrent;
using System.Net;
using System.Net.Sockets;
using System.Threading;
using UnityEngine;

public class UDPManager : MonoBehaviour
{
    const int PORT = 65500;
    UdpClient udp;
    Thread receiveThread;

    // ★受信スレッドとメインスレッドの受け渡し用
    readonly ConcurrentQueue<string> queue = new ConcurrentQueue<string>();

    void Start()
    {
        udp = new UdpClient(PORT);
        receiveThread = new Thread(Receive) { IsBackground = true };
        receiveThread.Start();
    }

    void Receive()
    {
        var ep = new IPEndPoint(IPAddress.Any, 0);
        while (true)
        {
            byte[] data = udp.Receive(ref ep);
            queue.Enqueue(System.Text.Encoding.UTF8.GetString(data));
        }
    }

    void Update()
    {
        // ★Unityのオブジェクトを触ってよいのはメインスレッドだけ
        while (queue.TryDequeue(out string json))
        {
            var parts = JsonUtility.FromJson<PartsData>(json);
            ApplyToAvatar(parts);
        }
    }

    void OnApplicationQuit()
    {
        udp?.Close();
        receiveThread?.Abort();
    }
}

★Unityのメインスレッド制約

ここがUnity側でいちばんつまずいたところです。

⚠ Unityのオブジェクトは、メインスレッドからしか触れない
受信は別スレッドで待ち受ける必要がありますが、受け取ったその場で transform.position を書き換えるとエラーになります
→ 受信スレッドは ConcurrentQueue に積むだけにして、Update() で取り出して反映する。Python側と同じ「受け取る人と使う人を分ける」形になります。

また、受信スレッドは自分では止まりませんOnApplicationQuit で閉じておかないと、エディタで再生を止めてもスレッドが残り、次に再生したときポートが使用中になります。これも実際にはまりました😅

顔の向きを計算する

顔の輪郭4点から、顔がどちらを向いているかを計算します。

// 顔の輪郭4点(上下左右)から向きを作る
Vector3 up    = (top - bottom).normalized;      // 縦の軸
Vector3 right = (rightPt - leftPt).normalized;  // 横の軸
Vector3 fwd   = Vector3.Cross(right, up);       // 2つに垂直な向き=正面

transform.rotation = Quaternion.LookRotation(fwd, up);

上下の点を結んだ向き左右の点を結んだ向きがあれば、その2つに垂直な向き=正面が求まります(外積)。点をたくさん使わなくても、これで顔の向きは十分に取れました。

表情はブレンドシェイプで

目や口の動きは、点の位置ではなくブレンドシェイプで反映します。MediaPipeが返す52種類の度合い(0〜1)を、モデル側の変形の重み(0〜100)に渡すだけです。

// 表情はブレンドシェイプの重み(0〜100)で反映する
SkinnedMeshRenderer smr = faceObject.GetComponent<SkinnedMeshRenderer>();

smr.SetBlendShapeWeight(idxBlinkL, blendshapes.eyeBlinkLeft   * 100f);
smr.SetBlendShapeWeight(idxBlinkR, blendshapes.eyeBlinkRight  * 100f);
smr.SetBlendShapeWeight(idxSmile,  blendshapes.mouthSmileLeft * 100f);

ブレンドシェイプはメッシュの変形しか反映されません。位置の移動や回転は含まれないので、たとえばまばたきはまぶたのメッシュを下方向に引き伸ばすという作り方になります。

動きを滑らかにする

届いた値をそのまま入れると、細かく震えて見えます。少しずつ近づける形にすると落ち着きます。

// そのまま入れるとカクつくので、少しずつ近づける
target.position = Vector3.Lerp(target.position, newPosition, 0.35f);
target.rotation = Quaternion.Slerp(target.rotation, newRotation, 0.35f);

係数を大きくすると反応が速くなり、小さくすると滑らかになります。0.3〜0.4あたりが、遅れを感じずに震えも取れるちょうどよさでした。

UnityからPythonを起動する

毎回ターミナルからPythonを起動するのは面倒なので、Unityの再生と同時に立ち上げるようにしました。

using System.Diagnostics;
using UnityEngine;

public class MediaPipeManager : MonoBehaviour
{
    [SerializeField] string pythonPath;   // インスペクタで指定する
    [SerializeField] string scriptPath;
    Process process;

    void Start()
    {
        var psi = new ProcessStartInfo
        {
            FileName = pythonPath,
            Arguments = scriptPath,
            UseShellExecute = false,
            CreateNoWindow = true,
        };
        process = Process.Start(psi);
    }

    void OnApplicationQuit()
    {
        // ★UDPで「終わって」と伝える(強制終了だとカメラが解放されないことがある)
        UDPManager.SendToPython("{\"cmd\":\"quit\"}");
        if (process != null && !process.HasExited)
        {
            process.WaitForExit(2000);
            if (!process.HasExited) process.Kill();
        }
    }
}

ここでUDPを双方向にしています。Unityを止めるときにPython側へ「終わって」と伝え、Python側でカメラを解放してから終了させます。いきなりプロセスを落とすと、カメラが掴まれたままになることがあるためです。

認識が切れたときの処理

顔や手がカメラから外れると、検出結果が空で返ってきます。何もしないと最後の位置にオブジェクトが取り残されて不自然なので、消すようにしました。

// 検出できなかったフレームは、配列が空で届く
if (parts.righthand.Length == 0)
{
    rightHandRoot.SetActive(false);   // 消す
}
else
{
    rightHandRoot.SetActive(true);
    ApplyHand(parts.righthand);
}

Blenderで作った顔を持ち込む

最初はUnityの標準の球や立方体で顔を作っていましたが、Blenderで作った顔モデルに差し替えました。パーツは顔・目(左右)・瞳(左右)・眉(左右)で、口とまぶたはメッシュの変形で表現しています。

作ったらFBX形式で書き出してUnityに読み込みます。ここで注意が要ります。

⚠ BlenderとUnityで座標の決まりが違う
BlenderはZが上、UnityはYが上です。さらに右手系・左手系の違いもあるため、そのまま持ち込むと向きがずれます
→ FBXの書き出し設定で軸を合わせておくと、読み込んだあとに回転を打ち消す必要がなくなります。

Unity側では、もともと置いていた顔パーツを消して、読み込んだモデルに差し替えます。眉・まぶた・口はブレンドシェイプで動かすので、スクリプトはそのまま使えました。

★手もモデルで動かす

顔ができたので、次は手です。最初はランドマーク同士を球で繋いで輪郭を作っていましたが、ボーンの入ったモデルに替えました。

Blenderで作った手のモデル
作った手のモデル。★手首から各指へ伸びるボーンは、あえて削除している

ここで1つ手を加えています。手首から各指の付け根へ伸びるボーンを削除しました。

削除前のモデル
削除する前のモデル。手首から指へボーンが伸びている

なぜ削除したのか
このボーンと指のボーンの間に、もともと26°ほどの角度が付いていました
Unity側では掌と指の角度から回転量を計算しているので、最初から角度が付いていると、その分を差し引く計算が要ります。
計算を複雑にするより、モデル側を素直な形にしたほうが早い、という判断でした。スクリプト側には手首から第1関節へのボーンが存在する前提が残っているので、そこにはダミーのオブジェクトを割り当てています。

★BlenderとUnityで軸を合わせる

先ほど触れた座標系の違いは、実際には次の手順で合わせました。

  1. -y方向を正面とする
  2. オブジェクトモードで、x軸を -y方向へ90°回転
  3. 編集モードで、x軸を y方向へ90°回転

2と3で打ち消し合うので、見た目は元に戻ります。ですが中身では、オブジェクト全体が90°下を向いた状態になっています。この「見た目は同じなのに中身が違う」状態を作ることで、Unityに読み込んだときに向きが揃います。

★オブジェクトモードと編集モードの違いが効く
オブジェクトモードでの回転は「置き方」を変えるだけで、編集モードでの回転は形そのものを回します
この2つを逆向きに同じ角度かけると、見た目は戻るのに基準の向きだけがずれる——という状態が作れます。軸合わせでよく使う手です。

顔の回転を求める

顔の回転は、角度を直接計算しようとすると難しくなります。2つの目印を用意して、そちらを向かせるほうが素直でした。

  • facedir1facedir2 という空のオブジェクトを用意する
  • facedir2facedir1にする
  • 親子関係にしておくと、facedir2 の向きがそのまま顔全体の回転を表す
private void NoseMove(PartsData_face face)
{
    Transform t = Nose.transform;
    Vector3 pos = t.localPosition;
    // 基準位置からのずれとして足す(倍率は軸ごとに違う)
    pos.x = Nose_pos_base.x + (face.nose[0] *   6f);
    pos.y = Nose_pos_base.y + (face.nose[1] *   3f) * -1f;
    pos.z = Nose_pos_base.z + (face.nose[2] * 100f) * -1f;
    t.localPosition = pos;

    // 顔の向きは、目印のオブジェクトを向かせて表す
    facedir1.transform.LookAt(topPoint);
    // facedir2 は facedir1 の子。これが顔全体の回転になる
}

鼻の位置も、ランドマークの値をそのまま使うのではなく基準位置からのずれとして足しています。倍率(6倍・3倍・100倍)が軸ごとに違うのは、MediaPipeが返す値の範囲と、Unity側の大きさを合わせるためです。zだけ倍率が極端に大きいのは、奥行きの値がごく小さい範囲でしか動かないからです。

手の位置と指の角度

private void HandPosition(GameObject[] hand_objs, Vector3[] pos_list)
{
    Transform t = hand_objs[(int)RightHandLandmarkName.Wrist].transform;
    Vector3 pos = t.position;

    pos.x = (pos_list[(int)RightHandLandmarkName.Wrist].x * 12f) + (-3f);
    pos.y = (pos_list[(int)RightHandLandmarkName.Wrist].y * 12f) + ( 8f);
    // ★手首のzは動かないので、親指の付け根の値を使う
    pos.z = (pos_list[(int)RightHandLandmarkName.Thumb_Cmc].z * 20f);

    t.position = pos;
}

手全体の位置は手首のランドマークから決めます。ただしz(奥行き)だけは手首の値が動きませんでした。そこで親指の付け根(Thumb_Cmc)の値を代わりに使っています。

指の角度は、掌の向きと各指のボーンの角度から求めます。ここがいちばん手こずったところで、力技になっている部分が多く残りました🥺

Unityのエディタ設定
オブジェクトがz方向を正面にしているので、カメラはそれと正対する位置に置く
右手の割り当て
右手の割り当て。★モデルに無い「手首から第1関節」のボーンには、ダミーのオブジェクトを当てている

動作確認

顔と手のトラッキングの動作確認
顔と手が、カメラの前の動きに合わせて動くようになった

ある程度は動くようになりました😁 ただ、手全体を回すと、関節の角度がおかしくなることがあります。MediaPipeが返す精度の問題なのか、こちらの計算の問題なのか、切り分けきれていません。

⚠つまずいた点と課題

つまずき原因対処
映像の処理が遅くなる結果を受け取る場所で送信していたキューに詰めて別スレッドで送る
Unityでエラーになる受信スレッドからオブジェクトを触っていたUpdate()で取り出して反映する
再生を止めてもポートが空かない受信スレッドが残っていたOnApplicationQuitで閉じる
カメラが掴まれたままPythonを強制終了していたUDPで終了を伝えてから閉じる
モデルの向きがずれるBlenderとUnityで座標の決まりが違うFBX書き出し時に軸を合わせる

残っている課題もあります。MediaPipeの処理とUnityの描画を同じパソコンで動かしているので、負荷が高くなりがちです。フレームレートを落とす、送る点を減らす、といった調整が必要になる場面がありました。

手についても、指の回転を合わせるのが難しく、球を引き伸ばして輪郭を作るという形にとどめています🥺

まとめ

  • 2つのプログラムを繋ぐならUDPが向いている。落ちても次がすぐ来るデータだから
  • 送るのは必要な点だけ。顔は478点でなく、向きが計算できる4点+表情の度合いで足りた
  • 結果を受け取る場所では詰めるだけにして、送信は別スレッドに任せる
  • Unityのオブジェクトはメインスレッドからしか触れない。受信スレッドはキューに積むだけ
  • スレッドは自分では止まらない。終了時に必ず閉じる
  • 表情はブレンドシェイプで。メッシュの変形しか反映されない点に注意
  • 届いた値は少しずつ近づけると震えが取れる(係数0.3〜0.4)
  • BlenderとUnityは座標の決まりが違う。FBX書き出しで軸を合わせる

「座標を渡すだけ」と思って始めたのですが、詰まらせない工夫と、後片づけの作法のほうに時間がかかりました。別々のプログラムを繋ぐときは、だいたいこの2つが山になるのだと思います😊

それでは、今回はここまで。最後までありがとうございました😊