3D姿勢推定・モーションキャプチャの応用演習:生活・社会に根ざした全身動作の活用

サイト内の関連ページ: Open3D + MediaPipe による3Dモーションキャプチャ/アバター駆動

目次

テーマ名と概要

本資料は、単眼カメラと MediaPipe Pose Landmarker(pose_world_landmarks:腰中心を原点とする実寸メートル単位の3次元ワールド座標)だけで全身の3D姿勢を取得し、生活や社会の課題に役立てる応用演習を6つ示します。先行する基礎演習(BVHモーションの順運動学、Open3D での骨格・メッシュ描画、glTF/VRMアバターのスキニング)で学んだ技術を、見守り・リハビリ・人間工学・動作記録・歩行解析・型の採点といった実用テーマに展開するのが本資料の狙いです。とくに最後の演習6では、基礎演習で扱った BVH モーションと順運動学(FK)を「手本のゴースト骨格」として再利用し、これに DTW(動的時間伸縮)による時間合わせを組み合わせて、リアルタイム姿勢推定と既存モーション資産をつなぐ発展課題に取り組みます。

この技術が他の手法と比べて優れている点は3つあります。第一に、単眼カメラだけで実寸メートル単位の全身3D姿勢が得られるため、専用の光学式モーションキャプチャ室や深度センサ、装着型センサが不要です。第二に、出力が関節座標(数値)であるため、映像そのものを保存・送信せず、容貌を含まない匿名の動作記録としてローカルに残せます。第三に、すべてローカル(Windowsパソコン上)で完結し、クラウドへの送信やAPIキーを必要としないため、ネットワークが使えない現場やプライバシー配慮が必要な場面でも動きます。これらの強みを軸に、各演習を構成しています。

なお本資料は、基礎演習の「はじめに」に記載した環境・前提(Windows、Python 3.9〜3.12、Webカメラまたは動画ファイル、ESC または3D表示ウィンドウを閉じて終了、VIDEOモードの同期処理)をそのまま引き継ぎます。Linux などの追加知識は一切必要ありません。GPU 搭載機・CPU のみのどちらでも動作します(軽量な pose_landmarker_lite を使うため、CPU のみでも動きます)。本資料の6演習で実際に使うパッケージは次の4つだけです。

pip install --no-user mediapipe opencv-python open3d numpy

DTW(演習6)は外部パッケージを使わず numpy だけで実装するため、追加インストールは不要です。重複する説明は省きますので、未読の場合は基礎演習の「はじめに」を先にご覧ください。

保存先について(重要)

各スクリプトは、モデル・データ・出力ファイル(CSV・スクリーンショット等)をコードが保存されているフォルダBASE_DIR = os.path.dirname(os.path.abspath(__file__)))の配下に保存します。これは、コマンドプロンプトで C:\ のような書き込み権限のない場所から実行された場合でも、書き込み可能なコード保存フォルダ側へ確実に保存させるためです。ダウンロード済みのモデル・データがあれば再ダウンロードはスキップします。各演習は単独で動作します。

共通の前提(各演習で繰り返さない事項)


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 ウィンドウは,マウスクリックでウィンドウをアクティブ(操作対象の状態)にしてからキーを押すと終了する

演習1:転倒・姿勢崩れの見守りセンサ

手順

カメラ(または動画)から全身3D姿勢を取得し、頭部と腰の高さ・体幹の鉛直に対する傾き角・腰の鉛直方向の落下速度から転倒を判定します。判定に使うのは pose_world_landmarks の数値だけで、映像は保存しません。転倒を検知すると、その時刻と指標を BASE_DIR/fall_events.csv に追記し、カメラ映像にも警告を表示します。3D骨格を別ウィンドウに匿名表示します。終了は ESC または3D表示ウィンドウを閉じます。

"""
演習1: 転倒・姿勢崩れの見守りセンサ
- 単眼カメラ + MediaPipe Pose (VIDEOモード) で全身3D姿勢を取得
- 体幹の傾き角・腰の落下速度から転倒を検知し、イベントのみをCSVに記録(映像は保存しない)
- 3D骨格を匿名表示
依存: mediapipe, opencv-python, open3d, numpy
"""

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

# === 保存先はコード保存フォルダ。モデルが無ければDL ==========================
MODEL_URL = "https://storage.googleapis.com/mediapipe-models/pose_landmarker/pose_landmarker_lite/float16/1/pose_landmarker_lite.task"
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
MODEL_PATH = os.path.join(BASE_DIR, "pose_landmarker_lite.task")
CSV_PATH = os.path.join(BASE_DIR, "fall_events.csv")
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),
    (0, 11), (0, 12),
]
NUM_LANDMARKS = 33

# 転倒判定のしきい値(目安。利用環境に合わせて調整する)
TILT_THRESHOLD_DEG = 45.0   # 体幹が鉛直からこの角度以上傾いたら「傾き」
FALL_SPEED_MPS = 1.2        # 腰がこの速度(m/s)以上で下降したら「落下」
LOW_HIP_RATIO = 0.4         # 腰の高さが立位時のこの割合以下なら「低姿勢」

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


def landmarks_to_points(world_landmarks):
    pts = np.zeros((NUM_LANDMARKS, 3))
    for i, lm in enumerate(world_landmarks):
        pts[i] = [lm.x, -lm.y, -lm.z]
    return pts


def trunk_tilt_deg(pts):
    """体幹ベクトル(腰中心->肩中心)と鉛直(+y)のなす角[度]。"""
    hip = 0.5 * (pts[23] + pts[24])
    sho = 0.5 * (pts[11] + pts[12])
    trunk = sho - hip
    n = np.linalg.norm(trunk)
    if n < 1e-6:
        return 0.0
    cos = np.clip(np.dot(trunk / n, np.array([0.0, 1.0, 0.0])), -1.0, 1.0)
    return float(np.degrees(np.arccos(cos)))


def draw_landmarks_2d(image, norm_landmarks):
    h, w = image.shape[:2]
    px = [(int(lm.x * w), int(lm.y * h)) for lm in norm_landmarks]
    for a, b in POSE_CONNECTIONS:
        cv2.line(image, px[a], px[b], (255, 200, 0), 2)
    for (x, y) in px:
        cv2.circle(image, (x, y), 4, (0, 0, 255), -1)


if not os.path.exists(CSV_PATH):
    with open(CSV_PATH, "w", newline="") as f:
        csv.writer(f).writerow(["unix_time", "tilt_deg", "hip_speed_mps", "reason"])

cap = cv2.VideoCapture(0)   # カメラ。動画で試す場合は VideoCapture("movie.mp4")
FPS_ASSUMED = 30.0
frame_index = 0

init_pts = np.zeros((NUM_LANDMARKS, 3))
line_set = o3d.geometry.LineSet(
    points=o3d.utility.Vector3dVector(init_pts),
    lines=o3d.utility.Vector2iVector(np.array(POSE_CONNECTIONS)))
line_set.paint_uniform_color([0.1, 0.4, 0.9])
pcd = o3d.geometry.PointCloud()
pcd.points = o3d.utility.Vector3dVector(init_pts)
pcd.paint_uniform_color([0.85, 0.2, 0.2])

vis = o3d.visualization.Visualizer()
vis.create_window(window_name="Watch (3D skeleton)", width=900, height=700)
vis.add_geometry(line_set)
vis.add_geometry(pcd)
vis.add_geometry(o3d.geometry.TriangleMesh.create_coordinate_frame(size=0.5))

prev_hip_y = None
prev_t = None
standing_hip_y = None     # 立位時の腰の高さの基準(観測中の最大値で更新)
alert_until = 0.0         # 警告表示を一定時間保持

while True:
    ret, frame = cap.read()
    if not ret:
        break

    rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
    mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=rgb)
    timestamp_ms = int(frame_index * 1000.0 / FPS_ASSUMED)
    frame_index += 1
    now = time.time()

    result = landmarker.detect_for_video(mp_image, timestamp_ms)

    if result.pose_world_landmarks:
        pts = landmarks_to_points(result.pose_world_landmarks[0])
        new_pts = o3d.utility.Vector3dVector(pts)
        line_set.points = new_pts
        pcd.points = new_pts
        vis.update_geometry(line_set)
        vis.update_geometry(pcd)

        tilt = trunk_tilt_deg(pts)
        hip_y = 0.5 * (pts[23][1] + pts[24][1])

        if standing_hip_y is None or hip_y > standing_hip_y:
            standing_hip_y = hip_y

        hip_speed = 0.0
        if prev_hip_y is not None and prev_t is not None:
            dt = max(now - prev_t, 1e-3)
            hip_speed = (prev_hip_y - hip_y) / dt   # 正なら下降
        prev_hip_y, prev_t = hip_y, now

        reasons = []
        if tilt >= TILT_THRESHOLD_DEG:
            reasons.append("tilt")
        if hip_speed >= FALL_SPEED_MPS:
            reasons.append("fast_drop")
        if standing_hip_y is not None and hip_y <= standing_hip_y * LOW_HIP_RATIO:
            reasons.append("low_posture")

        if reasons:
            alert_until = now + 2.0
            with open(CSV_PATH, "a", newline="") as f:
                csv.writer(f).writerow(
                    [f"{now:.3f}", f"{tilt:.1f}", f"{hip_speed:.2f}", "|".join(reasons)])

    if result.pose_landmarks:
        draw_landmarks_2d(frame, result.pose_landmarks[0])

    if now < alert_until:
        cv2.putText(frame, "FALL ALERT", (30, 60),
                    cv2.FONT_HERSHEY_SIMPLEX, 1.5, (0, 0, 255), 3)

    if not vis.poll_events():
        break
    vis.update_renderer()
    cv2.imshow("camera", frame)

    if cv2.waitKey(1) & 0xFF == 27:
        break

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

ヒント

体幹の傾きは「腰中心→肩中心」のベクトルと鉛直軸(+y)のなす角で求めます。立位では小さく、転倒で体幹が水平に近づくほど90度へ近づきます。腰の落下速度は、前フレームとの腰の高さの差を経過時間で割って求めます。映像を保存しないことが本演習の要点で、記録するのは時刻と指標だけです(fall_events.csv)。しきい値(TILT_THRESHOLD_DEG 等)は環境や対象者で変わるため、実際に座る・しゃがむ・寝転ぶ動作を試し、誤検知と見逃しのバランスを見ながら調整します。

考察ポイント

座る・しゃがむなどの正常な動作と、転倒とを区別するには、傾き・落下速度・低姿勢のどれを組み合わせるのが有効かを考えます。映像を残さず座標だけを記録する方式が、見守りの受容性(撮られている抵抗感の低減)にどう寄与するかを検討します。単眼カメラの3D姿勢は奥行き方向の誤差が比較的大きいため、カメラの設置位置・高さが検知精度にどう影響するかを観察します。


演習2:リハビリ・運動指導の左右対称性チェッカー

手順

全身3D姿勢から、左右の肩・肘・股・膝の関節角度を計算し、左右差(絶対値の差)をカメラ映像の左上にリアルタイムに数値表示します。左右差が閾値(DIFF_WARN_DEG)以上の項目は文字色を赤にして知らせます。3D骨格は別ウィンドウに表示し、視点を回して奥行きを確認できます。手本となる動作と比較したい場合は、後述のヒントのとおり基礎演習のBVH再生をゴースト表示として並べる拡張ができます。終了は ESC または3D表示ウィンドウを閉じます。

"""
演習2: リハビリ・運動指導の左右対称性チェッカー
- 単眼カメラ + MediaPipe Pose (VIDEOモード)
- 左右の肩・肘・股・膝の関節角度を3Dで計測し、左右差を数値化・可視化
依存: mediapipe, opencv-python, open3d, numpy
"""

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

# === 保存先はコード保存フォルダ。モデルが無ければDL ==========================
MODEL_URL = "https://storage.googleapis.com/mediapipe-models/pose_landmarker/pose_landmarker_lite/float16/1/pose_landmarker_lite.task"
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
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),
    (0, 11), (0, 12),
]
NUM_LANDMARKS = 33

# 計測する関節角度: (名前, 親, 中心, 子) を左右ペアで定義
ANGLES = {
    "elbow": ((11, 13, 15), (12, 14, 16)),   # 肩-肘-手首
    "shoulder": ((23, 11, 13), (24, 12, 14)),  # 腰-肩-肘
    "hip": ((11, 23, 25), (12, 24, 26)),     # 肩-腰-膝
    "knee": ((23, 25, 27), (24, 26, 28)),    # 腰-膝-足首
}
DIFF_WARN_DEG = 15.0   # 左右差がこれ以上で強調

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


def landmarks_to_points(world_landmarks):
    pts = np.zeros((NUM_LANDMARKS, 3))
    for i, lm in enumerate(world_landmarks):
        pts[i] = [lm.x, -lm.y, -lm.z]
    return pts


def joint_angle(pts, a, b, c):
    """3点 a-b-c のうち b を頂点とする角度[度]。"""
    v1 = pts[a] - pts[b]
    v2 = pts[c] - pts[b]
    n1, n2 = np.linalg.norm(v1), np.linalg.norm(v2)
    if n1 < 1e-6 or n2 < 1e-6:
        return 0.0
    cos = np.clip(np.dot(v1 / n1, v2 / n2), -1.0, 1.0)
    return float(np.degrees(np.arccos(cos)))


def draw_landmarks_2d(image, norm_landmarks):
    h, w = image.shape[:2]
    px = [(int(lm.x * w), int(lm.y * h)) for lm in norm_landmarks]
    for a, b in POSE_CONNECTIONS:
        cv2.line(image, px[a], px[b], (255, 200, 0), 2)
    for (x, y) in px:
        cv2.circle(image, (x, y), 4, (0, 0, 255), -1)


cap = cv2.VideoCapture(0)   # カメラ。動画で試す場合は VideoCapture("movie.mp4")
FPS_ASSUMED = 30.0
frame_index = 0

init_pts = np.zeros((NUM_LANDMARKS, 3))
line_set = o3d.geometry.LineSet(
    points=o3d.utility.Vector3dVector(init_pts),
    lines=o3d.utility.Vector2iVector(np.array(POSE_CONNECTIONS)))
line_set.paint_uniform_color([0.1, 0.4, 0.9])
pcd = o3d.geometry.PointCloud()
pcd.points = o3d.utility.Vector3dVector(init_pts)
pcd.paint_uniform_color([0.85, 0.2, 0.2])

vis = o3d.visualization.Visualizer()
vis.create_window(window_name="Symmetry checker (3D)", width=900, height=700)
vis.add_geometry(line_set)
vis.add_geometry(pcd)
vis.add_geometry(o3d.geometry.TriangleMesh.create_coordinate_frame(size=0.5))

while True:
    ret, frame = cap.read()
    if not ret:
        break

    rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
    mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=rgb)
    timestamp_ms = int(frame_index * 1000.0 / FPS_ASSUMED)
    frame_index += 1

    result = landmarker.detect_for_video(mp_image, timestamp_ms)

    if result.pose_world_landmarks:
        pts = landmarks_to_points(result.pose_world_landmarks[0])
        new_pts = o3d.utility.Vector3dVector(pts)
        line_set.points = new_pts
        pcd.points = new_pts
        vis.update_geometry(line_set)
        vis.update_geometry(pcd)

        # カメラ映像の左上に左右角度と差を表示
        y = 30
        for name, (left, right) in ANGLES.items():
            la = joint_angle(pts, *left)
            ra = joint_angle(pts, *right)
            diff = abs(la - ra)
            color = (0, 0, 255) if diff >= DIFF_WARN_DEG else (0, 200, 0)
            cv2.putText(frame, f"{name:9s} L={la:5.1f} R={ra:5.1f} d={diff:4.1f}",
                        (10, y), cv2.FONT_HERSHEY_SIMPLEX, 0.6, color, 2)
            y += 28

    if result.pose_landmarks:
        draw_landmarks_2d(frame, result.pose_landmarks[0])

    if not vis.poll_events():
        break
    vis.update_renderer()
    cv2.imshow("camera", frame)

    if cv2.waitKey(1) & 0xFF == 27:
        break

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

ヒント

関節角度は、対象関節を頂点とする2本のベクトル(例:肘なら「肘→肩」と「肘→手首」)のなす角で求めます。3D座標で計算するため、2D映像では奥行きで縮んで見える角度も正しく測れます。手本との比較を行いたい場合は、基礎演習1〜3で読み込んだBVHの関節座標を半透明のゴースト骨格(別の LineSet を薄い色で追加)として同じウィンドウに並べ、利用者がそれに追従する形にできます。左右差のしきい値 DIFF_WARN_DEG は対象動作で調整します。

考察ポイント

同じ動作でも、カメラに対して体を斜めに向けると左右の見かけの角度がどう変わるかを観察し、3D計測が向きの影響をどの程度受けにくいかを確認します。左右差が大きく出る関節と、その人の利き手・既往との関係を考えます。正常な左右差(誰にでもある個人差)と、矯正すべき非対称との境界をどう設定するかを検討します。


演習3:作業現場の人間工学(エルゴノミクス)リスク評価

手順

全身3D姿勢から、体幹前傾角・上肢挙上角・膝屈曲角を連続計測し、角度の大きさを段階(低・中・高負担)に区分して負担スコアを表示します。これは RULA/REBA(作業姿勢の負担を点数化する人間工学評価手法)の角度区分の考え方を簡略化したものです。3D表示を回転すると、2D映像では分かりにくい前傾・ひねりを確認できます。スコアの時系列を BASE_DIR/ergo_log.csv に記録します。終了は ESC または3D表示ウィンドウを閉じます。

"""
演習3: 作業現場の人間工学リスク評価
- 単眼カメラ + MediaPipe Pose (VIDEOモード)
- 体幹前傾角・上肢挙上角・膝屈曲角を計測し、簡易的な負担スコアに区分してCSV記録
依存: mediapipe, opencv-python, open3d, numpy
"""

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

# === 保存先はコード保存フォルダ。モデルが無ければDL ==========================
MODEL_URL = "https://storage.googleapis.com/mediapipe-models/pose_landmarker/pose_landmarker_lite/float16/1/pose_landmarker_lite.task"
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
MODEL_PATH = os.path.join(BASE_DIR, "pose_landmarker_lite.task")
CSV_PATH = os.path.join(BASE_DIR, "ergo_log.csv")
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),
    (0, 11), (0, 12),
]
NUM_LANDMARKS = 33

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


def landmarks_to_points(world_landmarks):
    pts = np.zeros((NUM_LANDMARKS, 3))
    for i, lm in enumerate(world_landmarks):
        pts[i] = [lm.x, -lm.y, -lm.z]
    return pts


def angle_from_vertical(vec):
    """ベクトルと鉛直(+y)のなす角[度]。"""
    n = np.linalg.norm(vec)
    if n < 1e-6:
        return 0.0
    cos = np.clip(np.dot(vec / n, np.array([0.0, 1.0, 0.0])), -1.0, 1.0)
    return float(np.degrees(np.arccos(cos)))


def joint_angle(pts, a, b, c):
    v1 = pts[a] - pts[b]
    v2 = pts[c] - pts[b]
    n1, n2 = np.linalg.norm(v1), np.linalg.norm(v2)
    if n1 < 1e-6 or n2 < 1e-6:
        return 0.0
    cos = np.clip(np.dot(v1 / n1, v2 / n2), -1.0, 1.0)
    return float(np.degrees(np.arccos(cos)))


def score_by_bands(value, bands):
    """value が bands=[(上限, 点数), ...] のどの区分かを返す。"""
    for upper, pts_score in bands:
        if value < upper:
            return pts_score
    return bands[-1][1]


def draw_landmarks_2d(image, norm_landmarks):
    h, w = image.shape[:2]
    px = [(int(lm.x * w), int(lm.y * h)) for lm in norm_landmarks]
    for a, b in POSE_CONNECTIONS:
        cv2.line(image, px[a], px[b], (255, 200, 0), 2)
    for (x, y) in px:
        cv2.circle(image, (x, y), 4, (0, 0, 255), -1)


if not os.path.exists(CSV_PATH):
    with open(CSV_PATH, "w", newline="") as f:
        csv.writer(f).writerow(
            ["unix_time", "trunk_deg", "arm_deg", "knee_flex_deg", "score"])

cap = cv2.VideoCapture(0)   # カメラ。動画で試す場合は VideoCapture("movie.mp4")
FPS_ASSUMED = 30.0
frame_index = 0

init_pts = np.zeros((NUM_LANDMARKS, 3))
line_set = o3d.geometry.LineSet(
    points=o3d.utility.Vector3dVector(init_pts),
    lines=o3d.utility.Vector2iVector(np.array(POSE_CONNECTIONS)))
line_set.paint_uniform_color([0.1, 0.4, 0.9])
pcd = o3d.geometry.PointCloud()
pcd.points = o3d.utility.Vector3dVector(init_pts)
pcd.paint_uniform_color([0.85, 0.2, 0.2])

vis = o3d.visualization.Visualizer()
vis.create_window(window_name="Ergonomics (3D)", width=900, height=700)
vis.add_geometry(line_set)
vis.add_geometry(pcd)
vis.add_geometry(o3d.geometry.TriangleMesh.create_coordinate_frame(size=0.5))

while True:
    ret, frame = cap.read()
    if not ret:
        break

    rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
    mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=rgb)
    timestamp_ms = int(frame_index * 1000.0 / FPS_ASSUMED)
    frame_index += 1

    result = landmarker.detect_for_video(mp_image, timestamp_ms)

    if result.pose_world_landmarks:
        pts = landmarks_to_points(result.pose_world_landmarks[0])
        new_pts = o3d.utility.Vector3dVector(pts)
        line_set.points = new_pts
        pcd.points = new_pts
        vis.update_geometry(line_set)
        vis.update_geometry(pcd)

        hip = 0.5 * (pts[23] + pts[24])
        sho = 0.5 * (pts[11] + pts[12])
        trunk_deg = angle_from_vertical(sho - hip)            # 体幹前傾
        # 上肢挙上は左右の大きい方(肩->肘 と鉛直下向きのなす角を挙上角に換算)
        arm_l = 180.0 - angle_from_vertical(pts[13] - pts[11])
        arm_r = 180.0 - angle_from_vertical(pts[14] - pts[12])
        arm_deg = max(arm_l, arm_r)
        # 膝屈曲は 180度(伸展)からの差。左右の大きい方
        knee_l = 180.0 - joint_angle(pts, 23, 25, 27)
        knee_r = 180.0 - joint_angle(pts, 24, 26, 28)
        knee_flex = max(knee_l, knee_r)

        # 簡易スコア(数字が大きいほど高負担)
        score = (score_by_bands(trunk_deg, [(20, 1), (45, 2), (60, 3), (999, 4)])
                 + score_by_bands(arm_deg, [(20, 1), (45, 2), (90, 3), (999, 4)])
                 + score_by_bands(knee_flex, [(30, 1), (60, 2), (999, 3)]))

        with open(CSV_PATH, "a", newline="") as f:
            csv.writer(f).writerow(
                [f"{time.time():.3f}", f"{trunk_deg:.1f}", f"{arm_deg:.1f}",
                 f"{knee_flex:.1f}", score])

        for i, txt in enumerate([
                f"trunk {trunk_deg:5.1f} deg",
                f"arm   {arm_deg:5.1f} deg",
                f"knee  {knee_flex:5.1f} deg",
                f"SCORE {score}"]):
            color = (0, 0, 255) if (txt.startswith("SCORE") and score >= 7) else (0, 200, 0)
            cv2.putText(frame, txt, (10, 30 + i * 28),
                        cv2.FONT_HERSHEY_SIMPLEX, 0.7, color, 2)

    if result.pose_landmarks:
        draw_landmarks_2d(frame, result.pose_landmarks[0])

    if not vis.poll_events():
        break
    vis.update_renderer()
    cv2.imshow("camera", frame)

    if cv2.waitKey(1) & 0xFF == 27:
        break

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

ヒント

体幹前傾角は「腰中心→肩中心」と鉛直のなす角、上肢挙上角は「肩→肘」を鉛直下向き基準から挙上量へ換算した値、膝屈曲角は伸展(180度)からの差で表します。score_by_bands は角度を段階点に変換する小さな関数で、区分の上限と点数を差し替えれば評価基準を調整できます。本演習のスコアは RULA/REBA の考え方を簡略化した独自の目安であり、正式な評価には公式の評価表と専門家の判断が必要です。

考察ポイント

同じ前傾でも、カメラに対して正面・横・斜めのどの向きで立つかによって、2D映像と3D計測でどれだけ評価が変わるかを観察します。前傾・挙上・屈曲のどれが負担スコアを支配しやすいかを、実際の作業動作(持ち上げ・棚入れなど)で確認します。簡易スコアを実務で使うには、公式評価手法のどの要素(荷重・反復回数・保持時間など)が不足しているかを考えます。


演習4:匿名3D骨格による行動記録・動作教材アーカイブ

手順

カメラ映像を保存せず、毎フレームの pose_world_landmarks を関節時系列として BASE_DIR/motion_record.npz(座標)と確認用の3D表示だけで記録します。記録モードと再生モードを変数 MODE の設定で切り替えます。記録モードでは骨格を表示しながら座標を蓄積し、終了時に保存します。再生モードでは保存済みの座標を骨格アニメーションとして再生します(基礎演習3と同じ更新方式)。終了は ESC または3D表示ウィンドウを閉じます。

"""
演習4: 匿名3D骨格による行動記録・動作教材アーカイブ
- MODE="record": カメラから pose_world_landmarks を時系列で蓄積し npz 保存(映像は保存しない)
- MODE="play"  : 保存済み座標を骨格アニメーションとして再生
依存: mediapipe(recordのみ), opencv-python(recordのみ), open3d, numpy
"""

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

# === 保存先はコード保存フォルダ ==============================================
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")
REC_PATH = os.path.join(BASE_DIR, "motion_record.npz")
# === ここまで ================================================================

MODE = "record"   # "record"=記録, "play"=再生
FPS_ASSUMED = 30.0

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),
    (0, 11), (0, 12),
]
NUM_LANDMARKS = 33


def landmarks_to_points(world_landmarks):
    pts = np.zeros((NUM_LANDMARKS, 3))
    for i, lm in enumerate(world_landmarks):
        pts[i] = [lm.x, -lm.y, -lm.z]
    return pts


def make_skeleton():
    init = np.zeros((NUM_LANDMARKS, 3))
    ls = o3d.geometry.LineSet(
        points=o3d.utility.Vector3dVector(init),
        lines=o3d.utility.Vector2iVector(np.array(POSE_CONNECTIONS)))
    ls.paint_uniform_color([0.1, 0.4, 0.9])
    pc = o3d.geometry.PointCloud()
    pc.points = o3d.utility.Vector3dVector(init)
    pc.paint_uniform_color([0.85, 0.2, 0.2])
    return ls, pc


if MODE == "record":
    import cv2
    import mediapipe as mp
    from mediapipe.tasks import python as mp_python
    from mediapipe.tasks.python import vision

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

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

    cap = cv2.VideoCapture(0)   # カメラ。動画で試す場合は VideoCapture("movie.mp4")
    frame_index = 0
    recorded = []

    line_set, pcd = make_skeleton()
    vis = o3d.visualization.Visualizer()
    vis.create_window(window_name="Recording (3D skeleton)", width=900, height=700)
    vis.add_geometry(line_set)
    vis.add_geometry(pcd)
    vis.add_geometry(o3d.geometry.TriangleMesh.create_coordinate_frame(size=0.5))

    while True:
        ret, frame = cap.read()
        if not ret:
            break
        rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
        mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=rgb)
        timestamp_ms = int(frame_index * 1000.0 / FPS_ASSUMED)
        frame_index += 1
        result = landmarker.detect_for_video(mp_image, timestamp_ms)
        if result.pose_world_landmarks:
            pts = landmarks_to_points(result.pose_world_landmarks[0])
            recorded.append(pts)
            new_pts = o3d.utility.Vector3dVector(pts)
            line_set.points = new_pts
            pcd.points = new_pts
            vis.update_geometry(line_set)
            vis.update_geometry(pcd)
        if not vis.poll_events():
            break
        vis.update_renderer()
        cv2.imshow("camera (NOT saved)", frame)
        if cv2.waitKey(1) & 0xFF == 27:
            break

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

    if recorded:
        np.savez_compressed(REC_PATH, frames=np.array(recorded), fps=FPS_ASSUMED)
        print(f"保存しました: {REC_PATH}  ({len(recorded)} フレーム)")
    else:
        print("記録なし(姿勢が検出されませんでした)")

else:  # MODE == "play"
    if not os.path.exists(REC_PATH):
        raise SystemExit("再生する記録がありません。先に MODE='record' で記録してください。")
    data = np.load(REC_PATH)
    all_pos = data["frames"]
    fps = float(data["fps"])
    n_frames = len(all_pos)
    print(f"再生: {n_frames} フレーム / {fps:.1f} FPS")

    line_set, pcd = make_skeleton()
    line_set.points = o3d.utility.Vector3dVector(all_pos[0])
    pcd.points = o3d.utility.Vector3dVector(all_pos[0])

    vis = o3d.visualization.Visualizer()
    vis.create_window(window_name="Playback (3D skeleton)", width=900, height=700)
    vis.add_geometry(line_set)
    vis.add_geometry(pcd)
    vis.add_geometry(o3d.geometry.TriangleMesh.create_coordinate_frame(size=0.5))

    frame = 0
    while True:
        pts = o3d.utility.Vector3dVector(all_pos[frame])
        line_set.points = pts
        pcd.points = pts
        vis.update_geometry(line_set)
        vis.update_geometry(pcd)
        if not vis.poll_events():
            break
        vis.update_renderer()
        time.sleep(1.0 / fps)
        frame = (frame + 1) % n_frames

    vis.destroy_window()

ヒント

記録されるのは関節座標の配列だけで、np.savez_compressedmotion_record.npz に保存します。映像は一切保存しないため、容貌を含まない動作データになります。再生モード(MODE="play")は MediaPipe もカメラも使わず、保存済み座標を基礎演習3と同じ「頂点差し替え」方式で動かします。記録フォーマットを関節時系列にしておくと、後から角度計算(演習2・3)や別形式(BVH互換の関節列)への変換にも再利用できます。

考察ポイント

映像アーカイブと骨格アーカイブを比べ、保存容量・プライバシー・後からの解析しやすさがどう違うかを考えます。容貌を含まない記録が、技能伝承や研究データ共有の場面でどの制約(肖像権・同意)を緩和するかを検討します。骨格だけでは失われる情報(表情・手指の細かな動き・道具との接触)が何かを確認し、用途に応じて何を補うべきかを考えます。


演習5:歩行解析による健康スクリーニング

手順

全身3D姿勢から、左右の足首の前後位置の時系列をもとに歩行周期(歩数)を検出し、歩幅の目安・歩行リズムの左右差を推定します。専用の床反力計やモーションキャプチャ室を使わず、単眼カメラだけで歩容の指標を得るのが狙いです。検出した1歩ごとの情報を BASE_DIR/gait_log.csv に記録し、カメラ映像に歩数と前に出ている足を表示します。終了は ESC または3D表示ウィンドウを閉じます。

"""
演習5: 歩行解析による健康スクリーニング
- 単眼カメラ + MediaPipe Pose (VIDEOモード)
- 足首の前後位置の入れ替わりから歩数を検出し、歩幅の目安・左右リズムを推定してCSV記録
依存: mediapipe, opencv-python, open3d, numpy
"""

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

# === 保存先はコード保存フォルダ。モデルが無ければDL ==========================
MODEL_URL = "https://storage.googleapis.com/mediapipe-models/pose_landmarker/pose_landmarker_lite/float16/1/pose_landmarker_lite.task"
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
MODEL_PATH = os.path.join(BASE_DIR, "pose_landmarker_lite.task")
CSV_PATH = os.path.join(BASE_DIR, "gait_log.csv")
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),
    (0, 11), (0, 12),
]
NUM_LANDMARKS = 33

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


def landmarks_to_points(world_landmarks):
    pts = np.zeros((NUM_LANDMARKS, 3))
    for i, lm in enumerate(world_landmarks):
        pts[i] = [lm.x, -lm.y, -lm.z]
    return pts


def draw_landmarks_2d(image, norm_landmarks):
    h, w = image.shape[:2]
    px = [(int(lm.x * w), int(lm.y * h)) for lm in norm_landmarks]
    for a, b in POSE_CONNECTIONS:
        cv2.line(image, px[a], px[b], (255, 200, 0), 2)
    for (x, y) in px:
        cv2.circle(image, (x, y), 4, (0, 0, 255), -1)


if not os.path.exists(CSV_PATH):
    with open(CSV_PATH, "w", newline="") as f:
        csv.writer(f).writerow(
            ["unix_time", "which_foot", "step_interval_s", "step_length_m"])

cap = cv2.VideoCapture(0)   # カメラ。動画で試す場合は VideoCapture("movie.mp4")
FPS_ASSUMED = 30.0
frame_index = 0

init_pts = np.zeros((NUM_LANDMARKS, 3))
line_set = o3d.geometry.LineSet(
    points=o3d.utility.Vector3dVector(init_pts),
    lines=o3d.utility.Vector2iVector(np.array(POSE_CONNECTIONS)))
line_set.paint_uniform_color([0.1, 0.4, 0.9])
pcd = o3d.geometry.PointCloud()
pcd.points = o3d.utility.Vector3dVector(init_pts)
pcd.paint_uniform_color([0.85, 0.2, 0.2])

vis = o3d.visualization.Visualizer()
vis.create_window(window_name="Gait (3D)", width=900, height=700)
vis.add_geometry(line_set)
vis.add_geometry(pcd)
vis.add_geometry(o3d.geometry.TriangleMesh.create_coordinate_frame(size=0.5))

step_count = 0
last_leading = None        # 直前に前に出ていた足 "L"/"R"
last_step_time = None
last_ankle_gap = 0.0

while True:
    ret, frame = cap.read()
    if not ret:
        break

    rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
    mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=rgb)
    timestamp_ms = int(frame_index * 1000.0 / FPS_ASSUMED)
    frame_index += 1

    result = landmarker.detect_for_video(mp_image, timestamp_ms)

    if result.pose_world_landmarks:
        pts = landmarks_to_points(result.pose_world_landmarks[0])
        new_pts = o3d.utility.Vector3dVector(pts)
        line_set.points = new_pts
        pcd.points = new_pts
        vis.update_geometry(line_set)
        vis.update_geometry(pcd)

        # 左足首=27, 右足首=28。z(前後)で「前に出ている足」を判定
        # 変換後 z は手前が負側のため、より小さい z の足が前
        lz, rz = pts[27][2], pts[28][2]
        leading = "L" if lz < rz else "R"
        ankle_gap = abs(lz - rz)   # 前後の開き(歩幅の目安)

        # 前に出る足が入れ替わった瞬間を1歩として数える
        if last_leading is not None and leading != last_leading:
            now = time.time()
            interval = (now - last_step_time) if last_step_time else 0.0
            step_length = max(ankle_gap, last_ankle_gap)
            step_count += 1
            with open(CSV_PATH, "a", newline="") as f:
                csv.writer(f).writerow(
                    [f"{now:.3f}", leading, f"{interval:.2f}", f"{step_length:.3f}"])
            last_step_time = now
        last_leading = leading
        last_ankle_gap = ankle_gap

        cv2.putText(frame, f"steps: {step_count}  lead: {leading}", (10, 40),
                    cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 200, 0), 2)

    if result.pose_landmarks:
        draw_landmarks_2d(frame, result.pose_landmarks[0])

    if not vis.poll_events():
        break
    vis.update_renderer()
    cv2.imshow("camera", frame)

    if cv2.waitKey(1) & 0xFF == 27:
        break

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

ヒント

歩数は「前に出ている足(前後位置 z が小さい方)が左右で入れ替わった瞬間」を1歩として数えます。歩幅の目安は左右足首の前後の開き、歩行リズムは1歩ごとの時間間隔から得られ、左右で間隔が偏ればリズムの乱れの手がかりになります。実寸メートルの座標を使うため、歩幅をおおよそメートル単位で推定できます。判定方法は撮影方向で調整が要ります。横向き歩行では前後位置(z)が有効ですが、トレッドミルを正面から撮る場合は足首の高さ(y)の上下動で歩数を数える方が安定します。

考察ポイント

カメラの設置(横から・正面から・斜めから)によって、歩幅・歩数の推定精度がどう変わるかを観察します。すり足や左右非対称な歩行が、足首の高さ・前後位置の時系列にどう現れるかを確認します。単眼カメラの歩行解析が、専用の歩行分析装置に対してどこまで代替でき、どこに限界(接地タイミングの正確さ・床反力の欠如)があるかを考えます。


演習6:舞踊・武道・体操の「型」採点と差分可視化

お詫び:演習6は、プログラムの誤りを確認中です。お待ちください

手順

手本の「型」として CMU モーションキャプチャ(基礎演習1〜3で使った BVH)を読み込み、順運動学(FK)で関節座標を求めて MediaPipe の主要関節(肩・肘・手首・腰・膝・足首・頭)へ対応づけ、半透明のゴースト骨格(緑)として表示します。学習者はカメラの前で一定秒数(CAPTURE_SECONDS)だけ動作し、その姿勢時系列を記録します。記録後、手本と学習者は速さやタイミングがずれているのが普通なので、DTW(動的時間伸縮)で両者のフレーム対応をとり、対応づけたペアを並べて再生します。各ペアで関節ごとのずれを測り、大きい関節を赤で強調し、平均ずれを「ずれスコア」として表示します。

DTW は外部パッケージを使わず numpy だけで実装します(依存を増やさず、中身を学べるようにするためです)。手本の BVH もモデルと同様にコード保存フォルダ(BASE_DIR)配下へ自動ダウンロードし、既存ならスキップします。終了は ESC または3D表示ウィンドウを閉じます。

"""
演習6: 「型」の採点と差分可視化(BVH手本ゴースト + DTW時間合わせ)
- 手本: CMU BVH(cgspeed版ミラー)をFKで関節座標化し、MediaPipe 33点の主要関節へ対応づけ
- 学習者: カメラから一定秒数の姿勢を記録
- DTW(動的時間伸縮)で手本と学習者のフレーム対応をとり、対応づけた区間を並べて再生
- 関節ごとのずれを色で強調し、平均ずれスコアを表示
依存: mediapipe, opencv-python, open3d, numpy
"""

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

# === 保存先はコード保存フォルダ。モデル・BVHが無ければDL ====================
MODEL_URL = "https://storage.googleapis.com/mediapipe-models/pose_landmarker/pose_landmarker_lite/float16/1/pose_landmarker_lite.task"
BVH_URL = "https://raw.githubusercontent.com/una-dinosauria/cmu-mocap/master/data/001/01_01.bvh"
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
MODEL_PATH = os.path.join(BASE_DIR, "pose_landmarker_lite.task")
BVH_PATH = os.path.join(BASE_DIR, "mocap_data", "001", "01_01.bvh")
if not os.path.exists(MODEL_PATH):
    urllib.request.urlretrieve(MODEL_URL, MODEL_PATH)
if not os.path.exists(BVH_PATH):
    os.makedirs(os.path.dirname(BVH_PATH), exist_ok=True)
    urllib.request.urlretrieve(BVH_URL, BVH_PATH)
# === ここまで ================================================================

NUM_LANDMARKS = 33
COMPARE_IDS = [11, 12, 13, 14, 15, 16, 23, 24, 25, 26, 27, 28]
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),
]
CAPTURE_SECONDS = 6.0      # 学習者の動作を記録する秒数
DIFF_WARN_M = 0.15         # この距離(m)以上ずれた関節を赤で強調
FPS_ASSUMED = 30.0


# --- BVH パーサ + FK (基礎演習2・3と同一方針) -------------------------------
class Joint:
    def __init__(self, name):
        self.name = name
        self.offset = np.zeros(3)
        self.channels = []
        self.parent = None
        self.children = []


def parse_bvh(path):
    with open(path, "r") as f:
        tokens = f.read().split()
    i = 0
    joints, stack = [], []

    def new_joint(name):
        j = Joint(name)
        if stack:
            j.parent = stack[-1]
            stack[-1].children.append(j)
        joints.append(j)
        return j

    while i < len(tokens):
        t = tokens[i]
        if t in ("ROOT", "JOINT"):
            stack.append(new_joint(tokens[i + 1])); i += 2
        elif t == "End":
            stack.append(None); i += 2
        elif t == "OFFSET":
            off = np.array([float(tokens[i + 1]), float(tokens[i + 2]), float(tokens[i + 3])])
            if stack[-1] is not None:
                stack[-1].offset = off
            i += 4
        elif t == "CHANNELS":
            n = int(tokens[i + 1])
            stack[-1].channels = tokens[i + 2:i + 2 + n]; i += 2 + n
        elif t == "{":
            i += 1
        elif t == "}":
            stack.pop(); i += 1
        elif t == "MOTION":
            i += 1; break
        else:
            i += 1

    num_frames = int(tokens[i + 1])
    frame_time = float(tokens[i + 4])
    i += 5
    total = sum(len(j.channels) for j in joints)
    values = np.array(tokens[i:i + num_frames * total], dtype=float)
    return joints, values.reshape(num_frames, total), frame_time


def rotation_matrix(axis, deg):
    r = np.radians(deg); c, s = np.cos(r), np.sin(r)
    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]])


def compute_joint_positions(joints, frame):
    positions = {}
    ch_index = 0
    ch_map = {}
    for j in joints:
        ch_map[j] = frame[ch_index:ch_index + len(j.channels)]
        ch_index += len(j.channels)

    def recurse(joint, parent_pos, parent_rot):
        vals = ch_map[joint]
        trans = np.zeros(3); rot = np.eye(3); vi = 0
        for ch in joint.channels:
            v = vals[vi]; vi += 1
            if ch.endswith("position"):
                trans["XYZ".index(ch[0])] = v
            elif ch.endswith("rotation"):
                rot = rot @ rotation_matrix(ch[0], v)
        world_pos = parent_pos + parent_rot @ (joint.offset + trans)
        world_rot = parent_rot @ rot
        positions[joint] = world_pos
        for c in joint.children:
            recurse(c, world_pos, world_rot)

    recurse(joints[0], np.zeros(3), np.eye(3))
    return positions


# BVH関節名(MotionBuilder命名) -> MediaPipe番号。鼻(0)はHead位置で代用
BVH_TO_MP = {
    "LeftArm": 11, "RightArm": 12,
    "LeftForeArm": 13, "RightForeArm": 14,
    "LeftHand": 15, "RightHand": 16,
    "LeftUpLeg": 23, "RightUpLeg": 24,
    "LeftLeg": 25, "RightLeg": 26,
    "LeftFoot": 27, "RightFoot": 28,
    "Head": 0,
}


def bvh_to_landmark_series(path):
    """BVHを読み、各フレームを MediaPipe 33点配列(腰中心・メートル)に変換した時系列を返す。"""
    joints, frames_data, _ = parse_bvh(path)
    name_to_joint = {j.name: j for j in joints}
    pairs = [(nm, mid) for nm, mid in BVH_TO_MP.items() if nm in name_to_joint]

    n = len(frames_data)
    series = np.zeros((n, NUM_LANDMARKS, 3))
    for fr in range(n):
        pos = compute_joint_positions(joints, frames_data[fr])
        for nm, mid in pairs:
            series[fr, mid] = pos[name_to_joint[nm]]
    series *= 0.01   # cm -> m (CMU BVHはcm規模)
    for fr in range(n):
        hip = 0.5 * (series[fr, 23] + series[fr, 24])
        series[fr] -= hip   # 腰中心を原点へ
    return series[1:]   # フレーム0(Tポーズ)は除外


# --- DTW (動的時間伸縮): numpyのみ ------------------------------------------
def pose_feature(pts):
    """比較用の特徴: 主要関節の座標を平らに並べたベクトル。"""
    return pts[COMPARE_IDS].reshape(-1)


def dtw_path(A_series, B_series):
    """手本Aと学習者Bのフレーム対応(ワーピングパス) [(ia, ib), ...] を返す。"""
    A = np.stack([pose_feature(p) for p in A_series])
    B = np.stack([pose_feature(p) for p in B_series])
    na, nb = len(A), len(B)
    D = np.linalg.norm(A[:, None, :] - B[None, :, :], axis=2)   # コスト行列
    acc = np.full((na + 1, nb + 1), np.inf)
    acc[0, 0] = 0.0
    for i in range(1, na + 1):
        for j in range(1, nb + 1):
            acc[i, j] = D[i - 1, j - 1] + min(
                acc[i - 1, j], acc[i, j - 1], acc[i - 1, j - 1])
    i, j = na, nb
    path = []
    while i > 0 and j > 0:
        path.append((i - 1, j - 1))
        step = np.argmin([acc[i - 1, j - 1], acc[i - 1, j], acc[i, j - 1]])
        if step == 0:
            i, j = i - 1, j - 1
        elif step == 1:
            i = i - 1
        else:
            j = j - 1
    path.reverse()
    return path


# --- MediaPipe / 描画ユーティリティ ----------------------------------------
def landmarks_to_points(world_landmarks):
    pts = np.zeros((NUM_LANDMARKS, 3))
    for i, lm in enumerate(world_landmarks):
        pts[i] = [lm.x, -lm.y, -lm.z]
    return pts


def align_to_hip(pts):
    hip = 0.5 * (pts[23] + pts[24])
    return pts - hip


def draw_landmarks_2d(image, norm_landmarks):
    h, w = image.shape[:2]
    px = [(int(lm.x * w), int(lm.y * h)) for lm in norm_landmarks]
    for a, b in POSE_CONNECTIONS:
        cv2.line(image, px[a], px[b], (255, 200, 0), 2)
    for (x, y) in px:
        cv2.circle(image, (x, y), 4, (0, 0, 255), -1)


def make_lineset(color):
    ls = o3d.geometry.LineSet(
        points=o3d.utility.Vector3dVector(np.zeros((NUM_LANDMARKS, 3))),
        lines=o3d.utility.Vector2iVector(np.array(POSE_CONNECTIONS)))
    ls.paint_uniform_color(color)
    return ls


# === 1) 手本(BVH)をゴースト用の関節時系列に変換 =============================
teacher = bvh_to_landmark_series(BVH_PATH)
print(f"手本(BVH)フレーム数: {len(teacher)}")

# === 2) 学習者の動作を一定秒数だけ記録 ======================================
base = mp_python.BaseOptions(model_asset_path=MODEL_PATH)
options = vision.PoseLandmarkerOptions(
    base_options=base, running_mode=vision.RunningMode.VIDEO, num_poses=1)
landmarker = vision.PoseLandmarker.create_from_options(options)

cap = cv2.VideoCapture(0)   # カメラ。動画で試す場合は VideoCapture("movie.mp4")
frame_index = 0
learner = []

print(f"{CAPTURE_SECONDS:.0f}秒間、手本の動きをまねてください。記録中…")
start = time.time()
while time.time() - start < CAPTURE_SECONDS:
    ret, frame = cap.read()
    if not ret:
        break
    rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
    mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=rgb)
    timestamp_ms = int(frame_index * 1000.0 / FPS_ASSUMED)
    frame_index += 1
    result = landmarker.detect_for_video(mp_image, timestamp_ms)
    if result.pose_world_landmarks:
        learner.append(align_to_hip(landmarks_to_points(result.pose_world_landmarks[0])))
    if result.pose_landmarks:
        draw_landmarks_2d(frame, result.pose_landmarks[0])
    remain = CAPTURE_SECONDS - (time.time() - start)
    cv2.putText(frame, f"REC {remain:4.1f}s", (10, 40),
                cv2.FONT_HERSHEY_SIMPLEX, 0.9, (0, 0, 255), 2)
    cv2.imshow("camera", frame)
    if cv2.waitKey(1) & 0xFF == 27:
        break

cap.release()
cv2.destroyAllWindows()
landmarker.close()

learner = np.array(learner)
if len(learner) < 2:
    raise SystemExit("学習者の姿勢が十分に記録できませんでした。明るい場所で再試行してください。")
print(f"学習者フレーム数: {len(learner)}")

# === 3) DTWで時間対応をとる =================================================
path = dtw_path(teacher, learner)
print(f"DTW対応ペア数: {len(path)}")

# === 4) 対応ペアを並べて再生し、ずれを可視化 ================================
teacher_ls = make_lineset([0.2, 0.8, 0.3])   # 手本ゴースト(緑)
learner_ls = make_lineset([0.1, 0.4, 0.9])   # 学習者(青)
pcd = o3d.geometry.PointCloud()
pcd.points = o3d.utility.Vector3dVector(np.zeros((NUM_LANDMARKS, 3)))

vis = o3d.visualization.Visualizer()
vis.create_window(window_name="Kata scoring (BVH ghost + DTW)", width=900, height=700)
vis.add_geometry(teacher_ls)
vis.add_geometry(learner_ls)
vis.add_geometry(pcd)
vis.add_geometry(o3d.geometry.TriangleMesh.create_coordinate_frame(size=0.5))

k = 0
while True:
    ia, ib = path[k]
    t_pts = teacher[ia]
    l_pts = learner[ib]
    teacher_ls.points = o3d.utility.Vector3dVector(t_pts)
    learner_ls.points = o3d.utility.Vector3dVector(l_pts)
    pcd.points = o3d.utility.Vector3dVector(l_pts)

    # 関節ごとのずれ -> 平均スコアと色
    diffs = {i: float(np.linalg.norm(l_pts[i] - t_pts[i])) for i in COMPARE_IDS}
    mean_dev = sum(diffs.values()) / len(diffs)
    colors = np.tile(np.array([0.1, 0.4, 0.9]), (NUM_LANDMARKS, 1))
    for i, d in diffs.items():
        if d >= DIFF_WARN_M:
            colors[i] = [0.95, 0.1, 0.1]
    pcd.colors = o3d.utility.Vector3dVector(colors)

    vis.update_geometry(teacher_ls)
    vis.update_geometry(learner_ls)
    vis.update_geometry(pcd)
    if not vis.poll_events():
        break
    vis.update_renderer()
    print(f"\rpair {k+1}/{len(path)}  mean deviation {mean_dev:.3f} m", end="")

    time.sleep(1.0 / FPS_ASSUMED)
    k = (k + 1) % len(path)   # ループ再生

vis.destroy_window()
print()

ヒント

手本(BVH)と学習者は、どちらも腰中心を原点にそろえて比較します。BVH はセンチメートル規模・MotionBuilder 命名(LeftArm, LeftForeArm, LeftHand, LeftUpLeg…)なので、BVH_TO_MP で MediaPipe 番号へ対応づけ、0.01 倍してメートルへそろえます。鼻(0)は BVH に対応点がないため Head の位置で代用しています。DTW は手本と学習者のフレーム数が違っても、コスト行列の累積最小経路(動的計画法)で各フレームの対応をとります。pose_feature を関節角度ベースの特徴に変えると、体格差の影響を受けにくい比較にできます。CAPTURE_SECONDS を手本動作のおおよその長さに合わせると対応が安定します。

考察ポイント

DTW を入れる前(フレーム番号をそのまま対応づけた場合)と入れた後とで、速さの違う動作の比較がどう変わるかを確認します。腰中心でそろえる正規化が立ち位置の違いを相殺する一方、重心移動そのものは評価できなくなる点を考えます。手本(CMU の一般動作)と学習者の体格差・骨格構造の違いが、座標ベースのずれにどう影響するかを観察し、角度ベースの特徴や正規化で何が改善できるかを検討します。赤で強調された関節が本人の自覚するずれと一致するかも見て、3D差分の可視化が指導にどう役立つかを考えます。