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次元座標(x, y, z)として推定。z座標は手首を基準とした相対的な深度情報を提供する。指接触判定により日本語指文字の認識支援を行う。
# 学習済みモデル: 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ランドマークと関連情報)、1秒間隔でprint()で処理結果を表示、プログラム終了時にprint()で表示した処理結果をresult.txtファイルに保存
#   - 処理手順: 1.カメラから画像取得、2.MediaPipe Handsで手指検出、3.21点の3D座標抽出、4.関節角度・手の向き・掌法線ベクトル計算、5.結果を画面に描画
#   - 前処理、後処理: 前処理: BGR→RGB変換(MediaPipeの入力要件)、後処理: 時系列フィルタリング(過去3フレームの移動平均によるランドマーク位置の安定化)
#   - 追加処理: z座標の正規化(10倍スケーリング)により3次元ベクトル演算の精度向上、関節角度計算による手指姿勢の定量化、掌法線ベクトル計算による手の向き推定、指接触判定による日本語指文字認識支援
#   - 調整を必要とする設定値: HAND_CONFIDENCE(手検出の信頼度閾値、デフォルト0.7)、TRACKING_CONFIDENCE(追跡の信頼度閾値、デフォルト0.5)、MAX_NUM_HANDS(検出する手の最大数、デフォルト2)
# 将来方策: HAND_CONFIDENCEとTRACKING_CONFIDENCEの最適値を自動調整するため、検出成功率を監視し、一定時間ごとに閾値を動的に調整する機能の実装が可能
# その他の重要事項: Windows環境専用(DirectShowバックエンド使用)、日本語フォントはWindows標準のMSゴシックを使用
# 前準備: 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 os
import time

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

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

# 履歴管理
HISTORY_SIZE = 5

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

# 色定義
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)   # 手首 - グレー
}

# 手指ランドマーク構造定義(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
}

# 日本語フォント設定
font_large = ImageFont.truetype('C:/Windows/Fonts/msgothic.ttc', FONT_LARGE)
font_medium = ImageFont.truetype('C:/Windows/Fonts/msgothic.ttc', FONT_MEDIUM)
font_small = ImageFont.truetype('C:/Windows/Fonts/msgothic.ttc', FONT_SMALL)
font_tiny = ImageFont.truetype('C:/Windows/Fonts/msgothic.ttc', FONT_TINY)

# プログラム開始時の説明
print('MediaPipe 3D手指ランドマーク検出プログラム')
print('=' * 50)
print('概要: MediaPipe Handsを使用して手指の21点3Dランドマークをリアルタイム検出します')
print('特徴: 単一のRGBカメラから3次元座標(x, y, z)を推定')
print('   z座標は手首を基準とした相対的な深度情報')
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)
print('1. 親指と他の指の先端接触(輪を作る)の判定特徴量:')
print('')
print('「お」:親指先端と人差指先端の距離が閾値以下、かつ親指と人差指のDIP関節角度が120度以上(輪の形成)')
print('「き」:親指先端と人差指先端、中指先端の両方との距離が閾値以下、かつ3本の指が同一平面上にある')
print('「ら」:「き」と同様の接触+手首の回転角度が横向き(掌法線ベクトルのx成分で判定)')
print('2. 親指と他の指の側面接触の判定特徴量:')
print('')
print('「す」:親指先端と人差指のPIP-DIP間の最短距離(側面への接触)')
print('「せ」:親指先端と人差指DIP関節の距離(第一関節付近)')
print('「ぬ」:親指先端と人差指MCP、中指MCPの両方との距離(根元を押さえる)')
print('3. 親指と他の指の根元接触の判定特徴量:')
print('')
print('「め」:親指先端と小指MCP関節の距離')
print('「む」:親指先端と人差指MCP関節の距離')
print('4. 指同士の交差の判定特徴量:')
print('')
print('「ね」:人差指と中指の先端距離+PIP関節同士の距離(交差により接近)')
print('=' * 50)

# 結果記録用リスト
result_log = []
last_print_time = time.time()

# 判定特徴量の説明を記録
result_log.append('1. 親指と他の指の先端接触(輪を作る)の判定特徴量:')
result_log.append('')
result_log.append('「お」:親指先端と人差指先端の距離が閾値以下、かつ親指と人差指のDIP関節角度が120度以上(輪の形成)')
result_log.append('「き」:親指先端と人差指先端、中指先端の両方との距離が閾値以下、かつ3本の指が同一平面上にある')
result_log.append('「ら」:「き」と同様の接触+手首の回転角度が横向き(掌法線ベクトルのx成分で判定)')
result_log.append('2. 親指と他の指の側面接触の判定特徴量:')
result_log.append('')
result_log.append('「す」:親指先端と人差指のPIP-DIP間の最短距離(側面への接触)')
result_log.append('「せ」:親指先端と人差指DIP関節の距離(第一関節付近)')
result_log.append('「ぬ」:親指先端と人差指MCP、中指MCPの両方との距離(根元を押さえる)')
result_log.append('3. 親指と他の指の根元接触の判定特徴量:')
result_log.append('')
result_log.append('「め」:親指先端と小指MCP関節の距離')
result_log.append('「む」:親指先端と人差指MCP関節の距離')
result_log.append('4. 指同士の交差の判定特徴量:')
result_log.append('')
result_log.append('「ね」:人差指と中指の先端距離+PIP関節同士の距離(交差により接近)')
result_log.append('=' * 50)


def video_processing(frame, frame_count):
    global last_print_time, result_log

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

    # 再検出時は一時的にstatic_image_modeをTrueに
    if static_mode:
        hands.static_image_mode = True

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

    # MediaPipe Handsで手指を検出・追跡
    results = hands.process(rgb_frame)

    # static_image_modeを元に戻す
    if static_mode:
        hands.static_image_mode = False

    # 日本語描画のためPillowに変換
    img_pil = Image.fromarray(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB))
    draw = ImageDraw.Draw(img_pil)

    # 情報表示
    draw.text((10, 30), f'MediaPipe 3D手指姿勢推定 (フレーム: {frame_count})',
              font=font_large, fill=(0, 255, 0))

    detected_hands = 0
    current_time = time.time()

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

            # 履歴に追加
            landmark_history.append(hand_landmarks)
            if len(landmark_history) > HISTORY_SIZE:
                landmark_history.pop(0)

            # スムージング用の座標を計算
            smoothed_points_3d = None
            if len(landmark_history) >= 3:
                recent_landmarks = landmark_history[-3:]
                smoothed_points_3d = []

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

                    smoothed_points_3d.append([avg_x, avg_y, avg_z])

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

            # 座標スケールの統一
            normalized_points_3d = []
            for point in points_3d:
                normalized_points_3d.append([point[0], point[1], point[2] * 10])

            # 指の関節角度計算
            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):
                    p1 = normalized_points_3d[chain[i]]
                    p2 = normalized_points_3d[chain[i+1]]
                    p3 = normalized_points_3d[chain[i+2]]

                    v1 = np.array(p1) - np.array(p2)
                    v2 = np.array(p3) - np.array(p2)

                    norm1, norm2 = np.linalg.norm(v1), np.linalg.norm(v2)
                    if norm1 == 0 or norm2 == 0:
                        angle = 0.0
                    else:
                        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))

                    finger_angles.append(angle)

            # 手のひらの法線ベクトル計算
            palm_normal = None
            try:
                wrist = np.array(points_3d[0])
                middle_mcp = np.array(points_3d[9])
                pinky_mcp = np.array(points_3d[17])

                v1 = middle_mcp - wrist
                v2 = pinky_mcp - wrist
                normal = np.cross(v1, v2)

                norm = np.linalg.norm(normal)
                if norm > 0:
                    normal = normal / norm
                    palm_normal = normal.tolist()
            except:
                pass

            # 手首から中指MCPへの方向ベクトル計算
            hand_direction = None
            try:
                wrist = np.array(points_3d[0])
                middle_mcp = np.array(points_3d[9])
                direction = middle_mcp - wrist
                norm = np.linalg.norm(direction)
                if norm > 0:
                    direction = direction / norm
                    hand_direction = direction.tolist()
            except:
                pass

            # 手のスケール計算
            hand_scale = 0.0
            try:
                wrist = np.array(points_3d[0])
                middle_tip = np.array(points_3d[12])
                hand_scale = float(np.linalg.norm(middle_tip - wrist))
            except:
                pass

            # 指接触判定(日本語指文字認識用)
            contact_threshold = hand_scale * 0.05  # 手のスケールの5%を接触閾値とする
            finger_contacts = []

            # 親指先端と他の指の各部位との距離計算
            thumb_tip = np.array(normalized_points_3d[FINGER_LANDMARKS['THUMB_TIP']])

            # 各指との接触判定
            finger_parts = [
                ('人差指先端', FINGER_LANDMARKS['INDEX_FINGER_TIP']),
                ('人差指DIP', FINGER_LANDMARKS['INDEX_FINGER_DIP']),
                ('人差指PIP', FINGER_LANDMARKS['INDEX_FINGER_PIP']),
                ('人差指MCP', FINGER_LANDMARKS['INDEX_FINGER_MCP']),
                ('中指先端', FINGER_LANDMARKS['MIDDLE_FINGER_TIP']),
                ('中指MCP', FINGER_LANDMARKS['MIDDLE_FINGER_MCP']),
                ('薬指MCP', FINGER_LANDMARKS['RING_FINGER_MCP']),
                ('小指MCP', FINGER_LANDMARKS['PINKY_MCP'])
            ]

            for part_name, landmark_idx in finger_parts:
                part_pos = np.array(normalized_points_3d[landmark_idx])
                distance = np.linalg.norm(thumb_tip - part_pos)
                if distance < contact_threshold:
                    finger_contacts.append((part_name, distance))

            # 人差し指と中指の交差判定
            index_tip = np.array(normalized_points_3d[FINGER_LANDMARKS['INDEX_FINGER_TIP']])
            middle_tip = np.array(normalized_points_3d[FINGER_LANDMARKS['MIDDLE_FINGER_TIP']])
            index_middle_distance = np.linalg.norm(index_tip - middle_tip)
            if index_middle_distance < contact_threshold * 1.5:
                finger_contacts.append(('人差指-中指交差', index_middle_distance))

            # 指定された特徴量の計算
            # 親指先端と各指先端の距離
            thumb_index_tip_dist = np.linalg.norm(thumb_tip - np.array(normalized_points_3d[FINGER_LANDMARKS['INDEX_FINGER_TIP']]))
            thumb_middle_tip_dist = np.linalg.norm(thumb_tip - np.array(normalized_points_3d[FINGER_LANDMARKS['MIDDLE_FINGER_TIP']]))

            # 親指先端と人差指PIP-DIP間の最短距離
            index_pip = np.array(normalized_points_3d[FINGER_LANDMARKS['INDEX_FINGER_PIP']])
            index_dip = np.array(normalized_points_3d[FINGER_LANDMARKS['INDEX_FINGER_DIP']])
            # 線分上の最近点を計算
            pip_to_dip = index_dip - index_pip
            t = np.dot(thumb_tip - index_pip, pip_to_dip) / np.dot(pip_to_dip, pip_to_dip)
            t = np.clip(t, 0, 1)
            closest_point = index_pip + t * pip_to_dip
            thumb_index_side_dist = np.linalg.norm(thumb_tip - closest_point)

            # 親指先端と各関節の距離
            thumb_index_dip_dist = np.linalg.norm(thumb_tip - np.array(normalized_points_3d[FINGER_LANDMARKS['INDEX_FINGER_DIP']]))
            thumb_index_mcp_dist = np.linalg.norm(thumb_tip - np.array(normalized_points_3d[FINGER_LANDMARKS['INDEX_FINGER_MCP']]))
            thumb_middle_mcp_dist = np.linalg.norm(thumb_tip - np.array(normalized_points_3d[FINGER_LANDMARKS['MIDDLE_FINGER_MCP']]))
            thumb_pinky_mcp_dist = np.linalg.norm(thumb_tip - np.array(normalized_points_3d[FINGER_LANDMARKS['PINKY_MCP']]))

            # 人差指と中指の距離
            index_middle_tip_dist = np.linalg.norm(index_tip - middle_tip)
            index_pip_pos = np.array(normalized_points_3d[FINGER_LANDMARKS['INDEX_FINGER_PIP']])
            middle_pip_pos = np.array(normalized_points_3d[FINGER_LANDMARKS['MIDDLE_FINGER_PIP']])
            index_middle_pip_dist = np.linalg.norm(index_pip_pos - middle_pip_pos)

            # 深度推定
            depth_estimation = [p[2] for p in points_3d]

            # OpenCVに戻して描画処理
            frame = cv2.cvtColor(np.array(img_pil), cv2.COLOR_RGB2BGR)

            # 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

                radius = max(2, int(8 * (1 - min(abs(z) * 10, 1))))

                if i == 0:
                    color = COLORS['wrist']
                elif 1 <= i <= 4:
                    color = COLORS['thumb']
                elif 5 <= i <= 8:
                    color = COLORS['index']
                elif 9 <= i <= 12:
                    color = COLORS['middle']
                elif 13 <= i <= 16:
                    color = COLORS['ring']
                elif 17 <= i <= 20:
                    color = COLORS['pinky']
                else:
                    color = COLORS['palm']

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

                if i in [FINGER_LANDMARKS['INDEX_FINGER_TIP'], FINGER_LANDMARKS['THUMB_TIP']]:
                    for name, idx in FINGER_LANDMARKS.items():
                        if idx == i:
                            cv2.putText(frame, name[-3:], (x + 3, y - 3),
                                       cv2.FONT_HERSHEY_SIMPLEX, 0.3, color, 1)
                            break
                else:
                    cv2.putText(frame, str(i), (x + 3, y - 3),
                               cv2.FONT_HERSHEY_SIMPLEX, 0.3, color, 1)

            # 接続線描画
            mp_drawing.draw_landmarks(
                frame, hand_landmarks, mp_hands.HAND_CONNECTIONS,
                mp_drawing_styles.get_default_hand_landmarks_style(),
                mp_drawing_styles.get_default_hand_connections_style()
            )

            # 再度Pillowに変換して日本語テキスト描画
            img_pil = Image.fromarray(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB))
            draw = ImageDraw.Draw(img_pil)

            # マルチスケール特徴情報表示
            y_offset = 80
            draw.text((10, y_offset), f'検出手: {hand_label}', font=font_medium, fill=(255, 255, 255))
            y_offset += 25

            if hand_scale > 0:
                draw.text((10, y_offset), f'手のスケール: {hand_scale:.3f}',
                         font=font_small, fill=(0, 255, 255))
                y_offset += 20

            if palm_normal:
                draw.text((10, y_offset),
                         f'掌法線: ({palm_normal[0]:.2f}, {palm_normal[1]:.2f}, {palm_normal[2]:.2f})',
                         font=font_tiny, fill=(255, 255, 0))
                y_offset += 20

            if hand_direction:
                draw.text((10, y_offset),
                         f'手の向き: ({hand_direction[0]:.2f}, {hand_direction[1]:.2f}, {hand_direction[2]:.2f})',
                         font=font_tiny, fill=(255, 255, 0))
                y_offset += 20

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

            if depth_estimation:
                depth_range = (min(depth_estimation), max(depth_estimation))
                draw.text((10, y_offset), f'深度範囲: [{depth_range[0]:.3f}, {depth_range[1]:.3f}]',
                         font=font_tiny, fill=(200, 200, 200))
                y_offset += 20

            if points_3d:
                index_tip = points_3d[FINGER_LANDMARKS['INDEX_FINGER_TIP']]
                draw.text((10, y_offset),
                         f'人差指先端: ({index_tip[0]:.2f}, {index_tip[1]:.2f}, {index_tip[2]:.3f})',
                         font=font_tiny, fill=(0, 255, 0))

            # 1秒間隔でコンソール出力
            if current_time - last_print_time >= 1.0:
                output_text = f"[{time.strftime('%H:%M:%S')}] フレーム {frame_count} - {hand_label} - 信頼度: {confidence:.3f} - 手のスケール: {hand_scale:.3f}"
                print(output_text)
                result_log.append(output_text)

                if finger_angles:
                    angle_text = f'  関節角度サンプル: {[f"{a:.1f}°" for a in finger_angles[:5]]}'
                    print(angle_text)
                    result_log.append(angle_text)

                if palm_normal:
                    normal_text = f'  掌法線ベクトル: ({palm_normal[0]:.3f}, {palm_normal[1]:.3f}, {palm_normal[2]:.3f})'
                    print(normal_text)
                    result_log.append(normal_text)

                # 指定された特徴量の出力
                dist_text1 = f'  親指-人差指先端: {thumb_index_tip_dist:.3f}, 親指-中指先端: {thumb_middle_tip_dist:.3f}'
                print(dist_text1)
                result_log.append(dist_text1)

                dist_text2 = f'  親指-人差指側面: {thumb_index_side_dist:.3f}, 親指-人差指DIP: {thumb_index_dip_dist:.3f}'
                print(dist_text2)
                result_log.append(dist_text2)

                dist_text3 = f'  親指-人差指MCP: {thumb_index_mcp_dist:.3f}, 親指-中指MCP: {thumb_middle_mcp_dist:.3f}'
                print(dist_text3)
                result_log.append(dist_text3)

                dist_text4 = f'  親指-小指MCP: {thumb_pinky_mcp_dist:.3f}'
                print(dist_text4)
                result_log.append(dist_text4)

                dist_text5 = f'  人差指-中指先端: {index_middle_tip_dist:.3f}, 人差指-中指PIP: {index_middle_pip_dist:.3f}'
                print(dist_text5)
                result_log.append(dist_text5)

                last_print_time = current_time

            # OpenCVに戻す
            frame = cv2.cvtColor(np.array(img_pil), cv2.COLOR_RGB2BGR)

    # 検出統計表示
    img_pil = Image.fromarray(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB))
    draw = ImageDraw.Draw(img_pil)
    draw.text((10, 60), f'検出された手の数: {detected_hands}', font=font_medium, fill=(0, 255, 255))

    # 技術情報表示
    info_y = frame.shape[0] - 60
    draw.text((10, info_y), '特徴抽出: 3D手指姿勢 + 深度推定 + 指接触',
             font=font_small, fill=(255, 255, 255))
    draw.text((10, info_y + 20), '軽量基本モジュール (MediaPipe)',
             font=font_small, fill=(255, 255, 255))
    draw.text((10, info_y + 40), 'z座標は手首を基準とした相対深度',
             font=font_tiny, fill=(200, 200, 200))

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

    # 凡例の円を先に描画
    frame = cv2.cvtColor(np.array(img_pil), cv2.COLOR_RGB2BGR)
    for i, (label, color) in enumerate(legend_items):
        y_pos = 100 + i * 25
        cv2.circle(frame, (frame.shape[1] - 100, y_pos), 5, color, -1)

    # 凡例テキストを一度に描画
    img_pil = Image.fromarray(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB))
    draw = ImageDraw.Draw(img_pil)
    for i, (label, color) in enumerate(legend_items):
        y_pos = 100 + i * 25
        draw.text((frame.shape[1] - 90, y_pos - 5), label, font=font_tiny, fill=color)

    frame = cv2.cvtColor(np.array(img_pil), cv2.COLOR_RGB2BGR)

    return frame


# 乱数シード設定
np.random.seed(RANDOM_SEED)

# MediaPipe初期化
mp_hands = mp.solutions.hands
mp_drawing = mp.solutions.drawing_utils
mp_drawing_styles = mp.solutions.drawing_styles

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

# 時系列フィルタリング用の履歴
landmark_history = []

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

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

choice = input('選択: ')
temp_file = None

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)
    cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)
elif choice == '2':
    # サンプル動画ダウンロード・処理
    import urllib.request
    url = 'https://raw.githubusercontent.com/opencv/opencv/master/samples/data/vtest.avi'
    filename = 'vtest.avi'
    try:
        urllib.request.urlretrieve(url, filename)
        temp_file = filename
        cap = cv2.VideoCapture(filename)
    except Exception as e:
        print(f'動画のダウンロードに失敗しました: {url}')
        print(f'エラー: {e}')
        exit()
else:
    print('無効な選択です')
    exit()

# メイン処理
frame_count = 0
try:
    while True:
        cap.grab()
        ret, frame = cap.retrieve()
        if not ret:
            break

        frame_count += 1
        processed_frame = video_processing(frame, frame_count)
        cv2.imshow('MediaPipe 3D手指姿勢推定', processed_frame)

        if cv2.waitKey(1) & 0xFF == ord('q'):
            break
finally:
    cap.release()
    cv2.destroyAllWindows()
    hands.close()

    # 結果をファイルに保存
    if result_log:
        with open('result.txt', 'w', encoding='utf-8') as f:
            for line in result_log:
                f.write(line + '\n')
        print('result.txtに保存しました')

    if temp_file:
        os.remove(temp_file)

    print('プログラムを終了しました')

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の頑健性を体験できる。