【Live2D × Unity × MediaPipe】VTuberの上半身モーションキャプチャーとKawaiiモーション
https://gyazo.com/0520c64f73fb84af8ba5f4eb41b02052
はじめに
「ガワはかわいくても中身はおっさん」
という厳しい現実にぶち当たります。動きがどうしてもね(という悲痛な叫びが届いており……)。
それをなんとかしするために「どうせなら『ガワ』だけでなく『動き』も自動でかわいく」、「講義に使いやすいようにモーションの取得のしかたをカスタム」といった機能を盛り込むべく、MediaPipeによるモーションキャプチャーでLive2Dアバターを動かすシステムを構築することにしました。
たんに「WEBカメラ1つでLive2Dアバターをうごかしたい!」という方にも役立つ内容になっています。
0. ゴール
Webカメラ1台・マーカレスで 上半身(肩〜手首+頭部・表情)をリアルタイム駆動
男性→女性アバターでも自然に見える“Kawaii補正”をルールベースで適用
出力は Live2D(既存モデルを使用)
目標:60fps / エンドツーエンド遅延50ms以下
1. 環境 & 依存関係
Unity 2022.3.61f1(LTSを推奨)
Live2D Cubism SDK for Unity(モデル読み込み)
ライセンス:Live2Dの利用条件は用途によって異なるので確認すること
Python(テスト送信、MediaPipe連携に使用)
MediaPipe(姿勢推定)
2. プロジェクト構成(主要スクリプト)
code:フォルダ構成
Assets
├ Live2D (Live2D Cubism SDK for Unityをインポートするとできる)
├ Yamazako-chan_export (Live2Dモデルのデータ一式。フォルダ名は適当でOK。)
├ Scenes(シーンを保存)
| └ SampleScene.unity
└ Scripts(スクリプトを保存)
└ OscUnifiedReceiver.cs(OSC受信)
└ Live2DParamBridge_UsingUnified.cs(頭部+表情)
└ UpperBodyIKToLive2D_Flexible.cs(上半身)
└ OneEuroFilter.cs(OneEuroフィルタ)
└ ArmIkHud.cs(Arm値表示用のHUD。デバッグ用なので基本使わない。)
└ OscUnifiedHud.cs(頭部値表示用のHUD。デバッグ用なので基本使わない。)
└ ExpressionAssist.cs
└ StylePresetSwitcher.cs
OSCを受け取り、AngleXDeg / EyeLOpen01 / LShoulder … などのプロパティに反映。
メインスレッドで値を更新(※当初、別スレッドで Time を読み込んで更新できずハマった)。
OneEuroフィルタ+Slew制限+擬似呼吸(BodyAngleY)+女性らしさ補正(ロール微バイアス等)。
遅延バインド+自動リバインド対応(起動直後に反応しない問題を解消。コレもハマった)。
2D-IK(肩・肘・手首)を算出。左→右ミラー(mirrorRightFromLeft)で片側だけでも両腕を動かせる。
遅延バインド+自動再バインド。
YAMAZAKO専用IDマッピング:(※Live2DモデルのID、Nameのつけ方が特殊なため用意。通常は使わない)
左肩 ← ParamArmR(Name: 右腕)
右肩 ← ParamArmL(Name: 左腕)
右肘 ← ParamHandR(Name: 左手)
手首と衝突時は自動で無効化
左肘(L:el)→右肘Param(=左手)を線形マッピング(driveRightElbowFromLeftElbow)
ヒジの可動範囲の対応を調整可能に
例)L:el=45 → +1、L:el=130 → -1
OneEuroフィルタ
デバッグ用で基本は使わない。空のGameObjectにアタッチして使用
ExpressionAssist.cs
表出アシスト機能。Live2Dモデルにアタッチ
StylePresetSwitcher.cs
表出プリセット切り替えスイッチ。空のオブジェクトにスイッチしてExpression Assistを指定。
3. シーンへの組み込み手順
3.0 Live2D Cubism SDK for Unity、OSC Jackをインポート
上の「1. 環境 & 依存関係」に従ってインポート
3.1 Live2Dモデル(*.model3.json)をシーンへ配置(CubismModel が付与される)。
AssetsフォルダにエクスポートしたLive2Dモデルをフォルダごとおくと、prefabが作成される
このprefabをHierarchyのSceane(デフォルトのままなら”SampleScene”)の下にドラッグ&ドロップする
https://gyazo.com/ddbfee1ba1672361c1477531bd842a5a
インスペクターから適当なスケールに調整(ここではX,Y,Z = 5に)
https://gyazo.com/0d82c6eab128d4028f3d49eb8a99c23a
3.2 受信機(Reciver)を配置
OscUnifiedReceiver.cs を空の GameObject(ここではReciver_integrated) にアタッチ
受信ポート(例:9000)を設定(※理由がなければデフォルトのままでOK)
3.3 頭部・表情用ブリッジを配置
Live2DParamBridge_UsingUnified.cs をLive2Dモデル(ここではYAMAZAKO_CHANG)にアタッチ
インスペクターからReceiver を設定(ここではReciver_integrated)
未設定でもOK(自動で FindObjectOfType)
3.4 上半身IKを配置
UpperBodyIKToLive2D_Flexible.cs を同じLive2Dモデル(YAMAZAKO_CHANG)に追加
pose は未設定でもOK(自動検出)
3.5 Script Execution Order (基本、気にしなくていい)
UpperBodyIKToLive2D_Flexible:DefaultExecutionOrder(200)
Live2DParamBridge_UsingUnified:DefaultExecutionOrder(250)
うまく動かない場合は Project Settings > Script Execution Order でBridge を後ろへ
4. OSCメッセージ仕様(現状)
送信は UDP 127.0.0.1:9000
頭部・表情(例)
/v1/angleX : float(deg)
/v1/angleY : float(deg)
/v1/angleZ : float(deg)
/v1/mouthOpen : float 0..1
/v1/eyeLOpen : float 0..1
/v1/eyeROpen : float 0..1
上半身ランドマーク(0..1 正規化 2D)
/v1/pose/l_shoulder : x, y /v1/pose/r_shoulder : x, y(※ミラー運用時は不要) table:OSCアドレスと引数一覧
種別 OSCアドレス 引数
Yaw/Pitch/Roll /v1/angleX, /v1/angleY, /v1/angleZ float(度)
体幹Yaw /v1/bodyAngleY float(度)
目開度 /v1/eyeLOpen, /v1/eyeROpen float(0..1)
口開度 /v1/mouthOpen float(0..1)
左肩/肘/手首 /v1/pose/l_shoulder, /v1/pose/l_elbow, /v1/pose/l_wrist float2(x,y:0..1)
右肩/肘/手首 /v1/pose/r_shoulder, /v1/pose/r_elbow, /v1/pose/r_wrist float2(x,y:0..1)
5. 動作確認の最小テスト
code:TestPoseSender.py
from pythonosc.udp_client import SimpleUDPClient
c = SimpleUDPClient("127.0.0.1", 9000)
# 頭部
c.send_message("/v1/angleX", 10.0)
c.send_message("/v1/mouthOpen", 0.6)
# 左腕ランドマーク(0..1)
MediaPipeとの連携
Webカメラ・マーカレスで取得した顔・姿勢検出結果をOSCで送信
顔・姿勢検出にはMediaPipeを使用
Apache 2.0 ライセンス
下の webcam_to_osc.py を保存して実行
送信先は 127.0.0.1:9000(Unityの OscUnifiedReceiver と同じポート)
既存のHUDで Pose Fresh: true になり、肩(or BodyAngleYミックス)が動けば成功
invertY=true(既定)のままでOK(MediaPipeの正規化は左上原点)
カメラIDを適切に指定すること
code:pip
pip install opencv-python mediapipe numpy python-osc
code:webcam_to_osc.py
# webcam_to_osc.py
import cv2
import numpy as np
from pythonosc.udp_client import SimpleUDPClient
import mediapipe as mp
mp_pose = mp.solutions.pose
mp_face = mp.solutions.face_mesh
# ====== 設定 ======
HOST, PORT = "127.0.0.1", 9000 # Unity OscUnifiedReceiver と一致
CAM_INDEX = 0 # ★自分の環境にあわせてカメラIDを設定すること
WIDTH, HEIGHT = 640, 480 # 低解像度の方が低遅延
TARGET_FPS = 60
# 目(EAR)のしきい値(経験則)
EAR_OPEN = 0.30
EAR_CLOSED= 0.15
# ====== 送信 ======
osc = SimpleUDPClient(HOST, PORT)
def send_head(yaw_deg, pitch_deg, roll_deg):
# 角度の符号はモデルに合わせて微調整してOK
osc.send_message("/v1/angleX", float(pitch_deg))
osc.send_message("/v1/angleY", float(yaw_deg))
osc.send_message("/v1/angleZ", float(roll_deg))
def send_face_metrics(eyeL_open01, eyeR_open01, mouth_open01):
osc.send_message("/v1/eyeLOpen", float(np.clip(eyeL_open01, 0, 1)))
osc.send_message("/v1/eyeROpen", float(np.clip(eyeR_open01, 0, 1)))
osc.send_message("/v1/mouthOpen", float(np.clip(mouth_open01, 0, 1)))
def send_pose_norm(name, x, y, w, h):
# MediaPipe の x,y は 0..1 正規化のためそのままでOK(Unity側 invertY=true 推奨)
# ====== ユーティリティ ======
def euler_from_rvec(rvec):
R, _ = cv2.Rodrigues(rvec)
sy = np.sqrt(R0,0**2 + R1,0**2) # ZYX (yaw-pitch-roll) を想定
yaw = np.degrees(np.arctan2(R2,1, R2,2)) pitch = np.degrees(np.arctan2(-R2,0, sy)) roll = np.degrees(np.arctan2(R1,0, R0,0)) return yaw, pitch, roll
def eye_ear(pts):
# 6点版 EAR: (|p2-p6| + |p3-p5|) / (2*|p1-p4|)
p1,p2,p3,p4,p5,p6 = pts
v1 = np.linalg.norm(p2-p6)
v2 = np.linalg.norm(p3-p5)
h = np.linalg.norm(p1-p4)
if h < 1e-6: return 0.0
return (v1 + v2) / (2.0*h)
def normalize_01(val, lo, hi):
return float(np.clip((val - lo) / max(1e-6, hi - lo), 0, 1))
# ====== MediaPipe 準備 ======
pose = mp_pose.Pose(model_complexity=1, enable_segmentation=False,
min_detection_confidence=0.5, min_tracking_confidence=0.5)
face = mp_face.FaceMesh(max_num_faces=1, refine_landmarks=True,
min_detection_confidence=0.5, min_tracking_confidence=0.5)
# PnP用の3Dモデル点(ざっくり比率)
# 参照: FaceMeshの6点 (1:鼻先, 33:左目外, 263:右目外, 61:口左, 291:口右, 199:顎あたり) を使う例が一般的。:contentReferenceoaicite:0{index=0} model_3d = np.array([
], dtype=np.float32)
# FaceMesh の対応インデックス
# 目のEAR用(よく使われるセット):contentReferenceoaicite:1{index=1} # PnP用 2D点のインデックス(上の model_3d と順に対応):contentReferenceoaicite:2{index=2} PNP_IDXS = 1, 33, 263, 61, 291, 199 # nose, l-eye-outer, r-eye-outer, mouthL, mouthR, chin # ====== カメラ ======
cap = cv2.VideoCapture(CAM_INDEX)
cap.set(cv2.CAP_PROP_FRAME_WIDTH, WIDTH)
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, HEIGHT)
cap.set(cv2.CAP_PROP_FPS, TARGET_FPS)
cap.set(cv2.CAP_PROP_BUFFERSIZE, 1) # 遅延対策
# 内部行列(簡易近似:fx=fy=幅、主点=中心)
fx = WIDTH
fy = HEIGHT
cx = WIDTH / 2
cy = HEIGHT / 2
dist = np.zeros((4,1), dtype=np.float32)
print("Start streaming to", HOST, PORT)
while True:
ok, frame = cap.read()
if not ok: break
rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
# ===== Face (姿勢+目・口) =====
f_res = face.process(rgb)
if f_res.multi_face_landmarks:
lm = f_res.multi_face_landmarks0.landmark # PnP 2D点(ピクセル座標)
pts_2d = []
for idx in PNP_IDXS:
pts_2d = np.array(pts_2d, dtype=np.float32)
# 姿勢推定
try:
okp, rvec, tvec = cv2.solvePnP(model_3d, pts_2d, K, dist, flags=cv2.SOLVEPNP_ITERATIVE)
if okp:
yaw, pitch, roll = euler_from_rvec(rvec)
# Live2D向けに軽くスケール/符号調整(必要に応じて反転)
send_head(yaw_deg=+yaw, pitch_deg=+pitch, roll_deg=+roll)
except cv2.error:
pass
# 目の開き(EAR→0..1)
def pts(idx_list):
return np.array([[lmi.x*WIDTH, lmi.y*HEIGHT] for i in idx_list], dtype=np.float32) ear_r = eye_ear(pts(RIGHT_EYE))
ear_l = eye_ear(pts(LEFT_EYE))
eyeR = normalize_01(ear_r, EAR_CLOSED, EAR_OPEN)
eyeL = normalize_01(ear_l, EAR_CLOSED, EAR_OPEN)
# 口の開き(上下距離/口幅)
mouth_left = np.array([lm61.x*WIDTH, lm61.y*HEIGHT]) mouth_right = np.array([lm291.x*WIDTH, lm291.y*HEIGHT]) # 上下は内側唇の代表点(13:上、14:下)がよく使われる
mouth_up = np.array([lm13.x*WIDTH, lm13.y*HEIGHT]) mouth_down = np.array([lm14.x*WIDTH, lm14.y*HEIGHT]) mouth_width = np.linalg.norm(mouth_right - mouth_left)
mouth_open = np.linalg.norm(mouth_down - mouth_up) / max(1e-6, mouth_width)
# 経験的に 0.02〜0.35 程度を 0..1 に正規化
mouth01 = normalize_01(mouth_open, 0.02, 0.35)
send_face_metrics(eyeL, eyeR, mouth01)
# ===== Pose(肩〜手首) =====
p_res = pose.process(rgb)
if p_res.pose_landmarks:
pl = p_res.pose_landmarks.landmark
def N(i): return (pli.x, pli.y) # 0..1 normalized # 左: 11肩,13肘,15手首/右: 12肩,14肘,16手首(MediaPipe Poseの標準)
send_pose_norm("l_shoulder", *N(11), WIDTH, HEIGHT)
send_pose_norm("l_elbow", *N(13), WIDTH, HEIGHT)
send_pose_norm("l_wrist", *N(15), WIDTH, HEIGHT)
send_pose_norm("r_shoulder", *N(12), WIDTH, HEIGHT)
send_pose_norm("r_elbow", *N(14), WIDTH, HEIGHT)
send_pose_norm("r_wrist", *N(16), WIDTH, HEIGHT)
# キーで終了
if cv2.waitKey(1) & 0xFF == 27: # ESC
break
cap.release()
6. モデル固有マッピング(YAMAZAKO-chan)
※この章はヤマザコちゃんのLive2Dモデルを扱うときの固有の内容です。
モデル内で Id と Name が左右逆風味なので、Flexible側に強制マップを用意しています。チェックを入れて使用すること。
https://gyazo.com/57466aa4fbe854664e62e5c3f8c3c936
左肩 ← Id: ParamArmR(Name: 右腕)
右肩 ← Id: ParamArmL(Name: 左腕)
右肘 ← Id: ParamHandR(Name: 左手)
左肘は存在しない → 左肘角(L:el)を右肘Paramに直結(driveRightElbowFromLeftElbow=ON)。
肘の可動域のマッピング
推奨:elAtRightElbowNormPos1=45、elAtRightElbowNormNeg1=130
https://gyazo.com/28a55115f1daec96a10d62d13fdbe9a0
7. 推奨パラメータ(現状の“効いた値”)
Flexible(上半身)
mirrorRightFromLeft = ON(左半身のみで両腕を駆動)
flipWristSignL = true, flipWristSignR = true(必要に応じて)
https://gyazo.com/e81fb8ca63be2cafd409768fb29c76ae
肩の可動域合わせ:shoulderOffsetL = +35, shoulderRangeDeg = 25
https://gyazo.com/83ef4b332b4d168069cba067b14ad8cb
https://gyazo.com/f82678e79e74c1952fb0ccd695086264
Bridge(頭部・表情)
minCutoffFace=1.4, betaFace=0.006
minCutoffOpen=2.0, betaOpen=0.01
headAngleScale=0.85, rollBiasDeg=+2.0
breathingAmpDeg=0.5, breathingHz=0.25
8. よくある症状と対処(ハマった箇所)
起動直後に反応しない
Bridge / Flexible とも Awakeではパラメータ探索しない。
OnEnable/Start→1フレ待ち+最大60フレ再試行でバインド。
それでもダメなら Script Execution Order で Bridge を後ろに。
Consoleに「get_realtimeSinceStartupAsDouble はメインスレッドのみ」
受信コールバックで Time.realtimeSinceStartup を触らない。メインスレッドのUpdate側で値を読む(修正済み)。
Pose Fresh が常に False
OSCアドレスや配列サイズを確認。受信側の正規化(0..1)や左右設定(swapLeftRight)もチェック。
片方の肩の角度が 0/90 みたいな二値でしか出てこない
正面構図の撮影環境だと両肩の値を取るのは難しい(未解決)。
対策:ミラー駆動(片腕の値で両腕を動かす!)
左肩の値で右肩も操作
左肘→右肘Param直結で安定化
ヒジが動かない/手首が勝手に効く
ヤマザコちゃん特有の問題
モデルのParam割当が特殊(ParamHandRが“左手”だが右肘Paramとして使う)。
Flexible が衝突検知で手首Param側を自動無効化するので、Elbow→HandRの直結を有効に。
9. 実装メモ(設計のキモ)
遅延バインド
Cubism内部の初期化はAwake直後には未完のことがある。
1フレ待つ+パラメータ数の変化で再バインドが手堅い。
OneEuro+Slew
OneEuroフィルタで高周波ノイズを抑えつつ遅延を最小化。
Slew(最大deg/s)でジャークを防止(可愛く見える)。
★ Kawaii補正
頭ロールに微バイアス、肩振幅スケール、手首外向きバイアスなどルールベースで動作を甘めに。
★ デフォルメ駆動
人の関節=アバターの関節に拘らず、
「観測しやすい身体部位 → モデルが効くParam」へ意味的マッピング。
今回は 左肩 → 右肩(=右手) がキモ。
10. 動作確認リスト
OscUnifiedReceiver のポート = 送信側と一致(例:9000)
Bridge / Flexible のEnabled が起動時から ON
Script Execution Order:Bridge を IK より後ろ(遅く)
Flexible:forceYamazakoIdMap=ON、driveRightElbowFromLeftElbow=ON
Python テスト or Webカメラ送信でHead が動くことを先に確認
肩オフセット・レンジ、手首符号の微調整で“可愛い”域に
11. 今後のやるかも
表情セットの実装(これはやりたい)
口形状(母音分類)・笑顔スコアの補助推定
視線・首根元のスウィング(物理orLFO)
ルールベース → 学習ベース(揺れもの同期や“元気っ子/おしとやか”の自動化)
https://gyazo.com/71c7de59f100448c29cdb7f29fbd171b