Open3D + MediaPipe による3Dモーションキャプチャ/アバター駆動

【目次】

はじめに:環境・前提・読み方

この資料で学ぶこと

この資料は、モーションキャプチャデータ(BVH)の表示から、カメラ映像による姿勢推定、メッシュアバターの駆動までを6つの演習で扱う。各演習は単独で実行でき、後の演習ほど前の演習で学んだ知識を利用する。

主要用語

本資料で用いる用語を、最初に使用する箇所より前に定義する。

単位とスケール

BVHの位置は、BVHファイル固有の長さ単位で与えられる。BVH形式自体は物理単位を定めておらず、このCMUデータでは全身がおよそ数十単位の大きさになる。一方、MediaPipeの pose_world_landmarks はメートルで与えられる。座標系もスケールも異なるため、描画サイズは演習ごとに調整する。また、各演習の3D画面には座標軸(赤 X、緑 Y、青 Z)を表示する(演習6は DEBUGTrue のときのみ表示)。描画パラメータは次の通り。

前準備

Windows PC で動作する。GPU・CPU のいずれでも動作する(GPUは必須ではない)。Python は 3.9〜3.12 を用いる。

Python 3.12 のインストール

Pythonのインストールを行い、Pythonのプログラムを実行する環境を整える。扱う環境は、Windows搭載パソコンである。金子研究室では、Python 3.12.10を推奨する。

[Windows での Python 3.12 のインストール手順を見るには、ここをクリック]

Windows での Python 3.12 のインストール

以下のいずれかの方法でPython 3.12をインストールする。Pythonがインストール済みの場合、この手順は不要である。

方法 1:winget によるインストール

インストールコマンドの実行方法

管理者権限コマンドプロンプトを起動する(手順:Windowsキーまたはスタートメニュー → cmd と入力 → 右クリック → 「管理者として実行」)。そして、コマンド全体をコマンドプロンプトにコピー&ペーストする。

--scope machine を指定することで、システム全体(全ユーザー向け)にインストールされる。このオプションの実行には管理者権限が必要である。インストール完了後、コマンドプロンプトを再起動するとPATHが反映される。

REM Python 3.12 をシステム領域にインストール
winget install --id Python.Python.3.12 -e --scope machine --silent --accept-source-agreements --accept-package-agreements --override "/quiet InstallAllUsers=1 PrependPath=1 Include_test=0 Include_pip=1 Include_launcher=1 InstallLauncherAllUsers=1 TargetDir=\"C:\Program Files\Python312\""

REM Python と Scripts を PATH 先頭に追加
powershell -NoProfile -Command "$p='C:\Program Files\Python312'; $s=\"$p\Scripts\"; $c=[Environment]::GetEnvironmentVariable('Path','Machine'); if((Test-Path $p) -and (';'+$c+';' -notlike \"*;$p;*\") -and (';'+$c+';' -notlike \"*;$s;*\")){[Environment]::SetEnvironmentVariable('Path',\"$p;$s;$c\",'Machine')}"

方法 2:インストーラーによるインストール

  1. Python公式サイト(https://www.python.org/downloads/)にアクセスし、「Download Python 3.x.x」ボタンからWindows用インストーラーをダウンロードする。
  2. ダウンロードしたインストーラーを実行する。
  3. 初期画面の下部に表示される「Add python.exe to PATH」にチェックを入れてから「Customize installation」を選択する。このチェックを入れ忘れると、コマンドプロンプトから python コマンドを実行できない。
  4. 「Install Python 3.xx for all users」にチェックを入れ、「Install」をクリックする。

インストールの確認

コマンドプロンプトで以下を実行する。

python --version

バージョン番号(例:Python 3.12.x)が表示されればインストール成功である。「'python' は、内部コマンドまたは外部コマンドとして認識されていません。」と表示される場合は、インストールが正常に完了していない。

Build Tools・CUDA Toolkit・PyTorch のインストール

本章では、C++ ビルドツール、NVIDIA CUDA Toolkit、PyTorch のインストールを行い、GPU を活用した機械学習プログラムを実行する環境を整える。扱う環境は、Windows 搭載パソコンである。

[Build Tools・CUDA Toolkit・PyTorch のインストール手順を見るには、ここをクリック]

Windows での Build Tools for Visual Studio 2026 のインストール

Build Tools for Visual Studio 2026 は、C++ ソースコードを Windows 用バイナリにコンパイルするための開発ツール群である。unsloth 等の一部 Python パッケージは、インストール時に C++ コードのビルドを必要とするため、これらのツールが必須となる。

以下のコマンドは、Build Tools が未インストールの場合は winget で新規インストールし、インストール済みの場合は setup.exe modify でコンポーネントを追加する(バージョンは変更しない)。

インストールコマンドの実行方法

管理者権限コマンドプロンプトを起動する(手順:Windows キーまたはスタートメニュー → cmd と入力 → 右クリック → 「管理者として実行」)。そして、コマンド全体をコマンドプロンプトにコピー&ペーストする。

REM VC++ ランタイム
winget install --scope machine --id Microsoft.VCRedist.2015+.x64 -e --silent --disable-interactivity --force --accept-source-agreements --accept-package-agreements --override "/quiet /norestart"

REM ============================================================
REM Visual Studio Build Tools + Desktop development with C++
REM (VCTools、MSBuildTools、CMake連携、Clang、Windows 11 SDK)
REM ============================================================
REM 進行中のインストーラーを停止(ロック競合回避)
taskkill /F /IM vs_setup.exe /T >nul 2>&1
taskkill /F /IM vs_installer.exe /T >nul 2>&1
taskkill /F /IM vs_installerservice.exe /T >nul 2>&1

REM 未インストール時: winget で新規インストール
REM インストール済み時: setup.exe modify でコンポーネント追加(バージョンは変更しない)
winget list --id Microsoft.VisualStudio.BuildTools 2>nul | findstr /i "BuildTools" >nul 2>&1
if %ERRORLEVEL% EQU 0 (
    for /f "usebackq delims=" %P in (`"C:\Program Files (x86)\Microsoft Visual Studio\Installer\vswhere.exe" -products Microsoft.VisualStudio.Product.BuildTools -property installationPath`) do start /wait "" "C:\Program Files (x86)\Microsoft Visual Studio\Installer\setup.exe" modify --installPath "%P" --add Microsoft.VisualStudio.Workload.VCTools --add Microsoft.VisualStudio.Workload.MSBuildTools --add Microsoft.VisualStudio.Component.VC.CMake.Project --add Microsoft.VisualStudio.Component.VC.Llvm.Clang --add Microsoft.VisualStudio.Component.VC.Llvm.ClangToolset --add Microsoft.VisualStudio.Component.Windows11SDK.26100 --includeRecommended --quiet --norestart --nocache
) else (
    winget install --scope machine --id Microsoft.VisualStudio.BuildTools -e --silent --disable-interactivity --force --accept-source-agreements --accept-package-agreements --override "--quiet --wait --norestart --nocache --add Microsoft.VisualStudio.Workload.VCTools --includeRecommended --add Microsoft.VisualStudio.Workload.MSBuildTools --add Microsoft.VisualStudio.Component.VC.CMake.Project --add Microsoft.VisualStudio.Component.VC.Llvm.Clang --add Microsoft.VisualStudio.Component.VC.Llvm.ClangToolset --add Microsoft.VisualStudio.Component.Windows11SDK.26100"
)

REM 破損時の修復(任意、動作がおかしくなった場合)
REM "C:\Program Files (x86)\Microsoft Visual Studio\Installer\setup.exe" repair --installPath "C:\Program Files (x86)\Microsoft Visual Studio\18\BuildTools" --quiet --norestart

REM 導入確認(インストールパスが表示されれば正常)
"C:\Program Files (x86)\Microsoft Visual Studio\Installer\vswhere.exe" -products * -requires Microsoft.VisualStudio.Workload.VCTools -property installationPath

上記のコマンドでは、Build Tools 本体と Visual C++ 再頒布可能パッケージをインストールし、続いて以下のコンポーネントを追加している。

追加のコンポーネントが必要になった場合は Visual Studio Installer で個別にインストールできる。

Windows での NVIDIA CUDA Toolkit のインストール

NVIDIA CUDA Toolkit は、NVIDIA GPU 上で計算を行うためのコンパイラ・ライブラリ群である。PyTorch や vLLM 等が GPU を利用するために必要となる。GPU を使用しない場合、この手順は不要である。

前提条件:NVIDIA GPU、NVIDIA ドライバ、Build Tools for Visual Studio もしくは Visual Studio が必要である。

インストール中の注意:他のウインドウは閉じておくこと。

インストールコマンドの実行方法

管理者権限コマンドプロンプトを起動する(手順:Windows キーまたはスタートメニュー → cmd と入力 → 右クリック → 「管理者として実行」)。そして、コマンド全体をコマンドプロンプトにコピー&ペーストする。

REM NVIDIA CUDA Toolkit 12.8 をシステム領域にインストール
winget install --scope machine --id Nvidia.CUDA --version 12.8 -e --silent --disable-interactivity --force --uninstall-previous --accept-source-agreements --accept-package-agreements --override "-s -n"

REM 環境変数TEMP, TMPの設定(一時ファイルの保存先を短いパスに変更)
mkdir C:\TEMP
setx TEMP "C:\TEMP" /M
setx TMP "C:\TEMP" /M

環境変数 TEMP および TMP を C:\TEMP に変更しているのは、後続のインストール処理で長いパス名や空白を含むパス名がエラーの原因となる場合があるためである。

Windows での PyTorch のインストール

https://pytorch.org のインストールガイドに従い、自環境の CUDA バージョンに対応したコマンドを取得して実行する。CUDA バージョンは以下で確認できる。

nvcc --version

Python 3.12、CUDA 12.6 以上の場合は、管理者権限コマンドプロンプトを起動し、以下を実行する。cu128 は CUDA 12.8 用のタグである。CUDA バージョンが異なる場合は、上記公式サイトで該当するタグを確認し、URL 末尾の cu128 を置き換えること。

pip install --no-user -U numpy torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu128

以降の章では、必要に応じて題材に応じた必要なソフトウェアを追加する。

Python の開発環境 Visual Studio Code のインストールと Python 用の設定

Python の開発環境Visual Studio Code(プログラムを編集するソフトウェア。以下、VS Code)を整える。

[Windows での Visual Studio Code のインストールと Python 用の設定手順を見るには、ここをクリック]

Windows での Visual Studio Code のインストールと Python 用の設定手順

1. VS Code と拡張機能のインストール

以下のコマンドにより,既存の VS Code を削除し,全ユーザー共有の設定で再インストールしたうえで,拡張機能(VS Code に機能を追加するソフトウェア)をまとめて導入する.

インストールコマンドの実行方法

管理者権限コマンドプロンプトを起動する(手順:Windows キーまたはスタートメニュー → cmd と入力 → 右クリック → 「管理者として実行」)。そして,コマンド全体をコマンドプロンプトにコピー&ペーストする。

インストールコマンド


REM ============================================================
REM Microsoft Visual Studio Code
REM ============================================================
winget uninstall -e --id Microsoft.VisualStudioCode --silent --disable-interactivity --accept-source-agreements
rmdir /s /q C:\ProgramData\vscode-extensions 2>nul
rmdir /s /q "%APPDATA%\Code" 2>nul
rmdir /s /q "%USERPROFILE%\.vscode" 2>nul
rmdir /s /q "%LOCALAPPDATA%\Microsoft\vscode-update" 2>nul

REM VS Code をシステム領域に新規インストール
winget install --scope machine --id Microsoft.VisualStudioCode -e --silent --accept-source-agreements --accept-package-agreements

REM 全ユーザー共有の拡張機能フォルダ
mkdir C:\ProgramData\vscode-extensions 2>nul
icacls "C:\ProgramData\vscode-extensions" /grant "Everyone:(OI)(CI)M" /T

REM スタートメニューのショートカットを --extensions-dir 付きで再作成
rmdir /s /q "C:\ProgramData\Microsoft\Windows\Start Menu\Programs\Visual Studio Code" 2>nul
del "C:\ProgramData\Microsoft\Windows\Start Menu\Programs\Visual Studio Code.lnk" 2>nul
powershell -NoProfile -Command "$s=New-Object -ComObject WScript.Shell; $lnk=$s.CreateShortcut('C:\ProgramData\Microsoft\Windows\Start Menu\Programs\Visual Studio Code.lnk'); $lnk.TargetPath='C:\Program Files\Microsoft VS Code\Code.exe'; $lnk.Arguments='--extensions-dir \"C:\ProgramData\vscode-extensions\"'; $lnk.Save()"
REM ショートカットの検証
powershell -NoProfile -Command "$s=New-Object -ComObject WScript.Shell; $lnk=$s.CreateShortcut('C:\ProgramData\Microsoft\Windows\Start Menu\Programs\Visual Studio Code.lnk'); Write-Host 'TargetPath:' $lnk.TargetPath; Write-Host 'Arguments:' $lnk.Arguments"

REM ファイル / フォルダ右クリックの「Code で開く」を登録
reg add "HKLM\SOFTWARE\Classes\*\shell\VSCode\command" /ve /d "\"C:\Program Files\Microsoft VS Code\Code.exe\" --extensions-dir \"C:\ProgramData\vscode-extensions\" \"%1\"" /f
reg add "HKLM\SOFTWARE\Classes\Directory\shell\VSCode\command" /ve /d "\"C:\Program Files\Microsoft VS Code\Code.exe\" --extensions-dir \"C:\ProgramData\vscode-extensions\" \"%1\"" /f
reg add "HKLM\SOFTWARE\Classes\Directory\Background\shell\VSCode\command" /ve /d "\"C:\Program Files\Microsoft VS Code\Code.exe\" --extensions-dir \"C:\ProgramData\vscode-extensions\" \"%V\"" /f

REM --extensions-dir 付きで起動する code.cmd ラッパを作成
REM (%* を echo で書くと対話的 cmd で失われるため、PowerShell で [char]37+'*' を書き出す)
powershell -NoProfile -Command "$pct=[char]37; $q=[char]34; $c='@echo off'+[char]13+[char]10+$q+'C:\Program Files\Microsoft VS Code\bin\code.cmd'+$q+' --extensions-dir '+$q+'C:\ProgramData\vscode-extensions'+$q+' '+$pct+'*'+[char]13+[char]10; [IO.File]::WriteAllText('C:\ProgramData\vscode-extensions\vscode.cmd',$c,[Text.Encoding]::ASCII)"

REM 拡張機能のインストール
set "CODE=C:\Program Files\Microsoft VS Code\bin\code.cmd"
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --uninstall-extension GitHub.copilot
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --uninstall-extension GitHub.copilot-chat
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --install-extension ms-python.python
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --install-extension ms-python.vscode-pylance
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --install-extension ms-python.debugpy
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --install-extension MS-CEINTL.vscode-language-pack-ja
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --install-extension saoudrizwan.claude-dev
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --install-extension rust-lang.rust-analyzer
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --install-extension tamasfe.even-better-toml
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --install-extension anthropic.claude-code
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --install-extension almenon.arepl
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --list-extensions --show-versions
echo === セットアップ完了 ===

2. Python インタプリタの選択

同一マシンに複数の Python がインストールされている場合,VS Code で使用する Python 本体(インタプリタ:Python プログラムを解釈・実行するソフトウェア)を選択する必要がある.

  1. コマンドパレット(コマンド名で機能を呼び出す VS Code の入力欄)を開く(Ctrl+Shift+P
  2. Python: Select Interpreter と入力する
  3. 表示される一覧から,使用する Python(例:C:\Program Files\Python312\python.exe)を選択する.

Python プログラム実行手順

[Windows での Python プログラム実行手順を見るには、ここをクリック]

Windows での Python 実行手順(Visual Studio Codeを使用)

プログラムファイルの作成と保存

  1. 左サイドバーの「エクスプローラー」アイコン(Ctrl+Shift+E)をクリックする
  2. 「NO FOLDER OPENED」(作業対象フォルダが未選択の状態)と表示される場合は,「Open Folder」をクリックし,プログラムを保存するフォルダを選択する

    続いて「フォルダを信用するか」を確認する画面(フォルダ内のコードを実行してよいか確認する VS Code の仕組み)が表示されるので,チェックして Yes を選択する

  3. フォルダ名の右側に表示される「新しいファイル」アイコンをクリックする
  4. ファイル名(例:aitask.py.ファイル名は何でも良い)を入力し Enter を押す.拡張子は .py(Python ファイルを示す拡張子)とする
  5. 実行したいコードを選択し,Ctrl+C でコピーする.VS Code のエディタ領域に Ctrl+V で貼り付ける
  6. Ctrl+S で保存する

プログラムの実行

  1. エディタ右上の三角形「▷」アイコン(Run Python File:現在開いている Python ファイルを実行するボタン)をクリックする.または,エディタ上で右クリックし「ターミナルで Python ファイルを実行」を選択する
  2. VS Code 下部のターミナル(コマンドの入出力を表示する画面)に,実行結果(print 関数の出力等)が表示される
  3. tkinter(Python 標準の GUI ライブラリ)のファイル選択ダイアログを使うプログラムを実行した場合は,ダイアログが開くので対象画像を選択する
  4. VS Code 下部のターミナルで実行結果を確認する.OpenCV ウィンドウ(OpenCV が画像を表示するために開く専用ウィンドウ)が開いた場合はそちらも確認する.OpenCV ウィンドウは,マウスクリックでウィンドウをアクティブ(操作対象の状態)にしてからキーを押すと終了する

ライブラリのインストール、ファイルの配置

管理者権限コマンドプロンプトを起動する (手順:Windowsキーまたはスタートメニュー → cmd と入力 → 右クリック → 「管理者として実行」)。

次のコマンドを順に実行し、OpenCV を一度削除してから入れ直す(複数のOpenCVパッケージが混在すると競合するため)。

pip uninstall -y opencv-python-headless opencv-contrib-python-headless opencv-python
pip install -U --no-user --force-reinstall opencv-contrib-python
pip install -U --no-user open3d pygltflib mediapipe numpy

プログラムについて

ファイルの配置

作業ディレクトリはスクリプトと同じ場所とする。次のファイルを同一ディレクトリ以下に置く。各演習のコードは、ファイルが無い場合のみ、コード中に記載のURLから初回実行時に自動でダウンロードするため、手作業での配置は不要である。

カメラと終了操作の共通仕様

演習4以降はWebカメラを用いる。カメラが無い場合は、コード中の cv2.VideoCapture(0)cv2.VideoCapture("movie.mp4") に変更して動画ファイルで代替できる。

終了は、カメラ映像ウィンドウがアクティブな状態で ESC キーを押す(Open3Dのウィンドウではなく、OpenCVの映像ウィンドウを選択した状態で押す)。

座標変換と特異点の共通仕様

MediaPipe の world ランドマーク (x, y, z) は、Open3D 描画用に [x, -y, -z] へ変換する(鏡像反転は行わない)。

2つの位置がほぼ同じになり、始点から終点へのベクトル長が0に近づくと、方向ベクトルから回転を求める計算で除算が不安定になる。本資料では、ベクトル長がほぼ0のときは回転を適用せず直前の状態を保つことで、この特異点を回避する。

主要ランドマークインデックス

MediaPipe Pose の主要な番号は、鼻 0、左肩 11、右肩 12、左肘 13、右肘 14、左手首 15、右手首 16、左腰 23、右腰 24、左膝 25、右膝 26、左足首 27、右足首 28、左つま先 31、右つま先 32。

姿勢推定モードと描画の前提

MediaPipe は VIDEO(同期)モードを使用する。VIDEO モードはフレームを順に同期処理し、各フレームに単調増加するタイムスタンプを渡す(LIVE_STREAM モードは非同期コールバックで結果が返るため、描画ループとの同期が複雑になる。本資料では実装を単純化するため VIDEO モードを用いる)。タイムスタンプには、MediaPipeを用いる各演習を通じて、time.perf_counter で得た秒値をミリ秒へ換算した整数を用いる。同じミリ秒値が続く場合は直前の値に1を足した値を用い、フレームごとのタイムスタンプを厳密に単調増加させる。

Open3D の視点(カメラ方向)は、マウス操作で変わる。そのため画面上での左右の見え方は視点に依存する。左右の対応を確認するときは、被写体の右手を動かし、アバターの右側のボーンが動くかで判断する。

演習1.BVHモーションデータの入手と内容理解

手順

BVHファイルを ./mocap_data/001/01_01.bvh に用意する(ファイルが無い場合のみ、コード中に記載のURLから自動でダウンロードする)。ファイルを開き、骨格の階層構造、関節数、フレーム数、フレーム間隔(FPS)を読み取って表示する。コードは標準ライブラリのみで記述する(メモ帳を用いる場合は a.py のようなファイル名で保存して実行)。

import os
import urllib.request

BASE_DIR = os.path.dirname(os.path.abspath(__file__))
BVH_URL = "https://raw.githubusercontent.com/una-dinosauria/cmu-mocap/master/data/001/01_01.bvh"
BVH_PATH = os.path.join(BASE_DIR, "mocap_data", "001", "01_01.bvh")

# ファイルが無い場合のみダウンロード
if not os.path.exists(BVH_PATH):
    os.makedirs(os.path.dirname(BVH_PATH), exist_ok=True)
    urllib.request.urlretrieve(BVH_URL, BVH_PATH)

with open(BVH_PATH, "r") as f:
    lines = f.readlines()

# HIERARCHY部の関節名と階層の深さを表示
depth = 0
joint_count = 0
for line in lines:
    s = line.strip()
    if s.startswith("{"):
        depth += 1
    elif s.startswith("}"):
        depth -= 1
    elif s.startswith("ROOT") or s.startswith("JOINT"):
        name = s.split()[1]
        joint_count += 1
        print("  " * depth + name)
    elif s.startswith("Frames:"):
        frames = int(s.split()[1])
    elif s.startswith("Frame Time:"):
        frame_time = float(s.split()[2])

print("関節数:", joint_count)
print("フレーム数:", frames)
print("フレーム間隔(秒):", frame_time, " FPS:", round(1.0 / frame_time, 2))
print("再生時間(秒):", round(frames * frame_time, 2))

ヒント

BVHは HIERARCHY 部(骨格定義)と MOTION 部(フレームごとの数値)に分かれる。Frames: 行にフレーム数、Frame Time: 行に1フレームの秒数が記載される。このデータでは、関節数 31、フレーム数 2752、フレーム間隔 1/120 秒(120 FPS)と表示される。

考察ポイント

階層の深さと関節数、フレーム数とフレーム間隔から再生時間を求められること、ルート関節(Hips)のみ位置と回転の6チャンネルを持ち、他の関節は回転の3チャンネルのみを持つことを確認する。

演習2.Open3DでBVHの1フレームを骨格として静止描画

手順

演習1と同じBVHを用いる。指定フレームについてFKで各関節位置を計算し、Open3Dで関節を球(半径1.5)、骨を LineSet、座標軸を長さ20で描画する。FRAME_INDEX = 1 を描画対象とする。コードは open3dnumpy を用いる(メモ帳を用いる場合は a.py のようなファイル名で保存して実行)。

import os
import urllib.request
import numpy as np
import open3d as o3d

BASE_DIR = os.path.dirname(os.path.abspath(__file__))
BVH_URL = "https://raw.githubusercontent.com/una-dinosauria/cmu-mocap/master/data/001/01_01.bvh"
BVH_PATH = os.path.join(BASE_DIR, "mocap_data", "001", "01_01.bvh")
FRAME_INDEX = 1

if not os.path.exists(BVH_PATH):
    os.makedirs(os.path.dirname(BVH_PATH), exist_ok=True)
    urllib.request.urlretrieve(BVH_URL, BVH_PATH)

# BVHを解析し、関節の親子関係・オフセット・チャンネル・モーションを取得
def parse_bvh(path):
    with open(path, "r") as f:
        tokens = f.read().split()
    names, parents, offsets, channels = [], [], [], []
    stack = [-1]
    i = 0
    while tokens[i] != "MOTION":
        t = tokens[i]
        if t in ("ROOT", "JOINT"):
            names.append(tokens[i + 1])
            parents.append(stack[-1])
            offsets.append([0.0, 0.0, 0.0])
            channels.append([])
            current = len(names) - 1
            i += 2
        elif t == "OFFSET":
            offsets[current] = [float(tokens[i + 1]), float(tokens[i + 2]), float(tokens[i + 3])]
            i += 4
        elif t == "CHANNELS":
            n = int(tokens[i + 1])
            channels[current] = tokens[i + 2:i + 2 + n]
            i += 2 + n
        elif t == "{":
            stack.append(current)
            i += 1
        elif t == "}":
            stack.pop()
            i += 1
        elif t == "End":
            # End Site は末端のオフセットのみを持ち、関節ではない。
            # ブロック全体(End Site { OFFSET x y z })の8トークンを読み飛ばす
            i += 8
        else:
            i += 1
    # MOTION部
    fi = tokens.index("Frames:")
    frames = int(tokens[fi + 1])
    ft = float(tokens[fi + 4])
    start = fi + 5
    values = [float(v) for v in tokens[start:]]
    motion = np.array(values).reshape(frames, -1)
    return names, parents, offsets, channels, motion, ft

# 回転(オイラー角)を回転行列に変換
def euler_to_matrix(axis, angle_deg):
    a = np.radians(angle_deg)
    c, s = np.cos(a), np.sin(a)
    if axis == "X":
        return np.array([[1, 0, 0], [0, c, -s], [0, s, c]])
    if axis == "Y":
        return np.array([[c, 0, s], [0, 1, 0], [-s, 0, c]])
    return np.array([[c, -s, 0], [s, c, 0], [0, 0, 1]])

# FKで各関節のグローバル位置を計算
def forward_kinematics(names, parents, offsets, channels, frame):
    positions = [None] * len(names)
    globals_rot = [None] * len(names)
    idx = 0
    for j in range(len(names)):
        local_pos = np.array(offsets[j], dtype=float)
        rot = np.eye(3)
        for ch in channels[j]:
            v = frame[idx]
            idx += 1
            if ch.endswith("position"):
                axis = "XYZ".index(ch[0])
                local_pos[axis] += v
            else:
                rot = rot @ euler_to_matrix(ch[0], v)
        if parents[j] == -1:
            globals_rot[j] = rot
            positions[j] = local_pos
        else:
            p = parents[j]
            globals_rot[j] = globals_rot[p] @ rot
            positions[j] = positions[p] + globals_rot[p] @ local_pos
    return positions

names, parents, offsets, channels, motion, ft = parse_bvh(BVH_PATH)
positions = forward_kinematics(names, parents, offsets, channels, motion[FRAME_INDEX])

# 球(関節)と LineSet(骨)を作成
geoms = []
for p in positions:
    sphere = o3d.geometry.TriangleMesh.create_sphere(radius=1.5)
    sphere.translate(p)
    sphere.paint_uniform_color([0.8, 0.3, 0.3])
    sphere.compute_vertex_normals()
    geoms.append(sphere)

points = [p.tolist() for p in positions]
lines = [[parents[j], j] for j in range(len(names)) if parents[j] != -1]
line_set = o3d.geometry.LineSet()
line_set.points = o3d.utility.Vector3dVector(points)
line_set.lines = o3d.utility.Vector2iVector(lines)
geoms.append(line_set)

# 座標軸(赤 X、緑 Y、青 Z)
geoms.append(o3d.geometry.TriangleMesh.create_coordinate_frame(size=20.0))

o3d.visualization.draw_geometries(geoms)

ヒント

FKでは、ルートから順に各関節の回転を親の変換へ掛け合わせ、グローバル位置を求める。骨は親子の関節位置を結ぶ線分として表す。

考察ポイント

球の位置と骨の接続が、演習1で確認した階層構造と一致するかを確認する。座標軸との比較で、Y軸が上方向(身長方向)に対応することも確認する。

演習3.Open3DでBVHを骨格アニメーション再生

手順

全フレームの関節位置を事前に計算する。Open3D の Visualizer をループで回し、各フレームで幾何データを更新する(処理順は update_geometrypoll_eventsupdate_renderer)。time.sleep でBVHのフレーム間隔に合わせて再生速度を調整する。演習2の parse_bvheuler_to_matrixforward_kinematics を同じ定義で再利用する(メモ帳を用いる場合は a.py のようなファイル名で保存して実行)。

import os
import time
import urllib.request
import numpy as np
import open3d as o3d

BASE_DIR = os.path.dirname(os.path.abspath(__file__))
BVH_URL = "https://raw.githubusercontent.com/una-dinosauria/cmu-mocap/master/data/001/01_01.bvh"
BVH_PATH = os.path.join(BASE_DIR, "mocap_data", "001", "01_01.bvh")
LOOP = True

if not os.path.exists(BVH_PATH):
    os.makedirs(os.path.dirname(BVH_PATH), exist_ok=True)
    urllib.request.urlretrieve(BVH_URL, BVH_PATH)

# BVHを解析し、関節の親子関係・オフセット・チャンネル・モーションを取得
def parse_bvh(path):
    with open(path, "r") as f:
        tokens = f.read().split()
    names, parents, offsets, channels = [], [], [], []
    stack = [-1]
    i = 0
    while tokens[i] != "MOTION":
        t = tokens[i]
        if t in ("ROOT", "JOINT"):
            names.append(tokens[i + 1])
            parents.append(stack[-1])
            offsets.append([0.0, 0.0, 0.0])
            channels.append([])
            current = len(names) - 1
            i += 2
        elif t == "OFFSET":
            offsets[current] = [float(tokens[i + 1]), float(tokens[i + 2]), float(tokens[i + 3])]
            i += 4
        elif t == "CHANNELS":
            n = int(tokens[i + 1])
            channels[current] = tokens[i + 2:i + 2 + n]
            i += 2 + n
        elif t == "{":
            stack.append(current)
            i += 1
        elif t == "}":
            stack.pop()
            i += 1
        elif t == "End":
            # End Site は末端のオフセットのみを持ち、関節ではない。
            # ブロック全体(End Site { OFFSET x y z })の8トークンを読み飛ばす
            i += 8
        else:
            i += 1
    # MOTION部
    fi = tokens.index("Frames:")
    frames = int(tokens[fi + 1])
    ft = float(tokens[fi + 4])
    start = fi + 5
    values = [float(v) for v in tokens[start:]]
    motion = np.array(values).reshape(frames, -1)
    return names, parents, offsets, channels, motion, ft

# 回転(オイラー角)を回転行列に変換
def euler_to_matrix(axis, angle_deg):
    a = np.radians(angle_deg)
    c, s = np.cos(a), np.sin(a)
    if axis == "X":
        return np.array([[1, 0, 0], [0, c, -s], [0, s, c]])
    if axis == "Y":
        return np.array([[c, 0, s], [0, 1, 0], [-s, 0, c]])
    return np.array([[c, -s, 0], [s, c, 0], [0, 0, 1]])

# FKで各関節のグローバル位置を計算
def forward_kinematics(names, parents, offsets, channels, frame):
    positions = [None] * len(names)
    globals_rot = [None] * len(names)
    idx = 0
    for j in range(len(names)):
        local_pos = np.array(offsets[j], dtype=float)
        rot = np.eye(3)
        for ch in channels[j]:
            v = frame[idx]
            idx += 1
            if ch.endswith("position"):
                axis = "XYZ".index(ch[0])
                local_pos[axis] += v
            else:
                rot = rot @ euler_to_matrix(ch[0], v)
        if parents[j] == -1:
            globals_rot[j] = rot
            positions[j] = local_pos
        else:
            p = parents[j]
            globals_rot[j] = globals_rot[p] @ rot
            positions[j] = positions[p] + globals_rot[p] @ local_pos
    return positions

names, parents, offsets, channels, motion, ft = parse_bvh(BVH_PATH)

# 全フレームの関節位置を事前計算
all_positions = []
for frame in motion:
    all_positions.append(forward_kinematics(names, parents, offsets, channels, frame))

lines = [[parents[j], j] for j in range(len(names)) if parents[j] != -1]

line_set = o3d.geometry.LineSet()
line_set.points = o3d.utility.Vector3dVector([p.tolist() for p in all_positions[0]])
line_set.lines = o3d.utility.Vector2iVector(lines)

pcd = o3d.geometry.PointCloud()
pcd.points = o3d.utility.Vector3dVector([p.tolist() for p in all_positions[0]])
pcd.paint_uniform_color([0.8, 0.3, 0.3])

vis = o3d.visualization.Visualizer()
vis.create_window()
vis.add_geometry(line_set)
vis.add_geometry(pcd)
vis.add_geometry(o3d.geometry.TriangleMesh.create_coordinate_frame(size=20.0))

frame_index = 0
running = True
while running:
    pts = [p.tolist() for p in all_positions[frame_index]]
    line_set.points = o3d.utility.Vector3dVector(pts)
    pcd.points = o3d.utility.Vector3dVector(pts)
    vis.update_geometry(line_set)
    vis.update_geometry(pcd)
    running = vis.poll_events()      # ウィンドウを閉じると False
    vis.update_renderer()
    time.sleep(ft)
    frame_index += 1
    if frame_index >= len(all_positions):
        if LOOP:
            frame_index = 0
        else:
            break

vis.destroy_window()

ヒント

幾何データを更新したら必ず update_geometry を呼ぶ(呼ばないと描画が更新されない)。ウィンドウを閉じると poll_eventsFalse を返す。最初にTポーズに近い姿勢(収録開始時の基準姿勢)が表示され、続いて動き出す。LOOP = False にすると最終フレームで停止する。

考察ポイント

再生速度がフレーム間隔とおおむね一致するか(time.sleep はFKや描画の処理時間を含まないため、実際の再生はわずかに遅くなる)、最終フレームで停止するかループするかを確認する。

演習4.カメラ+MediaPipeでリアルタイム3D骨格描画と2D重畳

手順

使用パッケージは mediapipeopencv-contrib-pythonopen3dnumpy。姿勢推定モデルを ./pose_landmarker_lite.task へダウンロードする(ファイルが無い場合のみ)。OpenCVでカメラフレームを取得し、BGRからRGBへ変換してMediaPipe(VIDEOモード)へ渡す。world ランドマークを [x, -y, -z] に変換し、Open3D の LineSet(骨)と点群(関節)で3D骨格を描画する。同時に、2DランドマークをOpenCVの映像へ重畳表示する。VIDEOモードへ渡すタイムスタンプは、同じミリ秒値が連続しないようにして、各フレームで厳密に増加させる(メモ帳を用いる場合は a.py のようなファイル名で保存して実行)。

import os
import time
import urllib.request
import numpy as np
import cv2
import open3d as o3d
import mediapipe as mp
from mediapipe.tasks import python as mp_python
from mediapipe.tasks.python import vision

BASE_DIR = os.path.dirname(os.path.abspath(__file__))
MODEL_URL = "https://storage.googleapis.com/mediapipe-models/pose_landmarker/pose_landmarker_lite/float16/1/pose_landmarker_lite.task"
MODEL_PATH = os.path.join(BASE_DIR, "pose_landmarker_lite.task")

if not os.path.exists(MODEL_PATH):
    urllib.request.urlretrieve(MODEL_URL, MODEL_PATH)

# MediaPipe Pose の骨の接続(主要部位)
POSE_CONNECTIONS = [
    (11, 12), (11, 13), (13, 15), (12, 14), (14, 16),
    (11, 23), (12, 24), (23, 24),
    (23, 25), (25, 27), (24, 26), (26, 28),
]

# world ランドマークを Open3D 用座標に変換(鏡像なし)
def landmarks_to_points(landmarks):
    return np.array([[lm.x, -lm.y, -lm.z] for lm in landmarks])

# PoseLandmarker(VIDEOモード)を作成
options = vision.PoseLandmarkerOptions(
    base_options=mp_python.BaseOptions(model_asset_path=MODEL_PATH),
    running_mode=vision.RunningMode.VIDEO,
)
landmarker = vision.PoseLandmarker.create_from_options(options)

cap = cv2.VideoCapture(0)   # カメラが無い場合は "movie.mp4" に変更

# Open3D 初期化
line_set = o3d.geometry.LineSet()
line_set.lines = o3d.utility.Vector2iVector(POSE_CONNECTIONS)
pcd = o3d.geometry.PointCloud()

vis = o3d.visualization.Visualizer()
vis.create_window()
vis.add_geometry(o3d.geometry.TriangleMesh.create_coordinate_frame(size=0.5))
added = False

start = time.perf_counter()
last_timestamp_ms = -1
running = True
while running:
    ok, frame = cap.read()
    if not ok:
        break
    rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
    mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=rgb)

    raw_timestamp_ms = int((time.perf_counter() - start) * 1000)
    timestamp_ms = max(raw_timestamp_ms, last_timestamp_ms + 1)
    last_timestamp_ms = timestamp_ms

    result = landmarker.detect_for_video(mp_image, timestamp_ms)

    if result.pose_world_landmarks:
        pts = landmarks_to_points(result.pose_world_landmarks[0])
        line_set.points = o3d.utility.Vector3dVector(pts)
        pcd.points = o3d.utility.Vector3dVector(pts)
        pcd.paint_uniform_color([0.8, 0.3, 0.3])
        if not added:
            vis.add_geometry(line_set)
            vis.add_geometry(pcd)
            added = True
        vis.update_geometry(line_set)
        vis.update_geometry(pcd)

        # 2Dランドマークを映像に重畳
        h, w = frame.shape[:2]
        for lm in result.pose_landmarks[0]:
            cv2.circle(frame, (int(lm.x * w), int(lm.y * h)), 4, (0, 255, 0), -1)

    running = vis.poll_events()
    vis.update_renderer()
    cv2.imshow("camera", frame)
    if cv2.waitKey(1) == 27:   # ESC(映像ウィンドウがアクティブなとき)
        break

cap.release()
cv2.destroyAllWindows()
vis.destroy_window()

ヒント

タイムスタンプは time.perf_counter で得た秒値をミリ秒へ換算した整数を基本にし、同じミリ秒値が続くときは直前の値に1を足して、フレームごとに厳密に単調増加させる。3Dの骨格と点群は、最初にランドマークが検出された時点でシーンへ追加する(空のまま追加しないため)。終了は映像ウィンドウがアクティブな状態で ESC キーを押す。

考察ポイント

被写体の動きと3D骨格・2D重畳がリアルタイムに追従するか、左右の対応が正しいか(被写体の右手を動かすと画面の対応する側が動くか)を確認する。

演習5.カメラ+MediaPipeで円柱メッシュアバターを駆動

手順

姿勢推定モデルは演習4と同じものを ./pose_landmarker_lite.task から用いる(ファイルが無い場合のみダウンロードする)。骨を円柱メッシュ、関節を球メッシュで表す。基準となる円柱と球を1つずつ作成し、ボーン数・関節数だけ複製して各フレームで変換する。円柱は bone_transform で長さ方向(Z軸)のみを骨の長さに合わせて伸縮させ、太さ(半径)は一定に保つ。各フレームの変換は前フレームの累積を避けるため、毎回基準メッシュから計算する。VIDEOモードへ渡すタイムスタンプは、同じミリ秒値が連続しないようにして、各フレームで厳密に増加させる(メモ帳を用いる場合は a.py のようなファイル名で保存して実行)。

import os
import time
import urllib.request
import numpy as np
import cv2
import open3d as o3d
import mediapipe as mp
from mediapipe.tasks import python as mp_python
from mediapipe.tasks.python import vision

BASE_DIR = os.path.dirname(os.path.abspath(__file__))
MODEL_URL = "https://storage.googleapis.com/mediapipe-models/pose_landmarker/pose_landmarker_lite/float16/1/pose_landmarker_lite.task"
MODEL_PATH = os.path.join(BASE_DIR, "pose_landmarker_lite.task")

if not os.path.exists(MODEL_PATH):
    urllib.request.urlretrieve(MODEL_URL, MODEL_PATH)

POSE_CONNECTIONS = [
    (11, 12), (11, 13), (13, 15), (12, 14), (14, 16),
    (11, 23), (12, 24), (23, 24),
    (23, 25), (25, 27), (24, 26), (26, 28),
]
JOINT_IDS = sorted({i for c in POSE_CONNECTIONS for i in c})

def landmarks_to_points(landmarks):
    return np.array([[lm.x, -lm.y, -lm.z] for lm in landmarks])

# 始点 p0 から終点 p1 へ向かう円柱の変換行列を作る
# Z軸方向のみ length にスケールし、半径は一定に保つ
# 特異点(長さがほぼ0)のときは None を返し、呼び出し側で直前の状態を保つ
def bone_transform(p0, p1):
    d = p1 - p0
    length = np.linalg.norm(d)
    if length < 1e-6:
        return None
    z = d / length
    # Z軸を z に合わせる回転を構築
    up = np.array([0.0, 0.0, 1.0])
    axis = np.cross(up, z)
    axis_len = np.linalg.norm(axis)
    if axis_len < 1e-6:
        R = np.eye(3) if z[2] > 0 else np.diag([1.0, -1.0, -1.0])
    else:
        axis = axis / axis_len
        angle = np.arccos(np.clip(np.dot(up, z), -1.0, 1.0))
        c, s = np.cos(angle), np.sin(angle)
        K = np.array([[0, -axis[2], axis[1]],
                      [axis[2], 0, -axis[0]],
                      [-axis[1], axis[0], 0]])
        R = np.eye(3) + s * K + (1 - c) * (K @ K)
    S = np.diag([1.0, 1.0, length])   # 長さ方向のみ伸縮、太さ一定
    T = np.eye(4)
    T[:3, :3] = R @ S
    T[:3, 3] = (p0 + p1) / 2.0        # 円柱は中点に配置
    return T

options = vision.PoseLandmarkerOptions(
    base_options=mp_python.BaseOptions(model_asset_path=MODEL_PATH),
    running_mode=vision.RunningMode.VIDEO,
)
landmarker = vision.PoseLandmarker.create_from_options(options)

cap = cv2.VideoCapture(0)   # カメラが無い場合は "movie.mp4" に変更

# 基準メッシュ(高さ1の円柱を中心原点・Z軸方向に作成)
bone_base = o3d.geometry.TriangleMesh.create_cylinder(radius=0.02, height=1.0)
bone_base.compute_vertex_normals()
joint_base = o3d.geometry.TriangleMesh.create_sphere(radius=0.05)
joint_base.compute_vertex_normals()

bones = [o3d.geometry.TriangleMesh(bone_base) for _ in POSE_CONNECTIONS]
joints = [o3d.geometry.TriangleMesh(joint_base) for _ in JOINT_IDS]
for b in bones:
    b.paint_uniform_color([0.4, 0.5, 0.9])
for j in joints:
    j.paint_uniform_color([0.8, 0.3, 0.3])

vis = o3d.visualization.Visualizer()
vis.create_window()
opt = vis.get_render_option()
opt.background_color = np.array([0.1, 0.1, 0.1])
opt.light_on = True
opt.mesh_show_back_face = True
vis.add_geometry(o3d.geometry.TriangleMesh.create_coordinate_frame(size=0.5))
added = False

start = time.perf_counter()
last_timestamp_ms = -1
running = True
while running:
    ok, frame = cap.read()
    if not ok:
        break
    rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
    mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=rgb)

    raw_timestamp_ms = int((time.perf_counter() - start) * 1000)
    timestamp_ms = max(raw_timestamp_ms, last_timestamp_ms + 1)
    last_timestamp_ms = timestamp_ms

    result = landmarker.detect_for_video(mp_image, timestamp_ms)

    if result.pose_world_landmarks:
        pts = landmarks_to_points(result.pose_world_landmarks[0])
        # 関節(球)を毎回基準メッシュから配置(累積を避ける)
        for k, jid in enumerate(JOINT_IDS):
            m = o3d.geometry.TriangleMesh(joint_base)
            m.translate(pts[jid])
            joints[k].vertices = m.vertices
        # 骨(円柱)を毎回基準メッシュから変換
        for k, (a, b) in enumerate(POSE_CONNECTIONS):
            T = bone_transform(pts[a], pts[b])
            if T is None:   # 特異点:この骨は直前の状態を保つ
                continue
            m = o3d.geometry.TriangleMesh(bone_base)
            m.transform(T)
            bones[k].vertices = m.vertices

        if not added:
            for g in bones + joints:
                vis.add_geometry(g)
            added = True
        for g in bones + joints:
            g.compute_vertex_normals()
            vis.update_geometry(g)

    running = vis.poll_events()
    vis.update_renderer()
    cv2.imshow("camera", frame)
    if cv2.waitKey(1) == 27:   # ESC(映像ウィンドウがアクティブなとき)
        break

cap.release()
cv2.destroyAllWindows()
vis.destroy_window()

ヒント

描画する関節と接続は JOINT_IDSPOSE_CONNECTIONS で限定する。タイムスタンプは、演習4と同じくフレームごとに厳密に単調増加させる。終了は映像ウィンドウがアクティブな状態で ESC キーを押す。

考察ポイント

円柱の太さが骨の長さによらず一定に保たれるか、関節と骨の接続が破綻しないかを確認する。

演習6.カメラ+MediaPipeでglTF標準アバターをスキニング駆動

手順

アバターには Khronos glTF-Sample-Models の CesiumMan(高さ約1.5mの人型モデル)を用い、./avatar.glb へダウンロードする(ファイルが無い場合のみ)。pygltflib でメッシュとスキニングデータ(JOINTS_0WEIGHTS_0inverseBindMatrices)を読み込む。glTFのノード変換はTRS形式またはmatrix形式で格納されるため、どちらの場合も平行移動とスケールを保ち、回転だけを差し替える。ボーンの階層からグローバル変換を計算し、MediaPipeのランドマークが示す方向(親側の点から子側の点へ向かうベクトル)へ各ボーンの向きを合わせる回転を、親から子の順に求め、LBSで頂点位置を更新する。胴は左右の腰の中点から左右の肩の中点へ向かう方向で駆動する。左腕のボーン(ランドマーク 11, 13, 15)はアバターの左側ボーンへ、右腕のボーン(12, 14, 16)は右側ボーンへ対応させ、左脚のボーン(ランドマーク 23, 25, 27, 31)はアバターの左側ボーンへ、右脚のボーン(24, 26, 28, 32)は右側ボーンへ対応させる(腕の左右対応と同じ向きにそろえる)。ボーンの回転のみを更新し、ボーンの長さ(平行移動)は変えないため、アバターの体格は保たれる。テクスチャは扱わず、メッシュは単色で表示する。VIDEOモードへ渡すタイムスタンプは、同じミリ秒値が連続しないようにして、各フレームで厳密に増加させる(メモ帳を用いる場合は a.py のようなファイル名で保存して実行)。

import os
import time
import struct
import urllib.request
import numpy as np
import cv2
import open3d as o3d
import mediapipe as mp
from mediapipe.tasks import python as mp_python
from mediapipe.tasks.python import vision
from pygltflib import GLTF2

BASE_DIR = os.path.dirname(os.path.abspath(__file__))
MODEL_URL = "https://storage.googleapis.com/mediapipe-models/pose_landmarker/pose_landmarker_lite/float16/1/pose_landmarker_lite.task"
MODEL_PATH = os.path.join(BASE_DIR, "pose_landmarker_lite.task")
GLB_URL = "https://raw.githubusercontent.com/KhronosGroup/glTF-Sample-Models/master/2.0/CesiumMan/glTF-Binary/CesiumMan.glb"
GLB_PATH = os.path.join(BASE_DIR, "avatar.glb")
VIS_MIN = 0.5    # ランドマークの可視度がこの値未満なら直前の姿勢を保つ
DEBUG = False    # True にすると座標軸を表示し、駆動ボーンの診断を表示する

if not os.path.exists(MODEL_PATH):
    urllib.request.urlretrieve(MODEL_URL, MODEL_PATH)
if not os.path.exists(GLB_PATH):
    urllib.request.urlretrieve(GLB_URL, GLB_PATH)

# ---- glTF の読み込み ----
gltf = GLTF2().load(GLB_PATH)
blob = gltf.binary_blob()

# アクセサの数値データを numpy 配列として読み出す
CTYPE = {
    5120: ("b", 1),   # BYTE
    5121: ("B", 1),   # UNSIGNED_BYTE
    5122: ("h", 2),   # SHORT
    5123: ("H", 2),   # UNSIGNED_SHORT
    5125: ("I", 4),   # UNSIGNED_INT
    5126: ("f", 4),   # FLOAT
}
NCOMP = {"SCALAR": 1, "VEC2": 2, "VEC3": 3, "VEC4": 4, "MAT4": 16}
NORMALIZE_DIV = {5120: 127.0, 5121: 255.0, 5122: 32767.0, 5123: 65535.0}

def read_accessor(idx):
    acc = gltf.accessors[idx]
    bv = gltf.bufferViews[acc.bufferView]
    fmt, size = CTYPE[acc.componentType]
    n = NCOMP[acc.type]
    start = (bv.byteOffset or 0) + (acc.byteOffset or 0)
    stride = bv.byteStride if bv.byteStride else size * n
    data = np.zeros((acc.count, n), dtype=float)
    for i in range(acc.count):
        data[i] = struct.unpack_from("<" + fmt * n, blob, start + i * stride)
    if acc.normalized and acc.componentType in NORMALIZE_DIV:
        data = data / NORMALIZE_DIV[acc.componentType]
        if acc.componentType in (5120, 5122):
            data = np.maximum(data, -1.0)
    return data

# クォータニオン (x, y, z, w) を回転行列へ変換
def quat_to_matrix(q):
    x, y, z, w = q
    return np.array([
        [1 - 2 * (y * y + z * z), 2 * (x * y - z * w), 2 * (x * z + y * w)],
        [2 * (x * y + z * w), 1 - 2 * (x * x + z * z), 2 * (y * z - x * w)],
        [2 * (x * z - y * w), 2 * (y * z + x * w), 1 - 2 * (x * x + y * y)],
    ])

# ノードの基準ローカル変換行列(matrix または TRS)
def node_base_matrix(node):
    if node.matrix:
        return np.array(node.matrix, dtype=float).reshape(4, 4).T
    R = quat_to_matrix(node.rotation) if node.rotation else np.eye(3)
    S = np.diag(node.scale) if node.scale else np.eye(3)
    M = np.eye(4)
    M[:3, :3] = R @ S
    if node.translation:
        M[:3, 3] = node.translation
    return M

# ノードのローカル変換行列。rot_override があるときは平行移動とスケールを保ち、回転のみ差し替える
def node_local_matrix(node, rot_override=None):
    M = node_base_matrix(node)
    if rot_override is not None:
        scales = np.linalg.norm(M[:3, :3], axis=0)
        M[:3, :3] = rot_override @ np.diag(scales)
    return M

# ノードの親子関係と、親→子の処理順
parents = [-1] * len(gltf.nodes)
for i, n in enumerate(gltf.nodes):
    for c in (n.children or []):
        parents[c] = i

node_order = []
def visit(i):
    node_order.append(i)
    for c in (gltf.nodes[i].children or []):
        visit(c)

for i in range(len(gltf.nodes)):
    if parents[i] == -1:
        visit(i)

name_to_node = {n.name: i for i, n in enumerate(gltf.nodes)}

# 全ノードのグローバル変換を計算(local_rots で指定したボーンは回転を差し替え)
def global_transforms(local_rots):
    G = [None] * len(gltf.nodes)
    for i in node_order:
        L = node_local_matrix(gltf.nodes[i], local_rots.get(i))
        G[i] = L if parents[i] == -1 else G[parents[i]] @ L
    return G

# ---- メッシュとスキニングデータ ----
mesh_node = [i for i, n in enumerate(gltf.nodes) if n.mesh is not None][0]
prim = gltf.meshes[gltf.nodes[mesh_node].mesh].primitives[0]
verts = read_accessor(prim.attributes.POSITION)
verts_h = np.concatenate([verts, np.ones((len(verts), 1))], axis=1)
tris = read_accessor(prim.indices).astype(int).reshape(-1, 3)
joint_ids = read_accessor(prim.attributes.JOINTS_0).astype(int)
weights = read_accessor(prim.attributes.WEIGHTS_0)
skin = gltf.skins[0]
ibm = read_accessor(skin.inverseBindMatrices).reshape(-1, 4, 4).transpose(0, 2, 1)
skin_joints = skin.joints

# LBS:頂点位置 = Σ ウェイト × (グローバル変換 @ inverseBindMatrix) @ 頂点
def skin_vertices(G):
    M = np.stack([G[skin_joints[k]] @ ibm[k] for k in range(len(skin_joints))])
    out = np.zeros((len(verts), 3))
    for k in range(joint_ids.shape[1]):
        p = np.einsum("nij,nj->ni", M[joint_ids[:, k]], verts_h)[:, :3]
        out += weights[:, k:k + 1] * p
    return out

# ベクトル a をベクトル b へ向ける回転行列(Rodrigues の公式)
def rotation_between(a, b):
    axis = np.cross(a, b)
    axis_len = np.linalg.norm(axis)
    if axis_len < 1e-6:
        if np.dot(a, b) > 0:
            return np.eye(3)    # 同方向:回転不要
        p = np.cross(a, [1.0, 0.0, 0.0])
        if np.linalg.norm(p) < 1e-6:
            p = np.cross(a, [0.0, 1.0, 0.0])
        p = p / np.linalg.norm(p)
        return 2.0 * np.outer(p, p) - np.eye(3)    # 反対方向:垂直軸まわりに180度
    axis = axis / axis_len
    angle = np.arccos(np.clip(np.dot(a, b), -1.0, 1.0))
    c, s = np.cos(angle), np.sin(angle)
    K = np.array([[0, -axis[2], axis[1]],
                  [axis[2], 0, -axis[0]],
                  [-axis[1], axis[0], 0]])
    return np.eye(3) + s * K + (1 - c) * (K @ K)

# 3×3変換から回転成分を取り出す
def rotation_only(A):
    u, _, vt = np.linalg.svd(A)
    R = u @ vt
    if np.linalg.det(R) < 0:
        u[:, -1] *= -1
        R = u @ vt
    return R

# 駆動するボーン:(ボーン名, 子ボーン名, 始点ランドマーク, 終点ランドマーク)
# 始点・終点が複数のときは中点を用いる。ボーン名は CesiumMan 固有
DRIVEN = [
    ("Skeleton_torso_joint_1", "Skeleton_torso_joint_2", [23, 24], [11, 12]),   # 胴
    ("Skeleton_arm_joint_L__4_", "Skeleton_arm_joint_L__3_", [11], [13]),       # 左上腕
    ("Skeleton_arm_joint_L__3_", "Skeleton_arm_joint_L__2_", [13], [15]),       # 左前腕
    ("Skeleton_arm_joint_R", "Skeleton_arm_joint_R__2_", [12], [14]),           # 右上腕
    ("Skeleton_arm_joint_R__2_", "Skeleton_arm_joint_R__3_", [14], [16]),       # 右前腕
    ("leg_joint_L_1", "leg_joint_L_2", [23], [25]),                             # 左太もも
    ("leg_joint_L_2", "leg_joint_L_3", [25], [27]),                             # 左すね
    ("leg_joint_L_3", "leg_joint_L_5", [27], [31]),                             # 左足
    ("leg_joint_R_1", "leg_joint_R_2", [24], [26]),                             # 右太もも
    ("leg_joint_R_2", "leg_joint_R_3", [26], [28]),                             # 右すね
    ("leg_joint_R_3", "leg_joint_R_5", [28], [32]),                             # 右足
]

local_rots = {}    # ボーンごとのローカル回転(前フレームの状態を保持)

# ランドマークに合わせて DRIVEN の各ボーンを親から順に回転させる
def update_pose(pts, visibility):
    for bone_name, child_name, src, dst in DRIVEN:
        ids = src + dst
        if min(visibility[i] for i in ids) < VIS_MIN:
            continue    # 可視度が低い:直前の姿勢を保つ
        t = np.mean(pts[dst], axis=0) - np.mean(pts[src], axis=0)
        t_len = np.linalg.norm(t)
        if t_len < 1e-6:
            continue    # 特異点:直前の姿勢を保つ
        t = t / t_len

        G = global_transforms(local_rots)
        j = name_to_node[bone_name]
        c = name_to_node[child_name]
        d = G[c][:3, 3] - G[j][:3, 3]
        d_len = np.linalg.norm(d)
        if d_len < 1e-6:
            continue    # 特異点:直前の姿勢を保つ
        d = d / d_len

        joint_rot = rotation_only(G[j][:3, :3])
        parent_rot = np.eye(3) if parents[j] == -1 else rotation_only(G[parents[j]][:3, :3])
        new_global_rot = rotation_between(d, t) @ joint_rot
        local_rots[j] = parent_rot.T @ new_global_rot

# world ランドマークを Open3D 用座標に変換(鏡像なし)
def landmarks_to_points(landmarks):
    return np.array([[lm.x, -lm.y, -lm.z] for lm in landmarks])

if DEBUG:
    G0 = global_transforms({})
    for bone_name, child_name, src, dst in DRIVEN:
        j, c = name_to_node[bone_name], name_to_node[child_name]
        d = G0[c][:3, 3] - G0[j][:3, 3]
        print(bone_name, "バインド姿勢方向:", np.round(d / np.linalg.norm(d), 2), "ランドマーク:", src, "->", dst)

options = vision.PoseLandmarkerOptions(
    base_options=mp_python.BaseOptions(model_asset_path=MODEL_PATH),
    running_mode=vision.RunningMode.VIDEO,
)
landmarker = vision.PoseLandmarker.create_from_options(options)

cap = cv2.VideoCapture(0)   # カメラが無い場合は "movie.mp4" に変更

# アバターのメッシュ(バインド姿勢で初期化)
avatar = o3d.geometry.TriangleMesh()
avatar.vertices = o3d.utility.Vector3dVector(skin_vertices(global_transforms(local_rots)))
avatar.triangles = o3d.utility.Vector3iVector(tris)
avatar.paint_uniform_color([0.7, 0.7, 0.75])
avatar.compute_vertex_normals()

vis = o3d.visualization.Visualizer()
vis.create_window()
opt = vis.get_render_option()
opt.background_color = np.array([0.1, 0.1, 0.1])
opt.light_on = True
opt.mesh_show_back_face = True
vis.add_geometry(avatar)
if DEBUG:
    vis.add_geometry(o3d.geometry.TriangleMesh.create_coordinate_frame(size=0.5))

start = time.perf_counter()
last_timestamp_ms = -1
running = True
while running:
    ok, frame = cap.read()
    if not ok:
        break
    rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
    mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=rgb)

    raw_timestamp_ms = int((time.perf_counter() - start) * 1000)
    timestamp_ms = max(raw_timestamp_ms, last_timestamp_ms + 1)
    last_timestamp_ms = timestamp_ms

    result = landmarker.detect_for_video(mp_image, timestamp_ms)

    if result.pose_world_landmarks:
        lms = result.pose_world_landmarks[0]
        pts = landmarks_to_points(lms)
        visibility = [lm.visibility for lm in lms]
        update_pose(pts, visibility)
        avatar.vertices = o3d.utility.Vector3dVector(skin_vertices(global_transforms(local_rots)))
        avatar.compute_vertex_normals()
        vis.update_geometry(avatar)

    running = vis.poll_events()
    vis.update_renderer()
    cv2.imshow("camera", frame)
    if cv2.waitKey(1) == 27:   # ESC(映像ウィンドウがアクティブなとき)
        break

cap.release()
cv2.destroyAllWindows()
vis.destroy_window()

ヒント

操作するボーンは主要なもの(腕・脚・胴)に限る(首・指などの細かいボーンは対応しない)。DRIVEN のボーン名はこのアバター(CesiumMan)固有のため、別のモデルを使う場合は name_to_node で名前を確認して対応付けを修正する。CesiumMan のボーンノードはTRS形式ではなくmatrix形式で格納されている場合があるため、コードではどちらの形式でも平行移動とスケールを保ったまま回転だけを差し替える。バインド姿勢方向の長さがほぼ0のとき、または可視度(visibility)が VIS_MIN 未満のときは回転を適用せず直前の姿勢を保つ。アバターの向きがずれる場合は DEBUGTrue にして座標軸と駆動ボーンの診断表示を有効にする(既定は False)。終了は映像ウィンドウがアクティブな状態で ESC キーを押す。

考察ポイント

被写体の動きにメッシュが追従するか、左右の対応が腕・脚で一致するか、関節付近のメッシュ変形が自然か(ウェイトの効果)を確認する。