MediaPipe Face Landmarkerによる瞳孔と虹彩追跡(ソースコードと実行結果)

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

ここでは、最低限の事前準備について説明する。機械学習や深層学習を行う場合は、NVIDIA CUDA、Visual Studio、Cursorなどを追加でインストールすると便利である。これらについては別ページ https://www.kkaneko.jp/cc/dev/aiassist.htmlで詳しく解説しているので、必要に応じて参照してください。

Python 3.12 のインストール

インストール済みの場合は実行不要。

管理者権限でコマンドプロンプトを起動(手順:Windowsキーまたはスタートメニュー > cmd と入力 > 右クリック > 「管理者として実行」)し、以下を実行する。管理者権限は、wingetの--scope machineオプションでシステム全体にソフトウェアをインストールするために必要である。

REM Python をシステム領域にインストール
winget install --scope machine --id Python.Python.3.12 -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

関連する外部ページ

Python の公式ページ: https://www.python.org/

AI エディタ Windsurf のインストール

Pythonプログラムの編集・実行には、AI エディタの利用を推奨する。ここでは,Windsurfのインストールを説明する。

管理者権限でコマンドプロンプトを起動(手順:Windowsキーまたはスタートメニュー > cmd と入力 > 右クリック > 「管理者として実行」)し、以下を実行して、Windsurfをシステム全体にインストールする。管理者権限は、wingetの--scope machineオプションでシステム全体にソフトウェアをインストールするために必要となる。

winget install --scope machine Codeium.Windsurf -e --silent

関連する外部ページ

Windsurf の公式ページ: https://windsurf.com/

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

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


pip install mediapipe opencv-python numpy pillow

MediaPipe Face Landmarkerによる瞳孔と虹彩追跡プログラム

概要

MediaPipe Face Landmarkerによる瞳孔と虹彩追跡。目の動き、瞬きを検出。サッカードは近似的に検出(サッケードは極めて高速の動きであるため、それをすべてとらえることは、普通のカメラではできない、特別のカメラが必要となる)

用語集

MediaPipe Face Landmarkerの詳細

プログラム実行で得られるデータ

システム動作仕様
1. print()での表示タイミング

間隔: 1秒ごと
設定値: PRINT_INTERVAL = 1.0で設定
判定条件: current_timestamp - last_print_time >= PRINT_INTERVALの条件で1秒間隔をチェック
例外: 瞬きとサッカードは検出時に即座に出力

2. result.txtへの保存タイミング

保存タイミング: プログラム終了時に一括保存(1秒ごとではない)
データ蓄積: results_logリストに蓄積されたすべてのメッセージを最後にファイル出力

保存内容:

    1秒ごとの定期出力
    瞬き検出の即時出力
    サッカード検出の即時出力


3. 5つのグラフの更新タイミング

更新頻度: 毎フレーム更新(約30FPS)
更新処理: video_processing(frame)関数内で毎フレーム呼び出されるdraw_graph()で更新
特徴: リアルタイムで最新データを反映

4. 各グラフのデータ表示範囲
【1. Gaze Angle (deg) - 視線角度】

    表示範囲: 直近30秒間
    データ点数: 900個(30秒 × 30FPS)
    更新: 毎フレーム(虹彩検出時)


【2. Iris Speed Trend (px/s²) - 虹彩速度トレンド】

    表示範囲: 直近30秒間
    データ点数: 900個(30秒 × 30FPS)
    更新: 毎フレーム(虹彩検出時)


【3. Blink Freq Trend (per min²) - 瞬き頻度トレンド】

    表示範囲: 直近30秒間
    データ点数: 900個(30秒 × 30FPS)
    更新: 毎フレーム(虹彩検出時)


【4. Movement Freq (per sec) - 動き頻度】

    表示範囲: 直近30秒間
    データ点数: 900個(30秒分の秒単位データをFPS分拡張)
    更新: 1秒ごとに新しい秒のデータを追加、表示は毎フレーム


【5. Saccade Freq (per min) - サッカード頻度】

    表示範囲: 直近30秒間
    データ点数: 900個(30秒 × 30FPS)
    更新: 毎フレーム(虹彩検出時)


設定値

  GRAPH_TIME_WINDOW = 30  # 30秒の時間窓

ソースコード


# プログラム名: MediaPipe Face Landmarkerによる瞳孔と虹彩追跡プログラム
# 特徴技術名: MediaPipe
# 出典: MediaPipe Tasks - Google
# 特徴機能: MediaPipe Face Landmarkerによる虹彩追跡。リアルタイムで動作する軽量な目の動き検出
# 学習済みモデル: Face Landmarker事前学習済みモデル(478顔ランドマーク)
# 方式設計:
#   - 関連利用技術:
#     - MediaPipe: Googleが開発したマルチプラットフォーム機械学習ソリューション
#     - OpenCV: 画像処理、カメラ制御、描画処理、動画入出力管理
#   - 入力と出力: 入力: 動画(ユーザは「0:動画ファイル,1:カメラ,2:サンプル動画」のメニューで選択.0:動画ファイルの場合はtkinterでファイル選択.1の場合はOpenCVでカメラが開く.2の場合はhttps://raw.githubusercontent.com/opencv/opencv/master/samples/data/vtest.aviを使用)、出力: OpenCV画面でリアルタイム表示(検出した虹彩を円で表示)、1秒間隔でprint()による処理結果表示、プログラム終了時にresult.txtファイルに保存
#   - 処理手順: 1.フレーム取得、2.MediaPipe推論実行、3.顔ランドマーク検出、4.虹彩位置計算、5.カルマンフィルタ適用、6.サッケード検出、7.虹彩円描画
#   - 前処理、後処理: 前処理:MediaPipe内部で自動実行。後処理:虹彩中心座標の計算、カルマンフィルタによる平滑化を実施
#   - 追加処理: 左右の虹彩位置を個別に追跡し表示、サッケード検出と頻度計算
#   - 調整を必要とする設定値: CONF_THRESH(顔検出信頼度閾値、デフォルト0.5)- 値を上げると誤検出が減少するが検出漏れが増加、KALMAN_Q(プロセスノイズ)、KALMAN_R(観測ノイズ)、SACCADE_THRESH(サッケード検出閾値)
# 将来方策: CONF_THRESHの動的調整機能。フレーム毎の検出数を監視し、検出数が閾値を超えた場合は信頼度を上げ、検出数が少ない場合は下げる適応的制御の実装
# その他の重要事項: Windows環境専用設計、初回実行時は学習済みモデルの自動ダウンロード
# 前準備:
#   - pip install mediapipe opencv-python numpy

import cv2
import tkinter as tk
from tkinter import filedialog
import os
import numpy as np
import mediapipe as mp
import warnings
import time
import urllib.request
import math
from collections import deque

warnings.filterwarnings('ignore')

# ===== 設定・定数管理 =====
# MediaPipe設定
BaseOptions = mp.tasks.BaseOptions
FaceLandmarker = mp.tasks.vision.FaceLandmarker
FaceLandmarkerOptions = mp.tasks.vision.FaceLandmarkerOptions
VisionRunningMode = mp.tasks.vision.RunningMode

# モデル選択(0, 2, ssdから選択可能)
MODEL_SIZE = '0'  # 使用するモデルサイズ(0=標準モデル)

# モデル情報
MODEL_INFO = {
    '0': {
        'name': 'Face Landmarker',
        'desc': '顔ランドマーク検出(虹彩追跡対応)',
        'url': 'https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/1/face_landmarker.task',
        'file': 'face_landmarker.task'
    }
}

MODEL_URL = MODEL_INFO[MODEL_SIZE]['url']
MODEL_PATH = MODEL_INFO[MODEL_SIZE]['file']

# 虹彩関連のランドマークインデックス
LEFT_IRIS_INDICES = [468, 469, 470, 471, 472]  # 左目虹彩
RIGHT_IRIS_INDICES = [473, 474, 475, 476, 477]  # 右目虹彩

# 目のランドマークインデックス(EAR計算用)
LEFT_EYE_INDICES = [33, 160, 158, 133, 153, 144]  # 左目の輪郭
RIGHT_EYE_INDICES = [362, 385, 387, 263, 373, 380]  # 右目の輪郭

SAMPLE_URL = 'https://raw.githubusercontent.com/opencv/opencv/master/samples/data/vtest.avi'
SAMPLE_FILE = 'vtest.avi'
RESULT_FILE = 'result.txt'

# カメラ設定
WINDOW_WIDTH = 1280  # カメラ解像度幅
WINDOW_HEIGHT = 720  # カメラ解像度高さ
FPS = 30  # フレームレート

# 検出パラメータ(調整可能)
CONF_THRESH = 0.5  # 顔検出信頼度閾値(0.0-1.0)
EAR_THRESH = 0.2  # 瞬き検出用EAR閾値

# カルマンフィルタパラメータ(調整可能)
KALMAN_Q = 0.01  # プロセスノイズ(小さいほど滑らか)
KALMAN_R = 0.1   # 観測ノイズ(大きいほど観測値を信頼しない)

# サッケード検出パラメータ(調整可能)
SACCADE_THRESH = 300.0  # サッケード検出速度閾値(px/s)
SACCADE_MIN_DURATION = 0.02  # サッケード最小持続時間(秒)

# 表示設定
PRINT_INTERVAL = 1.0  # 結果出力間隔(秒)
GRAPH_TIME_WINDOW = 30  # グラフ表示時間窓(秒)- 実装に合わせて30秒に修正
GRAPH_WIDTH = 400  # グラフの幅(ピクセル)
GRAPH_HEIGHT = 120  # 各グラフの高さ(ピクセル) - 5つのグラフ用に調整
GRAPH_MARGIN = 10  # グラフ間のマージン
TREND_WINDOW = 10  # トレンド計算用時間窓(秒)


class KalmanFilter2D:
    """2次元カルマンフィルタ(x, y座標用)"""
    def __init__(self, process_noise=KALMAN_Q, measurement_noise=KALMAN_R):
        self.kf = cv2.KalmanFilter(4, 2)  # 状態4次元(x,y,vx,vy)、観測2次元(x,y)

        # 状態遷移行列
        self.kf.transitionMatrix = np.array([[1, 0, 1, 0],
                                            [0, 1, 0, 1],
                                            [0, 0, 1, 0],
                                            [0, 0, 0, 1]], np.float32)

        # 観測行列
        self.kf.measurementMatrix = np.array([[1, 0, 0, 0],
                                             [0, 1, 0, 0]], np.float32)

        # プロセスノイズ
        self.kf.processNoiseCov = np.eye(4, dtype=np.float32) * process_noise

        # 観測ノイズ
        self.kf.measurementNoiseCov = np.eye(2, dtype=np.float32) * measurement_noise

        # 初期化フラグ
        self.initialized = False

    def update(self, measurement):
        """測定値で更新"""
        if not self.initialized:
            # 初期状態設定
            self.kf.statePre = np.array([measurement[0], measurement[1], 0, 0], np.float32)
            self.kf.statePost = np.array([measurement[0], measurement[1], 0, 0], np.float32)
            self.initialized = True

        # 予測
        self.kf.predict()

        # 更新
        measurement_array = np.array([[measurement[0]], [measurement[1]]], np.float32)
        self.kf.correct(measurement_array)

        # 推定位置を返す
        return self.kf.statePost[0], self.kf.statePost[1]


# プログラム概要表示
print('=== MediaPipe虹彩追跡プログラム ===')
print('概要: リアルタイムで虹彩(目の動き)を検出し、円で表示します')
print('機能: MediaPipe Face Landmarkerによる虹彩追跡、カルマンフィルタ平滑化、サッケード検出')
print('操作: qキーで終了')
print('出力: 1秒間隔での処理結果表示、終了時にresult.txt保存')
print()

# システム初期化
print('システム初期化中...')
start_time = time.time()

# モデルダウンロード
if not os.path.exists(MODEL_PATH):
    print(f'{MODEL_INFO[MODEL_SIZE]["name"]}モデルをダウンロード中...')
    try:
        urllib.request.urlretrieve(MODEL_URL, MODEL_PATH)
        print('モデルのダウンロードが完了しました')
    except Exception as e:
        print(f'モデルのダウンロードに失敗しました: {e}')
        exit()

# MediaPipeモデル初期化
detector = None
try:
    print(f'MediaPipe Face Landmarker {MODEL_INFO[MODEL_SIZE]["name"]}モデルを初期化中...')
    options = FaceLandmarkerOptions(
        base_options=BaseOptions(model_asset_path=MODEL_PATH),
        running_mode=VisionRunningMode.VIDEO,
        num_faces=10,
        min_face_detection_confidence=CONF_THRESH,
        min_face_presence_confidence=CONF_THRESH,
        min_tracking_confidence=CONF_THRESH,
        output_face_blendshapes=False,
        output_facial_transformation_matrixes=False
    )
    detector = FaceLandmarker.create_from_options(options)
    print(f'MediaPipe Face Landmarker {MODEL_INFO[MODEL_SIZE]["name"]}モデルの初期化が完了しました')
    print(f'モデル: {MODEL_INFO[MODEL_SIZE]["name"]} ({MODEL_INFO[MODEL_SIZE]["desc"]})')
    print(f'検出可能: 顔ランドマーク478点(虹彩含む)')
except Exception as e:
    print('MediaPipe Face Landmarkerモデルの初期化に失敗しました')
    print(f'エラー: {e}')
    exit()

print('CPUモード')
print('初期化完了')
print()

# グローバル変数
frame_count = 0
last_print_time = time.time()
results_log = []
previous_positions = {}  # 前フレームの虹彩位置を保存
blink_states = {}  # 瞬き状態を保存
last_movement_update = 0  # 動き頻度更新用
current_movement_count = 0  # 現在の秒の動きカウント
kalman_filters = {}  # カルマンフィルタ保存用
saccade_states = {}  # サッケード検出状態
saccade_events = []  # サッケードイベント記録

# グラフ用データ保存
graph_data = {
    'time': deque(maxlen=int(GRAPH_TIME_WINDOW * FPS)),
    'gaze_angle': deque(maxlen=int(GRAPH_TIME_WINDOW * FPS)),
    'speed': deque(maxlen=int(GRAPH_TIME_WINDOW * FPS)),
    'speed_trend': deque(maxlen=int(GRAPH_TIME_WINDOW * FPS)),
    'iris_size': deque(maxlen=int(GRAPH_TIME_WINDOW * FPS)),
    'blink_times': deque(maxlen=1000),  # 瞬き時刻
    'blink_freq': deque(maxlen=int(GRAPH_TIME_WINDOW * FPS)),
    'blink_trend': deque(maxlen=int(GRAPH_TIME_WINDOW * FPS)),
    'movement_count': deque(maxlen=GRAPH_TIME_WINDOW),  # 秒ごとの動き回数
    'saccade_freq': deque(maxlen=int(GRAPH_TIME_WINDOW * FPS)),  # サッケード頻度
    'saccade_times': deque(maxlen=1000),  # サッケード発生時刻
}
movement_threshold = 5.0  # 動きとみなす速度閾値(px/s)


def calculate_ear(eye_landmarks):
    """Eye Aspect Ratio (EAR)を計算"""
    # 垂直距離
    v1 = np.linalg.norm(eye_landmarks[1] - eye_landmarks[5])
    v2 = np.linalg.norm(eye_landmarks[2] - eye_landmarks[4])
    # 水平距離
    h = np.linalg.norm(eye_landmarks[0] - eye_landmarks[3])
    # EAR計算
    ear = (v1 + v2) / (2.0 * h) if h > 0 else 0
    return ear


def calculate_iris_size(iris_landmarks):
    """虹彩サイズ(直径)を計算"""
    # 虹彩の5点から最大距離を計算
    max_dist = 0
    for i in range(len(iris_landmarks)):
        for j in range(i + 1, len(iris_landmarks)):
            dist = np.linalg.norm(iris_landmarks[i] - iris_landmarks[j])
            max_dist = max(max_dist, dist)
    return max_dist


def calculate_gaze_angle(iris_center, eye_center):
    """視線角度を計算(度数)"""
    dx = iris_center[0] - eye_center[0]
    dy = iris_center[1] - eye_center[1]
    angle = math.degrees(math.atan2(dy, dx))
    # 距離も計算(正規化用)
    distance = math.sqrt(dx**2 + dy**2)
    return angle, distance


def calculate_trend(data, time_data, window_seconds):
    """線形回帰によりトレンド(傾き)を計算"""
    if len(data) < 2 or len(time_data) < 2:
        return 0

    # 指定時間窓内のデータを抽出
    current_time = time_data[-1] if len(time_data) > 0 else 0
    valid_indices = []
    for i in range(len(time_data)):
        if current_time - time_data[i] <= window_seconds:
            valid_indices.append(i)

    if len(valid_indices) < 2:
        return 0

    # 線形回帰で傾きを計算
    x = np.array([time_data[i] - time_data[valid_indices[0]] for i in valid_indices])
    y = np.array([data[i] for i in valid_indices if data[i] is not None])

    if len(y) < 2:
        return 0

    # 最小二乗法
    n = len(x)
    sum_x = np.sum(x)
    sum_y = np.sum(y)
    sum_xy = np.sum(x * y)
    sum_x2 = np.sum(x * x)

    denominator = n * sum_x2 - sum_x * sum_x
    if abs(denominator) < 1e-10:
        return 0

    slope = (n * sum_xy - sum_x * sum_y) / denominator
    return slope


def draw_graph(frame, data, y_range, y_label, graph_x, graph_y, color=(0, 255, 0)):
    """グラフを描画"""
    # グラフ背景
    cv2.rectangle(frame, (graph_x, graph_y), (graph_x + GRAPH_WIDTH, graph_y + GRAPH_HEIGHT),
                  (50, 50, 50), -1)

    # グリッド線
    for i in range(5):
        y = graph_y + int(i * GRAPH_HEIGHT / 4)
        cv2.line(frame, (graph_x, y), (graph_x + GRAPH_WIDTH, y), (100, 100, 100), 1)

    # 直近30秒のデータのみを抽出
    if len(data) > 0:
        # 30秒分のデータポイント数を計算(FPS=30なら900個)
        max_points = int(GRAPH_TIME_WINDOW * FPS)
        if len(data) > max_points:
            # 最新のmax_points個のデータを取得
            recent_data = list(data)[-max_points:]
        else:
            recent_data = list(data)
    else:
        recent_data = []

    # データプロット
    if len(recent_data) > 1:
        points = []
        for i, value in enumerate(recent_data):
            if value is not None:
                x = graph_x + int(i * GRAPH_WIDTH / len(recent_data))
                y = graph_y + GRAPH_HEIGHT - int((value - y_range[0]) / (y_range[1] - y_range[0]) * GRAPH_HEIGHT)
                y = max(graph_y, min(graph_y + GRAPH_HEIGHT, y))
                points.append((x, y))

        if len(points) > 1:
            for i in range(1, len(points)):
                cv2.line(frame, points[i-1], points[i], color, 2)

    # ラベル
    cv2.putText(frame, y_label, (graph_x + 5, graph_y + 20),
                cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 255, 255), 1)

    # Y軸の値
    cv2.putText(frame, f'{y_range[1]:.0f}', (graph_x + GRAPH_WIDTH - 40, graph_y + 20),
                cv2.FONT_HERSHEY_SIMPLEX, 0.4, (255, 255, 255), 1)
    cv2.putText(frame, f'{y_range[0]:.0f}', (graph_x + GRAPH_WIDTH - 40, graph_y + GRAPH_HEIGHT - 5),
                cv2.FONT_HERSHEY_SIMPLEX, 0.4, (255, 255, 255), 1)


def calculate_blink_frequency(blink_times, current_time, window=60):
    """瞬き頻度を計算(回/分)"""
    recent_blinks = [t for t in blink_times if current_time - t < window]
    return len(recent_blinks)


def calculate_saccade_frequency(saccade_times, current_time, window=60):
    """サッケード頻度を計算(回/分)"""
    recent_saccades = [t for t in saccade_times if current_time - t < window]
    return len(recent_saccades)


def calculate_movement_frequency(speed_data, threshold):
    """動きの頻度を計算(回/秒)"""
    if len(speed_data) < 2:
        return 0

    movements = 0
    in_movement = False
    for speed in speed_data:
        if speed is not None:
            if speed > threshold and not in_movement:
                movements += 1
                in_movement = True
            elif speed <= threshold:
                in_movement = False

    return movements


def video_processing(frame):
    """フレーム処理メイン関数"""
    global frame_count, last_print_time, results_log, previous_positions, blink_states, last_movement_update, current_movement_count, kalman_filters, saccade_states, saccade_events

    frame_count += 1
    current_timestamp = time.time()

    # 横方向に拡張したフレーム作成(右側にグラフ領域)
    extended_width = frame.shape[1] + GRAPH_WIDTH + GRAPH_MARGIN * 2
    extended_frame = np.zeros((frame.shape[0], extended_width, 3), dtype=np.uint8)
    extended_frame[:, :frame.shape[1]] = frame

    # RGB変換(MediaPipeはRGBを期待)
    rgb_frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)

    # MediaPipe Image作成
    mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=rgb_frame)

    # 検出実行
    timestamp_ms = int(time.time() * 1000)
    detection_result = detector.detect_for_video(mp_image, timestamp_ms)

    objects = []
    blink_events = []
    current_saccade_events = []

    if detection_result.face_landmarks:
        height, width = frame.shape[:2]

        for face_idx, face_landmarks in enumerate(detection_result.face_landmarks):
            # 最初の顔のみ処理(グラフ表示用)
            if face_idx > 0:
                continue

            # ランドマークをnumpy配列に変換
            landmarks_array = np.array([(lm.x * width, lm.y * height) for lm in face_landmarks])

            # 左目のEAR計算
            left_eye_points = landmarks_array[LEFT_EYE_INDICES]
            left_ear = calculate_ear(left_eye_points)

            # 右目のEAR計算
            right_eye_points = landmarks_array[RIGHT_EYE_INDICES]
            right_ear = calculate_ear(right_eye_points)

            # 平均EAR
            avg_ear = (left_ear + right_ear) / 2.0

            # 瞬き検出(EAR方式)
            face_key = f"face_{face_idx}"
            if face_key not in blink_states:
                blink_states[face_key] = {'is_blinking': False, 'ear_blink': False, 'iris_blink': False}

            ear_blink_detected = False
            if avg_ear < EAR_THRESH and not blink_states[face_key]['ear_blink']:
                blink_states[face_key]['ear_blink'] = True
                ear_blink_detected = True
            elif avg_ear >= EAR_THRESH:
                blink_states[face_key]['ear_blink'] = False

            # 虹彩検出と計算
            iris_detected = True
            try:
                # 左目虹彩
                left_iris_points = landmarks_array[LEFT_IRIS_INDICES]
                left_iris_center = np.mean(left_iris_points, axis=0)
                left_iris_size = calculate_iris_size(left_iris_points)

                # 右目虹彩
                right_iris_points = landmarks_array[RIGHT_IRIS_INDICES]
                right_iris_center = np.mean(right_iris_points, axis=0)
                right_iris_size = calculate_iris_size(right_iris_points)

                # カルマンフィルタの初期化と更新
                if face_key not in kalman_filters:
                    kalman_filters[face_key] = {
                        'left': KalmanFilter2D(),
                        'right': KalmanFilter2D()
                    }

                # カルマンフィルタで平滑化
                left_iris_filtered = kalman_filters[face_key]['left'].update(left_iris_center)
                right_iris_filtered = kalman_filters[face_key]['right'].update(right_iris_center)

                # フィルタ後の位置を使用
                left_iris_center = np.array(left_iris_filtered)
                right_iris_center = np.array(right_iris_filtered)

                # 目の中心計算
                left_eye_center = np.mean(left_eye_points, axis=0)
                right_eye_center = np.mean(right_eye_points, axis=0)

                # 視線角度計算
                left_gaze_angle, left_gaze_dist = calculate_gaze_angle(left_iris_center, left_eye_center)
                right_gaze_angle, right_gaze_dist = calculate_gaze_angle(right_iris_center, right_eye_center)
                avg_gaze_angle = (left_gaze_angle + right_gaze_angle) / 2.0

            except:
                iris_detected = False

            # 瞬き検出(虹彩非検出方式)
            iris_blink_detected = False
            if not iris_detected and not blink_states[face_key]['iris_blink']:
                blink_states[face_key]['iris_blink'] = True
                iris_blink_detected = True
            elif iris_detected:
                blink_states[face_key]['iris_blink'] = False

            # 瞬きイベント記録
            if ear_blink_detected or iris_blink_detected:
                blink_type = []
                if ear_blink_detected:
                    blink_type.append("EAR")
                if iris_blink_detected:
                    blink_type.append("虹彩非検出")
                blink_events.append({
                    'face_id': face_idx,
                    'timestamp': current_timestamp,
                    'type': '/'.join(blink_type)
                })
                if face_idx == 0:  # 最初の顔のみグラフ用データに記録
                    graph_data['blink_times'].append(current_timestamp)

            if iris_detected:
                # 速度計算
                left_speed = 0
                right_speed = 0
                if face_key in previous_positions:
                    time_diff = current_timestamp - previous_positions[face_key]['timestamp']
                    if time_diff > 0:
                        left_speed = np.linalg.norm(left_iris_center - previous_positions[face_key]['left']) / time_diff
                        right_speed = np.linalg.norm(right_iris_center - previous_positions[face_key]['right']) / time_diff

                avg_speed = (left_speed + right_speed) / 2.0

                # サッケード検出
                if face_key not in saccade_states:
                    saccade_states[face_key] = {
                        'in_saccade': False,
                        'saccade_start': 0,
                        'max_speed': 0
                    }

                # サッケード開始検出
                if avg_speed > SACCADE_THRESH and not saccade_states[face_key]['in_saccade']:
                    saccade_states[face_key]['in_saccade'] = True
                    saccade_states[face_key]['saccade_start'] = current_timestamp
                    saccade_states[face_key]['max_speed'] = avg_speed
                elif saccade_states[face_key]['in_saccade']:
                    # サッケード中の最大速度更新
                    saccade_states[face_key]['max_speed'] = max(saccade_states[face_key]['max_speed'], avg_speed)

                    # サッケード終了検出
                    if avg_speed < SACCADE_THRESH:
                        duration = current_timestamp - saccade_states[face_key]['saccade_start']
                        if duration >= SACCADE_MIN_DURATION:
                            # 有効なサッケードとして記録
                            current_saccade_events.append({
                                'face_id': face_idx,
                                'timestamp': current_timestamp,
                                'duration': duration,
                                'max_speed': saccade_states[face_key]['max_speed']
                            })
                            if face_idx == 0:
                                graph_data['saccade_times'].append(current_timestamp)
                        saccade_states[face_key]['in_saccade'] = False

                # 動きカウント
                if avg_speed > movement_threshold:
                    current_movement_count += 1

                # 現在位置を保存
                previous_positions[face_key] = {
                    'left': left_iris_center.copy(),
                    'right': right_iris_center.copy(),
                    'timestamp': current_timestamp
                }

                # グラフ用データ記録(最初の顔のみ)
                if face_idx == 0:
                    graph_data['time'].append(current_timestamp)
                    graph_data['gaze_angle'].append(avg_gaze_angle)
                    graph_data['speed'].append(avg_speed)
                    graph_data['iris_size'].append((left_iris_size + right_iris_size) / 2.0)

                    # 瞬き頻度計算
                    blink_freq = calculate_blink_frequency(graph_data['blink_times'], current_timestamp)
                    graph_data['blink_freq'].append(blink_freq)

                    # サッケード頻度計算
                    saccade_freq = calculate_saccade_frequency(graph_data['saccade_times'], current_timestamp)
                    graph_data['saccade_freq'].append(saccade_freq)

                    # トレンド計算
                    speed_trend = calculate_trend(list(graph_data['speed']), list(graph_data['time']), TREND_WINDOW)
                    graph_data['speed_trend'].append(speed_trend)

                    blink_trend = calculate_trend(list(graph_data['blink_freq']), list(graph_data['time']), TREND_WINDOW)
                    graph_data['blink_trend'].append(blink_trend)

                object_data = {
                    'face_id': face_idx,
                    'left_iris': tuple(left_iris_center.astype(int)),
                    'right_iris': tuple(right_iris_center.astype(int)),
                    'left_iris_size': left_iris_size,
                    'right_iris_size': right_iris_size,
                    'left_speed': left_speed,
                    'right_speed': right_speed,
                    'gaze_angle': avg_gaze_angle,
                    'ear': avg_ear,
                    'speed_trend': speed_trend if face_idx == 0 else 0,
                    'blink_trend': blink_trend if face_idx == 0 else 0,
                    'saccade_freq': saccade_freq if face_idx == 0 else 0
                }
                objects.append(object_data)

    # 動き頻度の更新(1秒ごと)
    if int(current_timestamp) != last_movement_update:
        graph_data['movement_count'].append(current_movement_count)
        last_movement_update = int(current_timestamp)
        current_movement_count = 0  # リセット

    # 瞬きイベントの即時出力
    for blink in blink_events:
        blink_msg = f'瞬き検出 - 顔{blink["face_id"]+1} 時刻:{blink["timestamp"]:.3f} 検出方式:{blink["type"]}'
        print(blink_msg)
        results_log.append(blink_msg)

    # サッケードイベントの即時出力と記録
    for saccade in current_saccade_events:
        saccade_msg = f'サッケード検出 - 顔{saccade["face_id"]+1} 時刻:{saccade["timestamp"]:.3f} 持続時間:{saccade["duration"]*1000:.1f}ms 最大速度:{saccade["max_speed"]:.1f}px/s'
        print(saccade_msg)
        results_log.append(saccade_msg)
        saccade_events.append(saccade)

    # 1秒間隔での定期出力
    if objects and current_timestamp - last_print_time >= PRINT_INTERVAL:
        for obj in objects:
            output = (f'フレーム {frame_count}: 顔{obj["face_id"]+1} - '
                     f'視線角度:{obj["gaze_angle"]:.1f}度 | '
                     f'虹彩サイズ:左{obj["left_iris_size"]:.1f}px,右{obj["right_iris_size"]:.1f}px | '
                     f'速度トレンド:{obj["speed_trend"]:.2f}px/s² | '
                     f'瞬きトレンド:{obj["blink_trend"]:.2f}回/分² | '
                     f'サッケード頻度:{obj["saccade_freq"]:.0f}回/分 | '
                     f'EAR:{obj["ear"]:.3f}')
            print(output)
            results_log.append(output)
        last_print_time = current_timestamp

    # 描画処理
    for i, obj in enumerate(objects):
        # 左目虹彩
        cv2.circle(extended_frame, obj['left_iris'], 5, (0, 255, 0), -1)
        cv2.circle(extended_frame, obj['left_iris'], 15, (0, 255, 0), 2)

        # 右目虹彩
        cv2.circle(extended_frame, obj['right_iris'], 5, (0, 0, 255), -1)
        cv2.circle(extended_frame, obj['right_iris'], 15, (0, 0, 255), 2)

        # ラベル表示
        label = f'Face {obj["face_id"]+1}'
        cv2.putText(extended_frame, label, (obj['left_iris'][0]-30, obj['left_iris'][1]-30),
                    cv2.FONT_HERSHEY_SIMPLEX, 0.7, (255, 255, 255), 2)

        # 視線角度表示
        gaze_label = f'Gaze: {obj["gaze_angle"]:.1f}deg'
        cv2.putText(extended_frame, gaze_label, (obj['left_iris'][0]-30, obj['left_iris'][1]-50),
                    cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 255, 0), 1)

    # グラフ描画(右側に配置)
    graph_x_start = frame.shape[1] + GRAPH_MARGIN

    # グラフの高さを均等に配分(5つのグラフ用)
    total_graph_height = frame.shape[0] - 2 * GRAPH_MARGIN
    single_graph_height = (total_graph_height - 4 * GRAPH_MARGIN) // 5

    # 1. 視線角度グラフ
    graph_y = GRAPH_MARGIN
    draw_graph(extended_frame, list(graph_data['gaze_angle']), (-180, 180),
               "Gaze Angle (deg)", graph_x_start, graph_y, (0, 255, 255))

    # 2. 虹彩速度トレンドグラフ
    graph_y += single_graph_height + GRAPH_MARGIN
    draw_graph(extended_frame, list(graph_data['speed_trend']), (-10, 10),
               "Iris Speed Trend (px/s²)", graph_x_start, graph_y, (0, 255, 0))

    # 3. 瞬き頻度トレンドグラフ
    graph_y += single_graph_height + GRAPH_MARGIN
    draw_graph(extended_frame, list(graph_data['blink_trend']), (-5, 5),
               "Blink Freq Trend (per min²)", graph_x_start, graph_y, (255, 0, 255))

    # 4. 動き頻度グラフ(秒ごとのデータをフレームに拡張)
    graph_y += single_graph_height + GRAPH_MARGIN
    movement_freq_data = []
    if len(graph_data['movement_count']) > 0:
        # 秒ごとのデータをフレームレートに合わせて拡張
        for i in range(len(graph_data['movement_count'])):
            # 各秒のデータをFPS分繰り返す
            for j in range(FPS):
                movement_freq_data.append(graph_data['movement_count'][i])
    draw_graph(extended_frame, movement_freq_data, (0, 50),
               "Movement Freq (per sec)", graph_x_start, graph_y, (255, 255, 0))

    # 5. サッケード頻度グラフ(新規追加)
    graph_y += single_graph_height + GRAPH_MARGIN
    draw_graph(extended_frame, list(graph_data['saccade_freq']), (0, 120),
               "Saccade Freq (per min)", graph_x_start, graph_y, (255, 128, 0))

    # システム情報表示
    info1 = f'MediaPipe (CPU) | Frame: {frame_count} | Faces: {len(objects)}'
    info2 = 'Press: q=Quit'

    cv2.putText(extended_frame, info1, (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (255, 255, 255), 2)
    cv2.putText(extended_frame, info2, (10, 60), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 255, 0), 1)

    return extended_frame


# 入力選択
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)
    if not cap.isOpened():
        print(f'動画ファイルを開けませんでした: {path}')
        exit()
elif choice == '1':
    cap = cv2.VideoCapture(0, cv2.CAP_DSHOW)
    cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)
    cap.set(cv2.CAP_PROP_FRAME_WIDTH, WINDOW_WIDTH)
    cap.set(cv2.CAP_PROP_FRAME_HEIGHT, WINDOW_HEIGHT)
    cap.set(cv2.CAP_PROP_FPS, FPS)
    if not cap.isOpened():
        print('カメラを開けませんでした')
        exit()
elif choice == '2':
    # サンプル動画ダウンロード・処理
    try:
        urllib.request.urlretrieve(SAMPLE_URL, SAMPLE_FILE)
        temp_file = SAMPLE_FILE
        cap = cv2.VideoCapture(SAMPLE_FILE)
        if not cap.isOpened():
            print(f'サンプル動画を開けませんでした: {SAMPLE_FILE}')
            exit()
        print('サンプル動画のダウンロードが完了しました')
    except Exception as e:
        print(f'動画のダウンロードに失敗しました: {SAMPLE_URL}')
        print(f'エラー: {e}')
        exit()
else:
    print('無効な選択です')
    exit()

# グラフの説明を表示
print('\n=== グラフの説明 ===')
print('\n【1. Gaze Angle (deg) - 視線角度】')
print('表示内容: 虹彩中心と目の中心を結ぶ線の角度(-180度~180度)')
print('読み取り可能な情報: 視線の方向変化、注視パターン')
print('算出方法: atan2(虹彩中心Y - 目中心Y, 虹彩中心X - 目中心X)を度数変換')

print('\n【2. Iris Speed Trend (px/s²) - 虹彩速度トレンド】')
print('表示内容: 虹彩移動速度の変化率(加速度)')
print('読み取り可能な情報: 目の動きの加速・減速パターン、動きの滑らかさ')
print('算出方法: 直近10秒間の虹彩移動速度データに線形回帰を適用し傾きを計算')

print('\n【3. Blink Freq Trend (per min²) - 瞬き頻度トレンド】')
print('表示内容: 1分あたりの瞬き回数の変化率')
print('読み取り可能な情報: 瞬き頻度の増減傾向、疲労度の変化の可能性')
print('算出方法: 直近10秒間の瞬き頻度データに線形回帰を適用し傾きを計算')

print('\n【4. Movement Freq (per sec) - 動き頻度】')
print('表示内容: 1秒あたりの虹彩の動き検出回数(閾値5px/s以上)')
print('読み取り可能な情報: 目の活動レベル、集中度の変化の可能性')
print('算出方法: 各秒で虹彩速度が閾値を超えた回数をカウント')

print('\n【5. Saccade Freq (per min) - サッケード頻度】')
print('表示内容: 1分あたりの急速眼球運動(サッケード)の回数')
print('読み取り可能な情報: 視覚的探索活動、読書パターン、注意の切り替え頻度')
print('算出方法: 虹彩速度が300px/sを超え、20ms以上持続する動きをサッケードとして検出・カウント')

print('\n※ 全てのグラフは直近30秒間のデータを表示します')
print('※ カルマンフィルタにより虹彩位置を平滑化しノイズを低減しています')

# 動画処理開始メッセージ
print('\n=== 動画処理開始 ===')
print('操作方法:')
print('  q キー: プログラム終了')
print()

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

        processed_frame = video_processing(frame)
        cv2.imshow('MediaPipe Iris Tracking', processed_frame)

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

    # 結果保存
    if results_log:
        with open(RESULT_FILE, 'w', encoding='utf-8') as f:
            f.write('=== MediaPipe虹彩追跡結果 ===\n')
            f.write(f'処理フレーム数: {frame_count}\n')
            f.write(f'使用デバイス: CPU\n')
            f.write(f'総サッケード検出数: {len(saccade_events)}\n')
            if saccade_events:
                avg_duration = sum(s['duration'] for s in saccade_events) / len(saccade_events) * 1000
                avg_speed = sum(s['max_speed'] for s in saccade_events) / len(saccade_events)
                f.write(f'平均サッケード持続時間: {avg_duration:.1f}ms\n')
                f.write(f'平均サッケード最大速度: {avg_speed:.1f}px/s\n')
            f.write('\n')
            f.write('\n'.join(results_log))

        print(f'\n処理結果を{RESULT_FILE}に保存しました')

    if temp_file and os.path.exists(temp_file):
        os.remove(temp_file)

print('\n=== プログラム終了 ===')