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

【目次】

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

この資料で学ぶこと

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

主要用語

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

単位とスケール

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

動作環境

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 opencv-contrib-python numpy

ファイルの配置

作業ディレクトリはスクリプトと同じ場所とする。次のファイルを同一ディレクトリ以下に置く。

各演習のコードは、ファイルが無い場合のみ初回実行時に自動でダウンロードする。

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

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

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

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

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

2つの位置が一直線に近づくと、方向ベクトルから回転を求める計算で除算が不安定になる。本資料では、ベクトル長がほぼ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 で得た秒値をミリ秒へ換算した整数を用いる。

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

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

手順

次のURLからBVHファイルをダウンロードし、./mocap_data/001/01_01.bvh に保存する。ファイルを開き、骨格の階層構造、フレーム数、フレーム間隔(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フレームの秒数が記載される。

考察ポイント

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

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

手順

演習1と同じBVHを用いる。指定フレームについてFKで各関節位置を計算し、Open3Dで関節を球(半径1.5)、骨を LineSet として描画する。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)

o3d.visualization.draw_geometries(geoms)

ヒント

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

考察ポイント

球の位置と骨の接続が、演習1で確認した階層構造と一致するかを確認する。

演習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)

# parse_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)

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 にすると最終フレームで停止する。

考察ポイント

再生速度がフレーム間隔と一致するか、最終フレームで停止するかループするかを確認する。

演習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の映像へ重畳表示する(メモ帳を用いる場合は 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()
added = False

start = time.perf_counter()
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)
    timestamp_ms = int((time.perf_counter() - start) * 1000)
    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 で得た秒値をミリ秒へ換算した整数を用い、フレームごとに単調増加させる。終了は映像ウィンドウがアクティブな状態で ESC キーを押す。

考察ポイント

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

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

手順

姿勢推定モデルは演習4と同じものを ./pose_landmarker_lite.task から用いる(ファイルが無い場合のみダウンロードする)。骨を円柱メッシュ、関節を球メッシュで表す。基準となる円柱と球を1つずつ作成し、ボーン数・関節数だけ複製して各フレームで変換する。円柱は bone_transform で長さ方向(Z軸)のみを骨の長さに合わせて伸縮させ、太さ(半径)は一定に保つ。各フレームの変換は前フレームの累積を避けるため、毎回基準メッシュから計算する(メモ帳を用いる場合は 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 にスケールし、半径は一定に保つ
def bone_transform(p0, p1):
    d = p1 - p0
    length = np.linalg.norm(d)
    T = np.eye(4)
    if length < 1e-6:    # 特異点:長さ0なら回転を適用しない
        T[:3, 3] = p0
        return T
    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[: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
added = False

start = time.perf_counter()
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)
    timestamp_ms = int((time.perf_counter() - start) * 1000)
    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])
            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 で限定する。終了は映像ウィンドウがアクティブな状態で ESC キーを押す。

考察ポイント

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

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

手順

glTFアバターを ./avatar.glb へダウンロードする(ファイルが無い場合のみ)。pygltflib でメッシュとスキニングデータ(JOINTS_0WEIGHTS_0inverseBindMatrices)を読み込む。ボーンの階層からグローバル変換を計算し、MediaPipeのランドマークに基づいて主要ボーンを回転させ、LBSで頂点位置を更新する。左脚のボーン(ランドマーク 23, 25, 27, 31)はアバターの左側ボーンへ、右脚のボーン(24, 26, 28, 32)は右側ボーンへ対応させる(腕の左右対応と同じ向きにそろえる)。


調整中

ヒント

操作するボーンは主要なもの(腕・脚・胴)に限る(指などの細かいボーンは対応しない)。DRIVEN のボーン名はこのアバター(CesiumMan)固有のため、別のモデルを使う場合は name_to_node で名前を確認して対応付けを修正する。バインド姿勢方向の長さがほぼ0のとき、または可視度が低いときは回転を適用せず直前の姿勢を保つ。アバターの向きがずれる場合は DEBUGTrue にして軸の診断表示を有効にする(既定は False)。終了は映像ウィンドウがアクティブな状態で ESC キーを押す。

考察ポイント

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