瞬目検出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. | 手法名 | 発表 | 分類 | 概要 |
|---|---|---|---|---|
| 1 | EAR(Eye Aspect Ratio、目の縦横比)しきい値法 | CVWW 2016(Soukupová & Čech) | 幾何特徴+しきい値 | 目の周囲6点から縦横比を計算し、値がしきい値を下回るフレームを閉眼とみなす。計算量が小さく、判定の根拠が数式として目に見える古典的手法である。 |
| 2 | EAR時系列+SVM(サポートベクターマシン、2つのクラスを最もよく分ける境界を学習する識別器) | CVWW 2016(同上) | 時系列特徴+識別器 | 数フレーム分のEARの並びをまとめて識別器に入力し判定する。1フレームだけで判断するしきい値法より顔向きや個人差に強いが、学習データを別途必要とする。 |
| 3 | PERCLOS(PERcent of eyelid CLOSure、閉眼時間率) | Dinges & Grace 1998 | 集約指標(眠気指標) | 一定の時間窓の中で瞼が一定割合以上閉じていた時間の比率を指標化する。個々の瞬目ではなく持続的な閉眼を捉え、眠気推定の標準的指標として用いられてきた。 |
| 4 | MediaPipe Face Landmarker + Blendshape V2(eyeBlink係数) | Google, MediaPipe Tasks(2023〜) | 学習済み回帰モデル(入力から連続値を出力するよう学習させたモデル) | BlazeFaceで顔を検出し、FaceMesh-V2で478点を推定し、うち146点をMLP-Mixer(全結合層だけで構成された軽量ネットワーク)に入力して52種の表情係数を推定する。閉眼度が0〜1の値で直接得られる。 |
| 5 | RT-BENE | ICCV Workshops 2019(Cortacero ら) | CNNによる眼領域分類 | 切り出した両眼の画像をCNN(畳み込みニューラルネットワーク)で開眼/閉眼に分類する。眼鏡や照明変化を含む実環境データで学習しており、ランドマークの精度に左右されにくい。 |
| 6 | OpenFace 2.0 の AU45 強度 | FG 2018(Baltrušaitis ら) | 表情動作単位(AU)推定 | FACS(表情記述の国際的な体系)のAU45(blink)の強さを推定し、その時間変化から瞬目を判定する。他の表情要素も同時に得られるため併用解析に向く。 |
| 7 | EOG(Electro-OculoGraphy、眼電図) | 生理計測の標準手法 | 接触型生体信号 | 目の周りに貼った電極で角膜と網膜の電位差の変化を測る。時間分解能が高く正解データとして使えるが、電極を身体に装着する必要がある。 |
2-2. 主観的作業負荷の評価手法候補
| No. | 手法名 | 発表 | 分類 | 概要 |
|---|---|---|---|---|
| A | NASA-TLX(重み付け版) | Hart & Staveland 1988 | 多次元主観評価 | 6項目を評定したうえ、項目どうしを2つずつ比べて重みを決め、加重平均する。手続きが重く、短時間の実験では回答者の負担が大きい。 |
| B | Raw TLX(NASA-TLX簡易版) | Byers ら 1989 / Hart 2006 | 多次元主観評価(簡易) | 重み付けの手続きを省き、6項目の単純平均をスコアとする。所要時間が短く、重み付け版と近い感度を示すとの報告があり、実験室外でも使いやすい。 |
| C | Borg CR-10(主観的な強さを0〜10の段階で答えさせる尺度)等の主観的疲労感尺度 | Borg 1982 ほか | 単一次元主観評価 | 疲労や努力の強さを1本の尺度で答えさせる。繰り返し尋ねやすい反面、負荷の内訳(精神的か時間的か)は分けられない。 |
| D | KSS(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:Blendshape | eyeBlinkLeft と eyeBlinkRight の平均(0〜1、1に近いほど閉眼) | 直近の履歴(両手法がともに開眼と判定したフレームだけを蓄積したもの)の下位10パーセンタイル(小さい方から数えて10%の位置にある値)を「目が開いているときの基準値」とし、基準値から上限1.0までの残り幅にマージンを掛けた分を基準値に足した値をしきい値とする。この値以上のフレームを閉眼とみなす | 瞬き回数と発生時刻 |
| B:EAR | 左右の目それぞれ6点から求めたEARの平均(目が開いているほど大きい) | 直近の履歴(Aと同じく開眼と判定したフレームだけを蓄積したもの)の上位90パーセンタイルを基準値とし、それに比率(既定0.75)を掛けた値以下になったフレームを閉眼とみなす | 瞬き回数と発生時刻 |
| C:PERCLOS | Aと同じ閉眼度 | 閉眼水準(既定0.80)以上のフレームを「閉眼」と数え、移動窓(現在時刻からさかのぼる一定長さの時間帯)の中での割合を求める | 閉眼時間率(%) |
A と B はいずれも「閉眼と判定された状態が最小持続フレーム数以上つづき、そのあと開眼に転じた瞬間」に1回と数える(計測停止・動画末尾・ウィンドウ終了の時点で閉眼が続いていた場合は、その時点で1回として確定する)。両者は信号の向きが逆であること、すなわち A は閉じると値が上がり、B は閉じると値が下がることに注意する。C は回数ではなく時間の割合であり、A・B とは測っている現象そのものが異なる。
他の候補と比べた特徴・使用上の注意
- A は学習済みモデルが閉眼度を直接出力するため実装が短くて済むが、その係数の意味は「表情の強さ」であって「瞬目である確率」ではない。一方 B は計算式が完全に見えており、なぜその値になったのかを座標までさかのぼって追える。この両者の一致と不一致を観察することが本実験の中心である。
- No.2(EAR+SVM)、No.5(RT-BENE)、No.6(OpenFace)は、学習データの用意や別環境の構築が必要なため本コードでは扱わない。
- No.7(EOG)は本来の正解データにあたるが、電極の装着が必要で本コードでは用意できない。したがって本実験に「正解」は存在せず、A と B のどちらが正しいかを決めることはできない。比較できるのは、両者の一致の度合いと、設定を変えたときの結果の動きやすさである。
- C(PERCLOS)は眠気推定の指標であり、A・B の回数と同じ向きに動くとは限らない。長い閉眼が増えれば C は上がるが、回数はむしろ減ることもある。
モデルの所在と学習データ
| 項目 | 内容 |
|---|---|
| ソースコード | 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 のような一般公開データセットではないため、内訳の詳細は公表されていない。 |
- 手法Aのしきい値は「基準値+マージン×(1−基準値)」であり、基準値がどれだけ高くなっても閉眼度の上限である 1.0 を超えることはない。ただしマージンを大きくするほどしきい値は 1.0 に近づくため、浅い瞬目は取りこぼされる。しきい値の値は画面で常時目視できる。
- 基準値を求める履歴は、両手法がともに開眼と判定したフレームでのみ更新される。長く目を閉じ続けても基準値が閉眼側へ引きずられることはないが、その代わり、しきい値の設定が不適切で常に閉眼と判定される状態に陥ると履歴が更新されなくなり、その状態から自動では戻らない。片方の手法だけが閉眼と判定した場合も履歴は更新されないため、一方の設定の誤りが他方の基準値にも影響する。
- CSVに記録される時刻は「閉じ始めた瞬間」ではなく「開いた瞬間」であり、瞬目の持続時間の分だけ後ろにずれる。
- 顔が検出できないフレームは「開眼」ではなく「未観測」として扱い、閉眼カウンタを保持したまま判定も履歴更新も行わない。そのため検出が途切れている間の瞬目は数えられないが、その時間は計測時間には含まれるので、平均頻度は実際より低く出うる。
- 手法Bの EAR は、正規化座標(画像の幅・高さを1として0〜1で表した座標)に画像の幅と高さを掛けて画素座標に直してから計算している。正規化座標のまま計算すると、画面の縦横比の分だけ値が歪むためである。
- 手法Cは本来、瞼が瞳孔を覆う面積の割合で定義される指標である。本コードはこれをブレンドシェイプの閉眼度で代用した近似であり、文献値とそのまま比較できる量ではない。
cv2.CAP_DSHOWは Windows 専用のバックエンド(カメラを制御する下位ソフトウェア)指定である。本コードは Windows 環境を前提とする。
4. プログラムの概要
- 構成:単一の Python ファイルである。GUI は Tkinter(ttk)で構成し、映像表示・パラメータ調整・TLX入力・結果表示を1つのウィンドウにまとめている。
- モデル取得:スクリプトと同じディレクトリに
face_landmarker.taskが無ければ起動時に自動ダウンロードする。既にあれば再ダウンロードしない。 - 入力:ラジオボタンでカメラ(番号0〜3をスピンボックス(数値を上下の矢印で選ぶ入力欄)で選択)と動画ファイル(ファイル選択ダイアログ)を切り替える。カメラは
cv2.CAP_DSHOWで開き、起動時に生じる長い待ち時間を避ける。 - 映像表示:映像を横幅640画素に揃えて表示する。目の輪郭16点を左右で色分けし(緑=本人の左目、マゼンタ=本人の右目)、EARの計算に使う6点は水色の丸で強調する。
- 重畳表示(映像の上に文字を重ねて描くこと。4行):1行目に手法A・Bそれぞれの瞬き回数、2行目に経過時間(秒)・処理fps・時間分解能fpsと最小閉眼持続時間(ミリ秒)、3行目に閉眼度とEARの現在値および各しきい値、4行目にPERCLOSを表示する。顔が取れない間は3行目が赤字の警告に変わる。
- 結果表示:ウィンドウ下部のテキスト欄に、手法別の総回数・平均頻度・区間別頻度・時間経過との相関係数、手法間の一致度、PERCLOSの平均と最大、Raw TLX を一覧表示する。同じ内容をコンソールにも出力する。
- ファイル出力:スクリプトと同じディレクトリに
blink_log.csv(列:time_sec, method, index)とsignal_log.csv(列:time_sec, blendshape_closedness, ear, perclos)を保存する。 - 実行トリガー:「計測開始」ボタンで映像取得・検出・表示の繰り返しが始まり、「計測停止」ボタンで止まる。停止後に「結果を集計して保存」ボタンを押すと集計とCSV保存を行う。
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:インストーラーによるインストール
- Python公式サイト(https://www.python.org/downloads/)にアクセスし、「Download Python 3.x.x」ボタンからWindows用インストーラーをダウンロードする。
- ダウンロードしたインストーラーを実行する。
- 初期画面の下部に表示される「Add python.exe to PATH」にチェックを入れてから「Customize installation」を選択する。このチェックを入れ忘れると、コマンドプロンプトから
pythonコマンドを実行できない。 - 「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++ 再頒布可能パッケージをインストールし、続いて以下のコンポーネントを追加している。
- VCTools:C++ デスクトップ開発ワークロード(
--includeRecommendedにより、MSVC コンパイラ、C++ AddressSanitizer、vcpkg、CMake ツール、Windows 11 SDK 等の推奨コンポーネントが含まれる) - MSBuildTools:MSBuild によるビルドツールのワークロード
- VC.CMake.Project:Windows 向け C++ CMake ツール
- VC.Llvm.Clang:Windows 向け C++ Clang コンパイラ
- VC.Llvm.ClangToolset:MSBuild から Clang を使用するための clang-cl ツールセット
- Windows11SDK.26100:Windows 11 SDK(ビルド 10.0.26100)
追加のコンポーネントが必要になった場合は 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 プログラムを解釈・実行するソフトウェア)を選択する必要がある.
- コマンドパレット(コマンド名で機能を呼び出す VS Code の入力欄)を開く(
Ctrl+Shift+P) Python: Select Interpreterと入力する
- 表示される一覧から,使用する Python(例:
C:\Program Files\Python312\python.exe)を選択する.
Python プログラム実行手順
[Windows での Python プログラム実行手順を見るには、ここをクリック]
Windows での Python 実行手順(Visual Studio Codeを使用)
プログラムファイルの作成と保存
- 左サイドバーの「エクスプローラー」アイコン(
Ctrl+Shift+E)をクリックする
- 「NO FOLDER OPENED」(作業対象フォルダが未選択の状態)と表示される場合は,「Open Folder」をクリックし,プログラムを保存するフォルダを選択する
続いて「フォルダを信用するか」を確認する画面(フォルダ内のコードを実行してよいか確認する VS Code の仕組み)が表示されるので,チェックして Yes を選択する
- フォルダ名の右側に表示される「新しいファイル」アイコンをクリックする
- ファイル名(例:
aitask.py.ファイル名は何でも良い)を入力しEnterを押す.拡張子は.py(Python ファイルを示す拡張子)とする
- 実行したいコードを選択し,
Ctrl+Cでコピーする.VS Code のエディタ領域にCtrl+Vで貼り付ける Ctrl+Sで保存する
プログラムの実行
- エディタ右上の三角形「▷」アイコン(Run Python File:現在開いている Python ファイルを実行するボタン)をクリックする.または,エディタ上で右クリックし「ターミナルで Python ファイルを実行」を選択する
- VS Code 下部のターミナル(コマンドの入出力を表示する画面)に,実行結果(
print関数の出力等)が表示される
- tkinter(Python 標準の GUI ライブラリ)のファイル選択ダイアログを使うプログラムを実行した場合は,ダイアログが開くので対象画像を選択する
- VS Code 下部のターミナルで実行結果を確認する.OpenCV ウィンドウ(OpenCV が画像を表示するために開く専用ウィンドウ)が開いた場合はそちらも確認する.OpenCV ウィンドウは,マウスクリックでウィンドウをアクティブ(操作対象の状態)にしてからキーを押すと終了する
必要なライブラリのインストール
管理者権限でコマンドプロンプトを起動する
(手順:Windowsキーまたはスタートメニュー → cmd と入力 → 右クリック → 「管理者として実行」)。
次のコマンドを実行し、関連ライブラリをインストールする。
pip install -U --no-user mediapipe opencv-python numpy pillow
実行手順
- スクリプトを実行する。初回のみモデルファイルを自動ダウンロードするため、インターネット接続が必要である。
- 「1. 入力の選択」でカメラまたは動画ファイルを選ぶ。自分の作業中の瞬目を測るならカメラ、パラメータの比較を行うなら同じ動画ファイルを繰り返し使う。
- 「計測開始」を押す。まず10秒ほど、意図的に数回まばたきをして、目の周囲に点が乗ること、閉眼度がしきい値を超えること、手法Aと手法Bの回数が同じだけ増えるかを確認する。これが動作確認である。
- そのまま作業(ゲーム、読書など)を行う。経過時間の表示で進み具合を確認する。動画ファイル入力の場合は末尾に達すると自動的に停止する。
- 「計測停止」を押す。
- カメラで自分を計測した場合は、「3. NASA-TLX簡易版」の6項目を0〜100で入力する(今回の計測全体に対して1回だけ)。動画ファイルを解析した場合は評価すべき主観体験が存在しないため、入力せず既定値のままにし、結果欄のTLX行は無視する。
- 「結果を集計して保存」を押す。テキスト欄に結果が出て、2つのCSVが保存される。
- 区間長スライダーを動かしてから同じボタンをもう一度押せば、計測をやり直さずに別の区間長で集計し直せる。
- パラメータを変えて再実行する場合、CSVは上書きされる。残したい結果は別名で保存してから次の計測に進む。
6. GUIで設定可能なパラメータ
| 対象 | 名称(GUI表記) | 初期値 | 範囲 | 説明 |
|---|---|---|---|---|
| 入力 | カメラ番号 | 0 | 0〜3 | 使用するカメラ機器の番号。ノートパソコン内蔵のカメラは通常0である。 |
| 手法A | 閉眼判定マージン(残り幅比) | 0.30 | 0.05〜0.60 | 基準値から上限1.0までの残り幅にこの比を掛け、基準値に足したものを閉眼判定のしきい値とする。小さくすると軽い目の動きも回数に数え、大きくすると浅い瞬目を取りこぼす。 |
| 手法B | EAR比率しきい値(開眼時EARに対する比) | 0.75 | 0.50〜0.95 | 開眼時のEAR基準値にこの比を掛けた値以下になったら閉眼とみなす。小さくするほど深い閉眼だけを検出する。 |
| A・B共通 | 最小閉眼持続フレーム数 | 2 | 1〜6 | 何フレーム連続で閉眼判定が続いたら1回と数えるか。実時間に換算した値は画面に「最小閉眼○ms」として常時表示される。 |
| A・B共通 | 基準値推定の窓長(フレーム) | 60 | 20〜300 | 開眼時の基準値を求めるためにさかのぼるフレーム数(履歴は両手法が開眼と判定したフレームでのみ更新される)。長いほど姿勢の変化に追従しにくく、短いほど直近のわずかな変動でしきい値が動きやすい。 |
| 手法C | PERCLOS閉眼水準 | 0.80 | 0.50〜0.95 | 閉眼度がこの値以上のフレームを「閉眼」と数える。0.80は文献でいうP80(瞼が瞳孔の80%以上を覆った状態を閉眼とみなす定義)に相当する設定である(ただし本実装は閉眼度による近似)。 |
| 手法C | PERCLOS移動窓長(秒) | 30 | 10〜120 | 閉眼時間率を計算する時間帯の長さ。長くすると値は滑らかになり、短くすると変動が大きくなる。 |
| 集計 | 集計区間長(分) | 2 | 1〜10 | 区間別頻度と相関係数を求める時間の幅。停止後に変更して集計ボタンを押し直せば再集計される。 |
| 集計 | NASA-TLX 6項目 | 50 | 0〜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(値なし)と表示される。
9. ヒントと考察ポイント
ヒント
- 本番の計測の前に10〜20秒だけカメラで試し、意図的な瞬目でAとBの回数が同じだけ増えるか、視線を動かしただけでは増えないかを確認する。ここが合っていないと以降の数値は解釈できない。
- パラメータを比べる実験は、必ず同じ動画ファイルを入力にする。入力が完全に同一になるため、回数の差はパラメータだけに由来すると言い切れる。カメラ入力では毎回の入力が違うので比較にならない。
- 顔検出失敗の割合が数パーセントを超えた回は、照明とカメラ位置を直して取り直すほうが早い。
- 集計区間長は計測後に変えて「結果を集計して保存」を押し直せば再集計される。区間長だけを変えて相関係数がどう動くかを見るのに、計測のやり直しは要らない。
- CSVはボタンを押すたびに上書きされる。残したい結果は別名で保存してから次に進む。
実験のバリエーション
- 同じ動画ファイルに対し、手法Aのマージンを0.15/0.30/0.45と変え、Aの回数と一致度(Dice係数)がどう動くかを記録する。
- 同様に手法BのEAR比率しきい値を0.65/0.75/0.85と変え、Bの回数を「Aに最も近づく値」に合わせられるかを試す。合わせられたとして、それが正しい設定と言えるかを考える。
- 最小閉眼持続フレーム数を1/2/4と変え、画面の「最小閉眼○ms」表示と併せて、何ミリ秒より短い閉眼を切り捨てているのかを対応づける。
- PERCLOSの閉眼水準(0.70/0.80/0.90)と移動窓長(10/30/60秒)を変え、値の大きさと変動の滑らかさがどう変わるかを見る。
- 基準値推定の窓長を20/60/300と変え、途中で意図的に俯いたり顔を傾けたりした映像に対して、しきい値表示の追従の速さと回数の変化を比べる。
考察ポイント
- 手法Aと手法Bの回数が一致しないとき、どちらが正しいかを本コードの出力だけで決められるか。決められないとすれば、何があれば決められるのかを述べる。
- 一致度をパラメータ調整だけで1に近づけられたとして、それは「両手法が正しくなった」ことを意味するのか、「たまたま同じ振る舞いに揃えただけ」なのかを区別して論じる。
- 時間経過との相関係数の符号や大きさが、集計区間長を変えるだけで変わるかを確かめる。変わるならば、その相関係数は観察対象の性質ではなく集計の設計を反映していることになる。
- PERCLOSと瞬き頻度が同じ向きに動くか、逆に動く区間があるか。逆に動く場面があれば、2つの指標が別の現象を測っている証拠になる。
- 平均瞬き頻度とRaw TLXが並んで表示されるが、この1回の計測から両者の関係について何が言えて何が言えないかを、データ点の個数という観点から説明する。