MediaPipe Hands による3次元手指ランドマーク検出と指接触判定

【概要】MediaPipe Handsは、カメラの映像から手の21点の3次元座標を推定する技術である。機械学習モデルにより単一のRGB画像から手の位置を検出し、各指の関節位置を3次元座標として出力する。手の動きが21個の3次元座標点として可視化され、 指の関節角度,手のひらの向きなどの姿勢情報をリアルタイムで観察できる。実験を通じて、コンピュータビジョンとジェスチャー認識の基礎を確認できる。Windows環境での実行手順、プログラムコード、実験アイデアを含む。

目次

  1. Python開発環境,ライブラリ類
  2. プログラムコード
  3. 使用方法
  4. 実験・探求のアイデア

2. Python開発環境,ライブラリ類

Python, Windsurfをインストールしていない場合の手順(インストール済みの場合は実行不要)。

  1. 管理者権限でコマンドプロンプトを起動(手順:Windowsキーまたはスタートメニュー > cmd と入力 > 右クリック > 「管理者として実行」)し、以下を実行する。
  2. 以下のコマンドをそれぞれ実行する(winget コマンドは1つずつ実行)。

REM Python をシステム領域にインストール
winget install --scope machine --id Python.Python.3.12 -e --silent
REM Windsurf をシステム領域にインストール
winget install --scope machine --id Codeium.Windsurf -e --silent
REM Python のパス設定
set "PYTHON_PATH=C:\Program Files\Python312"
set "PYTHON_SCRIPTS_PATH=C:\Program Files\Python312\Scripts"
echo "%PATH%" | find /i "%PYTHON_PATH%" >nul
if errorlevel 1 setx PATH "%PATH%;%PYTHON_PATH%" /M >nul
echo "%PATH%" | find /i "%PYTHON_SCRIPTS_PATH%" >nul
if errorlevel 1 setx PATH "%PATH%;%PYTHON_SCRIPTS_PATH%" /M >nul
REM Windsurf のパス設定
set "WINDSURF_PATH=C:\Program Files\Windsurf"
if exist "%WINDSURF_PATH%" (
    echo "%PATH%" | find /i "%WINDSURF_PATH%" >nul
    if errorlevel 1 setx PATH "%PATH%;%WINDSURF_PATH%" /M >nul
)

必要なPythonライブラリのインストール

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


pip install mediapipe opencv-python numpy pillow

3. プログラムコード

用語集

主要技術

主要技術:MediaPipe Hands

技術的仕組み:MediaPipe Handsは機械学習モデルを使用して、単一のRGB画像から手の位置を検出し、21個の3次元ランドマークを推定する。このモデルは、2段階のパイプラインで構成される:手の検出段階と、検出された手領域から21点の座標を推定する段階。深度情報は、学習データから獲得した手の形状に関する事前知識を用いて、2次元画像から推定される。モバイルデバイスでのリアルタイム動作を実現するため、モデルアーキテクチャと推論処理が最適化されている。

このプログラムでの3次元座標系

このプログラムの調整可能ポイント

出典

Zhang, F., Bazarevsky, V., Vakunov, A., Tkachenka, A., Sung, G., Chang, C. L., & Grundmann, M. (2020). MediaPipe Hands: On-device Real-time Hand Tracking. arXiv preprint arXiv:2006.10214.

ソースコード


# プログラム名: MediaPipe 3D手指ランドマーク検出と指接触判定
# 特徴技術名: MediaPipe Hands
# 出典: F. Zhang et al., "MediaPipe Hands: On-device Real-time Hand Tracking," arXiv preprint arXiv:2006.10214, 2020.
# 特徴機能: 21点3D手指ランドマークのリアルタイム検出。手のひら検出モデルと手指ランドマーク検出モデルの二段階パイプラインにより、単一のRGBカメラから手指の21個の関節位置を3次元座標として推定。指接触判定により日本語指文字の認識支援を行う。
# 学習済みモデル: MediaPipeモデルバンドル(手のひら検出モデルと手指ランドマーク検出モデルを含む)。約30K枚の実画像と合成手モデルで訓練。model_complexity=0(軽量版)とmodel_complexity=1(標準版)が利用可能。MediaPipeライブラリに内蔵されており、自動的に読み込まれる。
# 方式設計:
#   - 関連利用技術: OpenCV(カメラ入力・画像表示)、NumPy(ベクトル演算・角度計算)、Pillow(日本語テキスト描画)
#   - 入力と出力: 入力: 動画(ユーザは「0:動画ファイル,1:カメラ,2:サンプル動画」のメニューで選択.0:動画ファイルの場合はtkinterでファイル選択.1の場合はOpenCVでカメラが開く.2の場合はhttps://raw.githubusercontent.com/opencv/opencv/master/samples/data/vtest.aviを使用)、出力: OpenCV画面でリアルタイム表示(検出された手指の3Dランドマークと関連情報)、処理結果を表示、プログラム終了時に処理結果をresult.txtファイルに保存
#   - 処理手順: 1.カメラから画像取得、2.MediaPipe Handsで手指検出、3.21点の3D座標抽出、4.関節角度・手の向き・掌法線ベクトル計算、5.結果を画面に描画
#   - 前処理、後処理: 前処理: BGR→RGB変換(MediaPipeの入力要件)、後処理: 時系列フィルタリング(過去3フレームの移動平均によるランドマーク位置の安定化)
#   - 座標系設計: MediaPipe座標系の特性を正しく理解した統一スケール処理。すべての座標を同一の物理的基準で正規化し、3D空間での一貫した距離計算を実現。論理的に完全に正しい相対的近接判定を提供。
#   - 追加処理: 統一スケール座標系による3D空間での一貫したベクトル演算、3D関節角度計算による手指姿勢の正確な定量化、3D掌法線ベクトル計算による手の向き推定、論理的に正しい指接触判定による日本語指文字認識支援
#   - 調整を必要とする設定値: HAND_CONFIDENCE(手検出の信頼度閾値、デフォルト0.7)、TRACKING_CONFIDENCE(追跡の信頼度閾値、デフォルト0.5)、MAX_NUM_HANDS(検出する手の最大数、デフォルト2)
# 将来方策: HAND_CONFIDENCEとTRACKING_CONFIDENCEの最適値を自動調整するため、検出成功率を監視し、一定時間ごとに閾値を動的に調整する機能の実装が可能
# その他の重要事項: Windows環境専用(DirectShowバックエンド使用)、日本語フォントはWindows標準のメイリオを使用
# 前準備: pip install mediapipe opencv-python numpy pillow

import cv2
import numpy as np
import mediapipe as mp
import math
from PIL import Image, ImageDraw, ImageFont
import tkinter as tk
from tkinter import filedialog
import time
import urllib.request
from datetime import datetime
import torch

# GPU/CPU自動選択
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
print(f'デバイス: {str(device)}')

# 定数定義
HAND_CONFIDENCE = 0.7
TRACKING_CONFIDENCE = 0.5
MAX_NUM_HANDS = 2

# フォントサイズ定数
FONT_LARGE = 30
FONT_MEDIUM = 20
FONT_SMALL = 16
FONT_TINY = 12

# 履歴管理
HISTORY_SIZE = 3  # 適度な平滑化のため3フレーム

# 再検出間隔(フレーム数)
REDETECTION_INTERVAL = 300  # 10秒ごと(30fps想定)

# 接触判定閾値(正規化座標系での適切な値)
CONTACT_THRESHOLD_3D = 0.1      # 3D距離の接触閾値(手のサイズの10%)
CONTACT_THRESHOLD_2D = 0.08     # 2D距離の接触閾値(手のサイズの8%)
CONTACT_THRESHOLD_Z = 0.06      # z方向距離の接触閾値(手のサイズの6%)

# 画面ウィンドウ名の定数化
MAIN_FUNC_DESC = "MediaPipe 3D手指姿勢推定"

# 色定義(RGB)。OpenCV描画時はBGRに変換する。
COLORS = {
    'thumb': (255, 0, 0),      # 親指 - 赤
    'index': (0, 255, 0),      # 人差し指 - 緑
    'middle': (0, 0, 255),     # 中指 - 青
    'ring': (255, 255, 0),     # 薬指 - 黄
    'pinky': (255, 0, 255),    # 小指 - マゼンタ
    'palm': (0, 255, 255),     # 手のひら - シアン
    'wrist': (128, 128, 128)   # 手首 - グレー
}

def rgb_to_bgr(c):
    return (c[2], c[1], c[0])

# 指別の色取得(機能は不変、if-elif連鎖の関数化)
def get_landmark_color(i):
    # 指別の色分け(RGB)
    if i == 0:
        return COLORS['wrist']
    elif 1 <= i <= 4:
        return COLORS['thumb']
    elif 5 <= i <= 8:
        return COLORS['index']
    elif 9 <= i <= 12:
        return COLORS['middle']
    elif 13 <= i <= 16:
        return COLORS['ring']
    elif 17 <= i <= 20:
        return COLORS['pinky']
    else:
        return COLORS['palm']

# 手指ランドマーク構造定義(MediaPipe 21点)
FINGER_LANDMARKS = {
    'WRIST': 0,
    'THUMB_CMC': 1, 'THUMB_MCP': 2, 'THUMB_IP': 3, 'THUMB_TIP': 4,
    'INDEX_FINGER_MCP': 5, 'INDEX_FINGER_PIP': 6, 'INDEX_FINGER_DIP': 7, 'INDEX_FINGER_TIP': 8,
    'MIDDLE_FINGER_MCP': 9, 'MIDDLE_FINGER_PIP': 10, 'MIDDLE_FINGER_DIP': 11, 'MIDDLE_FINGER_TIP': 12,
    'RING_FINGER_MCP': 13, 'RING_FINGER_PIP': 14, 'RING_FINGER_DIP': 15, 'RING_FINGER_TIP': 16,
    'PINKY_MCP': 17, 'PINKY_PIP': 18, 'PINKY_DIP': 19, 'PINKY_TIP': 20
}

# ランドマーク名ラベルの逆引きマップ(末尾3文字)。親指/人差指先端は識別性向上のため上書き。
IDX_TO_SUFFIX = {idx: name[-3:] for name, idx in FINGER_LANDMARKS.items()}
IDX_TO_SUFFIX[FINGER_LANDMARKS['THUMB_TIP']] = 'TTip'
IDX_TO_SUFFIX[FINGER_LANDMARKS['INDEX_FINGER_TIP']] = 'ITip'

# 日本語フォント設定(メイリオ使用)
FONT_PATH = 'C:/Windows/Fonts/meiryo.ttc'
font_large = ImageFont.truetype(FONT_PATH, FONT_LARGE)
font_medium = ImageFont.truetype(FONT_PATH, FONT_MEDIUM)
font_small = ImageFont.truetype(FONT_PATH, FONT_SMALL)
font_tiny = ImageFont.truetype(FONT_PATH, FONT_TINY)

# プログラム開始時の説明
print('MediaPipe 3D手指ランドマーク検出プログラム')
print('=' * 50)
print('概要: MediaPipe Handsを使用して手指の21点3Dランドマークを検出します')
print('特徴: 統一スケール処理3D空間での処理')
print('   3D距離計算と3D関節角度による手指姿勢推定')
print('   指の接触判定により日本語指文字の認識を支援')
print('操作: qキーで終了')
print('=' * 50)
print('日本語指文字の接触パターン分類:')
print('')
print('親指と他の指の先端接触(輪を作る)')
print('  お:親指と人差し指で輪')
print('  き:親指と人差し指・中指で輪(3本)')
print('  ら:親指と人差し指・中指で輪(横向き)')
print('')
print('親指と他の指の側面接触')
print('  す:親指が人差し指の側面に接触')
print('  せ:親指が人差し指の第一関節付近に接触')
print('  ぬ:親指が人差し指と中指の根元を押さえる')
print('')
print('親指と他の指の根元接触')
print('  め:親指が小指の根元(MCP関節)に接触')
print('  む:親指が人差し指の根元に接触')
print('')
print('指同士の交差')
print('  ね:人差し指と中指を交差')
print('  れ:親指が他の4指の下を通る')
print('=' * 50)


def process_unified_coordinates(raw_points_3d):
    """
    統一スケールでの座標処理(論理的に正しい方法)

    MediaPipe座標系の特性:
    - x, y: カメラ画像内の正規化座標(0-1)
    - z: 手首を基準とした奥行き方向の相対距離(実距離ではなく、相対尺度であり符号付き)

    統一処理方針:
    - 手首を原点とした相対座標系に変換
    - 手のサイズ(手首から中指先端までの距離)で正規化
    - これにより全ての座標が手のサイズに対する相対値となる

    Args:
        raw_points_3d: MediaPipeの生座標リスト [[x,y,z], ...]

    Returns:
        tuple: (統一座標系のポイントリスト, 手のサイズ)
    """
    try:
        # 手首位置(原点基準)
        wrist_pos = np.array(raw_points_3d[0])

        # 手のサイズを中指の長さで推定(手首から中指先端)
        middle_tip = np.array(raw_points_3d[12])  # 中指先端

        # 手首から中指先端までの3D距離を手のサイズとする
        hand_size = np.linalg.norm(middle_tip - wrist_pos)

        # 0除算防止
        if hand_size < 0.01:
            hand_size = 0.2  # デフォルト値

        # 統一座標系への変換(手首を原点、手のサイズで正規化)
        unified_points = []
        for point in raw_points_3d:
            relative_pos = (np.array(point) - wrist_pos) / hand_size
            unified_points.append(relative_pos.tolist())

        return unified_points, hand_size

    except Exception as e:
        print(f"統一座標変換エラー: {e}")
        # エラー時のフォールバック処理
        wrist_pos = np.array(raw_points_3d[0])
        fallback_points = []
        for point in raw_points_3d:
            relative_pos = (np.array(point) - wrist_pos).tolist()
            fallback_points.append(relative_pos)
        return fallback_points, 0.2


def calculate_3d_finger_angles(unified_points):
    """
    3D空間での正確な指関節角度計算

    統一座標系を使用することで、3D空間での正確な関節角度を計算
    2D投影による情報損失を回避

    Args:
        unified_points: 統一座標系のランドマーク座標

    Returns:
        list: 各関節の角度(度)のリスト
    """
    finger_angles = []
    finger_chains = [
        [1, 2, 3, 4],    # 親指
        [5, 6, 7, 8],    # 人差し指
        [9, 10, 11, 12], # 中指
        [13, 14, 15, 16], # 薬指
        [17, 18, 19, 20] # 小指
    ]

    for chain in finger_chains:
        for i in range(len(chain) - 2):
            try:
                # 3D座標での角度計算
                p1 = np.array(unified_points[chain[i]])
                p2 = np.array(unified_points[chain[i+1]])  # 関節点
                p3 = np.array(unified_points[chain[i+2]])

                # ベクトル計算
                v1 = p1 - p2  # 関節から前の点へのベクトル
                v2 = p3 - p2  # 関節から次の点へのベクトル

                # ベクトルの長さ
                norm1, norm2 = np.linalg.norm(v1), np.linalg.norm(v2)

                # 0ベクトルでない場合のみ角度計算
                if norm1 > 1e-10 and norm2 > 1e-10:
                    cos_angle = np.dot(v1, v2) / (norm1 * norm2)
                    cos_angle = np.clip(cos_angle, -1.0, 1.0)  # 数値誤差対策
                    angle = math.degrees(np.arccos(cos_angle))
                else:
                    angle = 0.0

                finger_angles.append(angle)

            except Exception:
                finger_angles.append(0.0)

    return finger_angles


def calculate_3d_palm_normal(unified_points):
    """
    3D空間での正確な掌法線ベクトル計算

    統一座標系を使用することで、手のひら平面の正確な法線ベクトルを算出

    Args:
        unified_points: 統一座標系のランドマーク座標

    Returns:
        list or None: 正規化された3D法線ベクトル [x, y, z] または None
    """
    try:
        # 手首を原点とした3D座標系で計算
        wrist = np.array(unified_points[0])      # 原点 (0,0,0)
        middle_mcp = np.array(unified_points[9]) # 中指根元
        pinky_mcp = np.array(unified_points[17]) # 小指根元

        # 手のひら平面を定義する2つのベクトル
        v1 = middle_mcp - wrist  # 手首から中指根元
        v2 = pinky_mcp - wrist   # 手首から小指根元

        # 3D外積で法線ベクトルを計算
        normal = np.cross(v1, v2)

        # 正規化
        norm = np.linalg.norm(normal)
        if norm > 1e-10:
            normal = normal / norm
            return normal.tolist()
        else:
            return None

    except Exception:
        return None


def calculate_3d_hand_direction(unified_points):
    """
    3D空間での正確な手の向きベクトル計算

    Args:
        unified_points: 統一座標系のランドマーク座標

    Returns:
        list or None: 正規化された3D方向ベクトル [x, y, z] または None
    """
    try:
        wrist = np.array(unified_points[0])      # 原点 (0,0,0)
        middle_mcp = np.array(unified_points[9]) # 中指根元

        # 手首から中指根元への3D方向ベクトル
        direction = middle_mcp - wrist

        # 正規化
        norm = np.linalg.norm(direction)
        if norm > 1e-10:
            direction = direction / norm
            return direction.tolist()
        else:
            return None

    except Exception:
        return None


def calculate_unified_distances(unified_points):
    """
    統一スケールでの一貫した距離計算

    すべての距離が同一基準で計算されるため:
    - 直接比較可能
    - 統一閾値での判定が可能
    - 論理的に一貫した結果

    Args:
        unified_points: 統一座標系のランドマーク座標

    Returns:
        dict: 各種距離計算結果
    """
    distances = {}

    try:
        # 主要ランドマーク(統一座標系)
        thumb_tip = np.array(unified_points[FINGER_LANDMARKS['THUMB_TIP']])
        index_tip = np.array(unified_points[FINGER_LANDMARKS['INDEX_FINGER_TIP']])
        middle_tip = np.array(unified_points[FINGER_LANDMARKS['MIDDLE_FINGER_TIP']])
        index_pip = np.array(unified_points[FINGER_LANDMARKS['INDEX_FINGER_PIP']])
        index_dip = np.array(unified_points[FINGER_LANDMARKS['INDEX_FINGER_DIP']])
        index_mcp = np.array(unified_points[FINGER_LANDMARKS['INDEX_FINGER_MCP']])
        middle_mcp = np.array(unified_points[FINGER_LANDMARKS['MIDDLE_FINGER_MCP']])
        pinky_mcp = np.array(unified_points[FINGER_LANDMARKS['PINKY_MCP']])
        middle_pip = np.array(unified_points[FINGER_LANDMARKS['MIDDLE_FINGER_PIP']])

        # 3D距離計算(統一スケール - 最も正確)
        distances['thumb_index_tip_3d'] = np.linalg.norm(thumb_tip - index_tip)
        distances['thumb_middle_tip_3d'] = np.linalg.norm(thumb_tip - middle_tip)
        distances['index_middle_tip_3d'] = np.linalg.norm(index_tip - middle_tip)
        distances['thumb_index_dip_3d'] = np.linalg.norm(thumb_tip - index_dip)
        distances['thumb_index_mcp_3d'] = np.linalg.norm(thumb_tip - index_mcp)
        distances['thumb_middle_mcp_3d'] = np.linalg.norm(thumb_tip - middle_mcp)
        distances['thumb_pinky_mcp_3d'] = np.linalg.norm(thumb_tip - pinky_mcp)
        distances['index_middle_pip_3d'] = np.linalg.norm(index_pip - middle_pip)

        # 2D投影距離(x,y平面での距離)
        distances['thumb_index_tip_2d'] = np.linalg.norm((thumb_tip - index_tip)[:2])
        distances['thumb_middle_tip_2d'] = np.linalg.norm((thumb_tip - middle_tip)[:2])
        distances['index_middle_tip_2d'] = np.linalg.norm((index_tip - middle_tip)[:2])
        distances['thumb_index_dip_2d'] = np.linalg.norm((thumb_tip - index_dip)[:2])
        distances['thumb_index_mcp_2d'] = np.linalg.norm((thumb_tip - index_mcp)[:2])
        distances['thumb_middle_mcp_2d'] = np.linalg.norm((thumb_tip - middle_mcp)[:2])
        distances['thumb_pinky_mcp_2d'] = np.linalg.norm((thumb_tip - pinky_mcp)[:2])
        distances['index_middle_pip_2d'] = np.linalg.norm((index_pip - middle_pip)[:2])

        # z軸方向距離(深度方向)
        distances['thumb_index_tip_z'] = abs(thumb_tip[2] - index_tip[2])
        distances['thumb_middle_tip_z'] = abs(thumb_tip[2] - middle_tip[2])
        distances['index_middle_tip_z'] = abs(index_tip[2] - middle_tip[2])
        distances['thumb_index_dip_z'] = abs(thumb_tip[2] - index_dip[2])
        distances['thumb_index_mcp_z'] = abs(thumb_tip[2] - index_mcp[2])
        distances['thumb_middle_mcp_z'] = abs(thumb_tip[2] - middle_mcp[2])
        distances['thumb_pinky_mcp_z'] = abs(thumb_tip[2] - pinky_mcp[2])
        distances['index_middle_pip_z'] = abs(index_pip[2] - middle_pip[2])

        # 親指先端と人差指PIP-DIP間の最短距離(側面接触用)
        pip_to_dip = index_dip - index_pip
        pip_to_dip_norm = np.linalg.norm(pip_to_dip)

        if pip_to_dip_norm > 1e-10:
            # 線分上の最近点を計算
            t = np.dot(thumb_tip - index_pip, pip_to_dip) / (pip_to_dip_norm ** 2)
            t = np.clip(t, 0, 1)  # 線分の範囲内に制限
            closest_point = index_pip + t * pip_to_dip
            distances['thumb_index_side_3d'] = np.linalg.norm(thumb_tip - closest_point)
            distances['thumb_index_side_2d'] = np.linalg.norm((thumb_tip - closest_point)[:2])
            distances['thumb_index_side_z'] = abs(thumb_tip[2] - closest_point[2])
        else:
            # 線分が退化している場合はPIP点との距離
            distances['thumb_index_side_3d'] = distances['thumb_index_dip_3d']
            distances['thumb_index_side_2d'] = distances['thumb_index_dip_2d']
            distances['thumb_index_side_z'] = distances['thumb_index_dip_z']

    except Exception as e:
        print(f"統一距離計算エラー: {e}")
        # エラー時のフォールバック値
        distance_keys = [
            'thumb_index_tip_3d', 'thumb_middle_tip_3d', 'index_middle_tip_3d',
            'thumb_index_side_3d', 'thumb_index_dip_3d', 'thumb_index_mcp_3d',
            'thumb_middle_mcp_3d', 'thumb_pinky_mcp_3d', 'index_middle_pip_3d',
            'thumb_index_tip_2d', 'thumb_middle_tip_2d', 'index_middle_tip_2d',
            'thumb_index_side_2d', 'thumb_index_dip_2d', 'thumb_index_mcp_2d',
            'thumb_middle_mcp_2d', 'thumb_pinky_mcp_2d', 'index_middle_pip_2d',
            'thumb_index_tip_z', 'thumb_middle_tip_z', 'index_middle_tip_z',
            'thumb_index_side_z', 'thumb_index_dip_z', 'thumb_index_mcp_z',
            'thumb_middle_mcp_z', 'thumb_pinky_mcp_z', 'index_middle_pip_z'
        ]
        for key in distance_keys:
            distances[key] = 999.0

    return distances


def detect_finger_contacts_unified(unified_points, distances=None):
    """
    統一スケールでの論理的に正しい指接触判定

    統一された座標系での一貫した閾値判定:
    - 3D距離:最も正確な接触判定
    - 2D距離:平面的な接触判定
    - z距離:奥行き方向の接触判定

    Args:
        unified_points: 統一座標系のランドマーク座標

    Returns:
        list: 接触検出結果のリスト [(部位名, 3D距離, 2D距離, z距離), ...]
    """
    contacts = []
    if distances is None:
        distances = calculate_unified_distances(unified_points)

    try:
        # 接触判定対象(主要な指文字パターン)
        contact_patterns = [
            ('親指-人差指先端', 'thumb_index_tip_3d', 'thumb_index_tip_2d', 'thumb_index_tip_z'),
            ('親指-中指先端', 'thumb_middle_tip_3d', 'thumb_middle_tip_2d', 'thumb_middle_tip_z'),
            ('親指-人差指側面', 'thumb_index_side_3d', 'thumb_index_side_2d', 'thumb_index_side_z'),
            ('親指-人差指DIP', 'thumb_index_dip_3d', 'thumb_index_dip_2d', 'thumb_index_dip_z'),
            ('親指-人差指MCP', 'thumb_index_mcp_3d', 'thumb_index_mcp_2d', 'thumb_index_mcp_z'),
            ('親指-中指MCP', 'thumb_middle_mcp_3d', 'thumb_middle_mcp_2d', 'thumb_middle_mcp_z'),
            ('親指-小指MCP', 'thumb_pinky_mcp_3d', 'thumb_pinky_mcp_2d', 'thumb_pinky_mcp_z'),
            ('人差指-中指先端', 'index_middle_tip_3d', 'index_middle_tip_2d', 'index_middle_tip_z'),
            ('人差指-中指PIP', 'index_middle_pip_3d', 'index_middle_pip_2d', 'index_middle_pip_z')
        ]

        # 各パターンの接触判定(統一閾値での判定)
        for pattern_name, key_3d, key_2d, key_z in contact_patterns:
            dist_3d = distances.get(key_3d, 999.0)
            dist_2d = distances.get(key_2d, 999.0)
            dist_z = distances.get(key_z, 999.0)

            # 3D距離での主判定(最も正確)
            if dist_3d < CONTACT_THRESHOLD_3D:
                contacts.append((pattern_name, dist_3d, dist_2d, dist_z))
            # 2D距離とz距離の組み合わせ判定(補助的)
            elif dist_2d < CONTACT_THRESHOLD_2D and dist_z < CONTACT_THRESHOLD_Z:
                contacts.append((pattern_name + '(2D+Z)', dist_3d, dist_2d, dist_z))

    except Exception as e:
        print(f"統一接触判定エラー: {e}")

    return contacts


# グローバル変数
frame_count = 0
results_log = []
landmark_history = []  # 既存の履歴(未使用だが保存)
landmark_history_map = {'Right': [], 'Left': []}  # 手別履歴(平滑化用)
no_detection_count = 0  # 手が検出されない連続フレーム数


def video_frame_processing(frame):
    """
    メイン動画処理関数

    Args:
        frame: 入力フレーム

    Returns:
        tuple: (処理済みフレーム, 結果文字列, 現在時刻)
    """
    global frame_count, landmark_history_map, no_detection_count
    current_time = time.time()
    frame_count += 1

    # 定期的な再検出のためのstatic_image_mode切り替え
    static_mode = (frame_count % REDETECTION_INTERVAL == 0)

    rgb_frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)

    # MediaPipe Handsで手指を検出・追跡
    # static_image_modeはランタイム変更が反映されないため、必要時のみ一時インスタンスで処理する
    if static_mode:
        temp_hands = mp_hands.Hands(
            static_image_mode=True,
            max_num_hands=MAX_NUM_HANDS,
            min_detection_confidence=HAND_CONFIDENCE,
            min_tracking_confidence=TRACKING_CONFIDENCE,
            model_complexity=0
        )
        results = temp_hands.process(rgb_frame)
        temp_hands.close()
    else:
        results = hands.process(rgb_frame)

    # 情報表示(後段で一括描画するために蓄積)
    texts = []
    # 情報表示
    texts.append((10, 30, f'MediaPipe 3D手指姿勢推定 (フレーム: {frame_count})', font_large, (0, 255, 0)))

    detected_hands = 0
    result_text = ""

    if results.multi_hand_landmarks:
        no_detection_count = 0  # 検出成功時はリセット

        for hand_idx, (hand_landmarks, handedness) in enumerate(zip(results.multi_hand_landmarks, results.multi_handedness)):
            detected_hands += 1
            hand_label = '右手' if handedness.classification[0].label == 'Right' else '左手'
            label_en = handedness.classification[0].label  # 'Right' or 'Left'
            confidence = handedness.classification[0].score

            # 履歴に追加(手ごとに分離)
            hist = landmark_history_map.setdefault(label_en, [])
            hist.append(hand_landmarks)
            if len(hist) > HISTORY_SIZE:
                hist.pop(0)

            # スムージング処理(手ごとの履歴を使用)
            smoothed_points_3d = None
            if len(hist) >= 2:
                # 最新の履歴を使用(最大HISTORY_SIZE個)
                history_to_use = hist[-min(len(hist), HISTORY_SIZE):]
                smoothed_points_3d = []

                for i in range(21):
                    avg_x = sum(landmarks.landmark[i].x for landmarks in history_to_use) / len(history_to_use)
                    avg_y = sum(landmarks.landmark[i].y for landmarks in history_to_use) / len(history_to_use)
                    avg_z = sum(landmarks.landmark[i].z for landmarks in history_to_use) / len(history_to_use)
                    smoothed_points_3d.append([avg_x, avg_y, avg_z])

            # 3D座標抽出
            if smoothed_points_3d:
                raw_points_3d = smoothed_points_3d
            else:
                raw_points_3d = []
                for lm in hand_landmarks.landmark:
                    raw_points_3d.append([lm.x, lm.y, lm.z])

            # 統一スケール処理(論理的に正しい方法)
            unified_points, hand_size = process_unified_coordinates(raw_points_3d)

            # 各種計算(統一座標系使用)
            finger_angles = calculate_3d_finger_angles(unified_points)
            palm_normal = calculate_3d_palm_normal(unified_points)
            hand_direction = calculate_3d_hand_direction(unified_points)
            # 重複回避のために一度だけ距離計算し、接触判定へ渡す
            distances = calculate_unified_distances(unified_points)
            finger_contacts = detect_finger_contacts_unified(unified_points, distances=distances)

            # 深度推定(統一座標系のz成分範囲)
            z_values = [p[2] for p in unified_points]
            depth_range = (min(z_values), max(z_values))

            # 21点の3次元ランドマークを描画
            h, w, _ = frame.shape
            for i, landmark in enumerate(hand_landmarks.landmark):
                x = int(landmark.x * w)
                y = int(landmark.y * h)
                z = landmark.z

                # z座標に基づく深度表現(半径調整)
                radius = max(2, int(8 * (1 - min(abs(z) * 3, 1))))

                # 指別の色分け(OpenCVはBGR)
                color_rgb = get_landmark_color(i)
                color_bgr = rgb_to_bgr(color_rgb)

                cv2.circle(frame, (x, y), radius, color_bgr, -1)

                # 重要なランドマークにラベル表示
                if i in [FINGER_LANDMARKS['INDEX_FINGER_TIP'], FINGER_LANDMARKS['THUMB_TIP']]:
                    cv2.putText(frame, IDX_TO_SUFFIX.get(i, str(i)), (x + 3, y - 3),
                                cv2.FONT_HERSHEY_SIMPLEX, 0.3, color_bgr, 1)
                else:
                    cv2.putText(frame, str(i), (x + 3, y - 3),
                                cv2.FONT_HERSHEY_SIMPLEX, 0.3, color_bgr, 1)

            # 接続線描画(スタイルは事前取得済みのキャッシュを使用)
            mp_drawing.draw_landmarks(
                frame, hand_landmarks, mp_hands.HAND_CONNECTIONS,
                HAND_LANDMARKS_STYLE,
                HAND_CONNECTIONS_STYLE
            )

            # 手の情報表示(複数手で重ならないように縦位置を分離)
            base_y = 80 + hand_idx * 160
            y_offset = base_y
            texts.append((10, y_offset, f'検出手: {hand_label}', font_medium, (255, 255, 255)))
            y_offset += 25

            texts.append((10, y_offset, f'手のサイズ: {hand_size:.3f}', font_small, (0, 255, 255)))
            y_offset += 20

            if palm_normal:
                texts.append((10, y_offset,
                              f'掌法線(3D): ({palm_normal[0]:.2f}, {palm_normal[1]:.2f}, {palm_normal[2]:.2f})',
                              font_tiny, (255, 255, 0)))
                y_offset += 20

            if hand_direction:
                texts.append((10, y_offset,
                              f'手の向き(3D): ({hand_direction[0]:.2f}, {hand_direction[1]:.2f}, {hand_direction[2]:.2f})',
                              font_tiny, (255, 255, 0)))
                y_offset += 20

            # 指接触情報の表示
            if finger_contacts:
                texts.append((10, y_offset, '指接触検出:', font_small, (255, 128, 0)))
                y_offset += 20
                for contact_name, dist_3d, dist_2d, dist_z in finger_contacts[:3]:  # 最大3つまで表示
                    texts.append((20, y_offset, f'{contact_name}: 3D={dist_3d:.3f}', font_tiny, (255, 200, 0)))
                    y_offset += 16

            texts.append((10, y_offset, f'深度範囲: [{depth_range[0]:.3f}, {depth_range[1]:.3f}]',
                          font_tiny, (200, 200, 200)))
            y_offset += 20

            if unified_points:
                index_tip = unified_points[FINGER_LANDMARKS['INDEX_FINGER_TIP']]
                texts.append((10, y_offset,
                              f'人差指先端(統一): ({index_tip[0]:.2f}, {index_tip[1]:.2f}, {index_tip[2]:.3f})',
                              font_tiny, (0, 255, 0)))

            # 結果テキストの構築
            result_text = f"{hand_label} - 信頼度: {confidence:.3f} - 手のサイズ: {hand_size:.3f}"

            if finger_angles:
                result_text += f" - 関節角度サンプル(3D): {[f'{a:.1f}°' for a in finger_angles[:5]]}"

            if palm_normal:
                result_text += f" - 掌法線(3D): ({palm_normal[0]:.3f}, {palm_normal[1]:.3f}, {palm_normal[2]:.3f})"

            # 距離特徴量の追加
            result_text += f" - 親指-人差指先端(3D): {distances['thumb_index_tip_3d']:.3f}"
            result_text += f", 親指-中指先端(3D): {distances['thumb_middle_tip_3d']:.3f}"

    else:
        # 手が検出されない場合
        no_detection_count += 1
        if no_detection_count > HISTORY_SIZE * 2:  # 一定フレーム検出されない場合は履歴クリア
            for k in landmark_history_map:
                landmark_history_map[k].clear()
        result_text = "手が検出されませんでした"

    # 検出統計表示(末尾一括描画のために蓄積)
    texts.append((10, 60, f'検出された手の数: {detected_hands}', font_medium, (0, 255, 255)))

    # 技術情報表示(末尾一括描画用)
    info_y = frame.shape[0] - 60
    texts.append((10, info_y, '特徴抽出: 3D手指姿勢 + 統一スケール処理 + 指接触',
                  font_small, (255, 255, 255)))
    texts.append((10, info_y + 20, '論理的完全統一座標系 (MediaPipe)',
                  font_small, (255, 255, 255)))
    texts.append((10, info_y + 40, '3D空間での一貫した距離計算による正確な相対判定',
                  font_tiny, (200, 200, 200)))

    # 色分け凡例
    legend_items = [
        ('親指', COLORS['thumb']),
        ('人差指', COLORS['index']),
        ('中指', COLORS['middle']),
        ('薬指', COLORS['ring']),
        ('小指', COLORS['pinky'])
    ]

    # 凡例の円を先に描画(OpenCVはBGR)
    for i, (label, color_rgb) in enumerate(legend_items):
        y_pos = 100 + i * 25
        cv2.circle(frame, (frame.shape[1] - 100, y_pos), 5, rgb_to_bgr(color_rgb), -1)

    # 凡例テキストを一度に描画(PillowはRGB)
    for i, (label, color_rgb) in enumerate(legend_items):
        y_pos = 100 + i * 25
        texts.append((frame.shape[1] - 90, y_pos - 5, label, font_tiny, color_rgb))

    # 日本語描画のためPillowに変換
    img_pil = Image.fromarray(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB))
    draw = ImageDraw.Draw(img_pil)
    # 情報表示
    for (x, y, text, font, fill) in texts:
        draw.text((x, y), text, font=font, fill=fill)
    frame = cv2.cvtColor(np.array(img_pil), cv2.COLOR_RGB2BGR)

    return frame, result_text, current_time


# MediaPipe初期化
mp_hands = mp.solutions.hands
mp_drawing = mp.solutions.drawing_utils
mp_drawing_styles = mp.solutions.drawing_styles
# MediaPipe描画スタイルのキャッシュ
HAND_LANDMARKS_STYLE = mp_drawing_styles.get_default_hand_landmarks_style()
HAND_CONNECTIONS_STYLE = mp_drawing_styles.get_default_hand_connections_style()

# MediaPipe Handsモデル初期化
hands = mp_hands.Hands(
    static_image_mode=False,
    max_num_hands=MAX_NUM_HANDS,
    min_detection_confidence=HAND_CONFIDENCE,
    min_tracking_confidence=TRACKING_CONFIDENCE,
    model_complexity=0
)

print('手指姿勢推定モデル(MediaPipe Hands)をロードしました')

print("0: 動画ファイル")
print("1: カメラ")
print("2: サンプル動画")

choice = input("選択: ")

if choice == '0':
    root = tk.Tk()
    root.withdraw()
    path = filedialog.askopenfilename()
    if not path:
        exit()
    cap = cv2.VideoCapture(path)
elif choice == '1':
    cap = cv2.VideoCapture(0, cv2.CAP_DSHOW)
    if not cap.isOpened():
        cap = cv2.VideoCapture(0)
    cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)
else:
    # サンプル動画ダウンロード・処理
    SAMPLE_URL = 'https://raw.githubusercontent.com/opencv/opencv/master/samples/data/vtest.avi'
    SAMPLE_FILE = 'vtest.avi'
    urllib.request.urlretrieve(SAMPLE_URL, SAMPLE_FILE)
    cap = cv2.VideoCapture(SAMPLE_FILE)

if not cap.isOpened():
    print('動画ファイル・カメラを開けませんでした')
    exit()

# メイン処理
print('\n=== 動画処理開始 ===')
print('操作方法:')
print('  q キー: プログラム終了')
try:
    while True:
        ret, frame = cap.read()
        if not ret:
            break

        processed_frame, result, current_time = video_frame_processing(frame)
        cv2.imshow(MAIN_FUNC_DESC, processed_frame)
        if choice == '1':  # カメラの場合
            print(datetime.fromtimestamp(current_time).strftime("%Y-%m-%d %H:%M:%S.%f")[:-3], result)
        else:  # 動画ファイルの場合
            print(frame_count, result)
        results_log.append(result)
        if cv2.waitKey(1) & 0xFF == ord('q'):
            break
finally:
    print('\n=== プログラム終了 ===')
    cap.release()
    cv2.destroyAllWindows()
    if results_log:
        with open('result.txt', 'w', encoding='utf-8') as f:
            f.write('=== 結果 ===\n')
            f.write(f'処理フレーム数: {frame_count}\n')
            f.write(f'使用デバイス: {str(device).upper()}\n')
            if device.type == 'cuda':
                f.write(f'GPU: {torch.cuda.get_device_name(0)}\n')
            f.write('\n')
            f.write('\n'.join(results_log))
        print(f'\n処理結果をresult.txtに保存しました')

hands.close()

4. 使用方法

  1. 上記のプログラムを実行する
  2. Webカメラが起動し、手を映すと21点の3次元ランドマークが表示される。
  3. 各指は異なる色で表示され、関節角度や手のひらの向きなどの情報がリアルタイムで更新される。
  4. 終了するにはqキーを押す。

5. 実験・探求のアイデア

AIモデル選択

プログラム内のmodel_complexityパラメータを変更することで、異なる精度のモデルを選択できる:

実験要素

  1. 検出感度の調整: HAND_CONFIDENCETRACKING_CONFIDENCEの値を0.1から0.9の範囲で変更し、検出精度と安定性の変化を観察する。
  2. 両手認識の実験: MAX_NUM_HANDSを1に変更して片手のみ、2のままで両手を検出し、処理速度の違いを比較する。
  3. 時系列フィルタリングの効果: history_sizeを1から10の範囲で変更し、手の動きの滑らかさがどう変化するか観察する。値を1にすると生の検出結果、値を大きくするほど滑らかになるが遅延が増加する。

体験・実験・探求のアイデア

  1. ジェスチャー認識の基礎実験: 人差し指と親指の先端座標を取得し、その距離を計算してピンチジェスチャーを検出する機能を追加する。距離の閾値を変えることで、検出感度の違いを体験できる。
  2. 3次元空間での手の動き追跡: 手首の座標を時系列で記録し、3次元空間での軌跡を可視化する。手を8の字に動かしたり、円を描いたりして、z座標(奥行き)の変化パターンを観察する。
  3. 指の曲げ角度による状態判定: 各指の関節角度データを使って、グー・チョキ・パーの判定を実装する。角度の閾値を調整することで、認識精度がどう変わるか実験できる。
  4. 手のサイズによる個人差の観察: 複数の人の手でhand_scale値を測定し、個人差がどの程度あるか調査する。この値を使った正規化処理を加えることで、個人差に依存しないジェスチャー認識の可能性を探る。
  5. 照明条件による影響の検証: 部屋の照明を変えたり、逆光状態で実験したりして、検出精度への影響を観察する。MediaPipeの頑健性を体験できる。