瞬目検出3手法の比較と瞬き頻度の時間推移の観察

概要

MediaPipe Face Landmarker を用い、Blendshape 法・EAR 法・PERCLOS の3手法を同一映像に同時適用して比較する。実行し、パラメータを変え、結果を確かめるための探求ガイドである。

目的は、単眼RGB映像(通常のカラーカメラ1台で撮った映像)から瞬目(まばたき)を非接触で検出する代表的3手法を同一入力に適用し、瞬き回数・平均頻度(回/分)・区間別頻度・時間経過との相関、および手法間の一致度を定量比較することである。実装は、MediaPipe Face Landmarker が出力する478点の顔ランドマーク(顔の特徴点の座標)と52種のブレンドシェイプ係数(表情の強さを0〜1で表す値)から、Blendshape動的しきい値法(判定の境目を固定値にせず、直近の履歴から毎フレーム計算し直す方式)・EAR適応しきい値法・PERCLOSを並列に算出し、結果を画面とCSVへ出力するTkinter(Python に標準で付属するGUI作成ライブラリ)プログラムである。

目次

1. 目的と前提

出力データは、映像上に常時表示される手法A・Bそれぞれの瞬き回数、経過時間、処理fps(1秒あたりに解析できたフレーム数)、時間分解能fps(1秒を何コマに刻めているか)、各手法の信号値としきい値(判定の境目となる値)、PERCLOS値であり、計測後にはこれらに加えて総フレーム数、顔検出失敗率、手法別の総回数と平均頻度、区間別頻度、時間経過との相関係数(2つの量が同じ向きに変化する度合いを −1〜+1 で表す値)、手法間の一致度、Raw TLX(主観的な作業負荷を6項目で自己評価する尺度。2-2節で述べる)が表示される。ファイルとしては blink_log.csv(瞬目の発生時刻)と signal_log.csv(顔を検出できたフレームごとの閉眼度・EAR・PERCLOS)が残る。これにより、同じ映像でも手法としきい値設定によって「瞬き回数」がどれだけ変わるか、瞬き頻度が時間経過とともに増減するか、その傾向が主観的な負荷の自己申告と同じ向きかを観察できる。ただし得られる結論は「本条件・本設定下で各手法が何回と数えたか」という記述に限られ、真の瞬目回数や疲労との因果関係を主張することはできない。技術選定の理由は、ブレンドシェイプのモデルが閉眼度を直接出力するため座標計算を自作せずに済み、しかも同じ478点からEARも計算できるため、2手法を追加機材なしで公平な条件下で比較できる点にある。社会的意義は、非接触・低コストな瞬目計測がVDT作業(画面を見続ける作業)の管理、運転者の眠気検知、eスポーツ選手のコンディション管理など、労働安全や事故防止に接続しうる点にある。

2. 手法候補の一覧

2-1. 瞬目検出・眼開閉推定の手法候補(青い行が本コードの実装対象)

No.手法名発表分類概要
1EAR(Eye Aspect Ratio、目の縦横比)しきい値法CVWW 2016(Soukupová & Čech)幾何特徴+しきい値 目の周囲6点から縦横比を計算し、値がしきい値を下回るフレームを閉眼とみなす。計算量が小さく、判定の根拠が数式として目に見える古典的手法である。
2EAR時系列+SVM(サポートベクターマシン、2つのクラスを最もよく分ける境界を学習する識別器)CVWW 2016(同上)時系列特徴+識別器 数フレーム分のEARの並びをまとめて識別器に入力し判定する。1フレームだけで判断するしきい値法より顔向きや個人差に強いが、学習データを別途必要とする。
3PERCLOS(PERcent of eyelid CLOSure、閉眼時間率)Dinges & Grace 1998集約指標(眠気指標) 一定の時間窓の中で瞼が一定割合以上閉じていた時間の比率を指標化する。個々の瞬目ではなく持続的な閉眼を捉え、眠気推定の標準的指標として用いられてきた。
4MediaPipe Face Landmarker + Blendshape V2(eyeBlink係数)Google, MediaPipe Tasks(2023〜)学習済み回帰モデル(入力から連続値を出力するよう学習させたモデル) BlazeFaceで顔を検出し、FaceMesh-V2で478点を推定し、うち146点をMLP-Mixer(全結合層だけで構成された軽量ネットワーク)に入力して52種の表情係数を推定する。閉眼度が0〜1の値で直接得られる。
5RT-BENEICCV Workshops 2019(Cortacero ら)CNNによる眼領域分類 切り出した両眼の画像をCNN(畳み込みニューラルネットワーク)で開眼/閉眼に分類する。眼鏡や照明変化を含む実環境データで学習しており、ランドマークの精度に左右されにくい。
6OpenFace 2.0 の AU45 強度FG 2018(Baltrušaitis ら)表情動作単位(AU)推定 FACS(表情記述の国際的な体系)のAU45(blink)の強さを推定し、その時間変化から瞬目を判定する。他の表情要素も同時に得られるため併用解析に向く。
7EOG(Electro-OculoGraphy、眼電図)生理計測の標準手法接触型生体信号 目の周りに貼った電極で角膜と網膜の電位差の変化を測る。時間分解能が高く正解データとして使えるが、電極を身体に装着する必要がある。

2-2. 主観的作業負荷の評価手法候補

No.手法名発表分類概要
ANASA-TLX(重み付け版)Hart & Staveland 1988多次元主観評価 6項目を評定したうえ、項目どうしを2つずつ比べて重みを決め、加重平均する。手続きが重く、短時間の実験では回答者の負担が大きい。
BRaw TLX(NASA-TLX簡易版)Byers ら 1989 / Hart 2006多次元主観評価(簡易) 重み付けの手続きを省き、6項目の単純平均をスコアとする。所要時間が短く、重み付け版と近い感度を示すとの報告があり、実験室外でも使いやすい。
CBorg CR-10(主観的な強さを0〜10の段階で答えさせる尺度)等の主観的疲労感尺度Borg 1982 ほか単一次元主観評価 疲労や努力の強さを1本の尺度で答えさせる。繰り返し尋ねやすい反面、負荷の内訳(精神的か時間的か)は分けられない。
DKSS(Karolinska 眠気尺度)等の眠気・覚醒度自己評定Åkerstedt & Gillberg 1990 ほか単一次元主観評価 眠気の強さを数段階で自己申告させる。PERCLOSとの対応を見やすいが、答える行為そのものが目を覚まさせてしまう点に注意が要る。

3. コードで実装されている手法

本コードは、表2-1の No.4(手法A:Blendshape eyeBlink 動的しきい値法)No.1(手法B:EAR適応しきい値法)No.3(手法C:PERCLOS) の3つを、同一フレームに対して同時に計算する。表2-2からは B(Raw TLX) を用いる。3手法はいずれも MediaPipe Face Landmarker の出力を入力源とするため、顔検出が成功したか否かという条件が完全に共通である。したがって結果の差は、判定の原理としきい値の設定だけに由来する。

各手法の実装内容

手法入力信号しきい値の決め方出力
A:BlendshapeeyeBlinkLeft と eyeBlinkRight の平均(0〜1、1に近いほど閉眼) 直近の履歴(両手法がともに開眼と判定したフレームだけを蓄積したもの)の下位10パーセンタイル(小さい方から数えて10%の位置にある値)を「目が開いているときの基準値」とし、基準値から上限1.0までの残り幅にマージンを掛けた分を基準値に足した値をしきい値とする。この値以上のフレームを閉眼とみなす瞬き回数と発生時刻
B:EAR左右の目それぞれ6点から求めたEARの平均(目が開いているほど大きい) 直近の履歴(Aと同じく開眼と判定したフレームだけを蓄積したもの)の上位90パーセンタイルを基準値とし、それに比率(既定0.75)を掛けた値以下になったフレームを閉眼とみなす瞬き回数と発生時刻
C:PERCLOSAと同じ閉眼度 閉眼水準(既定0.80)以上のフレームを「閉眼」と数え、移動窓(現在時刻からさかのぼる一定長さの時間帯)の中での割合を求める閉眼時間率(%)

A と B はいずれも「閉眼と判定された状態が最小持続フレーム数以上つづき、そのあと開眼に転じた瞬間」に1回と数える(計測停止・動画末尾・ウィンドウ終了の時点で閉眼が続いていた場合は、その時点で1回として確定する)。両者は信号の向きが逆であること、すなわち A は閉じると値が上がり、B は閉じると値が下がることに注意する。C は回数ではなく時間の割合であり、A・B とは測っている現象そのものが異なる。

他の候補と比べた特徴・使用上の注意

モデルの所在と学習データ

項目内容
ソースコードMediaPipe(Google AI Edge)の公開実装。Python からは mediapipe.tasks.python.vision.FaceLandmarker として利用する。
学習済みモデルhttps://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/1/face_landmarker.task。初回実行時に自動ダウンロードしてスクリプトと同じディレクトリに保存し、2回目以降はそのファイルをそのまま使う(キャッシュ=一度取得したものを保存して再利用する仕組み)。
構成モデルBlazeFace(顔検出, Bazarevsky ら 2019)、FaceMesh-V2(478点の3次元ランドマーク, Kartynnik ら CVPRW 2019 の系列)、Blendshape V2(52係数)の3モデルを1つのファイルに束ねたもの。
Blendshape V2 の入出力入力は FaceMesh-V2 の478点のうち146点の2次元ランドマーク、出力は52種の係数(0〜1)。構造は MLP-Mixer 系の軽量ネットワークである。
学習データ公開されているモデルカード(モデルの仕様書)によれば、研究室内で複数のカメラにより収録した多視点の顔画像と、そこから復元した3D GHUMメッシュ(人体の3次元形状モデル)を基に、人物・表情・顔の向きの組み合わせから多数の学習サンプルを合成し、入力するランドマークにノイズを加えて学習させている。ImageNet のような一般公開データセットではないため、内訳の詳細は公表されていない。
注意点(教科書的な技術と、この実装の違い)
技術の限界:瞬目の持続時間はおよそ100〜400ミリ秒であるのに対し、一般的なカメラや動画は概ね30fps(1フレーム約33ミリ秒)であり、1回の瞬目は3〜12フレーム程度しか記録されない。開閉の時刻や瞼の動く速さをサブフレーム精度(1フレームよりも細かい時間の精度)で求めることはできず、結果はしきい値と最小持続フレーム数の設定に依存する。また Raw TLX は1回の計測全体に対する1個の主観評価であるため、1回の計測から瞬き頻度と主観負荷の相関を求めることはできない。画面に並ぶ平均瞬き頻度とTLXは、あくまで並べて眺めるための材料である。

4. プログラムの概要

5. プログラム実行手順

Python 3.12 のインストール

Pythonのインストールを行い、Pythonのプログラムを実行する環境を整える。扱う環境は、Windows搭載パソコンである。金子研究室では、Python 3.12.10を推奨する。

[Windows での Python 3.12 のインストール手順を見るには、ここをクリック]

Windows での Python 3.12 のインストール

以下のいずれかの方法でPython 3.12をインストールする。Pythonがインストール済みの場合、この手順は不要である。

方法 1:winget によるインストール

インストールコマンドの実行方法

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

--scope machine を指定することで、システム全体(全ユーザー向け)にインストールされる。このオプションの実行には管理者権限が必要である。インストール完了後、コマンドプロンプトを再起動するとPATHが反映される。

REM Python 3.12 をシステム領域にインストール
winget install --id Python.Python.3.12 -e --scope machine --silent --accept-source-agreements --accept-package-agreements --override "/quiet InstallAllUsers=1 PrependPath=1 Include_test=0 Include_pip=1 Include_launcher=1 InstallLauncherAllUsers=1 TargetDir=\"C:\Program Files\Python312\""

REM Python と Scripts を PATH 先頭に追加
powershell -NoProfile -Command "$p='C:\Program Files\Python312'; $s=\"$p\Scripts\"; $c=[Environment]::GetEnvironmentVariable('Path','Machine'); if((Test-Path $p) -and (';'+$c+';' -notlike \"*;$p;*\") -and (';'+$c+';' -notlike \"*;$s;*\")){[Environment]::SetEnvironmentVariable('Path',\"$p;$s;$c\",'Machine')}"

方法 2:インストーラーによるインストール

  1. Python公式サイト(https://www.python.org/downloads/)にアクセスし、「Download Python 3.x.x」ボタンからWindows用インストーラーをダウンロードする。
  2. ダウンロードしたインストーラーを実行する。
  3. 初期画面の下部に表示される「Add python.exe to PATH」にチェックを入れてから「Customize installation」を選択する。このチェックを入れ忘れると、コマンドプロンプトから python コマンドを実行できない。
  4. 「Install Python 3.xx for all users」にチェックを入れ、「Install」をクリックする。

インストールの確認

コマンドプロンプトで以下を実行する。

python --version

バージョン番号(例:Python 3.12.x)が表示されればインストール成功である。「'python' は、内部コマンドまたは外部コマンドとして認識されていません。」と表示される場合は、インストールが正常に完了していない。

Build Tools・CUDA Toolkit・PyTorch のインストール

本章では、C++ ビルドツール、NVIDIA CUDA Toolkit、PyTorch のインストールを行い、GPU を活用した機械学習プログラムを実行する環境を整える。扱う環境は、Windows 搭載パソコンである。

[Build Tools・CUDA Toolkit・PyTorch のインストール手順を見るには、ここをクリック]

Windows での Build Tools for Visual Studio 2026 のインストール

Build Tools for Visual Studio 2026 は、C++ ソースコードを Windows 用バイナリにコンパイルするための開発ツール群である。unsloth 等の一部 Python パッケージは、インストール時に C++ コードのビルドを必要とするため、これらのツールが必須となる。

以下のコマンドは、Build Tools が未インストールの場合は winget で新規インストールし、インストール済みの場合は setup.exe modify でコンポーネントを追加する(バージョンは変更しない)。

インストールコマンドの実行方法

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

REM VC++ ランタイム
winget install --scope machine --id Microsoft.VCRedist.2015+.x64 -e --silent --disable-interactivity --force --accept-source-agreements --accept-package-agreements --override "/quiet /norestart"

REM ============================================================
REM Visual Studio Build Tools + Desktop development with C++
REM (VCTools、MSBuildTools、CMake連携、Clang、Windows 11 SDK)
REM ============================================================
REM 進行中のインストーラーを停止(ロック競合回避)
taskkill /F /IM vs_setup.exe /T >nul 2>&1
taskkill /F /IM vs_installer.exe /T >nul 2>&1
taskkill /F /IM vs_installerservice.exe /T >nul 2>&1

REM 未インストール時: winget で新規インストール
REM インストール済み時: setup.exe modify でコンポーネント追加(バージョンは変更しない)
winget list --id Microsoft.VisualStudio.BuildTools 2>nul | findstr /i "BuildTools" >nul 2>&1
if %ERRORLEVEL% EQU 0 (
    for /f "usebackq delims=" %P in (`"C:\Program Files (x86)\Microsoft Visual Studio\Installer\vswhere.exe" -products Microsoft.VisualStudio.Product.BuildTools -property installationPath`) do start /wait "" "C:\Program Files (x86)\Microsoft Visual Studio\Installer\setup.exe" modify --installPath "%P" --add Microsoft.VisualStudio.Workload.VCTools --add Microsoft.VisualStudio.Workload.MSBuildTools --add Microsoft.VisualStudio.Component.VC.CMake.Project --add Microsoft.VisualStudio.Component.VC.Llvm.Clang --add Microsoft.VisualStudio.Component.VC.Llvm.ClangToolset --add Microsoft.VisualStudio.Component.Windows11SDK.26100 --includeRecommended --quiet --norestart --nocache
) else (
    winget install --scope machine --id Microsoft.VisualStudio.BuildTools -e --silent --disable-interactivity --force --accept-source-agreements --accept-package-agreements --override "--quiet --wait --norestart --nocache --add Microsoft.VisualStudio.Workload.VCTools --includeRecommended --add Microsoft.VisualStudio.Workload.MSBuildTools --add Microsoft.VisualStudio.Component.VC.CMake.Project --add Microsoft.VisualStudio.Component.VC.Llvm.Clang --add Microsoft.VisualStudio.Component.VC.Llvm.ClangToolset --add Microsoft.VisualStudio.Component.Windows11SDK.26100"
)

REM 破損時の修復(任意、動作がおかしくなった場合)
REM "C:\Program Files (x86)\Microsoft Visual Studio\Installer\setup.exe" repair --installPath "C:\Program Files (x86)\Microsoft Visual Studio\18\BuildTools" --quiet --norestart

REM 導入確認(インストールパスが表示されれば正常)
"C:\Program Files (x86)\Microsoft Visual Studio\Installer\vswhere.exe" -products * -requires Microsoft.VisualStudio.Workload.VCTools -property installationPath

上記のコマンドでは、Build Tools 本体と Visual C++ 再頒布可能パッケージをインストールし、続いて以下のコンポーネントを追加している。

追加のコンポーネントが必要になった場合は Visual Studio Installer で個別にインストールできる。

Windows での NVIDIA CUDA Toolkit のインストール

NVIDIA CUDA Toolkit は、NVIDIA GPU 上で計算を行うためのコンパイラ・ライブラリ群である。PyTorch や vLLM 等が GPU を利用するために必要となる。GPU を使用しない場合、この手順は不要である。

前提条件:NVIDIA GPU、NVIDIA ドライバ、Build Tools for Visual Studio もしくは Visual Studio が必要である。

インストール中の注意:他のウインドウは閉じておくこと。

インストールコマンドの実行方法

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

REM NVIDIA CUDA Toolkit 12.8 をシステム領域にインストール
winget install --scope machine --id Nvidia.CUDA --version 12.8 -e --silent --disable-interactivity --force --uninstall-previous --accept-source-agreements --accept-package-agreements --override "-s -n"

REM 環境変数TEMP, TMPの設定(一時ファイルの保存先を短いパスに変更)
mkdir C:\TEMP
setx TEMP "C:\TEMP" /M
setx TMP "C:\TEMP" /M

環境変数 TEMP および TMP を C:\TEMP に変更しているのは、後続のインストール処理で長いパス名や空白を含むパス名がエラーの原因となる場合があるためである。

Windows での PyTorch のインストール

https://pytorch.org のインストールガイドに従い、自環境の CUDA バージョンに対応したコマンドを取得して実行する。CUDA バージョンは以下で確認できる。

nvcc --version

Python 3.12、CUDA 12.6 以上の場合は、管理者権限コマンドプロンプトを起動し、以下を実行する。cu128 は CUDA 12.8 用のタグである。CUDA バージョンが異なる場合は、上記公式サイトで該当するタグを確認し、URL 末尾の cu128 を置き換えること。

pip install --no-user -U numpy torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu128

以降の章では、必要に応じて題材に応じた必要なソフトウェアを追加する。

Python の開発環境 Visual Studio Code のインストールと Python 用の設定

Python の開発環境Visual Studio Code(プログラムを編集するソフトウェア。以下、VS Code)を整える。

[Windows での Visual Studio Code のインストールと Python 用の設定手順を見るには、ここをクリック]

Windows での Visual Studio Code のインストールと Python 用の設定手順

1. VS Code と拡張機能のインストール

以下のコマンドにより,既存の VS Code を削除し,全ユーザー共有の設定で再インストールしたうえで,拡張機能(VS Code に機能を追加するソフトウェア)をまとめて導入する.

インストールコマンドの実行方法

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

インストールコマンド


REM ============================================================
REM Microsoft Visual Studio Code
REM ============================================================
winget uninstall -e --id Microsoft.VisualStudioCode --silent --disable-interactivity --accept-source-agreements
rmdir /s /q C:\ProgramData\vscode-extensions 2>nul
rmdir /s /q "%APPDATA%\Code" 2>nul
rmdir /s /q "%USERPROFILE%\.vscode" 2>nul
rmdir /s /q "%LOCALAPPDATA%\Microsoft\vscode-update" 2>nul

REM VS Code をシステム領域に新規インストール
winget install --scope machine --id Microsoft.VisualStudioCode -e --silent --accept-source-agreements --accept-package-agreements

REM 全ユーザー共有の拡張機能フォルダ
mkdir C:\ProgramData\vscode-extensions 2>nul
icacls "C:\ProgramData\vscode-extensions" /grant "Everyone:(OI)(CI)M" /T

REM スタートメニューのショートカットを --extensions-dir 付きで再作成
rmdir /s /q "C:\ProgramData\Microsoft\Windows\Start Menu\Programs\Visual Studio Code" 2>nul
del "C:\ProgramData\Microsoft\Windows\Start Menu\Programs\Visual Studio Code.lnk" 2>nul
powershell -NoProfile -Command "$s=New-Object -ComObject WScript.Shell; $lnk=$s.CreateShortcut('C:\ProgramData\Microsoft\Windows\Start Menu\Programs\Visual Studio Code.lnk'); $lnk.TargetPath='C:\Program Files\Microsoft VS Code\Code.exe'; $lnk.Arguments='--extensions-dir \"C:\ProgramData\vscode-extensions\"'; $lnk.Save()"
REM ショートカットの検証
powershell -NoProfile -Command "$s=New-Object -ComObject WScript.Shell; $lnk=$s.CreateShortcut('C:\ProgramData\Microsoft\Windows\Start Menu\Programs\Visual Studio Code.lnk'); Write-Host 'TargetPath:' $lnk.TargetPath; Write-Host 'Arguments:' $lnk.Arguments"

REM ファイル / フォルダ右クリックの「Code で開く」を登録
reg add "HKLM\SOFTWARE\Classes\*\shell\VSCode\command" /ve /d "\"C:\Program Files\Microsoft VS Code\Code.exe\" --extensions-dir \"C:\ProgramData\vscode-extensions\" \"%1\"" /f
reg add "HKLM\SOFTWARE\Classes\Directory\shell\VSCode\command" /ve /d "\"C:\Program Files\Microsoft VS Code\Code.exe\" --extensions-dir \"C:\ProgramData\vscode-extensions\" \"%1\"" /f
reg add "HKLM\SOFTWARE\Classes\Directory\Background\shell\VSCode\command" /ve /d "\"C:\Program Files\Microsoft VS Code\Code.exe\" --extensions-dir \"C:\ProgramData\vscode-extensions\" \"%V\"" /f

REM --extensions-dir 付きで起動する code.cmd ラッパを作成
REM (%* を echo で書くと対話的 cmd で失われるため、PowerShell で [char]37+'*' を書き出す)
powershell -NoProfile -Command "$pct=[char]37; $q=[char]34; $c='@echo off'+[char]13+[char]10+$q+'C:\Program Files\Microsoft VS Code\bin\code.cmd'+$q+' --extensions-dir '+$q+'C:\ProgramData\vscode-extensions'+$q+' '+$pct+'*'+[char]13+[char]10; [IO.File]::WriteAllText('C:\ProgramData\vscode-extensions\vscode.cmd',$c,[Text.Encoding]::ASCII)"

REM 拡張機能のインストール
set "CODE=C:\Program Files\Microsoft VS Code\bin\code.cmd"
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --uninstall-extension GitHub.copilot
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --uninstall-extension GitHub.copilot-chat
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --install-extension ms-python.python
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --install-extension ms-python.vscode-pylance
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --install-extension ms-python.debugpy
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --install-extension MS-CEINTL.vscode-language-pack-ja
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --install-extension saoudrizwan.claude-dev
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --install-extension rust-lang.rust-analyzer
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --install-extension tamasfe.even-better-toml
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --install-extension anthropic.claude-code
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --install-extension almenon.arepl
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --list-extensions --show-versions
echo === セットアップ完了 ===

2. Python インタプリタの選択

同一マシンに複数の Python がインストールされている場合,VS Code で使用する Python 本体(インタプリタ:Python プログラムを解釈・実行するソフトウェア)を選択する必要がある.

  1. コマンドパレット(コマンド名で機能を呼び出す VS Code の入力欄)を開く(Ctrl+Shift+P
  2. Python: Select Interpreter と入力する
  3. 表示される一覧から,使用する Python(例:C:\Program Files\Python312\python.exe)を選択する.

Python プログラム実行手順

[Windows での Python プログラム実行手順を見るには、ここをクリック]

Windows での Python 実行手順(Visual Studio Codeを使用)

プログラムファイルの作成と保存

  1. 左サイドバーの「エクスプローラー」アイコン(Ctrl+Shift+E)をクリックする
  2. 「NO FOLDER OPENED」(作業対象フォルダが未選択の状態)と表示される場合は,「Open Folder」をクリックし,プログラムを保存するフォルダを選択する

    続いて「フォルダを信用するか」を確認する画面(フォルダ内のコードを実行してよいか確認する VS Code の仕組み)が表示されるので,チェックして Yes を選択する

  3. フォルダ名の右側に表示される「新しいファイル」アイコンをクリックする
  4. ファイル名(例:aitask.py.ファイル名は何でも良い)を入力し Enter を押す.拡張子は .py(Python ファイルを示す拡張子)とする
  5. 実行したいコードを選択し,Ctrl+C でコピーする.VS Code のエディタ領域に Ctrl+V で貼り付ける
  6. Ctrl+S で保存する

プログラムの実行

  1. エディタ右上の三角形「▷」アイコン(Run Python File:現在開いている Python ファイルを実行するボタン)をクリックする.または,エディタ上で右クリックし「ターミナルで Python ファイルを実行」を選択する
  2. VS Code 下部のターミナル(コマンドの入出力を表示する画面)に,実行結果(print 関数の出力等)が表示される
  3. tkinter(Python 標準の GUI ライブラリ)のファイル選択ダイアログを使うプログラムを実行した場合は,ダイアログが開くので対象画像を選択する
  4. VS Code 下部のターミナルで実行結果を確認する.OpenCV ウィンドウ(OpenCV が画像を表示するために開く専用ウィンドウ)が開いた場合はそちらも確認する.OpenCV ウィンドウは,マウスクリックでウィンドウをアクティブ(操作対象の状態)にしてからキーを押すと終了する

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

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

次のコマンドを実行し、関連ライブラリをインストールする。

pip install -U --no-user mediapipe opencv-python numpy pillow

実行手順

  1. スクリプトを実行する。初回のみモデルファイルを自動ダウンロードするため、インターネット接続が必要である。
  2. 「1. 入力の選択」でカメラまたは動画ファイルを選ぶ。自分の作業中の瞬目を測るならカメラ、パラメータの比較を行うなら同じ動画ファイルを繰り返し使う。
  3. 「計測開始」を押す。まず10秒ほど、意図的に数回まばたきをして、目の周囲に点が乗ること、閉眼度がしきい値を超えること、手法Aと手法Bの回数が同じだけ増えるかを確認する。これが動作確認である。
  4. そのまま作業(ゲーム、読書など)を行う。経過時間の表示で進み具合を確認する。動画ファイル入力の場合は末尾に達すると自動的に停止する。
  5. 「計測停止」を押す。
  6. カメラで自分を計測した場合は、「3. NASA-TLX簡易版」の6項目を0〜100で入力する(今回の計測全体に対して1回だけ)。動画ファイルを解析した場合は評価すべき主観体験が存在しないため、入力せず既定値のままにし、結果欄のTLX行は無視する。
  7. 「結果を集計して保存」を押す。テキスト欄に結果が出て、2つのCSVが保存される。
  8. 区間長スライダーを動かしてから同じボタンをもう一度押せば、計測をやり直さずに別の区間長で集計し直せる。
  9. パラメータを変えて再実行する場合、CSVは上書きされる。残したい結果は別名で保存してから次の計測に進む。

6. GUIで設定可能なパラメータ

対象名称(GUI表記)初期値範囲説明
入力カメラ番号00〜3 使用するカメラ機器の番号。ノートパソコン内蔵のカメラは通常0である。
手法A閉眼判定マージン(残り幅比)0.300.05〜0.60 基準値から上限1.0までの残り幅にこの比を掛け、基準値に足したものを閉眼判定のしきい値とする。小さくすると軽い目の動きも回数に数え、大きくすると浅い瞬目を取りこぼす。
手法BEAR比率しきい値(開眼時EARに対する比)0.750.50〜0.95 開眼時のEAR基準値にこの比を掛けた値以下になったら閉眼とみなす。小さくするほど深い閉眼だけを検出する。
A・B共通最小閉眼持続フレーム数21〜6 何フレーム連続で閉眼判定が続いたら1回と数えるか。実時間に換算した値は画面に「最小閉眼○ms」として常時表示される。
A・B共通基準値推定の窓長(フレーム)6020〜300 開眼時の基準値を求めるためにさかのぼるフレーム数(履歴は両手法が開眼と判定したフレームでのみ更新される)。長いほど姿勢の変化に追従しにくく、短いほど直近のわずかな変動でしきい値が動きやすい。
手法CPERCLOS閉眼水準0.800.50〜0.95 閉眼度がこの値以上のフレームを「閉眼」と数える。0.80は文献でいうP80(瞼が瞳孔の80%以上を覆った状態を閉眼とみなす定義)に相当する設定である(ただし本実装は閉眼度による近似)。
手法CPERCLOS移動窓長(秒)3010〜120 閉眼時間率を計算する時間帯の長さ。長くすると値は滑らかになり、短くすると変動が大きくなる。
集計集計区間長(分)21〜10 区間別頻度と相関係数を求める時間の幅。停止後に変更して集計ボタンを押し直せば再集計される。
集計NASA-TLX 6項目500〜100(5刻み) 精神的要求・身体的要求・時間的切迫感・作業成績・努力・フラストレーションの各評定値。6つの平均がRaw TLXとなる。

7. プログラムコード

# -*- coding: utf-8 -*-
"""
瞬目(まばたき)検出3手法の比較 ― 実験用プログラム(Windows前提)

【使用AIモデル】MediaPipe Face Landmarker(Google AI Edge, Tasks API)
  - 顔検出      : BlazeFace
  - ランドマーク: FaceMesh-V2(478点3次元)
  - 表情係数    : Blendshape V2(52係数)
  - 重みファイル: face_landmarker.task(float16)をスクリプトと同じディレクトリへ自動ダウンロードする。

【実装する3手法】
  手法A: Blendshape eyeBlink 動的しきい値法
  手法B: EAR 適応しきい値法
  手法C: PERCLOS

【この版での主な論理修正】
  - 手法Aのしきい値を base + margin ではなく base + margin * (1 - base) とし、
    しきい値が1.0を超えて検出不能になる状態を避ける。
  - 基準値推定用の履歴は、両手法が開眼と判定したフレームでのみ更新する。
    これにより、長い閉眼中に基準値が閉眼側へ drift して検出が壊れることを避ける。
  - 顔未検出フレームを「開眼」とは扱わず、閉眼カウンタを即時リセットしない。
  - 停止時・動画末尾・ウィンドウ終了時に閉眼継続中なら、その閉眼を確定してから終了する。
  - カメラ時刻は time.perf_counter() を使い、システム時計の変更に影響されないようにする。
  - 動画時刻はフレーム番号とfpsから作り、CAP_PROP_POS_MSEC が進まない動画でも時間軸が潰れないようにする。
  - 検出パラメータは計測開始時に固定する。停止後に集計区間長だけを変えて再集計できる。
"""

import os
import csv
import time
from collections import deque
import urllib.request
import tkinter as tk
from tkinter import ttk, filedialog

import numpy as np
import cv2
from PIL import Image, ImageDraw, ImageFont, ImageTk
import mediapipe as mp
from mediapipe.tasks import python
from mediapipe.tasks.python import vision

# ---- 出力先はカレントディレクトリではなくスクリプトと同じディレクトリ ----
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
MODEL_PATH = os.path.join(BASE_DIR, "face_landmarker.task")
BLINK_CSV = os.path.join(BASE_DIR, "blink_log.csv")
SIGNAL_CSV = os.path.join(BASE_DIR, "signal_log.csv")
MODEL_URL = (
    "https://storage.googleapis.com/mediapipe-models/face_landmarker/"
    "face_landmarker/float16/1/face_landmarker.task"
)

DISPLAY_WIDTH = 640
WARMUP_FRAMES = 15
HISTORY_MAX = 300
MATCH_TOL = 0.5

DEFAULT_VIDEO_FPS = 30.0
MAX_VIDEO_FPS = 1000.0

if not os.path.exists(MODEL_PATH):
    print("モデルファイルをダウンロードしています...")
    urllib.request.urlretrieve(MODEL_URL, MODEL_PATH)
    print("ダウンロードが完了しました。")

# OpenCVのputTextは日本語を描けないためPillowで描画する
FONT_PATH = next(
    p for p in [
        "C:/Windows/Fonts/meiryo.ttc",
        "C:/Windows/Fonts/YuGothM.ttc",
        "C:/Windows/Fonts/YuGothR.ttc",
        "C:/Windows/Fonts/msgothic.ttc",
        "C:/Windows/Fonts/arial.ttf",
        "C:/Windows/Fonts/segoeui.ttf",
    ]
    if os.path.exists(p)
)
FONT = ImageFont.truetype(FONT_PATH, 17)

# 目の輪郭(表示用)。緑=本人の左目、マゼンタ=本人の右目
EYE_RINGS = [
    ((0, 255, 0), [263, 249, 390, 373, 374, 380, 381, 382, 362, 398, 384, 385, 386, 387, 388, 466]),
    ((255, 0, 255), [33, 7, 163, 144, 145, 153, 154, 155, 133, 173, 157, 158, 159, 160, 161, 246]),
]

# EAR用の6点(p1,p2,p3,p4,p5,p6)
EAR_LEFT = [362, 385, 387, 263, 373, 380]
EAR_RIGHT = [33, 160, 158, 133, 153, 144]

TLX_ITEMS = ["精神的要求", "身体的要求", "時間的切迫感", "作業成績", "努力", "フラストレーション"]


def normalize_fps(value):
    """動画の時刻計算に使うfpsを、MediaPipeのミリ秒時刻で扱える数値範囲に正規化する。"""
    return float(
        np.clip(
            np.nan_to_num(
                value,
                nan=DEFAULT_VIDEO_FPS,
                posinf=DEFAULT_VIDEO_FPS,
                neginf=DEFAULT_VIDEO_FPS,
            ),
            1.0,
            MAX_VIDEO_FPS,
        )
    )


def eye_aspect_ratio(landmarks, idx, w, h):
    """正規化座標を画素座標に直してからEARを計算する。"""
    p = np.array([[landmarks[i].x * w, landmarks[i].y * h] for i in idx])
    vertical = np.linalg.norm(p[1] - p[5]) + np.linalg.norm(p[2] - p[4])
    horizontal = np.linalg.norm(p[0] - p[3])
    return vertical / (2.0 * horizontal)


def blendshape_threshold(history, n_win, margin):
    """
    手法Aのしきい値。
    base + margin ではなく、base + margin * (1 - base) とする。
    これにより、閉眼度の上限1.0を超えるしきい値にならない。
    """
    base = np.percentile(list(history)[-n_win:], 10)
    return base + margin * (1.0 - base)


def ear_threshold(history, n_win, ratio):
    """手法Bのしきい値。開眼時EARの代表値に比率を掛ける。"""
    base = np.percentile(list(history)[-n_win:], 90)
    return base * ratio


def segment_edges(total_sec, segment_sec):
    """区間の境目を返す。末尾の端数は最終区間に吸収させる。"""
    n = max(1, int(round(total_sec / segment_sec)))
    return [i * segment_sec for i in range(n)] + [total_sec]


def count_matches(times_a, times_b, tol):
    """時間差がtol以内の瞬目を、時刻順に1対1で対応づける。"""
    i = 0
    j = 0
    matched = 0

    while i < len(times_a) and j < len(times_b):
        ta = times_a[i]
        tb = times_b[j]

        if tb < ta - tol:
            j += 1
        elif tb > ta + tol:
            i += 1
        else:
            matched += 1
            i += 1
            j += 1

    return matched


class BlinkApp:
    def __init__(self, root):
        self.root = root
        root.title("瞬目検出3手法の比較(Blendshape / EAR / PERCLOS)")

        self.capture = None
        self.landmarker = None
        self.running = False
        self.is_file = False
        self.file_path = ""
        self.photo = None

        self.build_gui()
        self.reset_state()
        self.root.protocol("WM_DELETE_WINDOW", self.on_close)

    # ---------------- GUI ----------------
    def build_gui(self):
        self.video_label = ttk.Label(self.root)
        self.video_label.grid(row=0, column=0, rowspan=6, padx=8, pady=8, sticky="n")

        # (1) 入力の選択
        src = ttk.LabelFrame(self.root, text="1. 入力の選択")
        src.grid(row=0, column=1, sticky="ew", padx=8, pady=4)

        self.source_variable = tk.StringVar(value="camera")

        ttk.Radiobutton(
            src,
            text="カメラ",
            variable=self.source_variable,
            value="camera",
        ).grid(row=0, column=0, sticky="w", padx=4)

        ttk.Label(src, text="カメラ番号").grid(row=0, column=1, sticky="e")

        self.camera_index_variable = tk.IntVar(value=0)
        ttk.Spinbox(
            src,
            from_=0,
            to=3,
            width=4,
            state="readonly",
            textvariable=self.camera_index_variable,
        ).grid(row=0, column=2, sticky="w")

        ttk.Radiobutton(
            src,
            text="動画ファイル",
            variable=self.source_variable,
            value="file",
        ).grid(row=1, column=0, sticky="w", padx=4)

        ttk.Button(
            src,
            text="ファイルを選択",
            command=self.choose_file,
        ).grid(row=1, column=1, columnspan=2, sticky="w")

        self.file_label = ttk.Label(src, text="(未選択)")
        self.file_label.grid(row=2, column=0, columnspan=3, sticky="w", padx=6)

        # (2) パラメータ
        par = ttk.LabelFrame(self.root, text="2. パラメータ")
        par.grid(row=1, column=1, sticky="ew", padx=8, pady=4)

        self.margin_v = self.add_slider(
            par, 0, "手法A 閉眼判定マージン(残り幅比)", 0.05, 0.60, 0.30, "{:.2f}"
        )
        self.ratio_v = self.add_slider(
            par, 1, "手法B EAR比率しきい値", 0.50, 0.95, 0.75, "{:.2f}"
        )
        self.frames_v = self.add_slider(
            par, 2, "最小閉眼持続フレーム数", 1, 6, 2, "{:.0f}"
        )
        self.window_v = self.add_slider(
            par, 3, "基準値推定の窓長(フレーム)", 20, HISTORY_MAX, 60, "{:.0f}"
        )
        self.level_v = self.add_slider(
            par, 4, "手法C PERCLOS閉眼水準", 0.50, 0.95, 0.80, "{:.2f}"
        )
        self.pwin_v = self.add_slider(
            par, 5, "手法C 移動窓長(秒)", 10, 120, 30, "{:.0f}"
        )
        self.seg_v = self.add_slider(
            par, 6, "集計区間長(分)", 1, 10, 2, "{:.0f}"
        )

        # 実行ボタン
        btn = ttk.Frame(self.root)
        btn.grid(row=2, column=1, sticky="ew", padx=8, pady=4)

        self.start_btn = ttk.Button(btn, text="計測開始", command=self.start)
        self.start_btn.grid(row=0, column=0, padx=2)

        self.stop_btn = ttk.Button(btn, text="計測停止", command=self.stop, state="disabled")
        self.stop_btn.grid(row=0, column=1, padx=2)

        ttk.Button(
            btn,
            text="結果を集計して保存",
            command=self.show_results,
        ).grid(row=0, column=2, padx=2)

        self.status = ttk.Label(self.root, text="待機中")
        self.status.grid(row=3, column=1, sticky="w", padx=12)

        # (3) NASA-TLX
        tlx = ttk.LabelFrame(
            self.root,
            text="3. NASA-TLX簡易版(カメラで自分を計測した回のみ、計測終了後に1回入力)",
        )
        tlx.grid(row=4, column=1, sticky="ew", padx=8, pady=4)

        self.tlx_vars = {}
        for i, item in enumerate(TLX_ITEMS):
            ttk.Label(tlx, text=item).grid(row=i // 2, column=(i % 2) * 2, sticky="e", padx=4)

            v = tk.IntVar(value=50)
            ttk.Spinbox(
                tlx,
                from_=0,
                to=100,
                increment=5,
                width=5,
                state="readonly",
                textvariable=v,
            ).grid(row=i // 2, column=(i % 2) * 2 + 1, sticky="w")

            self.tlx_vars[item] = v

        # 結果表示
        self.result_text = tk.Text(self.root, width=68, height=17, font=("Consolas", 9))
        self.result_text.grid(row=5, column=1, padx=8, pady=4, sticky="nsew")

    def add_slider(self, parent, row, text, lo, hi, init, fmt):
        var = tk.DoubleVar(value=init)

        ttk.Label(parent, text=text).grid(row=row, column=0, sticky="w", padx=4)

        shown = ttk.Label(parent, text=fmt.format(init), width=6)
        shown.grid(row=row, column=2, sticky="w")

        ttk.Scale(
            parent,
            from_=lo,
            to=hi,
            variable=var,
            orient="horizontal",
            length=180,
            command=lambda v: shown.config(text=fmt.format(float(v))),
        ).grid(row=row, column=1, padx=4)

        return var

    def choose_file(self):
        path = filedialog.askopenfilename(
            filetypes=[("動画ファイル", "*.mp4 *.avi *.mov *.mkv")]
        )
        if path:
            self.file_path = path
            self.file_label.config(text=os.path.basename(path))
            self.source_variable.set("file")

    def on_close(self):
        if self.running:
            self.stop()
        self.root.destroy()

    # ---------------- 計測 ----------------
    def reset_state(self):
        self.hist_bs = deque(maxlen=HISTORY_MAX)
        self.hist_ear = deque(maxlen=HISTORY_MAX)
        self.perclos_buf = deque()

        self.blinks_a = []
        self.blinks_b = []

        self.closed_a = 0
        self.closed_b = 0

        self.frame_count = 0
        self.detected_count = 0
        self.rows = []

        self.frame_times = deque(maxlen=30)
        self.last_ts = -1
        self.now_t = 0.0
        self.total_time = 0.0
        self.proc_fps = 0.0
        self.source_fps = DEFAULT_VIDEO_FPS

        # 計測開始時に固定して使う検出パラメータ
        self.margin = self.margin_v.get()
        self.ear_ratio = self.ratio_v.get()
        self.min_closed_frames = int(round(self.frames_v.get()))
        self.baseline_window = int(round(self.window_v.get()))
        self.perclos_level = self.level_v.get()
        self.perclos_window = int(round(self.pwin_v.get()))

    def snapshot_detection_parameters(self):
        """検出に使うパラメータを計測開始時点で固定する。"""
        self.margin = self.margin_v.get()
        self.ear_ratio = self.ratio_v.get()
        self.min_closed_frames = int(round(self.frames_v.get()))
        self.baseline_window = int(round(self.window_v.get()))
        self.perclos_level = self.level_v.get()
        self.perclos_window = int(round(self.pwin_v.get()))

    def start(self):
        if self.source_variable.get() == "file" and not self.file_path:
            self.status.config(text="動画ファイルが未選択である")
            return

        self.reset_state()
        self.snapshot_detection_parameters()

        if self.source_variable.get() == "camera":
            self.capture = cv2.VideoCapture(self.camera_index_variable.get(), cv2.CAP_DSHOW)
            self.capture.set(cv2.CAP_PROP_BUFFERSIZE, 1)
            self.is_file = False
        else:
            self.capture = cv2.VideoCapture(self.file_path)
            self.is_file = True
            self.source_fps = normalize_fps(
                self.capture.get(cv2.CAP_PROP_FPS) or DEFAULT_VIDEO_FPS
            )

        self.landmarker = vision.FaceLandmarker.create_from_options(
            vision.FaceLandmarkerOptions(
                base_options=python.BaseOptions(model_asset_path=MODEL_PATH),
                output_face_blendshapes=True,
                running_mode=vision.RunningMode.VIDEO,
                num_faces=1,
            )
        )

        self.start_wall = time.perf_counter()
        self.running = True

        self.start_btn.config(state="disabled")
        self.stop_btn.config(state="normal")
        self.status.config(text="計測中(検出パラメータは開始時値)")

        self.update_frame()

    def finalize_pending_blinks(self):
        """
        閉眼状態のまま停止・動画末尾・ウィンドウ終了になった場合、
        最小持続フレーム数を満たしている閉眼を1回として確定する。
        """
        k = self.min_closed_frames

        if self.closed_a >= k:
            self.blinks_a.append(self.total_time)

        if self.closed_b >= k:
            self.blinks_b.append(self.total_time)

        self.closed_a = 0
        self.closed_b = 0

    def stop(self):
        if not self.running:
            return

        self.finalize_pending_blinks()

        self.running = False
        self.capture.release()
        self.landmarker.close()

        self.start_btn.config(state="normal")
        self.stop_btn.config(state="disabled")
        self.status.config(text=f"計測終了({self.total_time:.1f}秒)→ 集計ボタンを押す")

    def update_frame(self):
        if not self.running:
            return

        ret, frame = self.capture.read()
        if not ret:
            self.stop()
            return

        self.frame_count += 1

        # 時間軸:
        # カメラは単調増加時計、動画はフレーム番号/fpsで作る。
        if self.is_file:
            t = self.frame_count / self.source_fps
        else:
            t = time.perf_counter() - self.start_wall

        t = max(t, self.now_t + 1e-3)
        self.now_t = t
        self.total_time = t

        wall = time.perf_counter()
        self.frame_times.append(wall)
        if len(self.frame_times) >= 2:
            elapsed_for_fps = max(self.frame_times[-1] - self.frame_times[0], 1e-9)
            self.proc_fps = (len(self.frame_times) - 1) / elapsed_for_fps

        eff_fps = self.source_fps if self.is_file else max(self.proc_fps, 1.0)

        h, w = frame.shape[:2]
        frame = cv2.resize(frame, (DISPLAY_WIDTH, int(h * DISPLAY_WIDTH / w)))
        h, w = frame.shape[:2]

        ts = max(int(t * 1000), self.last_ts + 1)
        self.last_ts = ts

        mp_image = mp.Image(
            image_format=mp.ImageFormat.SRGB,
            data=cv2.cvtColor(frame, cv2.COLOR_BGR2RGB),
        )
        result = self.landmarker.detect_for_video(mp_image, ts)

        k = self.min_closed_frames
        n_win = self.baseline_window
        level = self.perclos_level
        pwin = self.perclos_window

        line3 = "顔を検出できません"
        color3 = (255, 60, 60)
        line4 = ""

        if result.face_blendshapes:
            self.detected_count += 1

            lms = result.face_landmarks[0]
            s = {c.category_name: c.score for c in result.face_blendshapes[0]}

            closedness = (s["eyeBlinkLeft"] + s["eyeBlinkRight"]) / 2.0
            ear = (
                eye_aspect_ratio(lms, EAR_LEFT, w, h)
                + eye_aspect_ratio(lms, EAR_RIGHT, w, h)
            ) / 2.0

            thr_a = None
            thr_b = None

            if len(self.hist_bs) < WARMUP_FRAMES or len(self.hist_ear) < WARMUP_FRAMES:
                # 初期だけは基準値作成のためにそのまま蓄積する。
                self.hist_bs.append(closedness)
                self.hist_ear.append(ear)
            else:
                thr_a = blendshape_threshold(self.hist_bs, n_win, self.margin)
                thr_b = ear_threshold(self.hist_ear, n_win, self.ear_ratio)

                is_closed_a = closedness >= thr_a
                is_closed_b = ear <= thr_b

                # 手法A:閉眼が続いたあと開いた瞬間に1回と数える
                if is_closed_a:
                    self.closed_a += 1
                else:
                    if self.closed_a >= k:
                        self.blinks_a.append(t)
                    self.closed_a = 0

                # 手法B:EARは閉眼で下降する
                if is_closed_b:
                    self.closed_b += 1
                else:
                    if self.closed_b >= k:
                        self.blinks_b.append(t)
                    self.closed_b = 0

                # 閉眼中の値で基準値を更新しない。
                # 片方の手法だけが閉眼と見なした場合も、目の状態が不確かなので履歴更新しない。
                if not is_closed_a and not is_closed_b:
                    self.hist_bs.append(closedness)
                    self.hist_ear.append(ear)

            # 手法C:移動窓の中で閉眼水準以上だったフレームの割合
            self.perclos_buf.append((t, closedness))
            while self.perclos_buf[0][0] < t - pwin:
                self.perclos_buf.popleft()

            perclos = np.mean(
                [1.0 if c >= level else 0.0 for _, c in self.perclos_buf]
            )

            self.rows.append(
                (
                    round(t, 3),
                    round(closedness, 4),
                    round(ear, 4),
                    round(perclos, 4),
                )
            )

            for color, ring in EYE_RINGS:
                for i in ring:
                    cv2.circle(
                        frame,
                        (int(lms[i].x * w), int(lms[i].y * h)),
                        2,
                        color,
                        -1,
                    )

            for i in EAR_LEFT + EAR_RIGHT:
                cv2.circle(
                    frame,
                    (int(lms[i].x * w), int(lms[i].y * h)),
                    3,
                    (255, 255, 0),
                    1,
                )

            ta = f"{thr_a:.3f}" if thr_a is not None else "算出前"
            tb = f"{thr_b:.3f}" if thr_b is not None else "算出前"

            line3 = (
                f"A 閉眼度={closedness:.3f}(しきい値{ta})   "
                f"B EAR={ear:.3f}(しきい値{tb})"
            )
            line4 = f"C PERCLOS(水準{level:.2f}, 直近{pwin}秒)={perclos * 100:5.1f}%"
            color3 = (0, 255, 255)
        else:
            # 顔未検出は「開眼」ではなく「未観測」として扱う。
            # ここで閉眼カウンタを壊さないことで、瞬目中の一時的な未検出で回数が失われるのを避ける。
            pass

        line1 = (
            f"瞬き回数  A(Blendshape)={len(self.blinks_a)}回   "
            f"B(EAR)={len(self.blinks_b)}回"
        )
        line2 = (
            f"経過時間={t:6.1f}秒  処理fps={self.proc_fps:4.1f}  "
            f"時間分解能={eff_fps:4.1f}fps(最小閉眼{k / eff_fps * 1000:.0f}ms)"
        )

        img = Image.fromarray(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB))
        draw = ImageDraw.Draw(img)

        for i, (text, col) in enumerate(
            [
                (line1, (0, 255, 0)),
                (line2, (0, 255, 0)),
                (line3, color3),
                (line4, (255, 200, 0)),
            ]
        ):
            draw.text((8, 6 + 22 * i), text, font=FONT, fill=col)

        self.photo = ImageTk.PhotoImage(img)
        self.video_label.config(image=self.photo)

        self.root.after(1, self.update_frame)

    # ---------------- 集計 ----------------
    def show_results(self):
        if self.running or self.detected_count == 0:
            self.status.config(text="計測を行って停止し、顔が検出できた状態で押す")
            return

        total = self.total_time
        eff_fps = self.source_fps if self.is_file else self.frame_count / total
        k = self.min_closed_frames

        na = len(self.blinks_a)
        nb = len(self.blinks_b)

        matched = count_matches(self.blinks_a, self.blinks_b, MATCH_TOL)
        dice = 2 * matched / (na + nb) if na + nb > 0 else 0.0

        perclos_all = [r[3] for r in self.rows]

        seg_min = max(1, int(round(self.seg_v.get())))
        edges = segment_edges(total, seg_min * 60)

        rates_a = []
        rates_b = []
        lines = []

        for i in range(len(edges) - 1):
            lo = edges[i]
            hi = edges[i + 1]

            ca = sum(1 for x in self.blinks_a if lo <= x < hi)
            cb = sum(1 for x in self.blinks_b if lo <= x < hi)

            minutes = (hi - lo) / 60

            rates_a.append(ca / minutes)
            rates_b.append(cb / minutes)

            lines.append(
                f"  {lo / 60:5.1f}〜{hi / 60:5.1f}分  "
                f"A:{ca:4d}回({ca / minutes:5.1f}/分)   "
                f"B:{cb:4d}回({cb / minutes:5.1f}/分)"
            )

        out = ["=" * 64]

        out.append(
            f"入力: {'動画 ' + os.path.basename(self.file_path) if self.is_file else 'カメラ'}"
        )
        out.append(
            f"総フレーム数 {self.frame_count}   顔検出失敗 "
            f"{self.frame_count - self.detected_count}フレーム "
            f"({(self.frame_count - self.detected_count) / self.frame_count * 100:.1f}%)"
        )
        out.append(
            f"計測時間 {total / 60:.2f}分   時間分解能 {eff_fps:.1f}fps   "
            f"最小閉眼持続 {k / eff_fps * 1000:.0f}ms"
        )

        out.append("-" * 64)
        out.append(f"手法A Blendshape : {na:4d}回   平均 {na / (total / 60):5.1f} 回/分")
        out.append(f"手法B EAR        : {nb:4d}回   平均 {nb / (total / 60):5.1f} 回/分")
        out.append(
            f"手法C PERCLOS    : 平均 {np.mean(perclos_all) * 100:5.1f}%   "
            f"最大 {np.max(perclos_all) * 100:5.1f}%"
        )
        out.append(f"AとBの対応 : 一致{matched}回 / Aのみ{na - matched}回 / Bのみ{nb - matched}回")
        out.append(
            f"一致度(Dice係数 2M/(Na+Nb))= {dice:.3f}   "
            f"±{MATCH_TOL}秒以内を同一とみなす"
        )
        out.append("  ※どちらも正解データではないため、これは一致の度合いであり精度ではない")

        out.append("-" * 64)
        out.append(f"区間別の瞬き頻度(区間長 約{seg_min}分。端数は最終区間に含める)")
        out.extend(lines)

        if len(rates_a) >= 2:
            idx = np.arange(len(rates_a))
            out.append(
                f"時間経過との相関係数 r : "
                f"A={np.corrcoef(idx, rates_a)[0, 1]:+.3f}   "
                f"B={np.corrcoef(idx, rates_b)[0, 1]:+.3f}"
            )
            out.append("  ※全区間で頻度が同一の場合は nan と表示される")
        else:
            out.append("区間が1つしかないため相関係数は算出しない")

        out.append("-" * 64)

        tlx = {item: v.get() for item, v in self.tlx_vars.items()}

        out.append(f"Raw TLX(6項目平均)= {sum(tlx.values()) / len(tlx):.1f}")
        out.append("  " + "  ".join(f"{i}={v}" for i, v in tlx.items()))
        out.append("  ※1計測につき1個の主観評価であり、1回の計測から相関は求められない")
        out.append("=" * 64)

        text = "\n".join(out)

        self.result_text.delete("1.0", tk.END)
        self.result_text.insert(tk.END, text)
        print(text)

        with open(BLINK_CSV, "w", newline="", encoding="utf-8") as f:
            writer = csv.writer(f)
            writer.writerow(["time_sec", "method", "index"])
            writer.writerows(
                (round(t, 3), "A_blendshape", i + 1)
                for i, t in enumerate(self.blinks_a)
            )
            writer.writerows(
                (round(t, 3), "B_ear", i + 1)
                for i, t in enumerate(self.blinks_b)
            )

        with open(SIGNAL_CSV, "w", newline="", encoding="utf-8") as f:
            writer = csv.writer(f)
            writer.writerow(["time_sec", "blendshape_closedness", "ear", "perclos"])
            writer.writerows(self.rows)

        self.status.config(text="集計完了:blink_log.csv と signal_log.csv を保存した")


root = tk.Tk()
app = BlinkApp(root)
root.mainloop()

8. 画面表示の読み方

計測中の映像には4行の文字が重なる。1行目は手法Aと手法Bそれぞれの瞬き回数である。この2つが揃って増えるか、片方だけ増えるかを見ることが本実験の出発点である。2行目は経過時間と2種類のfpsで、処理fpsは「1秒あたり何フレーム解析できているか」という速度、時間分解能fpsは「1秒を何コマに刻めているか」という時間の細かさであり、両者は別の量である。カメラ入力ではこの2つは一致するが、動画ファイル入力では映像自体のfpsが時間の細かさを決めるため、解析が遅くても時間分解能は変わらない。括弧内の「最小閉眼○ms」は、最小閉眼持続フレーム数を時間に換算した値であり、これより短い閉眼は計数されないことを意味する。

3行目は手法Aの閉眼度(1に近いほど閉眼)と手法BのEAR(小さいほど閉眼)、およびそれぞれのしきい値である。しきい値はいずれも固定値ではなく直近の履歴から求めた動的な値なので、姿勢を変えるとしきい値も動く。手法Aのしきい値は基準値と 1.0 の間に必ず収まる仕組みなので 1.000 を超えることはないが、基準値が上がればしきい値も上がり、浅い瞬目は計数されにくくなる。なお計測開始直後の15フレームは基準値を作るための蓄積期間であり、しきい値欄には「算出前」と表示され、この間の瞬目は計数されない。4行目のPERCLOSは移動窓の中での閉眼時間率であり、長い閉眼が増えると上がる。回数とは別の量である点に注意する。

集計結果では、まず顔検出失敗の割合と時間分解能を確認する。ここが悪い回のデータは、以降の数値を解釈しても意味がない。次に手法A・Bの回数と一致度を見る。一致度は Dice 係数と呼ばれる指標で、±0.5秒以内に対応がとれた瞬目の数を M、各手法の回数を Na・Nb として 2M/(Na+Nb) と定義され、1に近いほど両手法が同じ瞬目を捉えていることを示す。区間は総時間を区間長で割った個数に丸めて分割し、端数は最終区間に吸収させるため、最終区間だけが指定値のおよそ0.5〜1.5倍の長さになる(総時間が区間長の半分に満たないときは全体が1区間になる)。相関係数 r は区間が2つ以上のときに算出され、−1から+1の値をとり、正なら時間とともに増加、負なら減少の傾向を示す。全区間で頻度が完全に同一の場合は nan(値なし)と表示される。

参考として、安静時の自発性瞬目(無意識に起こるまばたき)は一般に毎分15〜20回程度、画面注視や読書などの視覚課題の最中はそれより低下すると報告されている。自分の測定値が桁違いであれば、まずしきい値の設定か顔検出の失敗を疑うとよい。

9. ヒントと考察ポイント

ヒント

実験のバリエーション

考察ポイント